포스트

가구 분해·수리 미니게임 시스템 설계

새 경로를 만들지 않는 것이 설계의 첫 제약이었다

드라이버로 가구를 분해하고 렌치로 고치는 미니게임을 붙였다. 입력·운반·적재 판정이 이미 있는 프로젝트라, 설계의 대부분은 '무엇을 새로 만들지 않을까'를 정하는 일이었다.

가구 분해·수리 미니게임 시스템 설계

운반 게임에 분해·수리를 얹었다. 드라이버를 들고 가구를 보면 분해 미니게임이 열리고, 진행도가 차면 가구가 조각으로 흩어진다. 조각은 가구처럼 들고 뛰어 트럭에 싣는다. 렌치는 같은 틀로 고장 난 것을 고친다.

1인 분해 → 파츠 3개를 들고 운반 → 트럭 적재 3/3 인정까지

기능 자체는 한 줄로 설명되는데, 설계에서 시간을 쓴 곳은 기능이 아니라 경계였다. 이 프로젝트에는 입력 라우팅·아이템 능력·운반·적재 판정이 이미 있고 그 대부분이 팀원 소유 파일이다. 그래서 결정의 형태가 매번 이랬다 — “이걸 새로 만들 것인가, 있는 것에 얹을 것인가.”

결정 ① — 입력을 새로 파지 않는다

원래 스펙은 인터랙트(F) 슬롯에 태그를 확장하는 것이었다. 실측해 보니 이 프로젝트는 아이템을 든 상태의 좌클릭이 이미 아이템 능력의 Primary 슬롯으로 라우팅된다.

1
2
// ATCPlayerCharacter::Interact
if (HeldItem || HeldUsableItem) { ServerItemPrimary(); return; }

새 InputAction과 매핑 컨텍스트를 만들면 공용 입력 에셋을 건드려야 하고, 그건 팀 소유다. 그래서 칸을 신설하지 않고 기존 슬롯에 능력을 꽂았다.

도구Primary 슬롯
드라이버Ability.Item.Disassemble
렌치Ability.Item.Repair

FTCItemAbilityRow(아이템 능력 표의 행) 구조는 그대로다. 도구 하나 늘리는 데 필요한 것이 표에 행을 한 줄 넣는 일로 끝난다.

결정 ② — 미니게임 상태는 플레이어가 쥔다

진행도·스킬체크·판정을 어디에 둘지가 다음 갈림길이었다. 가구에 두면 “이 가구가 분해 중”이 자연스럽지만, 가구는 팀원 소유 클래스고 여럿이 같은 가구에 붙는 협동을 생각하면 상태가 한 액터에 몰린다.

플레이어 부착 컴포넌트(UTCMinigameComponent)로 갔다.

  • 진행도·종류·대상은 서버 권위, 복제 프로퍼티
  • 가구는 대상일 뿐 상태를 갖지 않는다 — 자격 판정과 완료 처리만 안다
  • 클라이언트는 표시와 입력만 한다

가구 쪽에 남긴 것은 인터페이스 하나다.

1
2
3
4
// ITCDisassemblable — 분해될 수 있는 것이 아는 것 셋
CanDisassemble(Instigator)   // 자격 (1인 가구 제외 등)
GetPartsRowName()            // 조각 정의를 찾을 표 행 키
OnDisassembled(Instigator)   // 완료 통지

수리 쪽에서는 인터페이스를 되레 지웠다

수리도 처음엔 같은 모양으로 ITCRepairable을 뒀다. 그런데 파손 상태(bBroken)와 알림은 어차피 어딘가에 저장돼야 하고, 인터페이스는 저장을 못 한다. 그래서 인터페이스를 구현한 액터가 상태를 들고, 인터페이스가 그걸 노출하는 이중 구조가 됐다.

인터페이스를 지우고 UTCRepairableComponent 하나로 흡수했다. 자격·상태·알림이 전부 그 안에 있다.

1
2
// 붙이면 렌치로 고칠 수 있는 대상이 된다. 파손 상태와 그 알림을 이 컴포넌트가 전부 쥔다 —
// 액터가 인터페이스를 따로 구현하는 이중 구조를 두지 않는다.

효과가 분명했다. 실제 가구도 이 컴포넌트만 붙이면 수리 대상이 된다 — 클래스 수정도, 레벨 인스턴스 재작업도 필요 없다.

