Post

2026-08-04 TIL (111일차)

2026-08-04 TIL (111일차)

편의점 시뮬레이션 손님 AI — 클래스 6개 역할 분리 정리

손님 파트 구현을 마치고 클래스별 점검 문서를 작성했습니다. 개별 변수·함수 사전이 아니라 “왜 이렇게 쪼갰는가”“손님 한 명이 어떤 순서로 이 시스템을 통과하는가” 를 중심으로 정리합니다.


1. 전체 구조 한눈에

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CSCustomerSpawner (레벨 배치)
   │  타이머로 생성 + ExitLocation 주입
   ▼
CSCustomerCharacter ─── 몸 (이동 물리, 컴포넌트 보유)
   │                        │
   │ AutoPossessAI          └── CSCustomerStateComponent ─ 개인 상태
   ▼
CSCustomerAIController ─ 머리 (StateTreeAIComponent 그릇)
   │
   │ ST_Customer 태스크/조건이 호출
   ▼
CSStoreCustomerSubsystem ─ 중개자 (공유 상태)
   │                              │
   ├── ACSShelfActor (팀원 클래스, 직접 참조)
   └── ICSCustomerCounterInterface ─ 계약 (계산대 구현체 교체 가능)
클래스역할갖고 있는 것
ACSCustomerCharacter — 판단은 안 하고 실행될 몸통상태 컴포넌트, 이동 세팅
ACSCustomerAIController머리 — StateTree 실행 컴포넌트를 붙인 그릇UStateTreeAIComponent
UCSCustomerStateComponent개인 수첩고른 상품, 방문 기록, 계산 완료, 퇴장 좌표
UCSStoreCustomerSubsystem중개자 — 세상과의 모든 접촉 경유지진열대 목록, 계산대, 대기열
ACSCustomerSpawner발생기 — 언제/얼마나/어디서/어디로만 담당스폰 설정, 생존 손님 장부
ICSCustomerCounterInterface계약 — 정체가 아니라 약속함수 선언 1개

앞의 5개는 “구체적인 하나의 정체”인데, 인터페이스만 성격이 다릅니다. 정체가 아니라 “계산대이려면 최소한 이건 있어야 한다”는 규칙만 정의하고, 실제 구현은 전혀 모릅니다.


2. 데이터 소유 경계 — 판단 공식 하나

데이터어디에이유
진열대 목록, 계산대, 대기열서브시스템모든 손님이 공유
고른 상품, 방문 기록, 계산 완료, 퇴장 지점StateComponent손님마다 다름
시간, 단계, bCanSpawnCustomersCSStoreGameState (팀원)가게 전체 상태
상품 가격, 재고 수치, 자금, 평점아직 없음 (편의점 파트)그쪽 소유

판단 공식: “손님 A와 손님 B가 같은 값을 봐야 하나?” → 예면 서브시스템, 아니면 컴포넌트.

이 경계 덕분에 서브시스템 함수 시그니처의 모양이 정해집니다. 서브시스템은 손님 목록을 갖고 있지 않으므로, “이 손님이 무엇을 봤는지” 를 알려면 호출할 때 방문 기록을 파라미터로 넘겨야 합니다. (HasUnvisitedShelf(VisitedShelves))


3. 손님 한 명의 일생

시작 — 월드가 열릴 때

1
2
레벨 로드 → 서브시스템 자동 생성(레벨 배치 불필요) → OnWorldBeginPlay()
          → RefreshShelves(): TActorIterator<ACSShelfActor>로 레벨 전체를 훑어 수집

진열대는 서브시스템이 찾아나서고, 계산대는 자기가 등록(RegisterCounter)합니다. 비대칭인 이유:

대상방식근거
진열대서브시스템이 탐색개수가 많고, 팀원 코드에 등록 코드를 요구하지 않기 위해
계산대스스로 등록하나뿐이고, 인터페이스 구현 여부를 검사해 걸러야

① 어디로 갈지 정할 때 (Shopping 진입)

1
2
3
4
5
조건 HasUnvisitedShelf → Subsystem.HasUnvisitedShelf(손님의 VisitedShelves)
통과 → Task PickNextShelf
        → Subsystem.PickRandomUnvisitedShelf(VisitedShelves)   // 없으면 nullptr
        → Subsystem.GetShelfBrowseLocation(진열대, 120)          // 진열대 위치 + 정면 × 120
        → Output으로 내보냄 (자식 상태가 바인딩)

