Compare commits

...

122 Commits

Author SHA1 Message Date
2f35c134aa ConvAgent: cut BOTH agents' speech when the player interrupts the dialogue
On a player barge-in, the engaged agent stops (its mic feeds the player's voice to ElevenLabs, which interrupts it), but the witness agent never hears the player and kept finishing its line (e.g. '...I'll give you the cash...') mid-scene. InterruptForPlayer now calls InterruptAgent() on both CompA and CompB. Conversations stay open; the witness still receives the contextual_updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:58:32 +02:00
086311da38 ConvReplay: split agent utterances on silence gaps (fix reply glued to greeting)
A first-message greeting opens the agent segment but never fires OnAgentStoppedSpeaking (no agent_response), so the segment stayed open and the later reply was appended to it — inheriting the greeting's early StartTime and playing before the player's question on replay.

RecordAgentAudio now flushes the open segment and starts a fresh one when audio resumes after a >1.5s gap, so the greeting keeps its time and the reply gets its own (correct, later) timestamp. Also flush open agent segments at record stop. Added record-side diagnostic logs behind ps.convreplay.Debug (agent-START/STOP/SPLIT, player-SEG, user-TEXT).

Record-side fix: existing sidecars keep the old segmentation; re-record to benefit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:06:40 +02:00
c783613117 ConvReplay: agent-less player voice recording + audio polish
Record every player's mic into the replay even with no agent in the scene (listen-server/standalone). The bridge arms each player pawn's InteractionComponent via a replicated flag; the host captures locally, remote clients relay via a new ServerRecordVoice RPC, and the server broadcasts OnPlayerVoiceForRecording into the existing player-audio pipeline. Agent and agent-less paths are mutually exclusive (no double-record).

Replay routing: match a recorded agent by id, else by nearest position (runtime-spawned NPCs get unstable names on replay), keeping lip-sync.

Player audio: per-utterance bidirectional AGC toward a target RMS with an envelope peak limiter (instant attack, ~9ms release) instead of hard clipping. Live CVars: ps.convreplay.PlayerTargetRms (default 0.08), ps.convreplay.PlayerGain, ps.convreplay.Debug (per-record logs off by default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:21:19 +02:00
d92e402722 fix emotion poses 2026-07-03 10:56:39 +02:00
231ca4145d ConvReplay: record & replay agent + player conversation audio in demos
New PS_AI_ConvReplay bridge plugin records ElevenLabs agent TTS and player mic audio (Opus) into a sidecar next to the .replay, and plays it back during DemoPlay with lip-sync / body-expression / gaze / emotion. Agent audio is re-fed through the agent's own voice; player audio is spatialized at the recorded position.

ConvAgent hooks (generic): PlayExternalAudioPCM, StopExternalAudio, TriggerReplayedAction/TriggerReplayedClientToolCall, and an OnPlayerAudioCaptured delegate broadcast in ServerSendMicAudioFromPlayer.

Agent matching is id-or-nearest-position (runtime-spawned NPCs get unstable names on replay). Player level normalized per-utterance (bidirectional AGC + soft limiter), tunable live via ps.convreplay.PlayerGain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:43:02 +02:00
087c83018c ConvAgent: fire the voice-onset interrupt server-side (dedicated-server support)
The agent-to-agent dialogue interrupt was detected only in FeedExternalAudio,
which runs client-side for a remote player. On a DEDICATED server (no host
player) no one could trigger the reliable VAD interrupt -- everyone fell back to
the slower ElevenLabs transcript path.

Also broadcast OnUserVoiceDetected from ServerSendMicAudioFromPlayer, where the
player's mic (the host's OR a remote client's, relayed) always arrives on the
server. Shares the LastUserVoiceOnset debounce so the host does not double-fire.
The orchestrator still attributes the correct player (ResolveSpeakingPlayer
finds the connected client via NetConnectedPawns).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:18:10 +02:00
f49a57f892 ConvAgent: agent-to-agent dialogue orchestrator + player 3-way floor mode
New APS_AI_ConvAgent_AgentDialogue actor drives two ElevenLabs agents in a
text-only conversation (they speak aloud and look at each other), and lets a
player barge in and take over.

Orchestrator (AgentDialogue):
- Single-floor agent<->agent turn-taking over text (no STT); gaze override so
  the agents look at each other; TurnDelaySeconds for pacing.
- Voice-onset interrupt: the player is detected via a local mic VAD
  (OnUserVoiceDetected), not the unreliable STT round-trip. On interrupt both
  agents turn to the player; auto-released once the player leaves both agents.
- 3-way "floor" mode: the engaged agent is reframed ("a real person now
  addresses you") while the witness agent overhears the exchange via
  contextual_update, so it can reference it when the player later turns to it.
  Configurable messages: Interrupt/Bystander context + overheard prefix.

Gaze: honour GazeOverrideTarget on every path that could steal the gaze back to
the player -- OnRep_ActiveSpeaker, InteractionComponent AttachGazeTarget /
SetSelectedAgent, and the PS_AI_Behavior proximity gaze (IsConversationActiveOnPawn
now treats an agent-to-agent dialogue as an active conversation).

Conversation lifecycle: a watching player joining/leaving by look no longer
tears down an orchestrated agent's WebSocket (ServerLeaveConversation guarded by
GazeOverrideTarget) -- fixes agents going silent / lip sync deactivating when
the player looks away.

ElevenLabs: add SendContextualUpdate (contextual_update -- inject context with no
reply triggered) on the component and WebSocket proxy; add OnUserVoiceDetected
local voice-onset event fired from FeedExternalAudio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:07:10 +02:00
6d92ec3c26 ConvAgent: add waveform-splice discontinuity debug detector
Diagnostic for the audio "pops": EnqueueAgentAudio compares the last sample of
the previous enqueued block with the first sample of the new one and logs
[AUDIO-DBG] with the delta + a running count when the jump exceeds a threshold
(gated behind ps.ai.ConvAgent.Debug.ElevenLabs; role-tagged). The host's raw-PCM
stream is continuous so its count stays ~0; a client whose count climbs at
message cadence still has a splice problem. Confirms the carry-over fix
(bcb6c44) at runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:45:16 +02:00
bcb6c44f57 ConvAgent: carry Opus sub-frame remainder across messages (fix pops)
The remaining pops were per-MESSAGE, not per-sub-chunk: the Opus encoder
processes whole 640-byte frames only, so each ElevenLabs message dropped its
<1-frame (<20ms) tail. The lost ~10-16ms per message left a waveform
discontinuity at each message splice -> audible click (2-3 per sentence).
Confirmed by log (message sizes not 640-aligned; no underflow) and the
standalone-vs-client asymmetry: both use the same playback queue, but the host
plays raw PCM (no drop) and is clean while clients play the Opus-encoded stream
and pop.

Fix (server-only path): carry the sub-frame remainder to the next message.
Prepend the previous message's leftover before encoding, emit only whole frames,
and stash the new tail. No samples dropped -> continuous stream -> no splice
click. Leftover reset per turn (new-turn guard + StopAgentAudio).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:41:25 +02:00
cf83a2302d ConvAgent: reassemble agent audio sub-chunks into one enqueue (fix pops)
After sub-chunking each ElevenLabs message into N ~0.8s Opus packets, the
client enqueued each sub-chunk separately, re-running the playback state
machine per sub-chunk (spurious Play() restart / pre-buffer race / underrun
at the seam) -> an audible pop at each ~0.8s boundary (2-3 per sentence).
Confirmed via multi-agent analysis + logs (no generation skips, no decode
truncation -> not a codec issue; the discontinuity is on the enqueue side).

- MulticastReceiveAgentAudio gains a bLastSubChunk flag; the server marks the
  final sub-chunk of each message (encode path frame-aligned so the last
  sub-chunk is well-defined; raw-PCM fallback likewise).
- The client accumulates decoded PCM across a message's sub-chunks and calls
  EnqueueAgentAudio (and lip-sync broadcast) ONCE, on the last sub-chunk.
- ClientReassemblyPCM reset in StopAgentAudio so a half-received message can't
  leak across a turn/interruption.

No added latency: a message's sub-chunks are sent back-to-back in one server
tick and arrive together. Each RPC still <=40 frames (Reliable, under the
encode/decode limits).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:23:46 +02:00
5b3a11a733 ConvAgent: agent audio multicast back to Reliable (fix Opus crackle/gaps)
Sending Opus over Unreliable multicast caused missing words and crackling:
Opus is a stateful inter-frame codec, so a dropped or reordered packet
desyncs the decoder and corrupts all following frames until the stream
resets. Unreliable was originally adopted only to stop a reliable-buffer
overflow — but that overflow came from 32-152KB raw-PCM RPCs (pre-Opus).
With Opus + sub-chunking each RPC is ~2KB (2-3 partial bunches) at
~1.25 RPC/s, so the 512-bunch reliable buffer stays in single digits and
cannot overflow. Reliable restores ordered, lossless delivery.

- MulticastReceiveAgentAudio: Unreliable -> Reliable.
- Keep the 40-frame (0.8s, ~2KB) sub-chunking — it is what keeps packets
  small enough for Reliable to be safe.
- Harden the raw-PCM fallback (only reached if Opus init fails): hard-cap
  one message at 320KB and drop the tail so it can never overflow the
  reliable buffer, and warn on every fallback (config bug: [Voice]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:31:02 +02:00
2ca5643f32 ConvAgent: fix agent voice truncation on remote clients (Opus)
Long ElevenLabs audio messages were cut off on remote clients:
- the client Opus decode buffer was only 32000 bytes (~0.88s), so the
  engine decoder aborted with "Decompression buffer too small" and
  dropped the tail of any packet longer than that;
- the server also encoded a whole message as a single Opus packet, which
  the engine encoder caps at 76 frames (~1.52s), silently dropping the rest.
The host/authority plays raw PCM and never decodes, so standalone and
listen-server hosts were unaffected, which masked the bug.

Fix (HandleAudioReceived + MulticastReceiveAgentAudio_Implementation):
- sub-chunk the Opus encode into <=40-frame (25600-byte, 0.8s) blocks,
  one RPC each: stays under both the 76-frame encode cap and the client
  decode buffer, and keeps each Unreliable RPC small;
- raise the client decode buffer 32000 -> 64*1024 as headroom.

Empirically confirmed: networked client logged 16x "Decompression buffer
too small" with Opus active; standalone (Opus equally active) logged zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:11:28 +02:00
4854a7fa9a ConvAgent: send agent audio multicast Unreliable + add size debug log
- MulticastReceiveAgentAudio: Reliable -> Unreliable. Realtime audio: a lost or
  oversized packet is dropped (minor glitch) instead of backing up the reliable
  buffer and dropping the client connection.
- Raw-PCM fallback chunk size 32000 -> 4000 bytes so an unreliable RPC is not
  auto-promoted to reliable when fragmented into many partial bunches (which
  would reintroduce the overflow).
- Add net-audio size log gated behind ps.ai.ConvAgent.Debug.ElevenLabs; fires on
  Authority even in solo PIE: "[NET-SRV] Opus audio chunk: N bytes (raw M -> Xx)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:52:48 +02:00
41900991ef ConvAgent: enable Voice module so the Opus codec initializes
The project had no [Voice] section, so the engine default bEnabled=false left
FVoiceModule::CreateVoiceEncoder() returning null (Encoder/Decoder NULL).
Networked agent audio then fell back to raw PCM in large reliable multicast
RPCs, overflowing the reliable buffer and dropping VR client connections the
moment the avatar spoke. Enabling Voice lets Opus compress (~13-20x), keeping
RPCs small.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:52:48 +02:00
efd9921fde ElevenLabs: guard null gaze target on late join (replicated ref not resolved)
A client that joins while a conversation is already active can run OnRep with
NetConnectedPawns/NetActiveSpeakerPawn entries whose replicated pawn references
haven't resolved yet (actor channel not open). NetConnectedPawns.Num() > 0 did
not guarantee a non-null element, so OnRep_ConversationState dereferenced a null
pawn in its log (crash in Dev/PIE) and ApplyConversationGaze set TargetActor to
null (gaze glitch). Both now skip until the reference resolves — OnRep fires
again once it's patched in. More likely with 3+ clients.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:16:35 +02:00
fee7d0c679 Gaze: stateless head/eyes so client matches server (fix ~60° head offset)
Single-player clients sometimes showed the head turned ~60° off while the
server looked correct. Two client-only causes:

1. The client overwrote the actor's rotation every frame (SetActorRotation)
   and then read back its own output as the "replicated" yaw, so SmoothedBodyYaw
   diverged from the true server body yaw.
2. The head/eye cascade was sticky / path-dependent (TargetHeadYaw updated on
   overflow events), so running it independently on a client fed by lagged,
   stepped replication latched into a different state — the head overflowed to
   MaxHeadYaw and stayed there.

Rewrite head/eyes as a STATELESS function of the actual (authoritative on the
server, replicated on the client) body facing + target direction. No sticky
state, FInterpTo only eases the visual. The client no longer overrides the actor
rotation. Body turn stays authority-only (lazy turn beyond head+eyes range).
Server and client now converge to the same pose by construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:13:53 +02:00
777ffa09a8 ElevenLabs: fix multi-player speaker arbitration with open mic
With an open mic streaming continuously (Server VAD + interruption), every
chunk refreshed the active speaker's LastSpeakTime, so SpeakerSwitchHysteresis
never elapsed and SpeakerIdleTimeout never fired — the first speaker locked the
floor forever and the agent never turned to anyone else.

Now only chunks with real voice energy (RMS >= 0.02) hold/take the floor: the
active speaker is still forwarded continuously (silence tail for VAD), but their
lock only persists while they actually speak. When they pause, another player's
voiced audio can switch in and the agent turns to them (NetActiveSpeakerPawn
replicated -> gaze). Validated with two players on one NPC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:13:44 +02:00
ab2482a209 ElevenLabs: remove temporary [MIC-DBG] mic uplink diagnostics
Client mic uplink confirmed working in a packaged build across two PCs
(networked Server VAD): the agent now replies to the player's voice.
Remove the [MIC-DBG]/[MIC-DBG-SRV] instrumentation added to trace it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:27:39 +02:00
c2ca2e3dbb ElevenLabs: fix client mic uplink in networked Server VAD mode
Networked clients could hear the agent but the agent never replied to them
(worked in standalone/listen-server host). Root causes, client-only paths
that never run on the authority:

1. Deferred StartListening was never retried once the conversation went live.
   OnRep_ConversationState now (re)starts listening when bNetIsConversing
   replicates and the local player is in the conversation — closes the
   RPC/replication ordering race so bIsListening reliably opens the uplink.

2. The client stripped ALL silent mic chunks to save bandwidth, but ElevenLabs
   Server VAD needs the trailing silence to detect end-of-turn — so the agent
   heard the words but never the pause and never responded. Replaced the hard
   silence drop with a silence "tail": keep streaming for MicSilenceTailSeconds
   (default 2s, tunable) after the last voiced chunk, then drop during idle.
   The authority path is unchanged (always streams the full mic).

Also: debug HUD now shows "CONNECTED (via server)" on clients instead of a
misleading "DISCONNECTED" (clients have no local WebSocket; IsConnected() is
always false there).

[MIC-DBG] temporary diagnostics are kept in this commit to validate the fix on
another machine; they will be removed in a follow-up once confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:07:30 +02:00
8ae73bbacb compil bug fix 2026-06-24 16:58:53 +02:00
6a8c22c77e ElevenLabs: editor-only API key for agent authoring (no leak in builds)
Re-enables agent/tool/action-set authoring in the editor after the API
key was removed from Project Settings. Adds an editor-only key source so
it can never be committed-as-config or baked into a packaged build.

- New UPS_AI_ConvAgent_EditorSettings_ElevenLabs (UDeveloperSettings,
  config=EditorPerProjectUserSettings, Editor category): single global
  field, stored under Saved/Config (never cooked), in the editor module
  (never packaged).
- ResolveEditorApiKey(): editor setting -> ELEVENLABS_API_KEY env -> runtime
  SaveGame fallback. All three editor customizations route through it.
- Fix the now-stale "API Key not set in Project Settings" messages.

Runtime path is untouched: packaged game still uses the client-provided
key via the SaveGame (Set/GetElevenLabsAPIKey).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:36:47 +02:00
7d99a89ac7 android 2026-06-12 08:09:24 +02:00
a5b0d5db84 PS_AI_Behavior: freeze movement during conversation + manual lever
Auto pause now triggers as soon as a conversation is genuinely active (agent greeting included) instead of waiting for the user to speak — the NPC no longer walks away during its opening line.

Add DisableBehaviorMovement()/EnableBehaviorMovement() (BlueprintCallable): freeze the NPC's autonomous movement while keeping the Behavior Tree running (perception, threat, gaze, reactions). Movement is paused when EITHER manually disabled OR a conversation is active. Both drive the ConversationPaused Blackboard flag read by the movement-branch decorators; leave the Flee branch un-decorated so fleeing still works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:41:06 +02:00
cfade2a265 PS_AI_Behavior: make spline StopFollowing a firm, BT-respected stop
StopFollowing() now sets a bManuallyStopped flag (cleared only by an explicit StartFollowing/StartFollowingAtDistance). The FindAndFollowSpline and FollowSpline BT tasks bail while it is set, so the BT no longer auto-resumes a manual stop on the next tick. ResumeFollowing() also respects the flag. PauseFollowing() is unchanged (BT may resume).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:40:56 +02:00
27d0906d21 ElevenLabs: secure private agents (signed URL) + encrypt key at rest
Builds on the client-provided-key SaveGame work to actually secure usage:

- Private agents: connect via a short-lived Signed URL fetched with the stored key (GET /v1/convai/conversation/get-signed-url), instead of a direct agent_id+header connection which ElevenLabs rejects for auth-enabled agents. Falls back to a direct connection only when no key is set (public agents). Enforces 'valid key required': no key -> no signed URL -> no conversation.

- Force agent creation/update to private (platform_settings.auth.enable_auth=true) so a public, key-less agent can't be created by mistake.

- Encrypt the API key at rest in the SaveGame (AES-256); plaintext kept only in the in-memory cache.

- Redact signed-URL query (auth token) from logs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:00:05 +02:00
89d555b734 ElevenLabs: client-provided API key via SaveGame
The API key is no longer stored in project settings / DefaultEngine.ini (it was committed and baked into shipped builds). It is now provided per-install by the client at runtime and persisted in a SaveGame slot.

- Add UPS_AI_ConvAgent_SaveGame_ElevenLabs (slot PS_AI_ConvAgent_ElevenLabs)

- BlueprintLibrary is the single owner of the key: Set/Get/Has/Clear + in-memory cache backed by the SaveGame

- Add async BP node 'Test ElevenLabs API Key' (GET /v1/models)

- Remove API_Key UPROPERTY from UPS_AI_ConvAgent_Settings_ElevenLabs and the key logic from the module

- Route all consumers (WebSocket auth, region probe, editor sync customizations) through GetElevenLabsAPIKey()

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:27:29 +02:00
7430a18e66 change for UE 5.7 2026-05-28 18:05:06 +02:00
ceaf89f836 bug fix pour 5.7 2026-05-06 17:52:08 +02:00
a1e34e6dfa Decouple ForceDisableConversation from action flow + mic timing fix
- ElevenLabsComponent::ForceDisableConversation() — removed ActionName param. Now a pure conversation master-switch with no knowledge of actions. OnReadyForAction is fired by the action flow itself: PendingActionName is set when OnAgentActionRequested is broadcasted, then OnReadyForAction fires either after blend-out (if conversation was disabled) or on the next tick (if not).
- Rename bConversationDisabledByAction → bConversationForceDisabled — the old name implied coupling with actions which no longer exists.
- Add bPendingStartListening — when StartListening is called before the WebSocket is connected (race between StartConversation and StartListening from InteractionComponent), defer instead of silently failing. Consumed automatically in HandleConnected / ClientStartConversation. Cleared by StopListening / EndConversation.
- PerceptionComponent — demote the "perceived N actors but ALL filtered out" warning to Verbose; it's a normal case for civilians surrounded by non-hostile actors, not a misconfiguration.
- CLAUDE.md — workflow rule: always describe and request confirmation before code changes.
- Martin.uasset — agent config tweaks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 12:37:18 +02:00
f338c0586e 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>
2026-04-22 20:20:22 +02:00
48d1543329 Reorganize Blueprint categories under ASTERION namespace, move bAutoStartBehavior to PersonalityComponent, add user documentation
- Bulk rename Category/ClassGroup strings to ASTERION|PS_AI_ConvAgent|... and ASTERION|PS_AI_Behavior|... (284 occurrences, 24 files) so functions/properties/components group cleanly in Blueprint menus
- Move bAutoStartBehavior from AIController to PersonalityComponent so the same AIController class can drive multiple NPC archetypes with per-instance scripted-mode override
- Add PowerPoint user documentation for both plugins in Docs/ with generator scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:30:48 +02:00
15871be9e9 Remove plugin binaries from tracking, add Plugins/*/Binaries/ to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:43:56 +02:00
6fcbdb2005 Merge branch 'main' of https://git.polymorph.fr/j.foucher/PS_AI_Agent 2026-04-09 12:40:00 +02:00
e322a400be bin 2026-04-09 12:39:36 +02:00
fda2e4e32f Add .gitattributes to normalize line endings across machines
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:42:52 +02:00
e32c53ffd6 Remove Live Coding patch files from tracking, add *.patch_* to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:39:41 +02:00
66171cc0bd Add ElevenLabs Tool system, update ConvAgent binaries and cleanup patches
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:27:58 +02:00
c1afddc8b7 Add Scripted state and bAutoStartBehavior for external NPC control
Allow NPCs to start without a Behavior Tree and be controlled externally
via Blueprint, Level Sequences, or triggers. Adds Scripted enum state that
prevents BT services from overriding state, plus StartBehavior/StopBehavior
BlueprintCallable functions to toggle BT execution at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:26:26 +02:00
a31ac1d782 refactor 2026-04-03 08:19:49 +02:00
661e8c66ac Add CVar debug system: ps.ai.Behavior.Debug controls all diagnostic logs
- New CVar ps.ai.Behavior.Debug: -1=use bDebug property, 0=force off, 1=force on
- IsDebugEnabled() static helper checks CVar then PersonalityComponent.bDebug
- All diagnostic logs gated by IsDebugEnabled (no spam when debug off)
- Logs use Log level (visible) when enabled, silent when disabled
- Applied to: UpdateThreat, FindAndFollowSpline, FindCover, PerceptionComponent,
  PersonalityComponent EvaluateReaction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:44:32 +02:00
b473fcda22 Defer BodyExpression activation to first agent speech, demote spammy logs to Verbose
- BodyExpressionComponent: don't activate on OnConversationConnected, wait for
  OnSpeakingStarted (prevents body anims while NPC still walking)
- Demote recurring logs to Verbose: UpdateThreat per-tick, FindAndFollowSpline
  debug, FindCover distance check, perception target scoring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:38:41 +02:00
c32aba9902 Add local VAD and conversation-driven NPC pause
MicrophoneCaptureComponent:
- Local Voice Activity Detection (RMS-based, independent of ElevenLabs)
- Configurable threshold, onset time, silence time
- bIsUserSpeaking flag + OnUserVoiceActivityChanged delegate
- Hysteresis prevents flickering between speech/silence

AIController gaze bridge:
- Resolve MicComponent from player Pawn (not NPC) via reflection
- ConversationPaused BB key blocks movement branches via BT decorator
- NPC stops only when user actually speaks (not just on proximity connect)
- NPC resumes when conversation disconnects
- Spline PauseFollowing/ResumeFollowing on conversation start/end

BT setup required:
- Add Blackboard Condition (ConversationPaused Is Not Set, Aborts=Both)
  on spline and patrol branches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:22:21 +02:00
90bee03b44 Gaze bridge cleanup: revert premature conversation pause, clean up diag logs
- Revert conversation movement pause (needs local VAD, not bNetIsConversing)
- Remove temporary gaze proximity debug log
- Remove bConversationPaused flag (will be reimplemented with VAD)
- TODO: pause NPC movement only when local voice activity is detected

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:50:38 +02:00
d9fb46bc96 Add gaze bridge: behavior-driven look-at via ConvAgent GazeComponent
New BTService_UpdateGaze bridges PS_AI_Behavior to PS_AI_ConvAgent's
GazeComponent via runtime reflection (zero compile dependency).

Priority system:
- Combat/TakingCover: gaze disabled (aim animation handles it)
- Alerted: look at ThreatActor (head + eyes, no body)
- Conversation: skip (ConvAgent manages)
- Proximity: glance at nearest perceived actor within radius

Proximity gaze features:
- Lock on target until release (no jumping)
- Configurable duration, cooldown, radius on AIController
- Front-facing only (dot product filter)
- Skip spectators and hostile actors

GazeComponent fix:
- Sync SmoothedBodyYaw to actual actor rotation when body tracking
  is disabled, preventing stale head offset during spline movement

Files:
- New: BTService_UpdateGaze.h/.cpp
- Modified: AIController.h/.cpp (gaze bridge, config, bind, cleanup)
- Modified: GazeComponent.cpp (body yaw sync fix)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:16:33 +02:00
d471714fbd Fix rival faction detection: include Faction in fallback TeamId resolution
- GetTeamAttitudeTowards fallback (interface path) was calling MakeTeamId
  without Faction, so enemies of different factions had identical TeamIds
  → always Friendly instead of Hostile
- Now reads Faction from PersonalityProfile when resolving via interface

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:14:36 +02:00
af93194f48 Multiple fixes: threat decay per profile, state hysteresis, spline pathfinding, attack readiness
- Move ThreatDecayRate from global Settings to PersonalityProfile (per-archetype)
- Add state hysteresis in EvaluateReaction to prevent Fleeing/Combat flickering
- FindCover: verify distance on arrival, retry movement if too far
- FindAndFollowSpline: sample multiple spline points when closest is blocked
- BTTask_Attack: call BehaviorStartAttack immediately (draw weapon before LOS/range)
- CoverShootCycle: call BehaviorStartAttack on entry (weapon ready during cover approach)
- FindOwningPawn: walk AttachParentActor chain for ChildActor weapons without Owner
- Initialize PreferCover BB key to false at setup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:42:05 +02:00
f5897b46cc Remove unused Loyalty/Discipline traits, lower Aggressivity combat threshold to 0
- Remove Loyalty and Discipline from EPS_AI_Behavior_TraitAxis enum (never used in gameplay)
- Clean up PersonalityProfile and PersonalityComponent default trait initialization
- Add descriptive tooltips to remaining traits (Courage, Aggressivity, Caution)
- Lower Aggressivity combat gate from 0.3 to 0.0 (only Aggressivity=0 prevents combat)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:16:19 +02:00
de3a5310f4 Update tooltips and comments: fix outdated references, clarify personality modulation
- BTTask_Attack.h: remove reference to non-existent GetBehaviorOptimalAttackRange, document PersonalityProfile ranges
- AIController.h: update TeamId comment to reflect actual MakeTeamId encoding (nibble-based)
- CoverShootCycle.h: clarify that Peek/Cover durations are base values modulated by personality traits
- FindCover.h: clarify ManualPointBonus is additive score
- CombatComponent.h: clarify AttackRange/AttackCooldown are for ExecuteAttack, not BTTask_Attack

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:04:59 +02:00
69b9844a4b bin 2026-03-31 20:33:30 +02:00
78149fffcd Add personality-driven Combat/Cover cycle with BB PreferCover key
- Add CombatCoverCycleDuration to PersonalityProfile (configurable base duration)
- PersonalityComponent cycles bPreferCover based on Aggressivity/Caution ratio
  - Combat duration = CycleDuration × Aggressivity/(Aggressivity+Caution)
  - Cover duration = CycleDuration × Caution/(Aggressivity+Caution)
  - Min 2s per phase, ±20% jitter
- Write PreferCover bool to Blackboard for BT decorator observer aborts
- IsCoverNeeded decorator checks both target type AND ShouldPreferCover()
- Remove TakingCover state from EvaluateReaction — cover is now a sub-mode of Combat
- BT uses Blackboard Condition on PreferCover with Observer Aborts=Both

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:26:37 +02:00
b60086d107 Add server authority checks for networking safety
- PerceptionComponent: omniscient TActorIterator only runs with HasAuthority()
- PersonalityComponent: ApplyReaction and ForceState gate replicated CurrentState writes to server only

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:24:55 +02:00
d6325b373d Fix IsCoverNeeded decorator: resolve AimTargetActor to owning Pawn
- IsCoverNeeded used Cast<APawn> on ThreatActor which failed for AimTargetActors
  → always returned true (assume dangerous) → enemies took cover against civilians
- Fix: use FindOwningPawn to walk Owner/Instigator chain to the actual Pawn
- Revert inline civilian check in EvaluateReaction (decorator handles it in BT)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:20:04 +02:00
011bfcf62a Add uncrouch on state change and flee: civilians stand up when leaving cover
- AIController: auto uncrouch when leaving Fleeing/TakingCover state
- FleeFrom: uncrouch at start of flee (civilian was still crouched from HidingSpot)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:59:22 +02:00
391a35ac2c bin 2026-03-31 16:33:57 +02:00
44f7e860aa Cover system Phase 8: EQS filter fix, LOS lateral spread, omniscient threat awareness, debug improvements
- Fix EQS SetScore filter bug: pass FloatValueMin/Max instead of hardcoded 0/1 (filters were ignored)
- Add LateralSpread to LineOfSight EQS test: multiple traces for wider LOS check
- Add bDrawDebug to CoverQuality and LineOfSight EQS tests
- Ignore ThreatActor collision in EQS traces (AimTargetActor was blocking its own LOS)
- Add navmesh projection in EQSContext_CoverLocation for cover points inside geometry
- Add omniscient awareness: enemies detect top-priority targets (Protectors) within sight radius without perception cone
- Suppress target switching during TakingCover state to prevent cover invalidation
- Fix flanking check: trace at chest height instead of feet
- Add debug visualization for EQS fallback paths (NO REFINE, NO FIRE POS, IN PLACE)
- Clean up diagnostic logs: verbose for per-trace EQS details, log level for summaries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:30:40 +02:00
86d3ae118d Cover system Phase 7: flanking detection, EQS tuning, target persistence, MoveTimeout safety
- CoverShootCycle: SetFocus only during AtCover (not shooting), flanking detection every 0.5s abandons compromised cover
- CoverQuality EQS: ground hit filter, MaxNearbyHitDist (300cm), lateral spread traces, capsule-relative heights
- EQSContext_CoverLocation: prefer CoverPoint actor location over refined vector
- UpdateThreat: never abandon target during Combat/TakingCover states
- MoveTimeout (5s) on all movement states to prevent stuck NPCs
- FindCover: detailed refinement result logging
- Debug: Peeking timer log for investigating timer-stops-counting bug (needs rebuild + test)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 21:12:39 +02:00
9aaf16c655 WIP: Cover system firing position, IsCoverNeeded decorator, EQS contexts
- CoverShootCycle: add FiringPositionQuery EQS for peek/shoot positions
  (NPC moves between cover position and firing position with LOS)
- Add BTDecorator_IsCoverNeeded: skip cover when target is Civilian
- Add EQSContext_CoverLocation: provides BB CoverLocation to EQS generators
- FindCover: add debug draw toggle and EQS refinement debug spheres
- Definitions: add ECoverShootSubState and CoverPointType::HidingSpot

NOTE: Has compilation errors to fix (signature mismatches in
CoverShootCycle StartPeeking/ReturnToCover, missing forward-declare)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:53:52 +02:00
98f0dbdce5 Add cover system LOS checks, crouch interface, and EQS refinement
- SetBehaviorCrouch() interface function for cover/hiding crouch control
- CoverShootCycle: continuous LOS check during Peeking (stop if target hides)
- CoverShootCycle: crouch/stand transitions at all cover state changes
- CoverShootCycle: fail when no LOS and no advancing cover (falls to Attack)
- FindCover: crouch on arrival, stand up on abort
- FindCover: optional EQS RefinementQuery to refine exact position around CoverPoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 07:47:11 +02:00
25abd59512 Fix EQS firing position: invalid BB vector check, AlreadyAtGoal fallback
- LastKnownTargetPosition check now filters FLT_MAX/InvalidLocation (not just zero)
- EQS result with AlreadyAtGoal triggers fallback advance instead of no-op
- bProjectGoal enabled for EQS move to handle NavMesh projection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 11:14:04 +02:00
59a23e61db Add EQS LineOfSight test, LOS combat plan, and updated binaries
- EQSTest_LineOfSight: new EQS test for firing position queries
- Updated plugin binaries (PS_AI_Behavior + PS_AI_ConvAgent)
- TeamComponent, Settings, Build.cs updates from LOS plan implementation
- PLAN.md updated with LOS combat implementation progress

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 11:07:21 +01:00
65b86e2fbd Fix combat BT stuck on spline, LOS through target capsule, NoTeam targeting
- Attack/CoverShootCycle: de-escalate to Alerted on failure so decorator
  can re-trigger when threat returns (fixes BT stuck on spline branch)
- UpdateThreat: keep BB ThreatActor during brief perception gaps instead
  of clearing immediately (use LOS timeout for graceful degradation)
- HasLineOfSight: ignore actors up the attachment chain so trace doesn't
  hit the target Pawn's capsule when aiming at its AimTarget child actor
- NoTeam actors (spectators, editor pawns) treated as Neutral instead of
  Hostile, plus SpectatorPawn explicit filter in perception
- BB debug key ThreatPawnName shows owning Pawn name (resolved via
  perception's LastThreatOwningPawn) instead of cryptic AimTarget name
- FindOwningPawn promoted to public static on PerceptionComponent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 11:03:27 +01:00
2588883a1c Fix perception to separate Pawn (team checks) from ThreatTarget (aiming)
Split ResolveToPawn into FindOwningPawn + GetThreatTarget so non-Pawn
actors (PS_AimTargetActor) are properly resolved for team/attitude checks
while still being used as BB target. Add attack range hysteresis (10%
buffer), target persistence (80% threshold), melee no-cooldown chase,
ranged midpoint approach. New files: CoverShootCycle task, CheckCombatType
decorator, MinAttackRange/MaxAttackRange in PersonalityProfile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:20:42 +01:00
e7c3598dce Fix combat cycle, spline resume, team identity, and debug system
- Add TeamComponent for player pawn team identity (Role-based: Civilian/Enemy/Protector)
- Add IsTargetActorValid to interface for dead target filtering
- Fix GetTeamAttitudeTowards to check IGenericTeamAgentInterface + TeamComponent
- Guarantee BehaviorStopAttack via OnTaskFinished (all exit paths)
- Prevent Combat state without ThreatActor (stay Alerted until perception catches up)
- Resume spline at closest point from current position after combat
- Sync CurrentSpline and SplineProgress to Blackboard in FollowSpline tick
- Auto-detect Patrol state when NPC has a spline (fixes Idle speed=0 blocking movement)
- Add per-component debug toggles (Personality + SplineFollower independent)
- Use AddMovementInput instead of RequestDirectMove for reliable post-combat movement
- Add bTickInEditor for Personality debug in Simulate mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 12:43:08 +01:00
0c4c409ebc add binaries 2026-03-27 08:11:03 +01:00
137aeb5953 Update CLAUDE.md with PS_AI_Behavior architecture and testing status
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:04:55 +01:00
eb5113ea30 Fix TeamId auto-assignment and interface default implementations
- Always recalculate TeamId at OnPossess (BP CDOs may reset to 0)
- Remove duplicate _Implementation definitions (UHT auto-generates them)
- Fixes crash when interface functions not overridden in BP

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:03:14 +01:00
1799ba28c0 Delegate combat to Pawn via IPS_AI_Behavior interface
- Add BehaviorStartAttack/BehaviorStopAttack to IPS_AI_Behavior_Interface
- Attack task now calls interface instead of CombatComponent directly
- Task stays InProgress permanently, Decorator Observer Aborts handles exit
- Remove CombatComponent dependency from Attack task
- Pawn handles actual aiming/shooting via its own systems

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:42:30 +01:00
9d054cc46f Fix BT state transitions, attack persistence, and hostile team switching
BT Attack task:
- Stay InProgress permanently instead of returning Succeeded after each attack
- Stay InProgress on MoveToActor failure instead of Failed (retry next tick)
- Add verbose logging for attack state (target, range, distance)

BT EvaluateReaction service:
- Auto-detect hostility changes via IPS_AI_Behavior interface
- Dynamically update TeamId when IsBehaviorHostile() changes (infiltrator reveal)

AIController:
- Remove GetBehaviorTeamId from interface (TeamId is now 100% automatic)
- TeamId derived from NPCType + hostile state, no user implementation needed
- Add BB State change logging for debug
- Use SetValueAsEnum consistently for BehaviorState key

Interface:
- Remove GetBehaviorTeamId — TeamId is computed by the plugin automatically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:02:48 +01:00
2e04cb0334 Fix perception, patrol, and spline following for PS_AI_Behavior
Perception:
- Configure senses in BeginPlay + RequestStimuliListenerUpdate (not constructor)
- Filter threats by team attitude: only Hostile actors generate threat
- Skip Friendly and Neutral actors in CalculateThreatLevel and GetHighestThreatActor
- All Hostile actors are valid threats regardless of TargetPriority list

Blackboard:
- Use SetValueAsEnum/GetValueAsEnum for BehaviorState key (was Int)

Patrol:
- Auto NavMesh patrol when no manual waypoints defined
- Project HomeLocation onto NavMesh before searching random points
- Fallback to NPC current position if HomeLocation not on NavMesh

Spline following:
- Choose direction based on NPC forward vector vs spline tangent (not longest distance)
- Skip re-search if already following a spline (prevent BT re-boucle)
- Reverse at end: wait for NPC to catch up before inverting direction
- Use NPC actual CharacterMovement speed for target point advancement
- Face toward target point (not spline tangent) to prevent crab-walking in turns
- Junction switching: direction continuity check, 70% switch chance, world gap tolerance
- Debug draw: green sphere (target), yellow line (gap), cyan arrow (tangent), text overlay
- Add GetWorldDirectionAtDistance to SplinePath

Editor:
- Add Placeable to SplinePath and CoverPoint UCLASS
- Add PersonalityProfileFactory for Data Asset creation
- Add EnsureBlackboardAsset declaration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:44:39 +01:00
909583a1bb Fix PS_AI_Behavior compilation errors for UE 5.5
- Remove NavigationSystem from .uplugin Plugins (it's a module, not a plugin)
- Fix UInterface naming: IPS_AI_Behavior -> IPS_AI_Behavior_Interface (UHT requirement)
- Fix TWeakObjectPtr<AActor> TArray not Blueprint-compatible (remove BlueprintReadOnly)
- Fix UseBlackboard TObjectPtr ref: use raw pointer intermediary
- Fix FEdMode::HandleClick signature: FInputClick -> FViewportClick (UE 5.5)
- Fix SplineNetwork: use OnWorldBeginPlay(UWorld&) override instead of delegate
- Fix PerceptionComponent: remove const from methods calling non-const GetActorsPerception
- Fix EQS SetScore: use 5-arg float overload (Score, FilterMin, FilterMax)
- Fix BTTask_FindCover: add missing Definitions.h include, fix const World
- Fix BTTask_FollowSpline: replace AddWeakLambda with polling in TickTask
- Fix CoverPoint: initialize Color before switch, add default case
- Export LogPS_AI_Behavior with PS_AI_BEHAVIOR_API for cross-module visibility
- Remove unused variables (WorldOrigin, WorldDirection)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:41:42 +01:00
5d5b85380a Add PS_AI_Behavior plugin: NPC behavior system with BT, EQS, splines, cover, and editor tools
New plugin providing a complete NPC behavior framework:

- Personality system: score-based traits (Courage, Aggressivity, Caution, Loyalty, Discipline)
  with PersonalityProfile data assets and runtime-modifiable traits
- NPC types: Civilian, Enemy, Protector with team affiliation and perception
- IPS_AI_Behavior interface: decoupled bridge between plugin and host project
  (type, hostility, team, movement speed, state notifications)
- AIController: auto Blackboard setup, BT launch, team ID mapping,
  GetTeamAttitudeTowards (Civilian<->Protector friendly), soft ConvAgent binding
- Perception: sight/hearing/damage senses, target priority system per profile,
  multi-player support, ClassifyActor helper
- BT nodes: UpdateThreat, EvaluateReaction services; CheckTrait decorator;
  Patrol, FleeFrom, FindCover, Attack, FollowSpline, FindAndFollowSpline tasks
- Combat: CombatComponent with range, cooldown, damage, NetMulticast attack events
- Spline navigation: SplinePath actors (typed), SplineNetwork subsystem with
  junction detection, SplineFollowerComponent for fluid movement
- Cover points: manually placed CoverPoint actors (Cover/HidingSpot),
  occupancy system, hybrid BTTask (manual + procedural fallback),
  EQS generator for cover point queries
- Editor tools: SplineEdMode with interactive placement, cover point tool,
  SplinePanel (list, create, validate, snap-to-ground), SplineVisualizer
  (junctions, direction arrows, distance markers)
- Replication: CurrentState, ThreatLevel, CurrentTarget, CurrentSpline replicated
  for multiplayer; server-authoritative AI with client cosmetic callbacks
- Movement speed per state: configurable in PersonalityProfile, applied via interface

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:27:20 +01:00
a07ac36686 no message 2026-03-16 18:26:36 +01:00
18996a7254 Add Expressive Mode support for ElevenLabs V3 Conversational TTS
- Add bExpressiveMode toggle and editable ExpressiveModePromptFragment
  with audio tag instructions ([laughs], [whispers], [sighs], [slow], [excited])
- BuildAgentPayload: append prompt fragment, set expressive_mode API field
  on agent config, auto-override TTS model to eleven_v3_conversational
- OnFetchAgent: strip expressive fragment from prompt (exact + marker fallback),
  read expressive_mode bool from API, auto-detect V3 model
- TTS model combo: inject asset's current model if absent from /v1/models list
  (covers agent-only models like eleven_v3_conversational)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:06:20 +01:00
e5a32f5997 Fix audio cutoff and lip sync activation bugs during agent switching
- Fix A→B→A audio cutoff: when switching back to a pending-leave agent,
  cancel the deferred leave instead of force-completing it (was calling
  StopAgentAudio on the agent we're returning to)
- Fix deferred leave firing during TTS gaps: use IsAgentSpeakingOrPending()
  instead of IsAgentSpeaking() — checks bAgentGenerating and
  bAgentResponseReceived to avoid premature leave during inter-batch silence
- Convert silence detection from tick-based to time-based: SilentTickCount
  → SilentTime (float seconds), GeneratingTickCount → GeneratingTime.
  Consistent behavior regardless of frame rate (was 5s@120fps vs 20s@30fps)
- Fix lazy binding: add OnAgentConnected/OnAgentDisconnected in LipSync
  and FacialExpression TickComponent lazy-bind path (bActive stayed false
  forever in packaged builds when component init order differed)
- Fix reconnection: reset bWaitingForAgentResponse and GeneratingTime
  before entering reconnect mode to avoid stale state on new session
- Fix event_ID audio filtering: reset LastInterruptEventId in
  HandleAgentResponse and SendUserTurnStart so first audio chunks of a
  new turn are not silently discarded by stale interrupt filter
- Preserve retained gaze when switching back to same agent (don't
  CleanupRetainedGaze if PrevRetained == NewAgent)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:50:43 +01:00
aea02abe89 Add ForceDisableConversation, ActionSet data asset, passive gaze, and debug HUD improvements
- ForceDisableConversation/ForceEnableConversation: disable agent conversation
  with blend-out monitoring and OnReadyForAction event (with ActionName param)
- ActionSet data asset: configurable action list per agent with editor
  customization (Update All Agents button, custom detail panel)
- Passive gaze by proximity: nearby non-selected agents track the player
  with configurable head+eyes and body checkboxes (bAutoPassiveGaze,
  bPassiveGazeHeadEyes, bPassiveGazeBody)
- Retained gaze on conversation switch now uses the same passive gaze config
- OnPassiveGazeStarted/OnPassiveGazeStopped events on ElevenLabsComponent
- Fix debug HUD key collisions: per-actor key ranges prevent multi-agent
  HUD flickering, add actor name to all HUD titles
- Fix retained gaze bug: re-activate gaze after ExecuteLeave before
  ApplyConversationGaze kills it
- Safety timeout (5s) for blend-out monitoring
- Guard on AttachGazeTarget when conversation is disabled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:59:22 +01:00
eaa52a5c5f Unreal Datas updated 2026-03-06 17:07:30 +01:00
28aed55cd3 Allow switching agents mid-conversation by looking at another agent
Conversation lock no longer prevents switching to a different agent.
When in an active conversation, the player can look at another nearby
agent for ConversationSwitchDelay seconds (default 1s) to switch.
Looking at empty space keeps the current agent selected (no deselect).
Works in multiplayer — each player has independent switch tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:07:03 +01:00
4456dfa9dc Add turn eagerness, speculative turn, adaptive pre-buffer, and latency HUD improvements
- Add TurnEagerness (Eager/Normal/Patient) and bSpeculativeTurn to agent config
  data asset, sent as conversation_config_override at WebSocket connection time
- Add adaptive pre-buffer system: measures inter-chunk TTS timing and decreases
  pre-buffer when chunks arrive fast enough (decrease-only, resets each conversation)
- New UPROPERTY: bAdaptivePreBuffer toggle, AudioPreBufferMs as starting/worst-case value
- Rework latency HUD: TTS+Net, PreBuf actual/target with trend indicator, Gen>Ear,
  WS Ping, server region display
- Fetch ElevenLabs server region from REST API x-region header
- Add editor Detail Customization: TurnEagerness dropdown + SpeculativeTurn checkbox
  in AgentConfig with LLM picker and Language picker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:43:20 +01:00
2169c58cd7 Update memory: latency HUD status + future server-side metrics TODO
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:47:11 +01:00
5fad6376bc Fix latency HUD: anchor all metrics to agent_response_started
user_transcript arrives AFTER agent_response_started in Server VAD mode
(the server detects end of speech via VAD, starts generating immediately,
and STT completes later). This caused Transcript>Gen to show stale values
(19s) and Total < Gen>Audio (impossible).

Now all metrics are anchored to GenerationStartTime (agent_response_started),
which is the closest client-side proxy for "user stopped speaking":

- Gen>Audio: generation start → first audio chunk (LLM + TTS)
- Pre-buffer: wait before playback
- Gen>Ear: generation start → playback starts (user-perceived)

Removed STTToGenMs, TotalMs, EndToEarMs, UserSpeechMs (all depended on
unreliable timestamps). Simpler, always correct, 3 clear metrics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:39:49 +01:00
0b190c3149 Rework latency HUD: base all measurements on first user_transcript
Previous approach used TurnEndTime (from StopListening) which was never
set in Server VAD mode. Now all latency measurements are anchored to
TurnStartTime, captured when the first user_transcript arrives from the
ElevenLabs server — the earliest client-side confirmation of user speech.

Timeline: [user speaks] → STT → user_transcript(=T0) → agent_response_started → audio → playback

Metrics shown:
- Transcript>Gen: T0 → generation start (LLM think time)
- Gen>Audio: generation start → first audio chunk (LLM + TTS)
- Total: T0 → first audio chunk (full pipeline)
- Pre-buffer: wait before playback
- End-to-Ear: T0 → playback starts (user-perceived)

Removed UserSpeechMs (unmeasurable without client-side VAD).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:32:59 +01:00
56b072c45e Fix UserSpeechMs growing indefinitely in Server VAD mode
TurnStartTime was only set in StartListening(), which is called once.
In Server VAD + interruption mode the mic stays open, so TurnStartTime
was never updated between turns. Now reset TurnStartTime when the agent
stops speaking (normal end + interruption), marking the start of the
next potential user turn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:27:14 +01:00
a8dd5a022f Fix latency HUD showing --- in Server VAD mode
Move latency reset from StopListening to HandleAgentResponseStarted.
In Server VAD + interruption mode, StopListening is never called so
TurnEndTime stayed at 0 and all dependent metrics showed ---. Now
HandleAgentResponseStarted detects whether StopListening provided a
fresh TurnEndTime; if not (Server VAD), it uses Now as approximation.
Also fix DisplayTime from 0 to 1s to prevent HUD flicker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:22:28 +01:00
955c97e0dd Improve latency debug HUD: separate toggle, per-turn reset, multi-line display
- Add bDebugLatency property + CVar (ps.ai.ConvAgent.Debug.Latency)
  independent from bDebug to save HUD space
- Reset latencies to zero each turn (StopListening) instead of persisting
- Add UserSpeechMs and PreBufferMs to the latency struct
- Move latency captures outside bDebug guard (always measured)
- Replace single-line latency in DrawDebugHUD with dedicated DrawLatencyHUD()
  showing 6 metrics on separate lines with color coding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:06:39 +01:00
d60f8d8484 Add response latency metrics to ElevenLabs debug HUD
Track 4 latencies per conversation turn (computed only when bDebug is active):
- STT→Gen: user stops talking → server starts generating
- Gen→Audio: server generating → first audio chunk received
- Total: user stops talking → first audio chunk (end-to-end)
- End-to-Ear: user stops talking → audio playback starts (includes pre-buffer)

New timestamps: GenerationStartTime (HandleAgentResponseStarted),
PlaybackStartTime (3 OnAudioPlaybackStarted sites). Values persist on
HUD between turns, reset when new turn starts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:51:25 +01:00
6d4ef21269 Merge feature/multi-player-shared-agent into main 2026-03-05 17:39:05 +01:00
c922fd304c Fix multi-player regressions: audio/text drops, thread safety
1. StartConversationWithSelectedAgent: remove early return when WebSocket
   is already connected (persistent mode). Always call ServerJoinConversation
   so the pawn is added to NetConnectedPawns and bNetIsConversing is set.

2. ServerSendMicAudioFromPlayer: bypass speaker arbitration in standalone
   mode (<=1 connected pawn). Send audio directly to avoid silent drops
   caused by pawn not being in NetConnectedPawns array. Add warning logs
   for multi-player drops to aid debugging.

3. OnMicrophoneDataCaptured: restore direct WebSocketProxy->SendAudioChunk
   on the server path. This callback runs on the WASAPI audio thread —
   accessing game-thread state (NetConnectedPawns, LastSpeakTime) was
   causing undefined behavior. Internal mic is always the local player,
   no speaker arbitration needed.

4. StopListening flush: send directly to WebSocket (active speaker already
   established, no arbitration needed for the tail of the current turn).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:33:43 +01:00
ca10689bb6 Fix thread-safety crash in LipSync anim node: TMap race condition
GetCurrentBlendshapes() was copying CurrentBlendshapes on the anim worker
thread while the game thread mutated it (TSet::UnhashElements crash).
Use a snapshot pattern: game thread copies to ThreadSafeBlendshapes under
FCriticalSection at end of TickComponent, anim node reads the snapshot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:16:10 +01:00
8d4065944c Multi-player shared agent: multiple players can converse with the same agent simultaneously
Replace exclusive single-player agent lock with shared multi-player model:
- NetConversatingPawn/Player → NetConnectedPawns array + NetActiveSpeakerPawn
- Server-side speaker arbitration with hysteresis (0.3s) prevents gaze ping-pong
- Speaker idle timeout (3.0s) clears active speaker after silence
- Agent gaze follows the active speaker via replicated OnRep_ActiveSpeaker
- New ServerJoinConversation/ServerLeaveConversation RPCs (idempotent join/leave)
- Backward-compatible: old ServerRequest/Release delegate to new Join/Leave
- InteractionComponent no longer skips occupied agents
- DrawDebugHUD shows connected player count and active speaker
- All mic audio paths (FeedExternalAudio, OnMicCapture, StopListening flush) route
  through speaker arbitration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:01:26 +01:00
8ad66ae4d5 Use CameraComponent for view point and gaze target (VR/FPS compatibility)
- InteractionComponent: GetPawnViewPoint() now prefers UCameraComponent
  over PlayerController::GetPlayerViewPoint() — fixes agent selection
  in VR where the pawn root stays at spawn while the HMD moves freely
- GazeComponent: ResolveTargetPosition() uses camera first for locally
  controlled pawns (VR HMD / FPS eye position), falls back to bone
  chain for NPCs and remote players in multiplayer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:13:09 +01:00
e5f40c65ec sln 2026-03-05 13:34:04 +01:00
9321e21a3b Debug HUD: fix flicker, add CVars, mic VU meter, reuse existing mic component
- Fix DisplayTime=0.0f causing flicker on all debug HUDs (now 1.0f)
- Add per-component CVars (ps.ai.ConvAgent.Debug.*) for console debug toggle
- Add MicrophoneCapture debug HUD with VU meter (RMS/peak/dB bar)
- InteractionComponent reuses existing MicrophoneCaptureComponent on pawn
  instead of always creating a new one

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:33:33 +01:00
fb641d5aa4 Fix body expression sync, conversation stability, and persistent session disconnect
- Sync body animation with actual audio playback via new OnAudioPlaybackStarted
  delegate instead of OnAgentStartedSpeaking (accounts for pre-buffer delay)
- Fix stale pre-buffer broadcasts by cancelling bPreBuffering on silence detection
  and guarding pre-buffer timeout with bAgentSpeaking check
- Smooth body crossfade using FInterpTo instead of linear interpolation
- Add conversation lock in EvaluateBestAgent: keep agent selected during active
  conversation regardless of view cone (distance-only check prevents deselect
  flicker on fast camera turns)
- Broadcast OnAgentDisconnected in persistent session EndConversation so all
  expression components (body, facial, lip sync, gaze) properly deactivate
  when the player leaves the interaction zone

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:18:35 +01:00
2e96e3c766 Smooth transitions, lip sync speech blend, body expression override mode, SendTextToSelectedAgent
- Replace FInterpConstantTo with FInterpTo (exponential ease-out) in all 4 anim components
- Apply SmoothStep to crossfade alphas for smooth animation transitions
- Add SpeechBlendAlpha to LipSync: fades out mouth curves when not speaking,
  letting FacialExpression emotion curves (including mouth) show through
- Remove LipSync AnimNode grace period zeroing to avoid overwriting emotion curves
- Revert BodyExpression to override mode (additive broken with full-pose anims)
- Default ExcludeBones = neck_01 to prevent Gaze/Posture conflicts
- Fix mid-crossfade animation pop in BodyExpression SwitchToNewAnim
- Add Neutral emotion fallback in Body and Facial expression components
- Add SendTextToSelectedAgent convenience method on InteractionComponent
- Add debug HUD display for BodyExpression component
- Update CoreRedirects for Posture → Gaze rename

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:09:00 +01:00
8bb4371a74 Metahuman 2026-03-04 18:27:02 +01:00
acfae96420 Body Expression system, auto external mic, gaze auto-target eyes, cleanup bActive
- Add BodyExpression system: emotion-driven body animations with per-emotion
  anim lists (Idle/Normal/Medium/Extreme), random selection, auto-cycle on
  loop complete, crossfade transitions, upper-body-only or full-body mode
- Replace bExternalMicManagement with auto-detecting ShouldUseExternalMic()
- Add bAutoTargetEyes to GazeComponent: auto-aim at target's eye bones
  (MetaHuman), with fallback chain (eyes > head > FallbackEyeHeight)
- Hide bActive from Details panel on all 4 anim components (read-only,
  code-managed): FacialExpression, Gaze, LipSync, BodyExpression
- Remove misleading mh_arkit_mapping_pose warning from LipSync
- Add bUpperBodyOnly toggle to BodyExpression AnimNode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:26:39 +01:00
ae40153252 V1 2026-03-03 14:01:26 +01:00
ac2d40b67b Fix head/eye tracking lost on 2nd conversation cycle (persistent session)
Three issues prevented posture re-activation on re-entry:
- StartConversation skipped bNetIsConversing/NetConversatingPawn in standalone
  (guarded by NM_Standalone check) so ApplyConversationPosture always deactivated
- SetSelectedAgent required !IsConnected() to auto-start conversation, but in
  persistent mode the WS stays connected → StartConversation never called on re-entry
- AttachPostureTarget never set Posture->bActive = true, relying solely on
  ApplyConversationPosture which could be skipped due to the above condition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 13:43:48 +01:00
d035f5410a Add bPersistentSession: keep WebSocket alive across conversation cycles
When true (default), the first StartConversation() opens the WebSocket
and EndConversation() only stops the mic/posture — the WebSocket stays
open until EndPlay. The agent remembers the full conversation context.
When false, each Start/EndConversation opens/closes the WebSocket (previous behavior).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 12:50:56 +01:00
35d217f6ec Reliability & perf: atomic flags, WebSocket reconnect, zero-alloc eye curves, audio queue read-offset
- Make bAgentGenerating, bWaitingForAgentResponse, bWaitingForResponse,
  bFirstAudioResponseLogged, bAgentResponseStartedFired (std::atomic<bool>)
  and LastInterruptEventId (std::atomic<int32>) for thread-safety
- Add WebSocket auto-reconnection with exponential backoff (1s→30s cap,
  max 5 attempts), distinguishing intentional vs unexpected disconnects
- Add FillCurrentEyeCurves() zero-allocation method using FindOrAdd()
  to eliminate per-frame TMap heap allocation in anim thread
- Replace AudioQueue RemoveAt(0,N) with read-offset pattern — O(1) per
  underflow callback, periodic compaction when offset > half buffer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 12:19:45 +01:00
8886e7a7a2 Client: override actor rotation with smoothed yaw to eliminate body mesh jitter
Previous fix only smoothed cascade inputs (head/eyes) via SmoothedBodyYaw
but the body mesh still followed the raw replicated actor rotation which
jumped at ~30Hz network rate. Now the client calls SetActorRotation with
the smoothed yaw so the mesh visually interpolates. Replication overwrites
on next network update; SmoothedBodyYaw absorbs the correction smoothly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:37:59 +01:00
28964f0a40 fine tune eye look values 2026-03-03 11:28:12 +01:00
b5c52c9236 Client-side smoothing for replicated body rotation
Server-only AddActorWorldRotation (HasAuthority guard) prevents
client/server tug-of-war. Client interpolates toward replicated
rotation via SmoothedBodyYaw (angle-aware FInterpTo at 3x body
speed) to eliminate step artifacts from ~30Hz network updates.
Cascade (DeltaYaw, head, eyes) uses SmoothedBodyYaw on all machines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:23:44 +01:00
7f92dcab51 Revert animation-only body tracking — restore AddActorWorldRotation
The pelvis bone rotation approach didn't work in practice. Reverts to
the previous AddActorWorldRotation() body tracking (replicated actor
rotation). Thread-safety fix and deprecated IsValid() removal are kept.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:07:24 +01:00
677f08e936 Fix thread-safety crash and body rotation saccades in network play
- FacialExpressionComponent: add FCriticalSection around emotion curves
  to prevent race between TickComponent (game thread) and anim worker
  thread — root cause of EXCEPTION_ACCESS_VIOLATION at Evaluate_AnyThread
- Remove deprecated FBlendedCurve::IsValid() guards (UE 5.5: always true)
- Body tracking: replace AddActorWorldRotation() with animation-only
  pelvis bone rotation via AnimNode — eliminates replication tug-of-war
  that caused client-side saccades when server overwrote local rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:55:59 +01:00
45ee0c6f7d fix crash + posture replication 2026-03-03 10:21:52 +01:00
1c4dbfc402 Merge branch 'main' of ssh://git.polymorph.fr:5070/j.foucher/PS_AI_Agent 2026-03-03 09:30:44 +01:00
86b7d9744e Opus-compress mic audio on client→server relay path (~16x bandwidth reduction)
- Create OpusEncoder on ALL machines (was Authority-only) — clients now
  encode mic audio, server decodes it; server still encodes agent audio
- FeedExternalAudio / OnMicrophoneDataCaptured: Opus-encode accumulated
  PCM buffer before sending via ServerRelayMicAudio RPC on client path
  (~200 bytes/100ms instead of 3200 bytes = ~16 Kbits/s vs 256 Kbits/s)
- ServerRelayMicAudio_Implementation: auto-detect Opus (size < raw chunk)
  and decode back to PCM before forwarding to WebSocket
- Add public DecompressMicAudio() helper for clean API access from
  InteractionComponent relay without exposing private Opus members
- Graceful fallback: if Opus unavailable, raw PCM is sent/received as before

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 18:27:40 +01:00
5e18e7cc8c disable ray tracing 2026-03-02 18:20:35 +01:00
3952847ece Enable audio spatialization by default for agent voice
- Attach AudioPlaybackComponent to owner's root component for proper
  3D world positioning (was unattached = stuck at origin)
- Enable default inline spatialization with 15m falloff distance when
  no external SoundAttenuation asset is set
- External SoundAttenuation asset still overrides the default if set

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:58:10 +01:00
76dd13944a remove lumen from scene 2026-03-02 17:54:13 +01:00
bf08bb67d9 Fix conversation handoff and server-side posture for network
- Auto-end conversation on deselection: when bAutoStartConversation is
  true and the player walks out of range, EndConversation() is called
  so the NPC becomes available for other players
- Server-side posture: add ApplyConversationPosture() helper called
  from ServerRequestConversation, ServerReleaseConversation,
  EndConversation (Authority path), and HandleDisconnected — fixes
  NPC not tracking the client on the listen server (OnRep never fires
  on Authority)
- Guard DetachPostureTarget: only clear TargetActor if it matches our
  pawn, preventing the server IC from overwriting posture set by the
  conversation system for a remote client

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:44:48 +01:00
215cb398fd Fix network saturation, lazy init, body tracking, and mic race condition
- Silence gate: skip sending silent mic audio over network RPCs on clients
  (~256 Kbits/s saved when not speaking, fixes chaotic teleporting)
- Lazy init: defer InteractionComponent mic creation from BeginPlay to
  TickComponent with IsLocallyControlled guard (fixes "No owning connection"
  from server-side replicas of remote pawns)
- Body tracking: use bNetIsConversing as fallback for IsConnected() on
  clients where WebSocket doesn't exist
- EvaluateBestAgent: null-check NetConversatingPawn before comparison
- MicCaptureComponent: use TWeakObjectPtr in AsyncTask lambda to prevent
  FMRSWRecursiveAccessDetector race on component destruction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:23:26 +01:00
3c4389a43d Fix InteractionComponent ticking on server for remote client pawns
In a listen server, the server-side copy of a remote client's pawn also
has an InteractionComponent that ticks. This caused a race condition:
the server-side tick would start conversations using GetFirstPlayerController()
(= server's PC), setting NetConversatingPawn to the server's pawn instead
of the client's. The client's relay RPC arrived too late and was rejected
because bNetIsConversing was already true.

Fix: disable tick and skip mic creation in BeginPlay for non-locally-controlled
pawns. The client handles all interaction locally via relay RPCs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:57:33 +01:00
4d662ebada debug Network 2026-03-02 16:55:11 +01:00
8a3304fea9 Add relay RPC pattern for client→NPC communication over network
UE5 clients cannot call Server RPCs on actors they don't own. NPC actors
are server-owned, causing "No owning connection" errors when remote clients
try to start conversations, send mic audio, or interrupt agents.

Solution: relay all client→NPC RPCs through the InteractionComponent on
the player's pawn (which IS owned by the client). The relay forwards
commands to the NPC's ElevenLabsComponent on the server side.

Changes:
- InteractionComponent: add Server relay RPCs (Start/End conversation,
  mic audio, text message, interrupt) and Client relay RPCs
  (ConversationStarted/Failed) with GetLifetimeReplicatedProps
- ElevenLabsComponent: implement FindLocalRelayComponent(), route all
  client-side calls through relay (StartConversation, EndConversation,
  SendTextMessage, InterruptAgent, FeedExternalAudio, mic capture)
- Fix HandleConnected/ServerRequestConversation to route Client RPCs
  through the player pawn's relay instead of the NPC (no owning connection)
- Fix StartListening/FeedExternalAudio/StopListening to accept
  bNetIsConversing on clients (WebSocket only exists on server)
- Fix EvaluateBestAgent to use NetConversatingPawn instead of
  NetConversatingPlayer (NULL on remote clients due to bOnlyRelevantToOwner)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:40:27 +01:00
ce84a5dc58 Fix head tracking on remote clients: activate PostureComponent in OnRep
PostureComponent starts with bActive=false and waits for
OnConversationConnected, which only fires on the server (WebSocket).
Remote clients never got bActive=true, so CurrentActiveAlpha stayed
at 0 and all head/eye rotation was zeroed out.

Now OnRep_ConversationState also sets Posture->bActive alongside
TargetActor, matching what was already done for FacialExpression
and LipSync components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:33:07 +01:00
daf79d0d89 Add diagnostic logs to OnRep_ConversationState for head tracking debug
Logs NetConversatingPawn, PostureComponent availability, and TargetActor
assignment to diagnose why head tracking doesn't work on remote clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:26:28 +01:00
0124de8b53 Fix audio pre-buffer bypass on clients due to network sub-chunking
Raw PCM is split into 32KB sub-chunks for network transmission.
On the client, these sub-chunks arrive nearly simultaneously,
triggering the "second chunk arrived" fast-path which cancelled
the pre-buffer after ~10ms instead of the intended 2000ms.

Now the fast-path only applies on Authority (server) where
chunks represent genuine separate TTS batches. Clients always
wait the full pre-buffer duration via TickComponent timer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:19:05 +01:00
c0c1b2cea4 Fix head tracking on remote clients: replicate Pawn instead of PlayerController
PlayerControllers have bOnlyRelevantToOwner=true, so NetConversatingPlayer
was always nullptr on remote clients. Added NetConversatingPawn (APawn*)
which IS replicated to all clients. OnRep_ConversationState now uses
NetConversatingPawn as the posture TargetActor, enabling head/eye
tracking on all clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:16:48 +01:00
fc728454d0 Fix raw PCM multicast exceeding UE5 replicated array limit
UE5 limits replicated TArrays to 65535 elements. ElevenLabs sends
audio chunks up to ~72K bytes, exceeding this limit when sent as
raw PCM. Split into 32000-byte sub-chunks (1s of 16kHz 16-bit mono)
before calling MulticastReceiveAgentAudio.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:04:33 +01:00
6cac56fa06 Fix network audio: raw PCM fallback when Opus codecs unavailable
UE5 5.5's FVoiceModule returns NULL encoder/decoder, breaking
Opus-based audio replication. This adds a transparent fallback:
- Server sends raw PCM when OpusEncoder is null (~32KB/s, fine for LAN)
- Client accepts raw PCM when OpusDecoder is null
- Changed MulticastReceiveAgentAudio from Unreliable to Reliable
  to handle larger uncompressed payloads without packet loss
- Added OnlineSubsystemNull config for future Opus compatibility
- Removed premature bAgentSpeaking=true from MulticastAgentStartedSpeaking
  to fix race condition with audio initialization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:58:21 +01:00
196 changed files with 28288 additions and 1758 deletions

View File

@@ -1,6 +1,23 @@
# PS_AI_ConvAgent — Notes pour Claude # PS_AI_Agent — Notes pour Claude
## Naming Convention ## Workflow règles
**TOUJOURS demander avant de lancer un changement de code.** Avant d'appeler Edit/Write/sed sur un fichier source :
1. Expliquer ce qu'on s'apprête à faire (quel fichier, quelle logique, quel impact)
2. Attendre la confirmation explicite de l'utilisateur
3. Seulement après → effectuer le changement
Cela s'applique à : modifications de code (.cpp/.h), refactors, renames bulk, ajouts de nouvelles fonctions.
Cela ne s'applique PAS à : lecture de fichiers, recherches, git status/log/diff, builds, questions diagnostiques.
Exceptions où on peut agir directement :
- L'utilisateur dit explicitement "fais-le" / "go" / "lance" / "commit"
- L'utilisateur demande explicitement un changement précis ("renomme X en Y", "ajoute cette fonction")
- Annulation / revert demandé par l'utilisateur
## Plugins dans ce repo
### PS_AI_ConvAgent
- **Module**: `PS_AI_ConvAgent` / `PS_AI_ConvAgentEditor` - **Module**: `PS_AI_ConvAgent` / `PS_AI_ConvAgentEditor`
- **API macro**: `PS_AI_CONVAGENT_API` - **API macro**: `PS_AI_CONVAGENT_API`
- **Class prefix**: `PS_AI_ConvAgent_` (e.g. `UPS_AI_ConvAgent_PostureComponent`) - **Class prefix**: `PS_AI_ConvAgent_` (e.g. `UPS_AI_ConvAgent_PostureComponent`)
@@ -8,29 +25,71 @@
- **UI display**: `"PS AI ConvAgent"` (categories, DisplayName) - **UI display**: `"PS AI ConvAgent"` (categories, DisplayName)
- **CoreRedirects**: DefaultEngine.ini handles both ElevenLabs* and PS_AI_Agent_* → PS_AI_ConvAgent_* - **CoreRedirects**: DefaultEngine.ini handles both ElevenLabs* and PS_AI_Agent_* → PS_AI_ConvAgent_*
### PS_AI_Behavior
- **Module**: `PS_AI_Behavior` / `PS_AI_BehaviorEditor`
- **API macro**: `PS_AI_BEHAVIOR_API`
- **Class prefix**: `PS_AI_Behavior_` (e.g. `UPS_AI_Behavior_PersonalityComponent`)
- **UI display**: `"PS AI Behavior"` (categories, DisplayName)
- **Interface**: `IPS_AI_Behavior_Interface` — implements on host project Pawns
## PS_AI_Behavior — Architecture
### Core Design
- **Interface-driven**: `IPS_AI_Behavior_Interface` on Pawns decouples plugin from host project
- **Personality profiles**: DataAsset with 5 trait axes, thresholds, target priority, speed per state
- **NPCType enum**: Civilian, Enemy, Protector, Any (unified — no separate SplineCategory)
- **TeamIds**: Civilian=1, Enemy=2, Protector=3, auto-assigned in OnPossess
- **Hostile switch**: Enemy with IsBehaviorHostile=false → TeamId=1 (disguised), flips to 2 at runtime
### Blackboard
- Key `BehaviorState` = Enum type, path: `/Script/PS_AI_Behavior.EPS_AI_Behavior_State`
- Values: Idle(0), Patrol(1), Alerted(2), Combat(3), Fleeing(4), TakingCover(5), Dead(6)
- BB asset created manually in editor (runtime fallback exists but prefer asset)
- SetValueAsEnum / GetValueAsEnum (not Int)
### BT Pattern
- Services on Selector: UpdateThreat + EvaluateReaction
- Decorator Observer Aborts = Both on Combat/Flee branches
- Spline branch as fallback (lowest priority, no condition decorator)
- Attack task stays InProgress permanently, Decorator pulls out on state change
### Spline System
- SplinePath actors placed in level, SplineCategory = NPCType
- SplineNetwork WorldSubsystem auto-detects junctions
- SplineFollowerComponent on Pawns: uses Pawn's real velocity, debug green sphere
- Junction switching: 70% probability, direction filter (dot > -0.5)
### Perception
- Senses configured in BeginPlay (NOT constructor — NewObject crashes in CDO)
- RequestStimuliListenerUpdate() after ConfigureSenses
- Detect ALL affiliations, filter by Hostile attitude in CalculateThreatLevel
- Target priority from PersonalityProfile (combat targeting)
### Known Gotchas (UE 5.5)
- BB Enum picker doesn't list plugin enums — use string path
- NTFS junctions (not symlinks) for UE5 cross-project plugin linking
- SVN doesn't traverse junctions — robocopy for initial import
- Live Coding fails on header changes — full rebuild required
- BlueprintNativeEvent: UHT auto-generates _Implementation defaults, don't duplicate in .cpp
- FGenericTeamId::NoTeam comparison: always recalculate TeamId (BP CDOs may reset to 0)
### Testing Status (2026-03-26)
- ✅ Patrol, Spline following, Perception, Threat, Flee, Combat delegation, Hostile switch
- 🔲 Cover Points, EQS, Editor tools (SplineEdMode, CoverPoint placement)
## Posture System — Diagonal Tilt Bug (à corriger) ## Posture System — Diagonal Tilt Bug (à corriger)
### Problème ### Problème
Le tilt diagonal (ear-to-shoulder) est PLUS VISIBLE avec la neck bone chain qu'avec le mono-bone. Le swing-twist decomposition ne suffit pas. Le tilt diagonal (ear-to-shoulder) est PLUS VISIBLE avec la neck bone chain qu'avec le mono-bone.
### Cause probable
Le swing-twist est fait une seule fois sur le tip bone (head), puis le CleanOffset est extrait via `Swing * Inverse(TipBoneRot)`. Ce CleanOffset est ensuite distribué via Slerp à chaque bone de la chaîne. Mais chaque bone a une orientation locale différente — appliquer le même offset "world-space-like" à des bones avec des axes locaux différents peut réintroduire du tilt.
### Piste de fix ### Piste de fix
Faire le swing-twist **PER-BONE** : pour chaque bone de la chaîne, composer le FractionalRot avec le bone's own rotation, swing-twist autour du bone's own tilt axis, puis appliquer le swing seulement. Faire le swing-twist **PER-BONE** : pour chaque bone de la chaîne, composer le FractionalRot avec le bone's own rotation, swing-twist autour du bone's own tilt axis.
### Fichiers concernés ### Fichiers concernés
- `AnimNode_PS_AI_ConvAgent_Posture.cpp` — Evaluate_AnyThread, section multi-bone chain - `AnimNode_PS_AI_ConvAgent_Posture.cpp` — Evaluate_AnyThread, section multi-bone chain
- Commit actuel: `8df6967` sur main
### Config actuelle neck chain (BP_Taro) ## Architecture Posture (ConvAgent)
neck_01=0.25, neck_02=0.35, head=0.40 - **PS_AI_ConvAgent_PostureComponent** (game thread) : cascade Eyes→Head→Body
## Architecture Posture
- **PS_AI_ConvAgent_PostureComponent** (game thread) : cascade Eyes→Head→Body, produit FQuat + eye curves
- **AnimNode_PS_AI_ConvAgent_Posture** (anim thread) : applique rotation sur bone(s) + injecte curves - **AnimNode_PS_AI_ConvAgent_Posture** (anim thread) : applique rotation sur bone(s) + injecte curves
- Pipeline 100% FQuat (pas de round-trip FRotator) - Pipeline 100% FQuat, Thread safety via FCriticalSection
- Thread safety via FCriticalSection - ARKit eye curves normalisées par range fixe (40°/35°)
- ARKit eye curves normalisées par range fixe (40°/35°), pas par MaxEye threshold
- Body drift compensation: applied ONLY on first chain bone, AFTER swing-twist
- Debug gaze: per-eye lines from Face mesh using Z-axis (GetAxisZ())

View File

@@ -17,69 +17,70 @@
## Plugins ## Plugins
| Plugin | Path | Purpose | | Plugin | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| Convai (reference) | `<repo_root>/ConvAI/Convai/` | gRPC + protobuf streaming to Convai API. Has ElevenLabs voice type enum in `ConvaiDefinitions.h`. Used as architectural reference. | | Convai (reference) | `<repo_root>/ConvAI/Convai/` | gRPC + protobuf streaming to Convai API. Used as architectural reference. |
| **PS_AI_Agent_ElevenLabs** | `<repo_root>/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/` | Our ElevenLabs Conversational AI integration. See `.claude/elevenlabs_plugin.md` for full details. | | **PS_AI_ConvAgent** | `<repo_root>/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/` | Main plugin — ElevenLabs Conversational AI, posture, gaze, lip sync, facial expressions. |
## User Preferences ## User Preferences
- Plugin naming: `PS_AI_Agent_<Service>` (e.g. `PS_AI_Agent_ElevenLabs`) - Plugin naming: `PS_AI_ConvAgent` (renamed from PS_AI_Agent_ElevenLabs)
- Save memory frequently during long sessions - Save memory frequently during long sessions
- Goal: ElevenLabs Conversational AI integration — simpler than Convai, no gRPC
- Full original ask + intent: see `.claude/project_context.md`
- Git remote is a **private server** — no public exposure risk - Git remote is a **private server** — no public exposure risk
- Full original ask + intent: see `.claude/project_context.md`
## Current Branch & Work
- **Branch**: `main`
- **Recent merges**: `feature/multi-player-shared-agent` merged to main
### Latency Debug HUD (just implemented)
- Separate `bDebugLatency` property + CVar `ps.ai.ConvAgent.Debug.Latency`
- All metrics anchored to `GenerationStartTime` (`agent_response_started` event)
- Metrics: Gen>Audio (LLM+TTS), Pre-buffer, Gen>Ear (user-perceived)
- Reset per turn in `HandleAgentResponseStarted()`
- `DrawLatencyHUD()` separate from `DrawDebugHUD()`
### Future: Server-Side Latency from ElevenLabs API
**TODO — high-value improvement parked for later:**
- `GET /v1/convai/conversations/{conversation_id}` returns:
- `conversation_turn_metrics` with `elapsed_time` per metric (STT, LLM, TTS breakdown!)
- `tool_latency_secs`, `step_latency_secs`, `rag_latency_secs`
- `time_in_call_secs` per message
- `ping` WS event has `ping_ms` (network round-trip) — could display on HUD
- `vad_score` WS event (0.0-1.0) — could detect real speech start client-side
- Docs: https://elevenlabs.io/docs/api-reference/conversations/get
### Multi-Player Shared Agent — Key Design
- **Old model**: exclusive lock (one player per agent via `NetConversatingPawn`)
- **New model**: shared array (`NetConnectedPawns`) + active speaker (`NetActiveSpeakerPawn`)
- Speaker arbitration: server-side with `SpeakerSwitchHysteresis` (0.3s) + `SpeakerIdleTimeout` (3.0s)
- In standalone (≤1 player): speaker arbitration bypassed, audio sent directly to WebSocket
- Internal mic (WASAPI thread): direct WebSocket send, no game-thread state access
- `GetCurrentBlendshapes()` thread-safe via `ThreadSafeBlendshapes` snapshot + `BlendshapeLock`
## Key UE5 Plugin Patterns ## Key UE5 Plugin Patterns
- Settings object: `UCLASS(config=Engine, defaultconfig)` inheriting `UObject`, registered via `ISettingsModule` - Settings object: `UCLASS(config=Engine, defaultconfig)` inheriting `UObject`, registered via `ISettingsModule`
- Module startup: `NewObject<USettings>(..., RF_Standalone)` + `AddToRoot()`
- WebSocket: `FWebSocketsModule::Get().CreateWebSocket(URL, TEXT(""), Headers)` - WebSocket: `FWebSocketsModule::Get().CreateWebSocket(URL, TEXT(""), Headers)`
- `WebSockets` is a **module** (Build.cs only) — NOT a plugin, don't put it in `.uplugin` - Audio capture: `Audio::FAudioCapture::OpenAudioCaptureStream()` (UE 5.3+)
- Audio capture: `Audio::FAudioCapture::OpenAudioCaptureStream()` (UE 5.3+, replaces deprecated `OpenCaptureStream`) - Callback arrives on **background thread** — marshal to game thread
- `AudioCapture` IS a plugin — declare it in `.uplugin` Plugins array - Procedural audio playback: `USoundWaveProcedural` + `OnSoundWaveProceduralUnderflow`
- Callback type: `FOnAudioCaptureFunction` = `TFunction<void(const void*, int32, int32, int32, double, bool)>`
- Cast `const void*` to `const float*` inside — device sends float32 interleaved
- Procedural audio playback: `USoundWaveProcedural` + `OnSoundWaveProceduralUnderflow` delegate
- Audio capture callbacks arrive on a **background thread** — always marshal to game thread with `AsyncTask(ENamedThreads::GameThread, ...)`
- Resample mic audio to **16000 Hz mono** before sending to ElevenLabs - Resample mic audio to **16000 Hz mono** before sending to ElevenLabs
- `TArray::RemoveAt(idx, count, EAllowShrinking::No)` — bool overload deprecated in UE 5.5 - `TArray::RemoveAt(idx, count, EAllowShrinking::No)` — bool overload deprecated in UE 5.5
## Plugin Status
- **PS_AI_Agent_ElevenLabs**: compiles cleanly on UE 5.5 Win64 (verified 2026-02-19)
- v1.5.0 — mic audio chunk size fixed: WASAPI 5ms callbacks accumulated to 100ms before sending
- v1.4.0 — push-to-talk fully fixed: bAutoStartListening now ignored in Client turn mode
- Binary WS frame handling implemented (ElevenLabs sends ALL frames as binary, not text)
- First-byte discrimination: `{` = JSON control message, else = raw PCM audio
- `SendTextMessage()` added to both WebSocketProxy and ConversationalAgentComponent
- `conversation_initiation_client_data` now sent immediately on WS connect (required for mic + latency)
## Audio Chunk Size — CRITICAL
- WASAPI fires mic callbacks every ~5ms → **158 bytes** at 16kHz 16-bit mono
- ElevenLabs VAD/STT requires **≥3200 bytes (100ms)** per chunk; smaller chunks are silently ignored
- Fix: `MicAccumulationBuffer` in component accumulates chunks; sends only when `>= MicChunkMinBytes` (3200)
- `StopListening()` flushes remainder so final partial chunk is never dropped before end-of-turn
## ElevenLabs WebSocket Protocol Notes ## ElevenLabs WebSocket Protocol Notes
- **ALL frames are binary** — bind ONLY `OnRawMessage`; NEVER bind `OnMessage` (text) — UE fires both for same frame → double audio bug - **ALL frames are binary** — bind ONLY `OnRawMessage`; NEVER bind `OnMessage` (text)
- Binary frame discrimination: peek byte[0] → `'{'` (0x7B) = JSON, else = raw PCM audio - Binary frame discrimination: peek byte[0] → `'{'` (0x7B) = JSON, else = raw PCM audio
- Fragment reassembly: accumulate into `BinaryFrameBuffer` until `BytesRemaining == 0`
- Pong: `{"type":"pong","event_id":N}``event_id` is **top-level**, NOT nested - Pong: `{"type":"pong","event_id":N}``event_id` is **top-level**, NOT nested
- Transcript: type=`user_transcript`, key=`user_transcription_event`, field=`user_transcript` - `user_transcript` arrives AFTER `agent_response_started` in Server VAD mode
- Client turn mode (`client_vad`): send `user_activity` **with every audio chunk** (not just once) — server needs continuous signal to know user is speaking; stopping chunks = silence detected = agent responds - **MUST send `conversation_initiation_client_data` immediately after WS connect**
- Text input: `{"type":"user_message","text":"..."}` — agent replies with audio + text
- **MUST send `conversation_initiation_client_data` immediately after WS connect** — without it, server won't process client audio (mic appears dead)
- `conversation_initiation_client_data` payload: `conversation_config_override.agent.turn.mode`, `conversation_config_override.tts.optimize_streaming_latency`, `custom_llm_extra_body.enable_intermediate_response`
- `enable_intermediate_response: true` in `custom_llm_extra_body` reduces time-to-first-audio latency
## API Keys / Secrets ## API Keys / Secrets
- ElevenLabs API key is set in **Project Settings → Plugins → ElevenLabs AI Agent** in the Editor - ElevenLabs API key: **Project Settings → Plugins → ElevenLabs AI Agent**
- UE saves it to `DefaultEngine.ini` under `[/Script/PS_AI_Agent_ElevenLabs.ElevenLabsSettings]` - Saved to `DefaultEngine.ini` **stripped before every commit**
- **The key is stripped from `DefaultEngine.ini` before every commit** — do not commit it
- Each developer sets the key locally; it does not go in git
## Claude Memory Files in This Repo ## Claude Memory Files in This Repo
| File | Contents | | File | Contents |
|------|----------| |------|----------|
| `.claude/MEMORY.md` | This file — project structure, patterns, status | | `.claude/MEMORY.md` | This file — project structure, patterns, status |
| `.claude/elevenlabs_plugin.md` | Plugin file map, ElevenLabs WS protocol, design decisions | | `.claude/elevenlabs_plugin.md` | Plugin file map, ElevenLabs WS protocol, design decisions |
| `.claude/elevenlabs_api_reference.md` | Full ElevenLabs API reference (WS messages, REST, signed URL, Agent ID location) | | `.claude/elevenlabs_api_reference.md` | Full ElevenLabs API reference (WS messages, REST, signed URL) |
| `.claude/project_context.md` | Original ask, intent, short/long-term goals | | `.claude/project_context.md` | Original ask, intent, short/long-term goals |
| `.claude/session_log_2026-02-19.md` | Full session record: steps, commits, technical decisions, next steps | | `.claude/session_log_2026-02-19.md` | Session record: steps, commits, technical decisions |
| `.claude/PS_AI_Agent_ElevenLabs_Documentation.md` | User-facing Markdown reference doc | | `.claude/PS_AI_Agent_ElevenLabs_Documentation.md` | User-facing Markdown reference doc |

30
.gitattributes vendored Normal file
View File

@@ -0,0 +1,30 @@
# Force consistent line endings across all machines
# LF in the repo, native (CRLF on Windows) in working tree
* text=auto
# Source code — always normalize
*.cpp text
*.h text
*.cs text
*.py text
*.ini text
*.json text
*.md text
*.xml text
*.yaml text
*.yml text
# Unreal assets — binary, no normalization
*.uasset binary
*.umap binary
*.uproject text
# Binaries — no normalization
*.dll binary
*.exe binary
*.pdb binary
*.lib binary
*.exp binary
*.so binary
*.dylib binary
*.a binary

5
.gitignore vendored
View File

@@ -5,10 +5,13 @@ Unreal/PS_AI_Agent/Intermediate/
Unreal/PS_AI_Agent/Saved/ Unreal/PS_AI_Agent/Saved/
ConvAI/Convai/Binaries/ ConvAI/Convai/Binaries/
# All plugin build artifacts (Binaries + Intermediate for any plugin) # Plugin build artifacts (regenerated)
Unreal/PS_AI_Agent/Plugins/*/Binaries/ Unreal/PS_AI_Agent/Plugins/*/Binaries/
Unreal/PS_AI_Agent/Plugins/*/Intermediate/ Unreal/PS_AI_Agent/Plugins/*/Intermediate/
# Live Coding patch files (temporary, regenerated each session)
*.patch_*
# UE5 generated solution files # UE5 generated solution files
Unreal/PS_AI_Agent/*.sln Unreal/PS_AI_Agent/*.sln
Unreal/PS_AI_Agent/*.suo Unreal/PS_AI_Agent/*.suo

65
Build_Plugin.bat Normal file
View File

@@ -0,0 +1,65 @@
@echo off
setlocal
:: ─── Configuration ──────────────────────────────────────────────────────────
set UE_ROOT=C:\Program Files\Epic Games\UE_5.5
set UPROJECT=E:\ASTERION\GIT\PS_AI_Agent\Unreal\PS_AI_Agent\PS_AI_Agent.uproject
set PLATFORM=Win64
set CONFIG=Development
set TARGET=PS_AI_AgentEditor
:: ─── Colors ─────────────────────────────────────────────────────────────────
set GREEN=[92m
set RED=[91m
set YELLOW=[93m
set RESET=[0m
:: ─── Banner ─────────────────────────────────────────────────────────────────
echo.
echo %GREEN%============================================%RESET%
echo PS_AI_Agent - Plugin Build
echo Target: %TARGET% %PLATFORM% %CONFIG%
echo %GREEN%============================================%RESET%
echo.
:: ─── Verify paths ───────────────────────────────────────────────────────────
if not exist "%UE_ROOT%\Engine\Binaries\ThirdParty\DotNet\8.0.300\win-x64\dotnet.exe" (
echo %RED%ERROR: UE5.5 dotnet not found at %UE_ROOT%%RESET%
echo Check UE_ROOT path in this script.
pause
exit /b 1
)
if not exist "%UPROJECT%" (
echo %RED%ERROR: .uproject not found: %UPROJECT%%RESET%
pause
exit /b 1
)
:: ─── Build ──────────────────────────────────────────────────────────────────
echo %YELLOW%Building %TARGET% (%CONFIG%)...%RESET%
echo.
"%UE_ROOT%\Engine\Binaries\ThirdParty\DotNet\8.0.300\win-x64\dotnet.exe" ^
"%UE_ROOT%\Engine\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.dll" ^
%TARGET% %PLATFORM% %CONFIG% ^
-Project="%UPROJECT%" ^
-WaitMutex ^
-FromMsBuild
set BUILD_EXIT=%ERRORLEVEL%
echo.
if %BUILD_EXIT% EQU 0 (
echo %GREEN%============================================%RESET%
echo BUILD SUCCEEDED
echo %GREEN%============================================%RESET%
) else (
echo %RED%============================================%RESET%
echo BUILD FAILED (exit code: %BUILD_EXIT%)
echo %RED%============================================%RESET%
)
echo.
pause
exit /b %BUILD_EXIT%

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

691
Docs/gen_behavior_doc.js Normal file
View File

@@ -0,0 +1,691 @@
// PS_AI_Behavior User Documentation Generator
// Palette: Warm Terracotta (terracotta #B85042, sand #E7E8D1, sage #A7BEAE)
const pptxgen = require("pptxgenjs");
const pres = new pptxgen();
pres.layout = "LAYOUT_WIDE"; // 13.333 x 7.5 inch
// ─── Color Palette ─────────────────────────────────
const TERRA = "B85042";
const TERRA_D = "7A3229";
const SAND = "E7E8D1";
const SAND_LT = "F5F5EA";
const SAGE = "A7BEAE";
const SAGE_D = "5F7566";
const WHITE = "FFFFFF";
const CHARCOAL = "2E2A26";
const GOLD = "D4A84B";
// ─── Font ──────────────────────────────────────────
const FONT_H = "Calibri";
const FONT_B = "Calibri";
// ─── Master slide backgrounds ──────────────────────
pres.defineSlideMaster({
title: "CONTENT",
background: { color: SAND_LT },
objects: [
{ rect: { x: 0, y: 0, w: 13.333, h: 0.35, fill: { color: TERRA } } },
{ rect: { x: 0, y: 7.15, w: 13.333, h: 0.35, fill: { color: TERRA } } },
{ text: {
text: "PS_AI_Behavior — User Documentation",
options: { x: 0.3, y: 7.18, w: 8, h: 0.3, fontSize: 10, color: SAND, fontFace: FONT_B }
} },
{ text: {
text: "ASTERION",
options: { x: 11.5, y: 7.18, w: 1.5, h: 0.3, fontSize: 10, color: SAND, fontFace: FONT_B, align: "right", bold: true }
} },
],
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 1 — TITLE
// ═══════════════════════════════════════════════════════════════════
let s = pres.addSlide();
s.background = { color: TERRA };
s.addShape(pres.ShapeType.rect, { x: 0, y: 3.3, w: 13.333, h: 0.05, fill: { color: GOLD } });
s.addText("PS_AI_Behavior", { x: 0.6, y: 2.2, w: 12, h: 1.2, fontSize: 60, bold: true, color: WHITE, fontFace: FONT_H });
s.addText("Personality-Driven NPC AI for Unreal Engine", { x: 0.6, y: 3.5, w: 12, h: 0.6, fontSize: 22, color: SAND, fontFace: FONT_B, italic: true });
s.addText("User Documentation", { x: 0.6, y: 4.4, w: 12, h: 0.5, fontSize: 18, color: SAND, fontFace: FONT_B });
s.addText("Civilians • Enemies • Protectors • Cover System • Splines", { x: 0.6, y: 5.2, w: 12, h: 0.4, fontSize: 14, color: SAND, fontFace: FONT_B });
s.addText("ASTERION • 2026", { x: 0.6, y: 6.6, w: 12, h: 0.4, fontSize: 12, color: SAND, fontFace: FONT_B });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 2 — OVERVIEW
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Plugin Overview", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("What it does", { x: 0.5, y: 1.7, w: 6, h: 0.4, fontSize: 18, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "Personality-driven AI for NPCs", options: { bullet: true, fontSize: 14 } },
{ text: "Three archetypes: Civilian, Enemy, Protector", options: { bullet: true, fontSize: 14 } },
{ text: "Trait-based reactions: Courage, Aggressivity, Caution", options: { bullet: true, fontSize: 14 } },
{ text: "Tactical combat + cover system + EQS", options: { bullet: true, fontSize: 14 } },
{ text: "Spline-based patrol networks", options: { bullet: true, fontSize: 14 } },
{ text: "Interface-driven — no Pawn class dependency", options: { bullet: true, fontSize: 14 } },
], { x: 0.5, y: 2.1, w: 6, h: 3, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 8 });
s.addShape(pres.ShapeType.roundRect, { x: 7, y: 1.7, w: 5.8, h: 4.8, fill: { color: TERRA }, rectRadius: 0.1 });
s.addText("Primary Use Case", { x: 7.2, y: 1.9, w: 5.4, h: 0.4, fontSize: 16, bold: true, color: GOLD, fontFace: FONT_H });
s.addText("Populate your world with intelligent NPCs that perceive threats, evaluate risk based on personality, and respond appropriately — civilians flee from gunfire, enemies engage in tactical combat, protectors defend civilians.", {
x: 7.2, y: 2.4, w: 5.4, h: 1.7, fontSize: 13, color: SAND, fontFace: FONT_B, italic: true
});
s.addText("Key Differentiators", { x: 7.2, y: 4.2, w: 5.4, h: 0.4, fontSize: 16, bold: true, color: GOLD, fontFace: FONT_H });
s.addText([
{ text: "Data-driven personality profiles", options: { bullet: true, fontSize: 12 } },
{ text: "Built-in cover cycle state machine", options: { bullet: true, fontSize: 12 } },
{ text: "Spline network with junction detection", options: { bullet: true, fontSize: 12 } },
{ text: "Gaze bridge to ConvAgent (reflection)", options: { bullet: true, fontSize: 12 } },
], { x: 7.2, y: 4.7, w: 5.4, h: 1.7, color: SAND, fontFace: FONT_B, paraSpaceAfter: 4 });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 3 — CORE ARCHITECTURE
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Core Architecture", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("The plugin is built around four concepts:", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
const pillars = [
{ title: "Interface", subtitle: "IPS_AI_Behavior_Interface", desc: "Implemented on host project Pawns. Zero compile dependency — identity, team, combat, movement." },
{ title: "NPC Type", subtitle: "Civilian / Enemy / Protector", desc: "Determines team, spline access, default tree. Enemies can be disguised (hostile=false)." },
{ title: "State", subtitle: "Behavior State enum", desc: "Idle, Patrol, Alerted, Combat, Fleeing, TakingCover, Dead, Scripted. Written to Blackboard." },
{ title: "Profile", subtitle: "PersonalityProfile data asset", desc: "Traits, thresholds, target priority, speed per state, default BT." },
];
pillars.forEach((p, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const x = 0.5 + col * 6.3;
const y = 2.2 + row * 2.3;
s.addShape(pres.ShapeType.roundRect, { x: x, y: y, w: 6.0, h: 2.1, fill: { color: TERRA }, rectRadius: 0.1 });
s.addText(p.title, { x: x + 0.2, y: y + 0.15, w: 5.6, h: 0.45, fontSize: 20, bold: true, color: GOLD, fontFace: FONT_H });
s.addText(p.subtitle, { x: x + 0.2, y: y + 0.6, w: 5.6, h: 0.35, fontSize: 12, color: SAND, italic: true, fontFace: "Consolas" });
s.addText(p.desc, { x: x + 0.2, y: y + 1.0, w: 5.6, h: 1.0, fontSize: 12, color: SAND, fontFace: FONT_B });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 4 — NPC TYPES & TEAM ID
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("NPC Types & TeamId", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Identity drives perception, targeting, and access control.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
// Three NPC cards
const npcs = [
{ name: "CIVILIAN", color: SAGE, id: "0x00", desc: "Non-hostile. Flees. Allied with Protector." },
{ name: "ENEMY", color: TERRA, id: "0x10", desc: "Hostile. Tactical combat. Can be disguised." },
{ name: "PROTECTOR", color: SAGE_D, id: "0x20", desc: "Police / guard. Defends Civilians." },
];
npcs.forEach((n, i) => {
const x = 0.5 + i * 4.3;
s.addShape(pres.ShapeType.roundRect, { x: x, y: 2.15, w: 4.0, h: 2.2, fill: { color: n.color }, rectRadius: 0.1 });
s.addText(n.name, { x: x + 0.2, y: 2.3, w: 3.6, h: 0.5, fontSize: 22, bold: true, color: WHITE, align: "center", fontFace: FONT_H });
s.addText(`TeamId = ${n.id}`, { x: x + 0.2, y: 2.85, w: 3.6, h: 0.35, fontSize: 14, color: WHITE, align: "center", italic: true, fontFace: "Consolas" });
s.addText(n.desc, { x: x + 0.2, y: 3.3, w: 3.6, h: 0.95, fontSize: 12, color: WHITE, align: "center", fontFace: FONT_B });
});
// TeamId encoding
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 4.6, w: 12.3, h: 2.3, fill: { color: TERRA }, rectRadius: 0.1 });
s.addText("TeamId Encoding", { x: 0.7, y: 4.7, w: 12, h: 0.4, fontSize: 16, bold: true, color: GOLD, fontFace: FONT_H });
s.addText("uint8 = (high nibble: NPCType) | (low nibble: Faction 0-15)", { x: 0.7, y: 5.1, w: 12, h: 0.3, fontSize: 13, color: SAND, italic: true, fontFace: "Consolas" });
s.addText([
{ text: "Same type + same faction → Friendly (allies)", options: { bullet: true, fontSize: 12 } },
{ text: "Same type + different faction → Hostile (rival gangs)", options: { bullet: true, fontSize: 12 } },
{ text: "Civilian ↔ Protector → always Friendly", options: { bullet: true, fontSize: 12 } },
{ text: "Everything else → Hostile", options: { bullet: true, fontSize: 12 } },
{ text: "Disguised Enemy (hostile=false) → TeamId 0x01 (civilian disguise)", options: { bullet: true, fontSize: 12 } },
], { x: 0.7, y: 5.45, w: 12, h: 1.4, color: SAND, fontFace: FONT_B, paraSpaceAfter: 3 });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 5 — BEHAVIOR STATES
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Behavior States", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Enum written to Blackboard, drives BT branching.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
const states = [
{ name: "Idle", desc: "Resting / waiting. Proximity gaze active." },
{ name: "Patrol", desc: "Wandering on waypoints or splines." },
{ name: "Alerted", desc: "Suspicious, heightened awareness." },
{ name: "Combat", desc: "Engaged with a threat, attacking." },
{ name: "Fleeing", desc: "Running away from danger." },
{ name: "TakingCover", desc: "Behind tactical cover during combat." },
{ name: "Dead", desc: "NPC deceased, all systems shut down." },
{ name: "Scripted", desc: "Manual override (conversations, cinematics)." },
];
states.forEach((state, i) => {
const col = i % 4;
const row = Math.floor(i / 4);
const x = 0.5 + col * 3.15;
const y = 2.2 + row * 2.3;
s.addShape(pres.ShapeType.roundRect, { x: x, y: y, w: 2.95, h: 2.1, fill: { color: SAND }, line: { color: TERRA, width: 1 }, rectRadius: 0.08 });
s.addShape(pres.ShapeType.rect, { x: x, y: y, w: 2.95, h: 0.55, fill: { color: TERRA } });
s.addText(state.name, { x: x, y: y + 0.05, w: 2.95, h: 0.5, fontSize: 16, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: FONT_H });
s.addText(state.desc, { x: x + 0.15, y: y + 0.7, w: 2.65, h: 1.3, fontSize: 11, color: CHARCOAL, valign: "top", fontFace: FONT_B });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 6 — AI CONTROLLER
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("AI Controller", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("APS_AI_Behavior_AIController — the central orchestrator.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
s.addText("Auto-setup on Possession", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "Discovers PersonalityComponent on Pawn", options: { bullet: true, fontSize: 12 } },
{ text: "Computes TeamId from NPCType + Faction", options: { bullet: true, fontSize: 12 } },
{ text: "Initializes Blackboard with required keys", options: { bullet: true, fontSize: 12 } },
{ text: "Binds gaze bridge to ConvAgent (if present)", options: { bullet: true, fontSize: 12 } },
{ text: "Starts BT (unless bAutoStartBehavior = false)", options: { bullet: true, fontSize: 12 } },
], { x: 0.5, y: 2.55, w: 6, h: 2.5, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 4 });
s.addText("Key Methods", { x: 7, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "StartBehavior() / StopBehavior()", options: { bullet: true, fontSize: 12 } },
{ text: "SetBehaviorState() / GetBehaviorState()", options: { bullet: true, fontSize: 12 } },
{ text: "SetTeamId() — runtime team change", options: { bullet: true, fontSize: 12 } },
{ text: "GetBehaviorPerception()", options: { bullet: true, fontSize: 12 } },
{ text: "GetPersonalityComponent()", options: { bullet: true, fontSize: 12 } },
], { x: 7, y: 2.55, w: 6, h: 2.5, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 4 });
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 5.2, w: 12.3, h: 1.8, fill: { color: TERRA }, rectRadius: 0.1 });
s.addText("Gaze Bridge (Proximity)", { x: 0.7, y: 5.3, w: 12, h: 0.35, fontSize: 14, bold: true, color: GOLD, fontFace: FONT_H });
s.addText("Reflection-based integration with PS_AI_ConvAgent. During Idle/Patrol, NPC glances at nearby actors within GazeProximityRadius. Configurable duration and cooldown. Auto-pauses during active conversations.", {
x: 0.7, y: 5.7, w: 12, h: 1.25, fontSize: 12, color: SAND, italic: true, fontFace: FONT_B
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 7 — PERSONALITY COMPONENT
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Personality Component", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Runtime traits & state reactions for the NPC.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
// Traits triangle
s.addText("The Three Traits", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
const traits = [
{ name: "Courage", desc: "0 = coward, 1 = fearless", effect: "Gates flee threshold & cover advancement", color: "C9463A" },
{ name: "Aggressivity", desc: "0 = peaceful, 1 = violent", effect: "Drives attack threshold, combat/cover cycle", color: "D4803A" },
{ name: "Caution", desc: "0 = reckless, 1 = prudent", effect: "Flee threshold, cover duration, spline threat", color: "7C9D7B" },
];
traits.forEach((t, i) => {
const y = 2.6 + i * 0.95;
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: y, w: 1.8, h: 0.8, fill: { color: t.color }, rectRadius: 0.08 });
s.addText(t.name, { x: 0.5, y: y + 0.1, w: 1.8, h: 0.3, fontSize: 14, bold: true, color: WHITE, align: "center", fontFace: FONT_H });
s.addText(t.desc, { x: 0.5, y: y + 0.45, w: 1.8, h: 0.3, fontSize: 10, color: WHITE, align: "center", italic: true, fontFace: FONT_B });
s.addText(t.effect, { x: 2.4, y: y + 0.15, w: 4.2, h: 0.6, fontSize: 12, color: CHARCOAL, valign: "middle", fontFace: FONT_B });
});
s.addText("Properties & Events", { x: 7, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "Profile — PersonalityProfile DataAsset", options: { bullet: true, fontSize: 12 } },
{ text: "bAutoStartBehavior — per-NPC override", options: { bullet: true, fontSize: 12 } },
{ text: "RuntimeTraits — editable at runtime", options: { bullet: true, fontSize: 12 } },
{ text: "PerceivedThreatLevel — replicated", options: { bullet: true, fontSize: 12 } },
{ text: "CurrentState — replicated with OnRep", options: { bullet: true, fontSize: 12 } },
{ text: "EvaluateReaction() / ApplyReaction()", options: { bullet: true, fontSize: 12 } },
{ text: "ModifyTrait() / GetTrait()", options: { bullet: true, fontSize: 12 } },
{ text: "ForceState() — scripting override", options: { bullet: true, fontSize: 12 } },
{ text: "OnBehaviorStateChanged delegate", options: { bullet: true, fontSize: 12 } },
], { x: 7, y: 2.55, w: 6, h: 4.5, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 3 });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 8 — PERSONALITY PROFILE
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("PersonalityProfile DataAsset", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 32, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("The behavioral recipe — one profile per NPC archetype.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
const profileCats = [
{ title: "Identity", items: ["ProfileName", "NPCType", "Faction (0-15)"] },
{ title: "Traits", items: ["Courage (0-1)", "Aggressivity (0-1)", "Caution (0-1)"] },
{ title: "Thresholds", items: ["FleeThreshold", "AttackThreshold", "AlertThreshold", "ThreatDecayRate"] },
{ title: "Combat", items: ["TargetPriority[]", "MinAttackRange / MaxAttackRange", "CombatCoverCycleDuration"] },
{ title: "Movement", items: ["SpeedPerState map", "DefaultWalkSpeed"] },
{ title: "Behavior", items: ["DefaultBehaviorTree (soft ref)"] },
];
profileCats.forEach((c, i) => {
const col = i % 3;
const row = Math.floor(i / 3);
const x = 0.5 + col * 4.15;
const y = 2.2 + row * 2.3;
s.addShape(pres.ShapeType.roundRect, { x: x, y: y, w: 3.95, h: 2.1, fill: { color: SAND }, line: { color: TERRA, width: 1 }, rectRadius: 0.08 });
s.addText(c.title, { x: x + 0.15, y: y + 0.1, w: 3.65, h: 0.35, fontSize: 14, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: x + 0.15, y: y + 0.45, w: 0.35, h: 0.04, fill: { color: GOLD } });
s.addText(c.items.map(t => ({ text: t, options: { bullet: true, fontSize: 11 } })),
{ x: x + 0.15, y: y + 0.55, w: 3.65, h: 1.4, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 3 });
});
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 6.85, w: 12.3, h: 0.35, fill: { color: TERRA }, rectRadius: 0.04 });
s.addText("Example presets: Cowardly Villager • Aggressive Guard • Rival Gang Member", { x: 0.7, y: 6.88, w: 12, h: 0.3, fontSize: 11, color: SAND, italic: true, fontFace: FONT_B });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 9 — COMPONENTS OVERVIEW
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Components", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Attach to Pawns as needed — most are optional.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
const components = [
{ name: "Personality", desc: "Traits, profile, state reactions", required: true },
{ name: "Perception", desc: "AI Perception wrapper, threat computation", required: "auto" },
{ name: "Team", desc: "Identity for non-AI pawns (player)", required: false },
{ name: "Combat", desc: "Attack events, fire/kill delegates", required: false },
{ name: "Spline Follower", desc: "Spline walking, junction detection", required: false },
];
components.forEach((c, i) => {
const y = 2.2 + i * 0.9;
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: y, w: 3.5, h: 0.8, fill: { color: TERRA }, rectRadius: 0.08 });
s.addText(c.name, { x: 0.5, y: y, w: 3.5, h: 0.8, fontSize: 16, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: FONT_H });
s.addShape(pres.ShapeType.roundRect, { x: 4.2, y: y, w: 7.0, h: 0.8, fill: { color: SAND }, line: { color: TERRA, width: 1 }, rectRadius: 0.06 });
s.addText(c.desc, { x: 4.4, y: y + 0.1, w: 6.8, h: 0.6, fontSize: 13, color: CHARCOAL, valign: "middle", fontFace: FONT_B });
let tagText = c.required === true ? "Required" : (c.required === "auto" ? "Auto" : "Optional");
let tagColor = c.required === true ? TERRA : (c.required === "auto" ? SAGE_D : GOLD);
s.addShape(pres.ShapeType.roundRect, { x: 11.4, y: y + 0.2, w: 1.4, h: 0.4, fill: { color: tagColor }, rectRadius: 0.08 });
s.addText(tagText, { x: 11.4, y: y + 0.2, w: 1.4, h: 0.4, fontSize: 11, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: FONT_H });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 10 — PERCEPTION COMPONENT
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Perception Component", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Sight, hearing, damage senses — auto-configured.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
s.addText("Senses", { x: 0.5, y: 2.15, w: 4, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "Sight — vision cone + line of sight", options: { bullet: true, fontSize: 13 } },
{ text: "Hearing — noise events with tags", options: { bullet: true, fontSize: 13 } },
{ text: "Damage — damage received tracking", options: { bullet: true, fontSize: 13 } },
], { x: 0.5, y: 2.55, w: 4, h: 2, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("Threat Scoring", { x: 4.8, y: 2.15, w: 4, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "Target priority (from Profile)", options: { bullet: true, fontSize: 13 } },
{ text: "Recent damage weight", options: { bullet: true, fontSize: 13 } },
{ text: "Proximity weight", options: { bullet: true, fontSize: 13 } },
{ text: "Target persistence (80% score hold)", options: { bullet: true, fontSize: 13 } },
], { x: 4.8, y: 2.55, w: 4.2, h: 2.2, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("Key Methods", { x: 9.3, y: 2.15, w: 3.5, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "GetHighestThreatActor()", options: { bullet: true, fontSize: 12 } },
{ text: "CalculateThreatLevel()", options: { bullet: true, fontSize: 12 } },
{ text: "GetThreatLocation()", options: { bullet: true, fontSize: 12 } },
{ text: "ClassifyActor()", options: { bullet: true, fontSize: 12 } },
], { x: 9.3, y: 2.55, w: 3.5, h: 2, color: CHARCOAL, fontFace: "Consolas", paraSpaceAfter: 4 });
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 5.15, w: 12.3, h: 1.85, fill: { color: TERRA }, rectRadius: 0.1 });
s.addText("Perception / Threat Resolution", { x: 0.7, y: 5.25, w: 12, h: 0.35, fontSize: 14, bold: true, color: GOLD, fontFace: FONT_H });
s.addText("Detects ALL affiliations → filters by Hostile attitude. Supports separate ThreatTarget (e.g. AimTargetActor sphere) vs. owning Pawn. Line-of-sight tracking with last-known-position memory.", {
x: 0.7, y: 5.65, w: 12, h: 1.3, fontSize: 12, color: SAND, italic: true, fontFace: FONT_B
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 11 — COMBAT COMPONENT
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Combat Component", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Attack execution, cooldown, damage events.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
s.addText("Properties", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "AttackRange — max distance (cm)", options: { bullet: true, fontSize: 13 } },
{ text: "AttackCooldown — minimum between attacks", options: { bullet: true, fontSize: 13 } },
{ text: "AttackDamage — base damage value", options: { bullet: true, fontSize: 13 } },
{ text: "CurrentTarget — replicated", options: { bullet: true, fontSize: 13 } },
], { x: 0.5, y: 2.55, w: 6, h: 2.3, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("Methods & Events", { x: 7, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "CanAttack() — cooldown check", options: { bullet: true, fontSize: 13 } },
{ text: "IsInAttackRange() — distance test", options: { bullet: true, fontSize: 13 } },
{ text: "ExecuteAttack() — apply damage + multicast", options: { bullet: true, fontSize: 13 } },
{ text: "OnAttackExecuted (multicast)", options: { bullet: true, fontSize: 13 } },
{ text: "OnDamageReceived (server-only)", options: { bullet: true, fontSize: 13 } },
], { x: 7, y: 2.55, w: 6, h: 2.5, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 5.3, w: 12.3, h: 1.7, fill: { color: TERRA }, rectRadius: 0.1 });
s.addText("Combat Type", { x: 0.7, y: 5.4, w: 12, h: 0.35, fontSize: 14, bold: true, color: GOLD, fontFace: FONT_H });
s.addText("NPC's combat type (Melee / Ranged) comes from IPS_AI_Behavior::GetBehaviorCombatType(). Melee = rush the target (no cooldown between re-positions). Ranged = maintain distance, use cover cycle, peek to fire.", {
x: 0.7, y: 5.75, w: 12, h: 1.2, fontSize: 12, color: SAND, italic: true, fontFace: FONT_B
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 12 — WORLD ACTORS
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("World Actors", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Placed by level designers — drive navigation and tactics.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
const actors = [
{ name: "SplinePath", desc: "Placeable spline defining a navigation corridor. Filtered by NPCType. Bidirectional, with priority for junction selection." },
{ name: "SplineNetwork", desc: "WorldSubsystem — auto-detects intersections between SplinePath actors. Provides navigation queries, threat-aware switching." },
{ name: "CoverPoint", desc: "Manually placed tactical position. Cover or HidingSpot type, quality score, crouch flag, max occupants, type filter." },
];
actors.forEach((a, i) => {
const y = 2.2 + i * 1.5;
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: y, w: 12.3, h: 1.35, fill: { color: SAND }, line: { color: TERRA, width: 1 }, rectRadius: 0.1 });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: y, w: 3.5, h: 1.35, fill: { color: TERRA } });
s.addText(a.name, { x: 0.5, y: y, w: 3.5, h: 1.35, fontSize: 20, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: FONT_H });
s.addText(a.desc, { x: 4.2, y: y + 0.15, w: 8.4, h: 1.05, fontSize: 13, color: CHARCOAL, valign: "middle", fontFace: FONT_B });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 13 — BEHAVIOR TREE SERVICES & TASKS
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Behavior Tree — Services & Tasks", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 30, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Reusable BT nodes — build custom trees per NPC type.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
s.addText("Services (root-level)", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
const services = [
{ name: "UpdateThreat", desc: "Queries perception, writes threat BB keys" },
{ name: "EvaluateReaction", desc: "Computes state from traits + threat" },
{ name: "UpdateGaze", desc: "Manages eye contact, ConvAgent bridge" },
];
services.forEach((svc, i) => {
const y = 2.55 + i * 0.7;
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: y, w: 2.3, h: 0.55, fill: { color: SAGE_D }, rectRadius: 0.06 });
s.addText(svc.name, { x: 0.5, y: y, w: 2.3, h: 0.55, fontSize: 12, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: FONT_H });
s.addText(svc.desc, { x: 2.9, y: y, w: 3.7, h: 0.55, fontSize: 11, color: CHARCOAL, valign: "middle", fontFace: FONT_B });
});
s.addText("Tasks (leaves)", { x: 7, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
const tasks = [
{ name: "FollowSpline", desc: "Drives SplineFollower" },
{ name: "FindAndFollowSpline", desc: "Auto-locate + follow" },
{ name: "Patrol", desc: "Cycle PatrolPoints" },
{ name: "FindCover", desc: "Manual + procedural cover" },
{ name: "CoverShootCycle", desc: "Cover → peek → fire cycle" },
{ name: "Attack", desc: "Chase + attack (Melee/Ranged)" },
{ name: "FleeFrom", desc: "Move away from threat" },
];
tasks.forEach((task, i) => {
const y = 2.55 + i * 0.58;
s.addShape(pres.ShapeType.roundRect, { x: 7, y: y, w: 2.3, h: 0.45, fill: { color: TERRA }, rectRadius: 0.06 });
s.addText(task.name, { x: 7, y: y, w: 2.3, h: 0.45, fontSize: 11, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: FONT_H });
s.addText(task.desc, { x: 9.4, y: y, w: 3.5, h: 0.45, fontSize: 10, color: CHARCOAL, valign: "middle", fontFace: FONT_B });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 14 — COVER SYSTEM
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Cover System", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Manual CoverPoints + procedural raycast fallback + EQS refinement.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
const coverCols = [
{
title: "Manual CoverPoints",
items: ["Placed by level designer", "Type: Cover or HidingSpot", "Quality score (01)", "MaxOccupants, Crouch flag", "Type filter (Any / Civ / Enemy / Protector)"]
},
{
title: "Procedural",
items: ["Fallback if no manual points", "Raycast sampling around threat", "LOS blockage test", "Advancement bias toward threat"]
},
{
title: "EQS Refinement",
items: ["OnCircle generator around cover", "LineOfSight filter", "Distance + quality tests", "Optional firing position refine"]
},
];
coverCols.forEach((c, i) => {
const x = 0.5 + i * 4.28;
s.addShape(pres.ShapeType.roundRect, { x: x, y: 2.2, w: 4.08, h: 3.1, fill: { color: SAND }, line: { color: TERRA, width: 1 }, rectRadius: 0.1 });
s.addText(c.title, { x: x + 0.2, y: 2.3, w: 3.78, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: x + 0.2, y: 2.72, w: 0.4, h: 0.04, fill: { color: GOLD } });
s.addText(c.items.map(t => ({ text: t, options: { bullet: true, fontSize: 11 } })),
{ x: x + 0.2, y: 2.85, w: 3.78, h: 2.3, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 3 });
});
// Cover cycle state machine
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 5.55, w: 12.3, h: 1.45, fill: { color: TERRA }, rectRadius: 0.1 });
s.addText("Cover Cycle State Machine (CombatSubState)", { x: 0.7, y: 5.65, w: 12, h: 0.35, fontSize: 14, bold: true, color: GOLD, fontFace: FONT_H });
const subStates = ["Engaging", "AtCover", "MovingToFire", "Peeking", "ReturningToCover", "Advancing"];
subStates.forEach((sub, i) => {
const x = 0.7 + i * 2.0;
s.addShape(pres.ShapeType.roundRect, { x: x, y: 6.15, w: 1.9, h: 0.7, fill: { color: TERRA_D }, rectRadius: 0.05 });
s.addText(sub, { x: x, y: 6.15, w: 1.9, h: 0.7, fontSize: 11, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: FONT_H });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 15 — INTERFACE (IMPLEMENTERS GUIDE)
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("IPS_AI_Behavior_Interface", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 34, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("What your Pawn must implement (C++ or Blueprint).", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
const ifCats = [
{
title: "Identity",
items: ["GetBehaviorNPCType()", "SetBehaviorNPCType()", "IsBehaviorHostile()", "SetBehaviorHostile()"]
},
{
title: "Movement",
items: ["SetBehaviorMovementSpeed()", "GetBehaviorMovementSpeed()", "SetBehaviorCrouch()"]
},
{
title: "State Feedback",
items: ["OnBehaviorStateChanged()", "IsTargetActorValid()", "GetBehaviorThreatActor()"]
},
{
title: "Combat",
items: ["BehaviorStartAttack()", "BehaviorStopAttack()", "CanBehaviorAttack()", "GetBehaviorCombatType()"]
},
];
ifCats.forEach((c, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const x = 0.5 + col * 6.3;
const y = 2.2 + row * 2.4;
s.addShape(pres.ShapeType.roundRect, { x: x, y: y, w: 6.0, h: 2.2, fill: { color: TERRA }, rectRadius: 0.1 });
s.addText(c.title, { x: x + 0.2, y: y + 0.15, w: 5.6, h: 0.4, fontSize: 16, bold: true, color: GOLD, fontFace: FONT_H });
s.addText(c.items.map(t => ({ text: t, options: { bullet: true, fontSize: 12 } })),
{ x: x + 0.2, y: y + 0.6, w: 5.6, h: 1.55, color: SAND, fontFace: "Consolas", paraSpaceAfter: 3 });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 16 — SETUP WORKFLOW
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Setup Workflow", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("From zero to a behaving NPC in 6 steps.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
const bsteps = [
{ num: "1", title: "Personality Profile", desc: "Create DataAsset — traits, thresholds, DefaultBehaviorTree." },
{ num: "2", title: "Implement Interface", desc: "Pawn: implement IPS_AI_Behavior_Interface (identity, combat, movement)." },
{ num: "3", title: "Add Components", desc: "Pawn: add PersonalityComponent. Optional: Combat, SplineFollower." },
{ num: "4", title: "Behavior Tree", desc: "Branch on BehaviorState. Add services UpdateThreat + EvaluateReaction at root." },
{ num: "5", title: "Set AIController", desc: "Pawn's AIController class = APS_AI_Behavior_AIController." },
{ num: "6", title: "Place World Actors", desc: "Optional: SplinePaths, CoverPoints, SplineNetwork (auto)." },
];
bsteps.forEach((step, i) => {
const y = 2.2 + i * 0.78;
s.addShape(pres.ShapeType.ellipse, { x: 0.5, y: y, w: 0.7, h: 0.7, fill: { color: GOLD } });
s.addText(step.num, { x: 0.5, y: y, w: 0.7, h: 0.7, fontSize: 24, bold: true, color: TERRA, align: "center", valign: "middle", fontFace: FONT_H });
s.addShape(pres.ShapeType.roundRect, { x: 1.4, y: y, w: 11.4, h: 0.7, fill: { color: SAND }, line: { color: TERRA, width: 1 }, rectRadius: 0.05 });
s.addText(step.title, { x: 1.6, y: y + 0.05, w: 3.5, h: 0.3, fontSize: 13, bold: true, color: TERRA, fontFace: FONT_H });
s.addText(step.desc, { x: 1.6, y: y + 0.35, w: 11.2, h: 0.35, fontSize: 11, color: CHARCOAL, italic: true, fontFace: FONT_B });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 17 — BLACKBOARD KEYS
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Blackboard Keys", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Auto-created by the AIController — use in your BT decorators and tasks.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
const bbKeys = [
{ name: "BehaviorState", type: "Enum", desc: "Current state (Idle, Combat, etc.)" },
{ name: "ThreatActor", type: "Object", desc: "Current highest-scored threat" },
{ name: "ThreatLocation", type: "Vector", desc: "Last known threat position" },
{ name: "ThreatLevel", type: "Float", desc: "Perceived threat (01+)" },
{ name: "CoverLocation", type: "Vector", desc: "Target cover position" },
{ name: "CoverPoint", type: "Object", desc: "Current CoverPoint actor" },
{ name: "PatrolIndex", type: "Int", desc: "Current patrol waypoint index" },
{ name: "HomeLocation", type: "Vector", desc: "Spawn / home point" },
{ name: "CurrentSpline", type: "Object", desc: "Active SplinePath" },
{ name: "SplineProgress", type: "Float", desc: "Distance along current spline" },
{ name: "CombatSubState", type: "Enum", desc: "Cover cycle sub-state (Peeking, etc.)" },
{ name: "LastKnownTargetPosition", type: "Vector", desc: "LOS-lost target memory" },
{ name: "PreferCover", type: "Bool", desc: "Combat/cover cycle toggle" },
{ name: "ConversationPaused", type: "Bool", desc: "Active dialogue pause flag" },
];
bbKeys.forEach((k, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const x = 0.5 + col * 6.3;
const y = 2.2 + row * 0.62;
s.addShape(pres.ShapeType.roundRect, { x: x, y: y, w: 1.8, h: 0.5, fill: { color: TERRA }, rectRadius: 0.05 });
s.addText(k.name, { x: x, y: y, w: 1.8, h: 0.5, fontSize: 10, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: "Consolas" });
s.addShape(pres.ShapeType.roundRect, { x: x + 1.85, y: y + 0.1, w: 0.6, h: 0.3, fill: { color: GOLD }, rectRadius: 0.05 });
s.addText(k.type, { x: x + 1.85, y: y + 0.1, w: 0.6, h: 0.3, fontSize: 9, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: FONT_H });
s.addText(k.desc, { x: x + 2.55, y: y, w: 3.7, h: 0.5, fontSize: 10, color: CHARCOAL, valign: "middle", italic: true, fontFace: FONT_B });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 18 — NETWORKING
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Networking", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Server-authoritative AI, minimal replication for visuals.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
s.addText("Server-only", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "All AI planning & decision-making", options: { bullet: true, fontSize: 13 } },
{ text: "Threat evaluation, state transitions", options: { bullet: true, fontSize: 13 } },
{ text: "Perception stimuli processing", options: { bullet: true, fontSize: 13 } },
{ text: "Path selection, cover choice", options: { bullet: true, fontSize: 13 } },
{ text: "Combat targeting", options: { bullet: true, fontSize: 13 } },
], { x: 0.5, y: 2.55, w: 6, h: 2.7, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("Replicated to clients", { x: 7, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "CurrentState (OnRep fires delegate)", options: { bullet: true, fontSize: 13 } },
{ text: "PerceivedThreatLevel (HUD/debug)", options: { bullet: true, fontSize: 13 } },
{ text: "CurrentTarget (anims/visuals)", options: { bullet: true, fontSize: 13 } },
{ text: "CurrentSpline, bIsFollowing", options: { bullet: true, fontSize: 13 } },
{ text: "OnAttackExecuted (multicast)", options: { bullet: true, fontSize: 13 } },
], { x: 7, y: 2.55, w: 6, h: 2.7, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 5.4, w: 12.3, h: 1.6, fill: { color: TERRA }, rectRadius: 0.1 });
s.addText("Character movement syncs via CharacterMovementComponent. Spline follower updates server-side, position replicates naturally. Clients receive enough state for animation and HUD without any AI logic running locally.", {
x: 0.7, y: 5.6, w: 12, h: 1.2, fontSize: 13, color: SAND, italic: true, fontFace: FONT_B, valign: "middle"
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 19 — INTEGRATION WITH CONVAGENT
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Integration with PS_AI_ConvAgent", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 28, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Optional — behavior and conversation plugins talk via reflection.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
const integrations = [
{ title: "Gaze Bridge", desc: "AIController discovers GazeComponent on the Pawn, sets target during Idle/Patrol (proximity gaze) and forwards current threat during Combat." },
{ title: "Conversation Pause", desc: "When ConvAgent is conversing, AI planning pauses — NPC stops moving, waits. Local VAD detects user speech." },
{ title: "Disguise Reveal", desc: "Enemy NPC with IsBehaviorHostile=false starts as Civilian TeamId. Flips to hostile via interface when triggered by dialogue." },
{ title: "Forced State", desc: "ConvAgent LLM can invoke tools that call ForceState(Scripted) or ForceState(Fleeing) for narrative control." },
];
integrations.forEach((item, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const x = 0.5 + col * 6.3;
const y = 2.2 + row * 2.3;
s.addShape(pres.ShapeType.roundRect, { x: x, y: y, w: 6.0, h: 2.1, fill: { color: SAND }, line: { color: TERRA, width: 1 }, rectRadius: 0.1 });
s.addText(item.title, { x: x + 0.2, y: y + 0.15, w: 5.6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: x + 0.2, y: y + 0.58, w: 0.4, h: 0.04, fill: { color: GOLD } });
s.addText(item.desc, { x: x + 0.2, y: y + 0.7, w: 5.6, h: 1.3, fontSize: 12, color: CHARCOAL, fontFace: FONT_B });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 20 — DEBUG TOOLS
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Debug Tools", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: TERRA, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: GOLD } });
s.addText("Visualize state, perception, and targeting at runtime.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: CHARCOAL, italic: true, fontFace: FONT_B });
s.addText("Per-NPC Debug", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "PersonalityComponent.bDebug — floating text", options: { bullet: true, fontSize: 13 } },
{ text: "Name, NPCType, TeamId, State", options: { bullet: true, fontSize: 13 } },
{ text: "ThreatLevel, ThreatActor", options: { bullet: true, fontSize: 13 } },
{ text: "Hostile, active spline", options: { bullet: true, fontSize: 13 } },
{ text: "SplineFollower debug sphere (green)", options: { bullet: true, fontSize: 13 } },
], { x: 0.5, y: 2.55, w: 6, h: 2.8, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("Global Logging", { x: 7, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: TERRA, fontFace: FONT_H });
s.addText([
{ text: "CVar: ps.ai.Behavior.Debug 1", options: { bullet: true, fontSize: 13 } },
{ text: "Toggles verbose logs globally", options: { bullet: true, fontSize: 13 } },
{ text: "LogPS_AI_Behavior category", options: { bullet: true, fontSize: 13 } },
{ text: "BTView / AIDebug (Unreal built-in)", options: { bullet: true, fontSize: 13 } },
{ text: "EQS Visualizer for cover queries", options: { bullet: true, fontSize: 13 } },
], { x: 7, y: 2.55, w: 6, h: 2.8, color: CHARCOAL, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 5.5, w: 12.3, h: 1.5, fill: { color: TERRA }, rectRadius: 0.1 });
s.addText("Pro tip", { x: 0.7, y: 5.6, w: 12, h: 0.35, fontSize: 14, bold: true, color: GOLD, fontFace: FONT_H });
s.addText("Enable bDebug only on specific NPCs (or via a debug HUD key). Global debug logs can be overwhelming in crowded scenes. The floating text is cheap but accumulates.",
{ x: 0.7, y: 5.95, w: 12, h: 1.0, fontSize: 12, color: SAND, italic: true, fontFace: FONT_B });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 21 — SUMMARY
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide();
s.background = { color: TERRA };
s.addShape(pres.ShapeType.rect, { x: 0, y: 2.5, w: 13.333, h: 0.05, fill: { color: GOLD } });
s.addText("Summary", { x: 0.6, y: 0.8, w: 12, h: 1.2, fontSize: 54, bold: true, color: WHITE, fontFace: FONT_H });
s.addText("Complete NPC behavior stack for tactical game AI.", { x: 0.6, y: 2.0, w: 12, h: 0.5, fontSize: 18, color: SAND, italic: true, fontFace: FONT_B });
const bsummary = [
{ h: "Personality-driven", t: "Three traits modulate every decision" },
{ h: "Cover-aware", t: "Manual CoverPoints + procedural fallback + EQS" },
{ h: "Spline navigation", t: "Network with junction detection, type filtering" },
{ h: "Perception built-in", t: "Sight + hearing + damage, priority-based threat" },
{ h: "Server-authoritative", t: "Minimal replication, client just animates" },
{ h: "Plugin-friendly", t: "Gaze bridge to ConvAgent, interface-only dependency" },
];
bsummary.forEach((item, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const x = 0.6 + col * 6.2;
const y = 3.0 + row * 1.4;
s.addShape(pres.ShapeType.roundRect, { x: x, y: y, w: 5.9, h: 1.2, fill: { color: TERRA_D }, line: { color: GOLD, width: 1 }, rectRadius: 0.1 });
s.addText(item.h, { x: x + 0.2, y: y + 0.1, w: 5.5, h: 0.4, fontSize: 18, bold: true, color: GOLD, fontFace: FONT_H });
s.addText(item.t, { x: x + 0.2, y: y + 0.55, w: 5.5, h: 0.6, fontSize: 12, color: SAND, italic: true, fontFace: FONT_B });
});
s.addText("ASTERION • PS_AI_Behavior • 2026", { x: 0.6, y: 6.9, w: 12, h: 0.4, fontSize: 12, color: SAND, fontFace: FONT_B });
// ═══════════════════════════════════════════════════════════════════
// SAVE
// ═══════════════════════════════════════════════════════════════════
pres.writeFile({ fileName: "PS_AI_Behavior_Documentation.pptx" })
.then(name => console.log(`Generated: ${name}`))
.catch(err => { console.error(err); process.exit(1); });

711
Docs/gen_convagent_doc.js Normal file
View File

@@ -0,0 +1,711 @@
// PS_AI_ConvAgent User Documentation Generator
// Palette: Midnight Executive (navy #1E2761, ice blue #CADCFC, white)
const pptxgen = require("pptxgenjs");
const pres = new pptxgen();
pres.layout = "LAYOUT_WIDE"; // 13.333 x 7.5 inch
// ─── Color Palette ─────────────────────────────────
const NAVY = "1E2761";
const NAVY_DARK = "141B45";
const ICE = "CADCFC";
const ICE_DEEP = "7892D6";
const WHITE = "FFFFFF";
const GREY = "3A3F5C";
const LIGHT_GREY= "F5F7FC";
const ACCENT = "F9A825"; // warm gold accent
// ─── Font ──────────────────────────────────────────
const FONT_H = "Calibri";
const FONT_B = "Calibri";
// ─── Master slide backgrounds ──────────────────────
pres.defineSlideMaster({
title: "CONTENT",
background: { color: LIGHT_GREY },
objects: [
{ rect: { x: 0, y: 0, w: 13.333, h: 0.35, fill: { color: NAVY } } },
{ rect: { x: 0, y: 7.15, w: 13.333, h: 0.35, fill: { color: NAVY } } },
{ text: {
text: "PS_AI_ConvAgent — User Documentation",
options: { x: 0.3, y: 7.18, w: 8, h: 0.3, fontSize: 10, color: ICE, fontFace: FONT_B }
} },
{ text: {
text: "ASTERION",
options: { x: 11.5, y: 7.18, w: 1.5, h: 0.3, fontSize: 10, color: ICE, fontFace: FONT_B, align: "right", bold: true }
} },
],
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 1 — TITLE
// ═══════════════════════════════════════════════════════════════════
let s = pres.addSlide();
s.background = { color: NAVY };
s.addShape(pres.ShapeType.rect, { x: 0, y: 3.3, w: 13.333, h: 0.05, fill: { color: ACCENT } });
s.addText("PS_AI_ConvAgent", { x: 0.6, y: 2.2, w: 12, h: 1.2, fontSize: 60, bold: true, color: WHITE, fontFace: FONT_H });
s.addText("Conversational AI for MetaHumans in Unreal Engine", { x: 0.6, y: 3.5, w: 12, h: 0.6, fontSize: 22, color: ICE, fontFace: FONT_B, italic: true });
s.addText("User Documentation", { x: 0.6, y: 4.4, w: 12, h: 0.5, fontSize: 18, color: ICE_DEEP, fontFace: FONT_B });
s.addText("ElevenLabs • MetaHuman • Listen Server", { x: 0.6, y: 5.2, w: 12, h: 0.4, fontSize: 14, color: ICE_DEEP, fontFace: FONT_B });
s.addText("ASTERION • 2026", { x: 0.6, y: 6.6, w: 12, h: 0.4, fontSize: 12, color: ICE_DEEP, fontFace: FONT_B });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 2 — OVERVIEW
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Plugin Overview", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("What it does", { x: 0.5, y: 1.7, w: 6, h: 0.4, fontSize: 18, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "Full conversational AI NPC integration for Unreal Engine", options: { bullet: true, fontSize: 14 } },
{ text: "Real-time voice capture, turn-taking, TTS playback", options: { bullet: true, fontSize: 14 } },
{ text: "Emotion-driven MetaHuman animations (face, body, gaze)", options: { bullet: true, fontSize: 14 } },
{ text: "Multiplayer support with speaker arbitration", options: { bullet: true, fontSize: 14 } },
{ text: "Tool system for LLM-triggered game actions", options: { bullet: true, fontSize: 14 } },
], { x: 0.5, y: 2.1, w: 6, h: 2.6, color: GREY, fontFace: FONT_B, paraSpaceAfter: 8 });
s.addShape(pres.ShapeType.roundRect, { x: 7, y: 1.7, w: 5.8, h: 4.8, fill: { color: NAVY }, rectRadius: 0.1 });
s.addText("Primary Use Case", { x: 7.2, y: 1.9, w: 5.4, h: 0.4, fontSize: 16, bold: true, color: ACCENT, fontFace: FONT_H });
s.addText("Interactive, emotionally expressive voice NPCs that listen to players, respond with contextual answers, express emotion through facial animation and body language, and trigger physical actions based on conversation flow.", {
x: 7.2, y: 2.4, w: 5.4, h: 1.7, fontSize: 13, color: ICE, fontFace: FONT_B, italic: true
});
s.addText("Key Differentiators", { x: 7.2, y: 4.2, w: 5.4, h: 0.4, fontSize: 16, bold: true, color: ACCENT, fontFace: FONT_H });
s.addText([
{ text: "Full-duplex Server VAD or push-to-talk", options: { bullet: true, fontSize: 12 } },
{ text: "Adaptive pre-buffering & speculative TTS", options: { bullet: true, fontSize: 12 } },
{ text: "Spectral lip sync + artist-driven poses", options: { bullet: true, fontSize: 12 } },
{ text: "Opus codec + distance LOD for multiplayer", options: { bullet: true, fontSize: 12 } },
], { x: 7.2, y: 4.7, w: 5.4, h: 1.7, color: ICE, fontFace: FONT_B, paraSpaceAfter: 4 });
s.addText("\"Drop components on a MetaHuman, configure a few data assets, and talk.\"", {
x: 0.5, y: 5.3, w: 6, h: 0.6, fontSize: 13, color: NAVY, italic: true, fontFace: FONT_B, bold: true
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 3 — ARCHITECTURE DIAGRAM
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Architecture", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Component chain on a MetaHuman NPC", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
// Input row
const boxY = 2.4;
const boxH = 1.0;
const boxes = [
{ x: 0.5, label: "Microphone\nCapture", sub: "VAD • 16kHz" },
{ x: 2.6, label: "Interaction\nComponent", sub: "Agent select\n(player side)" },
{ x: 4.7, label: "ElevenLabs\nComponent", sub: "WebSocket\nTurn-taking" },
{ x: 6.8, label: "Lip Sync\nComponent", sub: "Spectral\nFFT → visemes" },
{ x: 8.9, label: "Facial Expr.\nComponent", sub: "Emotion\ncurves" },
{ x: 11.0, label: "Body Expr.\nComponent", sub: "Gesture\nanim blend" },
];
boxes.forEach((b, i) => {
s.addShape(pres.ShapeType.roundRect, { x: b.x, y: boxY, w: 2.0, h: boxH, fill: { color: NAVY }, rectRadius: 0.1 });
s.addText(b.label, { x: b.x, y: boxY + 0.1, w: 2.0, h: 0.5, fontSize: 13, bold: true, color: WHITE, align: "center", fontFace: FONT_H });
s.addText(b.sub, { x: b.x, y: boxY + 0.58, w: 2.0, h: 0.4, fontSize: 9, color: ICE, align: "center", fontFace: FONT_B });
});
// Arrows between boxes
for (let i = 0; i < boxes.length - 1; i++) {
const ax = boxes[i].x + 2.0;
s.addShape(pres.ShapeType.line, { x: ax, y: boxY + 0.5, w: 0.1, h: 0, line: { color: ACCENT, width: 2 } });
}
// Gaze row
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 4.0, w: 2.0, h: boxH, fill: { color: ICE_DEEP }, rectRadius: 0.1 });
s.addText("Gaze\nComponent", { x: 0.5, y: 4.1, w: 2.0, h: 0.5, fontSize: 13, bold: true, color: WHITE, align: "center", fontFace: FONT_H });
s.addText("Eyes • Head\n• Body", { x: 0.5, y: 4.58, w: 2.0, h: 0.4, fontSize: 9, color: WHITE, align: "center", fontFace: FONT_B });
// AnimGraph pipeline
s.addText("→ AnimGraph (Face + Body)", { x: 2.8, y: 4.2, w: 4.5, h: 0.5, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText("FacialExpression → LipSync | Gaze → BodyExpression", { x: 2.8, y: 4.65, w: 8, h: 0.4, fontSize: 12, color: GREY, italic: true, fontFace: FONT_B });
// Project settings layer
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 5.6, w: 12.3, h: 0.9, fill: { color: LIGHT_GREY }, line: { color: NAVY, width: 1 }, rectRadius: 0.08 });
s.addText("PROJECT SETTINGS — API Key • Server Region • Global Context Prompt", { x: 0.7, y: 5.75, w: 12, h: 0.3, fontSize: 13, bold: true, color: NAVY, fontFace: FONT_H });
s.addText("Data Assets: AgentConfig • EmotionPoseMap • LipSyncPoseMap • BodyPoseMap • ActionSet • Tool", { x: 0.7, y: 6.1, w: 12, h: 0.3, fontSize: 11, color: GREY, fontFace: FONT_B });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 4 — ELEVENLABS COMPONENT
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("ElevenLabs Component", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("The core conversation engine — WebSocket, turn-taking, audio pipeline.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
s.addText("Key Properties", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "AgentConfig — identity, voice, tools", options: { bullet: true, fontSize: 12 } },
{ text: "TurnMode — ServerVAD / Client (push-to-talk)", options: { bullet: true, fontSize: 12 } },
{ text: "MicChunkDurationMs (50250 ms)", options: { bullet: true, fontSize: 12 } },
{ text: "AudioPreBufferMs / bAdaptivePreBuffer", options: { bullet: true, fontSize: 12 } },
{ text: "bAllowInterruption, bPersistentSession", options: { bullet: true, fontSize: 12 } },
{ text: "SoundAttenuation (optional 3D audio)", options: { bullet: true, fontSize: 12 } },
{ text: "AudioLODCullDistance / LipSyncLODDistance", options: { bullet: true, fontSize: 12 } },
], { x: 0.5, y: 2.55, w: 6, h: 3.6, color: GREY, fontFace: FONT_B, paraSpaceAfter: 4 });
s.addText("Key Methods", { x: 7, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "StartConversation() / EndConversation()", options: { bullet: true, fontSize: 12 } },
{ text: "StartListening() / StopListening()", options: { bullet: true, fontSize: 12 } },
{ text: "SendTextMessage() — text without voice", options: { bullet: true, fontSize: 12 } },
{ text: "InterruptAgent() — force stop mid-speech", options: { bullet: true, fontSize: 12 } },
{ text: "ForceDisableConversation(Action) — blend to neutral", options: { bullet: true, fontSize: 12 } },
{ text: "ForceEnableConversation() — re-enable", options: { bullet: true, fontSize: 12 } },
{ text: "FeedExternalAudio() — external mic source", options: { bullet: true, fontSize: 12 } },
], { x: 7, y: 2.55, w: 6, h: 3.6, color: GREY, fontFace: FONT_B, paraSpaceAfter: 4 });
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 6.2, w: 12.3, h: 0.75, fill: { color: NAVY }, rectRadius: 0.08 });
s.addText("Major events: OnAgentConnected • OnAgentTranscript • OnAgentTextResponse • OnAgentStartedSpeaking • OnAgentEmotionChanged • OnAgentActionRequested • OnActiveSpeakerChanged • OnReadyForAction", { x: 0.7, y: 6.3, w: 12, h: 0.55, fontSize: 11, color: ICE, fontFace: FONT_B, italic: true });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 5 — MICROPHONE CAPTURE
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Microphone Capture Component", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 34, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Captures, resamples, and performs local voice activity detection.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
s.addText("Audio Pipeline", { x: 0.5, y: 2.2, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "WASAPI 48kHz stereo float32 input", options: { bullet: true, fontSize: 13 } },
{ text: "Resample to 16kHz mono float", options: { bullet: true, fontSize: 13 } },
{ text: "Convert to int16 LE byte stream", options: { bullet: true, fontSize: 13 } },
{ text: "Delivered via multicast delegate", options: { bullet: true, fontSize: 13 } },
], { x: 0.5, y: 2.6, w: 6, h: 2.4, color: GREY, fontFace: FONT_B, paraSpaceAfter: 6 });
s.addText("Voice Activity Detection", { x: 7, y: 2.2, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "VolumeMultiplier — mic gain", options: { bullet: true, fontSize: 13 } },
{ text: "VoiceActivityThreshold — RMS threshold", options: { bullet: true, fontSize: 13 } },
{ text: "VoiceOnsetTime — debounce on speech start", options: { bullet: true, fontSize: 13 } },
{ text: "VoiceSilenceTime — debounce on end", options: { bullet: true, fontSize: 13 } },
{ text: "OnUserVoiceActivityChanged event", options: { bullet: true, fontSize: 13 } },
], { x: 7, y: 2.6, w: 6, h: 2.4, color: GREY, fontFace: FONT_B, paraSpaceAfter: 6 });
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 5.4, w: 12.3, h: 1.4, fill: { color: LIGHT_GREY }, line: { color: NAVY, width: 1 }, rectRadius: 0.08 });
s.addText("Public API", { x: 0.7, y: 5.5, w: 12, h: 0.3, fontSize: 13, bold: true, color: NAVY, fontFace: FONT_H });
s.addText("StartCapture() • StopCapture() • IsCapturing()", { x: 0.7, y: 5.85, w: 12, h: 0.3, fontSize: 13, color: GREY, fontFace: "Consolas" });
s.addText("The component auto-opens the default input device. Output is consumed by ElevenLabsComponent (or InteractionComponent, which relays via Server RPC in networked games).", { x: 0.7, y: 6.2, w: 12, h: 0.55, fontSize: 11, color: GREY, italic: true, fontFace: FONT_B });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 6 — LIP SYNC
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Lip Sync Component", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Spectral analysis of agent audio → 15 OVR visemes → ARKit blendshapes.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
s.addText("How it works", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "FFT on agent audio stream", options: { bullet: true, fontSize: 13 } },
{ text: "Estimates 15 OVR viseme weights", options: { bullet: true, fontSize: 13 } },
{ text: "Maps to ARKit blendshapes (jawOpen, mouthFunnel…)", options: { bullet: true, fontSize: 13 } },
{ text: "Optional: custom PoseMap with artist-crafted poses", options: { bullet: true, fontSize: 13 } },
{ text: "Thread-safe curve output for AnimGraph", options: { bullet: true, fontSize: 13 } },
], { x: 0.5, y: 2.55, w: 6, h: 3.5, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("Key Properties", { x: 7, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "LipSyncStrength — overall intensity", options: { bullet: true, fontSize: 13 } },
{ text: "SmoothingSpeed (3565) — viseme interp", options: { bullet: true, fontSize: 13 } },
{ text: "SpeechBlendDuration — fade in/out", options: { bullet: true, fontSize: 13 } },
{ text: "EmotionExpressionBlend (01) — emotion bleed through mouth", options: { bullet: true, fontSize: 13 } },
{ text: "EnvelopeAttackMs / EnvelopeReleaseMs", options: { bullet: true, fontSize: 13 } },
{ text: "PoseMap — optional artist override", options: { bullet: true, fontSize: 13 } },
], { x: 7, y: 2.55, w: 6, h: 3.5, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 6.2, w: 12.3, h: 0.75, fill: { color: NAVY }, rectRadius: 0.08 });
s.addText("AnimGraph placement (Face AnimBP): Live Link → FacialExpression → LipSync → mh_arkit_mapping_pose", { x: 0.7, y: 6.3, w: 12, h: 0.55, fontSize: 12, color: ICE, fontFace: FONT_B, italic: true, bold: true });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 7 — FACIAL EXPRESSION
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Facial Expression Component", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 34, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Emotion-driven face animation via real-time AnimSequence sampling.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
s.addText("Design", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "Plays AnimSequences in real-time (looping)", options: { bullet: true, fontSize: 13 } },
{ text: "Extracts emotion curves (eyes, brows, cheeks, mouth mood)", options: { bullet: true, fontSize: 13 } },
{ text: "Injects into AnimGraph via FacialExpression AnimNode", options: { bullet: true, fontSize: 13 } },
{ text: "Lip sync overrides mouth area during speech", options: { bullet: true, fontSize: 13 } },
{ text: "3 intensity levels: Normal / Medium / Extreme", options: { bullet: true, fontSize: 13 } },
{ text: "Crossfades between emotions", options: { bullet: true, fontSize: 13 } },
], { x: 0.5, y: 2.55, w: 6, h: 3.5, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("Properties", { x: 7, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "EmotionPoseMap — data asset of AnimSequences", options: { bullet: true, fontSize: 13 } },
{ text: "ActivationBlendDuration (default 0.5s)", options: { bullet: true, fontSize: 13 } },
{ text: "EmotionBlendDuration — crossfade time", options: { bullet: true, fontSize: 13 } },
{ text: "bActive — auto-managed by conversation", options: { bullet: true, fontSize: 13 } },
], { x: 7, y: 2.55, w: 6, h: 2.4, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addShape(pres.ShapeType.roundRect, { x: 7, y: 5.0, w: 5.8, h: 1.5, fill: { color: LIGHT_GREY }, line: { color: NAVY, width: 1 }, rectRadius: 0.08 });
s.addText("Emotions covered", { x: 7.15, y: 5.1, w: 5.6, h: 0.3, fontSize: 13, bold: true, color: NAVY, fontFace: FONT_H });
s.addText("Joy • Sadness • Anger • Fear • Surprise • Disgust • Neutral", { x: 7.15, y: 5.45, w: 5.6, h: 0.9, fontSize: 12, color: GREY, italic: true, fontFace: FONT_B });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 8 — BODY EXPRESSION
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Body Expression Component", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 34, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Upper-body gestures and posture driven by emotion + speaking state.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
s.addText("Animation lists per emotion", { x: 0.5, y: 2.15, w: 6.3, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
const animLists = [
{ name: "Idle", desc: "Played while listening (not speaking)" },
{ name: "Normal", desc: "Low-intensity speaking anims" },
{ name: "Medium", desc: "Medium-intensity speaking" },
{ name: "Extreme", desc: "High-intensity speaking" },
];
animLists.forEach((a, i) => {
const y = 2.7 + i * 0.75;
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: y, w: 1.5, h: 0.55, fill: { color: NAVY }, rectRadius: 0.05 });
s.addText(a.name, { x: 0.5, y: y, w: 1.5, h: 0.55, fontSize: 13, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: FONT_H });
s.addText(a.desc, { x: 2.15, y: y + 0.05, w: 4.6, h: 0.45, fontSize: 12, color: GREY, valign: "middle", fontFace: FONT_B });
});
s.addText("Key Properties", { x: 7.2, y: 2.15, w: 5.8, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "BodyPoseMap — emotion → anim lists", options: { bullet: true, fontSize: 13 } },
{ text: "ActivationBlendDuration (0.5s)", options: { bullet: true, fontSize: 13 } },
{ text: "EmotionBlendDuration (1.0s)", options: { bullet: true, fontSize: 13 } },
{ text: "BlendWeight (01)", options: { bullet: true, fontSize: 13 } },
], { x: 7.2, y: 2.55, w: 5.8, h: 2, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("AnimNode behaviour", { x: 7.2, y: 4.7, w: 5.8, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "Override mode, per-bone mask", options: { bullet: true, fontSize: 12 } },
{ text: "Default BlendRootBone = spine_02", options: { bullet: true, fontSize: 12 } },
{ text: "Excludes neck_01 (Gaze handles it)", options: { bullet: true, fontSize: 12 } },
{ text: "Lower body passes through upstream", options: { bullet: true, fontSize: 12 } },
], { x: 7.2, y: 5.1, w: 5.8, h: 2, color: GREY, fontFace: FONT_B, paraSpaceAfter: 3 });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 9 — GAZE
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Gaze Component", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Multi-layer look-at: body • head • eyes.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
const layers = [
{ name: "BODY", ratio: "full 360°", desc: "Actor yaw — follows target around the character" },
{ name: "HEAD", ratio: "limited", desc: "Head bone yaw + pitch, clamped" },
{ name: "EYES", ratio: "limited", desc: "ARKit eye curves, fine targeting" },
];
layers.forEach((l, i) => {
const y = 2.3 + i * 1.1;
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: y, w: 2.2, h: 0.9, fill: { color: NAVY }, rectRadius: 0.08 });
s.addText(l.name, { x: 0.5, y: y + 0.05, w: 2.2, h: 0.4, fontSize: 18, bold: true, color: WHITE, align: "center", fontFace: FONT_H });
s.addText(l.ratio, { x: 0.5, y: y + 0.48, w: 2.2, h: 0.3, fontSize: 11, color: ACCENT, align: "center", italic: true, fontFace: FONT_B });
s.addText(l.desc, { x: 3.0, y: y + 0.15, w: 5.0, h: 0.7, fontSize: 13, color: GREY, valign: "middle", fontFace: FONT_B });
});
s.addText("Key Properties", { x: 8.5, y: 2.15, w: 4.5, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "TargetActor — who to look at", options: { bullet: true, fontSize: 12 } },
{ text: "MaxHeadYaw / MaxHeadPitch", options: { bullet: true, fontSize: 12 } },
{ text: "MaxEyeHorizontal / MaxEyeVertical", options: { bullet: true, fontSize: 12 } },
{ text: "BodyInterpSpeed / HeadInterpSpeed / EyeInterpSpeed", options: { bullet: true, fontSize: 12 } },
{ text: "HeadAnimationCompensation (0.9)", options: { bullet: true, fontSize: 12 } },
{ text: "EyeAnimationCompensation (0.6)", options: { bullet: true, fontSize: 12 } },
{ text: "BodyDriftCompensation (0.8)", options: { bullet: true, fontSize: 12 } },
{ text: "NeckBoneChain — multi-bone distribution", options: { bullet: true, fontSize: 12 } },
], { x: 8.5, y: 2.55, w: 4.5, h: 4, color: GREY, fontFace: FONT_B, paraSpaceAfter: 3 });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 10 — INTERACTION COMPONENT
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Interaction Component (Player)", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 32, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Placed on the player pawn — selects NPCs, routes mic, manages gaze.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
s.addText("Selection Logic", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "Proximity + view cone detection", options: { bullet: true, fontSize: 13 } },
{ text: "MaxInteractionDistance (300 cm default)", options: { bullet: true, fontSize: 13 } },
{ text: "ViewConeHalfAngle (45° default)", options: { bullet: true, fontSize: 13 } },
{ text: "SelectionStickyAngle — hysteresis", options: { bullet: true, fontSize: 13 } },
{ text: "ConversationSwitchDelay — anti-flicker", options: { bullet: true, fontSize: 13 } },
], { x: 0.5, y: 2.55, w: 6, h: 3, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("Auto-management", { x: 7, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "bAutoStartConversation — open WS on select", options: { bullet: true, fontSize: 13 } },
{ text: "bAutoManageListening — mic open/close", options: { bullet: true, fontSize: 13 } },
{ text: "bAutoManageGaze — set agent gaze to player", options: { bullet: true, fontSize: 13 } },
{ text: "bAutoPassiveGaze — out-of-range passive tracking", options: { bullet: true, fontSize: 13 } },
], { x: 7, y: 2.55, w: 6, h: 2.5, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 5.8, w: 12.3, h: 1.2, fill: { color: NAVY }, rectRadius: 0.08 });
s.addText("Events: OnAgentSelected • OnAgentDeselected • OnNoAgentInRange", { x: 0.7, y: 5.95, w: 12, h: 0.3, fontSize: 13, bold: true, color: ACCENT, fontFace: FONT_H });
s.addText("Methods: GetSelectedAgent() • ForceSelectAgent() • StartConversationWithSelectedAgent() • SendTextToSelectedAgent() • ClearSelection()", { x: 0.7, y: 6.3, w: 12, h: 0.55, fontSize: 11, color: ICE, fontFace: FONT_B });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 11 — DATA ASSETS OVERVIEW
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Data Assets", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Reusable configuration assets for agent identity, animations, and tools.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
const dataAssets = [
{ name: "AgentConfig", desc: "Agent identity, voice, LLM model, tools", color: NAVY },
{ name: "EmotionPoseMap", desc: "Emotion → facial AnimSequences (3 levels)", color: NAVY },
{ name: "LipSyncPoseMap", desc: "15 OVR visemes → mouth poses", color: NAVY },
{ name: "BodyPoseMap", desc: "Emotion → body gesture anim lists", color: NAVY },
{ name: "ActionSet", desc: "Actions the agent can trigger", color: ICE_DEEP },
{ name: "Tool", desc: "Custom LLM tools with parameters", color: ICE_DEEP },
];
dataAssets.forEach((d, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const x = 0.5 + col * 6.3;
const y = 2.2 + row * 1.5;
s.addShape(pres.ShapeType.roundRect, { x: x, y: y, w: 6.0, h: 1.3, fill: { color: d.color }, rectRadius: 0.1 });
s.addText(d.name, { x: x + 0.2, y: y + 0.2, w: 5.6, h: 0.4, fontSize: 18, bold: true, color: ACCENT, fontFace: FONT_H });
s.addText(d.desc, { x: x + 0.2, y: y + 0.65, w: 5.6, h: 0.6, fontSize: 12, color: ICE, italic: true, fontFace: FONT_B });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 12 — AGENT CONFIG DETAILS
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("AgentConfig (ElevenLabs)", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 34, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Complete agent definition — editable in Content Browser with API buttons.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
const configCats = [
{ title: "Identity", items: ["AgentID", "AgentName"] },
{ title: "Voice", items: ["VoiceID / VoiceName", "TTSModelID", "Stability • Similarity • Speed"] },
{ title: "LLM", items: ["LLMModel (gemini-2.5-flash default)", "Language (en default)"] },
{ title: "Behavior", items: ["CharacterPrompt (personality)", "FirstMessage (greeting)", "TurnTimeout, MaxTurns"] },
{ title: "Latency", items: ["TurnEagerness (Eager/Normal/Patient)", "bSpeculativeTurn"] },
{ title: "Tools", items: ["Tools[] array of Tool assets", "ActionSet integration"] },
];
configCats.forEach((c, i) => {
const col = i % 3;
const row = Math.floor(i / 3);
const x = 0.5 + col * 4.15;
const y = 2.2 + row * 2.15;
s.addShape(pres.ShapeType.roundRect, { x: x, y: y, w: 3.95, h: 1.95, fill: { color: LIGHT_GREY }, line: { color: NAVY, width: 1 }, rectRadius: 0.08 });
s.addText(c.title, { x: x + 0.15, y: y + 0.1, w: 3.65, h: 0.35, fontSize: 14, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: x + 0.15, y: y + 0.45, w: 0.35, h: 0.04, fill: { color: ACCENT } });
s.addText(c.items.map(t => ({ text: t, options: { bullet: true, fontSize: 11 } })),
{ x: x + 0.15, y: y + 0.55, w: 3.65, h: 1.3, color: GREY, fontFace: FONT_B, paraSpaceAfter: 2 });
});
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 6.55, w: 12.3, h: 0.45, fill: { color: NAVY }, rectRadius: 0.05 });
s.addText("Editor: Voice picker • Model picker • LLM picker • Create / Update / Fetch Agent buttons", { x: 0.7, y: 6.6, w: 12, h: 0.35, fontSize: 11, color: ICE, fontFace: FONT_B, italic: true, bold: true });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 13 — POSE MAPS
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Pose Maps", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Three pose-map data assets map abstract states → AnimSequences.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
// Emotion
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 2.15, w: 4.0, h: 4.5, fill: { color: NAVY }, rectRadius: 0.1 });
s.addText("EmotionPoseMap", { x: 0.6, y: 2.3, w: 3.8, h: 0.4, fontSize: 17, bold: true, color: ACCENT, fontFace: FONT_H });
s.addText("Emotion → facial AnimSequences\n(3 intensity levels per emotion)", { x: 0.6, y: 2.7, w: 3.8, h: 0.8, fontSize: 11, color: ICE, italic: true, fontFace: FONT_B });
s.addText([
{ text: "Joy (N / M / E)", options: { bullet: true, fontSize: 11 } },
{ text: "Sadness (N / M / E)", options: { bullet: true, fontSize: 11 } },
{ text: "Anger (N / M / E)", options: { bullet: true, fontSize: 11 } },
{ text: "Fear (N / M / E)", options: { bullet: true, fontSize: 11 } },
{ text: "Surprise (N / M / E)", options: { bullet: true, fontSize: 11 } },
{ text: "Disgust (N / M / E)", options: { bullet: true, fontSize: 11 } },
{ text: "Neutral (N / M / E)", options: { bullet: true, fontSize: 11 } },
], { x: 0.7, y: 3.7, w: 3.7, h: 2.8, color: ICE, fontFace: FONT_B, paraSpaceAfter: 3 });
// LipSync
s.addShape(pres.ShapeType.roundRect, { x: 4.7, y: 2.15, w: 4.0, h: 4.5, fill: { color: NAVY }, rectRadius: 0.1 });
s.addText("LipSyncPoseMap", { x: 4.8, y: 2.3, w: 3.8, h: 0.4, fontSize: 17, bold: true, color: ACCENT, fontFace: FONT_H });
s.addText("15 OVR visemes →\nphoneme AnimSequences", { x: 4.8, y: 2.7, w: 3.8, h: 0.7, fontSize: 11, color: ICE, italic: true, fontFace: FONT_B });
s.addText([
{ text: "sil • PP • FF • TH • DD", options: { bullet: true, fontSize: 11 } },
{ text: "kk • CH • SS • nn • RR", options: { bullet: true, fontSize: 11 } },
{ text: "aa • E • ih • oh • ou", options: { bullet: true, fontSize: 11 } },
{ text: "MetaHuman MHF_* recommended", options: { bullet: true, fontSize: 11 } },
{ text: "Shareable across characters", options: { bullet: true, fontSize: 11 } },
], { x: 4.9, y: 3.5, w: 3.7, h: 2.9, color: ICE, fontFace: FONT_B, paraSpaceAfter: 4 });
// Body
s.addShape(pres.ShapeType.roundRect, { x: 8.9, y: 2.15, w: 4.0, h: 4.5, fill: { color: NAVY }, rectRadius: 0.1 });
s.addText("BodyPoseMap", { x: 9.0, y: 2.3, w: 3.8, h: 0.4, fontSize: 17, bold: true, color: ACCENT, fontFace: FONT_H });
s.addText("Emotion → body anim lists\n(Idle / N / M / E per emotion)", { x: 9.0, y: 2.7, w: 3.8, h: 0.8, fontSize: 11, color: ICE, italic: true, fontFace: FONT_B });
s.addText([
{ text: "Idle: listening animations", options: { bullet: true, fontSize: 11 } },
{ text: "Normal: light speaking", options: { bullet: true, fontSize: 11 } },
{ text: "Medium: expressive speaking", options: { bullet: true, fontSize: 11 } },
{ text: "Extreme: emphatic speaking", options: { bullet: true, fontSize: 11 } },
{ text: "Random selection per loop", options: { bullet: true, fontSize: 11 } },
{ text: "Continuous variety", options: { bullet: true, fontSize: 11 } },
], { x: 9.0, y: 3.7, w: 3.8, h: 2.8, color: ICE, fontFace: FONT_B, paraSpaceAfter: 3 });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 14 — TOOLS & ACTIONSETS
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Tools & ActionSets", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("LLM-triggered actions that bridge conversation → gameplay.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
s.addText("Tool asset", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "ToolName — snake_case identifier", options: { bullet: true, fontSize: 13 } },
{ text: "ToolDescription — when LLM should use it", options: { bullet: true, fontSize: 13 } },
{ text: "PromptFragment — appended system prompt", options: { bullet: true, fontSize: 13 } },
{ text: "Parameters[] — typed LLM arguments", options: { bullet: true, fontSize: 13 } },
{ text: "ToolID auto-populated on Create", options: { bullet: true, fontSize: 13 } },
], { x: 0.5, y: 2.55, w: 6, h: 2.8, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("Built-in Tools", { x: 7, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "set_emotion(emotion, intensity)", options: { bullet: true, fontSize: 13 } },
{ text: "perform_action(action) — from ActionSet", options: { bullet: true, fontSize: 13 } },
{ text: "Custom tools — any parameters", options: { bullet: true, fontSize: 13 } },
], { x: 7, y: 2.55, w: 6, h: 2, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 5.4, w: 12.3, h: 1.6, fill: { color: LIGHT_GREY }, line: { color: NAVY, width: 1 }, rectRadius: 0.08 });
s.addText("Action flow", { x: 0.7, y: 5.5, w: 12, h: 0.3, fontSize: 14, bold: true, color: NAVY, fontFace: FONT_H });
s.addText("1. LLM decides to call a tool (e.g. perform_action(\"flee\"))\n2. Plugin fires OnAgentActionRequested(\"flee\")\n3. Game calls ForceDisableConversation(\"flee\") → animations blend to neutral\n4. OnReadyForAction(\"flee\") fires → play gameplay montage\n5. Game calls ForceEnableConversation() when done", { x: 0.7, y: 5.85, w: 12, h: 1.1, fontSize: 11, color: GREY, fontFace: "Consolas" });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 15 — ANIMGRAPH INTEGRATION
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("AnimGraph Integration", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 34, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Four AnimNodes auto-discover components — no manual wiring.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
// Face AnimBP chain
s.addText("Face AnimBP", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
const faceChain = [
{ label: "Live Link", color: ICE_DEEP },
{ label: "Facial\nExpression", color: "E88B3E" },
{ label: "Gaze\n(eyes only)", color: "5E7AC7" },
{ label: "Lip Sync", color: "3EB086" },
{ label: "mh_arkit\n_mapping", color: GREY },
];
faceChain.forEach((n, i) => {
const x = 0.5 + i * 2.55;
s.addShape(pres.ShapeType.roundRect, { x: x, y: 2.65, w: 2.3, h: 1.0, fill: { color: n.color }, rectRadius: 0.1 });
s.addText(n.label, { x: x, y: 2.75, w: 2.3, h: 0.8, fontSize: 12, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: FONT_H });
});
// Body AnimBP chain
s.addText("Body AnimBP", { x: 0.5, y: 4.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
const bodyChain = [
{ label: "Locomotion\n/ Idle", color: ICE_DEEP },
{ label: "Gaze\n(head rot)", color: "5E7AC7" },
{ label: "Body\nExpression", color: "B85042" },
{ label: "Output Pose", color: GREY },
];
bodyChain.forEach((n, i) => {
const x = 0.5 + i * 3.2;
s.addShape(pres.ShapeType.roundRect, { x: x, y: 4.65, w: 2.9, h: 1.0, fill: { color: n.color }, rectRadius: 0.1 });
s.addText(n.label, { x: x, y: 4.75, w: 2.9, h: 0.8, fontSize: 12, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: FONT_H });
});
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: 6.15, w: 12.3, h: 0.85, fill: { color: NAVY }, rectRadius: 0.08 });
s.addText("Why this order?", { x: 0.7, y: 6.2, w: 12, h: 0.3, fontSize: 13, bold: true, color: ACCENT, fontFace: FONT_H });
s.addText("Emotion curves set mood (brows, eyes, cheeks). Lip sync overrides mouth during speech. Gaze head rotation runs before body expression so it doesn't fight arms.", { x: 0.7, y: 6.55, w: 12, h: 0.4, fontSize: 11, color: ICE, italic: true, fontFace: FONT_B });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 16 — PROJECT SETTINGS
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Project Settings", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Project Settings → Plugins → PS AI ConvAgent - ElevenLabs", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
const settings = [
{ name: "API_Key", desc: "ElevenLabs API key — required for authentication" },
{ name: "ServerRegion", desc: "Global / US / EU / India" },
{ name: "CustomWebSocketURL", desc: "Override WebSocket URL (advanced)" },
{ name: "bVerboseLogging", desc: "Verbose WebSocket logging (development)" },
{ name: "GlobalContextPrompt", desc: "Shared context injected as dynamic variables for all agents" },
];
settings.forEach((setting, i) => {
const y = 2.3 + i * 0.85;
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: y, w: 3.5, h: 0.65, fill: { color: NAVY }, rectRadius: 0.05 });
s.addText(setting.name, { x: 0.5, y: y, w: 3.5, h: 0.65, fontSize: 13, bold: true, color: ACCENT, align: "center", valign: "middle", fontFace: "Consolas" });
s.addText(setting.desc, { x: 4.2, y: y + 0.05, w: 8.6, h: 0.55, fontSize: 12, color: GREY, valign: "middle", fontFace: FONT_B });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 17 — SETUP WORKFLOW
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Setup Workflow", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("From zero to a talking MetaHuman in 5 steps.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
const steps = [
{ num: "1", title: "Project Settings", desc: "Enter API_Key, pick ServerRegion." },
{ num: "2", title: "Data Assets", desc: "Create AgentConfig, EmotionPoseMap, LipSyncPoseMap, BodyPoseMap." },
{ num: "3", title: "NPC Components", desc: "Add ElevenLabs + Mic + LipSync + FacialExpr + BodyExpr + Gaze on the MetaHuman." },
{ num: "4", title: "AnimGraphs", desc: "Wire AnimNodes in Face + Body AnimBPs. Components auto-discover." },
{ num: "5", title: "Player Pawn", desc: "Add InteractionComponent. Bind selection events to UI. Play." },
];
steps.forEach((step, i) => {
const y = 2.25 + i * 0.9;
s.addShape(pres.ShapeType.ellipse, { x: 0.5, y: y, w: 0.8, h: 0.8, fill: { color: ACCENT } });
s.addText(step.num, { x: 0.5, y: y, w: 0.8, h: 0.8, fontSize: 28, bold: true, color: NAVY, align: "center", valign: "middle", fontFace: FONT_H });
s.addShape(pres.ShapeType.roundRect, { x: 1.6, y: y, w: 11.2, h: 0.8, fill: { color: LIGHT_GREY }, line: { color: NAVY, width: 1 }, rectRadius: 0.05 });
s.addText(step.title, { x: 1.8, y: y + 0.1, w: 3.5, h: 0.3, fontSize: 14, bold: true, color: NAVY, fontFace: FONT_H });
s.addText(step.desc, { x: 1.8, y: y + 0.4, w: 11, h: 0.35, fontSize: 11, color: GREY, italic: true, fontFace: FONT_B });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 18 — NETWORKING
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Networking & Multiplayer", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 34, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Listen Server model — server owns WebSocket, clients relay via RPCs.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
s.addText("Audio Pipeline", { x: 0.5, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "Client mic → Server RPC (Opus-encoded)", options: { bullet: true, fontSize: 13 } },
{ text: "Server forwards to ElevenLabs WebSocket", options: { bullet: true, fontSize: 13 } },
{ text: "Agent audio → Multicast RPC to all clients", options: { bullet: true, fontSize: 13 } },
{ text: "Clients decode Opus → procedural sound wave", options: { bullet: true, fontSize: 13 } },
], { x: 0.5, y: 2.55, w: 6, h: 2.4, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("Speaker Arbitration", { x: 7, y: 2.15, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "SpeakerSwitchHysteresis — min silence before switching", options: { bullet: true, fontSize: 13 } },
{ text: "SpeakerIdleTimeout — clear active speaker", options: { bullet: true, fontSize: 13 } },
{ text: "NetActiveSpeakerPawn replicated for gaze", options: { bullet: true, fontSize: 13 } },
{ text: "Only active speaker's audio forwarded to LLM", options: { bullet: true, fontSize: 13 } },
], { x: 7, y: 2.55, w: 6, h: 2.4, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("Level of Detail (LOD)", { x: 0.5, y: 5.1, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "AudioLODCullDistance — skip audio at distance", options: { bullet: true, fontSize: 13 } },
{ text: "LipSyncLODDistance — skip lip sync at distance", options: { bullet: true, fontSize: 13 } },
{ text: "Active speaker always full quality", options: { bullet: true, fontSize: 13 } },
], { x: 0.5, y: 5.5, w: 6, h: 1.5, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
s.addText("Replicated State", { x: 7, y: 5.1, w: 6, h: 0.4, fontSize: 16, bold: true, color: NAVY, fontFace: FONT_H });
s.addText([
{ text: "bNetIsConversing", options: { bullet: true, fontSize: 13 } },
{ text: "NetConnectedPawns[]", options: { bullet: true, fontSize: 13 } },
{ text: "CurrentEmotion + CurrentEmotionIntensity", options: { bullet: true, fontSize: 13 } },
], { x: 7, y: 5.5, w: 6, h: 1.5, color: GREY, fontFace: FONT_B, paraSpaceAfter: 5 });
// ═══════════════════════════════════════════════════════════════════
// SLIDE 19 — FORCE DISABLE FLOW
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("ForceDisableConversation Flow", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 30, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Clean handoff between conversation and gameplay actions.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
const flow = [
{ step: "LLM Tool Call", desc: "perform_action(\"flee\")" },
{ step: "OnAgentActionRequested", desc: "Event fires on ElevenLabs component" },
{ step: "ForceDisableConversation", desc: "Blend out face/body/gaze animations" },
{ step: "OnReadyForAction", desc: "All components have reached neutral" },
{ step: "Play Gameplay", desc: "Montage / behavior switch" },
{ step: "ForceEnableConversation", desc: "Resume conversation when done" },
];
flow.forEach((f, i) => {
const y = 2.2 + i * 0.75;
s.addShape(pres.ShapeType.roundRect, { x: 0.5, y: y, w: 4, h: 0.6, fill: { color: NAVY }, rectRadius: 0.08 });
s.addText(f.step, { x: 0.5, y: y, w: 4, h: 0.6, fontSize: 13, bold: true, color: WHITE, align: "center", valign: "middle", fontFace: "Consolas" });
s.addText("→", { x: 4.6, y: y + 0.05, w: 0.5, h: 0.5, fontSize: 22, color: ACCENT, align: "center", valign: "middle", bold: true, fontFace: FONT_H });
s.addText(f.desc, { x: 5.2, y: y + 0.05, w: 7.6, h: 0.5, fontSize: 13, color: GREY, italic: true, valign: "middle", fontFace: FONT_B });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 20 — EVENTS CHEAT SHEET
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide({ masterName: "CONTENT" });
s.addText("Events Cheat Sheet", { x: 0.5, y: 0.6, w: 12, h: 0.8, fontSize: 36, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: 0.5, y: 1.45, w: 0.6, h: 0.06, fill: { color: ACCENT } });
s.addText("Blueprint-assignable delegates on ElevenLabs component.", { x: 0.5, y: 1.65, w: 12, h: 0.4, fontSize: 14, color: GREY, italic: true, fontFace: FONT_B });
const eventCols = [
{
title: "Connection",
items: ["OnAgentConnected", "OnAgentDisconnected", "OnAgentError"]
},
{
title: "Dialogue",
items: ["OnAgentTranscript", "OnAgentTextResponse", "OnAgentPartialResponse", "OnAgentStartedGenerating"]
},
{
title: "Audio",
items: ["OnAgentStartedSpeaking", "OnAudioPlaybackStarted", "OnAgentStoppedSpeaking", "OnAgentInterrupted"]
},
{
title: "State / Actions",
items: ["OnAgentEmotionChanged", "OnAgentActionRequested", "OnAgentClientToolCall", "OnActiveSpeakerChanged", "OnReadyForAction", "OnAgentResponseTimeout"]
},
];
eventCols.forEach((c, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const x = 0.5 + col * 6.3;
const y = 2.15 + row * 2.45;
s.addShape(pres.ShapeType.roundRect, { x: x, y: y, w: 6.0, h: 2.25, fill: { color: LIGHT_GREY }, line: { color: NAVY, width: 1 }, rectRadius: 0.08 });
s.addText(c.title, { x: x + 0.2, y: y + 0.1, w: 5.6, h: 0.3, fontSize: 14, bold: true, color: NAVY, fontFace: FONT_H });
s.addShape(pres.ShapeType.rect, { x: x + 0.2, y: y + 0.42, w: 0.35, h: 0.04, fill: { color: ACCENT } });
s.addText(c.items.map(t => ({ text: t, options: { bullet: true, fontSize: 11 } })),
{ x: x + 0.2, y: y + 0.52, w: 5.6, h: 1.7, color: GREY, fontFace: "Consolas", paraSpaceAfter: 2 });
});
// ═══════════════════════════════════════════════════════════════════
// SLIDE 21 — CLOSING / SUMMARY
// ═══════════════════════════════════════════════════════════════════
s = pres.addSlide();
s.background = { color: NAVY };
s.addShape(pres.ShapeType.rect, { x: 0, y: 2.5, w: 13.333, h: 0.05, fill: { color: ACCENT } });
s.addText("Summary", { x: 0.6, y: 0.8, w: 12, h: 1.2, fontSize: 54, bold: true, color: WHITE, fontFace: FONT_H });
s.addText("A complete conversational AI stack for Unreal Engine.", { x: 0.6, y: 2.0, w: 12, h: 0.5, fontSize: 18, color: ICE, italic: true, fontFace: FONT_B });
const summary = [
{ h: "Real-time", t: "Spectral lip sync, speculative TTS, adaptive pre-buffering" },
{ h: "Expressive", t: "Emotion-driven face/body animation via AnimSequences" },
{ h: "Multiplayer", t: "Listen Server + Opus codec + distance LOD + speaker arbitration" },
{ h: "Data-driven", t: "Reusable PoseMaps, AgentConfigs, Tools, ActionSets" },
{ h: "Bridgeable", t: "Behavior plugin gaze bridge, ForceDisable handoff for gameplay" },
{ h: "BP-friendly", t: "All components blueprintable, rich event system" },
];
summary.forEach((item, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const x = 0.6 + col * 6.2;
const y = 3.0 + row * 1.4;
s.addShape(pres.ShapeType.roundRect, { x: x, y: y, w: 5.9, h: 1.2, fill: { color: NAVY_DARK }, line: { color: ACCENT, width: 1 }, rectRadius: 0.1 });
s.addText(item.h, { x: x + 0.2, y: y + 0.1, w: 5.5, h: 0.4, fontSize: 18, bold: true, color: ACCENT, fontFace: FONT_H });
s.addText(item.t, { x: x + 0.2, y: y + 0.55, w: 5.5, h: 0.6, fontSize: 12, color: ICE, italic: true, fontFace: FONT_B });
});
s.addText("ASTERION • PS_AI_ConvAgent • 2026", { x: 0.6, y: 6.9, w: 12, h: 0.4, fontSize: 12, color: ICE_DEEP, fontFace: FONT_B });
// ═══════════════════════════════════════════════════════════════════
// SAVE
// ═══════════════════════════════════════════════════════════════════
pres.writeFile({ fileName: "PS_AI_ConvAgent_Documentation.pptx" })
.then(name => console.log(`Generated: ${name}`))
.catch(err => { console.error(err); process.exit(1); });

Binary file not shown.

View File

@@ -1,359 +0,0 @@
seem to less stuck but timeout often : LogDebuggerCommands: Repeating last play command: Selected Viewport
LogPlayLevel: PlayLevel: No blueprints needed recompiling
LogPlayLevel: Creating play world package: /Game/UEDPIE_0_TestMap
LogPlayLevel: PIE: StaticDuplicateObject took: (0.005478s)
LogPlayLevel: PIE: Created PIE world by copying editor world from /Game/TestMap.TestMap to /Game/UEDPIE_0_TestMap.TestMap (0.005520s)
LogUObjectHash: Compacting FUObjectHashTables data took 0.64ms
LogChaosDD: Creating Chaos Debug Draw Scene for world TestMap
LogPlayLevel: PIE: World Init took: (0.001821s)
LogAudio: Display: Creating Audio Device: Id: 4, Scope: Unique, Realtime: True
LogAudioMixer: Display: Audio Mixer Platform Settings:
LogAudioMixer: Display: Sample Rate: 48000
LogAudioMixer: Display: Callback Buffer Frame Size Requested: 1024
LogAudioMixer: Display: Callback Buffer Frame Size To Use: 1024
LogAudioMixer: Display: Number of buffers to queue: 1
LogAudioMixer: Display: Max Channels (voices): 32
LogAudioMixer: Display: Number of Async Source Workers: 4
LogAudio: Display: AudioDevice MaxSources: 32
LogAudio: Display: Audio Spatialization Plugin: None (built-in).
LogAudio: Display: Audio Reverb Plugin: None (built-in).
LogAudio: Display: Audio Occlusion Plugin: None (built-in).
LogAudioMixer: Display: Initializing audio mixer using platform API: 'XAudio2'
LogAudioMixer: Display: Using Audio Hardware Device Speakers (Realtek(R) Audio)
LogAudioMixer: Display: Initializing Sound Submixes...
LogAudioMixer: Display: Creating Master Submix 'MasterSubmixDefault'
LogAudioMixer: Display: Creating Master Submix 'MasterReverbSubmixDefault'
LogAudioMixer: FMixerPlatformXAudio2::StartAudioStream() called. InstanceID=4
LogAudioMixer: Display: Output buffers initialized: Frames=1024, Channels=2, Samples=2048, InstanceID=4
LogAudioMixer: Display: Starting AudioMixerPlatformInterface::RunInternal(), InstanceID=4
LogAudioMixer: Display: FMixerPlatformXAudio2::SubmitBuffer() called for the first time. InstanceID=4
LogInit: FAudioDevice initialized with ID 4.
LogAudio: Display: Audio Device (ID: 4) registered with world 'TestMap'.
LogAudioMixer: Initializing Audio Bus Subsystem for audio device with ID 4
LogLoad: Game class is 'GameModeBase'
LogWorld: Bringing World /Game/UEDPIE_0_TestMap.TestMap up for play (max tick rate 60) at 2026.02.20-17.38.08
LogWorld: Bringing up level for play took: 0.000919
LogOnline: OSS: Created online subsystem instance for: :Context_3
LogElevenLabsWS: Connecting to ElevenLabs: wss://api.elevenlabs.io/v1/convai/conversation?agent_id=agent_5301kc1qkq49fn2av43nrbsar65k
PIE: Server logged in
PIE: Play in editor total start time 0,075 seconds.
LogElevenLabsWS: WebSocket connected. Sending conversation_initiation_client_data...
LogElevenLabsWS: Sending initiation: {
"type": "conversation_initiation_client_data",
"conversation_config_override":
{
"agent":
{
"turn":
{
"turn_timeout": 1
}
},
"tts":
{
"optimize_streaming_latency": 3
}
},
"custom_llm_extra_body":
{
"enable_intermediate_response": true
}
}
LogElevenLabsWS: Received message type: conversation_initiation_metadata
LogElevenLabsWS: [T+0.00s] Conversation initiated. ID=conv_2901khxymx7xed88dq8tw35b2pfk
LogElevenLabsAgent: [T+0.00s] Agent connected. ConversationID=conv_2901khxymx7xed88dq8tw35b2pfk
LogBlueprintUserMessages: [test_AI_Actor_C_2] Connected
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Listening
LogElevenLabsWS: [T+2.18s] User turn started — mic open, audio chunks will follow.
LogAudioCaptureCore: Display: WasapiCapture AudioFormat SampeRate: 48000, BitDepth: 32-Bit Floating Point
LogElevenLabsMic: Capture device: Microphone Array (Intel® Smart Sound Technology for Digital Microphones) | Rate=48000 | Channels=2
LogElevenLabsMic: Audio capture started.
LogElevenLabsAgent: [T+2.18s] [Turn 1] Mic opened — user speaking.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Listening
LogElevenLabsMic: Audio capture stopped.
LogElevenLabsWS: [T+4.60s] User turn ended — server VAD silence detection started (turn_timeout=1s).
LogElevenLabsAgent: [T+4.60s] [Turn 1] Mic closed — user spoke 2.42s. Waiting for server response (timeout 10s)...
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: [T+5.66s] Agent started generating (1061 ms after turn end — includes VAD silence timeout + LLM start).
LogElevenLabsAgent: [T+5.66s] [Turn 1] Agent generating. (1.06s after turn end)
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Agent Start Generating
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Warning: [T+7.95s] [LATENCY] First audio: 3346 ms after turn end (3346 ms after last chunk)
LogElevenLabsAgent: [T+7.95s] [Turn 1] Agent speaking — first audio chunk. (3.35s after turn end)
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Speaking
LogElevenLabsWS: Received message type: user_transcript
LogElevenLabsWS: Warning: [T+7.95s] [LATENCY] User transcript: 3346 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Transcript : Hello, how are you?
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Received message type: agent_response
LogElevenLabsWS: Warning: [T+9.60s] [LATENCY] Agent text response: 4994 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Text Response : Hello! I'm doing well, thank you for asking. How can I assist you today?
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Speaking
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Listening
LogElevenLabsWS: [T+18.84s] User turn started — mic open, audio chunks will follow.
LogAudioCaptureCore: Display: WasapiCapture AudioFormat SampeRate: 48000, BitDepth: 32-Bit Floating Point
LogElevenLabsMic: Capture device: Microphone Array (Intel® Smart Sound Technology for Digital Microphones) | Rate=48000 | Channels=2
LogElevenLabsMic: Audio capture started.
LogElevenLabsAgent: [T+18.84s] [Turn 2] Mic opened — user speaking.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Listening
LogElevenLabsMic: Audio capture stopped.
LogElevenLabsWS: [T+21.67s] User turn ended — server VAD silence detection started (turn_timeout=1s).
LogElevenLabsAgent: [T+21.67s] [Turn 2] Mic closed — user spoke 2.83s. Waiting for server response (timeout 10s)...
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: [T+22.45s] Agent started generating (777 ms after turn end — includes VAD silence timeout + LLM start).
LogElevenLabsAgent: [T+22.45s] [Turn 2] Agent generating. (0.78s after turn end)
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Agent Start Generating
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Warning: [T+23.85s] [LATENCY] First audio: 2180 ms after turn end (2180 ms after last chunk)
LogElevenLabsAgent: [T+23.85s] [Turn 2] Agent speaking — first audio chunk. (2.18s after turn end)
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Speaking
LogElevenLabsWS: Received message type: user_transcript
LogElevenLabsWS: Warning: [T+23.90s] [LATENCY] User transcript: 2227 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Transcript : Nothing special, just talking.
LogElevenLabsAgent: Warning: [Turn 2] Agent silence hard-timeout (2s) without agent_response — declaring agent stopped.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Speaking
LogElevenLabsWS: Received message type: audio
LogElevenLabsAgent: [T+28.55s] [Turn 2] Agent speaking — first audio chunk. (6.88s after turn end)
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Speaking
LogElevenLabsWS: Received message type: agent_response
LogElevenLabsWS: Warning: [T+28.58s] [LATENCY] Agent text response: 6910 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Text Response : That's perfectly fine! I'm here to chat if you have any questions or just want to talk. What's on your mind?
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Speaking
LogEOSSDK: LogEOS: Updating Product SDK Config, Time: 347.575653
LogEOSSDK: LogEOS: SDK Config Product Update Request Completed - No Change
LogEOSSDK: LogEOS: ScheduleNextSDKConfigDataUpdate - Time: 347.774902, Update Interval: 340.359497
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Listening
LogElevenLabsWS: [T+41.83s] User turn started — mic open, audio chunks will follow.
LogAudioCaptureCore: Display: WasapiCapture AudioFormat SampeRate: 48000, BitDepth: 32-Bit Floating Point
LogElevenLabsMic: Capture device: Microphone Array (Intel® Smart Sound Technology for Digital Microphones) | Rate=48000 | Channels=2
LogElevenLabsMic: Audio capture started.
LogElevenLabsAgent: [T+41.83s] [Turn 3] Mic opened — user speaking.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Listening
LogElevenLabsMic: Audio capture stopped.
LogElevenLabsWS: [T+44.86s] User turn ended — server VAD silence detection started (turn_timeout=1s).
LogElevenLabsAgent: [T+44.86s] [Turn 3] Mic closed — user spoke 3.03s. Waiting for server response (timeout 10s)...
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: [T+45.20s] Agent started generating (343 ms after turn end — includes VAD silence timeout + LLM start).
LogElevenLabsAgent: [T+45.20s] [Turn 3] Agent generating. (0.34s after turn end)
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Agent Start Generating
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Warning: [T+48.22s] [LATENCY] First audio: 3361 ms after turn end (3361 ms after last chunk)
LogElevenLabsAgent: [T+48.22s] [Turn 3] Agent speaking — first audio chunk. (3.36s after turn end)
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Speaking
LogElevenLabsWS: Received message type: user_transcript
LogElevenLabsWS: Warning: [T+48.22s] [LATENCY] User transcript: 3361 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Transcript : I wanna... Hi agent!
LogElevenLabsWS: Received message type: agent_response
LogElevenLabsWS: Warning: [T+48.25s] [LATENCY] Agent text response: 3393 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Text Response : Hello! It's good to hear from you. How can I help you today?
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Speaking
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Listening
LogElevenLabsWS: [T+57.55s] User turn started — mic open, audio chunks will follow.
LogAudioCaptureCore: Display: WasapiCapture AudioFormat SampeRate: 48000, BitDepth: 32-Bit Floating Point
LogElevenLabsMic: Capture device: Microphone Array (Intel® Smart Sound Technology for Digital Microphones) | Rate=48000 | Channels=2
LogElevenLabsMic: Audio capture started.
LogElevenLabsAgent: [T+57.55s] [Turn 4] Mic opened — user speaking.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Listening
LogElevenLabsMic: Audio capture stopped.
LogElevenLabsWS: [T+60.33s] User turn ended — server VAD silence detection started (turn_timeout=1s).
LogElevenLabsAgent: [T+60.33s] [Turn 4] Mic closed — user spoke 2.78s. Waiting for server response (timeout 10s)...
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: [T+60.39s] Agent started generating (61 ms after turn end — includes VAD silence timeout + LLM start).
LogElevenLabsAgent: [T+60.39s] [Turn 4] Agent generating. (0.06s after turn end)
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Agent Start Generating
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Warning: [T+62.44s] [LATENCY] First audio: 2111 ms after turn end (2111 ms after last chunk)
LogElevenLabsAgent: [T+62.44s] [Turn 4] Agent speaking — first audio chunk. (2.11s after turn end)
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Speaking
LogElevenLabsWS: Received message type: user_transcript
LogElevenLabsWS: Warning: [T+62.44s] [LATENCY] User transcript: 2112 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Transcript : Do you speak French?
LogElevenLabsAgent: Warning: [Turn 4] Agent silence hard-timeout (2s) without agent_response — declaring agent stopped.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Speaking
LogElevenLabsWS: Received message type: audio
LogElevenLabsAgent: [T+66.24s] [Turn 4] Agent speaking — first audio chunk. (5.91s after turn end)
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Speaking
LogElevenLabsWS: Received message type: agent_response
LogElevenLabsWS: Warning: [T+66.27s] [LATENCY] Agent text response: 5944 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Text Response : Yes, I can communicate in French. Would you like to ask me something in French or perhaps practice a bit?
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Speaking
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Listening
LogElevenLabsWS: [T+74.14s] User turn started — mic open, audio chunks will follow.
LogAudioCaptureCore: Display: WasapiCapture AudioFormat SampeRate: 48000, BitDepth: 32-Bit Floating Point
LogElevenLabsMic: Capture device: Microphone Array (Intel® Smart Sound Technology for Digital Microphones) | Rate=48000 | Channels=2
LogElevenLabsMic: Audio capture started.
LogElevenLabsAgent: [T+74.14s] [Turn 5] Mic opened — user speaking.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Listening
LogElevenLabsMic: Audio capture stopped.
LogElevenLabsWS: [T+76.88s] User turn ended — server VAD silence detection started (turn_timeout=1s).
LogElevenLabsAgent: [T+76.88s] [Turn 5] Mic closed — user spoke 2.75s. Waiting for server response (timeout 10s)...
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: [T+77.28s] Agent started generating (393 ms after turn end — includes VAD silence timeout + LLM start).
LogElevenLabsAgent: [T+77.28s] [Turn 5] Agent generating. (0.39s after turn end)
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Agent Start Generating
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Warning: [T+78.31s] [LATENCY] First audio: 1428 ms after turn end (1428 ms after last chunk)
LogElevenLabsAgent: [T+78.31s] [Turn 5] Agent speaking — first audio chunk. (1.43s after turn end)
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Speaking
LogElevenLabsWS: Received message type: user_transcript
LogElevenLabsWS: Warning: [T+78.34s] [LATENCY] User transcript: 1460 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Transcript : Yes, tell me something in French.
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Received message type: audio
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Listening
LogElevenLabsAgent: StartListening: interrupting agent (speaking) to allow user to speak.
LogElevenLabsWS: Sending interrupt — ignoring incoming content until server acks.
LogElevenLabsAgent: [T+84.24s] [Turn 5] Agent stopped speaking (spoke 5.93s, full turn round-trip 7.36s).
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Speaking
LogElevenLabsWS: [T+84.24s] User turn started — mic open, audio chunks will follow.
LogAudioCaptureCore: Display: WasapiCapture AudioFormat SampeRate: 48000, BitDepth: 32-Bit Floating Point
LogElevenLabsMic: Capture device: Microphone Array (Intel® Smart Sound Technology for Digital Microphones) | Rate=48000 | Channels=2
LogElevenLabsMic: Audio capture started.
LogElevenLabsAgent: [T+84.24s] [Turn 6] Mic opened — user speaking.
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Received message type: agent_response
LogElevenLabsWS: Warning: [T+84.43s] [LATENCY] Agent text response: 7549 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Text Response : Bien sûr! Je peux vous dire: "Bonjour, comment allez-vous aujourd'hui?" This means "Hello, how are you today?"
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Listening
LogElevenLabsMic: Audio capture stopped.
LogElevenLabsWS: [T+88.56s] User turn ended — server VAD silence detection started (turn_timeout=1s).
LogElevenLabsAgent: [T+88.56s] [Turn 6] Mic closed — user spoke 4.32s. Waiting for server response (timeout 10s)...
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Warning: [T+92.37s] [LATENCY] First audio: 3811 ms after turn end (3811 ms after last chunk)
LogElevenLabsWS: Received message type: user_transcript
LogElevenLabsWS: Warning: [T+92.37s] [LATENCY] User transcript: 3811 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Transcript : Mais c'est super! Tu parles très bien!
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Received message type: agent_response
LogElevenLabsWS: Warning: [T+97.23s] [LATENCY] Agent text response: 8677 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Text Response : Merci beaucoup! Je suis ravie que vous le pensiez. Y a-t-il autre chose que vous aimeriez savoir ou discuter en français?
LogElevenLabsAgent: Warning: [T+98.56s] [Turn 6] Response timeout — server did not start generating after 10.0s. Firing OnAgentResponseTimeout.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Listening
LogElevenLabsWS: [T+110.60s] User turn started — mic open, audio chunks will follow.
LogAudioCaptureCore: Display: WasapiCapture AudioFormat SampeRate: 48000, BitDepth: 32-Bit Floating Point
LogElevenLabsMic: Capture device: Microphone Array (Intel® Smart Sound Technology for Digital Microphones) | Rate=48000 | Channels=2
LogElevenLabsMic: Audio capture started.
LogElevenLabsAgent: [T+110.60s] [Turn 7] Mic opened — user speaking.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Listening
LogElevenLabsMic: Audio capture stopped.
LogElevenLabsWS: [T+113.82s] User turn ended — server VAD silence detection started (turn_timeout=1s).
LogElevenLabsAgent: [T+113.82s] [Turn 7] Mic closed — user spoke 3.22s. Waiting for server response (timeout 10s)...
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Warning: [T+119.37s] [LATENCY] First audio: 5546 ms after turn end (5546 ms after last chunk)
LogElevenLabsWS: Received message type: user_transcript
LogElevenLabsWS: Warning: [T+119.37s] [LATENCY] User transcript: 5546 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Transcript : And do you speak German?
LogElevenLabsWS: Received message type: agent_response
LogElevenLabsWS: Warning: [T+119.40s] [LATENCY] Agent text response: 5577 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Text Response : Yes, I can also communicate in German. Would you like me to say something in German for you?
LogElevenLabsAgent: Warning: [T+123.83s] [Turn 7] Response timeout — server did not start generating after 10.0s. Firing OnAgentResponseTimeout.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Listening
LogElevenLabsWS: [T+131.53s] User turn started — mic open, audio chunks will follow.
LogAudioCaptureCore: Display: WasapiCapture AudioFormat SampeRate: 48000, BitDepth: 32-Bit Floating Point
LogElevenLabsMic: Capture device: Microphone Array (Intel® Smart Sound Technology for Digital Microphones) | Rate=48000 | Channels=2
LogElevenLabsMic: Audio capture started.
LogElevenLabsAgent: [T+131.53s] [Turn 8] Mic opened — user speaking.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Listening
LogElevenLabsMic: Audio capture stopped.
LogElevenLabsWS: [T+134.42s] User turn ended — server VAD silence detection started (turn_timeout=1s).
LogElevenLabsAgent: [T+134.42s] [Turn 8] Mic closed — user spoke 2.88s. Waiting for server response (timeout 10s)...
LogElevenLabsAgent: Warning: [T+144.42s] [Turn 8] Response timeout — server did not start generating after 10.0s. Firing OnAgentResponseTimeout.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Listening
LogElevenLabsWS: [T+148.56s] User turn started — mic open, audio chunks will follow.
LogAudioCaptureCore: Display: WasapiCapture AudioFormat SampeRate: 48000, BitDepth: 32-Bit Floating Point
LogElevenLabsMic: Capture device: Microphone Array (Intel® Smart Sound Technology for Digital Microphones) | Rate=48000 | Channels=2
LogElevenLabsMic: Audio capture started.
LogElevenLabsAgent: [T+148.56s] [Turn 9] Mic opened — user speaking.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Listening
LogElevenLabsMic: Audio capture stopped.
LogElevenLabsWS: [T+150.35s] User turn ended — server VAD silence detection started (turn_timeout=1s).
LogElevenLabsAgent: [T+150.35s] [Turn 9] Mic closed — user spoke 1.79s. Waiting for server response (timeout 10s)...
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Warning: [T+151.56s] [LATENCY] First audio: 1211 ms after turn end (1211 ms after last chunk)
LogElevenLabsWS: Received message type: user_transcript
LogElevenLabsWS: Warning: [T+151.59s] [LATENCY] User transcript: 1245 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Transcript : Hello.
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Received message type: agent_response
LogElevenLabsWS: Warning: [T+152.87s] [LATENCY] Agent text response: 2526 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Text Response : Hello! How can I help you today?
LogElevenLabsAgent: Warning: [T+160.36s] [Turn 9] Response timeout — server did not start generating after 10.0s. Firing OnAgentResponseTimeout.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Start Listening
LogElevenLabsWS: [T+162.59s] User turn started — mic open, audio chunks will follow.
LogAudioCaptureCore: Display: WasapiCapture AudioFormat SampeRate: 48000, BitDepth: 32-Bit Floating Point
LogElevenLabsMic: Capture device: Microphone Array (Intel® Smart Sound Technology for Digital Microphones) | Rate=48000 | Channels=2
LogElevenLabsMic: Audio capture started.
LogElevenLabsAgent: [T+162.59s] [Turn 10] Mic opened — user speaking.
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Stop Listening
LogElevenLabsMic: Audio capture stopped.
LogElevenLabsWS: [T+165.69s] User turn ended — server VAD silence detection started (turn_timeout=1s).
LogElevenLabsAgent: [T+165.69s] [Turn 10] Mic closed — user spoke 3.10s. Waiting for server response (timeout 10s)...
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: agent_chat_response_part
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Warning: [T+168.47s] [LATENCY] First audio: 2778 ms after turn end (2778 ms after last chunk)
LogElevenLabsWS: Received message type: user_transcript
LogElevenLabsWS: Warning: [T+168.50s] [LATENCY] User transcript: 2810 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Transcript : Why are you so slow?
LogElevenLabsAgent: Warning: [T+175.70s] [Turn 10] Response timeout — server did not start generating after 10.0s. Firing OnAgentResponseTimeout.
LogElevenLabsWS: Received message type: audio
LogElevenLabsWS: Received message type: agent_response
LogElevenLabsWS: Warning: [T+175.85s] [LATENCY] Agent text response: 10161 ms after turn end
LogBlueprintUserMessages: [test_AI_Actor_C_2] [AI Agent] Text Response : I apologize if my response seemed slow. I'm designed to be as quick as possible while still being helpful. Is there something specific I can do to improve your experience?
LogSlate: Updating window title bar state: overlay mode, drag disabled, window buttons hidden, title bar hidden
LogWorld: BeginTearingDown for /Game/UEDPIE_0_TestMap
Show less
Excellent logs — je vois deux bugs distincts :
Bug A — bIgnoreIncomingContent jamais effacé : après l'interruption au Turn 5, le serveur n'envoie pas d'interruption_event (ou on ne le reçoit pas). Le flag reste true → tout le contenu du Turn 6, 7, 8, 9, 10 est silencieusement ignoré → timeout systématique. Preuve : les [LATENCY] First audio du WebSocketProxy s'affichent (l'audio arrive) mais JAMAIS le Agent speaking — first audio chunk de l'AgentComponent (l'audio est jeté).
Bug B — HardSilenceTimeoutTicks = 120 (2s) trop court : l'agent répondait en deux batches audio avec 4.7s de gap (Turn 2 : T+23.85s puis T+28.55s). Le timeout de 2s s'est déclenché pendant le gap → faux "Stop Speaking" → Blueprint ouvre le mic → puis le 2e batch arrive → "Start Speaking" à nouveau.
Fixes :

View File

@@ -1,21 +1,21 @@
[/Script/EngineSettings.GameMapsSettings] [/Script/EngineSettings.GameMapsSettings]
GameDefaultMap=/Game/voidMap.voidMap GameDefaultMap=/PS_AI_ConvAgent/Demo_Metahuman.Demo_Metahuman
EditorStartupMap=/Game/voidMap.voidMap EditorStartupMap=/PS_AI_ConvAgent/Demo_Metahuman.Demo_Metahuman
[/Script/Engine.RendererSettings] [/Script/Engine.RendererSettings]
r.AllowStaticLighting=False r.AllowStaticLighting=False
r.GenerateMeshDistanceFields=True r.GenerateMeshDistanceFields=True
r.DynamicGlobalIlluminationMethod=1 r.DynamicGlobalIlluminationMethod=0
r.ReflectionMethod=1 r.ReflectionMethod=0
r.SkinCache.CompileShaders=True r.SkinCache.CompileShaders=True
r.RayTracing=True r.RayTracing=False
r.Shadow.Virtual.Enable=1 r.Shadow.Virtual.Enable=1
@@ -27,6 +27,8 @@ r.DefaultFeature.LocalExposure.ShadowContrastScale=0.8
r.GPUSkin.Support16BitBoneIndex=True r.GPUSkin.Support16BitBoneIndex=True
r.GPUSkin.UnlimitedBoneInfluences=True r.GPUSkin.UnlimitedBoneInfluences=True
SkeletalMesh.UseExperimentalChunking=1 SkeletalMesh.UseExperimentalChunking=1
r.Lumen.HardwareRayTracing=False
r.Lumen.Reflections.HardwareRayTracing.Translucent.Refraction.EnableForProject=False
[/Script/WindowsTargetPlatform.WindowsTargetSettings] [/Script/WindowsTargetPlatform.WindowsTargetSettings]
DefaultGraphicsRHI=DefaultGraphicsRHI_DX12 DefaultGraphicsRHI=DefaultGraphicsRHI_DX12
@@ -88,7 +90,7 @@ FontDPI=72
; ── Generic classes: ElevenLabs → PS_AI_ConvAgent ── ; ── Generic classes: ElevenLabs → PS_AI_ConvAgent ──
+ClassRedirects=(OldName="ElevenLabsLipSyncComponent", NewName="PS_AI_ConvAgent_LipSyncComponent") +ClassRedirects=(OldName="ElevenLabsLipSyncComponent", NewName="PS_AI_ConvAgent_LipSyncComponent")
+ClassRedirects=(OldName="ElevenLabsFacialExpressionComponent", NewName="PS_AI_ConvAgent_FacialExpressionComponent") +ClassRedirects=(OldName="ElevenLabsFacialExpressionComponent", NewName="PS_AI_ConvAgent_FacialExpressionComponent")
+ClassRedirects=(OldName="ElevenLabsPostureComponent", NewName="PS_AI_ConvAgent_PostureComponent") +ClassRedirects=(OldName="ElevenLabsPostureComponent", NewName="PS_AI_ConvAgent_GazeComponent")
+ClassRedirects=(OldName="ElevenLabsMicrophoneCaptureComponent", NewName="PS_AI_ConvAgent_MicrophoneCaptureComponent") +ClassRedirects=(OldName="ElevenLabsMicrophoneCaptureComponent", NewName="PS_AI_ConvAgent_MicrophoneCaptureComponent")
+ClassRedirects=(OldName="ElevenLabsLipSyncPoseMap", NewName="PS_AI_ConvAgent_LipSyncPoseMap") +ClassRedirects=(OldName="ElevenLabsLipSyncPoseMap", NewName="PS_AI_ConvAgent_LipSyncPoseMap")
+ClassRedirects=(OldName="ElevenLabsEmotionPoseMap", NewName="PS_AI_ConvAgent_EmotionPoseMap") +ClassRedirects=(OldName="ElevenLabsEmotionPoseMap", NewName="PS_AI_ConvAgent_EmotionPoseMap")
@@ -96,7 +98,7 @@ FontDPI=72
; ── Generic classes: PS_AI_Agent → PS_AI_ConvAgent (intermediate rename) ── ; ── Generic classes: PS_AI_Agent → PS_AI_ConvAgent (intermediate rename) ──
+ClassRedirects=(OldName="PS_AI_Agent_LipSyncComponent", NewName="PS_AI_ConvAgent_LipSyncComponent") +ClassRedirects=(OldName="PS_AI_Agent_LipSyncComponent", NewName="PS_AI_ConvAgent_LipSyncComponent")
+ClassRedirects=(OldName="PS_AI_Agent_FacialExpressionComponent", NewName="PS_AI_ConvAgent_FacialExpressionComponent") +ClassRedirects=(OldName="PS_AI_Agent_FacialExpressionComponent", NewName="PS_AI_ConvAgent_FacialExpressionComponent")
+ClassRedirects=(OldName="PS_AI_Agent_PostureComponent", NewName="PS_AI_ConvAgent_PostureComponent") +ClassRedirects=(OldName="PS_AI_Agent_PostureComponent", NewName="PS_AI_ConvAgent_GazeComponent")
+ClassRedirects=(OldName="PS_AI_Agent_MicrophoneCaptureComponent", NewName="PS_AI_ConvAgent_MicrophoneCaptureComponent") +ClassRedirects=(OldName="PS_AI_Agent_MicrophoneCaptureComponent", NewName="PS_AI_ConvAgent_MicrophoneCaptureComponent")
+ClassRedirects=(OldName="PS_AI_Agent_LipSyncPoseMap", NewName="PS_AI_ConvAgent_LipSyncPoseMap") +ClassRedirects=(OldName="PS_AI_Agent_LipSyncPoseMap", NewName="PS_AI_ConvAgent_LipSyncPoseMap")
+ClassRedirects=(OldName="PS_AI_Agent_EmotionPoseMap", NewName="PS_AI_ConvAgent_EmotionPoseMap") +ClassRedirects=(OldName="PS_AI_Agent_EmotionPoseMap", NewName="PS_AI_ConvAgent_EmotionPoseMap")
@@ -112,18 +114,23 @@ FontDPI=72
; ── AnimNode structs ── ; ── AnimNode structs ──
+StructRedirects=(OldName="AnimNode_ElevenLabsLipSync", NewName="AnimNode_PS_AI_ConvAgent_LipSync") +StructRedirects=(OldName="AnimNode_ElevenLabsLipSync", NewName="AnimNode_PS_AI_ConvAgent_LipSync")
+StructRedirects=(OldName="AnimNode_ElevenLabsFacialExpression", NewName="AnimNode_PS_AI_ConvAgent_FacialExpression") +StructRedirects=(OldName="AnimNode_ElevenLabsFacialExpression", NewName="AnimNode_PS_AI_ConvAgent_FacialExpression")
+StructRedirects=(OldName="AnimNode_ElevenLabsPosture", NewName="AnimNode_PS_AI_ConvAgent_Posture") +StructRedirects=(OldName="AnimNode_ElevenLabsPosture", NewName="AnimNode_PS_AI_ConvAgent_Gaze")
+StructRedirects=(OldName="AnimNode_PS_AI_Agent_LipSync", NewName="AnimNode_PS_AI_ConvAgent_LipSync") +StructRedirects=(OldName="AnimNode_PS_AI_Agent_LipSync", NewName="AnimNode_PS_AI_ConvAgent_LipSync")
+StructRedirects=(OldName="AnimNode_PS_AI_Agent_FacialExpression", NewName="AnimNode_PS_AI_ConvAgent_FacialExpression") +StructRedirects=(OldName="AnimNode_PS_AI_Agent_FacialExpression", NewName="AnimNode_PS_AI_ConvAgent_FacialExpression")
+StructRedirects=(OldName="AnimNode_PS_AI_Agent_Posture", NewName="AnimNode_PS_AI_ConvAgent_Posture") +StructRedirects=(OldName="AnimNode_PS_AI_Agent_Posture", NewName="AnimNode_PS_AI_ConvAgent_Gaze")
; ── AnimGraphNode classes ── ; ── AnimGraphNode classes ──
+ClassRedirects=(OldName="AnimGraphNode_ElevenLabsLipSync", NewName="AnimGraphNode_PS_AI_ConvAgent_LipSync") +ClassRedirects=(OldName="AnimGraphNode_ElevenLabsLipSync", NewName="AnimGraphNode_PS_AI_ConvAgent_LipSync")
+ClassRedirects=(OldName="AnimGraphNode_ElevenLabsFacialExpression", NewName="AnimGraphNode_PS_AI_ConvAgent_FacialExpression") +ClassRedirects=(OldName="AnimGraphNode_ElevenLabsFacialExpression", NewName="AnimGraphNode_PS_AI_ConvAgent_FacialExpression")
+ClassRedirects=(OldName="AnimGraphNode_ElevenLabsPosture", NewName="AnimGraphNode_PS_AI_ConvAgent_Posture") +ClassRedirects=(OldName="AnimGraphNode_ElevenLabsPosture", NewName="AnimGraphNode_PS_AI_ConvAgent_Gaze")
+ClassRedirects=(OldName="AnimGraphNode_PS_AI_Agent_LipSync", NewName="AnimGraphNode_PS_AI_ConvAgent_LipSync") +ClassRedirects=(OldName="AnimGraphNode_PS_AI_Agent_LipSync", NewName="AnimGraphNode_PS_AI_ConvAgent_LipSync")
+ClassRedirects=(OldName="AnimGraphNode_PS_AI_Agent_FacialExpression", NewName="AnimGraphNode_PS_AI_ConvAgent_FacialExpression") +ClassRedirects=(OldName="AnimGraphNode_PS_AI_Agent_FacialExpression", NewName="AnimGraphNode_PS_AI_ConvAgent_FacialExpression")
+ClassRedirects=(OldName="AnimGraphNode_PS_AI_Agent_Posture", NewName="AnimGraphNode_PS_AI_ConvAgent_Posture") +ClassRedirects=(OldName="AnimGraphNode_PS_AI_Agent_Posture", NewName="AnimGraphNode_PS_AI_ConvAgent_Gaze")
; ── Posture → Gaze rename ──
+ClassRedirects=(OldName="PS_AI_ConvAgent_PostureComponent", NewName="PS_AI_ConvAgent_GazeComponent")
+StructRedirects=(OldName="AnimNode_PS_AI_ConvAgent_Posture", NewName="AnimNode_PS_AI_ConvAgent_Gaze")
+ClassRedirects=(OldName="AnimGraphNode_PS_AI_ConvAgent_Posture", NewName="AnimGraphNode_PS_AI_ConvAgent_Gaze")
; ── Factory classes ── ; ── Factory classes ──
+ClassRedirects=(OldName="ElevenLabsLipSyncPoseMapFactory", NewName="PS_AI_ConvAgent_LipSyncPoseMapFactory") +ClassRedirects=(OldName="ElevenLabsLipSyncPoseMapFactory", NewName="PS_AI_ConvAgent_LipSyncPoseMapFactory")
@@ -153,6 +160,20 @@ FontDPI=72
+EnumRedirects=(OldName="EPS_AI_Agent_Emotion", NewName="EPS_AI_ConvAgent_Emotion") +EnumRedirects=(OldName="EPS_AI_Agent_Emotion", NewName="EPS_AI_ConvAgent_Emotion")
+EnumRedirects=(OldName="EPS_AI_Agent_EmotionIntensity", NewName="EPS_AI_ConvAgent_EmotionIntensity") +EnumRedirects=(OldName="EPS_AI_Agent_EmotionIntensity", NewName="EPS_AI_ConvAgent_EmotionIntensity")
[OnlineSubsystem]
DefaultPlatformService=Null
[OnlineSubsystemNull]
bEnabled=true
[Voice]
; Enable the Voice module so FVoiceModule::CreateVoiceEncoder() returns a valid
; Opus encoder/decoder. The engine default is bEnabled=false, which leaves the
; encoder NULL and makes networked agent audio fall back to raw PCM in large
; reliable multicast RPCs — these overflow the reliable buffer and drop VR
; client connections the moment the avatar speaks. With Opus, chunks are ~1-4KB.
bEnabled=true
[/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings] [/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings]
bEnablePlugin=True bEnablePlugin=True
bAllowNetworkConnection=True bAllowNetworkConnection=True
@@ -168,5 +189,9 @@ bUseManualIPAddress=False
ManualIPAddress= ManualIPAddress=
[/Script/PS_AI_ConvAgent.PS_AI_ConvAgent_Settings_ElevenLabs] [/Script/PS_AI_ConvAgent.PS_AI_ConvAgent_Settings_ElevenLabs]
API_Key=7b73c4244ccbec394cc010aaab01b0ec59ce0b11fc636ce4828354f675ca14a5 ; NOTE: there is intentionally no API_Key here. The ElevenLabs key is provided
; per-install by the client at runtime (BlueprintLibrary "Set ElevenLabs API Key")
; and stored in a SaveGame slot (Saved/SaveGames/PS_AI_ConvAgent_ElevenLabs.sav),
; so it is never committed or baked into a shipped build.
ServerRegion=Global

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,10 @@
{ {
"Name": "AudioCapture", "Name": "AudioCapture",
"Enabled": true "Enabled": true
},
{
"Name": "PS_AI_Behavior",
"Enabled": true
} }
] ]
} }

View File

@@ -46,7 +46,7 @@ echo.
echo Copier le dossier Builds\Windows\ sur chaque PC. echo Copier le dossier Builds\Windows\ sur chaque PC.
echo Utiliser Host.bat et Join.bat pour lancer. echo Utiliser Host.bat et Join.bat pour lancer.
echo. echo.
pause
REM Copy Host/Join scripts into the build output REM Copy Host/Join scripts into the build output
copy /Y "%~dp0Host.bat" "%OUTPUT%\Windows\" copy /Y "%~dp0Host.bat" "%OUTPUT%\Windows\"
copy /Y "%~dp0Join.bat" "%OUTPUT%\Windows\" copy /Y "%~dp0Join.bat" "%OUTPUT%\Windows\"

View File

@@ -0,0 +1,511 @@
# PS_AI_Behavior — Plan d'implémentation V1
## Vue d'ensemble
Plugin UE5.5 pour gérer les comportements de NPCs (civils et ennemis) via Behavior Trees, EQS et un système de personnalité à scores. Navigation sur NavMesh, détection d'ennemis, combat basique, fuite, couverture.
Dépendance optionnelle vers PS_AI_ConvAgent (détectée à l'exécution, pas de link-time dependency).
---
## 1. Structure du plugin
```
Plugins/PS_AI_Behavior/
├── PS_AI_Behavior.uplugin
├── Config/
│ └── DefaultPS_AI_Behavior.ini
├── Content/
│ ├── BehaviorTrees/
│ │ ├── BT_Civilian.uasset (BT civils)
│ │ └── BT_Enemy.uasset (BT ennemis)
│ ├── EQS/
│ │ ├── EQS_FindCover.uasset (trouver couverture)
│ │ ├── EQS_FindFleePoint.uasset (point de fuite)
│ │ └── EQS_FindPatrolPoint.uasset (point de patrouille)
│ └── Data/
│ ├── DA_Trait_Coward.uasset (exemple Data Asset)
│ └── DA_Trait_Aggressive.uasset
└── Source/
├── PS_AI_Behavior/ (module Runtime)
│ ├── PS_AI_Behavior.Build.cs
│ ├── Public/
│ │ ├── PS_AI_Behavior.h (module def)
│ │ ├── PS_AI_Behavior_Definitions.h (enums, structs, log category)
│ │ ├── PS_AI_Behavior_Settings.h (Project Settings)
│ │ │
│ │ ├── PS_AI_Behavior_AIController.h (AIController principal)
│ │ ├── PS_AI_Behavior_PersonalityComponent.h (traits de personnalité)
│ │ ├── PS_AI_Behavior_PerceptionComponent.h (wrapper AIPerception)
│ │ ├── PS_AI_Behavior_CombatComponent.h (état combat)
│ │ ├── PS_AI_Behavior_PersonalityProfile.h (Data Asset profil)
│ │ │
│ │ ├── BT/ (BT Tasks, Services, Decorators)
│ │ │ ├── PS_AI_Behavior_BTTask_FindCover.h
│ │ │ ├── PS_AI_Behavior_BTTask_FleeFrom.h
│ │ │ ├── PS_AI_Behavior_BTTask_Attack.h
│ │ │ ├── PS_AI_Behavior_BTTask_Patrol.h
│ │ │ ├── PS_AI_Behavior_BTService_UpdateThreat.h
│ │ │ ├── PS_AI_Behavior_BTService_EvaluateReaction.h
│ │ │ └── PS_AI_Behavior_BTDecorator_CheckTrait.h
│ │ │
│ │ └── EQS/
│ │ ├── PS_AI_Behavior_EQSContext_Threat.h
│ │ └── PS_AI_Behavior_EQSTest_CoverQuality.h
│ │
│ └── Private/
│ ├── PS_AI_Behavior.cpp
│ ├── PS_AI_Behavior_Settings.cpp
│ ├── PS_AI_Behavior_AIController.cpp
│ ├── PS_AI_Behavior_PersonalityComponent.cpp
│ ├── PS_AI_Behavior_PerceptionComponent.cpp
│ ├── PS_AI_Behavior_CombatComponent.cpp
│ ├── PS_AI_Behavior_PersonalityProfile.cpp
│ ├── BT/
│ │ ├── PS_AI_Behavior_BTTask_FindCover.cpp
│ │ ├── PS_AI_Behavior_BTTask_FleeFrom.cpp
│ │ ├── PS_AI_Behavior_BTTask_Attack.cpp
│ │ ├── PS_AI_Behavior_BTTask_Patrol.cpp
│ │ ├── PS_AI_Behavior_BTService_UpdateThreat.cpp
│ │ ├── PS_AI_Behavior_BTService_EvaluateReaction.cpp
│ │ └── PS_AI_Behavior_BTDecorator_CheckTrait.cpp
│ └── EQS/
│ ├── PS_AI_Behavior_EQSContext_Threat.cpp
│ └── PS_AI_Behavior_EQSTest_CoverQuality.cpp
└── PS_AI_BehaviorEditor/ (module Editor — futur, pas V1)
├── PS_AI_BehaviorEditor.Build.cs
└── ...
```
---
## 2. Classes principales
### 2.1 Definitions (`PS_AI_Behavior_Definitions.h`)
```cpp
// Log category
DECLARE_LOG_CATEGORY_EXTERN(LogPS_AI_Behavior, Log, All);
// Type de NPC
UENUM(BlueprintType)
enum class EPS_AI_Behavior_NPCType : uint8
{
Civilian,
Enemy,
Neutral
};
// État comportemental haut-niveau
UENUM(BlueprintType)
enum class EPS_AI_Behavior_State : uint8
{
Idle,
Patrol,
Alerted,
Combat,
Fleeing,
TakingCover,
Dead
};
// Axes de personnalité (scores 0.0 → 1.0)
UENUM(BlueprintType)
enum class EPS_AI_Behavior_TraitAxis : uint8
{
Courage, // 0 = lâche, 1 = téméraire
Aggressivity, // 0 = pacifique, 1 = violent
Loyalty, // 0 = égoïste, 1 = dévoué
Caution, // 0 = imprudent, 1 = prudent
Discipline // 0 = indiscipliné, 1 = discipliné
};
// Struct pour un trait + valeur
USTRUCT(BlueprintType)
struct FPS_AI_Behavior_TraitScore
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(ClampMin=0.0, ClampMax=1.0))
float Value = 0.5f;
};
```
### 2.2 PersonalityProfile — Data Asset (`PS_AI_Behavior_PersonalityProfile.h`)
```cpp
UCLASS(BlueprintType)
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_PersonalityProfile : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Personality")
FText ProfileName;
// Scores par axe : TMap<EPS_AI_Behavior_TraitAxis, float>
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Personality")
TMap<EPS_AI_Behavior_TraitAxis, float> TraitScores;
// Seuils de réaction
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Reaction Thresholds",
meta=(ClampMin=0.0, ClampMax=1.0))
float FleeThreshold = 0.6f; // Threat level au-delà duquel on fuit (modulé par Courage)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Reaction Thresholds",
meta=(ClampMin=0.0, ClampMax=1.0))
float AttackThreshold = 0.4f; // Threat level au-delà duquel on attaque (modulé par Aggressivity)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Behavior")
EPS_AI_Behavior_NPCType DefaultNPCType = EPS_AI_Behavior_NPCType::Civilian;
// Behavior Tree à utiliser (peut être overridé par l'AIController)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Behavior")
TSoftObjectPtr<UBehaviorTree> DefaultBehaviorTree;
// Helper
float GetTrait(EPS_AI_Behavior_TraitAxis Axis) const;
};
```
### 2.3 PersonalityComponent (`PS_AI_Behavior_PersonalityComponent.h`)
Attaché au Pawn. Fournit l'accès runtime aux traits, modifie les seuils dynamiquement.
```cpp
UCLASS(ClassGroup="PS AI Behavior", meta=(BlueprintSpawnableComponent))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_PersonalityComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Personality")
UPS_AI_Behavior_PersonalityProfile* Profile;
// Runtime overrides (initialisés depuis Profile au BeginPlay)
UPROPERTY(BlueprintReadWrite, Category="Personality|Runtime")
TMap<EPS_AI_Behavior_TraitAxis, float> RuntimeTraits;
// Threat level perçu (mis à jour par BTService_UpdateThreat)
UPROPERTY(BlueprintReadWrite, Category="Personality|Runtime")
float PerceivedThreatLevel = 0.0f;
// Décision finale basée sur traits + threat
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Personality")
EPS_AI_Behavior_State EvaluateReaction() const;
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Personality")
float GetTrait(EPS_AI_Behavior_TraitAxis Axis) const;
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Personality")
void ModifyTrait(EPS_AI_Behavior_TraitAxis Axis, float Delta);
};
```
**Logique `EvaluateReaction()`** :
```
EffectiveCourage = RuntimeTraits[Courage] * (1 - PerceivedThreatLevel * 0.5)
if PerceivedThreatLevel > FleeThreshold * (1 + EffectiveCourage) → Fleeing
if PerceivedThreatLevel > AttackThreshold * (1 - Aggressivity) → Combat
if PerceivedThreatLevel > 0.1 → Alerted
else → Idle/Patrol
```
### 2.4 AIController (`PS_AI_Behavior_AIController.h`)
```cpp
UCLASS()
class PS_AI_BEHAVIOR_API APS_AI_Behavior_AIController : public AAIController
{
GENERATED_BODY()
public:
APS_AI_Behavior_AIController();
// Blackboard keys (nom constants)
static const FName BB_State; // EPS_AI_Behavior_State
static const FName BB_ThreatActor; // UObject*
static const FName BB_ThreatLocation; // FVector
static const FName BB_ThreatLevel; // float
static const FName BB_CoverLocation; // FVector
static const FName BB_PatrolIndex; // int32
static const FName BB_HomeLocation; // FVector
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior")
UBehaviorTree* BehaviorTreeAsset;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior")
UBlackboardData* BlackboardAsset;
// Patrol waypoints (set par level designer ou spawner)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Patrol")
TArray<FVector> PatrolPoints;
protected:
virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override;
// Auto-détection optionnelle de PS_AI_ConvAgent
void TryBindConversationAgent();
};
```
**`OnPossess`** :
1. Trouve `PersonalityComponent` sur le Pawn
2. Crée/initialise le Blackboard
3. Lit `DefaultBehaviorTree` du ProfileData (ou utilise `BehaviorTreeAsset`)
4. Lance `RunBehaviorTree()`
5. Appelle `TryBindConversationAgent()`
**`TryBindConversationAgent()`** :
- Via `FindComponentByClass` (pas de include direct, utilise `FindObject` ou interface)
- Si trouvé : bind OnAgentActionRequested pour injecter des actions dans le BT
### 2.5 PerceptionComponent (`PS_AI_Behavior_PerceptionComponent.h`)
Wrapper configuré autour de `UAIPerceptionComponent` :
```cpp
UCLASS(ClassGroup="PS AI Behavior", meta=(BlueprintSpawnableComponent))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_PerceptionComponent : public UAIPerceptionComponent
{
GENERATED_BODY()
public:
UPS_AI_Behavior_PerceptionComponent();
// Pré-configure : Sight (60m, 90° FOV) + Hearing (30m) + Damage
virtual void BeginPlay() override;
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Perception")
AActor* GetHighestThreat() const;
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Perception")
float CalculateThreatLevel() const;
protected:
UFUNCTION()
void OnPerceptionUpdated(const TArray<AActor*>& UpdatedActors);
};
```
### 2.6 CombatComponent (`PS_AI_Behavior_CombatComponent.h`)
Gère l'état combat, les distances, le cooldown d'attaque :
```cpp
UCLASS(ClassGroup="PS AI Behavior", meta=(BlueprintSpawnableComponent))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_CombatComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Combat")
float AttackRange = 200.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Combat")
float AttackCooldown = 1.5f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Combat")
float AttackDamage = 20.0f;
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Combat")
bool CanAttack() const;
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Combat")
void ExecuteAttack(AActor* Target);
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Combat")
bool IsInAttackRange(AActor* Target) const;
};
```
---
## 3. Behavior Tree — Nodes
### 3.1 Services (tournent en continu)
**BTService_UpdateThreat** :
- Lit `PerceptionComponent::CalculateThreatLevel()`
- Écrit `BB_ThreatLevel`, `BB_ThreatActor`, `BB_ThreatLocation`
- Met à jour `PersonalityComponent::PerceivedThreatLevel`
**BTService_EvaluateReaction** :
- Appelle `PersonalityComponent::EvaluateReaction()`
- Écrit `BB_State` dans le Blackboard
- Le BT utilise des decorators pour brancher sur cet état
### 3.2 Tasks
**BTTask_Patrol** :
- Lit `BB_PatrolIndex`, navigue vers `PatrolPoints[idx]`
- Au succès, incrémente l'index (cyclique)
- Supporte pause aléatoire aux waypoints
**BTTask_FleeFrom** :
- Lit `BB_ThreatLocation`
- Utilise EQS `EQS_FindFleePoint` (direction opposée à la menace)
- `MoveTo()` vers le point trouvé
**BTTask_FindCover** :
- Lance EQS `EQS_FindCover` (scoring : distance menace, line-of-sight block, distance au NPC)
- Navigue vers le meilleur point
- Écrit `BB_CoverLocation`
**BTTask_Attack** :
- Vérifie `CombatComponent::CanAttack()`
- Si hors range : `MoveTo(Target)`
- Si in range : `CombatComponent::ExecuteAttack(Target)`
### 3.3 Decorators
**BTDecorator_CheckTrait** :
- Paramètres : `TraitAxis`, `ComparisonOp` (>, <, ==), `Threshold`
- Lit le trait depuis `PersonalityComponent`
- Exemple : "Exécuter seulement si Courage > 0.5"
---
## 4. EQS
### EQS_FindCover
- **Generator** : Points sur grille autour du NPC (rayon 15m)
- **Tests** :
- Distance à la menace (préfère mi-distance, pas trop loin)
- Trace visibility (préfère les points non-visibles depuis la menace)
- Distance au NPC (préfère les points proches)
- `EQSTest_CoverQuality` (custom) : raycasts multiples pour évaluer la qualité de couverture
### EQS_FindFleePoint
- **Generator** : Points sur donut (rayon 10-25m)
- **Tests** :
- Dot product direction (opposé à la menace : score max)
- PathExistence (doit être atteignable sur NavMesh)
- Distance à la menace (préfère loin)
### EQS_FindPatrolPoint
- **Generator** : Points depuis la liste PatrolPoints de l'AIController
- **Tests** :
- Distance au NPC (préfère le plus proche non-visité)
### EQSContext_Threat
- Renvoie l'acteur/location de `BB_ThreatActor` / `BB_ThreatLocation`
---
## 5. Intégration optionnelle PS_AI_ConvAgent
**Mécanisme** : Pas de `#include` direct. L'AIController utilise `FindComponentByClass` avec le nom de classe via UObject reflection :
```cpp
void APS_AI_Behavior_AIController::TryBindConversationAgent()
{
// Soft reference — no link-time dependency
UActorComponent* ConvComp = GetPawn()->FindComponentByClass(
LoadClass<UActorComponent>(nullptr,
TEXT("/Script/PS_AI_ConvAgent.PS_AI_ConvAgent_ElevenLabsComponent")));
if (ConvComp)
{
// Bind to OnAgentActionRequested via dynamic delegate
// Actions from conversation can inject BT state changes
}
}
```
Cela permet :
- Un NPC conversationnel qui reçoit "Fuis !" via ElevenLabs → injecte State=Fleeing dans le BT
- Aucune dépendance de compilation
---
## 6. Build.cs — Dépendances
```csharp
// PS_AI_Behavior.Build.cs
PublicDependencyModuleNames.AddRange(new string[] {
"Core", "CoreUObject", "Engine",
"AIModule", // AAIController, BehaviorTree, Blackboard
"GameplayTasks", // UGameplayTask (requis par BT tasks)
"NavigationSystem", // NavMesh queries
});
PrivateDependencyModuleNames.AddRange(new string[] {
"Settings", // ISettingsModule
});
// PAS de dépendance vers PS_AI_ConvAgent
```
---
## 7. Ordre d'implémentation (étapes)
### Étape 1 — Squelette plugin
- [ ] Créer la structure de fichiers du plugin
- [ ] `.uplugin`, `Build.cs`, module class
- [ ] `Definitions.h` (enums, structs, log category)
- [ ] `Settings.h/cpp` (settings vides pour l'instant)
- [ ] Ajouter au `.uproject`
- [ ] **Vérification** : compile sans erreur
### Étape 2 — Personality System
- [ ] `PersonalityProfile` (Data Asset)
- [ ] `PersonalityComponent` avec `EvaluateReaction()`
- [ ] **Vérification** : peut créer un Data Asset dans l'éditeur, lire les traits en BP
### Étape 3 — AIController + Perception
- [ ] `PS_AI_Behavior_AIController` avec Blackboard setup
- [ ] `PS_AI_Behavior_PerceptionComponent` (sight + hearing)
- [ ] `BlackboardData` asset par défaut
- [ ] **Vérification** : un NPC spawné détecte les acteurs proches
### Étape 4 — BT Services + Decorators
- [ ] `BTService_UpdateThreat`
- [ ] `BTService_EvaluateReaction`
- [ ] `BTDecorator_CheckTrait`
- [ ] **Vérification** : le Blackboard se met à jour en jeu
### Étape 5 — BT Tasks (Navigation)
- [ ] `BTTask_Patrol`
- [ ] `BTTask_FleeFrom`
- [ ] `BTTask_FindCover`
- [ ] **Vérification** : NPC patrouille et fuit
### Étape 6 — Combat
- [ ] `CombatComponent`
- [ ] `BTTask_Attack`
- [ ] **Vérification** : NPC ennemi attaque le joueur
### Étape 7 — EQS
- [ ] `EQSContext_Threat`
- [ ] `EQSTest_CoverQuality`
- [ ] Assets EQS dans Content/
- [ ] **Vérification** : NPC trouve des couvertures intelligemment
### Étape 8 — Intégration ConvAgent (optionnelle)
- [ ] `TryBindConversationAgent()` soft binding
- [ ] Test avec un NPC qui a les deux plugins
---
## 8. Résumé des fichiers à créer
| # | Fichier | Rôle |
|---|---------|------|
| 1 | `PS_AI_Behavior.uplugin` | Plugin descriptor |
| 2 | `PS_AI_Behavior.Build.cs` | Module dependencies |
| 3 | `PS_AI_Behavior.h / .cpp` | Module class (register settings) |
| 4 | `PS_AI_Behavior_Definitions.h` | Enums, structs, log |
| 5 | `PS_AI_Behavior_Settings.h / .cpp` | Project settings |
| 6 | `PS_AI_Behavior_PersonalityProfile.h / .cpp` | Data Asset |
| 7 | `PS_AI_Behavior_PersonalityComponent.h / .cpp` | Personality runtime |
| 8 | `PS_AI_Behavior_AIController.h / .cpp` | AIController |
| 9 | `PS_AI_Behavior_PerceptionComponent.h / .cpp` | AI Perception |
| 10 | `PS_AI_Behavior_CombatComponent.h / .cpp` | Combat state |
| 11 | `BT/PS_AI_Behavior_BTTask_Patrol.h / .cpp` | Patrol task |
| 12 | `BT/PS_AI_Behavior_BTTask_FleeFrom.h / .cpp` | Flee task |
| 13 | `BT/PS_AI_Behavior_BTTask_FindCover.h / .cpp` | Cover task |
| 14 | `BT/PS_AI_Behavior_BTTask_Attack.h / .cpp` | Attack task |
| 15 | `BT/PS_AI_Behavior_BTService_UpdateThreat.h / .cpp` | Threat service |
| 16 | `BT/PS_AI_Behavior_BTService_EvaluateReaction.h / .cpp` | Reaction service |
| 17 | `BT/PS_AI_Behavior_BTDecorator_CheckTrait.h / .cpp` | Trait decorator |
| 18 | `EQS/PS_AI_Behavior_EQSContext_Threat.h / .cpp` | EQS context |
| 19 | `EQS/PS_AI_Behavior_EQSTest_CoverQuality.h / .cpp` | EQS test |
**Total : ~38 fichiers C++ (19 paires h/cpp) + 1 .uplugin + 1 .Build.cs**

View File

@@ -0,0 +1,35 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0.0",
"FriendlyName": "PS AI Behavior",
"Description": "NPC behavior system using Behavior Trees, EQS, and personality-driven reactions for civilians and enemies.",
"Category": "AI",
"CreatedBy": "Asterion",
"CanContainContent": true,
"IsBetaVersion": true,
"Modules": [
{
"Name": "PS_AI_Behavior",
"Type": "Runtime",
"LoadingPhase": "PreDefault",
"PlatformAllowList": [
"Win64",
"Mac",
"Linux",
"Android"
]
},
{
"Name": "PS_AI_BehaviorEditor",
"Type": "UncookedOnly",
"LoadingPhase": "Default",
"PlatformAllowList": [
"Win64",
"Mac",
"Linux"
]
}
],
"Plugins": []
}

View File

@@ -0,0 +1,27 @@
// Copyright Asterion. All Rights Reserved.
using UnrealBuildTool;
public class PS_AI_Behavior : ModuleRules
{
public PS_AI_Behavior(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"InputCore",
"AIModule",
"GameplayTasks",
"NavigationSystem",
});
PrivateDependencyModuleNames.AddRange(new string[]
{
"DeveloperSettings",
});
}
}

View File

@@ -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<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) return false;
APawn* Pawn = AIC->GetPawn();
if (!Pawn || !Pawn->Implements<UPS_AI_Behavior_Interface>()) return false;
return IPS_AI_Behavior_Interface::Execute_GetBehaviorCombatType(Pawn) == RequiredType;
}
FString UPS_AI_Behavior_BTDecorator_CheckCombatType::GetStaticDescription() const
{
const UEnum* TypeEnum = StaticEnum<EPS_AI_Behavior_CombatType>();
return FString::Printf(TEXT("Combat Type == %s"),
*TypeEnum->GetDisplayNameTextByValue(static_cast<int64>(RequiredType)).ToString());
}

View File

@@ -0,0 +1,43 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTDecorator_CheckTrait.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
UPS_AI_Behavior_BTDecorator_CheckTrait::UPS_AI_Behavior_BTDecorator_CheckTrait()
{
NodeName = TEXT("Check Trait");
}
bool UPS_AI_Behavior_BTDecorator_CheckTrait::CalculateRawConditionValue(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) return false;
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
if (!Personality) return false;
const float TraitValue = Personality->GetTrait(TraitAxis);
switch (Comparison)
{
case EPS_AI_Behavior_ComparisonOp::GreaterThan: return TraitValue > Threshold;
case EPS_AI_Behavior_ComparisonOp::GreaterOrEqual: return TraitValue >= Threshold;
case EPS_AI_Behavior_ComparisonOp::LessThan: return TraitValue < Threshold;
case EPS_AI_Behavior_ComparisonOp::LessOrEqual: return TraitValue <= Threshold;
case EPS_AI_Behavior_ComparisonOp::Equal: return FMath::IsNearlyEqual(TraitValue, Threshold, 0.01f);
default: return false;
}
}
FString UPS_AI_Behavior_BTDecorator_CheckTrait::GetStaticDescription() const
{
const UEnum* AxisEnum = StaticEnum<EPS_AI_Behavior_TraitAxis>();
const UEnum* OpEnum = StaticEnum<EPS_AI_Behavior_ComparisonOp>();
return FString::Printf(TEXT("Trait: %s %s %.2f"),
*AxisEnum->GetDisplayNameTextByValue(static_cast<int64>(TraitAxis)).ToString(),
*OpEnum->GetDisplayNameTextByValue(static_cast<int64>(Comparison)).ToString(),
Threshold);
}

View File

@@ -0,0 +1,67 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTDecorator_IsCoverNeeded.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_PerceptionComponent.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Interface.h"
#include "BehaviorTree/BlackboardComponent.h"
UPS_AI_Behavior_BTDecorator_IsCoverNeeded::UPS_AI_Behavior_BTDecorator_IsCoverNeeded()
{
NodeName = TEXT("Is Cover Needed");
// Default: cover needed against Protector and Enemy, not Civilian
DangerousTargetTypes.Add(EPS_AI_Behavior_NPCType::Protector);
DangerousTargetTypes.Add(EPS_AI_Behavior_NPCType::Enemy);
}
bool UPS_AI_Behavior_BTDecorator_IsCoverNeeded::CalculateRawConditionValue(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return false;
AActor* ThreatActor = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!ThreatActor) return false;
// Resolve ThreatActor to owning Pawn (AimTargetActor → Character)
APawn* ThreatPawn = UPS_AI_Behavior_PerceptionComponent::FindOwningPawn(ThreatActor);
if (!ThreatPawn || !ThreatPawn->Implements<UPS_AI_Behavior_Interface>())
{
return true; // Can't resolve → assume dangerous (safe default)
}
const EPS_AI_Behavior_NPCType TargetType =
IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(ThreatPawn);
if (!DangerousTargetTypes.Contains(TargetType))
{
return false; // Target is not dangerous → no cover needed
}
// Check personality-driven combat/cover cycle timer
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
if (const UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent())
{
return Personality->ShouldPreferCover();
}
}
return true; // No personality → default to cover
}
FString UPS_AI_Behavior_BTDecorator_IsCoverNeeded::GetStaticDescription() const
{
const UEnum* TypeEnum = StaticEnum<EPS_AI_Behavior_NPCType>();
FString TypeList;
for (const EPS_AI_Behavior_NPCType& Type : DangerousTargetTypes)
{
if (!TypeList.IsEmpty()) TypeList += TEXT(", ");
TypeList += TypeEnum->GetDisplayNameTextByValue(static_cast<int64>(Type)).ToString();
}
return FString::Printf(TEXT("Cover needed vs: %s"),
TypeList.IsEmpty() ? TEXT("None") : *TypeList);
}

View File

@@ -0,0 +1,201 @@
// Copyright Asterion. All Rights Reserved.
#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()
{
NodeName = TEXT("Evaluate Reaction");
Interval = 0.5f;
RandomDeviation = 0.1f;
}
void UPS_AI_Behavior_BTService_EvaluateReaction::TickNode(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) { UE_LOG(LogPS_AI_Behavior, Warning, TEXT("EvaluateReaction: no AIC!")); return; }
// Scripted state: external control — don't touch the state
if (AIC->GetBehaviorState() == EPS_AI_Behavior_State::Scripted) { return; }
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
if (!Personality) { UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] EvaluateReaction: no PersonalityComponent!"), *AIC->GetName()); return; }
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) { UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] EvaluateReaction: no BB!"), *AIC->GetName()); return; }
// ─── Check for hostility change → update TeamId dynamically ────────
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
const bool bHostile = IPS_AI_Behavior_Interface::Execute_IsBehaviorHostile(Pawn);
const EPS_AI_Behavior_NPCType NPCType = IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(Pawn);
// 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;
if (NPCType == EPS_AI_Behavior_NPCType::Enemy && !bHostile)
{
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 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());
}
}
}
// ─── Gunshot reaction: flip non-hostile enemies to hostile ──────────
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
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<AActor*> 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<APawn>(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<APawn>(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<UAISense_Hearing>() &&
PS_AI_Behavior_Tags_Internal::IsGunfire(S.Tag))
{
// For VR: check if Pawn has a custom threat actor
if (ResolvedActor->Implements<UPS_AI_Behavior_Interface>())
{
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();
// Don't enter Combat or TakingCover without a valid ThreatActor in BB
if (NewState == EPS_AI_Behavior_State::Combat || NewState == EPS_AI_Behavior_State::TakingCover)
{
const AActor* ThreatActor = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!ThreatActor)
{
// Threat level says fight, but no target yet — stay Alerted until perception catches up
NewState = EPS_AI_Behavior_State::Alerted;
}
}
// Write to Blackboard
AIC->SetBehaviorState(NewState);
}
FString UPS_AI_Behavior_BTService_EvaluateReaction::GetStaticDescription() const
{
return TEXT("Evaluates NPC reaction from personality + threat.\nWrites: BehaviorState.");
}

View File

@@ -0,0 +1,28 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTService_UpdateGaze.h"
#include "PS_AI_Behavior_AIController.h"
UPS_AI_Behavior_BTService_UpdateGaze::UPS_AI_Behavior_BTService_UpdateGaze()
{
NodeName = TEXT("Update Gaze");
Interval = 0.3f;
RandomDeviation = 0.05f;
}
void UPS_AI_Behavior_BTService_UpdateGaze::TickNode(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->UpdateGazeTarget(DeltaSeconds);
}
}
FString UPS_AI_Behavior_BTService_UpdateGaze::GetStaticDescription() const
{
return TEXT("Updates gaze target from behavior state + proximity.\nBridges to PS_AI_ConvAgent GazeComponent (if present).");
}

View File

@@ -0,0 +1,292 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTService_UpdateThreat.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_PerceptionComponent.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_PersonalityProfile.h"
#include "PS_AI_Behavior_Statics.h"
#include "PS_AI_Behavior_Settings.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
UPS_AI_Behavior_BTService_UpdateThreat::UPS_AI_Behavior_BTService_UpdateThreat()
{
NodeName = TEXT("Update Threat");
Interval = 0.3f;
RandomDeviation = 0.05f;
}
void UPS_AI_Behavior_BTService_UpdateThreat::TickNode(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) return;
// Scripted state: external control — don't accumulate threat
if (AIC->GetBehaviorState() == EPS_AI_Behavior_State::Scripted) { return; }
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return;
UPS_AI_Behavior_PerceptionComponent* Perception = AIC->GetBehaviorPerception();
if (!Perception)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] UpdateThreat: No PerceptionComponent!"), *AIC->GetName());
return;
}
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
// Debug: check what perception sees
TArray<AActor*> PerceivedActors;
Perception->GetCurrentlyPerceivedActors(nullptr, PerceivedActors);
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] UpdateThreat: Perceived %d actors"),
*AIC->GetName(), PerceivedActors.Num());
for (AActor* A : PerceivedActors)
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT(" - %s"), *A->GetName());
}
}
// Also check known actors (perceived in the past but maybe lost sight)
TArray<AActor*> KnownActors;
Perception->GetKnownPerceivedActors(nullptr, KnownActors);
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()) && KnownActors.Num() != PerceivedActors.Num())
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] UpdateThreat: Known (past) %d actors"),
*AIC->GetName(), KnownActors.Num());
}
// Calculate current threat
const float RawThreat = Perception->CalculateThreatLevel();
// Get current stored threat for decay
const float StoredThreat = BB->GetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel);
// Apply decay when no threat, or take the max of new vs decayed
// Decay rate comes from PersonalityProfile (per-archetype), fallback to global Settings
float DecayRate = GetDefault<UPS_AI_Behavior_Settings>()->ThreatDecayRate;
if (Personality && Personality->Profile)
{
DecayRate = Personality->Profile->ThreatDecayRate;
}
const float DecayedThreat = FMath::Max(0.0f, StoredThreat - DecayRate * DeltaSeconds);
const float FinalThreat = FMath::Max(RawThreat, DecayedThreat);
BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, FinalThreat);
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] UpdateThreat: raw=%.2f, stored=%.2f, final=%.2f"),
*AIC->GetName(), RawThreat, StoredThreat, FinalThreat);
}
// ─── Update threat actor and location with LOS tracking ─────────
FUpdateThreatMemory* Memory = reinterpret_cast<FUpdateThreatMemory*>(NodeMemory);
AActor* ThreatActor = Perception->GetHighestThreatActor();
AActor* CurrentBBTarget = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (ThreatActor)
{
// Target switched by perception (higher score)
if (ThreatActor != CurrentBBTarget)
{
const EPS_AI_Behavior_State CurrentState = AIC->GetBehaviorState();
// During TakingCover: don't switch target — cover is positioned against current threat.
// The flanking check will handle cover invalidation if needed.
if (CurrentBBTarget && CurrentState == EPS_AI_Behavior_State::TakingCover)
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] UpdateThreat: suppressed target switch to '%s' — in TakingCover (keeping '%s')"),
*AIC->GetName(), *ThreatActor->GetName(), *CurrentBBTarget->GetName());
ThreatActor = CurrentBBTarget; // Keep current target
}
else
{
Memory->TimeSinceLOS = 0.0f;
Memory->bHadLOS = true;
Memory->bInvestigating = false;
Memory->LastVisiblePosition = ThreatActor->GetActorLocation();
BB->ClearValue(PS_AI_Behavior_BB::LastKnownTargetPosition);
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] UpdateThreat: target switched to '%s', LOS tracking reset"),
*AIC->GetName(), *ThreatActor->GetName());
}
}
BB->SetValueAsObject(PS_AI_Behavior_BB::ThreatActor, ThreatActor);
// Debug: write the owning Pawn's name so BB shows who we're actually targeting
if (ThreatActor != CurrentBBTarget)
{
APawn* OwningPawn = Perception->LastThreatOwningPawn.Get();
BB->SetValueAsString(PS_AI_Behavior_BB::ThreatPawnName,
OwningPawn ? OwningPawn->GetName() : ThreatActor->GetName());
}
// ─── LOS check ──────────────────────────────────────────────
const APawn* Pawn = AIC->GetPawn();
const bool bHasLOS = Pawn
? UPS_AI_Behavior_Statics::HasLineOfSight(Pawn->GetWorld(), Pawn, ThreatActor, EyeHeightOffset)
: false;
if (bHasLOS)
{
// Can see target → update last visible position, reset timer
Memory->LastVisiblePosition = ThreatActor->GetActorLocation();
Memory->TimeSinceLOS = 0.0f;
Memory->bHadLOS = true;
Memory->bInvestigating = false;
BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, ThreatActor->GetActorLocation());
BB->ClearValue(PS_AI_Behavior_BB::LastKnownTargetPosition);
}
else
{
// Cannot see target → accumulate time, write last known position
Memory->TimeSinceLOS += DeltaSeconds;
// Write ThreatLocation as the LAST VISIBLE position (not real-time)
if (!Memory->LastVisiblePosition.IsZero())
{
BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, Memory->LastVisiblePosition);
}
else
{
BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, ThreatActor->GetActorLocation());
}
// Phase 1: after LOSLostTimeout → write LastKnownTargetPosition for investigation
if (Memory->TimeSinceLOS > LOSLostTimeout && !Memory->bInvestigating)
{
Memory->bInvestigating = true;
BB->SetValueAsVector(PS_AI_Behavior_BB::LastKnownTargetPosition, Memory->LastVisiblePosition);
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] UpdateThreat: LOS lost for %.0fs → investigating last known position %s"),
*AIC->GetName(), Memory->TimeSinceLOS, *Memory->LastVisiblePosition.ToString());
}
// Phase 2: after 2x timeout → give up, clear target so NPC can re-evaluate
// BUT never abandon in active combat states — the NPC knows the threat is there
if (Memory->TimeSinceLOS > LOSLostTimeout * 2.0f)
{
const EPS_AI_Behavior_State CurrentState = AIC->GetBehaviorState();
if (CurrentState == EPS_AI_Behavior_State::Combat ||
CurrentState == EPS_AI_Behavior_State::TakingCover)
{
// In combat: keep target, just update location to last known
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] UpdateThreat: LOS lost for %.0fs but in %s state — keeping target"),
*AIC->GetName(), Memory->TimeSinceLOS,
CurrentState == EPS_AI_Behavior_State::Combat ? TEXT("Combat") : TEXT("TakingCover"));
}
else
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] UpdateThreat: LOS lost for %.0fs → clearing target, de-escalating"),
*AIC->GetName(), Memory->TimeSinceLOS);
BB->ClearValue(PS_AI_Behavior_BB::ThreatActor);
BB->ClearValue(PS_AI_Behavior_BB::LastKnownTargetPosition);
BB->ClearValue(PS_AI_Behavior_BB::ThreatPawnName);
Memory->TimeSinceLOS = 0.0f;
Memory->bInvestigating = false;
Memory->bHadLOS = true;
}
}
}
}
else if (CurrentBBTarget)
{
// Perception lost the actor momentarily, but we had a target in BB.
// Don't clear immediately — use the LOS tracking timeout instead.
// This prevents brief perception gaps (1-2 ticks) from dropping the target.
Memory->TimeSinceLOS += DeltaSeconds;
// Keep ThreatLocation at last visible position
if (!Memory->LastVisiblePosition.IsZero())
{
BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, Memory->LastVisiblePosition);
}
// Phase 1: after LOSLostTimeout → investigate last known position
if (Memory->TimeSinceLOS > LOSLostTimeout && !Memory->bInvestigating)
{
Memory->bInvestigating = true;
BB->SetValueAsVector(PS_AI_Behavior_BB::LastKnownTargetPosition, Memory->LastVisiblePosition);
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] UpdateThreat: perception lost target for %.0fs → investigating last known position %s"),
*AIC->GetName(), Memory->TimeSinceLOS, *Memory->LastVisiblePosition.ToString());
}
// Phase 2: after 2x timeout → give up, clear target
// BUT never abandon in active combat states
if (Memory->TimeSinceLOS > LOSLostTimeout * 2.0f)
{
const EPS_AI_Behavior_State CurrentState = AIC->GetBehaviorState();
if (CurrentState == EPS_AI_Behavior_State::Combat ||
CurrentState == EPS_AI_Behavior_State::TakingCover)
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] UpdateThreat: perception lost target for %.0fs but in %s state — keeping target"),
*AIC->GetName(), Memory->TimeSinceLOS,
CurrentState == EPS_AI_Behavior_State::Combat ? TEXT("Combat") : TEXT("TakingCover"));
}
else
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] UpdateThreat: perception lost target for %.0fs → clearing target, de-escalating"),
*AIC->GetName(), Memory->TimeSinceLOS);
BB->ClearValue(PS_AI_Behavior_BB::ThreatActor);
BB->ClearValue(PS_AI_Behavior_BB::LastKnownTargetPosition);
BB->ClearValue(PS_AI_Behavior_BB::ThreatPawnName);
Memory->TimeSinceLOS = 0.0f;
Memory->bInvestigating = false;
Memory->bHadLOS = true;
}
}
}
else
{
// No threat actor at all (never had one)
Memory->TimeSinceLOS = 0.0f;
Memory->bInvestigating = false;
Memory->bHadLOS = true;
if (FinalThreat > 0.0f)
{
// Threat exists but no specific actor target (e.g. gunshot from friendly).
FVector ThreatLoc;
if (Perception->GetGunShotStimulusLocation(ThreatLoc))
{
BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, ThreatLoc);
}
}
else
{
BB->ClearValue(PS_AI_Behavior_BB::ThreatLocation);
}
}
// Sync to PersonalityComponent
if (Personality)
{
Personality->PerceivedThreatLevel = FinalThreat;
}
}
FString UPS_AI_Behavior_BTService_UpdateThreat::GetStaticDescription() const
{
return FString::Printf(
TEXT("Updates Blackboard threat data from perception.\nLOS timeout: %.0fs (clear at %.0fs)\nWrites: ThreatActor, ThreatLocation, ThreatLevel, LastKnownTargetPosition."),
LOSLostTimeout, LOSLostTimeout * 2.0f);
}

View File

@@ -0,0 +1,482 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_Attack.h"
#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 "PS_AI_Behavior_Statics.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Navigation/PathFollowingComponent.h"
#include "NavigationSystem.h"
#include "EnvironmentQuery/EnvQuery.h"
#include "EnvironmentQuery/EnvQueryManager.h"
UPS_AI_Behavior_BTTask_Attack::UPS_AI_Behavior_BTTask_Attack()
{
NodeName = TEXT("Attack");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn())
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("Attack: FAILED — no AIC or Pawn"));
return EBTNodeResult::Failed;
}
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: FAILED — no BB"), *AIC->GetName());
return EBTNodeResult::Failed;
}
AActor* Target = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!Target)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: FAILED — no ThreatActor in BB"), *AIC->GetName());
// De-escalate so decorator can re-trigger when threat returns
AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted);
return EBTNodeResult::Failed;
}
APawn* Pawn = AIC->GetPawn();
// Validate target before starting attack
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(Pawn, Target))
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: FAILED — IsTargetActorValid returned false for '%s'"),
*AIC->GetName(), *Target->GetName());
BB->ClearValue(PS_AI_Behavior_BB::ThreatActor);
// De-escalate so decorator can re-trigger when threat returns
AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted);
return EBTNodeResult::Failed;
}
}
// ─── Query combat type and range ─────────────────────────────────
FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory);
Memory->bMovingToTarget = false;
Memory->bAttacking = false;
Memory->bInRange = false;
Memory->bHasLOS = true;
Memory->bSeekingFiringPos = false;
Memory->bEQSQueryRunning = false;
Memory->RepositionTimer = 0.0f;
Memory->LOSCheckTimer = 0.0f;
Memory->CombatType = EPS_AI_Behavior_CombatType::Melee;
Memory->MinRange = 100.0f;
Memory->MaxRange = AttackMoveRadius;
// CombatType from interface
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
Memory->CombatType = IPS_AI_Behavior_Interface::Execute_GetBehaviorCombatType(Pawn);
}
// Min/Max attack range from PersonalityProfile
if (UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent())
{
if (Personality->Profile)
{
Memory->MinRange = Personality->Profile->MinAttackRange;
Memory->MaxRange = Personality->Profile->MaxAttackRange;
}
}
// ─── Initial LOS check (ranged only) ────────────────────────────
if (Memory->CombatType == EPS_AI_Behavior_CombatType::Ranged)
{
Memory->bHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight(
Pawn->GetWorld(), Pawn, Target, EyeHeightOffset);
}
// Melee: approach to half MinRange. 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;
// Start attack immediately (draw weapon, enter combat stance)
// regardless of range or LOS — the NPC is committed to combat
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
Memory->bAttacking = true;
}
// Check if already in range
const float DistToTarget = FVector::Dist(Pawn->GetActorLocation(), Target->GetActorLocation());
if (DistToTarget <= Memory->MaxRange)
{
Memory->bInRange = 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, LOS=%d, attacking=%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->bHasLOS ? 1 : 0, Memory->bAttacking ? 1 : 0);
return EBTNodeResult::InProgress;
}
void UPS_AI_Behavior_BTTask_Attack::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn())
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
AActor* Target = BB ? Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)) : nullptr;
if (!Target)
{
// De-escalate so decorator can re-trigger when threat returns
AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted);
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
APawn* Pawn = AIC->GetPawn();
// Validate target
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(Pawn, Target))
{
AIC->StopMovement();
BB->ClearValue(PS_AI_Behavior_BB::ThreatActor);
// De-escalate so decorator can re-trigger when threat returns
AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted);
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
}
FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory);
// Tick cooldowns
if (Memory->RepositionTimer > 0.0f)
{
Memory->RepositionTimer -= DeltaSeconds;
}
// ─── Periodic LOS check (ranged only) ───────────────────────────
if (Memory->CombatType == EPS_AI_Behavior_CombatType::Ranged)
{
Memory->LOSCheckTimer -= DeltaSeconds;
if (Memory->LOSCheckTimer <= 0.0f)
{
Memory->LOSCheckTimer = LOSCheckInterval;
Memory->bHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight(
Pawn->GetWorld(), Pawn, Target, EyeHeightOffset);
}
}
// Melee always has "LOS" (they just rush)
const float DistToTarget = FVector::Dist(Pawn->GetActorLocation(), Target->GetActorLocation());
const bool bCanReposition = (Memory->RepositionTimer <= 0.0f);
// ─── MOVEMENT LOGIC ─────────────────────────────────────────────
if (Memory->CombatType == EPS_AI_Behavior_CombatType::Melee)
{
// Melee: continuously chase target (no LOS check — just rush)
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: LOS-aware positioning ──────────────────────────
if (Memory->bSeekingFiringPos)
{
// Waiting for firing position move to complete
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
Memory->bSeekingFiringPos = false;
// Re-check LOS immediately after arriving
Memory->LOSCheckTimer = 0.0f;
}
}
else if (!Memory->bHasLOS && !Memory->bEQSQueryRunning &&
AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
// No LOS and idle → find a firing position
// Check if investigation (last known position) is active
// ClearValue sets vectors to FAISystem::InvalidLocation, not zero — must check both
const FVector LastKnownPos = BB->GetValueAsVector(PS_AI_Behavior_BB::LastKnownTargetPosition);
const bool bHasLastKnown = !LastKnownPos.IsZero() &&
!LastKnownPos.ContainsNaN() &&
LastKnownPos.X < 1e30f; // Filter out FLT_MAX / InvalidLocation
if (bHasLastKnown)
{
// Investigation mode: go to last known position
AIC->MoveToLocation(
LastKnownPos, 100.0f, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false);
Memory->bSeekingFiringPos = true;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Attack: investigating last known position %s"),
*AIC->GetName(), *LastKnownPos.ToString());
}
else
{
// No investigation yet — try EQS for a flanking position
RunFiringPositionQuery(OwnerComp, NodeMemory, Pawn, Target);
}
}
else if (Memory->bHasLOS && AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
// Has LOS — normal range maintenance
const float MidRange = (Memory->MinRange + Memory->MaxRange) * 0.5f;
if (bCanReposition && DistToTarget < Memory->MinRange)
{
// Too close — back away
const FVector AwayDir = (Pawn->GetActorLocation() - Target->GetActorLocation()).GetSafeNormal2D();
const float RetreatDist = MidRange - DistToTarget + 50.0f;
const FVector RetreatPoint = Pawn->GetActorLocation() + AwayDir * RetreatDist;
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(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);
Memory->RepositionTimer = RepositionCooldown * 3.0f;
}
}
}
else if (DistToTarget > Memory->MaxRange)
{
// Too far — advance
AIC->MoveToLocation(
Target->GetActorLocation(), MidRange, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false);
}
}
}
// ─── Toggle attack based on range + LOS ─────────────────────────
const float EnterRange = Memory->MaxRange;
const float LeaveRange = Memory->MaxRange * 1.1f;
const bool bNowInRange = Memory->bInRange
? (DistToTarget <= LeaveRange)
: (DistToTarget <= EnterRange);
// For ranged: require LOS to attack. For melee: always allow.
const bool bCanAttack = (Memory->CombatType == EPS_AI_Behavior_CombatType::Melee) || Memory->bHasLOS;
if (bNowInRange && bCanAttack && !Memory->bInRange)
{
// Entered range with LOS → start attacking
Memory->bInRange = true;
if (Pawn->Implements<UPS_AI_Behavior_Interface>() && !Memory->bAttacking)
{
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
Memory->bAttacking = true;
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: in range + LOS → StartAttack on '%s'"),
*AIC->GetName(), *Target->GetName());
}
}
else if (bNowInRange && !bCanAttack && Memory->bAttacking)
{
// In range but lost LOS → stop attacking (stay in range though)
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
Memory->bAttacking = false;
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: in range but NO LOS → StopAttack"),
*AIC->GetName());
}
}
else if (bNowInRange && bCanAttack && !Memory->bAttacking)
{
// In range, regained LOS → restart attacking
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
Memory->bAttacking = true;
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: LOS regained → StartAttack on '%s'"),
*AIC->GetName(), *Target->GetName());
}
}
else if (!bNowInRange && Memory->bInRange)
{
// Left range → stop attacking
Memory->bInRange = false;
if (Pawn->Implements<UPS_AI_Behavior_Interface>() && Memory->bAttacking)
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
Memory->bAttacking = false;
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: out of range → StopAttack"),
*AIC->GetName());
}
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->StopMovement();
}
return EBTNodeResult::Aborted;
}
void UPS_AI_Behavior_BTTask_Attack::OnTaskFinished(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, EBTNodeResult::Type TaskResult)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
}
}
Super::OnTaskFinished(OwnerComp, NodeMemory, TaskResult);
}
// ─── EQS Firing Position Query ──────────────────────────────────────────────
void UPS_AI_Behavior_BTTask_Attack::RunFiringPositionQuery(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory,
APawn* Pawn, AActor* Target)
{
FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory);
if (!FiringPositionQuery)
{
// No EQS query configured → fallback: advance toward target
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
const float MidRange = (Memory->MinRange + Memory->MaxRange) * 0.5f;
AIC->MoveToLocation(
Target->GetActorLocation(), MidRange, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false);
Memory->bSeekingFiringPos = true;
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[%s] Attack: no EQS query, fallback advance toward target"),
*AIC->GetName());
}
return;
}
UWorld* World = Pawn->GetWorld();
UEnvQueryManager* EQSManager = UEnvQueryManager::GetCurrent(World);
if (!EQSManager)
{
return;
}
Memory->bEQSQueryRunning = true;
TWeakObjectPtr<AActor> WeakTarget = Target;
FEnvQueryRequest Request(FiringPositionQuery, Pawn);
Request.Execute(EEnvQueryRunMode::SingleResult,
FQueryFinishedSignature::CreateUObject(this,
&UPS_AI_Behavior_BTTask_Attack::OnFiringPositionQueryFinished,
&OwnerComp, NodeMemory, WeakTarget));
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attack: EQS firing position query launched"),
*Pawn->GetName());
}
void UPS_AI_Behavior_BTTask_Attack::OnFiringPositionQueryFinished(
TSharedPtr<FEnvQueryResult> Result,
UBehaviorTreeComponent* OwnerComp, uint8* NodeMemory,
TWeakObjectPtr<AActor> WeakTarget)
{
if (!OwnerComp || !NodeMemory) return;
FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory);
Memory->bEQSQueryRunning = false;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp->GetAIOwner());
if (!AIC || !AIC->GetPawn()) return;
AActor* Target = WeakTarget.Get();
// Fallback lambda: advance directly toward target
auto FallbackAdvance = [&](const TCHAR* Reason)
{
if (Target)
{
const float MidRange = (Memory->MinRange + Memory->MaxRange) * 0.5f;
AIC->MoveToLocation(
Target->GetActorLocation(), MidRange, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false);
Memory->bSeekingFiringPos = true;
}
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Attack: %s, fallback advance toward target"),
*AIC->GetName(), Reason);
};
if (Result.IsValid() && Result->IsSuccessful())
{
const FVector FiringPos = Result->GetItemAsLocation(0);
const EPathFollowingRequestResult::Type MoveResult = AIC->MoveToLocation(
FiringPos, 50.0f, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false);
if (MoveResult == EPathFollowingRequestResult::RequestSuccessful)
{
Memory->bSeekingFiringPos = true;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Attack: EQS moving to firing position %s"),
*AIC->GetName(), *FiringPos.ToString());
}
else
{
// AlreadyAtGoal or Failed — EQS position is too close or unreachable
FallbackAdvance(TEXT("EQS position unreachable or too close"));
}
}
else
{
FallbackAdvance(TEXT("EQS no result"));
}
}
FString UPS_AI_Behavior_BTTask_Attack::GetStaticDescription() const
{
return FString::Printf(TEXT("LOS-aware attack.\nFallback radius: %.0fcm\nLOS check: every %.1fs\nEQS query: %s"),
AttackMoveRadius, LOSCheckInterval,
FiringPositionQuery ? *FiringPositionQuery->GetName() : TEXT("None (fallback)"));
}

View File

@@ -0,0 +1,885 @@
// 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 "PS_AI_Behavior_Statics.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Navigation/PathFollowingComponent.h"
#include "CollisionQueryParams.h"
#include "EngineUtils.h"
#include "EnvironmentQuery/EnvQuery.h"
#include "EnvironmentQuery/EnvQueryManager.h"
#include "DrawDebugHelpers.h"
// ─── Helper: Crouch at cover if required ────────────────────────────────────
namespace
{
void CrouchAtCoverIfNeeded(APawn* Pawn, UBlackboardComponent* BB)
{
if (!Pawn || !Pawn->Implements<UPS_AI_Behavior_Interface>() || !BB) return;
const APS_AI_Behavior_CoverPoint* CoverPt =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (CoverPt && CoverPt->bCrouch)
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, true);
}
}
void SetSubState(UBlackboardComponent* BB, EPS_AI_Behavior_CombatSubState State)
{
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState, static_cast<uint8>(State));
}
}
// ─── Constructor ────────────────────────────────────────────────────────────
UPS_AI_Behavior_BTTask_CoverShootCycle::UPS_AI_Behavior_BTTask_CoverShootCycle()
{
NodeName = TEXT("Cover Shoot Cycle");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
// ─── ExecuteTask ────────────────────────────────────────────────────────────
EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return EBTNodeResult::Failed;
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;
}
AActor* Target = Cast<AActor>(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 ──────────────────────────────────────────
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
Memory->SubState = EPS_AI_Behavior_CombatSubState::Engaging;
Memory->Timer = 0.0f;
Memory->PhaseDuration = 0.0f;
Memory->CycleCount = 0;
Memory->bMoveRequested = false;
Memory->FiringPosition = FVector::ZeroVector;
Memory->bHasFiringPosition = false;
Memory->bEQSRunning = false;
Memory->MoveTimeout = 5.0f;
// 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);
const float AggrFactor = 0.7f + Aggressivity * 0.6f;
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)));
const float CautionFactor = 0.5f + Caution * 1.0f;
Memory->EffCoverMin *= CautionFactor;
Memory->EffCoverMax *= CautionFactor;
Memory->EffPeekMin /= CautionFactor;
Memory->EffPeekMax /= CautionFactor;
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, 30.0f, true, true, true, false);
if (Result == EPathFollowingRequestResult::Failed)
{
return EBTNodeResult::Failed;
}
// Face the threat while in cover
AIC->SetFocus(Target);
// Start attack immediately (draw weapon, enter combat stance)
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
}
if (Result == EPathFollowingRequestResult::AlreadyAtGoal)
{
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::AtCover);
}
else
{
Memory->bMoveRequested = true;
Memory->MoveTimeout = 5.0f;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::Engaging);
}
return EBTNodeResult::InProgress;
}
// ─── TickTask ───────────────────────────────────────────────────────────────
void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(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<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!Target)
{
AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted);
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(Pawn, Target))
{
AIC->StopMovement();
BB->ClearValue(PS_AI_Behavior_BB::ThreatActor);
AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted);
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
}
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
// Wait for EQS to complete
if (Memory->bEQSRunning) return;
switch (Memory->SubState)
{
// ─── ENGAGING: Moving to cover ──────────────────────────────────
case EPS_AI_Behavior_CombatSubState::Engaging:
{
Memory->MoveTimeout -= DeltaSeconds;
if (!Memory->bMoveRequested || AIC->GetMoveStatus() == EPathFollowingStatus::Idle || Memory->MoveTimeout <= 0.0f)
{
Memory->bMoveRequested = false;
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::AtCover);
AIC->SetFocus(Target);
CrouchAtCoverIfNeeded(Pawn, BB);
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:
{
// Flanking check: if threat has LOS to NPC, cover is compromised → abandon
Memory->LOSCheckTimer -= DeltaSeconds;
if (Memory->LOSCheckTimer <= 0.0f)
{
Memory->LOSCheckTimer = 0.5f;
// Check if threat can see NPC at chest height (not feet)
const FVector ThreatEye = Target->GetActorLocation() + FVector(0, 0, 60.0f);
const FVector NpcChest = Pawn->GetActorLocation() + FVector(0, 0, 60.0f);
FHitResult FlankHit;
FCollisionQueryParams FlankParams(SCENE_QUERY_STAT(FlankLOS), true);
FlankParams.AddIgnoredActor(Target);
FlankParams.AddIgnoredActor(Pawn);
const bool bThreatHasLOS = !Pawn->GetWorld()->LineTraceSingleByChannel(
FlankHit, ThreatEye, NpcChest, ECC_Visibility, FlankParams);
if (bThreatHasLOS)
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] CoverShootCycle: FLANKED — threat has LOS, abandoning cover"),
*AIC->GetName());
// Release current cover and fail → BT will re-run FindCover
APS_AI_Behavior_CoverPoint* OldPoint =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (OldPoint) OldPoint->Release(Pawn);
BB->ClearValue(PS_AI_Behavior_BB::CoverPoint);
BB->ClearValue(PS_AI_Behavior_BB::CoverLocation);
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
}
AIC->ClearFocus(EAIFocusPriority::Gameplay);
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
}
Memory->Timer -= DeltaSeconds;
if (Memory->Timer <= 0.0f)
{
// Try to find a firing position via EQS, or peek in place
StartPeeking(OwnerComp, NodeMemory, AIC, Pawn, Target);
}
break;
}
// ─── MOVING TO FIRE: Going to firing position ───────────────────
case EPS_AI_Behavior_CombatSubState::MovingToFire:
{
Memory->MoveTimeout -= DeltaSeconds;
if (!Memory->bMoveRequested || AIC->GetMoveStatus() == EPathFollowingStatus::Idle || Memory->MoveTimeout <= 0.0f)
{
// Arrived at firing position → start shooting
Memory->bMoveRequested = false;
Memory->SubState = EPS_AI_Behavior_CombatSubState::Peeking;
Memory->PhaseDuration = FMath::RandRange(Memory->EffPeekMin, Memory->EffPeekMax);
Memory->Timer = Memory->PhaseDuration;
Memory->LOSCheckTimer = 0.3f;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::Peeking);
AIC->ClearFocus(EAIFocusPriority::Gameplay);
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
}
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: at firing position, shooting for %.1fs"),
*AIC->GetName(), Memory->PhaseDuration);
}
break;
}
// ─── PEEKING: Shooting at target ────────────────────────────────
case EPS_AI_Behavior_CombatSubState::Peeking:
{
// Continuous LOS check
Memory->LOSCheckTimer -= DeltaSeconds;
if (Memory->LOSCheckTimer <= 0.0f)
{
Memory->LOSCheckTimer = 0.3f;
const bool bStillHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight(
Pawn->GetWorld(), Pawn, Target, 150.0f);
if (!bStillHasLOS)
{
// Lost LOS → stop shooting, return to cover
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
}
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] CoverShootCycle: lost LOS during peek → returning to cover"),
*AIC->GetName());
ReturnToCover(OwnerComp, NodeMemory, AIC, Pawn);
break;
}
}
Memory->Timer -= DeltaSeconds;
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Peeking: timer=%.2f/%.2f dt=%.4f"),
*AIC->GetName(), Memory->Timer, Memory->PhaseDuration, DeltaSeconds);
if (Memory->Timer <= 0.0f)
{
// Peek timer expired → stop shooting
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
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;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::Advancing);
APS_AI_Behavior_CoverPoint* OldPoint =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (OldPoint) OldPoint->Release(Pawn);
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());
Memory->bHasFiringPosition = false; // Reset firing position for new cover
AIC->MoveToLocation(
NewPoint->GetActorLocation(), 30.0f, true, true, true, false);
Memory->bMoveRequested = true;
Memory->MoveTimeout = 5.0f;
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 → return to current cover, reset cycle
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[%s] CoverShootCycle: no advancing cover found, returning to cover"),
*AIC->GetName());
ReturnToCover(OwnerComp, NodeMemory, AIC, Pawn);
Memory->CycleCount = 0;
}
}
else
{
// ─── Return to cover ────────────────────────────
ReturnToCover(OwnerComp, NodeMemory, AIC, Pawn);
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[%s] CoverShootCycle: returning to cover (cycle %d/%d)"),
*AIC->GetName(), Memory->CycleCount, Memory->EffMaxCycles);
}
}
break;
}
// ─── RETURNING TO COVER: Moving back after shooting ─────────────
case EPS_AI_Behavior_CombatSubState::ReturningToCover:
{
Memory->MoveTimeout -= DeltaSeconds;
if (!Memory->bMoveRequested || AIC->GetMoveStatus() == EPathFollowingStatus::Idle || Memory->MoveTimeout <= 0.0f)
{
Memory->bMoveRequested = false;
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::AtCover);
AIC->SetFocus(Target);
CrouchAtCoverIfNeeded(Pawn, BB);
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: back at cover, ducking for %.1fs"),
*AIC->GetName(), Memory->PhaseDuration);
}
break;
}
// ─── ADVANCING: Moving to next cover ────────────────────────────
case EPS_AI_Behavior_CombatSubState::Advancing:
{
Memory->MoveTimeout -= DeltaSeconds;
if (!Memory->bMoveRequested || AIC->GetMoveStatus() == EPathFollowingStatus::Idle || Memory->MoveTimeout <= 0.0f)
{
Memory->bMoveRequested = false;
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::AtCover);
AIC->SetFocus(Target);
CrouchAtCoverIfNeeded(Pawn, BB);
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: arrived at new cover, ducking"),
*AIC->GetName());
}
break;
}
}
// ─── Debug visualization ────────────────────────────────────────
#if ENABLE_DRAW_DEBUG
if (bDebugDraw && Pawn && Target)
{
UWorld* World = Pawn->GetWorld();
const FVector HeadLoc = Pawn->GetActorLocation() + FVector(0, 0, 65.0f);
const FVector TargetLoc = Target->GetActorLocation();
const FVector CoverLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
// Sub-state label
const TCHAR* SubStateStr = TEXT("?");
FColor SubStateColor = FColor::White;
switch (Memory->SubState)
{
case EPS_AI_Behavior_CombatSubState::Engaging: SubStateStr = TEXT("ENGAGING"); SubStateColor = FColor::Yellow; break;
case EPS_AI_Behavior_CombatSubState::AtCover: SubStateStr = TEXT("AT COVER"); SubStateColor = FColor::Cyan; break;
case EPS_AI_Behavior_CombatSubState::MovingToFire: SubStateStr = TEXT("→ FIRE POS"); SubStateColor = FColor::Magenta; break;
case EPS_AI_Behavior_CombatSubState::Peeking: SubStateStr = TEXT("PEEKING"); SubStateColor = FColor::Red; break;
case EPS_AI_Behavior_CombatSubState::ReturningToCover: SubStateStr = TEXT("→ COVER"); SubStateColor = FColor::Blue; break;
case EPS_AI_Behavior_CombatSubState::Advancing: SubStateStr = TEXT("ADVANCING"); SubStateColor = FColor::Orange; break;
}
DrawDebugString(World, HeadLoc + FVector(0, 0, 30.0f),
FString::Printf(TEXT("%s [%.1fs] C:%d/%d"), SubStateStr, Memory->Timer, Memory->CycleCount, Memory->EffMaxCycles),
nullptr, SubStateColor, 0.0f, true);
// LOS line to target (eye height on both ends)
const bool bLOS = UPS_AI_Behavior_Statics::HasLineOfSight(World, Pawn, Target, 150.0f);
const FVector TargetHeadLoc = TargetLoc + FVector(0, 0, 60.0f);
DrawDebugLine(World, HeadLoc, TargetHeadLoc,
bLOS ? FColor::Green : FColor::Red, false, 0.0f, 0, 1.0f);
// Firing position marker
if (Memory->bHasFiringPosition)
{
// Firing position: red solid point
DrawDebugSphere(World, Memory->FiringPosition + FVector(0, 0, 30.0f),
20.0f, 8, FColor::Red, false, 0.0f);
// Line cover → firing position (white)
DrawDebugLine(World, CoverLoc + FVector(0, 0, 20.0f),
Memory->FiringPosition + FVector(0, 0, 20.0f),
FColor::White, false, 0.0f, 0, 1.0f);
// Line firing position → threat (green, head height)
DrawDebugLine(World, Memory->FiringPosition + FVector(0, 0, 65.0f),
TargetHeadLoc,
FColor(0, 200, 0), false, 0.0f, 0, 1.0f);
DrawDebugString(World, Memory->FiringPosition + FVector(0, 0, 50.0f),
TEXT("FIRE"), nullptr, FColor::Red, 0.0f, true);
}
else if (Memory->SubState == EPS_AI_Behavior_CombatSubState::Peeking)
{
// No firing position — shooting in place
DrawDebugSphere(World, Pawn->GetActorLocation() + FVector(0, 0, 30.0f),
15.0f, 6, FColor::Orange, false, 0.0f);
DrawDebugString(World, Pawn->GetActorLocation() + FVector(0, 0, 50.0f),
TEXT("IN PLACE"), nullptr, FColor::Orange, 0.0f, true);
}
}
#endif
}
// ─── StartPeeking ───────────────────────────────────────────────────────────
void UPS_AI_Behavior_BTTask_CoverShootCycle::StartPeeking(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory,
APS_AI_Behavior_AIController* AIC, APawn* Pawn, AActor* Target)
{
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
// If we have a FiringPositionQuery → find a firing position via EQS
if (FiringPositionQuery)
{
RunFiringPositionQuery(OwnerComp, NodeMemory, Pawn);
return; // EQS callback will handle the transition
}
// No EQS query → legacy behavior: check LOS from cover and shoot in place
const bool bHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight(
Pawn->GetWorld(), Pawn, Target, 150.0f);
if (!bHasLOS)
{
// No LOS and no firing position query → try to advance
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] CoverShootCycle: no LOS from cover and no FiringPositionQuery → advancing"),
*AIC->GetName());
Memory->SubState = EPS_AI_Behavior_CombatSubState::Advancing;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::Advancing);
APS_AI_Behavior_CoverPoint* OldPoint =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (OldPoint) OldPoint->Release(Pawn);
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(), 30.0f, true, true, true, false);
Memory->bMoveRequested = true;
Memory->MoveTimeout = 5.0f;
Memory->CycleCount = 0;
}
else
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] CoverShootCycle: no LOS and no advancing cover → abandoning cover"),
*AIC->GetName());
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
}
return;
}
// Has LOS → shoot in place (legacy)
Memory->SubState = EPS_AI_Behavior_CombatSubState::Peeking;
Memory->PhaseDuration = FMath::RandRange(Memory->EffPeekMin, Memory->EffPeekMax);
Memory->Timer = Memory->PhaseDuration;
Memory->LOSCheckTimer = 0.3f;
Memory->bHasFiringPosition = false;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::Peeking);
AIC->ClearFocus(EAIFocusPriority::Gameplay);
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
}
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: peeking in place for %.1fs"),
*AIC->GetName(), Memory->PhaseDuration);
}
// ─── ReturnToCover ──────────────────────────────────────────────────────────
void UPS_AI_Behavior_BTTask_CoverShootCycle::ReturnToCover(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory,
APS_AI_Behavior_AIController* AIC, APawn* Pawn)
{
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
// If we have a firing position (we moved away from cover), move back
if (Memory->bHasFiringPosition)
{
const FVector CoverLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
Memory->SubState = EPS_AI_Behavior_CombatSubState::ReturningToCover;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::ReturningToCover);
AIC->MoveToLocation(CoverLoc, 30.0f, true, true, true, false);
Memory->bMoveRequested = true;
Memory->MoveTimeout = 5.0f;
}
else
{
// No firing position was used → already at cover, duck directly
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::AtCover);
AIC->SetFocus(Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)));
CrouchAtCoverIfNeeded(Pawn, BB);
}
}
// ─── EQS Firing Position ────────────────────────────────────────────────────
void UPS_AI_Behavior_BTTask_CoverShootCycle::RunFiringPositionQuery(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, APawn* Pawn)
{
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
UWorld* World = Pawn->GetWorld();
UEnvQueryManager* EQSManager = UEnvQueryManager::GetCurrent(World);
if (!EQSManager)
{
// Fallback: peek in place
Memory->bHasFiringPosition = false;
return;
}
Memory->bEQSRunning = true;
FEnvQueryRequest Request(FiringPositionQuery, Pawn);
Request.Execute(EEnvQueryRunMode::SingleResult,
FQueryFinishedSignature::CreateUObject(this,
&UPS_AI_Behavior_BTTask_CoverShootCycle::OnFiringPositionQueryFinished,
&OwnerComp, NodeMemory));
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: firing position EQS launched"),
*Pawn->GetName());
}
void UPS_AI_Behavior_BTTask_CoverShootCycle::OnFiringPositionQueryFinished(
TSharedPtr<FEnvQueryResult> Result,
UBehaviorTreeComponent* OwnerComp, uint8* NodeMemory)
{
if (!OwnerComp || !NodeMemory) return;
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
Memory->bEQSRunning = false;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp->GetAIOwner());
if (!AIC || !AIC->GetPawn()) return;
APawn* Pawn = AIC->GetPawn();
UBlackboardComponent* BB = OwnerComp->GetBlackboardComponent();
if (!BB) return;
AActor* Target = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (Result.IsValid() && Result->IsSuccessful())
{
// Found a firing position → move to it
Memory->FiringPosition = Result->GetItemAsLocation(0);
Memory->bHasFiringPosition = true;
Memory->SubState = EPS_AI_Behavior_CombatSubState::MovingToFire;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::MovingToFire);
// Stand up to move to firing position
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
}
AIC->MoveToLocation(Memory->FiringPosition, 30.0f, true, true, true, false);
Memory->bMoveRequested = true;
Memory->MoveTimeout = 5.0f;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] CoverShootCycle: moving to firing position %s"),
*AIC->GetName(), *Memory->FiringPosition.ToString());
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
UWorld* World = Pawn->GetWorld();
// Draw all EQS firing candidates as solid boxes
const int32 NumItems = Result->Items.Num();
for (int32 i = 0; i < NumItems; ++i)
{
if (!Result->Items[i].IsValid()) continue;
const FVector ItemLoc = Result->GetItemAsLocation(i);
const float ItemScore = Result->GetItemScore(i);
const uint8 G = static_cast<uint8>(FMath::Lerp(50.0f, 255.0f, FMath::Clamp(ItemScore, 0.0f, 1.0f)));
DrawDebugBox(World, ItemLoc + FVector(0, 0, 20.0f),
FVector(8.0f), FColor(255, G, 0), false, 5.0f);
}
// Chosen firing position: large red point
DrawDebugSphere(World, Memory->FiringPosition + FVector(0, 0, 30.0f),
25.0f, 8, FColor::Red, false, 5.0f);
DrawDebugString(World, Memory->FiringPosition + FVector(0, 0, 55.0f),
FString::Printf(TEXT("FIRE POS (%d)"), NumItems),
nullptr, FColor::Red, 5.0f, true);
}
#endif
}
else
{
// EQS failed → fallback: peek in place if LOS exists
Memory->bHasFiringPosition = false;
const int32 NumItems = Result.IsValid() ? Result->Items.Num() : 0;
const FVector CoverLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] CoverShootCycle: firing position EQS FAILED at cover %s — items=%d"),
*AIC->GetName(), *CoverLoc.ToString(), NumItems);
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
UWorld* World = Pawn->GetWorld();
const FVector PawnLoc = Pawn->GetActorLocation();
DrawDebugSphere(World, PawnLoc + FVector(0, 0, 30.0f),
20.0f, 8, FColor::Orange, false, 5.0f);
DrawDebugString(World, PawnLoc + FVector(0, 0, 55.0f),
TEXT("NO FIRE POS"), nullptr, FColor::Orange, 5.0f, true);
}
#endif
if (Target)
{
const bool bHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight(
Pawn->GetWorld(), Pawn, Target, 150.0f);
if (bHasLOS)
{
Memory->SubState = EPS_AI_Behavior_CombatSubState::Peeking;
Memory->PhaseDuration = FMath::RandRange(Memory->EffPeekMin, Memory->EffPeekMax);
Memory->Timer = Memory->PhaseDuration;
Memory->LOSCheckTimer = 0.3f;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::Peeking);
AIC->ClearFocus(EAIFocusPriority::Gameplay);
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
}
}
else
{
// No LOS, no firing position → stay in cover, wait
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::AtCover);
AIC->SetFocus(Target);
}
}
}
}
// ─── AbortTask ──────────────────────────────────────────────────────────────
EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->StopMovement();
AIC->ClearFocus(EAIFocusPriority::Gameplay);
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
}
}
return EBTNodeResult::Aborted;
}
// ─── OnTaskFinished ─────────────────────────────────────────────────────────
void UPS_AI_Behavior_BTTask_CoverShootCycle::OnTaskFinished(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, EBTNodeResult::Type TaskResult)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->ClearFocus(EAIFocusPriority::Gameplay);
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
}
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (BB)
{
APS_AI_Behavior_CoverPoint* Point =
Cast<APS_AI_Behavior_CoverPoint>(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<APS_AI_Behavior_CoverPoint> It(const_cast<UWorld*>(World)); It; ++It)
{
APS_AI_Behavior_CoverPoint* Point = *It;
if (!Point || !Point->bEnabled) continue;
if (Point->PointType != CoverPointType) continue;
if (!Point->IsAccessibleTo(NPCType)) continue;
if (!Point->HasRoom()) continue;
const float DistFromNpc = FVector::Dist(NpcLoc, Point->GetActorLocation());
if (DistFromNpc > AdvanceSearchRadius) continue;
const float CoverDistToThreat = FVector::Dist(Point->GetActorLocation(), ThreatLoc);
if (CoverDistToThreat >= NpcDistToThreat) continue;
float Score = Point->EvaluateAgainstThreat(ThreatLoc);
Score += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, AdvanceSearchRadius), FVector2D(0.15f, 0.0f), DistFromNpc);
if (NpcDistToThreat > 0.0f)
{
const float AdvanceRatio = (NpcDistToThreat - CoverDistToThreat) / NpcDistToThreat;
Score += AdvancementBias * AdvanceRatio * 0.3f;
}
// LOS bonus — favor covers near positions with LOS to threat
{
const FVector TraceStart = Point->GetActorLocation() + FVector(0, 0, 150.0f);
FHitResult Hit;
FCollisionQueryParams Params(SCENE_QUERY_STAT(CoverLOS), true);
if (!const_cast<UWorld*>(World)->LineTraceSingleByChannel(
Hit, TraceStart, ThreatLoc, ECC_Visibility, Params))
{
Score += 0.3f;
}
}
if (Score > OutScore)
{
OutScore = Score;
BestPoint = Point;
}
}
return BestPoint;
}
// ─── GetStaticDescription ───────────────────────────────────────────────────
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)\nFiring EQS: %s"),
PeekDurationMin, PeekDurationMax,
CoverDurationMin, CoverDurationMax,
MaxCyclesBeforeAdvance, AdvanceSearchRadius,
FiringPositionQuery ? *FiringPositionQuery->GetName() : TEXT("None (in-place)"));
}

View File

@@ -0,0 +1,326 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_FindAndFollowSpline.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_SplineFollowerComponent.h"
#include "PS_AI_Behavior_SplineNetwork.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Statics.h"
#include "PS_AI_Behavior_Definitions.h"
#include "Navigation/PathFollowingComponent.h"
UPS_AI_Behavior_BTTask_FindAndFollowSpline::UPS_AI_Behavior_BTTask_FindAndFollowSpline()
{
NodeName = TEXT("Find & Start Spline");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindAndFollowSpline::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
UPS_AI_Behavior_SplineFollowerComponent* Follower =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (!Follower)
{
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("[%s] FindAndFollowSpline: no SplineFollowerComponent."), *AIC->GetName());
return EBTNodeResult::Failed;
}
// Respect a manual StopFollowing(): do not search for or resume a spline until
// the caller explicitly StartFollowing() again. Without this, the task would
// resume any CurrentSpline and override the stop on the very next tick.
if (Follower->bManuallyStopped)
{
return EBTNodeResult::Failed;
}
// Debug: log state on entry
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] FindAndFollowSpline: bIsFollowing=%d CurrentSpline=%s"),
*AIC->GetName(),
(int32)Follower->bIsFollowing,
Follower->CurrentSpline ? *Follower->CurrentSpline->GetName() : TEXT("null"));
}
// If already following a spline, don't re-search — just succeed immediately
// The Follow Spline task will continue the movement
if (Follower->bIsFollowing && Follower->CurrentSpline)
{
return EBTNodeResult::Succeeded;
}
// If paused (e.g. after combat abort) but still has a valid spline, resume it
// Find the closest point on the spline from NPC's CURRENT position (not old pause point)
if (!Follower->bIsFollowing && Follower->CurrentSpline)
{
const FVector PawnLoc = AIC->GetPawn()->GetActorLocation();
float ClosestDist = 0.0f;
FVector ClosestPoint = FVector::ZeroVector;
const float GapToSpline = Follower->CurrentSpline->GetClosestPointOnSpline(
PawnLoc, ClosestDist, ClosestPoint);
// Determine direction from pawn forward vs spline tangent at closest point
const FVector PawnFwd = AIC->GetPawn()->GetActorForwardVector();
const FVector SplineDir = Follower->CurrentSpline->GetWorldDirectionAtDistance(ClosestDist);
const bool bForward = FVector::DotProduct(PawnFwd, SplineDir) >= 0.0f;
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindAndFollowSpline: resuming spline '%s' at closest point (gap=%.0fcm, dist=%.0f, bWalkToSpline=%d, AcceptanceRadius=%.0f)"),
*AIC->GetName(), *Follower->CurrentSpline->GetName(), GapToSpline, ClosestDist,
(int32)bWalkToSpline, AcceptanceRadius);
}
if (bWalkToSpline && GapToSpline > AcceptanceRadius)
{
// Walk to closest spline point via NavMesh
Follower->CurrentDistance = ClosestDist;
const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation(
ClosestPoint, AcceptanceRadius, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectDestinationToNavigation=*/true,
/*bCanStrafe=*/false);
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindAndFollowSpline: MoveToLocation result=%d (0=Failed, 1=AlreadyAtGoal, 2=RequestSuccessful)"),
*AIC->GetName(), (int32)Result);
}
if (Result == EPathFollowingRequestResult::AlreadyAtGoal)
{
Follower->StartFollowingAtDistance(Follower->CurrentSpline, ClosestDist, bForward);
return EBTNodeResult::Succeeded;
}
if (Result != EPathFollowingRequestResult::Failed)
{
FFindSplineMemory* Memory = reinterpret_cast<FFindSplineMemory*>(NodeMemory);
Memory->bMovingToSpline = true;
return EBTNodeResult::InProgress;
}
// Pathfinding failed — fall through to full re-search below
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("[%s] FindAndFollowSpline: pathfinding FAILED to reach spline, falling through to re-search"),
*AIC->GetName());
}
else
{
// Close enough — clear any residual movement request and start following
AIC->StopMovement();
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindAndFollowSpline: close enough, StartFollowingAtDistance(dist=%.0f, fwd=%d)"),
*AIC->GetName(), ClosestDist, (int32)bForward);
}
Follower->StartFollowingAtDistance(Follower->CurrentSpline, ClosestDist, bForward);
return EBTNodeResult::Succeeded;
}
}
// Determine NPC type
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Civilian;
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
if (Personality)
{
NPCType = Personality->GetNPCType();
}
// Find closest spline
UPS_AI_Behavior_SplineNetwork* Network =
GetWorld()->GetSubsystem<UPS_AI_Behavior_SplineNetwork>();
if (!Network)
{
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("[%s] FindAndFollowSpline: SplineNetwork subsystem not available."), *AIC->GetName());
return EBTNodeResult::Failed;
}
APS_AI_Behavior_SplinePath* ClosestSpline = nullptr;
float DistAlongSpline = 0.0f;
if (!Network->FindClosestSpline(
AIC->GetPawn()->GetActorLocation(), NPCType, MaxSearchDistance,
ClosestSpline, DistAlongSpline))
{
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindAndFollowSpline: no accessible spline within %.0fcm."),
*AIC->GetName(), MaxSearchDistance);
}
return EBTNodeResult::Failed;
}
// Check if we need to walk to the spline first
const FVector SplinePoint = ClosestSpline->GetWorldLocationAtDistance(DistAlongSpline);
const float GapToSpline = FVector::Dist(AIC->GetPawn()->GetActorLocation(), SplinePoint);
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindAndFollowSpline: found spline '%s' at dist=%.0f/%.0f, gap=%.0fcm"),
*AIC->GetName(), *ClosestSpline->GetName(), DistAlongSpline,
ClosestSpline->GetSplineLength(), GapToSpline);
}
if (bWalkToSpline && GapToSpline > AcceptanceRadius)
{
// Try to reach the closest spline point. If blocked, sample other points along the spline.
const float SplineLength = ClosestSpline->GetSplineLength();
const int32 NumSamples = 8;
const float SampleStep = SplineLength / NumSamples;
// Start with the closest point, then try evenly spaced samples
TArray<float> DistancesToTry;
DistancesToTry.Add(DistAlongSpline);
for (int32 i = 1; i <= NumSamples; ++i)
{
const float D = FMath::Fmod(DistAlongSpline + i * SampleStep, SplineLength);
DistancesToTry.Add(D);
}
float ReachableDist = -1.0f;
FVector ReachablePoint = FVector::ZeroVector;
for (float TestDist : DistancesToTry)
{
const FVector TestPoint = ClosestSpline->GetWorldLocationAtDistance(TestDist);
const EPathFollowingRequestResult::Type TestResult = AIC->MoveToLocation(
TestPoint, AcceptanceRadius, true, true, true, false);
if (TestResult == EPathFollowingRequestResult::AlreadyAtGoal)
{
const FVector Fwd = AIC->GetPawn()->GetActorForwardVector();
const FVector SpDir = ClosestSpline->GetWorldDirectionAtDistance(TestDist);
Follower->StartFollowingAtDistance(ClosestSpline, TestDist,
FVector::DotProduct(Fwd, SpDir) >= 0.0f);
return EBTNodeResult::Succeeded;
}
if (TestResult != EPathFollowingRequestResult::Failed)
{
ReachableDist = TestDist;
ReachablePoint = TestPoint;
break; // Found a reachable point, use it
}
// This point is blocked, try next
AIC->StopMovement();
}
if (ReachableDist < 0.0f)
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindAndFollowSpline: no reachable point on spline '%s' (%d samples tested)"),
*AIC->GetName(), *ClosestSpline->GetName(), DistancesToTry.Num());
return EBTNodeResult::Failed;
}
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindAndFollowSpline: walking to reachable spline point at dist=%.0f (gap=%.0fcm)"),
*AIC->GetName(), ReachableDist,
FVector::Dist(AIC->GetPawn()->GetActorLocation(), ReachablePoint));
Follower->CurrentSpline = ClosestSpline;
Follower->CurrentDistance = ReachableDist;
FFindSplineMemory* Memory = reinterpret_cast<FFindSplineMemory*>(NodeMemory);
Memory->bMovingToSpline = true;
return EBTNodeResult::InProgress;
}
// Close enough — start immediately
// Choose direction based on NPC's current movement direction vs spline tangent
const FVector NpcForward = AIC->GetPawn()->GetActorForwardVector();
const FVector SplineDirForward = ClosestSpline->GetWorldDirectionAtDistance(DistAlongSpline);
const float DotForward = FVector::DotProduct(NpcForward, SplineDirForward);
const bool bForward = (DotForward >= 0.0f);
// If NPC is very close to an end, ensure we don't start going into a wall
const float SplineLen = ClosestSpline->GetSplineLength();
const float MinEndMargin = 50.0f;
bool bFinalForward = bForward;
if (bFinalForward && DistAlongSpline > SplineLen - MinEndMargin)
{
bFinalForward = false; // Too close to the end, go backward
}
else if (!bFinalForward && DistAlongSpline < MinEndMargin)
{
bFinalForward = true; // Too close to the start, go forward
}
Follower->StartFollowingAtDistance(ClosestSpline, DistAlongSpline, bFinalForward);
return EBTNodeResult::Succeeded;
}
void UPS_AI_Behavior_BTTask_FindAndFollowSpline::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
FFindSplineMemory* Memory = reinterpret_cast<FFindSplineMemory*>(NodeMemory);
if (!Memory->bMovingToSpline) return;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn())
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
// Manual StopFollowing() requested mid-approach: cancel the move-to-spline and bail.
if (UPS_AI_Behavior_SplineFollowerComponent* StopCheck =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>())
{
if (StopCheck->bManuallyStopped)
{
AIC->StopMovement();
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
}
// Check if we've reached the spline
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
Memory->bMovingToSpline = false;
UPS_AI_Behavior_SplineFollowerComponent* Follower =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (Follower && Follower->CurrentSpline)
{
Follower->StartFollowingAtDistance(
Follower->CurrentSpline, Follower->CurrentDistance);
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
else
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
}
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindAndFollowSpline::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
AAIController* AIC = OwnerComp.GetAIOwner();
if (AIC)
{
AIC->StopMovement();
}
return EBTNodeResult::Aborted;
}
FString UPS_AI_Behavior_BTTask_FindAndFollowSpline::GetStaticDescription() const
{
return FString::Printf(TEXT("Find nearest spline (max %.0fcm) and start following%s"),
MaxSearchDistance, bWalkToSpline ? TEXT(" (walk to)") : TEXT(""));
}

View File

@@ -0,0 +1,583 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_FindCover.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_Statics.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"
#include "Navigation/PathFollowingComponent.h"
#include "CollisionQueryParams.h"
#include "Engine/World.h"
#include "EngineUtils.h"
#include "EnvironmentQuery/EnvQuery.h"
#include "EnvironmentQuery/EnvQueryManager.h"
#include "DrawDebugHelpers.h"
namespace
{
FColor ScoreToColor(float Score)
{
// 0 = red, 0.5 = yellow, 1 = green
const float Clamped = FMath::Clamp(Score, 0.0f, 1.0f);
const uint8 R = static_cast<uint8>(FMath::Lerp(255.0f, 0.0f, Clamped));
const uint8 G = static_cast<uint8>(FMath::Lerp(0.0f, 255.0f, Clamped));
return FColor(R, G, 0);
}
}
UPS_AI_Behavior_BTTask_FindCover::UPS_AI_Behavior_BTTask_FindCover()
{
NodeName = TEXT("Find Cover");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindCover::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return EBTNodeResult::Failed;
UWorld* World = GetWorld();
if (!World) return EBTNodeResult::Failed;
const FVector NpcLoc = AIC->GetPawn()->GetActorLocation();
const FVector ThreatLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::ThreatLocation);
if (ThreatLoc.IsZero())
{
return EBTNodeResult::Failed;
}
// Determine NPC type for accessibility filtering
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Any;
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
if (Personality)
{
NPCType = Personality->GetNPCType();
}
// ─── Phase 1: Search manual CoverPoints ─────────────────────────────
float ManualScore = -1.0f;
APS_AI_Behavior_CoverPoint* BestManualPoint =
FindBestManualCoverPoint(World, NpcLoc, ThreatLoc, NPCType, ManualScore);
FVector BestCoverPos = FVector::ZeroVector;
float BestScore = -1.0f;
APS_AI_Behavior_CoverPoint* ChosenPoint = nullptr;
if (BestManualPoint)
{
BestCoverPos = BestManualPoint->GetActorLocation();
BestScore = ManualScore + ManualPointBonus; // Bonus for manual placement
ChosenPoint = BestManualPoint;
}
// ─── Phase 2: Procedural fallback (if allowed) ──────────────────────
if (!bUseManualPointsOnly)
{
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(World);
if (NavSys)
{
for (int32 i = 0; i < NumCandidates; ++i)
{
const float Angle = (360.0f / NumCandidates) * i;
const float Dist = FMath::RandRange(SearchRadius * 0.3f, SearchRadius);
const FVector Dir = FVector::ForwardVector.RotateAngleAxis(Angle, FVector::UpVector);
const FVector Candidate = NpcLoc + Dir * Dist;
FNavLocation NavLoc;
if (!NavSys->ProjectPointToNavigation(Candidate, NavLoc, FVector(300.0f, 300.0f, 200.0f)))
{
continue;
}
const float Score = EvaluateCoverQuality(World, NavLoc.Location, ThreatLoc, NpcLoc);
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
DrawDebugSphere(World, NavLoc.Location + FVector(0, 0, 30.0f),
15.0f, 6, ScoreToColor(Score), false, 5.0f);
DrawDebugString(World, NavLoc.Location + FVector(0, 0, 55.0f),
FString::Printf(TEXT("%.2f"), Score), nullptr, FColor::White, 5.0f, true);
}
#endif
if (Score > BestScore)
{
BestScore = Score;
BestCoverPos = NavLoc.Location;
ChosenPoint = nullptr; // Procedural, no actor
}
}
}
}
if (BestScore < 0.1f)
{
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] FindCover: no suitable cover found."), *AIC->GetName());
}
return EBTNodeResult::Failed;
}
// ─── Claim and write to Blackboard ──────────────────────────────────
if (ChosenPoint)
{
ChosenPoint->Claim(AIC->GetPawn());
BB->SetValueAsObject(PS_AI_Behavior_BB::CoverPoint, ChosenPoint);
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] FindCover: using manual %s '%s' (score %.2f)"),
*AIC->GetName(),
ChosenPoint->PointType == EPS_AI_Behavior_CoverPointType::Cover ? TEXT("Cover") : TEXT("HidingSpot"),
*ChosenPoint->GetName(), BestScore);
}
}
else
{
BB->ClearValue(PS_AI_Behavior_BB::CoverPoint);
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] FindCover: using procedural cover (score %.2f)"),
*AIC->GetName(), BestScore);
}
}
BB->SetValueAsVector(PS_AI_Behavior_BB::CoverLocation, BestCoverPos);
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
// Search radius circle
DrawDebugCircle(World, NpcLoc, SearchRadius, 32, FColor(80, 80, 80),
false, 5.0f, 0, 1.0f, FVector::RightVector, FVector::ForwardVector);
// Chosen cover: large sphere + line from NPC
DrawDebugSphere(World, BestCoverPos + FVector(0, 0, 40.0f),
30.0f, 10, FColor::Cyan, false, 5.0f);
DrawDebugLine(World, NpcLoc, BestCoverPos, FColor::Cyan, false, 5.0f, 0, 1.5f);
DrawDebugString(World, BestCoverPos + FVector(0, 0, 65.0f),
FString::Printf(TEXT("COVER %.2f %s"), BestScore,
ChosenPoint ? TEXT("(Manual)") : TEXT("(Procedural)")),
nullptr, FColor::Cyan, 5.0f, true);
// Line to threat
DrawDebugLine(World, BestCoverPos, ThreatLoc, FColor(255, 100, 0), false, 5.0f, 0, 1.0f);
}
#endif
FCoverMemory* Memory = reinterpret_cast<FCoverMemory*>(NodeMemory);
// If we have a refinement EQS query and a manual CoverPoint, refine the position
if (RefinementQuery && ChosenPoint)
{
RunRefinementQuery(OwnerComp, NodeMemory, AIC->GetPawn(), BestCoverPos);
return EBTNodeResult::InProgress;
}
// Navigate to cover directly (no refinement)
const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation(
BestCoverPos, AcceptanceRadius, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectDestinationToNavigation=*/true,
/*bCanStrafe=*/false);
if (Result == EPathFollowingRequestResult::Failed)
{
return EBTNodeResult::Failed;
}
if (Result == EPathFollowingRequestResult::AlreadyAtGoal)
{
return EBTNodeResult::Succeeded;
}
Memory->bMoveRequested = true;
return EBTNodeResult::InProgress;
}
void UPS_AI_Behavior_BTTask_FindCover::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
FCoverMemory* Memory = reinterpret_cast<FCoverMemory*>(NodeMemory);
if (Memory->bEQSRunning) return; // Waiting for EQS callback
if (!Memory->bMoveRequested) return;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) { FinishLatentTask(OwnerComp, EBTNodeResult::Failed); return; }
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
// Verify we actually reached the cover — pathfinding can stop early (blocked by other NPCs, etc.)
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
APawn* Pawn = AIC->GetPawn();
if (BB && Pawn)
{
const FVector CoverLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
const float DistToCover = FVector::Dist2D(Pawn->GetActorLocation(), CoverLoc);
if (UPS_AI_Behavior_Statics::IsDebugEnabled(AIC->GetPawn()))
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindCover: MoveStatus=Idle, dist2D=%.0fcm, acceptance=%.0fcm, coverLoc=%s, pawnLoc=%s"),
*AIC->GetName(), DistToCover, AcceptanceRadius,
*CoverLoc.ToString(), *Pawn->GetActorLocation().ToString());
}
if (DistToCover > AcceptanceRadius * 2.0f)
{
// Still too far — retry movement
const EPathFollowingRequestResult::Type Retry = AIC->MoveToLocation(
CoverLoc, AcceptanceRadius, true, true, true, false);
if (Retry == EPathFollowingRequestResult::Failed)
{
// Can't reach at all — give up
Memory->bMoveRequested = false;
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
if (Retry != EPathFollowingRequestResult::AlreadyAtGoal)
{
return; // Keep waiting for retry movement
}
// AlreadyAtGoal — fall through to success
}
}
Memory->bMoveRequested = false;
// Crouch at cover if the point requires it
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>() && BB)
{
const APS_AI_Behavior_CoverPoint* CoverPt =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (CoverPt && CoverPt->bCrouch)
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, true);
}
}
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindCover::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->StopMovement();
// Stand up if crouching
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
}
// Release any claimed cover point
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (BB)
{
APS_AI_Behavior_CoverPoint* Point =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (Point)
{
if (Pawn) Point->Release(Pawn);
BB->ClearValue(PS_AI_Behavior_BB::CoverPoint);
}
}
}
return EBTNodeResult::Aborted;
}
// ─── Manual CoverPoint Search ───────────────────────────────────────────────
APS_AI_Behavior_CoverPoint* UPS_AI_Behavior_BTTask_FindCover::FindBestManualCoverPoint(
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;
for (TActorIterator<APS_AI_Behavior_CoverPoint> It(const_cast<UWorld*>(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
const float Dist = FVector::Dist(NpcLoc, Point->GetActorLocation());
if (Dist > SearchRadius) continue;
// Evaluate quality against current threat
float Score = Point->EvaluateAgainstThreat(ThreatLoc);
// Distance bonus — closer to NPC is better
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;
}
}
// Debug: sphere colored by score (cover candidates)
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
const FVector Loc = Point->GetActorLocation();
DrawDebugSphere(const_cast<UWorld*>(World), Loc + FVector(0, 0, 30.0f),
15.0f, 6, ScoreToColor(Score), false, 5.0f);
DrawDebugString(const_cast<UWorld*>(World), Loc + FVector(0, 0, 55.0f),
FString::Printf(TEXT("%.2f"), Score), nullptr, FColor::White, 5.0f, true);
}
#endif
if (Score > OutScore)
{
OutScore = Score;
BestPoint = Point;
}
}
return BestPoint;
}
// ─── Procedural Cover Quality ───────────────────────────────────────────────
float UPS_AI_Behavior_BTTask_FindCover::EvaluateCoverQuality(
const UWorld* World, const FVector& CandidatePos,
const FVector& ThreatLoc, const FVector& NpcLoc) const
{
float Score = 0.0f;
const FVector TraceStart = CandidatePos + FVector(0, 0, MinCoverHeight);
const FVector TraceEnd = ThreatLoc + FVector(0, 0, 100.0f);
FHitResult Hit;
FCollisionQueryParams Params(SCENE_QUERY_STAT(CoverCheck), true);
if (World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_Visibility, Params))
{
Score += 0.5f;
}
const FVector TraceStartLow = CandidatePos + FVector(0, 0, MinCoverHeight * 0.5f);
if (World->LineTraceSingleByChannel(Hit, TraceStartLow, TraceEnd, ECC_Visibility, Params))
{
Score += 0.15f;
}
const float DistFromThreat = FVector::Dist(CandidatePos, ThreatLoc);
const float DistFromNpc = FVector::Dist(CandidatePos, NpcLoc);
Score += FMath::GetMappedRangeValueClamped(
FVector2D(300.0f, 1500.0f), FVector2D(0.0f, 0.2f), DistFromThreat);
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;
}
FString UPS_AI_Behavior_BTTask_FindCover::GetStaticDescription() const
{
return FString::Printf(TEXT("Find cover within %.0fcm\nManual %s + Procedural (%d candidates)\nBonus: +%.0f%%\nRefinement: %s"),
SearchRadius,
CoverPointType == EPS_AI_Behavior_CoverPointType::Cover ? TEXT("Cover") : TEXT("Hiding"),
NumCandidates,
ManualPointBonus * 100.0f,
RefinementQuery ? *RefinementQuery->GetName() : TEXT("None"));
}
// ─── EQS Refinement ────────────────────────────────────────────────────────
void UPS_AI_Behavior_BTTask_FindCover::RunRefinementQuery(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory,
APawn* Pawn, const FVector& CoverCenter)
{
FCoverMemory* Memory = reinterpret_cast<FCoverMemory*>(NodeMemory);
UWorld* World = Pawn->GetWorld();
UEnvQueryManager* EQSManager = UEnvQueryManager::GetCurrent(World);
if (!EQSManager)
{
// Fallback: move directly to the cover center
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->MoveToLocation(CoverCenter, AcceptanceRadius, true, true, true, false);
Memory->bMoveRequested = true;
}
return;
}
Memory->bEQSRunning = true;
FEnvQueryRequest Request(RefinementQuery, Pawn);
Request.Execute(EEnvQueryRunMode::SingleResult,
FQueryFinishedSignature::CreateUObject(this,
&UPS_AI_Behavior_BTTask_FindCover::OnRefinementQueryFinished,
&OwnerComp, NodeMemory, CoverCenter));
if (UPS_AI_Behavior_Statics::IsDebugEnabled(Pawn))
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] FindCover: EQS refinement query launched around %s"),
*Pawn->GetName(), *CoverCenter.ToString());
}
}
void UPS_AI_Behavior_BTTask_FindCover::OnRefinementQueryFinished(
TSharedPtr<FEnvQueryResult> Result,
UBehaviorTreeComponent* OwnerComp, uint8* NodeMemory,
FVector OriginalCoverPos)
{
if (!OwnerComp || !NodeMemory) return;
FCoverMemory* Memory = reinterpret_cast<FCoverMemory*>(NodeMemory);
Memory->bEQSRunning = false;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp->GetAIOwner());
if (!AIC || !AIC->GetPawn()) return;
FVector FinalPos = OriginalCoverPos; // Fallback to original position
if (Result.IsValid() && Result->IsSuccessful())
{
FinalPos = Result->GetItemAsLocation(0);
// Log all items with their scores for debugging
const int32 NumSurvived = Result->Items.Num();
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] FindCover: EQS refinement — %d items survived, refined %s → %s"),
*AIC->GetName(), NumSurvived, *OriginalCoverPos.ToString(), *FinalPos.ToString());
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
UWorld* World = AIC->GetWorld();
if (World)
{
// Draw EQS refinement items as BOXES (cover = spheres, refinement = boxes)
const int32 NumItems = Result->Items.Num();
for (int32 i = 0; i < NumItems; ++i)
{
if (!Result->Items[i].IsValid()) continue;
const FVector ItemLoc = Result->GetItemAsLocation(i);
const float ItemScore = Result->GetItemScore(i);
DrawDebugBox(World, ItemLoc + FVector(0, 0, 20.0f),
FVector(8.0f), ScoreToColor(ItemScore), false, 5.0f);
}
// Original position (yellow box)
DrawDebugBox(World, OriginalCoverPos + FVector(0, 0, 50.0f),
FVector(15.0f), FColor::Yellow, false, 5.0f, 0, 2.0f);
// Refined position (green box, bigger)
DrawDebugBox(World, FinalPos + FVector(0, 0, 50.0f),
FVector(20.0f), FColor::Green, false, 5.0f, 0, 2.5f);
// Arrow from original to refined
DrawDebugDirectionalArrow(World, OriginalCoverPos + FVector(0, 0, 50.0f),
FinalPos + FVector(0, 0, 50.0f), 10.0f, FColor::Green, false, 5.0f, 0, 1.5f);
DrawDebugString(World, FinalPos + FVector(0, 0, 75.0f),
FString::Printf(TEXT("REFINED (%d)"), NumItems),
nullptr, FColor::Green, 5.0f, true);
}
}
#endif
}
else
{
const int32 NumItems = Result.IsValid() ? Result->Items.Num() : 0;
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindCover: EQS refinement FAILED — items=%d, valid=%s"),
*AIC->GetName(), NumItems,
Result.IsValid() ? TEXT("yes") : TEXT("no"));
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
UWorld* World = AIC->GetWorld();
if (World)
{
// Show that refinement failed — orange box at original position
DrawDebugBox(World, OriginalCoverPos + FVector(0, 0, 50.0f),
FVector(20.0f), FColor::Orange, false, 5.0f, 0, 2.5f);
DrawDebugString(World, OriginalCoverPos + FVector(0, 0, 75.0f),
TEXT("NO REFINE"), nullptr, FColor::Orange, 5.0f, true);
}
}
#endif
}
// Update BB with refined position
UBlackboardComponent* BB = OwnerComp->GetBlackboardComponent();
if (BB)
{
BB->SetValueAsVector(PS_AI_Behavior_BB::CoverLocation, FinalPos);
}
// Navigate to the refined (or original) position
const EPathFollowingRequestResult::Type MoveResult = AIC->MoveToLocation(
FinalPos, AcceptanceRadius, true, true, true, false);
if (MoveResult == EPathFollowingRequestResult::Failed)
{
FinishLatentTask(*OwnerComp, EBTNodeResult::Failed);
return;
}
if (MoveResult == EPathFollowingRequestResult::AlreadyAtGoal)
{
// Crouch at cover if needed
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
const APS_AI_Behavior_CoverPoint* CoverPt =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (CoverPt && CoverPt->bCrouch)
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, true);
}
}
FinishLatentTask(*OwnerComp, EBTNodeResult::Succeeded);
return;
}
Memory->bMoveRequested = true;
}

View File

@@ -0,0 +1,143 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_FleeFrom.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Interface.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"
#include "Navigation/PathFollowingComponent.h"
UPS_AI_Behavior_BTTask_FleeFrom::UPS_AI_Behavior_BTTask_FleeFrom()
{
NodeName = TEXT("Flee From Threat");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FleeFrom::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
// Stand up if crouching (e.g. was hiding at a cover point before fleeing)
APawn* FleePawn = AIC->GetPawn();
if (FleePawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(FleePawn, false);
}
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return EBTNodeResult::Failed;
const FVector ThreatLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::ThreatLocation);
const FVector Origin = AIC->GetPawn()->GetActorLocation();
// If no valid threat location, fail
if (ThreatLoc.IsZero())
{
return EBTNodeResult::Failed;
}
FVector FleePoint;
if (!FindFleePoint(Origin, ThreatLoc, FleePoint))
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Flee: could not find valid flee point."), *AIC->GetName());
return EBTNodeResult::Failed;
}
const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation(
FleePoint, AcceptanceRadius, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectDestinationToNavigation=*/true,
/*bCanStrafe=*/false);
if (Result == EPathFollowingRequestResult::Failed)
{
return EBTNodeResult::Failed;
}
if (Result == EPathFollowingRequestResult::AlreadyAtGoal)
{
return EBTNodeResult::Succeeded;
}
FFleeMemory* Memory = reinterpret_cast<FFleeMemory*>(NodeMemory);
Memory->bMoveRequested = true;
return EBTNodeResult::InProgress;
}
void UPS_AI_Behavior_BTTask_FleeFrom::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
FFleeMemory* Memory = reinterpret_cast<FFleeMemory*>(NodeMemory);
if (!Memory->bMoveRequested) return;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) { FinishLatentTask(OwnerComp, EBTNodeResult::Failed); return; }
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
Memory->bMoveRequested = false;
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FleeFrom::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->StopMovement();
}
return EBTNodeResult::Aborted;
}
bool UPS_AI_Behavior_BTTask_FleeFrom::FindFleePoint(
const FVector& Origin, const FVector& ThreatLoc, FVector& OutFleePoint) const
{
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(GetWorld());
if (!NavSys) return false;
// Direction away from threat
FVector FleeDir = (Origin - ThreatLoc).GetSafeNormal2D();
if (FleeDir.IsNearlyZero())
{
FleeDir = FVector::ForwardVector; // Fallback
}
// Try multiple angles to find a valid navmesh point
const int32 NumAttempts = 8;
const float AngleStep = 45.0f;
float BestDistFromThreat = 0.0f;
for (int32 i = 0; i < NumAttempts; ++i)
{
// Spread from directly away, rotating by increments
const float Angle = (i % 2 == 0 ? 1.0f : -1.0f) * (i / 2) * AngleStep;
const FVector RotatedDir = FleeDir.RotateAngleAxis(Angle, FVector::UpVector);
const float FleeDist = FMath::RandRange(MinFleeDistance, MaxFleeDistance);
const FVector CandidatePoint = Origin + RotatedDir * FleeDist;
// Project onto NavMesh
FNavLocation NavLoc;
if (NavSys->ProjectPointToNavigation(CandidatePoint, NavLoc, FVector(500.0f, 500.0f, 250.0f)))
{
const float DistFromThreat = FVector::Dist(NavLoc.Location, ThreatLoc);
if (DistFromThreat > BestDistFromThreat)
{
BestDistFromThreat = DistFromThreat;
OutFleePoint = NavLoc.Location;
}
}
}
return BestDistFromThreat > 0.0f;
}
FString UPS_AI_Behavior_BTTask_FleeFrom::GetStaticDescription() const
{
return FString::Printf(TEXT("Flee %.0f-%.0fcm from threat"),
MinFleeDistance, MaxFleeDistance);
}

View File

@@ -0,0 +1,155 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_FollowSpline.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_SplineFollowerComponent.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
UPS_AI_Behavior_BTTask_FollowSpline::UPS_AI_Behavior_BTTask_FollowSpline()
{
NodeName = TEXT("Follow Spline");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FollowSpline::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
AAIController* AIC = OwnerComp.GetAIOwner();
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
UPS_AI_Behavior_SplineFollowerComponent* Follower =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (!Follower) return EBTNodeResult::Failed;
// Respect a manual StopFollowing(): refuse to resume until an explicit restart.
if (Follower->bManuallyStopped)
{
return EBTNodeResult::Failed;
}
if (!Follower->CurrentSpline)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] FollowSpline: no current spline set."),
*AIC->GetName());
return EBTNodeResult::Failed;
}
// Optional random direction
if (bRandomDirection)
{
Follower->bMovingForward = FMath::RandBool();
}
// Start or resume following
if (!Follower->bIsFollowing)
{
Follower->ResumeFollowing();
}
// Initialize memory — TickTask will poll bIsFollowing to detect end-of-spline
FFollowMemory* Memory = reinterpret_cast<FFollowMemory*>(NodeMemory);
Memory->Elapsed = 0.0f;
Memory->bEndReached = false;
return EBTNodeResult::InProgress;
}
void UPS_AI_Behavior_BTTask_FollowSpline::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
FFollowMemory* Memory = reinterpret_cast<FFollowMemory*>(NodeMemory);
// Check if spline end was reached (poll bIsFollowing — set to false by SplineFollowerComponent)
AAIController* AICCheck = OwnerComp.GetAIOwner();
if (AICCheck && AICCheck->GetPawn())
{
UPS_AI_Behavior_SplineFollowerComponent* FollowerCheck =
AICCheck->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
// Sync spline info to Blackboard
if (FollowerCheck)
{
if (UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent())
{
BB->SetValueAsObject(PS_AI_Behavior_BB::CurrentSpline,
FollowerCheck->CurrentSpline);
BB->SetValueAsFloat(PS_AI_Behavior_BB::SplineProgress,
FollowerCheck->GetProgress());
}
}
if (FollowerCheck && !FollowerCheck->bIsFollowing)
{
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
return;
}
}
// Time limit check
if (MaxFollowTime > 0.0f)
{
Memory->Elapsed += DeltaSeconds;
if (Memory->Elapsed >= MaxFollowTime)
{
// Pause following (don't stop — can resume later)
AAIController* AIC = OwnerComp.GetAIOwner();
if (AIC && AIC->GetPawn())
{
UPS_AI_Behavior_SplineFollowerComponent* Follower =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (Follower)
{
Follower->PauseFollowing();
}
}
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
return;
}
}
// Verify pawn is still valid
AAIController* AIC = OwnerComp.GetAIOwner();
if (!AIC || !AIC->GetPawn())
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FollowSpline::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
AAIController* AIC = OwnerComp.GetAIOwner();
if (AIC && AIC->GetPawn())
{
UPS_AI_Behavior_SplineFollowerComponent* Follower =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (Follower)
{
Follower->PauseFollowing();
}
}
// Clear spline info from BB
if (UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent())
{
BB->ClearValue(PS_AI_Behavior_BB::CurrentSpline);
BB->SetValueAsFloat(PS_AI_Behavior_BB::SplineProgress, 0.0f);
}
return EBTNodeResult::Aborted;
}
FString UPS_AI_Behavior_BTTask_FollowSpline::GetStaticDescription() const
{
if (MaxFollowTime > 0.0f)
{
return FString::Printf(TEXT("Follow current spline (max %.1fs%s)"),
MaxFollowTime, bRandomDirection ? TEXT(", random dir") : TEXT(""));
}
return FString::Printf(TEXT("Follow current spline%s"),
bRandomDirection ? TEXT(" (random dir)") : TEXT(""));
}

View File

@@ -0,0 +1,194 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_Patrol.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"
#include "Navigation/PathFollowingComponent.h"
UPS_AI_Behavior_BTTask_Patrol::UPS_AI_Behavior_BTTask_Patrol()
{
NodeName = TEXT("Patrol");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_Patrol::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return EBTNodeResult::Failed;
FPatrolMemory* Memory = reinterpret_cast<FPatrolMemory*>(NodeMemory);
Memory->bIsWaiting = false;
Memory->bMoveRequested = false;
Memory->WaitRemaining = 0.0f;
FVector Destination;
if (AIC->PatrolPoints.Num() > 0)
{
// ─── Mode manuel : waypoints définis ────────────────────────────
const int32 PatrolIdx = BB->GetValueAsInt(PS_AI_Behavior_BB::PatrolIndex);
const int32 SafeIdx = PatrolIdx % AIC->PatrolPoints.Num();
Destination = AIC->PatrolPoints[SafeIdx];
}
else
{
// ─── Mode auto : point aléatoire sur NavMesh ────────────────────
const FVector HomeLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::HomeLocation);
const FVector CurrentLoc = AIC->GetPawn()->GetActorLocation();
if (!FindRandomPatrolPoint(GetWorld(), HomeLoc, CurrentLoc, Destination))
{
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[%s] Patrol: no valid NavMesh point found within %.0fcm."),
*AIC->GetName(), PatrolRadius);
return EBTNodeResult::Failed;
}
}
// Issue move request
const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation(
Destination, AcceptanceRadius, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectDestinationToNavigation=*/true,
/*bCanStrafe=*/false);
if (Result == EPathFollowingRequestResult::Failed)
{
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Patrol: MoveTo failed."), *AIC->GetName());
return EBTNodeResult::Failed;
}
if (Result == EPathFollowingRequestResult::AlreadyAtGoal)
{
Memory->bIsWaiting = true;
Memory->WaitRemaining = FMath::RandRange(MinWaitTime, MaxWaitTime);
// Advance patrol index (for manual mode)
if (AIC->PatrolPoints.Num() > 0)
{
const int32 PatrolIdx = BB->GetValueAsInt(PS_AI_Behavior_BB::PatrolIndex);
BB->SetValueAsInt(PS_AI_Behavior_BB::PatrolIndex,
(PatrolIdx + 1) % AIC->PatrolPoints.Num());
}
return EBTNodeResult::InProgress;
}
Memory->bMoveRequested = true;
return EBTNodeResult::InProgress;
}
void UPS_AI_Behavior_BTTask_Patrol::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
FPatrolMemory* Memory = reinterpret_cast<FPatrolMemory*>(NodeMemory);
if (Memory->bIsWaiting)
{
Memory->WaitRemaining -= DeltaSeconds;
if (Memory->WaitRemaining <= 0.0f)
{
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
return;
}
if (Memory->bMoveRequested)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) { FinishLatentTask(OwnerComp, EBTNodeResult::Failed); return; }
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
// Move completed — start wait
Memory->bMoveRequested = false;
Memory->bIsWaiting = true;
Memory->WaitRemaining = FMath::RandRange(MinWaitTime, MaxWaitTime);
// Advance patrol index (for manual mode)
if (AIC->PatrolPoints.Num() > 0)
{
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (BB)
{
const int32 PatrolIdx = BB->GetValueAsInt(PS_AI_Behavior_BB::PatrolIndex);
BB->SetValueAsInt(PS_AI_Behavior_BB::PatrolIndex,
(PatrolIdx + 1) % AIC->PatrolPoints.Num());
}
}
}
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_Patrol::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->StopMovement();
}
return EBTNodeResult::Aborted;
}
bool UPS_AI_Behavior_BTTask_Patrol::FindRandomPatrolPoint(
const UWorld* World, const FVector& HomeLoc,
const FVector& CurrentLoc, FVector& OutPoint) const
{
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(
const_cast<UWorld*>(World));
if (!NavSys) return false;
// Project HomeLoc onto NavMesh first (spawn position might not be exactly on it)
FNavLocation ProjectedHome;
const FVector ProjectionExtent(500.0f, 500.0f, 500.0f);
FVector SearchOrigin = HomeLoc;
if (NavSys->ProjectPointToNavigation(HomeLoc, ProjectedHome, ProjectionExtent))
{
SearchOrigin = ProjectedHome.Location;
}
else
{
// HomeLoc not on NavMesh — try from current position instead
FNavLocation ProjectedCurrent;
if (NavSys->ProjectPointToNavigation(CurrentLoc, ProjectedCurrent, ProjectionExtent))
{
SearchOrigin = ProjectedCurrent.Location;
}
else
{
return false; // Neither home nor current pos are on NavMesh
}
}
// Try multiple times to find a valid point far enough from current position
for (int32 Attempt = 0; Attempt < 10; ++Attempt)
{
FNavLocation NavLoc;
if (NavSys->GetRandomReachablePointInRadius(SearchOrigin, PatrolRadius, NavLoc))
{
const float DistFromCurrent = FVector::Dist2D(NavLoc.Location, CurrentLoc);
if (DistFromCurrent >= MinPatrolDistance)
{
OutPoint = NavLoc.Location;
return true;
}
}
}
return false;
}
FString UPS_AI_Behavior_BTTask_Patrol::GetStaticDescription() const
{
return FString::Printf(
TEXT("Patrol (wait %.1f-%.1fs)\nManual waypoints OR auto NavMesh (radius %.0fcm, min dist %.0fcm)"),
MinWaitTime, MaxWaitTime, PatrolRadius, MinPatrolDistance);
}

View File

@@ -0,0 +1,61 @@
// Copyright Asterion. All Rights Reserved.
#include "EQS/PS_AI_Behavior_EQSContext_CoverLocation.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Definitions.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_Point.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"
void UPS_AI_Behavior_EQSContext_CoverLocation::ProvideContext(
FEnvQueryInstance& QueryInstance, FEnvQueryContextData& ContextData) const
{
const AActor* QuerierActor = Cast<AActor>(QueryInstance.Owner.Get());
if (!QuerierActor) return;
const APawn* QuerierPawn = Cast<APawn>(QuerierActor);
if (!QuerierPawn) return;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(QuerierPawn->GetController());
if (!AIC) return;
UBlackboardComponent* BB = AIC->GetBlackboardComponent();
if (!BB) return;
// Use CoverPoint actor location (center of cover geometry) for circle generation.
// Fallback to CoverLocation vector if no actor.
FVector RawLoc = FVector::ZeroVector;
const AActor* CoverPointActor = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (CoverPointActor)
{
RawLoc = CoverPointActor->GetActorLocation();
}
else
{
RawLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
}
if (RawLoc.IsZero()) return;
// Project to navmesh so the EQS generator works correctly
// (cover points inside geometry or above navmesh need projection)
FVector FinalLoc = RawLoc;
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(QuerierPawn->GetWorld());
if (NavSys)
{
FNavLocation NavLoc;
if (NavSys->ProjectPointToNavigation(RawLoc, NavLoc, FVector(200.0f, 200.0f, 200.0f)))
{
FinalLoc = NavLoc.Location;
}
}
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[EQSContext_CoverLocation] %s '%s' raw=%s → nav=%s"),
CoverPointActor ? TEXT("CoverPoint") : TEXT("CoverLocation"),
CoverPointActor ? *CoverPointActor->GetName() : TEXT("(vector)"),
*RawLoc.ToString(), *FinalLoc.ToString());
UEnvQueryItemType_Point::SetContextHelper(ContextData, FinalLoc);
}

View File

@@ -0,0 +1,41 @@
// Copyright Asterion. All Rights Reserved.
#include "EQS/PS_AI_Behavior_EQSContext_Threat.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Definitions.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_Actor.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_Point.h"
#include "BehaviorTree/BlackboardComponent.h"
void UPS_AI_Behavior_EQSContext_Threat::ProvideContext(
FEnvQueryInstance& QueryInstance, FEnvQueryContextData& ContextData) const
{
// Get the querier's AIController
const AActor* QuerierActor = Cast<AActor>(QueryInstance.Owner.Get());
if (!QuerierActor) return;
const APawn* QuerierPawn = Cast<APawn>(QuerierActor);
if (!QuerierPawn) return;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(QuerierPawn->GetController());
if (!AIC) return;
UBlackboardComponent* BB = AIC->GetBlackboardComponent();
if (!BB) return;
// Try to provide the threat actor first
AActor* ThreatActor = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (ThreatActor)
{
UEnvQueryItemType_Actor::SetContextHelper(ContextData, ThreatActor);
return;
}
// Fall back to threat location
const FVector ThreatLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::ThreatLocation);
if (!ThreatLoc.IsZero())
{
UEnvQueryItemType_Point::SetContextHelper(ContextData, ThreatLoc);
}
}

View File

@@ -0,0 +1,86 @@
// Copyright Asterion. All Rights Reserved.
#include "EQS/PS_AI_Behavior_EQSGenerator_CoverPoints.h"
#include "PS_AI_Behavior_CoverPoint.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Interface.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_Actor.h"
#include "EngineUtils.h"
#include "GameFramework/Pawn.h"
UPS_AI_Behavior_EQSGenerator_CoverPoints::UPS_AI_Behavior_EQSGenerator_CoverPoints()
{
ItemType = UEnvQueryItemType_Actor::StaticClass();
}
void UPS_AI_Behavior_EQSGenerator_CoverPoints::GenerateItems(FEnvQueryInstance& QueryInstance) const
{
const UObject* QueryOwner = QueryInstance.Owner.Get();
if (!QueryOwner) return;
const AActor* QuerierActor = Cast<AActor>(QueryOwner);
if (!QuerierActor) return;
const UWorld* World = QuerierActor->GetWorld();
if (!World) return;
const FVector QuerierLoc = QuerierActor->GetActorLocation();
// Determine NPC type for accessibility check
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Any;
const APawn* QuerierPawn = Cast<APawn>(QuerierActor);
if (QuerierPawn)
{
if (QuerierPawn->Implements<UPS_AI_Behavior_Interface>())
{
NPCType = IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(const_cast<APawn*>(QuerierPawn));
}
else if (const auto* PC = QuerierPawn->FindComponentByClass<UPS_AI_Behavior_PersonalityComponent>())
{
NPCType = PC->GetNPCType();
}
}
// Collect matching cover points
TArray<AActor*> FoundPoints;
for (TActorIterator<APS_AI_Behavior_CoverPoint> It(const_cast<UWorld*>(World)); It; ++It)
{
APS_AI_Behavior_CoverPoint* Point = *It;
if (!Point || !Point->bEnabled) continue;
// Type filter
if (Point->PointType != PointTypeFilter) continue;
// NPC type accessibility
if (!Point->IsAccessibleTo(NPCType)) continue;
// Availability
if (bOnlyAvailable && !Point->HasRoom()) continue;
// Distance
if (FVector::Dist(QuerierLoc, Point->GetActorLocation()) > MaxDistance) continue;
FoundPoints.Add(Point);
}
// Add items to the query
for (AActor* Point : FoundPoints)
{
QueryInstance.AddItemData<UEnvQueryItemType_Actor>(Point);
}
}
FText UPS_AI_Behavior_EQSGenerator_CoverPoints::GetDescriptionTitle() const
{
return FText::FromString(FString::Printf(TEXT("Cover Points (%s)"),
PointTypeFilter == EPS_AI_Behavior_CoverPointType::Cover ? TEXT("Cover") : TEXT("Hiding")));
}
FText UPS_AI_Behavior_EQSGenerator_CoverPoints::GetDescriptionDetails() const
{
return FText::FromString(FString::Printf(
TEXT("Max dist: %.0f, Available only: %s"),
MaxDistance, bOnlyAvailable ? TEXT("Yes") : TEXT("No")));
}

View File

@@ -0,0 +1,162 @@
// Copyright Asterion. All Rights Reserved.
#include "EQS/PS_AI_Behavior_EQSTest_CoverQuality.h"
#include "EQS/PS_AI_Behavior_EQSContext_Threat.h"
#include "PS_AI_Behavior_Definitions.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_VectorBase.h"
#include "CollisionQueryParams.h"
#include "Engine/World.h"
#include "DrawDebugHelpers.h"
UPS_AI_Behavior_EQSTest_CoverQuality::UPS_AI_Behavior_EQSTest_CoverQuality()
{
Cost = EEnvTestCost::High; // Uses raycasts
ValidItemType = UEnvQueryItemType_VectorBase::StaticClass();
SetWorkOnFloatValues(true);
}
void UPS_AI_Behavior_EQSTest_CoverQuality::RunTest(FEnvQueryInstance& QueryInstance) const
{
UObject* QueryOwner = QueryInstance.Owner.Get();
if (!QueryOwner) return;
// Get threat locations from context
TArray<FVector> ThreatLocations;
if (!QueryInstance.PrepareContext(UPS_AI_Behavior_EQSContext_Threat::StaticClass(), ThreatLocations))
{
return;
}
if (ThreatLocations.Num() == 0)
{
return;
}
const FVector ThreatLoc = ThreatLocations[0];
const UWorld* World = GEngine->GetWorldFromContextObject(QueryOwner, EGetWorldErrorMode::LogAndReturnNull);
if (!World) return;
FCollisionQueryParams TraceParams(SCENE_QUERY_STAT(CoverQualityEQS), true);
// Ignore the threat actor itself (e.g. AimTargetActor sphere blocks its own traces)
TArray<AActor*> ThreatActors;
if (QueryInstance.PrepareContext(UPS_AI_Behavior_EQSContext_Threat::StaticClass(), ThreatActors))
{
for (AActor* A : ThreatActors)
{
TraceParams.AddIgnoredActor(A);
for (AActor* Parent = A->GetAttachParentActor(); Parent; Parent = Parent->GetAttachParentActor())
{
TraceParams.AddIgnoredActor(Parent);
}
}
}
// Compute height steps
TArray<float> TraceHeights;
if (NumTraceHeights == 1)
{
TraceHeights.Add((MinTraceHeight + MaxTraceHeight) * 0.5f);
}
else
{
for (int32 i = 0; i < NumTraceHeights; ++i)
{
const float Alpha = static_cast<float>(i) / (NumTraceHeights - 1);
TraceHeights.Add(FMath::Lerp(MinTraceHeight, MaxTraceHeight, Alpha));
}
}
// Compute lateral offsets: center + left + right perpendicular to threat direction
// This helps evaluate cover around narrow objects (poles, pillars)
TArray<float> LateralOffsets;
LateralOffsets.Add(0.0f); // Center trace
if (LateralSpread > 0.0f)
{
LateralOffsets.Add(-LateralSpread);
LateralOffsets.Add(LateralSpread);
}
const int32 TotalTraces = TraceHeights.Num() * LateralOffsets.Num();
for (FEnvQueryInstance::ItemIterator It(this, QueryInstance); It; ++It)
{
const FVector CandidatePos = GetItemLocation(QueryInstance, It.GetIndex());
float BlockedCount = 0.0f;
// Direction from candidate to threat (2D), and its perpendicular
const FVector DirToThreat = (ThreatLoc - CandidatePos).GetSafeNormal2D();
const FVector LateralDir = FVector::CrossProduct(FVector::UpVector, DirToThreat);
for (float Height : TraceHeights)
{
for (float Lateral : LateralOffsets)
{
const FVector TraceStart = CandidatePos + FVector(0, 0, Height) + LateralDir * Lateral;
const FVector TraceEnd = ThreatLoc + FVector(0, 0, 60.0f) + LateralDir * Lateral;
FHitResult Hit;
if (World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd,
ECC_Visibility, TraceParams))
{
const float HitDist = FVector::Dist(CandidatePos, Hit.ImpactPoint);
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("CoverQuality[%d] h=%.0f lat=%.0f: HIT '%s' dist=%.0fcm"),
It.GetIndex(), Height, Lateral,
Hit.GetActor() ? *Hit.GetActor()->GetName() : TEXT("null"),
HitDist);
const bool bGroundHit = Hit.ImpactPoint.Z < TraceStart.Z - 10.0f;
if (!bGroundHit && HitDist <= MaxNearbyHitDist)
{
BlockedCount += 1.0f;
}
}
else
{
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("CoverQuality[%d] h=%.0f lat=%.0f: NO HIT"),
It.GetIndex(), Height, Lateral);
}
}
}
const float Score = BlockedCount / static_cast<float>(TotalTraces);
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("CoverQuality[%d] at %s → score=%.2f (blocked=%d/%d)"),
It.GetIndex(), *CandidatePos.ToString(),
Score, (int32)BlockedCount, TotalTraces);
#if ENABLE_DRAW_DEBUG
if (bDrawDebug)
{
// Color: red(0) → yellow(0.5) → green(1)
const uint8 R = static_cast<uint8>(FMath::Lerp(255.0f, 0.0f, FMath::Clamp(Score, 0.0f, 1.0f)));
const uint8 G = static_cast<uint8>(FMath::Lerp(0.0f, 255.0f, FMath::Clamp(Score, 0.0f, 1.0f)));
DrawDebugBox(const_cast<UWorld*>(World), CandidatePos + FVector(0, 0, 20.0f),
FVector(8.0f), FColor(R, G, 0), false, 5.0f);
DrawDebugString(const_cast<UWorld*>(World), CandidatePos + FVector(0, 0, 35.0f),
FString::Printf(TEXT("%.0f%%"), Score * 100.0f), nullptr,
FColor(R, G, 0), 5.0f, true);
}
#endif
It.SetScore(TestPurpose, FilterType, Score, FloatValueMin.GetValue(), FloatValueMax.GetValue());
}
}
FText UPS_AI_Behavior_EQSTest_CoverQuality::GetDescriptionTitle() const
{
return FText::FromString(TEXT("Cover Quality (vs Threat)"));
}
FText UPS_AI_Behavior_EQSTest_CoverQuality::GetDescriptionDetails() const
{
return FText::FromString(FString::Printf(
TEXT("%d heights (%.0f%.0fcm) × %s, maxHitDist=%.0f"),
NumTraceHeights, MinTraceHeight, MaxTraceHeight,
LateralSpread > 0.0f ? *FString::Printf(TEXT("3 lateral (±%.0fcm)"), LateralSpread) : TEXT("center only"),
MaxNearbyHitDist));
}

View File

@@ -0,0 +1,139 @@
// Copyright Asterion. All Rights Reserved.
#include "EQS/PS_AI_Behavior_EQSTest_LineOfSight.h"
#include "EQS/PS_AI_Behavior_EQSContext_Threat.h"
#include "PS_AI_Behavior_Definitions.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_VectorBase.h"
#include "CollisionQueryParams.h"
#include "Engine/World.h"
#include "DrawDebugHelpers.h"
UPS_AI_Behavior_EQSTest_LineOfSight::UPS_AI_Behavior_EQSTest_LineOfSight()
{
Cost = EEnvTestCost::High; // Uses raycasts
ValidItemType = UEnvQueryItemType_VectorBase::StaticClass();
SetWorkOnFloatValues(true);
}
void UPS_AI_Behavior_EQSTest_LineOfSight::RunTest(FEnvQueryInstance& QueryInstance) const
{
UObject* QueryOwner = QueryInstance.Owner.Get();
if (!QueryOwner) return;
// Get threat locations from context
TArray<FVector> ThreatLocations;
if (!QueryInstance.PrepareContext(UPS_AI_Behavior_EQSContext_Threat::StaticClass(), ThreatLocations))
{
return;
}
if (ThreatLocations.Num() == 0)
{
return;
}
const FVector ThreatLoc = ThreatLocations[0];
const UWorld* World = GEngine->GetWorldFromContextObject(QueryOwner, EGetWorldErrorMode::LogAndReturnNull);
if (!World) return;
FCollisionQueryParams TraceParams(SCENE_QUERY_STAT(LOSTestEQS), true);
// Ignore the threat actor itself (e.g. AimTargetActor sphere blocks its own traces)
TArray<AActor*> ThreatActors;
if (QueryInstance.PrepareContext(UPS_AI_Behavior_EQSContext_Threat::StaticClass(), ThreatActors))
{
for (AActor* A : ThreatActors)
{
TraceParams.AddIgnoredActor(A);
// Also ignore parent chain (AimTarget → Character capsule)
for (AActor* Parent = A->GetAttachParentActor(); Parent; Parent = Parent->GetAttachParentActor())
{
TraceParams.AddIgnoredActor(Parent);
}
}
}
// Lateral offsets: center + optional left/right perpendicular to threat direction
TArray<float> LateralOffsets;
LateralOffsets.Add(0.0f);
if (LateralSpread > 0.0f)
{
LateralOffsets.Add(-LateralSpread);
LateralOffsets.Add(LateralSpread);
}
const int32 TotalTraces = LateralOffsets.Num();
for (FEnvQueryInstance::ItemIterator It(this, QueryInstance); It; ++It)
{
const FVector CandidatePos = GetItemLocation(QueryInstance, It.GetIndex());
// Direction from candidate to threat (2D) and its perpendicular
const FVector DirToThreat = (ThreatLoc - CandidatePos).GetSafeNormal2D();
const FVector LateralDir = FVector::CrossProduct(FVector::UpVector, DirToThreat);
int32 ClearCount = 0;
for (float Lateral : LateralOffsets)
{
const FVector TraceStart = CandidatePos + FVector(0, 0, TraceHeight) + LateralDir * Lateral;
const FVector TraceEnd = ThreatLoc + FVector(0, 0, TargetHeightOffset) + LateralDir * Lateral;
FHitResult Hit;
const bool bBlocked = World->LineTraceSingleByChannel(
Hit, TraceStart, TraceEnd, ECC_Visibility, TraceParams);
if (!bBlocked)
{
ClearCount++;
}
else
{
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("LOS[%d] lat=%.0f: BLOCKED by '%s' at %s (dist=%.0fcm)"),
It.GetIndex(), Lateral,
Hit.GetActor() ? *Hit.GetActor()->GetName() : TEXT("null"),
*Hit.ImpactPoint.ToString(),
FVector::Dist(CandidatePos, Hit.ImpactPoint));
}
#if ENABLE_DRAW_DEBUG
if (bDrawDebug)
{
const FColor Color = bBlocked ? FColor::Red : FColor::Green;
DrawDebugLine(const_cast<UWorld*>(World), TraceStart, TraceEnd,
Color, false, 5.0f, 0, 0.5f);
}
#endif
}
// Score: ratio of clear traces (1.0 = all clear, 0.0 = all blocked)
const float Score = static_cast<float>(ClearCount) / static_cast<float>(TotalTraces);
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("LOS[%d] at %s → clear=%d/%d score=%.2f"),
It.GetIndex(), *CandidatePos.ToString(),
ClearCount, TotalTraces, Score);
#if ENABLE_DRAW_DEBUG
if (bDrawDebug)
{
const uint8 G = static_cast<uint8>(Score * 255.0f);
DrawDebugSphere(const_cast<UWorld*>(World), CandidatePos + FVector(0, 0, 20.0f),
10.0f, 6, FColor(255 - G, G, 0), false, 5.0f);
}
#endif
It.SetScore(TestPurpose, FilterType, Score, FloatValueMin.GetValue(), FloatValueMax.GetValue());
}
}
FText UPS_AI_Behavior_EQSTest_LineOfSight::GetDescriptionTitle() const
{
return FText::FromString(TEXT("Line of Sight (to Threat)"));
}
FText UPS_AI_Behavior_EQSTest_LineOfSight::GetDescriptionDetails() const
{
return FText::FromString(FString::Printf(
TEXT("Trace from %.0fcm to threat +%.0fcm"), TraceHeight, TargetHeightOffset));
}

View File

@@ -0,0 +1,20 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior.h"
#include "PS_AI_Behavior_Definitions.h"
#define LOCTEXT_NAMESPACE "FPS_AI_BehaviorModule"
IMPLEMENT_MODULE(FPS_AI_BehaviorModule, PS_AI_Behavior)
void FPS_AI_BehaviorModule::StartupModule()
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("PS_AI_Behavior module started."));
}
void FPS_AI_BehaviorModule::ShutdownModule()
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("PS_AI_Behavior module shut down."));
}
#undef LOCTEXT_NAMESPACE

View File

@@ -0,0 +1,115 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_CombatComponent.h"
#include "PS_AI_Behavior_Definitions.h"
#include "GameFramework/DamageType.h"
#include "Engine/DamageEvents.h"
#include "Kismet/GameplayStatics.h"
#include "Net/UnrealNetwork.h"
UPS_AI_Behavior_CombatComponent::UPS_AI_Behavior_CombatComponent()
{
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.TickInterval = 0.1f; // Don't need per-frame
SetIsReplicatedByDefault(true);
}
void UPS_AI_Behavior_CombatComponent::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UPS_AI_Behavior_CombatComponent, CurrentTarget);
}
void UPS_AI_Behavior_CombatComponent::BeginPlay()
{
Super::BeginPlay();
if (!DamageTypeClass)
{
DamageTypeClass = UDamageType::StaticClass();
}
}
void UPS_AI_Behavior_CombatComponent::TickComponent(
float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// Cooldown only ticks on server (BT runs server-only)
if (GetOwner() && GetOwner()->HasAuthority() && CooldownRemaining > 0.0f)
{
CooldownRemaining = FMath::Max(0.0f, CooldownRemaining - DeltaTime);
}
}
bool UPS_AI_Behavior_CombatComponent::CanAttack() const
{
return CooldownRemaining <= 0.0f;
}
bool UPS_AI_Behavior_CombatComponent::IsInAttackRange(AActor* Target) const
{
if (!Target || !GetOwner())
{
return false;
}
const float Dist = FVector::Dist(GetOwner()->GetActorLocation(), Target->GetActorLocation());
return Dist <= AttackRange;
}
bool UPS_AI_Behavior_CombatComponent::ExecuteAttack(AActor* Target)
{
// Only execute on server (authority)
if (!GetOwner() || !GetOwner()->HasAuthority())
{
return false;
}
if (!CanAttack() || !Target)
{
return false;
}
if (!IsInAttackRange(Target))
{
return false;
}
// Apply damage (server-only — UE5 damage system is server-authoritative)
UGameplayStatics::ApplyDamage(
Target,
AttackDamage,
GetOwner()->GetInstigatorController(),
GetOwner(),
DamageTypeClass);
// Start cooldown
CooldownRemaining = AttackCooldown;
CurrentTarget = Target; // Replicated → clients see the target
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attacked '%s' for %.1f damage."),
*GetOwner()->GetName(), *Target->GetName(), AttackDamage);
// Multicast cosmetic event to all clients (VFX, sound, anims)
Multicast_OnAttackExecuted(Target);
return true;
}
void UPS_AI_Behavior_CombatComponent::Multicast_OnAttackExecuted_Implementation(AActor* Target)
{
// Fires on server AND all clients
OnAttackExecuted.Broadcast(Target);
}
void UPS_AI_Behavior_CombatComponent::NotifyDamageReceived(float Damage, AActor* DamageInstigator)
{
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Received %.1f damage from '%s'."),
*GetOwner()->GetName(), Damage,
DamageInstigator ? *DamageInstigator->GetName() : TEXT("Unknown"));
OnDamageReceived.Broadcast(Damage, DamageInstigator);
}

View File

@@ -0,0 +1,212 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_CoverPoint.h"
#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()
{
PrimaryActorTick.bCanEverTick = false;
bNetLoadOnClient = true;
USceneComponent* Root = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
RootComponent = Root;
#if WITH_EDITORONLY_DATA
ArrowComp = CreateDefaultSubobject<UArrowComponent>(TEXT("Arrow"));
ArrowComp->SetupAttachment(Root);
ArrowComp->SetArrowSize(0.5f);
ArrowComp->SetArrowLength(80.0f);
ArrowComp->SetRelativeLocation(FVector(0, 0, 50.0f));
ArrowComp->bIsScreenSizeScaled = false;
SpriteComp = CreateDefaultSubobject<UBillboardComponent>(TEXT("Sprite"));
SpriteComp->SetupAttachment(Root);
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<UTexture2D> HidingSpotSpriteFinder(
TEXT("/Engine/EditorResources/S_AtmosphericHeightFog"));
CoverSpriteTexture = nullptr; // Keep default billboard sprite for Cover
HidingSpotSpriteTexture = HidingSpotSpriteFinder.Succeeded() ? HidingSpotSpriteFinder.Object : nullptr;
#endif
}
void APS_AI_Behavior_CoverPoint::BeginPlay()
{
Super::BeginPlay();
// Cleanup stale occupant refs
CurrentOccupants.RemoveAll([](const TWeakObjectPtr<AActor>& Ptr) { return !Ptr.IsValid(); });
}
bool APS_AI_Behavior_CoverPoint::IsAccessibleTo(EPS_AI_Behavior_NPCType NPCType) const
{
if (!bEnabled) return false;
if (AllowedNPCType == EPS_AI_Behavior_NPCType::Any) return true;
return AllowedNPCType == NPCType;
}
bool APS_AI_Behavior_CoverPoint::HasRoom() const
{
if (!bEnabled) return false;
// Cleanup stale refs
int32 ValidCount = 0;
for (const TWeakObjectPtr<AActor>& Occ : CurrentOccupants)
{
if (Occ.IsValid()) ++ValidCount;
}
return ValidCount < MaxOccupants;
}
bool APS_AI_Behavior_CoverPoint::Claim(AActor* Occupant)
{
if (!Occupant || !bEnabled) return false;
// Already claimed by this occupant?
for (const TWeakObjectPtr<AActor>& Occ : CurrentOccupants)
{
if (Occ.Get() == Occupant) return true;
}
// Cleanup stale
CurrentOccupants.RemoveAll([](const TWeakObjectPtr<AActor>& Ptr) { return !Ptr.IsValid(); });
if (CurrentOccupants.Num() >= MaxOccupants)
{
return false;
}
CurrentOccupants.Add(Occupant);
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[CoverPoint %s] Claimed by '%s' (%d/%d occupants)"),
*GetName(), *Occupant->GetName(), CurrentOccupants.Num(), MaxOccupants);
return true;
}
void APS_AI_Behavior_CoverPoint::Release(AActor* Occupant)
{
if (!Occupant) return;
const int32 Removed = CurrentOccupants.RemoveAll([Occupant](const TWeakObjectPtr<AActor>& Ptr)
{
return !Ptr.IsValid() || Ptr.Get() == Occupant;
});
if (Removed > 0)
{
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[CoverPoint %s] Released by '%s' (%d/%d occupants)"),
*GetName(), *Occupant->GetName(), CurrentOccupants.Num(), MaxOccupants);
}
}
FVector APS_AI_Behavior_CoverPoint::GetCoverDirection() const
{
return GetActorForwardVector();
}
float APS_AI_Behavior_CoverPoint::EvaluateAgainstThreat(const FVector& ThreatLocation) const
{
if (!bEnabled) return 0.0f;
const UWorld* World = GetWorld();
if (!World) return Quality;
const FVector CoverPos = GetActorLocation();
float Score = Quality * 0.5f; // Manual quality = half the score
// Raycast check at crouch and standing heights
FCollisionQueryParams Params(SCENE_QUERY_STAT(CoverPointEval), true);
Params.AddIgnoredActor(this);
const float Heights[] = { 60.0f, 100.0f, 170.0f };
int32 BlockedCount = 0;
for (float H : Heights)
{
FHitResult Hit;
const FVector TraceStart = CoverPos + FVector(0, 0, H);
const FVector TraceEnd = ThreatLocation + FVector(0, 0, 150.0f);
if (World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_Visibility, Params))
{
++BlockedCount;
}
}
// Raycast score = other half
const float RaycastScore = static_cast<float>(BlockedCount) / UE_ARRAY_COUNT(Heights);
Score += RaycastScore * 0.5f;
return FMath::Clamp(Score, 0.0f, 1.0f);
}
void APS_AI_Behavior_CoverPoint::SetEnabled(bool bNewEnabled)
{
bEnabled = bNewEnabled;
if (!bEnabled)
{
// Release all occupants
for (const TWeakObjectPtr<AActor>& Occ : CurrentOccupants)
{
// Occupants will re-evaluate in their next BT tick
}
CurrentOccupants.Empty();
}
UpdateVisualization();
}
void APS_AI_Behavior_CoverPoint::UpdateVisualization()
{
#if WITH_EDITORONLY_DATA
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;
}
if (!bEnabled)
{
Color *= 0.3f; // Dimmed when disabled
}
ArrowComp->SetArrowColor(Color);
if (SpriteComp && SpriteTexture)
{
SpriteComp->SetSprite(SpriteTexture);
}
#endif
}
#if WITH_EDITOR
void APS_AI_Behavior_CoverPoint::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
Super::PostEditChangeProperty(PropertyChangedEvent);
UpdateVisualization();
}
#endif

View File

@@ -0,0 +1,5 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_Definitions.h"
DEFINE_LOG_CATEGORY(LogPS_AI_Behavior);

View File

@@ -0,0 +1,7 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_Interface.h"
// Default implementations for BlueprintNativeEvent functions are auto-generated
// by UHT via GENERATED_BODY(). No manual _Implementation needed.
// If a BP class doesn't override a function, the UHT-generated default is used.

View File

@@ -0,0 +1,788 @@
// Copyright Asterion. All Rights Reserved.
#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_TeamComponent.h"
#include "PS_AI_Behavior_Statics.h"
#include "PS_AI_Behavior_Settings.h"
#include "Perception/AISenseConfig_Sight.h"
#include "Perception/AISenseConfig_Hearing.h"
#include "Perception/AISenseConfig_Damage.h"
#include "Perception/AISense_Sight.h"
#include "Perception/AISense_Hearing.h"
#include "Perception/AISense_Damage.h"
#include "GameFramework/Pawn.h"
#include "GameFramework/SpectatorPawn.h"
#include "AIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "EngineUtils.h"
UPS_AI_Behavior_PerceptionComponent::UPS_AI_Behavior_PerceptionComponent()
{
// Senses must be configured after construction (NewObject not allowed in CDO constructors).
// We configure in BeginPlay and force a perception system update.
}
void UPS_AI_Behavior_PerceptionComponent::BeginPlay()
{
ConfigureSenses();
Super::BeginPlay();
// Force the perception system to re-register our senses now that they're configured
RequestStimuliListenerUpdate();
OnPerceptionUpdated.AddDynamic(this, &UPS_AI_Behavior_PerceptionComponent::HandlePerceptionUpdated);
}
void UPS_AI_Behavior_PerceptionComponent::ConfigureSenses()
{
const UPS_AI_Behavior_Settings* Settings = GetDefault<UPS_AI_Behavior_Settings>();
// ─── Sight ──────────────────────────────────────────────────────────
UAISenseConfig_Sight* SightConfig = NewObject<UAISenseConfig_Sight>(this);
SightConfig->SightRadius = Settings->DefaultSightRadius;
SightConfig->LoseSightRadius = Settings->DefaultSightRadius * 1.2f;
SightConfig->PeripheralVisionAngleDegrees = Settings->DefaultSightHalfAngle;
SightConfig->SetMaxAge(Settings->PerceptionMaxAge);
SightConfig->AutoSuccessRangeFromLastSeenLocation = 500.0f;
// Detect ALL affiliations — target filtering is handled by TargetPriority
// in GetHighestThreatActor(), not at the perception level.
// This is necessary because an Enemy needs to *see* Civilians to target them.
SightConfig->DetectionByAffiliation.bDetectEnemies = true;
SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
SightConfig->DetectionByAffiliation.bDetectFriendlies = true;
ConfigureSense(*SightConfig);
// ─── Hearing ────────────────────────────────────────────────────────
UAISenseConfig_Hearing* HearingConfig = NewObject<UAISenseConfig_Hearing>(this);
HearingConfig->HearingRange = Settings->DefaultHearingRange;
HearingConfig->SetMaxAge(Settings->HearingMaxAge);
HearingConfig->DetectionByAffiliation.bDetectEnemies = true;
HearingConfig->DetectionByAffiliation.bDetectNeutrals = true;
HearingConfig->DetectionByAffiliation.bDetectFriendlies = true;
ConfigureSense(*HearingConfig);
// ─── Damage ─────────────────────────────────────────────────────────
UAISenseConfig_Damage* DamageConfig = NewObject<UAISenseConfig_Damage>(this);
DamageConfig->SetMaxAge(Settings->PerceptionMaxAge * 2.0f); // Damage memories last longer
ConfigureSense(*DamageConfig);
// Sight is the dominant sense
SetDominantSense(UAISense_Sight::StaticClass());
}
void UPS_AI_Behavior_PerceptionComponent::HandlePerceptionUpdated(const TArray<AActor*>& UpdatedActors)
{
// Placeholder — BTService_UpdateThreat does the heavy lifting.
// This callback can be used for immediate alert reactions.
}
// ─── Helpers ────────────────────────────────────────────────────────────────
/**
* Find the owning Pawn for a perceived actor.
* Walks up the Owner/Instigator chain until a Pawn is found.
* Returns the Pawn itself — used for team/attitude checks.
* Returns nullptr if no Pawn can be found.
*/
APawn* UPS_AI_Behavior_PerceptionComponent::FindOwningPawn(AActor* Actor)
{
if (!Actor) return nullptr;
// If already a Pawn, return it
if (APawn* ActorAsPawn = Cast<APawn>(Actor))
{
return ActorAsPawn;
}
// Not a Pawn — walk up Owner/Instigator/Attachment 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)
{
if (APawn* OwnerPawn = Cast<APawn>(OwnerActor))
{
return OwnerPawn;
}
Current = OwnerActor;
continue;
}
// Try attachment parent (ChildActorComponent → parent Character)
AActor* ParentActor = Current->GetAttachParentActor();
if (ParentActor && ParentActor != Current)
{
if (APawn* ParentPawn = Cast<APawn>(ParentActor))
{
return ParentPawn;
}
Current = ParentActor;
continue;
}
break; // No more chain to walk
}
// Fallback: could not resolve to a Pawn.
// Log once per actor class to avoid spam.
static TSet<FName> 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<UPS_AI_Behavior_Interface>())
{
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<APawn>(Actor);
if (!ActorPawn) return FGenericTeamId::NoTeam;
if (const AController* C = ActorPawn->GetController())
{
if (const IGenericTeamAgentInterface* T = Cast<IGenericTeamAgentInterface>(C))
{
return T->GetGenericTeamId().GetId();
}
}
if (const UPS_AI_Behavior_TeamComponent* TC = ActorPawn->FindComponentByClass<UPS_AI_Behavior_TeamComponent>())
{
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
const APawn* Pawn = Cast<APawn>(Actor);
// 1) Check via IPS_AI_Behavior interface (NPCs and any Pawn implementing it)
if (Actor->Implements<UPS_AI_Behavior_Interface>())
{
const EPS_AI_Behavior_NPCType NPCType =
IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(const_cast<AActor*>(Actor));
switch (NPCType)
{
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;
}
}
// 2) Check TeamComponent (player characters, non-AI pawns)
if (Pawn)
{
if (const UPS_AI_Behavior_TeamComponent* TeamComp = Pawn->FindComponentByClass<UPS_AI_Behavior_TeamComponent>())
{
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<UPS_AI_Behavior_PersonalityComponent>())
{
switch (PersonalityComp->GetNPCType())
{
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;
}
}
// 4) Player-controlled but no role defined → Player type
if (Pawn->IsPlayerControlled())
{
return EPS_AI_Behavior_TargetType::Player;
}
}
return EPS_AI_Behavior_TargetType::Civilian;
}
// ─── Target Selection ───────────────────────────────────────────────────────
AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor()
{
// Get priority from PersonalityProfile if available
TArray<EPS_AI_Behavior_TargetType> Priority;
const AActor* Owner = GetOwner();
if (Owner)
{
// Owner is the AIController, get the Pawn
const AAIController* AIC = Cast<AAIController>(Owner);
const APawn* MyPawn = AIC ? ToRawPtr(AIC->GetPawn()) : Cast<APawn>(const_cast<AActor*>(Owner));
if (MyPawn)
{
if (const auto* PersonalityComp = MyPawn->FindComponentByClass<UPS_AI_Behavior_PersonalityComponent>())
{
if (PersonalityComp->Profile)
{
Priority = PersonalityComp->Profile->TargetPriority;
}
}
}
}
return GetHighestThreatActor(Priority);
}
AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor(
const TArray<EPS_AI_Behavior_TargetType>& TargetPriority)
{
// Gather all perceived actors from all senses
TArray<AActor*> PerceivedActors;
GetCurrentlyPerceivedActors(nullptr, PerceivedActors); // All senses at once
// Resolve debug flag once for all logs in this function
const AAIController* DebugAIC = Cast<AAIController>(GetOwner());
const APawn* DebugPawn = DebugAIC ? DebugAIC->GetPawn() : nullptr;
const bool bDebugLog = UPS_AI_Behavior_Statics::IsDebugEnabled(DebugPawn);
// ─── Omniscient awareness for high-priority target types ────────
// Protectors (police, player) are always known if within sight radius,
// even outside the perception cone. This ensures enemies prioritize
// dangerous threats they're aware of (e.g. a cop standing next to them).
if (TargetPriority.Num() > 0)
{
const AAIController* OwnerAIC = Cast<AAIController>(GetOwner());
const APawn* OwnerPawn = OwnerAIC ? OwnerAIC->GetPawn() : nullptr;
// Only run on server — TActorIterator results differ on clients
if (OwnerPawn && OwnerPawn->HasAuthority())
{
const EPS_AI_Behavior_TargetType TopPriority = TargetPriority[0];
const UPS_AI_Behavior_Settings* Settings = GetDefault<UPS_AI_Behavior_Settings>();
const float AwarenessRadius = Settings->DefaultSightRadius;
const FVector OwnerLoc = OwnerPawn->GetActorLocation();
for (TActorIterator<APawn> It(GetWorld()); It; ++It)
{
APawn* CandidatePawn = *It;
if (!CandidatePawn || CandidatePawn == OwnerPawn) continue;
// Already perceived → skip
if (PerceivedActors.Contains(CandidatePawn)) continue;
// Check distance
const float Dist = FVector::Dist(OwnerLoc, CandidatePawn->GetActorLocation());
if (Dist > AwarenessRadius) continue;
// Check if this actor matches the top priority type
const EPS_AI_Behavior_TargetType CandidateType = ClassifyActor(CandidatePawn);
const EPS_AI_Behavior_TargetType MappedType =
(CandidateType == EPS_AI_Behavior_TargetType::Player)
? EPS_AI_Behavior_TargetType::Protector
: CandidateType;
if (MappedType == TopPriority)
{
PerceivedActors.Add(CandidatePawn);
if (bDebugLog)
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] Omniscient awareness: added '%s' (type=%d, dist=%.0f) — top priority target"),
*OwnerPawn->GetName(), *CandidatePawn->GetName(),
static_cast<int32>(CandidateType), Dist);
}
}
}
}
}
if (PerceivedActors.Num() == 0)
{
return nullptr;
}
const AActor* Owner = GetOwner();
if (!Owner)
{
return nullptr;
}
// Use default priority if none provided
static const TArray<EPS_AI_Behavior_TargetType> DefaultPriority = {
EPS_AI_Behavior_TargetType::Protector,
EPS_AI_Behavior_TargetType::Player,
EPS_AI_Behavior_TargetType::Civilian,
};
const TArray<EPS_AI_Behavior_TargetType>& ActivePriority =
TargetPriority.Num() > 0 ? TargetPriority : DefaultPriority;
const FVector OwnerLoc = Owner->GetActorLocation();
AActor* BestThreat = nullptr;
float BestScore = -1.0f;
// Get our Pawn for IsTargetActorValid checks
const AAIController* AIC = Cast<AAIController>(Owner);
APawn* MyPawn = AIC ? ToRawPtr(AIC->GetPawn()) : Cast<APawn>(const_cast<AActor*>(Owner));
// ─── 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
{
AActor* Actor = nullptr;
float Score = 0.0f;
EPS_AI_Behavior_TargetType ActorType = EPS_AI_Behavior_TargetType::Civilian;
bool bIsHostile = false;
};
TMap<AActor*, FActorScore> ScoreMap;
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;
// Skip spectator pawns (editor camera, spectating players, etc.)
if (OwningPawn->IsA<ASpectatorPawn>()) 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;
if (AIC)
{
// Attitude check against the PAWN (has TeamId), not the ThreatTarget
const ETeamAttitude::Type Attitude = AIC->GetTeamAttitudeTowards(*OwningPawn);
bActorIsHostile = (Attitude == ETeamAttitude::Hostile);
if (!bActorIsHostile)
{
// Same exact team (same NPCType + same Faction) → always skip
// Allied teams (Civilian ↔ Protector) → allow gunfire through
if (AIC->GetGenericTeamId().GetId() == GetActorTeamId(OwningPawn))
{
if (bDebugLog)
{
UE_LOG(LogPS_AI_Behavior, Log, 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<UAISense_Hearing>() &&
PS_AI_Behavior_Tags_Internal::IsGunfire(S.Tag))
{
bActorHasGunshot = true;
break;
}
}
}
if (!bActorHasGunshot)
{
if (bDebugLog)
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Target '%s': SKIPPED (attitude=%d, not hostile, no gunshot, theirTeam=0x%02X, myTeam=0x%02X)"),
*Owner->GetName(), *OwningPawn->GetName(), static_cast<int32>(Attitude),
GetActorTeamId(OwningPawn), AIC->GetGenericTeamId().GetId());
}
continue; // Not hostile, no gunshot — skip
}
}
}
// Skip invalid targets (dead, despawning, etc.) via interface
if (MyPawn && MyPawn->Implements<UPS_AI_Behavior_Interface>())
{
if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(MyPawn, ThreatTarget))
{
continue;
}
}
// ─── Classify & score per owning Pawn, but store ThreatTarget for BB ───
FActorScore& Entry = ScoreMap.FindOrAdd(OwningPawn);
if (!Entry.Actor)
{
// 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);
}
// Accumulate sense scores from this RawActor (weapon heard + pawn seen → both count)
FActorPerceptionBlueprintInfo Info;
if (GetActorsPerception(RawActor, Info))
{
for (const FAIStimulus& Stimulus : Info.LastSensedStimuli)
{
if (!Stimulus.IsValid()) continue;
if (Stimulus.Type == UAISense::GetSenseID<UAISense_Damage>())
{
Entry.Score += 500.0f;
}
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Hearing>())
{
if (PS_AI_Behavior_Tags_Internal::IsGunfire(Stimulus.Tag))
{
Entry.Score += 400.0f;
}
else
{
Entry.Score += 5.0f;
}
}
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Sight>())
{
Entry.Score += 10.0f;
}
}
}
}
// ─── 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 (const UBlackboardComponent* BB = AIC->GetBlackboardComponent())
{
CurrentTarget = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
}
}
APawn* BestOwningPawn = nullptr;
for (const auto& Pair : ScoreMap)
{
const FActorScore& Entry = Pair.Value;
if (bDebugLog)
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Target '%s' (pawn='%s'): type=%d, hostile=%d, score=%.0f"),
*Owner->GetName(), *Entry.Actor->GetName(), *Pair.Key->GetName(),
static_cast<int32>(Entry.ActorType), Entry.bIsHostile ? 1 : 0, Entry.Score);
}
if (Entry.Score > BestScore)
{
BestScore = Entry.Score;
BestThreat = Entry.Actor;
BestOwningPawn = Cast<APawn>(Pair.Key);
}
// 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;
// Find the owning Pawn for the kept target
for (const auto& Pair : ScoreMap)
{
if (Pair.Value.Actor == CurrentTarget) { BestOwningPawn = Cast<APawn>(Pair.Key); break; }
}
}
}
// Store the owning Pawn for callers (UpdateThreat reads this for debug display)
LastThreatOwningPawn = BestOwningPawn;
if (BestThreat)
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] → Best target: '%s' (pawn='%s', score=%.0f%s)"),
*Owner->GetName(), *BestThreat->GetName(),
BestOwningPawn ? *BestOwningPawn->GetName() : TEXT("?"),
BestScore,
(BestThreat == CurrentTarget) ? TEXT(" [kept]") : TEXT(""));
}
else if (PerceivedActors.Num() > 0)
{
// Normal case: civilian surrounded by non-hostile actors, or disguised enemy
// seeing allies. Only log at Verbose — not an issue.
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[%s] GetHighestThreatActor: perceived %d actors, none hostile (myTeam=0x%02X)."),
*Owner->GetName(), PerceivedActors.Num(),
AIC ? AIC->GetGenericTeamId().GetId() : 255);
}
return BestThreat;
}
float UPS_AI_Behavior_PerceptionComponent::CalculateThreatLevel()
{
const AActor* Owner = GetOwner();
if (!Owner) return 0.0f;
const FVector OwnerLoc = Owner->GetActorLocation();
float TotalThreat = 0.0f;
TArray<AActor*> PerceivedActors;
GetCurrentlyPerceivedActors(nullptr, PerceivedActors); // All senses
// Get our AIController and Pawn for checks
const AAIController* AIC = Cast<AAIController>(Owner);
APawn* MyPawn = AIC ? ToRawPtr(AIC->GetPawn()) : Cast<APawn>(const_cast<AActor*>(Owner));
for (AActor* RawActor : PerceivedActors)
{
if (!RawActor) continue;
// Find the owning Pawn (for team/attitude checks)
APawn* OwningPawn = FindOwningPawn(RawActor);
if (!OwningPawn) continue;
// Skip spectator pawns
if (OwningPawn->IsA<ASpectatorPawn>()) 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;
if (AIC)
{
// 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;
}
}
// 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<UAISense_Hearing>() &&
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<UPS_AI_Behavior_Interface>())
{
if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(MyPawn, ThreatTarget))
{
continue;
}
}
float ActorThreat = 0.0f;
const float Dist = FVector::Dist(OwnerLoc, ThreatTarget->GetActorLocation());
if (bIsHostile)
{
// ─── Normal hostile threat calculation ──────────────────────
ActorThreat += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, 5000.0f), FVector2D(0.5f, 0.05f), Dist);
FActorPerceptionBlueprintInfo Info;
if (GetActorsPerception(RawActor, Info))
{
for (const FAIStimulus& Stimulus : Info.LastSensedStimuli)
{
if (!Stimulus.IsValid()) continue;
if (Stimulus.Type == UAISense::GetSenseID<UAISense_Damage>())
{
ActorThreat += 0.6f;
}
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Sight>())
{
ActorThreat += 0.2f;
}
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Hearing>())
{
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;
}
// Clamp to reasonable range (can exceed 1.0 for multiple threats)
return FMath::Min(TotalThreat, 2.0f);
}
bool UPS_AI_Behavior_PerceptionComponent::GetThreatLocation(FVector& OutLocation)
{
AActor* Threat = GetHighestThreatActor();
if (Threat)
{
OutLocation = Threat->GetActorLocation();
return true;
}
// Fallback: check last known stimulus location
TArray<AActor*> KnownActors;
GetKnownPerceivedActors(nullptr, KnownActors);
if (KnownActors.Num() > 0 && KnownActors[0])
{
FActorPerceptionBlueprintInfo Info;
if (GetActorsPerception(KnownActors[0], Info))
{
for (const FAIStimulus& Stimulus : Info.LastSensedStimuli)
{
if (Stimulus.IsValid())
{
OutLocation = Stimulus.StimulusLocation;
return true;
}
}
}
}
return false;
}
bool UPS_AI_Behavior_PerceptionComponent::GetGunShotStimulusLocation(FVector& OutLocation)
{
TArray<AActor*> 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<UAISense_Hearing>() &&
PS_AI_Behavior_Tags_Internal::IsGunfire(Stimulus.Tag))
{
OutLocation = Stimulus.StimulusLocation;
return true;
}
}
}
}
return false;
}

View File

@@ -0,0 +1,490 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Interface.h"
#include "PS_AI_Behavior_PersonalityProfile.h"
#include "PS_AI_Behavior_Statics.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_SplineFollowerComponent.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Net/UnrealNetwork.h"
#include "DrawDebugHelpers.h"
#include "Kismet/KismetSystemLibrary.h"
#include "AIController.h"
UPS_AI_Behavior_PersonalityComponent::UPS_AI_Behavior_PersonalityComponent()
{
PrimaryComponentTick.bCanEverTick = true;
bTickInEditor = true; // Required for Simulate In Editor
SetIsReplicatedByDefault(true);
}
void UPS_AI_Behavior_PersonalityComponent::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UPS_AI_Behavior_PersonalityComponent, CurrentState);
DOREPLIFETIME(UPS_AI_Behavior_PersonalityComponent, PerceivedThreatLevel);
}
void UPS_AI_Behavior_PersonalityComponent::BeginPlay()
{
Super::BeginPlay();
// Initialize runtime traits from profile
if (Profile)
{
RuntimeTraits = Profile->TraitScores;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Personality initialized from profile '%s' (%s)"),
*GetOwner()->GetName(),
*Profile->ProfileName.ToString(),
*UEnum::GetValueAsString(Profile->NPCType));
}
else
{
// Defaults — all traits at 0.5
RuntimeTraits.Add(EPS_AI_Behavior_TraitAxis::Courage, 0.5f);
RuntimeTraits.Add(EPS_AI_Behavior_TraitAxis::Aggressivity, 0.5f);
RuntimeTraits.Add(EPS_AI_Behavior_TraitAxis::Caution, 0.5f);
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] No PersonalityProfile assigned — using default traits."),
*GetOwner()->GetName());
}
}
float UPS_AI_Behavior_PersonalityComponent::GetTrait(EPS_AI_Behavior_TraitAxis Axis) const
{
const float* Found = RuntimeTraits.Find(Axis);
return Found ? *Found : 0.5f;
}
void UPS_AI_Behavior_PersonalityComponent::ModifyTrait(EPS_AI_Behavior_TraitAxis Axis, float Delta)
{
float& Value = RuntimeTraits.FindOrAdd(Axis, 0.5f);
Value = FMath::Clamp(Value + Delta, 0.0f, 1.0f);
}
EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::EvaluateReaction() const
{
if (CurrentState == EPS_AI_Behavior_State::Dead)
{
return EPS_AI_Behavior_State::Dead;
}
if (CurrentState == EPS_AI_Behavior_State::Scripted)
{
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);
// Get thresholds from profile (or use defaults)
float FleeThresh = Profile ? Profile->FleeThreshold : 0.5f;
float AttackThresh = Profile ? Profile->AttackThreshold : 0.4f;
float AlertThresh = Profile ? Profile->AlertThreshold : 0.15f;
// Modulate thresholds by personality:
// - High courage raises the flee threshold (harder to scare)
// - High aggressivity lowers the attack threshold (quicker to fight)
// - High caution lowers the flee threshold (quicker to run)
const float EffectiveFleeThresh = FleeThresh * (0.5f + Courage * 0.5f) * (1.5f - Caution * 0.5f);
const float EffectiveAttackThresh = AttackThresh * (1.5f - Aggressivity * 0.5f);
if (UPS_AI_Behavior_Statics::IsDebugEnabled(Cast<APawn>(GetOwner())))
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] EvaluateReaction: threat=%.2f, aggr=%.2f, courage=%.2f, caution=%.2f | attackThresh=%.2f, fleeThresh=%.2f, alertThresh=%.2f"),
GetOwner() ? *GetOwner()->GetName() : TEXT("?"),
PerceivedThreatLevel, Aggressivity, Courage, Caution,
EffectiveAttackThresh, EffectiveFleeThresh, AlertThresh);
}
// Decision cascade — with hysteresis to prevent state flickering.
// Once in a state, require a larger threshold drop to leave it.
const float Hysteresis = 0.15f;
if (CurrentState == EPS_AI_Behavior_State::Fleeing)
{
// Stay fleeing until threat drops well below the threshold
if (PerceivedThreatLevel >= (EffectiveFleeThresh - Hysteresis))
{
return EPS_AI_Behavior_State::Fleeing;
}
}
else if (PerceivedThreatLevel >= EffectiveFleeThresh && Courage < 0.7f)
{
return EPS_AI_Behavior_State::Fleeing;
}
// Disguised enemies never enter active combat — skip the Combat branch entirely.
if (!bDisguisedEnemy)
{
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;
}
}
if (PerceivedThreatLevel >= AlertThresh)
{
return EPS_AI_Behavior_State::Alerted;
}
// No threat — patrol if following a spline, otherwise idle
if (const AActor* Owner = GetOwner())
{
if (const UPS_AI_Behavior_SplineFollowerComponent* Spline =
Owner->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>())
{
if (Spline->bIsFollowing || Spline->CurrentSpline)
{
return EPS_AI_Behavior_State::Patrol;
}
}
}
return EPS_AI_Behavior_State::Idle;
}
EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::ApplyReaction()
{
// Only server can change replicated state
if (!GetOwner() || !GetOwner()->HasAuthority())
{
return CurrentState;
}
EPS_AI_Behavior_State NewState = EvaluateReaction();
// ─── Combat/Cover cycle timer ──────────────────────────────────
// While in Combat, alternate bPreferCover flag based on personality ratio.
// Writes PreferCover bool to Blackboard so BT decorators can react via observer aborts.
if (NewState == EPS_AI_Behavior_State::Combat)
{
if (!bCombatCoverCycleActive)
{
// Start the cycle — begin with cover if cautious, attack if aggressive
bCombatCoverCycleActive = true;
const float Aggressivity = GetTrait(EPS_AI_Behavior_TraitAxis::Aggressivity);
const float Caution = GetTrait(EPS_AI_Behavior_TraitAxis::Caution);
const float CombatRatio = (Aggressivity + Caution > 0.0f)
? Aggressivity / (Aggressivity + Caution) : 0.5f;
bPreferCover = (CombatRatio <= 0.5f);
CombatCoverTimer = CalculatePhaseDuration(
bPreferCover ? EPS_AI_Behavior_State::TakingCover : EPS_AI_Behavior_State::Combat);
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Combat/Cover cycle started: %s for %.1fs"),
*GetOwner()->GetName(), bPreferCover ? TEXT("Cover") : TEXT("Attack"), CombatCoverTimer);
}
else
{
// Tick down the timer (ApplyReaction is called every ~0.5s by the BT service)
CombatCoverTimer -= 0.5f;
if (CombatCoverTimer <= 0.0f)
{
// Switch phase
bPreferCover = !bPreferCover;
CombatCoverTimer = CalculatePhaseDuration(
bPreferCover ? EPS_AI_Behavior_State::TakingCover : EPS_AI_Behavior_State::Combat);
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Combat/Cover cycle switch: → %s for %.1fs"),
*GetOwner()->GetName(), bPreferCover ? TEXT("Cover") : TEXT("Attack"), CombatCoverTimer);
}
}
// Write to BB so Blackboard-based decorators can observe the change
if (AActor* Owner = GetOwner())
{
if (AAIController* AIC = Cast<AAIController>(Cast<APawn>(Owner) ?
Cast<APawn>(Owner)->GetController() : nullptr))
{
if (UBlackboardComponent* BB = AIC->GetBlackboardComponent())
{
BB->SetValueAsBool(PS_AI_Behavior_BB::PreferCover, bPreferCover);
}
}
}
}
else
{
// Left combat → reset cycle
if (bCombatCoverCycleActive)
{
bCombatCoverCycleActive = false;
bPreferCover = false;
CombatCoverTimer = 0.0f;
if (AActor* Owner = GetOwner())
{
if (AAIController* AIC = Cast<AAIController>(Cast<APawn>(Owner) ?
Cast<APawn>(Owner)->GetController() : nullptr))
{
if (UBlackboardComponent* BB = AIC->GetBlackboardComponent())
{
BB->SetValueAsBool(PS_AI_Behavior_BB::PreferCover, false);
}
}
}
}
}
if (NewState != CurrentState)
{
const EPS_AI_Behavior_State OldState = CurrentState;
CurrentState = NewState; // Replicated → OnRep fires on clients
if (UPS_AI_Behavior_Statics::IsDebugEnabled(Cast<APawn>(GetOwner())))
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] State: %s -> %s (Threat: %.2f)"),
*GetOwner()->GetName(),
*UEnum::GetValueAsString(OldState),
*UEnum::GetValueAsString(NewState),
PerceivedThreatLevel);
}
HandleStateChanged(OldState, NewState);
}
return CurrentState;
}
float UPS_AI_Behavior_PersonalityComponent::CalculatePhaseDuration(EPS_AI_Behavior_State Phase) const
{
const float Aggressivity = GetTrait(EPS_AI_Behavior_TraitAxis::Aggressivity);
const float Caution = GetTrait(EPS_AI_Behavior_TraitAxis::Caution);
const float CycleDuration = Profile ? Profile->CombatCoverCycleDuration : 15.0f;
const float Sum = Aggressivity + Caution;
const float CombatRatio = (Sum > 0.0f) ? Aggressivity / Sum : 0.5f;
float Duration;
if (Phase == EPS_AI_Behavior_State::Combat)
{
Duration = CycleDuration * CombatRatio;
}
else
{
Duration = CycleDuration * (1.0f - CombatRatio);
}
// Clamp minimum 2s, add ±20% jitter
Duration = FMath::Max(Duration, 2.0f);
Duration *= FMath::RandRange(0.8f, 1.2f);
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
if (!GetOwner() || !GetOwner()->HasAuthority())
{
return;
}
if (NewState != CurrentState)
{
const EPS_AI_Behavior_State OldState = CurrentState;
CurrentState = NewState; // Replicated → OnRep fires on clients
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] State forced: %s -> %s"),
*GetOwner()->GetName(),
*UEnum::GetValueAsString(OldState),
*UEnum::GetValueAsString(NewState));
HandleStateChanged(OldState, NewState);
}
}
void UPS_AI_Behavior_PersonalityComponent::OnRep_CurrentState(EPS_AI_Behavior_State OldState)
{
// On clients: fire delegate only (speed is set by CMC replication)
OnBehaviorStateChanged.Broadcast(OldState, CurrentState);
// Also notify the Pawn via interface (for client-side cosmetics)
AActor* Owner = GetOwner();
if (Owner && Owner->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_OnBehaviorStateChanged(Owner, CurrentState, OldState);
}
}
void UPS_AI_Behavior_PersonalityComponent::HandleStateChanged(
EPS_AI_Behavior_State OldState, EPS_AI_Behavior_State NewState)
{
// 1. Broadcast delegate (server)
OnBehaviorStateChanged.Broadcast(OldState, NewState);
AActor* Owner = GetOwner();
if (!Owner) return;
// 2. Set movement speed via interface
if (Owner->Implements<UPS_AI_Behavior_Interface>())
{
float NewSpeed = Profile ? Profile->GetSpeedForState(NewState) : 150.0f;
IPS_AI_Behavior_Interface::Execute_SetBehaviorMovementSpeed(Owner, NewSpeed);
// 3. Notify the Pawn of the state change
IPS_AI_Behavior_Interface::Execute_OnBehaviorStateChanged(Owner, NewState, OldState);
}
}
void UPS_AI_Behavior_PersonalityComponent::TickComponent(
float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (bDebug)
{
DrawDebugInfo();
}
}
void UPS_AI_Behavior_PersonalityComponent::DrawDebugInfo() const
{
const AActor* Owner = GetOwner();
if (!Owner) return;
const UWorld* World = GetWorld();
if (!World) return;
// Position: above the NPC's head
const FVector HeadPos = Owner->GetActorLocation() + FVector(0.f, 0.f, 120.f);
// ─── Gather info ────────────────────────────────────────────────────
const FString NPCName = Owner->GetName();
const EPS_AI_Behavior_NPCType NPCType = GetNPCType();
// TeamId + Hostile from AIController
uint8 DebugTeamId = 255;
FString ThreatActorName = TEXT("none");
float ThreatLevel = PerceivedThreatLevel;
bool bHostile = false;
const APawn* Pawn = Cast<APawn>(Owner);
if (Pawn)
{
if (const APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(Pawn->GetController()))
{
DebugTeamId = AIC->GetGenericTeamId().GetId();
// Get threat target name from Blackboard (prefer Pawn name for readability)
if (const UBlackboardComponent* BB = AIC->GetBlackboardComponent())
{
const FString PawnName = BB->GetValueAsString(PS_AI_Behavior_BB::ThreatPawnName);
if (!PawnName.IsEmpty())
{
ThreatActorName = PawnName;
}
else if (AActor* ThreatActor = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)))
{
ThreatActorName = ThreatActor->GetName();
}
}
}
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
bHostile = IPS_AI_Behavior_Interface::Execute_IsBehaviorHostile(const_cast<APawn*>(Pawn));
}
}
// Spline info
FString SplineInfo = TEXT("");
if (const UPS_AI_Behavior_SplineFollowerComponent* Spline =
Owner->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>())
{
if (Spline->bIsFollowing && Spline->CurrentSpline)
{
SplineInfo = FString::Printf(TEXT("\nSpline: %s (%.0f%%)"),
*Spline->CurrentSpline->GetName(), Spline->GetProgress() * 100.f);
}
}
// ─── Color by state ─────────────────────────────────────────────────
FColor TextColor;
switch (CurrentState)
{
case EPS_AI_Behavior_State::Idle: TextColor = FColor::White; break;
case EPS_AI_Behavior_State::Patrol: TextColor = FColor::Green; break;
case EPS_AI_Behavior_State::Alerted: TextColor = FColor::Yellow; break;
case EPS_AI_Behavior_State::Combat: TextColor = FColor::Red; break;
case EPS_AI_Behavior_State::Fleeing: TextColor = FColor::Orange; break;
case EPS_AI_Behavior_State::TakingCover: TextColor = FColor::Cyan; break;
case EPS_AI_Behavior_State::Dead: TextColor = FColor(80, 80, 80); break;
default: TextColor = FColor::White; break;
}
// ─── Build text ─────────────────────────────────────────────────────
// Decode faction from TeamId
const uint8 DebugFaction = PS_AI_Behavior_Team::GetFaction(DebugTeamId);
const FString FactionStr = DebugFaction > 0 ? FString::Printf(TEXT(" F%d"), DebugFaction) : TEXT("");
const FString DebugText = FString::Printf(
TEXT("%s [%s%s] %s\n%s Threat:%.2f → %s%s"),
*NPCName,
*UEnum::GetDisplayValueAsText(NPCType).ToString(),
*FactionStr,
bHostile ? TEXT("HOSTILE") : TEXT(""),
*UEnum::GetDisplayValueAsText(CurrentState).ToString(),
ThreatLevel,
*ThreatActorName,
*SplineInfo);
UKismetSystemLibrary::DrawDebugString(
GetOwner(), HeadPos, DebugText, nullptr, FLinearColor(TextColor), 0.f);
}
EPS_AI_Behavior_NPCType UPS_AI_Behavior_PersonalityComponent::GetNPCType() const
{
// Prefer the IPS_AI_Behavior interface on the owning actor
AActor* Owner = GetOwner();
if (Owner && Owner->Implements<UPS_AI_Behavior_Interface>())
{
return IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(Owner);
}
// Fallback: read from PersonalityProfile
return Profile ? Profile->NPCType : EPS_AI_Behavior_NPCType::Civilian;
}

View File

@@ -0,0 +1,42 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_PersonalityProfile.h"
UPS_AI_Behavior_PersonalityProfile::UPS_AI_Behavior_PersonalityProfile()
{
// Initialize all trait axes to a neutral 0.5
TraitScores.Add(EPS_AI_Behavior_TraitAxis::Courage, 0.5f);
TraitScores.Add(EPS_AI_Behavior_TraitAxis::Aggressivity, 0.5f);
TraitScores.Add(EPS_AI_Behavior_TraitAxis::Caution, 0.5f);
// Default target priority: Protector first, then Player, then Civilian
TargetPriority.Add(EPS_AI_Behavior_TargetType::Protector);
TargetPriority.Add(EPS_AI_Behavior_TargetType::Player);
TargetPriority.Add(EPS_AI_Behavior_TargetType::Civilian);
// Default speeds per state (cm/s)
SpeedPerState.Add(EPS_AI_Behavior_State::Idle, 0.0f);
SpeedPerState.Add(EPS_AI_Behavior_State::Patrol, 150.0f);
SpeedPerState.Add(EPS_AI_Behavior_State::Alerted, 200.0f);
SpeedPerState.Add(EPS_AI_Behavior_State::Combat, 350.0f);
SpeedPerState.Add(EPS_AI_Behavior_State::Fleeing, 500.0f);
SpeedPerState.Add(EPS_AI_Behavior_State::TakingCover, 400.0f);
SpeedPerState.Add(EPS_AI_Behavior_State::Dead, 0.0f);
}
float UPS_AI_Behavior_PersonalityProfile::GetTrait(EPS_AI_Behavior_TraitAxis Axis) const
{
const float* Found = TraitScores.Find(Axis);
return Found ? *Found : 0.5f;
}
float UPS_AI_Behavior_PersonalityProfile::GetSpeedForState(EPS_AI_Behavior_State State) const
{
const float* Found = SpeedPerState.Find(State);
return Found ? *Found : DefaultWalkSpeed;
}
FPrimaryAssetId UPS_AI_Behavior_PersonalityProfile::GetPrimaryAssetId() const
{
return FPrimaryAssetId(TEXT("PersonalityProfile"), GetFName());
}

View File

@@ -0,0 +1,7 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_Settings.h"
UPS_AI_Behavior_Settings::UPS_AI_Behavior_Settings()
{
}

View File

@@ -0,0 +1,525 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_SplineFollowerComponent.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "PS_AI_Behavior_SplineNetwork.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "Components/SplineComponent.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Net/UnrealNetwork.h"
#include "DrawDebugHelpers.h"
UPS_AI_Behavior_SplineFollowerComponent::UPS_AI_Behavior_SplineFollowerComponent()
{
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.TickGroup = TG_PrePhysics;
SetIsReplicatedByDefault(true);
// Each NPC walks at a slightly different speed for natural look
SpeedVariation = FMath::RandRange(0.85f, 1.15f);
}
void UPS_AI_Behavior_SplineFollowerComponent::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UPS_AI_Behavior_SplineFollowerComponent, CurrentSpline);
DOREPLIFETIME(UPS_AI_Behavior_SplineFollowerComponent, bIsFollowing);
}
bool UPS_AI_Behavior_SplineFollowerComponent::StartFollowing(
APS_AI_Behavior_SplinePath* Spline, bool bForward)
{
if (!Spline || !GetOwner())
{
return false;
}
// Snap to closest point on spline
float Dist = 0.0f;
FVector ClosestPoint;
Spline->GetClosestPointOnSpline(GetOwner()->GetActorLocation(), Dist, ClosestPoint);
return StartFollowingAtDistance(Spline, Dist, bForward);
}
bool UPS_AI_Behavior_SplineFollowerComponent::StartFollowingAtDistance(
APS_AI_Behavior_SplinePath* Spline, float StartDistance, bool bForward)
{
if (!Spline)
{
return false;
}
APS_AI_Behavior_SplinePath* OldSpline = CurrentSpline;
CurrentSpline = Spline;
CurrentDistance = FMath::Clamp(StartDistance, 0.0f, Spline->GetSplineLength());
bMovingForward = bForward;
bIsFollowing = true;
bManuallyStopped = false; // an explicit Start lifts any previous manual stop
LastHandledJunctionIndex = -1;
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("[%s] StartFollowingAtDistance: spline='%s' dist=%.0f/%.0f dir=%s (was spline='%s')"),
GetOwner() ? *GetOwner()->GetName() : TEXT("?"),
*Spline->GetName(), CurrentDistance, Spline->GetSplineLength(),
bForward ? TEXT("FWD") : TEXT("BWD"),
OldSpline ? *OldSpline->GetName() : TEXT("none"));
if (OldSpline && OldSpline != Spline)
{
OnSplineChanged.Broadcast(OldSpline, Spline);
}
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Started following spline '%s' at d=%.0f (%s)"),
*GetOwner()->GetName(), *Spline->GetName(), CurrentDistance,
bForward ? TEXT("forward") : TEXT("reverse"));
return true;
}
void UPS_AI_Behavior_SplineFollowerComponent::StopFollowing()
{
bIsFollowing = false;
// Firm, manual stop: the Behavior Tree spline tasks will refuse to re-start
// following until StartFollowing()/StartFollowingAtDistance() is called again.
bManuallyStopped = true;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] StopFollowing: manual stop (BT will not auto-resume)."),
GetOwner() ? *GetOwner()->GetName() : TEXT("?"));
}
void UPS_AI_Behavior_SplineFollowerComponent::PauseFollowing()
{
// Pause is NOT a manual stop — the BT is allowed to resume it.
bIsFollowing = false;
}
void UPS_AI_Behavior_SplineFollowerComponent::ResumeFollowing()
{
// Do not resume over a manual stop — only an explicit Start lifts that.
if (CurrentSpline && !bManuallyStopped)
{
bIsFollowing = true;
}
}
void UPS_AI_Behavior_SplineFollowerComponent::SwitchToSpline(
APS_AI_Behavior_SplinePath* NewSpline, float DistanceOnNew, bool bNewForward)
{
if (!NewSpline) return;
APS_AI_Behavior_SplinePath* OldSpline = CurrentSpline;
CurrentSpline = NewSpline;
CurrentDistance = FMath::Clamp(DistanceOnNew, 0.0f, NewSpline->GetSplineLength());
bMovingForward = bNewForward;
LastHandledJunctionIndex = -1;
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Switched to spline '%s' at d=%.0f"),
GetOwner() ? *GetOwner()->GetName() : TEXT("?"), *NewSpline->GetName(), CurrentDistance);
if (OldSpline != NewSpline)
{
OnSplineChanged.Broadcast(OldSpline, NewSpline);
}
}
float UPS_AI_Behavior_SplineFollowerComponent::GetEffectiveSpeed() const
{
float Speed = DefaultWalkSpeed;
if (CurrentSpline && CurrentSpline->SplineWalkSpeed > 0.0f)
{
Speed = CurrentSpline->SplineWalkSpeed;
}
return Speed * SpeedVariation;
}
float UPS_AI_Behavior_SplineFollowerComponent::GetProgress() const
{
if (!CurrentSpline) return 0.0f;
const float Len = CurrentSpline->GetSplineLength();
return (Len > 0.0f) ? (CurrentDistance / Len) : 0.0f;
}
void UPS_AI_Behavior_SplineFollowerComponent::TickComponent(
float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (!bIsFollowing || !CurrentSpline || !GetOwner())
{
return;
}
const float SplineLen = CurrentSpline->GetSplineLength();
if (SplineLen <= 0.0f)
{
return;
}
// ─── Pure Pursuit: project NPC onto spline, target = lookahead ahead ──
// This eliminates gap accumulation in turns. The NPC always steers toward
// a point that follows the curve naturally at a fixed distance ahead.
AActor* Owner = GetOwner();
const FVector CurrentLocation = Owner->GetActorLocation();
const FRotator CurrentRotation = Owner->GetActorRotation();
// Project NPC position onto the spline to find where we actually are
float ProjectedDistance = 0.0f;
FVector ProjectedPoint;
CurrentSpline->GetClosestPointOnSpline(CurrentLocation, ProjectedDistance, ProjectedPoint);
// Ensure monotonic progress: don't let projection jump backward past current
// (can happen at tight S-curves where two segments are close in world space)
const float ProgressDir = bMovingForward ? 1.0f : -1.0f;
const float ProgressDelta = (ProjectedDistance - CurrentDistance) * ProgressDir;
if (ProgressDelta < -LookaheadDistance)
{
// Projection jumped too far backward — keep current distance and advance normally
float Speed = GetEffectiveSpeed();
ACharacter* TmpChar = Cast<ACharacter>(Owner);
if (TmpChar && TmpChar->GetCharacterMovement())
{
Speed = TmpChar->GetCharacterMovement()->GetMaxSpeed();
}
if (bMovingForward)
{
CurrentDistance += Speed * DeltaTime;
}
else
{
CurrentDistance -= Speed * DeltaTime;
}
}
else
{
CurrentDistance = ProjectedDistance;
}
// ─── Compute target (lookahead) distance ────────────────────────────
float TargetDistance;
if (bMovingForward)
{
TargetDistance = CurrentDistance + LookaheadDistance;
}
else
{
TargetDistance = CurrentDistance - LookaheadDistance;
}
// ─── End of spline handling ─────────────────────────────────────────
if (TargetDistance >= SplineLen)
{
if (CurrentSpline->SplineComp && CurrentSpline->SplineComp->IsClosedLoop())
{
TargetDistance = FMath::Fmod(TargetDistance, SplineLen);
}
else if (bReverseAtEnd)
{
// Clamp target to end, and reverse when NPC is close enough
TargetDistance = SplineLen;
const FVector EndPoint = CurrentSpline->GetWorldLocationAtDistance(SplineLen);
const float GapToEnd = FVector::Dist2D(CurrentLocation, EndPoint);
if (GapToEnd < 80.0f)
{
bMovingForward = false;
TargetDistance = CurrentDistance - LookaheadDistance;
TargetDistance = FMath::Max(TargetDistance, 0.0f);
}
}
else
{
TargetDistance = SplineLen;
const FVector EndPoint = CurrentSpline->GetWorldLocationAtDistance(SplineLen);
const float GapToEnd = FVector::Dist2D(CurrentLocation, EndPoint);
if (GapToEnd < 80.0f)
{
bIsFollowing = false;
OnSplineEndReached.Broadcast(CurrentSpline);
return;
}
}
}
else if (TargetDistance <= 0.0f)
{
if (CurrentSpline->SplineComp && CurrentSpline->SplineComp->IsClosedLoop())
{
TargetDistance = SplineLen + TargetDistance;
}
else if (bReverseAtEnd)
{
TargetDistance = 0.0f;
const FVector StartPoint = CurrentSpline->GetWorldLocationAtDistance(0.0f);
const float GapToStart = FVector::Dist2D(CurrentLocation, StartPoint);
if (GapToStart < 80.0f)
{
bMovingForward = true;
TargetDistance = CurrentDistance + LookaheadDistance;
TargetDistance = FMath::Min(TargetDistance, SplineLen);
}
}
else
{
TargetDistance = 0.0f;
const FVector StartPoint = CurrentSpline->GetWorldLocationAtDistance(0.0f);
const float GapToStart = FVector::Dist2D(CurrentLocation, StartPoint);
if (GapToStart < 80.0f)
{
bIsFollowing = false;
OnSplineEndReached.Broadcast(CurrentSpline);
return;
}
}
}
// ─── Move the pawn ──────────────────────────────────────────────────
const FVector TargetLocation = CurrentSpline->GetWorldLocationAtDistance(TargetDistance);
float Speed = GetEffectiveSpeed();
ACharacter* Character = Cast<ACharacter>(Owner);
if (Character && Character->GetCharacterMovement())
{
Speed = Character->GetCharacterMovement()->GetMaxSpeed();
const FVector ToTarget = TargetLocation - CurrentLocation;
const float GapToTarget = ToTarget.Size2D();
FVector MoveDirection;
if (GapToTarget < 1.0f)
{
// On top of the target — use spline tangent direction
MoveDirection = CurrentSpline->GetWorldDirectionAtDistance(CurrentDistance)
* (bMovingForward ? 1.0f : -1.0f);
}
else
{
MoveDirection = ToTarget.GetSafeNormal();
}
// Use AddMovementInput — works reliably regardless of AIController movement state
Character->AddMovementInput(MoveDirection, 1.0f);
// Debug: log velocity to verify movement is happening
const FVector Vel = Character->GetVelocity();
if (Vel.Size() < 1.0f)
{
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("[%s] SplineFollower: AddMovementInput but velocity=%.1f! MaxSpeed=%.0f Gap=%.0f CMC_MovementMode=%d"),
*GetOwner()->GetName(), Vel.Size(), Speed, GapToTarget,
(int32)Character->GetCharacterMovement()->MovementMode.GetValue());
}
}
else
{
// Direct placement for non-Characters
Owner->SetActorLocation(TargetLocation);
}
// Smooth rotation — face toward the target point (follows curve naturally)
const FVector ToTargetDir = (TargetLocation - CurrentLocation).GetSafeNormal2D();
FRotator FinalTargetRot;
if (!ToTargetDir.IsNearlyZero())
{
FinalTargetRot = ToTargetDir.Rotation();
}
else
{
// Fallback to spline tangent
FinalTargetRot = CurrentSpline->GetWorldRotationAtDistance(CurrentDistance);
if (!bMovingForward)
{
FinalTargetRot.Yaw += 180.0f;
}
}
const FRotator SmoothedRot = FMath::RInterpConstantTo(
CurrentRotation, FinalTargetRot, DeltaTime, RotationInterpSpeed);
Owner->SetActorRotation(FRotator(0.0f, SmoothedRot.Yaw, 0.0f));
// ─── Debug drawing ─────────────────────────────────────────────────
if (bDebug)
{
const UWorld* World = GetWorld();
if (World)
{
// Projected point on spline (small blue sphere = where NPC actually is on spline)
DrawDebugSphere(World, ProjectedPoint, 10.0f, 6, FColor::Blue, false, -1.0f, 0, 1.5f);
// Lookahead target point (green sphere = where NPC steers toward)
DrawDebugSphere(World, TargetLocation, 15.0f, 8, FColor::Green, false, -1.0f, 0, 2.0f);
// Line from NPC to lookahead target (yellow)
DrawDebugLine(World, CurrentLocation, TargetLocation, FColor::Yellow, false, -1.0f, 0, 1.5f);
// Spline tangent direction at NPC position (cyan arrow)
const FVector Tangent = CurrentSpline->GetWorldDirectionAtDistance(CurrentDistance)
* (bMovingForward ? 1.0f : -1.0f);
DrawDebugDirectionalArrow(World, ProjectedPoint, ProjectedPoint + Tangent * 150.0f,
20.0f, FColor::Cyan, false, -1.0f, 0, 2.0f);
// Info text
const float DebugGap = FVector::Dist2D(CurrentLocation, TargetLocation);
const float SplineGap = FVector::Dist2D(CurrentLocation, ProjectedPoint);
DrawDebugString(World, CurrentLocation + FVector(0, 0, 100.0f),
FString::Printf(TEXT("Look: %.0fcm Off: %.0fcm D: %.0f/%.0f"),
DebugGap, SplineGap, CurrentDistance, SplineLen),
nullptr, FColor::White, -1.0f, true);
}
}
// ─── Junction handling ──────────────────────────────────────────────
HandleJunctions();
}
void UPS_AI_Behavior_SplineFollowerComponent::HandleJunctions()
{
if (!CurrentSpline) return;
const TArray<FPS_AI_Behavior_SplineJunction>& Junctions = CurrentSpline->Junctions;
if (Junctions.Num() == 0) return;
for (int32 i = 0; i < Junctions.Num(); ++i)
{
if (i == LastHandledJunctionIndex)
{
continue;
}
const FPS_AI_Behavior_SplineJunction& J = Junctions[i];
const float DistToJunction = FMath::Abs(J.DistanceOnThisSpline - CurrentDistance);
// Are we approaching this junction?
if (DistToJunction <= JunctionDetectionDistance)
{
// Check direction: only handle junctions ahead of us
if (bMovingForward && J.DistanceOnThisSpline < CurrentDistance)
{
continue; // Junction is behind us
}
if (!bMovingForward && J.DistanceOnThisSpline > CurrentDistance)
{
continue; // Junction is behind us
}
LastHandledJunctionIndex = i;
OnApproachingJunction.Broadcast(CurrentSpline, i, DistToJunction);
if (bDebug)
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] Junction detected: idx=%d, dist along=%.0f, dist to=%.0fcm, worldPos=(%.0f,%.0f,%.0f)"),
GetOwner() ? *GetOwner()->GetName() : TEXT("?"),
i, J.DistanceOnThisSpline, DistToJunction,
J.WorldLocation.X, J.WorldLocation.Y, J.WorldLocation.Z);
}
if (bAutoChooseAtJunction)
{
// Only switch with a probability — don't always change at every junction
const float SwitchChance = 0.7f; // 70% chance to switch
const float Roll = FMath::FRand();
if (Roll > SwitchChance)
{
if (bDebug)
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] Junction %d: skipped (roll=%.2f > chance=%.2f)"),
GetOwner() ? *GetOwner()->GetName() : TEXT("?"), i, Roll, SwitchChance);
}
break; // Stay on current spline
}
// Use SplineNetwork subsystem to choose
UPS_AI_Behavior_SplineNetwork* Network =
GetWorld()->GetSubsystem<UPS_AI_Behavior_SplineNetwork>();
if (!Network) break;
// Get NPC type and caution from personality
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Civilian;
float Caution = 0.5f;
UPS_AI_Behavior_PersonalityComponent* Personality =
GetOwner()->FindComponentByClass<UPS_AI_Behavior_PersonalityComponent>();
if (Personality)
{
NPCType = Personality->GetNPCType();
Caution = Personality->GetTrait(EPS_AI_Behavior_TraitAxis::Caution);
}
APS_AI_Behavior_SplinePath* ChosenSpline = Network->ChooseSplineAtJunction(
CurrentSpline, i, NPCType, FVector::ZeroVector, Caution);
if (ChosenSpline && ChosenSpline != CurrentSpline)
{
// Only switch if the junction points are actually close in world space
const FVector CurrentPos = GetOwner()->GetActorLocation();
const FVector JunctionPos = J.WorldLocation;
const float WorldGap = FVector::Dist(CurrentPos, JunctionPos);
if (WorldGap > JunctionDetectionDistance * 2.0f)
{
if (bDebug)
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] Junction %d: rejected (worldGap=%.0f > detect=%.0f)"),
GetOwner() ? *GetOwner()->GetName() : TEXT("?"),
i, WorldGap, JunctionDetectionDistance);
}
break; // Too far in world space
}
// Check direction continuity: avoid U-turns
// Compare our current movement direction with the new spline's direction at the junction
const FVector CurrentDir = CurrentSpline->GetWorldDirectionAtDistance(CurrentDistance)
* (bMovingForward ? 1.0f : -1.0f);
const FVector NewDirForward = ChosenSpline->GetWorldDirectionAtDistance(J.DistanceOnOtherSpline);
const FVector NewDirBackward = -NewDirForward;
const float DotForward = FVector::DotProduct(CurrentDir, NewDirForward);
const float DotBackward = FVector::DotProduct(CurrentDir, NewDirBackward);
// Choose the direction on the new spline that best continues our current heading
bool bNewForward;
float BestDot;
if (DotForward >= DotBackward)
{
bNewForward = true;
BestDot = DotForward;
}
else
{
bNewForward = false;
BestDot = DotBackward;
}
// Only switch if the direction change is less than ~135° (dot > -0.7)
// This prevents full U-turns but allows wide turns at crossings
if (BestDot < -0.7f)
{
if (bDebug)
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] Junction %d: rejected U-turn (dot=%.2f)"),
GetOwner() ? *GetOwner()->GetName() : TEXT("?"), i, BestDot);
}
break; // Would cause a near-180° U-turn, skip
}
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[%s] Junction switch: %s -> %s (dot=%.2f, dir=%s, worldGap=%.0fcm)"),
GetOwner() ? *GetOwner()->GetName() : TEXT("?"),
*CurrentSpline->GetName(), *ChosenSpline->GetName(),
BestDot, bNewForward ? TEXT("fwd") : TEXT("bwd"),
FVector::Dist(CurrentPos, JunctionPos));
SwitchToSpline(ChosenSpline, J.DistanceOnOtherSpline, bNewForward);
}
}
break; // Only handle one junction per tick
}
}
}

View File

@@ -0,0 +1,257 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_SplineNetwork.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "Components/SplineComponent.h"
#include "EngineUtils.h"
#include "Engine/World.h"
// ─── Subsystem Lifecycle ────────────────────────────────────────────────────
void UPS_AI_Behavior_SplineNetwork::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
}
void UPS_AI_Behavior_SplineNetwork::Deinitialize()
{
AllSplines.Empty();
TotalJunctions = 0;
Super::Deinitialize();
}
void UPS_AI_Behavior_SplineNetwork::OnWorldBeginPlay(UWorld& InWorld)
{
Super::OnWorldBeginPlay(InWorld);
RebuildNetwork();
}
// ─── Network Build ──────────────────────────────────────────────────────────
void UPS_AI_Behavior_SplineNetwork::RebuildNetwork()
{
UWorld* World = GetWorld();
if (!World) return;
AllSplines.Empty();
TotalJunctions = 0;
// Gather all spline paths
for (TActorIterator<APS_AI_Behavior_SplinePath> It(World); It; ++It)
{
AllSplines.Add(*It);
It->Junctions.Empty(); // Reset
}
UE_LOG(LogPS_AI_Behavior, Log, TEXT("SplineNetwork: Found %d spline paths. Computing junctions..."),
AllSplines.Num());
// Junction detection tolerance (cm) — how close two splines must be to form a junction
constexpr float JunctionTolerance = 150.0f;
// Detect junctions for all pairs
for (int32 i = 0; i < AllSplines.Num(); ++i)
{
for (int32 j = i + 1; j < AllSplines.Num(); ++j)
{
if (AllSplines[i] && AllSplines[j])
{
DetectJunctions(AllSplines[i], AllSplines[j], JunctionTolerance);
}
}
}
// Sort junctions by distance along spline for each path
for (APS_AI_Behavior_SplinePath* Spline : AllSplines)
{
if (Spline)
{
Spline->Junctions.Sort([](const FPS_AI_Behavior_SplineJunction& A,
const FPS_AI_Behavior_SplineJunction& B)
{
return A.DistanceOnThisSpline < B.DistanceOnThisSpline;
});
}
}
UE_LOG(LogPS_AI_Behavior, Log, TEXT("SplineNetwork: %d junctions detected."), TotalJunctions);
}
void UPS_AI_Behavior_SplineNetwork::DetectJunctions(
APS_AI_Behavior_SplinePath* SplineA,
APS_AI_Behavior_SplinePath* SplineB,
float Tolerance)
{
if (!SplineA || !SplineB) return;
if (!SplineA->SplineComp || !SplineB->SplineComp) return;
const float LengthA = SplineA->GetSplineLength();
const float LengthB = SplineB->GetSplineLength();
if (LengthA <= 0.0f || LengthB <= 0.0f) return;
// Sample SplineA at regular intervals and check proximity to SplineB
const float SampleStep = FMath::Max(50.0f, LengthA / 200.0f); // At least every 50cm
const float ToleranceSq = Tolerance * Tolerance;
// Track the last junction distance to avoid duplicates (merge nearby junctions)
float LastJunctionDistA = -Tolerance * 3.0f;
for (float DistA = 0.0f; DistA <= LengthA; DistA += SampleStep)
{
// Skip if too close to last detected junction
if (DistA - LastJunctionDistA < Tolerance * 2.0f)
{
continue;
}
const FVector PointA = SplineA->GetWorldLocationAtDistance(DistA);
// Find closest point on SplineB
float DistB = 0.0f;
FVector PointB = FVector::ZeroVector;
const float Gap = SplineB->GetClosestPointOnSpline(PointA, DistB, PointB);
if (Gap <= Tolerance)
{
// Found a junction!
const FVector JunctionLoc = (PointA + PointB) * 0.5f;
// Add junction to SplineA
FPS_AI_Behavior_SplineJunction JunctionOnA;
JunctionOnA.OtherSpline = SplineB;
JunctionOnA.DistanceOnThisSpline = DistA;
JunctionOnA.DistanceOnOtherSpline = DistB;
JunctionOnA.WorldLocation = JunctionLoc;
SplineA->Junctions.Add(JunctionOnA);
// Add mirror junction to SplineB
FPS_AI_Behavior_SplineJunction JunctionOnB;
JunctionOnB.OtherSpline = SplineA;
JunctionOnB.DistanceOnThisSpline = DistB;
JunctionOnB.DistanceOnOtherSpline = DistA;
JunctionOnB.WorldLocation = JunctionLoc;
SplineB->Junctions.Add(JunctionOnB);
++TotalJunctions;
LastJunctionDistA = DistA;
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("SplineNetwork: Junction at (%.0f, %.0f, %.0f) between '%s' (d=%.0f) and '%s' (d=%.0f), gap=%.1fcm"),
JunctionLoc.X, JunctionLoc.Y, JunctionLoc.Z,
*SplineA->GetName(), DistA,
*SplineB->GetName(), DistB,
Gap);
}
}
}
// ─── Queries ────────────────────────────────────────────────────────────────
bool UPS_AI_Behavior_SplineNetwork::FindClosestSpline(
const FVector& WorldLocation, EPS_AI_Behavior_NPCType NPCType,
float MaxDistance, APS_AI_Behavior_SplinePath*& OutSpline, float& OutDistance) const
{
OutSpline = nullptr;
float BestGap = MaxDistance;
for (APS_AI_Behavior_SplinePath* Spline : AllSplines)
{
if (!Spline || !Spline->IsAccessibleTo(NPCType))
{
continue;
}
float Dist = 0.0f;
FVector ClosestPoint;
const float Gap = Spline->GetClosestPointOnSpline(WorldLocation, Dist, ClosestPoint);
if (Gap < BestGap)
{
BestGap = Gap;
OutSpline = Spline;
OutDistance = Dist;
}
}
return OutSpline != nullptr;
}
TArray<APS_AI_Behavior_SplinePath*> UPS_AI_Behavior_SplineNetwork::GetSplinesForCategory(
EPS_AI_Behavior_NPCType Category) const
{
TArray<APS_AI_Behavior_SplinePath*> Result;
for (APS_AI_Behavior_SplinePath* Spline : AllSplines)
{
if (Spline && Spline->IsAccessibleTo(Category))
{
Result.Add(Spline);
}
}
return Result;
}
APS_AI_Behavior_SplinePath* UPS_AI_Behavior_SplineNetwork::ChooseSplineAtJunction(
APS_AI_Behavior_SplinePath* CurrentSpline, int32 JunctionIndex,
EPS_AI_Behavior_NPCType NPCType,
const FVector& ThreatLocation, float CautionScore) const
{
if (!CurrentSpline || !CurrentSpline->Junctions.IsValidIndex(JunctionIndex))
{
return CurrentSpline;
}
const FPS_AI_Behavior_SplineJunction& Junction = CurrentSpline->Junctions[JunctionIndex];
APS_AI_Behavior_SplinePath* OtherSpline = Junction.OtherSpline.Get();
// If other spline is invalid or not accessible, stay
if (!OtherSpline || !OtherSpline->IsAccessibleTo(NPCType))
{
return CurrentSpline;
}
// Score each option
float CurrentScore = 0.0f;
float OtherScore = 0.0f;
// Priority
CurrentScore += CurrentSpline->Priority * 10.0f;
OtherScore += OtherSpline->Priority * 10.0f;
// Randomness for natural behavior (less random if disciplined)
const float RandomRange = FMath::Lerp(30.0f, 5.0f, CautionScore);
CurrentScore += FMath::RandRange(-RandomRange, RandomRange);
OtherScore += FMath::RandRange(-RandomRange, RandomRange);
// Threat avoidance (if threat is present)
if (!ThreatLocation.IsZero())
{
const FVector JunctionLoc = Junction.WorldLocation;
// How far along each spline leads away from threat
// Sample a point ahead on each spline
const float SampleAhead = 500.0f;
const float CurrentDist = Junction.DistanceOnThisSpline;
const float CurrentLen = CurrentSpline->GetSplineLength();
const FVector CurrentAhead = CurrentSpline->GetWorldLocationAtDistance(
FMath::Min(CurrentDist + SampleAhead, CurrentLen));
const float CurrentDistFromThreat = FVector::Dist(CurrentAhead, ThreatLocation);
const float OtherDist = Junction.DistanceOnOtherSpline;
const float OtherLen = OtherSpline->GetSplineLength();
const FVector OtherAhead = OtherSpline->GetWorldLocationAtDistance(
FMath::Min(OtherDist + SampleAhead, OtherLen));
const float OtherDistFromThreat = FVector::Dist(OtherAhead, ThreatLocation);
// Cautious NPCs heavily favor paths away from threat
const float ThreatWeight = 20.0f * CautionScore;
CurrentScore += (CurrentDistFromThreat / 100.0f) * ThreatWeight;
OtherScore += (OtherDistFromThreat / 100.0f) * ThreatWeight;
}
// Slight bias toward continuing on current spline (inertia)
CurrentScore += 5.0f;
return (OtherScore > CurrentScore) ? OtherSpline : CurrentSpline;
}

View File

@@ -0,0 +1,153 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_SplinePath.h"
#include "Components/SplineComponent.h"
APS_AI_Behavior_SplinePath::APS_AI_Behavior_SplinePath()
{
PrimaryActorTick.bCanEverTick = false;
SplineComp = CreateDefaultSubobject<USplineComponent>(TEXT("SplineComp"));
RootComponent = SplineComp;
// Defaults for a nice visible path
SplineComp->SetDrawDebug(true);
SplineComp->SetUnselectedSplineSegmentColor(FLinearColor::Green);
}
void APS_AI_Behavior_SplinePath::BeginPlay()
{
Super::BeginPlay();
UpdateSplineVisualization();
// Hide spline debug at runtime — only visible in editor
if (SplineComp)
{
SplineComp->SetDrawDebug(false);
}
}
bool APS_AI_Behavior_SplinePath::IsAccessibleTo(EPS_AI_Behavior_NPCType NPCType) const
{
// Any spline → accessible to all
if (SplineCategory == EPS_AI_Behavior_NPCType::Any)
{
return true;
}
// Exact match
if (SplineCategory == NPCType)
{
return true;
}
// Protectors can also use Civilian splines (allies)
if (SplineCategory == EPS_AI_Behavior_NPCType::Civilian
&& NPCType == EPS_AI_Behavior_NPCType::Protector)
{
return true;
}
return false;
}
float APS_AI_Behavior_SplinePath::GetClosestPointOnSpline(
const FVector& WorldLocation, float& OutDistance, FVector& OutWorldPoint) const
{
if (!SplineComp) return MAX_FLT;
const float InputKey = SplineComp->FindInputKeyClosestToWorldLocation(WorldLocation);
OutDistance = SplineComp->GetDistanceAlongSplineAtSplineInputKey(InputKey);
OutWorldPoint = SplineComp->GetLocationAtDistanceAlongSpline(OutDistance, ESplineCoordinateSpace::World);
return FVector::Dist(WorldLocation, OutWorldPoint);
}
TArray<FPS_AI_Behavior_SplineJunction> APS_AI_Behavior_SplinePath::GetUpcomingJunctions(
float CurrentDistance, float LookAheadDist, bool bForward) const
{
TArray<FPS_AI_Behavior_SplineJunction> Result;
for (const FPS_AI_Behavior_SplineJunction& J : Junctions)
{
if (bForward)
{
if (J.DistanceOnThisSpline > CurrentDistance
&& J.DistanceOnThisSpline <= CurrentDistance + LookAheadDist)
{
Result.Add(J);
}
}
else
{
if (J.DistanceOnThisSpline < CurrentDistance
&& J.DistanceOnThisSpline >= CurrentDistance - LookAheadDist)
{
Result.Add(J);
}
}
}
return Result;
}
float APS_AI_Behavior_SplinePath::GetSplineLength() const
{
return SplineComp ? SplineComp->GetSplineLength() : 0.0f;
}
FVector APS_AI_Behavior_SplinePath::GetWorldLocationAtDistance(float Distance) const
{
if (!SplineComp) return FVector::ZeroVector;
return SplineComp->GetLocationAtDistanceAlongSpline(Distance, ESplineCoordinateSpace::World);
}
FRotator APS_AI_Behavior_SplinePath::GetWorldRotationAtDistance(float Distance) const
{
if (!SplineComp) return FRotator::ZeroRotator;
return SplineComp->GetRotationAtDistanceAlongSpline(Distance, ESplineCoordinateSpace::World);
}
FVector APS_AI_Behavior_SplinePath::GetWorldDirectionAtDistance(float Distance) const
{
if (!SplineComp) return FVector::ForwardVector;
return SplineComp->GetDirectionAtDistanceAlongSpline(Distance, ESplineCoordinateSpace::World);
}
void APS_AI_Behavior_SplinePath::UpdateSplineVisualization()
{
if (!SplineComp) return;
FLinearColor Color;
switch (SplineCategory)
{
case EPS_AI_Behavior_NPCType::Civilian:
Color = FLinearColor::Green;
break;
case EPS_AI_Behavior_NPCType::Enemy:
Color = FLinearColor::Red;
break;
case EPS_AI_Behavior_NPCType::Protector:
Color = FLinearColor(0.2f, 0.4f, 1.0f); // Blue
break;
case EPS_AI_Behavior_NPCType::Any:
default:
Color = FLinearColor(1.0f, 0.7f, 0.0f); // Orange
break;
}
SplineComp->SetUnselectedSplineSegmentColor(Color);
SplineComp->SetSelectedSplineSegmentColor(FLinearColor::White);
}
#if WITH_EDITOR
void APS_AI_Behavior_SplinePath::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
Super::PostEditChangeProperty(PropertyChangedEvent);
if (PropertyChangedEvent.GetPropertyName() == GET_MEMBER_NAME_CHECKED(APS_AI_Behavior_SplinePath, SplineCategory))
{
UpdateSplineVisualization();
}
}
#endif

View File

@@ -0,0 +1,80 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_Statics.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "Perception/AISense_Hearing.h"
#include "CollisionQueryParams.h"
#include "Engine/World.h"
static TAutoConsoleVariable<int32> CVarBehaviorDebug(
TEXT("ps.ai.Behavior.Debug"),
-1,
TEXT("Global debug override for PS_AI_Behavior. -1=use per-NPC bDebug property, 0=force off, 1=force on."),
ECVF_Default);
void UPS_AI_Behavior_Statics::ReportGunfire(UObject* WorldContext, FVector Location,
AActor* Shooter, bool bIsEnemyFire, float Loudness, float MaxRange)
{
UWorld* World = GEngine ? GEngine->GetWorldFromContextObject(WorldContext, EGetWorldErrorMode::LogAndReturnNull) : nullptr;
if (!World)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("ReportGunfire: invalid WorldContext."));
return;
}
const FName Tag = bIsEnemyFire ? PS_AI_Behavior_Tags::EnemyFire : PS_AI_Behavior_Tags::PlayerFire;
UAISense_Hearing::ReportNoiseEvent(
World,
Location,
Loudness,
Shooter,
MaxRange,
Tag);
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("ReportGunfire: at %s by '%s' (tag=%s, loudness=%.1f)"),
*Location.ToString(),
Shooter ? *Shooter->GetName() : TEXT("null"),
*Tag.ToString(),
Loudness);
}
bool UPS_AI_Behavior_Statics::HasLineOfSight(const UWorld* World, const AActor* Source,
const AActor* Target, float EyeHeightOffset)
{
if (!World || !Source || !Target) return false;
const FVector TraceStart = Source->GetActorLocation() + FVector(0, 0, EyeHeightOffset);
const FVector TraceEnd = Target->GetActorLocation();
FHitResult Hit;
FCollisionQueryParams Params(SCENE_QUERY_STAT(BehaviorLOS), true);
Params.AddIgnoredActor(Source);
Params.AddIgnoredActor(Target);
// Also ignore actors up the attachment chain (e.g. AimTarget → ChildActor → Character capsule)
// This handles cases where Owner/Instigator aren't set but the actor is physically attached.
for (const AActor* Cur = Target->GetAttachParentActor(); Cur; Cur = Cur->GetAttachParentActor())
{
Params.AddIgnoredActor(Cur);
}
for (const AActor* Cur = Source->GetAttachParentActor(); Cur; Cur = Cur->GetAttachParentActor())
{
Params.AddIgnoredActor(Cur);
}
return !World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_Visibility, Params);
}
bool UPS_AI_Behavior_Statics::IsDebugEnabled(const APawn* Pawn)
{
// CVar override: 0=off, 1=on, -1=use property
const int32 CVar = CVarBehaviorDebug.GetValueOnGameThread();
if (CVar == 0) return false;
if (CVar == 1) return true;
// Fall through to per-NPC property
if (!Pawn) return false;
const auto* Personality = Pawn->FindComponentByClass<UPS_AI_Behavior_PersonalityComponent>();
return Personality && Personality->bDebug;
}

View File

@@ -0,0 +1,13 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_TeamComponent.h"
UPS_AI_Behavior_TeamComponent::UPS_AI_Behavior_TeamComponent()
{
PrimaryComponentTick.bCanEverTick = false;
}
uint8 UPS_AI_Behavior_TeamComponent::GetTeamId() const
{
return PS_AI_Behavior_Team::MakeTeamId(Role, Faction);
}

View File

@@ -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;
};

View File

@@ -0,0 +1,48 @@
// 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_CheckTrait.generated.h"
/** Comparison operator for trait checks. */
UENUM(BlueprintType)
enum class EPS_AI_Behavior_ComparisonOp : uint8
{
GreaterThan UMETA(DisplayName = ">"),
GreaterOrEqual UMETA(DisplayName = ">="),
LessThan UMETA(DisplayName = "<"),
LessOrEqual UMETA(DisplayName = "<="),
Equal UMETA(DisplayName = "=="),
};
/**
* BT Decorator: Checks a personality trait against a threshold.
* Use to gate branches: e.g. "Only attack if Courage > 0.5".
*/
UCLASS(meta = (DisplayName = "PS AI: Check Trait"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTDecorator_CheckTrait : public UBTDecorator
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTDecorator_CheckTrait();
/** Which personality axis to check. */
UPROPERTY(EditAnywhere, Category = "Trait Check")
EPS_AI_Behavior_TraitAxis TraitAxis = EPS_AI_Behavior_TraitAxis::Courage;
/** Comparison operator. */
UPROPERTY(EditAnywhere, Category = "Trait Check")
EPS_AI_Behavior_ComparisonOp Comparison = EPS_AI_Behavior_ComparisonOp::GreaterThan;
/** Threshold value to compare against. */
UPROPERTY(EditAnywhere, Category = "Trait Check", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float Threshold = 0.5f;
protected:
virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;
virtual FString GetStaticDescription() const override;
};

View File

@@ -0,0 +1,37 @@
// 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_IsCoverNeeded.generated.h"
/**
* BT Decorator: Checks whether the current threat target warrants taking cover.
*
* Reads the ThreatActor from Blackboard and inspects its NPCType.
* Cover is considered necessary against armed/dangerous targets (Protector, Enemy)
* but not against unarmed targets (Civilian).
*
* Place on the cover-shoot sequence so that enemies skip cover when chasing civilians.
*/
UCLASS(meta = (DisplayName = "PS AI: Is Cover Needed"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTDecorator_IsCoverNeeded : public UBTDecorator
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTDecorator_IsCoverNeeded();
/**
* NPC types that are considered dangerous enough to require cover.
* If the threat's NPCType is NOT in this set, the decorator fails → skip cover.
*/
UPROPERTY(EditAnywhere, Category = "Cover", meta = (Bitmask, BitmaskEnum = "/Script/PS_AI_Behavior.EPS_AI_Behavior_NPCType"))
TArray<EPS_AI_Behavior_NPCType> DangerousTargetTypes;
protected:
virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;
virtual FString GetStaticDescription() const override;
};

View File

@@ -0,0 +1,26 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTService.h"
#include "PS_AI_Behavior_BTService_EvaluateReaction.generated.h"
/**
* BT Service: Evaluates the NPC's reaction based on personality traits and threat level.
* Calls PersonalityComponent::ApplyReaction() and writes the resulting state to the Blackboard.
*
* Should be placed alongside or below BTService_UpdateThreat in the tree.
*/
UCLASS(meta = (DisplayName = "PS AI: Evaluate Reaction"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTService_EvaluateReaction : public UBTService
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTService_EvaluateReaction();
protected:
virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual FString GetStaticDescription() const override;
};

View File

@@ -0,0 +1,33 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTService.h"
#include "PS_AI_Behavior_BTService_UpdateGaze.generated.h"
/**
* BT Service: Updates the NPC's gaze target based on behavior state and proximity.
*
* Bridges PS_AI_Behavior to PS_AI_ConvAgent's GazeComponent via runtime reflection.
* No compile-time dependency on PS_AI_ConvAgent — silently skips if not present.
*
* Priority order:
* 1. Combat/Alerted → look at ThreatActor (head + eyes + body)
* 2. Conversation active → skip (managed by ConvAgent)
* 3. Proximity → closest perceived actor within radius (head + eyes only)
*
* Place on the root Selector alongside UpdateThreat and EvaluateReaction.
*/
UCLASS(meta = (DisplayName = "PS AI: Update Gaze"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTService_UpdateGaze : public UBTService
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTService_UpdateGaze();
protected:
virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual FString GetStaticDescription() const override;
};

View File

@@ -0,0 +1,47 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTService.h"
#include "PS_AI_Behavior_BTService_UpdateThreat.generated.h"
/**
* BT Service: Updates threat information in the Blackboard.
* Queries PerceptionComponent for the highest threat actor and threat level.
* Writes: BB_ThreatActor, BB_ThreatLocation, BB_ThreatLevel.
* Also updates PersonalityComponent::PerceivedThreatLevel.
*
* Place on the root node of any behavior tree that needs threat awareness.
*/
UCLASS(meta = (DisplayName = "PS AI: Update Threat"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTService_UpdateThreat : public UBTService
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTService_UpdateThreat();
/** Seconds without LOS before writing LastKnownTargetPosition and starting investigation. */
UPROPERTY(EditAnywhere, Category = "Threat|LOS", meta = (ClampMin = "5.0"))
float LOSLostTimeout = 20.0f;
/** Eye height offset for LOS traces (cm above pawn origin). */
UPROPERTY(EditAnywhere, Category = "Threat|LOS", meta = (ClampMin = "50.0", ClampMax = "200.0"))
float EyeHeightOffset = 150.0f;
protected:
virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual FString GetStaticDescription() const override;
private:
struct FUpdateThreatMemory
{
float TimeSinceLOS = 0.0f;
FVector LastVisiblePosition = FVector::ZeroVector;
bool bHadLOS = true;
bool bInvestigating = false;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FUpdateThreatMemory); }
};

View File

@@ -0,0 +1,95 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_BTTask_Attack.generated.h"
class UEnvQuery;
/**
* BT Task: Move toward the threat actor and delegate combat to the Pawn.
*
* Queries IPS_AI_Behavior_Interface for CombatType (Melee/Ranged).
* Attack ranges come from PersonalityProfile (MinAttackRange/MaxAttackRange),
* with fallback to AttackMoveRadius if no profile is available.
* - Melee: rush toward target, continuous pursuit within MinRange.
* - Ranged: maintain distance — back away if closer than MinRange, advance if farther than MaxRange.
*
* 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.
*
* Stays InProgress permanently — the Decorator Observer Aborts pulls it out
* when the BehaviorState changes away from Combat.
*/
UCLASS(meta = (DisplayName = "PS AI: Attack"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_Attack : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_Attack();
/** Fallback attack range (cm) used when PersonalityProfile is unavailable. */
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;
/** Eye height offset for line-of-sight traces (cm above actor origin). */
UPROPERTY(EditAnywhere, Category = "Attack|LOS", meta = (ClampMin = "50.0", ClampMax = "200.0"))
float EyeHeightOffset = 150.0f;
/** How often to check LOS (seconds). Avoids per-frame traces. */
UPROPERTY(EditAnywhere, Category = "Attack|LOS", meta = (ClampMin = "0.1", ClampMax = "1.0"))
float LOSCheckInterval = 0.2f;
/**
* EQS query asset for finding a firing position with clear LOS.
* Compose in editor: OnCircle generator + LineOfSight filter + Distance tests.
* If null, fallback: advance directly toward the target.
*/
UPROPERTY(EditAnywhere, Category = "Attack|LOS")
TObjectPtr<UEnvQuery> FiringPositionQuery;
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 FAttackMemory
{
bool bMovingToTarget = false;
bool bAttacking = false; // true when BehaviorStartAttack is active
bool bInRange = false; // true when within MaxRange of target
bool bHasLOS = true; // true when line-of-sight to target is clear
bool bSeekingFiringPos = false; // true when moving to a flanking/firing position
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;
float LOSCheckTimer = 0.0f; // cooldown for LOS trace checks
bool bEQSQueryRunning = false; // true while an EQS query is in flight
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FAttackMemory); }
/** Run the EQS firing position query. Falls back to advancing toward target if no query asset. */
void RunFiringPositionQuery(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory,
APawn* Pawn, AActor* Target);
/** Callback when the EQS query completes. */
void OnFiringPositionQueryFinished(TSharedPtr<FEnvQueryResult> Result,
UBehaviorTreeComponent* OwnerComp, uint8* NodeMemory,
TWeakObjectPtr<AActor> WeakTarget);
};

View File

@@ -0,0 +1,150 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_BTTask_CoverShootCycle.generated.h"
class APS_AI_Behavior_AIController;
class APS_AI_Behavior_CoverPoint;
class UEnvQuery;
/**
* BT Task: Cover-shoot cycle for ranged combat.
*
* State machine:
* Engaging → AtCover (crouch) → [EQS] → MovingToFire → Peeking (shoot)
* → ReturningToCover → AtCover ... → Advancing → AtCover ...
*
* If FiringPositionQuery is set, the NPC physically moves between the cover
* position (protected) and a nearby firing position (with LOS to threat).
* If null, the NPC shoots from cover (legacy behavior: stand up and fire in place).
*
* 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 ─────────────────────────────────────────────────────────
/** Base minimum time (seconds) spent peeking/shooting. Modulated at runtime: Aggressivity increases, Caution decreases. */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5"))
float PeekDurationMin = 2.0f;
/** Base maximum time (seconds) spent peeking/shooting. Modulated at runtime: Aggressivity increases, Caution decreases. */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5"))
float PeekDurationMax = 5.0f;
/** Base minimum time (seconds) spent ducked behind cover. Modulated at runtime: Caution increases, Aggressivity decreases. */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5"))
float CoverDurationMin = 1.0f;
/** Base maximum time (seconds) spent ducked behind cover. Modulated at runtime: Caution increases, Aggressivity decreases. */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5"))
float CoverDurationMax = 3.0f;
// ─── Firing Position ────────────────────────────────────────────────
/**
* Optional EQS query to find a firing position near the cover.
* Should return positions with LOS to threat, close to CoverLocation.
* Use OnCircle generator around PS AI: Cover Location + LineOfSight filter + Distance score.
* If null, the NPC shoots from cover (stand up in place — legacy fallback).
*/
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Firing Position")
TObjectPtr<UEnvQuery> FiringPositionQuery;
// ─── Advancement ────────────────────────────────────────────────────
/** Number of peek/duck cycles before attempting to advance to a closer cover point. Modulated by Aggressivity. */
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;
// ─── Debug ─────────────────────────────────────────────────────────
/** Draw debug info: sub-state label, LOS line, cycle counter, firing position. */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Debug")
bool bDebugDraw = false;
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;
float LOSCheckTimer = 0.0f;
// Firing position (found via EQS)
FVector FiringPosition = FVector::ZeroVector;
bool bHasFiringPosition = false;
bool bEQSRunning = false;
float MoveTimeout = 0.0f;
// 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;
/** Run EQS to find the best firing position near the cover. */
void RunFiringPositionQuery(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, APawn* Pawn);
/** Callback when the firing position EQS query completes. */
void OnFiringPositionQueryFinished(TSharedPtr<FEnvQueryResult> Result,
UBehaviorTreeComponent* OwnerComp, uint8* NodeMemory);
/** Transition to peeking: move to firing position or shoot in place. */
void StartPeeking(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory,
APS_AI_Behavior_AIController* AIC, APawn* Pawn, AActor* Target);
/** Transition back to cover after shooting. */
void ReturnToCover(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory,
APS_AI_Behavior_AIController* AIC, APawn* Pawn);
};

View File

@@ -0,0 +1,50 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "PS_AI_Behavior_BTTask_FindAndFollowSpline.generated.h"
/**
* BT Task: Find the nearest accessible spline and start following it.
* Uses the SplineNetwork subsystem to find the closest spline matching the NPC's type.
* Then activates the SplineFollowerComponent.
*
* Succeeds immediately after starting — use BTTask_FollowSpline to actually follow.
* Or use this as a setup node in a Sequence before BTTask_FollowSpline.
*/
UCLASS(meta = (DisplayName = "PS AI: Find & Start Spline"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_FindAndFollowSpline : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_FindAndFollowSpline();
/** Maximum distance to search for a spline (cm). */
UPROPERTY(EditAnywhere, Category = "Spline", meta = (ClampMin = "100.0"))
float MaxSearchDistance = 3000.0f;
/** If true, move toward the closest spline point before starting. */
UPROPERTY(EditAnywhere, Category = "Spline")
bool bWalkToSpline = true;
/** Acceptance radius for reaching the spline starting point (cm). */
UPROPERTY(EditAnywhere, Category = "Spline", meta = (ClampMin = "10.0", EditCondition = "bWalkToSpline"))
float AcceptanceRadius = 100.0f;
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 FString GetStaticDescription() const override;
private:
struct FFindSplineMemory
{
bool bMovingToSpline = false;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FFindSplineMemory); }
};

View File

@@ -0,0 +1,133 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_BTTask_FindCover.generated.h"
class APS_AI_Behavior_CoverPoint;
class UEnvQuery;
/**
* BT Task: Find a cover position and navigate to it.
*
* First checks for manually placed CoverPoint actors in range.
* If none found (or bUseManualPointsOnly is false), falls back to
* procedural raycast-based cover finding.
*
* Writes CoverLocation and CoverPoint to the Blackboard on success.
*/
UCLASS(meta = (DisplayName = "PS AI: Find Cover"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_FindCover : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_FindCover();
/** Search radius around the NPC for cover candidates (cm). */
UPROPERTY(EditAnywhere, Category = "Cover", meta = (ClampMin = "200.0"))
float SearchRadius = 1500.0f;
/** Number of procedural candidate points to evaluate. */
UPROPERTY(EditAnywhere, Category = "Cover|Procedural", meta = (ClampMin = "4", ClampMax = "32"))
int32 NumCandidates = 12;
/** Acceptance radius for reaching cover (cm). */
UPROPERTY(EditAnywhere, Category = "Cover", meta = (ClampMin = "10.0"))
float AcceptanceRadius = 80.0f;
/** Minimum height of geometry considered as cover (cm). */
UPROPERTY(EditAnywhere, Category = "Cover|Procedural", meta = (ClampMin = "30.0"))
float MinCoverHeight = 90.0f;
/**
* Cover point type to search for (Cover for enemies, HidingSpot for civilians).
*/
UPROPERTY(EditAnywhere, Category = "Cover|Manual Points")
EPS_AI_Behavior_CoverPointType CoverPointType = EPS_AI_Behavior_CoverPointType::Cover;
/**
* Score bonus added to manual CoverPoints (additive, 0-1 range).
* A manual point with score 0.5 + bonus 0.3 = 0.8 total.
* Higher = manual points strongly preferred over procedural candidates.
*/
UPROPERTY(EditAnywhere, Category = "Cover|Manual Points", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float ManualPointBonus = 0.3f;
/**
* If true, only use manually placed CoverPoints — never procedural.
* If false (default), manual points are preferred but procedural is fallback.
*/
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;
/**
* Optional EQS query to refine the exact cover position around a selected CoverPoint.
* The query runs centered on the chosen CoverPoint and picks the best nearby spot.
* Use OnCircle generator (small radius ~200cm) + CoverQuality test + Distance tests.
* If null, the NPC goes directly to the CoverPoint's location (no refinement).
*/
UPROPERTY(EditAnywhere, Category = "Cover|EQS Refinement")
TObjectPtr<UEnvQuery> RefinementQuery;
// ─── Debug ─────────────────────────────────────────────────────────
/** Draw debug spheres showing cover candidates and their scores at runtime. */
UPROPERTY(EditAnywhere, Category = "Cover|Debug")
bool bDebugDraw = false;
/** Radius around the CoverPoint for EQS refinement search (cm). */
UPROPERTY(EditAnywhere, Category = "Cover|EQS Refinement", meta = (ClampMin = "100.0", ClampMax = "500.0"))
float RefinementRadius = 200.0f;
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 FString GetStaticDescription() const override;
private:
struct FCoverMemory
{
bool bMoveRequested = false;
bool bEQSRunning = false;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FCoverMemory); }
/**
* Evaluate cover quality at a given position.
* Returns 0.0 (bad) to 1.0 (excellent cover).
*/
float EvaluateCoverQuality(const UWorld* World, const FVector& CandidatePos,
const FVector& ThreatLoc, const FVector& NpcLoc) const;
/**
* Search for the best manual CoverPoint in range.
* Returns the best point and its score, or nullptr if none found.
*/
APS_AI_Behavior_CoverPoint* FindBestManualCoverPoint(
const UWorld* World, const FVector& NpcLoc, const FVector& ThreatLoc,
EPS_AI_Behavior_NPCType NPCType, float& OutScore) const;
/** Run EQS refinement query around the chosen CoverPoint. */
void RunRefinementQuery(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory,
APawn* Pawn, const FVector& CoverCenter);
/** Callback when the refinement EQS query completes. */
void OnRefinementQueryFinished(TSharedPtr<FEnvQueryResult> Result,
UBehaviorTreeComponent* OwnerComp, uint8* NodeMemory,
FVector OriginalCoverPos);
};

View File

@@ -0,0 +1,50 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "PS_AI_Behavior_BTTask_FleeFrom.generated.h"
/**
* BT Task: Flee away from the current threat.
* Finds a point in the opposite direction of ThreatLocation and navigates to it.
* Can optionally use an EQS query for smarter flee-point selection.
*/
UCLASS(meta = (DisplayName = "PS AI: Flee From Threat"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_FleeFrom : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_FleeFrom();
/** Minimum flee distance from threat (cm). */
UPROPERTY(EditAnywhere, Category = "Flee", meta = (ClampMin = "200.0"))
float MinFleeDistance = 1000.0f;
/** Maximum flee distance from threat (cm). */
UPROPERTY(EditAnywhere, Category = "Flee", meta = (ClampMin = "500.0"))
float MaxFleeDistance = 2500.0f;
/** Acceptance radius for the flee destination (cm). */
UPROPERTY(EditAnywhere, Category = "Flee", meta = (ClampMin = "10.0"))
float AcceptanceRadius = 150.0f;
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 FString GetStaticDescription() const override;
private:
struct FFleeMemory
{
bool bMoveRequested = false;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FFleeMemory); }
/** Find a navmesh-projected point away from the threat. */
bool FindFleePoint(const FVector& Origin, const FVector& ThreatLoc, FVector& OutFleePoint) const;
};

View File

@@ -0,0 +1,56 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "PS_AI_Behavior_BTTask_FollowSpline.generated.h"
/**
* BT Task: Follow the current spline path.
* Uses the SplineFollowerComponent on the Pawn.
*
* This task runs InProgress while the NPC moves along the spline.
* It succeeds when the end of the spline is reached (if not looping/reversing).
* It can be aborted to interrupt spline movement.
*
* To start on a specific spline, use BTTask_FindAndFollowSpline first,
* or set CurrentSpline via Blueprint/code before this task runs.
*/
UCLASS(meta = (DisplayName = "PS AI: Follow Spline"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_FollowSpline : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_FollowSpline();
/**
* Maximum time (seconds) this task will follow the spline before succeeding.
* 0 = no time limit (run until end of spline or abort).
*/
UPROPERTY(EditAnywhere, Category = "Spline", meta = (ClampMin = "0.0"))
float MaxFollowTime = 0.0f;
/**
* If true, picks a random direction when starting.
* If false, continues in the current direction.
*/
UPROPERTY(EditAnywhere, Category = "Spline")
bool bRandomDirection = false;
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 FString GetStaticDescription() const override;
private:
struct FFollowMemory
{
float Elapsed = 0.0f;
bool bEndReached = false;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FFollowMemory); }
};

View File

@@ -0,0 +1,75 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "PS_AI_Behavior_BTTask_Patrol.generated.h"
/**
* BT Task: Patrol around the NPC's home location.
*
* Two modes (automatic):
* - If AIController has PatrolPoints → cycles through them.
* - If no PatrolPoints → picks a random NavMesh point within PatrolRadius
* around the HomeLocation (from Blackboard).
*
* Waits a random time at each waypoint before moving to the next.
*/
UCLASS(meta = (DisplayName = "PS AI: Patrol"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_Patrol : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_Patrol();
/** Acceptance radius for reaching a waypoint (cm). */
UPROPERTY(EditAnywhere, Category = "Patrol", meta = (ClampMin = "10.0"))
float AcceptanceRadius = 100.0f;
/** Minimum wait time at each waypoint (seconds). */
UPROPERTY(EditAnywhere, Category = "Patrol", meta = (ClampMin = "0.0"))
float MinWaitTime = 1.0f;
/** Maximum wait time at each waypoint (seconds). */
UPROPERTY(EditAnywhere, Category = "Patrol", meta = (ClampMin = "0.0"))
float MaxWaitTime = 4.0f;
/**
* Radius around HomeLocation to pick random patrol points (cm).
* Only used when no manual PatrolPoints are defined.
*/
UPROPERTY(EditAnywhere, Category = "Patrol|Auto", meta = (ClampMin = "200.0"))
float PatrolRadius = 1500.0f;
/**
* Minimum distance from current position for a random patrol point (cm).
* Prevents picking a point right next to the NPC.
*/
UPROPERTY(EditAnywhere, Category = "Patrol|Auto", meta = (ClampMin = "100.0"))
float MinPatrolDistance = 300.0f;
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 FString GetStaticDescription() const override;
private:
struct FPatrolMemory
{
float WaitRemaining = 0.0f;
bool bIsWaiting = false;
bool bMoveRequested = false;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FPatrolMemory); }
/**
* Find a random navigable point within PatrolRadius of HomeLocation.
* Ensures the point is at least MinPatrolDistance from the NPC.
*/
bool FindRandomPatrolPoint(const UWorld* World, const FVector& HomeLoc,
const FVector& CurrentLoc, FVector& OutPoint) const;
};

View File

@@ -0,0 +1,22 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "EnvironmentQuery/EnvQueryContext.h"
#include "PS_AI_Behavior_EQSContext_CoverLocation.generated.h"
/**
* EQS Context: Returns the current CoverLocation from the Blackboard.
* Use as the "Generate Around" context in EQS queries (e.g., OnCircle generator)
* to generate refinement candidates around the chosen CoverPoint instead of around the Querier.
*/
UCLASS(meta = (DisplayName = "PS AI: Cover Location"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_EQSContext_CoverLocation : public UEnvQueryContext
{
GENERATED_BODY()
public:
virtual void ProvideContext(FEnvQueryInstance& QueryInstance,
FEnvQueryContextData& ContextData) const override;
};

View File

@@ -0,0 +1,22 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "EnvironmentQuery/EnvQueryContext.h"
#include "PS_AI_Behavior_EQSContext_Threat.generated.h"
/**
* EQS Context: Returns the current threat actor (or its last known location).
* Reads from the Blackboard keys ThreatActor / ThreatLocation.
* Use in EQS queries as the "Threat" context for distance/visibility tests.
*/
UCLASS(meta = (DisplayName = "PS AI: Threat"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_EQSContext_Threat : public UEnvQueryContext
{
GENERATED_BODY()
public:
virtual void ProvideContext(FEnvQueryInstance& QueryInstance,
FEnvQueryContextData& ContextData) const override;
};

View File

@@ -0,0 +1,41 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "EnvironmentQuery/EnvQueryGenerator.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_EQSGenerator_CoverPoints.generated.h"
/**
* EQS Generator: returns all CoverPoint actors in the level as query items.
* Filters by: type (Cover/HidingSpot), NPC type accessibility, availability (HasRoom),
* and max distance from querier.
*
* Use with EQSTest_CoverQuality for scoring, or with standard distance/trace tests.
*/
UCLASS(meta = (DisplayName = "PS AI: Cover Points"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_EQSGenerator_CoverPoints : public UEnvQueryGenerator
{
GENERATED_BODY()
public:
UPS_AI_Behavior_EQSGenerator_CoverPoints();
/** Filter by cover point type. */
UPROPERTY(EditDefaultsOnly, Category = "Generator")
EPS_AI_Behavior_CoverPointType PointTypeFilter = EPS_AI_Behavior_CoverPointType::Cover;
/** Maximum distance from querier to include a cover point (cm). */
UPROPERTY(EditDefaultsOnly, Category = "Generator", meta = (ClampMin = "100.0"))
float MaxDistance = 3000.0f;
/** Only include points that have room for another occupant. */
UPROPERTY(EditDefaultsOnly, Category = "Generator")
bool bOnlyAvailable = true;
protected:
virtual void GenerateItems(FEnvQueryInstance& QueryInstance) const override;
virtual FText GetDescriptionTitle() const override;
virtual FText GetDescriptionDetails() const override;
};

View File

@@ -0,0 +1,59 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "EnvironmentQuery/EnvQueryTest.h"
#include "PS_AI_Behavior_EQSTest_CoverQuality.generated.h"
/**
* EQS Test: Evaluates how well a candidate point provides cover from a threat context.
* Performs raycasts at multiple heights to assess visual concealment.
* Higher score = better cover.
*
* Use with EQSContext_Threat as the context for the "threat from" parameter.
*/
UCLASS(meta = (DisplayName = "PS AI: Cover Quality"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_EQSTest_CoverQuality : public UEnvQueryTest
{
GENERATED_BODY()
public:
UPS_AI_Behavior_EQSTest_CoverQuality();
/** Number of raycasts from candidate to threat at different heights. */
UPROPERTY(EditAnywhere, Category = "Cover", meta = (ClampMin = "1", ClampMax = "5"))
int32 NumTraceHeights = 3;
/** Minimum height for the lowest trace (cm relative to item — items are at navmesh level). */
UPROPERTY(EditAnywhere, Category = "Cover")
float MinTraceHeight = 0.0f;
/** Maximum height for the highest trace (cm relative to item — 70 ≈ knee-to-waist from navmesh). */
UPROPERTY(EditAnywhere, Category = "Cover")
float MaxTraceHeight = 70.0f;
/**
* Maximum distance (cm) between candidate and the blocking hit point for it to count as cover.
* Hits farther than this are likely distant obstacles, not actual nearby cover.
*/
UPROPERTY(EditAnywhere, Category = "Cover", meta = (ClampMin = "50.0"))
float MaxNearbyHitDist = 300.0f;
/**
* Lateral offset (cm) for side traces. Traces are fired from center, left, and right.
* Helps evaluate cover around narrow objects like poles/pillars.
* 0 = center trace only, 30 = adds traces at ±30cm to each side.
*/
UPROPERTY(EditAnywhere, Category = "Cover", meta = (ClampMin = "0.0", ClampMax = "100.0"))
float LateralSpread = 30.0f;
/** Draw debug boxes for each candidate with color-coded score. */
UPROPERTY(EditAnywhere, Category = "Debug")
bool bDrawDebug = false;
protected:
virtual void RunTest(FEnvQueryInstance& QueryInstance) const override;
virtual FText GetDescriptionTitle() const override;
virtual FText GetDescriptionDetails() const override;
};

View File

@@ -0,0 +1,50 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "EnvironmentQuery/EnvQueryTest.h"
#include "PS_AI_Behavior_EQSTest_LineOfSight.generated.h"
/**
* EQS Test: Evaluates whether a candidate position has clear line-of-sight to the threat.
* Inverse of EQSTest_CoverQuality — high score means the candidate CAN see the threat.
*
* Use with EQSContext_Threat as the context for the threat location.
* Combine with a ring/grid generator and distance tests to find firing positions.
*
* Score: 1.0 = clear LOS, 0.0 = fully blocked.
*/
UCLASS(meta = (DisplayName = "PS AI: Line of Sight"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_EQSTest_LineOfSight : public UEnvQueryTest
{
GENERATED_BODY()
public:
UPS_AI_Behavior_EQSTest_LineOfSight();
/** Height offset for the trace origin at the candidate position (cm above ground). Approximates NPC eye height. */
UPROPERTY(EditDefaultsOnly, Category = "LOS", meta = (ClampMin = "10.0"))
float TraceHeight = 150.0f;
/** Height offset for the trace end at the threat position (cm above ground). Approximates target center mass. */
UPROPERTY(EditDefaultsOnly, Category = "LOS", meta = (ClampMin = "0.0"))
float TargetHeightOffset = 100.0f;
/**
* Lateral offset (cm) for side traces perpendicular to threat direction.
* 0 = center trace only, 30 = adds traces at ±30cm.
* Requires wider clear LOS, not just a narrow slit past cover edge.
*/
UPROPERTY(EditAnywhere, Category = "LOS", meta = (ClampMin = "0.0", ClampMax = "100.0"))
float LateralSpread = 0.0f;
/** Draw debug spheres and LOS lines for each candidate. */
UPROPERTY(EditAnywhere, Category = "Debug")
bool bDrawDebug = false;
protected:
virtual void RunTest(FEnvQueryInstance& QueryInstance) const override;
virtual FText GetDescriptionTitle() const override;
virtual FText GetDescriptionDetails() const override;
};

View File

@@ -0,0 +1,12 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "Modules/ModuleManager.h"
class FPS_AI_BehaviorModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
};

View File

@@ -0,0 +1,232 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "GenericTeamAgentInterface.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_AIController.generated.h"
class UPS_AI_Behavior_PerceptionComponent;
class UPS_AI_Behavior_PersonalityComponent;
class UBehaviorTree;
class UBlackboardData;
class UBlackboardComponent;
/**
* Base AI Controller for the PS AI Behavior system.
* Manages Blackboard setup, Behavior Tree execution, perception, and patrol waypoints.
* Automatically discovers PersonalityComponent on the possessed Pawn.
*
* Optionally detects PS_AI_ConvAgent_ElevenLabsComponent at runtime (no compile dependency).
*/
UCLASS(BlueprintType, Blueprintable)
class PS_AI_BEHAVIOR_API APS_AI_Behavior_AIController : public AAIController
{
GENERATED_BODY()
public:
APS_AI_Behavior_AIController();
// ─── Team / Affiliation ─────────────────────────────────────────────
/**
* Team ID — determines perception affiliation (Enemy/Friendly/Neutral).
* Auto-assigned from NPCType + Faction at possession via MakeTeamId():
* - Encoding: high nibble = NPCType, low nibble = Faction
* - Civilian F0 = 0x00, Enemy F0 = 0x10, Protector F0 = 0x20
* - Same NPCType + same Faction → Friendly
* - Same NPCType + different Faction → Hostile (rival gangs)
* - Civilian ↔ Protector → always Friendly
* - Everything else → Hostile
*
* Disguised enemies (hostile=false) use DisguisedTeamId (0x01).
* You can override this in Blueprint or per-instance in the editor.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Team")
uint8 TeamId = FGenericTeamId::NoTeam;
/** Set the team ID at runtime. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Team")
void SetTeamId(uint8 NewTeamId);
// ─── IGenericTeamAgentInterface (inherited from AAIController) ────
virtual FGenericTeamId GetGenericTeamId() const override;
virtual void SetGenericTeamId(const FGenericTeamId& InTeamId) override;
virtual ETeamAttitude::Type GetTeamAttitudeTowards(const AActor& Other) const override;
// ─── Configuration ──────────────────────────────────────────────────
/** Behavior Tree to run. If null, uses the Profile's DefaultBehaviorTree. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Behavior")
TObjectPtr<UBehaviorTree> BehaviorTreeAsset;
/** Blackboard Data asset. If null, a default one is created at runtime. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Behavior")
TObjectPtr<UBlackboardData> BlackboardAsset;
/** Patrol waypoints — set by level designer, spawner, or Blueprint. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Patrol")
TArray<FVector> PatrolPoints;
// ─── Gaze Configuration ────────────────────────────────────────────
/** Detection radius for proximity gaze (cm). NPC glances at nearby actors within this range. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_AI_Behavior|Gaze", meta = (ClampMin = "100.0", ClampMax = "1500.0"))
float GazeProximityRadius = 400.0f;
/** Maximum duration (seconds) the NPC looks at a proximity target before releasing. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_AI_Behavior|Gaze", meta = (ClampMin = "0.5", ClampMax = "30.0"))
float GazeMaxDuration = 3.0f;
/** Cooldown (seconds) before re-targeting the same actor after releasing. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_AI_Behavior|Gaze", meta = (ClampMin = "0.0", ClampMax = "30.0"))
float GazeCooldown = 5.0f;
/** Enable/disable proximity gaze (NPC glances at nearby actors in Idle/Patrol). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_AI_Behavior|Gaze")
bool bEnableProximityGaze = true;
// ─── Component Access ───────────────────────────────────────────────
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
UPS_AI_Behavior_PerceptionComponent* GetBehaviorPerception() const { return BehaviorPerception; }
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
UPS_AI_Behavior_PersonalityComponent* GetPersonalityComponent() const { return PersonalityComp; }
// ─── Blackboard Helpers ─────────────────────────────────────────────
/** Write the current behavior state to the Blackboard. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Blackboard")
void SetBehaviorState(EPS_AI_Behavior_State NewState);
/** Read the current behavior state from the Blackboard. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Blackboard")
EPS_AI_Behavior_State GetBehaviorState() const;
/** Start (or restart) the Behavior Tree. If state is Scripted, transitions to Idle. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void StartBehavior();
/** Stop the Behavior Tree and enter Scripted state. NPC stays alive and perceptible. */
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();
/**
* Freeze the NPC's autonomous movement WITHOUT stopping the Behavior Tree.
* The BT keeps running (perception, threat, gaze, reactions) but movement
* branches are blocked via the ConversationPaused Blackboard flag, and any
* in-flight move is cancelled. Fleeing is NOT blocked (leave the Flee branch
* un-decorated). Use this to keep an NPC in place during a scripted moment
* (e.g. dialogue) regardless of the automatic conversation pause.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void DisableBehaviorMovement();
/** Release the manual movement freeze set by DisableBehaviorMovement().
* Movement stays paused if a conversation is still active. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void EnableBehaviorMovement();
protected:
virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override;
/** Our custom perception component — created in constructor. */
UPROPERTY(VisibleAnywhere, Category = "Components")
TObjectPtr<UPS_AI_Behavior_PerceptionComponent> BehaviorPerception;
/** Cached ref to the Pawn's PersonalityComponent. */
UPROPERTY(Transient)
TObjectPtr<UPS_AI_Behavior_PersonalityComponent> PersonalityComp;
/** Shut down all AI systems when the NPC dies. Called automatically by SetBehaviorState(Dead). */
void HandleDeath();
private:
/** Initialize Blackboard with required keys. */
void SetupBlackboard();
/**
* Attempt to bind to PS_AI_ConvAgent_ElevenLabsComponent if present on the Pawn.
* Uses UObject reflection — no compile-time dependency on PS_AI_ConvAgent.
*/
void TryBindConversationAgent();
// ─── Gaze Bridge (runtime reflection to PS_AI_ConvAgent) ────────
/** Discover GazeComponent on the Pawn and cache property pointers. */
void TryBindGazeComponent();
/** Update the gaze target based on behavior state and proximity. Called by BTService_UpdateGaze. */
void UpdateGazeTarget(float DeltaSeconds);
/** Set the GazeComponent's target actor via reflection. */
void SetGazeTarget(AActor* Target, bool bEnableBody);
/** Clear the GazeComponent's target (smooth return to neutral). */
void ClearGazeTarget();
/** Check if a conversation is active on the Pawn (via reflection on ElevenLabsComponent). */
bool IsConversationActiveOnPawn() const;
/** Apply (or release) the movement pause: syncs the ConversationPaused Blackboard
* flag, stops in-flight movement, and pauses/resumes the spline follower.
* No-op if the state is unchanged. */
void ApplyMovementPaused(bool bPaused);
// Cached reflection pointers (null if PS_AI_ConvAgent not loaded)
TWeakObjectPtr<UActorComponent> CachedGazeComponent;
FObjectProperty* GazeProp_TargetActor = nullptr;
FBoolProperty* GazeProp_bActive = nullptr;
FBoolProperty* GazeProp_bEnableBodyTracking = nullptr;
// Conversation detection cache
TWeakObjectPtr<UActorComponent> CachedConvAgentComponent;
FBoolProperty* ConvProp_bNetIsConversing = nullptr;
// Set by the ConvAgent agent-to-agent orchestrator while two agents converse.
FObjectProperty* ConvProp_GazeOverrideTarget = nullptr;
// Mic VAD cache (local voice activity detection)
TWeakObjectPtr<UActorComponent> CachedMicComponent;
FBoolProperty* MicProp_bIsUserSpeaking = nullptr;
// Conversation pause state
bool bConversationPaused = false;
bool bUserHasSpokenInConversation = false;
// True while movement is frozen by an explicit DisableBehaviorMovement() call.
// Combined with the automatic conversation pause: movement is paused when
// either this is set OR a conversation is active.
bool bMovementManuallyDisabled = false;
// Proximity gaze state
TWeakObjectPtr<AActor> ProximityGazeTarget;
float ProximityGazeDuration = 0.0f;
float ProximityGazeCooldownTimer = 0.0f;
TWeakObjectPtr<AActor> ProximityGazeCooldownActor;
friend class UPS_AI_Behavior_BTService_UpdateGaze;
};

View File

@@ -0,0 +1,103 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "PS_AI_Behavior_CombatComponent.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAttackExecuted, AActor*, Target);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnDamageReceived, float, Damage, AActor*, Instigator);
/**
* Manages NPC combat state: attack range, cooldown, damage dealing.
*
* Replication: CurrentTarget is replicated so clients know who the NPC
* is fighting. ExecuteAttack fires a NetMulticast for cosmetic effects.
*
* Attach to the NPC Pawn alongside PersonalityComponent.
*/
UCLASS(ClassGroup = "ASTERION|PS_AI_Behavior", meta = (BlueprintSpawnableComponent, DisplayName = "PS AI Behavior - Combat"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_CombatComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPS_AI_Behavior_CombatComponent();
// ─── Configuration ──────────────────────────────────────────────────
/** Maximum distance at which ExecuteAttack() succeeds (cm). Independent from PersonalityProfile ranges used by BTTask_Attack. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat", meta = (ClampMin = "50.0"))
float AttackRange = 200.0f;
/** Minimum time between consecutive ExecuteAttack() calls (seconds). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat", meta = (ClampMin = "0.1"))
float AttackCooldown = 1.5f;
/** Base damage per attack. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat", meta = (ClampMin = "0.0"))
float AttackDamage = 20.0f;
/** Damage type class for applying damage. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat")
TSubclassOf<UDamageType> DamageTypeClass;
// ─── Runtime State ──────────────────────────────────────────────────
/** Current attack target — replicated so clients can show targeting visuals. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Combat|Runtime")
TObjectPtr<AActor> CurrentTarget;
// ─── Delegates ──────────────────────────────────────────────────────
/** Fired on ALL machines (server + clients) when an attack is executed. */
UPROPERTY(BlueprintAssignable, Category = "ASTERION|PS_AI_Behavior|Combat")
FOnAttackExecuted OnAttackExecuted;
/** Fired on server when damage is received. */
UPROPERTY(BlueprintAssignable, Category = "ASTERION|PS_AI_Behavior|Combat")
FOnDamageReceived OnDamageReceived;
// ─── API ────────────────────────────────────────────────────────────
/** Whether the NPC can currently attack (cooldown elapsed). Server-only. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Combat")
bool CanAttack() const;
/** Whether the target is within attack range. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Combat")
bool IsInAttackRange(AActor* Target) const;
/**
* Execute an attack on the target. Applies damage (server), triggers
* cooldown, and multicasts cosmetic event to all clients.
* @return True if the attack was executed.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Combat")
bool ExecuteAttack(AActor* Target);
/**
* Called when this NPC takes damage. Updates threat perception.
* Hook this into the owning Actor's OnTakeAnyDamage.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Combat")
void NotifyDamageReceived(float Damage, AActor* DamageInstigator);
// ─── Replication ────────────────────────────────────────────────────
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
protected:
virtual void BeginPlay() override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
/** Multicast: notify all clients that an attack happened (for VFX, sound, anims). */
UFUNCTION(NetMulticast, Unreliable)
void Multicast_OnAttackExecuted(AActor* Target);
private:
/** Time remaining before next attack is allowed. Server-only. */
float CooldownRemaining = 0.0f;
};

View File

@@ -0,0 +1,141 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_CoverPoint.generated.h"
class UArrowComponent;
class UBillboardComponent;
class UTexture2D;
/**
* A manually placed strategic point in the level.
*
* - **Cover**: positioned behind walls/barricades for enemies in combat.
* The arrow shows the direction the NPC will face (toward the threat).
*
* - **Hiding Spot**: under desks, in closets, behind cars — for panicking civilians.
*
* Features:
* - Occupancy system: only one NPC per point (configurable max).
* - Quality score: manually set by the level designer (0.0-1.0).
* - Crouch flag: NPC should crouch at this cover.
* - Editor: color-coded (blue=Cover, yellow=HidingSpot), arrow shows facing.
*/
UCLASS(BlueprintType, Blueprintable, Placeable, meta = (DisplayName = "PS AI Cover Point"))
class PS_AI_BEHAVIOR_API APS_AI_Behavior_CoverPoint : public AActor
{
GENERATED_BODY()
public:
APS_AI_Behavior_CoverPoint();
// ─── Components ─────────────────────────────────────────────────────
#if WITH_EDITORONLY_DATA
UPROPERTY(VisibleAnywhere, Category = "Components")
TObjectPtr<UBillboardComponent> SpriteComp;
UPROPERTY(VisibleAnywhere, Category = "Components")
TObjectPtr<UArrowComponent> ArrowComp;
#endif
// ─── Configuration ──────────────────────────────────────────────────
/** Type of this point. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cover Point")
EPS_AI_Behavior_CoverPointType PointType = EPS_AI_Behavior_CoverPointType::Cover;
/**
* Manual quality score set by the level designer.
* 0.0 = poor cover, 1.0 = excellent cover.
* Combined with runtime raycast verification.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cover Point", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float Quality = 0.7f;
/** Maximum number of NPCs that can occupy this point simultaneously. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cover Point", meta = (ClampMin = "1", ClampMax = "4"))
int32 MaxOccupants = 1;
/** NPC should crouch when using this cover point. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cover Point")
bool bCrouch = true;
/**
* Optional: restrict this point to specific NPC types.
* Any = all NPC types can use it. Otherwise, matches the NPC's type.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cover Point")
EPS_AI_Behavior_NPCType AllowedNPCType = EPS_AI_Behavior_NPCType::Any;
/**
* Whether this point is currently enabled. Disabled points are ignored.
* Useful for scripted scenarios (e.g. barricade destroyed → disable cover).
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cover Point")
bool bEnabled = true;
// ─── Runtime (server-only) ──────────────────────────────────────────
/** Current occupants. Managed by the BT / EQS. */
UPROPERTY(Transient)
TArray<TWeakObjectPtr<AActor>> CurrentOccupants;
// ─── API ────────────────────────────────────────────────────────────
/** Can this point be used by the given NPC type? */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Cover")
bool IsAccessibleTo(EPS_AI_Behavior_NPCType NPCType) const;
/** Is there room for one more occupant? */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Cover")
bool HasRoom() const;
/** Try to claim this point for an NPC. Returns true if successful. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Cover")
bool Claim(AActor* Occupant);
/** Release this point (NPC leaves cover). */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Cover")
void Release(AActor* Occupant);
/** Get the facing direction (forward vector of the actor). */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Cover")
FVector GetCoverDirection() const;
/**
* Evaluate cover quality at runtime with a raycast check against a threat.
* Combines manual Quality score with actual line-of-sight blockage.
* @param ThreatLocation Where the threat is.
* @return Combined score 0.0 to 1.0.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Cover")
float EvaluateAgainstThreat(const FVector& ThreatLocation) const;
/** Enable/disable at runtime (e.g. barricade destroyed). */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Cover")
void SetEnabled(bool bNewEnabled);
#if WITH_EDITOR
virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
#endif
protected:
virtual void BeginPlay() override;
private:
void UpdateVisualization();
#if WITH_EDITORONLY_DATA
/** Cached editor sprite textures (loaded once in constructor). */
UPROPERTY(Transient)
TObjectPtr<UTexture2D> CoverSpriteTexture;
UPROPERTY(Transient)
TObjectPtr<UTexture2D> HidingSpotSpriteTexture;
#endif
};

View File

@@ -0,0 +1,193 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "PS_AI_Behavior_Definitions.generated.h"
// ─── Log Category ───────────────────────────────────────────────────────────
PS_AI_BEHAVIOR_API DECLARE_LOG_CATEGORY_EXTERN(LogPS_AI_Behavior, Log, All);
// ─── API Macro ──────────────────────────────────────────────────────────────
// Defined by UBT from module name; redeclare for clarity
#ifndef PS_AI_BEHAVIOR_API
#define PS_AI_BEHAVIOR_API
#endif
// ─── Enums ──────────────────────────────────────────────────────────────────
/**
* Type of NPC — determines team affiliation, spline access, and default behavior.
* Also used on splines to restrict which NPCs can walk on them.
* "Any" means accessible to all types (splines only — not a valid NPC type).
*/
UENUM(BlueprintType)
enum class EPS_AI_Behavior_NPCType : uint8
{
Civilian UMETA(DisplayName = "Civilian", ToolTip = "Non-hostile civilians"),
Enemy UMETA(DisplayName = "Enemy", ToolTip = "Hostile NPCs"),
Protector UMETA(DisplayName = "Protector", ToolTip = "Police/guards, allied with Civilians"),
Any UMETA(DisplayName = "Any", ToolTip = "Splines only: accessible to all types"),
};
/** High-level behavioral state written to the Blackboard. */
UENUM(BlueprintType)
enum class EPS_AI_Behavior_State : uint8
{
Idle UMETA(DisplayName = "Idle"),
Patrol UMETA(DisplayName = "Patrol"),
Alerted UMETA(DisplayName = "Alerted"),
Combat UMETA(DisplayName = "Combat"),
Fleeing UMETA(DisplayName = "Fleeing"),
TakingCover UMETA(DisplayName = "Taking Cover"),
Dead UMETA(DisplayName = "Dead"),
Scripted UMETA(DisplayName = "Scripted"),
};
/**
* Target type for combat priority.
* Includes Player which is not an NPC type but is a valid target.
*/
UENUM(BlueprintType)
enum class EPS_AI_Behavior_TargetType : uint8
{
Player UMETA(DisplayName = "Player", ToolTip = "Human-controlled character"),
Civilian UMETA(DisplayName = "Civilian", ToolTip = "Non-hostile NPC"),
Protector UMETA(DisplayName = "Protector", ToolTip = "Police/guard NPC"),
Enemy UMETA(DisplayName = "Enemy", ToolTip = "Hostile NPC (same faction — rare)"),
};
/**
* Type of strategic point placed manually in the level.
* Cover = enemies use it for tactical combat cover.
* HidingSpot = civilians use it to hide when panicking.
*/
UENUM(BlueprintType)
enum class EPS_AI_Behavior_CoverPointType : uint8
{
Cover UMETA(DisplayName = "Cover", ToolTip = "Tactical cover for enemies (behind walls, barricades)"),
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"),
MovingToFire UMETA(DisplayName = "Moving To Fire", ToolTip = "Moving to firing position"),
Peeking UMETA(DisplayName = "Peeking", ToolTip = "At firing position, shooting"),
ReturningToCover UMETA(DisplayName = "Returning To Cover", ToolTip = "Moving back to cover after 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
{
Courage UMETA(DisplayName = "Courage", ToolTip = "0 = coward, 1 = fearless. Gates flee threshold and cover advancement."),
Aggressivity UMETA(DisplayName = "Aggressivity", ToolTip = "0 = peaceful, 1 = violent. Drives attack threshold and Combat/Cover time ratio."),
Caution UMETA(DisplayName = "Caution", ToolTip = "0 = reckless, 1 = prudent. Drives flee threshold, cover duration, and spline threat avoidance."),
};
// ─── 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
{
inline const FName State = TEXT("BehaviorState");
inline const FName ThreatActor = TEXT("ThreatActor");
inline const FName ThreatLocation = TEXT("ThreatLocation");
inline const FName ThreatLevel = TEXT("ThreatLevel");
inline const FName CoverLocation = TEXT("CoverLocation");
inline const FName CoverPoint = TEXT("CoverPoint");
inline const FName PatrolIndex = TEXT("PatrolIndex");
inline const FName HomeLocation = TEXT("HomeLocation");
inline const FName CurrentSpline = TEXT("CurrentSpline");
inline const FName SplineProgress = TEXT("SplineProgress");
inline const FName CombatSubState = TEXT("CombatSubState");
inline const FName LastKnownTargetPosition = TEXT("LastKnownTargetPosition");
inline const FName ThreatPawnName = TEXT("ThreatPawnName"); // Debug: name of the owning Pawn behind ThreatActor
inline const FName PreferCover = TEXT("PreferCover"); // Bool: personality-driven cover preference cycle
inline const FName ConversationPaused = TEXT("ConversationPaused"); // Bool: NPC paused during active conversation
}

View File

@@ -0,0 +1,182 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_Interface.generated.h"
/**
* General-purpose interface for the PS AI Behavior plugin.
*
* Implement this on your Pawn/Character classes so the behavior system can
* query and modify NPC identity, hostility, and team affiliation without
* any compile-time dependency on your project's class hierarchy.
*
* Implementable in C++ (BlueprintNativeEvent) or Blueprint (BlueprintImplementableEvent).
*
* Example C++ implementation on your Character:
*
* class AMyCharacter : public ACharacter, public IPS_AI_Behavior
* {
* EPS_AI_Behavior_NPCType MyType = EPS_AI_Behavior_NPCType::Civilian;
* bool bHostile = false;
*
* virtual EPS_AI_Behavior_NPCType GetBehaviorNPCType_Implementation() const override { return MyType; }
* virtual void SetBehaviorNPCType_Implementation(EPS_AI_Behavior_NPCType T) override { MyType = T; }
* virtual bool IsBehaviorHostile_Implementation() const override { return bHostile; }
* virtual void SetBehaviorHostile_Implementation(bool b) override { bHostile = b; }
* };
*/
UINTERFACE(BlueprintType, Blueprintable, meta = (DisplayName = "PS AI Behavior Interface"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_Interface : public UInterface
{
GENERATED_BODY()
};
class PS_AI_BEHAVIOR_API IPS_AI_Behavior_Interface
{
GENERATED_BODY()
public:
// ─── NPC Type ───────────────────────────────────────────────────────
/** Get this NPC's type (Civilian, Enemy, Protector). */
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
EPS_AI_Behavior_NPCType GetBehaviorNPCType() const;
/** Set this NPC's type. Called by gameplay logic or plugin actions. */
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void SetBehaviorNPCType(EPS_AI_Behavior_NPCType NewType);
// ─── Hostility ──────────────────────────────────────────────────────
/**
* Is this NPC currently hostile?
* An infiltrated Enemy with IsHostile=false appears as Civilian to the perception system.
* When SetHostile(true) is called, the NPC reveals itself and TeamId changes.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
bool IsBehaviorHostile() const;
/**
* Set hostility state. Typically called by gameplay scripts or ConvAgent actions.
* Implementors should update their TeamId accordingly.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void SetBehaviorHostile(bool bNewHostile);
// ─── Movement ───────────────────────────────────────────────────────
/**
* Request the Pawn to change its movement speed.
* Called by the behavior system when the NPC's state changes
* (e.g. panicking civilian runs, cautious enemy crouches slowly).
*
* The Pawn implements this however it wants — typically by setting
* CharacterMovementComponent::MaxWalkSpeed.
*
* @param NewSpeed Desired walk speed in cm/s.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void SetBehaviorMovementSpeed(float NewSpeed);
/**
* Get the Pawn's current movement speed (cm/s).
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
float GetBehaviorMovementSpeed() const;
/**
* Notify the Pawn that the behavioral state changed.
* The Pawn can use this to trigger animations, voice lines, VFX, etc.
* Called on the server — the Pawn is responsible for replicating
* any cosmetic effects if needed.
*
* @param NewState The new behavioral state.
* @param OldState The previous behavioral state.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void OnBehaviorStateChanged(EPS_AI_Behavior_State NewState, EPS_AI_Behavior_State OldState);
// ─── Target Validation ──────────────────────────────────────────────
/**
* Check whether a perceived actor is still a valid target (alive, not despawning, etc.).
* Called by the perception and combat systems to discard dead or invalid targets.
*
* The host project implements this to check its own health/death system.
* Default returns true (all actors are valid unless the Pawn says otherwise).
*
* @param TargetActor The actor to validate.
* @return True if the actor can still be targeted.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
bool IsTargetActorValid(AActor* TargetActor) const;
// ─── Combat ─────────────────────────────────────────────────────────
/**
* Order the Pawn to start attacking a target.
* The Pawn implements this with its own combat system (aim, fire, melee, etc.).
* Called when the BT enters the Attack task.
*
* @param Target The actor to attack.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void BehaviorStartAttack(AActor* Target);
/**
* Order the Pawn to stop attacking.
* Called when the BT exits the Attack task (state changed, target lost, etc.).
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void BehaviorStopAttack();
/**
* Query whether the Pawn can currently attack the target (has ammo, weapon ready, etc.).
* If false, the BT will keep the NPC in combat stance but won't call BehaviorStartAttack.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
bool CanBehaviorAttack(AActor* Target) const;
// ─── Stance ─────────────────────────────────────────────────────────
/**
* Order the Pawn to crouch or stand up.
* Called by the cover system when entering/leaving a cover point with bCrouch set.
* The Pawn implements this however it wants (CharacterMovement->Crouch, animation, etc.).
*
* @param bCrouch True = crouch, False = stand up.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void SetBehaviorCrouch(bool bCrouch);
// ─── 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 = "ASTERION|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 = "ASTERION|PS_AI_Behavior")
AActor* GetBehaviorThreatActor() const;
};

View File

@@ -0,0 +1,91 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Perception/AIPerceptionComponent.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_PerceptionComponent.generated.h"
/**
* Pre-configured AI Perception component for the behavior system.
* Sets up Sight, Hearing, and Damage senses with defaults from plugin settings.
* Provides helpers to query the highest threat and compute a threat level.
*
* Automatically added by PS_AI_Behavior_AIController — you don't need to add it manually.
*/
UCLASS(ClassGroup = "ASTERION|PS_AI_Behavior", meta = (DisplayName = "PS AI Behavior - Perception"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_PerceptionComponent : public UAIPerceptionComponent
{
GENERATED_BODY()
public:
UPS_AI_Behavior_PerceptionComponent();
// ─── Queries ────────────────────────────────────────────────────────
/**
* Get the actor that represents the highest threat, considering target priority.
* Scoring: priority rank (from PersonalityProfile) > damage sense > proximity.
*
* @param TargetPriority Ordered list of target types (first = highest priority).
* If empty, uses default [Protector, Player, Civilian].
* @return The most threatening actor, or nullptr if none perceived.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Perception")
AActor* GetHighestThreatActor(const TArray<EPS_AI_Behavior_TargetType>& TargetPriority);
/** Convenience overload — reads priority from the Pawn's PersonalityProfile. */
AActor* GetHighestThreatActor();
/**
* Compute an aggregate threat level from all currently perceived hostile stimuli.
* Returns 0.0 (no threat) to 1.0+ (extreme danger).
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Perception")
float CalculateThreatLevel();
/**
* Get the location of the last known threat stimulus.
* @param OutLocation Filled with the threat location if any threat exists.
* @return True if a threat was found.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|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);
/** Walk Owner/Instigator chain to find the Pawn that owns a perceived actor (weapon, AimTarget, etc.). */
static APawn* FindOwningPawn(AActor* Actor);
/** The owning Pawn of the last selected ThreatActor (set by GetHighestThreatActor). */
UPROPERTY(Transient)
TWeakObjectPtr<APawn> LastThreatOwningPawn;
protected:
virtual void BeginPlay() override;
UFUNCTION()
void HandlePerceptionUpdated(const TArray<AActor*>& UpdatedActors);
/**
* Classify an actor as a TargetType.
* Uses IsPlayerControlled() for Player, IPS_AI_Behavior interface or
* PersonalityComponent for NPC type.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Perception")
static EPS_AI_Behavior_TargetType ClassifyActor(const AActor* Actor);
private:
/** Configure sight, hearing, and damage senses from plugin settings. */
void ConfigureSenses();
};

View File

@@ -0,0 +1,173 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "Net/UnrealNetwork.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_PersonalityComponent.generated.h"
class UPS_AI_Behavior_PersonalityProfile;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnBehaviorStateChanged, EPS_AI_Behavior_State, OldState, EPS_AI_Behavior_State, NewState);
/**
* Manages an NPC's personality traits at runtime.
* Reads from a PersonalityProfile data asset, maintains runtime-modifiable trait scores,
* and evaluates the NPC's behavioral reaction to perceived threats.
*
* Replication: CurrentState and PerceivedThreatLevel are replicated to all clients
* so that animations and HUD can reflect the NPC's current behavior.
*
* Attach to the NPC Pawn/Character.
*/
UCLASS(ClassGroup = "ASTERION|PS_AI_Behavior", meta = (BlueprintSpawnableComponent, DisplayName = "PS AI Behavior - Personality"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_PersonalityComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPS_AI_Behavior_PersonalityComponent();
// ─── Configuration ──────────────────────────────────────────────────
/** Personality profile data asset. Set in the editor per NPC archetype. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Personality")
TObjectPtr<UPS_AI_Behavior_PersonalityProfile> Profile;
/**
* If false, the Behavior Tree does NOT start on possess.
* Use this for NPCs controlled externally (conversations, cinematics).
* Call StartBehavior() on the AIController to activate manually.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Personality")
bool bAutoStartBehavior = true;
/**
* Master debug toggle for this NPC.
* When enabled, draws floating text above the NPC's head with:
* Name, NPCType, TeamId, State, ThreatLevel, ThreatActor, Hostile, Spline.
* Also enables debug visuals on SplineFollowerComponent.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Personality|Debug")
bool bDebug = false;
// ─── Runtime State ──────────────────────────────────────────────────
/**
* Runtime trait scores — initialized from Profile at BeginPlay.
* Can be modified during gameplay (e.g. NPC becomes more courageous over time).
* Server-only: traits drive AI decisions which run on server.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Personality|Runtime")
TMap<EPS_AI_Behavior_TraitAxis, float> RuntimeTraits;
/**
* Current perceived threat level (0.0 = safe, 1.0 = maximum danger).
* Written by BTService_UpdateThreat on the server.
* Replicated for client HUD/debug display.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Replicated, Category = "Personality|Runtime")
float PerceivedThreatLevel = 0.0f;
/**
* Current behavioral state — replicated with OnRep to fire delegate on clients.
* Only written on the server (by BT or ForceState).
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, ReplicatedUsing = OnRep_CurrentState, Category = "Personality|Runtime")
EPS_AI_Behavior_State CurrentState = EPS_AI_Behavior_State::Idle;
// ─── Delegates ──────────────────────────────────────────────────────
/** Fired when the behavioral state changes (on server AND clients via OnRep). */
UPROPERTY(BlueprintAssignable, Category = "ASTERION|PS_AI_Behavior|Personality")
FOnBehaviorStateChanged OnBehaviorStateChanged;
// ─── API ────────────────────────────────────────────────────────────
/**
* Evaluate the NPC's reaction based on current traits and perceived threat.
* Returns the recommended behavioral state. Server-only.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Personality")
EPS_AI_Behavior_State EvaluateReaction() const;
/**
* Evaluate and apply the reaction — updates CurrentState and fires delegate if changed.
* Server-only: state is replicated to clients via OnRep.
* @return The new behavioral state.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Personality")
EPS_AI_Behavior_State ApplyReaction();
/** Get a runtime trait value (returns 0.5 if undefined). */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Personality")
float GetTrait(EPS_AI_Behavior_TraitAxis Axis) const;
/** Modify a runtime trait by delta, clamped to [0, 1]. Server-only. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Personality")
void ModifyTrait(EPS_AI_Behavior_TraitAxis Axis, float Delta);
/** Force a specific state (e.g. from conversation agent action). Server-only. */
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;
/**
* Whether the NPC currently prefers cover over direct attack.
* Driven by the Combat/Cover cycle timer based on Aggressivity vs Caution.
* Used by the IsCoverNeeded BT decorator.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Personality")
bool ShouldPreferCover() const { return bPreferCover; }
// ─── Replication ────────────────────────────────────────────────────
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
protected:
virtual void BeginPlay() override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
UFUNCTION()
void OnRep_CurrentState(EPS_AI_Behavior_State OldState);
private:
/**
* Central handler for state transitions. Called on server when state changes.
* - Broadcasts the delegate
* - Calls IPS_AI_Behavior::SetBehaviorMovementSpeed on the Pawn
* - Calls IPS_AI_Behavior::OnBehaviorStateChanged on the Pawn
*/
void HandleStateChanged(EPS_AI_Behavior_State OldState, EPS_AI_Behavior_State NewState);
// ─── Combat/Cover Cycle Timer ──────────────────────────────────────
/** Countdown timer for the current Combat or TakingCover phase. */
float CombatCoverTimer = 0.0f;
/** Whether the combat/cover cycle timer is active. */
bool bCombatCoverCycleActive = false;
/** Current cover preference — toggled by the cycle timer. */
bool bPreferCover = false;
/** Calculate the duration for a Combat or TakingCover phase based on personality. */
float CalculatePhaseDuration(EPS_AI_Behavior_State Phase) const;
/** Draw floating debug text above the NPC's head. */
void DrawDebugInfo() const;
};

View File

@@ -0,0 +1,172 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_PersonalityProfile.generated.h"
class UBehaviorTree;
/**
* Data Asset defining an NPC's personality profile.
* Contains trait scores, reaction thresholds, and default behavior tree.
* Create one per archetype (e.g. "Coward Civilian", "Aggressive Guard").
*/
UCLASS(BlueprintType)
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_PersonalityProfile : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPS_AI_Behavior_PersonalityProfile();
// ─── Identity ───────────────────────────────────────────────────────
/** Human-readable profile name (e.g. "Cowardly Villager"). */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality")
FText ProfileName;
/** NPC type — determines base behavior tree selection. */
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. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Traits",
meta = (ClampMin = "0.0", ClampMax = "1.0"))
TMap<EPS_AI_Behavior_TraitAxis, float> TraitScores;
// ─── Reaction Thresholds ────────────────────────────────────────────
/**
* Base threat level above which the NPC considers fleeing.
* Actual threshold is modulated at runtime by Courage trait.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Reaction",
meta = (ClampMin = "0.0", ClampMax = "1.0"))
float FleeThreshold = 0.5f;
/**
* Base threat level above which the NPC engages in combat.
* Actual threshold is modulated at runtime by Aggressivity trait.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Reaction",
meta = (ClampMin = "0.0", ClampMax = "1.0"))
float AttackThreshold = 0.4f;
/**
* Base threat level above which the NPC becomes alerted (but not yet fleeing/attacking).
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Reaction",
meta = (ClampMin = "0.0", ClampMax = "1.0"))
float AlertThreshold = 0.15f;
/**
* How fast the perceived threat level decays per second when no active threat is perceived.
* Low values = NPC stays scared/alert longer (civilians).
* High values = NPC calms down quickly (trained soldiers).
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Reaction",
meta = (ClampMin = "0.001", ClampMax = "1.0"))
float ThreatDecayRate = 0.05f;
// ─── Target Priority (Combat) ───────────────────────────────────────
/**
* Target selection priority for combat, in order of preference.
* First entry = highest priority target type.
*
* Example for a terrorist: [Player, Protector, Civilian]
* Example for a thief: [Civilian, Player] (avoids Protectors)
* Example for a rival gang: [Enemy, Protector, Player]
*
* If empty, defaults to: [Protector, Player, Civilian].
* Target types not in the list will not be attacked.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat")
TArray<EPS_AI_Behavior_TargetType> 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;
// ─── Combat/Cover Cycle ────────────────────────────────────────────
/**
* Base duration (seconds) of the Combat↔TakingCover cycle.
* The actual time in each state is proportional to Aggressivity vs Caution:
* Combat duration = CombatCoverCycleDuration × Aggressivity / (Aggressivity + Caution)
* TakingCover duration = CombatCoverCycleDuration × Caution / (Aggressivity + Caution)
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat",
meta = (ClampMin = "4.0", ClampMax = "60.0"))
float CombatCoverCycleDuration = 15.0f;
// ─── Movement Speed per State ──────────────────────────────────────
/**
* Movement speed (cm/s) for each behavioral state.
* The behavior system calls IPS_AI_Behavior::SetBehaviorMovementSpeed()
* on the Pawn when the state changes.
*
* States not in this map use DefaultWalkSpeed.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Movement")
TMap<EPS_AI_Behavior_State, float> SpeedPerState;
/**
* Base walk speed (cm/s) used when the current state is not in SpeedPerState.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Movement", meta = (ClampMin = "0.0"))
float DefaultWalkSpeed = 150.0f;
// ─── Behavior ───────────────────────────────────────────────────────
/** Default Behavior Tree for this personality archetype. Can be overridden on the AIController. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Behavior")
TSoftObjectPtr<UBehaviorTree> DefaultBehaviorTree;
// ─── Helpers ────────────────────────────────────────────────────────
/**
* Get the score for a given trait axis. Returns 0.5 if the axis is not defined.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Personality")
float GetTrait(EPS_AI_Behavior_TraitAxis Axis) const;
/** Get the speed for a given state. Returns DefaultWalkSpeed if state not in SpeedPerState. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Personality")
float GetSpeedForState(EPS_AI_Behavior_State State) const;
/** UPrimaryDataAsset interface */
virtual FPrimaryAssetId GetPrimaryAssetId() const override;
};

View File

@@ -0,0 +1,58 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DeveloperSettings.h"
#include "PS_AI_Behavior_Settings.generated.h"
/**
* Project-wide settings for the PS AI Behavior plugin.
* Accessible via Project Settings -> Plugins -> PS AI Behavior.
*/
UCLASS(config = Game, defaultconfig, meta = (DisplayName = "ASTERION|PS_AI_Behavior"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_Settings : public UDeveloperSettings
{
GENERATED_BODY()
public:
UPS_AI_Behavior_Settings();
// ─── General ────────────────────────────────────────────────────────
/** Enable verbose logging for the behavior plugin. */
UPROPERTY(config, EditAnywhere, Category = "General")
bool bVerboseLogging = false;
// ─── Perception Defaults ────────────────────────────────────────────
/** Default sight radius for NPC perception (cm). */
UPROPERTY(config, EditAnywhere, Category = "Perception", meta = (ClampMin = 100.0, ClampMax = 10000.0))
float DefaultSightRadius = 6000.0f;
/** Default sight half-angle (degrees). */
UPROPERTY(config, EditAnywhere, Category = "Perception", meta = (ClampMin = 10.0, ClampMax = 180.0))
float DefaultSightHalfAngle = 45.0f;
/** Default hearing range (cm). */
UPROPERTY(config, EditAnywhere, Category = "Perception", meta = (ClampMin = 100.0, ClampMax = 10000.0))
float DefaultHearingRange = 3000.0f;
/** Seconds before a sight stimulus is forgotten. */
UPROPERTY(config, EditAnywhere, Category = "Perception", meta = (ClampMin = 1.0, ClampMax = 60.0))
float PerceptionMaxAge = 10.0f;
/** Seconds before a hearing stimulus is forgotten (gunshots, etc.). Higher = NPCs stay scared longer. */
UPROPERTY(config, EditAnywhere, Category = "Perception", meta = (ClampMin = 1.0, ClampMax = 120.0))
float HearingMaxAge = 30.0f;
// ─── Threat ─────────────────────────────────────────────────────────
/** Threat level decay rate per second when no active threat stimulus exists. */
UPROPERTY(config, EditAnywhere, Category = "Threat", meta = (ClampMin = 0.0, ClampMax = 2.0))
float ThreatDecayRate = 0.05f;
// ─── Section Name ───────────────────────────────────────────────────
virtual FName GetCategoryName() const override { return TEXT("Plugins"); }
};

View File

@@ -0,0 +1,188 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_SplineFollowerComponent.generated.h"
class APS_AI_Behavior_SplinePath;
struct FPS_AI_Behavior_SplineJunction;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnApproachingJunction,
APS_AI_Behavior_SplinePath*, CurrentSpline,
int32, JunctionIndex,
float, DistanceToJunction);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSplineChanged,
APS_AI_Behavior_SplinePath*, OldSpline,
APS_AI_Behavior_SplinePath*, NewSpline);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSplineEndReached,
APS_AI_Behavior_SplinePath*, Spline);
/**
* Drives smooth NPC movement along spline paths.
* Handles:
* - Fluid motion with rotation interpolation
* - Automatic junction detection and spline switching
* - Speed variation based on spline settings
* - Forward/reverse travel on bidirectional splines
*
* Attach to the NPC Pawn. Works with or without the AI Controller.
*/
UCLASS(ClassGroup = "ASTERION|PS_AI_Behavior", meta = (BlueprintSpawnableComponent, DisplayName = "PS AI Behavior - Spline Follower"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_SplineFollowerComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPS_AI_Behavior_SplineFollowerComponent();
// ─── Replication ────────────────────────────────────────────────────
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
// ─── Configuration ──────────────────────────────────────────────────
/** Walk speed along spline (cm/s). If the spline has its own speed, this is overridden. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower", meta = (ClampMin = "10.0"))
float DefaultWalkSpeed = 150.0f;
/** How far ahead to look for junctions (cm). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower", meta = (ClampMin = "50.0"))
float JunctionDetectionDistance = 300.0f;
/** How quickly the NPC rotates to face the spline direction (degrees/sec). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower", meta = (ClampMin = "30.0"))
float RotationInterpSpeed = 360.0f;
/**
* How far ahead on the spline to place the target point (cm).
* Larger = smoother wider turns, smaller = tighter cornering.
* The NPC always steers toward this point, which follows the spline curve naturally.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower", meta = (ClampMin = "50.0"))
float LookaheadDistance = 200.0f;
/**
* Whether to auto-choose a spline at junctions.
* If false, OnApproachingJunction fires and you must call SwitchToSpline manually.
* If true, uses SplineNetwork::ChooseSplineAtJunction automatically.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower")
bool bAutoChooseAtJunction = true;
/**
* If true, on reaching the end of a non-looped spline, reverse direction.
* If false, stop and fire OnSplineEndReached.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower")
bool bReverseAtEnd = true;
/** Draw debug info: target point on spline, direction, gap distance. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower|Debug")
bool bDebug = false;
// ─── Runtime State ──────────────────────────────────────────────────
/**
* Currently followed spline. Replicated so clients know which spline the NPC is on.
* Null if not following any. Movement itself is synced via CMC.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Spline Follower|Runtime")
TObjectPtr<APS_AI_Behavior_SplinePath> CurrentSpline;
/** Current distance along the spline (cm). Server-only, not replicated (CMC handles position). */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Spline Follower|Runtime")
float CurrentDistance = 0.0f;
/** True if moving in the positive direction along the spline. */
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Spline Follower|Runtime")
bool bMovingForward = true;
/** Is the follower actively moving? Replicated for client animation state. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Spline Follower|Runtime")
bool bIsFollowing = false;
/** Set by StopFollowing(): a firm, manual stop. While true, the Behavior Tree
* spline tasks refuse to (re)start following — only an explicit StartFollowing()
* / StartFollowingAtDistance() clears it. This is what distinguishes a deliberate
* stop from PauseFollowing() (which the BT is allowed to auto-resume). */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Spline Follower|Runtime")
bool bManuallyStopped = false;
// ─── Delegates ──────────────────────────────────────────────────────
/** Fired when approaching a junction. Use to make custom spline selection. */
UPROPERTY(BlueprintAssignable, Category = "ASTERION|PS_AI_Behavior|Spline Follower")
FOnApproachingJunction OnApproachingJunction;
/** Fired when the NPC switches to a different spline. */
UPROPERTY(BlueprintAssignable, Category = "ASTERION|PS_AI_Behavior|Spline Follower")
FOnSplineChanged OnSplineChanged;
/** Fired when the NPC reaches the end of a spline (if bReverseAtEnd is false). */
UPROPERTY(BlueprintAssignable, Category = "ASTERION|PS_AI_Behavior|Spline Follower")
FOnSplineEndReached OnSplineEndReached;
// ─── API ────────────────────────────────────────────────────────────
/**
* Start following the given spline from the closest point.
* @param Spline The spline to follow.
* @param bForward Direction of travel.
* @return True if successfully started.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline Follower")
bool StartFollowing(APS_AI_Behavior_SplinePath* Spline, bool bForward = true);
/**
* Start following the given spline from a specific distance.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline Follower")
bool StartFollowingAtDistance(APS_AI_Behavior_SplinePath* Spline, float StartDistance, bool bForward = true);
/** Stop following the current spline. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline Follower")
void StopFollowing();
/** Pause/resume without losing state. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline Follower")
void PauseFollowing();
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline Follower")
void ResumeFollowing();
/**
* Switch to another spline at a junction point.
* @param NewSpline The spline to switch to.
* @param DistanceOnNew Distance along the new spline to start from.
* @param bNewForward Direction on the new spline.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline Follower")
void SwitchToSpline(APS_AI_Behavior_SplinePath* NewSpline, float DistanceOnNew, bool bNewForward = true);
/** Get the effective walk speed (considering spline override). */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline Follower")
float GetEffectiveSpeed() const;
/** Get progress as a 0-1 ratio. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline Follower")
float GetProgress() const;
protected:
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
private:
/** Check for upcoming junctions and handle them. */
void HandleJunctions();
/** Index of the junction we already handled (to avoid re-triggering). */
int32 LastHandledJunctionIndex = -1;
/** Speed multiplier for variety (set randomly on spawn). */
float SpeedVariation = 1.0f;
};

View File

@@ -0,0 +1,108 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/WorldSubsystem.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_SplineNetwork.generated.h"
class APS_AI_Behavior_SplinePath;
/**
* World Subsystem that manages the network of spline paths.
* At BeginPlay, scans all SplinePath actors, detects intersections between them,
* and populates their Junction arrays.
*
* Provides queries for NPCs to find the nearest accessible spline, pick a path
* at a junction, etc.
*/
UCLASS()
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_SplineNetwork : public UWorldSubsystem
{
GENERATED_BODY()
public:
// ─── UWorldSubsystem ────────────────────────────────────────────────
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
// ─── Network Build ──────────────────────────────────────────────────
/**
* Scan the world for all SplinePath actors and compute junctions.
* Called automatically after world initialization. Can be called again
* if splines are added/removed at runtime.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|SplineNetwork")
void RebuildNetwork();
// ─── Queries ────────────────────────────────────────────────────────
/**
* Find the closest accessible spline for the given NPC type.
* @param WorldLocation The NPC's current position.
* @param NPCType Filter: only return splines accessible to this type.
* @param MaxDistance Maximum snap distance (cm). Default = 2000.
* @param OutSpline The closest spline (if found).
* @param OutDistance Distance along the spline to the closest point.
* @return True if a spline was found within MaxDistance.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|SplineNetwork")
bool FindClosestSpline(const FVector& WorldLocation,
EPS_AI_Behavior_NPCType NPCType, float MaxDistance,
APS_AI_Behavior_SplinePath*& OutSpline, float& OutDistance) const;
/**
* Get all splines of a given category.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|SplineNetwork")
TArray<APS_AI_Behavior_SplinePath*> GetSplinesForCategory(
EPS_AI_Behavior_NPCType Category) const;
/**
* Choose the best spline to switch to at a junction.
* Considers spline priority, NPC personality (Caution → avoids main roads),
* and optional bias away from a threat location.
*
* @param CurrentSpline The spline the NPC is currently on.
* @param JunctionIndex Index into CurrentSpline->Junctions.
* @param NPCType NPC type filter.
* @param ThreatLocation Optional: bias away from this point. ZeroVector = ignore.
* @param CautionScore Optional: NPC's caution trait (0-1). Higher = prefer quieter paths.
* @return The chosen spline (could be the same if staying is best).
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|SplineNetwork")
APS_AI_Behavior_SplinePath* ChooseSplineAtJunction(
APS_AI_Behavior_SplinePath* CurrentSpline, int32 JunctionIndex,
EPS_AI_Behavior_NPCType NPCType,
const FVector& ThreatLocation = FVector::ZeroVector,
float CautionScore = 0.5f) const;
/** Total number of splines in the network. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|SplineNetwork")
int32 GetSplineCount() const { return AllSplines.Num(); }
/** Total number of junctions detected. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|SplineNetwork")
int32 GetJunctionCount() const { return TotalJunctions; }
private:
/** All registered spline paths. */
UPROPERTY()
TArray<TObjectPtr<APS_AI_Behavior_SplinePath>> AllSplines;
/** Cached junction count. */
int32 TotalJunctions = 0;
/**
* Detect junctions between two splines by sampling one and projecting onto the other.
* Tolerance = max distance between splines to consider a junction.
*/
void DetectJunctions(APS_AI_Behavior_SplinePath* SplineA,
APS_AI_Behavior_SplinePath* SplineB, float Tolerance);
/** UWorldSubsystem override — called when world begins play. */
virtual void OnWorldBeginPlay(UWorld& InWorld) override;
};

View File

@@ -0,0 +1,148 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_SplinePath.generated.h"
class USplineComponent;
/**
* A junction (intersection) between two splines.
* Stored by the SplineNetwork subsystem after scanning overlaps.
*/
USTRUCT(BlueprintType)
struct FPS_AI_Behavior_SplineJunction
{
GENERATED_BODY()
/** The other spline at this junction. */
UPROPERTY(BlueprintReadOnly, Category = "Spline")
TWeakObjectPtr<class APS_AI_Behavior_SplinePath> OtherSpline;
/** Distance along THIS spline where the junction is. */
UPROPERTY(BlueprintReadOnly, Category = "Spline")
float DistanceOnThisSpline = 0.0f;
/** Distance along the OTHER spline where the junction is. */
UPROPERTY(BlueprintReadOnly, Category = "Spline")
float DistanceOnOtherSpline = 0.0f;
/** World location of the junction. */
UPROPERTY(BlueprintReadOnly, Category = "Spline")
FVector WorldLocation = FVector::ZeroVector;
};
/**
* Spline path actor — place in the level to define NPC navigation paths.
* Think of it as a sidewalk, patrol route, or corridor.
*
* - Set SplineCategory to Civilian, Enemy, Protector, or Any to control access.
* - Splines can overlap/intersect. The SplineNetwork subsystem detects junctions
* and lets NPCs switch between paths at those points.
* - Supports bidirectional travel by default.
*/
UCLASS(BlueprintType, Blueprintable, Placeable, meta = (DisplayName = "PS AI Spline Path"))
class PS_AI_BEHAVIOR_API APS_AI_Behavior_SplinePath : public AActor
{
GENERATED_BODY()
public:
APS_AI_Behavior_SplinePath();
// ─── Components ─────────────────────────────────────────────────────
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Spline")
TObjectPtr<USplineComponent> SplineComp;
// ─── Configuration ──────────────────────────────────────────────────
/**
* Which NPC type is allowed on this spline.
* Civilian = civilians + protectors, Enemy = enemies only,
* Protector = protectors only, Any = all types.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline")
EPS_AI_Behavior_NPCType SplineCategory = EPS_AI_Behavior_NPCType::Any;
/** If true, NPCs can travel in both directions on this spline. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline")
bool bBidirectional = true;
/**
* Base walk speed on this spline (cm/s). 0 = use NPC's default speed.
* Useful for making NPCs walk slower on narrow sidewalks.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline", meta = (ClampMin = "0.0"))
float SplineWalkSpeed = 0.0f;
/**
* Priority when multiple splines are available at a junction.
* Higher = more likely to be chosen. 0 = default.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline")
int32 Priority = 0;
// ─── Junctions (populated at runtime by SplineNetwork) ──────────────
/** All junctions on this spline, sorted by distance along spline. */
UPROPERTY(BlueprintReadOnly, Category = "Spline|Junctions")
TArray<FPS_AI_Behavior_SplineJunction> Junctions;
// ─── API ────────────────────────────────────────────────────────────
/** Can the given NPC type use this spline? */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline")
bool IsAccessibleTo(EPS_AI_Behavior_NPCType NPCType) const;
/**
* Get the closest point on this spline to a world location.
* @param WorldLocation The reference point.
* @param OutDistance Distance along the spline to the closest point.
* @param OutWorldPoint World location of the closest point on the spline.
* @return Distance from WorldLocation to the closest spline point.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline")
float GetClosestPointOnSpline(const FVector& WorldLocation,
float& OutDistance, FVector& OutWorldPoint) const;
/**
* Get all junctions within a distance range on this spline.
* @param CurrentDistance Current distance along the spline.
* @param LookAheadDist How far ahead to look for junctions.
* @param bForward Travel direction (true = increasing distance).
* @return Array of upcoming junctions.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline")
TArray<FPS_AI_Behavior_SplineJunction> GetUpcomingJunctions(
float CurrentDistance, float LookAheadDist, bool bForward) const;
/** Total spline length. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline")
float GetSplineLength() const;
/** Get world location at a distance along the spline. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline")
FVector GetWorldLocationAtDistance(float Distance) const;
/** Get world rotation at a distance along the spline. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline")
FRotator GetWorldRotationAtDistance(float Distance) const;
/** Get world-space tangent direction at a distance along the spline. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Spline")
FVector GetWorldDirectionAtDistance(float Distance) const;
#if WITH_EDITOR
virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
#endif
protected:
virtual void BeginPlay() override;
private:
/** Update spline color in editor based on category. */
void UpdateSplineVisualization();
};

View File

@@ -0,0 +1,59 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_Statics.generated.h"
/**
* Static helpers for the PS AI Behavior plugin.
* Call from Blueprint or C++ to interact with the behavior system.
*/
UCLASS()
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_Statics : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/**
* Report a gunfire noise event so all nearby NPCs react.
* Uses tag "EnemyFire" or "PlayerFire" depending on bIsEnemyFire.
*
* Compatible with existing ReportNoiseEvent calls using those tags.
*
* - Enemies (non-hostile) hearing this will become hostile toward the shooter.
* - Civilians hearing this will flee based on their personality traits.
* - Already-hostile enemies treat this as additional threat.
*
* @param WorldContext Any world-context object (self, weapon, etc.)
* @param Location World location of the gunshot.
* @param Shooter The actor who fired (Instigator for perception).
* @param bIsEnemyFire True = tag "EnemyFire", False = tag "PlayerFire".
* @param Loudness Noise loudness (default 1.0). Scales with HearingRange.
* @param MaxRange Override max hearing range (0 = use sense default).
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Perception",
meta = (WorldContext = "WorldContext", DefaultToSelf = "Shooter"))
static void ReportGunfire(UObject* WorldContext, FVector Location,
AActor* Shooter, bool bIsEnemyFire = false,
float Loudness = 1.0f, float MaxRange = 0.0f);
/**
* Check if there is a clear line-of-sight between two actors.
* Traces from the source actor's eye height to the target actor's location.
*
* @param World The world to trace in.
* @param Source The actor looking (trace starts at Source + EyeHeightOffset).
* @param Target The actor being looked at (trace ends at Target location).
* @param EyeHeightOffset Height offset above Source's origin for the trace start (cm).
* @return True if line-of-sight is clear (no blocking geometry).
*/
static bool HasLineOfSight(const UWorld* World, const AActor* Source, const AActor* Target,
float EyeHeightOffset = 150.0f);
/** Check if debug is enabled for a given Pawn (checks CVar override first, then PersonalityComponent.bDebug). */
static bool IsDebugEnabled(const APawn* Pawn);
};

View File

@@ -0,0 +1,51 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "GenericTeamAgentInterface.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_TeamComponent.generated.h"
/**
* Drop this component on any Pawn (player or non-AI character) to give it
* a role that the PS AI Behavior perception system will recognize.
*
* NPCs controlled by PS_AI_Behavior_AIController already have their role
* set automatically via the Interface — this component is meant for Pawns
* WITHOUT that controller (typically the player character).
*
* Role determines team affiliation:
* Civilian (Team 1) — Enemies attack, Protectors defend
* Enemy (Team 2) — Protectors and Civilians react
* Protector (Team 3) — Allied with Civilians, hostile to Enemies
*/
UCLASS(ClassGroup = "ASTERION|PS_AI_Behavior", meta = (BlueprintSpawnableComponent,
DisplayName = "PS AI Behavior Pawn Identity"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_TeamComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPS_AI_Behavior_TeamComponent();
/** Role of this actor in the behavior system. Determines team affiliation. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_AI_Behavior|Team")
EPS_AI_Behavior_NPCType Role = EPS_AI_Behavior_NPCType::Civilian;
/** Faction within the same role. Same role + different faction = rivals. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ASTERION|PS_AI_Behavior|Team",
meta = (ClampMin = "0", ClampMax = "15"))
uint8 Faction = 0;
/** Change role at runtime. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Team")
void SetRole(EPS_AI_Behavior_NPCType NewRole) { Role = NewRole; }
/** Returns the TeamId derived from the current Role. */
uint8 GetTeamId() const;
/** Returns the TeamId as FGenericTeamId. */
FGenericTeamId GetGenericTeamId() const { return FGenericTeamId(GetTeamId()); }
};

Some files were not shown because too many files have changed in this diff Show More