Jak jsem udělal herního bota, co vydělával 15 USD za den
Úvod do tématu
Abych vás uvedl do kontextu, tak musím nejdříve zmínit jaké druhy botů vlastně jsou. Nejefektivnější jsou boti, kteří se napojí přímo na funkce hry například analýzou volání funkcí programu nebo síťovou komunikací.
Ovšem vývoj těchto botů trvá dlouho a množství reverse engineeringu, co je nutná je vyčerpávající. Na druhou stranu zisk je daleko větší, protože se škáluje daleko lépe.
Další druh bota je vizuální a tedy ten co simuluje hráče. Ten zabere na vývoj podstatně kratší dobu, a nemusíte se tolik obávat o anti-cheat systém. V mém případě vývoj trval dva víkendy.
Zdrojové kódy a článek má čistě vzdělávací účel, pokud informace použijete v nelegálním způsobu neberu za to žádnou zodpovědnost.
O co ve hře Dual Universe jde?
Do nedávného updatu šlo jen o těžení ložisek a následně o prodej natěžené rudy. Těžily jsme tedy pomocí nástroje "Mining tool" hroudy skryté pod zemí v podobě rozdílné textury. Tuhle rudu jsme poté odvezli a prodali za herní měnu podle tržní hodnoty. Tato herní měna se dá dále prodat za peníze přes portály, kde nabídneme za kolik prodáme.
Příklad rudy ve hře:
FAQ chvilka
Nyní si probereme nejčastější dotazy.
Proč s tím přicházím teď ?
Samozřejmě by byla ode mě hloupost zveřejňovat zdrojové kódy a informace, když bot vydělával. Aktuálně se stalo těžší prodávat herní suroviny za peníze a tak bot není dále profitabilní.
Jaký byl profit a jak se zvládl zaplatit ?
Bot vydělával kolem 15 USD hrubého zisku za den, ale musíte odečíst cenu virtuálního serveru, který musel běžet, aby byl bot v provozu. To udělalo zhruba 4,68 USD/den. Finální částku nechci sdělovat, ale pro zvídavé prozradím, že bot běžel zhruba 4 měsíce.
Jsou k dispozici zdrojové kódy ?
Ano, zdrojové kódy budou k dispozici na mém Githubu na konci článku. Poprosil bych, ale o trochu respektu a pokud to plánujete použít, využijte fork Githubu nebo zmiňte zdroj. Pokud se vám to líbilo, byl bych velmi vděčný za hvězdičku na repozitáři.
Počáteční myšlenka
Určitě jste už přemýšlel, jak takového bota udělat, myšlenka vám prošla nad identifikací barev a ovládáním klávesnice. Z jednoho pohledu jste blíž než se zdá, ale z druhého je tento pohled dobrý jen pro extrémně jednoduché věci. Tento nápad ke computer vision totiž lidé měli dávno předtím, už když automatizovali továrny postupně se ale dostali k tomu, že při jakékoli změně nasvícení to bohužel už nefungovalo. Avšak ve hře Metin2 byl tento jednoduchý způsob velmi úspěšný a to s chytáním ryb.
Vývoj Bota
Nyní k samotnému vytváření bota.
Plánování vývoje
Na začátek byla úvaha jasná, je potřeba klasifikovat, co se kolem bota děje a zanášet to do jeho řídící jednotky. Jedno řešení bylo udělat si vlastní klasifikátor / neuronovou síť, , co bude klasifikovat materiály a druhé řešení bylo použít open source knihovnu jako Tensorflow a netrávit čas na vlastním klasifikátoru. Tensorflow jsem tehdy ještě nepoužil a chtěl jsem vytvořit něco, čemu rozumím do podrobnosti.
Je snadné si říct, je potřeba vědět, co se děje kolem bota. Ale co vše vlastně potřebujeme vědět z obrazovky a co vše můžeme dopočítat ?
Musíme vědět:
- Kde je ruda
- Jsme příliš daleko pro těžbu ?
Rudu lokalizujeme podle její unikátní textury ve
hře, problém ale je omezení dálky těžby. Naštěstí pro nás v tomto
případě zobrazí hra v dolní části varování:
Věci které se dají odvodit:
- Velikost těžícího kruhu
- Pozice hráče
- Nářadí v ruce
Velikost těžícího kruhu je mezi 0 - 1 (0-100%) nedá se přečíst spolehlivě lehce jelikož je znázorněno kruhem na obrazovce. Nepotřebujeme, ale vědět hodnotu mezi 0 a 1, a tak zvýšíme nebo snížíme vždy na maximum. Ve hře existuje souřadnicový systém, který lze přečíst. Nářadí ve hře jako je "Mining Tool" nepotřebujeme rozlišovat, stačí použít nástroj a víme, že ho postava bude držet.
Vývoj Klasifikátoru
Naivní přístup k problému byl rozpoznávat pixely, které se vyskytují
skrz texturu nejčastěji. Tato technika spočívala v "navzorkování" tisíců
obrázků textur rudy. Toto prohnat matematickými funkcemi pro zjednodušení
jako Gaussovo rozostření. Vstupní obrázek byl převeden do
matice, a následně prolnut s výskytem pixelů každým vzorkem. Prolnutí
bylo klasifikováno True
/False
na základě meze, co
byla určena například 0.7 by znamenalo, že z 70 % musí obrázek obsahovat
stejné pixely jako vzorek. Pokud toto bylo splněno bylo zvýšeno skóre.
Výsledkem bylo skóre vstupního obrázku, které se porovnalo s finálním
číslem, které bylo vypočítáno jako
sum_samples - sum_samples * corruption_constant
, kde
sum_samples
byl celkový počet vzorků pro rudu a
corruption_constant
bylo procento chybovosti.
Aby se nevyhazovali čtverce, které nevyhovují, ale jen pixely, hodnotil algoritmus po pixelech, a výstupem nebylo jen skóre, ale i binární matice.
Omlouvám se za nehezkost, ale byla to pracovní verze a nakonec se přešlo k jinému přístupu. Zdrojový kód vypadal takto:
import os from scipy.ndimage.morphology import grey_dilation, generate_binary_structure, iterate_structure, binary_dilation import numpy as np from PIL import Image dir_scanned = "ore" def image_to_matrix(path: str): img = Image.open(path) data = np.array(img) return data def intersect_colors(red, green, blue): if not (red.shape == green.shape == blue.shape): return False mat_intersect = np.where((red == green), red, 0) mat_intersect = np.where((blue == mat_intersect), blue, 0) return mat_intersect def image_recognize(screen_data: np.array, sample_data: np.array, cropped=True): ir = lambda rgb: image_recognize_by_color(screen_data, sample_data, rgb, cropped) return intersect_colors(ir(0), ir(1), ir(2)) def image_crop_center(img, width, height): y, x = img.shape startx = x // 2 - width // 2 starty = y // 2 - height // 2 return img[starty:starty + height, startx:startx + width] def image_recognize_by_color(screen_data: np.array, sample_data: np.array, rgb: int, small_area=True): """ :param small_area: cropped at center of screen :param screen_data: all data in screenshot :param sample_data: sample that we are comparing to :param rgb: number of index of color r=0 g=1 b=2 :return: """ sample = np.unique(sample_data[:, :, rgb]).flatten() if small_area: cropped_screen = image_crop_center(screen_data[:, :, rgb], 500, 500) else: cropped_screen = screen_data[:, :, rgb] chunk_weights = np.isin(cropped_screen, sample, assume_unique=True) return chunk_weights def blur(img, radius): orig = np.zeros_like(img, dtype='int32') np.copyto(orig, img, casting='unsafe') d = 2 * radius - 1 avg = np.zeros_like(orig) for i in range(d): for j in range(d): avg += np.pad(orig[i: _omit_zero(i - d + 1), j: _omit_zero(j - d + 1)], _get_pad_tuple(len(img.shape), d, i, j), 'edge') avg = avg // (d ** 2) avg = avg.clip(0, 255) res = np.empty_like(img) np.copyto(res, avg, casting='unsafe') return res def unsharp_mask(img, amount, radius, threshold): orig = np.zeros_like(img, dtype='int32') np.copyto(orig, img, casting='unsafe') d = 2 * radius - 1 lowpass = np.zeros_like(orig) for i in range(d): for j in range(d): lowpass += np.pad(orig[i: _omit_zero(i - d + 1), j: _omit_zero(j - d + 1)], _get_pad_tuple(len(img.shape), d, i, j), 'edge') lowpass = lowpass // (d ** 2) highpass = orig - lowpass tmp = orig + (amount / 100.) * highpass tmp = tmp.clip(0, 255) res = np.zeros_like(img) np.copyto(res, tmp, casting='unsafe') res = np.where(np.abs(img^res) < threshold, img, res) return res def _omit_zero(x): if x == 0: return None return x def _get_pad_tuple(dim, d, i, j): if dim == 2: # greyscale return (d - i - 1, i), (d - j - 1, j) else: # color (and alpha) channels return (d - i - 1, i), (d - j - 1, j), (0, 0) def image_weight(screen_data: np.array, sample_data: np.array): chunk_weights = image_recognize(screen_data, sample_data) num_of_true = np.sum(chunk_weights) comp_size = np.size(chunk_weights) return num_of_true / comp_size def image_weight_by_color(screen_data: np.array, sample_data: np.array, rgb: int): """ :param screen_data: all data in screenshot :param sample_data: sample that we are comparing to :param rgb: number of index of color r=0 g=1 b=2 :return: """ chunk_weights = image_recognize_by_color(screen_data, sample_data, rgb) num_of_true = np.sum(chunk_weights) comp_size = np.size(chunk_weights) return num_of_true / comp_size def compare_to_all_samples(screen_data: np.array, cropped=True): samples = os.listdir("../images/"+dir_scanned+"/") recognized = None for sample in samples: sample_mat = image_to_matrix("../images/"+dir_scanned+"/" + sample) if recognized is None: recognized = image_recognize(screen_data, sample_mat, cropped) else: recognized += image_recognize(screen_data, sample_mat, cropped) return recognized def img_frombytes(data): """ For debuging only graymap :param data: :return: """ size = data.shape[::-1] databytes = np.packbits(data, axis=1) return Image.frombytes(mode='1', size=size, data=databytes) def apply_filter(mat, confidence: int): """ :param confidence: count of sample that matched ( put there number higher than 2 ) :return: Filtered matrix """ return np.greater(mat, confidence) def apply_filter_dilation(mat, iterations): return binary_dilation(mat, iterations=iterations)
Příklad
Vstup:

Výstup:

Filtr na dilataci byl použit k sjednocení necelých ploch pixelů
označených jako True
.
Možná budete překvapeni, ale tento přístup byl rychlejší než Tensorflow, avšak požadoval manuální změnu typu rudy. Processing celé obrazovky trval zhruba 0.54s, malá část obrazovky o velikosti 500x500 byla zpracována za 0.02s (CPU).
Doufám, že vás článek zaujal.
Stáhnout
Stažením následujícího souboru souhlasíš s licenčními podmínkami
Staženo 37x (5.63 kB)
Aplikace je včetně zdrojových kódů v jazyce Python