9 분 소요

🎮 2D SRPG 만들기 선행학습 6부작 — 파랜드 택틱스 같은 쿼터뷰 SRPG 를 직접 만들기 위한 기초 체력을 쌓습니다. 게임 루프부터 세이브/로드, 맵 에디터, UI, 사운드, 연출까지 편마다 실행되는 코드로 하나씩. 전체 10편.
  1. 게임 루프와 델타타임 — 모든 게임의 심장
  2. 타일맵과 쿼터뷰 좌표계 — 마름모 세상의 수학
  3. 스프라이트와 애니메이션 — 캐릭터에 생명 불어넣기
  4. 이동 범위와 A* 길찾기 — SRPG 의 파란 칸
  5. 턴제 전투와 상태머신 — 턴 큐·데미지·적 AI
  6. 데이터 설계와 세이브/로드 — 게임을 콘텐츠로 채우기
  7. Tiled 맵 에디터 — 배열 손편집 졸업하기
  8. UI 와 메뉴 — 정보를 보여주는 기술지금 글
  9. 사운드 — 타격감은 귀에서 나온다
  10. 연출 — 손맛을 만드는 잔기술

Summary

7편까지로 게임의 뼈대와 맵 파이프라인이 완성됐는데, 막상 플레이해보면 답답한 게 하나 있어요. 정보가 안 보입니다. HP 는 조그만 막대뿐이라 정확한 숫자를 모르겠고, 공격이 몇 % 확률로 맞는지는 콘솔에만 찍히고, 이동이 끝나면 무조건 인접 적을 자동 공격해버려서 “그냥 대기”를 선택할 수도 없죠.

파랜드 택틱스를 떠올려보면 화면 아래에 항상 유닛 정보창이 떠 있고, 행동을 고르는 메뉴가 뜨고, 공격 전에 예상 결과를 보여줬어요. 이런 UI(User Interface) 가 없으면 게임은 시스템일 뿐, 플레이할 수 있는 물건이 아닙니다. 오늘은 pygame 으로 이 UI 층을 만드는 기술을 다뤄요. 5편의 상태머신이 “새 기능은 새 상태로”라는 약속을 지키는 걸 실전으로 확인하는 편이기도 합니다.

💡 이 글에서 다루는 것

  • UI 의 두 층 — 전장을 따라가는 월드 UI 와 화면에 고정된 스크린 UI
  • 한글 폰트 렌더링 — OS 별 폴백과 캐싱 (매 프레임 만들면 안 돼요)
  • 유닛 정보 패널 — 반투명 Surface 로 만드는 HUD
  • 행동 메뉴 — 상태머신에 ACTION 상태 추가 + Rect 클릭 판정
  • 전투 예측창 — 공격 확정 전에 명중률·예상 데미지 보여주기
  • 데미지 팝업 — 떠올랐다 사라지는 숫자, 델타타임 응용



1. UI 의 두 층 — 월드와 스크린

UI 를 만들기 전에 좌표계부터 정리해야 합니다. 게임 UI 는 사는 곳이 다른 두 종류가 있어요.

예시 좌표계 카메라 이동 시
월드 UI HP 바, 데미지 팝업,
유닛 머리 위 아이콘
전장(그리드→화면 변환) 같이 움직임
스크린 UI 정보 패널, 행동 메뉴,
턴 표시, 골드
화면 픽셀 고정 안 움직임

5편에서 만든 HP 바는 유닛 좌표(tile_center)에 붙어 있으니 월드 UI 였던 거예요. 오늘 만들 정보 패널과 메뉴는 스크린 UI 라서 카메라와 무관하게 화면 구석에 고정됩니다.

그리는 순서도 층이 정합니다. 맵 → 유닛 → 월드 UI → 스크린 UI — 뒤로 갈수록 위에 덮여요. 스크린 UI 가 항상 맨 마지막인 이유는 간단합니다. 정보창이 캐릭터에 가려지는 게임은 없으니까요.

# 매 프레임 그리기 순서 (규칙으로 고정)
draw_map()          # 1. 타일
draw_units()        # 2. 유닛 스프라이트
draw_world_ui()     # 3. HP 바, 데미지 팝업
draw_screen_ui()    # 4. 정보 패널, 메뉴 — 항상 최상단



