(2/10) 2D SRPG 만들기 선행학습 — 타일맵과 쿼터뷰 좌표계
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 만들기 선행학습 — 스프라이트와 애니메이션