· 9 min readOOP

OOP로 이해하는 포켓몬 배틀 시스템(7)

#pokemon-oop

image

Battle Loop

지난 글에서는 Trigger를 봤다. 어떤 일이 일어났을 때 그 사실을 보고 특성이나 도구, 전장 규칙이 어떻게 반응하는지를 살펴봤다.

이제 마지막 질문이 남는다.

이 모든 것들이 실제 한 턴 안에서 어떤 순서로 돌아갈까?

지금까지 시리즈에서는 Condition, Effect, Event, Number, Trigger를 따로따로 떼어 봤다. 각각은 따로 놓고 보면 충분히 납득된다. 문제는 실제 배틀에서는 이 요소들이 따로 떨어져 움직이지 않는다는 점이다.

한 턴이 시작되면 입력을 받고, 행동 순서를 정하고, 실행 가능 여부를 확인하고, 상태를 바꾸고, 이벤트를 남기고, 반응을 처리한 뒤, 턴 종료 효과까지 이어진다.

왜 Battle Loop가 필요한가

flowchart TB
    A["Turn 시작"] --> B["플레이어 입력 수집"]
    B --> C["Attempt 만들기"]
    C --> D["행동 순서 결정"]
    D --> E["Attempt 하나 실행"]
    E --> F["Condition 확인"]
    F --> G["Effect 실행"]
    G --> H["Event 기록"]
    H --> I["Trigger 처리"]
    I --> J["다음 Attempt 또는 턴 종료"]
    J --> K["턴 종료 효과"]
    K --> L["다음 Turn"]

처음 구현할 때는 공격 하나를 함수로 만들고 그 안에서 일어나는 일을 차례대로 적고 싶을 수 있다.

"기술 선택", "명중 판정", "데미지 계산", "상태이상", "특성 반응", "도구 반응", "기절 판정", "턴 종료 처리"를 한 함수 안에 전부 밀어 넣는 식이다.

그런데 그렇게 가면 기술 하나를 처리하는 로직이 턴 전체 흐름, 반응 순서, 로그 타이밍, 교체 규칙까지 전부 알아야 하기 때문에 함수 하나가 너무 많은 책임을 갖게 된다.

반대로 배틀을 루프로 보면 각 조각이 들어갈 자리가 또렷해진다. Condition은 실행 전에, Effect는 실행 중에, Event는 실행 뒤에, Trigger는 그 다음 반응 단계에 들어간다. Number는 그 사이사이에서 필요한 값을 계산한다.

Battle Loop는 새로운 규칙이 아니라 지금까지 본 규칙들이 실제로 어떤 순서로 돌아가는지 보여주는 흐름이다.

입력은 Attempt로 받는다

프레셔 특성같이 시작 직후 바로 발동되는 경우를 제외하면 배틀은 보통 플레이어 입력부터 시작한다. 플레이어 입장에서는 그저 기술을 선택하거나 포켓몬 교체를 고르는데 시스템 입장에서는 그 선택을 곧바로 실행하지 않는다. 먼저 "이번 턴에 시도할 행동"으로 받아 놓는다.

이 시점에서 1편의 MoveAttempt가 다시 등장한다.

flowchart LR
    A["플레이어 입력"] --> B["Move 선택"]
    B --> C["Attempt 생성"]
    C --> D["실행 대기열에 추가"]

이 구조가 필요한 이유는, 배틀이 입력을 받자마자 바로 실행되는 게임이 아니기 때문이다. 두 포켓몬의 선택을 모은 뒤 누가 먼저 행동하는지 정하고, 그 순서에 따라 하나씩 처리해야 한다.

그래서 Attempt는 "지금 당장 실행된 행동"이라기보다 "곧 실행될 행동"을 의미한다.

행동 순서 정하기

입력을 모았다면 다음은 순서다.

포켓몬 배틀은 같은 턴 안에서도 누가 먼저 움직이느냐가 중요하다. 우선도, 스피드, 특정 전장 규칙이 모두 여기에 얽힌다. 세부 공식까지 깊게 들어가지는 않겠지만 최소한 배틀 루프 안에서 순서 결정이 어디에 들어가는지는 보는 게 좋다.

flowchart LR
    A["Attempt 목록"] --> B["우선도 비교"]
    B --> C["스피드 비교"]
    C --> D["실행 순서 확정"]

이걸 따로 떼어 둬야 기술 효과와 순서 결정을 섞지 않을 수 있다.