② 진열대 앞에서 (Browse) — 이 시스템의 핵심 구간

1
2
3
4
5
6
7
8
9
10
1.5초 대기
 → 손님의 MarkVisited(진열대)          ← 개인 데이터라 서브시스템 안 거침
 → Subsystem.EvaluatePurchase(손님, 진열대)
     → HasShelfStock(): Attach된 ACSProductPickupActor 수집 → 하나라도 있으면 true
     → 재고 있으면 50% 랜덤            ← 소비심리 파트가 교체할 지점
 → true면 Subsystem.TryTakeOneProduct(진열대, 손님)
     → 랜덤 1개 선택 → ProductId 미리 확보
     → ICSShelfPlaceable::RemovePlacedItem()   ★ 진열대 슬롯 비우기
     → 상품 액터 Destroy() → OnItemTaken 방송 → ProductId 반환
 → 손님의 장바구니에 추가              ← 다시 개인 데이터

⚠️ RemovePlacedItem이 중요한 이유: 플레이어가 진열된 물건을 집을 때와 똑같은 경로입니다. 이걸 빠뜨리면 상품은 사라졌는데 진열대는 “그 슬롯 아직 차 있음” 으로 착각해서 그 자리에 다시 진열이 안 되는 버그가 납니다.

③ 계산 줄에 설 때 (Checkout)

1
2
3
4
5
6
7
8
Task WaitInQueue 진입
 → Subsystem.JoinQueue(손님) → Queue 맨 뒤 추가, 순번 반환
 → Subsystem.GetQueueSlotLocation(순번) → Counter를 인터페이스로 캐스팅 → 좌표
 → 그 좌표로 이동

매 틱:
 → bCheckoutDone == true  → 상태 성공 종료 (Leave로)
 → 아니면 GetQueueIndex(손님)로 순번 재확인 → 바뀌었으면 새 좌표로 이동

줄이 당겨지는 로직이 따로 없다는 게 포인트입니다. Queue는 그냥 TArray이고 RemoveAt(0) 하면 나머지가 자동으로 한 칸 당겨집니다. 각 손님이 매 틱 자기 번호만 확인하니, 결과적으로 줄이 움직입니다. 자료구조의 성질에 로직을 위임한 사례.

④ 계산 완료

1
2
3
4
Subsystem.CompleteFrontCheckout()   (지금: 목 계산대 2초 뒤 / 나중: 플레이어 상호작용)
 → Queue[0] 제거          ← 이 순간 뒷줄이 당겨짐
 → 그 손님의 bCheckoutDone = true
 → OnCheckoutCompleted 방송  ← 매출 집계 파트가 구독

손님은 자기가 계산됐다는 걸 모릅니다. 서브시스템이 대신 플래그를 켜고, 손님은 자기 플래그만 확인합니다. 계산대가 손님 객체를 직접 만지지 않게 하려는 구조입니다.

⑤ 퇴장

1
2
Task LeaveAndDespawn → ExitLocation으로 이동 (스포너가 스폰 때 주입)
 → 도착하면 Destroy() → Character::Destroyed()가 AIController도 정리

퇴장은 서브시스템을 안 거칩니다. 이동 목표가 개인 데이터라서입니다.


4. 설계 원칙 4가지

원칙내용얻는 것
모든 접촉은 서브시스템 경유손님 태스크는 진열대·상품 액터를 직접 만지지 않고 물어보고 결과만 받는다팀원 코드가 바뀌어도 고칠 곳이 한 파일로 갇힌다
최소 계약 원칙인터페이스에 GetQueueSlotLocation 하나만 넣음결제·스캔 로직은 손님이 알 필요 없음 → 구현체 교체 자유
코드는 그릇, 내용물은 에셋어떤 StateTree를 돌릴지는 C++에 없고 BP 설정값상태 구조 변경이 컴파일 없이 가능
개인 데이터는 넘겨받는다서브시스템이 손님 목록을 갖지 않음소유권이 흐려지지 않음

최소 계약이 왜 중요한가: 손님 AI가 계산대에 대해 알아야 할 건 “내가 몇 번째로 서면 어디 서 있어야 하는가” 뿐입니다. 그래서 나중에 플레이어 파트가 진짜 계산대를 만들 때 손님 코드를 한 줄도 안 고치고 인터페이스만 구현하면 연동됩니다.


