Последние несколько сотен лет Дурак – безоговорочно самая популярная народная карточная игра.
Правила просты. Тот, кто остался последним с картами на руках, дурак, тот же, кто сбросил карты, победил!
Сыграв несколько тысяч игр, стало интересно посчитать винрейт.
Речь идет про Дурак Онлайн, лучшую онлайн-версию. За победу дают 96 баллов, проигрыш стоит 100. В среднем за 30–35 игр получалась стабильная 1000+, без отката, то есть это скорее плюс 1100–1200. Иногда на это уходило больше, около 60, а то и все 100 игр, но порой могло уйти меньше, около 15. Это экстремумы, сильно чаще, буквально 30–40 игр.
Винрейт около 65 процентов. В игре нет рейтинга, и это фулл-рандом по скиллу, это не игра на реальные деньги, это не игра против ботов, это заведомо повышает винрейт.
Винрейт состоит из мелочей:
Был еще один пункт, который однозначно мог повысить винрейт и о котором я всегда думал, но не использовал. Речь идет про подсчет карт. Несмотря на очевидность, практически никто этого не делает. Этот вывод легко сделать по последнему ходу, люди не считают карты даже в сокращенной колоде практически никогда. Это может дать до 3 процентов винрейта.
С одной стороны, карточная игра это в каком-то смысле текст, снять текст, сделать метчинг, звучит как хорошая идея. Но OCR-тулзы (типа Tesseract) может не понять изображение с игральных карт как текст, так как оно слишком не похоже на буквальный текст с блокнота. По небольшим тестам я сделал именно такой вывод, хотя не исключаю, что его можно и доработать, и донастроить, а значит, это тоже может быть одним из путей.
Я буду использовать библиотеку OpenCV со стратегией сопоставления шаблонов.
По сути, код завязан на идее о том, что, исходя из скриншота, он понимает, где мы находимся сейчас, что происходит и что с этим делать.
Все это на Windows-платформе через приложение https://play.google.com/store/apps/details?id=com.rstgames.durak&hl=ru, хотя сама идея может быть перенесена на мобильные приложения через передачу изображения на сервер с его обработкой, в определенном смысле это адаптированный вариант, и само приложение уже крутится на том же устройстве, где запущен код.
Использование OpenCV (пример)
Подключаем библиотеку cv2, подгружаем два изображения, первое то, что ищем, второе то, где ищем. Передаем их в функцию matchTemplate, обратно забираем параметр max_val. Он представляет собой число, в идеальном случае равное 1.0. Для изображений, где не будет метча, число будет в диапазоне 0.90 - 0.95. При диапазоне 0.99 - 1.0 утверждение «Объект найден» становится истинным.
Часто в примерах использования OpenCV применяется метод TM_CCORR_NORMED при передаче в функцию matchTemplate. Так или иначе, во время тестов он выдавал абсолютно неконкретные значения, а вот TM_CCOEFF_NORMED показал себя хорошо, он более лоялен к разнице в яркости и положению во время сверки. Впрочем, я не уверен, что именно это стало причиной, но так или иначе именно он адекватно понимал разницу.
Скрипты для заготовки шаблонов сверки
Делает скриншоты раз в секунду (запускаем скрипт + запускаем игру).
Скрипт для вырезки участков со скриншотов с двумя режимами работы: по координатам либо выбором через выделение участка мышкой.
Все начинается с началом игры и заканчивается с ее концом, все остальное происходит между двумя этими событиями.
Паттерн для начала игры, это число 12 на экране, оно означает, что произошла первая раздача (24 - 2*6 = 12)
В конце игры победитель получает 96 баллов на баланс, это верный паттерн, игра уже окончена
Состояние игры определяется через положение карт. О карте может быть ничего не известно, она может быть нашей, может быть оппонента (если известно, что он ее взял), может быть на столе, а может уйти в отбой.
Игра начинается с раздачи, после которой на руках оказываются первые 6 карт. Всего карт 24, но, поскольку одна карта (допустим, 9S) на позиции 1 в раздаче (первая слева) и та же карта на 6-й позиции с точки зрения шаблона не одно и то же, следует заготовить по 6 шаблонов на карту, всего 144.
Полученный скриншот прогоняем по всей базе шаблонов. На данном этапе операция сверки занимает около 25 секунд.
Оптимизируем за счет:
Время сверки сокращается до 0.9 секунды для функции определения начальных карт.
Существует несколько ветвлений, от которых зависит дальнейшее изменение состояния карт.
Done + возможный I take (и его положение)
Done без I Take, независимо от положение, означает, найденные карты вышли.
Положение I Take определяет сценарий. Когда он обнаружен сверху, найденные на столе карты получают статус enemy, та же функция, которая определяла начальные карты, применяется для анализа добора. Когда же он обнаружен снизу, найденные на столе карты получают статус Me.
Помимо Done и I Take, на скриншотах может быть Pass.
Сценарий также зависит от расположения: если сверху статус карт Me, если снизу статус enemy + проверка добора.
Положение стола определяем через шаблоны всех возможных комбинаций первой карты, которая потенциально может побить вторую хотя бы при одном варианте козыря. Всего это 516 комбинаций. Однако из-за особенностей игры иногда карты могут ложиться немного по-разному, хотя это и происходит редко и зависит от некой случайной переменной. Это увеличивает количество шаблонов.
Пример того, что карты могут иметь немного разный наклон на комбинации TD_TS.
Следует играть новые партии параллельно, сохраняя скриншоты стола, повышая точность определения после каждой игры, используя новую информацию, параллельно немного понизив процент для удачной сверки. Он может быть определён на диапазоне 0.976 - 0.98. Добавлю также, что реальных комбинаций сильно меньше 516, около половины. Например, комбинация KH_9C (козырный король черви бьет девятку трефы), хоть и потенциально возможна, в реальности практически никогда не будет отыграна.
Следовательно, идея очень проста: по ходу игры мы отслеживаем состояние карт согласно шаблонам, ожидая финального хода, который наступает в момент последней раздачи, которая определяется по шаблону одного из четырех знаков козырей на месте расположения карт.
В идеальном случае (все шаблоны успешно совпали) комбинация карт со статусом ENEMY и статусом UNKNOWN и есть карты оппонента, а значит, мы видим руку оппонента и строим ходы исходя из этой информации.
Финальный шаг
Отправка в телеграм
Разберем структуру кода подробнее
В main.py происходит бесконечный цикл, который пытается поймать начало игры, дергает определение козыря, определение начальных карт и изменение стейта, проверку паттернов смены хода, финального хода (финальной раздачи, если быть точнее) и конца игры.
В templates.py ряд шаблонов.
В helpers.py логгер, апдейтеры стейтов, сейвер скриншота и сендер в Telegram.
В steps.py анализ стола, анализ добора (он же для начальных карт), определение козыря, финального шага, конца игры, проверка скриншота на смену хода.
Весь проект - https://gofile.io/d/4FFkQXCD
Плюс 3 процента винрейта через технологию компьютерного зрения.
Правила просты. Тот, кто остался последним с картами на руках, дурак, тот же, кто сбросил карты, победил!
Спойлер: Подробнее
Колода 36 (или 24, или 56) карт. Игроков от 2 до 6. В начале каждому раздается 6 карт. Одна случайная карта кладется под колоду, ее масть назначается козырем. Козырь бьет любую другую масть (между собой козыри бьются по старшинству). Первым ходит тот, у кого младший козырь. Ходят по часовой стрелке. Игрок кладет карту перед соперником, соперник бьет её своей картой либо старшей, либо козырем. Если это изначально козырь, то старшим козырем. Далее зависит от конфигурации. Если это подкидной дурак, то игроки (первым тот, кто ходит, далее остальные) могут подкинуть карты, но только того же достоинства, что уже лежат на столе, и не больше, чем карт на руке у отбивающегося, и не больше 6 (5, если это первая защита в игре). Если это переводной дурак, отбивающийся может не бить карту, а перевести ее следующему игроку с помощью своей карты того же достоинства. Тогда отбиваться будет следующий игрок по кругу (он также может перевести, если на руках есть карта того же достоинства). Если отбивающийся отбил все карты, они уходят в отбой и выходят из игры. Тот, кто отбился, ходит следующим. Если отбивающийся не смог или не захотел отбиваться, он забирает все карты со стола себе в руку. Следующий игрок по часовой стрелке начинает новый ход. После каждого хода игроки добирают карты из колоды, чтобы на руках снова стало 6, первым тот, кто ходил. Когда колода пуста и один игрок скинул все свои карты, он выиграл. Оставшиеся продолжают. Последний, у кого карты на руках, проигравший.
Сыграв несколько тысяч игр, стало интересно посчитать винрейт.
Речь идет про Дурак Онлайн, лучшую онлайн-версию. За победу дают 96 баллов, проигрыш стоит 100. В среднем за 30–35 игр получалась стабильная 1000+, без отката, то есть это скорее плюс 1100–1200. Иногда на это уходило больше, около 60, а то и все 100 игр, но порой могло уйти меньше, около 15. Это экстремумы, сильно чаще, буквально 30–40 игр.
Винрейт около 65 процентов. В игре нет рейтинга, и это фулл-рандом по скиллу, это не игра на реальные деньги, это не игра против ботов, это заведомо повышает винрейт.
Винрейт состоит из мелочей:
- Те же правила, то же количество игроков, всегда (или на выбранный период), это основа. В каждой конфигурации есть свои микро-тонкости. В моем случае игра 1 на 1, на сокращенную колоду из 24 карт, переводной, подкидной, без ничьей, без платного функционала. Банк не связан с винрейтом напрямую, но лучше играть со стабильным банком, например на 100, без азарта.
- Не сдаваться. Никогда. Это дает около 2 процентов винрейта, в отдельных выборках может дать до 3–4. Почти никто не сдается на начальных раскладах, сдаются ближе к финалу. Порой людям кажется, что исход очевиден, иногда это и вправду так, но не так часто, как кажется. Порой это вообще не очевидно. Я наблюдал десятки игр, где люди сдавались, хотя объективно по картам не проигрывали.
- В игре есть возможность посылать смайлики, как бы комментируя ход игры. Если навязчиво комментировать каждый момент, будь то в добром, саркастичном или злом ключе (или даже бессмысленном), можно вызвать разные эмоции – от злости до непонимания. Точно сказать нельзя, некоторые отвечают обратно, красиво резонируя партию. Как бы там ни было, в комбинации с никогда не сдаваться это даст еще 1 процент винрейта, поскольку такая тактика у некоторых людей усиливает желание сдаться.
- Я не использую весь потенциал времени хода. На ход дается около 30 секунд. Если ходить за 28–29, это вызовет сильное раздражение, мощно усилит желание сдаться, формально не нарушив правил. Я не рассматриваю это в границах адекватности, и в целом мне нравились те игры, где ответы происходили практически мгновенно, но нельзя не отметить, что это будет работать и может дать и все 5 процентов винрейта сверху, а то и 7–9. Впрочем, я персонально не считаю это адекватным.
- Пропустить ход через взятие карт – это опция. Предположим, у нас хорошая рука, все дамы не выходили, на руках одна дама, вероятность дамы у соперника высока, у соперника, скорее всего, не очень хорошая рука (поскольку у нас хорошая), вероятность перевода очень высокая, по ощущениям, процентов 70. Ходим даму, получаем даму переводом назад, взяли две дамы, по сути, пропустили ход.
- Ходить с учетом перевода / двойного перевода на возможность отбиться, особенно если карты достоинства, которым ходим, не выходили и вероятность перевода / двойного перевода высока.
- По возможности не плодить карты разного достоинства во время защиты. Порой лучше потратить 4 короля, закрыв ход, чем две десятки, одну даму и одного короля, рискнув получить дополнительные карты на этапе подкидывания.
- Опция взять карты в целом недооценена. Важны комбинации с учетом достоинства. Если карты не выходили, взять 4 валета выгодно, а если на руках нет 9 и они не выходили, то четыре 10 хорошие карты. Карты одинакового достоинства дают возможность для длинного подкидывания, что позволяет контролировать ход партии.
- Умеренная агрессия может сыграть в плюс. В среднем люди слишком берегут козыря. Иногда отбить 9 козырным тузом, получив контроль следующего хода, может быть хорошим ходом. Впрочем, с агрессией легко переборщить, тогда все карты просто вернутся обратно на последних ходах.
Был еще один пункт, который однозначно мог повысить винрейт и о котором я всегда думал, но не использовал. Речь идет про подсчет карт. Несмотря на очевидность, практически никто этого не делает. Этот вывод легко сделать по последнему ходу, люди не считают карты даже в сокращенной колоде практически никогда. Это может дать до 3 процентов винрейта.
С одной стороны, карточная игра это в каком-то смысле текст, снять текст, сделать метчинг, звучит как хорошая идея. Но OCR-тулзы (типа Tesseract) может не понять изображение с игральных карт как текст, так как оно слишком не похоже на буквальный текст с блокнота. По небольшим тестам я сделал именно такой вывод, хотя не исключаю, что его можно и доработать, и донастроить, а значит, это тоже может быть одним из путей.
Я буду использовать библиотеку OpenCV со стратегией сопоставления шаблонов.
По сути, код завязан на идее о том, что, исходя из скриншота, он понимает, где мы находимся сейчас, что происходит и что с этим делать.
Все это на Windows-платформе через приложение https://play.google.com/store/apps/details?id=com.rstgames.durak&hl=ru, хотя сама идея может быть перенесена на мобильные приложения через передачу изображения на сервер с его обработкой, в определенном смысле это адаптированный вариант, и само приложение уже крутится на том же устройстве, где запущен код.
Использование OpenCV (пример)
Python:
import cv2
is_it_new_game = cv2.imread('templates/start.png')
temp = cv2.imread('1.png')
result = cv2.matchTemplate(temp, is_it_new_game, cv2.TM_CCORR_NORMED)
_, max_val, _, _ = cv2.minMaxLoc(result)
print(max_val)
if max_val > 0.9:
print("Игра началась")
Подключаем библиотеку cv2, подгружаем два изображения, первое то, что ищем, второе то, где ищем. Передаем их в функцию matchTemplate, обратно забираем параметр max_val. Он представляет собой число, в идеальном случае равное 1.0. Для изображений, где не будет метча, число будет в диапазоне 0.90 - 0.95. При диапазоне 0.99 - 1.0 утверждение «Объект найден» становится истинным.
Часто в примерах использования OpenCV применяется метод TM_CCORR_NORMED при передаче в функцию matchTemplate. Так или иначе, во время тестов он выдавал абсолютно неконкретные значения, а вот TM_CCOEFF_NORMED показал себя хорошо, он более лоялен к разнице в яркости и положению во время сверки. Впрочем, я не уверен, что именно это стало причиной, но так или иначе именно он адекватно понимал разницу.
Скрипты для заготовки шаблонов сверки
Python:
import os
import time
import subprocess
from datetime import datetime
from PIL import ImageGrab
os.makedirs("screenshots", exist_ok=True)
while True:
filename = f"screenshots/{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
img = ImageGrab.grab(all_screens=True)
img.save(filename, "PNG")
print(f"+: {filename}")
time.sleep(1)
Делает скриншоты раз в секунду (запускаем скрипт + запускаем игру).
Python:
import cv2
import os
import sys
if len(sys.argv) != 2:
exit()
path = f"screenshots/{sys.argv[1]}.png"
img = cv2.imread(path)
if img is None:
print("Не грузится. Проверь путь.")
exit()
h, w = img.shape[:2]
print(f"Размер: {w}x{h}")
print("\nКак режем?")
print(" 1 - По кординатам (x y width height)")
print(" 2 - Мышкой (выдели область, энтер, ESC)")
mode = "2"
os.makedirs("templates", exist_ok=True)
if mode == "1":
try:
x, y, rw, rh = map(int, input("Корды x y w h: ").split())
except:
print("Не то ввел")
exit()
if x < 0 or y < 0 or x+rw > w or y+rh > h:
print("Вылез за края")
exit()
template = img[y:y+rh, x:x+rw]
elif mode == "2":
print("Выдели область мышкой - ENTER (подтвердить) - ESC (закрыть окно)")
roi = cv2.selectROI("Вырезаем", img, showCrosshair=True)
cv2.destroyAllWindows()
x, y, rw, rh = roi
if rw == 0 or rh == 0:
print("Ничего не выделил")
exit()
template = img[int(y):int(y+rh), int(x):int(x+rw)]
else:
print("1 или 2, Третьего не дано")
exit()
name = input("Название шаблона (без .png): ").strip()
if not name:
name = "unnamed"
cv2.imwrite(f"templates/{name}.png", template)
print(f"Готово: templates/{name}.png | x:{x} y:{y} w:{rw} h:{rh}")
Все начинается с началом игры и заканчивается с ее концом, все остальное происходит между двумя этими событиями.
Паттерн для начала игры, это число 12 на экране, оно означает, что произошла первая раздача (24 - 2*6 = 12)
Не удалось прогрузить медиа
В конце игры победитель получает 96 баллов на баланс, это верный паттерн, игра уже окончена
Не удалось прогрузить медиа
Состояние игры определяется через положение карт. О карте может быть ничего не известно, она может быть нашей, может быть оппонента (если известно, что он ее взял), может быть на столе, а может уйти в отбой.
Python:
UNKNOWN = 0
ME = 1
ENEMY = 2
TABLE = 3
DISCARD = 4
cards = {
'9S': UNKNOWN,
'TS': UNKNOWN,
'JS': UNKNOWN,
'QS': UNKNOWN,
'KS': UNKNOWN,
'AS': UNKNOWN,
'9H': UNKNOWN,
'TH': UNKNOWN,
'JH': UNKNOWN,
'QH': UNKNOWN,
'KH': UNKNOWN,
'AH': UNKNOWN,
'9D': UNKNOWN,
'TD': UNKNOWN,
'JD': UNKNOWN,
'QD': UNKNOWN,
'KD': UNKNOWN,
'AD': UNKNOWN,
'9C': UNKNOWN,
'TC': UNKNOWN,
'JC': UNKNOWN,
'QC': UNKNOWN,
'KC': UNKNOWN,
'AC': UNKNOWN,
}
Игра начинается с раздачи, после которой на руках оказываются первые 6 карт. Всего карт 24, но, поскольку одна карта (допустим, 9S) на позиции 1 в раздаче (первая слева) и та же карта на 6-й позиции с точки зрения шаблона не одно и то же, следует заготовить по 6 шаблонов на карту, всего 144.
Не удалось прогрузить медиа
Не удалось прогрузить медиа
Python:
import os
import cv2
screen = cv2.imread("tests/start/3.png")
found = set()
for file in os.listdir("templates/start"):
template = cv2.imread(f"templates/start/{file}")
result = cv2.matchTemplate(screen, template, cv2.TM_CCORR_NORMED)
_, max_val, _, _ = cv2.minMaxLoc(result)
if max_val > 0.993:
found.add(file[:2])
print(sorted(found))
Оптимизируем за счет:
- Карт не может быть больше 6. После нахождения 6 дропаем функцию
- Если нашли карту X на позиции Y, пропускаем X*, например, нашли TS (десятка пик) на позиции 2 пропускаем сверку TS* (остальных десяток пик)
- Тестируем только нижнюю 1/3 по высоте и среднюю 1/3 по ширине скриншота
- Добавляем ProcessPool
Python:
import os
import cv2
import time
from concurrent.futures import ThreadPoolExecutor
import threading
start = time.perf_counter()
screen = cv2.imread("tests/start/3.png")
h, w = screen.shape[:2]
screen = screen[2*h//3:, w//3:2*w//3]
templates = []
for file in os.listdir("templates/start"):
name = file[:2]
image = cv2.imread(f"templates/start/{file}")
templates.append((name, image))
found = set()
lock = threading.Lock()
stop_event = threading.Event()
def check(template):
if stop_event.is_set():
return None
name, template = template
with lock:
if name in found:
return None
_, score, _, _ = cv2.minMaxLoc(
cv2.matchTemplate(screen, template, cv2.TM_CCORR_NORMED)
)
if score > 0.993:
with lock:
found.add(name)
if len(found) >= 6:
stop_event.set()
return name
return None
with ThreadPoolExecutor(max_workers=4) as executor:
for name in executor.map(check, templates):
pass
print(sorted(found))
print(f"Время: {time.perf_counter() - start:.4f} сек")
Время сверки сокращается до 0.9 секунды для функции определения начальных карт.
Существует несколько ветвлений, от которых зависит дальнейшее изменение состояния карт.
Done + возможный I take (и его положение)
Не удалось прогрузить медиа
Done без I Take, независимо от положение, означает, найденные карты вышли.
Не удалось прогрузить медиа
Положение I Take определяет сценарий. Когда он обнаружен сверху, найденные на столе карты получают статус enemy, та же функция, которая определяла начальные карты, применяется для анализа добора. Когда же он обнаружен снизу, найденные на столе карты получают статус Me.
Помимо Done и I Take, на скриншотах может быть Pass.
Не удалось прогрузить медиа
Сценарий также зависит от расположения: если сверху статус карт Me, если снизу статус enemy + проверка добора.
Положение стола определяем через шаблоны всех возможных комбинаций первой карты, которая потенциально может побить вторую хотя бы при одном варианте козыря. Всего это 516 комбинаций. Однако из-за особенностей игры иногда карты могут ложиться немного по-разному, хотя это и происходит редко и зависит от некой случайной переменной. Это увеличивает количество шаблонов.
Не удалось прогрузить медиа
Не удалось прогрузить медиа
Пример того, что карты могут иметь немного разный наклон на комбинации TD_TS.
Следует играть новые партии параллельно, сохраняя скриншоты стола, повышая точность определения после каждой игры, используя новую информацию, параллельно немного понизив процент для удачной сверки. Он может быть определён на диапазоне 0.976 - 0.98. Добавлю также, что реальных комбинаций сильно меньше 516, около половины. Например, комбинация KH_9C (козырный король черви бьет девятку трефы), хоть и потенциально возможна, в реальности практически никогда не будет отыграна.
Следовательно, идея очень проста: по ходу игры мы отслеживаем состояние карт согласно шаблонам, ожидая финального хода, который наступает в момент последней раздачи, которая определяется по шаблону одного из четырех знаков козырей на месте расположения карт.
Не удалось прогрузить медиа
В идеальном случае (все шаблоны успешно совпали) комбинация карт со статусом ENEMY и статусом UNKNOWN и есть карты оппонента, а значит, мы видим руку оппонента и строим ходы исходя из этой информации.
Python:
def check_final_step(screen):
trump = state.game['trump'] # узнаем козырь
if trump is None:
return False
template = FINAL_STEPS[trump] # нужен что бы сократить количество сверок с 4 до 1
screen = screen[280:406, 544:652]
result = cv2.matchTemplate(
screen,
template,
cv2.TM_CCORR_NORMED
)
_, score, _, _ = cv2.minMaxLoc(result)
if score > 0.99:
log(f'{trump} - финальный шаг')
final_cards = [
card
for card, status in state.cards.items()
if status in (state.UNKNOWN, state.ENEMY)
]
log(f'Карты на финальном шаге: {final_cards}')
send_final_cards_to_telegram(final_cards)
return True
return False
Python:
def send_final_cards_to_telegram(final_cards):
text = f"Финальный шаг\nКарты: {', '.join(final_cards)}"
url = f"https://api.telegram.org/botтокен/sendMessage"
requests.post(
url,
data={
"chat_id": чат_айди,
"text": text
}
)
Разберем структуру кода подробнее
В main.py происходит бесконечный цикл, который пытается поймать начало игры, дергает определение козыря, определение начальных карт и изменение стейта, проверку паттернов смены хода, финального хода (финальной раздачи, если быть точнее) и конца игры.
Спойлер: main.py
Python:
import time
import cv2
import numpy as np
import state
from PIL import ImageGrab
from templates import (
START,
END,
TRUMPS,
FINAL_STEPS,
STEPS,
YOUR_TURN,
)
from helpers import log, update_cards_its_my_now, reset_game
from steps import (
detect_draw,
detect_trump,
check_final_step,
check_game_end,
check_steps
)
# Статусы которые управляют состоянием игры, либо ждем старта, либо концовки
WAIT_START = 0
WAIT_END = 1
the_game_state = WAIT_START # При запуске скрипта начинаем ждать начало игры
final_step_done = False # Флаг для обнаружения финального хода
steps_locked_until = 0 # Полезная что бы блокировать паттерны конца хода (такие как Done) иначе может поймать пару раз на нескольких скринах
previous_screen = None # Нужна для анализа
def get_previous_screen(): # Можно передавать аргументами, но можно и так сделать что бы со степс дергать вроде не очень красиво, ну можно это в стейт запихнуть
return previous_screen
# Бесконечный цикл - Основная функция
while True:
# Получаем скриншот экрана
screen = np.array(
ImageGrab.grab(all_screens=True)
)
# Меняем формат на cv2 асептебел
screen = cv2.cvtColor(
screen,
cv2.COLOR_RGB2BGR
)
# Игра еще не начата? Тогда идем сюда и проверяем на паттерн начала игры
if the_game_state == WAIT_START:
t_screen = screen[240:291, 550:599] # делаем вырезку со скрина что бы оно не перебило другие переменные а то может
result = cv2.matchTemplate(
t_screen,
START,
cv2.TM_CCOEFF_NORMED
)
_, score, _, _ = cv2.minMaxLoc(result)
if score > 0.9: # Если нашли меняем статус
log('Игра началась')
the_game_state = WAIT_END
# Сюда идем если игра уже идет
else:
if state.game['trump'] is None: # Если козырь не определен идем сюда
trump = detect_trump(screen) # Определили
if trump: # Попали сюда если опеределили, если нет то попробуем снова на следующем скриншоте (возможно не закочилась раздача), хотя почти всегда будет 1 попытка
state.game['trump'] = trump # Изменили состояние козыря
log(f'Козырь определен: {trump}') # Прологировали
time.sleep(1) # Техническая задержка, на скриншоте с козырем может еще не закончится раздача
t_screen = np.array( # Сделали скриншот
ImageGrab.grab(all_screens=True)
)
t_screen = cv2.cvtColor( # Форматнули
t_screen,
cv2.COLOR_RGB2BGR
)
found_cards = detect_draw(t_screen) # Определили начальные карты
log(f'Найдены карты: {found_cards}') # Прологировали
update_cards_its_my_now(found_cards) # Обновили стейт
if time.perf_counter() >= steps_locked_until: # Проверка нету ли блокировки на проверку стола
if check_steps(screen, get_previous_screen()): # Проверяем скриншот на Done etc. паттерны
steps_locked_until = time.perf_counter() + 3 # Если получили обратно True делаем блокировку на 3 секунды проверки стола
if not final_step_done: # Проверка а не финальный ли это ход
if check_final_step(screen): # Если так меняем статус переменной
final_step_done = True
if check_game_end(screen): # Проверка а не закончилась ли игра вообще
log('Игра закончилась')
reset_game()
final_step_done = False
log('Состояние игры сброшено')
the_game_state = WAIT_START
previous_screen = screen.copy() # Делаем копию для анализа стола (на определенных моментах нужно)
time.sleep(1) # Делаем скриншоты раз в 1 секунду
Спойлер: templates.py
Python:
import cv2
TRUMPS = {
'C': cv2.imread('templates/trumps/clubs.png'),
'D': cv2.imread('templates/trumps/diamonds.png'),
'H': cv2.imread('templates/trumps/hearts.png'),
'S': cv2.imread('templates/trumps/spades.png'),
}
FINAL_STEPS = {
'C': cv2.imread('templates/final_step/clubs.png'),
'D': cv2.imread('templates/final_step/diamonds.png'),
'H': cv2.imread('templates/final_step/hearts.png'),
'S': cv2.imread('templates/final_step/spades.png'),
}
STEPS = {
'done': cv2.imread('templates/steps/done.png'),
'pass_up': cv2.imread('templates/steps/pass_up.png'),
'pass_down': cv2.imread('templates/steps/pass_down.png'),
'i_take_up': cv2.imread('templates/steps/i_take_up.png'),
'i_take_down': cv2.imread('templates/steps/i_take_down.png'),
}
START = cv2.imread('templates/start.png')
END = cv2.imread('templates/end.png')
YOUR_TURN = cv2.imread('templates/steps/your_turn.png')
В helpers.py логгер, апдейтеры стейтов, сейвер скриншота и сендер в Telegram.
Спойлер: helpers.py
Python:
import time
import state
import os
from datetime import datetime
from PIL import Image
import cv2
import requests
# Логирует с отметкой по времени
def log(text):
with open('log.txt', 'a', encoding='utf-8') as f:
f.write(
f'{time.strftime("%Y-%m-%d %H:%M:%S")} {text}\n'
)
# Обновляет найденные карты на статус Me
def update_cards_its_my_now(found_cards):
for card in found_cards:
old_status = state.cards[card]
if old_status != state.ME:
state.cards[card] = state.ME
log(
f'Карта изменена: {card}'
f'{old_status} -> {state.ME}'
)
# Обновляет найденные карты на статус Enemy
def update_cards_its_enemy_now(found_cards):
for card in found_cards:
old_status = state.cards[card]
if old_status != state.ENEMY:
state.cards[card] = state.ENEMY
log(
f'Карта изменена: {card}'
f'{old_status} -> {state.ENEMY}'
)
# Обновляет найденные карты на статус DISCARD
def update_cards_its_discard_now(found_cards):
for card in found_cards:
old_status = state.cards[card]
if old_status != state.DISCARD:
state.cards[card] = state.DISCARD
log(
f'Карта изменена: {card}'
f'{old_status} -> {state.DISCARD}'
)
# Сбрасывает стейт
def reset_game():
state.game['trump'] = None
state.game['attacker'] = None
state.game['defender'] = None
state.game['turn'] = 0
for card in state.cards:
state.cards[card] = state.UNKNOWN
def save_previous_screen(screen):
if screen is not None:
os.makedirs("screenshots", exist_ok=True)
filename = f"screenshots/{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
img = Image.fromarray(cv2.cvtColor(screen, cv2.COLOR_BGR2RGB))
img.save(filename, "PNG")
def send_final_cards_to_telegram(final_cards):
text = f"Финальный шаг\nКарты: {', '.join(final_cards)}"
url = f"https://api.telegram.org/bot/sendMessage"
requests.post(
url,
data={
"chat_id": ,
"text": text
}
)
В steps.py анализ стола, анализ добора (он же для начальных карт), определение козыря, финального шага, конца игры, проверка скриншота на смену хода.
Спойлер: steps.py
Python:
import os
import time
import cv2
import threading
import state
from concurrent.futures import ThreadPoolExecutor
from PIL import ImageGrab
import numpy as np
from helpers import log, update_cards_its_my_now, update_cards_its_enemy_now, update_cards_its_discard_now, save_previous_screen, send_final_cards_to_telegram
from templates import (
TRUMPS,
FINAL_STEPS,
STEPS,
END,
)
# Анализ Стола
def detect_table(previous_screen):
start = time.perf_counter() # Отчитываем время старта для анализа
previous_screen = previous_screen[147:616, 543:1054] # Кусок который идет на анализ
templates = [] # Сюда пойдет (['JH', 'TH'], image) / (['AS'], image)
for file in os.listdir("templates/table"): # Перебираем шаблоны
filename = os.path.splitext(file)[0] # Убираем расширение png
if "_" in filename: # Если в названии есть _ значит это пара
cards = filename.split("_") # JH_TH становится ['JH', 'TH']
else:
cards = [filename] # ['JH']
image = cv2.imread(f"templates/table/{file}")
templates.append((cards, image))
found_pairs = [] # Сюда попадают найденные пары
found_singles = set() # Сюда соло карты
lock = threading.Lock() # Синхронизация потоков
stop_event = threading.Event() # Дропаем когда найденно достаточное кол-во элементов
def check(template_data): # Проверяем шаблоны один за другим
if stop_event.is_set():
return None
cards, template = template_data # (['JH', 'TH'], image) переходит в cards = ['JH', 'TH'] + template = image
# Если это пара и одна из ее карт уже найдена одиночкой пару вообще не проверяем
if len(cards) == 2:
with lock:
if cards[0] in found_singles or cards[1] in found_singles:
return None
_, score, _, _ = cv2.minMaxLoc(
cv2.matchTemplate(
previous_screen,
template,
cv2.TM_CCORR_NORMED
)
)
if score <= 0.976: # Адекватный параметр
return None
with lock: # Если нашли попадаем сюда
if len(cards) == 2: # Проверяем пара это или нет
# Если одну из карт пары уже нашли одиночкой эту пару пропускаем (по сути дубль проверка)
if cards[0] in found_singles or cards[1] in found_singles:
return
pair = tuple(cards) # ['JH', 'TH'] -> ('JH', 'TH')
if pair not in found_pairs:
found_pairs.append(pair) # Добавляем найденную пару
if len(found_pairs) + len(found_singles) >= 6: # считается количество элементов стола, а не физических карт
stop_event.set() # Дропаем на 6
return
card = cards[0] # если это одиночная карта
if card not in found_singles: # Добавляем одиночную карту
found_singles.add(card)
if len(found_pairs) + len(found_singles) >= 6: # считается количество элементов стола, а не физических карт
stop_event.set() # Дропаем на 6
# Разделяем шаблоны на пары и одиночные
pair_templates = [item for item in templates if len(item[0]) == 2]
single_templates = [item for item in templates if len(item[0]) == 1]
# Сначала ищем одиночки
with ThreadPoolExecutor(max_workers=4) as executor:
for result in executor.map(check, single_templates):
pass
# это легаси проверка которая была когда проверяли сначала пары
if len(found_pairs) + len(found_singles) < 6:
stop_event.clear()
with ThreadPoolExecutor(max_workers=4) as executor:
for result in executor.map(check, pair_templates):
pass
found = set() # финальный набор
for pair in found_pairs: # ('JH', 'TH'), - > 'JH', 'TH',
found.update(pair)
found.update(found_singles) # Добавляем одиночные
# Однажды оно вернуло {'JH.', 'KH'}, почему, не понятно, но в остальном нормально было, убрали точку {'JH.', 'KH'} станет {'JH', 'KH'}
found = {card[:2] if len(card) == 3 and card.endswith(".") else card for card in found}
if found:
log(f'Найдены карты на столе: {found}')
log(f'Время выполнения detect_table: {time.perf_counter() - start:.4f} сек')
return sorted(found)
# Добор + он же анализ начально полученных карт
def detect_draw(screen):
start = time.perf_counter() # Запускаем таймер для понимании за сколько отработала функция
screen = screen[577:829, 539:1065]
templates = [] # Сюда идут все шаблоны
for file in os.listdir("templates/start"):
name = file[:2] # Первые два символа имени файла это название карты, типа 9S
image = cv2.imread(f"templates/start/{file}")
templates.append((name, image)) # название карты + шаблон
found = set() # сюда идут найденные
lock = threading.Lock() # это нужно для синхронизации потоков
stop_event = threading.Event() # это нужно что бы дропнуть функцию после того как нашли 6
# Функция для проверки одной штуки шаблон
def check(template):
if stop_event.is_set(): # Если нашли уже 6 больше не надо
return None
name, template = template # Название карты + шаблон
with lock: # не была ли эта карта уже найдена другим потоком (на другой позиции)
if name in found:
return None
_, score, _, _ = cv2.minMaxLoc( # сравнимаем шаблон + скриншот
cv2.matchTemplate(
screen,
template,
cv2.TM_CCORR_NORMED
)
)
# Проверка совпадения. Изначально было на 0.993 но так слишком жестко получалось, на 0.991 вроде очень хорошо отрабатывает
if score > 0.991:
with lock:
found.add(name) # Добавляем найденную карту
if len(found) >= 6: # Если нашли 6 карт дропаем
stop_event.set()
return name
return None
# Запускаем проверку в 4 потоках для всех шаблонов
with ThreadPoolExecutor(max_workers=4) as executor:
for name in executor.map(check, templates):
pass
log(f'Найдены карты: {found}')
log(f'Время выполнения detect_draw: 'f'{time.perf_counter() - start:.4f} сек')
return sorted(found)
# Берет 4 возможных козыря и делает сверку - ВСЕ
def detect_trump(screen):
screen = screen[301:455, 540:641]
for suit, template in TRUMPS.items():
result = cv2.matchTemplate(
screen,
template,
cv2.TM_CCORR_NORMED
)
_, score, _, _ = cv2.minMaxLoc(result)
if score > 0.99:
return suit
return None
def check_final_step(screen):
trump = state.game['trump'] # узнаем козырь
if trump is None:
return False
template = FINAL_STEPS[trump] # нужен что бы сократить количество сверок с 4 до 1
screen = screen[280:406, 544:652]
result = cv2.matchTemplate(
screen,
template,
cv2.TM_CCORR_NORMED
)
_, score, _, _ = cv2.minMaxLoc(result)
if score > 0.99:
log(f'{trump} - финальный шаг')
final_cards = [
card
for card, status in state.cards.items()
if status in (state.UNKNOWN, state.ENEMY)
]
log(f'Карты на финальном шаге: {final_cards}')
send_final_cards_to_telegram(final_cards)
return True
return False
# Проверяем а не закончилась ли игра
def check_game_end(screen):
screen = screen[9:868, 720:875]
result = cv2.matchTemplate(
screen,
END,
cv2.TM_CCOEFF_NORMED
)
_, score, _, _ = cv2.minMaxLoc(result)
return score > 0.9
def check_steps(screen, previous_screen):
screen = screen[13:868, 739:866]
# Проверка DONE универсальна (сразу и на вверх и на низ)
result = cv2.matchTemplate(
screen,
STEPS['done'],
cv2.TM_CCORR_NORMED
)
_, score, _, _ = cv2.minMaxLoc(result)
if score > 0.99:
log('Done найден')
log('Нужно проверить I take')
result = cv2.matchTemplate(
screen,
STEPS['i_take_up'],
cv2.TM_CCORR_NORMED
)
_, score_up, _, _ = cv2.minMaxLoc(result)
if score_up > 0.99:
log('I take найден вверху')
log('Проверили стол')
save_previous_screen(previous_screen)
founds_cards_on_table = detect_table(previous_screen)
log(f'Найдены карты: {founds_cards_on_table}')
log('Найденные карты статус Enemy')
update_cards_its_enemy_now(founds_cards_on_table)
log('Проверка добора')
time.sleep(1)
pure_screen = np.array(ImageGrab.grab(all_screens=True)) # Тут делаем скрин, нужен более свежий, тот что летает на анализе он немного не готов
pure_screen = cv2.cvtColor(pure_screen,cv2.COLOR_RGB2BGR)
found_cards = detect_draw(pure_screen)
log(f'Найдены карты: {found_cards}')
update_cards_its_my_now(found_cards)
return True
# I TAKE DOWN
result = cv2.matchTemplate(
screen,
STEPS['i_take_down'],
cv2.TM_CCORR_NORMED
)
_, score_down, _, _ = cv2.minMaxLoc(result)
if score_down > 0.99:
log('I take найден внизу')
log('Проверили стол')
save_previous_screen(previous_screen)
founds_cards_on_table = detect_table(previous_screen)
log(f'Найдены карты: {founds_cards_on_table}')
log('Найденные карты статус Me')
update_cards_its_my_now(founds_cards_on_table)
log('Проверка добора')
time.sleep(1)
pure_screen = np.array(ImageGrab.grab(all_screens=True)) # Тут делаем скрин, нужен более свежий, тот что летает на анализе он немного не готов
pure_screen = cv2.cvtColor(pure_screen,cv2.COLOR_RGB2BGR)
found_cards = detect_draw(pure_screen)
log(f'Найдены карты: {found_cards}')
update_cards_its_my_now(found_cards)
return True
log('I take не найден')
log('Проверили стол')
save_previous_screen(previous_screen)
founds_cards_on_table = detect_table(previous_screen)
log(f'Найдены карты: {founds_cards_on_table}')
log('Найденные карты вышли')
update_cards_its_discard_now(founds_cards_on_table)
log('Проверка добора')
time.sleep(1)
pure_screen = np.array(ImageGrab.grab(all_screens=True)) # Тут делаем скрин, нужен более свежий, тот что летает на анализе он немного не готов
pure_screen = cv2.cvtColor(pure_screen,cv2.COLOR_RGB2BGR)
found_cards = detect_draw(pure_screen)
log(f'Найдены карты: {found_cards}')
update_cards_its_my_now(found_cards)
return True
# PASS UP
result = cv2.matchTemplate(
screen,
STEPS['pass_up'],
cv2.TM_CCORR_NORMED
)
_, score_up, _, _ = cv2.minMaxLoc(result)
if score_up > 0.99:
log('Опонент пассует - у нас I take')
log('Проверяем стол')
save_previous_screen(previous_screen)
founds_cards_on_table = detect_table(previous_screen)
log(f'Найдены карты: {founds_cards_on_table}')
log('Найденные карты статус Me')
update_cards_its_my_now(founds_cards_on_table)
return True
# PASS DOWN
result = cv2.matchTemplate(
screen,
STEPS['pass_down'],
cv2.TM_CCORR_NORMED
)
_, score_down, _, _ = cv2.minMaxLoc(result)
if score_down > 0.99:
log('Мы пассуем - у Enemy I take')
log('Проверяем стол')
save_previous_screen(previous_screen)
founds_cards_on_table = detect_table(previous_screen)
log(f'Найдены карты: {founds_cards_on_table}')
log('Найденные карты статус Enemy')
update_cards_its_enemy_now(founds_cards_on_table)
return True
return False
Весь проект - https://gofile.io/d/4FFkQXCD
Плюс 3 процента винрейта через технологию компьютерного зрения.
👍48
🔥9
😁4
😮9
😢4
💯7
LIMB