模拟人类点击屏幕的模型

一.大环境背景:

类似于阴阳师刷副本的时候总是有压力,需要弄个个自动点击的脚本,但是官方有检查脚本的内容,这让普通的点击屏幕的python脚本显得非常困难,必须做的像个人一样的点击才行,现在我们来探讨下如何模拟人类行为实现自动点击,设想一个坐在屏幕前的人不可能一直专注于屏幕看着阴阳师的战斗结束,会有一些其他操作也就是非阴阳师上的浮点坐标点击,比如说玩其他游戏或者静止不动,在阴阳师对战结束的时候会有不理会或者延时点击等动作,这不是一个x轴为时间的简单线性的函数,他会有各种波动,具有一定的随机性,我们先简单的设想函数x轴为时间y轴为点击次数,斜率为专注度建立坐标,当然这是我想象的,建立出来可能具有随机性但是能从随机中找寻函数波动的规律,那是否可以建立空间坐标系,那么z轴是什么呢?

显然简单的二次函数是不能满足目的的,因为需要考虑人类点击屏幕时的:

点击的空间位置(是否在游戏窗口内)
人的注意力状态(专注 / 分心 / 离开)
点击的力度/速度(按下到抬起的时间间隔)

倘若强行构建空间坐标系则z轴候选为:

Z轴候选含义优势
点击响应延迟(ms)战斗结束到实际点击的时间差最直接反映人的反应特性
注意力权重(0~1)当前对游戏的专注程度可驱动其他参数变化
点击误差半径(px)点击偏离按钮中心的距离模拟手指不精准的特性

这样看来这三者缺一不可,如果非要选一个的话我会选择 Z 轴 = 注意力权重 α(t),因为它可以同时影响:延迟时间,点击坐标的偏移量,是否产生"游离点击"

其实在这里我想到了更好的方法就是引入状态机而非单纯坐标系,

[专注状态] → 快速响应点击,坐标精准落在按钮上
[分心状态] → 延迟响应,可能先有几次屏幕其他位置的点击
[离开状态] → 长时间无操作(静止)
[回归状态] → 从离开/分心切换回专注,有一个"反应时间"

状态之间的转移可以用马尔可夫链描述(随机版本的 FSM     P(Xt+1​=Sj​∣Xt​=Si​,Xt−1​…)=P(Xt+1​=Sj​∣Xt​=Si​)),每个状态有转移概率。

二.重新认识这个问题的本质

1.向量的构建

我们要建模的不是一个"点",而是一个行为向量:

B(t) = [α(t), δ(t), ε(t), S(t)]
符号含义类型
α(t)注意力权重 (0~1)连续变量,隐变量
δ(t)点击响应延迟 (ms)连续变量,由α驱动
ε(t)点击误差半径 (px)连续变量,由α驱动
S(t)当前状态离散变量(状态机)

α(t) 是隐变量,它不可直接观测,但它驱动了所有可观测的行为参数。这正是为什么三维坐标系不够用——真实的驱动力藏在更高维度里。

2.状态机:离散行为的骨架
 

S ∈ {专注, 分心, 游离, 离开, 回归}

这里我们让ai为我们画个人类专注度的图(人类的专注权重不可能一直在游戏上)

                    ┌─────────────────────────────────┐
                    │                                 │
              ┌─────▼──────┐    长时间     ┌──────────┴───┐
    开始 ───► │   专注      │ ──────────► │    离开       │
              │ α: 0.7~1.0 │             │  α: 0~0.1    │
              └─────┬──────┘             └──────────┬───┘
                    │ 注意力下降                      │ 返回
                    ▼                               │
              ┌─────────────┐             ┌─────────▼────┐
              │    分心      │             │    回归       │
              │ α: 0.3~0.7  │             │ α: 0.1~0.5  │
              └─────┬───────┘             └─────────┬────┘
                    │ 完全走神                        │ 稳定
                    ▼                               ▼
              ┌─────────────┐             ┌──────────────┐
              │    游离      │ ──────────► │   专注        │
              │ α: 0.1~0.3  │  重新关注    └──────────────┘
              └─────────────┘

状态转移矩阵(马尔可夫链)

从现在开始大多数的模块代码都由Claude完成

import numpy as np

# 每秒的状态转移概率矩阵
# 行: 当前状态, 列: 下一状态
# [专注, 分心, 游离, 离开, 回归]
TRANSITION_MATRIX = np.array([
    #专注    分心    游离    离开    回归
    [0.95,  0.04,  0.005, 0.004, 0.001],  # 专注
    [0.10,  0.80,  0.08,  0.015, 0.005],  # 分心
    [0.05,  0.15,  0.70,  0.08,  0.02 ],  # 游离
    [0.00,  0.00,  0.00,  0.85,  0.15 ],  # 离开
    [0.30,  0.40,  0.20,  0.05,  0.05 ],  # 回归
])

STATE_NAMES = ['专注', '分心', '游离', '离开', '回归']

class StateMachine:
    def __init__(self):
        self.current_state = 0  # 初始为专注
        self.state_duration = 0  # 在当前状态持续的时间(秒)
    
    def transition(self):
        """根据转移矩阵决定下一个状态"""
        probs = TRANSITION_MATRIX[self.current_state]
        next_state = np.random.choice(len(STATE_NAMES), p=probs)
        
        if next_state != self.current_state:
            print(f"状态切换: {STATE_NAMES[self.current_state]} → {STATE_NAMES[next_state]}")
            self.state_duration = 0
        else:
            self.state_duration += 1
            
        self.current_state = next_state
        return self.current_state

3.注意力函数 α(t):连续行为的灵魂

α(t) 不是简单的随机数,它由三层叠加构成:

α(t) = α_base(t) × α_state(S) + α_noise(t)

第一层:基础节律(人的生理周期)

我查阅了一些人类平均的疲劳周期:

def alpha_base(t):
    """
    人的注意力有自然的周期性波动
    - 约4~5分钟的短周期(微注意力波动)
    - 约20分钟的中周期(注意力疲劳周期)
    """
    short_cycle  = 0.1 * np.sin(2 * np.pi * t / 240)   # 4分钟周期
    medium_cycle = 0.15 * np.sin(2 * np.pi * t / 1200) # 20分钟周期
    base = 0.65  # 人类平均基础专注度
    return base + short_cycle + medium_cycle

第二层:状态修正系数

# 不同状态下注意力的范围映射
STATE_ALPHA_RANGE = {
    0: (0.70, 1.00),  # 专注
    1: (0.30, 0.70),  # 分心
    2: (0.10, 0.30),  # 游离
    3: (0.00, 0.10),  # 离开
    4: (0.10, 0.50),  # 回归(注意力正在恢复)
}

def alpha_state_modifier(state, base_alpha):
    """将基础注意力映射到当前状态允许的范围内"""
    low, high = STATE_ALPHA_RANGE[state]
    # 将base_alpha(0~1)线性映射到[low, high]
    return low + (high - low) * base_alpha

第三层:随机噪声(人的不可预测性)