2. 텍스트 — 한글 폰트와 캐싱

UI 의 8할은 텍스트인데, pygame 텍스트엔 두 가지 함정이 있어요. 한글성능입니다.

pygame 기본 폰트는 한글 글리프가 없어서 한글이 네모(□)로 깨집니다. OS 에 설치된 폰트를 SysFont 로 불러와야 하는데, OS 마다 폰트 이름이 달라요. 다행히 SysFont 는 쉼표로 이은 이름 목록을 받아서 앞에서부터 있는 폰트를 골라 줍니다. 3대 OS 를 한 줄로 커버할 수 있어요.

import pygame

pygame.init()
screen = pygame.display.set_mode((960, 540))

# macOS / Windows / Linux 폴백 스택
font = pygame.font.SysFont("applesdgothicneo,malgungothic,notosanscjkkr", 20)
surf = font.render("공격", True, (248, 250, 252))
print(type(surf).__name__, surf.get_size())
Surface (34, 24)

render 의 결과는 익숙한 Surface 입니다. 3편에서 배운 대로 blit 하면 끝이에요. 크기는 설치된 폰트에 따라 조금씩 다르게 나올 수 있습니다.

두 번째 함정이 성능이에요. SysFont 로 폰트를 만드는 건 디스크에서 폰트 파일을 찾는 느린 작업이라, 게임 루프 안에서 부르면 프레임이 뚝뚝 떨어집니다. render 도 공짜가 아니라서, 매 프레임 같은 문자열을 다시 그리는 건 낭비고요. 원칙은 이겁니다.

⚠️ 폰트 객체는 시작할 때 한 번만, render 결과는 내용이 바뀔 때만. 폰트는 전역에서 만들어두고, “공격”/”대기” 같은 고정 라벨은 render 결과 Surface 를 변수에 담아 재사용하세요. HP 처럼 바뀌는 텍스트만 값이 바뀐 프레임에 다시 render 합니다.

자주 쓰니 헬퍼 하나 만들어두면 편합니다.

FONT = pygame.font.SysFont("applesdgothicneo,malgungothic,notosanscjkkr", 16)

def draw_text(surface, text, x, y, color=(226, 232, 240)):
    surface.blit(FONT.render(text, True, color), (x, y))

print(FONT.render("HP 24 / 24", True, (255, 255, 255)).get_size())
(72, 20)



3. 유닛 정보 패널 — 반투명 HUD

유닛에 마우스를 올리거나 선택하면 화면 하단에 정보가 뜨는 패널을 만들어봅시다. 포인트는 반투명이에요. 불투명 패널은 전장을 가려서 답답하고, 반투명은 3편에서 배운 SRCALPHA Surface 로 만듭니다 — RGBA 의 네 번째 값이 불투명도(0~255)예요.

def draw_unit_panel(screen, unit):
    panel = pygame.Surface((300, 96), pygame.SRCALPHA)
    panel.fill((14, 23, 38, 210))                        # 반투명 남색
    pygame.draw.rect(panel, (94, 198, 214), panel.get_rect(), 2, border_radius=8)

    draw_text(panel, f"{unit['name']}  ({unit['team']})", 14, 10)
    draw_text(panel, f"HP {unit['hp']} / {unit['max_hp']}", 14, 36)
    draw_text(panel, f"공격 {unit['atk']}   방어 {unit['dfn']}   이동 {unit['mov']}", 14, 62)

    screen.blit(panel, (16, 540 - 96 - 16))              # 좌하단 고정

6편의 유닛 인스턴스(딕셔너리)를 그대로 받아서 그립니다. 게임 루프 쪽에선 “마우스가 올라간 유닛 또는 선택된 유닛”을 골라 한 줄 부르면 돼요.

hover_unit = unit_at(screen_to_grid(*pygame.mouse.get_pos()))
if selected or hover_unit:
    draw_unit_panel(screen, selected or hover_unit)

파랜드 택틱스에서 커서를 적 유닛에 올리면 즉시 능력치가 보이던 것, 정확히 이 구조입니다. 정보가 클릭 없이 보인다는 게 SRPG UI 의 중요한 매너예요 — 플레이어는 적 능력치를 보고 나서 움직일지 말지 결정하니까요.



