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לכידת תוכנת המשחק#

ענף ה-Windows של grab_client מאתר תחילה את אזור הלקוח, ואז לוכד את המלבן שלו עם 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 סינתטי לתכנון, ומצב אבחון לפני שליחת קלט. ציון התאמה גבוה לבדו אינו הוכחה לזיהוי נכון. בדקו מחדש תצפיות ישנות, הסתרות וסמנים דו-משמעיים; זמן הריצה משחרר מקשים לחוצים בעצירה או ביציאה מהלולאה הראשית. המאמר מסביר את המימוש, ואינו ערובה לתאימות או היתר לבצע אוטומציה במשחק.