def alpha_noise(t):
    """
    使用1/f噪声(粉红噪声)而不是白噪声
    因为人类行为的随机性更接近粉红噪声的特性
    """
    # 简化版:用多个不同频率的正弦波叠加模拟
    noise = 0
    for freq in [0.1, 0.3, 0.7, 1.5, 3.0]:
        phase = np.random.uniform(0, 2*np.pi)
        noise += (1/freq) * 0.02 * np.sin(2 * np.pi * freq * t + phase)
    return noise

完整的注意力计算

class AttentionModel:
    def __init__(self):
        self.sm = StateMachine()
        self.t = 0
    
    def get_alpha(self):
        """获取当前时刻的注意力值"""
        state = self.sm.transition()
        
        base = alpha_base(self.t)
        base = np.clip(base, 0, 1)
        
        alpha = alpha_state_modifier(state, base)
        alpha += alpha_noise(self.t)
        alpha = np.clip(alpha, 0.01, 1.0)
        
        self.t += 1
        return alpha, state

四、三个行为参数如何由 α(t) 驱动?

参数1:响应延迟 δ(t)

def compute_delay(alpha, state):
    """
    延迟由注意力和状态共同决定
    
    人类反应时间分布更接近对数正态分布,而非正态分布
    因为反应时间有下界(生理极限约150ms)但无上界
    """
    # 基础反应时间(生理极限)
    BASE_RT = 150  # ms
    
    if state == 3:  # 离开状态:可能几十秒到几分钟不点
        return np.random.uniform(30000, 300000)  # 30秒~5分钟
    
    # 注意力越低,均值和方差都增大
    mu = BASE_RT + (1 - alpha) * 2000    # 均值:150ms ~ 2150ms
    sigma = (1 - alpha) * 800 + 50       # 标准差:50ms ~ 850ms
    
    # 对数正态分布采样(更符合真实反应时间分布)
    log_mu = np.log(mu)
    log_sigma = sigma / mu  # 近似转换
    delay = np.random.lognormal(log_mu, log_sigma)
    
    return max(BASE_RT, delay)

参数2:点击误差 ε(t)

def compute_click_error(alpha, target_x, target_y):
    """
    点击误差不是均匀圆形分布,而是椭圆形高斯分布
    因为手指在某个方向上更容易偏移(通常是垂直方向)
    """
    # 注意力越低,误差越大
    sigma_x = (1 - alpha) * 12 + 1  # 水平误差:1px ~ 13px
    sigma_y = (1 - alpha) * 18 + 1  # 垂直误差更大:1px ~ 19px
    
    # 高斯偏移
    error_x = np.random.normal(0, sigma_x)
    error_y = np.random.normal(0, sigma_y)
    
    actual_x = target_x + error_x
    actual_y = target_y + error_y
    
    return actual_x, actual_y

参数3:游离点击(分心时的随机点击)

       

def maybe_stray_click(alpha, state, screen_w=1080, screen_h=1920):
    """
    游离点击:不在游戏按钮上的随机点击
    模拟用户在玩其他东西或乱点屏幕
    """
    # 游离点击概率
    stray_prob = {
        0: 0.00,   # 专注:不会乱点
        1: 0.05,   # 分心:偶尔乱点
        2: 0.25,   # 游离:经常乱点
        3: 0.00,   # 离开:不点
        4: 0.10,   # 回归:偶尔乱点
    }
    
    if np.random.random() < stray_prob[state]:
        # 游离点击倾向于集中在屏幕边缘或特定区域(比如通知栏)
        region = np.random.choice(['top', 'bottom', 'random'], p=[0.3, 0.4, 0.3])
        
        if region == 'top':
            x = np.random.uniform(0, screen_w)
            y = np.random.uniform(0, 100)
        elif region == 'bottom':
            x = np.random.uniform(0, screen_w)
            y = np.random.uniform(screen_h - 200, screen_h)
        else:
            x = np.random.uniform(0, screen_w)
            y = np.random.uniform(0, screen_h)
            
        return (x, y)
    
    return None

五、整合:完整的行为生成器

import time

class HumanBehaviorSimulator:
    """
    完整的人类行为模拟器
    将状态机 + 注意力函数 + 三个行为参数整合
    """
    def __init__(self, screen_w=1080, screen_h=1920):
        self.attention_model = AttentionModel()
        self.screen_w = screen_w
        self.screen_h = screen_h
    
    def simulate_click(self, target_x, target_y):
        """
        模拟一次人类点击行为
        返回: (实际点击坐标, 延迟时间, 是否有游离点击)
        """
        # 1. 获取当前注意力和状态
        alpha, state = self.attention_model.get_alpha()
        
        print(f"[状态:{STATE_NAMES[state]}] 注意力:{alpha:.2f}")
        
        # 2. 计算响应延迟
        delay = compute_delay(alpha, state)
        print(f"  响应延迟: {delay:.0f}ms")
        
        # 3. 检查是否有游离点击(在正式点击之前)
        stray = maybe_stray_click(alpha, state, self.screen_w, self.screen_h)
        if stray:
            print(f"  游离点击: ({stray[0]:.0f}, {stray[1]:.0f})")
        
        # 4. 计算实际点击坐标
        actual_x, actual_y = compute_click_error(alpha, target_x, target_y)
        print(f"  目标坐标: ({target_x}, {target_y})")
        print(f"  实际坐标: ({actual_x:.0f}, {actual_y:.0f})")
        
        return {
            'delay_ms': delay,
            'stray_click': stray,
            'actual_pos': (actual_x, actual_y),
            'alpha': alpha,
            'state': STATE_NAMES[state]
        }
    
    def run(self, target_x, target_y, click_count=10):
        """连续模拟多次点击"""
        results = []
        for i in range(click_count):
            print(f"\n=== 第 {i+1} 次点击 ===")
            result = self.simulate_click(target_x, target_y)
            results.append(result)
            
            # 实际执行延迟(可接入 adb 或 pyautogui)
            # time.sleep(result['delay_ms'] / 1000)
            
        return results

# 使用示例
simulator = HumanBehaviorSimulator()
simulator.run(target_x=540, target_y=960, click_count=5)

写到这里以及初具雏形了,但是还是不能完全应用到阴阳师上,原因由以下几点
1.确实开始战斗的位置在target_x/y,但是人类每次手指点击的坐标不可能这么精确,会有微小的偏移,(我需要引入点击区域的大小)每次浮点坐标要在这个范围内运动
2.阴阳师的战斗模式是  点击开始然后在一个误差不超过2s的时间内(这个时间我也需要拟定)结束,在结束的时候点击屏幕任意区域(这个区域大小我也需要拟定)返回副本入口,再点击开始战斗,这个过程中也存在着注意力分散
3.副本入口->副本中的点击也需要关联到分心状态注意力越低 → 等待间隔越长(越心不在焉,越久才检查一次),从这种频繁的check机制来还原人类的分心状态
4.最基本的东西我开始构建的时候忘了,浮点坐标与分心耗时的取值是整数,这并不满足随机性

