8 분 소요

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

Summary

파랜드 택틱스 화면을 떠올리면 가장 먼저 생각나는 건 비스듬히 내려다보는 마름모 타일 전장이에요. 이 화면을 쿼터뷰 또는 아이소메트릭 뷰라고 부릅니다. 1편에서 만든 빈 창에 오늘 이 전장을 깔아볼 거예요.

미리 말씀드리면, 쿼터뷰의 핵심은 그림 실력이 아니라 좌표 변환 두 줄입니다. “맵의 (3, 2) 칸은 화면의 어느 픽셀인가”를 계산하는 변환식과 그 역변환만 손에 익으면, 마름모 세상은 절반 이상 정복한 거예요.

💡 이 글에서 다루는 것

  • 맵 = 2차원 배열 — 타일 ID 로 지형을 정의하는 법
  • 워밍업 — 탑다운(직교) 뷰로 먼저 그려보기
  • 쿼터뷰 변환식 — 그리드 좌표 → 화면 픽셀, 단 두 줄의 수학
  • 역변환 — 마우스 클릭이 어느 타일인지 알아내기
  • 카메라 오프셋 — 큰 맵에서 화면을 움직이는 법
  • 실습 — 마우스를 따라 타일이 빛나는 마름모 전장



1. 맵은 2차원 배열이다

게임 화면에서 전장은 복잡해 보이지만, 데이터로 보면 그냥 숫자가 든 2차원 배열입니다. 숫자 하나가 타일 하나의 지형 종류(타일 ID)를 나타내요.

# 0=풀밭, 1=물, 2=산
MAP = [
    [0, 0, 0, 0, 2, 2, 0, 0],
    [0, 0, 0, 0, 0, 2, 0, 0],
    [0, 1, 1, 0, 0, 0, 0, 0],
    [0, 1, 1, 0, 0, 0, 2, 0],
    [0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 1, 1, 0, 0],
    [0, 0, 2, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0],
]
ROWS, COLS = len(MAP), len(MAP[0])

접근은 MAP[y][x] 순서입니다. 바깥 리스트가 행(세로, y)이고 안쪽이 열(가로, x)이라서 그래요. 이 순서를 헷갈리면 맵이 대각선으로 뒤집혀 나오는데, 게임 개발 입문에서 가장 흔한 버그 중 하나입니다.

⚠️ 좌표 표기는 이 시리즈 내내 (gx, gy) = (열, 행) 으로 통일하고, 배열 접근할 때만 MAP[gy][gx] 로 뒤집습니다. “배열은 행 먼저, 좌표는 x 먼저” — 이 한 줄을 기억해두세요.

타일 ID 에는 지형의 속성을 붙일 수 있어요. 나중에 4편(길찾기)에서 “물은 못 지나감”, “산은 이동력 2 소모” 같은 규칙이 전부 이 표에서 나옵니다.

타일 ID 지형 색(임시) 통행
0 풀밭 초록 가능 (비용 1)
1 파랑 불가
2 회갈색 가능 (비용 2)



2. 워밍업 — 탑다운으로 먼저 그리기

쿼터뷰로 바로 가기 전에, 위에서 수직으로 내려다보는 탑다운(직교) 뷰로 먼저 그려봅니다. 변환이 단순해서 “배열 → 화면” 감각을 잡기에 좋아요.

TILE = 48   # 타일 한 변(px)

def grid_to_screen_ortho(gx, gy):
    return gx * TILE, gy * TILE

# (3, 2) 칸은 화면 어디에 그려질까?
sx, sy = grid_to_screen_ortho(3, 2)
print(sx, sy)
144 96

x 는 열 번호 × 타일 크기, y 는 행 번호 × 타일 크기. 끝이에요. 이중 for 문으로 전체 맵을 돌면서 타일 ID 에 맞는 색 사각형을 그리면 체스판 같은 맵이 나옵니다.

COLORS = {0: (106, 168, 79), 1: (61, 133, 198), 2: (127, 106, 79)}

for gy in range(ROWS):
    for gx in range(COLS):
        sx, sy = grid_to_screen_ortho(gx, gy)
        pygame.draw.rect(screen, COLORS[MAP[gy][gx]], (sx, sy, TILE, TILE))
        pygame.draw.rect(screen, (30, 30, 46), (sx, sy, TILE, TILE), 1)  # 경계선

여기까지는 아마 예상 그대로일 거예요. 이제 이걸 마름모로 기울입니다.



3. 쿼터뷰 변환 — 단 두 줄의 수학

쿼터뷰는 정사각형 타일을 45도 돌리고 세로를 절반으로 눌러서, 가로:세로 = 2:1 마름모로 그리는 방식입니다. 파랜드 택틱스도, 디아블로 2도, 오리지널 심시티도 전부 이 비율이에요. 타일 가로를 64px 로 잡으면 세로는 32px 이 됩니다.

