(5/10) 2D SRPG 만들기 선행학습 — 턴제 전투와 상태머신
Summary
4편까지로 유닛이 규칙대로 움직이게 됐으니, 이제 싸우게 만들 차례예요. 그런데 전투를 만들기 시작하면 코드가 갑자기 스파게티가 되기 시작합니다. “지금 유닛을 선택하는 중인가, 이동 칸을 고르는 중인가, 공격 대상을 고르는 중인가, 적 턴인가”에 따라 같은 클릭이 전혀 다른 의미가 되거든요.
이 복잡함을 길들이는 도구가 상태머신(FSM, Finite State Machine) 입니다. 게임 개발 전체를 통틀어 가장 자주 쓰는 설계 패턴이고, 오늘 이걸 중심으로 턴 순서·데미지 공식·적 AI 를 얹어 전투를 완성해요.
💡 이 글에서 다루는 것
- 상태머신 — “지금 무엇을 하는 중인가”를 코드 구조로 만들기
- 턴 순서 설계 — 진영 교대 방식 vs 속도 기반 방식
- 데미지 공식과 명중 판정 — 고전 SRPG 스타일 그대로
- 적 AI — 가장 가까운 아군에게 접근해서 공격하는 그리디 AI
- 실습 — 아군 2 vs 적 2 미니 전투 완성
1. 상태머신 — 전투 흐름을 길들이는 도구
파랜드 택틱스의 한 턴을 관찰하면 이런 흐름이 보여요.
[유닛 선택 대기] ─유닛 클릭→ [이동 칸 선택] ─칸 클릭→ [이동 중]
↑ │
│ 이동 완료
│ ↓
[적 턴 진행] ←아군 전원 행동 완료─ [행동 선택: 공격/대기]
각 네모가 상태(state), 화살표가 전이(transition) 입니다. 상태머신의 규칙은 단 두 개예요. 게임은 항상 정확히 하나의 상태에 있고, 입력과 업데이트는 현재 상태에 따라 다르게 처리됩니다.
구현은 거창할 것 없이 문자열 하나와 분기면 충분합니다.
STATE = "SELECT" # SELECT / MOVE_TARGET / MOVING / ACTION / ENEMY_TURN
def on_click(pos):
global STATE
g = screen_to_grid(*pos)
if STATE == "SELECT":
if find_ally_at(g):
STATE = "MOVE_TARGET" # 이동 범위 표시 시작
elif STATE == "MOVE_TARGET":
if g in reach:
STATE = "MOVING" # 걷기 시작
elif STATE == "ACTION":
if find_enemy_at(g) and is_adjacent(g):
attack(...)
이 구조의 힘은 “이 클릭이 무슨 의미인지” 고민이 사라진다는 거예요. SELECT 상태의 클릭은 유닛 선택일 수밖에 없고, MOVING 상태에선 클릭을 아예 무시합니다. 버그의 온상인 “이동 중에 다른 유닛을 클릭하면?” 같은 엣지 케이스가 구조적으로 차단돼요.
💡 상태가 5개를 넘고 상태별 진입/퇴장 처리가 필요해지면 문자열 대신 클래스(상태 객체 패턴)로 올라가면 됩니다. 원리는 동일해요. 선행학습 단계에선 문자열 + 분기로 감각을 잡는 게 먼저입니다.
2. 턴 순서 — 두 가지 유파
턴제 게임의 턴 순서는 크게 두 유파가 있습니다.
| 방식 | 흐름 | 대표작 | 구현 난이도 |
|---|---|---|---|
| 진영 교대 | 아군 전원 행동 → 적군 전원 행동 | 파이어 엠블렘, 파랜드 택틱스 | 쉬움 |
| 속도 기반 | 민첩이 높은 유닛부터 개별 턴 | 파이널판타지 택틱스 | 중간 |
이 시리즈는 진영 교대 방식으로 갑니다. 구현이 단순한데도 전략성은 충분하고, 파랜드 택틱스의 감성에도 이쪽이 가까워요. 구현은 “이번 턴에 아직 행동 안 한 아군 목록”을 관리하는 게 전부입니다.
def start_player_turn(units):
for u in units:
if u["team"] == "ally":
u["acted"] = False # 전원 행동권 리셋
def player_turn_done(units):
return all(u["acted"] for u in units if u["team"] == "ally" and u["hp"] > 0)
아군이 행동을 마칠 때마다 acted = True 로 찍고, 전원이 True 가 되면 ENEMY_TURN 상태로 전이합니다. 속도 기반이 궁금하다면, 유닛마다 속도 게이지를 dt 로 채워서 100이 넘은 유닛에게 턴을 주는 방식(CTB)으로 확장하면 돼요 — 상태머신 골격은 그대로 재사용됩니다.
3. 데미지 공식 — 숫자가 손맛을 만든다
데미지 공식은 게임의 손맛을 결정하는 곳이에요. 고전 SRPG 의 표준은 의외로 단순합니다 — 공격력에서 방어력을 빼고, 약간의 난수와 크리티컬을 얹는 것.
import random
def calc_damage(atk, dfn, rng=random):
base = max(1, atk - dfn) # 최소 1은 들어가게
variance = rng.uniform(0.9, 1.1) # ±10% 난수
crit = rng.random() < 0.10 # 10% 크리티컬
return round(base * variance * (2 if crit else 1)), crit
# 공격 14, 방어 6인 상대를 4번 때려보면
random.seed(7)
for _ in range(4):
print(calc_damage(14, 6))
(8, False)
(16, True)
(8, False)
(7, False)
기본 데미지 8에 ±10% 가 흔들리고, 두 번째 타격은 크리티컬이 터져 16이 들어갔어요. max(1, ...) 이 은근히 중요한데, 방어가 공격보다 높은 탱커전에서 데미지 0만 반복되면 게임이 영원히 안 끝나기 때문입니다.
명중 판정도 같은 요령이에요. 명중률에서 회피율을 빼되, 상한과 하한을 둡니다.
def hit_check(acc, evade, rng=random):
chance = max(10, min(100, acc - evade)) # 10~100% 로 클램프
return rng.randint(1, 100) <= chance, chance
random.seed(7)
print(hit_check(90, 25))
(True, 65)
⚠️ 밸런스 감각 하나: 명중률 하한을 두는 이유는 “절대 못 맞추는 상대”가 생기면 전략이 아니라 답이 없는 상황이 되기 때문이에요. 반대로 상한을 100 미만(예: 95)으로 두면 “확정 상황”이 사라져 긴장감이 생깁니다. 이런 숫자들이 전부 게임 디자인 결정이에요 — 공식은 베끼되 숫자는 직접 굴려보면서 조정하세요.
4. 적 AI — 그리디면 충분히 무섭다
“AI” 라고 하면 거창하게 들리지만, SRPG 적 AI 의 첫걸음은 그리디(탐욕) 전략 하나면 충분합니다. 적 유닛 하나가 자기 턴에 하는 일을 순서대로 적으면 이래요.
- 살아있는 아군 중 가장 가까운 대상을 고른다
- 이동 범위 안에서 그 대상에게 가장 가까워지는 칸으로 이동한다
- 이동 후 대상이 공격 사거리 안이면 공격한다
4편의 movement_range 와 A* 가 여기서 그대로 재활용됩니다. 코드로 옮기면:
def enemy_act(enemy, allies):
alive = [a for a in allies if a["hp"] > 0]
if not alive:
return
# 1. 맨해튼 거리 기준 가장 가까운 아군
target = min(alive, key=lambda a: abs(a["gx"] - enemy["gx"])
+ abs(a["gy"] - enemy["gy"]))
# 2. 이동 범위 중 대상과 가장 가까워지는 칸 (점유 칸 제외)
reach = movement_range((enemy["gx"], enemy["gy"]), enemy["mov"])
free = {g: d for g, d in reach.items() if not unit_at(g) or g == (enemy["gx"], enemy["gy"])}
dest = min(free, key=lambda g: abs(g[0] - target["gx"]) + abs(g[1] - target["gy"]))
enemy["path"] = astar((enemy["gx"], enemy["gy"]), dest)[1:]
# 3. 공격은 이동이 끝난 뒤 인접 판정으로 (상태머신에서 처리)
이 단순한 규칙만으로도 적들이 우르르 몰려와 포위하는, 꽤 SRPG 다운 압박이 만들어져요. 나중에 “HP 가 낮은 아군 우선”, “원거리 유닛은 거리 유지”, “회복 유닛 먼저 노리기” 같은 규칙을 점수 함수로 추가하면 난이도가 올라갑니다. 구조는 똑같이 “후보 행동에 점수를 매기고 최고점을 고른다”예요.
✅ 적 AI 를 만들면 얻는 보너스가 하나 있는데, 아군 자동 전투도 공짜로 생깁니다. 같은 함수를 아군에게 돌리면 반복 전투 자동화(요즘 게임의 “자동 진행” 버튼)가 돼요.
5. 실습 — 아군 2 vs 적 2 미니 전투
지금까지의 부품을 상태머신으로 조립한 미니 전투입니다. 2편의 맵/변환, 4편의 알고리즘, 3장의 전투 공식, 4장의 AI 함수는 본문 그대로 쓰니 생략 표시했어요. 아군(청록)을 클릭해 이동시키고, 이동이 끝나면 인접한 적(자주색)을 자동으로 공격합니다. 아군 둘이 행동을 마치면 적 턴이 자동으로 돌아요.
import pygame, heapq, random
pygame.init()
screen = pygame.display.set_mode((960, 540))
pygame.display.set_caption("SRPG 선행학습 5편 — 미니 전투")
clock = pygame.time.Clock()
font = pygame.font.SysFont("applesdgothicneo", 18)
# MAP / 좌표 변환 / draw_tile / tile_center ← 2편 그대로
# neighbors / enter_cost / movement_range / astar / update_walk ← 4편 그대로
# calc_damage / hit_check / enemy_act ← 이번 편 본문 그대로
def make_unit(gx, gy, team):
u = {"gx": gx, "gy": gy, "team": team, "hp": 20, "max_hp": 20,
"atk": 14, "dfn": 6, "mov": 4, "acted": False, "path": []}
u["px"], u["py"] = tile_center(gx, gy)
return u
units = [make_unit(1, 1, "ally"), make_unit(2, 6, "ally"),
make_unit(6, 2, "enemy"), make_unit(6, 6, "enemy")]
def unit_at(g):
return next((u for u in units if (u["gx"], u["gy"]) == g and u["hp"] > 0), None)
def try_attack(attacker):
for nb in neighbors(attacker["gx"], attacker["gy"]):
target = unit_at(nb)
if target and target["team"] != attacker["team"]:
dmg, crit = calc_damage(attacker["atk"], target["dfn"])
target["hp"] = max(0, target["hp"] - dmg)
print(f"{attacker['team']} 공격! {dmg} 데미지{' (크리티컬!)' if crit else ''}")
return
STATE, selected, reach = "SELECT", None, {}
enemy_queue = []
running = True
while running:
dt = clock.tick(60) / 1000
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and STATE in ("SELECT", "MOVE_TARGET"):
g = screen_to_grid(*event.pos)
u = unit_at(g)
if STATE == "SELECT" and u and u["team"] == "ally" and not u["acted"]:
selected, STATE = u, "MOVE_TARGET"
reach = {c: d for c, d in movement_range(g, u["mov"]).items()
if not unit_at(c) or c == g}
elif STATE == "MOVE_TARGET" and g in reach:
selected["path"] = astar((selected["gx"], selected["gy"]), g)[1:]
STATE, reach = "MOVING", {}
if STATE == "MOVING":
update_walk(selected, dt)
if not selected["path"]: # 이동 완료 → 공격 → 턴 소모
try_attack(selected)
selected["acted"], selected = True, None
allies = [u for u in units if u["team"] == "ally" and u["hp"] > 0]
if all(u["acted"] for u in allies):
enemy_queue = [u for u in units if u["team"] == "enemy" and u["hp"] > 0]
for e in enemy_queue:
enemy_act(e, allies) # 경로 미리 계산
STATE = "ENEMY_TURN"
else:
STATE = "SELECT"
elif STATE == "ENEMY_TURN":
if enemy_queue:
e = enemy_queue[0]
update_walk(e, dt)
if not e["path"]:
try_attack(e)
enemy_queue.pop(0)
else: # 적 전원 완료 → 아군 턴
for u in units:
u["acted"] = False
STATE = "SELECT"
screen.fill((30, 30, 46))
for gy in range(ROWS):
for gx in range(COLS):
draw_tile(gx, gy, COLORS[MAP[gy][gx]])
for g in reach:
draw_tile(*g, (90, 140, 230))
for u in units:
if u["hp"] <= 0:
continue
color = (94, 198, 214) if u["team"] == "ally" else (198, 94, 150)
pygame.draw.circle(screen, color, (int(u["px"]), int(u["py"]) - 8), 10)
ratio = u["hp"] / u["max_hp"] # HP 바
pygame.draw.rect(screen, (200, 60, 60), (int(u["px"]) - 12, int(u["py"]) - 26, 24, 4))
pygame.draw.rect(screen, (80, 200, 90), (int(u["px"]) - 12, int(u["py"]) - 26, int(24 * ratio), 4))
pygame.display.flip()
pygame.quit()
돌려보면 놀랄 거예요. 아군을 움직여 공격하고 나면, 적 둘이 각자 알아서 가까운 아군을 향해 걸어와서 때립니다. 상태머신이 클릭·이동·공격·턴 교대를 전부 교통정리하고 있고, HP 바가 깎이면서 전투가 진행돼요. 파랜드 택틱스의 한 판이 아주 거칠게나마 돌아가는 순간입니다.
🎉 승패 판정(한쪽 전멸 시 결과 화면), 공격 애니메이션, 데미지 숫자 팝업 같은 살은 이 골격 위에 붙이면 됩니다. 전부 “상태 하나 추가”로 해결돼요 — VICTORY 상태, ATTACK_ANIM 상태처럼요. 상태머신을 배운 보람이 여기서 나옵니다.
6. 정리
- 상태머신 — 게임은 항상 하나의 상태에 있고, 입력의 의미는 상태가 결정한다
- 진영 교대 턴 —
acted플래그 리셋과 전원 완료 체크가 전부 - 데미지 =
max(1, 공격-방어) x 난수 x 크리— 하한·클램프가 밸런스를 지킨다 - 그리디 적 AI — 가장 가까운 대상에게 접근 후 공격, 4편 알고리즘 재활용
- 새 기능은 새 상태로 — 스파게티 대신 상태 추가
이제 게임은 돌아가는데, 유닛 능력치가 전부 코드에 하드코딩돼 있어요. 캐릭터 20명, 스킬 50개를 이렇게 만들 수는 없죠.
일단 오늘은 여기까지….. 다음 글에서는 데이터 주도 설계와 세이브/로드를 정리해볼게요.
← 이전 글: (4/10) 2D SRPG 만들기 선행학습 — 이동 범위와 A* 길찾기 | 다음 글 →: (6/10) 2D SRPG 만들기 선행학습 — 데이터 설계와 세이브/로드