import numpy as np
import time
import subprocess
import threading
import json
import os
import random
import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox
from dataclasses import dataclass
from typing import Optional

# ── 可选导入 pyautogui(鼠标模式)──────────────────────────
try:
    import pyautogui
    PYAUTOGUI_OK = True
except ImportError:
    PYAUTOGUI_OK = False

CONFIG_FILE = "bot_config.json"

# ============================================================
# 第一层:常量定义
# ============================================================
STATE_NAMES = {0: '专注', 1: '分心', 2: '游离', 3: '离开', 4: '回归'}

TRANSITION_MATRIX = np.array([
    [0.92,  0.06,  0.015, 0.004, 0.001],
    [0.08,  0.78,  0.10,  0.025, 0.015],
    [0.04,  0.18,  0.68,  0.08,  0.02 ],
    [0.00,  0.00,  0.05,  0.82,  0.13 ],
    [0.25,  0.45,  0.20,  0.05,  0.05 ],
])

STATE_ALPHA_RANGE = {
    0: (0.72, 1.00),
    1: (0.28, 0.72),
    2: (0.08, 0.32),
    3: (0.00, 0.08),
    4: (0.10, 0.52),
}

# ============================================================
# 第二层:状态机
# ============================================================
class StateMachine:
    def __init__(self):
        self.current_state  = 0
        self.state_duration = 0

    def transition(self) -> int:
        probs      = TRANSITION_MATRIX[self.current_state]
        next_state = int(np.random.choice(len(STATE_NAMES), p=probs))
        if next_state != self.current_state:
            self.state_duration = 0
        else:
            self.state_duration += 1
        self.current_state = next_state
        return self.current_state

# ============================================================
# 第三层:注意力模型
# ============================================================
class AttentionModel:
    def __init__(self):
        self.sm           = StateMachine()
        self.t            = 0.0
        self.phase_offset = np.random.uniform(0, 2 * np.pi)
        self.noise_phases = [np.random.uniform(0, 2 * np.pi) for _ in range(6)]

    def _base_trend(self) -> float:
        t = self.t
        trend = (
            0.65
            + 0.08 * np.sin(2 * np.pi * t / 300  + self.phase_offset)
            + 0.06 * np.sin(2 * np.pi * t / 1200 + self.phase_offset)
            + 0.04 * np.sin(2 * np.pi * t / 3600 + self.phase_offset)
        )
        return float(np.clip(trend, 0.0, 1.0))

    def _noise(self) -> float:
        t     = self.t
        freqs = [0.05, 0.12, 0.31, 0.73, 1.47, 3.10]
        amps  = [0.030, 0.025, 0.018, 0.012, 0.008, 0.005]
        noise = sum(
            a * np.sin(2 * np.pi * f * t + p)
            for f, a, p in zip(freqs, amps, self.noise_phases)
        )
        return float(noise)

    def _state_map(self, state: int, base: float) -> float:
        low, high = STATE_ALPHA_RANGE[state]
        return low + (high - low) * base

    def get_alpha(self) -> tuple:
        state = self.sm.transition()
        base  = self._base_trend()
        noise = self._noise()
        raw   = float(np.clip(base + noise, 0.01, 0.99))
        alpha = self._state_map(state, raw)
        alpha = float(np.clip(alpha, 0.01, 1.0))
        self.t += 1.0
        return alpha, state

# ============================================================
# 第四层:行为参数函数
# ============================================================
def compute_delay_ms(alpha: float, state: int) -> float:
    if state == 3:
        minutes = np.random.exponential(scale=2.0)
        minutes = float(np.clip(minutes, 0.5, 1.5))
        return minutes * 60 * 1000
    params = {
        0: (220,  0.25),
        1: (700,  0.55),
        2: (2000, 0.60),
        4: (900,  0.45),
    }
    mu_ms, sigma_ratio = params.get(state, (500, 0.5))
    delay = float(np.random.lognormal(np.log(mu_ms), sigma_ratio))
    if state == 1 and np.random.random() < 0.08:
        delay += np.random.uniform(15000, 40000)
    return float(np.clip(delay, 150.0, 30000.0))


def compute_click_pos(alpha, center_x, center_y, zone_w, zone_h):
    sigma_x     = (1.0 - alpha) * 14.0 + 1.5
    sigma_y     = (1.0 - alpha) * 20.0 + 1.5
    zone_rand_x = np.random.uniform(-zone_w / 2, zone_w / 2) * 0.6
    zone_rand_y = np.random.uniform(-zone_h / 2, zone_h / 2) * 0.6
    jitter_x    = float(np.random.normal(0, sigma_x))
    jitter_y    = float(np.random.normal(0, sigma_y))
    x = center_x + zone_rand_x + jitter_x + np.random.uniform(-0.49, 0.49)
    y = center_y + zone_rand_y + jitter_y + np.random.uniform(-0.49, 0.49)
    return round(x, 2), round(y, 2)


def maybe_stray_click(alpha, state, screen_w=1080, screen_h=1920):
    stray_prob = {0: 0.00, 1: 0.04, 2: 0.20, 3: 0.00, 4: 0.08}
    if np.random.random() < stray_prob.get(state, 0):
        x = float(np.random.uniform(50, screen_w  - 50))
        y = float(np.random.uniform(200, screen_h - 200))
        return round(x, 2), round(y, 2)
    return None

# ============================================================
# 第五层:数据类
# ============================================================
@dataclass
class ClickZone:
    center_x: float
    center_y: float
    width:    float
    height:   float

@dataclass
class BattleConfig:
    start_zone:            ClickZone
    end_zone:              ClickZone
    battle_duration_base:  float = 60.0
    battle_duration_error: float = 3.0
    return_wait_base:      float = 3.0
    return_wait_max_extra: float = 600.0

# ============================================================
# 第六层:ADB 工具函数
# ============================================================
def get_screen_size_adb():
    try:
        result = subprocess.run("adb shell wm size", shell=True,
                                capture_output=True, text=True, timeout=5)
        for line in result.stdout.strip().splitlines():
            if "Override size:" in line:
                w, h = line.split("Override size:")[-1].strip().split("x")
                return int(w), int(h)
            if "Physical size:" in line:
                w, h = line.split("Physical size:")[-1].strip().split("x")
                return int(w), int(h)
    except Exception:
        pass
    return None


def get_cpu_abi():
    try:
        r = subprocess.run("adb shell getprop ro.product.cpu.abi",
                           shell=True, capture_output=True, text=True, timeout=5)
        return r.stdout.strip()
    except Exception:
        return None


