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

Results & Events
지난 글에서는 Effect를 봤다. 조건을 통과한 뒤 실제로 HP가 깎이고, 상태이상이 걸리고, 날씨가 바뀌는 그 변화 말이다.
그런데 여기서 한 가지가 더 남는다.
"무슨 일이 일어났는지"를 시스템은 어떻게 기억할까?
이 질문이 생각보다 중요하다. 피카츄의 10만 볼트가 맞아서 상대 HP가 줄었다고 해보자. 유저 입장에서는 그냥 "맞았네"로 끝날 수 있지만, 시스템 입장에서는 그 뒤에 알고 싶은 게 훨씬 많다.
누가 누구를 때렸는지, 얼마나 데미지가 들어갔는지, 추가 효과가 붙었는지, 그 결과 상대가 쓰러졌는지, 화면에는 무슨 로그를 띄워야 하는지 같은 것들 말이다.
그래서 Effect가 상태를 바꾸는 것만으로는 조금 부족하다. 그 변화가 일어났다는 사실도 함께 남겨야 한다.
한 줄로 줄이면 이렇다.
Effect = 상태를 바꾼다
Event = 무슨 일이 일어났는지 남긴다
왜 Event가 필요한가
예를 들어 플레어드라이브를 생각해보자. 이 기술은 상대에게 데미지를 주고, 일정 확률로 화상을 입히고, 마지막에 자신도 반동 데미지를 받는다.
상태 변화만 놓고 보면 "상대 HP 감소", "상대 화상", "사용자 HP 감소" 세 줄이면 끝이다. 그런데 실제 배틀은 여기서 멈추지 않는다.
상대가 쓰러졌다면 쓰러짐 메시지가 떠야 하고, 접촉 기술이었다면 특정 특성이나 도구가 반응할 수도 있다. 사용자가 반동으로 기절했다면 그에 맞는 처리도 이어져야 한다.
즉 시스템은 단순히 "HP가 바뀌었다"보다 한 단계 더 구체적인 사실을 알아야 한다.
flowchart LR
A[Effect 실행] --> B[상태 변경]
B --> C[무슨 일이 일어났는지 기록]
C --> D[로그 출력]
C --> E[후속 트리거]
C --> F[다음 규칙 판단]
여기서 Event가 필요해진다. Event는 "방금 배틀에서 실제로 벌어진 일"이라고 생각하면 된다.
3편 그림 기준으로 보면 이 Event는 추상적인 개념이 아니라, 구체적인 Effect들과 강하게 연결된다. FormulaDamage, DirectDamage, RestoreHP, Paralyze, AttackStatChange, Faint, OHKO 같은 것들이 실행되면, 그에 대응하는 "일어난 일"도 함께 남는 경우가 많다는 뜻이다.
IEvent
이제 Event도 타입으로 잡아볼 수 있다.
classDiagram
class IEvent
class DamageDealt
class ContactHappened
class StatusInflicted
class StatChanged
class HPRecovered
class WeatherChanged
class BattlerFainted
class MoveMissed
IEvent <|.. DamageDealt
IEvent <|.. ContactHappened
IEvent <|.. StatusInflicted
IEvent <|.. StatChanged
IEvent <|.. HPRecovered
IEvent <|.. WeatherChanged
IEvent <|.. BattlerFainted
IEvent <|.. MoveMissed
여기서 중요한 건 Event가 "규칙 설명용 이름"을 갖는다는 점이다.
DamageDealt는 누가 누구에게 얼마의 데미지를 줬는지를 담고, ContactHappened는 접촉이 있었는지를 담는다. StatusInflicted는 누가 누구에게 어떤 상태이상을 걸었는지를 담는다. StatChanged는 공격이나 방어 랭크가 어떻게 바뀌었는지를 담고, HPRecovered는 누가 얼마만큼 회복했는지를 담는다. BattlerFainted는 어느 포켓몬이 쓰러졌는지라는 사실을 담는다.
이렇게 이름 붙은 Event를 남겨두면, 나중에 다른 규칙이 붙기가 쉬워진다.
DamageDealt = 데미지가 들어갔다
ContactHappened = 접촉이 발생했다
StatusInflicted = 상태이상이 걸렸다
HPRecovered = 체력이 회복됐다
BattlerFainted = 포켓몬이 쓰러졌다
WeatherChanged = 날씨가 바뀌었다
MoveMissed = 기술이 빗나갔다
이건 단순히 로그를 찍기 위해서만 필요한 게 아니다. 배틀 내부 규칙이 "무슨 일이 벌어졌는가"를 읽는 데도 필요하다.
Effect만으로는 왜 부족한가
조금 단순하게 만들려고 하면 가끔 이런 식으로 생각하게 된다.
데미지를 줬다
-> Battle.lastDamage 에 기록한다
-> 다음 효과가 그 값을 읽는다
이건 짧게 보면 꽤 그럴듯하다. 실제로 기가드레인처럼 "방금 발생한 데미지 결과의 일부만큼 회복한다"는 기술은 이것만으로도 얼추 돌아갈 것처럼 보인다.
그런데 조금만 복잡해지면 금방 불편해진다.
예를 들어 이런 질문이 붙기 시작한다.
그 lastDamage는 누가 준 데미지인가?
누가 맞은 데미지인가?
직전에 여러 번 맞았으면 어느 타격의 데미지인가?
접촉 기술이었는가?
급소였는가?
대타출동에 막힌 것인가?
그제서야 "숫자 하나만 들고 있는 것"으로는 부족하다는 게 보인다. 필요한 건 단순한 값이 아니라, "무슨 일이 일어났는가"에 대한 구조화된 기록이다.
그래서 Event가 더 낫다.
flowchart TB
A[Damage Effect]
A --> B[HP 감소]
A --> C[DamageDealt Event]
C --> D[회복/반동 계산]
C --> E[로그 출력]
C --> F[특성/도구 반응]
그림에 나온 구체적인 effect들까지 붙여 보면 대응은 대충 다음과 같이 파악할 수 있다.
FormulaDamage -> DamageDealt
DirectDamage -> DamageDealt
접촉 기술의 타격 -> ContactHappened
RestoreHP -> HPRecovered
Paralyze -> StatusInflicted
AttackStatChange-> StatChanged
Faint / OHKO -> BattlerFainted
NoEffect -> 보통은 아무 Event도 남기지 않음
명중 실패 분기 -> MoveMissed
다만 이걸 너무 딱딱한 1:1 대응으로 볼 필요는 없다. 하나의 Effect가 Event를 여러 개 남길 수도 있고, NoEffect처럼 아무 Event도 남기지 않을 수도 있다. 중요한 건 "상태를 바꾼다"와 "무슨 일이 일어났는지 남긴다"가 분리된다는 점이다.
이렇게 대응을 잡아두면 3편의 Effect 계층과 4편의 Event 계층이 자연스럽게 이어진다.
Battle Log
Event를 만들었으면, 이제 그걸 어디엔가 남겨야 한다.
제일 자연스러운 자리는 Battle 안이다. Battle이 원래도 현재 문맥을 들고 있는 곳이니, 배틀 중에 일어난 일들의 기록도 여기에 붙는 편이 잘 어울린다.
classDiagram
class Battle {
+Battler attacker
+Battler defender
+List~IEvent~ events
}
class IEvent
Battle --> IEvent
이렇게 해두면 어떤 Effect가 실행될 때마다 Battle 안에 Event가 하나씩 쌓인다.
flowchart LR
A[Effect 실행] --> B[상태 변경]
B --> C[Event 생성]
C --> D[Battle.events 에 추가]
이 기록은 여러 군데서 읽을 수 있다.
화면 로그를 만드는 쪽은 "무슨 문장을 보여줄까?"를 위해 읽고, 후속 규칙은 "방금 무슨 일이 일어났지?"를 위해 읽는다. 테스트 코드도 이걸 보면 편하다. 단순히 HP 숫자만 확인하는 게 아니라, 실제로 어떤 Event가 발생했는지도 함께 볼 수 있기 때문이다.
예를 들어 절대영도 같은 OHKO 계열은 상태 변화만 보면 "HP가 0이 됐다"로 끝낼 수도 있다. 그런데 Event 관점에서는 보통 BattlerFainted가 더 중요하다. 실제 후속 규칙은 HP 숫자보다 "쓰러졌다"는 사실에 반응하는 경우가 많기 때문이다.
AttemptResult
여기서 한 단계 더 생각해볼 수 있다.
Battle 전체에는 긴 로그가 쌓이지만, 현재 Attempt 하나만 놓고 보면 "이번 시도에서 무슨 일이 일어났는가"를 따로 묶어 보는 편이 편할 때가 있다.
예를 들어 AttemptResult 같은 걸 두면 이런 정보를 담을 수 있다.
명중했는가
빗나갔는가
이번 시도에서 발생한 Event 목록
이번 시도에서 발생한 데미지 결과는 무엇인가
이 시도로 누가 쓰러졌는가
이건 Battle 전체 로그보다 범위가 더 좁다. 방금 실행한 한 번을 따로 묶어 보여주는 정보라고 생각하면 된다.
flowchart LR
A[Attempt 실행] --> B[Effect 실행]
B --> C[Event 발생]
C --> D[AttemptResult 생성]
D --> E[Battle에 반영]
이런 게 있으면 Sequence 안에서 다음 단계가 앞 단계 결과를 읽기가 쉬워진다.
이를테면 기가드레인은 "이번 시도에서 발생한 데미지 결과"를 읽어야 하고, 플레어드라이브는 "이번 타격이 접촉이었는가" 같은 정보를 읽을 수 있어야 한다. 단순히 전체 Battle 로그를 전부 탐색하는 것보다, 현재 Attempt 결과를 요약한 묶음이 하나 있는 편이 구조적으로 이해하기 쉽다.
여기서 NoEffect도 의외로 의미가 있다. 아무 변화가 없더라도 AttemptResult는 여전히 남을 수 있기 때문이다. 예를 들어 추가 효과 판정이 실패했다면 상태이상 Event는 없겠지만, "이번 분기에서는 아무 일도 일어나지 않았다"는 건 여전히 흐름상 중요한 정보다.
Trigger
Event가 진짜 빛나는 건 여기서다. 다른 규칙이 그 Event를 보고 반응할 수 있기 때문이다.
예를 들어 상대를 접촉 기술로 때렸다고 해보자. 그러면 어떤 특성이나 도구는 "접촉당했음"이라는 사실을 보고 반응할 수 있다. 또 어떤 규칙은 포켓몬이 쓰러졌다는 Event를 보고 교체 단계를 열어야 한다.
어떤 규칙은 직접 HP를 들여다보기보다, Event를 보고 반응하는 쪽이 더 잘 맞는다.
flowchart LR
A[DamageDealt Event] --> B[로그 출력]
A --> C[접촉 반응 검사]
A --> D[생존 보정 적용 후 결과 검사]
A --> E[쓰러짐 여부 검사]
예를 들어 플레어드라이브가 맞은 뒤를 생각해보면 흐름은 대충 이럴 것이다.
1. 상대에게 데미지가 들어간다
2. DamageDealt Event가 남는다
3. 상대가 쓰러졌는지 확인한다
4. 추가 효과가 붙었으면 StatusInflicted Event가 남는다
5. 마지막으로 사용자 반동이 들어간다
6. 그 반동도 또 하나의 DamageDealt Event로 남는다
이렇게 보면 반동 데미지도 그냥 "특별한 예외"가 아니라, 또 하나의 Damage Event일 뿐이라는 게 보인다. 대상만 달라졌을 뿐이다.
적용
몇 개만 아주 짧게 적어보면 Event 쪽 감각이 더 잘 보인다.
10만 볼트는 이런 식이다.
10만 볼트
= DamageDealt(피카츄 -> 상대)
+ 조건을 통과하면 StatusInflicted(상대, 마비)
여기서 데미지가 FormulaDamage에서 왔는지, DirectDamage에서 왔는지 같은 정보가 Event에 함께 실리면 더 좋다. 그래야 나중에 로그나 후속 규칙이 "어떤 종류의 데미지였는가"까지 읽을 수 있다.
기가드레인은 이렇다.
기가드레인
= DamageDealt(사용자 -> 상대)
+ HPRecovered(사용자, 이번 시도의 DamageDealt 기반)
플레어드라이브는 조금 더 길어진다.
플레어드라이브
= DamageDealt(사용자 -> 상대)
+ 조건을 통과하면 StatusInflicted(상대, 화상)
+ DamageDealt(사용자 -> 사용자, 반동)
비바라기는 개별 포켓몬보다 Battle 전체에 가까운 Event를 남긴다.
비바라기
= WeatherChanged(맑음 -> 비)
그림 기준의 다른 예도 이어 붙일 수 있다.
칼춤
= StatChanged(사용자, 공격 +2)
회복
= HPRecovered(사용자, 회복량)
절대영도
+ 명중에 성공했다면 BattlerFainted(상대)
이렇게 적고 보면 Event는 기술 이름보다 "무슨 일이 일어났는가"를 더 잘 드러낸다.
이번 글 정리
이번 글에서 하고 싶었던 얘기는 단순하다.
Effect가 상태를 바꾸는 것만으로는 조금 부족하다. 배틀 시스템은 그와 동시에 "방금 무슨 일이 일어났는가"도 알아야 한다. 그래야 로그를 만들 수 있고, 후속 트리거를 붙일 수 있고, 다음 규칙이 앞 단계 결과를 읽을 수 있다.
다시 짧게 적어보면 이렇다.
Effect = 상태를 바꾼다
Event = 그 변화가 일어났다는 사실을 남긴다
Result = 이번 실행 하나를 요약한다
포켓몬 배틀처럼 한 번의 기술 안에서도 여러 단계가 이어지고, 그 단계마다 다른 규칙이 반응하는 도메인에서는 이 구분이 꽤 유용하다.
다음에는 여기서 더 들어가서, 데미지 공식처럼 "숫자를 어떻게 계산할 것인가"를 따로 떼어 생각해볼 수도 있겠다. 지금까지 Condition, Effect, Event를 봤다면, 이제는 "그 숫자는 어디서 오는가"를 볼 차례이기도 하다.