J.J. Huang   2026-04-22   Python OpenCV 08.專案實作篇   瀏覽次數:次  

Python | OpenCV 專案:天堂私服遊戲輔助(四)目標優先序選擇

📑 目錄
  1. ⚠️ 免責聲明
  2. 📚 前言
  3. 🎯 本篇目標
  4. 🗂️ 本篇新增/更新檔案
  5. 💻 目標優先序模組:target_selector.py
  6. 🔧 detector.py
  7. 💻 執行緒架構(本篇)
  8. 💻 主程式:main.py(最終篇版本)
  9. ⚠️ 注意事項
    1. 🎯 優先序與類別名稱
    2. 🧵 與 Part 3 的熱切換共存
    3. ❓️ 新手常踩的雷
  10. 🎯 結語

⚠️ 免責聲明

本文章內容僅供學術研究與電腦視覺技術學習之用途,所有程式碼與技術說明均以教育目的為出發點。

  • 本文作者不提供任何形式的輔助程式販售、散佈或商業服務
  • 本文所有範例程式僅限在自行架設的私有伺服器環境中測試,不得用於任何正式營運的線上遊戲伺服器。
  • 使用遊戲輔助程式可能違反個別遊戲的使用者條款,並導致帳號封鎖、法律責任等後果,讀者須自行承擔一切相關風險與責任
  • 本文技術內容若被用於任何違法或損害他人利益之行為,作者概不負責
  • 私有伺服器的架設與使用涉及遊戲著作權相關法律問題,讀者應自行評估所在地區的法規,並確認於合法範圍內使用。

📚 前言

在上一篇 (三)YOLOv8 怪物偵測整合 中,我們完成了雙執行緒偵測架構,並做出 live(bbox)/ grid(扁平菱形小地圖) 兩種預覽模式,也支援執行中熱換模型。

本篇加入 目標優先序選擇:當畫面同時出現多隻怪物時,依使用者設定的優先類別順序距離畫面中心的遠近,選出最值得攻擊的目標,並在 live 與 grid 兩種預覽中都以紅色特別標記。​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌‌​‌​​​‌‌​​​​‌​‌‌‌​​‌​​‌‌​​‌‌‌​‌‌​​‌​‌​‌‌‌​‌​​

系列總覽:

篇次 主題
主程式 GUI 框架與 HP/MP 血量監控
自動補藥
YOLOv8 怪物偵測整合
本篇(四/最終篇) 目標優先序選擇

🎯 本篇目標

  • 建立 target_selector.py:依「類別優先清單 + 距離畫面中心」排序,從偵測結果挑出最優先目標
  • 微調 detector.pyrender() / render_grid() 接收 target,讓 live 模式在鎖定目標框上畫紅色grid 模式把鎖定格塗紅
  • main.py 更新:SharedState 新增 target 欄位;detection_loop 呼叫 TargetSelector.select() 後把結果餵進 render()
  • UI 新增「優先類別」輸入欄(逗號分隔),預覽區底部顯示當前鎖定目標;優先清單可執行中熱切換

🗂️ 本篇新增/更新檔案

1
2
3
4
5
6
7
8
9
10
lineage_assistant/
├── main.py ← 更新(SharedState + Thread 2 + UI + _poll 推送優先清單)
├── window_capture.py ← 不變
├── hp_monitor.py ← 不變
├── auto_potion.py ← 不變
├── detector.py ← 更新(render / render_grid 接收 target)
├── target_selector.py ← 新增(本篇重點)
├── calibrate_game_roi.py ← 不變
└── models/
└── lineage_detector.pt

Python - 圖 1 (target architecture)
Python - 圖 2 (target selection flow)

💻 目標優先序模組:target_selector.py

排序規則(由高到低):​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌‌​‌​​​‌‌​​​​‌​‌‌‌​​‌​​‌‌​​‌‌‌​‌‌​​‌​‌​‌‌‌​‌​​

  1. 類別在優先清單中的位置越靠前,優先度越高
  2. 未列入清單的類別排最後
  3. 同優先度時,距離畫面中心越近越優先(減少滑鼠移動距離)

Thread 2 每秒會跑幾次 select(),若目標每幀都寫 log 會洗版,所以這裡用 _last_key 只在「目標實際切換」時才寫一筆。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# target_selector.py
import math
from typing import Callable, Dict, List, Optional