def get_touch_info_adb():
    try:
        result = subprocess.run("adb shell getevent -p", shell=True,
                                capture_output=True, text=True, timeout=8)
        current_dev = None
        max_x = max_y = None
        for line in result.stdout.splitlines():
            s = line.strip()
            if s.startswith("add device"):
                current_dev = s.split(":")[-1].strip()
                max_x = max_y = None
            if current_dev and ("ABS_MT_POSITION_X" in s or s.startswith("0035")) and "max" in s:
                try:
                    max_x = int(s.split("max")[1].split(",")[0].strip())
                except Exception:
                    pass
            if current_dev and ("ABS_MT_POSITION_Y" in s or s.startswith("0036")) and "max" in s:
                try:
                    max_y = int(s.split("max")[1].split(",")[0].strip())
                except Exception:
                    pass
            if max_x and max_y:
                return {"device": current_dev, "max_x": max_x, "max_y": max_y}
    except Exception:
        pass
    return None


def convert_touch_coord(raw_x, raw_y, screen_w, screen_h,
                        touch_max_x, touch_max_y):
    px = int(round(raw_x / touch_max_x * screen_w))
    py = int(round(raw_y / touch_max_y * screen_h))
    return px, py


def check_adb_device(log_func=None) -> bool:
    result  = subprocess.run("adb devices", shell=True,
                             capture_output=True, text=True)
    lines   = result.stdout.strip().split('\n')
    devices = [l for l in lines[1:] if l.strip() and 'device' in l]
    if not devices:
        if log_func: log_func("❌ 未检测到设备!请先连接手机或启动模拟器")
        return False
    if log_func: log_func(f"✅ 检测到设备: {devices[0]}")
    return True

# ============================================================
# 第六层(续):MinitouchManager(支持ADB + 鼠标双模式)
# ============================================================
class MinitouchManager:
    """
    双模式点击管理器:
    - ADB模式:adb shell input tap(需要连接手机)
    - 鼠标模式:pyautogui.click(控制电脑鼠标,用于模拟器)
    """
    MODE_ADB   = "adb"
    MODE_MOUSE = "mouse"

    def __init__(self, log_func=None):
        self.log    = log_func or print
        self._ready = False
        self.mode   = self.MODE_ADB   # 默认ADB模式

    def set_screen(self, w: int, h: int):
        pass

    def setup(self) -> bool:
        """自动检测:有ADB设备用ADB,否则切换鼠标模式"""
        # 优先尝试ADB
        r = subprocess.run(
            "adb devices", shell=True,
            capture_output=True, text=True, timeout=5
        )
        lines   = r.stdout.strip().split('\n')
        devices = [l for l in lines[1:] if l.strip() and 'device' in l]

        if devices:
            self.mode   = self.MODE_ADB
            self._ready = True
            self.log("  ✅ ADB 模式就绪(使用 adb shell input tap)")
            self.log(f"  📱 设备: {devices[0]}")
            return True

        # ADB不可用,尝试鼠标模式
        if PYAUTOGUI_OK:
            self.mode   = self.MODE_MOUSE
            self._ready = True
            self.log("  ⚠️  未检测到ADB设备,已切换为【鼠标模式】")
            self.log("  🖱️  将使用 pyautogui 控制电脑鼠标")
            self.log("  ⚠️  注意:鼠标模式坐标为电脑屏幕坐标,请重新配置!")
            return True
        else:
            self.log("  ❌ 未检测到设备,且 pyautogui 未安装")
            self.log("  💡 请运行:pip install pyautogui")
            return False

    def force_mouse_mode(self) -> bool:
        """强制切换为鼠标模式"""
        if not PYAUTOGUI_OK:
            self.log("  ❌ pyautogui 未安装,请运行:pip install pyautogui")
            return False
        self.mode   = self.MODE_MOUSE
        self._ready = True
        self.log("  🖱️  已强制切换为鼠标模式")
        return True

    def force_adb_mode(self) -> bool:
        """强制切换为ADB模式"""
        r = subprocess.run(
            "adb devices", shell=True,
            capture_output=True, text=True, timeout=5
        )
        lines   = r.stdout.strip().split('\n')
        devices = [l for l in lines[1:] if l.strip() and 'device' in l]
        if devices:
            self.mode   = self.MODE_ADB
            self._ready = True
            self.log(f"  ✅ 已切换为ADB模式,设备: {devices[0]}")
            return True
        self.log("  ❌ 未检测到ADB设备")
        return False

    @property
    def ready(self) -> bool:
        return self._ready

    @property
    def mode_name(self) -> str:
        return "ADB手机" if self.mode == self.MODE_ADB else "鼠标(电脑)"

    def tap(self, x: float, y: float, log_func=None) -> bool:
        _log = log_func or self.log
        ix, iy = int(round(x)), int(round(y))

        if self.mode == self.MODE_ADB:
            r = subprocess.run(
                f"adb shell input tap {ix} {iy}",
                shell=True, capture_output=True, timeout=5
            )
            _log(f"  [ADB tap] 点击: ({ix}, {iy})")
            return r.returncode == 0

        elif self.mode == self.MODE_MOUSE:
            if not PYAUTOGUI_OK:
                _log("  ❌ pyautogui 不可用")
                return False
            pyautogui.click(ix, iy)
            _log(f"  [鼠标点击] ({ix}, {iy})")
            return True

        return False

    def stop(self):
        self._ready = False