예를 들어 전광석화가 먼저 나가는 이유는 데미지 공식 때문이 아니라 선제공격이 이뤄진다는 우선도 규칙 때문이다. 둘 다 전광석화를 썼다면 기존 규칙으로 돌아가 스피드 높은 개체가 선공인 점도 여기서 쉽게 이해할 수 있다.

Attempt 하나는 어떻게 실행되나

순서가 정해지면 이제 그 턴의 첫 번째 Attempt를 실행한다.

flowchart LR
    A["Attempt 실행 시작"] --> B["Condition 확인"]
    B --> C["실행 가능"]
    C --> D["필요한 Number 계산"]
    D --> E["Effect 실행"]
    E --> F["상태 변화"]
    F --> G["Event 기록"]
    G --> H["Trigger 처리"]

먼저 이 행동이 정말 실행 가능한지 본다. 마비 때문에 행동 불능은 아닌지, PP가 남았는지, 대상이 면역은 아닌지 같은 것들이 여기서 걸린다. 이 단계는 2편의 Condition이 맡는다.

통과했다면 실제 상태를 바꾼다. HP를 깎거나, 상태이상을 걸거나, 랭크를 바꾸거나, 날씨를 바꾼다. 이건 3편의 Effect다.

그 과정에서 숫자가 필요하면 5편의 Number가 계산을 맡는다. 단순 고정값일 수도 있고 위력과 공격/방어 비율, 상성, 랜덤 보정이 섞인 공식일 수도 있다.

그리고 실행이 끝나면 4편의 Event가 남는다. 누가 누구를 때렸는지, 실제 데미지가 들어갔는지, 누가 쓰러졌는지 같은 사실들이 여기 기록된다.

마지막으로 6편의 Trigger가 그 이벤트를 보고 반응한다. 특성, 도구, 날씨, 필드 효과가 여기서 움직인다.

결국 한 번의 행동은 작은 단계들이 이어진 한 묶음이라고 볼 수 있다.

한 가지 시나리오로 자세히 풀어보겠다.

팬텀이 섀도볼을 쓰고 상대는 별다른 반응 특성이나 도구가 없다고 해보자.

1. 팬텀이 섀도볼을 선택한다
2. Attempt가 만들어진다
3. 행동 순서가 정해진다
4. Condition을 확인한다
5. 데미지 Number를 계산한다
6. Effect가 상대 HP를 깎는다
7. DamageDealt Event가 남는다
8. 추가 효과가 붙으면 StatChanged Event가 남는다
9. 반응할 Trigger가 없으면 다음 단계로 간다

그냥 맞추고 피깎고, 추가 효과까지 확인하고 끝났다. 이제 변수를 늘려보도록 하겠다. 공격자가 드래곤클로를 썼고 상대 특성은 까칠한피부이며 공격자는 생명의구슬 상대는 기합의띠를 들고 있다고 해보자. 기술은 단순하게 두고, 4세대 도구 반응이 루프 중간에 어디 끼어드는지 보는 단순화된 예시라고 생각하면 된다.

flowchart TB
    A["드래곤클로 Attempt"] --> B["Condition 통과"]
    B --> C["데미지 Number 계산"]
    C --> D["상대에게 Damage Effect"]
    D --> E["DamageDealt Event"]
    E --> F["기합의띠 확인(상대가 풀피였다면)"]
    F --> G["상대 생존 여부 확정"]
    G --> H["ContactHappened Event"]
    H --> I["까칠한피부 Trigger"]
    I --> J["공격자 반사 데미지"]
    J --> K["생명의구슬 Trigger"]
    K --> L["공격자 반동 데미지"]

여기서 중요한 건 이 모든 걸 드래곤클로라는 기술 하나가 전부 알고 있을 필요는 없다는 점이다. 기술은 자기 Effect를 실행하고, 그 뒤에 남은 Event를 보고 다른 규칙이 차례대로 반응하면 된다.

턴 종료

포켓몬 배틀에는 턴 종료에 따로 처리되는 것들이 많다. 날씨 데미지, 상태이상 데미지, 지속 효과, 남은 카운트 감소 같은 것들이 이 단계에서 처리된다.

그래서 배틀 루프는 보통 "모든 행동 실행"에서 끝나지 않고 마지막에 턴 종료 단계가 한 번 더 붙는다.

flowchart LR
    A["모든 Attempt 실행 완료"] --> B["턴 종료 Effect"]
    B --> C["턴 종료 Event"]
    C --> D["턴 종료 Trigger"]
    D --> E["카운트 감소 및 다음 Turn 준비"]