class TargetSelector:
def __init__(self,
priority_classes: Optional[List[str]] = None,
min_conf: float = 0.0,
log_fn: Optional[Callable[[str], None]] = None):
# detector.conf 已先做過一道信心度過濾;這裡 min_conf 預設 0 不重複過濾,
# 想只鎖定高信心目標時才拉高
self.priority = list(priority_classes or [])
self.min_conf = min_conf
self._log = log_fn or (lambda msg: None)
self._last_key = None # (name, center) — 只在目標切換時寫日誌

def select(self, detections: List[Dict],
frame_center: Optional[tuple] = None) -> Optional[Dict]:
"""從偵測結果中選出最優先目標;無可選目標則回傳 None"""
valid = [d for d in detections if d["conf"] >= self.min_conf]
if not valid:
if self._last_key is not None:
self._log("目標:無")
self._last_key = None
return None

def sort_key(d):
name = d["name"]
try:
prio = self.priority.index(name)
except ValueError:
prio = len(self.priority) # 未列出的類別排最後
dist = 0.0
if frame_center:
cx, cy = d["center"]
dist = math.hypot(cx - frame_center[0], cy - frame_center[1])
return (prio, dist)

valid.sort(key=sort_key)
target = valid[0]

key = (target["name"], target["center"])
if key != self._last_key:
self._log(
f"🎯 鎖定目標:{target['name']} "
f"({target['conf']:.2f}) @ {target['center']}"
)
self._last_key = key
return target

🔧 detector.py

Part 3 的 annotate() 本來就能接 target(鎖定的框畫紅色),只是 render() 派發器沒把它傳下去、render_grid() 也還沒用到。本篇補兩件事:

  • render() 加上 target 參數並派發下去
  • render_grid() 新增 target 參數,命中鎖定目標的那一格填紅色(其他怪物仍是黃色)