# ============================================================
# 第七层:Bot 核心
# ============================================================
class YinYangShiBot:
    def __init__(self, config: BattleConfig,
                 screen_w: int = 1080, screen_h: int = 1920,
                 minitouch: MinitouchManager = None,
                 log_func=None, stop_event=None):
        self.config     = config
        self.am         = AttentionModel()
        self.screen_w   = screen_w
        self.screen_h   = screen_h
        self.minitouch  = minitouch
        self.round      = 0
        self.log        = log_func or print
        self.stop_event = stop_event or threading.Event()

    def _tap(self, x: float, y: float):
        if self.minitouch and self.minitouch.ready:
            self.minitouch.tap(x, y, self.log)
        else:
            self.log(f"  ❌ 点击器不可用,跳过点击 ({x:.0f},{y:.0f})")

    def _stopped(self) -> bool:
        return self.stop_event.is_set()

    def _sleep_with_log(self, delay_ms: float, label: str = "等待"):
        total_s  = delay_ms / 1000
        elapsed  = 0.0
        interval = 5.0
        self.log(f"  [{label}] 总等待: {total_s:.1f}s")
        while elapsed < total_s:
            if self._stopped():
                self.log("  [中断] 检测到停止信号")
                return
            sleep_time = min(interval, total_s - elapsed)
            time.sleep(sleep_time)
            elapsed += sleep_time
            self.log(f"  [{label}] 已等待: {elapsed:.1f}s / {total_s:.1f}s")

    def run(self, rounds: int = 10):
        self.log(f"开始刷副本,计划刷 {rounds} 轮\n")
        for _ in range(rounds):
            if self._stopped(): break
            self._one_battle()
        self.log(f"\n{'='*40}")
        self.log(f"  全部完成!共刷了 {self.round} 轮")
        self.log(f"{'='*40}\n")

    def _one_battle(self):
        self.round += 1
        self.log(f"\n{'='*40}")
        self.log(f"  第 {self.round} 轮战斗开始")
        self.log(f"{'='*40}")
        self._click_start()
        if self._stopped(): return
        self._wait_battle()
        if self._stopped(): return
        self._click_end_screen()
        if self._stopped(): return
        self._wait_return()

    def _click_start(self):
        alpha, state = self.am.get_alpha()
        self.log(f"\n[点击开始] 状态:{STATE_NAMES[state]} 注意力:{alpha:.4f}")
        delay_ms = compute_delay_ms(alpha, state)
        z        = self.config.start_zone
        x, y     = compute_click_pos(alpha, z.center_x, z.center_y,
                                     z.width, z.height)
        self.log(f"  点击坐标: ({x:.2f}, {y:.2f})")
        self.log(f"  点击延迟: {delay_ms:.1f}ms ({delay_ms/1000:.2f}s)")
        self._sleep_with_log(delay_ms, "点击开始延迟")
        self._tap(x, y)

    def _wait_battle(self):
        error       = np.random.uniform(-self.config.battle_duration_error,
                                         self.config.battle_duration_error)
        battle_time = self.config.battle_duration_base + error
        self.log(f"\n[等待战斗] 预计时长: {battle_time:.2f}s")
        start_time = time.time()
        while True:
            if self._stopped(): return
            elapsed = time.time() - start_time
            if elapsed >= battle_time: break
            time.sleep(min(1.0, max(battle_time - elapsed, 0.01)))
            alpha, state = self.am.get_alpha()
            elapsed = time.time() - start_time
            self.log(f"  [{elapsed:.2f}s/{battle_time:.2f}s] "
                     f"状态:{STATE_NAMES[state]} 注意力:{alpha:.4f}")
        self.log(f"  战斗结束!实际等待: {time.time() - start_time:.2f}s")

    def _click_end_screen(self):
        alpha, state = self.am.get_alpha()
        self.log(f"\n[点击结算] 状态:{STATE_NAMES[state]} 注意力:{alpha:.4f}")
        delay_ms = compute_delay_ms(alpha, state)
        z        = self.config.end_zone
        x, y     = compute_click_pos(alpha, z.center_x, z.center_y,
                                     z.width, z.height)
        self.log(f"  响应延迟: {delay_ms:.1f}ms ({delay_ms/1000:.2f}s)")
        self.log(f"  点击坐标: ({x:.2f}, {y:.2f})")
        stray = maybe_stray_click(alpha, state, self.screen_w, self.screen_h)
        if stray:
            stray_delay = np.random.uniform(200, 800)
            self.log(f"  [误触] 坐标:({stray[0]:.2f},{stray[1]:.2f})"
                     f" 延迟:{stray_delay:.1f}ms")
            time.sleep(stray_delay / 1000)
            self._tap(*stray)
        self._sleep_with_log(delay_ms, "点击返回延迟")
        self._tap(x, y)

    def _wait_return(self):
        wait_time = random.uniform(2.0, 3.5)
        self.log(f"\n[等待返回] 预计等待: {wait_time:.2f}s")
        time.sleep(wait_time)
        alpha, state     = self.am.get_alpha()
        delay_ms         = compute_delay_ms(alpha, state)
        return_x, return_y = 540.0, 1600.0
        self.log(f"  [点击返回确认] 状态:{STATE_NAMES[state]} 注意力:{alpha:.4f}")
        self.log(f"  响应延迟: {delay_ms:.1f}ms ({delay_ms/1000:.2f}s)")
        self.log(f"  点击坐标: ({return_x:.2f}, {return_y:.2f})")
        self._sleep_with_log(delay_ms, "返回确认延迟")
        self._tap(return_x, return_y)
        time.sleep(random.uniform(1.5, 2.5))

# ============================================================
# 第八层:配置持久化
# ============================================================
DEFAULT_CONFIG = {
    "start_center_x":        540.0,
    "start_center_y":        1650.0,
    "start_width":           200.0,
    "start_height":          80.0,
    "end_center_x":          540.0,
    "end_center_y":          960.0,
    "end_width":             800.0,
    "end_height":            1200.0,
    "battle_duration_base":  10.0,
    "battle_duration_error": 3.0,
    "return_wait_base":      3.0,
    "return_wait_max_extra": 600.0,
    "rounds":                10,
    "screen_w":              1080,
    "screen_h":              1920,
}

def load_config() -> dict:
    if os.path.exists(CONFIG_FILE):
        try:
            with open(CONFIG_FILE, "r", encoding="utf-8") as f:
                data = json.load(f)
            for k, v in DEFAULT_CONFIG.items():
                data.setdefault(k, v)
            return data
        except Exception:
            pass
    return DEFAULT_CONFIG.copy()

