AI 비헤이비어 트리 액션 및 컨디셔널 구현

RestaurantOrderAvailable, StartRestaurantOrder, MoveToInteractionTarget
This commit is contained in:
Jeonghyeon Ha 2025-08-21 19:12:06 +09:00
parent 8f56d99bf7
commit 4ef63ec9a9
25 changed files with 476 additions and 30 deletions

Binary file not shown.

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c31792c3491f44878a9a5e8ee59504cf
timeCreated: 1755770768

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 49c654aa3aa94cb9928e2d161cad789a
timeCreated: 1755770768

View File

@ -0,0 +1,15 @@
using UnityEngine;
namespace DDD
{
/// <summary>
/// 공용 AI 블랙보드 인터페이스.
/// - 다양한 캐릭터 AI에서 공통으로 참조하는 현재 인터랙션 타겟만 정의합니다.
/// - 필요 시 키-값 확장을 고려하되, 현재는 최소 요구만 충족합니다.
/// </summary>
public interface IAISharedBlackboard
{
void SetCurrentInteractionTarget(GameObject targetGameObject);
GameObject GetCurrentInteractionTarget();
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 62510fba6cb047419ca463dc523ae536
timeCreated: 1755770768

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: cfc8e456b2134c4a87b9fcd0d385cf1d
timeCreated: 1755767029

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ab824a41c52d4cca8cafb1fc96d5d8e7
timeCreated: 1755769401

View File

@ -0,0 +1,165 @@
using System;
using Opsive.BehaviorDesigner.Runtime.Tasks;
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
using UnityEngine;
namespace DDD
{
/// <summary>
/// IAiMovement를 이용해 인터랙션 타겟(주로 RestaurantInteractionComponent가 붙은 오브젝트)으로 이동하는 액션.
/// - 타겟은 우선 블랙보드(IRestaurantCustomerBlackboard)에서 가져오고, 없으면 Interactor의 FocusedInteractable에서 가져옵니다.
/// - 타겟에 RestaurantInteractionComponent가 있다면 가장 가까운 InteractionPoint를 목적지로 사용합니다.
/// </summary>
public class MoveToInteractionTarget : Opsive.BehaviorDesigner.Runtime.Tasks.Actions.Action
{
[Header("Target")]
[Tooltip("타겟에 RestaurantInteractionComponent가 있을 때 InteractionPoints를 사용해 가장 가까운 지점으로 이동합니다.")]
[SerializeField] private bool _useInteractionPoints = true;
[Tooltip("타겟이 없을 때 즉시 실패할지 여부")]
[SerializeField] private bool _failIfNoTarget = true;
[Header("Movement")]
[Tooltip("목적지에 도달했다고 간주할 거리")]
[SerializeField] private float _stoppingDistance = 0.5f;
[Tooltip("이동 중 목적지를 재계산하는 주기(초). 0 이하면 재계산하지 않음")]
[SerializeField] private float _repathInterval = 0.5f;
private IAiMovement _movement;
private float _repathTimer;
private Vector3 _currentDestination;
private bool _started;
private GameObject _resolvedTarget;
public override void OnStart()
{
_movement = gameObject.GetComponentInParent<IAiMovement>();
_repathTimer = 0f;
_started = false;
_resolvedTarget = ResolveTargetFromContext();
}
public override TaskStatus OnUpdate()
{
if (_movement == null)
{
return TaskStatus.Failure;
}
// 매 프레임 타겟이 갱신될 수 있으므로 재해결 (필요 시 비용 줄일 수 있음)
_resolvedTarget = _resolvedTarget ?? ResolveTargetFromContext();
if (_resolvedTarget == null)
{
if (_failIfNoTarget) return TaskStatus.Failure;
return TaskStatus.Success;
}
// 타겟이 파괴되었는지 확인
if (_resolvedTarget == null || (_resolvedTarget as UnityEngine.Object) == null)
{
return TaskStatus.Failure;
}
// 목적지 계산 및 이동 시작/유지
_repathTimer -= Time.deltaTime;
if (!_started || _repathInterval > 0f && _repathTimer <= 0f)
{
_currentDestination = GetBestDestination(_resolvedTarget);
_repathTimer = _repathInterval;
if (!_started)
{
if (!_movement.TryMoveToPosition(_currentDestination))
{
return TaskStatus.Failure;
}
_movement.EnableMove();
_movement.PlayMove();
_started = true;
}
else
{
// 이동 중 목적지 갱신 시도
_movement.TryMoveToPosition(_currentDestination);
}
}
// 도달 판정
var sqrDist = (GetAgentPosition() - _currentDestination).sqrMagnitude;
if (sqrDist <= _stoppingDistance * _stoppingDistance || _movement.HasReachedDestination())
{
_movement.StopMove();
_movement.DisableMove();
return TaskStatus.Success;
}
return TaskStatus.Running;
}
public override void OnEnd()
{
// 액션 종료 시 이동 중지(안전장치)
if (_movement != null)
{
_movement.StopMove();
_movement.DisableMove();
}
_resolvedTarget = null;
}
private GameObject ResolveTargetFromContext()
{
// 1) 공용 블랙보드에서 가져오기
var sharedBlackboard = gameObject.GetComponentInParent<IAISharedBlackboard>();
var bbTarget = sharedBlackboard?.GetCurrentInteractionTarget();
if (bbTarget != null) return bbTarget;
// 2) Interactor의 포커싱 대상에서 가져오기
var interactor = gameObject.GetComponentInParent<IInteractor>();
var focused = interactor?.GetFocusedInteractable();
if (focused != null)
{
return focused.GetInteractableGameObject();
}
return null;
}
private Vector3 GetAgentPosition()
{
return _movement != null ? _movement.CurrentPosition : transform.position;
}
private Vector3 GetBestDestination(GameObject target)
{
if (!_useInteractionPoints)
{
return target.transform.position;
}
if (target.TryGetComponent<RestaurantInteractionComponent>(out var ric))
{
var points = ric.GetInteractionPoints();
if (points != null && points.Length > 0)
{
var from = GetAgentPosition();
float bestSqr = float.MaxValue;
Vector3 best = target.transform.position;
for (int i = 0; i < points.Length; i++)
{
var p = points[i];
var sqr = (p - from).sqrMagnitude;
if (sqr < bestSqr)
{
bestSqr = sqr;
best = p;
}
}
return best;
}
}
return target.transform.position;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d97cd08353334cb698807d4b526d01b6
timeCreated: 1755769413

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6f1cc55df1da4604a2a7f445527710de
timeCreated: 1755765247

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6777cf4d7cf4408fafe4bc9097c32b01
timeCreated: 1755767888

View File

@ -0,0 +1,60 @@
using Opsive.BehaviorDesigner.Runtime.Tasks;
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
using UnityEngine;
namespace DDD
{
public class StartRestaurantOrder : Action
{
[Tooltip("상호작용할 RestaurantOrderType")]
[SerializeField] private RestaurantOrderType _targetOrderType = RestaurantOrderType.Wait;
[Tooltip("실제 상호작용 가능 여부를 확인하고 수행합니다")]
[SerializeField] private bool _requireCanInteract = true;
[Tooltip("성공 시 블랙보드에 현재 인터랙션 대상을 등록합니다")]
[SerializeField] private bool _registerOnBlackboard = true;
public override TaskStatus OnUpdate()
{
// TODO : 아래 타겟 찾기가 RestaurantOrderAvailable과 동일해야 함, 동일하면 중복될 필요 없으니 스태틱 유틸 함수정도로 만들어서 공유하기.
// 레스토랑 주문 인터랙션 후보를 가져옴
TaskStatus targetSearchSuccess = RestaurantOrderAvailable.FindAvailableOrderInteractable(_requireCanInteract, _targetOrderType, out var
outInteractable);
if (targetSearchSuccess == TaskStatus.Failure)
{
return TaskStatus.Failure;
}
// TODO : 아래 상호작용 수행 로직이 우리 프로젝트의 권장하는 방식이 아님. 플레이어가 오브젝트에 인터랙션하는 것과 비슷한 흐름으로 NPC가 오브젝트에 인터랙션하게 만들 것.
// 상호작용 수행: 액션이 붙은 에이전트를 Interactor로 사용
var interactor = gameObject.GetComponentInParent<IInteractor>();
if (interactor == null)
{
return TaskStatus.Failure;
}
var interacted = outInteractable.OnInteracted(interactor);
if (!interacted)
{
return TaskStatus.Failure;
}
if (_registerOnBlackboard)
{
// 공용 블랙보드 우선
var shared = gameObject.GetComponentInParent<IAISharedBlackboard>();
if (shared != null)
{
shared.SetCurrentInteractionTarget(outInteractable.gameObject);
}
else
{
// 하위 호환: 고객 전용 블랙보드 지원
var customerBb = gameObject.GetComponentInParent<IRestaurantCustomerBlackboard>();
customerBb?.SetCurrentInteractionTarget(outInteractable.gameObject);
}
}
return TaskStatus.Success;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 84b6c26acd1e41afa6b07ed4c6caf860
timeCreated: 1755767930

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c4f20081a3974c08b60b9efdbe1568a7
timeCreated: 1755765256

View File

@ -0,0 +1,73 @@
using Opsive.BehaviorDesigner.Runtime.Tasks;
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
using UnityEngine;
using DDD;
namespace DDD
{
public class RestaurantOrderAvailable : Conditional
{
[Tooltip("검사할 RestaurantOrderType")]
[SerializeField] private RestaurantOrderType _targetOrderType = RestaurantOrderType.Wait;
[Tooltip("인터랙션 가능 여부까지 확인할지 선택")]
[SerializeField] private bool _checkCanInteract = true;
public RestaurantOrderType TargetOrderType
{
get => _targetOrderType;
set => _targetOrderType = value;
}
public bool CheckCanInteract
{
get => _checkCanInteract;
set => _checkCanInteract = value;
}
public override TaskStatus OnUpdate()
{
TaskStatus targetSearchSuccess = FindAvailableOrderInteractable(_checkCanInteract, _targetOrderType, out var
outInteractable);
return targetSearchSuccess;
}
public static TaskStatus FindAvailableOrderInteractable(bool checkCanInteract, RestaurantOrderType targetOrderType, out RestaurantInteractionComponent outInteractable)
{
outInteractable = null;
var environmentState = RestaurantState.Instance?.EnvironmentState;
if (environmentState == null)
{
return TaskStatus.Failure;
}
var interactables = environmentState.GetInteractablesByType(InteractionType.RestaurantOrder);
foreach (var interactable in interactables)
{
// 서브시스템에서 RestaurantOrderType을 가져와 비교
outInteractable = interactable as RestaurantInteractionComponent;
if (outInteractable == null) continue;
if (!outInteractable.TryGetSubsystemObject<RestaurantOrderType>(out var subsystem)) continue;
if (subsystem.GetInteractionSubsystemType() == targetOrderType)
{
// CheckCanInteract이 false면 타입만 맞으면 성공
if (!checkCanInteract)
{
return TaskStatus.Success;
}
// CheckCanInteract이 true면 실제 인터랙션 가능 여부까지 확인
if (interactable.CanInteract())
{
return TaskStatus.Success;
}
}
}
return TaskStatus.Failure;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: bef354fd7cae4273b4cb4e7ff9e270f5
timeCreated: 1755765519

View File

@ -0,0 +1,39 @@
using Opsive.BehaviorDesigner.Runtime;
using UnityEngine;
namespace DDD
{
public class RestaurantCustomerBlackboardComponent : MonoBehaviour, IRestaurantCustomerBlackboard, IAISharedBlackboard
{
private Subtree _subtree;
private GameObject _currentInteractionTarget;
public void InitializeWithBehaviorTree(Subtree subtree)
{
_subtree = subtree;
if (_subtree != null)
{
_subtree.SetVariableValue(nameof(RestaurantCustomerBlackboardKey.SelfGameObject), gameObject);
}
}
public void SetCustomerData(CustomerData inCustomerData)
{
if (_subtree == null) return;
_subtree.SetVariableValue(nameof(RestaurantCustomerBlackboardKey.CustomerData), inCustomerData);
}
public void SetCurrentInteractionTarget(GameObject targetGameObject)
{
_currentInteractionTarget = targetGameObject;
if (_subtree == null) return;
_subtree.SetVariableValue(nameof(RestaurantCustomerBlackboardKey.CurrentInteractionTarget), targetGameObject);
}
public GameObject GetCurrentInteractionTarget()
{
// 캐시 우선 반환. 필요 시 Subtree에서 직접 조회하도록 확장 가능.
return _currentInteractionTarget;
}
}
}

View File

@ -1,21 +0,0 @@
using Opsive.BehaviorDesigner.Runtime;
using UnityEngine;
namespace DDD
{
public class RestaurantCustomerBlackboardComponent : MonoBehaviour, IRestaurantCustomerBlackboard
{
private Subtree _subtree;
public void InitializeWithBehaviorTree(Subtree subtree)
{
_subtree = subtree;
_subtree.SetVariableValue(nameof(RestaurantCustomerBlackboardKey.SelfGameObject), gameObject);
}
public void SetCustomerData(CustomerData inCustomerData)
{
_subtree.SetVariableValue(nameof(RestaurantCustomerBlackboardKey.CustomerData), inCustomerData);;
}
}
}

View File

@ -1,13 +1,18 @@
using UnityEngine;
namespace DDD
{
public enum RestaurantCustomerBlackboardKey
{
SelfGameObject,
CustomerData,
CurrentInteractionTarget,
}
public interface IRestaurantCustomerBlackboard
{
void SetCustomerData(CustomerData inCustomerData);
void SetCurrentInteractionTarget(GameObject targetGameObject);
GameObject GetCurrentInteractionTarget();
}
}

View File

@ -3,7 +3,6 @@
namespace DDD
{
[Flags]
public enum RestaurantOrderType : uint
{
Wait = 0u,
@ -35,11 +34,9 @@ public bool CanInteract()
public bool OnInteracted(IInteractor interactor, ScriptableObject payloadSo = null)
{
if (GetInteractionSubsystemType() == RestaurantOrderType.Wait)
{
// DO WAIT CUSTOMER
}
// 간단한 상태 전이: 현재 상태에서 다음 상태로 이동
var prev = currentRestaurantOrderType;
currentRestaurantOrderType = GetNextState(prev);
return true;
}
@ -52,5 +49,19 @@ public RestaurantOrderType GetInteractionSubsystemType()
{
return currentRestaurantOrderType;
}
private RestaurantOrderType GetNextState(RestaurantOrderType state)
{
switch (state)
{
case RestaurantOrderType.Wait: return RestaurantOrderType.Reserved;
case RestaurantOrderType.Reserved: return RestaurantOrderType.Order;
case RestaurantOrderType.Order: return RestaurantOrderType.Serve;
case RestaurantOrderType.Serve: return RestaurantOrderType.Busy;
case RestaurantOrderType.Busy: return RestaurantOrderType.Dirty;
case RestaurantOrderType.Dirty: return RestaurantOrderType.Wait;
default: return RestaurantOrderType.Wait;
}
}
}
}

View File

@ -30,14 +30,31 @@ public class RestaurantInteractionComponent : MonoBehaviour, IInteractable, IInt
private Dictionary<InteractionType, IInteractionSubsystemObject> _subsystems = new();
private void Start()
private void OnEnable()
{
// Register this interactable to environment state
var environmentState = RestaurantState.Instance?.EnvironmentState;
environmentState?.RegisterInteractable(this);
if (autoInitialize)
{
InitializeInteraction(_interactionType);
}
}
private void OnDisable()
{
var environmentState = RestaurantState.Instance?.EnvironmentState;
environmentState?.UnregisterInteractable(this);
}
private void Start()
{
// 보수적으로 Start에서도 등록 시도 (OnEnable 시점에 EnvironmentState가 없었을 경우 대비)
var environmentState = RestaurantState.Instance?.EnvironmentState;
environmentState?.RegisterInteractable(this);
}
private static IEnumerable GetAllInteractionTypes()
{
return System.Enum.GetValues(typeof(InteractionType))

View File

@ -21,5 +21,54 @@ public class RestaurantEnvironmentState : ScriptableObject
{
public List<RestaurantPropLocation> Props = new List<RestaurantPropLocation>();
public List<RestaurantPropLocation> Objects = new List<RestaurantPropLocation>();
// 인터랙션 가능한 객체(IInteractable)를 관리하기 위한 리스트 (런타임 전용)
private readonly List<IInteractable> _registeredInteractables = new List<IInteractable>();
/// <summary>
/// 인터랙션 가능한 객체를 등록합니다
/// </summary>
public void RegisterInteractable(IInteractable interactable)
{
if (interactable == null) return;
if (_registeredInteractables.Contains(interactable)) return;
_registeredInteractables.Add(interactable);
}
/// <summary>
/// 인터랙션 가능한 객체를 해제합니다
/// </summary>
public void UnregisterInteractable(IInteractable interactable)
{
if (interactable == null) return;
_registeredInteractables.Remove(interactable);
}
/// <summary>
/// 특정 InteractionType에 해당하는 인터랙션 객체들을 반환합니다
/// </summary>
public List<IInteractable> GetInteractablesByType(InteractionType interactionType)
{
var result = new List<IInteractable>();
// null 또는 Destroyed 오브젝트 정리
_registeredInteractables.RemoveAll(item => item == null || (item as UnityEngine.Object) == null);
foreach (var interactable in _registeredInteractables)
{
if (interactable.GetInteractionType() == interactionType)
{
result.Add(interactable);
}
}
return result;
}
/// <summary>
/// 모든 등록된 인터랙션 객체들을 반환합니다
/// </summary>
public List<IInteractable> GetAllInteractables()
{
_registeredInteractables.RemoveAll(item => item == null || (item as UnityEngine.Object) == null);
return new List<IInteractable>(_registeredInteractables);
}
}
}