두 축의 결론이 반대인 이유는 저장할 상태의 유무다. 분해는 가구가 상태를 가질 필요가 없어 인터페이스가 맞고, 수리는 상태가 대상에 붙어 있어야 해서 컴포넌트가 맞다.

진행도와 스킬체크

진행은 두 갈래로 오른다. 시간이 자동으로 밀고, 스킬체크를 맞히면 얹힌다.

1
2
3
const float Duration = FMath::Max(0.1f, ActiveRow.DurationSec);
const float Speed = 1.f + FMath::Max(0.f, ActiveRow.CoopSpeedBonus) * Crew.Num();
Progress01 = FMath::Clamp(Progress01 + DeltaTime * Speed / Duration, 0.f, 1.f);
기본
DurationSec9.0혼자서 끝까지 미는 데 걸리는 시간
CoopSpeedBonus0.6합류자 1인당 속도 가산 → 2인이면 1.6배
SkillCheckCount3진행 구간에 균등 배치되는 스킬체크 수
SkillCheckZone010.12성공존 폭 (원의 43도)
SkillCheckBonus010.06성공 시 진행도 가산
FailSetback010.15실패 시 진행도 후퇴

실패가 중단이 아니라 후퇴인 것이 이 미니게임의 성격을 정한다. 혼자 실수해도 게임이 끊기지 않고, 대신 시간이 늘어난다.

스킬체크 창은 1.2초에 바늘이 한 바퀴 도는 형태다. 이 창은 순간값이라 복제로는 못 맞춘다 — 값이 도착했을 땐 이미 창이 지나간다. 그래서 창의 시작·폭·시간은 소유 클라이언트에만 Client RPC로 보내고, 판정은 서버가 한다. 도착 시각만으로 판정하면 지연이 큰 쪽이 손해라 편도 지연 보정을 두되, 보정이 창 폭보다 커지지 않게 상한을 걸었다.

성공존의 화면 폭은 C++에 픽셀로 박지 않고 위젯이 계산한다. 기본값 0.12는 원의 43도인데 고정 26px 블록으로 그리면 원둘레의 5.5%밖에 안 돼 눈으로 겨눈 자리와 서버 판정이 어긋난다. 판 반지름을 레이아웃에서 읽어 2R·sin(π·Zone01)로 폭을 맞췄다.

협동 — 진행도 정본은 팀당 하나

같은 가구·같은 종류로 이미 돌고 있는 미니게임이 있으면 새로 시작하지 않고 그쪽에 합류한다. 합류자는 진행도를 밀지 않고 자기 스킬체크만 돌린다.

1
2
3
4
5
6
7
void UTCMinigameComponent::AddSharedProgress(float Delta)
{
    UTCMinigameComponent* Owner = Lead ? Lead.Get() : this;
    Owner->Progress01 = FMath::Clamp(Owner->Progress01 + Delta, 0.f, 1.f);
    Owner->OnProgressChanged.Broadcast(Owner->Progress01);
    Owner->PushProgressToCrew();
}

가산도 후퇴도 전부 이 함수를 지난다. 정본이 값을 쥐고, 각 기여자에게 밀어 준 값이 각자의 클라이언트로 복제된다. LeadCrew서버 전용이고 복제하지 않는다 — 클라이언트는 판정을 안 하니 팀 구성을 알 필요가 없다.

이탈 처리는 개인 사유와 세션 종료를 갈랐다. 서버가 매 틱 두 가지를 본다.

  • 도구 상실 — 시작 자격 태그(분해는 드라이버, 수리는 렌치)가 사라지면 그 사람만 빠진다. 방망이에 맞아 기절하면 아이템을 떨어뜨리므로 이 경로로 자동 처리된다.
  • 거리 이탈 — 대상 바운드 표면에서 AbortDistance(기본 250cm)를 넘으면 빠진다. 상호작용 스윕이 전방 90cm까지 닿으니 그보다 넉넉해야 제자리에서 오작동하지 않는다.

정본이 먼저 빠지면 PromoteNewLead()가 남은 사람에게 진행도와 남은 스킬체크를 넘긴다. 전체 실패가 아니라 속도 보너스만 인원 수로 다시 계산된다. 마지막 한 명이 빠질 때 비로소 취소다.

조각을 무엇으로 만들 것인가

분해 결과물(파츠)을 새 액터 계통으로 만들면 줍기·들기·놓기·복제를 다시 짜야 한다. 그래서 기존 경로부터 실측했다.

