├── LICENCE ├── notes ├── path_planning.txt ├── pathing problem.txt └── terrain_information.txt ├── readme.md ├── requirements.txt ├── rune_trainer ├── arrow_classifier_keras_gray.h5 ├── class_indices.txt ├── convert_img2gray.py ├── count_images.py ├── image format and how to make data.txt ├── keras_model_verifier.py ├── merge_images.py ├── move_traindata2test.py ├── opencv_arrow_detector.py ├── readme.md ├── rune_dataset_classifier.py ├── rune_screen_capture.py ├── trainer_keras.py ├── validation_rgb.png └── validator_img_classifier_tool.py ├── src ├── directinput_constants.py ├── keybind_setup_window.py ├── keystate_manager.py ├── macro_script.py ├── macro_script_astar.py ├── main.py ├── platform_data_creator.py ├── player_controller.py ├── readme.md ├── rune_solver.py ├── screen_processor.py └── terrain_analyzer.py └── tests ├── IMPORTANT PLEASE READ ME.txt ├── __init__.py ├── non-unittests ├── __init__.py ├── dbg_keyboardlistner.py ├── doublejump_time_height.py ├── maplestory_screen_viewer.py ├── minimap color test.py ├── src.keystate_manager.KeyboardInputManager.py ├── src.macro_script.py ├── src.monster_detector.py ├── src.player_controller.py ├── src.screen_processor.py ├── src.terrain_analyzer_PlatformScan.py ├── src.terrain_analyzer_astar.py ├── src.terrain_analyzer_bfs.py ├── src.terrain_analyzer_create_terrain_file.py ├── util.jump_distance_measurer.py ├── util.plotter.py └── util.skill_delay_measurer.py ├── test_macroController.py └── test_pathAnalyzer.py /notes/path_planning.txt: -------------------------------------------------------------------------------- 1 | Proposing a heuristical way to approximate the effectiveness of a given path 2 | 3 | One of the core features of the bot is deemed to be the ability to plan an efficient path given the start, end coordinates 4 | of each platform and its respective solutions(inter-platform movement methods). 5 | In order to select the most efficient path, a method for measuring the effectiveness of paths needs to be implemented. 6 | 7 | Here are some rules we assume to be true during evaluation 8 | 9 | 1. A platform's number of mobs at any given time is proportional to its length. 10 | 2. The cost of solutions, in biggest to least order is: 11 | dbljmp_max > dbljmp_half > drop > jmpl = jmpr 12 | 13 | Platform efficiency is defined as length of platform multiplied by mob constant M 14 | 15 | We can now design a heuristical function where for a given solution which includes solutions S and platforms P, its efficiency 16 | can be defined the sum of solution cost subtracted from sum of Platform efficiency 17 | 18 | S(efficiency) = -Sigma(solution_cost) + Sigma(Platform_Efficiency) 19 | 20 | The cost coefficients for each solutions is as follows: 21 | 22 | dbljmp_max 5 23 | dbljmp_half 3 24 | drop 2 25 | jmpl,jmpr 1 26 | 27 | ------------------- 28 | 29 | Practical calculation of efficiency with actual platforms 30 | 31 | Each platform, if longer than 5 pixels, is subdivided into subplatforms of length 5 32 | From start subplatform SPs in platform Ps, traverse through other connected subplatforms and solutions 33 | Keep track of number of subplatforms and solutions used during traversal 34 | If within current platform and next platform goal is decided, movement direction within the current platform cannot be changed 35 | Terminate when start platform is reached and calculate efficiency for each path found 36 | Select path with highest efficiency 37 | 38 | Some research on correlation between double up press delay and jump height in double jumps: 39 | maximum delay between jump and up key: 0.45 (measured in 0.05 increments) 40 | minimum time for max height jump: 0.15 41 | 42 | regression result for 43 | absolute height difference y 44 | delay between pressing alt and first up press x 45 | 46 | first regression result: 47 | ŷ = -76.42857X + 41.92857 48 | 49 | second regression result: 50 | ŷ = -78.33333X + 41.5 -------------------------------------------------------------------------------- /notes/pathing problem.txt: -------------------------------------------------------------------------------- 1 | Automatic platform pathing methods 2 | 3 | Currently known data: 4 | - Player minimap coordinates 5 | - Platform start, end coordinates 6 | 7 | What we want to achieve: 8 | Given current player coordinates and platform start, end coordinates, compute a way to reach 9 | platform A to platform B 10 | 11 | Method 1 - Standard jumps between platforms 12 | 1. Compute Euclidean Square distance between all platform starts and ends 13 | 2. If distance between two points is less that jump distance(to be determined), map two each coordinates movable by jumps 14 | 15 | Method 2 - Utilizing vertical super jump 16 | 1. Find vertical overlaps of 2 different platforms. 17 | 2. Calculate vertical distance between platform 18 | 3. If distance is less that super jump range, map as reachable by superjump/fall 19 | 20 | Method 3 - Utilizing ladders/ropes 21 | 1. 22 | 23 | Method 4 - Utilizing glide in large platforms 24 | 25 | -------------------------------------------------------------------------------- /notes/terrain_information.txt: -------------------------------------------------------------------------------- 1 | This file will attempt to explain how terrain_analyzer.PathAnalyzer.find_available_moves() works. 2 | It attempts to find methods to move from platform A to platform B. 3 | 4 | Some basic forms of movement methods: 5 | 1. drop 6 | If platform A is located higher than platform B and have an overlapping portion on the x axis, it is possible to drop 7 | from A to B. 8 | 9 | ------------------- A 10 | 11 | 12 | |-----------|--------- B 13 | 14 | 2. jmpr, jmpl 15 | Simple. You can get to another platform by simply jumping from an end of platform to another platform 16 | Currently, it's a very hacky solution because it measures the euclidean distance between the ends of 2 platforms and 17 | if the distance is within a certain threshold, it's deemed jumpable 18 | A 19 | ------ 20 | 21 | B 22 | ----- 23 | Distance between point A and B is less than threshold? Jumpable 24 | 25 | 3. dbljmp_max, dbljmp_half 26 | Pretty much the opposite of drop, if the departing platform is on top of another one, and y distance is within threshold 27 | the it can be reached. dbljmp_half is pretty much half the jump height of dbljmp_max 28 | 29 | Proposed Movement optimization techniques: 30 | 1. Lookahead-Jumps 31 | Suppose there are 3 platforms A, B, C in the respective form: 32 | 33 | A---------+ 34 | 35 | B-----------+ 36 | 37 | C----------- 38 | 39 | If a path was planned A->B->C, normal jumpr would resort to jumping from the "+" at A, landing somewhere in the middle 40 | of B, move and Jump from "+" in B. 41 | However, if we are at platform A, and we have information that the next destination from B is C, movement can be 42 | optimized, resulting in smoother and faster movement. 43 | Since we do not have to be at the exact end of a platform to perform a downwards-right jump, we can resort to 44 | glide-jumping earlier and land near the middle of the platform, since glide jump can "glode" over some of its platform. 45 | An attack command would be issued at the middle of each platform, which reduces the time needed to move to an optimal 46 | spot to attack, and can move to the next platform from the exact position where the attack was done. 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # MS-Visionify: OpenCV based KMS MapleStory automation on Python3 2 | This projects aims to create a intelligent automated bot for KMS MapleStory with the character Demon Avenger. 3 | The bot uses OpenCV to find features from the game screen and use DirectInput emulation to interact 4 | with the game. 5 | 6 | ## 2019.3.1 - No longer being updated because of abuse and personal decisions 7 | Ms-Visionify was created to attempt to perfectly mimicking a human in a RPG environment. Although I was aware bots were 8 | against the terms of service of the game, I did continue developing the bot, and unfortunately, it has been exploited 9 | for commercial purposes like selling partly and fully compiled versions of the bot. I reconsidered the effects on the 10 | game if this bot becomes well known, and concluded that it's not right to continue the development and fully release the 11 | source so anyone can bot the game. Therefore, I have decided that **no further development will be continued on MS-Visionify.** 12 | However, I will not take any of the source down or the repo itself. Although a short-lived project, it provided valuable 13 | experience in image processing and automation. 14 | 15 | 개발을 계속하는 것과 누구나 사용할수 있게끔 소스를 공개하는 행동은 부정적인 영향밖에 없을거라고 생각했습니다. MS-Visionify의 개발을 중지하기로 결정했습니다. 16 | 다만 현재까지 공개된 소스와 repository 자체는 삭제하지 않을 예정입니다. 17 | 18 | ### *How does it work?* 19 | It's actually very simple. The bot uses image processing to extract player coordinates from the on-screen minimap. On 20 | the regular version, it maintains a tree structure of the platforms, selecting a destination platform based on least-visited-first 21 | strategy. On Version 2, it will use A* to construct a path. After that, it's just a matter of using skills at the right intervals. 22 | 23 | ## prerequisites: 24 | * OpenCV-Python 25 | * imutils 26 | * numpy 27 | * pywin32 28 | * PIL(Pillow) 29 | * tensorflow 30 | * Keras 31 | * pythoncom, pyHook (optional, for debugging) 32 | * matplotlib (optional, for debugging) 33 | 34 | ### Note of regard to code users 35 | *Commercial usage of the following code is free of will, but discouraged.* This project was not intended to be commercialized, and was 36 | only for research purposes and proof-of-concept. Any malicious uses of the following code can result in 37 | Nexon reenforcing anti-bot features which will render this bot and future improvements useless. 38 | 39 | ### To Nexon: 40 | If you have issues with the following code being developed/distributed, please contact me so we can get the issues resolved. 41 | 42 | This software is in no way associated with or endorsed by Nexon or any subsidiaries. 43 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | absl-py==0.7.0 2 | astor==0.7.1 3 | gast==0.2.2 4 | grpcio==1.18.0 5 | h5py==2.9.0 6 | imutils==0.5.2 7 | Keras==2.2.4 8 | Keras-Applications==1.0.7 9 | Keras-Preprocessing==1.0.9 10 | Markdown==3.0.1 11 | matplotlib==3.0.2 12 | numpy==1.16.1 13 | opencv-python==4.0.0.21 14 | Pillow==5.4.1 15 | protobuf==3.6.1 16 | pywin32==224 17 | PyYAML==3.13 18 | scipy==1.2.1 19 | six==1.12.0 20 | tensorboard==1.12.2 21 | tensorflow==1.12.0 22 | termcolor==1.1.0 23 | Werkzeug==0.14.1 24 | -------------------------------------------------------------------------------- /rune_trainer/arrow_classifier_keras_gray.h5: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dashadower/MS-Visionify/a7e48c71005a577443d998fb1e8c0f4cbdb03ba7/rune_trainer/arrow_classifier_keras_gray.h5 -------------------------------------------------------------------------------- /rune_trainer/class_indices.txt: -------------------------------------------------------------------------------- 1 | None 2 | training_set indices: 3 | {'down': 0, 'left': 1, 'right': 2, 'up': 3}test_set indices: 4 | {'down': 0, 'left': 1, 'right': 2, 'up': 3} -------------------------------------------------------------------------------- /rune_trainer/convert_img2gray.py: -------------------------------------------------------------------------------- 1 | """ 2 | RGB to Grayscale batch converter. 3 | """ 4 | import os, cv2, glob 5 | 6 | categories = ["up", "down", "left", "right"] 7 | subdirs = ["external_images"] 8 | 9 | os.chdir("images/cropped") 10 | 11 | for fdir in subdirs: 12 | for cat in categories: 13 | files = glob.glob("%s/%s/*.png"%(fdir, cat)) 14 | for file in files: 15 | img = cv2.imread(file) 16 | ds = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) 17 | # Maximize saturation 18 | ds[:, :, 1] = 255 19 | ds[:, :, 2] = 255 20 | ds = cv2.cvtColor(ds, cv2.COLOR_HSV2BGR) 21 | ds = cv2.cvtColor(ds, cv2.COLOR_BGR2GRAY) 22 | cv2.imwrite(file, ds) 23 | print(file, "done") 24 | -------------------------------------------------------------------------------- /rune_trainer/count_images.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | # -*- coding:utf-8 -*- 3 | """ 4 | Merge two directories of up,down,left,right images 5 | """ 6 | import os, cv2, glob 7 | 8 | categories = ["up", "down", "left", "right"] 9 | dir = "testdata" 10 | 11 | os.chdir("images/cropped") 12 | 13 | for cat in categories: 14 | files = glob.glob("%s/%s/*.png"%(dir, cat)) 15 | print(cat, len(files)) 16 | 17 | 18 | -------------------------------------------------------------------------------- /rune_trainer/image format and how to make data.txt: -------------------------------------------------------------------------------- 1 | IMPORTANT: ALL IMAGE PROCESSING IS DONE AT RUNE_SCREEN_CAPTURE 2 | 3 | Info on scripts on this dir: 4 | convert_img2gray.py: Converts Images in given directory to grayscale using coversion method listed below 5 | count_images.py: returns a count of files in given directories 6 | keras_model_verifier.py: simple script to test trained model against running maplestory 7 | merge_images.py: when you have 2 directories containing a subdir of categories, merges images using labeldata.txt 8 | move_traindata2test.py: moves some images of each class folder to a different directory class folder 9 | rune_dataset_classifier: tool to classify directory of 60x60 images into classes. 10 | rune_screen_cacpture.py: tool to capture maplestory screen in rgb 11 | train_keras.py: CNN train script 12 | 13 | 1. run rune_screen_capture. Will create cropped processed grayscale images of 4 rune markers saves to image/screenshots 14 | 2. run rune_dataset_classifier Will crop images to 4 circles and move them accordingly to images/cropped 15 | 3. run move_traindata2test will move some images in images/cropped/traindata to testdata 16 | 4. run trainer_keras 17 | 18 | 19 | image capture format: 20 | capture as RGB - BGR image 21 | convert to HSV and increase saturation and value 22 | 23 | ds = cv2.cvtColor(ds, cv2.COLOR_BGR2HSV) 24 | # Maximize saturation 25 | ds[:, :, 1] = 255 26 | ds[:, :, 2] = 255 27 | 28 | finally convert to bgr and then gray 29 | 30 | ds = cv2.cvtColor(ds, cv2.COLOR_HSV2BGR) 31 | ds = cv2.cvtColor(ds, cv2.COLOR_BGR2GRAY) 32 | 33 | Dataset capture location: 34 | 룬당 7장, 채널변경, 지역 5개 이상 35 | 1. 츄츄 아일랜드 36 | 2. 소멸의 여로 37 | 3. 헤이븐 38 | 4. 타락한 세계수 39 | 5. 황혼의 페리온 40 | 6. 여우골짜기 41 | 7. 파괴된 헤네시스 42 | 8. 커닝타워 43 | 9. 세 개의 문(시간의 신전) 44 | 10. 사자왕의 성 45 | 11. 엘린숲 46 | 12. 유적발굴지 47 | 13. 슬리피 던전 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /rune_trainer/keras_model_verifier.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | """Classifier model verifier""" 3 | import sys 4 | sys.path.append("../src") 5 | 6 | from screen_processor import MapleScreenCapturer 7 | import cv2, time, imutils, os, glob, random 8 | import numpy as np 9 | from win32gui import SetForegroundWindow 10 | from keras.models import load_model 11 | from tensorflow import device 12 | cap = MapleScreenCapturer() 13 | 14 | from keras import backend as K 15 | from tensorflow import Session, ConfigProto, GPUOptions 16 | # Use GPU Mode TF 17 | """gpuoptions = GPUOptions(allow_growth=True) 18 | session = Session(config=ConfigProto(gpu_options=gpuoptions)) 19 | K.set_session(session) 20 | # End Use GPU Mode TF 21 | """ 22 | model_name = "arrow_classifier_keras_gray.h5" 23 | #with device("/cpu:0"): 24 | model = load_model(model_name) 25 | model.compile(optimizer = "adam", loss = 'categorical_crossentropy', metrics = ['accuracy']) 26 | model.load_weights(model_name) 27 | 28 | labels = {'down': 0, 'left': 1, 'right': 2, 'up': 3} 29 | 30 | mode = input("{0} validator\n1 to test from validation_data\n2 to run data capture tool\n>".format(model_name)) 31 | if mode == str(1): 32 | 33 | os.chdir("images/cropped/validation_data") 34 | _images = glob.glob("*.png") 35 | random.shuffle(_images) 36 | images = [] 37 | for x in range(4): 38 | images.append(cv2.imread(os.path.join(os.getcwd(), _images[x]), cv2.IMREAD_GRAYSCALE)) 39 | print(os.path.join(os.getcwd(), _images[x])) 40 | img2tensor = np.vstack([np.reshape(x, [1,60,60,1]).astype(np.float32) for x in images]) 41 | res = model.predict(img2tensor, batch_size=4) 42 | index = 0 43 | print(res) 44 | for result in res: 45 | final_class = np.argmax(result, axis=-1) 46 | for key, val in labels.items(): 47 | if final_class == val: 48 | print(key) 49 | 50 | cv2.imshow(str(result), images[index]) 51 | cv2.waitKeyEx(0) 52 | cv2.destroyAllWindows() 53 | index += 1 54 | 55 | elif mode == str(2): 56 | x, y, w, h = 450, 180, 500, 130 57 | ds = None 58 | while True: 59 | img = cap.capture(set_focus=False) 60 | img_arr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) 61 | img_arr = img_arr[y:y + h, x:x + w] 62 | final_img = imutils.resize(img_arr, width = 200) 63 | cv2.imshow("q to save image", final_img) 64 | inp = cv2.waitKey(1) 65 | 66 | if inp == ord("q"): 67 | SetForegroundWindow(cap.ms_get_screen_hwnd()) 68 | time.sleep(0.3) 69 | ds = cap.capture(set_focus=False) 70 | ds = cv2.cvtColor(np.array(ds), cv2.COLOR_RGB2BGR) 71 | ds = ds[y:y + h, x:x + w] 72 | print("saved") 73 | cv2.destroyAllWindows() 74 | break 75 | 76 | 77 | display = ds.copy() 78 | gray = display.copy() 79 | gray = cv2.cvtColor(gray, cv2.COLOR_BGR2HSV) 80 | # Maximize saturation 81 | gray[:, :, 1] = 255 82 | gray[:, :, 2] = 255 83 | gray = cv2.cvtColor(gray, cv2.COLOR_HSV2BGR) 84 | gray = cv2.cvtColor(gray, cv2.COLOR_BGR2GRAY) 85 | 86 | circles = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT,1, gray.shape[0]/8 , param1=100, param2=30,minRadius=18, maxRadius=30) 87 | circle_roi = [] 88 | os.chdir("images/cropped/validation_data") 89 | if circles is not None: 90 | circles = np.round(circles[0, :]).astype("int") 91 | for (x, y, r) in circles: 92 | print(x, y, r) 93 | cropped = gray[max(0,int(y-60/2)):int(y+60/2), max(0,int(x-60/2)):int(x+60/2)] 94 | cv2.imwrite("%d%d%d.png" % (x, y, r), cropped) 95 | circle_roi.append([np.reshape(cropped, [1,60,60,1]).astype(np.float32), (x,y), cropped]) 96 | 97 | cv2.circle(gray, (x, y), r, (0, 255, 0), 2) 98 | cv2.circle(display, (x, y), r, (0, 255, 0), 2) 99 | 100 | cv2.imshow("", display) 101 | cv2.waitKey(0) 102 | cv2.destroyAllWindows() 103 | cv2.imshow("", gray) 104 | cv2.waitKey(0) 105 | cv2.destroyAllWindows() 106 | circle_roi = sorted(circle_roi, key=lambda x: x[1][0]) 107 | img2tensor = np.vstack([x[0] for x in circle_roi]) 108 | res = model.predict(img2tensor, batch_size=4) 109 | index = 0 110 | for result in res: 111 | final_class = np.argmax(result, axis=-1) 112 | for key, val in labels.items(): 113 | if final_class == val: 114 | print(key) 115 | 116 | cv2.imshow(str(result), circle_roi[index][2]) 117 | 118 | cv2.waitKeyEx(0) 119 | cv2.destroyAllWindows() 120 | index += 1 121 | -------------------------------------------------------------------------------- /rune_trainer/merge_images.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | """ 3 | Merge two directories of up,down,left,right images 4 | """ 5 | import os, cv2, glob 6 | 7 | categories = ["up", "down", "left", "right"] 8 | fromdir = "external_images" 9 | todir = "traindata" 10 | os.chdir("images/cropped") 11 | numberofphotos = int(open("labeldata.txt", "r").read()) 12 | print(numberofphotos) 13 | 14 | 15 | for cat in categories: 16 | files = glob.glob("%s/%s/*.png"%(fromdir, cat)) 17 | for file in files: 18 | numberofphotos += 1 19 | os.rename(file, "%s/%s/%d.png"%(todir, cat, numberofphotos)) 20 | #cv2.imwrite(file, ds) 21 | print(file, "done") 22 | 23 | open("labeldata.txt", "w").write(str(numberofphotos)) 24 | -------------------------------------------------------------------------------- /rune_trainer/move_traindata2test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Train data to test data splitter. 3 | """ 4 | # -*- coding:utf-8 -*- 5 | import os, glob, random 6 | os.chdir("images/cropped") 7 | 8 | categories = ["up", "down", "left", "right"] 9 | 10 | ops = int(input("Number of pictures from each category to move?")) 11 | 12 | for cat in categories: 13 | opt = 0 14 | dirs = glob.glob("traindata\\%s\\*.png"%(cat)) 15 | random.shuffle(dirs) 16 | for file in dirs: 17 | os.rename(file, "testdata\\%s\\%s"%(cat, file.split("\\")[-1])) 18 | print(file, "->", "testdata/%s/%s"%(cat, file.split("\\")[-1])) 19 | opt = opt + 1 20 | if opt >= ops and ops != 0: 21 | break 22 | #pass -------------------------------------------------------------------------------- /rune_trainer/opencv_arrow_detector.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import sys 3 | sys.path.append("../src") 4 | from screen_processor import MapleScreenCapturer 5 | import cv2, time, imutils, math, glob, random 6 | import numpy as np 7 | cap = MapleScreenCapturer() 8 | from win32gui import SetForegroundWindow 9 | 10 | 11 | 12 | x, y, w, h = 450, 180, 500, 130 13 | ds = None 14 | while True: 15 | img = cap.capture(rect=[0,0,1600,900], set_focus=False) 16 | img_arr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) 17 | final_img = imutils.resize(img_arr, width = 200) 18 | cv2.imshow("s to save image", final_img) 19 | inp = cv2.waitKey(1) 20 | 21 | 22 | 23 | if inp == ord("s"): 24 | SetForegroundWindow(cap.ms_get_screen_hwnd()) 25 | time.sleep(0.3) 26 | ds = cap.capture(set_focus=False) 27 | ds = cv2.cvtColor(np.array(ds), cv2.COLOR_RGB2BGR) 28 | ds = ds[y:y + h, x:x + w] 29 | print("saved") 30 | 31 | elif inp == ord("q"): 32 | cv2.destroyAllWindows() 33 | break 34 | elif inp == ord("r"): 35 | imgpath = "C:\\Users\\tttll\\PycharmProjects\\MacroSTory\\rune_trainer\\images\\screenshots\\finished\\*.png" 36 | dt = random.choice(glob.glob(imgpath)) 37 | 38 | ds = cv2.imread(dt) 39 | print("read data") 40 | 41 | display = ds.copy() 42 | gray = display.copy() 43 | gray = cv2.cvtColor(gray, cv2.COLOR_BGR2HSV) 44 | # Maximize saturation 45 | gray[:, :, 1] = 255 46 | gray[:, :, 2] = 255 47 | gray = cv2.cvtColor(gray, cv2.COLOR_HSV2BGR) 48 | gray = cv2.cvtColor(gray, cv2.COLOR_BGR2GRAY) 49 | cv2.imwrite("validation_rgb.png", display) 50 | circles = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT,1, gray.shape[0]/8 , param1=100, param2=30,minRadius=18, maxRadius=30) 51 | circle_roi = [] 52 | if circles is not None: 53 | circles = np.round(circles[0, :]).astype("int") 54 | for (x, y, r) in circles: 55 | print(x, y, r) 56 | cropped_color = display[max(0, int(y - 60 / 2)):int(y + 60 / 2), max(0, int(x - 60 / 2)):int(x + 60 / 2)] 57 | cropped = gray[max(0,int(y-60/2)):int(y+60/2), max(0,int(x-60/2)):int(x+60/2)] 58 | cropped = cv2.erode(cropped, (3,3)) 59 | cropped = cv2.dilate(cropped, (3,3)) 60 | cropped = cv2.Canny(cropped, threshold1=120, threshold2=190) 61 | im2, contours, hierachy = cv2.findContours(cropped, cv2.RETR_TREE , cv2.CHAIN_APPROX_SIMPLE) 62 | cv2.drawContours(cropped_color,contours,-1,(167,143,255),1) 63 | temp = np.zeros([60,60], np.uint8) 64 | for y in range(0, 60): 65 | for x in range(0, 60): 66 | if (cropped_color[y,x].tolist() == [167,143,255]): 67 | temp[y,x] = 255 68 | 69 | #cv2.circle(gray, (x, y), r, (0, 255, 0), 2) 70 | #cv2.circle(display, (x, y), r, (0, 255, 0), 2) 71 | circle_roi.append([temp, (x, y), cropped_color]) 72 | 73 | cv2.imshow("", display) 74 | cv2.waitKey(0) 75 | cv2.destroyAllWindows() 76 | cv2.imshow("", gray) 77 | cv2.waitKey(0) 78 | cv2.destroyAllWindows() 79 | circle_roi = sorted(circle_roi, key=lambda x: x[1][0]) 80 | 81 | circle_radius = 18 82 | 83 | def distance(x1, y1, x2, y2): 84 | return math.sqrt((x2-x1)**2 + (y2-y1)**2) 85 | 86 | 87 | quadrants = [0,0,0,0] 88 | for circ in circle_roi: 89 | circ_black = circ[0] 90 | circ_rgb = circ[2] 91 | center = circ[1] 92 | 93 | w = circ_black.shape[1] 94 | h = circ_black.shape[0] 95 | 96 | for y in range(0,h): 97 | for x in range(0,w): 98 | if distance(30,30, x, y) <= circle_radius: 99 | if circ_black[y,x] == 255: 100 | fixed_x = x - 30 101 | fixed_y = y - 30 102 | 103 | if fixed_y > 0: 104 | if fixed_x > 0: 105 | quadrants[0] += 1 106 | elif fixed_x < 0: 107 | quadrants[1] += 1 108 | elif fixed_y < 0: 109 | if fixed_x > 0: 110 | quadrants[3] += 1 111 | elif fixed_x < 0: 112 | quadrants[2] += 1 113 | 114 | print("top vs bottom", quadrants[0]+quadrants[1], quadrants[2]+quadrants[3]) 115 | print("left vs right", quadrants[1]+quadrants[2], quadrants[0]+quadrants[3]) 116 | cv2.circle(circ_rgb, (30, 30), circle_radius, (0, 255, 0), 2) 117 | cv2.imshow("", imutils.resize(circ_rgb, width=400)) 118 | cv2.imshow("b",imutils.resize(circ_black, width=400)) 119 | cv2.waitKeyEx(0) 120 | 121 | -------------------------------------------------------------------------------- /rune_trainer/readme.md: -------------------------------------------------------------------------------- 1 | ## CNN to solve runes automatically 2 | This directory contains the necessary files to generate images and train a CNN from the images. 3 | 4 | Info on scripts on this dir: 5 | * convert_img2gray.py: Converts Images in given directory to grayscale using coversion method listed below 6 | * count_images.py: returns a count of files in given directories 7 | * keras_model_verifier.py: simple script to test trained model against running maplestory 8 | * merge_images.py: when you have 2 directories containing a subdir of categories, merges images using labeldata.txt 9 | * move_traindata2test.py: moves some images of each class folder to a different directory class folder 10 | * rune_dataset_classifier: tool to classify directory of 60x60 images into classes. 11 | * rune_screen_cacpture.py: tool to capture maplestory screen in rgb 12 | * train_keras.py: CNN train script 13 | 14 | ### How to use it: 15 | 1. Create subdirectory `images` and underlying directories. Result should be: 16 | ``` 17 | images/ 18 | cropped/ 19 | traindata/ 20 | testdata/ 21 | screenshots/ 22 | ``` 23 | 24 | 2. Run `rune_screen_capture.py` while you have MS open. Now find runes and after activating them, press `s` or `d` to take screenshots of the rune ROI 25 | 3. Run `rune_dataset_classifier.py`. After you have a sufficient amount of uncropped images, this script will process them, find the indivisual rune arrows within the images. 26 | You have to manually classify them using your arrow keys. Once it's done, it will generate images into `traindata`. 27 | 4. Run `move_traindata2testpy` This script will move an equal amount of images from `cropped/traindata` to `cropped/testdata`. 80 to 20 28 | is a good amount 29 | 5. Run `trainer_keras.py`. This will start the training. 30 | 31 | ## *Note on dataset* 32 | 33 | I did not include the dataset I have personally used, to prevent malicious users getting stuff without effort. ~~If you would like 34 | to request the dataset, please personally contact me.~~ Under no circumstances I will release any more information related 35 | to botting or the repo itself. -------------------------------------------------------------------------------- /rune_trainer/rune_dataset_classifier.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import cv2, os, glob 3 | import numpy as np 4 | 5 | 6 | 7 | min_dist = 30 8 | min_r = 15 9 | max_r = 27 10 | hough_ksize = 2.0 11 | 12 | w,h = 60,60 13 | 14 | 15 | #traindata_path = os.path.join(os.getcwd(), "images\\cropped\\testdata") 16 | traindata_path = os.path.join(os.getcwd(), "images\\cropped\\traindata") 17 | os.chdir("images/screenshots") 18 | images = glob.glob("*.png") 19 | numberofphotos = int((open("../cropped/labeldata.txt", "r").read())) 20 | print(numberofphotos) 21 | total = 0 22 | last_img_name = None 23 | img_path = None 24 | for _img in images: 25 | print(_img) 26 | img = cv2.imread(_img, cv2.IMREAD_GRAYSCALE) 27 | circles = cv2.HoughCircles(img, cv2.HOUGH_GRADIENT, hough_ksize, min_dist, minRadius=min_r, maxRadius=max_r) 28 | if circles is not None: 29 | circles = np.round(circles[0, :]).astype("int") 30 | for (x, y, r) in circles: 31 | cropped = img[max(0,int(y-h/2)):int(y+h/2), max(0,int(x-w/2)):int(x+w/2)] 32 | cv2.imshow("", cropped) 33 | dt = cv2.waitKeyEx(0) 34 | if dt == 2490368: 35 | 36 | cv2.imwrite(os.path.join(traindata_path, "up", "%d.png"%(numberofphotos+total+1)), cropped) 37 | print(os.path.join(traindata_path, "up", "%d.png"%(numberofphotos+total+1))) 38 | total += 1 39 | img_path = "up" 40 | elif dt == 2621440: 41 | cv2.imwrite(os.path.join(traindata_path, "down", "%d.png" % (numberofphotos + total + 1)), cropped) 42 | print(os.path.join(traindata_path, "down", "%d.png"%(numberofphotos+total+1))) 43 | total += 1 44 | img_path = "down" 45 | elif dt == 2424832: 46 | cv2.imwrite(os.path.join(traindata_path, "left", "%d.png" % (numberofphotos + total + 1)), cropped) 47 | print(os.path.join(traindata_path, "left", "%d.png"%(numberofphotos+total+1))) 48 | total += 1 49 | img_path = "left" 50 | elif dt == 2555904: 51 | cv2.imwrite(os.path.join(traindata_path, "right", "%d.png" % (numberofphotos + total + 1)), cropped) 52 | print(os.path.join(traindata_path, "right", "%d.png"%(numberofphotos+total+1))) 53 | total += 1 54 | img_path = "right" 55 | else: 56 | pass 57 | with open("../cropped/labeldata.txt", "w") as b: 58 | b.write(str(numberofphotos+total)) 59 | print("added %d new images"%(total)) -------------------------------------------------------------------------------- /rune_trainer/rune_screen_capture.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | from screen_processor import MapleScreenCapturer 3 | from keystate_manager import KeyboardInputManager 4 | from directinput_constants import DIK_SPACE, DIK_UP 5 | import cv2, os, time, glob, imutils 6 | import numpy as np 7 | cap = MapleScreenCapturer() 8 | kbd = KeyboardInputManager() 9 | from win32gui import SetForegroundWindow 10 | os.chdir("images/screenshots/finished") 11 | imgs = glob.glob("*.png") 12 | highest = 0 13 | for name in imgs: 14 | order = int(name[6:].split(".")[0]) 15 | highest = max(order, highest) 16 | 17 | os.chdir("../") 18 | x, y, w, h = 450, 180, 500, 130 19 | while True: 20 | img = cap.capture(set_focus=False) 21 | img_arr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) 22 | final_img = imutils.resize(img_arr, width = 200) 23 | cv2.imshow("s to automatically save 3 images in a row, d for just one", final_img) 24 | inp = cv2.waitKey(1) 25 | if inp == ord("q"): 26 | cv2.destroyAllWindows() 27 | break 28 | elif inp == ord("s"): 29 | SetForegroundWindow(cap.ms_get_screen_hwnd()) 30 | for t in range(5): 31 | time.sleep(1) 32 | kbd.single_press(DIK_SPACE) 33 | time.sleep(0.3) 34 | ds = cap.capture(set_focus=False) 35 | ds = cv2.cvtColor(np.array(ds), cv2.COLOR_RGB2BGR) 36 | ds = ds[y:y+h, x:x+w] 37 | ds = cv2.cvtColor(ds, cv2.COLOR_BGR2HSV) 38 | # Maximize saturation 39 | ds[:, :, 1] = 255 40 | ds[:, :, 2] = 255 41 | ds = cv2.cvtColor(ds, cv2.COLOR_HSV2BGR) 42 | ds = cv2.cvtColor(ds, cv2.COLOR_BGR2GRAY) 43 | highest = highest + 1 44 | cv2.imwrite("output%d.png"%(highest), ds) 45 | print("saved", "output%d.png"%(highest)) 46 | for g in range(3): 47 | kbd.single_press(DIK_UP) 48 | time.sleep(0.2) 49 | time.sleep(3) 50 | print("done") 51 | elif inp == ord("d"): 52 | SetForegroundWindow(cap.ms_get_screen_hwnd()) 53 | time.sleep(0.3) 54 | ds = cap.capture(set_focus=False) 55 | ds = cv2.cvtColor(np.array(ds), cv2.COLOR_RGB2BGR) 56 | ds = ds[y:y + h, x:x + w] 57 | ds = cv2.cvtColor(ds, cv2.COLOR_BGR2HSV) 58 | # Maximize saturation 59 | ds[:, :, 1] = 255 60 | ds[:, :, 2] = 255 61 | ds = cv2.cvtColor(ds, cv2.COLOR_HSV2BGR) 62 | ds = cv2.cvtColor(ds, cv2.COLOR_BGR2GRAY) 63 | highest = highest + 1 64 | cv2.imwrite("output%d.png" % (highest), ds) 65 | print("saved", "output%d.png" % (highest)) -------------------------------------------------------------------------------- /rune_trainer/trainer_keras.py: -------------------------------------------------------------------------------- 1 | 2 | # -*- coding:utf-8 -*- 3 | import cv2 4 | import numpy as np 5 | import os 6 | from random import shuffle 7 | import glob 8 | 9 | 10 | train_dir = "images\\cropped\\traindata" 11 | test_dir = "images\\cropped\\testdata" 12 | MODEL_NAME = "ARROWS.model" 13 | 14 | img_size = 60 15 | 16 | 17 | # Importing the Keras libraries and packages 18 | from keras.models import Sequential 19 | from keras.layers import Conv2D 20 | from keras.layers import MaxPooling2D 21 | from keras.layers import Flatten 22 | from keras.layers import Dense 23 | from keras.layers import Dropout 24 | from keras.layers import Activation 25 | from keras.layers import BatchNormalization 26 | from keras.preprocessing.image import ImageDataGenerator 27 | from keras.optimizers import adam 28 | from keras.callbacks import TensorBoard 29 | from keras import backend as K 30 | from tensorflow import Session, ConfigProto, GPUOptions 31 | gpuoptions = GPUOptions(allow_growth=True) 32 | session = Session(config=ConfigProto(gpu_options=gpuoptions)) 33 | K.set_session(session) 34 | classifier = Sequential() 35 | 36 | classifier.add(Conv2D(32, (3,3), input_shape=(img_size, img_size, 1))) 37 | classifier.add(Activation("relu")) 38 | 39 | classifier.add(Conv2D(32, (3,3))) 40 | classifier.add(Activation("relu")) 41 | classifier.add(MaxPooling2D(pool_size=(2, 2))) 42 | classifier.add(Dropout(0.25)) 43 | 44 | classifier.add(Conv2D(64, (3,3), padding='same')) 45 | classifier.add(Activation("relu")) 46 | classifier.add(Conv2D(64, (3,3))) 47 | classifier.add(Activation("relu")) 48 | classifier.add(MaxPooling2D(pool_size=(2, 2))) 49 | classifier.add(Dropout(0.25)) 50 | #classifier.add(Dropout(0.25)) 51 | 52 | classifier.add(Flatten()) 53 | classifier.add(Dense(256)) 54 | classifier.add(Activation("relu")) 55 | classifier.add(Dropout(0.5)) 56 | 57 | 58 | classifier.add(Dense(4)) 59 | classifier.add(Activation("softmax")) 60 | 61 | 62 | classifier.compile(optimizer = adam(lr=1e-6), loss = 'categorical_crossentropy', metrics = ['accuracy']) 63 | 64 | train_datagen = ImageDataGenerator(rotation_range=12) 65 | 66 | test_datagen = ImageDataGenerator(rotation_range=12) 67 | 68 | training_set = train_datagen.flow_from_directory('images/cropped/traindata', 69 | color_mode="grayscale", 70 | target_size = (img_size, img_size), 71 | batch_size = 32, 72 | class_mode = 'categorical', shuffle=True) 73 | 74 | test_set = test_datagen.flow_from_directory('images/cropped/testdata', 75 | color_mode="grayscale", 76 | target_size = (img_size, img_size), 77 | batch_size = 32, 78 | class_mode = 'categorical', shuffle=True) 79 | 80 | with open("class_indices.txt", "w") as indices_fine: 81 | indices_fine.write(str(classifier.summary())) 82 | indices_fine.write("\n") 83 | indices_fine.write("training_set indices:\n"+str(training_set.class_indices)) 84 | indices_fine.write("test_set indices:\n"+str(test_set.class_indices)) 85 | tbCallBack = TensorBoard(log_dir='./log', histogram_freq=0, write_graph=True, write_images=True) 86 | classifier.fit_generator(training_set,steps_per_epoch = 8000,epochs = 20,validation_data = test_set,validation_steps = 2000, shuffle=True, callbacks=[tbCallBack]) 87 | 88 | classifier.save("arrow_classifier_keras_gray.h5") 89 | -------------------------------------------------------------------------------- /rune_trainer/validation_rgb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dashadower/MS-Visionify/a7e48c71005a577443d998fb1e8c0f4cbdb03ba7/rune_trainer/validation_rgb.png -------------------------------------------------------------------------------- /rune_trainer/validator_img_classifier_tool.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import cv2, os, glob 3 | 4 | 5 | 6 | w,h = 60,60 7 | 8 | 9 | os.chdir("validation_data") 10 | images = glob.glob("*.png") 11 | for _img in images: 12 | 13 | if "-" in _img: 14 | continue 15 | print(_img) 16 | cropped = cv2.imread(_img) 17 | cv2.imshow("", cropped) 18 | dt = cv2.waitKeyEx(0) 19 | if dt == 2490368: 20 | os.rename(_img, _img.split(".")[0]+"-up"+".png") 21 | img_path = "up" 22 | elif dt == 2621440: 23 | os.rename(_img, _img.split(".")[0] + "-down" + ".png") 24 | img_path = "down" 25 | elif dt == 2424832: 26 | os.rename(_img, _img.split(".")[0] + "-left" + ".png") 27 | img_path = "left" 28 | elif dt == 2555904: 29 | os.rename(_img, _img.split(".")[0] + "-right" + ".png") 30 | img_path = "right" 31 | else: 32 | pass 33 | -------------------------------------------------------------------------------- /src/directinput_constants.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | # IMPORTANT!!! Arrow keys are default bound to numpad 4,6,2,8. Disable numlock befure use!!!!! 3 | # If keyboard input does not work, run as admin 4 | # http://www.flint.jp/misc/?q=dik&lang=en DirectInput Key Codes 5 | 6 | 7 | DIK_1 = 0x02 8 | DIK_2 = 0x03 9 | DIK_3 = 0x04 10 | DIK_4 = 0x05 11 | DIK_5 = 0x06 12 | DIK_6 = 0x07 13 | DIK_7 = 0x08 14 | DIK_8 = 0x08 15 | DIK_9 = 0x0A 16 | DIK_0 = 0x0B 17 | 18 | DIK_A = 0x1E 19 | DIK_B = 0x30 20 | DIK_C = 0x2E 21 | DIK_D = 0x20 22 | DIK_E = 0x12 23 | DIK_F = 0x21 24 | DIK_G = 0x22 25 | DIK_H = 0x23 26 | DIK_I = 0x17 27 | DIK_J = 0x24 28 | DIK_K = 0x25 29 | DIK_L = 0x26 30 | DIK_M = 0x32 31 | DIK_N = 0x31 32 | DIK_O = 0x18 33 | DIK_P = 0x19 34 | DIK_Q = 0x10 35 | DIK_R = 0x13 36 | DIK_S = 0x1F 37 | DIK_T = 0x14 38 | DIK_U = 0x16 39 | DIK_V = 0x2F 40 | DIK_W = 0x11 41 | DIK_X = 0x2D 42 | DIK_Y = 0x15 43 | DIK_Z = 0x2c 44 | 45 | DIK_COMMA = 0x33 46 | 47 | DIK_LEFT = 0xCB 48 | DIK_RIGHT = 0xCD 49 | DIK_UP = 0xC8 50 | DIK_DOWN = 0xD0 51 | 52 | DIK_ALT = 0xB8 53 | DIK_SPACE = 0x39 54 | 55 | DIK_NUMLOCK = 0x45 56 | 57 | DIK_LCTRL = 0x9D 58 | 59 | DIK_RMENU = 0xB8 # Right Alt button (Kor/En) 60 | 61 | DIK_RETURN = 0x1C 62 | 63 | keysym_map = { # tkinter event keysym to dik key code coversion table 64 | "ALT_L":DIK_ALT, 65 | "CONTROL_L": DIK_LCTRL, 66 | "space": DIK_SPACE, 67 | "comma": DIK_COMMA, 68 | "a": DIK_A, 69 | "b": DIK_B, 70 | "c": DIK_C, 71 | "d": DIK_D, 72 | "e": DIK_E, 73 | "f": DIK_F, 74 | "g": DIK_G, 75 | "h": DIK_H, 76 | "i": DIK_I, 77 | "j": DIK_J, 78 | "k": DIK_K, 79 | "l": DIK_L, 80 | "m": DIK_M, 81 | "n": DIK_N, 82 | "o": DIK_O, 83 | "p": DIK_P, 84 | "q": DIK_Q, 85 | "r": DIK_R, 86 | "s": DIK_S, 87 | "t": DIK_T, 88 | "u": DIK_U, 89 | "v": DIK_V, 90 | "w": DIK_W, 91 | "x": DIK_X, 92 | "y": DIK_Y, 93 | "z": DIK_Z, 94 | "1": DIK_1, 95 | "2": DIK_2, 96 | "3": DIK_3, 97 | "4": DIK_4, 98 | "5": DIK_5, 99 | "6": DIK_6, 100 | "7": DIK_7, 101 | "8": DIK_8, 102 | "9": DIK_9, 103 | "0": DIK_0 104 | } 105 | -------------------------------------------------------------------------------- /src/keybind_setup_window.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter.constants import * 3 | from tkinter.messagebox import showinfo, showwarning 4 | import os, pickle 5 | from directinput_constants import keysym_map 6 | from keystate_manager import DEFAULT_KEY_MAP 7 | class SetKeyMap(tk.Toplevel): 8 | def __init__(self, master): 9 | tk.Toplevel.__init__(self, master) 10 | self.wm_minsize(200, 30) 11 | self.master = master 12 | self.title("키설정") 13 | self.focus_get() 14 | self.grab_set() 15 | if not os.path.exists("keymap.keymap"): 16 | self.create_default_keymap() 17 | self.keymap_data = self.read_keymap_file() 18 | self.labels = {} 19 | keycount = 0 20 | _keyname = "" 21 | self.columnconfigure(1, weight=1, minsize=150) 22 | self.columnconfigure(0, weight=1) 23 | for keyname, value in self.keymap_data.items(): 24 | dik_code, kor_name = value 25 | tk.Label(self, text=kor_name, borderwidth=1, relief=SOLID, padx=2).grid(row=keycount, column=0, sticky=N+S+E+W, pady=5, padx=(5,0)) 26 | _keyname = keyname 27 | self.labels[_keyname] = tk.StringVar() 28 | self.labels[_keyname].set(self.dik2keysym(dik_code)) 29 | tk.Button(self, textvariable=self.labels[_keyname], command=lambda _keyname=_keyname: self.set_key(_keyname), borderwidth=1, relief=SOLID).grid(row=keycount, column=1, sticky=N+S+E+W, pady=5, padx=(0,5)) 30 | self.rowconfigure(keycount, weight=1) 31 | 32 | keycount += 1 33 | 34 | tk.Button(self, text="기본값 복원", command=self.set_default_keymap).grid(row=keycount, column=0) 35 | tk.Button(self, text="저장하고 종료", command=self.onSave).grid(row=keycount, column=1) 36 | 37 | def set_default_keymap(self): 38 | self.create_default_keymap() 39 | showinfo("키설정", "기본값으로 복원했습니다") 40 | self.destroy() 41 | 42 | def keysym2dik(self, keysym): 43 | try: 44 | dik = keysym_map[keysym] 45 | return dik 46 | except: 47 | return 0 48 | 49 | def onSave(self): 50 | with open("keymap.keymap", "wb") as f: 51 | pickle.dump({"keymap": self.keymap_data}, f) 52 | self.destroy() 53 | 54 | def onPress(self, event, key_name): 55 | found = False 56 | for key, value in keysym_map.items(): 57 | if event.keysym == key or str(event.keysym).upper() == key: 58 | self.keymap_data[key_name] = [value, self.keymap_data[key_name][1]] 59 | self.labels[key_name].set(key) 60 | found = True 61 | break 62 | if not found: 63 | showwarning("키설정", "현재 지원하지 않는 키입니다. 기본 키로 초기화 됩니다."+str(event.keysym)) 64 | self.keymap_data[key_name] = DEFAULT_KEY_MAP[key_name] 65 | self.labels[key_name].set(self.dik2keysym(DEFAULT_KEY_MAP[key_name][0])) 66 | 67 | self.unbind("") 68 | 69 | def set_key(self, key_name): 70 | self.labels[key_name].set("키를 입력해 주세요") 71 | self.unbind("") 72 | self.bind("", lambda event: self.onPress(event, key_name)) 73 | 74 | def dik2keysym(self, dik): 75 | for keysym, _dik in keysym_map.items(): 76 | if dik == _dik: 77 | return keysym 78 | 79 | 80 | def read_keymap_file(self): 81 | if os.path.exists("keymap.keymap"): 82 | with open("keymap.keymap", "rb") as f: 83 | try: 84 | data = pickle.load(f) 85 | keymap = data["keymap"] 86 | except: 87 | return 0 88 | else: 89 | return keymap 90 | 91 | def create_default_keymap(self): 92 | with open("keymap.keymap", "wb") as f: 93 | pickle.dump({"keymap": DEFAULT_KEY_MAP}, f) -------------------------------------------------------------------------------- /src/keystate_manager.py: -------------------------------------------------------------------------------- 1 | import directinput_constants as dic 2 | import time, ctypes 3 | from win32api import GetKeyState 4 | from win32con import VK_NUMLOCK 5 | 6 | SendInput = ctypes.windll.user32.SendInput 7 | # C struct redefinitions 8 | PUL = ctypes.POINTER(ctypes.c_ulong) 9 | 10 | 11 | class KeyBdInput(ctypes.Structure): 12 | _fields_ = [("wVk", ctypes.c_ushort), 13 | ("wScan", ctypes.c_ushort), 14 | ("dwFlags", ctypes.c_ulong), 15 | ("time", ctypes.c_ulong), 16 | ("dwExtraInfo", PUL)] 17 | 18 | class HardwareInput(ctypes.Structure): 19 | _fields_ = [("uMsg", ctypes.c_ulong), 20 | ("wParamL", ctypes.c_short), 21 | ("wParamH", ctypes.c_ushort)] 22 | 23 | class MouseInput(ctypes.Structure): 24 | _fields_ = [("dx", ctypes.c_long), 25 | ("dy", ctypes.c_long), 26 | ("mouseData", ctypes.c_ulong), 27 | ("dwFlags", ctypes.c_ulong), 28 | ("time",ctypes.c_ulong), 29 | ("dwExtraInfo", PUL)] 30 | 31 | 32 | class Input_I(ctypes.Union): 33 | _fields_ = [("ki", KeyBdInput), 34 | ("mi", MouseInput), 35 | ("hi", HardwareInput)] 36 | 37 | 38 | class Input(ctypes.Structure): 39 | _fields_ = [("type", ctypes.c_ulong), 40 | ("ii", Input_I)] 41 | 42 | # Actual Functions 43 | 44 | def PressKey(hexKeyCode): 45 | extra = ctypes.c_ulong(0) 46 | ii_ = Input_I() 47 | ii_.ki = KeyBdInput(0, hexKeyCode, 0x0008, 0, ctypes.pointer(extra) ) 48 | x = Input( ctypes.c_ulong(1), ii_) 49 | ctypes.windll.user32.SendInput(1, ctypes.pointer(x), ctypes.sizeof(x)) 50 | 51 | 52 | def ReleaseKey(hexKeyCode): 53 | extra = ctypes.c_ulong(0) 54 | ii_ = Input_I() 55 | ii_.ki = KeyBdInput(0, hexKeyCode, 0x0008 | 0x0002, 0, ctypes.pointer(extra)) # 0x0008: KEYEVENTF_SCANCODE 56 | x = Input( ctypes.c_ulong(1), ii_) 57 | ctypes.windll.user32.SendInput(1, ctypes.pointer(x), ctypes.sizeof(x)) # 0x0002: KEYEVENTF_KEYUP 58 | 59 | 60 | def toggle_numlock(): 61 | if GetKeyState(VK_NUMLOCK): 62 | PressKey(dic.DIK_NUMLOCK) 63 | time.sleep(0.05) 64 | ReleaseKey(dic.DIK_NUMLOCK) 65 | 66 | class KeyboardInputManager: 67 | """ 68 | This is an attempt to manage input from a single source. It remembers key "states" , which consists of keypress 69 | modifications, and actuates them in a single batch. 70 | """ 71 | def __init__(self, debug=False): 72 | """ 73 | Class variables: 74 | self.key_state: Temporary state dictionary before being actuated. Dictionary with DIK key names as keys with 75 | 0 or 1 as keys (0 for release, 1 for press) 76 | self.actual_key_state: Actual key state dictionary. This dictionary is used to keep track of which keys are 77 | currently being pressed. Same format as self.key_state 78 | :param debug: Debug flag 79 | """ 80 | self.key_state = {} 81 | self.actual_key_state = {} 82 | self.debug = debug 83 | toggle_numlock() 84 | 85 | def get_key_state(self, key_code=None): 86 | """ 87 | Returns key state or states of current manager state 88 | :param key_code : DIK key name of key to look up. Please refer to directinput_constants.py. If undefined, returns enture key state 89 | :return: None""" 90 | if key_code: 91 | if key_code in self.key_state.keys(): 92 | return self.key_state[key_code] 93 | else: 94 | return None 95 | else: 96 | return self.key_state 97 | 98 | def set_key_state(self, key_code, value): 99 | """ 100 | Explicitly sets key state for key_code by value 101 | :param key_code: DIK Key name of keycode 102 | :param value: 0 for released, 1 for pressed 103 | :return: None""" 104 | self.key_state[key_code] = value 105 | 106 | def single_press(self, key_code, duration=0.08, additional_duration=0): 107 | """ 108 | Presses key_code for duration seconds. Since it uses time.sleep(), it is a blocking call. 109 | :param key_code: DIK key code of key 110 | :param duration: Float of keypress duration in seconds 111 | :param additional_duration: additinal delay to be added 112 | :return: None 113 | """ 114 | self._direct_press(key_code) 115 | time.sleep(duration+additional_duration) 116 | self._direct_release(key_code) 117 | 118 | def translate_key_state(self): 119 | """ 120 | Acuates key presses in self.key_state to self.actual_key_state by pressing keys and storing state in self.actual_key_state 121 | self.actual_key_state becomes self.key_state, and self.key_state will get reset 122 | :return: None 123 | """ 124 | for keycode, state in self.key_state.items(): 125 | if keycode in self.actual_key_state.keys(): 126 | if self.actual_key_state[keycode] != state: 127 | if state: 128 | PressKey(keycode) 129 | self.actual_key_state[keycode] = 1 130 | elif not state: 131 | ReleaseKey(keycode) 132 | self.actual_key_state[keycode] = 0 133 | else: 134 | if state: 135 | PressKey(keycode) 136 | self.actual_key_state[keycode] = 1 137 | elif not state: 138 | ReleaseKey(keycode) 139 | self.actual_key_state[keycode] = 0 140 | 141 | self.key_state = {} 142 | 143 | def _direct_press(self, key_code): 144 | PressKey(key_code) 145 | self.actual_key_state[key_code] = 1 146 | 147 | def _direct_release(self, key_code): 148 | ReleaseKey(key_code) 149 | self.actual_key_state[key_code] = 0 150 | 151 | def reset(self): 152 | """ 153 | Safe way of releasing all keys and resetting all states. 154 | :return: None 155 | """ 156 | for keycode, state in self.key_state.items(): 157 | if keycode in self.actual_key_state.keys(): 158 | self.key_state[keycode] = 0 159 | for keycode, state in self.actual_key_state.items(): 160 | self.key_state[keycode] = 0 161 | self.translate_key_state() 162 | 163 | DEFAULT_KEY_MAP = { 164 | "jump": [dic.DIK_ALT, "점프"], 165 | "moonlight_slash": [dic.DIK_A, "문라이트 슬래쉬"], 166 | "thousand_sword": [dic.DIK_F, "사우전드 소드"], 167 | "release_overload": [dic.DIK_Q, "릴리즈 오버로드"], 168 | "demon_strike": [dic.DIK_1, "데몬 스트라이크"], 169 | "shield_chase": [dic.DIK_S, "실드 체이싱"], 170 | "holy_symbol": [dic.DIK_4, "홀리 심볼"], 171 | "hyper_body": [dic.DIK_5, "하이퍼 바디"], 172 | "sharp_eyes": [dic.DIK_6, "샤프 아이즈"] 173 | } 174 | 175 | -------------------------------------------------------------------------------- /src/macro_script.py: -------------------------------------------------------------------------------- 1 | import keystate_manager as km 2 | import player_controller as pc 3 | import screen_processor as sp 4 | import terrain_analyzer as ta 5 | import directinput_constants as dc 6 | import rune_solver as rs 7 | import logging, math, time, random 8 | 9 | class CustomLogger: 10 | def __init__(self, logger_obj, logger_queue): 11 | self.logger_obj = logger_obj 12 | self.logger_queue = logger_queue 13 | 14 | def debug(self, *args): 15 | self.logger_obj.debug(" ".join([str(x) for x in args])) 16 | if self.logger_queue: 17 | self.logger_queue.put(("log", " ".join([str(x) for x in args]))) 18 | 19 | def exception(self, *args): 20 | self.logger_obj.exception(" ".join([str(x) for x in args])) 21 | if self.logger_queue: 22 | self.logger_queue.put(("log", " ".join([str(x) for x in args]))) 23 | 24 | class MacroController: 25 | def __init__(self, keymap=km.DEFAULT_KEY_MAP, rune_model_dir=r"arrow_classifier_keras_gray.h5", log_queue=None): 26 | 27 | #sys.excepthook = self.exception_hook 28 | 29 | self.screen_capturer = sp.MapleScreenCapturer() 30 | logger = logging.getLogger(self.__class__.__name__) 31 | logger.setLevel(logging.DEBUG) 32 | self.log_queue = log_queue 33 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 34 | 35 | fh = logging.FileHandler("logging.log") 36 | fh.setLevel(logging.DEBUG) 37 | fh.setFormatter(formatter) 38 | logger.addHandler(fh) 39 | 40 | self.logger = CustomLogger(logger, self.log_queue) 41 | self.logger.debug("%s init"%self.__class__.__name__) 42 | self.screen_processor = sp.StaticImageProcessor(self.screen_capturer) 43 | self.terrain_analyzer = ta.PathAnalyzer() 44 | self.keyhandler = km.KeyboardInputManager() 45 | self.player_manager = pc.PlayerController(self.keyhandler, self.screen_processor, keymap) 46 | 47 | 48 | self.last_platform_hash = None 49 | self.current_platform_hash = None 50 | self.goal_platform_hash = None 51 | 52 | self.platform_error = 3 # If y value is same as a platform and within 3 pixels of platform border, consider to be on said platform 53 | 54 | self.rune_model_path = rune_model_dir 55 | self.rune_solver = rs.RuneDetector(self.rune_model_path, screen_capturer=self.screen_capturer, key_mgr=self.keyhandler) 56 | self.rune_platform_offset = 2 57 | 58 | self.loop_count = 0 # How many loops did we loop over? 59 | self.reset_navmap_loop_count = 10 # every x times reset navigation map, scrambling pathing 60 | self.navmap_reset_type = 1 # navigation map reset type. 1 for random, -1 for just reset. GETS ALTERNATED 61 | 62 | self.walk_probability = 5 63 | # This sets random.randint(1, walk_probability) to decide of moonlight slash should just walk instead of glide 64 | # Probability of walking is (1/walk_probability) * 100 65 | 66 | self.restrict_moonlight_slash_probability = 5 67 | 68 | self.platform_fail_loops = 0 69 | # How many loops passed and we are not on a platform? 70 | 71 | self.platform_fail_loop_threshold = 10 72 | # If self.platform_fail_loops is greater than threshold, run unstick() 73 | 74 | self.unstick_attempts = 0 75 | # If not on platform, how many times did we attempt unstick()? 76 | 77 | self.unstick_attempts_threshold = 5 78 | # If unstick after this amount fails to get us on a known platform, abort abort. 79 | 80 | self.logger.debug("%s init finished"%self.__class__.__name__) 81 | 82 | def load_and_process_platform_map(self, path): 83 | retval = self.terrain_analyzer.load(path) 84 | self.terrain_analyzer.generate_solution_dict() 85 | if retval != 0: 86 | self.logger.debug("Loaded platform data %s"%(path)) 87 | else: 88 | self.logger.debug("Failed to load platform data %s, terrain_analyzer.load returned 0"%(path)) 89 | return retval 90 | 91 | def distance(self, x1, y1, x2, y2): 92 | return math.sqrt((x1-x2)**2 + (y1-y2)**2) 93 | 94 | def find_current_platform(self): 95 | current_platform_hash = None 96 | 97 | for key, platform in self.terrain_analyzer.oneway_platforms.items(): 98 | if self.player_manager.y >= min(platform.start_y, platform.end_y) and \ 99 | self.player_manager.y <= max(platform.start_y, platform.end_y) and \ 100 | self.player_manager.x >= platform.start_x and \ 101 | self.player_manager.x <= platform.end_x: 102 | current_platform_hash = platform.hash 103 | break 104 | 105 | for key, platform in self.terrain_analyzer.platforms.items(): 106 | if self.player_manager.y == platform.start_y and \ 107 | self.player_manager.x >= platform.start_x and \ 108 | self.player_manager.x <= platform.end_x: 109 | current_platform_hash = platform.hash 110 | break 111 | 112 | # Add additional check to take into account imperfect platform coordinates 113 | for key, platform in self.terrain_analyzer.platforms.items(): 114 | if self.player_manager.y == platform.start_y and \ 115 | self.player_manager.x >= platform.start_x - self.platform_error and \ 116 | self.player_manager.x <= platform.end_x + self.platform_error: 117 | current_platform_hash = platform.hash 118 | break 119 | 120 | if current_platform_hash: 121 | return current_platform_hash 122 | else: 123 | return 0 124 | 125 | def find_rune_platform(self): 126 | """ 127 | Checks if a rune exists on a platform and if exists, returns platform hash 128 | :return: Platform hash, rune_coord_tuple of platform where the rune is located, else 0, 0 if rune does not exist 129 | """ 130 | self.player_manager.update() 131 | rune_coords = self.screen_processor.find_rune_marker() 132 | if rune_coords: 133 | rune_platform_hash = None 134 | for key, platform in self.terrain_analyzer.platforms.items(): 135 | if rune_coords[1] >= platform.start_y - self.rune_platform_offset and \ 136 | rune_coords[1] <= platform.start_y + self.rune_platform_offset and \ 137 | rune_coords[0] >= platform.start_x and \ 138 | rune_coords[0] <= platform.end_x: 139 | rune_platform_hash = key 140 | for key, platform in self.terrain_analyzer.oneway_platforms.items(): 141 | if rune_coords[1] >= platform.start_y - self.rune_platform_offset and \ 142 | rune_coords[1] <= platform.start_y + self.rune_platform_offset and \ 143 | rune_coords[0] >= platform.start_x and \ 144 | rune_coords[0] <= platform.end_x: 145 | rune_platform_hash = key 146 | 147 | if rune_platform_hash: 148 | return rune_platform_hash, rune_coords 149 | else: 150 | return 0, 0 151 | else: 152 | return 0, 0 153 | 154 | def navigate_to_rune_platform(self): 155 | """ 156 | Automatically goes to rune_coords by calling find_rune_platform. Update platform information before calling. 157 | :return: 0 158 | """ 159 | rune_platform_hash, rune_coords = self.find_rune_platform() 160 | if not rune_platform_hash: 161 | return 0 162 | if self.current_platform_hash != rune_platform_hash: 163 | rune_solutions = self.terrain_analyzer.pathfind(self.current_platform_hash, rune_platform_hash) 164 | if rune_solutions: 165 | self.logger.debug("paths to rune: %s" % (" ".join(x.method for x in rune_solutions))) 166 | for solution in rune_solutions: 167 | if self.player_manager.x < solution.lower_bound[0]: 168 | # We are left of solution bounds. 169 | self.player_manager.horizontal_move_goal(solution.lower_bound[0]) 170 | else: 171 | # We are right of solution bounds 172 | self.player_manager.horizontal_move_goal(solution.upper_bound[0]) 173 | time.sleep(1) 174 | rune_movement_type = solution.method 175 | if rune_movement_type == ta.METHOD_DROP: 176 | self.player_manager.drop() 177 | time.sleep(1) 178 | elif rune_movement_type == ta.METHOD_JUMPL: 179 | self.player_manager.jumpl_double() 180 | time.sleep(0.5) 181 | elif rune_movement_type == ta.METHOD_JUMPR: 182 | self.player_manager.jumpr_double() 183 | time.sleep(0.5) 184 | elif rune_movement_type == ta.METHOD_DBLJMP_MAX: 185 | self.player_manager.dbljump_max() 186 | time.sleep(1) 187 | elif rune_movement_type == ta.METHOD_DBLJMP_HALF: 188 | self.player_manager.dbljump_half() 189 | time.sleep(1) 190 | time.sleep(0.5) 191 | else: 192 | self.logger.debug("could not generate path to rune platform %s from starting platform %s"%(rune_platform_hash, self.current_platform_hash)) 193 | return 0 194 | 195 | def log_skill_usage_statistics(self): 196 | """ 197 | checks self.player_manager.skill_cast_time and count and logs them if time is greater than threshold 198 | :return: None 199 | """ 200 | if not self.player_manager.skill_counter_time: 201 | self.player_manager.skill_counter_time = time.time() 202 | if time.time() - self.player_manager.skill_counter_time > 60: 203 | 204 | self.logger.debug("skills casted in duration %d: %d skill/s: %f"%(int(time.time() - self.player_manager.skill_counter_time), self.player_manager.skill_cast_counter, self.player_manager.skill_cast_counter/int(time.time() - self.player_manager.skill_counter_time))) 205 | self.player_manager.skill_cast_counter = 0 206 | self.player_manager.skill_counter_time = time.time() 207 | 208 | def loop(self): 209 | """ 210 | Main event loop for Macro 211 | Important note: Since this function uses PathAnalyzer's pathing algorithm, when this function moves to a new 212 | platform, it will invoke PathAnalyzer.move_platform. HOWEVER, in an attempt to make the system error-proof, 213 | platform movement and solution flagging is done on the loop call succeeding the loop call where the actual 214 | move ment is made. self.goal_platform is used for such purpose. 215 | :return: loop exit code 216 | exit code information: 217 | 0: all good 218 | -1: problem in image processing 219 | -2: problem in navigation/pathing 220 | """ 221 | # Check if MapleStory window is alive 222 | random.seed((time.time() * 10**4) % 10 **3) 223 | if random.randint(1, self.restrict_moonlight_slash_probability) == 2: 224 | restrict_moonlight_slash = True 225 | else: 226 | restrict_moonlight_slash = False 227 | 228 | self.log_skill_usage_statistics() 229 | 230 | if not self.screen_capturer.ms_get_screen_hwnd(): 231 | self.logger.debug("Failed to get MS screen rect") 232 | self.abort() 233 | return -1 234 | 235 | # Update Screen 236 | self.screen_processor.update_image(set_focus=False) 237 | 238 | # Update Constants 239 | player_minimap_pos = self.screen_processor.find_player_minimap_marker() 240 | if not player_minimap_pos: 241 | return -1 242 | self.player_manager.update(player_minimap_pos[0], player_minimap_pos[1]) 243 | 244 | # Placeholder for Lie Detector Detector (sounds weird) 245 | # End Placeholder 246 | 247 | # Check if player is on platform 248 | self.current_platform_hash = None 249 | get_current_platform = self.find_current_platform() 250 | if not get_current_platform: 251 | # Move to nearest platform and redo loop 252 | # Failed to find platform. 253 | self.platform_fail_loops += 1 254 | if self.platform_fail_loops >= self.platform_fail_loop_threshold: 255 | self.logger.debug("stuck. attempting unstick()...") 256 | self.unstick_attempts += 1 257 | self.unstick() 258 | if self.unstick_attempts >= self.unstick_attempts_threshold: 259 | self.logger.debug("unstick() threshold reached. sending error code..") 260 | return -2 261 | else: 262 | return 0 263 | else: 264 | self.platform_fail_loops = 0 265 | self.unstick_attempts = 0 266 | self.current_platform_hash = get_current_platform 267 | 268 | # Update navigation dictionary with last_platform and current_platform 269 | if self.goal_platform_hash and self.current_platform_hash == self.goal_platform_hash: 270 | self.terrain_analyzer.move_platform(self.last_platform_hash, self.current_platform_hash) 271 | 272 | # Reinitialize last_platform to current_platform 273 | self.last_platform_hash = self.current_platform_hash 274 | 275 | if self.loop_count % self.reset_navmap_loop_count == 0 and self.loop_count != 0: 276 | # Reset navigation map to randomize pathing 277 | self.terrain_analyzer.generate_solution_dict() 278 | numbers = [] 279 | for x in range(0, len(self.terrain_analyzer.platforms.keys())): 280 | numbers.append(x) 281 | random.shuffle(numbers) 282 | idx = 0 283 | if self.navmap_reset_type == 1: 284 | for key, platform in self.terrain_analyzer.platforms.items(): 285 | platform.last_visit = numbers[idx] 286 | idx += 1 287 | 288 | self.navmap_reset_type *= -1 289 | self.logger.debug("navigation map reset and randomized at loop #%d"%(self.loop_count)) 290 | 291 | # Rune Detector 292 | self.player_manager.update() 293 | rune_platform_hash, rune_coords = self.find_rune_platform() 294 | if rune_platform_hash: 295 | self.logger.debug("need to solve rune at platform {0}".format(rune_platform_hash)) 296 | rune_solve_time_offset = (time.time() - self.player_manager.last_rune_solve_time) 297 | if rune_solve_time_offset >= self.player_manager.rune_cooldown or rune_solve_time_offset <= 30: 298 | self.navigate_to_rune_platform() 299 | time.sleep(1) 300 | self.rune_solver.press_space() 301 | time.sleep(1.5) 302 | solve_result = self.rune_solver.solve_auto() 303 | self.logger.debug("rune_solver.solve_auto results: %d" % (solve_result)) 304 | if solve_result == -1: 305 | self.logger.debug("rune_solver.solve_auto failed to solve") 306 | for x in range(4): 307 | self.keyhandler.single_press(dc.DIK_LEFT) 308 | 309 | self.player_manager.last_rune_solve_time = time.time() 310 | self.current_platform_hash = rune_platform_hash 311 | time.sleep(0.5) 312 | # End Rune Detector 313 | 314 | # We are on a platform. find an optimal way to clear platform. 315 | # If we know our next platform destination, we can make our path even more efficient 316 | next_platform_solution = self.terrain_analyzer.select_move(self.current_platform_hash) 317 | #print("next platform solution:", next_platform_solution.method, next_platform_solution.to_hash) 318 | self.logger.debug("next solution destination: %s method: %s"%(next_platform_solution.to_hash, next_platform_solution.method)) 319 | self.goal_platform_hash = next_platform_solution.to_hash 320 | 321 | # lookahead pathing 322 | lookahead_platform_solution = self.terrain_analyzer.select_move(self.goal_platform_hash) 323 | lookahead_solution_lb = lookahead_platform_solution.lower_bound 324 | lookahead_solution_ub = lookahead_platform_solution.upper_bound 325 | 326 | #lookahead_lb = lookahead_lb[0] if lookahead_lb[0] >= next_platform_solution.lower_bound[0] else next_platform_solution.lower_bound[0] 327 | #lookahead_ub = lookahead_ub[0] if lookahead_ub[0] <= next_platform_solution.upper_bound[0] else next_platform_solution.upper_bound[0] 328 | 329 | if lookahead_solution_lb[0] < next_platform_solution.lower_bound[0] and lookahead_solution_ub[0] > next_platform_solution.lower_bound[0] or \ 330 | lookahead_solution_lb[0] > next_platform_solution.lower_bound[0] and lookahead_solution_lb[0] < next_platform_solution.upper_bound[0]: 331 | lookahead_lb = lookahead_solution_lb[0] if lookahead_solution_lb[0] >= next_platform_solution.lower_bound[0] and lookahead_solution_lb[0] <= next_platform_solution.upper_bound[0] else next_platform_solution.lower_bound[0] 332 | lookahead_ub = lookahead_solution_ub[0] if lookahead_solution_ub[0] <= next_platform_solution.upper_bound[0] and lookahead_solution_ub[0] >= next_platform_solution.lower_bound[0] else next_platform_solution.upper_bound[0] 333 | 334 | else: 335 | lookahead_lb = next_platform_solution.lower_bound[0] 336 | lookahead_ub = next_platform_solution.upper_bound[0] 337 | 338 | lookahead_lb = lookahead_lb + random.randint(0, 2) 339 | lookahead_ub = lookahead_ub - random.randint(0, 2) 340 | 341 | # end lookahead pathing 342 | # Start skill usage section 343 | if abs(self.player_manager.x - next_platform_solution.lower_bound[0]) < abs( 344 | self.player_manager.x - next_platform_solution.upper_bound[0]): 345 | # closer to lower bound 346 | skill_used = self.player_manager.randomize_skill() 347 | else: 348 | skill_used = self.player_manager.randomize_skill() 349 | # End skill usage 350 | 351 | # Find coordinates to move to next platform 352 | if self.player_manager.x >= next_platform_solution.lower_bound[0] and self.player_manager.x <= next_platform_solution.upper_bound[0]: 353 | # We are within the solution bounds. attack within solution range and move 354 | if abs(self.player_manager.x - next_platform_solution.lower_bound[0]) < abs(self.player_manager.x - next_platform_solution.upper_bound[0]): 355 | # We are closer to lower boound, so move to upper bound to maximize movement 356 | in_solution_movement_goal = lookahead_ub 357 | else: 358 | in_solution_movement_goal = lookahead_lb 359 | if restrict_moonlight_slash: 360 | self.player_manager.optimized_horizontal_move(in_solution_movement_goal) 361 | else: 362 | if random.randint(1, self.walk_probability) == 1: 363 | self.player_manager.moonlight_slash_sweep_move(in_solution_movement_goal, glide=False, no_attack_distance=skill_used * self.player_manager.moonlight_slash_x_radius+1.2) 364 | else: 365 | self.player_manager.moonlight_slash_sweep_move(in_solution_movement_goal, no_attack_distance=skill_used * self.player_manager.moonlight_slash_x_radius*1.2) 366 | 367 | else: 368 | # We need to move within the solution bounds. First, find closest solution bound which can cover majority of current platform. 369 | if self.player_manager.x < next_platform_solution.lower_bound[0]: 370 | # We are left of solution bounds. 371 | #print("run sweep move") 372 | if restrict_moonlight_slash: 373 | self.player_manager.optimized_horizontal_move(lookahead_ub) 374 | else: 375 | if random.randint(1, self.walk_probability) == 1: 376 | self.player_manager.moonlight_slash_sweep_move(lookahead_ub, glide=False, no_attack_distance=skill_used * self.player_manager.moonlight_slash_x_radius*1.2) 377 | else: 378 | self.player_manager.moonlight_slash_sweep_move(lookahead_ub, no_attack_distance=skill_used * self.player_manager.moonlight_slash_x_radius*1.2) 379 | 380 | else: 381 | # We are right of solution bounds 382 | #print("run sweep move") 383 | if restrict_moonlight_slash: 384 | self.player_manager.optimized_horizontal_move(lookahead_lb) 385 | else: 386 | if random.randint(1, self.walk_probability) == 1: 387 | self.player_manager.moonlight_slash_sweep_move(lookahead_lb, glide=False, no_attack_distance=skill_used * self.player_manager.moonlight_slash_x_radius*1.2) 388 | else: 389 | self.player_manager.moonlight_slash_sweep_move(lookahead_lb, no_attack_distance=skill_used * self.player_manager.moonlight_slash_x_radius*1.2) 390 | 391 | time.sleep(0.4) 392 | 393 | # All movement and attacks finished. Now perform movement 394 | movement_type = next_platform_solution.method 395 | if movement_type == ta.METHOD_DROP: 396 | self.player_manager.drop() 397 | time.sleep(1) 398 | elif movement_type == ta.METHOD_JUMPL: 399 | self.player_manager.jumpl_double() 400 | time.sleep(0.5) 401 | elif movement_type == ta.METHOD_JUMPR: 402 | self.player_manager.jumpr_double() 403 | time.sleep(0.5) 404 | elif movement_type == ta.METHOD_DBLJMP_MAX: 405 | self.player_manager.dbljump_max() 406 | time.sleep(1) 407 | elif movement_type == ta.METHOD_DBLJMP_HALF: 408 | self.player_manager.dbljump_half() 409 | time.sleep(0.7) 410 | 411 | #End inter-platform movement 412 | 413 | # Other buffs 414 | self.player_manager.holy_symbol() 415 | self.player_manager.hyper_body() 416 | self.player_manager.release_overload() 417 | time.sleep(0.05) 418 | 419 | # Finished 420 | self.loop_count += 1 421 | return 0 422 | 423 | 424 | def unstick(self): 425 | """ 426 | Run when script can't find which platform we are at. 427 | Solution: try random stuff to attempt it to reposition it self 428 | :return: None 429 | """ 430 | #Method one: get off ladder 431 | self.player_manager.jumpr() 432 | time.sleep(2) 433 | if self.find_current_platform(): 434 | return 0 435 | self.player_manager.dbljump_max() 436 | time.sleep(2) 437 | if self.find_current_platform(): 438 | return 0 439 | 440 | def abort(self): 441 | self.keyhandler.reset() 442 | self.logger.debug("aborted") 443 | if self.log_queue: 444 | self.log_queue.put(["stopped", None]) 445 | 446 | -------------------------------------------------------------------------------- /src/macro_script_astar.py: -------------------------------------------------------------------------------- 1 | import terrain_analyzer as ta 2 | from terrain_analyzer import METHOD_DROP, METHOD_MOVEL, METHOD_MOVER, METHOD_DBLJMP, METHOD_DBLJMP_HALF, METHOD_DBLJMP_MAX 3 | import directinput_constants as dc 4 | import macro_script 5 | import logging, math, time, random 6 | 7 | class CustomLogger: 8 | def __init__(self, logger_obj, logger_queue): 9 | self.logger_obj = logger_obj 10 | self.logger_queue = logger_queue 11 | 12 | def debug(self, *args): 13 | self.logger_obj.debug(" ".join([str(x) for x in args])) 14 | if self.logger_queue: 15 | self.logger_queue.put(("log", " ".join([str(x) for x in args]))) 16 | 17 | def exception(self, *args): 18 | self.logger_obj.exception(" ".join([str(x) for x in args])) 19 | if self.logger_queue: 20 | self.logger_queue.put(("log", " ".join([str(x) for x in args]))) 21 | 22 | class MacroControllerAStar(macro_script.MacroController): 23 | """ 24 | This is a new port of MacroController from macro_script with improved pathing. MacroController Used PlatforScan, 25 | which is an tree search algorithm I implemented, and works at indivisual platform level. However, V2 uses A* path 26 | finding and works at pixel level, which allows more randomized and fluent moving. 27 | """ 28 | def loop(self): 29 | """ 30 | Main event loop for Macro 31 | Will now use current coordinates and A* to find a new path. 32 | :return: loop exit code(same as macro_script.py) 33 | """ 34 | random.seed((time.time() * 10**4) % 10 **3) 35 | 36 | if not self.player_manager.skill_counter_time: 37 | self.player_manager.skill_counter_time = time.time() 38 | if time.time() - self.player_manager.skill_counter_time > 60: 39 | print("skills casted in duration %d: %d skill/s: %f"%(int(time.time() - self.player_manager.skill_counter_time), self.player_manager.skill_cast_counter, self.player_manager.skill_cast_counter/int(time.time() - self.player_manager.skill_counter_time))) 40 | self.logger.debug("skills casted in duration %d: %d skill/s: %f skill/s"%(int(time.time() - self.player_manager.skill_counter_time), self.player_manager.skill_cast_counter, self.player_manager.skill_cast_counter/int(time.time() - self.player_manager.skill_counter_time))) 41 | self.player_manager.skill_cast_counter = 0 42 | self.player_manager.skill_counter_time = time.time() 43 | if not self.screen_capturer.ms_get_screen_hwnd(): 44 | self.logger.debug("Failed to get MS screen rect") 45 | self.abort() 46 | return -1 47 | 48 | # Update Screen 49 | self.screen_processor.update_image(set_focus=False) 50 | # Update Constants 51 | player_minimap_pos = self.screen_processor.find_player_minimap_marker() 52 | if not player_minimap_pos: 53 | return -1 54 | self.player_manager.update(player_minimap_pos[0], player_minimap_pos[1]) 55 | 56 | # Placeholder for Lie Detector Detector (sounds weird) 57 | 58 | # End Placeholder 59 | 60 | # Check if player is on platform 61 | self.current_platform_hash = None 62 | get_current_platform = self.find_current_platform() 63 | if not get_current_platform: 64 | # Move to nearest platform and redo loop 65 | # Failed to find platform. 66 | self.platform_fail_loops += 1 67 | if self.platform_fail_loops >= self.platform_fail_loop_threshold: 68 | self.logger.debug("stuck. attempting unstick()...") 69 | self.unstick_attempts += 1 70 | self.unstick() 71 | if self.unstick_attempts >= self.unstick_attempts_threshold: 72 | self.logger.debug("unstick() threshold reached. sending error code..") 73 | return -2 74 | else: 75 | return 0 76 | else: 77 | self.platform_fail_loops = 0 78 | self.unstick_attempts = 0 79 | self.current_platform_hash = get_current_platform 80 | 81 | # Rune Detector 82 | self.player_manager.update() 83 | rune_platform_hash, rune_coords = self.find_rune_platform() 84 | if rune_platform_hash: 85 | self.logger.debug("need to solve rune at platform {0}".format(rune_platform_hash)) 86 | rune_solve_time_offset = (time.time() - self.player_manager.last_rune_solve_time) 87 | if rune_solve_time_offset >= self.player_manager.rune_cooldown or rune_solve_time_offset <= 30: 88 | self.navigate_to_rune_platform() 89 | time.sleep(1) 90 | self.rune_solver.press_space() 91 | time.sleep(1.5) 92 | solve_result = self.rune_solver.solve_auto() 93 | self.logger.debug("rune_solver.solve_auto results: %d" % (solve_result)) 94 | if solve_result == -1: 95 | self.logger.debug("rune_solver.solve_auto failed to solve") 96 | for x in range(4): 97 | self.keyhandler.single_press(dc.DIK_LEFT) 98 | 99 | self.player_manager.last_rune_solve_time = time.time() 100 | self.current_platform_hash = rune_platform_hash 101 | time.sleep(0.5) 102 | # End Rune Detector 103 | 104 | # Start inter-platform movement 105 | dest_platform_hash = random.choice([key for key in self.terrain_analyzer.platforms.keys() if key != self.current_platform_hash]) 106 | dest_platform = self.terrain_analyzer.platforms[dest_platform_hash] 107 | self.player_manager.update() 108 | random_platform_coord = (random.randint(dest_platform.start_x, dest_platform.end_x), dest_platform.start_y) 109 | # Once we have selected the platform to move, we can generate a path using A* 110 | pathlist = self.terrain_analyzer.astar_pathfind((self.player_manager.x, self.player_manager.y), random_platform_coord) 111 | print(pathlist) 112 | for mid_coord, method in pathlist: 113 | self.player_manager.update() 114 | print(mid_coord, method) 115 | if method == METHOD_MOVER or method == METHOD_MOVEL: 116 | self.player_manager.optimized_horizontal_move(mid_coord[0]) 117 | elif method == METHOD_DBLJMP: 118 | interdelay = self.terrain_analyzer.calculate_vertical_doublejump_delay(self.player_manager.y, mid_coord[1]) 119 | print(interdelay) 120 | self.player_manager.dbljump_timed(interdelay) 121 | elif method == METHOD_DROP: 122 | self.player_manager.drop() 123 | time.sleep(1) 124 | # End inter-platform movement 125 | 126 | self.player_manager.randomize_skill() 127 | 128 | # Other buffs 129 | self.player_manager.holy_symbol() 130 | self.player_manager.hyper_body() 131 | self.player_manager.release_overload() 132 | time.sleep(0.05) 133 | 134 | # Finished 135 | self.loop_count += 1 136 | return 0 137 | 138 | 139 | def navigate_to_rune_platform(self): 140 | """ 141 | Uses A* pathfinding to navigate to rune coord 142 | :return: None 143 | """ 144 | pass -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import logging 3 | default_logger = logging.getLogger("main") 4 | default_logger.setLevel(logging.DEBUG) 5 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 6 | 7 | fh = logging.FileHandler("logging.log") 8 | fh.setLevel(logging.DEBUG) 9 | fh.setFormatter(formatter) 10 | default_logger.addHandler(fh) 11 | try: 12 | import multiprocessing, tkinter as tk, time, webbrowser, os, signal, pickle, sys, argparse 13 | 14 | from tkinter.constants import * 15 | from tkinter.messagebox import showinfo, showerror, showwarning 16 | from tkinter.filedialog import askopenfilename, asksaveasfilename 17 | from tkinter.scrolledtext import ScrolledText 18 | 19 | from platform_data_creator import PlatformDataCaptureWindow 20 | from screen_processor import MapleScreenCapturer 21 | from keystate_manager import DEFAULT_KEY_MAP 22 | from directinput_constants import keysym_map 23 | from macro_script import MacroController 24 | #from macro_script_astar import MacroControllerAStar as MacroController 25 | from keybind_setup_window import SetKeyMap 26 | except: 27 | default_logger.exception("error during import") 28 | 29 | APP_TITLE = "MS-Visionify" 30 | VERSION = 1.0 31 | 32 | def destroy_child_widgets(parent): 33 | for child in parent.winfo_children(): 34 | child.destroy() 35 | 36 | 37 | def macro_loop(input_queue, output_queue): 38 | logger = logging.getLogger("macro_loop") 39 | logger.setLevel(logging.DEBUG) 40 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 41 | 42 | fh = logging.FileHandler("logging.log") 43 | fh.setLevel(logging.DEBUG) 44 | fh.setFormatter(formatter) 45 | logger.addHandler(fh) 46 | try: 47 | while True: 48 | time.sleep(0.5) 49 | if not input_queue.empty(): 50 | command = input_queue.get() 51 | logger.debug("recieved command {}".format(command)) 52 | if command[0] == "start": 53 | logger.debug("starting MacroController...") 54 | keymap = command[1] 55 | platform_file_dir = command[2] 56 | macro = MacroController(keymap, log_queue=output_queue) 57 | macro.load_and_process_platform_map(platform_file_dir) 58 | 59 | while True: 60 | macro.loop() 61 | if not input_queue.empty(): 62 | command = input_queue.get() 63 | if command[0] == "stop": 64 | macro.abort() 65 | break 66 | except: 67 | logger.exception("Exeption during loop execution:") 68 | output_queue.put(["log", "!! 봇 프로세스에서 오류가 발생했습니다. 로그파일을 확인해주세요. !!", ]) 69 | output_queue.put(["exception", "exception"]) 70 | 71 | 72 | 73 | class MainScreen(tk.Frame): 74 | def __init__(self, master, user_id="null", expiration_time=0): 75 | self.master = master 76 | destroy_child_widgets(self.master) 77 | tk.Frame.__init__(self, master) 78 | self.pack(expand=YES, fill=BOTH) 79 | self.user_id = user_id 80 | self.expiration_time = expiration_time 81 | 82 | self.menubar = tk.Menu() 83 | self.menubar.add_command(label="지형파일 생성하기", command=lambda: PlatformDataCaptureWindow()) 84 | self.menubar.add_command(label="키 설정하기", command=lambda: SetKeyMap(self.master)) 85 | 86 | self.master.config(menu=self.menubar) 87 | 88 | self.platform_file_dir = tk.StringVar() 89 | self.platform_file_name = tk.StringVar() 90 | 91 | self.keymap = None 92 | 93 | self.macro_pid = 0 94 | self.macro_process = None 95 | self.macro_process_out_queue = multiprocessing.Queue() 96 | self.macro_process_in_queue = multiprocessing.Queue() 97 | 98 | self.macro_pid_infotext = tk.StringVar() 99 | self.macro_pid_infotext.set("실행되지 않음") 100 | 101 | self.log_text_area = ScrolledText(self, height = 10, width = 20) 102 | self.log_text_area.pack(side=BOTTOM, expand=YES, fill=BOTH) 103 | self.log_text_area.bind("", lambda e: "break") 104 | self.macro_info_frame = tk.Frame(self, borderwidth=1, relief=GROOVE) 105 | self.macro_info_frame.pack(side=BOTTOM, anchor=S, expand=YES, fill=BOTH) 106 | 107 | tk.Label(self.macro_info_frame, text="봇 프로세스 상태:").grid(row=0, column=0) 108 | 109 | self.macro_process_label = tk.Label(self.macro_info_frame, textvariable=self.macro_pid_infotext, fg="red") 110 | self.macro_process_label.grid(row=0, column=1, sticky=N+S+E+W) 111 | self.macro_process_toggle_button = tk.Button(self.macro_info_frame, text="실행하기", command=self.toggle_macro_process) 112 | self.macro_process_toggle_button.grid(row=0, column=2, sticky=N+S+E+W) 113 | 114 | tk.Label(self.macro_info_frame, text="지형 파일:").grid(row=1, column=0, sticky=N+S+E+W) 115 | tk.Label(self.macro_info_frame, textvariable=self.platform_file_name).grid(row=1, column=1, sticky=N+S+E+W) 116 | self.platform_file_button = tk.Button(self.macro_info_frame, text="파일 선택하기", command=self.onPlatformFileSelect) 117 | self.platform_file_button.grid(row=1, column=2, sticky=N+S+E+W) 118 | 119 | self.macro_start_button = tk.Button(self.macro_info_frame, text="봇 시작", fg="green", command=self.start_macro) 120 | self.macro_start_button.grid(row=2, column=0, sticky=N+S+E+W) 121 | self.macro_end_button = tk.Button(self.macro_info_frame, text="봇 종료", fg="red", command=self.stop_macro, state=DISABLED) 122 | self.macro_end_button.grid(row=2, column=1, sticky=N + S + E + W) 123 | 124 | for x in range(5): 125 | self.macro_info_frame.grid_columnconfigure(x, weight=1) 126 | self.log("MS-Visionify NoAuth", VERSION) 127 | self.log("GNU GPL compliant version of MS-Visionify") 128 | self.log("해당 프로그램 사용시 계정제재 또는 제한이 가능하고, 책임은 전부 사용자에게 있음을 인지하시기 바랍니다.") 129 | self.log("MS-Visionify는 상업적 목표로 제작된 프로그램이 아니기에 무료이며, https://github.com/Dashadower/MS-Visionify 에 모든 소스가 공개되어 있습니다.") 130 | self.log("Please be known that using this bot may get your account banned. By using this software, you acknowledge that the developers are not liable for any damages caused to you or your account.") 131 | self.log("MS-Visionify was not created for commercial purposes and thus free of charge. The entire source code can be found at https://github.com/Dashadower/MS-Visionify") 132 | 133 | 134 | self.master.protocol("WM_DELETE_WINDOW", self.onClose) 135 | self.after(500, self.toggle_macro_process) 136 | self.after(1000, self.check_input_queue) 137 | 138 | def onClose(self): 139 | if self.macro_process: 140 | try: 141 | self.macro_process_out_queue.put("stop") 142 | os.kill(self.macro_pid, signal.SIGTERM) 143 | except: 144 | pass 145 | 146 | sys.exit() 147 | 148 | def check_input_queue(self): 149 | while not self.macro_process_in_queue.empty(): 150 | output = self.macro_process_in_queue.get() 151 | if output[0] == "log": 152 | self.log("Process - "+str(output[1])) 153 | elif output[0] == "stopped": 154 | self.log("봇 프로세스가 종료되었습니다.") 155 | elif output[0] == "exception": 156 | self.macro_end_button.configure(state=DISABLED) 157 | self.macro_start_button.configure(state=NORMAL) 158 | self.platform_file_button.configure(state=NORMAL) 159 | self.macro_process = None 160 | self.macro_pid = 0 161 | self.macro_process_toggle_button.configure(state=NORMAL) 162 | self.macro_pid_infotext.set("실행되지 않음") 163 | self.macro_process_label.configure(fg="red") 164 | self.macro_process_toggle_button.configure(text="실행하기") 165 | self.log("오류로 인해 봇 프로세스가 종료되었습니다. 로그파일을 확인해주세요.") 166 | 167 | self.after(1000, self.check_input_queue) 168 | 169 | 170 | def start_macro(self): 171 | if not self.macro_process: 172 | self.toggle_macro_process() 173 | keymap = self.get_keymap() 174 | if not keymap: 175 | showerror(APP_TITLE, "키설정을 읽어오지 못했습니다. 키를 다시 설정해주세요.") 176 | else: 177 | if not self.platform_file_dir.get(): 178 | showwarning(APP_TITLE, "지형 파일을 선택해 주세요.") 179 | else: 180 | if not MapleScreenCapturer().ms_get_screen_hwnd(): 181 | showwarning(APP_TITLE, "메이플 창을 찾지 못했습니다. 메이플을 실행해 주세요") 182 | else: 183 | 184 | cap = MapleScreenCapturer() 185 | hwnd = cap.ms_get_screen_hwnd() 186 | rect = cap.ms_get_screen_rect(hwnd) 187 | self.log("MS hwnd", hwnd) 188 | self.log("MS rect", rect) 189 | self.log("Out Queue put:", self.platform_file_dir.get()) 190 | if rect[0] < 0 or rect[1] < 0: 191 | showwarning(APP_TITLE, "메이플 창 위치를 가져오는데 실패했습니다.\n메이플 촹의 좌측 상단 코너가 화면 내에 있도록 메이플 창을 움직여주세요.") 192 | 193 | else: 194 | cap.capture() 195 | self.macro_process_out_queue.put(("start", keymap, self.platform_file_dir.get())) 196 | self.macro_start_button.configure(state=DISABLED) 197 | self.macro_end_button.configure(state=NORMAL) 198 | self.platform_file_button.configure(state=DISABLED) 199 | 200 | def stop_macro(self): 201 | self.macro_process_out_queue.put(("stop",)) 202 | self.log("봇 중지 요청 완료. 멈출때까지 잠시만 기다려주세요.") 203 | self.macro_end_button.configure(state=DISABLED) 204 | self.macro_start_button.configure(state=NORMAL) 205 | self.platform_file_button.configure(state=NORMAL) 206 | 207 | def get_keymap(self): 208 | if os.path.exists("keymap.keymap"): 209 | with open("keymap.keymap", "rb") as f: 210 | try: 211 | data = pickle.load(f) 212 | keymap = data["keymap"] 213 | except: 214 | return 0 215 | else: 216 | return keymap 217 | 218 | def log(self, *args): 219 | res_txt = [] 220 | for arg in args: 221 | res_txt.append(str(arg)) 222 | self.log_text_area.insert(END, " ".join(res_txt)+"\n") 223 | self.log_text_area.see(END) 224 | 225 | def toggle_macro_process(self): 226 | if not self.macro_process: 227 | self.macro_process_toggle_button.configure(state=DISABLED) 228 | self.macro_pid_infotext.set("실행중..") 229 | self.macro_process_label.configure(fg="orange") 230 | self.log("봇 프로세스 시작중...") 231 | self.macro_process_out_queue = multiprocessing.Queue() 232 | self.macro_process_in_queue = multiprocessing.Queue() 233 | p = multiprocessing.Process(target=macro_loop, args=(self.macro_process_out_queue, self.macro_process_in_queue)) 234 | p.daemon = True 235 | 236 | self.macro_process = p 237 | p.start() 238 | self.macro_pid = p.pid 239 | self.log("프로세스 생성완료(pid: %d)"%(self.macro_pid)) 240 | self.macro_pid_infotext.set("실행됨(%d)"%(self.macro_pid)) 241 | self.macro_process_label.configure(fg="green") 242 | self.macro_process_toggle_button.configure(state=NORMAL) 243 | self.macro_process_toggle_button.configure(text="중지하기") 244 | 245 | else: 246 | self.stop_macro() 247 | self.macro_process_toggle_button.configure(state=DISABLED) 248 | self.macro_pid_infotext.set("중지중..") 249 | self.macro_process_label.configure(fg="orange") 250 | 251 | self.log("SIGTERM %d"%(self.macro_pid)) 252 | os.kill(self.macro_pid, signal.SIGTERM) 253 | self.log("프로세스 종료완료") 254 | self.macro_process = None 255 | self.macro_pid = 0 256 | self.macro_process_toggle_button.configure(state=NORMAL) 257 | self.macro_pid_infotext.set("실행되지 않음") 258 | self.macro_process_label.configure(fg="red") 259 | self.macro_process_toggle_button.configure(text="실행하기") 260 | 261 | def onPlatformFileSelect(self): 262 | platform_file_path = askopenfilename(initialdir=os.getcwd(), title="지형파일 선택", filetypes=(("지형 파일(*.platform)", "*.platform"),)) 263 | if platform_file_path: 264 | if os.path.exists(platform_file_path): 265 | with open(platform_file_path, "rb") as f: 266 | try: 267 | data = pickle.load(f) 268 | platforms = data["platforms"] 269 | oneway_platforms = data["oneway"] 270 | minimap_coords = data["minimap"] 271 | self.log("지형파일 로딩 완료(플랫폼 갯수: ", len(platforms.keys()), "일방 플랫폼 갯수:", 272 | len(oneway_platforms.keys())) 273 | except: 274 | showerror(APP_TITLE, "파일 검증 오류\n 파일: %s\n파일 검증에 실패했습니다. 깨진 파일인지 확인해주세요."%(platform_file_path)) 275 | else: 276 | self.platform_file_dir.set(platform_file_path) 277 | self.platform_file_name.set(platform_file_path.split("/")[-1]) 278 | 279 | 280 | if __name__ == "__main__": 281 | multiprocessing.freeze_support() 282 | parser = argparse.ArgumentParser(description="MS-Visionify, a bot to play KMS MapleStory") 283 | parser.add_argument("-title", dest="title", help="change main window title to designated value") 284 | args = vars(parser.parse_args()) 285 | root = tk.Tk() 286 | 287 | root.title(args["title"] if args["title"] else APP_TITLE) 288 | root.resizable(0,0) 289 | root.wm_minsize() 290 | #CreatePlatformFileFrame(root) 291 | MainScreen(root, user_id="noauth") 292 | root.mainloop() 293 | -------------------------------------------------------------------------------- /src/platform_data_creator.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | from screen_processor import StaticImageProcessor, MapleScreenCapturer 3 | from terrain_analyzer import PathAnalyzer 4 | import cv2, imutils, logging, threading, os 5 | import tkinter as tk 6 | from tkinter.constants import * 7 | from PIL import Image, ImageTk 8 | from tkinter.messagebox import showinfo, showerror, showwarning 9 | from tkinter.filedialog import asksaveasfilename 10 | from tkinter.messagebox import askyesno 11 | 12 | class PlatformDataCaptureWindow(tk.Toplevel): 13 | def __init__(self): 14 | tk.Toplevel.__init__(self) 15 | self.wm_minsize(100, 30) 16 | self.resizable(0,0) 17 | self.focus_get() 18 | self.grab_set() 19 | self.title("지형파일 생성기") 20 | 21 | self.last_coord_x = None 22 | self.last_coord_y = None 23 | self.current_coords = [] 24 | 25 | self.screen_capturer = MapleScreenCapturer() 26 | if not self.screen_capturer.ms_get_screen_hwnd(): 27 | showerror("지형파일 생성기", "메이플 창을 찾지 못했습니다. 메이플을 실행해 주세요.") 28 | self.destroy() 29 | else: 30 | self.image_processor = StaticImageProcessor(self.screen_capturer) 31 | self.terrain_analyzer = PathAnalyzer() 32 | self.image_label = tk.Label(self) 33 | self.image_label.pack(expand=YES, fill=BOTH) 34 | 35 | self.master_tool_frame = tk.Frame(self, borderwidth=2, relief=GROOVE) 36 | self.master_tool_frame.pack(expand=YES, fill=BOTH) 37 | 38 | self.tool_frame_1 = tk.Frame(self.master_tool_frame) 39 | self.tool_frame_1.pack(fill=X) 40 | tk.Button(self.tool_frame_1, text="창 및 미니맵 다시 찾기", command=self.find_minimap_coords).pack(side=LEFT) 41 | self.coord_label = tk.Label(self.tool_frame_1, text="x,y") 42 | self.coord_label.pack(side=RIGHT, fill=Y, expand=YES) 43 | 44 | self.tool_frame_2 = tk.Frame(self.master_tool_frame) 45 | self.tool_frame_2.pack(fill=X) 46 | self.start_platform_record_button = tk.Button(self.tool_frame_2, text="스폰지형 기록시작", command=self.start_record_platform) 47 | self.start_platform_record_button.pack(side=LEFT, expand=YES, fill=X) 48 | self.stop_platform_record_button = tk.Button(self.tool_frame_2, text="스폰지형 기록중지", command=self.stop_record_platform, state=DISABLED) 49 | self.stop_platform_record_button.pack(side=RIGHT, expand=YES, fill=X) 50 | 51 | self.tool_frame_3 = tk.Frame(self.master_tool_frame) 52 | self.tool_frame_3.pack(fill=X) 53 | self.start_oneway_record_button = tk.Button(self.tool_frame_3, text="비스폰지형 기록시작", command=self.start_record_oneway) 54 | self.start_oneway_record_button.pack(side=LEFT, expand=YES, fill=X) 55 | self.stop_oneway_record_button = tk.Button(self.tool_frame_3, text="비스폰지형 기록중지",command=self.stop_record_oneway, state=DISABLED) 56 | self.stop_oneway_record_button.pack(side=RIGHT, expand=YES, fill=X) 57 | 58 | self.tool_frame_4 = tk.Frame(self.master_tool_frame) 59 | self.tool_frame_4.pack(fill=X, side=BOTTOM) 60 | tk.Button(self.tool_frame_4, text="초기화", command=self.on_reset_platforms).pack(side=LEFT,expand=YES,fill=X) 61 | tk.Button(self.tool_frame_4, text="저장하기",command=self.on_save).pack(side=RIGHT, expand=YES, fill=X) 62 | 63 | self.platform_listbox = tk.Listbox(self, selectmode=MULTIPLE) 64 | self.platform_listbox.pack(expand=YES, fill=BOTH) 65 | self.platform_listbox_platform_index = {} 66 | self.platform_listbox_oneway_index = {} 67 | self.platform_listbox.bind("", self.on_platform_list_rclick) 68 | 69 | self.platform_listbox_menu = tk.Menu(self, tearoff=0) 70 | self.platform_listbox_menu.add_command(label="선택된 항목 삭제", command=self.on_listbox_delete) 71 | 72 | self.image_processor.update_image(set_focus=False) 73 | self.minimap_rect = self.image_processor.get_minimap_rect() 74 | if not self.minimap_rect: 75 | self.image_label.configure(text="미니맵 찾을수 없음", fg="red") 76 | 77 | self.stopEvent = threading.Event() 78 | self.thread = threading.Thread(target=self.update_image, args=()) 79 | self.thread.start() 80 | 81 | self.record_mode = 0 # 0 if not recording, 1 if normal platform, 2 if oneway 82 | 83 | self.protocol("WM_DELETE_WINDOW", self.onClose) 84 | 85 | def onClose(self): 86 | self.stopEvent.set() 87 | self.after(200, self.destroy) 88 | 89 | def on_platform_list_rclick(self, event): 90 | try: 91 | self.platform_listbox_menu.tk_popup(event.x_root, event.y_root, 0) 92 | finally: 93 | self.platform_listbox_menu.grab_release() 94 | 95 | def on_listbox_delete(self): 96 | selected = self.platform_listbox.curselection() 97 | if not selected: 98 | showwarning("지형파일 생성기", "한개 이상의 항목을 선택해 주세요") 99 | else: 100 | if askyesno("지형파일 생성기", "정말 %d개의 항목을 지우겠습니까?"%(len(selected))): 101 | if self.record_mode != 0: 102 | showwarning("지형파일 생성기", "진행중인 기록 먼저 종료해주세요") 103 | else: 104 | for idx in selected: 105 | for key, hash in self.platform_listbox_platform_index.items(): 106 | if idx == key: 107 | del self.terrain_analyzer.platforms[hash] 108 | 109 | for key, hash in self.platform_listbox_oneway_index.items(): 110 | if idx == key: 111 | del self.terrain_analyzer.oneway_platforms[hash] 112 | self.update_listbox() 113 | 114 | def update_listbox(self): 115 | self.platform_listbox_platform_index = {} 116 | self.platform_listbox_oneway_index = {} 117 | self.platform_listbox.delete(0, END) 118 | cindex = 0 119 | for key, platform in self.terrain_analyzer.platforms.items(): 120 | self.platform_listbox.insert(END, "(%d,%d), (%d,%d) 스폰지형"%(platform.start_x, platform.start_y, platform.end_x, platform.end_y)) 121 | self.platform_listbox_platform_index[cindex] = key 122 | self.platform_listbox.itemconfigure(cindex, fg="green") 123 | cindex += 1 124 | 125 | for key, platform in self.terrain_analyzer.oneway_platforms.items(): 126 | self.platform_listbox.insert(END, "(%d,%d), (%d,%d) 비스폰지형"%(platform.start_x, platform.start_y, platform.end_x, platform.end_y)) 127 | self.platform_listbox_oneway_index[cindex] = key 128 | self.platform_listbox.itemconfigure(cindex, fg="red") 129 | cindex += 1 130 | 131 | def start_record_platform(self): 132 | self.record_mode = 1 133 | self.start_platform_record_button.configure(state=DISABLED) 134 | self.stop_platform_record_button.configure(state=NORMAL) 135 | self.start_oneway_record_button.configure(state=DISABLED) 136 | self.stop_oneway_record_button.configure(state=DISABLED) 137 | 138 | def stop_record_platform(self): 139 | self.record_mode = 0 140 | self.start_platform_record_button.configure(state=NORMAL) 141 | self.stop_platform_record_button.configure(state=DISABLED) 142 | self.start_oneway_record_button.configure(state=NORMAL) 143 | self.stop_oneway_record_button.configure(state=DISABLED) 144 | self.coord_label.configure(fg="black") 145 | self.terrain_analyzer.flush_input_coords_to_platform(coord_list=self.current_coords) 146 | self.current_coords = [] 147 | self.update_listbox() 148 | 149 | def start_record_oneway(self): 150 | self.record_mode = 2 151 | self.start_platform_record_button.configure(state=DISABLED) 152 | self.stop_platform_record_button.configure(state=DISABLED) 153 | self.start_oneway_record_button.configure(state=DISABLED) 154 | self.stop_oneway_record_button.configure(state=NORMAL) 155 | 156 | def stop_record_oneway(self): 157 | self.record_mode = 0 158 | self.start_platform_record_button.configure(state=NORMAL) 159 | self.stop_platform_record_button.configure(state=DISABLED) 160 | self.start_oneway_record_button.configure(state=NORMAL) 161 | self.stop_oneway_record_button.configure(state=DISABLED) 162 | self.coord_label.configure(fg="black") 163 | self.terrain_analyzer.flush_input_coords_to_oneway(coord_list=self.current_coords) 164 | self.current_coords = [] 165 | self.update_listbox() 166 | 167 | def on_save(self): 168 | save_dir = asksaveasfilename(initialdir=os.getcwd(), title="저장경로 설정", filetypes=(("지형 파일(*.platform)","*.platform"),)) 169 | if save_dir: 170 | if ".platform" not in save_dir: 171 | save_dir += ".platform" 172 | self.terrain_analyzer.save(save_dir, self.minimap_rect) 173 | showinfo("지형파일 생성기", "파일경로 {0}\n 저장되었습니다.".format(save_dir)) 174 | self.onClose() 175 | 176 | def on_reset_platforms(self): 177 | if askyesno("지형파일 생성기", "정말 모든 지형들을 삭제할까요?"): 178 | self.record_mode = 0 179 | self.coord_label.configure(fg="black") 180 | self.terrain_analyzer.reset() 181 | self.start_platform_record_button.configure(state=NORMAL) 182 | self.stop_platform_record_button.configure(state=DISABLED) 183 | self.start_oneway_record_button.configure(state=NORMAL) 184 | self.stop_oneway_record_button.configure(state=DISABLED) 185 | self.update_listbox() 186 | 187 | def find_minimap_coords(self): 188 | self.image_processor.update_image(set_focus=False, update_rect=True) 189 | self.minimap_rect = self.image_processor.get_minimap_rect() 190 | 191 | def update_image(self): 192 | while not self.stopEvent.is_set(): 193 | self.image_processor.update_image(set_focus=False) 194 | if not self.minimap_rect: 195 | self.image_label.configure(text="미니맵 찾을수 없음", fg="red") 196 | self.find_minimap_coords() 197 | continue 198 | 199 | playerpos = self.image_processor.find_player_minimap_marker(self.minimap_rect) 200 | 201 | if not playerpos: 202 | self.image_label.configure(text="플레이어 위치 찾을수 없음", fg="red") 203 | self.find_minimap_coords() 204 | continue 205 | 206 | self.last_coord_x, self.last_coord_y = playerpos 207 | if self.record_mode == 1 or self.record_mode == 2: 208 | if (self.last_coord_x, self.last_coord_y) not in self.current_coords: 209 | self.current_coords.append((self.last_coord_x, self.last_coord_y)) 210 | if self.record_mode == 1: 211 | self.coord_label.configure(fg="green") 212 | elif self.record_mode == 2: 213 | self.coord_label.configure(fg="red") 214 | 215 | self.coord_label.configure(text="%d,%d"%(playerpos[0], playerpos[1])) 216 | if self.minimap_rect == 0: 217 | continue 218 | 219 | cropped_img = cv2.cvtColor(self.image_processor.bgr_img[self.minimap_rect[1]:self.minimap_rect[1] + self.minimap_rect[3], self.minimap_rect[0]:self.minimap_rect[0] + self.minimap_rect[2]], cv2.COLOR_BGR2RGB) 220 | if self.record_mode: 221 | cv2.line(cropped_img, (playerpos[0], 0), (playerpos[0], cropped_img.shape[0]), (0, 0, 255), 1) 222 | cv2.line(cropped_img, (0, playerpos[1]), (cropped_img.shape[1], playerpos[1]), (0, 0, 255), 1) 223 | else: 224 | cv2.line(cropped_img, (playerpos[0], 0), (playerpos[0], cropped_img.shape[0]), (0,0,0), 1) 225 | cv2.line(cropped_img, (0, playerpos[1]), (cropped_img.shape[1], playerpos[1]), (0, 0, 0), 1) 226 | 227 | selected = self.platform_listbox.curselection() 228 | if selected: 229 | for idx in selected: 230 | for key, hash in self.platform_listbox_platform_index.items(): 231 | if idx == key: 232 | platform_obj = self.terrain_analyzer.platforms[hash] 233 | cv2.line(cropped_img, (platform_obj.start_x, platform_obj.start_y),(platform_obj.end_x, platform_obj.end_y), (0, 255, 0), 2) 234 | break 235 | for key, hash in self.platform_listbox_oneway_index.items(): 236 | if idx == key: 237 | platform_obj = self.terrain_analyzer.oneway_platforms[hash] 238 | cv2.line(cropped_img, (platform_obj.start_x, platform_obj.start_y),(platform_obj.end_x, platform_obj.end_y), (255, 0, 0), 2) 239 | break 240 | else: 241 | for key, platform in self.terrain_analyzer.platforms.items(): 242 | cv2.line(cropped_img, (platform.start_x, platform.start_y), (platform.end_x, platform.end_y), (0,255,0), 2) 243 | for key, platform in self.terrain_analyzer.oneway_platforms.items(): 244 | cv2.line(cropped_img, (platform.start_x, platform.start_y), (platform.end_x, platform.end_y), (255,0,0), 2) 245 | 246 | img = Image.fromarray(cropped_img) 247 | img_tk = ImageTk.PhotoImage(image=img) 248 | self.image_label.image = img_tk 249 | self.image_label.configure(image=img_tk) 250 | 251 | self.update() 252 | 253 | 254 | if __name__ == "__main__": 255 | root = tk.Tk() 256 | PlatformDataCaptureWindow() 257 | root.mainloop() -------------------------------------------------------------------------------- /src/player_controller.py: -------------------------------------------------------------------------------- 1 | from directinput_constants import DIK_RIGHT, DIK_DOWN, DIK_LEFT, DIK_UP, DIK_ALT 2 | from keystate_manager import DEFAULT_KEY_MAP 3 | import time, math, random 4 | # simple jump vertical distance: about 6 pixels 5 | 6 | class PlayerController: 7 | """ 8 | This class keeps track of character location and manages advanced movement and attacks. 9 | """ 10 | def __init__(self, key_mgr, screen_handler, keymap=DEFAULT_KEY_MAP): 11 | """ 12 | Class Variables: 13 | 14 | self.x: Known player minimap x coord. Needs to be updated manually 15 | self.x: Known player minimap y coord. Needs tobe updated manually 16 | self.key_mgr: handle to KeyboardInputManager 17 | self.screen_processor: handle to StaticImageProcessor 18 | self.goal_x: If moving, destination x coord 19 | self.goal_y: If moving, destination y coord 20 | self.busy: True if current class is calling blocking calls or in a loop 21 | :param key_mgr: Handle to KeyboardInputManager 22 | :param screen_handler: Handle to StaticImageProcessor. Only used to call find_player_minimap_marker 23 | 24 | Bot States: 25 | Idle 26 | ChangePlatform 27 | AttackinPlatform 28 | """ 29 | self.x = None 30 | self.y = None 31 | 32 | 33 | self.keymap = {} 34 | for key, value in keymap.items(): 35 | self.keymap[key] = value[0] 36 | self.jump_key = self.keymap["jump"] 37 | self.key_mgr = key_mgr 38 | self.screen_processor = screen_handler 39 | self.goal_x = None 40 | self.goal_y = None 41 | 42 | self.busy = False 43 | 44 | self.finemode_limit = 4 45 | self.horizontal_goal_offset = 5 46 | 47 | self.demonstrike_min_distance = 18 48 | 49 | self.horizontal_jump_distance = 10 50 | self.horizontal_jump_height = 9 51 | 52 | self.x_movement_enforce_rate = 15 # refer to optimized_horizontal_move 53 | 54 | self.moonlight_slash_x_radius = 13 # exceed: moonlight slash's estimalte x hitbox RADIUS in minimap coords. 55 | self.moonlight_slash_delay = 0.9 # delay after using MS where character is not movable 56 | 57 | self.horizontal_movement_threshold = 21 # Glide instead of walk if distance greater than threshold 58 | 59 | self.skill_cast_counter = 0 60 | self.skill_counter_time = 0 61 | 62 | self.last_shield_chase_time = 0 63 | self.shield_chase_cooldown = 6 64 | self.shield_chase_delay = 1.0 # delay after using SC where character is not movable 65 | self.last_shield_chase_coords = None 66 | self.min_shield_chase_distance = 20 67 | 68 | self.last_thousand_sword_time = 0 69 | self.thousand_sword_cooldown = 8 + 2 70 | self.thousand_sword_delay = 1.6 - 0.2 # delay after using thousand sword where character is not movable 71 | self.last_thousand_sword_coords = None 72 | self.min_thousand_sword_distance = 25 73 | 74 | self.rune_cooldown = 60 * 15 # 15 minutes for rune cooldown 75 | self.last_rune_solve_time = 0 76 | 77 | self.holy_symbol_cooldown = 60 * 3 + 1 78 | self.last_holy_symbol_time = 0 79 | self.holy_symbol_delay = 1.7 80 | 81 | self.hyper_body_cooldown = 60 * 3 + 1 82 | self.last_hyper_body_time = 0 83 | self.hyper_body_delay = 1.7 84 | 85 | self.overload_stack = 0 86 | 87 | # Initialization code for self.randomize_skill 88 | self.thousand_sword_percent = 30 89 | self.shield_chase_percent = 40 90 | self.choices = [] 91 | 92 | for obj in range(self.thousand_sword_percent): 93 | self.choices.append(1) 94 | for obj in range(self.shield_chase_percent): 95 | self.choices.append(2) 96 | 97 | for obj in range(100 - len(self.choices)): 98 | self.choices.append(0) 99 | 100 | def update(self, player_coords_x=None, player_coords_y=None): 101 | """ 102 | Updates self.x, self.y to input coordinates 103 | :param player_coords_x: Coordinates to update self.x 104 | :param player_coords_y: Coordinates to update self.y 105 | :return: None 106 | """ 107 | if not player_coords_x: 108 | self.screen_processor.update_image() 109 | scrp_ret_val = self.screen_processor.find_player_minimap_marker() 110 | if scrp_ret_val: 111 | player_coords_x, player_coords_y = scrp_ret_val 112 | else: 113 | #raise Exception("screen_processor did not return coordinates!!") 114 | player_coords_x = self.x 115 | player_coords_y = self.y 116 | self.x, self.y = player_coords_x, player_coords_y 117 | 118 | def jump_double_curve(self, start_x, start_y, current_x): 119 | """ 120 | Calculates the height at horizontal double jump starting from(start_x, start_y) at x coord current_x 121 | :param start_x: start x coord 122 | :param start_y: start y coord 123 | :param current_x: x of coordinate to calculate height 124 | :return: height at current_x 125 | """ 126 | slope = 0.05 127 | x_jump_range = 10 128 | y_jump_height = 1.4 129 | max_coord_x = (start_x*2 + x_jump_range)/2 130 | max_coord_y = start_y - y_jump_height 131 | if max_coord_y <= 0: 132 | return 0 133 | 134 | y = slope * (current_x - max_coord_x)**2 + max_coord_y 135 | return max(0, y) 136 | 137 | def distance(self, coord1, coord2): 138 | return math.sqrt((coord1[0]-coord2[0])**2 + (coord1[1]-coord2[1])**2) 139 | 140 | def moonlight_slash_sweep_move(self, goal_x, glide=True, no_attack_distance=0): 141 | """ 142 | This function will, while moving towards goal_x, constantly use exceed: moonlight slash and not overlapping 143 | This function currently does not have an time enforce implementation, meaning it may fall into an infinite loop 144 | if player coordinates are not read correctly. 145 | X coordinate max error on flat surface: +- 5 pixels 146 | :param goal_x: minimap x goal coordinate. 147 | :param glide: If True, will used optimized_horizontal_move. Else, will use horizontal_move_goal 148 | :param no_attack_distance: Distance in x pixels where any attack skill would not be used and just move 149 | :return: None 150 | """ 151 | start_x = self.x 152 | loc_delta = self.x - goal_x 153 | abs_loc_delta = abs(loc_delta) 154 | 155 | if not no_attack_distance: 156 | self.moonlight_slash() 157 | time.sleep(abs(self.random_duration())) 158 | if loc_delta > 0: 159 | # left movement 160 | if no_attack_distance and no_attack_distance < abs_loc_delta: 161 | self.optimized_horizontal_move(self.x-no_attack_distance+self.horizontal_goal_offset) 162 | 163 | self.update() 164 | loc_delta = self.x - goal_x 165 | abs_loc_delta = abs(loc_delta) 166 | if abs_loc_delta < self.moonlight_slash_x_radius: 167 | self.horizontal_move_goal(goal_x) 168 | 169 | else: 170 | while True: 171 | self.update() 172 | 173 | if self.x <= goal_x + self.horizontal_goal_offset: 174 | break 175 | 176 | elif abs(self.x - goal_x) < self.moonlight_slash_x_radius * 2: 177 | # Movement distance is too short to effectively glide. So just wak 178 | if glide: 179 | self.optimized_horizontal_move(goal_x) 180 | else: 181 | self.horizontal_move_goal(goal_x) 182 | 183 | if abs(self.x - start_x) >= no_attack_distance: 184 | time.sleep(abs(self.random_duration())) 185 | self.moonlight_slash() 186 | #time.sleep(abs(self.random_duration())) 187 | self.randomize_skill() 188 | 189 | 190 | else: 191 | if glide: 192 | self.optimized_horizontal_move(self.x - self.moonlight_slash_x_radius * 2 + self.random_duration(2, 0)) 193 | else: 194 | self.horizontal_move_goal(self.x - self.moonlight_slash_x_radius * 2 + self.random_duration(2, 0)) 195 | 196 | time.sleep(abs(self.random_duration())) 197 | self.moonlight_slash() 198 | #time.sleep(abs(self.random_duration())) 199 | self.randomize_skill() 200 | 201 | time.sleep(abs(self.random_duration())) 202 | 203 | elif loc_delta < 0: 204 | # right movement 205 | if no_attack_distance and no_attack_distance < abs_loc_delta: 206 | self.optimized_horizontal_move(self.x+no_attack_distance-self.horizontal_goal_offset) 207 | self.update() 208 | loc_delta = self.x - goal_x 209 | abs_loc_delta = abs(loc_delta) 210 | if abs_loc_delta < self.moonlight_slash_x_radius: 211 | self.horizontal_move_goal(goal_x) 212 | 213 | else: 214 | while True: 215 | self.update() 216 | 217 | if self.x >= goal_x - self.horizontal_goal_offset: 218 | break 219 | 220 | elif abs(goal_x - self.x) < self.moonlight_slash_x_radius * 2: 221 | if glide: 222 | self.optimized_horizontal_move(goal_x) 223 | else: 224 | self.horizontal_move_goal(goal_x) 225 | 226 | if abs(self.x - start_x) >= no_attack_distance: 227 | time.sleep(abs(self.random_duration())) 228 | self.moonlight_slash() 229 | #time.sleep(abs(self.random_duration())) 230 | self.randomize_skill() 231 | 232 | else: 233 | if glide: 234 | self.optimized_horizontal_move(self.x + self.moonlight_slash_x_radius * 2 - abs(self.random_duration(2, 0))) 235 | else: 236 | self.horizontal_move_goal(self.x + self.moonlight_slash_x_radius * 2 - abs(self.random_duration(2, 0))) 237 | 238 | time.sleep(abs(self.random_duration())) 239 | self.moonlight_slash() 240 | #time.sleep(abs(self.random_duration())) 241 | self.randomize_skill() 242 | 243 | time.sleep(abs(self.random_duration())) 244 | 245 | def optimized_horizontal_move(self, goal_x, ledge=False, enforce_time=True): 246 | """ 247 | Move from self.x to goal_x in as little time as possible. Uses multiple movement solutions for efficient paths. Blocking call 248 | :param goal_x: x coordinate to move to. This function only takes into account x coordinate movements. 249 | :param ledge: If true, goal_x is an end of a platform, and additional movement solutions can be used. If not, precise movement is required. 250 | :param enforce_time: If true, the function will stop moving after a time threshold is met and still haven't 251 | met the goal. Default threshold is 15 minimap pixels per second. 252 | :return: None 253 | """ 254 | loc_delta = self.x - goal_x 255 | abs_loc_delta = abs(loc_delta) 256 | start_time = time.time() 257 | horizontal_goal_offset = self.horizontal_goal_offset 258 | if loc_delta < 0: 259 | # we need to move right 260 | time_limit = math.ceil(abs_loc_delta/self.x_movement_enforce_rate) 261 | if abs_loc_delta <= self.horizontal_movement_threshold: 262 | # Just walk if distance is less than threshold 263 | self.key_mgr._direct_press(DIK_RIGHT) 264 | 265 | # Below: use a loop to continously press right until goal is reached or time is up 266 | while True: 267 | if time.time()-start_time > time_limit: 268 | break 269 | 270 | self.update() 271 | # Problem with synchonizing player_pos with self.x and self.y. Needs to get resolved. 272 | # Current solution: Just call self.update() (not good for redundancy) 273 | if self.x >= goal_x - self.horizontal_goal_offset: 274 | # Reached or exceeded destination x coordinates 275 | break 276 | 277 | self.key_mgr._direct_release(DIK_RIGHT) 278 | 279 | else: 280 | # Distance is quite big, so we glide 281 | self.key_mgr._direct_press(DIK_RIGHT) 282 | time.sleep(abs(0.05+self.random_duration(gen_range=0.1))) 283 | self.key_mgr._direct_press(self.jump_key) 284 | time.sleep(abs(0.15+self.random_duration(gen_range=0.15))) 285 | self.key_mgr._direct_release(self.jump_key) 286 | time.sleep(abs(0.1+self.random_duration(gen_range=0.15))) 287 | self.key_mgr._direct_press(self.jump_key) 288 | while True: 289 | if time.time() - start_time > time_limit: 290 | break 291 | 292 | self.update() 293 | if self.x >= goal_x - self.horizontal_goal_offset * 3: 294 | break 295 | self.key_mgr._direct_release(self.jump_key) 296 | time.sleep(0.1 + self.random_duration()) 297 | self.key_mgr._direct_release(DIK_RIGHT) 298 | 299 | 300 | elif loc_delta > 0: 301 | # we are moving to the left 302 | time_limit = math.ceil(abs_loc_delta / self.x_movement_enforce_rate) 303 | if abs_loc_delta <= self.horizontal_movement_threshold: 304 | # Just walk if distance is less than 10 305 | self.key_mgr._direct_press(DIK_LEFT) 306 | 307 | # Below: use a loop to continously press left until goal is reached or time is up 308 | while True: 309 | if time.time()-start_time > time_limit: 310 | break 311 | 312 | self.update() 313 | # Problem with synchonizing player_pos with self.x and self.y. Needs to get resolved. 314 | # Current solution: Just call self.update() (not good for redundancy) 315 | if self.x <= goal_x + self.horizontal_goal_offset: 316 | # Reached or exceeded destination x coordinates 317 | break 318 | 319 | self.key_mgr._direct_release(DIK_LEFT) 320 | 321 | else: 322 | # Distance is quite big, so we glide 323 | self.key_mgr._direct_press(DIK_LEFT) 324 | time.sleep(abs(0.05+self.random_duration(gen_range=0.1))) 325 | self.key_mgr._direct_press(self.jump_key) 326 | time.sleep(abs(0.15+self.random_duration(gen_range=0.15))) 327 | self.key_mgr._direct_release(self.jump_key) 328 | time.sleep(abs(0.1+self.random_duration(gen_range=0.15))) 329 | self.key_mgr._direct_press(self.jump_key) 330 | while True: 331 | if time.time() - start_time > time_limit: 332 | break 333 | 334 | self.update() 335 | if self.x <= goal_x + self.horizontal_goal_offset * 3: 336 | break 337 | self.key_mgr._direct_release(self.jump_key) 338 | time.sleep(0.1 + self.random_duration()) 339 | self.key_mgr._direct_release(DIK_LEFT) 340 | 341 | def horizontal_move_goal(self, goal_x): 342 | """ 343 | Blocking call to move from current x position(self.x) to goal_x. Only counts x coordinates. 344 | Refactor notes: This function references self.screen_processor 345 | :param goal_x: goal x coordinates 346 | :return: None 347 | """ 348 | current_x = self.x 349 | if goal_x - current_x > 0: 350 | # need to go right: 351 | mode = "r" 352 | elif goal_x - current_x < 0: 353 | # need to go left: 354 | mode = "l" 355 | else: 356 | return 0 357 | 358 | if mode == "r": 359 | # need to go right: 360 | self.key_mgr._direct_press(DIK_RIGHT) 361 | elif mode == "l": 362 | # need to go left: 363 | self.key_mgr._direct_press(DIK_LEFT) 364 | while True: 365 | self.update() 366 | if not self.x: 367 | assert 1 == 0, "horizontal_move goal: failed to recognize coordinates" 368 | 369 | if mode == "r": 370 | if self.x >= goal_x-self.horizontal_goal_offset: 371 | self.key_mgr._direct_release(DIK_RIGHT) 372 | break 373 | elif mode == "l": 374 | if self.x <= goal_x+self.horizontal_goal_offset: 375 | self.key_mgr._direct_release(DIK_LEFT) 376 | break 377 | 378 | 379 | def dbljump_max(self): 380 | """Warining: is a blocking call""" 381 | self.key_mgr._direct_press(self.jump_key) 382 | time.sleep(0.1 + self.random_duration(0.05)) 383 | self.key_mgr._direct_release(self.jump_key) 384 | time.sleep(abs(0.05 + self.random_duration(0.05))) 385 | self.key_mgr._direct_press(DIK_UP) 386 | time.sleep(abs(0.01 + self.random_duration(0.05))) 387 | self.key_mgr._direct_release(DIK_UP) 388 | time.sleep(0.1) 389 | self.key_mgr._direct_press(DIK_UP) 390 | time.sleep(abs(0.01 + self.random_duration(0.05))) 391 | self.key_mgr._direct_release(DIK_UP) 392 | 393 | def dbljump_half(self): 394 | """Warining: is a blocking call""" 395 | self.key_mgr._direct_press(self.jump_key) 396 | time.sleep(0.1 + self.random_duration(0.1)) 397 | self.key_mgr._direct_release(self.jump_key) 398 | time.sleep(0.23 + self.random_duration(0.1)) 399 | self.key_mgr._direct_press(DIK_UP) 400 | time.sleep(0.01) 401 | self.key_mgr._direct_release(DIK_UP) 402 | time.sleep(0.1) 403 | self.key_mgr._direct_press(DIK_UP) 404 | time.sleep(abs(0.01 + self.random_duration(0.15))) 405 | self.key_mgr._direct_release(DIK_UP) 406 | 407 | def dbljump_timed(self, delay): 408 | """ 409 | If using linear eq, delay explicit amount of time for double jump 410 | :param delay: time before double jump command is issued in float seconds 411 | :return: None 412 | """ 413 | self.key_mgr.single_press(self.jump_key) 414 | time.sleep(delay) 415 | self.key_mgr.single_press(DIK_UP) 416 | time.sleep(0.01) 417 | self.key_mgr.single_press(DIK_UP) 418 | 419 | def jumpl(self): 420 | """Blocking call""" 421 | self.key_mgr._direct_press(DIK_LEFT) 422 | time.sleep(0.05) 423 | self.key_mgr._direct_press(self.jump_key) 424 | time.sleep(0.1) 425 | self.key_mgr._direct_release(DIK_LEFT) 426 | self.key_mgr._direct_release(self.jump_key) 427 | 428 | def jumpl_double(self): 429 | """Blocking call""" 430 | self.key_mgr._direct_press(self.jump_key) 431 | time.sleep(abs(0.05 + self.random_duration(0.1))) 432 | self.key_mgr._direct_release(self.jump_key) 433 | time.sleep(0.1) 434 | self.key_mgr._direct_press(DIK_LEFT) 435 | time.sleep(abs(0.05 + self.random_duration(0.1))) 436 | self.key_mgr._direct_release(DIK_LEFT) 437 | time.sleep(0.05) 438 | self.key_mgr._direct_press(DIK_LEFT) 439 | time.sleep(abs(0.05 + self.random_duration(0.2))) 440 | self.key_mgr._direct_release(DIK_LEFT) 441 | 442 | def jumpl_glide(self): 443 | """Blocking call""" 444 | self.key_mgr._direct_press(DIK_LEFT) 445 | time.sleep(0.05) 446 | self.key_mgr._direct_press(self.jump_key) 447 | time.sleep(0.15) 448 | self.key_mgr._direct_release(self.jump_key) 449 | time.sleep(0.1) 450 | self.key_mgr._direct_press(self.jump_key) 451 | time.sleep(0.2) 452 | self.key_mgr._direct_release(self.jump_key) 453 | self.key_mgr._direct_release(DIK_LEFT) 454 | 455 | def jumpr(self): 456 | """Blocking call""" 457 | self.key_mgr._direct_press(DIK_RIGHT) 458 | time.sleep(0.05) 459 | self.key_mgr._direct_press(self.jump_key) 460 | time.sleep(0.1) 461 | self.key_mgr._direct_release(DIK_RIGHT) 462 | self.key_mgr._direct_release(self.jump_key) 463 | 464 | def jumpr_double(self): 465 | """Blocking call""" 466 | self.key_mgr._direct_press(self.jump_key) 467 | time.sleep(abs(0.05+self.random_duration(0.1))) 468 | self.key_mgr._direct_release(self.jump_key) 469 | time.sleep(0.1) 470 | self.key_mgr._direct_press(DIK_RIGHT) 471 | time.sleep(abs(0.05 + self.random_duration(0.1))) 472 | self.key_mgr._direct_release(DIK_RIGHT) 473 | time.sleep(0.05) 474 | self.key_mgr._direct_press(DIK_RIGHT) 475 | time.sleep(abs(0.05 + self.random_duration(0.2))) 476 | self.key_mgr._direct_release(DIK_RIGHT) 477 | 478 | def jumpr_glide(self): 479 | """Blocking call""" 480 | self.key_mgr._direct_press(DIK_RIGHT) 481 | time.sleep(0.05) 482 | self.key_mgr._direct_press(self.jump_key) 483 | time.sleep(0.15) 484 | self.key_mgr._direct_release(self.jump_key) 485 | time.sleep(0.1) 486 | self.key_mgr._direct_press(self.jump_key) 487 | time.sleep(0.2) 488 | self.key_mgr._direct_release(self.jump_key) 489 | self.key_mgr._direct_release(DIK_RIGHT) 490 | 491 | def drop(self): 492 | """Blocking call""" 493 | self.key_mgr._direct_press(DIK_DOWN) 494 | time.sleep(abs(0.1+self.random_duration())) 495 | self.key_mgr._direct_press(self.jump_key) 496 | time.sleep(abs(0.1+self.random_duration())) 497 | self.key_mgr._direct_release(DIK_DOWN) 498 | time.sleep(abs(0.1+self.random_duration())) 499 | self.key_mgr._direct_release(self.jump_key) 500 | 501 | def moonlight_slash(self): 502 | 503 | self.key_mgr.single_press(self.keymap["moonlight_slash"]) 504 | self.overload_stack += 1 505 | self.skill_cast_counter += 1 506 | time.sleep(self.moonlight_slash_delay) 507 | 508 | def thousand_sword(self): 509 | if time.time() - self.last_thousand_sword_time > self.thousand_sword_cooldown: 510 | self.update() 511 | if self.distance((self.x, self.y), self.last_thousand_sword_coords if self.last_thousand_sword_coords else (1000,1000)) >= self.min_thousand_sword_distance or \ 512 | time.time() - self.last_thousand_sword_time > self.thousand_sword_cooldown*3: 513 | self.key_mgr.single_press(self.keymap["thousand_sword"], additional_duration=abs(self.random_duration())) 514 | self.last_thousand_sword_time = time.time() 515 | self.skill_cast_counter += 1 516 | self.last_thousand_sword_coords = (self.x, self.y) 517 | self.overload_stack += 5 518 | print("thousand sword cast") 519 | time.sleep(self.thousand_sword_delay) 520 | 521 | def shield_chase(self): 522 | if time.time() - self.last_shield_chase_time > self.shield_chase_cooldown: 523 | if self.distance((self.x, self.y), self.last_shield_chase_coords if self.last_shield_chase_coords else (1000,1000)) >= self.min_shield_chase_distance or \ 524 | time.time() - self.last_shield_chase_time > self.shield_chase_cooldown*3: 525 | 526 | self.update() 527 | cast_yccords = self.y 528 | self.key_mgr.single_press(self.keymap["shield_chase"], additional_duration=abs(self.random_duration())) 529 | """self.key_mgr.single_press(DIK_ALT) 530 | self.update() 531 | after_cast_ycoords = self.y 532 | print("shield chase cast") 533 | if cast_yccords == after_cast_ycoords: 534 | # Shield chase has been used. 535 | self.last_shield_chase_time = time.time() 536 | self.last_shield_chase_coords = (self.x, self.y) 537 | self.skill_cast_counter += 1 538 | time.sleep(self.shield_chase_delay - 0.2) 539 | print("shield chase cast - actually casted") 540 | return 0 541 | else: 542 | # No monsters nearby, was not used 543 | print("shield chase cast - not casted") 544 | time.sleep(0.2) 545 | return 1""" 546 | # Disabled skill usage check for now 547 | self.skill_cast_counter += 1 548 | return 0 549 | 550 | def holy_symbol(self): 551 | if time.time() - self.last_holy_symbol_time > self.holy_symbol_cooldown + random.randint(0, 14): 552 | self.key_mgr.single_press(self.keymap["holy_symbol"], additional_duration=abs(self.random_duration())) 553 | self.skill_cast_counter += 1 554 | self.last_holy_symbol_time = time.time() 555 | time.sleep(self.holy_symbol_delay) 556 | 557 | def hyper_body(self): 558 | if time.time() - self.last_hyper_body_time > self.hyper_body_cooldown + random.randint(0, 14): 559 | self.key_mgr.single_press(self.keymap["hyper_body"], additional_duration=abs(self.random_duration())) 560 | self.skill_cast_counter += 1 561 | self.last_hyper_body_time = time.time() 562 | time.sleep(self.hyper_body_delay) 563 | 564 | def release_overload(self): 565 | if self.overload_stack >= 18 + random.randint(0, 12): 566 | self.key_mgr.single_press(self.keymap["release_overload"],additional_duration=abs(self.random_duration())) 567 | self.skill_cast_counter += 1 568 | self.overload_stack = 0 569 | time.sleep(0.1) 570 | 571 | def randomize_skill(self): 572 | selection = random.choice(self.choices) 573 | if selection == 0: 574 | return 0 575 | elif selection == 1: 576 | self.thousand_sword() 577 | return 1 578 | elif selection == 2: 579 | retval = self.shield_chase() 580 | if retval == 0: 581 | return 2 582 | else: 583 | return 0 584 | 585 | def random_duration(self, gen_range=0.1, digits=2): 586 | """ 587 | returns a random number x where -gen_range<=x<=gen_range rounded to digits number of digits under floating points 588 | :param gen_range: float for generating number x where -gen_range<=x<=gen_range 589 | :param digits: n digits under floating point to round. 0 returns integer as float type 590 | :return: random number float 591 | """ 592 | d = round(random.uniform(0, gen_range), digits) 593 | if random.choice([1,-1]) == -1: 594 | d *= -1 595 | return d 596 | 597 | -------------------------------------------------------------------------------- /src/readme.md: -------------------------------------------------------------------------------- 1 | This directory houses the core features related to the bot itself, including screen processing, logic, input management 2 | 3 | For further information regarding each module, please refer to indivisual documentation in each file. 4 | 5 | ### Note of regard to code users 6 | *Commercial usage of the following code is discouraged, but free of will.* This project was not intended to be commercialized, and was 7 | only for research purposes and proof-of-concept. Any malicious uses of the following code can result in 8 | Nexon reenforcing anti-bot features which will render this bot and future improvements useless. 9 | 10 | Personal notes: 11 | minimap to game screen x ratio: 12 | about 30:495 = ~1:16 13 | -------------------------------------------------------------------------------- /src/rune_solver.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | """Classifier model verifier""" 3 | import sys, logging 4 | 5 | logger = logging.getLogger("log") 6 | logger.setLevel(logging.DEBUG) 7 | logger.addHandler(logging.StreamHandler()) 8 | fh = logging.FileHandler("logging.log", encoding="utf-8") 9 | logger.addHandler(fh) 10 | try: 11 | from screen_processor import MapleScreenCapturer 12 | import cv2, time, os 13 | import numpy as np 14 | from keras.models import load_model 15 | from tensorflow import device 16 | from keystate_manager import KeyboardInputManager 17 | from directinput_constants import DIK_UP, DIK_DOWN, DIK_LEFT, DIK_RIGHT, DIK_NUMLOCK, DIK_SPACE 18 | from win32con import VK_NUMLOCK 19 | from win32api import GetKeyState 20 | except: 21 | logger.exception("EXCEPTION FROM IMPORTS") 22 | 23 | class RuneDetector: 24 | def __init__(self, model_path, labels=None, screen_capturer=None, key_mgr=None): 25 | """ 26 | Run just Once to initialize 27 | :param model_path: Path to trained keras model 28 | :param labels: dictionary with class names as keys, integer as values 29 | example: {'down': 0, 'left': 1, 'right': 2, 'up': 3} 30 | """ 31 | self.logger = logging.getLogger("RuneDetector") 32 | self.logger.setLevel(logging.DEBUG) 33 | 34 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 35 | 36 | fh = logging.FileHandler("logging.log") 37 | fh.setLevel(logging.DEBUG) 38 | fh.setFormatter(formatter) 39 | self.logger.addHandler(fh) 40 | self.labels = labels if labels else {'down': 0, 'left': 1, 'right': 2, 'up': 3} 41 | self.model_path = model_path 42 | with device("/cpu:0"): # Use cpu for evaluation 43 | model = load_model(self.model_path) 44 | #model.compile(optimizer="adam", loss='categorical_crossentropy', metrics=['accuracy']) 45 | model.load_weights(self.model_path) 46 | 47 | self.model = model 48 | 49 | self.rune_roi_1366 = [450, 180, 500, 130] # x, y, w, h 50 | self.rune_roi_1024 = [295, 180, 500, 133] 51 | self.rune_roi_800 = [170,200, 440, 135] 52 | self.rune_roi = self.rune_roi_800 # set as default rune roi 53 | self.screen_processor = MapleScreenCapturer() if not screen_capturer else screen_capturer 54 | self.key_mgr = KeyboardInputManager() if not key_mgr else key_mgr 55 | 56 | def capture_roi(self): 57 | screen_rect = self.screen_processor.ms_get_screen_rect(self.screen_processor.ms_get_screen_hwnd()) 58 | screen_width = screen_rect[2]-screen_rect[0] 59 | 60 | if screen_width > 1300: 61 | self.rune_roi = self.rune_roi_1366 62 | elif screen_width > 1000: 63 | self.rune_roi = self.rune_roi_1024 64 | elif screen_width > 800: 65 | self.rune_roi = self.rune_roi_800 66 | 67 | captured_image = self.screen_processor.capture(set_focus=False, rect=screen_rect) 68 | 69 | captured_roi = cv2.cvtColor(np.array(captured_image), cv2.COLOR_RGB2BGR) 70 | 71 | captured_roi = captured_roi[self.rune_roi[1]:self.rune_roi[1]+self.rune_roi[3], self.rune_roi[0]:self.rune_roi[0]+self.rune_roi[2]] 72 | 73 | return captured_roi 74 | 75 | def preprocess(self, img): 76 | """ 77 | finds and returns sorted list of 60 by 60 grayscale images of circles, centered 78 | :param img: BGR image of roi containing circle 79 | :return: list of grayscale images each containing a circle 80 | """ 81 | hsv_img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) 82 | hsv_img[:, :, 1] = 255 83 | hsv_img[:, :, 2] = 255 84 | bgr_img = cv2.cvtColor(hsv_img, cv2.COLOR_HSV2BGR) 85 | gray_img = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2GRAY) 86 | 87 | circles = cv2.HoughCircles(gray_img, cv2.HOUGH_GRADIENT, 1, gray_img.shape[0] / 8, param1=100, param2=30, minRadius=18, maxRadius=30) 88 | temp_list = [] 89 | img_index = 1 90 | if circles is not None: 91 | circles = np.round(circles[0, :]).astype("int") 92 | for (x, y, r) in circles: 93 | 94 | cropped = gray_img[max(0, int(y - 60 / 2)):int(y + 60 / 2), max(0, int(x - 60 / 2)):int(x + 60 / 2)].astype(np.float32) 95 | 96 | temp_list.append((cropped, (x, y))) 97 | img_index += 1 98 | 99 | temp_list = sorted(temp_list, key= lambda x: x[1][0]) 100 | return_list = [] 101 | for image in temp_list: 102 | return_list.append(image[0]) 103 | 104 | return return_list 105 | 106 | def images2tensor(self, img_list): 107 | """ 108 | Creates a tf compliant tensor by stacking images in img_list 109 | :param img_list: 110 | :return: np.array of shape [1, 60, 60, 1] 111 | """ 112 | return np.vstack([np.reshape(x, [1, 60, 60, 1]) for x in img_list]) 113 | 114 | def classify(self, tensor, batch_size = 4): 115 | """ 116 | Runs tensor through model and returns list of direction in string. 117 | :param tensor: input tensor 118 | :param batch_size: batch size 119 | :return: size of strings "up", "down", "left", "right" 120 | """ 121 | return_list = [] 122 | result = self.model.predict(tensor, batch_size=batch_size) 123 | for res in result: 124 | final_class = np.argmax(res, axis=-1) 125 | for key, val in self.labels.items(): 126 | if final_class == val: 127 | return_list.append(key) 128 | 129 | return return_list 130 | 131 | def solve_auto(self): 132 | """ 133 | Solves rune if present and sends key presses. 134 | :return: -1 if rune not detected, result of classify() if successful 135 | """ 136 | img = self.capture_roi() 137 | processed_imgs = self.preprocess(img) 138 | if len(processed_imgs) != 4: 139 | return -1 140 | #cv2.imwrite("roi.png", img) 141 | tensor = self.images2tensor(processed_imgs) 142 | result = self.classify(tensor) 143 | if GetKeyState(VK_NUMLOCK): 144 | self.key_mgr.single_press(DIK_NUMLOCK) 145 | time.sleep(0.2) 146 | self.logger.debug("Solved rune with solution %s"%(str(result))) 147 | for inp in result: 148 | if inp == "up": 149 | self.key_mgr.single_press(DIK_UP) 150 | elif inp == "down": 151 | self.key_mgr.single_press(DIK_DOWN) 152 | elif inp == "left": 153 | self.key_mgr.single_press(DIK_LEFT) 154 | elif inp == "right": 155 | self.key_mgr.single_press(DIK_RIGHT) 156 | time.sleep(0.1) 157 | return len(processed_imgs) 158 | 159 | def press_space(self): 160 | self.key_mgr.single_press(DIK_SPACE) 161 | 162 | def solve(self): 163 | """ 164 | Solves rune if present and just returns solution. 165 | :return: -1 if rune not detected, result of classify() if successful 166 | """ 167 | img = self.capture_roi() 168 | processed_imgs = self.preprocess(img) 169 | if len(processed_imgs) != 4: 170 | return -1 171 | tensor = self.images2tensor(processed_imgs) 172 | result = self.classify(tensor) 173 | 174 | return result 175 | 176 | if __name__ == "__main__": 177 | try: 178 | label = {'down': 0, 'left': 1, 'right': 2, 'up': 3} 179 | 180 | solver = RuneDetector("arrow_classifier_keras_gray.h5", label) 181 | logger.debug("Log start") 182 | logger.debug("screen handle: " + str(solver.screen_processor.ms_get_screen_hwnd())) 183 | logger.debug("screen rect: " + str(solver.screen_processor.ms_get_screen_rect(solver.screen_processor.ms_get_screen_hwnd()))) 184 | # solver.scrp.screen_capture(800,600, save=True, save_name="dta.png") 185 | logger.debug("Start processing input...") 186 | while True: 187 | img = solver.capture_roi() 188 | cv2.imshow("ExIt: Q", img) 189 | 190 | return_val = solver.solve_auto() 191 | if return_val == -1: 192 | print("no rune detected") 193 | else: 194 | logger.debug("Finished solving runes.") 195 | k = cv2.waitKey(1) 196 | if k == ord("q"): 197 | break 198 | logger.debug("Application exit.") 199 | except: 200 | logger.exception("EXCEPTION") 201 | 202 | 203 | 204 | -------------------------------------------------------------------------------- /src/screen_processor.py: -------------------------------------------------------------------------------- 1 | import cv2, win32gui, time, math, win32ui, win32con 2 | from PIL import ImageGrab 3 | import numpy as np, ctypes, ctypes.wintypes 4 | 5 | class MapleWindowNotFoundError(Exception): 6 | pass 7 | 8 | 9 | MAPLESTORY_WINDOW_TITLE = "MapleStory" 10 | 11 | 12 | 13 | 14 | 15 | class MapleScreenCapturer: 16 | """Container for capturing MS screen""" 17 | def __init__(self): 18 | self.hwnd = None 19 | 20 | def ms_get_screen_hwnd(self): 21 | window_hwnd = win32gui.FindWindow(0, MAPLESTORY_WINDOW_TITLE) 22 | if not window_hwnd: 23 | return 0 24 | else: 25 | return window_hwnd 26 | 27 | def ms_get_screen_rect(self, hwnd): 28 | """ 29 | Added compatibility code from 30 | https://stackoverflow.com/questions/51786794/using-imagegrab-with-bbox-from-pywin32s-getwindowrect 31 | :param hwnd: window handle from self.ms_get_screen_hwnd 32 | :return: window rect (x1, y1, x2, y2) of MS rect. 33 | """ 34 | try: 35 | f = ctypes.windll.dwmapi.DwmGetWindowAttribute 36 | except WindowsError: 37 | f = None 38 | if f: # Vista & 7 stuff 39 | rect = ctypes.wintypes.RECT() 40 | DWMWA_EXTENDED_FRAME_BOUNDS = 9 41 | f(ctypes.wintypes.HWND(self.ms_get_screen_hwnd()), 42 | ctypes.wintypes.DWORD(DWMWA_EXTENDED_FRAME_BOUNDS), 43 | ctypes.byref(rect), 44 | ctypes.sizeof(rect) 45 | ) 46 | size = (rect.left, rect.top, rect.right, rect.bottom) 47 | else: 48 | if not hwnd: 49 | hwnd = self.ms_get_screen_hwnd() 50 | size = win32gui.GetWindowRect(hwnd) 51 | return size # returns x1, y1, x2, y2 52 | 53 | def capture(self, set_focus=True, hwnd=None, rect=None): 54 | """Returns Maplestory window screenshot handle(not np.array!) 55 | :param set_focus : True if MapleStory window is to be focusesd before capture, False if not 56 | :param hwnd : Default: None Win32API screen handle to use. If None, sets and uses self.hwnd 57 | :param rect : If defined, captures specificed ScreenRect area (x1, y1, x2, y2). Else, uses MS window ms_screen_rect. 58 | :return : returns Imagegrab of screen (PIL Image)""" 59 | if hwnd: 60 | self.hwnd = hwnd 61 | if not hwnd: 62 | self.hwnd = self.ms_get_screen_hwnd() 63 | if not rect: 64 | rect = self.ms_get_screen_rect(self.hwnd) 65 | if set_focus: 66 | win32gui.SetForegroundWindow(self.hwnd) 67 | time.sleep(0.1) 68 | img = ImageGrab.grab(rect) 69 | return img 70 | 71 | def screen_capture(self,w, h, x=0, y=0, save=True, save_name=''): 72 | # hwnd = win32gui.FindWindow(None, None) 73 | hwnd = win32gui.GetDesktopWindow() 74 | wDC = win32gui.GetWindowDC(hwnd) 75 | dcObj = win32ui.CreateDCFromHandle(wDC) 76 | cDC = dcObj.CreateCompatibleDC() 77 | dataBitMap = win32ui.CreateBitmap() 78 | dataBitMap.CreateCompatibleBitmap(dcObj, w, h) 79 | cDC.SelectObject(dataBitMap) 80 | cDC.BitBlt((0, 0), (w, h), dcObj, (x, y), win32con.SRCCOPY) 81 | 82 | if save: 83 | dataBitMap.SaveBitmapFile(cDC, save_name) 84 | else: 85 | b = dataBitMap.GetBitmapBits(True) 86 | img = np.fromstring(b, np.uint8).reshape(h, w, 4) 87 | cv2.cvtColor(img, cv2.COLOR_RGB2BGR) 88 | 89 | dcObj.DeleteDC() 90 | cDC.DeleteDC() 91 | win32gui.ReleaseDC(hwnd, wDC) 92 | win32gui.DeleteObject(dataBitMap.GetHandle()) 93 | 94 | if not save: 95 | return img 96 | 97 | def pil_image_to_array(self, img): 98 | return cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) 99 | 100 | class StaticImageProcessor: 101 | def __init__(self, img_handle=None): 102 | """ 103 | 104 | :param img_handle: handle to MapleScreenCapturer 105 | """ 106 | if not img_handle: 107 | raise Exception("img_handle must reference an MapleScreenCapturer class!!") 108 | 109 | self.img_handle = img_handle 110 | self.bgr_img = None 111 | self.bin_img = None 112 | self.gray_img = None 113 | self.processed_img = None 114 | self.minimap_area = 0 115 | self.minimap_rect = None 116 | 117 | self.maximum_minimap_area = 40000 118 | 119 | self.default_minimap_scan_area = [0, 60, 400, 300] # x1, y1, x2, y2 120 | 121 | # Minimap player marker original BGR: 68, 221, 255 122 | self.lower_player_marker = np.array([67, 220, 254]) # B G R 123 | self.upper_player_marker = np.array([69, 222, 256]) 124 | self.lower_rune_marker = np.array([254, 101, 220]) # B G R 125 | self.upper_rune_marker = np.array([255, 103, 222]) 126 | 127 | self.hwnd = self.img_handle.ms_get_screen_hwnd() 128 | self.ms_screen_rect = None 129 | if self.hwnd: 130 | self.ms_screen_rect = self.img_handle.ms_get_screen_rect(self.hwnd) 131 | 132 | else: 133 | raise Exception("Could not find MapleStory window!!") 134 | 135 | 136 | 137 | def update_image(self, src=None, set_focus=True, update_rect=False): 138 | """ 139 | Calls ScreenCapturer's update function and updates images. 140 | :param src : rgb image data from PIL ImageGrab 141 | :param set_focus : True if win32api setfocus shall be called before capturing""" 142 | if src: 143 | rgb_img = src 144 | else: 145 | if update_rect: 146 | self.ms_screen_rect = self.img_handle.ms_get_screen_rect(self.hwnd) 147 | 148 | if not self.ms_screen_rect: 149 | self.ms_screen_rect = self.img_handle.ms_get_screen_rect(self.hwnd) 150 | rgb_img = self.img_handle.capture(set_focus, self.hwnd, self.ms_screen_rect) 151 | if not rgb_img: 152 | assert self.bgr_img != 0, "self.img_handle did not return img" 153 | self.bgr_img = cv2.cvtColor(np.array(rgb_img), cv2.COLOR_RGB2BGR) 154 | self.gray_img = cv2.cvtColor(self.bgr_img, cv2.COLOR_BGR2GRAY) 155 | 156 | def get_minimap_rect(self): 157 | """ 158 | Processes self.gray images, returns minimap bounding box 159 | :return: Array [x,y,w,h] bounding box of minimap if found, else 0 160 | """ 161 | cropped = self.gray_img[self.default_minimap_scan_area[1]:self.default_minimap_scan_area[3], self.default_minimap_scan_area[0]:self.default_minimap_scan_area[2]] 162 | blurred_img = cv2.GaussianBlur(cropped, (3,3), 3) 163 | morphed_img = cv2.erode(blurred_img, (7,7)) 164 | canny = cv2.Canny(morphed_img, threshold1=180, threshold2=255) 165 | try: 166 | im2, contours, hierachy = cv2.findContours(canny, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) 167 | except: 168 | contours, hierachy = cv2.findContours(canny, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) 169 | if contours: 170 | biggest_contour = max(contours, key = cv2.contourArea) 171 | if cv2.contourArea(biggest_contour) >= 100 and cv2.contourArea(biggest_contour) >= self.minimap_area and cv2.contourArea(biggest_contour) <= self.maximum_minimap_area: 172 | minimap_coords = cv2.boundingRect(biggest_contour) 173 | if minimap_coords[0] > 0 and minimap_coords[1] > 0 and minimap_coords[2] > 0 and minimap_coords[2] > 0: 174 | contour_area = cv2.contourArea(biggest_contour) 175 | self.minimap_area = contour_area 176 | minimap_coords = [minimap_coords[0], minimap_coords[1], minimap_coords[2], minimap_coords[3]] 177 | minimap_coords[0] += self.default_minimap_scan_area[0] 178 | minimap_coords[1] += self.default_minimap_scan_area[1] 179 | self.minimap_rect = minimap_coords 180 | return minimap_coords 181 | else: 182 | pass 183 | 184 | return 0 185 | 186 | def reset_minimap_area(self): 187 | """ 188 | Resets self.minimap_area which is used to reset self.get_minimap_rect search. 189 | :return: None 190 | """ 191 | self.minimap_area = 0 192 | 193 | def find_player_minimap_marker(self, rect=None): 194 | """ 195 | Processes self.bgr_image to return player coordinate on minimap. 196 | The player marker has exactly 12 pixels of the detection color to form a pixel circle(2,4,4,2 pixels). Therefore 197 | before calculation the mean pixel value of the mask, we remove "false positives", which are not part of the 198 | player color by finding pixels which do not have between 10 to 12 other pixels(including itself) of the same color in a 199 | distance of 3. 200 | :param rect: [x,y,w,h] bounding box of minimap in MapleStory screen. Call self.get_minimap_rect to obtain 201 | :return: x,y coordinate of player relative to ms_screen_rect if found, else 0 202 | """ 203 | if not rect and not self.minimap_rect: 204 | rect = self.get_minimap_rect() 205 | else: 206 | rect = self.minimap_rect 207 | 208 | assert rect, "Invalid minimap coordinates" 209 | 210 | cropped = self.bgr_img[rect[1]:rect[1]+rect[3], rect[0]:rect[0]+rect[2]] 211 | mask = cv2.inRange(cropped, self.lower_player_marker, self.upper_player_marker) 212 | td = np.transpose(np.where(mask > 0)).tolist() 213 | 214 | if len(td) > 0: 215 | avg_x = 0 216 | avg_y = 0 217 | totalpoints = 0 218 | for coord in td: 219 | nearest_points = 0 # Points which are close to coord pixel 220 | for ref_coord in td: 221 | # Calculate the range between every single pixel 222 | if math.sqrt(abs(ref_coord[0]-coord[0])**2 + abs(ref_coord[1]-coord[1])**2) <= 3: 223 | nearest_points += 1 224 | 225 | if nearest_points >= 10 and nearest_points <= 13: 226 | avg_y += coord[0] 227 | avg_x += coord[1] 228 | totalpoints += 1 229 | 230 | if totalpoints == 0: 231 | return 0 232 | 233 | avg_y = int(avg_y / totalpoints) 234 | avg_x = int(avg_x / totalpoints) 235 | return avg_x, avg_y 236 | 237 | return 0 238 | 239 | 240 | 241 | def find_other_player_marker(self, rect=None): 242 | """ 243 | Processes self.bgr_image to return coordinate of other players on minimap if exists. 244 | Does not behave as expected when there are more than one other player on map. Use this function to just detect. 245 | :param rect: [x,y,w,h] bounding box of minimap. Call self.get_minimap_rect 246 | :return: x,y coord of marker if found, else 0 247 | """ 248 | if not rect: 249 | rect = self.get_minimap_rect() 250 | assert rect, "Invalid minimap coordinates" 251 | cropped = self.bgr_img[rect[1]:rect[1]+rect[3], rect[0]:rect[0]+rect[2]] 252 | mask = cv2.inRange(cropped, (0, 0, 255), (0, 0, 255)) 253 | td = np.transpose(np.where(mask > 0)).tolist() 254 | if len(td) > 0: 255 | avg_x = 0 256 | avg_y = 0 257 | totalpoints = 0 258 | for coord in td: 259 | avg_y += coord[0] 260 | avg_x += coord[1] 261 | totalpoints += 1 262 | avg_y = int(avg_y / totalpoints) 263 | avg_x = int(avg_x / totalpoints) 264 | return avg_x, avg_y 265 | 266 | return 0 267 | 268 | def find_rune_marker(self, rect=None): 269 | """ 270 | Processes self.bgr_image to return coordinates of rune marker on minimap. 271 | :param rect: [x,y,w,h] bounding box of minimap. Call self.get_minimap_rect 272 | :return: x,y of rune minimap coordinates if found, else 0 273 | """ 274 | if not rect and not self.minimap_rect: 275 | rect = self.get_minimap_rect() 276 | else: 277 | rect = self.minimap_rect 278 | assert rect, "Invalid minimap coordinates" 279 | cropped = self.bgr_img[rect[1]:rect[1] + rect[3], rect[0]:rect[0] + rect[2]] 280 | mask = cv2.inRange(cropped, self.lower_rune_marker, self.upper_rune_marker) 281 | td = np.transpose(np.where(mask > 0)).tolist() 282 | if len(td) > 0: 283 | avg_x = 0 284 | avg_y = 0 285 | totalpoints = 0 286 | for coord in td: 287 | nearest_points = 0 # Points which are close to coord pixel 288 | for ref_coord in td: 289 | # Calculate the range between every single pixel 290 | if math.sqrt(abs(ref_coord[0] - coord[0]) ** 2 + abs(ref_coord[1] - coord[1]) ** 2) <= 6: 291 | nearest_points += 1 292 | 293 | if nearest_points >= 20 and nearest_points <= 25: 294 | avg_y += coord[0] 295 | avg_x += coord[1] 296 | totalpoints += 1 297 | 298 | if totalpoints == 0: 299 | return 0 300 | 301 | avg_y = int(avg_y / totalpoints) 302 | avg_x = int(avg_x / totalpoints) 303 | return avg_x, avg_y 304 | 305 | return 0 306 | 307 | if __name__ == "__main__": 308 | dx = MapleScreenCapturer() 309 | hwnd = dx.ms_get_screen_hwnd() 310 | rect = dx.ms_get_screen_rect(hwnd) 311 | dx.capture(rect=rect) 312 | -------------------------------------------------------------------------------- /src/terrain_analyzer.py: -------------------------------------------------------------------------------- 1 | import math, pickle, os, hashlib, random 2 | 3 | """ 4 | PlatformScan, graph based least-visited-node-first traversal algorithm 5 | Let map(V,E) be a directed cyclic graph 6 | function traverse(from, to): 7 | from[to].visited = 1 8 | map[to].last_visit = 0 9 | for node in map: 10 | node.last_visit += 1 11 | need_reset = True 12 | for vert in from.vertices: 13 | if vert.visited == 0: 14 | need_reset = False 15 | break 16 | 17 | if need_reset: 18 | for vert in from.vertices: 19 | vert.visited = 0 20 | 21 | function select(current_node): 22 | for vert in sorted(current_node.vertices, key=lambda x:vert.last_visit): 23 | if vert.visited = 0: 24 | return vert 25 | """ 26 | 27 | METHOD_DROP = "drop" 28 | METHOD_DBLJMP_MAX = "dbljmp_max" 29 | METHOD_DBLJMP_HALF = "dbljmp_half" 30 | METHOD_DBLJMP = "dbljmp" 31 | METHOD_JUMPR = "jumpr" 32 | METHOD_JUMPL = "jumpl" 33 | METHOD_MOVER = "movr" 34 | METHOD_MOVEL = "movl" 35 | class Platform: 36 | def __init__(self, start_x = None, start_y = None, end_x = None, end_y = None, last_visit = None, solutions = None, hash = None): 37 | self.start_x = start_x 38 | self.start_y = start_y 39 | self.end_x = end_x 40 | self.end_y = end_y 41 | self.last_visit = last_visit # list of a list: [solution, 0] 42 | self.solutions = solutions 43 | self.hash = hash 44 | 45 | class Solution: 46 | def __init__(self, from_hash=None, to_hash=None, lower_bound=None, upper_bound=None, method=None, visited=False): 47 | self.from_hash = from_hash 48 | self.to_hash = to_hash 49 | self.lower_bound = lower_bound 50 | self.upper_bound = upper_bound 51 | self.method = method 52 | self.visited = visited 53 | 54 | 55 | class AstarNode: 56 | def __init__(self, x=None, y=None, g=None, h=None, path=[]): 57 | self.x = x 58 | self.y = y 59 | self.g = g 60 | self.h = h 61 | self.f = 0 62 | self.path = path 63 | if self.g: 64 | self.f = self.g + self.h 65 | 66 | 67 | class PathAnalyzer: 68 | """Converts minimap player coordinates to terrain information like ladders and platforms.""" 69 | def __init__(self): 70 | """ 71 | Difference between self.platforms and self.oneway_platforms: platforms can be a destination and an origin. 72 | However, oneway_platforms can only be an origin. oneway_platforms can be used to detect when player goes out of bounds. 73 | self.platforms: list of tuples, where tuple[0] is starting coordinate of platform, tuple[1] is end coordinate. 74 | 75 | """ 76 | self.platforms = {} # Format: hash, Platform() 77 | self.oneway_platforms = {} 78 | self.ladders = [] 79 | self.visited_coordinates = [] 80 | self.current_platform_coords = [] 81 | self.current_oneway_coords = [] 82 | self.current_ladder_coords = [] 83 | self.last_x = None 84 | self.last_y = None 85 | self.movement = None 86 | 87 | self.platform_variance = 3 # If current pixel isn't in any pixel, if current x pixel within +- variance, include in platform 88 | self.ladder_variance = 2 89 | self.minimum_platform_length = 10 # Minimum x length of coordinates to be logged as a platform by input() 90 | self.minimum_ladder_length = 5 # Minimum y length of coordinated to be logged as a ladder by input() 91 | 92 | # below constants are used for generating solution graphs 93 | self.dbljump_max_height = 31 # total absolute jump height is about 31, but take account platform size 94 | self.jump_range = 16 # horizontal jump distance is about 9~10 EDIT:now using glide jump which has more range 95 | self.dbljump_half_height = 20 # absolute jump height of a half jump. Used for generating solution graph 96 | 97 | # below constants are used for path related algorithms. 98 | self.subplatform_length = 2 # length of subdivided platform 99 | 100 | self.astar_map_grid = [] # maap grid representation for a star graph search. reinitialized on every call 101 | self.astar_open_val_grid = [] # 2d array to keep track of "open" values in a star search. 102 | self.astar_minimap_rect = [] # minimap rect (x,y,w,h) for use in generating astar data 103 | 104 | def save(self, filename="mapdata.platform", minimap_roi = None): 105 | """Save platforms, oneway_platforms, ladders, minimap_roi to a file 106 | :param filename: path to save file 107 | :param minimap_roi: tuple or list of onscreen minimap bounding box coordinates which will be saved""" 108 | with open(filename, "wb") as f: 109 | pickle.dump({"platforms" : self.platforms, "oneway": self.oneway_platforms, "minimap" : minimap_roi}, f) 110 | 111 | def load(self, filename="mapdata.platform"): 112 | """Open a map data file and load data from file. Also sets class variables platform, oneway_platform, and minimap. 113 | :param filename: Plath to map data file 114 | :return boundingRect tuple of minimap as stored on file (defaults to (x, y, w, h) if file is valid else 0""" 115 | if not self.verify_data_file(filename): 116 | return 0 117 | else: 118 | with open(filename, "rb") as f: 119 | data = pickle.load(f) 120 | self.platforms = data["platforms"] 121 | self.oneway_platforms = data["oneway"] 122 | minimap_coords = data["minimap"] 123 | self.astar_minimap_rect = minimap_coords 124 | 125 | self.generate_solution_dict() 126 | self.astar_map_grid = [] 127 | self.astar_open_val_grid = [] 128 | map_width, map_height = self.astar_minimap_rect[2], self.astar_minimap_rect[3] 129 | 130 | # Reinitialize map grid data 131 | for height in range(map_height + 1): 132 | self.astar_map_grid.append([0 for x in range(map_width + 1)]) 133 | self.astar_open_val_grid.append([0 for x in range(map_width + 1)]) 134 | for key, platform in self.platforms.items(): 135 | # currently this only uses the platform's start x and y coords and traces them until end x coords. 136 | for platform_coord in range(platform.start_x, platform.end_x + 1): 137 | self.astar_map_grid[platform.start_y][platform_coord] = 1 138 | return minimap_coords 139 | 140 | def verify_data_file(self, filename): 141 | """ 142 | Verify a platform file to see if it is in correct format 143 | :param filename: file path 144 | :return: minimap coords if valid, 0 if corrupt or errored 145 | """ 146 | if os.path.exists(filename): 147 | with open(filename, "rb") as f: 148 | try: 149 | data = pickle.load(f) 150 | platforms = data["platforms"] 151 | oneway_platforms = data["oneway"] 152 | minimap_coords = data["minimap"] 153 | except: 154 | return 0 155 | return minimap_coords 156 | else: 157 | return 0 158 | 159 | 160 | def hash(self, data): 161 | """ 162 | Returns salted md5 hash of data 163 | :param data: String to be hashed 164 | :return: hexdigest string MD5 hash 165 | """ 166 | d_hash = hashlib.md5() 167 | d_hash.update((str(data) + str(random.random())).encode()) 168 | return str(d_hash.hexdigest())[:8] 169 | 170 | def pathfind(self, start_hash, goal_hash): 171 | """ 172 | Simple BFS algorithm to find a path from start platform to goal platform. 173 | :param start_hash: hash of starting platform 174 | :param goal_hash: hash of goal platform 175 | :return: list, in order of solutions to reach goal, 0 if no path 176 | """ 177 | 178 | try: 179 | start_platform = self.platforms[start_hash] 180 | except KeyError: 181 | start_platform = self.oneway_platforms[start_hash] 182 | max_steps = len(self.platforms) + len(self.oneway_platforms) + 2 183 | calculated_paths = [] 184 | bfs_queue = [] 185 | visited_platform_hashes = [] 186 | for solution in start_platform.solutions: 187 | if solution.to_hash not in visited_platform_hashes: 188 | bfs_queue.append([solution, [solution]]) 189 | 190 | while bfs_queue: 191 | current_solution, paths = bfs_queue.pop() 192 | visited_platform_hashes.append(current_solution.from_hash) 193 | if current_solution.to_hash == goal_hash: 194 | calculated_paths.append(paths) 195 | break 196 | 197 | try: 198 | next_solution = self.platforms[current_solution.to_hash].solutions 199 | except KeyError: 200 | next_solution = self.oneway_platforms[current_solution.to_hash].solutions 201 | for solution in next_solution: 202 | if solution.to_hash not in visited_platform_hashes: 203 | cv = paths 204 | cv.append(solution) 205 | bfs_queue.append([solution, cv]) 206 | 207 | if calculated_paths: 208 | return sorted(calculated_paths, key=lambda x: len(x))[0] 209 | else: 210 | return 0 211 | 212 | 213 | def generate_solution_dict(self): 214 | """Generates a solution dictionary, which is a dictionary with platform as keys and a dictionary of a list[strategy, 0] 215 | This function is now called automatically within load()""" 216 | for key, platform in self.platforms.items(): 217 | platform.last_visit = 0 218 | self.calculate_interplatform_solutions(key) 219 | for key, platform in self.oneway_platforms.items(): 220 | self.calculate_interplatform_solutions(key, oneway=True) 221 | 222 | def move_platform(self, from_platform, to_platform): 223 | """Update navigation map visit counter to keep track of visited platforms when moded 224 | :param from_platform: departing platform hash 225 | :param to_platform: destination platform hash""" 226 | 227 | need_reset = True 228 | try: 229 | for method in self.platforms[from_platform].solutions: 230 | solution = method 231 | if solution.to_hash == to_platform: 232 | self.platforms[solution.to_hash].last_visit = 0 233 | method.visited = True 234 | 235 | else: 236 | if not method.visited: 237 | need_reset = False 238 | except: 239 | need_reset = False 240 | pass 241 | 242 | for key, platform in self.platforms.items(): 243 | if key != to_platform and key != from_platform: 244 | self.platforms[key].last_visit += 1 245 | if need_reset: 246 | for method in self.platforms[from_platform].solutions: 247 | method.visited = False 248 | 249 | 250 | 251 | def select_move(self, current_platform): 252 | """ 253 | Selects a solution from current_platform using PlatformScan 254 | :param current_platform: hash of departing platform 255 | :return: solution list in solution array of current_playform 256 | """ 257 | try: 258 | for solution in sorted(self.platforms[current_platform].solutions, key= lambda x: self.platforms[x.to_hash].last_visit, reverse=True): 259 | """if not solution.visited: 260 | return solution""" 261 | return solution 262 | except KeyError: 263 | for solution in sorted(self.oneway_platforms[current_platform].solutions, key= lambda x: self.platforms[x.to_hash].last_visit, reverse=True): 264 | """if not solution.visited: 265 | return solution""" 266 | return solution 267 | 268 | def input_oneway_platform(self, inp_x, inp_y): 269 | """input values to use in finding one way(platforms which can't be a destination platform) 270 | Refer to input() to see how it works 271 | :param inp_x: x coordinate to log 272 | :param inp_y: y coordinate to log""" 273 | converted_tuple = (inp_x, inp_y) 274 | if converted_tuple not in self.visited_coordinates: 275 | self.visited_coordinates.append(converted_tuple) 276 | 277 | # check if in continous platform 278 | if inp_y >= self.last_y-2 and inp_y <= self.last_y+2 and self.last_x >= self.last_x - self.platform_variance and self.last_x <= self.last_x + self.platform_variance: 279 | # check if current coordinate is within platform being tracked 280 | if converted_tuple not in self.current_oneway_coords: 281 | self.current_oneway_coords.append(converted_tuple) 282 | else: 283 | # current coordinates do not belong in any platforms 284 | # terminate pending platform, if exists and create new pending platform 285 | if len(self.current_oneway_coords) >= self.minimum_platform_length-1: 286 | platform_start = min(self.current_oneway_coords, key=lambda x: x[0]) 287 | platform_end = max(self.current_oneway_coords, key=lambda x: x[0]) 288 | d_hash = self.hash(str(platform_start)) 289 | self.oneway_platforms[d_hash] = Platform(platform_start[0], platform_start[1], platform_end[0], 290 | platform_end[1], 0, [], d_hash) 291 | self.current_oneway_coords = [] 292 | if converted_tuple not in self.visited_coordinates: 293 | self.current_oneway_coords.append(converted_tuple) 294 | 295 | def flush_input_coords_to_platform(self, coord_list=None): 296 | if coord_list: 297 | self.current_platform_coords = coord_list 298 | if self.current_platform_coords: 299 | platform_start = min(self.current_platform_coords, key=lambda x: x[0]) 300 | platform_end = max(self.current_platform_coords, key=lambda x: x[0]) 301 | 302 | d_hash = self.hash(str(platform_start)) 303 | self.platforms[d_hash] = Platform(platform_start[0], platform_start[1], platform_end[0], platform_end[1], 0, 304 | [], d_hash) 305 | self.current_platform_coords = [] 306 | 307 | def flush_input_coords_to_oneway(self, coord_list=None): 308 | if coord_list: 309 | self.current_oneway_coords = coord_list 310 | if self.current_oneway_coords: 311 | platform_start = min(self.current_oneway_coords, key=lambda x: x[0]) 312 | platform_end = max(self.current_oneway_coords, key=lambda x: x[0]) 313 | 314 | d_hash = self.hash(str(platform_start)) 315 | self.oneway_platforms[d_hash] = Platform(platform_start[0], platform_start[1], platform_end[0], platform_end[1], 0, 316 | [], d_hash) 317 | self.current_oneway_coords_coords = [] 318 | 319 | def input(self, inp_x, inp_y): 320 | """Use player minimap coordinates to determine start and end of platforms 321 | This function logs player minimap marker coordinates in an attempt to identify platform coordinates from them. 322 | Player coordinates are temoporarily logged to self.current_platform_coords until a platform is determined for 323 | the given set of coordinates. 324 | Given that all platforms are parallel to the ground, meaning all coordinates of the platform are on the same 325 | elevation, a collection of input player coordinates are deemed to be on a same platform until a change in y 326 | coordinates is detected. 327 | :param inp_x: x player minimap coordinate to log 328 | :param inp_y: y player minimap coordinate to log""" 329 | converted_tuple = (inp_x, inp_y) 330 | if converted_tuple not in self.visited_coordinates: 331 | self.visited_coordinates.append(converted_tuple) 332 | 333 | # check if in continous platform 334 | if inp_y == self.last_y and self.last_x >= self.last_x - self.platform_variance and self.last_x <= self.last_x + self.platform_variance: 335 | # check if current coordinate is within platform being tracked 336 | if converted_tuple not in self.current_platform_coords: 337 | self.current_platform_coords.append(converted_tuple) 338 | else: 339 | # current coordinates do not belong in any platforms 340 | # terminate pending platform, if exists and create new pending platform 341 | if len(self.current_platform_coords) >= self.minimum_platform_length: 342 | platform_start = min(self.current_platform_coords, key=lambda x: x[0]) 343 | platform_end = max(self.current_platform_coords, key=lambda x: x[0]) 344 | 345 | d_hash = self.hash(str(platform_start)) 346 | self.platforms[d_hash] = Platform(platform_start[0], platform_start[1], platform_end[0], platform_end[1], 0, [], d_hash) 347 | 348 | self.current_platform_coords = [] 349 | if converted_tuple not in self.visited_coordinates: 350 | self.current_platform_coords.append(converted_tuple) 351 | 352 | # check if in continous ladder 353 | if inp_x == self.last_x and inp_y >= self.last_y - self.ladder_variance and inp_y <= self.last_y + self.ladder_variance: 354 | # current coordinate is within pending group of coordinates for a ladder or a rope or whatever 355 | if converted_tuple not in self.current_ladder_coords: 356 | self.current_ladder_coords.append(converted_tuple) 357 | else: 358 | # current coordinates do not belong in any ladders or ropes 359 | # terminate ladder or ropes 360 | if len(self.current_ladder_coords) >= self.minimum_ladder_length: 361 | ladder_start = min(self.current_ladder_coords, key=lambda x: x[1]) 362 | ladder_end = max(self.current_ladder_coords, key=lambda x: x[1]) 363 | self.ladders.append((ladder_start, ladder_end)) 364 | self.current_ladder_coords = [] 365 | if converted_tuple not in self.visited_coordinates: 366 | self.current_ladder_coords.append(converted_tuple) 367 | self.last_x = inp_x 368 | self.last_y = inp_y 369 | 370 | def calculate_interplatform_solutions(self, hash, oneway=False): 371 | """Find relationships between platform, like how one platform links to another using movement. 372 | :param platform : platform hash in self.platforms Platform 373 | :return : None 374 | destination_platform : platform object in self.platforms which is the destination 375 | x, y : coordinate area where the method can be used (x1<=coord_x<=x2, y1<=coord_y<=y2) 376 | method : movement method string 377 | drop : drop down directly 378 | jmpr : right jump 379 | jmpl : left jump 380 | dbljmp_max : double jump up fully 381 | dbljmp_half : double jump a bit less 382 | """ 383 | 384 | return_map_dict = [] 385 | if oneway: 386 | platform = self.oneway_platforms[hash] 387 | else: 388 | platform = self.platforms[hash] 389 | platform.solutions = [] 390 | for key, other_platform in self.platforms.items(): 391 | if platform.hash != key: 392 | # 1. Detect vertical overlaps 393 | if platform.start_x < other_platform.end_x and platform.end_x > other_platform.start_x or \ 394 | platform.start_x > other_platform.start_x and platform.start_x < other_platform.end_x: 395 | lower_bound_x = max(platform.start_x, other_platform.start_x) 396 | upper_bound_x = min(platform.end_x, other_platform.end_x) 397 | if platform.start_y < other_platform.end_y: 398 | # Platform is higher than current_platform. Thus we can just drop 399 | #solution = {"hash":key, "lower_bound":(lower_bound_x, platform.start_y), "upper_bound":(upper_bound_x, platform.start_y), "method":"drop", "visited" : False} 400 | solution = Solution(platform.hash, key, (lower_bound_x, platform.start_y), (upper_bound_x, platform.start_y), METHOD_DROP, False) 401 | # Changed to using classes for readability 402 | platform.solutions.append(solution) 403 | else: 404 | # We need to use double jump to get there, but first check if within jump height 405 | if abs(platform.start_y - other_platform.start_y) <= self.dbljump_half_height: 406 | #solution = {"hash":key, "lower_bound":(lower_bound_x, platform.start_y), "upper_bound":(upper_bound_x, platform.start_y), "method":"dbljmp_half", "visited" : False} 407 | solution = Solution(platform.hash, key, (lower_bound_x, platform.start_y), (upper_bound_x, platform.start_y), METHOD_DBLJMP_HALF, False) 408 | platform.solutions.append(solution) 409 | elif abs(platform.start_y - other_platform.start_y) <= self.dbljump_max_height: 410 | #solution = {"hash": key, "lower_bound": (lower_bound_x, platform.start_y),"upper_bound": (upper_bound_x, platform.start_y), "method": "dbljmp_max", "visited" : False} 411 | solution = Solution(platform.hash, key, (lower_bound_x, platform.start_y), (upper_bound_x, platform.start_y), METHOD_DBLJMP_MAX, False) 412 | platform.solutions.append(solution) 413 | else: 414 | # 2. No vertical overlaps. Calculate euclidean distance between each platform endpoints 415 | front_point_distance = math.sqrt((platform.start_x-other_platform.end_x)**2 + (platform.start_y-other_platform.end_y)**2) 416 | if front_point_distance <= self.jump_range: 417 | # We can jump from the left end of the platform to goal 418 | #solution = {"hash":key, "lower_bound":(platform.start_x, platform.start_y), "upper_bound":(platform.start_x, platform.start_y), "method":"jmpl", "visited" : False} 419 | solution = Solution(platform.hash, key, (platform.start_x, platform.start_y), (platform.start_x, platform.start_y), METHOD_JUMPL, False) 420 | platform.solutions.append(solution) 421 | 422 | back_point_distance = math.sqrt((platform.end_x-other_platform.start_x)**2 + (platform.end_y-other_platform.start_y)**2) 423 | if back_point_distance <= self.jump_range: 424 | # We can jump fomr the right end of the platform to goal platform 425 | #solution = {"hash":key, "lower_bound":(platform.end_x, platform.end_y), "upper_bound":(platform.end_x, platform.end_y), "method":"jmpr", "visited" : False} 426 | solution = Solution(platform.hash, key, (platform.end_x, platform.end_y), (platform.end_x, platform.end_y), METHOD_JUMPR, False) 427 | platform.solutions.append(solution) 428 | 429 | def astar_pathfind(self, start_coord, goal_coords): 430 | """ 431 | Uses A* pathfinding to calculate a action map from start coord to goal. 432 | :param start_coord: start coordinate tuple for generating path 433 | :param goal_coords: goal coordinate 434 | :return: list of action tuple (g, a) where g is action goal coordinate tuple, a an action METHOD 435 | """ 436 | self.astar_map_grid = [] 437 | self.astar_open_val_grid = [] 438 | map_width, map_height = self.astar_minimap_rect[2], self.astar_minimap_rect[3] 439 | 440 | # Reinitialize map grid data 441 | for height in range(map_height+1): 442 | self.astar_map_grid.append([0 for x in range(map_width+1)]) 443 | self.astar_open_val_grid.append([0 for x in range(map_width+1)]) 444 | 445 | for key, platform in self.platforms.items(): 446 | # currently this only uses the platform's start x and y coords and traces them until end x coords. 447 | for platform_coord in range(platform.start_x, platform.end_x + 1): 448 | self.astar_map_grid[platform.start_y][platform_coord] = 1 449 | 450 | open_list = set() 451 | closed_set = set() 452 | open_set = set() 453 | open_list.add(AstarNode(start_coord[0], start_coord[1], g=0, h=0)) 454 | open_set.add(start_coord) 455 | 456 | while open_list: 457 | selection = min(open_list, key=lambda x: x.g + x.h) 458 | 459 | if selection.x == goal_coords[0] and selection.y == goal_coords[1]: 460 | return self.astar_optimize_path(selection.path) 461 | 462 | open_list.remove(selection) 463 | open_set.remove((selection.x, selection.y)) 464 | closed_set.add((selection.x, selection.y)) 465 | for coordinate, method in self.astar_find_available_moves(selection.x, selection.y, goal_coords): 466 | if coordinate in closed_set: 467 | continue 468 | successor_g = selection.g + self.astar_g(selection.x, selection.y, coordinate[0], coordinate[1], method) 469 | successor_h = self.astar_h(coordinate[0], coordinate[1], goal_coords[0], goal_coords[1]) 470 | successor_path = selection.path + [(coordinate, method)] 471 | if coordinate in open_set: 472 | if self.astar_open_val_grid[coordinate[1]][coordinate[0]] < successor_g: 473 | continue 474 | 475 | successor_node = AstarNode(coordinate[0], coordinate[1], g=selection.g + successor_g, h=successor_h, path=successor_path) 476 | open_list.add(successor_node) 477 | open_set.add(coordinate) 478 | if self.astar_open_val_grid[coordinate[1]][coordinate[0]] > successor_g: 479 | self.astar_open_val_grid[coordinate[1]][coordinate[0]] = successor_g 480 | 481 | def astar_optimize_path(self, path): 482 | """ 483 | Optimizes astar generated paths. This will take horizontal movement methods and combine them into one if on 484 | the same height. 485 | :param path: A* path list 486 | :return: optimized A* path list 487 | """ 488 | print("input") 489 | print(path) 490 | new_path = [] 491 | current_index = 0 492 | while current_index <= len(path)-1: 493 | c_coords, c_method = path[current_index] 494 | if c_method == METHOD_MOVEL or c_method == METHOD_MOVER: 495 | increment = 0 # current_index + increment of intem we are sure needs to be optimized 496 | while current_index+increment < len(path)-1: 497 | n_coords, n_method = path[current_index+increment+1] 498 | if n_method == c_method and n_coords[1] == c_coords[1]: 499 | increment += 1 500 | else: 501 | new_path.append(path[current_index+increment]) 502 | break 503 | current_index += increment+1 504 | else: 505 | new_path.append(path[current_index]) 506 | current_index += 1 507 | 508 | print("output") 509 | print(new_path) 510 | return new_path 511 | 512 | def astar_g(self, current_x, current_y, goal_x, goal_y, method): 513 | """ 514 | generates A* g value 515 | :param current_x: x coordinate of current position 516 | :param current_y: y corodinate of current position 517 | :param goal_x: x coordinate of goal position 518 | :param goal_y: y coordinate of goal position 519 | :param method: find available moves method string 520 | :return: g value 521 | """ 522 | if current_y == goal_y: 523 | return abs(current_x-goal_x) 524 | else: 525 | if current_y < goal_y: 526 | if method == METHOD_DROP: 527 | return abs(current_y - goal_y) * 0.8 528 | if method == "horjmp": 529 | return abs(current_y - goal_y) * 5 530 | return abs(current_y-goal_y)* 1.5 531 | elif current_y > goal_y: 532 | if method == "horjmp": 533 | return abs(current_y - goal_y) * 5 534 | return abs(current_y-goal_y)* 1.2 535 | 536 | 537 | def astar_h(self, x1, y1, x2, y2): 538 | return math.sqrt((x1-x2)**2 + (y1-y2)**2) 539 | #return abs(x1-x2) + abs(y1-y2) 540 | 541 | def astar_find_available_moves(self, x, y, goal_coordinate): 542 | """ 543 | Finds all the pixels which can be reached from (x,y). Methods include horizontal movement, jump and dropping 544 | :param x: x coord 545 | :param y: y coord 546 | :param goal_coordinate: goal coordinate tuple 547 | :return: list of tuples (coord, method) where coord is a coordinate tuple, method 548 | """ 549 | map_width, map_height = self.astar_minimap_rect[2], self.astar_minimap_rect[3] 550 | return_list = [] 551 | # check horizontally touching pixels. 552 | for x_increment in [1, -1]: 553 | contiunue_check = True 554 | while contiunue_check: 555 | if x + x_increment == 0: 556 | return_list.append(((x + x_increment - 1, y), METHOD_MOVER if x_increment > 0 else "l")) 557 | break 558 | if (x + x_increment, y) == goal_coordinate: 559 | return_list.append(((x + x_increment, y), METHOD_MOVER if x_increment > 0 else METHOD_MOVEL)) 560 | break 561 | if x+x_increment > map_width: 562 | return_list.append(((x + x_increment, y), METHOD_MOVER if x_increment > 0 else METHOD_MOVEL)) 563 | break 564 | if self.astar_map_grid[y][x + x_increment] == 1: 565 | drop_distance = 1 566 | while y+drop_distance <= map_height: 567 | if self.astar_map_grid[y + drop_distance][x] == 1: 568 | return_list.append(((x + x_increment, y), METHOD_MOVER if x_increment > 0 else METHOD_MOVEL)) 569 | contiunue_check = False 570 | break 571 | drop_distance += 1 572 | 573 | for jmpheight in range(1, self.dbljump_max_height + 1): 574 | if y - jmpheight <= 0: 575 | break 576 | if self.astar_map_grid[y - jmpheight][x + x_increment] == 1: 577 | return_list.append(((x + x_increment, y), METHOD_MOVER if x_increment > 0 else METHOD_MOVEL)) 578 | contiunue_check = False 579 | break 580 | else: 581 | if x_increment != 1: 582 | return_list.append(((x + x_increment, y), METHOD_MOVER if x_increment > 0 else METHOD_MOVEL)) 583 | break 584 | 585 | if x_increment < 0: 586 | x_increment -= 1 587 | else: 588 | x_increment += 1 589 | 590 | for jmpheight in range(1, self.dbljump_max_height): 591 | if y - jmpheight == 0: 592 | break 593 | if self.astar_map_grid[y - jmpheight][x] == 1: 594 | return_list.append(((x, y - jmpheight), METHOD_DBLJMP)) 595 | 596 | drop_distance = 1 597 | while True: 598 | if y + drop_distance > map_height: 599 | break 600 | if self.astar_map_grid[y + drop_distance][x] == 1: 601 | return_list.append(((x, y + drop_distance), METHOD_DROP)) 602 | break 603 | 604 | drop_distance += 1 605 | 606 | # check if horizontal doublejump leads us to another platform 607 | jump_height = 6 608 | 609 | return return_list 610 | 611 | def astar_jump_double_curve(self, start_x, start_y, current_x): 612 | """ 613 | Calculates the height at horizontal double jump starting from(start_x, start_y) at x coord current_x 614 | :param start_x: start x coord 615 | :param start_y: start y coord 616 | :param current_x: x of coordinate to calculate height 617 | :return: height at current_x 618 | """ 619 | slope = 0.05 620 | x_jump_range = 10 if current_x > start_x else -10 621 | y_jump_height = 1.4 622 | max_coord_x = (start_x*2 + x_jump_range)/2 623 | max_coord_y = start_y - y_jump_height 624 | if max_coord_y <= 0: 625 | return 0 626 | y = slope * (current_x - max_coord_x) ** 2 + max_coord_y 627 | return max(0, y) 628 | 629 | def calculate_vertical_doublejump_delay(self, y1, y2): 630 | """ 631 | Calcuates the delay needed to double jump from height y1 to y1 632 | :param y1: y coord1 633 | :param y2: y coord2 634 | :return: float delay in second(s) needed 635 | """ 636 | height_delta = abs(y1-y2) 637 | 638 | t = round(-(height_delta-41.5)/76, 2) 639 | if t < 0.15: 640 | return 0.15 641 | elif t > 0.45: 642 | return 0.45 643 | else: 644 | return t 645 | 646 | def reset(self): 647 | """ 648 | Reset all platform data to default 649 | :return: None 650 | """ 651 | self.platforms = {} 652 | self.oneway_platforms = {} 653 | self.visited_coordinates = [] 654 | self.current_platform_coords = [] 655 | self.current_ladder_coords = [] 656 | self.ladders = [] 657 | 658 | -------------------------------------------------------------------------------- /tests/IMPORTANT PLEASE READ ME.txt: -------------------------------------------------------------------------------- 1 | problem with key input: 2 | sometimes the ALT key used for jumping/dropping gets stuck or not released and causes the macro to get st uck 3 | CHECK ALL PARTS WHICH USE ALT KEYS!!! -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dashadower/MS-Visionify/a7e48c71005a577443d998fb1e8c0f4cbdb03ba7/tests/__init__.py -------------------------------------------------------------------------------- /tests/non-unittests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dashadower/MS-Visionify/a7e48c71005a577443d998fb1e8c0f4cbdb03ba7/tests/non-unittests/__init__.py -------------------------------------------------------------------------------- /tests/non-unittests/dbg_keyboardlistner.py: -------------------------------------------------------------------------------- 1 | import pythoncom, pyHook 2 | 3 | def OnKeyboardEvent(event): 4 | print('MessageName:',event.MessageName) 5 | print('Message:',event.Message) 6 | print('Time:',event.Time) 7 | print('Window:',event.Window) 8 | print('WindowName:',event.WindowName) 9 | #print('Ascii:', event.Ascii, chr(event.Ascii)) 10 | print('Key:', event.Key) 11 | print('KeyID:', event.KeyID) 12 | print('ScanCode:', event.ScanCode) 13 | print('Extended:', event.Extended) 14 | print('Injected:', event.Injected) 15 | print('Alt', event.Alt) 16 | print('Transition', event.Transition) 17 | print('---') 18 | 19 | # return True to pass the event to other handlers 20 | return True 21 | 22 | # create a hook manager 23 | hm = pyHook.HookManager() 24 | # watch for all mouse events 25 | hm.KeyDown = OnKeyboardEvent 26 | # set the hook 27 | hm.HookKeyboard() 28 | # wait forever 29 | pythoncom.PumpMessages() 30 | -------------------------------------------------------------------------------- /tests/non-unittests/doublejump_time_height.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt, time 2 | from src.screen_processor import MapleScreenCapturer, StaticImageProcessor 3 | from src.directinput_constants import DIK_ALT, DIK_UP 4 | from src.keystate_manager import KeyboardInputManager 5 | 6 | cap = MapleScreenCapturer() 7 | scrp = StaticImageProcessor(cap) 8 | scrp.update_image(set_focus=True) 9 | rect = scrp.get_minimap_rect() 10 | 11 | inp = KeyboardInputManager() 12 | 13 | start_delay = 0.5 14 | increment = -0.05 15 | plot_data = [] 16 | while start_delay > 0: 17 | scrp.update_image(set_focus=False) 18 | current_y = scrp.find_player_minimap_marker(rect)[1] 19 | start_time = time.time() 20 | inp.single_press(DIK_ALT) 21 | time.sleep(start_delay) 22 | inp.single_press(DIK_UP) 23 | time.sleep(0.01) 24 | inp.single_press(DIK_UP) 25 | y_list = [] 26 | while abs(time.time() - start_time) < 3: 27 | scrp.update_image(set_focus=False) 28 | y_list.append(scrp.find_player_minimap_marker(rect)[1]) 29 | plot_data.append((round(start_delay, 2), abs(current_y-min(y_list)))) 30 | print("Delay: %f distance: %d"%(round(start_delay, 2), abs(current_y-min(y_list)))) 31 | time.sleep(2) 32 | start_delay += increment 33 | 34 | print(plot_data) 35 | x,y = zip(*plot_data) 36 | plt.scatter(x,y) 37 | plt.show() -------------------------------------------------------------------------------- /tests/non-unittests/maplestory_screen_viewer.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | from screen_processor import MapleScreenCapturer, StaticImageProcessor 3 | import cv2, imutils, time 4 | import numpy as np 5 | cap = MapleScreenCapturer() 6 | ct = StaticImageProcessor(cap) 7 | from win32gui import SetForegroundWindow 8 | while True: 9 | img = cap.capture(set_focus=False) 10 | img_arr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) 11 | img_arr = img_arr[ct.default_minimap_scan_area[1]:ct.default_minimap_scan_area[3], ct.default_minimap_scan_area[0]:ct.default_minimap_scan_area[2]] 12 | #grayscale = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2GRAY) 13 | #blurred = cv2.GaussianBlur(grayscale, (7,7), 5) 14 | #eroded = cv2.erode(blurred, (7,7)) 15 | #dilated = cv2.dilate(eroded, (7,7)) 16 | #canny = cv2.Canny(eroded, threshold1=210, threshold2=255) 17 | #canny = imutils.resize(dilated, width = 500) 18 | final_img = img_arr 19 | 20 | cv2.imshow("", imutils.resize(final_img, width=400)) 21 | 22 | inp = cv2.waitKey(1) 23 | if inp == ord("q"): 24 | cv2.destroyAllWindows() 25 | break 26 | elif inp == ord("c"): 27 | SetForegroundWindow(cap.ms_get_screen_hwnd()) 28 | time.sleep(1) 29 | ds = cap.capture(set_focus=False) 30 | ds = cv2.cvtColor(np.array(ds), cv2.COLOR_RGB2BGR) 31 | 32 | cv2.imwrite("output.png", ds) -------------------------------------------------------------------------------- /tests/non-unittests/minimap color test.py: -------------------------------------------------------------------------------- 1 | from src.screen_processor import MapleScreenCapturer, StaticImageProcessor 2 | import cv2, numpy as np 3 | mcap = MapleScreenCapturer() 4 | 5 | scrp = StaticImageProcessor(mcap) 6 | player_marker = np.array([68, 221, 255]) 7 | scrp.update_image() 8 | area = scrp.get_minimap_rect() 9 | print(area) 10 | while True: 11 | scrp.update_image(set_focus=False) 12 | cropped = scrp.bgr_img[area[1]:area[1] + area[3], area[0]:area[0] + area[2]] 13 | 14 | mask = cv2.inRange(cropped, player_marker, player_marker) 15 | td = np.transpose(np.where(mask > 0)).tolist() 16 | print(len(td)) 17 | """if len(td) > 0: 18 | avg_x = 0 19 | avg_y = 0 20 | totalpoints = 0 21 | for coord in td: 22 | avg_y += coord[0] 23 | avg_x += coord[1] 24 | totalpoints += 1 25 | avg_y = int(avg_y / totalpoints) 26 | avg_x = int(avg_x / totalpoints) 27 | return avg_x, avg_y""" 28 | cv2.imshow("", mask) 29 | dt = cv2.waitKey(1) 30 | if dt == ord("q"): 31 | cv2.destroyAllWindows() 32 | break -------------------------------------------------------------------------------- /tests/non-unittests/src.keystate_manager.KeyboardInputManager.py: -------------------------------------------------------------------------------- 1 | from src.keystate_manager import KeyboardInputManager 2 | from src.directinput_constants import * 3 | import time 4 | time.sleep(2) 5 | mgr = KeyboardInputManager() 6 | mgr.single_press(DIK_D) 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/non-unittests/src.macro_script.py: -------------------------------------------------------------------------------- 1 | import src.macro_script, time 2 | time.sleep(2) 3 | macro = src.macro_script.MacroController() 4 | macro.terrain_analyzer.load("본색을 드러내는 곳 2.platform") 5 | macro.terrain_analyzer.generate_solution_dict() 6 | while True: 7 | data = macro.loop() 8 | print(data) 9 | -------------------------------------------------------------------------------- /tests/non-unittests/src.monster_detector.py: -------------------------------------------------------------------------------- 1 | from src.screen_processor import MapleScreenCapturer 2 | import cv2, imutils, os 3 | from src.monster_detector import MonsterTemplateDetector 4 | from src.player_medal_detector import PlayerMedalDetector 5 | import numpy as np 6 | os.chdir("../src") 7 | wincap = MapleScreenCapturer() 8 | detector = MonsterTemplateDetector("img/ArcaneRiver/ChewChew/츄츄 아일랜드.json") 9 | playerdetector = PlayerMedalDetector() 10 | detector.create_template("mob1.png") 11 | playerdetector.create_template("medal1.png") 12 | capture_width = 700 13 | capture_height = 200 14 | while True: 15 | img = wincap.capture(set_focus=False) 16 | grayscale = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2GRAY) 17 | 18 | playerloc = playerdetector.find(grayscale) 19 | detected = [] 20 | if playerloc: 21 | bbox = grayscale[max((playerloc[1] - int(capture_height / 2)), 0):max((playerloc[1] + int(capture_height / 2)), 0), 22 | (playerloc[0] - int(capture_width / 2)):(playerloc[0] + int(capture_width / 2))] 23 | cv2.rectangle(grayscale, (playerloc[0] - int(capture_width / 2), playerloc[1] - int(capture_height / 2)), (playerloc[0] + int(capture_width / 2), playerloc[1] + int(capture_height / 2)),(0,0,255), 3) 24 | detected = detector.find(grayscale) 25 | cv2.circle(grayscale, playerloc, 15, (0,0,255), -1) 26 | 27 | if detected: 28 | for point in detected: 29 | cv2.circle(grayscale, (playerloc[0] - int(capture_width / 2)+point[0], playerloc[1] - int(capture_height / 2)+point[1]), 20, (0,0,255), -1) 30 | 31 | cv2.imshow("", imutils.resize(grayscale, width=500)) 32 | inp = cv2.waitKey(1) 33 | if inp == ord('q'): 34 | cv2.destroyAllWindows() 35 | break 36 | -------------------------------------------------------------------------------- /tests/non-unittests/src.player_controller.py: -------------------------------------------------------------------------------- 1 | from src.player_controller import PlayerController 2 | from src.keystate_manager import KeyboardInputManager 3 | import time 4 | from src.screen_processor import MapleScreenCapturer 5 | from src.screen_processor import StaticImageProcessor 6 | import directinput_constants 7 | from win32gui import SetForegroundWindow 8 | 9 | wcap = MapleScreenCapturer() 10 | scrp = StaticImageProcessor(wcap) 11 | hwnd = wcap.ms_get_screen_hwnd() 12 | kbd_mgr = KeyboardInputManager() 13 | player_cntrlr = PlayerController(kbd_mgr, scrp) 14 | 15 | SetForegroundWindow(hwnd) 16 | time.sleep(0.5) 17 | scrp.update_image() 18 | print(scrp.get_minimap_rect()) 19 | print(scrp.find_player_minimap_marker()) 20 | player_cntrlr.update() 21 | 22 | print(player_cntrlr.x, player_cntrlr.y) 23 | 24 | #player_cntrlr.moonlight_slash_sweep_move(player_cntrlr.x + 100) 25 | #player_cntrlr.optimized_horizontal_move(player_cntrlr.x + 1) 26 | player_cntrlr.dbljump_timed(1) 27 | #player_cntrlr.jumpr_glide() 28 | #player_cntrlr.dbljump_half() 29 | print(player_cntrlr.x, player_cntrlr.y) 30 | 31 | -------------------------------------------------------------------------------- /tests/non-unittests/src.screen_processor.py: -------------------------------------------------------------------------------- 1 | import imutils, cv2, numpy as np, time 2 | from src.screen_processor import MapleScreenCapturer 3 | from src.screen_processor import StaticImageProcessor 4 | 5 | 6 | screencap = MapleScreenCapturer() 7 | scrp = StaticImageProcessor(screencap) 8 | scrp.update_image() 9 | area = scrp.get_minimap_rect() 10 | 11 | 12 | while True: 13 | scrp.update_image(set_focus=False) 14 | st = time.time() 15 | playerpos = scrp.find_player_minimap_marker(area) 16 | et = time.time() 17 | #print("regular", et - st) 18 | 19 | regular_find = scrp.bgr_img[area[1]:area[1] + area[3], area[0]:area[0] + area[2]].copy() 20 | if playerpos: 21 | cv2.circle(regular_find, playerpos, 4, (255, 0, 0), -1) 22 | print(playerpos) 23 | regular_find = imutils.resize(regular_find, width=400) 24 | cv2.imshow("regular vs templ", regular_find) 25 | 26 | #print("regular", playerpos) 27 | #print("templ", playerpos_templ) 28 | 29 | inp = cv2.waitKey(1) 30 | if inp == ord("q"): 31 | cv2.destroyAllWindows() 32 | break 33 | 34 | elif inp == ord("r"): 35 | scrp.reset_minimap_area() 36 | area = scrp.get_minimap_rect() -------------------------------------------------------------------------------- /tests/non-unittests/src.terrain_analyzer_PlatformScan.py: -------------------------------------------------------------------------------- 1 | import sys, os, time 2 | sys.path.append("../src") 3 | sys.path.append("C:\\Users\\tttll\\PycharmProjects\\MacroSTory") 4 | from terrain_analyzer import PathAnalyzer 5 | pathextractor = PathAnalyzer() 6 | pathextractor.load("본색을 드러내는 곳 2.platform") 7 | print(__file__) 8 | print(pathextractor.platforms) 9 | pathextractor.generate_solution_dict() 10 | for key, val in pathextractor.platforms.items(): 11 | print(key) 12 | print(val.solutions) 13 | print(val.last_visit) 14 | print("-------------------") 15 | 16 | start_hash = input("current location?") 17 | 18 | while True: 19 | os.system("cls") 20 | print("result of select_move of start hash:") 21 | optimal = pathextractor.select_move(start_hash) 22 | print(optimal) 23 | print("current location:", start_hash) 24 | print("moving to :", optimal["hash"]) 25 | pathextractor.move_platform(start_hash, optimal["hash"]) 26 | start_hash = optimal["hash"] 27 | print("--------------") 28 | for key, val in pathextractor.platforms.items(): 29 | print(key) 30 | for obj in val.solutions: 31 | print(obj) 32 | print("last visit:", val.last_visit if val.last_visit <= len(pathextractor.platforms) * 2 -1 else "@@@@@@" + str(val.last_visit)) 33 | print("-------------------") 34 | 35 | time.sleep(5) 36 | -------------------------------------------------------------------------------- /tests/non-unittests/src.terrain_analyzer_astar.py: -------------------------------------------------------------------------------- 1 | import sys, os, time 2 | import tkinter as tk 3 | sys.path.append("../src") 4 | sys.path.append("C:\\Users\\tttll\\PycharmProjects\\MacroSTory") 5 | from terrain_analyzer import PathAnalyzer 6 | pathextractor = PathAnalyzer() 7 | 8 | minimap_roi = pathextractor.load("풍화된_기쁨과_분노의_땅.platform") 9 | print(__file__) 10 | print(pathextractor.platforms) 11 | pathextractor.generate_solution_dict() 12 | for key, val in pathextractor.platforms.items(): 13 | print(key, val.start_x, val.start_y, val.end_x, val.end_y) 14 | print("-------------------") 15 | 16 | root = tk.Tk() 17 | cv = tk.Canvas(root) 18 | cv.pack(expand="yes", fill="both") 19 | 20 | tile_size = root.winfo_screenwidth()/minimap_roi[2] 21 | def render(): 22 | cv.delete("all") 23 | for row in range(len(pathextractor.astar_map_grid)): 24 | for column in range(len(pathextractor.astar_map_grid[0])): 25 | if pathextractor.astar_map_grid[row][column] == 1: 26 | cv.create_rectangle(column*tile_size, row*tile_size, (column+1)*tile_size, (row+1)*tile_size, fill="red") 27 | else: 28 | cv.create_rectangle(column*tile_size, row*tile_size, (column+1)*tile_size, (row+1)*tile_size, fill="white") 29 | 30 | 31 | start_coord = (142,27) 32 | end_coord = (80,8) 33 | def onrightclick(event): 34 | global start_coord, end_coord 35 | print("reached") 36 | end_coord = (int(event.x/tile_size), int(event.y/tile_size)) 37 | 38 | render() 39 | cv.create_oval((start_coord[0] - 2) * tile_size, (start_coord[1] - 2) * tile_size, (start_coord[0] + 3) * tile_size, 40 | (start_coord[1] + 3) * tile_size, fill="white") 41 | print("pathfinding", start_coord, end_coord) 42 | s = time.time() 43 | path = pathextractor.astar_pathfind(start_coord, end_coord) 44 | e = time.time() 45 | print("time", e-s) 46 | print(path) 47 | cv.create_rectangle(start_coord[0] * tile_size, start_coord[1] * tile_size, (start_coord[0] + 1) * tile_size, 48 | (start_coord[1] + 1) * tile_size, fill="green") 49 | for index,method in enumerate(path): 50 | sc = method[0] 51 | cv.create_rectangle(sc[0] * tile_size, sc[1] * tile_size, 52 | (sc[0] + 1) * tile_size, 53 | (sc[1] + 1) * tile_size, fill="purple") 54 | if index == 0: 55 | old_coord = start_coord 56 | else: 57 | old_coord = path[index - 1][0] 58 | mtype = method[1] 59 | if mtype == "l" or mtype == "r": 60 | color = "green" 61 | elif mtype == "drop": 62 | color = "blue" 63 | elif mtype == "dbljmp": 64 | color = "yellow" 65 | elif mtype == "horjmp": 66 | color = "black" 67 | cv.create_line((old_coord[0] + 0.5) * tile_size, (old_coord[1] + 0.5) * tile_size, 68 | (sc[0] + 0.5) * tile_size, (sc[1] + 0.5) * tile_size, fill=color, 69 | width=5) 70 | 71 | def onleftclick(event): 72 | global clickmode, start_coord, end_coord 73 | render() 74 | start_coord = (int(event.x/tile_size), int(event.y/tile_size)) 75 | cv.create_rectangle(start_coord[0] * tile_size, start_coord[1] * tile_size, (start_coord[0] + 1) * tile_size, 76 | (start_coord[1] + 1) * tile_size, fill="green") 77 | 78 | cv.create_oval((start_coord[0] - 2) * tile_size, (start_coord[1] - 2) * tile_size, (start_coord[0] + 3) * tile_size, 79 | (start_coord[1] + 3) * tile_size, fill="white") 80 | print("pathfinding", start_coord, end_coord) 81 | s = time.time() 82 | path = pathextractor.astar_pathfind(start_coord, end_coord) 83 | e = time.time() 84 | print("time", e - s) 85 | print(path) 86 | cv.create_rectangle(start_coord[0] * tile_size, start_coord[1] * tile_size, (start_coord[0] + 1) * tile_size, 87 | (start_coord[1] + 1) * tile_size, fill="green") 88 | for index, method in enumerate(path): 89 | sc = method[0] 90 | cv.create_rectangle(sc[0] * tile_size, sc[1] * tile_size, 91 | (sc[0] + 1) * tile_size, 92 | (sc[1] + 1) * tile_size, fill="purple") 93 | if index == 0: 94 | old_coord = start_coord 95 | else: 96 | old_coord = path[index - 1][0] 97 | mtype = method[1] 98 | if mtype == "l" or mtype == "r": 99 | color = "green" 100 | elif mtype == "drop": 101 | color = "blue" 102 | elif mtype == "dbljmp": 103 | color = "yellow" 104 | elif mtype == "horjmp": 105 | color = "black" 106 | cv.create_line((old_coord[0] + 0.5) * tile_size, (old_coord[1] + 0.5) * tile_size, 107 | (sc[0] + 0.5) * tile_size, (sc[1] + 0.5) * tile_size, fill=color, 108 | width=5) 109 | 110 | cv.bind("", onrightclick) 111 | 112 | render() 113 | """def onleftclick(event): 114 | render() 115 | nc = (int(event.x/tile_size), int(event.y/tile_size)) 116 | cv.create_rectangle(nc[0] * tile_size, nc[1] * tile_size, (nc[0] + 1) * tile_size, 117 | (nc[1] + 1) * tile_size, fill="green") 118 | print("Start coord:", nc) 119 | for coord,method in pathextractor.astar_find_available_moves(nc[0], nc[1], (0,0)): 120 | print(coord, method) 121 | if method == "l" or method == "r": 122 | cv.create_rectangle(coord[0] * tile_size, coord[1] * tile_size, (coord[0] + 1) * tile_size, 123 | (coord[1] + 1) * tile_size, fill="yellow") 124 | elif method == "drop": 125 | cv.create_rectangle(coord[0] * tile_size, coord[1] * tile_size, (coord[0] + 1) * tile_size, 126 | (coord[1] + 1) * tile_size, fill="blue") 127 | elif method == "dbljmp": 128 | cv.create_rectangle(coord[0] * tile_size, coord[1] * tile_size, (coord[0] + 1) * tile_size, 129 | (coord[1] + 1) * tile_size, fill="orange") 130 | elif method == "horjmp": 131 | cv.create_rectangle(coord[0] * tile_size, coord[1] * tile_size, (coord[0] + 1) * tile_size, 132 | (coord[1] + 1) * tile_size, fill="black") 133 | elif method == "dbg": 134 | for cd in coord: 135 | cv.create_rectangle(cd[0] * tile_size, cd[1] * tile_size, (cd[0] + 1) * tile_size, 136 | (cd[1] + 1) * tile_size, fill="black")""" 137 | cv.bind("", onleftclick) 138 | 139 | 140 | 141 | 142 | 143 | root.mainloop() -------------------------------------------------------------------------------- /tests/non-unittests/src.terrain_analyzer_bfs.py: -------------------------------------------------------------------------------- 1 | import sys, os, time 2 | sys.path.append("../src") 3 | sys.path.append("C:\\Users\\tttll\\PycharmProjects\\MacroSTory") 4 | from terrain_analyzer import PathAnalyzer 5 | pathextractor = PathAnalyzer() 6 | 7 | pathextractor.load("풍화된_기쁨과_분노의_땅.platform") 8 | print(__file__) 9 | print(pathextractor.platforms) 10 | pathextractor.generate_solution_dict() 11 | for key, val in pathextractor.platforms.items(): 12 | print(key, val.start_x, val.start_y, val.end_x,val.end_y) 13 | print(val.solutions) 14 | print(val.last_visit) 15 | print("-------------------") 16 | 17 | start_hash = input("current location?") 18 | goal_hash = input("goal hash?") 19 | 20 | for solution in pathextractor.pathfind(start_hash, goal_hash): 21 | print(solution.method, solution.to_hash) 22 | -------------------------------------------------------------------------------- /tests/non-unittests/src.terrain_analyzer_create_terrain_file.py: -------------------------------------------------------------------------------- 1 | from src.screen_processor import StaticImageProcessor, MapleScreenCapturer 2 | from src.terrain_analyzer import PathAnalyzer 3 | import cv2, imutils 4 | 5 | wincap = MapleScreenCapturer() 6 | scrp = StaticImageProcessor(wincap) 7 | scrp.update_image() 8 | area = scrp.get_minimap_rect() 9 | pathextractor = PathAnalyzer() 10 | print(area) 11 | input_mode = 1 12 | while True: 13 | scrp.update_image(set_focus=False) 14 | #print("minimap area", area) 15 | 16 | if not area == 0: 17 | 18 | playerpos = scrp.find_player_minimap_marker(area) 19 | 20 | if playerpos != 0: 21 | if input_mode == 1: 22 | pathextractor.input(playerpos[0], playerpos[1]) 23 | else: 24 | 25 | pathextractor.input_oneway_platform(playerpos[0], playerpos[1]) 26 | 27 | 28 | print(pathextractor.platforms) 29 | #print(pathextractor.current_platform_coords) 30 | 31 | cropped_img = scrp.bgr_img[area[1]:area[1] + area[3], area[0]:area[0] + area[2]] 32 | if playerpos != 0: 33 | cv2.circle(cropped_img, playerpos, 3, (0, 0, 255), -1) 34 | if pathextractor.platforms or pathextractor.oneway_platforms: 35 | for key, platform in pathextractor.platforms.items(): 36 | cv2.line(cropped_img,(platform.start_x, platform.start_y), (platform.end_x, platform.end_y),(0,255,0), 2) 37 | for key, platform in pathextractor.oneway_platforms.items(): 38 | print("oneway",key) 39 | cv2.line(cropped_img,(platform.start_x, platform.start_y), (platform.end_x, platform.end_y),(255,0,0), 2) 40 | 41 | cropped_img = imutils.resize(cropped_img, width=500) 42 | 43 | cv2.imshow("test",cropped_img) 44 | 45 | inp = cv2.waitKey(1) 46 | if inp == ord('q'): 47 | cv2.destroyAllWindows() 48 | break 49 | elif inp == ord("r"): 50 | scrp.reset_minimap_area() 51 | area = scrp.get_minimap_rect() 52 | pathextractor.reset() 53 | 54 | elif inp == ord("o"): 55 | print("toggle mode") 56 | input_mode *= -1 57 | 58 | elif inp == ord("s"): 59 | filename = input("file name(with extension)?") 60 | pathextractor.save(filename+".platform", area) -------------------------------------------------------------------------------- /tests/non-unittests/util.jump_distance_measurer.py: -------------------------------------------------------------------------------- 1 | import cv2, imutils, os 2 | from src.screen_processor import MapleScreenCapturer 3 | from src.screen_processor import StaticImageProcessor 4 | from matplotlib import pyplot as plt 5 | import numpy as np 6 | 7 | screencap = MapleScreenCapturer() 8 | scrp = StaticImageProcessor(screencap) 9 | scrp.update_image() 10 | area = scrp.get_minimap_rect() 11 | os.chdir("../src") 12 | 13 | 14 | jmp_coords = [] 15 | start_x = None 16 | start_y = None 17 | end_x = None 18 | end_y = None 19 | last_coords = None 20 | is_recording = False 21 | min_y = 10000 22 | 23 | 24 | while True: 25 | scrp.update_image(set_focus=False) 26 | 27 | playerpos = scrp.find_player_minimap_marker(area) # use minimap 28 | #playerpos = medal.find(scrp.gray_img) # use screen medal 29 | if playerpos: 30 | if playerpos != last_coords: 31 | print(playerpos) 32 | if is_recording: 33 | jmp_coords.append(playerpos) 34 | min_y = min(min_y, playerpos[1]) 35 | last_coords = playerpos 36 | cv2.imshow("",imutils.resize(scrp.bgr_img[area[1]:area[1]+area[3], area[0]:area[0]+area[2]], width=400)) 37 | inp = cv2.waitKey(1) 38 | if inp == ord('q'): 39 | cv2.destroyAllWindows() 40 | break 41 | elif inp == ord("r"): 42 | if not is_recording: 43 | start_x = playerpos[0] 44 | start_y = playerpos[1] 45 | is_recording = True 46 | 47 | print("recording started") 48 | jmp_coords.append(playerpos) 49 | elif is_recording: 50 | end_x = playerpos[0] 51 | end_y = playerpos[1] 52 | 53 | is_recording = False 54 | 55 | starttime = 0 56 | endtime = 0 57 | print("finished") 58 | print(jmp_coords) 59 | print("y coord movement:", abs(start_y - min_y)) 60 | print("x coord movement:", abs(start_x - end_x)) 61 | 62 | elif inp == ord("r"): 63 | scrp.reset_minimap_area() 64 | area = scrp.get_minimap_rect() 65 | 66 | 67 | 68 | start_x = 91 69 | start_y = 34 70 | end_x = 101 71 | jmp_coords = [(91, 34), (92, 33), (93, 29), (95, 27), (97, 27), (98, 29), (100, 32), (101, 34)] 72 | x_val = [x[0] for x in jmp_coords] 73 | y_val = [y[1] for y in jmp_coords] 74 | x = np.linspace(start_x, end_x, 500) 75 | y = (0.53*(x-((start_x+end_x)/2)))**2 + start_y - 7.3 76 | plt.scatter(x_val, y_val) 77 | plt.plot(x, y) 78 | plt.show() 79 | -------------------------------------------------------------------------------- /tests/non-unittests/util.plotter.py: -------------------------------------------------------------------------------- 1 | from matplotlib import pyplot as plt 2 | import numpy as np 3 | 4 | jmp_coords = [(102, 49), (102, 48), (104, 47), (109, 47), (112, 48)] 5 | x_val = [x[0] for x in jmp_coords] 6 | y_val = [y[1] for y in jmp_coords] 7 | #x = np.linspace(start_x, end_x, 500) 8 | #y = (0.53*(x-((start_x+end_x)/2)))**2 + start_y - 7.3 9 | plt.scatter(x_val, y_val) 10 | #plt.plot(x, y) 11 | plt.show() 12 | -------------------------------------------------------------------------------- /tests/non-unittests/util.skill_delay_measurer.py: -------------------------------------------------------------------------------- 1 | import cv2, imutils, os 2 | from src.screen_processor import MapleScreenCapturer 3 | from src.screen_processor import StaticImageProcessor 4 | from src.keystate_manager import KeyboardInputManager 5 | import src.directinput_constants 6 | import numpy as np, time 7 | 8 | screencap = MapleScreenCapturer() 9 | scrp = StaticImageProcessor(screencap) 10 | scrp.update_image() 11 | area = scrp.get_minimap_rect() 12 | key_mgr = KeyboardInputManager() 13 | 14 | testkey = src.directinput_constants.DIK_A 15 | 16 | while True: 17 | scrp.update_image(set_focus=False) 18 | minimap_image = scrp.bgr_img[area[1]:area[1] + area[3], area[0]:area[0] + area[2]] 19 | playerpos = scrp.find_player_minimap_marker(area) 20 | if playerpos: 21 | cv2.circle(minimap_image, playerpos, 4, (255, 0, 0), -1) 22 | regular_find = imutils.resize(minimap_image, width=400) 23 | cv2.imshow("s to start measuring", regular_find) 24 | 25 | #print("regular", playerpos) 26 | #print("templ", playerpos_templ) 27 | 28 | inp = cv2.waitKey(1) 29 | if inp == ord("q"): 30 | cv2.destroyAllWindows() 31 | break 32 | 33 | elif inp == ord("r"): 34 | scrp.reset_minimap_area() 35 | scrp.update_image(set_focus=False) 36 | area = scrp.get_minimap_rect() 37 | 38 | elif inp == ord("s"): 39 | time.sleep(2) 40 | key_mgr.single_press(testkey) 41 | s = time.time() 42 | key_mgr._direct_press(src.directinput_constants.DIK_RIGHT) 43 | last_coords = playerpos 44 | while True: 45 | scrp.update_image() 46 | cpos = scrp.find_player_minimap_marker(area) 47 | if last_coords != cpos: 48 | print(time.time() - s) 49 | key_mgr._direct_release(src.directinput_constants.DIK_RIGHT) 50 | break 51 | else: 52 | print(last_coords, cpos) -------------------------------------------------------------------------------- /tests/test_macroController.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from src.macro_script import MacroController 3 | 4 | class TestMacroController(TestCase): 5 | def test_load_and_process_platform_map(self): 6 | g = MacroController(rune_model_dir="non-unittests/arrow_classifier_keras_gray.h5") 7 | retval = g.load_and_process_platform_map(r"unittest_data/test_valid_data.platform") 8 | self.assertNotEqual(retval, 0) 9 | 10 | -------------------------------------------------------------------------------- /tests/test_pathAnalyzer.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from src.terrain_analyzer import PathAnalyzer 3 | import random 4 | TEST_VALID_PLATFORM__DIR = r"unittest_data/test_valid_data.platform" 5 | TEST_CORRUPT_PLATFORM_DIR = r"unittest_data/test_corrupt_data.platform" 6 | 7 | class TestPathAnalyzer(TestCase): 8 | def test_load(self): 9 | with self.subTest(): 10 | analyzer = PathAnalyzer() 11 | retval = analyzer.load(TEST_VALID_PLATFORM__DIR) 12 | self.assertEqual(len(retval), 4) 13 | with self.subTest(): 14 | analyzer = PathAnalyzer() 15 | retval = analyzer.load(TEST_CORRUPT_PLATFORM_DIR) 16 | self.assertEqual(retval, 0) 17 | 18 | def test_verify_data_file(self): 19 | with self.subTest(): 20 | analyzer = PathAnalyzer() 21 | retval = analyzer.verify_data_file(TEST_VALID_PLATFORM__DIR) 22 | self.assertEqual(len(retval), 4) 23 | with self.subTest(): 24 | analyzer = PathAnalyzer() 25 | retval = analyzer.verify_data_file(TEST_CORRUPT_PLATFORM_DIR) 26 | self.assertEqual(retval, 0) 27 | 28 | def test_hash(self): 29 | test_string = "testdata123" 30 | analyzer = PathAnalyzer() 31 | retval = analyzer.hash(test_string) 32 | self.assertEqual(len(retval), 8) 33 | 34 | def test_pathfind(self): 35 | analyzer = PathAnalyzer() 36 | analyzer.load(TEST_VALID_PLATFORM__DIR) 37 | analyzer.generate_solution_dict() 38 | error = False 39 | for hash1 in analyzer.platforms.keys(): 40 | for hash2 in analyzer.platforms.keys(): 41 | if hash1 == hash2: 42 | continue 43 | rval = analyzer.pathfind(hash1, hash2) 44 | if not rval: 45 | error = True 46 | 47 | self.assertFalse(error) 48 | 49 | def test_generate_solution_dict(self): 50 | analyzer = PathAnalyzer() 51 | analyzer.load(TEST_VALID_PLATFORM__DIR) 52 | error = False 53 | for hash1 in analyzer.platforms.keys(): 54 | if analyzer.platforms[hash1].solutions == []: 55 | error = True 56 | self.assertFalse(error) 57 | 58 | def test_move_platform(self): 59 | analyzer = PathAnalyzer() 60 | analyzer.load(TEST_VALID_PLATFORM__DIR) 61 | error = False 62 | for hash1 in analyzer.platforms.keys(): 63 | for hash2 in analyzer.platforms.keys(): 64 | if hash1 == hash2: 65 | continue 66 | analyzer.move_platform(hash1, hash2) 67 | if analyzer.platforms[hash2].last_visit != 0: 68 | error = True 69 | 70 | self.assertFalse(error) 71 | 72 | def test_select_move(self): 73 | analyzer = PathAnalyzer() 74 | analyzer.load(TEST_VALID_PLATFORM__DIR) 75 | error = False 76 | for key in list(analyzer.platforms.keys()) + list(analyzer.oneway_platforms.keys()): 77 | if not analyzer.select_move(key): 78 | error = True 79 | 80 | self.assertFalse(error) --------------------------------------------------------------------------------