🔥 新作首發 🎮 天堂私服 YOLOv8 物件偵測實戰 — 從資料蒐集、模型訓練到即時偵測 立即閱讀 →
熱門系列
Like Share Discussion Bookmark Smile

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

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

⚠️ 免責聲明

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

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

📚 前言

在上一篇 (三)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


💻 目標優先序模組: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(最終篇版本)

相較 Part 3,變更的就這幾處:

  1. SharedState 新增 target 欄位
  2. detection_loop 多收一個 get_selector lambda;跑完 detectselector.select() 選目標,再傳進 detector.render(..., target=target)
  3. 怪物偵測設定面板加入「優先類別」輸入欄(逗號分隔,靠左優先)
  4. 偵測預覽區底部多一個「目標:」label 顯示當前鎖定目標
  5. _start 建立 TargetSelector_poll 每 200ms 把 優先類別 Entry 的最新內容寫回 selector.priority執行中改類別順序、下一幀就生效
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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
# main.py(最終篇版本)
import json
import os
import queue
import threading
import time
import tkinter as tk
from tkinter import ttk, scrolledtext
from typing import List, Optional

import cv2
import numpy as np
import win32api
from PIL import Image, ImageTk

from window_capture import capture_window, window_from_point
from hp_monitor import HPMonitor, CONFIG_FILE
from auto_potion import AutoPotion
from detector import Detector
from target_selector import TargetSelector

VK_LBUTTON = 0x01
VK_ESCAPE = 0x1B