以下是完整的更新後 detector.py(imports 與模組常數 VIEW_EW / VIEW_NS / GRID_HALF / CANVAS_W / CANVAS_H / get_game_roi / get_tile_px 沿用 Part 3,改動處用 # ← 本篇新增 標註):​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌‌​‌​​​‌‌​​​​‌​‌‌‌​​‌​​‌‌​​‌‌‌​‌‌​​‌​‌​‌‌‌​‌​​

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# detector.py
from typing import Dict, List, Literal, Optional

import cv2
import numpy as np
from ultralytics import YOLO

from window_capture import (get_game_roi, get_tile_px,
TILES_PER_HALF_WIDTH, TILES_PER_HALF_HEIGHT)

# 菱形視野邊界(把螢幕上「左右 ±8 / 上下 ±10」換算回 iso 世界座標)
VIEW_EW = 2 * TILES_PER_HALF_WIDTH # 東西邊界:|wx + wy| ≤ 16
VIEW_NS = 2 * TILES_PER_HALF_HEIGHT # 南北邊界:|wy - wx| ≤ 20
GRID_HALF = (VIEW_EW + VIEW_NS) // 2 # 迴圈迭代半徑(菱形 4 角 |wx|/|wy| 最大 = 18)

CANVAS_W = 200 # 小地圖畫布寬(視野寬 16·DW = 192,加一點邊距)
CANVAS_H = 130 # 小地圖畫布高(視野高 20·DH = 120,加一點邊距)


class Detector:
def __init__(self, model_path: str, conf: float = 0.5):
self.model = YOLO(model_path)
self.conf = conf
self.enabled = True
self.mode: Literal["live", "grid"] = "live"

def detect(self, frame) -> List[Dict]:
"""回傳偵測結果,每筆為:
{"name": str, "conf": float,
"box": (x1, y1, x2, y2), "center": (cx, cy)}
"""
results = self.model(frame, conf=self.conf, verbose=False)
detections = []
for r in results:
for box in r.boxes:
name = self.model.names[int(box.cls)]
c = float(box.conf)
x1, y1, x2, y2 = map(int, box.xyxy[0])
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
detections.append({
"name": name, "conf": c,
"box": (x1, y1, x2, y2), "center": (cx, cy)
})
return detections

def render(self, frame, detections: List[Dict],
target: Optional[Dict] = None): # ← 本篇新增 target 參數
"""主執行緒呼叫入口:依 mode 派發到 annotate / render_grid,並把 target 傳下去"""
if self.mode == "grid":
return self.render_grid(frame, detections, target=target) # ← 本篇新增
return self.annotate(frame, detections, target=target)

def annotate(self, frame, detections: List[Dict],
target: Optional[Dict] = None):
"""live 模式:在原畫面上繪製偵測框(鎖定目標畫紅色,其他畫綠色)"""
out = frame.copy()
for d in detections:
x1, y1, x2, y2 = d["box"]
color = (0, 0, 220) if (target and d is target) else (0, 220, 0)
cv2.rectangle(out, (x1, y1), (x2, y2), color, 2)
label = f"{d['name']} {d['conf']:.2f}"
cv2.putText(out, label, (x1, y1 - 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
return out

def render_grid(self, frame, detections: List[Dict],
target: Optional[Dict] = None): # ← 本篇新增 target 參數
"""grid 模式:以扁平等角菱形格呈現怪物相對方位;鎖定目標格填紅"""
fh, fw = frame.shape[:2]
rx, ry, rw, rh = get_game_roi(fw, fh)
px, py = rx + rw / 2, ry + rh / 2 # 玩家像素座標(ROI 中心)
tw, th = get_tile_px(fw, fh) # 一格地磚像素:roi_w/16, roi_h/20(分母不同!)

# 偵測中心 → 世界格座標;同時記錄 target 落在哪一格(本篇新增)
monsters = set()
target_cell = None # ← 本篇新增
for d in detections:
cx, cy = d["center"]
dx, dy = cx - px, cy - py
wx = round(dx / tw - dy / th)
wy = round(dx / tw + dy / th)
if abs(wx + wy) <= VIEW_EW and abs(wy - wx) <= VIEW_NS:
monsters.add((wx, wy))
if target is not None and d is target: # ← 本篇新增
target_cell = (wx, wy)

canvas = np.full((CANVAS_H, CANVAS_W, 3), 26, dtype=np.uint8)
ccx, ccy = CANVAS_W // 2, CANVAS_H // 2
DW, DH = 12, 6

WHITE, BLACK = (240, 240, 240), (16, 16, 16)
YELLOW, EDGE = (0, 220, 255), (95, 95, 115)
RED = (0, 0, 220) # ← 本篇新增:鎖定目標色

def diamond(wx, wy, fill, edge):
cx = ccx + (wx + wy) * DW / 2
cy = ccy + (wy - wx) * DH / 2
pts = np.array([
[cx, cy - DH / 2],
[cx + DW / 2, cy ],
[cx, cy + DH / 2],
[cx - DW / 2, cy ],
], dtype=np.int32)
if fill is not None:
cv2.fillConvexPoly(canvas, pts, fill)
cv2.polylines(canvas, [pts], True, edge, 1, cv2.LINE_AA)

for wx in range(-GRID_HALF, GRID_HALF + 1):
for wy in range(-GRID_HALF, GRID_HALF + 1):
if abs(wx + wy) > VIEW_EW: continue
if abs(wy - wx) > VIEW_NS: continue
if wx == 0 and wy == 0:
diamond(wx, wy, BLACK, WHITE) # 玩家
elif (wx, wy) == target_cell: # ← 本篇新增分支
diamond(wx, wy, RED, WHITE) # 鎖定目標:紅底 + 白邊
elif (wx, wy) in monsters:
diamond(wx, wy, YELLOW, EDGE) # 其他怪物
else:
diamond(wx, wy, None, EDGE) # 空格
return canvas

💻 執行緒架構(本篇)

相較 Part 3,差異只在 Thread 2 多繞一趟 TargetSelector.select();Thread 1 與 Main 完全不變。

1
2
3
4
5
6
7
8
9
10
11
12
13
Worker Thread 1 (monitor_loop)                ← 不變
└── capture_window → HPMonitor.read() → potion.check()
→ frame_q.put_nowait(frame)

Worker Thread 2 (detection_loop) ← 本篇更新
└── frame_q.get() → detector.detect()
→ selector.select(detections, frame_center) ← 新增
→ detector.render(frame, detections, target=target)
→ SharedState.detections / target / preview

Main Thread (tkinter)
└── root.after(200ms) → _poll → 更新 HP/MP + 預覽 + 當前目標 label
→ 即時把優先清單推回 TargetSelector

💻 主程式:main.py(最終篇版本)

這段內容已加密
需要 ☕ 咖啡會員 或更高等級的密碼才能閱讀
💡 年度公開密碼,到 /support 直接取得

整合目標優先序選擇的主程式,Thread 2 呼叫 TargetSelector 選出目標後以紅框標記,UI 顯示當前鎖定目標的名稱與信心度
圖:整合目標優先序選擇的主程式,Thread 2 呼叫 TargetSelector 選出目標後以紅框標記,UI 顯示當前鎖定目標的名稱與信心度

⚠️ 注意事項

🎯 優先序與類別名稱

  • 類別名稱要與訓練時完全一致:大小寫、底線要跟 data.yamlclasses.txt 對得上,拼錯只會變「未列出」落到最後。
  • 未列入優先清單的類別仍會被偵測,只是排序時放最後;全部都沒列也 OK,這時會退化成「距離畫面中心近者優先」。
  • 距離基準是畫面中心:若要改用角色實際位置(ROI 中心),把 selector.select()frame_center 換成 get_game_roi 算出的中心即可。

🧵 與 Part 3 的熱切換共存

  • 優先清單即時生效_poll 每 200ms 從「優先類別」Entry 把字串解析成 list 寫回 selector.priority,執行中隨便改都下一幀生效,不用停啟。
  • min_conf 預設 0detector.conf 已經先濾一次,這裡再濾一次會讓 UI 滑桿失去意義、目標也可能忽有忽無。想只鎖定很高信心的目標時才手動拉高。
  • 熱換模型時 target 不會殘留:Thread 2 每輪都重算 target;detector.enabled=False 或模型未載入時會直接 state.update(target=None),預覽的目標 label 會立刻回到「—」。

❓️ 新手常踩的雷

  • selector.priority = new_list 是整包換:字串解析的結果一次 assign,CPython 下是原子操作;不要用 .append() 累加,否則熱切換時會越加越長。
  • d is target 用 identity 判相等annotaterender_grid 都用 d is target 判斷是否為鎖定目標,所以不能 deep-copy detections,否則紅框永遠畫不出來。
  • Entry 內容留空_parse_priority 會回傳空 list,此時 TargetSelector 退化成純距離排序,不會崩潰。

🎯 結語

本篇也是整個天堂私服遊戲輔助系列的最終篇。 從 Part 1 的 tkinter GUI 與 HP/MP 血量偵測、Part 2 的 PostMessage 自動補藥、Part 3 的雙執行緒 YOLOv8 偵測整合,到本篇的目標優先序選擇,整套輔助程式的視覺辨識 → 狀態判讀 → 目標決策核心流程到這裡已經完整。​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌‌​‌​​​‌‌​​​​‌​‌‌‌​​‌​​‌‌​​‌‌‌​‌‌​​‌​‌​‌‌‌​‌​​

至於更進一步的 自動攻擊怪物自動撿取道具,因為涉及主動向遊戲下指令(攻擊熱鍵、滑鼠點擊),在法律與遊戲條款上相對敏感,本系列就不再實作。對有興趣延伸的讀者而言,前四篇打下的基礎已涵蓋核心技術 —— 鎖定後怎麼按下攻擊鍵、偵測到 item 類別後怎麼點擊,本質上都是把 SharedState.target / SharedState.detections 接到既有的 PostMessage 或滑鼠 API 上 —— 技術上不困難,是否實作與如何使用,請自行評估所在地區的法律與風險

📖 如在學習過程中遇到疑問,或是想了解更多相關主題,建議回顧一下 Python | OpenCV 系列導讀,掌握完整的章節目錄,方便快速找到你需要的內容。

註:以上參考了
Ultralytics YOLOv8 官方文件
Python math.hypot 文件
Python queue.Queue 文件
tkinter ttk.Combobox 文件​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌‌​‌​​​‌‌​​​​‌​‌‌‌​​‌​​‌‌​​‌‌‌​‌‌​​‌​‌​‌‌‌​‌​​