def save_config(data: dict):
    with open(CONFIG_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

# ============================================================
# 第九层:UI
# ============================================================
class BotUI:
    def __init__(self, root: tk.Tk):
        self.root       = root
        self.root.title("阴阳师刷副本 Bot")
        self.root.resizable(False, False)
        self.bot_thread = None
        self.stop_event = threading.Event()
        self.cfg        = load_config()

        self.coord_listening  = False
        self._touch_max_x     = None
        self._touch_max_y     = None
        self._screen_w_cache  = 1080
        self._screen_h_cache  = 1920

        self.minitouch = MinitouchManager(log_func=self._log)

        self._build_ui()
        self._fill_fields()

    def _build_ui(self):
        pad = {"padx": 6, "pady": 3}

        # ── 顶部:设备 + 模式切换 ──────────────────────────
        top = ttk.LabelFrame(self.root, text="设备 & 模式")
        top.grid(row=0, column=0, columnspan=2, sticky="ew", padx=8, pady=4)

        ttk.Button(top, text="检测ADB设备",
                   command=self._check_device).pack(side="left", **pad)
        self.device_label = ttk.Label(top, text="未检测", foreground="gray")
        self.device_label.pack(side="left", **pad)

        ttk.Button(top, text="🔧 自动初始化",
                   command=self._setup_minitouch).pack(side="left", **pad)
        ttk.Button(top, text="📱 强制ADB模式",
                   command=self._force_adb).pack(side="left", **pad)
        ttk.Button(top, text="🖱️ 强制鼠标模式",
                   command=self._force_mouse).pack(side="left", **pad)

        self.mt_label = ttk.Label(top, text="点击器: 未初始化",
                                  foreground="gray")
        self.mt_label.pack(side="left", **pad)

        # ── 左侧:参数配置 ─────────────────────────────────
        left = ttk.Frame(self.root)
        left.grid(row=1, column=0, sticky="n", padx=8, pady=4)

        f1 = ttk.LabelFrame(left, text="开始按钮区域")
        f1.grid(row=0, column=0, sticky="ew", pady=4)
        self.v_sx = self._row(f1, 0, "中心X", tip="开始按钮中心X坐标")
        self.v_sy = self._row(f1, 1, "中心Y", tip="开始按钮中心Y坐标")
        self.v_sw = self._row(f1, 2, "宽度",  tip="点击区域宽度")
        self.v_sh = self._row(f1, 3, "高度",  tip="点击区域高度")
        ttk.Button(f1, text="📍 获取坐标",
                   command=lambda: self._start_coord_pick(
                       self.v_sx, self.v_sy, "开始按钮")
                   ).grid(row=4, column=0, columnspan=3, pady=3)

        f2 = ttk.LabelFrame(left, text="结算界面区域")
        f2.grid(row=1, column=0, sticky="ew", pady=4)
        self.v_ex = self._row(f2, 0, "中心X", tip="自动计算(左上+右下中点)")
        self.v_ey = self._row(f2, 1, "中心Y", tip="自动计算(左上+右下中点)")
        self.v_ew = self._row(f2, 2, "宽度",  tip="自动计算(右下X - 左上X)")
        self.v_eh = self._row(f2, 3, "高度",  tip="自动计算(右下Y - 左上Y)")
        ttk.Button(f2, text="📍 框选区域(左上→右下)",
                   command=self._pick_end_zone
                   ).grid(row=4, column=0, columnspan=3, pady=3)

        f3 = ttk.LabelFrame(left, text="战斗参数")
        f3.grid(row=2, column=0, sticky="ew", pady=4)
        self.v_base   = self._row(f3, 0, "战斗时长(s)", tip="战斗基础时长,单位秒")
        self.v_err    = self._row(f3, 1, "时长误差(s)", tip="时长随机误差范围")
        self.v_rwb    = self._row(f3, 2, "返回等待(s)", tip="返回后固定加载等待")
        self.v_rmax   = self._row(f3, 3, "最长分心(s)", tip="最长分心等待上限")
        self.v_rounds = self._row(f3, 4, "刷副本轮数",  tip="总共刷几轮")

        f4 = ttk.LabelFrame(left, text="屏幕分辨率")
        f4.grid(row=3, column=0, sticky="ew", pady=4)
        self.v_scw = self._row(f4, 0, "宽度(px)", tip="手机/模拟器屏幕宽度")
        self.v_sch = self._row(f4, 1, "高度(px)", tip="手机/模拟器屏幕高度")
        ttk.Button(f4, text="📐 自动获取分辨率",
                   command=self._auto_get_resolution
                   ).grid(row=2, column=0, columnspan=3, pady=3)

        bf = ttk.Frame(left)
        bf.grid(row=4, column=0, pady=6)
        self.btn_start = ttk.Button(bf, text="▶ 开始运行",
                                    command=self._start_bot)
        self.btn_start.pack(side="left", padx=4)
        self.btn_stop  = ttk.Button(bf, text="⏹ 停止",
                                    command=self._stop_bot, state="disabled")
        self.btn_stop.pack(side="left", padx=4)
        ttk.Button(bf, text="💾 保存配置",
                   command=self._save_cfg).pack(side="left", padx=4)

        # ── 右侧:日志 ────────────────────────────────────
        right = ttk.LabelFrame(self.root, text="运行日志")
        right.grid(row=1, column=1, sticky="nsew", padx=8, pady=4)
        self.log_box = scrolledtext.ScrolledText(
            right, width=52, height=38,
            state="disabled", font=("Consolas", 9))
        self.log_box.pack(padx=4, pady=4)
        ttk.Button(right, text="清空日志",
                   command=self._clear_log).pack(pady=2)

        # ── 底部状态栏 ────────────────────────────────────
        self.status_var = tk.StringVar(value="就绪")
        ttk.Label(self.root, textvariable=self.status_var,
                  foreground="blue").grid(row=2, column=0,
                                         columnspan=2, pady=3)

    def _row(self, parent, row, label, tip="") -> tk.StringVar:
        ttk.Label(parent, text=label, width=10).grid(
            row=row, column=0, padx=4, pady=2, sticky="e")
        var = tk.StringVar()
        ttk.Entry(parent, textvariable=var, width=12).grid(
            row=row, column=1, padx=4, pady=2)
        if tip:
            ttk.Label(parent, text=tip, foreground="gray",
                      font=("", 8)).grid(row=row, column=2,
                                         padx=2, sticky="w")
        return var

    def _fill_fields(self):
        c = self.cfg
        self.v_sx.set(c["start_center_x"]);  self.v_sy.set(c["start_center_y"])
        self.v_sw.set(c["start_width"]);      self.v_sh.set(c["start_height"])
        self.v_ex.set(c["end_center_x"]);     self.v_ey.set(c["end_center_y"])
        self.v_ew.set(c["end_width"]);        self.v_eh.set(c["end_height"])
        self.v_base.set(c["battle_duration_base"])
        self.v_err.set(c["battle_duration_error"])
        self.v_rwb.set(c["return_wait_base"])
        self.v_rmax.set(c["return_wait_max_extra"])
        self.v_rounds.set(c["rounds"])
        self.v_scw.set(c["screen_w"]);        self.v_sch.set(c["screen_h"])

    def _read_fields(self) -> Optional[dict]:
        try:
            return {
                "start_center_x":        float(self.v_sx.get()),
                "start_center_y":        float(self.v_sy.get()),
                "start_width":           float(self.v_sw.get()),
                "start_height":          float(self.v_sh.get()),
                "end_center_x":          float(self.v_ex.get()),
                "end_center_y":          float(self.v_ey.get()),
                "end_width":             float(self.v_ew.get()),
                "end_height":            float(self.v_eh.get()),
                "battle_duration_base":  float(self.v_base.get()),
                "battle_duration_error": float(self.v_err.get()),
                "return_wait_base":      float(self.v_rwb.get()),
                "return_wait_max_extra": float(self.v_rmax.get()),
                "rounds":                int(self.v_rounds.get()),
                "screen_w":              int(self.v_scw.get()),
                "screen_h":              int(self.v_sch.get()),
            }
        except ValueError as e:
            messagebox.showerror("输入错误", f"参数格式有误:{e}")
            return None

    def _save_cfg(self):
        data = self._read_fields()
        if data:
            save_config(data); self.cfg = data
            self.status_var.set("✅ 配置已保存")

    def _log(self, msg: str):
        def _w():
            self.log_box.config(state="normal")
            self.log_box.insert("end", msg + "\n")
            self.log_box.see("end")
            self.log_box.config(state="disabled")
        self.root.after(0, _w)

    def _clear_log(self):
        self.log_box.config(state="normal")
        self.log_box.delete("1.0", "end")
        self.log_box.config(state="disabled")

    def _update_mt_label(self):
        if self.minitouch.ready:
            mode = self.minitouch.mode_name
            self.mt_label.config(
                text=f"点击器: {mode} ✅", foreground="green")
            self.status_var.set(f"✅ 点击器就绪({mode})")
        else:
            self.mt_label.config(
                text="点击器: 失败 ❌", foreground="red")
            self.status_var.set("❌ 点击器初始化失败,查看日志")

    def _check_device(self):
        def _do():
            ok = check_adb_device(self._log)
            self.root.after(0, lambda: self.device_label.config(
                text="已连接 ✅" if ok else "未连接 ❌",
                foreground="green" if ok else "red"))
        threading.Thread(target=_do, daemon=True).start()

    def _setup_minitouch(self):
        """自动初始化:有ADB用ADB,否则用鼠标"""
        def _do():
            self.root.after(0, lambda: self.mt_label.config(
                text="点击器: 初始化中...", foreground="orange"))
            self._log("\n🔧 开始自动初始化点击器...")
            ok = self.minitouch.setup()
            self.root.after(0, self._update_mt_label)
        threading.Thread(target=_do, daemon=True).start()

    def _force_adb(self):
        """强制切换ADB模式"""
        def _do():
            self._log("\n📱 强制切换ADB模式...")
            ok = self.minitouch.force_adb_mode()
            self.root.after(0, self._update_mt_label)
        threading.Thread(target=_do, daemon=True).start()

    def _force_mouse(self):
        """强制切换鼠标模式"""
        def _do():
            self._log("\n🖱️  强制切换鼠标模式...")
            ok = self.minitouch.force_mouse_mode()
            self.root.after(0, self._update_mt_label)
            if ok:
                self.root.after(0, lambda: messagebox.showinfo(
                    "鼠标模式",
                    "已切换为鼠标模式!\n\n"
                    "⚠️ 注意:\n"
                    "坐标现在是电脑屏幕坐标\n"
                    "请重新配置开始/结算区域坐标\n"
                    "建议使用「📍 获取坐标」在屏幕上点击获取"))
        threading.Thread(target=_do, daemon=True).start()

    def _auto_get_resolution(self):
        def _do():
            result  = subprocess.run("adb devices", shell=True,
                                     capture_output=True, text=True)
            lines   = result.stdout.strip().split('\n')
            devices = [l for l in lines[1:] if l.strip() and 'device' in l]

            if devices:
                size = get_screen_size_adb()
                if size:
                    w, h   = size
                    source = "手机(ADB)"
                    info   = get_touch_info_adb()
                    if info:
                        self._touch_max_x = info["max_x"]
                        self._touch_max_y = info["max_y"]
                        self._log(f"  📐 触摸硬件范围: "
                                  f"{info['max_x']} x {info['max_y']}")
                    self._screen_w_cache = w
                    self._screen_h_cache = h
                    self.minitouch.set_screen(w, h)
                else:
                    w, h   = (self.root.winfo_screenwidth(),
                               self.root.winfo_screenheight())
                    source = "电脑屏幕(回退)"
            else:
                w, h   = (self.root.winfo_screenwidth(),
                           self.root.winfo_screenheight())
                source = "电脑屏幕"

            def _fill():
                self.v_scw.set(w); self.v_sch.set(h)
                self._log(f"  📐 分辨率 [{source}]: {w} x {h}")
                self.status_var.set(f"📐 {w}x{h} [{source}]")
            self.root.after(0, _fill)
        threading.Thread(target=_do, daemon=True).start()

    def _raw_to_pixel(self, raw_x: float, raw_y: float):
        if self._touch_max_x and self._touch_max_y:
            return convert_touch_coord(
                raw_x, raw_y,
                self._screen_w_cache, self._screen_h_cache,
                self._touch_max_x, self._touch_max_y)
        return int(round(raw_x)), int(round(raw_y))

    def _start_bot(self):
        data = self._read_fields()
        if not data: return

        if not self.minitouch.ready:
            if not messagebox.askyesno(
                "提示",
                "点击器未就绪,点击将无法生效。\n"
                "建议先点击「🔧 自动初始化」。\n\n"
                "是否仍要继续?"
            ):
                return

        save_config(data)
        config = BattleConfig(
            start_zone=ClickZone(
                data["start_center_x"], data["start_center_y"],
                data["start_width"],    data["start_height"]),
            end_zone=ClickZone(
                data["end_center_x"], data["end_center_y"],
                data["end_width"],    data["end_height"]),
            battle_duration_base   = data["battle_duration_base"],
            battle_duration_error  = data["battle_duration_error"],
            return_wait_base       = data["return_wait_base"],
            return_wait_max_extra  = data["return_wait_max_extra"],
        )

        self.stop_event.clear()
        bot = YinYangShiBot(
            config,
            screen_w   = data["screen_w"],
            screen_h   = data["screen_h"],
            minitouch  = self.minitouch,
            log_func   = self._log,
            stop_event = self.stop_event,
        )

        def _run():
            self.root.after(0, lambda: self.btn_start.config(state="disabled"))
            self.root.after(0, lambda: self.btn_stop.config(state="normal"))
            bot.run(rounds=data["rounds"])
            self.root.after(0, lambda: self.btn_start.config(state="normal"))
            self.root.after(0, lambda: self.btn_stop.config(state="disabled"))
            self.root.after(0, lambda: self.status_var.set("✅ 运行完成"))

        self.bot_thread = threading.Thread(target=_run, daemon=True)
        self.bot_thread.start()
        self.status_var.set(
            f"🚀 运行中...({self.minitouch.mode_name})")

    def _stop_bot(self):
        self.stop_event.set()
        self.status_var.set("⏹ 已发送停止信号...")
        self._log("⏹ 用户请求停止,等待当前步骤完成...")

    def _has_adb(self) -> bool:
        result = subprocess.run("adb devices", shell=True,
                                capture_output=True, text=True)
        lines  = result.stdout.strip().split('\n')
        return bool([l for l in lines[1:] if l.strip() and 'device' in l])

    def _start_coord_pick(self, var_x, var_y, label):
        if self.coord_listening:
            self._log("⚠️ 已有坐标监听在运行"); return
        if self._has_adb():
            self._start_coord_pick_adb(var_x, var_y, label)
        else:
            self._start_coord_pick_screen_single(var_x, var_y, label)

    def _pick_end_zone(self):
        if self.coord_listening:
            self._log("⚠️ 已有坐标监听在运行"); return
        if self._has_adb():
            self._pick_end_zone_adb()
        else:
            self._pick_end_zone_screen()

    def _start_coord_pick_adb(self, var_x, var_y, label):
        self.coord_listening = True
        self._log(f"\n📍 [ADB] 请点击手机屏幕上的【{label}】...")

        def _listen():
            try:
                proc = subprocess.Popen("adb shell getevent -l", shell=True,
                                        stdout=subprocess.PIPE,
                                        stderr=subprocess.PIPE, text=True)
                x_val = y_val = None
                timeout = time.time() + 15
                for line in proc.stdout:
                    if time.time() > timeout:
                        self._log("⚠️ 超时"); break
                    line = line.strip()
                    if "ABS_MT_POSITION_X" in line:
                        try: x_val = int(line.split()[-1], 16)
                        except Exception: pass
                    elif "ABS_MT_POSITION_Y" in line:
                        try: y_val = int(line.split()[-1], 16)
                        except Exception: pass
                    if x_val is not None and y_val is not None:
                        px, py = self._raw_to_pixel(float(x_val), float(y_val))
                        def _fill(fx=float(px), fy=float(py)):
                            var_x.set(fx); var_y.set(fy)
                            self._log(f"  ✅ 坐标: ({fx}, {fy})")
                            self.status_var.set(f"✅ 【{label}】: ({fx},{fy})")
                            self.coord_listening = False
                        self.root.after(0, _fill)
                        proc.kill(); return
                proc.kill()
            except Exception as e:
                self._log(f"  ❌ 监听失败: {e}")
            finally:
                self.coord_listening = False

        threading.Thread(target=_listen, daemon=True).start()

    def _pick_end_zone_adb(self):
        self.coord_listening = True
        self._log("\n📍 [ADB框选] 请点击结算界面【左上角】...")

        def _listen_once(on_got):
            proc = subprocess.Popen("adb shell getevent -l", shell=True,
                                    stdout=subprocess.PIPE,
                                    stderr=subprocess.PIPE, text=True)
            x_val = y_val = None
            timeout = time.time() + 20
            for line in proc.stdout:
                if time.time() > timeout: proc.kill(); return
                line = line.strip()
                if "ABS_MT_POSITION_X" in line:
                    try: x_val = int(line.split()[-1], 16)
                    except Exception: pass
                elif "ABS_MT_POSITION_Y" in line:
                    try: y_val = int(line.split()[-1], 16)
                    except Exception: pass
                if x_val is not None and y_val is not None:
                    proc.kill()
                    px, py = self._raw_to_pixel(float(x_val), float(y_val))
                    on_got(float(px), float(py)); return
            proc.kill()

        def _step1():
            def _got_tl(x1, y1):
                self._log(f"  ✅ 左上角: ({x1:.0f}, {y1:.0f})")
                self.root.after(0, lambda: self._log("📍 请点击【右下角】..."))
                time.sleep(0.8)
                _listen_once(lambda x2, y2: _got_br(x1, y1, x2, y2))
            _listen_once(_got_tl)

        def _got_br(x1, y1, x2, y2):
            cx = round((x1+x2)/2, 2); cy = round((y1+y2)/2, 2)
            w  = round(abs(x2-x1), 2); h  = round(abs(y2-y1), 2)
            def _fill():
                self.v_ex.set(cx); self.v_ey.set(cy)
                self.v_ew.set(w);  self.v_eh.set(h)
                self._log(f"  ✅ 结算区域: 中心({cx},{cy}) {w}×{h}")
                self.coord_listening = False
            self.root.after(0, _fill)

        threading.Thread(target=_step1, daemon=True).start()

    def _pick_end_zone_screen(self):
        self.coord_listening = True
        sw = self.root.winfo_screenwidth()
        sh = self.root.winfo_screenheight()
        overlay = tk.Toplevel(self.root)
        overlay.attributes("-fullscreen", True)
        overlay.attributes("-alpha", 0.35)
        overlay.attributes("-topmost", True)
        overlay.config(bg="black")
        canvas = tk.Canvas(overlay, width=sw, height=sh,
                           bg="black", highlightthickness=0,
                           cursor="crosshair")
        canvas.pack(fill="both", expand=True)
        hint_id  = canvas.create_text(sw//2, int(sh*0.06),
                                      text="第1步:点击【左上角】    ESC取消",
                                      font=("", 18, "bold"), fill="white")
        coord_id = canvas.create_text(sw//2, int(sh*0.12),
                                      text="", font=("Consolas", 14),
                                      fill="#00ff99")
        step = [1]; pt1 = [None, None]

        def _on_move(e):
            canvas.delete("cross")
            canvas.create_line(e.x-25, e.y, e.x+25, e.y,
                               fill="red", width=2, tags="cross")
            canvas.create_line(e.x, e.y-25, e.x, e.y+25,
                               fill="red", width=2, tags="cross")
            canvas.itemconfig(coord_id, text=f"X={e.x}  Y={e.y}")

        def _on_click(e):
            if step[0] == 1:
                pt1[0], pt1[1] = e.x, e.y; step[0] = 2
                canvas.itemconfig(hint_id,
                                  text="第2步:点击【右下角】    ESC取消")
                canvas.create_oval(e.x-6, e.y-6, e.x+6, e.y+6,
                                   fill="yellow", outline="orange",
                                   width=2, tags="mark")
            else:
                cx = round((pt1[0]+e.x)/2, 2)
                cy = round((pt1[1]+e.y)/2, 2)
                w  = round(abs(e.x-pt1[0]), 2)
                h  = round(abs(e.y-pt1[1]), 2)
                self.v_ex.set(cx); self.v_ey.set(cy)
                self.v_ew.set(w);  self.v_eh.set(h)
                self._log(f"  ✅ 结算区域: 中心({cx},{cy}) {w}×{h}")
                self.coord_listening = False
                overlay.destroy()

        def _on_cancel(e=None):
            self.coord_listening = False; overlay.destroy()

        canvas.bind("<Motion>",   _on_move)
        canvas.bind("<Button-1>", _on_click)
        canvas.bind("<Escape>",   _on_cancel)
        overlay.bind("<Escape>",  _on_cancel)
        overlay.protocol("WM_DELETE_WINDOW", _on_cancel)
        canvas.focus_set()

    def _start_coord_pick_screen_single(self, var_x, var_y, label):
        self.coord_listening = True
        sw = self.root.winfo_screenwidth()
        sh = self.root.winfo_screenheight()
        overlay = tk.Toplevel(self.root)
        overlay.attributes("-fullscreen", True)
        overlay.attributes("-alpha", 0.35)
        overlay.attributes("-topmost", True)
        overlay.config(bg="black")
        cc = tk.Canvas(overlay, width=sw, height=sh,
                       bg="black", highlightthickness=0,
                       cursor="crosshair")
        cc.pack(fill="both", expand=True)
        cc.create_text(sw//2, int(sh*0.08),
                       text=f"请点击【{label}】    ESC取消",
                       font=("", 18, "bold"), fill="white")
        cid = cc.create_text(sw//2, int(sh*0.14),
                             text="", font=("Consolas", 14),
                             fill="#00ff99")

        def _on_move(e):
            cc.delete("cross")
            cc.create_line(e.x-25, e.y, e.x+25, e.y,
                           fill="red", width=2, tags="cross")
            cc.create_line(e.x, e.y-25, e.x, e.y+25,
                           fill="red", width=2, tags="cross")
            cc.itemconfig(cid, text=f"X={e.x}  Y={e.y}")

        def _on_click(e):
            var_x.set(e.x); var_y.set(e.y)
            self._log(f"  ✅ 坐标: ({e.x}, {e.y})")
            self.coord_listening = False
            overlay.destroy()

        def _on_cancel(e=None):
            self.coord_listening = False; overlay.destroy()

        cc.bind("<Motion>",   _on_move)
        cc.bind("<Button-1>", _on_click)
        cc.bind("<Escape>",   _on_cancel)
        overlay.bind("<Escape>", _on_cancel)
        overlay.protocol("WM_DELETE_WINDOW", _on_cancel)
        cc.focus_set()


# ============================================================
# 入口
# ============================================================
if __name__ == "__main__":
    root = tk.Tk()
    app  = BotUI(root)
    root.mainloop()

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值