4. 행동 메뉴 — 상태머신에 상태 하나 추가

5편 미니 전투의 아쉬운 점은 이동이 끝나면 무조건 자동 공격한다는 거였어요. 진짜 SRPG 는 이동 후 메뉴가 떠서 [공격 / 대기] 를 고르게 합니다. 5편에서 약속한 “새 기능은 새 상태로”를 지킬 시간이에요 — ACTION 상태를 추가합니다.

[SELECT] ─유닛 클릭→ [MOVE_TARGET] ─칸 클릭→ [MOVING]
    ↑                                          │ 이동 완료
    │                                          ↓
[ENEMY_TURN] ←전원 완료─ (턴 소모) ←공격/대기─ [ACTION]  ← 새 상태

메뉴의 구현 재료는 pygame.Rect 하나면 충분합니다. 항목마다 Rect 를 만들어두고, 클릭 좌표가 어느 Rect 안인지 collidepoint 로 판정해요.

menu_rects = {"공격": pygame.Rect(700, 200, 120, 36),
              "대기": pygame.Rect(700, 240, 120, 36)}

click = (752, 258)                       # 클릭 좌표가 들어왔다고 치면
picked = next((name for name, r in menu_rects.items()
               if r.collidepoint(click)), None)
print(picked)
대기

(752, 258) 은 “대기” 버튼의 영역 안이라 대기 가 나왔어요. 그리기는 Rect 그대로 사각형을 그리고 위에 텍스트를 얹으면 됩니다.

def draw_action_menu(screen, rects, hover_pos):
    for name, r in rects.items():
        hovered = r.collidepoint(hover_pos)
        pygame.draw.rect(screen, (40, 56, 84) if hovered else (23, 33, 51), r,
                         border_radius=6)
        pygame.draw.rect(screen, (94, 198, 214), r, 2, border_radius=6)
        draw_text(screen, name, r.x + 44, r.y + 8)

상태머신 쪽 변화는 딱 두 가지입니다. MOVING 이 끝나면 자동 공격 대신 STATE = "ACTION" 으로 전이하고, ACTION 상태의 클릭은 메뉴 Rect 에만 반응해요. 메뉴가 떠 있는 동안 전장 클릭이 무시되는 것 — UI 용어로 모달(modal) 이라고 하는데, 상태머신 구조에선 공짜로 얻어집니다. ACTION 상태엔 전장 클릭 처리 코드가 아예 없으니까요.

💡 키보드 지원도 같은 요령이에요. ACTION 상태에서 ↑↓ 로 menu_index 를 바꾸고 Enter 로 확정하면 패드 친화적인 고전 SRPG 조작감이 됩니다. 상태가 입력의 의미를 정한다는 5편의 원칙은 마우스든 키보드든 똑같이 통해요.



5. 전투 예측창 — 클릭 전에 결과를 보여주는 매너

공격을 골랐다고 바로 때리면 안 됩니다. 명중률 65% 짜리 공격인 줄 모르고 질렀다가 빗나가면, 플레이어는 게임이 아니라 자신의 운을 탓하게 돼요. 그래서 SRPG 는 공격 확정 전에 예상 결과를 보여줍니다. 파이어 엠블렘이 정립한 문법이고, 파랜드 택틱스류도 마찬가지예요.

예측에 새 공식은 필요 없습니다. 5편의 데미지·명중 공식에서 난수만 빼면 그게 예측이에요.

def battle_forecast(attacker, defender):
    base = max(1, attacker["atk"] - defender["dfn"])
    chance = max(10, min(100, attacker["acc"] - defender["evade"]))
    return {"hit": chance, "dmg_min": round(base * 0.9),
            "dmg_max": round(base * 1.1), "crit": 10}

fc = battle_forecast({"atk": 14, "acc": 90}, {"dfn": 6, "evade": 25})
print(fc)
{'hit': 65, 'dmg_min': 7, 'dmg_max': 9, 'crit': 10}

