- 개요
지금까지 사과게임 매크로 코드의 확장자는 ipynb였습니다.
ipynb 확장자에서는 코드 cell이 여러 개이므로 이를 한 번에 실행하기 위해 vscode의 [run all] 버튼을 눌렀습니다.
기능 구현만 할거면 이런 식으로 코드를 작성해도 상관없지만 하나의 프로그램으로 만들기 힘듭니다.
예를 들어 프롬프트창에서 매크로 프로그램을 실행하고, 조작이 가능하게 만들려면, 즉 Command Line Interface(CLI)로 코드를 실행 및 조작하게 만들려면 python [코드이름.py]를 쳐서 코드를 실행한 뒤 특정 버튼을 눌렀을 때 사과게임 매크로가 동작해야합니다.
이번 포스트에서는 CLI 기반으로 프로그램이 돌아가도록 사과게임 매크로 코드를 리팩토링해보겠습니다.
- 클래스 기반 코드 작성 이유
저번 매크로 코드는 작업 순서대로 작성되어 있습니다.
하지만 보통 프로그램 코드를 작성할 때는 클래스(Class) 사용합니다.
왜냐구요? 저도 컴공을 전공한 게 아니라서 잘 모릅니다만
제 경험상 클래스 기반으로 코드를 작성하면 이 코드가 무엇을 하고자 하는지 그 과정을 파악하기 쉽습니다.
먼저 클래스 기반 아닌 코드의 예시를 들어보겠습니다.
지금까지 작성한 사과게임 매크로 ipynb 코드를 py 확장자 코드로 만들었다고 쳐봅시다.
아래는 기능별 코드 줄수입니다.
1. 사과 안의 숫자 그림 파일 읽는 부분: 13줄
2. 클러스터링을 이용하여 numpy 배열 저장: 26줄
3. 마우스로 사과게임을 진행하는 부분: 32줄
총 60줄 정도로 그렇게 길지는 않습니다만 이 코드가 어떤 기능을 수행하는지 알려면 전체 코드를 다 읽어야 합니다.
3. 마우스로 사과게임을 진행하는 부분 코드까지 가려면 최소 39줄을 지나야 하죠.
심지어 전 여기에 기능을 더 추가할꺼니까 실제로는 기능이 더 많아 코드의 길이도 길어집니다.
이렇게 코드가 짜여있으면 이 코드의 핵심기능이 무엇인지 어디에 있는지 바로 파악하기 힘들죠.
그럼 클래스 기반 코드를 봅시다.
마찬가지로 자세한 코드 내용은 자세히 적지 않습니다.
"""
라이브러리 import 부분
"""
class App:
def __init__(self):
self.img_path = './imgs/'
self.program_running = True
self.macro_running = True
self.process()
keyboard.add_hotkey('d', self.do_applegame)
keyboard.add_hotkey('f', self.exit_applegame)
keyboard.add_hotkey('q', self.exit)
keyboard.add_hotkey('r', self.reset_and_process)
while self.program_running:
time.sleep(1.)
def process(self):
self.get_numpy()
self.get_mouse_pos()
self.set_yhxw()
def 위에서 정의한 함수들 정의해야함
pass
클래스 안에 함수를 선언하되 함수의 이름은 기능을 파악할수 있게 코드를 작성했습니다.
참고로 keyboard.addhotkey('d', self.do_applegame)은 키보드 d를 입력받을 때 self.do_applegame 함수를 실행하란 것입니다.
코드를 쭉 읽어보면
App이라는 클래스가 정의되면 program_running, macro_runining은 모두 True가 됩니다.
이를 보면 이게 False가 되면 프로그램, 매크로 종료될 것이란 걸 알 수 있죠.
다음으로 self.process()가 나오는데
def process(self):를 보면
get_numpy() >>> get_mouse_pos() >>> set_yhxw() 순으로 실행됩니다.
이건 딱 보면 와닿지 않을 수 있습니다만,
get_numpy()는 사과게임의 숫자를 numpy 배열로 변환,
get_mouse_pos()는 숫자 위치별 실제 모니터의 마우스 좌표를 반환,
set_yhxw()는 y좌표, 높이(h), x좌표, 폭(w)으로 이 값을 맞아 실제 마우스를 컨트롤 하는데 쓰입니다.
그리고 'd'를 누르면 사과게임 매크로 시작(마우스로 사과게임 수행),
'f'를 누르면 사과게임 매크로만 정지,
'q'를 누르면 프로그램이 종료됩니다.
'r'은 reset 버튼을 누르고 start 버튼을 눌러 새로운 게임을 수행합니다.
클래스 기반으로 작성하면 이렇게 짧은 줄수로 무엇을 하는 코드인지 쉽게 파악할 수 있습니다.
마지막으로 클래스 기반으로 코드를 작성하면 다른 코드를 작성할 때 활용하기 좋습니다.
기본적으로 함수화가 되어 있으므로 input, output이 뭔지 알면 이 함수를 바로 활용할 수 있죠.
그래서 다른 코드에 함수를 통째로 붙여넣기해서 쓰기 쉽습니다.
- 전체 코드
이번 코드는 길이가 좀 길어서 하나하나 모두 설명하기 어려워 전체 코드를 먼저 보여드립니다.
그 다음에 저번 코드와 달라진 점과 알면 좋겠다 싶은 부분만 설명하겠습니다.
딱히 코드 최적화는 여러분의 몫으로 남겨두었습니다.
1~9말고도 reset, play버튼도 인식해야하므로 img 파일을 첨부합니다.
import keyboard
import pyautogui
import pandas as pd
import numpy as np
import sys
import time
import threading
from sklearn.cluster import KMeans
import warnings
warnings.filterwarnings("ignore")
import os
os.environ["LOKY_MAX_CPU_COUNT"] = "4"
os.environ["OMP_NUM_THREADS"] = "2"
class App:
def __init__(self):
print('d: 사과게임 수행/재개')
print('f: 사과게임 중단')
print('q: 종료')
print('r: Reset 버튼 클릭 + Start 버튼 클릭')
self.img_path = './imgs/'
self.program_running = True
self.macro_running = True
play_positions = pyautogui.locateAllOnScreen(f'{self.img_path}play.png', confidence=0.90)
# play 버튼이 있으면 r을 클릭하여 게임 실행, 이미 게임화면 이면 process() 함수 동작
try:
if list(play_positions):
print('게임을 시작하려면 r을 클릭')
except:
self.process()
keyboard.add_hotkey('d', self.do_applegame)
keyboard.add_hotkey('f', self.exit_applegame)
keyboard.add_hotkey('q', self.exit)
keyboard.add_hotkey('r', self.reset_and_process)
while self.program_running:
time.sleep(1.)
def process(self):
self.get_numpy()
self.get_mouse_pos()
self.set_yhxw()
def do_applegame(self):
print('d를 누름')
def worker():
for i, (yi, h, xi, w) in enumerate(self.yhxw):
if not self.macro_running:
print('사과게임 중단, 재개는 "d"')
self.yhxw = self.yhxw[i:]
break
self.mouse_move(xi, yi, w, h)
self.macro_running = True
threading.Thread(target=worker).start()
def exit_applegame(self):
print('f를 누름')
self.macro_running = False
def reset_and_process(self):
print('r을 누름')
self.click_center(f'{self.img_path}reset.png')
self.click_center(f'{self.img_path}play.png')
self.process()
def click_center(self, img_path):
positions = pyautogui.locateAllOnScreen(img_path, confidence=0.90)
for pos in positions:
x_center = pos.left + pos.width / 2
y_center = pos.top + pos.height / 2
pyautogui.moveTo(x_center, y_center, duration=0.01)
pyautogui.mouseDown()
pyautogui.mouseUp()
break
def exit(self):
print('q를 누름')
print('프로그램 종료')
self.program_running = False
def get_numpy(self):
ll = 0
df = pd.DataFrame(columns=['num', 'top', 'left', 'width', 'height'])
for i in range(1, 9+1):
positions = pyautogui.locateAllOnScreen(f'{self.img_path}{i}.png', confidence=0.90)
for pos in positions:
new_row = {'num':i, 'top':pos.top, 'left':pos.left,
'width':pos.width, 'height':pos.height}
df = pd.concat([df, pd.DataFrame([new_row])], ignore_index=True)
ll += 1
kmeans = KMeans(n_clusters=170)
kmeans.fit(df[['top', 'left']])
df['cluster'] = kmeans.labels_
df = df.groupby(by='cluster').mean()
kmeans = KMeans(n_clusters=17)
kmeans.fit(df[['left']])
df['x_cls'] = kmeans.labels_
kmeans = KMeans(n_clusters=10)
kmeans.fit(df[['top']])
df['y_cls'] = kmeans.labels_
for xi in range(17):
mean_value = df.loc[df['x_cls'] == xi, 'left'].mean() # 먼저 평균값 계산
df.loc[df['x_cls'] == xi, 'left'] = mean_value # loc를 사용하여 값 업데이트
for xi in range(10):
mean_value = df.loc[df['y_cls'] == xi, 'top'].mean() # 먼저 평균값 계산
df.loc[df['y_cls'] == xi, 'top'] = mean_value # loc를 사용하여 값 업데이트
df = df.sort_values(by=['top', 'left']).reset_index(drop=True)
self.df = df
num1d = np.array(df['num'].values.tolist(), dtype=int)
self.num2d = num1d.reshape(10, 17)
def get_mouse_pos(self):
xloc = self.df['left'].unique()
yloc = self.df['top'].unique()
self.dx = np.mean(np.diff(xloc))
self.dy = np.mean(np.diff(yloc))
self.xloc_mouse = xloc - 0.25 * self.dx
self.yloc_mouse = yloc - 0.25 * self.dy
def set_yhxw(self):
self.yhxw = []
for _ in range(10):
for yi in range(10):
for xi in range(17):
w_max, h_max = 17 - xi, 10 - yi
for h in range(h_max):
for w in range(w_max):
if np.sum(self.num2d[yi: yi + h+1, xi: xi + w+1]) == 10:
self.num2d[yi: yi + h+1, xi: xi + w+1] = 0
self.yhxw.append((yi, h, xi, w ))
self.score = np.count_nonzero(self.num2d == 0)
print(f'예상점수: {self.score}')
def mouse_move(self, xi, yi, w, h, duration=0.01):
xloc_mouse = self.xloc_mouse
yloc_mouse = self.yloc_mouse
dx = self.dx
dy = self.dy
pyautogui.moveTo( xloc_mouse[xi], yloc_mouse[yi], duration=duration)
pyautogui.mouseDown()
pyautogui.moveTo(xloc_mouse[xi] + (w+1)*dx, yloc_mouse[yi] + (h+1)*dy, duration=duration)
pyautogui.moveTo(xloc_mouse[xi] + (w+1)*dx, yloc_mouse[yi] + (h+1)*dy, duration=duration)
pyautogui.mouseUp()
if __name__ == '__main__':
app = App()
- 키보드 입력 받기, 쓰레드 라이브러리 사용
1. keyboard 라이브러리를 쓰는 이유
보통 CLI 기반 프로그램은 명령 프롬프트 창을 메인창으로 유지합니다.
프로그램을 실행한 뒤 명령 프롬프트 창에 문자열을 치고 엔터키를 누르면 프로그램은 그 문자열에 맞는 기능을 수행합니다.
하지만 사과게임 매크로 코드를 명령 프롬프트에서 실행한 뒤 매크로를 돌리면 사과게임이 켜져있는 브라우저 화면을 클릭하게 됩니다.
브라우저 화면을 클릭버렸으니 이 때 우리가 키보드를 치면 명령 프롬프트에는 아무런 글자가 입력되지 않습니다.
그렇기에 우리는 실시간으로 키보드 입력을 감지하고 동작하는 코드를 만들어야 합니다.
이러한 기능은 keyboard 라이브러리로 구현할 수 있으며, 위에서도 한 번 언급했지만 keyboard.addhotkey()를 이용해 특정 문자입력시 함수를 동작하도록 만듭니다.
2. 쓰레드 라이브러리 사용
코드의 앞부분을 보시면 threading이라는 라이브러리를 import했습니다.
이 라이브러리를 사용하면 CPU의 쓰레드를 여러 개 쓸 수 있습니다.
쓰레드를 하나만 쓴다면 문제가 생깁니다.
매크로를 수행하면 do_applegame() 함수에서 for문이 돌아가는데 이 작업을 수행하는 중에는 키보드 입력을 받지 못합니다.
그래서 이 for문에 대한 작업을 다른 쓰레드에서 수행하게 만들고 메인 쓰레드는 키보드 입력을 받아야 중간에 키 입력을 받아 매크로를 중단할 수 있습니다.
def do_applegame(self):
print('d를 누름')
def worker():
for i, (yi, h, xi, w) in enumerate(self.yhxw):
if not self.macro_running:
print('사과게임 중단, 재개는 "d"')
self.yhxw = self.yhxw[i:]
break
self.mouse_move(xi, yi, w, h)
self.macro_running = True
"""
worker 함수를 메인 쓰레드가 아닌 새로운 쓰레드에서 처리하므로
메인 쓰레드는 키보드 입력을 받을 수 있음
"""
threading.Thread(target=worker).start()
- 예상점수 출력, 유저가 원할시 매크로 수행
저번 코드에서는 합이 10이 되는 구역을 찾으면 xi, yi, h, w를 입력받아 바로 마우스를 움직여 사과게임을 진행했씁니다.
하지만 우린 사과게임을 굳이 플레이하지 않아도 numpy 배열만 계산해서 몇 점이 될 지 예상할 수 있죠.
마우스를 움직이는 함수에 필요한 xi, yi, h, w만 self.yhxw 변수에 저장해주고, 정말로 사과게임을 수행을 하고 싶을 때 'd' 키를 눌러 마우스를 동작시킵니다.
def set_yhxw(self):
self.yhxw = []
for _ in range(10):
for yi in range(10):
for xi in range(17):
w_max, h_max = 17 - xi, 10 - yi
for h in range(h_max):
for w in range(w_max):
if np.sum(self.num2d[yi: yi + h+1, xi: xi + w+1]) == 10:
self.num2d[yi: yi + h+1, xi: xi + w+1] = 0
"""
기존 코드에서는 여기서 마우스를 움직이는 함수가 존재
원할시 마우스를 동작시키기 위해
xi, h, xi, w를 self.yhwx에 저장
"""
self.yhxw.append((yi, h, xi, w ))
# self.num2d에서 0의 갯수가 점수임
self.score = np.count_nonzero(self.num2d == 0)
- Reset and Start 기능 추가
사과게임 화면을 킨 뒤 코드를 실행하면 numpy 배열을 이용해 이번 판의 예상점수가 출력됩니다.
예상점수가 마음에 안 들면? 다시 시작하면 되는거죠.
다시 시작하면 process() 함수를 돌려 numpy 배열 생성, 예상점수 출력을 합니다.
reset.png, start.png로 버튼 이미지 파일을 저장하고 pyautogui.locateAllOnScreen()으로 해당 버튼의 좌표를 찾고 중심 좌표를 클릭하는 방식입니다.
def reset_and_process(self):
print('r을 누름')
self.click_center(f'{self.img_path}reset.png')
self.click_center(f'{self.img_path}play.png')
self.process()
def click_center(self, img_path):
positions = pyautogui.locateAllOnScreen(img_path, confidence=0.90)
for pos in positions:
x_center = pos.left + pos.width / 2
y_center = pos.top + pos.height / 2
pyautogui.moveTo(x_center, y_center, duration=0.01)
pyautogui.mouseDown()
pyautogui.mouseUp()
break
- 실제 동작
'프로젝트 > 사과게임 매크로 만들기' 카테고리의 다른 글
[python][사과게임 매크로 만들기] 2. 사과게임 매크로 코드 작성, 실행 (0) | 2025.04.05 |
---|---|
[python][사과게임 매크로 만들기] 1. 이미지 인식, numpy 배열로 변환 (0) | 2025.03.31 |