변환식은 이게 전부입니다.

TILE_W, TILE_H = 64, 32          # 마름모 가로/세로
ORIGIN_X, ORIGIN_Y = 480, 80    # 맵 (0,0) 타일이 놓일 화면 기준점

def grid_to_screen(gx, gy):
    sx = (gx - gy) * (TILE_W // 2) + ORIGIN_X
    sy = (gx + gy) * (TILE_H // 2) + ORIGIN_Y
    return sx, sy

식의 의미를 풀어보면 이래요. 그리드에서 x 가 1 늘면 화면에서 오른쪽 아래로(+32, +16), y 가 1 늘면 왼쪽 아래로(−32, +16) 이동합니다. 두 축이 모두 “아래로” 내려가면서 좌우로 갈라지니까, 결과적으로 마름모 격자가 짜여요.

실제 값을 넣어 확인해봅시다.

for g in [(0, 0), (1, 0), (0, 1), (3, 2)]:
    print(g, "→", grid_to_screen(*g))
(0, 0) → (480, 80)
(1, 0) → (512, 96)
(0, 1) → (448, 96)
(3, 2) → (512, 160)

(1, 0)은 기준점의 오른쪽 아래, (0, 1)은 왼쪽 아래로 정확히 대칭이죠. 이 반환값을 마름모의 꼭대기 꼭짓점 위치로 쓰고, 나머지 세 꼭짓점은 타일 크기에서 계산해 다각형을 그립니다.

def draw_tile(surface, gx, gy, color):
    sx, sy = grid_to_screen(gx, gy)
    points = [
        (sx, sy),                                  # 위
        (sx + TILE_W // 2, sy + TILE_H // 2),      # 오른쪽
        (sx, sy + TILE_H),                         # 아래
        (sx - TILE_W // 2, sy + TILE_H // 2),      # 왼쪽
    ]
    pygame.draw.polygon(surface, color, points)
    pygame.draw.polygon(surface, (30, 30, 46), points, 1)

이중 for 문에 draw_tile 만 끼우면, 아까의 체스판이 마름모 전장으로 바뀝니다. 처음 실행해서 마름모 격자가 짜잔 하고 뜨는 순간이 이 시리즈에서 제일 짜릿한 순간 중 하나예요.

💡 그리는 순서도 중요합니다. 이중 for 문을 gy 바깥, gx 안쪽으로 돌면 화면 뒤쪽(위) 타일부터 그려져서, 나중에 높이가 있는 타일이나 캐릭터를 얹을 때 앞 타일이 뒤 타일을 자연스럽게 가립니다. 이걸 화가 알고리즘(painter’s algorithm) 이라고 불러요.



4. 역변환 — 클릭한 곳이 어느 타일인가

SRPG 는 마우스(또는 커서)로 타일을 찍는 게임이라, “화면 픽셀 → 그리드 좌표” 역변환이 반드시 필요합니다. 3장의 변환식을 연립방정식으로 풀면 역변환도 두 줄이에요.

def screen_to_grid(sx, sy):
    dx = (sx - ORIGIN_X) / (TILE_W / 2)
    dy = (sy - ORIGIN_Y) / (TILE_H / 2)
    gx = (dx + dy) / 2
    gy = (dy - dx) / 2
    return int(gx), int(gy)     # 소수점 버림 → 타일 번호

# 아까 (3, 2) 가 화면 (512, 160) 이었으니, 그 근처를 클릭했다고 치면
print(screen_to_grid(520, 170))
(3, 2)

변환과 역변환이 서로를 되돌리는 걸 확인했어요. 마우스 위치는 매 프레임 pygame.mouse.get_pos() 로 읽어서 이 함수에 넣으면 됩니다.

⚠️ int() 버림은 음수에서 어긋납니다. 예를 들어 gx 가 −0.3 이면 int() 는 0 이 되는데, 실제로는 −1번 타일(맵 밖)이에요. 맵 밖 클릭을 걸러야 하니 결과가 0 <= gx < COLS and 0 <= gy < ROWS 범위인지 반드시 확인하고 쓰세요. 정확히 하려면 math.floor() 를 쓰면 됩니다.



5. 카메라 — 맵이 화면보다 클 때

8×8 맵은 화면에 다 들어가지만, 실전 SRPG 맵은 30×30 이 넘어요. 이때 필요한 게 카메라인데, 개념은 허무할 만큼 단순합니다. 모든 그리기 좌표에 오프셋을 더하는 것, 그게 전부예요.

camera_x, camera_y = 0.0, 0.0   # 카메라가 밀어낸 양

def grid_to_screen(gx, gy):
    sx = (gx - gy) * (TILE_W // 2) + ORIGIN_X - int(camera_x)
    sy = (gx + gy) * (TILE_H // 2) + ORIGIN_Y - int(camera_y)
    return sx, sy

카메라가 오른쪽으로 가면(camera_x 증가) 세상은 왼쪽으로 밀려 그려집니다. 역변환에도 같은 오프셋을 반영해야 클릭 판정이 어긋나지 않아요. 키 입력으로 카메라를 움직이는 코드는 1편의 이동 코드와 완전히 같습니다 — 대상이 캐릭터에서 카메라로 바뀌었을 뿐이에요.

keys = pygame.key.get_pressed()
camera_x += (keys[pygame.K_d] - keys[pygame.K_a]) * 400 * dt
camera_y += (keys[pygame.K_s] - keys[pygame.K_w]) * 400 * dt



6. 실습 — 마우스를 따라 빛나는 전장

이번 편의 내용을 전부 합친 실행 코드입니다. 마름모 전장이 그려지고, 마우스가 올라간 타일이 노랗게 빛나고, WASD 로 카메라가 움직여요.

import pygame

pygame.init()
screen = pygame.display.set_mode((960, 540))
pygame.display.set_caption("SRPG 선행학습 2편 — 쿼터뷰 전장")
clock = pygame.time.Clock()

MAP = [
    [0, 0, 0, 0, 2, 2, 0, 0],
    [0, 0, 0, 0, 0, 2, 0, 0],
    [0, 1, 1, 0, 0, 0, 0, 0],
    [0, 1, 1, 0, 0, 0, 2, 0],
    [0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 1, 1, 0, 0],
    [0, 0, 2, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0],
]
ROWS, COLS = len(MAP), len(MAP[0])
TILE_W, TILE_H = 64, 32
ORIGIN_X, ORIGIN_Y = 480, 120
COLORS = {0: (106, 168, 79), 1: (61, 133, 198), 2: (127, 106, 79)}

camera_x, camera_y = 0.0, 0.0

def grid_to_screen(gx, gy):
    sx = (gx - gy) * (TILE_W // 2) + ORIGIN_X - int(camera_x)
    sy = (gx + gy) * (TILE_H // 2) + ORIGIN_Y - int(camera_y)
    return sx, sy

def screen_to_grid(sx, sy):
    dx = (sx + int(camera_x) - ORIGIN_X) / (TILE_W / 2)
    dy = (sy + int(camera_y) - ORIGIN_Y) / (TILE_H / 2)
    return int((dx + dy) / 2), int((dy - dx) / 2)

def draw_tile(gx, gy, color, border=1):
    sx, sy = grid_to_screen(gx, gy)
    points = [(sx, sy), (sx + TILE_W // 2, sy + TILE_H // 2),
              (sx, sy + TILE_H), (sx - TILE_W // 2, sy + TILE_H // 2)]
    pygame.draw.polygon(screen, color, points)
    if border:
        pygame.draw.polygon(screen, (30, 30, 46), points, border)

running = True
while running:
    dt = clock.tick(60) / 1000
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    keys = pygame.key.get_pressed()
    camera_x += (keys[pygame.K_d] - keys[pygame.K_a]) * 400 * dt
    camera_y += (keys[pygame.K_s] - keys[pygame.K_w]) * 400 * dt

    hover = screen_to_grid(*pygame.mouse.get_pos())

    screen.fill((30, 30, 46))
    for gy in range(ROWS):
        for gx in range(COLS):
            draw_tile(gx, gy, COLORS[MAP[gy][gx]])
    if 0 <= hover[0] < COLS and 0 <= hover[1] < ROWS:
        draw_tile(*hover, (255, 217, 102))          # 하이라이트

    pygame.display.flip()

pygame.quit()

마우스를 움직일 때 마름모 모양 그대로 하이라이트가 따라오면, 변환·역변환이 정확히 맞물려 돌고 있다는 뜻이에요. 파랜드 택틱스에서 커서를 움직일 때 일어나는 일이 정확히 이것입니다.



7. 정리

  • 맵 = 타일 ID 가 든 2차원 배열, 접근은 MAP[gy][gx]
  • 쿼터뷰 변환식 — (gx-gy), (gx+gy) 두 줄이 마름모 세상의 전부
  • 역변환으로 마우스 클릭 → 타일 좌표, 맵 밖 범위 체크 필수
  • 카메라 = 그리기 전체에 오프셋 더하기
  • 화가 알고리즘 — 뒤(위)에서 앞(아래) 순서로 그리기

다음 편에서는 이 전장 위에 설 캐릭터를 만듭니다. 스프라이트시트를 자르고, 방향별 걷기 애니메이션을 프레임 타이밍으로 돌리는 법을 다뤄요.

일단 오늘은 여기까지….. 다음 글에서는 스프라이트와 애니메이션을 정리해볼게요.


← 이전 글: (1/10) 2D SRPG 만들기 선행학습 — 게임 루프와 델타타임다음 글 →: (3/10) 2D SRPG 만들기 선행학습 — 스프라이트와 애니메이션