“명중 65% · 데미지 7~9 · 크리 10%” — 이 딕셔너리를 3장의 패널과 같은 방식으로 그려서 공격 대상 옆에 띄우면 됩니다. 흐름은 상태 하나를 더 쪼개요. ACTION 에서 “공격”을 고르면 TARGETING 상태로 가서 사거리 내 적을 빨갛게 하이라이트하고, 적을 클릭하면 예측창을 띄우고, 한 번 더 클릭하면 확정합니다.

✅ 예측과 실제 계산이 같은 수식을 공유하는 게 중요합니다. 예측 함수 따로, 실제 데미지 함수 따로 만들면 언젠가 한쪽만 수정돼서 “예측 7~9랬는데 14가 떴다” 같은 신뢰 붕괴 버그가 나요. calc_damage 의 상수(0.9, 1.1, 크리 10%)를 바꾸면 예측도 자동으로 따라가게, 공식은 한 곳에만 두세요.



6. 데미지 팝업 — 숫자가 떠오르게

마지막으로 월드 UI 하나를 얹습니다. 타격 순간 데미지 숫자가 유닛 머리 위에서 떠오르며 사라지는 연출 — 콘솔 print 를 화면으로 옮기는 작업이에요. 원리는 1편 델타타임의 응용입니다. 팝업마다 남은 수명을 들고, 매 프레임 위로 이동시키면서 수명이 다하면 지워요.

popups = []      # 살아있는 팝업 목록

def add_popup(text, x, y, color=(255, 217, 102)):
    popups.append({"surf": FONT.render(text, True, color),
                   "x": x, "y": y, "life": 0.8})          # 0.8초 수명

def update_and_draw_popups(screen, dt):
    for p in popups[:]:                    # 순회 중 삭제하니 복사본으로
        p["life"] -= dt
        p["y"] -= 40 * dt                  # 초당 40px 상승
        if p["life"] <= 0:
            popups.remove(p)
            continue
        p["surf"].set_alpha(int(255 * min(1, p["life"] / 0.4)))   # 막판 페이드
        screen.blit(p["surf"], (int(p["x"]), int(p["y"])))

5편의 try_attack 안에 있던 print(...) 자리에 add_popup(f"-{dmg}", ...) 을 넣으면 끝입니다. 크리티컬이면 색을 바꾸고 텍스트를 키우는 것도 한 줄이에요. 수명 마지막 0.4초 동안 set_alpha 로 투명해지게 한 디테일이 체감을 크게 바꿉니다.



7. 실습 — 미니 전투에 UI 얹기

5편 미니 전투에 이번 편의 부품을 전부 얹은 조립도입니다. 이전 편 함수들(생략 표시)은 그대로 두고, 바뀌는 곳은 상태머신 분기뿐이라는 걸 눈여겨보세요.

import pygame, heapq, random

pygame.init()
screen = pygame.display.set_mode((960, 540))
pygame.display.set_caption("SRPG 선행학습 8편 — UI 와 메뉴")
clock = pygame.time.Clock()

# MAP·좌표 변환·draw_tile·tile_center            ← 2편 그대로
# movement_range·astar·update_walk               ← 4편 그대로
# calc_damage·enemy_act·make_unit·unit_at        ← 5편 그대로
# FONT·draw_text·draw_unit_panel·draw_action_menu·battle_forecast·팝업 ← 본문 그대로

units = [make_unit(1, 1, "ally"), make_unit(2, 6, "ally"),
         make_unit(6, 2, "enemy"), make_unit(6, 6, "enemy")]
STATE, selected, reach, target = "SELECT", None, {}, None
menu_rects = {"공격": pygame.Rect(700, 200, 120, 36),
              "대기": pygame.Rect(700, 240, 120, 36)}

def adjacent_enemies(u):
    return [t for nb in neighbors(u["gx"], u["gy"])
            if (t := unit_at(nb)) and t["team"] != u["team"]]

def end_action(u):
    global STATE, selected, target
    u["acted"], selected, target = True, None, None
    STATE = "SELECT"          # (아군 전원 완료 시 ENEMY_TURN 전이는 5편 그대로)

