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

Numbers
지난 글에서는 Effect가 상태를 바꾸고, Event와 AttemptResult가 "이번 시도에서 무슨 일이 일어났는가"를 남긴다고 봤다.
그런데 배틀 시스템은 여기서 한 가지를 더 알아야 한다.
그래서 얼마나 바뀌는가?
10만 볼트가 맞았다고 해도 얼마만큼 HP가 줄어드는지 계산해야 하고, 회복을 썼다면 얼마를 회복하는지도 알아야 한다. 기가드레인처럼 방금 준 데미지의 일부를 다시 가져오는 기술도 있고, 플레어드라이브처럼 방금 준 데미지의 일부를 반동으로 되돌려받는 기술도 있다.
결국 Effect가 "무엇을 바꾸는가"라면, Number는 "얼마나 바꾸는가"다.
짧게 줄이면 이렇다.
Condition = 실행 가능한가?
Effect = 무엇을 바꾸는가?
Event / Result = 무슨 일이 일어났는가?
Number = 얼마나 바뀌는가?
INumber
숫자도 객체로 보면 구조가 훨씬 또렷해진다.
영상에 나온 원본 화이트보드 구조를 최대한 그대로 옮기면 대략 이런 모양이다.
classDiagram
class INumber {
+Evaluate(Battle battle) double
}
class Exactly {
+value double
}
class Between {
+min double
+max double
}
class Weighted {
+weightedValues
}
class MaxHP {
+target
}
class CurrentHP {
+target
}
class Level {
+target
}
class CurDamageDealt {
+target
}
class Product
class Sum
class Quotient
INumber <|-- Exactly
INumber <|-- Between
INumber <|-- Weighted
INumber <|-- MaxHP
INumber <|-- CurrentHP
INumber <|-- Level
INumber <|-- CurDamageDealt
INumber <|-- Product
INumber <|-- Sum
INumber <|-- Quotient
Product o--> INumber
Sum o--> INumber
Quotient --> INumber
지금부터 나오는 도식들은 이 원형 구조를 읽기 쉽게 다시 풀어쓴 버전이다.
flowchart TB
A["INumber"] --> B["고정 숫자"]
A --> C["Battle에서 읽는 숫자"]
A --> D["숫자 조합"]
B --> B1["Exactly"]
B --> B2["Between"]
B --> B3["Weighted"]
C --> C1["MaxHP"]
C --> C2["CurrentHP"]
C --> C3["Level"]
C --> C4["CurDamageDealt"]
D --> D1["Product"]
D --> D2["Sum"]
D --> D3["Quotient"]
D --> E["최종 숫자"]
E --> F["데미지, 회복, 공식 데미지"]
Evaluate(Battle)라는 모양이 중요한 이유는, 배틀 안의 숫자가 생각보다 현재 문맥에 많이 의존하기 때문이다.
그냥 40, 120 같은 고정 숫자도 있지만, 실제 배틀에서는 이런 값이 훨씬 자주 나온다.
내 최대 HP의 절반
상대 현재 HP의 절반
공격자의 레벨
이번 시도에서 방금 들어간 데미지의 절반
자기 타입 보정과 상성 보정이 반영된 최종 데미지
이런 숫자는 메서드 안에서 즉석으로 계산해도 된다. 하지만 그렇게 두면 if, *, /, 반올림, 예외 처리가 여기저기로 흩어진다. 반대로 숫자 자체를 INumber로 보면 "숫자를 어떻게 만드는가"가 한눈에 들어온다.
Primitive Number
먼저 제일 작은 숫자 조각부터 본다.
flowchart TB
A["Primitive Number"] --> B["Exactly"]
A --> C["Between"]
A --> D["Weighted"]
B --> E["항상 같은 값"]
C --> F["매번 조금 달라지는 값"]
D --> G["가중치에 따라 고르는 값"]
Exactly는 말 그대로 정확히 그 숫자다. Exactly(40)이라면 그냥 40이다. 어떤 고정 데미지나 고정 회복량을 표현할 때 가장 단순하게 쓸 수 있다.
Between은 두 숫자 사이의 값이다. 포켓몬 데미지에는 랜덤 보정이 들어가는데, 글에서는 이를 아주 단순하게 0.85에서 1.00 사이의 값으로 생각해볼 수 있다. 즉 같은 10만 볼트라도 매번 숫자가 미묘하게 달라지는 이유를 이런 객체 하나로 분리할 수 있다.
Weighted는 여러 후보 숫자에 가중치를 붙여 하나를 고르는 방식으로 읽으면 된다. 그림의 (double, double)* 표기를 그대로 읽으면 이 해석이 가장 잘 맞는다. 예를 들어 "대부분은 1.0배지만, 가끔은 1.5배가 나온다" 같은 규칙을 숫자로 표현하고 싶다면 이런 형태가 어울린다.
짧게 쓰면 이렇다.
Exactly(40)
= 항상 40
Between(0.85, 1.00)
= 0.85와 1.00 사이의 값
Weighted((1.0, 90), (1.5, 10))
= 90 비중으로 1.0, 10 비중으로 1.5
중요한 건 셋 다 결국 "숫자 하나를 돌려준다"는 점이다. 숫자가 어디에서 왔는지는 다르지만, INumber 바깥에서는 그냥 같은 숫자로 다룰 수 있다.
Battle에서 읽어오는 숫자
이제 아래 부분을 보자. 숫자를 직접 들고 있는 게 아니라, 현재 배틀 문맥에서 읽어오는 노드들이 붙어 있다.
flowchart LR
A["Battle"] --> B["Attacker"]
A --> C["Defender"]
A --> D["이번 AttemptResult"]
B --> E["공격자의 레벨"]
B --> F["공격자의 최대 HP"]
C --> G["상대의 현재 HP"]
D --> H["이번 시도의 데미지 결과"]
E --> I["나이트헤드"]
F --> J["회복"]
G --> K["분노의앞니"]
H --> L["기가드레인, 플레어드라이브"]
여기서 말하는 "숫자"는 상수만이 아니라 현재 배틀 상태에서 읽어오는 값이기도 하다.
MaxHP는 대상의 최대 HP를 읽는다. 그래서 회복 같은 기술은 "사용자의 최대 HP의 절반"처럼 바로 쓸 수 있다.
CurrentHP는 지금 남아 있는 HP를 읽는다. 분노의앞니처럼 상대 현재 HP의 절반만큼 데미지를 주는 기술은 이런 숫자와 잘 맞는다.
Level은 레벨을 숫자로 가져온다. 나이트헤드처럼 사용자의 레벨만큼 고정 데미지를 주는 기술을 떠올리면 바로 감이 온다.
CurDamageDealt는 4편의 Event, AttemptResult 설명과 이어진다. 이 값은 그냥 임의의 숫자가 아니라, 이번 시도에서 실제로 나온 데미지 결과를 읽어온 것이다. 그래서 기가드레인처럼 방금 준 데미지의 절반을 회복하거나, 플레어드라이브처럼 방금 준 데미지의 일부를 반동으로 돌려받는 기술도 무리 없이 설명된다.
짧은 예시로 놓고 보면 이렇다.
회복
= MaxHP(Attacker) / 2
분노의앞니
= CurrentHP(Defender) / 2
나이트헤드
= Level(Attacker)
기가드레인의 회복량
= CurDamageDealt / 2
여기서 한 가지 덧붙이면, 그림에는 MaxHP, CurrentHP, Level, CurDamageDealt만 적혀 있지만 실제 데미지 공식으로 가면 여기에 더 많은 숫자 노드가 붙는다. 위력, 공격, 방어, 자기 타입 보정, 상성 보정, 날씨 보정 같은 것들도 결국 전부 숫자이기 때문이다.
Composite Number
숫자 조각이 생기면 그 다음은 조합이다.
그림 오른쪽의 Product, Sum, Quotient는 그런 조합을 맡는다.
flowchart TB
A["레벨 계수"] --> D["기본 데미지"]
B["위력"] --> D
C["공격 대비 방어 비율"] --> D
D --> E["보정 적용"]
F["STAB(자기 타입 보정)"] --> E
G["상성 보정"] --> E
H["랜덤 보정"] --> E
E --> I["최종 데미지"]
J["최대 HP"] --> K["2로 나누기"]
K --> L["최종 회복량"]
Product는 곱셈이다. 포켓몬의 데미지 공식은 곱셈 구조가 특히 많이 드러난다. 위력에 공격 대비 방어 비율이 붙고, 자기 타입 보정이 붙고, 상성 보정이 붙고, 랜덤 보정이 붙는다. 이걸 한 덩어리의 거대한 함수로 보는 대신 "숫자들을 차례대로 곱하는 구조"로 보면 훨씬 읽기 쉬워진다.
10만 볼트의 데미지 계산식 일부
= 위력
* 공격/방어 비율
* STAB(자기 타입 보정)
* 상성 보정
* Between(0.85, 1.00)
Quotient는 나눗셈이다. 이건 회복과 반동에서 특히 자주 등장한다.
회복
= MaxHP(Attacker) / 2
기가드레인의 회복량
= CurDamageDealt / 2
플레어드라이브의 반동
= CurDamageDealt / 3
분노의앞니
= CurrentHP(Defender) / 2
Sum은 더하기다. 포켓몬 공식 전체를 보면 이런 더하기도 계속 나온다. 예를 들어 레벨 계수처럼 2 * level / 5 + 2 같은 값도 결국 Product, Quotient, Sum을 이어 붙인 조합으로 쓸 수 있다.
중요한 건 숫자 계산을 한 번에 몰아넣는 게 아니라, 작은 숫자 연산을 조합해 읽기 쉬운 형태로 만든다는 점이다.
Number + Effect
여기까지 오면 3편에서 봤던 Effect와 다시 연결된다.
Effect는 상태를 바꾸고, Number는 그때 필요한 크기를 계산한다. 둘을 분리해 두면 같은 효과가 여러 숫자를 받아 재사용될 수 있다.
flowchart LR
A["레벨"] --> B["DirectDamage"]
C["상대 현재 HP의 절반"] --> D["DirectDamage"]
E["위력, 비율, 보정의 곱"] --> F["FormulaDamage"]
G["이번 데미지 결과의 절반"] --> H["RestoreHP"]
B --> I["나이트헤드"]
D --> J["분노의앞니"]
F --> K["10만 볼트"]
H --> L["기가드레인"]
예를 들어 RestoreHP는 "HP를 회복한다"는 사실만 책임지면 된다. 최대 HP의 절반을 회복하든, 이번 시도의 데미지 결과의 절반을 회복하든, 그 차이는 RestoreHP가 아니라 바깥에서 넘겨주는 INumber가 맡는다.
그래서 같은 회복 효과도 이렇게 달라진다.
회복
= RestoreHP(MaxHP(Attacker) / 2)
기가드레인
= RestoreHP(CurDamageDealt / 2)
데미지도 마찬가지다.
나이트헤드
= DirectDamage(Level(Attacker))
분노의앞니
= DirectDamage(CurrentHP(Defender) / 2)
10만 볼트
= FormulaDamage(여러 숫자의 곱)
이렇게 보면 Damage나 RestoreHP가 숫자 계산까지 떠안지 않아도 된다. 덕분에 "무엇을 바꾸는가"와 "얼마나 바꾸는가"가 깔끔하게 나뉜다.
적용
앞에서 본 조각들을 실제 기술 예시로 다시 묶어보면 그림의 뜻이 더 또렷해진다.
회복은 제일 단순하다. 사용자 최대 HP의 절반만큼 회복하면 된다.
회복
= Apply(Attacker, RestoreHP(Quotient(MaxHP(Attacker), Exactly(2))))
분노의앞니는 상대 현재 HP의 절반을 깎는다.
분노의앞니
= Apply(Defender, DirectDamage(Quotient(CurrentHP(Defender), Exactly(2))))
나이트헤드는 사용자의 레벨만큼 데미지를 준다.
나이트헤드
= Apply(Defender, DirectDamage(Level(Attacker)))
기가드레인은 먼저 데미지를 주고, 그 다음 이번 시도의 데미지 결과를 읽어 절반만큼 회복한다.
기가드레인
= Apply(Defender, FormulaDamage(데미지 공식))
+ Apply(Attacker, RestoreHP(Quotient(CurDamageDealt, Exactly(2))))
플레어드라이브도 비슷하다. 먼저 데미지를 주고, 그 다음 방금 준 데미지의 일부를 사용자에게 반동으로 돌린다.
플레어드라이브
= Apply(Defender, FormulaDamage(데미지 공식))
+ Apply(Attacker, DirectDamage(Quotient(CurDamageDealt, Exactly(3))))
10만 볼트는 숫자 조합이 조금 더 길어진다. 기본 데미지 숫자를 만든 뒤, 그 숫자를 FormulaDamage에 넘겨 상대 HP를 깎고, 추가 효과는 별도의 Conditional로 붙이면 된다.
10만 볼트
= Apply(Defender, FormulaDamage(Product(위력, 공격/방어 비율, STAB(자기 타입 보정), 상성 보정, Between(0.85, 1.00))))
+ Conditional(10% 확률, Apply(Defender, Paralyze))
여기까지 보면 숫자를 객체로 뺀 이유가 꽤 분명해진다.
처음에는 데미지 계산이든 회복량 계산이든 전부 기술 메서드 안에 넣고 싶어진다. 하지만 그렇게 가면 금방 if, *, /, 반올림, 예외 규칙이 기술마다 흩어진다. 반대로 숫자를 INumber로 분리해두면 기술은 "어떤 효과를 어떤 순서로 적용하나"에 집중하고, 숫자 객체는 "그 크기를 어떻게 계산하나"에 집중할 수 있다.
포켓몬 배틀처럼 규칙이 많고 예외가 많은 도메인에서는 이런 분리가 생각보다 오래 버틴다.
데미지 공식은 어떻게 쪼개지나
여기까지 왔으면 이런 생각이 든다.
10만 볼트의 데미지 공식도 정말 INumber 조합으로 볼 수 있을까?
결론부터 말하면 그렇다. 물론 실제 포켓몬 공식은 세대마다 차이가 있고, 반올림이 들어가는 위치나 세부 보정 순서도 꽤 민감하다. 그래서 여기서는 "세대별 공식을 완전히 재현한다"기보다, 설계 관점에서 공식을 어떤 숫자 노드들로 쪼개어 볼 수 있는지를 보려 한다.
핵심은 이렇다. 우리가 흔히 데미지 공식이라고 부르는 것도 사실 한 번에 계산되는 거대한 식이 아니라, 몇 개의 작은 숫자 덩어리를 차례로 계산해 이어 붙이는 과정으로 볼 수 있다.
flowchart TB
A["레벨"] --> B["레벨 계수"]
C["위력"] --> D["기본 데미지"]
E["공격 수치"] --> F["공격 대비 방어 비율"]
G["방어 수치"] --> F
B --> D
F --> D
D --> H["중간 데미지"]
I["STAB(자기 타입 보정)"] --> J["보정 묶음"]
K["상성 보정"] --> J
L["급소"] --> J
M["날씨 보정"] --> J
N["랜덤 보정"] --> J
H --> O["최종 곱셈"]
J --> O
O --> P["최종 데미지"]
P --> Q["FormulaDamage에 전달"]
이 그림을 글로 풀면 대략 두 단계로 나뉜다.
먼저 "기본 데미지"를 만든다. 여기에는 레벨, 위력, 공격 수치, 방어 수치 같은 비교적 뼈대에 가까운 숫자들이 들어간다. 쉽게 말해 기술 자체의 힘과, 때리는 쪽과 맞는 쪽의 능력치 차이를 반영하는 층이다.
그 다음 "보정 묶음"을 만든다. 여기에는 STAB(자기 타입 보정), 상성, 급소, 날씨, 랜덤 보정 같은 값이 들어간다. 다시 말해 기본 데미지를 만든 뒤, 그 숫자를 몇 배로 키우거나 줄이는 층이다.
이걸 INumber 조합처럼 쓰면 대략 이런 모양이 된다.
기본 데미지
= Product(레벨 계수, 위력, 공격/방어 비율)
보정 묶음
= Product(STAB(자기 타입 보정), 상성 보정, 급소 보정, 날씨 보정, Between(0.85, 1.00))
최종 데미지
= Product(기본 데미지, 보정 묶음)
물론 실제 구현으로 가면 Product만으로는 부족하고, 중간중간 Sum, Quotient, 반올림 처리, 최소값 보정 같은 것도 들어간다. 그래도 큰 그림은 잘 안 바뀐다. 먼저 뼈대를 만들고, 그 위에 보정을 덧붙인다는 점은 그대로다.
예를 들어 레벨 계수만 떼어놓고 봐도 이미 작은 숫자 조합이다.
레벨 계수
= Sum(Quotient(Product(Exactly(2), Level(Attacker)), Exactly(5)), Exactly(2))
공격/방어 비율도 마찬가지다.
공격/방어 비율
= Quotient(공격 수치, 방어 수치)
여기에 자기 타입 보정, 상성, 랜덤 보정을 곱하면 우리가 익숙하게 보는 "최종 데미지"에 조금씩 가까워진다.
이렇게 쪼개 두면 좋은 점이 있다.
10만 볼트와 화염방사는 속성은 다르지만 둘 다 비슷한 공식을 쓴다. 반면 나이트헤드는 공식형이 아니라 레벨 기반 고정 데미지다. 분노의앞니는 상대 현재 HP 기반 비율 데미지다. 즉 모든 데미지 기술이 같은 공식을 공유하는 게 아니라, "어떤 숫자 노드를 쓰는가"가 기술마다 다르다.
그래서 Damage를 하나로 두더라도, 그 안에 들어가는 INumber는 기술마다 전혀 다를 수 있다.
10만 볼트
= FormulaDamage(Product(기본 데미지, 보정 묶음))
나이트헤드
= DirectDamage(Level(Attacker))
분노의앞니
= DirectDamage(Quotient(CurrentHP(Defender), Exactly(2)))
이 관점이 중요한 이유는, 데미지 기술을 "전부 다른 기술"로 보기보다 "같은 Effect에 다른 Number를 꽂는 경우"로 보게 해주기 때문이다. 그렇게 보면 기술이 늘어나도 새로 만드는 것은 거대한 메서드가 아니라 새로운 숫자 조합 몇 개가 된다.
그리고 이건 데미지에서만 끝나지 않는다. 3편의 Effect, 4편의 Event, 이번 글의 Number를 같이 놓고 보면 기가드레인이 왜 좋은 예시인지도 잘 보인다.
먼저 FormulaDamage가 상대에게 데미지를 준다. 그 결과는 DamageDealt 같은 이벤트와 AttemptResult에 남는다. 그리고 그 다음 숫자 노드 CurDamageDealt / 2가 그 결과를 읽어 회복량을 만든다. 즉 앞 단계의 결과가 뒷 단계의 숫자가 된다.
flowchart LR
A["FormulaDamage"] --> B["DamageDealt Event"]
B --> C["CurDamageDealt"]
C --> D["2로 나눈 값"]
D --> E["RestoreHP"]
이쯤 되면 시리즈의 조각들이 조금씩 이어진다.
1편에서는 누가 무엇을 시도하는지를 나눴고, 2편에서는 실행 가능한지를 판단했고, 3편에서는 실제 상태 변화를 만들었고, 4편에서는 그 결과를 기록했다. 그리고 이번 5편에서는 그 변화량을 만드는 숫자까지 객체로 떼어냈다.
결국 포켓몬 배틀 시스템을 OOP로 본다는 건, "기술 하나를 거대한 함수로 구현한다"가 아니라 배틀을 이루는 문장들을 작은 객체로 나눠 보는 과정이다. Condition은 질문이고, Effect는 변화이고, Event는 기록이고, Number는 크기다.