Compare commits
127 Commits
9ee7960855
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f35c134aa | |||
| 086311da38 | |||
| c783613117 | |||
| d92e402722 | |||
| 231ca4145d | |||
| 087c83018c | |||
| f49a57f892 | |||
| 6d92ec3c26 | |||
| bcb6c44f57 | |||
| cf83a2302d | |||
| 5b3a11a733 | |||
| 2ca5643f32 | |||
| 4854a7fa9a | |||
| 41900991ef | |||
| efd9921fde | |||
| fee7d0c679 | |||
| 777ffa09a8 | |||
| ab2482a209 | |||
| c2ca2e3dbb | |||
| 8ae73bbacb | |||
| 6a8c22c77e | |||
| 7d99a89ac7 | |||
| a5b0d5db84 | |||
| cfade2a265 | |||
| 27d0906d21 | |||
| 89d555b734 | |||
| 7430a18e66 | |||
| ceaf89f836 | |||
| a1e34e6dfa | |||
| f338c0586e | |||
| 48d1543329 | |||
| 15871be9e9 | |||
| 6fcbdb2005 | |||
| e322a400be | |||
| fda2e4e32f | |||
| e32c53ffd6 | |||
| 66171cc0bd | |||
| c1afddc8b7 | |||
| a31ac1d782 | |||
| 661e8c66ac | |||
| b473fcda22 | |||
| c32aba9902 | |||
| 90bee03b44 | |||
| d9fb46bc96 | |||
| d471714fbd | |||
| af93194f48 | |||
| f5897b46cc | |||
| de3a5310f4 | |||
| 69b9844a4b | |||
| 78149fffcd | |||
| b60086d107 | |||
| d6325b373d | |||
| 011bfcf62a | |||
| 391a35ac2c | |||
| 44f7e860aa | |||
| 86d3ae118d | |||
| 9aaf16c655 | |||
| 98f0dbdce5 | |||
| 25abd59512 | |||
| 59a23e61db | |||
| 65b86e2fbd | |||
| 2588883a1c | |||
| e7c3598dce | |||
| 0c4c409ebc | |||
| 137aeb5953 | |||
| eb5113ea30 | |||
| 1799ba28c0 | |||
| 9d054cc46f | |||
| 2e04cb0334 | |||
| 909583a1bb | |||
| 5d5b85380a | |||
| a07ac36686 | |||
| 18996a7254 | |||
| e5a32f5997 | |||
| aea02abe89 | |||
| eaa52a5c5f | |||
| 28aed55cd3 | |||
| 4456dfa9dc | |||
| 2169c58cd7 | |||
| 5fad6376bc | |||
| 0b190c3149 | |||
| 56b072c45e | |||
| a8dd5a022f | |||
| 955c97e0dd | |||
| d60f8d8484 | |||
| 6d4ef21269 | |||
| c922fd304c | |||
| ca10689bb6 | |||
| 8d4065944c | |||
| 8ad66ae4d5 | |||
| e5f40c65ec | |||
| 9321e21a3b | |||
| fb641d5aa4 | |||
| 2e96e3c766 | |||
| 8bb4371a74 | |||
| acfae96420 | |||
| ae40153252 | |||
| ac2d40b67b | |||
| d035f5410a | |||
| 35d217f6ec | |||
| 8886e7a7a2 | |||
| 28964f0a40 | |||
| b5c52c9236 | |||
| 7f92dcab51 | |||
| 677f08e936 | |||
| 45ee0c6f7d | |||
| 1c4dbfc402 | |||
| 86b7d9744e | |||
| 5e18e7cc8c | |||
| 3952847ece | |||
| 76dd13944a | |||
| bf08bb67d9 | |||
| 215cb398fd | |||
| 3c4389a43d | |||
| 4d662ebada | |||
| 8a3304fea9 | |||
| ce84a5dc58 | |||
| daf79d0d89 | |||
| 0124de8b53 | |||
| c0c1b2cea4 | |||
| fc728454d0 | |||
| 6cac56fa06 | |||
| 5e1c50edf8 | |||
| 1990b828a2 | |||
| 913be3c631 | |||
| 84a4fd7e0e | |||
| 11255db576 |
@@ -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`
|
||||
- **API macro**: `PS_AI_CONVAGENT_API`
|
||||
- **Class prefix**: `PS_AI_ConvAgent_` (e.g. `UPS_AI_ConvAgent_PostureComponent`)
|
||||
@@ -8,29 +25,71 @@
|
||||
- **UI display**: `"PS AI ConvAgent"` (categories, DisplayName)
|
||||
- **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)
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
Le tilt diagonal (ear-to-shoulder) est PLUS VISIBLE avec la neck bone chain qu'avec le mono-bone.
|
||||
|
||||
### 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
|
||||
- `AnimNode_PS_AI_ConvAgent_Posture.cpp` — Evaluate_AnyThread, section multi-bone chain
|
||||
- Commit actuel: `8df6967` sur main
|
||||
|
||||
### Config actuelle neck chain (BP_Taro)
|
||||
neck_01=0.25, neck_02=0.35, head=0.40
|
||||
|
||||
## Architecture Posture
|
||||
- **PS_AI_ConvAgent_PostureComponent** (game thread) : cascade Eyes→Head→Body, produit FQuat + eye curves
|
||||
## Architecture Posture (ConvAgent)
|
||||
- **PS_AI_ConvAgent_PostureComponent** (game thread) : cascade Eyes→Head→Body
|
||||
- **AnimNode_PS_AI_ConvAgent_Posture** (anim thread) : applique rotation sur bone(s) + injecte curves
|
||||
- Pipeline 100% FQuat (pas de round-trip FRotator)
|
||||
- Thread safety via FCriticalSection
|
||||
- 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())
|
||||
- Pipeline 100% FQuat, Thread safety via FCriticalSection
|
||||
- ARKit eye curves normalisées par range fixe (40°/35°)
|
||||
|
||||
@@ -17,69 +17,70 @@
|
||||
## Plugins
|
||||
| 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. |
|
||||
| **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. |
|
||||
| Convai (reference) | `<repo_root>/ConvAI/Convai/` | gRPC + protobuf streaming to Convai API. Used as architectural reference. |
|
||||
| **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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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)`
|
||||
- `WebSockets` is a **module** (Build.cs only) — NOT a plugin, don't put it in `.uplugin`
|
||||
- Audio capture: `Audio::FAudioCapture::OpenAudioCaptureStream()` (UE 5.3+, replaces deprecated `OpenCaptureStream`)
|
||||
- `AudioCapture` IS a plugin — declare it in `.uplugin` Plugins array
|
||||
- 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, ...)`
|
||||
- Audio capture: `Audio::FAudioCapture::OpenAudioCaptureStream()` (UE 5.3+)
|
||||
- Callback arrives on **background thread** — marshal to game thread
|
||||
- Procedural audio playback: `USoundWaveProcedural` + `OnSoundWaveProceduralUnderflow`
|
||||
- Resample mic audio to **16000 Hz mono** before sending to ElevenLabs
|
||||
- `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
|
||||
- **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
|
||||
- Fragment reassembly: accumulate into `BinaryFrameBuffer` until `BytesRemaining == 0`
|
||||
- Pong: `{"type":"pong","event_id":N}` — `event_id` is **top-level**, NOT nested
|
||||
- Transcript: type=`user_transcript`, key=`user_transcription_event`, field=`user_transcript`
|
||||
- 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
|
||||
- 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
|
||||
- `user_transcript` arrives AFTER `agent_response_started` in Server VAD mode
|
||||
- **MUST send `conversation_initiation_client_data` immediately after WS connect**
|
||||
|
||||
## API Keys / Secrets
|
||||
- ElevenLabs API key is set in **Project Settings → Plugins → ElevenLabs AI Agent** in the Editor
|
||||
- UE saves it to `DefaultEngine.ini` under `[/Script/PS_AI_Agent_ElevenLabs.ElevenLabsSettings]`
|
||||
- **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
|
||||
- ElevenLabs API key: **Project Settings → Plugins → ElevenLabs AI Agent**
|
||||
- Saved to `DefaultEngine.ini` — **stripped before every commit**
|
||||
|
||||
## Claude Memory Files in This Repo
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `.claude/MEMORY.md` | This file — project structure, patterns, status |
|
||||
| `.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/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 |
|
||||
|
||||
30
.gitattributes
vendored
Normal file
30
.gitattributes
vendored
Normal 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
5
.gitignore
vendored
@@ -5,10 +5,13 @@ Unreal/PS_AI_Agent/Intermediate/
|
||||
Unreal/PS_AI_Agent/Saved/
|
||||
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/*/Intermediate/
|
||||
|
||||
# Live Coding patch files (temporary, regenerated each session)
|
||||
*.patch_*
|
||||
|
||||
# UE5 generated solution files
|
||||
Unreal/PS_AI_Agent/*.sln
|
||||
Unreal/PS_AI_Agent/*.suo
|
||||
|
||||
65
Build_Plugin.bat
Normal file
65
Build_Plugin.bat
Normal 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%
|
||||
BIN
Docs/PS_AI_Behavior_Documentation.pdf
Normal file
BIN
Docs/PS_AI_Behavior_Documentation.pdf
Normal file
Binary file not shown.
BIN
Docs/PS_AI_Behavior_Documentation.pptx
Normal file
BIN
Docs/PS_AI_Behavior_Documentation.pptx
Normal file
Binary file not shown.
BIN
Docs/PS_AI_ConvAgent_Documentation.pdf
Normal file
BIN
Docs/PS_AI_ConvAgent_Documentation.pdf
Normal file
Binary file not shown.
BIN
Docs/PS_AI_ConvAgent_Documentation.pptx
Normal file
BIN
Docs/PS_AI_ConvAgent_Documentation.pptx
Normal file
Binary file not shown.
691
Docs/gen_behavior_doc.js
Normal file
691
Docs/gen_behavior_doc.js
Normal 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 (0–1)", "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 (0–1+)" },
|
||||
{ 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
711
Docs/gen_convagent_doc.js
Normal 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 (50–250 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 (35–65) — viseme interp", options: { bullet: true, fontSize: 13 } },
|
||||
{ text: "SpeechBlendDuration — fade in/out", options: { bullet: true, fontSize: 13 } },
|
||||
{ text: "EmotionExpressionBlend (0–1) — 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 (0–1)", 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.
BIN
PS_AI_ConvAgent_Documentation.pptx
Normal file
BIN
PS_AI_ConvAgent_Documentation.pptx
Normal file
Binary file not shown.
@@ -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 :
|
||||
@@ -1,21 +1,21 @@
|
||||
|
||||
|
||||
[/Script/EngineSettings.GameMapsSettings]
|
||||
GameDefaultMap=/Game/voidMap.voidMap
|
||||
EditorStartupMap=/Game/voidMap.voidMap
|
||||
GameDefaultMap=/PS_AI_ConvAgent/Demo_Metahuman.Demo_Metahuman
|
||||
EditorStartupMap=/PS_AI_ConvAgent/Demo_Metahuman.Demo_Metahuman
|
||||
|
||||
[/Script/Engine.RendererSettings]
|
||||
r.AllowStaticLighting=False
|
||||
|
||||
r.GenerateMeshDistanceFields=True
|
||||
|
||||
r.DynamicGlobalIlluminationMethod=1
|
||||
r.DynamicGlobalIlluminationMethod=0
|
||||
|
||||
r.ReflectionMethod=1
|
||||
r.ReflectionMethod=0
|
||||
|
||||
r.SkinCache.CompileShaders=True
|
||||
|
||||
r.RayTracing=True
|
||||
r.RayTracing=False
|
||||
|
||||
r.Shadow.Virtual.Enable=1
|
||||
|
||||
@@ -27,6 +27,8 @@ r.DefaultFeature.LocalExposure.ShadowContrastScale=0.8
|
||||
r.GPUSkin.Support16BitBoneIndex=True
|
||||
r.GPUSkin.UnlimitedBoneInfluences=True
|
||||
SkeletalMesh.UseExperimentalChunking=1
|
||||
r.Lumen.HardwareRayTracing=False
|
||||
r.Lumen.Reflections.HardwareRayTracing.Translucent.Refraction.EnableForProject=False
|
||||
|
||||
[/Script/WindowsTargetPlatform.WindowsTargetSettings]
|
||||
DefaultGraphicsRHI=DefaultGraphicsRHI_DX12
|
||||
@@ -88,7 +90,7 @@ FontDPI=72
|
||||
; ── Generic classes: ElevenLabs → PS_AI_ConvAgent ──
|
||||
+ClassRedirects=(OldName="ElevenLabsLipSyncComponent", NewName="PS_AI_ConvAgent_LipSyncComponent")
|
||||
+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="ElevenLabsLipSyncPoseMap", NewName="PS_AI_ConvAgent_LipSyncPoseMap")
|
||||
+ClassRedirects=(OldName="ElevenLabsEmotionPoseMap", NewName="PS_AI_ConvAgent_EmotionPoseMap")
|
||||
@@ -96,7 +98,7 @@ FontDPI=72
|
||||
; ── 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_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_LipSyncPoseMap", NewName="PS_AI_ConvAgent_LipSyncPoseMap")
|
||||
+ClassRedirects=(OldName="PS_AI_Agent_EmotionPoseMap", NewName="PS_AI_ConvAgent_EmotionPoseMap")
|
||||
@@ -112,18 +114,23 @@ FontDPI=72
|
||||
; ── AnimNode structs ──
|
||||
+StructRedirects=(OldName="AnimNode_ElevenLabsLipSync", NewName="AnimNode_PS_AI_ConvAgent_LipSync")
|
||||
+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_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 ──
|
||||
+ClassRedirects=(OldName="AnimGraphNode_ElevenLabsLipSync", NewName="AnimGraphNode_PS_AI_ConvAgent_LipSync")
|
||||
+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_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 ──
|
||||
+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_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]
|
||||
bEnablePlugin=True
|
||||
bAllowNetworkConnection=True
|
||||
@@ -168,5 +189,9 @@ bUseManualIPAddress=False
|
||||
ManualIPAddress=
|
||||
|
||||
[/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
|
||||
|
||||
|
||||
BIN
Unreal/PS_AI_Agent/Content/Christophe.uasset
Normal file
BIN
Unreal/PS_AI_Agent/Content/Christophe.uasset
Normal file
Binary file not shown.
BIN
Unreal/PS_AI_Agent/Content/Martin.uasset
Normal file
BIN
Unreal/PS_AI_Agent/Content/Martin.uasset
Normal file
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,10 @@
|
||||
{
|
||||
"Name": "AudioCapture",
|
||||
"Enabled": true
|
||||
},
|
||||
{
|
||||
"Name": "PS_AI_Behavior",
|
||||
"Enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -46,7 +46,7 @@ echo.
|
||||
echo Copier le dossier Builds\Windows\ sur chaque PC.
|
||||
echo Utiliser Host.bat et Join.bat pour lancer.
|
||||
echo.
|
||||
|
||||
pause
|
||||
REM Copy Host/Join scripts into the build output
|
||||
copy /Y "%~dp0Host.bat" "%OUTPUT%\Windows\"
|
||||
copy /Y "%~dp0Join.bat" "%OUTPUT%\Windows\"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
511
Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/PLAN.md
Normal file
511
Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/PLAN.md
Normal 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**
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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).");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)"));
|
||||
}
|
||||
@@ -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)"));
|
||||
}
|
||||
@@ -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(""));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(""));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
// Copyright Asterion. All Rights Reserved.
|
||||
|
||||
#include "PS_AI_Behavior_Definitions.h"
|
||||
|
||||
DEFINE_LOG_CATEGORY(LogPS_AI_Behavior);
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright Asterion. All Rights Reserved.
|
||||
|
||||
#include "PS_AI_Behavior_Settings.h"
|
||||
|
||||
UPS_AI_Behavior_Settings::UPS_AI_Behavior_Settings()
|
||||
{
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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); }
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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); }
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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); }
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"); }
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user