UGrabComponent::ServerTryInteractITCInteractable로 돈다. 가구 전용 분기는 2인 운반 장부와 리프트 게임필에만 걸리고, 줍기 자체의 정본은 배트·드라이버가 쓰는 ATCToolItem이었다.

그래서 파츠 액터를 ATCToolItem 파생으로 만들었다. 새 경로 0개다 — 소유자 복제와 어태치, 저작 스케일 보존, CCD, 놓기 처리가 전부 상속으로 따라온다.

함정이 하나 있었다. 파츠는 스폰된 뒤에 표가 크기를 정하는데, 부모는 컴포넌트 초기화 시점에 저작 스케일을 캐시한다. 그래서 손에 든 순간 크기가 1로 튀었다. 정의를 적용하는 자리에서 저작 스케일을 다시 잡게 해서 맞췄다.

조각을 들고 뛰지 못하던 이유

1인 가구는 들고 달릴 수 있는데 조각은 안 됐다. 허용 조건은 무게 등급도 액터 타입도 아닌 운반 정원 1이었다.

1
2
const int32 Required = Stat ? Stat->GetRequiredPlayer() : 0;
return (Required > 0) ? Required : MaxCarriers;   // MaxCarriers 기본 2

파츠에는 가구 스탯 컴포넌트가 없어 Required = 0, 정원이 기본값 2로 잡혔다. 달리기 예외에서 빠지고, 덤으로 정원 − 인원 = 1이라 인원 미달로 잡혀 미달용 걷기 속도(150)까지 걸렸다. 조각을 든 채로 일반 가구보다 느리게 걷고 있었던 셈이다.

가구 스탯 컴포넌트의 생성자 기본값이 그대로 “최경량 1인”이라, 컴포넌트 하나를 붙이는 것으로 정원·달리기·운반 속도·인원 판정이 전부 1인 가구와 같아졌다.

적재 — 조각 N개가 가구 한 채가 되게

적재 판정은 팀원 소유다. 존이 도착을 확인하고 배달 카운트를 1 올리는데, 가중치를 줄 칸이 없다. 파츠에 그 인터페이스를 달면 조각 하나가 가구 한 채로 세어져 점수가 N배가 된다.

그래서 판정을 건드리는 대신 집계를 앞에 하나 세웠다.

  • UTCPartDeliverySubsystem(월드 서브시스템)이 존의 부피만 읽어 0.25초 눈금으로 자체 판정한다
  • 분해 1회 = 1그룹, 조각마다 그룹 id와 크기를 물린다
  • 한 벌이 다 들어왔을 때만 공개 API를 1회 부른다 → 조각당 1/N
  • 판정은 콜리전 응답이 아니라 부피 포함으로 본다 — 조각 프로필이 존 트리거와 오버랩을 안 내도 인정이 새지 않는다

점수도 승계한다. 조각 한 벌은 원본 가구 한 채이므로, 분해 시점에 대상의 가격을 읽어 조각에 실어 두고 한 벌이 모였을 때 그 값으로 올린다. 이게 없으면 조각 가격이 0이라 분해가 곧 점수 손해가 된다.

실전 스테이지에서 한 번 더 갈렸다

테스트 맵에서 되던 것이 실제 스테이지에서 안 됐다. 로그가 한 줄도 없는 것이 단서였다. 커맨드렛으로 맵 액터를 실측하니 이렇게 나왔다.

1
2
L_Level2   goalzone=0  stagemanager=0
           후보: ToyTruck / BP_TruckTrigger_C / CargoZoneViz

코드에 있는 적재존 클래스가 실제 스테이지에는 배치돼 있지 않았다. 실제 판정은 블루프린트 트리거가 하고 있었고, 그 그래프는 가구 액터로 캐스트한 뒤 통과시키는 형태라 조각은 거기서 떨어졌다. 서브시스템은 코드상의 존을 0개 찾고 조용히 반환했다. 양쪽 다 아무 로그 없이 실패한 것이 로그 0건의 정체였다.

