游戏窗口自动化点击与防误点设计:模板匹配 + 区域限制 + 强制置顶
"游戏的 UI 按钮比识别模型本身难写------'放弃奖励' 和 '提前离开' 之间就隔 200 像素,脚本一不留神就退出牌局。"

一、背景:识别准了 ≠ 脚本能玩
前三篇把"看牌"做完了:
但脚本跑起来后,新的问题来了:"怎么让脚本替我点屏幕?" 这一步看着简单,实际是我踩坑最久的------下面这张截图就是经典现场:

注意看这一帧有多少可点击元素:
| 元素 | 位置 | 期望行为 |
|---|---|---|
| 首充礼包(右上) | (1100, 80) | 不点(弹窗会盖住手牌带) |
| 首充图标(右下) | (1280, 690) | 不点 |
| +金币(底部) | (440, 700) | 不点(会触发充值弹窗) |
| 奖(底部) | (1050, 700) | 不点 |
| 过牌(中右) | (1080, 320) | 按需点 |
| 出牌提示气泡 | (650, 470) | 不点(看完自己消) |
| 聊天图标(右下) | (1300, 670) | 不点 |
| 挂小王(右侧) | (1100, 410) | 不点 |
如果脚本天真地用 find_tpl 全屏扫"出牌"模板,找到的最高分位置 80% 在"首充礼包"上------因为那是个红底大按钮,比对相似度比真正的"出牌"还高。
二、find_tpl 多尺度模板匹配
最底层的"在屏幕里找按钮"算法,和第 56 篇牌面识别的多尺度匹配思路一致,只是参数不同:
python
def find_tpl(gray, tpl_name, threshold=0.8, region=None, scales=(0.85, 0.9, 1.0, 1.1, 1.15)):
"""多尺度模板匹配:在 region 子图里找 tpl_name 模板。
返回 (cx, cy, score) 或 None。
"""
tpl = TEMPLATES[tpl_name] # 灰度图
if region is not None:
x0, y0, x1, y1 = region
sub = gray[y0:y1, x0:x1]
off_x, off_y = x0, y0
else:
sub = gray
off_x, off_y = 0, 0
best = None
for s in scales:
new_w = max(4, int(tpl.shape[1] * s))
new_h = max(4, int(tpl.shape[0] * s))
tpl_s = cv2.resize(tpl, (new_w, new_h), interpolation=cv2.INTER_AREA)
if tpl_s.shape[0] > sub.shape[0] or tpl_s.shape[1] > sub.shape[1]:
continue
res = cv2.matchTemplate(sub, tpl_s, cv2.TM_CCOEFF_NORMED)
_, score, _, loc = cv2.minMaxLoc(res)
if score > threshold and (best is None or score > best[2]):
best = (off_x + loc[0] + tpl_s.shape[1] // 2,
off_y + loc[1] + tpl_s.shape[0] // 2,
float(score))
return best
注意 scales=(0.85, 0.9, 1.0, 1.1, 1.15):和第 56 篇牌面识别的 0.5~1.5 不一样,按钮通常不需要太大尺度变化------UI 元素固定大小,缩放范围窄一些反而快。
三、第一个原则:每个按钮必须限定 region
这是自动化点击最重要的一条规则。 不限定 region,等于裸奔。
python
# 不同按钮的区域定义(投屏画面分辨率 1376×768)
REGIONS = {
"出牌": (100, 600, 1280, 720),
"过牌": (900, 280, 1200, 360),
"提示": (300, 280, 700, 400),
"放弃奖励": (400, 300, 850, 520), # 见踩坑第 6 节
"算贡": (200, 400, 1100, 700),
"取消托管": (900, 100, 1200, 200),
"进贡确认": (500, 400, 900, 600),
}
为什么必须限定?举几个反例:
| 不限定 region 的场景 | 错误点击 |
|---|---|
| "出牌"模板全屏扫 | 命中"首充礼包"红底按钮(更红) |
| "过牌"模板全屏扫 | 命中"+金币"圆形按钮(位置相近) |
| "提示"模板全屏扫 | 命中"挂小王"提示框(颜色相近) |
实战:所有"出牌"类按钮都限定在手牌带 y=600~720,误点率从 12% → 0.3%。
四、第二个原则:强制窗口置顶 + 真实坐标点击
脚本点击必须在游戏窗口前台才有效。Windows 下用 SetForegroundWindow + 模拟鼠标事件:
python
import win32gui, win32con, win32api, ctypes
def force_foreground(hwnd):
"""把窗口强制拉到前台。"""
if win32gui.IsIconic(hwnd):
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
win32gui.SetForegroundWindow(hwnd)
# Windows 焦点切换有保护,需要先按 Alt 解锁
ctypes.windll.user32.keybd_event(0x12, 0, 0, 0) # Alt down
ctypes.windll.user32.keybd_event(0x12, 0, 2, 0) # Alt up
win32gui.SetForegroundWindow(hwnd)
def click_xy(hwnd, x, y):
"""在窗口坐标 (x, y) 真实点击(先置顶再点击)。"""
force_foreground(hwnd)
# 把窗口客户区坐标转为屏幕坐标
left, top = win32gui.ClientToScreen(hwnd, (0, 0))
sx, sy = left + x, top + y
win32api.SetCursorPos((sx, sy))
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, sx, sy, 0, 0)
win32api.Sleep(50)
win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, sx, sy, 0, 0)
两个坑:
- Windows 焦点保护 :直接
SetForegroundWindow不一定生效,需要先按一下 Alt 模拟"切窗口"动作 - 客户区 vs 屏幕坐标:截图是客户区坐标(去掉标题栏),点击要转成屏幕坐标(加窗口位置)
五、第三个原则:截图与点击必须同一坐标系
python
def capture_window(hwnd):
"""截取窗口客户区,转灰度。"""
left, top, right, bottom = win32gui.GetClientRect(hwnd)
w, h = right - left, bottom - top
hwnd_dc = win32gui.GetWindowDC(hwnd)
mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc)
save_dc = mfc_dc.CreateCompatibleDC()
bmp = win32ui.CreateBitmap()
bmp.CreateCompatibleBitmap(mfc_dc, w, h)
save_dc.SelectObject(bmp)
# PW_RENDERFULLCONTENT = 2 让 DWM 截图包含图层窗口(投屏画面需要)
ctypes.windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), 2)
bmpinfo = bmp.GetInfo()
bmpstr = bmp.GetBitmapBits(True)
img = np.frombuffer(bmpstr, dtype=np.uint8).reshape(bmpinfo['bmHeight'], bmpinfo['bmWidth'], 4)
img = img[..., :3] # 去掉 alpha
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
关键参数:PrintWindow 的第三个参数 2(PW_RENDERFULLCONTENT)。如果不传,投屏画面是黑的(Windows 8+ 的 DWM 合成层)。
六、重头戏:防误点踩坑实录------"提前离开"按钮
这是我自动化点击史上最深的一个坑。
6.1 现象
牌局结束,弹出结算页。我设计的逻辑是:
- 看到"放弃奖励"按钮 → 点(关闭结算页)
- 没有"放弃奖励" → 不点,等 30 秒自己消
但脚本一上,对局结束就立刻退出大厅回到登录页------明显是误点了**"提前离开"**按钮。
6.2 排查
| 元素 | y 坐标范围 | x 坐标范围 |
|---|---|---|
| 活动广告"皇冠宝贝" | y=0~300 | x=300~1000 |
| 玩家头像/名字 | y=300~450 | x=200~1200 |
| "放弃奖励"按钮 | y=400~500 | x=500~800 |
| "提前离开"按钮 | y=550~650 | x=500~800 |
两个按钮横向位置几乎一样,"放弃奖励"在上、"提前离开"在下,距离只有 80~150 像素。
6.3 老代码的问题
旧代码:
python
# popup_region 是手牌带裁剪时复用的,区域很大
popup_region = (300, 150, 950, 720) # y 范围到 720
giveup = find_tpl(gray, '放弃奖励', threshold=0.60, region=popup_region)
问题分析:
| 问题 | 后果 |
|---|---|
| popup_region y 上限 720,把"提前离开"也包含进去 | find_tpl 在结算页里既找到了"放弃奖励"又找到了"提前离开",分数 0.78 vs 0.81------0.81 赢了,点了"提前离开" |
| threshold=0.60 太低 | 任何 0.6+ 的"放弃奖励"都触发,包括 logo/广告里的"放弃"字样 |
6.4 修复
python
# 新版:单独给 giveup 设区域 + 提高阈值
giveup_region = (400, 300, 850, 520) # 收窄到只看"放弃奖励"那一行
giveup = find_tpl(gray, '放弃奖励', threshold=0.80, region=giveup_region)
| 改动 | 旧 | 新 |
|---|---|---|
| region | (300, 150, 950, 720) | (400, 300, 850, 520) |
| threshold | 0.60 | 0.80 |
| 误点率 | 18.7% | 0.3% |
踩坑经验 :auto_test_loop.py 里早就留过一句注释------"桌面出现'提前离开'绝不点击,等着就行"。但当时没意识到"提前离开"和"放弃奖励"在同一个 find_tpl 候选里互相干扰,直到真挂了一次。
6.5 防误点的通用设计
总结三条防误点规则:
- 每个按钮独立 region------不让其他按钮的模板进入候选
- threshold ≥ 0.80------只信高置信度匹配,宁可不点不误点
- 多模板投票------"放弃奖励"和"提前离开"是反义按钮,绝不在同一个 region 里同时找
python
def safe_click_button(hwnd, gray, name, region, threshold=0.80):
"""安全点击:找不到高分按钮就不点。"""
result = find_tpl(gray, name, threshold=threshold, region=region)
if result is None:
return False
cx, cy, score = result
# 二次校验:候选位置 50px 内不能出现互斥按钮
for anti_name, anti_region in ANTI_BUTTONS.get(name, []):
if find_tpl(gray, anti_name, threshold=0.75, region=anti_region):
log.warn(f"互斥按钮 {anti_name} 同时出现,放弃点击 {name}")
return False
click_xy(hwnd, cx, cy)
return True
七、其他防误点设计
7.1 取消托管按钮过滤
玩家手动点过"托管"后,结算页会多一个"取消托管"按钮。
python
# 看到"取消托管"→ 跳过所有"放弃奖励"逻辑
if find_tpl(gray, '取消托管', 0.85, (900, 100, 1200, 200)):
return # 玩家在托管,别动他
7.2 算贡按钮 vs 出牌按钮
进贡阶段会有"进贡确认"按钮,位置和出牌按钮接近(手牌带同一区域):
python
# 看到"进贡确认"→ 不要走"出牌"分支
if find_tpl(gray, '进贡确认', 0.85, (500, 400, 900, 600)):
handle_gong_phase()
return
7.3 广告弹窗
首充礼包、皇冠宝贝、签到奖励这类弹窗,强制关掉:
python
# 任何"首充"类弹窗出现,30s 内直接关
for ad_name in ['首充礼包', '皇冠宝贝', '签到']:
if find_tpl(gray, ad_name, 0.80):
find_and_click(hwnd, '关闭X', 0.80)
time.sleep(1)
八、算贡场景的 UI 复杂度
算贡是够级最复杂的环节------4 个玩家同时交互,UI 上同时存在:

这一帧同时有:
- 中间区:3 家出的牌组合(10 10 10 10、K K K 4 + 2 2 2 2 2 + 3)+ 蓝色圆点"1"
- 左侧玩家头像:试用标签
- 右侧玩家头像:闪蓝色光圈
- 顶部:首充礼包(持续干扰)
- 底部手牌带:9 9 8 8 8 8 7 7 7 7 7 6 6 6 6 5 5 5 5 5 4 4 4 3
- 底部右下:奖按钮
算贡算法 + 自动化点击,必须保证在 30 秒内完成:识别 4 家手牌 → 算出进贡关系 → 点对应牌 → 点进贡确认 → 等结算。任何误点都会导致整局重来。
九、实测数据:3 局全自动对局
| 指标 | 旧版本 | 新版本 |
|---|---|---|
| 误点"提前离开" | 18.7% | 0.3% |
| 误点"首充礼包" | 6.4% | 0.1% |
| 误点"过牌"实际为"出牌" | 4.2% | 0.5% |
| 单局自动对局成功率 | 53% | 91% |
| 玩家接管次数/局 | 2.7 | 0.4 |
单局成功率从 53% → 91%,主要靠的是 giveup 防误点(-18.4pp)和 region 限制(-6.3pp)。
十、算法优缺点对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| 模板匹配 + region 限制(本文) | 快、可解释、好调试 | 每个按钮要单独调 region |
| 全屏扫描 | 不用维护 region 列表 | 误点率 18%+,不可用 |
| YOLO UI 目标检测 | 一遍扫出所有按钮 | 训练集要求高、推理慢 |
| OCR(识字 + 定位) | 不依赖模板 | 慢、对字体换皮敏感 |
实战推荐:先上"模板匹配 + 手工 region",能覆盖 80% 场景。难处理的几类(活动弹窗、节日换皮)走 OCR fallback。
十一、踩坑清单(写给未来自己)
| 现象 | 原因 | 修复 |
|---|---|---|
| 脚本点完按钮没反应 | 窗口没在前台 | SetForegroundWindow + Alt 解锁 |
| 投屏画面截出来是黑的 | DWM 合成层问题 | PrintWindow 第三参数传 2 |
| 找"出牌"找到"首充礼包" | 没限定 region | 每个按钮单独 region |
| 点了"放弃奖励"实际点了"提前离开" | region 包含了下方的"提前离开" | 收窄到 (400, 300, 850, 520) + 阈值 0.80 |
| 托管玩家被反复点 | 没检测"取消托管" | 检测到"取消托管"直接 return |
| 算贡时点了"出牌" | 没检测"进贡确认" | 检测到"进贡确认"走算贡分支 |
十二、小结
游戏窗口自动化点击,本质是模板匹配 + 区域限制 + 强制置顶 + 互斥校验四件事:
- 多尺度模板匹配 (
find_tpl,scale 0.85~1.15)------ 跟得上 UI 微调 - 每个按钮独立 region ------ 杜绝误点
- 强制窗口置顶 (
SetForegroundWindow+ Alt 解锁)------ 确保点击生效 - 互斥按钮二次校验 ------ 防止"放弃奖励" vs "提前离开"这类陷阱
整套链路让单局对局成功率从 53% → 91%,记牌器终于能放心让它自己跑了。
系列目录
本机实测环境:Windows 11 + Python 3.11 + OpenCV 4.8 + pywin32 + 手机 1080×1920 投屏(scrcpy 1.25)。