POTION_KEYS = ["F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]
PREVIEW_W, PREVIEW_H = 200, 130


# ── Shared State ───────────────────────────────────────────────
class SharedState:
"""執行緒安全的共享資料容器"""
def __init__(self):
self._lock = threading.Lock()
self.hp = 1.0
self.mp = 1.0
self.hwnd = None
self.running = False
self.detections = [] # List[Dict]
self.target = None # Dict | None(本篇新增)
self.preview = None # BGR numpy array

def update(self, **kwargs):
with self._lock:
for k, v in kwargs.items():
setattr(self, k, v)

def get(self, *keys):
with self._lock:
if len(keys) == 1:
return getattr(self, keys[0])
return tuple(getattr(self, k) for k in keys)


# ── Worker Thread 1:監控 + 補藥 + 傳幀(與 Part 3 相同) ──────
def monitor_loop(state: SharedState, monitor: HPMonitor, log_q: queue.Queue,
frame_q: queue.Queue,
potion: Optional[AutoPotion] = None):
hwnd = state.get("hwnd")
log_q.put(f"監控執行緒啟動,hwnd={hwnd}")

try:
while state.get("running"):
try:
frame = capture_window(hwnd)
if frame is None:
time.sleep(0.2)
continue
hp, mp = monitor.read(frame)
state.update(hp=hp, mp=mp)
if potion is not None:
potion.check(hp, mp)

try:
frame_q.put_nowait(frame)
except queue.Full:
try:
frame_q.get_nowait()
except queue.Empty:
pass
frame_q.put_nowait(frame)

time.sleep(0.2)
except Exception as e:
log_q.put(f"⚠️ 監控迴圈錯誤:{type(e).__name__}: {e}")
time.sleep(0.5)
finally:
state.update(running=False)
log_q.put("監控執行緒結束")


# ── Worker Thread 2:YOLO + 目標選擇(本篇更新) ───────────────
def detection_loop(state: SharedState, log_q: queue.Queue,
frame_q: queue.Queue,
get_detector, get_selector):
"""
Thread 2 常駐;get_detector/get_selector 皆為 lambda,支援執行中熱換:
- 模型未載入/停用:丟棄該幀、順便清空 target
- 有 selector 才做目標選擇;否則只畫一般 bbox
"""
log_q.put("偵測執行緒啟動")

try:
while state.get("running"):
try:
frame = frame_q.get(timeout=1.0)
except queue.Empty:
continue

detector = get_detector()
if detector is None or not detector.enabled:
state.update(target=None)
continue

try:
detections = detector.detect(frame)
selector = get_selector()
h, w = frame.shape[:2]
target = (selector.select(detections,
frame_center=(w // 2, h // 2))
if selector is not None else None)
preview = detector.render(frame, detections, target=target)
state.update(detections=detections,
target=target, preview=preview)
except Exception as e:
log_q.put(f"⚠️ 偵測迴圈錯誤:{type(e).__name__}: {e}")
time.sleep(0.5)
finally:
log_q.put("偵測執行緒結束")


# ── tkinter 主視窗 ─────────────────────────────────────────────
class LineageAssistant:
BG = "#1a1a2e"
FG = "#e0e0e0"
RED = "#e94560"
BLUE = "#4fc3f7"
GREEN = "#00ff99"
AMBER = "#f5a623"
AMBER_HOVER = "#ffc857"
MUTED = "#555577"

def __init__(self):
self.state = SharedState()
self.monitor = HPMonitor()
self.log_q = queue.Queue()
self.frame_q = queue.Queue(maxsize=2)
self.t_monitor = None
self.t_detection = None
self.potion = None
self.detector = None
self.selector = None # ← 本篇新增
self._tk_img = None
self._build_ui()
self._autoload_detector()
self._poll()
self.root.mainloop()

def _make_icon_btn(self, parent, text, cmd, font):
btn = tk.Button(
parent, text=text,
bg=self.BG, fg=self.AMBER,
activebackground=self.BG, activeforeground=self.AMBER_HOVER,
disabledforeground=self.MUTED,
font=font, relief="flat", bd=0, cursor="hand2",
command=cmd)

def on_enter(_):
if btn["state"] != "disabled":
btn.config(fg=self.AMBER_HOVER)

def on_leave(_):
if btn["state"] != "disabled":
btn.config(fg=self.AMBER)

btn.bind("<Enter>", on_enter)
btn.bind("<Leave>", on_leave)
return btn

def _build_ui(self):
self.root = tk.Tk()
self.root.title("天堂私服輔助 v0.4")
self.root.configure(bg=self.BG)
self.root.resizable(False, False)

# ── 標題 ──
tk.Label(self.root, text="⚔ 天堂私服輔助",
bg=self.BG, fg=self.FG,
font=("Consolas", 16, "bold")).pack(pady=8)

# ── 工具列 ──
toolbar = tk.Frame(self.root, bg=self.BG)
toolbar.pack(padx=20, pady=(0, 4), fill="x")

self.btn_pick = self._make_icon_btn(
toolbar, "🎯 選取視窗", self._pick_window,
font=("Consolas", 11, "bold"))
self.btn_pick.pack(side="left")

self.btn_calib = self._make_icon_btn(
toolbar, "⚙ 校準血條", self._calibrate,
font=("Consolas", 11, "bold"))
self.btn_calib.pack(side="right")

# ── 目標視窗資訊 ──
self.target_var = tk.StringVar(value="目標視窗:未選擇")
tk.Label(self.root, textvariable=self.target_var,
bg=self.BG, fg=self.FG,
font=("Consolas", 10), anchor="w"
).pack(padx=20, pady=(0, 6), fill="x")

# ── HP / MP 血條顯示 ──
bar_frame = tk.Frame(self.root, bg=self.BG)
bar_frame.pack(padx=20, fill="x")

style = ttk.Style()
style.theme_use("default")
style.configure("Red.Horizontal.TProgressbar",
troughcolor="#330000", background="#e94560")
style.configure("Blue.Horizontal.TProgressbar",
troughcolor="#001133", background="#4fc3f7")

tk.Label(bar_frame, text="HP", bg=self.BG, fg=self.RED,
font=("Consolas", 11, "bold"), width=4
).grid(row=0, column=0, sticky="w")
self.hp_var = tk.DoubleVar(value=1.0)
ttk.Progressbar(bar_frame, variable=self.hp_var, maximum=1.0,
length=280, style="Red.Horizontal.TProgressbar"
).grid(row=0, column=1, padx=5, pady=3)
self.hp_lbl = tk.Label(bar_frame, text="100%",
bg=self.BG, fg=self.FG,
font=("Consolas", 10), width=6)
self.hp_lbl.grid(row=0, column=2)

tk.Label(bar_frame, text="MP", bg=self.BG, fg=self.BLUE,
font=("Consolas", 11, "bold"), width=4
).grid(row=1, column=0, sticky="w")
self.mp_var = tk.DoubleVar(value=1.0)
ttk.Progressbar(bar_frame, variable=self.mp_var, maximum=1.0,
length=280, style="Blue.Horizontal.TProgressbar"
).grid(row=1, column=1, padx=5, pady=3)
self.mp_lbl = tk.Label(bar_frame, text="100%",
bg=self.BG, fg=self.FG,
font=("Consolas", 10), width=6)
self.mp_lbl.grid(row=1, column=2)

# ── 自動補藥設定面板 ──
pot_frame = tk.LabelFrame(self.root, text=" 自動補藥設定 ",
bg=self.BG, fg="#aaaaaa",
font=("Consolas", 9))
pot_frame.pack(padx=12, pady=4, fill="x")

self.potion_enabled = tk.BooleanVar(value=True)
tk.Checkbutton(pot_frame, text="啟用自動補藥",
variable=self.potion_enabled,
command=self._on_potion_toggle,
bg=self.BG, fg=self.FG, selectcolor=self.BG,
font=("Consolas", 9)
).grid(row=0, column=0, columnspan=5,
sticky="w", padx=6, pady=2)

tk.Label(pot_frame, text="HP 觸發 %", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=1, column=0, sticky="w", padx=6)
self.hp_thresh = tk.IntVar(value=60)
ttk.Scale(pot_frame, from_=10, to=90, variable=self.hp_thresh,
orient="horizontal", length=120
).grid(row=1, column=1, padx=4)
self.hp_thresh_lbl = tk.Label(pot_frame, text="60%",
bg=self.BG, fg=self.RED,
font=("Consolas", 9), width=5)
self.hp_thresh_lbl.grid(row=1, column=2)
tk.Label(pot_frame, text="鍵", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=1, column=3, padx=2)
self.hp_key_var = tk.StringVar(value="F5")
ttk.Combobox(pot_frame, textvariable=self.hp_key_var,
values=POTION_KEYS, state="readonly",
width=4, font=("Consolas", 10)
).grid(row=1, column=4, padx=4)

tk.Label(pot_frame, text="MP 觸發 %", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=2, column=0, sticky="w", padx=6)
self.mp_thresh = tk.IntVar(value=40)
ttk.Scale(pot_frame, from_=10, to=90, variable=self.mp_thresh,
orient="horizontal", length=120
).grid(row=2, column=1, padx=4)
self.mp_thresh_lbl = tk.Label(pot_frame, text="40%",
bg=self.BG, fg=self.BLUE,
font=("Consolas", 9), width=5)
self.mp_thresh_lbl.grid(row=2, column=2)
tk.Label(pot_frame, text="鍵", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=2, column=3, padx=2)
self.mp_key_var = tk.StringVar(value="F6")
ttk.Combobox(pot_frame, textvariable=self.mp_key_var,
values=POTION_KEYS, state="readonly",
width=4, font=("Consolas", 10)
).grid(row=2, column=4, padx=4)

# ── 怪物偵測設定(本篇新增「優先類別」欄位) ──
det_frame = tk.LabelFrame(self.root, text=" 怪物偵測設定 ",
bg=self.BG, fg="#aaaaaa",
font=("Consolas", 9))
det_frame.pack(padx=12, pady=4, fill="x")

self.detect_enabled = tk.BooleanVar(value=True)
tk.Checkbutton(det_frame, text="啟用怪物偵測",
variable=self.detect_enabled,
command=self._on_detect_toggle,
bg=self.BG, fg=self.FG, selectcolor=self.BG,
font=("Consolas", 9)
).grid(row=0, column=0, columnspan=3,
sticky="w", padx=6, pady=2)

tk.Label(det_frame, text="模型路徑", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=1, column=0, sticky="w", padx=6)
self.model_path_var = tk.StringVar(value="models/lineage_detector.pt")
tk.Entry(det_frame, textvariable=self.model_path_var, width=26,
bg="#0d0d1a", fg=self.FG, font=("Consolas", 9),
insertbackground="white"
).grid(row=1, column=1, padx=4, pady=2)
self.btn_load_model = self._make_icon_btn(
det_frame, "🔄 載入", self._load_detector,
font=("Consolas", 10, "bold"))
self.btn_load_model.grid(row=1, column=2, padx=4)

tk.Label(det_frame, text="信心度閾值", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=2, column=0, sticky="w", padx=6)
self.conf_thresh = tk.IntVar(value=50)
ttk.Scale(det_frame, from_=10, to=90, variable=self.conf_thresh,
orient="horizontal", length=120
).grid(row=2, column=1, padx=4)
self.conf_lbl = tk.Label(det_frame, text="50%",
bg=self.BG, fg=self.GREEN,
font=("Consolas", 9), width=5)
self.conf_lbl.grid(row=2, column=2)

# 本篇新增:優先類別輸入欄(逗號分隔,靠左優先)
tk.Label(det_frame, text="優先類別", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=3, column=0, sticky="w", padx=6)
self.priority_var = tk.StringVar(value="orc,troll,zombie")
tk.Entry(det_frame, textvariable=self.priority_var, width=26,
bg="#0d0d1a", fg="#ffe082", font=("Consolas", 9),
insertbackground="white"
).grid(row=3, column=1, columnspan=2, padx=4, pady=2)
tk.Label(det_frame, text="(逗號分隔,靠左優先;未列出的排最後)",
bg=self.BG, fg="#666666", font=("Consolas", 8)
).grid(row=4, column=0, columnspan=3,
sticky="w", padx=6, pady=(0, 4))

# ── 偵測預覽視窗 ──
preview_frame = tk.LabelFrame(self.root, text=" 偵測預覽 ",
bg=self.BG, fg="#aaaaaa",
font=("Consolas", 9))
preview_frame.pack(padx=12, pady=4, fill="x")

mode_row = tk.Frame(preview_frame, bg=self.BG)
mode_row.pack(fill="x", padx=6, pady=(2, 0))
self.preview_mode = tk.StringVar(value="live")
for val, txt in [("live", "即時畫面"), ("grid", "格子地圖")]:
tk.Radiobutton(mode_row, text=txt,
variable=self.preview_mode, value=val,
command=self._on_preview_mode,
bg=self.BG, fg=self.FG, selectcolor=self.BG,
activebackground=self.BG, activeforeground=self.FG,
font=("Consolas", 9)
).pack(side="left", padx=6)

blank = np.zeros((PREVIEW_H, PREVIEW_W, 3), dtype=np.uint8)
self._tk_img = ImageTk.PhotoImage(Image.fromarray(blank))
self.preview_lbl = tk.Label(preview_frame, image=self._tk_img,
bg=self.BG)
self.preview_lbl.pack(padx=4, pady=4)

# 本篇新增:偵測數 + 目標 name 同一列顯示
info_row = tk.Frame(preview_frame, bg=self.BG)
info_row.pack(fill="x", padx=6, pady=(0, 4))
self.det_count_lbl = tk.Label(info_row, text="偵測數:0",
bg=self.BG, fg=self.GREEN,
font=("Consolas", 9))
self.det_count_lbl.pack(side="left")
self.target_lbl = tk.Label(info_row, text="目標:—",
bg=self.BG, fg=self.RED,
font=("Consolas", 9, "bold"))
self.target_lbl.pack(side="right")

# ── 控制按鈕:單顆 ▶ / ■ toggle ──
btn_frame = tk.Frame(self.root, bg=self.BG)
btn_frame.pack(pady=8)

self.btn_toggle = self._make_icon_btn(
btn_frame, "▶ 啟動", self._toggle,
font=("Consolas", 13, "bold"))
self.btn_toggle.pack()

# ── 狀態列 ──
self.status_var = tk.StringVar(value="就緒")
tk.Label(self.root, textvariable=self.status_var,
bg=self.BG, fg="#888888",
font=("Consolas", 9)).pack()

# ── 日誌視窗 ──
self.log_box = scrolledtext.ScrolledText(
self.root, width=52, height=8, state="disabled",
bg="#0d0d1a", fg=self.GREEN,
font=("Consolas", 9), insertbackground="white")
self.log_box.pack(padx=10, pady=(5, 10))

def _log(self, msg: str):
self.log_q.put(msg)

def _flush_log(self):
while not self.log_q.empty():
msg = self.log_q.get_nowait()
ts = time.strftime("%H:%M:%S")
self.log_box.config(state="normal")
self.log_box.insert("end", f"{ts} {msg}\n")
self.log_box.see("end")
self.log_box.config(state="disabled")

def _parse_priority(self) -> List[str]:
raw = self.priority_var.get()
return [s.strip() for s in raw.split(",") if s.strip()]

# ── 🎯 滑鼠點擊選窗(與 Part 3 相同) ─────────────────────
def _pick_window(self):
if self.state.get("running"):
self._log("請先停止監控再重選目標視窗")
return
self._log("🎯 請點擊目標遊戲視窗(ESC 可取消)")
for btn in (self.btn_pick, self.btn_calib,
self.btn_load_model, self.btn_toggle):
btn.config(state="disabled")
self.root.config(cursor="crosshair")
self._poll_pick()

def _poll_pick(self):
if win32api.GetAsyncKeyState(VK_ESCAPE) & 0x8000:
self._reset_pick()
self._log("已取消選擇視窗")
return
if win32api.GetAsyncKeyState(VK_LBUTTON) & 0x8000:
x, y = win32api.GetCursorPos()
hwnd, title, pid = window_from_point(x, y)
self._reset_pick()
if hwnd:
self.state.update(hwnd=hwnd)
label = title or "<無標題>"
short = label if len(label) <= 30 else label[:28] + "…"
self.target_var.set(f"目標:{short} (pid={pid})")
self._log(f"已鎖定:{label} hwnd={hwnd} pid={pid}")
else:
self._log("未取得有效視窗,請再試一次")
return
self.root.after(30, self._poll_pick)

def _reset_pick(self):
self.root.config(cursor="")
for btn in (self.btn_pick, self.btn_calib,
self.btn_load_model, self.btn_toggle):
btn.config(state="normal")

# ── ⚙ ROI 校準(與 Part 3 相同) ─────────────────────────
def _calibrate(self):
if self.state.get("running"):
self._log("請先停止監控再校準")
return
hwnd = self.state.get("hwnd")
if not hwnd:
self._log("請先按「🎯 選取視窗」選取目標視窗")
return

frame = capture_window(hwnd)
if frame is None:
self._log("擷取失敗,視窗可能被最小化")
return

self._log("框選 HP 血條(紅色),Enter 確認")
hp = cv2.selectROI("校準 HP 血條(Enter 確認 / C 重選)",
frame, fromCenter=False, showCrosshair=True)
cv2.destroyAllWindows()

self._log("框選 MP 魔力條(藍色),Enter 確認")
mp = cv2.selectROI("校準 MP 魔力條(Enter 確認 / C 重選)",
frame, fromCenter=False, showCrosshair=True)
cv2.destroyAllWindows()

if hp[2] == 0 or mp[2] == 0:
self._log("校準已取消(未框選有效範圍)")
return

self.monitor.hp_roi = tuple(hp)
self.monitor.mp_roi = tuple(mp)
self._save_config()
self._log(f"✅ 校準完成:HP={hp}, MP={mp}")

def _save_config(self):
cfg = {
"hp_roi": list(self.monitor.hp_roi),
"mp_roi": list(self.monitor.mp_roi),
}
with open(CONFIG_FILE, "w") as f:
json.dump(cfg, f, indent=2)
self._log(f"已寫入 {CONFIG_FILE}")

# ── ▶ / ■ 切換 ──
def _toggle(self):
if self.state.get("running"):
self._stop()
else:
self._start()

def _on_potion_toggle(self):
if self.potion is not None:
self.potion.enabled = self.potion_enabled.get()
self._log(f"自動補藥:{'啟用' if self.potion_enabled.get() else '停用'}")

def _on_detect_toggle(self):
want = self.detect_enabled.get()
if self.detector is None:
if want:
self._log("尚未載入模型,請先填入路徑並按「🔄 載入」")
return
self.detector.enabled = want
self._log(f"怪物偵測:{'啟用' if want else '停用'}")

def _on_preview_mode(self):
mode = self.preview_mode.get()
if self.detector is not None:
self.detector.mode = mode
self._log(f"預覽模式:{'即時畫面' if mode == 'live' else '格子地圖'}")

def _autoload_detector(self):
path = self.model_path_var.get().strip()
if path and os.path.exists(path):
self._load_detector()
else:
self._log(
f"找不到預設模型:{path or '(未設定)'},"
f"請把 best.pt 放到該路徑,或改路徑後按「🔄 載入」"
)

def _load_detector(self):
path = self.model_path_var.get().strip()
if not path:
self._log("請先填入模型路徑")
return
self._log(f"載入模型中:{path} ...")
try:
new_det = Detector(model_path=path,
conf=self.conf_thresh.get() / 100)
new_det.enabled = self.detect_enabled.get()
new_det.mode = self.preview_mode.get()
self.detector = new_det # 原子指派,Thread 2 下一輪即熱換
self._log(
f"✅ 已載入模型:{path}"
f"(conf≥{self.conf_thresh.get()}%,"
f"{'啟用' if new_det.enabled else '停用'})"
)
except Exception as e:
self._log(f"⚠️ 載入模型失敗:{type(e).__name__}: {e}")

def _start(self):
hwnd = self.state.get("hwnd")
if not hwnd:
self._log("請先按「🎯 選取視窗」選取目標視窗")
return

self.potion = AutoPotion(
hwnd = hwnd,
hp_threshold = self.hp_thresh.get() / 100,
mp_threshold = self.mp_thresh.get() / 100,
hp_key = self.hp_key_var.get().lower(),
mp_key = self.mp_key_var.get().lower(),
cooldown = 1.5,
enabled = self.potion_enabled.get(),
log_fn = self._log,
)
self._log(
f"補藥設定:HP<{self.hp_thresh.get()}% 按{self.hp_key_var.get()},"
f"MP<{self.mp_thresh.get()}% 按{self.mp_key_var.get()}"
f"({'啟用' if self.potion_enabled.get() else '停用'})"
)

# 本篇新增:TargetSelector(啟動前後都可透過 priority_var 熱切換類別順序)
self.selector = TargetSelector(
priority_classes = self._parse_priority(),
min_conf = 0.0, # detector.conf 已濾過,這裡不重複過濾
log_fn = self._log,
)
self._log(f"優先類別:{self.selector.priority or '(未設定,採用距離排序)'}")

if self.detector is None and self.detect_enabled.get():
self._log("提示:目前尚無模型,可調整路徑後按「🔄 載入」再繼續")

self.state.update(running=True)

self.t_monitor = threading.Thread(
target=monitor_loop,
args=(self.state, self.monitor, self.log_q,
self.frame_q, self.potion),
daemon=True)
self.t_monitor.start()

# 透過 lambda 動態讀當前 Detector / TargetSelector,支援執行中熱換
self.t_detection = threading.Thread(
target=detection_loop,
args=(self.state, self.log_q, self.frame_q,
lambda: self.detector,
lambda: self.selector),
daemon=True)
self.t_detection.start()

self.btn_toggle.config(text="■ 停止")
self.status_var.set("運行中...")

def _stop(self):
self.state.update(running=False)
self.btn_toggle.config(text="▶ 啟動")
self.status_var.set("已停止")
self._log("使用者手動停止")

def _poll(self):
"""每 200ms 將 SharedState 同步到 UI;順便把面板設定即時推到背景模組"""
hp, mp, detections, target, preview = self.state.get(
"hp", "mp", "detections", "target", "preview")

self.hp_var.set(hp)
self.mp_var.set(mp)
self.hp_lbl.config(text=f"{hp * 100:.0f}%")
self.mp_lbl.config(text=f"{mp * 100:.0f}%")
self.hp_thresh_lbl.config(text=f"{self.hp_thresh.get()}%")
self.mp_thresh_lbl.config(text=f"{self.mp_thresh.get()}%")
self.conf_lbl.config(text=f"{self.conf_thresh.get()}%")

if preview is not None:
img_rgb = cv2.cvtColor(
cv2.resize(preview, (PREVIEW_W, PREVIEW_H)),
cv2.COLOR_BGR2RGB)
self._tk_img = ImageTk.PhotoImage(Image.fromarray(img_rgb))
self.preview_lbl.config(image=self._tk_img)
self.det_count_lbl.config(text=f"偵測數:{len(detections)}")

# 本篇新增:目標名稱 label
if target:
self.target_lbl.config(
text=f"目標:{target['name']} ({target['conf']:.2f})")
else:
self.target_lbl.config(text="目標:—")

workers_alive = any(
t is not None and t.is_alive()
for t in (self.t_monitor, self.t_detection))
if (not workers_alive
and self.btn_toggle.cget("text").startswith("■")):
self.state.update(running=False)
self.btn_toggle.config(text="▶ 啟動")
self.status_var.set("背景執行緒已停止(請看日誌)")

# 面板即時生效(CPython 下屬性寫入為原子操作)
if self.potion is not None:
self.potion.hp_threshold = self.hp_thresh.get() / 100
self.potion.mp_threshold = self.mp_thresh.get() / 100
self.potion.hp_key = self.hp_key_var.get().lower()
self.potion.mp_key = self.mp_key_var.get().lower()
if self.detector is not None:
self.detector.conf = self.conf_thresh.get() / 100
if self.selector is not None:
# 本篇新增:優先類別熱切換(下一幀生效)
self.selector.priority = self._parse_priority()

self._flush_log()
self.root.after(200, self._poll)


if __name__ == "__main__":
LineageAssistant()


圖:整合目標優先序選擇的主程式,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 文件