수정은 세 갈래였다.

  1. 전용 로그 채널을 신설해 단계마다 수치를 남겼다. 특히 [존없음] 적재 부피가 하나도 없다 — 감시 중 파츠 N개는 영원히 인정되지 않는다를 1회 남긴다. 조용한 실패를 없애는 것이 목적이다.
  2. 존 탐지를 실제 방식까지 넓혔다. 액터 전체 바운드가 아니라 트리거의 박스 컴포넌트 바운드를 쓴다 — 액터 바운드로 재면 트럭 모델까지 먹는다.
  3. 집계 경로도 넓혔다. 맵에 게임모드가 있으면 그쪽 공개 API를 부르고, 없으면 종전 경로를 쓴다.

이건 우회다. 트리거가 조각을 직접 인정하도록 그래프를 고치는 쪽이 오래 가는 해법이고, 그건 소유자와 합의가 되면 우회 코드를 걷어내면 된다.

표 하나로 닫은 카테고리 축

조각 구성과 난이도는 가구 종류마다 달라야 한다. 그런데 기존 가구 표에는 재질·유형 축이 없다 — 있는 것은 운반 연출용 무게 등급뿐이고, 그 표는 팀원 소유다.

축을 분해 표 안에서 닫았다. 행에 카테고리를 두고, 조각을 안 적은 행은 카테고리 행에서 물려받는다.

1
2
3
1. 가구 행에 조각 정의가 있으면 그 행        (개별 지정)
2. 없으면 그 행의 카테고리로 Category_* 행    (카테고리 폴백)
3. 그것도 없으면 C++ 폴백                     (표가 비어도 분해는 된다)
조각지속체크성공존후퇴
Category_Wood4 (널판)6.030.120.15
Category_Appliance3 (덩어리)9.040.090.20
Category_Plumbing5 (파편)5.020.160.10

가전은 오래 걸리고 성공존이 좁으며 실패 후퇴가 크다. 도기류는 짧고 넓다. 난이도가 축을 따라 붙는다.

실제 가구를 어느 축에 넣을지는 표에 행을 한 줄 추가하면 끝난다. 코드도 블루프린트도 고칠 것이 없다.

검증 — 검사 수가 자라는 것으로 남긴다

이 프로젝트에는 콘솔 한 줄로 도는 헤드리스 스모크가 있다. 기능을 얹을 때마다 검사를 같이 얹었다.

단계스모크
미니게임 골격33 → 54
파츠 액터·1인 그랩·적재 1/N54 → 63
카테고리 축63 → 69
수리 컴포넌트·파손 이벤트69 → 77

기존 기능 자동 검사 5종 371항목은 전 단계에서 무영향이었다(공전 120 · 그랩 120 · 스핀 120 · 겹침 9 · 클라부착 2).

스모크가 못 보는 것도 분명했다. 한 프레임짜리 콘솔 명령이라 틱이 도는 진행도 누적·스킬체크 창 타이밍·실패 후퇴량은 못 잰다. 적재 인정도 타이머 눈금과 존이 있는 월드가 필요해 못 본다. 그쪽은 플레이 절차를 따로 적어 실측으로 갈랐다.

검사 실행 자체에서도 함정을 하나 기록해 뒀다. 자동 검사를 -ExecCmds="<검사>, quit"로 돌리면 검사가 틱 구동이라 시작 직후 종료가 먹어 결과 줄 없이 끝난다. 종료 없이 띄우고 결과 줄을 감지한 뒤 프로세스를 내려야 한다.

결과

드라이버로 가구를 분해해 조각을 만들고, 조각을 들고 뛰어 트럭에 실으면 한 벌이 가구 한 채로 인정된다. 렌치는 같은 틀로 고친다. 새로 만든 액터 계통은 조각 하나뿐이고 그마저 기존 아이템 골격의 파생이다.

핵심 요약 — 이미 도는 프로젝트에 기능을 얹을 때 설계의 절반은 무엇을 새로 만들지 않을지를 정하는 일이다. 판정 기준은 두 가지였다. 첫째, 저장할 상태가 있으면 컴포넌트, 없으면 인터페이스 — 분해는 인터페이스로 두고 수리는 인터페이스를 지워 컴포넌트로 합친 이유가 이것이다. 둘째, 남의 판정을 바꿀 수 없으면 판정 앞에 집계를 세운다 — 조각 N개를 가구 한 채로 만드는 데 필요한 것은 그쪽 규칙 수정이 아니라 한 벌이 모일 때까지 세는 층 하나였다. 그리고 조용한 실패는 반드시 로그로 깨라. 존을 못 찾아 조용히 반환하던 코드 때문에 “안 도는 것”과 “돌지만 대상이 없는 것”이 구분되지 않았다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.