이 단계를 따로 두는 이유는, 턴 도중 반응과 턴 종료 반응이 같은 종류가 아니기 때문이다. 예를 들어 화상 데미지는 "공격 성공 직후"가 아닌 "턴이 끝날 때" 들어간다.

Battle은 지휘자 역할

여기까지 오면 1편에서 봤던 Battle의 역할도 더 분명해진다.

Battle은 직접 모든 규칙을 계산하는 거대한 객체라기보다 여러 규칙이 순서대로 흘러가도록 만드는 지휘자에 가깝다.

flowchart TB
    A["Battle"] --> B["입력 받기"]
    A --> C["Attempt 순서 정하기"]
    A --> D["Condition 실행"]
    A --> E["Effect 실행"]
    A --> F["Event 기록"]
    A --> G["Trigger 처리"]
    A --> H["턴 종료 처리"]

Battle이 중요한 이유는 "모든 걸 혼자 안다"가 아니라 "각 조각이 어느 타이밍에 움직여야 하는지 흐름을 제어한다"는 점이다.

7편까지의 내용을 정리하면 시리즈 전체 흐름은 이렇다.

Condition = 실행 가능한가
Effect    = 무엇을 바꾸는가
Event     = 무슨 일이 일어났는가
Number    = 얼마나 바뀌는가
Trigger   = 그 결과에 누가 반응하는가
Loop      = 이 전부가 어떤 순서로 이어지는가

포켓몬 배틀 시스템을 OOP로 본다는 건 배틀을 이루는 요소를 작은 객체와 단계로 나눠 읽어가는 과정에 가깝다. 핵심은 클래스 개수 자체가 아니라 "서로 다른 이유로 바뀌는 것들을 얼마나 잘 분리하는가"에 있다.

Condition은 실행 가능 여부가 바뀔 때 수정된다. Effect는 상태를 바꾸는 방식이 바뀔 때 수정된다. Number는 계산식이 바뀔 때 수정된다. Trigger는 후속 반응 규칙이 바뀔 때 수정된다. Battle Loop는 그 조각들의 실행 순서가 바뀔 때 수정된다.

이걸 전부 한 함수 안에 넣어두면 명중률 하나를 고칠 때도 데미지 계산과 상태이상, 특성 반응, 턴 종료 처리까지 함께 읽어야 한다. 반대로 역할을 나눠두면 "무엇이 왜 바뀌는가"가 훨씬 선명해지고 수정 범위도 자연스럽게 줄어든다.

요약하면 OOP 설계의 좋은 기준은 클래스를 많이 만드는 데 있는 게 아니라 바뀌는 부분을 얼마나 자연스럽게 나누느냐에 있다.

이 관점에서 보면 포켓몬 배틀은 꽤 재미있는 OOP 예시가 된다.

규칙이 많고 예외도 많고 세대별 차이도 있고, 특성이나 도구나 날씨처럼 서로 다른 계층의 규칙이 한 턴 안에서 계속 부딪힌다. 이런 도메인을 절차적으로만 밀어붙이면 코드가 금방 "이번엔 여기서 if 하나 더"의 연속이 된다.

그런데 객체로 잘게 나눠 보기 시작하면 질문도 바뀐다.

"이 기술은 무슨 if가 더 필요하지?"가 아니라, "이건 Condition인가, Effect인가, Trigger인가?" "이 숫자는 Number로 빼는 게 맞나?" "이 반응은 Battler가 가져야 하나, Battle이 가져야 하나?"

이렇게 질문 자체를 바꾸면 유연한 설계가 가능해진다.

OOP로 포켓몬 배틀을 본다는 건 포켓몬을 객체로 만든다는 뜻보다 배틀 규칙을 "읽을 수 있는 단위"로 잘게 쪼개어 다룬다는 뜻에 더 가깝다.

이건 포켓몬 배틀만의 이야기도 아니다. 결제 시스템이든 채팅 시스템이든 게임 서버든 복잡한 서비스일수록 비슷한 문제가 반복되는데, 입력이 들어오고 조건을 확인한 뒤 상태를 바꾸고 결과를 기록하면 또 다른 규칙이 그 결과에 반응한다.

도메인은 달라도 구조는 비슷하다.

그래서 이 시리즈를 쓰면서 가장 재미있었던 지점도 거기에 있었다. 처음에는 그냥 "포켓몬 배틀을 OOP로 보면 재밌겠다" 정도로 시작했는데 끝까지 와보니 이건 포켓몬 이야기이면서 동시에 복잡한 규칙을 어떻게 다뤄야 하는가에 대한 이야기이기도 했다.

Share:

Comments