okya for MapleStory
Русский
English한국어日本語简体中文繁體中文EspañolPortuguêsไทยTiếng ViệtDeutschFrançaisItalianoNederlandsעבריתSvenskaРусский
Содержание статьи⌄
okya for MapleStory›Как это работает

Инженерные заметки · Python / OpenCV

Как это работает

Узнайте, как Classic MapleStory Bot превращает изображения с экрана в решения стратегии, на примере 10 фрагментов кода и наглядных схем алгоритмов.

Пиксели → наблюдения → решения → ввод

Клиент считывает видимые изображения экрана. Восприятие оценивает позиции и состояние, планировщик выбирает действие, а среда выполнения планирует локальный ввод с клавиатуры. Слои разделены: распознавание монстра само по себе не нажимает клавишу, а отправка клавиши не доказывает, что игра её приняла.

  1. 01Захват
  2. 02Восприятие
  3. 03Решение
  4. 04Локальный ввод

01Захват игрового клиента#

Ветвь grab_client для Windows сначала находит клиентскую область, затем захватывает её прямоугольник через ImageGrab и переводит в RGB. Неверные размеры окна или изображения вызывают ошибку, а не передают в распознавание пустую картинку.

Python · vitals.py
window = find_client_origin(title)
if window is None:
    raise RuntimeError(active_translate("error_game_window_not_found", title=title))
left, top, width, height = window
if width <= 1 or height <= 1:
    raise RuntimeError(active_translate("error_game_capture_unavailable"))
image = ImageGrab.grab((left, top, left + width, top + height)).convert("RGB")
if image.width <= 1 or image.height <= 1:
    raise RuntimeError(active_translate("error_empty_game_capture_retry"))
return image
grab_clientL354–363

Этот фрагмент — ветвь для одной платформы. В macOS используется отдельный нативный путь захвата и обрезка клиентской области. Дальше код распознавания работает с BGR-массивами OpenCV: явно следите за порядком каналов при передаче изображений между Pillow и OpenCV.

02Ограничение поиска с помощью ROI#

Детектор мини-карты начинает с ограниченной области в левом верхнем углу. Он переводит её в оттенки серого, выделяет края методом Canny и получает контуры-кандидаты. Центры маркеров дают ещё одно ограничение на то, какой прямоугольник может быть картой.

Работа в нужной области

Кадр игры

Обрезка мини-карты

Прилагаемый скриншот игры и обрезка мини-карты показывают область интереса (ROI). Выделенная обрезка — пример, а не измеренный результат детектора. Меньшая область поиска сокращает число лишних кандидатов.
Python · map_position.py
if frame_bgr.size == 0 or frame_bgr.ndim != 3:
    return previous or generic_minimap_definition()
search = frame_bgr[:min(frame_bgr.shape[0], 420), :min(frame_bgr.shape[1], 430)]
edges = cv2.Canny(cv2.cvtColor(search, cv2.COLOR_BGR2GRAY), 45, 135)
contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)

marker_centers = [
    (x + width / 2.0, y + height / 2.0)
    for x, y, width, height, _ in _marker_components(search, player_pixels)
]
if not marker_centers:
    return previous or generic_minimap_definition()
detect_minimap_definitionL95–106

Это начало детектора, а не весь его алгоритм ранжирования прямоугольников. При неверных кадрах или отсутствии маркеров используется предыдущее определение или временное значение по умолчанию; такой откат не следует путать с новым подтверждённым распознаванием.

03Поиск персонажа по корреляции в оттенках серого#

match_player_feature переводит в оттенки серого и кадр, и маленький шаблон персонажа, а затем применяет TM_CCORR_NORMED. Необязательная маска позволяет исключить из сравнения прозрачные пиксели шаблона.

Шаблон скользит по изображению

Шаблон · T

Карта баллов · S(x, y)

Схема: каждая позиция-кандидат получает балл сходства. Яркая ячейка обозначает локальный максимум, а не реальное измерение на этом скриншоте. Нормированный балл корреляции не является вероятностью.
Python · vision.py
def match_player_feature(image_bgr: np.ndarray, template_bgr: np.ndarray,
                         mask: np.ndarray | None) -> np.ndarray:
    """Match normal player feature templates with tolerant grayscale correlation."""
    screen_gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
    template_gray = cv2.cvtColor(template_bgr, cv2.COLOR_BGR2GRAY)
    return cv2.matchTemplate(
        screen_gray,
        template_gray,
        cv2.TM_CCORR_NORMED,
        mask=mask,
    )
match_player_featureL100–110

Вызывающий код отбрасывает нечисловые баллы и выбирает лучшее совпадение до применения порога. В этом снимке порог равен 0,995; это параметр реализации, а не универсальный процент точности. Снимайте стабильную черту персонажа в обоих направлениях.

