1편에서 D2R의 메모리 구조를 분석하고 Python으로 게임 데이터를 읽는 데 성공했다. 다음 문제는 이 데이터를 게임 화면을 가리지 않으면서 표시하는 것이다. 해답은 게임 창 위에 완전히 겹쳐지는 투명 오버레이 창을 별도로 띄우는 방식이다. 이 글에서는 Win32 API로 게임 창을 추적하고, PyQt6로 투명 클릭 통과 오버레이를 구현하는 과정, 그리고 이소메트릭 좌표를 화면 픽셀로 변환하는 공식을 정리했다.
01 오버레이란 무엇인가
오버레이는 게임 프로세스 바깥에서 OS가 렌더링하는 별도의 투명 창이다. 게임 코드를 전혀 건드리지 않으면서 게임 화면 위에 정보를 그릴 수 있다. 스팀 오버레이, Discord 오버레이, GeForce Experience가 모두 같은 원리로 동작한다.
우리가 만들 오버레이가 충족해야 할 조건은 네 가지다:
-
게임 창과 완전히 동일한 위치 · 크기 D2R 창이 이동하거나 크기가 바뀌면 오버레이도 즉시 따라가야 한다
-
마우스 클릭 통과 오버레이가 클릭을 가로채면 게임 조작이 불가능해진다. 클릭은 반드시 게임으로 전달되어야 한다
-
배경 완전 투명 그린 마커 외에 나머지 영역은 픽셀 수준으로 투명해야 게임 화면이 그대로 보인다
-
항상 게임 위에 위치 다른 창이 활성화되어도 오버레이는 D2R 위에 유지되어야 한다
02 게임 창 찾기 — Win32 API
Python에는 Win32 API 바인딩이 기본 내장되어 있지 않다.
ctypes로 Windows DLL을 직접 호출하는 방식을 사용한다.
세 개의 API 함수를 조합하면 게임 창의 정확한 위치와 크기를 얻을 수 있다.
import ctypes, ctypes.wintypes def find_d2r_window(): user32 = ctypes.windll.user32 # 창 제목으로 핸들(HWND) 획득 hwnd = user32.FindWindowW(None, "Diablo II: Resurrected") if not hwnd: return None # 클라이언트 영역 크기 (타이틀바/테두리 제외) rect = ctypes.wintypes.RECT() user32.GetClientRect(hwnd, ctypes.byref(rect)) # 클라이언트 좌상단을 절대 화면 좌표로 변환 pt = ctypes.wintypes.POINT(0, 0) user32.ClientToScreen(hwnd, ctypes.byref(pt)) return pt.x, pt.y, rect.right, rect.bottom # x, y, width, height
GetWindowRect는 타이틀바와 테두리를 포함한 전체 창 크기를 반환한다.
오버레이를 순수 게임 화면에만 정확히 맞추려면 타이틀바를 제외한
클라이언트 영역만 필요하므로 GetClientRect + ClientToScreen 조합을 사용한다.
03 PyQt6로 투명 오버레이 창 만들기
오버레이의 핵심은 윈도우 플래그 설정이다. 아래 여섯 줄이 오버레이의 전부라고 해도 과언이 아니다.
from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QWidget class OverlayWindow(QWidget): def __init__(self): super().__init__() self.setWindowFlags( Qt.WindowType.FramelessWindowHint # 타이틀바 · 테두리 없음 | Qt.WindowType.WindowStaysOnTopHint # 항상 최상위 | Qt.WindowType.Tool # 작업표시줄에 미표시 | Qt.WindowType.WindowTransparentForInput # 마우스 클릭 통과 ★ ) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) # 배경 투명 self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating) # 포커스 뺏지 않음
각 플래그가 하는 역할을 정리하면 다음과 같다:
| 플래그 / 속성 | 역할 |
|---|---|
| FramelessWindowHint | 타이틀바와 창 테두리를 완전히 제거한다 |
| WindowStaysOnTopHint | 다른 창이 활성화되어도 항상 최상단에 유지된다 |
| Tool | 작업표시줄과 Alt+Tab 목록에 나타나지 않는다 |
| WindowTransparentForInput | 마우스 클릭 · 키 입력이 오버레이를 통과해 아래 창으로 전달된다 |
| WA_TranslucentBackground | 창 배경을 픽셀 수준으로 투명하게 만든다. 없으면 배경이 검게 채워진다 |
| WA_ShowWithoutActivating | 창을 표시할 때 게임 창의 포커스를 빼앗지 않는다 |
04 오버레이 위치 동기화
D2R 창이 이동하거나 전체화면 ↔ 창 모드 전환이 일어나면
오버레이 위치도 즉시 갱신되어야 한다.
QTimer로 3초마다 D2R 창을 다시 찾아 오버레이 geometry를 맞춘다.
from PyQt6.QtCore import QTimer # __init__ 내부 self._sync_timer = QTimer(self) self._sync_timer.timeout.connect(self._sync_game_window) self._sync_timer.start(3000) # 3초마다 def _sync_game_window(self): info = find_d2r_window() if info: gx, gy, w, h = info self.setGeometry(gx, gy, w, h) # 게임 창과 정확히 겹침 self.show() else: self.hide() # D2R이 꺼져 있으면 오버레이도 숨김
05 렌더링 루프 — 50ms마다 갱신
또 다른 QTimer를 50ms 간격으로 실행해 메모리 읽기와 화면 갱신을 반복한다.
게임은 보통 60fps로 동작하지만, 오버레이는 정보 표시 목적이므로
20fps(50ms)로도 충분하고 CPU 부하도 낮다.
self._timer = QTimer(self) self._timer.timeout.connect(self._on_tick) self._timer.start(50) def _on_tick(self): self._reader.update() # 메모리에서 최신 데이터 읽기 self.update() # Qt에 repaint 요청 → paintEvent 호출
update()는 "다음 이벤트 루프에서 다시 그려달라"는 요청이다.
Qt가 내부적으로 repaint 요청을 취합해 최적 타이밍에 paintEvent를 호출한다.
루프 안에서 직접 그리려 하면 안 된다.
06 핵심 — 이소메트릭 좌표 변환
메모리에서 읽은 게임 좌표(gx, gy)를 화면 픽셀 좌표로 변환하는 것이 이번 편의 기술적 핵심이다.
디아블로의 좌표계
디아블로는 2D 이소메트릭 게임이다. 게임 내부는 2D 그리드지만, 화면에는 45도 기울어진 마름모꼴 타일로 표시된다. 게임 좌표를 그대로 화면에 찍으면 전혀 다른 위치가 된다.
변환 공식
플레이어 위치(px, py)를 화면 중심으로 기준점을 잡고, 다른 오브젝트(gx, gy)의 상대 거리를 이소메트릭 화면 좌표로 투영한다.
def _g2s(self, gx: int, gy: int, px: int, py: int) -> tuple[float, float]: """게임 좌표 → 화면 픽셀 좌표""" dx = gx - px # 플레이어 기준 상대 거리 dy = gy - py # (dx - dy): 화면 가로 이동량 ← 45도 회전의 cos 성분 # (dx + dy): 화면 세로 이동량 ← 45도 회전의 sin 성분 sx = self._game_w / 2 + (dx - dy) * self._scale_x sy = self._game_h / 2 + (dx + dy) * self._scale_y return sx, sy # 이소메트릭 2:1 비율 — x방향 이동이 y방향의 2배 self._scale_x = 3.3 # 1080p 기준 실측값 self._scale_y = self._scale_x / 2
(dx - dy)와 (dx + dy)가 나오는 이유는 간단하다.
이소메트릭 뷰는 게임 좌표를 45도 회전시킨 뒤
y축을 절반으로 납작하게 누른 것과 동일하다.
45도 회전 행렬을 전개하면 정확히 이 두 식이 나온다.
scale_y = scale_x / 2가 그 납작함을 표현한다.
scale_x ≈ 3.3이 실측에 맞다.
해상도나 게임 줌 레벨이 다르면 값이 달라진다.
마커가 실제 오브젝트보다 멀리 표시되면 값을 줄이고,
겹쳐 보이면 늘리는 방식으로 조정한다.
화면 경계 체크로 렌더링 최적화
변환된 좌표가 화면 밖이면 그리지 않는다. 던전이 넓을수록 오브젝트 수가 많아지므로 이 필터가 성능에 직접적인 영향을 준다.
def _on_screen(self, sx: float, sy: float, margin: int = 80) -> bool: return (-margin < sx < self._game_w + margin and -margin < sy < self._game_h + margin)
07 paintEvent — 실제로 그리기
Qt의 모든 커스텀 드로잉은 paintEvent 안에서 QPainter로 처리한다.
레이어 순서가 중요한데, 아래에 그린 것이 위에 그린 것에 덮이므로
플레이어를 맨 마지막에 그려야 항상 최상단에 표시된다.
from PyQt6.QtGui import QPainter def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) # 레이어 1: 방 구조 (Room 다각형) — 가장 아래 self._draw_rooms(painter) # 레이어 2: PresetUnit (미방문 방의 오브젝트) self._draw_preset_units(painter) # 레이어 3: 일반 오브젝트 (WP, 포탈, 상자) self._draw_objects(painter) # 레이어 4: 바닥 아이템 self._draw_items(painter) # 레이어 5: 몬스터 self._draw_monsters(painter) # 레이어 6: 플레이어 — 항상 최상단 self._draw_player(painter) painter.end()
08 트러블슈팅 — 실제로 겪은 문제들
-
▸ 오버레이가 마우스 클릭을 가로챈다
WindowTransparentForInput플래그 누락. 이 플래그 없이는 오버레이가 모든 마우스 입력을 소비해 게임 조작이 불가능해진다. -
▸ 배경이 검은색으로 채워진다
WA_TranslucentBackground속성 누락. setAttribute로 반드시 설정해야 창 배경이 투명해진다. -
▸ 마커 위치가 실제 오브젝트와 어긋난다
GetWindowRect사용이 원인. 타이틀바 높이만큼 y 오프셋이 생긴다.GetClientRect + ClientToScreen조합으로 교체해야 한다. -
▸ D2R 최소화 시 오버레이가 화면에 남는다D2R 창 감지 실패 시
self.hide()를 호출하지 않아서 생기는 문제. 동기화 타이머에서 창이 없으면 반드시 hide 처리해야 한다.
Win32 GetClientRect + ClientToScreen으로 게임 창 위치를 정확히 추적하고,
PyQt6 윈도우 플래그 6개로 투명 클릭 통과 오버레이를 완성했다.
이소메트릭 변환 공식 sx = (dx - dy) * scale으로
게임 좌표를 화면 픽셀에 정확히 매핑할 수 있다.
다음 편에서는 이 오버레이 위에 몬스터 등급, 워프포인트, 보스, 미니맵을
실제로 그리는 과정을 다룬다.
'비전공자의 바이브 코딩 > 일상 & 기초' 카테고리의 다른 글
| 쿠팡 최저가 알림 봇 만들기 — Claude Code로 1시간 완성 (네이버 쇼핑 API) (1) | 2026.06.09 |
|---|---|
| 디아블로2 봇 만들기 #3— (맵핵 구현)미니맵 내 오브젝트 표시 (0) | 2026.06.03 |
| 디아블로2 봇 만들기 #1— (맵핵구현)D2R 게임 메모리 구조 분석 (0) | 2026.06.03 |
| 클로드코드(Claude Code)로 코인 자동매매 프로그램 만들기 : #4. 마무리, 계획 (1) | 2026.05.16 |
| 클로드코드(Claude Code)로 코인 자동매매 프로그램 만들기 : #3. 편의 기능 및 1차 완성 (0) | 2026.05.16 |