running = True
while running:
    dt = clock.tick(60) / 1000
    mouse = pygame.mouse.get_pos()
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.MOUSEBUTTONDOWN:
            g = screen_to_grid(*event.pos)
            if STATE == "SELECT":                         # 5편과 동일
                u = unit_at(g)
                if 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", {}
            elif STATE == "ACTION":                       # 메뉴에만 반응 (모달)
                if menu_rects["공격"].collidepoint(event.pos) and adjacent_enemies(selected):
                    STATE = "TARGETING"
                elif menu_rects["대기"].collidepoint(event.pos):
                    end_action(selected)
            elif STATE == "TARGETING":
                t = unit_at(g)
                if t and t in adjacent_enemies(selected):
                    if target is t:                       # 두 번째 클릭 → 확정
                        dmg, crit = calc_damage(selected["atk"], t["dfn"])
                        t["hp"] = max(0, t["hp"] - dmg)
                        cx, cy = tile_center(t["gx"], t["gy"])
                        add_popup(f"-{dmg}" + ("!" if crit else ""), cx - 10, cy - 40)
                        end_action(selected)
                    else:
                        target = t                        # 첫 클릭 → 예측창
                else:
                    STATE, target = "ACTION", None        # 빗나간 클릭 → 메뉴로

    if STATE == "MOVING":
        update_walk(selected, dt)
        if not selected["path"]:
            STATE = "ACTION"                              # 자동 공격 대신 메뉴

    # --- 그리기: 맵 → 유닛 → 월드 UI → 스크린 UI ---
    screen.fill((30, 30, 46))
    draw_map(); draw_units()
    update_and_draw_popups(screen, dt)                    # 월드 UI
    hover = unit_at(screen_to_grid(*mouse))
    if selected or hover:
        draw_unit_panel(screen, selected or hover)        # 스크린 UI
    if STATE == "ACTION":
        draw_action_menu(screen, menu_rects, mouse)
    if STATE == "TARGETING" and target:
        fc = battle_forecast(selected, target)
        draw_text(screen, f"명중 {fc['hit']}%  데미지 {fc['dmg_min']}~{fc['dmg_max']}  크리 {fc['crit']}%",
                  600, 160, (255, 217, 102))
    pygame.display.flip()

pygame.quit()

돌려보면 게임의 인상이 완전히 달라집니다. 유닛에 마우스를 올리면 능력치가 뜨고, 이동이 끝나면 메뉴가 열리고, 공격 대상을 찍으면 명중률이 보이고, 확정하면 데미지 숫자가 떠올라요. 시스템은 5편 그대로인데 정보의 창구가 생기니 비로소 게임처럼 느껴집니다.

🎉 상태머신에 상태 두 개(ACTION·TARGETING)를 추가했을 뿐인데 조작 흐름 전체가 바뀌었어요. 5편에서 “새 기능은 새 상태로”라고 했던 약속이 지켜지는 순간입니다. 취소(우클릭이나 ESC 로 이전 상태 복귀)도 같은 요령으로 붙여보세요 — 좋은 연습 문제예요.



8. 정리

  • UI 는 두 층 — 월드 UI(카메라 따라감) / 스크린 UI(고정, 항상 맨 위)
  • 한글 폰트는 SysFont 폴백 스택으로, 폰트 객체와 render 결과는 캐싱
  • 패널은 SRCALPHA 반투명 Surface — 정보는 클릭 없이 보여주는 게 매너
  • 메뉴 = Rect + collidepoint, 모달은 상태머신에서 공짜
  • 전투 예측 = 데미지 공식에서 난수 빼기 — 공식은 반드시 한 곳에만
  • 데미지 팝업 — 수명·상승·페이드, 전부 델타타임 응용

눈에 보이는 것들은 이제 다 갖췄어요. 그런데 지금 우리 게임은 무음입니다. 공격이 맞아도 조용하고, 메뉴를 눌러도 조용하고, 전장에 음악도 없죠. 게임의 체감에서 소리가 차지하는 비중은 생각보다 훨씬 큽니다 — 타격음 하나가 데미지 팝업보다 손맛을 더 바꿔요.

일단 오늘은 여기까지….. 다음 글에서는 로드맵의 3번, 사운드를 정리해볼게요.


← 이전 글: (7/10) 2D SRPG 만들기 선행학습 — Tiled 맵 에디터로 전장 만들기다음 글 →: (9/10) 2D SRPG 만들기 선행학습 — 사운드, 타격감은 귀에서 나온다