04Превращение баллов шаблона в цели#

Это ветвь detect_template для непрозрачных шаблонов. Она отклоняет шаблоны больше области поиска, строит карту баллов и собирает позиции, достигающие заданного порога.

Python · vision.py
if image_bgr.shape[0] < template.shape[0] or image_bgr.shape[1] < template.shape[1]:
    return []
scores = cv2.matchTemplate(image_bgr, template, cv2.TM_CCOEFF_NORMED)
ys, xs = np.where(scores >= threshold)
targets = [Target(int(x + template.shape[1] / 2),
                  int(y) + top_to_feet if top_to_feet is not None else feature_center_to_feet(int(y), template.shape[0]),
                  float(scores[y, x]))
           for y, x in zip(ys, xs)]
return _non_maximum(targets, 15)
detect_templateL217–225

Позиция отсчитывается от левого верхнего угла шаблона. Код переводит её в горизонтальный центр и откалиброванную позицию ног, затем удаляет дубликаты. Прозрачные шаблоны идут через отдельную ветвь корреляции с маской, поэтому методы сопоставления в двух ветвях не полностью совпадают.

05Слияние повторных срабатываний#

Одному монстру могут соответствовать несколько соседних позиций или шаблонов поз. _non_maximum сортирует кандидатов по уверенности и оставляет кандидата, только если он достаточно далеко от всех уже принятых результатов.

Python · vision.py
def _non_maximum(points: list[Target], min_distance: int) -> list[Target]:
    kept: list[Target] = []
    for point in sorted(points, key=lambda item: item.confidence, reverse=True):
        if all(abs(point.center_x - other.center_x) > min_distance or abs(point.feet_y - other.feet_y) > min_distance for other in kept):
            kept.append(point)
    return kept
_non_maximumL194–199

Реализация использует расстояние по x/y, а не IoU ограничивающих рамок. Побеждает соседний кандидат с более высокой уверенностью. Слишком большое расстояние может слить двух реальных соседей; слишком малое оставляет дубликаты и искажает число целей.

06Сегментация маркера мини-карты в HSV#

Поиск маркера сначала пробует точное совпадение BGR. Если валидного компонента нет, запасной вариант переводит изображение в HSV и использует цветовой диапазон выбранного профиля сервера с дополнительным допуском по насыщенности.

Сегментация по цвету, затем геометрия

RGB → HSV → Бинарная маска

Компонент → Локальная позиция (x, y)

Схема: диапазон цвета даёт маску; связные компоненты группируют соседние пиксели. Клиент также проверяет размеры и площадь компонента, а не считает игроком каждый жёлтый пиксель.
Python · map_position.py
hsv = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2HSV)
hue_low, saturation_low, value_low = player_pixels.activity_hsv_lower
lower = np.asarray(
    (hue_low, max(0, saturation_low - 40), value_low), dtype=np.uint8,
)
upper = np.asarray(player_pixels.activity_hsv_upper, dtype=np.uint8)
tolerant_mask = cv2.inRange(hsv, lower, upper)
return matching_components(tolerant_mask, exact_area=False)
_marker_componentsL79–86

Затем matching_components отбирает связные компоненты по ожидаемой ширине, высоте и площади. Запасной вариант терпит различия в управлении цветом, не принимая произвольные жёлтые фигуры. Диапазоны оттенков и геометрия компонентов относятся к профилю сервера, а не к одному жёстко заданному цвету для всех серверов.

07Перевод маркера в локальные координаты карты#

Срез изображения использует найденные границы мини-карты. Смещение ног, своё для каждого сервера, связывает компонент маркера с точкой стояния; функция возвращает позицию, только если остался ровно один кандидат.

Python · map_position.py
right = min(frame_bgr.shape[1], minimap.end_x + 1)
bottom = min(frame_bgr.shape[0], minimap.end_y + 1)
if minimap.start_x >= right or minimap.start_y >= bottom:
    return None
image = frame_bgr[minimap.start_y:bottom, minimap.start_x:right]
candidates: list[tuple[int, int]] = []
for x, y, _, _, _ in _marker_components(image, player_pixels):
    # Each server profile maps the matched component's origin to the
    # marker's standing point with its own vertical foot offset.
    candidates.append((x, y + player_pixels.foot_offset))
if len(candidates) != 1:
    return None
x, foot_y = candidates[0]
source_width = max(1, right - minimap.start_x)
source_height = max(1, bottom - minimap.start_y)
source_x = min(source_width - 1, x)
source_y = min(source_height - 1, foot_y)
return float(source_x), float(source_y)
detect_minimap_playerL154–171

Полученные x/y относятся к обрезанной мини-карте, а не к координатам рабочего стола. Границы ограничиваются доступным кадром. При нуле или нескольких кандидатах возвращается None, чтобы вызывающий код не выдумывал позицию по неоднозначным пикселям.