5. 클래스별로 짚어둘 것

ACSCustomerCharacterDestroyed()의 순서가 중요

1
2
3
① Super::Destroyed() 호출 전에 GetController()를 변수에 캐시
② Super::Destroyed() 실행   ← 이 시점에 Possess 관계가 끊어짐
③ 캐시해둔 컨트롤러를 Destroy()

Super 이후에는 GetController()가 유효한 값을 반환하지 않을 수 있습니다. 이 순서를 안 지키면 LeaveAndDespawnDestroy()를 호출할 때마다 AIController가 고아 상태로 레벨에 계속 쌓이는 누수가 생깁니다.

그 외 생성자 세팅: bCanEverTick = false(판단은 전부 StateTree 태스크에서), bUseControllerRotationYaw = false + bOrientRotationToMovement = true(걸어가는 방향을 보고 걷게), RotationRate = (0, 360, 0)(사실상 즉시 회전).

ACSCustomerAIController — 로직이 거의 없는 게 의도

생성자가 StateTreeAI 컴포넌트를 하나 만드는 게 전부입니다. 어떤 트리를 돌릴지언제 시작할지(Start Logic Automatically)는 BP_CSCustomerAIController의 에디터 세팅에 있습니다. GetStateTreeAI()는 현재 호출하는 곳이 없고, 나중에 “현재 Shopping 상태인 손님 수” 같은 관측·치트 기능을 위해 열어둔 훅입니다.

UCSCustomerStateComponent — 필드 추가 기준

VisitedShelves의 원소 타입이 AActor*인 이유는, 진열대 종류가 늘어나도(냉장고 등) 손님 코드를 안 바꾸기 위해 일부러 넓혀둔 것입니다. 지금은 사실상 전부 ACSShelfActor입니다.

ACSCustomerSpawner — 스폰 실패로 조용히 사라지지 않게

SpawnCollisionHandlingOverride = AdjustIfPossibleButAlwaysSpawn으로 스폰 지점이 겹쳐도 밀어내서라도 생성합니다. 매 스폰 시도마다 SpawnedCustomers를 뒤에서부터 훑어 IsValid()가 false인 항목을 제거한 뒤 상한과 비교하는 장부 청소가 들어 있습니다.

ICSCustomerCounterInterface — U/I 쌍과 빈 .cpp

  • UE 인터페이스는 항상 U + I 쌍으로 만듭니다. U 쪽은 리플렉션 시스템용 표지일 뿐이고 실제 함수 선언·구현은 전부 I 쪽에 들어갑니다.
  • meta = (CannotImplementInterfaceInBlueprint)로 BP 구현 경로를 차단했습니다. 계산대는 C++로 만들어질 것을 전제한 설정입니다.
  • CSCustomerInterfaces.cppinclude 한 줄뿐입니다. UINTERFACE 헤더는 UHT가 만든 .generated.h가 어떤 .cpp엔가 include되어 있어야 링커가 처리하기 때문입니다. 엔진 소스의 StateTreeConditionBase.cpp도 같은 패턴입니다.

오늘의 정리

배운 것내용
소유권 경계가 곧 함수 시그니처“공유 데이터인가”를 먼저 정하면, 서브시스템이 개인 데이터를 파라미터로 받아야 한다는 결론이 자동으로 나온다
중개자 하나를 고집한 보상앞으로 예상되는 변경 5개가 전부 서브시스템 내부에 갇힌다. 태스크·Character·Spawner는 수정 불필요
최소 계약이 교체 가능성을 만든다인터페이스에 함수 하나만 두니 목 계산대 → 실제 계산대 교체가 손님 코드 수정 0줄
자료구조에 로직을 위임대기열 당기기를 TArray::RemoveAt(0) + 각자 순번 재확인으로 해결. 별도 로직 없음
조용한 실패는 문서로 막는다Start Logic Automatically, GameMode Override처럼 컴파일 에러 없이 실패하는 지점은 미리 목록화
소멸 순서는 명시적으로Super::Destroyed() 전에 컨트롤러를 캐시. RAII가 없는 엔진 객체는 해제 순서를 직접 설계해야 한다
This post is licensed under CC BY 4.0 by the author.