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

Effects
지난 글에서는 "지금 이 행동이 실행 가능한가"를 판단하는 Condition 쪽을 봤다.
이번에는 그 다음 단계다. 실행 가능 판정을 통과했다면, 이제는 실제로 배틀 상태를 바꿔야 한다.
피카츄가 10만 볼트를 썼다고 해보자. 명중 판정을 통과했고, 상대가 땅 타입도 아니라서 무효도 아니었다. 그러면 이제 남는 건 하나다. 실제로 무슨 일이 일어나는가?
상대 HP가 깎일 수도 있고, 운이 좋으면 마비가 걸릴 수도 있다. 다른 기술이라면 사용자의 HP가 회복될 수도 있고, 반동 데미지를 받을 수도 있고, 날씨가 바뀔 수도 있다.
겉으로 보면 전부 따로 노는 기능 같지만, 설계 관점에서는 한 덩어리다. 배틀 상태를 바꾸는 일이라는 점에서는 전부 같기 때문이다.
즉 지난 글의 Condition이 질문이었다면, 이번 글의 Effect는 변경이다.
한 번 아주 짧게 줄이면 이런 느낌이다.
Condition = 이 행동이 실행 가능한가?
Effect = 실행됐다면 무엇이 바뀌는가?
IEffect
가장 바깥쪽 인터페이스는 단순하게 둘 수 있다.
classDiagram
class IEffect {
+Execute(Battle battle) void
}
class NoEffect
class Sequence
class Conditional
class Apply
IEffect <|.. NoEffect
IEffect <|.. Sequence
IEffect <|.. Conditional
IEffect <|.. Apply
Sequence --> IEffect
Conditional --> IEffect
Condition이 check로 끝났다면, Effect는 execute를 호출하는 순간 실제 상태가 바뀐다.
여기서도 이전 글과 같은 발상을 가져가면 편하다. 필요한 정보를 이것저것 파라미터로 늘어놓는 대신, 현재 배틀 문맥을 들고 있는 Battle 하나만 넘기는 것이다.
왜냐하면 효과가 알아야 하는 정보는 생각보다 많기 때문이다. 지금 누가 공격자인지, 누가 방어자인지, 방금 이 공격이 명중했는지, 직전에 계산된 데미지가 얼마였는지, 상대가 이미 상태이상인지 같은 것들이 전부 Effect의 판단 재료가 된다.
예를 들어 기가드레인을 떠올려보면 바로 감이 온다. 이 기술은 그냥 "상대를 때린다"로 끝나지 않는다. 먼저 상대에게 데미지를 주고, 그 뒤에 방금 준 데미지의 일부만큼 자신이 회복한다. 그러려면 두 번째 효과는 첫 번째 효과의 결과를 알고 있어야 한다.
그래서 Effect도 Battle을 문맥으로 삼는 편이 자연스럽다. 어떤 효과든 "지금 배틀에서 일어난 일"을 보고 움직인다고 약속해두면, 시그니처도 단순해지고 조합도 쉬워진다.
그림으로 보면 여기서 NoEffect가 같이 있는 것도 중요하다. 말 그대로 "아무 일도 하지 않는 효과"인데, 생각보다 자주 필요하다.
예를 들어 어떤 조건을 검사했는데 실패했을 때 굳이 별도 예외 처리를 늘리는 대신, 실패 쪽을 NoEffect로 두면 구조가 훨씬 덜 지저분해진다. "아무 일도 안 일어난다"도 하나의 명시적인 선택지로 보는 셈이다.
flowchart LR
A[Move 실행] --> B[Condition 통과]
B --> C[Effect 실행]
C --> D[HP 변경]
C --> E[상태이상 부여]
C --> F[필드 상태 변경]
C --> G[반동/회복]
Primitive Effect
문제는 여기서부터다.
기술 하나를 구현할 때마다 ThunderboltEffect, FlamethrowerEffect, GigaDrainEffect 같은 클래스를 하나씩 만들기 시작하면, 기술 수만큼 Effect 클래스가 불어난다. 그러면 추상화를 했다고는 하지만 사실상 기술 목록을 클래스 목록으로 옮겨 적은 것밖에 안 된다.
그래서 여기서는 기술 이름이 아니라 상태 변화의 종류로 잘라보는 쪽이 더 마음에 든다.
classDiagram
class IBattlerEffect {
+Apply(Battler battler, Battle battle) void
}
class Damage
class Heal
class InflictStatus
class ModifyStat
IBattlerEffect <|.. Damage
IBattlerEffect <|.. Heal
IBattlerEffect <|.. InflictStatus
IBattlerEffect <|.. ModifyStat
예를 들면 Damage는 HP를 깎는 효과고, Heal은 HP를 회복하는 효과다. InflictStatus는 상태이상을 거는 효과고, ModifyStat는 공격이나 방어 같은 랭크를 바꾸는 효과라고 생각하면 되겠다.
포인트는 "기술 이름"이 아니라 "상태 변화의 종류"로 나눈다는 점이다.
이를테면 몸통박치기는 상대 HP를 깎는다. 회복은 자기 HP를 회복한다. 전기자석파는 상대를 마비 상태로 만든다. 칼춤은 자기 공격 랭크를 올린다. 겉으로는 전부 다른 기술이지만, Effect 관점에서는 익숙한 몇 가지 조각으로 환원된다.
10만 볼트, 화염방사, 냉동빔도 마찬가지다. 이름도 다르고 속성도 다르지만, 큰 틀에서는 "상대에게 데미지를 주고, 일정 확률로 상태이상을 건다"는 같은 구조를 갖는다. 그러니 기술마다 거대한 전용 클래스를 만드는 것보다, 내부에서 쓰는 변화 단위를 잘게 쪼개 재사용하는 편이 훨씬 낫다.
이렇게 해두면 새 기술이 추가될 때마다 새로운 세계가 열리는 게 아니라, 이미 있던 조각들을 다시 조합하게 된다. 이 감각이 꽤 중요하다고 생각한다.
아래처럼 놓고 보면 더 잘 보인다.
flowchart TB
A[몸통박치기] --> A1[Damage]
B[회복] --> B1[Heal]
C[전기자석파] --> C1[InflictStatus]
D[칼춤] --> D1[ModifyStat]
E[기가드레인] --> E1[Damage]
E --> E2[Heal]
몸통박치기 = 상대 HP를 깎는다
전기자석파 = 상대에게 마비를 건다
칼춤 = 자신의 공격 랭크를 올린다
기가드레인 = 상대를 때리고, 그 일부만큼 자신이 회복한다
그런데 손그림을 보면 여기서 한 단계 더 나아간 구분이 있다. 데미지도 전부 같은 데미지가 아니라는 점이다.
예를 들어 지진이나 10만 볼트처럼 공격/방어/위력/상성을 반영해서 계산하는 데미지가 있는가 하면, 나이트헤드처럼 레벨만큼 고정으로 들어가는 류도 있다. 둘 다 "HP를 깎는다"는 점에서는 같지만, 계산 방식은 다르다.
그래서 실제로는 이렇게 한 번 더 나눠볼 수 있다.
flowchart TB
A[Damage]
A --> B[FormulaDamage]
A --> C[DirectDamage]
D[Heal] --> E[RestoreHP]
F[InflictStatus] --> G[Paralyze]
H[ModifyStat] --> I[AttackStatChange]
J[Special] --> K[Faint]
J --> L[OHKO]
J --> M[Drain]
FormulaDamage = 위력, 능력치, 상성 등을 반영해 계산하는 데미지
DirectDamage = 계산식 없이 바로 들어가는 고정 데미지
RestoreHP = 체력을 회복한다
AttackStatChange = 공격 랭크를 바꾼다
Paralyze = 마비를 건다
Faint / OHKO = 즉시 쓰러짐과 가까운 특수 효과
Drain = 상대에게 준 결과를 바탕으로 자신을 회복한다
이 구분을 해두면 Damage 하나에 모든 걸 우겨 넣지 않아도 된다. "HP를 깎는다"는 공통점은 유지하면서도, 계산형 데미지와 고정 데미지를 자연스럽게 나눌 수 있다.
Apply
하지만 작은 효과만 만든다고 끝나지는 않는다.
Damage나 Heal 같은 효과는 "누구에게 적용할 것인가"가 빠지면 아직 반쪽짜리다. 그래서 2편의 For와 비슷하게, 대상을 고른 뒤 그 효과를 붙여주는 다리 역할이 하나 필요하다.
classDiagram
class ITarget {
+Resolve(Battle) Battler
}
class IEffect {
+Execute(Battle battle) void
}
class IBattlerEffect {
+Apply(Battler battler, Battle battle) void
}
class Apply {
+ITarget target
+IBattlerEffect effect
}
class Attacker
class Defender
ITarget <|.. Attacker
ITarget <|.. Defender
IEffect <|.. Apply
Apply --> ITarget
Apply --> IBattlerEffect
Apply의 역할은 단순하다. Battle에서 대상을 고르고, 그 대상에게 Battler Effect를 적용한다.
예를 들어 몸통박치기는 방어자에게 데미지를 주는 효과고, 회복은 공격자 자신에게 회복을 적용하는 효과다. 중요한 건 Damage나 Heal이 스스로 "내가 누구에게 가야 하지?"를 고민하지 않게 만드는 것이다. Damage는 그냥 HP를 깎는 방법만 알고 있으면 되고, Heal은 HP를 올리는 방법만 알고 있으면 된다. 누구에게 붙일지는 Apply가 정한다.
이 분리가 왜 좋으냐면, 같은 변화가 서로 다른 대상에게 반복해서 등장하기 때문이다. 플레어드라이브를 떠올려보면 바로 보인다. 상대에게는 데미지를 주고, 사용자 자신도 반동 데미지를 받는다. 둘 다 결국 Damage다. 달라지는 건 숫자와 대상뿐이다.
그래서 이런 경우를 기술마다 따로 예외 처리하는 것보다, "같은 변화인데 대상만 다르다"로 모델링하는 편이 훨씬 깔끔하다.
flowchart LR
A[Damage] --> B{누구에게?}
B --> C[Defender]
B --> D[Attacker]
C --> E[상대에게 데미지]
D --> F[반동 데미지]
몸통박치기 = Apply(Defender, Damage)
회복 = Apply(Attacker, Heal)
플레어드라이브 = Apply(Defender, Damage) + Apply(Attacker, Damage)
이 관점에서 보면 RestoreHP, Paralyze, AttackStatChange, Faint도 전부 똑같다. 결국은 "누구에게 붙일 것인가?"만 정해지면 된다.
전기자석파 = Apply(Defender, Paralyze)
칼춤 = Apply(Attacker, AttackStatChange)
회복 = Apply(Attacker, RestoreHP)
절대영도 = Apply(Defender, OHKO)
Sequence
효과를 작은 부품으로 쪼갰다면, 이제 다시 합쳐야 한다.
포켓몬 기술은 대부분 효과가 하나로 끝나지 않는다. 데미지를 주고, 추가 효과를 걸고, 사용자가 회복하거나 반동을 받는 식으로 여러 단계가 이어진다. 그래서 Effect를 순서대로 묶는 Composite가 필요하다.
classDiagram
class IEffect {
+Execute(Battle battle) void
}
class Sequence {
+List~IEffect~ effects
}
IEffect <|.. Sequence
Sequence --> IEffect
이제 기술 하나를 거대한 단일 로직으로 만드는 대신, 작은 효과들의 순서로 적을 수 있다.
예를 들어 기가드레인은 "상대에게 데미지를 준다. 그리고 방금 준 데미지의 일부만큼 자신이 회복한다"라고 읽으면 끝이다. 여기서 중요한 건 두 번째 효과가 첫 번째 효과의 결과를 읽는다는 점이다. 그래서 Battle 안에는 attacker/defender만 있는 게 아니라, 직전 명중이나 직전 데미지 같은 실행 문맥도 함께 들어 있어야 자연스럽다.
이 감각은 다른 기술에도 그대로 이어진다. 깨물어부수기는 데미지를 주고, 그다음 확률적으로 방어를 내린다. 플레어드라이브는 데미지를 주고, 그다음 확률적으로 화상을 입히고, 마지막에 반동을 받는다. 유턴은 데미지를 준 뒤 교체를 일으킨다.
유저 입장에서는 전부 그냥 "기술 한 번 썼다"로 보이지만, 내부적으로는 여러 단계가 순서대로 흘러간다. 그래서 Sequence가 필요하다. 무엇이 먼저 일어나고 무엇이 나중에 일어나는지를 구조로 붙잡아두기 위해서다.
플레어드라이브를 예로 들면 흐름은 대략 이렇게 보일 것이다.
flowchart LR
A[상대에게 데미지] --> B[추가 효과 판정]
B --> C[화상 부여 가능]
C --> D[사용자 반동]
그리고 기가드레인은 훨씬 단순하다.
flowchart LR
A[상대에게 데미지] --> B[방금 준 데미지 참조]
B --> C[사용자 회복]
손그림의 Drain이 딱 이쪽에 가깝다. Drain은 독립된 새 세계라기보다, 앞에서 일어난 데미지 결과를 읽어 회복으로 이어붙이는 효과라고 보면 이해가 쉽다.
Conditional Effect
모든 효과가 항상 실행되는 것도 아니다. 어떤 기술은 10% 확률로 마비를 걸고, 어떤 효과는 상대가 이미 상태이상이 아닐 때만 발동하고, 어떤 아이템은 체력이 절반 이하일 때만 터진다. 이런 것들은 "효과를 실행할지 말지"를 다시 조건으로 감싸는 게 자연스럽다.
classDiagram
class IEffect {
+Execute(Battle battle) void
}
class IConditionBattle["ICondition~Battle~"]
class Conditional {
+ICondition~Battle~ condition
+IEffect onPass
+IEffect onFail
}
IEffect <|.. Conditional
Conditional --> IEffect
Conditional --> IConditionBattle
여기서 재밌는 점은, 2편에서 만들었던 Condition 부품들을 그대로 재사용할 수 있다는 것이다.
예를 들어 10만 볼트의 추가 효과는 "10% 확률이면, 상대에게 마비를 건다"라고 읽을 수 있다. 화염방사라면 "10% 확률이면, 상대에게 화상을 건다"가 된다. 회복열매라면 "체력이 절반 이하이면, 회복 효과를 실행한다"가 된다.
앞 글에서는 Condition을 "실행해도 되나?"를 묻는 데 썼다. 그런데 여기서는 같은 Condition이 "이 추가 효과가 발동하나?"를 묻는 데도 쓰인다. 이게 꽤 좋다. 규칙을 다루는 언어가 통일되기 때문이다.
겉으로는 전부 다른 규칙 같지만, 설계 문장으로 옮기면 결국 같은 형태다. 어떤 조건을 만족하면, 어떤 효과를 실행한다. Conditional은 이 넓은 범주를 받아주는 껍데기라고 보면 되겠다.
여기서 손그림처럼 onPass / onFail 두 갈래로 생각하면 조금 더 풍부해진다. 조건을 통과했을 때만 어떤 효과를 실행하는 게 아니라, 실패했을 때는 다른 효과를 실행할 수도 있기 때문이다.
가장 흔한 실패 쪽은 역시 NoEffect다.
조건을 통과하면 = 마비를 건다
조건을 실패하면 = NoEffect
하지만 꼭 NoEffect만 있는 건 아니다. 예를 들어 명중 여부처럼 중요한 분기라면 성공 시 데미지를 주고, 실패 시에는 그냥 빗나감 로그만 남기거나 다른 처리로 넘어갈 수도 있다. 그러니 Conditional은 사실상 "if-else를 Effect 쪽으로 끌고 온 것"에 가깝다.
10만 볼트 = 상대에게 데미지
+ 10% 확률이면 마비
화염방사 = 상대에게 데미지
+ 10% 확률이면 화상
회복열매 = HP가 절반 이하이면
+ 회복 효과 실행
flowchart LR
A[조건 검사] -->|참| B[onPass]
A -->|거짓| C[onFail]
C --> D[보통은 NoEffect]
Number + Effect
지난 글에서 INumber를 따로 뺀 이유도 여기서 더 또렷해진다.
대부분의 효과는 "무언가를 바꾼다"로 끝나지 않고, "얼마나 바꿀지" 계산이 필요하다. 데미지를 얼마나 줄지, 회복을 얼마나 할지, 반동을 얼마나 받을지, 능력치 랭크를 몇 단계 바꿀지 같은 것들 말이다.
그래서 Effect는 변경의 종류를 책임지고, 숫자 계산은 INumber가 책임지는 편이 깔끔하다. 이렇게 해두면 Damage는 그대로 두고 바깥에서 숫자 공급자만 갈아 끼우면 된다. 고정 40 데미지든, 타입 상성과 STAB가 반영된 계산식이든, 직전 데미지의 절반만큼 들어가는 반동이든 전부 같은 Damage가 처리할 수 있다.
이 분리가 중요한 이유는 데미지 공식이 Effect보다 훨씬 자주 바뀌거나 복잡해질 수 있기 때문이다. 날씨, 급소, 타입 상성, 아이템, 랭크 보정이 들어오기 시작하면 숫자 계산은 금방 비대해진다. 그런데 그렇다고 Damage가 그 모든 규칙을 다 품고 있으면 책임이 너무 커진다.
예를 들어 지진과 용의파동은 둘 다 "상대에게 데미지를 준다"는 점에서는 같은 Effect다. 달라지는 건 실제 데미지를 계산하는 방식이다. 이 차이를 Damage 내부에 몰아넣기보다, 계산은 숫자 쪽에 맡기고 Damage는 "계산된 만큼 HP를 깎는다"만 책임지는 편이 훨씬 단단하다.
반동도 같은 이야기다. 플레어드라이브의 반동은 "고정 30"이 아니라 "방금 입힌 데미지의 일부"다. 이건 Effect의 종류가 다른 게 아니라 숫자를 구하는 방식이 다른 것이다. 즉 무엇을 바꿀지와 얼마나 바꿀지를 분리해두면 기술이 늘어날수록 오히려 관리가 쉬워진다.
flowchart LR
A[숫자 계산] --> B[Damage]
A --> C[Heal]
D[고정값]
E[타입 상성 반영]
F[직전 데미지 참조]
D --> A
E --> A
F --> A
고정 40 데미지
STAB + 상성 + 급소가 반영된 데미지
직전 데미지의 1/3만큼 반동
전부 "숫자를 만든 뒤 적용한다"는 점에서는 같은 구조다.
이걸 손그림처럼 다시 쓰면 FormulaDamage와 DirectDamage가 둘 다 INumber와 이어질 수 있다는 뜻이기도 하다. 하나는 "복잡한 공식으로 숫자를 만든다"는 쪽이고, 다른 하나는 "그냥 고정값 숫자를 만든다"는 쪽이다.
그래서 Damage를 한 종류로만 보지 않고, 계산형과 직접형으로 나눠두면 의도가 더 잘 드러난다.
Battle Effect
물론 모든 효과가 특정 Battler 한 마리에게만 적용되는 건 아니다. 비바라기는 특정 포켓몬 하나의 HP를 바꾸지 않고 배틀 전체의 날씨를 바꾼다. 스텔스록도 당장 상대 HP를 깎지 않는다. 대신 필드에 함정을 설치해서 이후 교체될 때 영향을 준다.
이런 기술까지 전부 "대상 하나에게 적용되는 효과"로 우겨 넣으면 모델이 어색해진다. 그래서 구조를 보면 보통은 두 층으로 나뉜다. 한 마리의 상태를 바꾸는 효과가 있고, 배틀 문맥 전체를 바꾸는 효과가 있다. 그리고 Apply가 전자 쪽을 후자 쪽으로 끌어올리는 다리 역할을 한다고 보면 되겠다.
결국 중요한 질문은 두 가지다. 누구 하나를 바꾸는가, 아니면 필드 전체를 바꾸는가. 이 둘을 나눠두면 기술 설명도 훨씬 읽기 쉬워진다.
flowchart TB
A[Effect]
A --> B[개별 Battler 변경]
A --> C[Battle 전체 변경]
B --> D[데미지]
B --> E[회복]
B --> F[상태이상]
C --> G[날씨]
C --> H[필드 함정]
C --> I[룸 효과]
비바라기 = Battle 전체 변경
스텔스록 = Battle 전체 변경
몸통박치기 = 개별 Battler 변경
칼춤 = 개별 Battler 변경
그리고 손그림의 Faint, OHKO 같은 건 살짝 경계선에 있는 효과다. 겉으로는 개별 Battler 하나를 바꾸는 것처럼 보이지만, 실제로는 쓰러짐 판정과 교체 흐름까지 이어지기 때문에 Battle 쪽 문맥과도 강하게 연결된다.
그래서 이런 효과는 "개별 대상에게 적용된다"와 "배틀 진행을 크게 바꾼다"가 동시에 들어 있다고 보는 편이 자연스럽다.
적용
이제 앞에서 본 조각들로 기술을 다시 읽어보자.
10만 볼트는 "상대에게 데미지를 주고, 10% 확률로 마비를 건다"라고 적으면 거의 끝이다. 플레어드라이브는 "상대에게 데미지를 주고, 일정 확률로 화상을 입히며, 마지막에 사용자가 반동을 받는다"라고 읽으면 된다. 기가드레인은 "상대를 때린 뒤 그 데미지의 일부만큼 자신이 회복한다"가 되고, 칼춤은 "공격자 자신의 공격 랭크를 올린다"가 된다. 스텔스록은 "상대 필드에 함정을 설치하고, 이후 교체될 때 그 함정을 참조한다"라고 볼 수 있다.
이렇게 적고 나면 기술마다 이름은 달라도, 실제로는 몇 가지 익숙한 Effect 문장을 조합하고 있다는 게 보인다. 데미지를 준다, 상태이상을 건다, 자신을 회복한다, 랭크를 바꾼다, 필드를 바꾼다. 달라지는 건 이 조각들의 순서와 조건, 그리고 숫자 계산 방식이다.
여기에 손그림 기준의 구체 구현을 얹어 보면 더 또렷하다.
지진
= Apply(Defender, FormulaDamage)
나이트헤드
= Apply(Defender, DirectDamage)
전기자석파
= Apply(Defender, Paralyze)
칼춤
= Apply(Attacker, AttackStatChange)
절대영도
= Apply(Defender, OHKO)
나는 이게 OOP로 포켓몬 배틀 시스템을 모델링할 때 꽤 중요한 전환이라고 생각한다. 초반에는 보통 기술 하나마다 거대한 실행 함수를 만들게 된다. 그런데 그렇게 가면 if (burnChance > 0), if (recoil > 0), if (drain > 0) 같은 분기가 계속 늘어나고, 결국 함수가 비대해진다.
반대로 Effect를 작은 조각으로 나눠두면 기술은 거대한 예외 덩어리가 아니라 "부품 조립서"가 된다. 무엇을 바꿀지, 누구에게 적용할지, 어떤 조건에서 실행할지, 어떤 순서로 일어날지가 구조에 그대로 드러난다.
마지막으로 몇 개만 아주 짧게 적어보면 이런 느낌이다.
10만 볼트
= Apply(Defender, Damage)
+ Conditional(10% 확률, Apply(Defender, 마비))
기가드레인
= Apply(Defender, Damage)
+ Apply(Attacker, Heal(직전 데미지 기반))
플레어드라이브
= Apply(Defender, Damage)
+ Conditional(화상 확률, Apply(Defender, 화상))
+ Apply(Attacker, Damage(반동))
결국 Condition이 판단의 언어였다면, Effect는 상태 변화의 언어다. 포켓몬 배틀처럼 예외가 많고 조합이 폭발하는 도메인에서는, 이 언어를 작은 객체들로 분해해 두는 것만으로도 구조가 훨씬 덜 무너진다.
다음에는 여기서 한 걸음 더 나아가, 데미지 공식이나 이벤트 로그처럼 "Effect가 실행되면서 남기는 결과"를 어떻게 모델링할지 이어서 생각해볼 수 있겠다.