08Чтение HP/MP по цвету и голосам столбцов#

Здесь bar — обрезанное RGB-изображение полоски состояния, pixels — доступ к её пикселям, а valid_columns изначально пуст. Пиксели с преобладанием красного голосуют за HP, с преобладанием синего — за MP. Столбец засчитывается, только если в нём достаточно совпавших строк.

Оценка заполнения, а не чтение текста

100 × (x_last + 1) / width

Полоска состояния → Крайний правый валидный столбец

Схема: достаточное число совпавших пикселей делает столбец валидным. Крайний правый валидный столбец задаёт длину заполнения. Метод измеряет заданную область полоски и не читает числовой текст HP/MP.
Python · vitals.py
valid_columns: list[int] = []
for column in range(bar.width):
    matches = 0
    for row in range(bar.height):
        red, green, blue = pixels[column, row]
        if kind == "hp":
            colored = red >= 130 and red >= green * 1.5 and red >= blue * 1.5
        else:
            colored = blue >= 110 and blue >= red * 1.25 and blue >= green * 1.15
        matches += int(colored)
    if matches >= max(2, int(bar.height * 0.2)):
        valid_columns.append(column)
if not valid_columns:
    return 0.0
percent = 100 * (valid_columns[-1] + 1) / bar.width
return 100.0 if percent >= 95 else round(percent, 1)
_bar_percent_from_imageL293–308

Процент считается по крайнему правому валидному столбцу, а не по доле всех цветных пикселей. Без валидного столбца получается ноль; в этом снимке значения от 95 округляются до 100. Неверная область или случайный цветной пиксель могут исказить результат, поэтому проверьте обрезку перед включением восстановления.

09Планировщик, независимый от зрения#

HorizontalHuntPlanner получает Observation вместо скриншота. same_line_targets сначала фильтрует по допуску высоты ног; затем decide выбирает ближайшую горизонтальную цель, атакует в пределах дистанции, сближается вне её или патрулирует, если целей не осталось.

Python · horizontal_hunt.py
def decide(self, observation: Observation) -> Decision:
    targets = self.same_line_targets(observation)
    if targets:
        target = min(targets, key=lambda m: abs(m.center_x - observation.player_x))
        delta = target.center_x - observation.player_x
        if abs(delta) <= self.config.target_distance:
            return Decision(HuntState.ATTACK, "attack_burst", "right" if delta >= 0 else "left", target)
        return Decision(HuntState.APPROACH, "move_pulse", "right" if delta > 0 else "left", target)
    return Decision(HuntState.PATROL, "move_pulse")
HorizontalHuntPlanner.decideL52–60

Это чистая логика решений: возвращаемый Decision описывает намерение, но сам не нажимает клавиш. Юнит-тесты могут подставлять искусственные позиции и цели, проверяя границы дистанции и выбор направления без запуска игры.

10Реже запускать дорогое сопоставление#

Среда выполнения разделяет частоту поиска монстров и более быстрое отслеживание маршрута. Она использует time.monotonic, чтобы решать, когда обновлять распознавание, и между обновлениями повторно использует последний результат.

Python · hunt_runtime.py
def _monsters_for_frame(self, frame: np.ndarray) -> tuple:
    """Run expensive monster matching at a lower rate than route tracking."""
    if self._monster_detection_suppressed():
        self.last_detected_monsters = ()
        self.last_monster_detection_at = 0.0
        return ()
    now = time.monotonic()
    if (self.last_monster_detection_at <= 0.0
            or now - self.last_monster_detection_at >= self.MONSTER_DETECTION_INTERVAL_SECONDS):
        self.last_detected_monsters = tuple(self._detect_monsters(frame))
        self.last_monster_detection_at = time.monotonic()
    return self.last_detected_monsters
HorizontalHuntRunner._monsters_for_frameL155–166

Когда распознавание приостановлено, кэш монстров очищается, а не хранит старую цель бесконечно. Это сокращает повторную работу, но делает важным возраст кэша: планировщик не должен считать кэшированное наблюдение доказательством того, что цель всё ещё видна. Тайминги, обработка остановки и отпускание клавиш остаются задачами среды выполнения.

От детектора к надёжной системе#

Проверяйте каждый слой отдельно: фиксированный скриншот для распознавания, синтетический Observation для планирования и диагностический режим перед отправкой ввода. Высокий балл совпадения сам по себе не доказывает правильное распознавание. Перепроверяйте устаревшие наблюдения, перекрытия и неоднозначные маркеры; при остановке или выходе из главного цикла среда выполнения отпускает зажатые клавиши. Статья объясняет реализацию и не является гарантией совместимости или разрешением автоматизировать игру.