本项目仅用于技术交流,源码已上传 GitHub(https://github.com/childofcuriosity/taoyuan-auto-assistant)
先介绍一下这个游戏的内容:
这是一个种田经营游戏,有很多生产线,时间不均匀,人总是要时时刻刻在线,这收那收的,机械重复,刚好适合用自动化实现。
游戏内容包括

生产线1

生产线2

生产线3

生产线4

出货1

出货2
没错,这就是个一路生产之后卖出去的结构。我相信聪明的观众已经完全看懂了。
接下来说一下我的实现。写代码有AI帮忙。
项目结构
TAOYUAN/
├── main.py # 启动入口
├── data.json # 你的配置文件 (运行后生成/手动改名,不上传)
└── src/ # 核心源码包
├── ui.py # 图形界面 (Tkinter 动态构建)
├── logic.py # 业务逻辑与数据持久化
├── tasks.py # 六大任务的具体逻辑实现
├── adb_utils.py # ADB 通信、截图、多点触控封装
└── ai_client.py # VLM 大模型调用接口
我决定从顶向下讲我的实现。
现在开始吧。
0.地表------主函数层------
运行界面
python main.py
运行后出现了给用户的ui界面。
文件: MyProject/main.py
from src.ui import MainApp
if name == "main ":
app = MainApp()
app.mainloop()
好,现在我们发现,最顶层的main.py下一层是src.ui ,我们就像地宫探险一样,下到地下一层src.ui 看看。
1.地下一层------ui界面:支撑主显示界面和用户命令开始的地方
文件: MyProject/src/ui.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from src.logic import AppLogic
from src.tasks import SCRIPT_REGISTRY
class MainApp(tk.Tk):
def init (self):
super().init ()
self.title("桃源助手专业版")
self.geometry("950x650")
self.logic = AppLogic()
# === 布局结构 ===
self.sidebar = tk.Frame(self, width=180, bg="#f0f0f0", relief="sunken", bd=1)
self.sidebar.pack(side=tk.LEFT, fill=tk.Y)
self.sidebar.pack_propagate(False)
self.content_area = tk.Frame(self, bg="white")
self.content_area.pack(side=tk.RIGHT, expand=True, fill=tk.BOTH)
# 初始化
self.create_sidebar()
self.show_task_page() # 默认显示任务页
def create_sidebar(self):
btn_style = {"bg": "#e1e1e1", "relief": "flat", "height": 2}
tk.Button(self.sidebar, text="配置填写", command=self.show_config_page, **btn_style).pack(fill=tk.X, padx=5, pady=5)
tk.Button(self.sidebar, text="任务列表", command=self.show_task_page, **btn_style).pack(fill=tk.X, padx=5, pady=5)
def clear_content(self):
for w in self.content_area.winfo_children():
w.destroy()
================= 页面 1:配置填写 (修改版) =================
def show_config_page(self):
self.clear_content()
# 1. 标题
tk.Label(self.content_area, text="全局配置", font=("微软雅黑", 16, "bold"), bg="white").pack(pady=15)
# 2. 创建滚动容器
canvas = tk.Canvas(self.content_area, bg="white", highlightthickness=0)
scroll = ttk.Scrollbar(self.content_area, command=canvas.yview)
frame = tk.Frame(canvas, bg="white")
frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0,0), window=frame, anchor="nw", width=700)
canvas.configure(yscrollcommand=scroll.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=20, pady=(0, 10))
scroll.pack(side=tk.RIGHT, fill=tk.Y)
# --- 第一组:基础环境 ---
g_env = tk.LabelFrame(frame, text="基础连接与AI", bg="white", font=("微软雅黑", 10, "bold"), padx=10, pady=10)
g_env.pack(fill=tk.X, pady=10)
# [已删除] 窗口名称
# API Key (行号移到了 0)
self._input(g_env, "API Key", "OPENAI_API_KEY", 0)
# ADB 选择器 (行号移到了 1)
tk.Label(g_env, text="ADB路径:", bg="white").grid(row=1, column=0, sticky="e", pady=5)
entry_adb = tk.Entry(g_env, width=40, bg="#f9f9f9")
entry_adb.insert(0, self.logic.config.get("adb_path", ""))
entry_adb.grid(row=1, column=1, sticky="w", pady=5)
entry_adb.bind("<FocusOut>", lambda e: self.logic.update_config("adb_path", entry_adb.get()))
tk.Button(g_env, text="浏览", command=lambda: self._sel_file(entry_adb)).grid(row=1, column=2, padx=5)
# --- [已删除] 第二组:自动化阈值 ---
# --- 第三组:运行控制 ---
# 建议把原来的 "延迟设置" 改名为 "运行控制 (延迟 & 循环)"
g_run = tk.LabelFrame(frame, text="运行控制 (延迟 & 循环)", bg="white", font=("微软雅黑", 10, "bold"), padx=10, pady=10)
g_run.pack(fill=tk.X, pady=10)
self._input(g_run, "小延迟(s)", "small_delay", 0, 0)
self._input(g_run, "大延迟(s)", "big_delay", 0, 2)
# === 新增这一行 ===
self._input(g_run, "循环总轮数", "loop_count", 1, 0)
tk.Label(g_run, text="(填9999即无限循环)", fg="gray", bg="white").grid(row=1, column=1, sticky="e", padx=5)
# === 复位坐标设置 ===
g_reset = tk.LabelFrame(frame, text="复位逻辑坐标 (x y)", bg="white", font=("微软雅黑", 10, "bold"), padx=10, pady=10)
g_reset.pack(fill=tk.X, pady=10)
self._input(g_reset, "订单图标", "reset_pos_order", 0, 0)
self._input(g_reset, "退出订单", "reset_pos_exit_order", 0, 2)
self._input(g_reset, "蒲公英图标", "reset_pos_dandelion", 1, 0)
self._input(g_reset, "退出蒲公英", "reset_pos_exit_dandelion", 1, 2)
# --- 底部保存按钮区域 ---
btn_area = tk.Frame(frame, bg="white", pady=20)
btn_area.pack(fill=tk.X)
tk.Button(btn_area, text="💾 保存并应用配置", bg="#007bff", fg="white",
font=("微软雅黑", 12, "bold"), width=25, height=2,
command=self.save_config_manual).pack()
tk.Label(btn_area, text="* 提示:配置会自动保存,点击按钮可强制刷新环境变量", fg="gray", bg="white").pack(pady=5)
# --- 新增:按钮点击事件 ---
def save_config_manual(self):
"""手动保存按钮的逻辑"""
# 1. 强制让当前焦点控件失去焦点 (这样能触发输入框的 <FocusOut> 事件,确保数据被写入 logic)
self.focus_set()
# 2. 再次调用 logic 的保存和应用环境方法
self.logic.save_data()
self.logic.apply_config_to_env()
# 3. 弹窗提示
messagebox.showinfo("成功", "✅ 全局配置已保存并生效!")
def _input(self, parent, label, key, r, c=0):
tk.Label(parent, text=f"{label}:", bg="white").grid(row=r, column=c, sticky="e", padx=5, pady=5)
e = tk.Entry(parent, width=20, bg="#f9f9f9")
e.insert(0, self.logic.config.get(key, ""))
e.grid(row=r, column=c+1, sticky="w", padx=5)
e.bind("<FocusOut>", lambda ev: self.logic.update_config(key, e.get()))
def _sel_file(self, entry):
path = filedialog.askopenfilename(filetypes=[("EXE", "*.exe")])
if path:
entry.delete(0, tk.END)
entry.insert(0, path)
self.logic.update_config("adb_path", path)
# ================= 任务页面 =================
def show_task_page(self):
self.clear_content()
# 顶部栏
top = tk.Frame(self.content_area, bg="white")
top.pack(fill=tk.X, padx=20, pady=10)
# 1. 新建任务
tk.Button(top, text="+ 新建任务", bg="#28a745", fg="white",
command=self.add_task).pack(side=tk.LEFT)
# 2. 全部启动
tk.Button(top, text="▶ 全部启动", bg="#007bff", fg="white",
command=self.run_all).pack(side=tk.LEFT, padx=10)
# === 3. 新增:保存按钮 ===
# 复用已有的 save_config_manual 方法,它会强制失去焦点并写入文件
tk.Button(top, text="💾 保存配置", bg="#6c757d", fg="white",
command=self.save_config_manual).pack(side=tk.LEFT, padx=0)
# 滚动列表
canvas = tk.Canvas(self.content_area, bg="white", highlightthickness=0)
scroll = ttk.Scrollbar(self.content_area, command=canvas.yview)
self.scroll_frame = tk.Frame(canvas, bg="white")
self.scroll_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0,0), window=self.scroll_frame, anchor="nw", width=700)
canvas.configure(yscrollcommand=scroll.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=20, pady=10)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
canvas.bind_all("<MouseWheel>", lambda e: canvas.yview_scroll(int(-1*(e.delta/120)), "units"))
self.refresh_list()
def refresh_list(self):
for w in self.scroll_frame.winfo_children(): w.destroy()
for i, t in enumerate(self.logic.tasks):
self.create_card(i, t)
=== 核心:卡片绘制 (样式修复版) ===
def create_card(self, idx, data):
# 1. 卡片整体容器 (加深边框,增加阴影感)
card = tk.Frame(self.scroll_frame, bg="white", bd=2, relief="groove")
card.pack(fill=tk.X, pady=10, ipady=5, padx=5) # 增加 padx 避免贴边
# === Row 1: 标题栏 (浅灰色背景) ===
r1 = tk.Frame(card, bg="#f0f0f0", height=35)
r1.pack(fill=tk.X)
# ID 和 标题
tk.Label(r1, text=f" 任务 #{data['id']} ", font=("微软雅黑", 10, "bold"), bg="#f0f0f0", fg="#333").pack(side=tk.LEFT, padx=5, pady=5)
# 右侧按钮组
bg = tk.Frame(r1, bg="#f0f0f0")
bg.pack(side=tk.RIGHT, padx=5)
# 删除按钮
tk.Button(bg, text="✕", font=("Arial", 10, "bold"), fg="#dc3545", bd=0, bg="#f0f0f0", cursor="hand2",
command=lambda: self.del_task(idx)).pack(side=tk.RIGHT, padx=5)
# 启用开关
enable_var = tk.BooleanVar(value=data["enable"])
tk.Checkbutton(bg, text="启用", variable=enable_var, bg="#f0f0f0", font=("微软雅黑", 9),
command=lambda: self.logic.update_task_status(idx, enable_var.get())).pack(side=tk.RIGHT, padx=5)
# 启动按钮
tk.Button(bg, text="▶ 运行此任务", bg="#17a2b8", fg="white", relief="flat", font=("微软雅黑", 9), padx=5,
command=lambda: self.run_single(idx)).pack(side=tk.RIGHT, padx=10)
# === Row 2: 任务类型选择 (单独一行,醒目) ===
r2 = tk.Frame(card, bg="white", pady=5, padx=10)
r2.pack(fill=tk.X)
tk.Label(r2, text="任务类型:", bg="white", font=("微软雅黑", 9, "bold")).pack(side=tk.LEFT)
cb = ttk.Combobox(r2, values=self.logic.get_available_types(), state="readonly", width=30)
cb.set(data["type"])
cb.pack(side=tk.LEFT, padx=10)
# === Row 3: 参数配置区 (重点修改区域) ===
# 使用 LabelFrame 将参数包裹起来,显得整洁
f_param_container = tk.LabelFrame(card, text="详细参数配置", bg="white", fg="#666", font=("微软雅黑", 9), padx=10, pady=10)
f_param_container.pack(fill=tk.X, padx=10, pady=10)
# 内部渲染函数
def render_params():
# 清空旧控件
for w in f_param_container.winfo_children(): w.destroy()
task_type = data["type"]
params_data = data["params"]
cls = SCRIPT_REGISTRY.get(task_type)
if not cls: return
config_def = cls.PARAM_CONFIG
if not config_def:
tk.Label(f_param_container, text="( 此任务类型无需额外配置 )", bg="white", fg="#999").pack(anchor="w")
return
# 遍历配置,垂直排列 (Label在上,Input在下)
for key, conf in config_def.items():
val = params_data.get(key, conf.get("default", ""))
label_text = conf.get("label", key)
input_type = conf.get("type", "string")
# 1. 每一个参数包裹在一个 Frame 里,方便布局
p_row = tk.Frame(f_param_container, bg="white")
p_row.pack(fill=tk.X, pady=5) # 垂直堆叠,增加间距
# 2. 标签 (左对齐,加粗)
tk.Label(p_row, text=label_text, bg="white", font=("微软雅黑", 9, "bold"), fg="#333").pack(anchor="w")
# 3. 输入控件 (根据类型判断)
if input_type == "text":
# === 多行文本框 (ADB语法专用) ===
# 黑色边框,高度设为4行
text_widget = tk.Text(p_row, height=4, font=("Consolas", 9), relief="solid", bd=1)
text_widget.insert("1.0", str(val))
text_widget.pack(fill=tk.X, pady=(2, 0)) # 填满横向宽度
# 绑定保存逻辑
def save_text(e, k=key, w=text_widget):
# 获取内容时去除末尾自动添加的换行符
content = w.get("1.0", "end-1c")
self.logic.update_task_param(idx, k, content)
text_widget.bind("<FocusOut>", save_text)
# 提示文字
tk.Label(p_row, text="* 支持多行输入,按回车换行", bg="white", fg="#999", font=("Arial", 8)).pack(anchor="w")
else:
# === 普通单行输入框 (int 或 string) ===
entry = tk.Entry(p_row, bg="#f9f9f9", font=("Consolas", 9), relief="sunken")
entry.insert(0, str(val))
entry.pack(fill=tk.X, pady=(2, 0)) # 填满横向宽度
def save_entry(e, k=key, w=entry):
self.logic.update_task_param(idx, k, w.get())
entry.bind("<FocusOut>", save_entry)
# 初次渲染
render_params()
# 类型切换事件
def on_change(event):
if cb.get() != data["type"]:
self.logic.update_task_type(idx, cb.get())
self.refresh_list() # 必须刷新整个列表以重新计算高度
cb.bind("<<ComboboxSelected>>", on_change)
# === 动作 ===
def add_task(self):
self.logic.add_task()
self.refresh_list()
def del_task(self, idx):
if messagebox.askyesno("确认", "删除此任务?"):
self.logic.remove_task(idx)
self.refresh_list()
def run_single(self, idx):
res = self.logic.run_single_task(idx)
print(res) # 控制台打印
messagebox.showinfo("运行结果", res)
def run_all(self):
res = self.logic.run_all_tasks()
messagebox.showinfo("运行结果", res)
看起来很大,别慌。读python从大块开始读。
好消息是只有一个大块MainApp。
那对大块class,对地牢探险的我们只要读哪里被调用了。
------对吗?不对。tk.Tk里面实现了被调用,而这只是完善了调用内容。
看起来我们犯难了。不急。因为__init__是必须执行的,所以我们看看__init__就找到了转折点。
------看起来__init__这个调用了
self.create_sidebar()
看下去,有
self.show_config_page
self.show_task_page
两个主体。因为我想把全局配置和具体任务分开。
那地牢分支了,看看show_config_page 。
重要关注的显然是如何输入和保存配置。
可以看到输入用了tk的各接口,前端打积木就好啦。
注意单独实现了常用的输入框
def _input(self, parent, label, key, r, c=0):
tk.Label(parent, text=f"{label}:", bg="white").grid(row=r, column=c, sticky="e", padx=5, pady=5)
e = tk.Entry(parent, width=20, bg="#f9f9f9")
e.insert(0, self.logic.config.get(key, ""))
e.grid(row=r, column=c+1, sticky="w", padx=5)
e.bind("", lambda ev: self.logic.update_config(key, e.get()))
输入框里有 self.logic.update_config这就是输入和保存配置的奥妙!
def update_config(self, key, value):
self.config[key] = value
self.save_data()
self.apply_config_to_env()
logic是下一个层:调度任务层。我做成了ui严格和logic对接的形式。先按下不表。
再看看任务界面的ui。
主要是,这玩意是怎么做到每个任务有各自的参数的呢?
是这样的:
show_task_page-->refresh_list-->create_card-->render_params
这么多层之后,我们一个任务的card的参数就依次render了出来。
另外,当配置和运行,也是下派到logic,我们接下来去看。
2.地下二层------调度任务层logic:给ui支撑材料和分配ui得到的命令到手下
文件: MyProject/src/logic.py
import json
import os
import time
from src.tasks import SCRIPT_REGISTRY
class AppLogic:
def init (self):
self.data_file = "data.json"
# === 定义默认全局配置 ===
self.config = {
"window_name": "MuMu安卓设备",
"adb_path": r"C:\Program Files\Netease\MuMu\nx_main\adb.exe",
"OPENAI_API_KEY": "",
# --- 新增:循环次数 ---
"loop_count": "1",
"small_delay": "1.5",
"big_delay": "8",
"reset_pos_order": "1273 829",
"reset_pos_exit_order": "1263 860",
"reset_pos_dandelion": "1153 826",
"reset_pos_exit_dandelion": "100 224"
}
self.tasks = []
self.load_data()
self.apply_config_to_env()
def get_available_types(self):
return list(SCRIPT_REGISTRY.keys())
def get_default_params(self, task_type):
"""从 PARAM_CONFIG 中提取 {key: default_value}"""
cls = SCRIPT_REGISTRY.get(task_type)
if not cls: return {}
defaults = {}
# 遍历配置字典,提取 default 字段
for key, conf in cls.PARAM_CONFIG.items():
defaults[key] = conf.get("default", "")
return defaults
def add_task(self):
new_id = len(self.tasks) + 1
default_type = self.get_available_types()[0]
new_task = {
"id": new_id,
"name": f"任务 {new_id}",
"type": default_type,
"params": self.get_default_params(default_type),
"enable": False
}
self.tasks.append(new_task)
self.save_data()
return new_task
def update_task_type(self, index, new_type):
if 0 <= index < len(self.tasks):
self.tasks[index]["type"] = new_type
self.tasks[index]["params"] = self.get_default_params(new_type)
self.save_data()
def update_task_param(self, index, key, value):
if 0 <= index < len(self.tasks):
self.tasks[index]["params"][key] = value
self.save_data()
def update_task_status(self, index, is_enable):
self.tasks[index]["enable"] = is_enable
self.save_data()
def remove_task(self, index):
if 0 <= index < len(self.tasks):
self.tasks.pop(index)
self.save_data()
def update_config(self, key, value):
self.config[key] = value
self.save_data()
self.apply_config_to_env()
def apply_config_to_env(self):
"""将配置注入到系统环境变量"""
for k, v in self.config.items():
os.environ[k] = str(v)
def save_data(self):
with open(self.data_file, 'w', encoding='utf-8') as f:
json.dump({"config": self.config, "tasks": self.tasks}, f, ensure_ascii=False, indent=4)
# 唯一需要注意的是 load_data,建议保持之前的"清洗逻辑"
def load_data(self):
if os.path.exists(self.data_file):
try:
with open(self.data_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.config = data.get("config", self.config)
tasks = data.get("tasks", [])
cleaned = []
for t in tasks:
# 确保 params 是字典
if not isinstance(t.get("params"), dict):
t_type = t.get("type", self.get_available_types()[0])
t["type"] = t_type
t["params"] = self.get_default_params(t_type)
cleaned.append(t)
self.tasks = cleaned
except Exception as e:
print(f"数据重置: {e}")
self.tasks = []
# 运行逻辑保持不变
def run_single_task(self, index):
self.apply_config_to_env()
task = self.tasks[index]
cls = SCRIPT_REGISTRY.get(task["type"])
if not cls: return "未知任务类型"
try:
instance = cls(task["params"])
instance.run()
return f"[{task['name']}] 执行完毕"
except Exception as e:
return f"执行出错: {e}"
def run_all_tasks(self):
self.apply_config_to_env()
# 获取循环次数
try:
total_loops = int(self.config.get("loop_count", "1"))
except:
total_loops = 1
print(f"=== 计划执行 {total_loops} 轮循环 ===")
executed_count = 0
for cycle in range(total_loops):
print(f"\n>>> 正在执行第 {cycle + 1} / {total_loops} 轮 ...")
for i, t in enumerate(self.tasks):
if t["enable"]:
self.run_single_task(i)
executed_count += 1
# 轮次间休息,避免过热 (最后一轮不休)
if cycle < total_loops - 1:
print(">>> 本轮结束,等待 5 秒...")
time.sleep(5)
return f"全部完成!共执行 {total_loops} 轮。"
我们终于来到了后端,它给前端提供显示数据的同时对前端发派的指令实际执行。
我们发现了保存参数实现成了配置环境变量。
我们发现运行任务实际上是从from src.tasks import SCRIPT_REGISTRY拿的,还是要向下走!到task层实际执行run函数。
3.地下三层------任务执行层tasks:真的去执行任务
文件: MyProject/src/tasks.py
import time
import os
import ast
from src.ai_client import query_vlm
务必引入 execute_multiline_adb 用于执行 area_adb
from src.adb_utils import adb_click, adb_swipe, adb_zoom_out, parse_coordinate, execute_multiline_adb
from src.adb_utils import stitch_images, execute_multiline_adb, adb_screenshot # 引入新工具
=== 基类:定义标准流程 ===
class GameScriptBase:
def init (self, config):
self.params = config
self.s_delay = float(os.environ.get("small_delay", 1.5))
self.b_delay = float(os.environ.get("big_delay", 8.0))
def log(self, msg):
print(f"[{self.__class__.__name__}] {msg}")
# 1. 复位逻辑 (保持不变)
def reset_state(self):
"""
[固定状态复位]
"""
self.log(">>> [Step 1] 执行复位逻辑...")
# 侧滑唤醒
self.log("动作: 侧滑唤醒UI")
adb_swipe(600, 500, 800, 500, 300)
time.sleep(self.s_delay)
# 点击订单图标
pos_order = parse_coordinate(os.environ.get("reset_pos_order"))
if pos_order:
self.log(f"动作: 点击订单 {pos_order}")
adb_click(pos_order[0], pos_order[1])
time.sleep(self.s_delay)
# 缩放
self.log("动作: 双指缩放")
adb_zoom_out()
time.sleep(self.s_delay + 1.0)
# 退出订单
pos_exit_order = parse_coordinate(os.environ.get("reset_pos_exit_order"))
if pos_exit_order:
self.log(f"动作: 退出订单 {pos_exit_order}")
adb_click(pos_exit_order[0], pos_exit_order[1])
time.sleep(self.s_delay)
# 点击蒲公英
pos_dandelion = parse_coordinate(os.environ.get("reset_pos_dandelion"))
if pos_dandelion:
self.log(f"动作: 点击蒲公英 {pos_dandelion}")
adb_click(pos_dandelion[0], pos_dandelion[1])
time.sleep(self.b_delay)
# 退出蒲公英
pos_exit_dandelion = parse_coordinate(os.environ.get("reset_pos_exit_dandelion"))
if pos_exit_dandelion:
self.log(f"动作: 退出蒲公英 {pos_exit_dandelion}")
adb_click(pos_exit_dandelion[0], pos_exit_dandelion[1])
time.sleep(self.s_delay)
# 2. 定位逻辑 (从 execute 中提取出来)
def navigate_to_target(self):
"""
[进场] 执行 '区域选中ADB'
"""
self.log(">>> [Step 2] 定位目标区域...")
area_cmd = self.params.get("area_adb")
if area_cmd:
execute_multiline_adb(area_cmd)
else:
self.log("提示: 当前任务无需定位或未配置 area_adb")
# 进场后通常需要一点等待,让二级菜单弹出来
time.sleep(self.s_delay)
# 4. 退出界面逻辑 (新增)
def exit_interface(self):
"""
[退场]
"""
self.log(">>> [Step 2] 定位目标区域...")
area_cmd = self.params.get("quit_adb")
if area_cmd:
execute_multiline_adb(area_cmd)
else:
self.log("提示: 当前任务无需定位或未配置 quit_adb")
# 进场后通常需要一点等待,让二级菜单弹出来
time.sleep(self.s_delay)
# === 核心流程控制 ===
def run(self):
self.log("====== 任务序列启动 ======")
# 调试断点 (默认注释掉,需要调试时打开)
# 1. 复位 (Reset)
self.reset_state()
# 2. 定位 (Locate)
self.navigate_to_target()
# 3. 运行 (Execute)
self.log(">>> [Step 3] 执行核心逻辑...")
try:
self.execute()
except Exception as e:
self.log(f"ERROR: 执行出错: {e}")
# 4. 退出 (Exit)
self.exit_interface()
self.log("====== 任务序列结束 ======\n")
def execute(self):
raise NotImplementedError
def parse_list(self, param_key):
raw = self.params.get(param_key, "[]")
try:
return ast.literal_eval(raw)
except:
self.log(f"参数 {param_key} 格式错误")
return []
==========================================
任务定义区
==========================================
文件: MyProject/src/tasks.py
... (前面的 imports 和 GameScriptBase 保持不变) ...
class FarmingTask(GameScriptBase):
LABEL = "1. 种田 (智能版)"
PARAM_CONFIG = {
=== 基础定位 ===
"area_adb": {
"label": "区域选中ADB (定位到田地附近)",
"type": "text",
"default": "input tap 1115 227\nsleep 1"
},
"field_select_adb": {
"label": "选中田地ADB (点击田地弹出作物栏)",
"type": "text",
"default": "input tap 803 464\nsleep 1"
},
"farm_info_pos": {
"label": "农田说明图标坐标 (x y) - 用于复位作物栏",
"type": "string",
"default": "168 772"
},
"close_seed_bar_adb": {
"label": "无需播种时关闭动作 (通常点击空地)",
"type": "text",
默认点击屏幕上方空白处,通常能关闭底部栏
"default": "sleep 1\ninput tap 300 300\nsleep 1"
},
=== 动作轨迹配置 ===
"sickle_pos": {
"label": "镰刀位置 (x y)",
"type": "string",
"default": "795 770"
},
"field_path": {
"label": "通用田地划过轨迹 (纯坐标串)",
"type": "text",
这就是之前算好的 6x6 网格 S型扫描路径
"default": "799 465 1144 318 1208 348 867 491 935 517 1260 367 1317 391 1003 544 1071 569 1400 416 1500 443 1143 601 1043 601"
},
# === 作物配置 (11种) ===
"crop_limits": {
"label": "作物库存上限列表",
"type": "string",
# 11个50
"default": "[50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50]"
},
"crop_pos_list": {
"label": "作物种子位置列表 ['x y', ...]",
"type": "string",
# 前7个是左页,后4个是右页,Y固定为760
"default": "['340 760', '475 760', '610 760', '745 760', '880 760', '1015 760', '1150 760', '845 760', '980 760', '1115 760', '1250 760']"
},
"crop_is_right_side": {
"label": "作物是否在右侧页 (0=左, 1=右)",
"type": "string",
# 前7个为0,后4个为1
"default": "[0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1]"
},
"crop_bar_swipe_adb": {
"label": "作物栏翻页动作 (向左滑)",
"type": "text",
# 稍微滑长一点确保翻过去
"default": "input swipe 1000 760 300 760 600"
},
# === 拼图识别 ===
"stitch_left_x": { "label": "拼图: 左图保留宽度 (X)", "type": "int", "default": "1230" },
"stitch_right_x": { "label": "拼图: 右图起始位置 (X)", "type": "int", "default": "780" }
}
def execute(self):
# 1. 点击田地
# 2. 状态判断
screenshot_path = "farm_state.png"
adb_screenshot(screenshot_path)
check_prompt = """
请分析这张游戏截图,判断当前处于什么状态。
只返回一个关键词:
- "harvest" : 画面底部有镰刀图标。
- "plant" : 画面底部有作物种子选择栏。
- "wait" : 其他情况。
"""
state = query_vlm(screenshot_path, check_prompt)
self.log(f"AI判断状态: {state}")
if "harvest" in state:
self.do_harvest()
time.sleep(self.s_delay)
self.do_plant_logic() # 收完直接种
elif "plant" in state:
self.do_plant_logic()
else:
self.log("无需操作")
def do_harvest(self):
self.log(">>> 执行收获逻辑")
sickle = self.params.get("sickle_pos", "795 770").strip()
path = self.params.get("field_path", "").strip()
# 拼装命令:drag_path 镰刀点 轨迹点
full_cmd = f"drag_path {sickle} {path}\nsleep 1"
self.log(f"生成收割指令: {full_cmd[:50]}...")
execute_multiline_adb(full_cmd)
self.log("收获完成")
def do_plant_logic(self):
self.log(">>> 执行播种逻辑")
info_pos = self.params.get("farm_info_pos", "168 772")
info_coords = parse_coordinate(info_pos)
execute_multiline_adb(self.params.get("field_select_adb"))
time.sleep(self.s_delay) # 等作物栏弹出来
# ==========================================
# 步骤 2: 截图识别库存
# ==========================================
# 左图
img_left = "crop_bar_1.png"
adb_screenshot(img_left)
# 翻页
execute_multiline_adb(self.params.get("crop_bar_swipe_adb"))
time.sleep(self.s_delay)
# 右图
img_right = "crop_bar_2.png"
adb_screenshot(img_right)
# 拼图
img_stitched = "crop_bar_full.png"
split_l = int(self.params.get("stitch_left_x", 1600))
split_r = int(self.params.get("stitch_right_x", 0))
stitch_images(img_left, img_right, img_stitched, split_l, split_r)
# AI 识别
count_prompt = "请读取底部作物栏的库存数字,返回纯数字列表: [x, x, ...]"
ai_res = query_vlm(img_stitched, count_prompt)
self.log(f"AI读取库存: {ai_res}")
# ==========================================
# 步骤 3: 决策与播种
# ==========================================
try:
import re
nums = re.findall(r'\d+', ai_res)
current_counts = [int(n) for n in nums]
limits = self.parse_list("crop_limits")
# 这里获取的是坐标字符串列表 ['200 850', '350 850'...]
seed_pos_list = self.parse_list("crop_pos_list")
is_right_list = self.parse_list("crop_is_right_side")
# 安全检查
if not current_counts: return
target_index = -1
min_count = 99999
loop_len = min(len(current_counts), len(limits), len(seed_pos_list))
for i in range(loop_len):
curr = current_counts[i]
limit = limits[i]
if curr < limit and curr < min_count:
min_count = curr
target_index = i
if target_index != -1:
self.log(f"决定种植第 {target_index+1} 种作物 (库存 {min_count})")
# --- 核心:准备播种 ---
# 3.1 再次归零 (为了确保位置准确)
# 因为刚才翻页去截图了,现在界面在第二页,如果目标在第一页,需要复位
# 如果目标就在第二页,其实不用复位,但为了逻辑简单,建议统一复位再操作
# 或者:判断 target_index 是否在右侧,如果刚才截图完在右侧,且目标也在右侧,就不动
# 这里为了稳妥,采用最笨的办法:复位 -> 再根据需要翻页
# 这里我们简化一下:刚才截图完停在"右页"。
# 如果目标是左页(0),需要复位。
# 如果目标是右页(1),不需要动 (假设翻页后就在右页)。
target_is_right = 0
if target_index < len(is_right_list):
target_is_right = is_right_list[target_index]
if target_is_right == 0:
self.log("目标在左侧,执行复位...")
# 点击说明 -> 点击田地
if info_coords: adb_click(info_coords[0], info_coords[1])
time.sleep(self.s_delay)
execute_multiline_adb(self.params.get("field_select_adb"))
time.sleep(self.s_delay)
else:
self.log("目标在右侧,保持当前页面")
# 如果刚才截图后你没有复位,那现在就在右侧,直接操作即可
# 3.2 提取种子坐标
# 列表里可能是 "input tap 200 850" 或者 "200 850",我们需要清洗出纯坐标
raw_pos = seed_pos_list[target_index]
# 简单清洗:去掉非数字字符,只留空格和数字
clean_pos = re.sub(r'[^\d\s]', '', raw_pos).strip()
# 3.3 拼装播种指令
path = self.params.get("field_path", "").strip()
plant_cmd = f"drag_path {clean_pos} {path}\nsleep 1"
self.log(f"生成播种指令: {plant_cmd}...")
execute_multiline_adb(plant_cmd)
else:
self.log("库存充足")
# === 关键修改:执行关闭动作 ===
execute_multiline_adb(self.params.get("close_seed_bar_adb"))
except Exception as e:
self.log(f"决策出错: {e}")
class GatheringTask(GameScriptBase):
LABEL = "2. 采集 (全自动版)"
PARAM_CONFIG = {
# === 基础操作 ===
"area_adb": {
"label": "区域选中ADB",
"type": "text",
"default": "input swipe 800 450 100 800 5000\ninput tap 1314 460\nsleep 1"
},
"btn_pos": {
"label": "收获/开始生产按键ADB",
"type": "text",
"default": "input tap 1243 750"
},
"nav_adb": {
"label": "地点切换ADB",
"type": "text",
"default": "input tap 823 662"
},
"quit_adb": {
"label": "退出界面ADB",
"type": "text",
"default": "input tap 763 798"
},
"location_count": {
"label": "林地数量 (整数)",
"type": "int",
"default": "3"
},
# === 核心基础配置:4个物理格子坐标 ===
"slot_adbs": {
"label": "顶部4个格子的固定ADB (物理坐标)",
"type": "text",
# 定义好这4个位置,后面代码会自动按顺序取用
"default": "['input tap 1040 206', 'input tap 1160 206', 'input tap 1280 206', 'input tap 1400 206']"
},
# === 新增:截图裁剪配置 ===
"digit_crop_box": {
"label": "库存数字截图裁剪区域 [x1, y1, x2, y2]",
"type": "string", # string 类型也就是单行文本框
"default": "[1060, 394, 1160, 464]"
},
# === 业务配置 ===
"item_names_list": {
"label": "各林地作物名称 [['松木'], ['竹子']...]",
"type": "text",
# 只要填了名字,代码会自动去点对应顺序的格子
"default": "[['木材'], ['青竹'], ['原始土','矿料']]"
},
# 移除了 item_slot_indices_list,由代码自动推导
"item_limits_list": {
"label": "物品库存上限 [[100], [100]...]",
"type": "text",
"default": "[[100], [100], [100, 100]]"
}
}
def execute(self):
count = int(self.params.get("location_count", 1))
nav = self.params.get("nav_adb").strip()
# 1. 解析全局格子配置
master_slots = self.parse_list("slot_adbs")
# 2. 解析业务数据
names_group = self.parse_list("item_names_list")
limits_group = self.parse_list("item_limits_list")
for i in range(count):
self.log(f"--- 正在处理第 {i+1}/{count} 个林地 ---")
# 获取当前林地的数据
curr_names = names_group[i] if i < len(names_group) else []
curr_limits = limits_group[i] if i < len(limits_group) else []
# === 核心逻辑优化:自动生成 ADB 指令 ===
curr_select_cmds = []
# 根据当前地点有几个物品 (len(curr_names)),就取前几个格子
# 例如:有2个物品,就取 master_slots[0] 和 master_slots[1]
for idx in range(len(curr_names)):
if idx < len(master_slots):
curr_select_cmds.append(master_slots[idx])
else:
self.log(f"警告:物品数量({len(curr_names)}) 超过了配置的格子数({len(master_slots)})")
# 超过部分无法点击,或者你可以填一个默认值
if curr_names:
self.process_single_location(curr_names, curr_select_cmds, curr_limits)
else:
self.log("未配置该地点的物品,跳过")
time.sleep(self.s_delay)
# 切换地点
if i < count - 1:
execute_multiline_adb(nav)
def process_single_location(self, names, selects, limits):
"""处理单个地点的核心逻辑"""
screenshot_path = "gather_state.png"
adb_screenshot(screenshot_path)
check_prompt = "判断右下角按钮,从以下选择三选一:'收获'、'开始生产' 或 '加速' (工作中)。只回关键词。"
state = query_vlm(screenshot_path, check_prompt)
self.log(f"当前状态: {state}")
if "harvest" in state or "收获" in state:
self.do_harvest()
time.sleep(self.s_delay)
self.do_production_logic(names, selects, limits)
elif "plant" in state or "生产" in state:
self.do_production_logic(names, selects, limits)
elif "working" in state or "加速" in state:
self.log("正在工作中,跳过")
else:
self.log("未知状态,跳过")
def do_harvest(self):
self.log(">>> 执行收获")
cmd = self.params.get("btn_pos")
execute_multiline_adb(cmd)
def do_production_logic(self, names, selects, limits):
self.log(">>> 轮询库存...")
current_counts = []
# 安全检查
loop_len = min(len(names), len(selects))
crop_list = self.parse_list("digit_crop_box")
crop_box = tuple(crop_list) if len(crop_list) == 4 else None
if crop_box is None:
self.log("警告:裁剪参数格式错误,将使用全图识别")
for k in range(loop_len):
item_name = names[k]
select_cmd = selects[k]
# 1. 选中
execute_multiline_adb(select_cmd)
time.sleep(self.s_delay)
# 2. 截图
img_name = f"item_{k}.png"
adb_screenshot(img_name, crop_box=crop_box)
# 提示词:读取中间显示的库存
prompt = (
f"这是'{item_name}'的库存数字特写。请识别图中的纯数字整数。"
f"【重要规则】图片中只有数字,**绝对没有英文字母**!"
f"如果你看到圆圈形状(如 'o', 'O'),请务必将其识别为数字 '0'。"
f"如果存在斜杠 '/' 及其后面的数字(如 '54/1'),请忽略斜杠部分,只读前面的库存数。"
)
ai_res = query_vlm(img_name, prompt)
# === 新增:暴力清洗数据,把 o/O 变回 0 ===
ai_res = ai_res.replace('o', '0').replace('O', '0')
try:
import re
nums = re.findall(r'\d+', ai_res)
count = int(nums[0]) if nums else 9999
except:
count = 9999
self.log(f"[{item_name}] 库存: {count}")
current_counts.append(count)
# 决策
if not current_counts: return
target_index = -1
min_count = 99999
# 安全检查
decision_len = min(len(current_counts), len(limits))
for i in range(decision_len):
curr = current_counts[i]
limit = limits[i]
if curr < limit and curr < min_count:
min_count = curr
target_index = i
if target_index != -1:
target_name = names[target_index]
self.log(f"决定生产: {target_name}")
# 1. 再次选中
execute_multiline_adb(selects[target_index])
time.sleep(self.s_delay)
# 2. 生产
btn_cmd = self.params.get("btn_pos")
execute_multiline_adb(btn_cmd)
else:
self.log("库存充足,不生产")
class ProcessingTask(GameScriptBase):
LABEL = "3. 加工 (普通/特殊工坊)"
PARAM_CONFIG = {
# === 基础导航 ===
"area_adb": {
"label": "区域选中ADB (进入起始位置)",
"type": "text",
"default": "input tap 1223 760\nsleep 2"
},
"nav_adb": {
"label": "切换上一个工坊ADB (向左切换),开头先切换一次。注意下面的参数顺序是执行顺序",
"type": "text",
"default": "input tap 110 664"
},
"quit_adb": {
"label": "退出界面ADB",
"type": "text",
"default": "input tap 100 100"
},
"location_count": {
"label": "工坊总数量",
"type": "int",
"default": "27"
},
"ui_types": {
"label": "UI类型 (0=普通, 1=特殊)",
"type": "string",
# 你的实际配置
"default": "[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1]"
},
# === [Type 0] 普通工坊配置 ===
"normal_slot_adbs": {
"label": "Type0: 右上角8个原料格物理坐标",
"type": "text",
# 这里填右上角那两排格子的坐标
"default": "['input tap 1050 220', 'input tap 1160 220', 'input tap 1260 220', 'input tap 1360 220', 'input tap 1050 320', 'input tap 1160 320', 'input tap 1260 320', 'input tap 1360 320']"
},
"normal_harvest_adb": {
"label": "Type0: 收获动作 (点击下方6个生产位)",
"type": "text",
# 依次点击下方的6个圆盘/格子
"default": "input tap 840 815\nsleep 1\ninput tap 1165 550\nsleep 1\ninput tap 710 815\nsleep 1\ninput tap 1165 550\nsleep 1\ninput tap 587 815\nsleep 1\ninput tap 1165 550\nsleep 1\ninput tap 463 815\nsleep 1\ninput tap 1165 550\nsleep 1\ninput tap 331 815\nsleep 1\ninput tap 1165 550\nsleep 1\ninput tap 223 815\nsleep 1\ninput tap 1165 550\nsleep 1\n"
},
"normal_produce_btn": {
"label": "Type0: 开始制作按钮",
"type": "text",
"default": "input tap 1300 760"
},
# === 新增:Type 0 截图裁剪配置 ===
"normal_digit_crop_box": {
"label": "Type0: 库存数字截图裁剪区域 [x1, y1, x2, y2]",
"type": "string",
"default": "[1035, 530, 1135, 600]"
},
# === [Type 1] 特殊工坊配置 ===
"special_harvest_btn": {
"label": "Type1: 收获按钮 (捡蛋/收蜜)",
"type": "text",
"default": "input tap 1314 733\nsleep 1\ninput tap 1314 667"
},
"special_feed_btn": {
"label": "Type1: 喂食/添加按钮",
"type": "text",
"default": "input tap 1080 733"
},
# === 物品配置 (平行列表) ===
"item_names_list": {
"label": "产品名称列表 [['粉1','粉2'], ['膏1']...]",
"type": "text",
"default": "[['元气壮骨粉','益寿延珍粉','畅悦清润粉'], ['白玉萤肌膏','逐元润颜膏','玲珑泽面膏'],['贝壳粉','珍珠粉','藕粉'], ['无芯莲','裙边干','甲鱼壳'],['青染布','红染布','紫染布','黄染布'],['青染料','红染料','紫染料','黄染料'],['烤地瓜','烤土豆','烤豆腐','烤鸡翅','烤羊腿'], ['布娃娃','虎头帽','荷包','绣球'],['麻布','绒线','棉布','蚕丝线'],['南瓜籽','粉丝','干豆豉'],['砚','笔','纸'],['陶器','单色瓷','彩绘瓷'],['陶土','釉料','釉彩'],['竹蜻蜓','泥哨','竹板','陀螺'],['竹片','竹篓','麻绳','斗笠'],['糯米糕','鸡蛋糕','发糕','南瓜糕','马蹄糕'],['陶艺木具','榫卯','鲁班锁'],['油豆腐','腐竹','豆腐乳','豆筋','豆腐脑','豆腐干'],['咸鸡蛋','酸菜','腌鸡肉','腌土豆','腌羊肉'],['鸡蛋饼','烧饼','蔬菜饼','土豆饼','南瓜饼','地瓜饼'],['糖','红糖','麦糖'],['鸡饲料','羊饲料','蚕食'],['豆腐','豆浆','豆豉'],['面粉','糕粉','土豆粉','地瓜粉'],['蚕丝'],['羊毛','羊肉'],['鸡蛋','鸡肉']]"
},
"item_limits_list": {
"label": "产品库存上限",
"type": "text",
"default": "[[50, 50, 50], [50, 50, 50], [50, 50, 50], [50, 50, 50], [50, 50, 50, 50], [50, 50, 50, 50], [50, 50, 50, 50, 50], [50, 50, 50, 50], [50, 50, 50, 50], [50, 50, 50], [50, 50, 50], [50, 50, 50], [50, 50, 50], [50, 50, 50, 50], [50, 50, 50, 50], [50, 50, 50, 50, 50], [50, 50, 50], [50, 50, 50, 50, 50, 50], [50, 50, 50, 50, 50], [50, 50, 50, 50, 50, 50], [50, 50, 50], [50, 50, 50], [50, 50, 50], [50, 50, 50, 50], [50], [50, 50], [50, 50]]"
}
}
def execute(self):
# 2. 准备循环数据
count = int(self.params.get("location_count", 27))
nav_cmd = self.params.get("nav_adb").strip()
ui_types = self.parse_list("ui_types")
names_group = self.parse_list("item_names_list")
limits_group = self.parse_list("item_limits_list")
# 预加载物理坐标
normal_slots = self.parse_list("normal_slot_adbs")
# 3. 开始倒序/正序循环 (根据你的 nav 是向前还是向后,这里按你的逻辑是向左切)
for i in range(count):
self.log(f"--- 处理第 {i+1}/{count} 个工坊 ---")
# 导航逻辑:除了第一次进入不用切,后面都要切
# 或者按照你的逻辑:area_adb 进的是第27个? 还是 dummy?
# 你的代码写的是:先 nav,再处理。这意味着 area_adb 进去的位置是 "起点",然后立刻左切进入 "第一个待处理"。
# 我们保持你的逻辑:
self.log("动作: 切换工坊")
execute_multiline_adb(nav_cmd)
time.sleep(self.s_delay)
# 获取数据
curr_type = ui_types[i] if i < len(ui_types) else 0
curr_names = names_group[i] if i < len(names_group) else []
curr_limits = limits_group[i] if i < len(limits_group) else []
if not curr_names:
self.log("未配置物品,跳过")
continue
# 分发逻辑
if curr_type == 0:
self.process_normal_factory(curr_names, curr_limits, normal_slots)
else:
self.process_special_factory(curr_names, curr_limits)
def process_normal_factory(self, names, limits, slot_adbs):
"""
[Type 0] 养生坊/药膏坊逻辑
"""
# 1. 先收获 (盲点下方6个位置)
self.log("执行收获...")
execute_multiline_adb(self.params.get("normal_harvest_adb"))
time.sleep(self.s_delay)
# 2. 轮询读取当前库存
self.log("读取库存...")
current_counts = []
# 安全检查
item_count = min(len(names), len(slot_adbs))
# === 修改点:从参数读取裁剪区域 ===
crop_list = self.parse_list("normal_digit_crop_box")
crop_box = tuple(crop_list) if len(crop_list) == 4 else None
for k in range(item_count):
# 点击右上角对应的格子
execute_multiline_adb(slot_adbs[k])
time.sleep(self.s_delay)
# 截图并识别
img_name = f"factory_item_{k}.png"
adb_screenshot(img_name, crop_box=crop_box)
# 提示词:读取中间显示的库存
prompt = (
f"这是'{names[k]}'的库存数字特写。请识别图中的纯数字整数。"
f"【重要规则】图片中只有数字,**绝对没有英文字母**!"
f"如果你看到圆圈形状(如 'o', 'O'),请务必将其识别为数字 '0'。"
f"如果存在斜杠 '/' 及其后面的数字(如 '54/1'),请忽略斜杠部分,只读前面的库存数。"
)
res = query_vlm(img_name, prompt)
# === 新增:暴力清洗数据,把 o/O 变回 0 ===
res = res.replace('o', '0').replace('O', '0')
try:
import re
nums = re.findall(r'\d+', res)
val = int(nums[0]) if nums else 9999
except:
val = 9999
self.log(f"[{names[k]}] 库存: {val}")
current_counts.append(val)
# 3. 生产循环 (假设有6个生产位,就尝试填满6次)
# 贪心算法:每次都生产最缺的那个,并模拟库存+1
produce_btn = self.params.get("normal_produce_btn")
for slot_idx in range(6): # 6个生产坑位
target_idx = -1
min_val = 99999
# 找最缺的
for k in range(min(len(current_counts), len(limits))):
curr = current_counts[k]
limit = limits[k]
if curr < limit and curr < min_val:
min_val = curr
target_idx = k
if target_idx != -1:
item_name = names[target_idx]
self.log(f"生产位[{slot_idx+1}/6]: 制作 {item_name} (模拟库存 {min_val}->{min_val+1})")
# 再次点击选中 (防止焦点丢失)
execute_multiline_adb(slot_adbs[target_idx])
time.sleep(self.s_delay)
# 点击制作
execute_multiline_adb(produce_btn)
time.sleep(self.s_delay)
# === 关键:手动增加计数,影响下一次循环决策 ===
current_counts[target_idx] += 1
else:
self.log(f"生产位[{slot_idx+1}/6]: 无需生产")
break # 如果都不缺,直接退出循环
def process_special_factory(self, names,limits):
"""
[Type 1] 鸡舍/羊圈逻辑
图2显示:右下角有两个资源 (比如 鸡蛋:20, 鸡肉:0)
"""
# 1. 先收获
self.log("执行捡蛋/收货...")
execute_multiline_adb(self.params.get("special_harvest_btn"))
time.sleep(self.s_delay) # 等动画
# 2. 截图读取右下角资源
img_name = "special_factory.png"
adb_screenshot(img_name)
# 提示词优化:针对图2右下角
prompt = f"请读取图片右下角'拥有:'后面的({str(names)}物品的)数字列表。例如'拥有:鸡蛋 20 鸡肉 0',请返回 [20, 0]。"
res = query_vlm(img_name, prompt)
self.log(f"AI读取资源: {res}")
try:
import re
nums = re.findall(r'\d+', res)
# 假设拥有列表顺序和 limits 顺序一致
current_owned = [int(n) for n in nums]
should_feed = False
# 逻辑:只要有任意一个资源低于上限,就喂食?或者必须两个都低?
# 通常逻辑是:只要缺货,就得喂。
for k in range(min(len(current_owned), len(limits))):
if current_owned[k] < limits[k]:
should_feed = True
self.log(f"资源[{k}] {current_owned[k]} < {limits[k]},需要喂食")
if should_feed:
self.log("执行喂食...")
execute_multiline_adb(self.params.get("special_feed_btn"))
else:
self.log("资源充足,不喂食")
except Exception as e:
self.log(f"特殊工坊识别出错: {e}")
class CookingTask(GameScriptBase):
LABEL = "4. 做菜(固定位置/盲盒模式)"
PARAM_CONFIG = {
# === 基础操作 ===
"area_adb": {
"label": "进入厨房 (★运行前请确保:所有菜谱分类处于折叠状态★)",
"type": "text",
"default": "input tap 875 122\nsleep 8"
},
"cook_btn_adb": {
"label": "开始/快速烹饪按钮ADB,包括等待动画",
"type": "text",
"default": "input tap 1256 770\nsleep 5\ninput tap 1256 770\n"
},
"quit_adb": {
"label": "退出界面ADB",
"type": "text",
"default": "input tap 1486 45\nsleep 5"
},
# === 统一参数 ===
"cook_batch_count": {
"label": "单次制作次数 (缺货时连点多少下)",
"type": "int",
"default": "1"
},
# === 核心配置:位置遍历 ===
"position_adbs_list": {
"label": "位置遍历列表 ADB (依次点击屏幕上的格子)",
"type": "text",
"default": "['input tap 1160 203\\nsleep 1\\ninput tap 1160 300\\nsleep 1', 'input tap 1160 203\\nsleep 1\\ninput tap 1160 406\\nsleep 1', 'input tap 1160 203\\nsleep 1\\ninput tap 1160 525\\nsleep 1', 'input tap 1160 203\\nsleep 1\\ninput tap 1160 656\\nsleep 1' ]",
"help": "请填入点击不同菜品位置的指令。脚本将按顺序执行。"
},
"dish_reset_adbs_list": {
"label": "位置复位列表 ADB (做完该位置的菜后,如何返回/关闭弹窗)",
"type": "text",
# 注意:下面这行末尾改成了逗号
"default": "['input swipe 1210 200 1210 880 2000\\nsleep 1\\ninput tap 1160 186\\nsleep 1','input swipe 1210 200 1210 880 2000\\nsleep 1\\ninput tap 1160 186\\nsleep 1','input swipe 1210 200 1210 880 2000\\nsleep 1\\ninput tap 1160 186\\nsleep 1','input swipe 1210 200 1210 880 2000\\nsleep 1\\ninput tap 1160 186\\nsleep 1']",
"help": "重要:做完菜后通常需要关闭详情页或弹窗,才能点击下一个位置。请确保此列表长度与位置列表一致。"
}
}
def execute(self):
# 1. 解析参数
pos_adbs = self.parse_list("position_adbs_list")
reset_adbs = self.parse_list("dish_reset_adbs_list")
cook_btn = self.params.get("cook_btn_adb")
batch_count = int(self.params.get("cook_batch_count", 1))
# 3. 循环遍历
# 以位置列表长度为准
total_steps = len(pos_adbs)
if total_steps == 0:
self.log("未配置位置列表,任务结束")
return
self.log(f"开始任务:共遍历 {total_steps} 个位置,单次制作 {batch_count} 份")
for i in range(total_steps):
# 获取当前步骤的指令
curr_pos_cmd = pos_adbs[i]
# 获取对应的复位指令,防止索引越界
curr_reset_cmd = reset_adbs[i] if i < len(reset_adbs) else ""
self.log(f"--- 步骤 [{i+1}/{total_steps}] ---")
# 3.1 选中位置
self.log(f"执行位置选中...")
execute_multiline_adb(curr_pos_cmd)
time.sleep(self.s_delay)
# 3.2 盲做
self.log(f"执行烹饪 ({batch_count}次)...")
for k in range(batch_count):
execute_multiline_adb(cook_btn)
# 如果 cook_btn 里面包含等待动画的时间,这里 sleep 可以短一点,否则建议给足时间
time.sleep(self.s_delay)
# 3.3 复位 (关键步骤)
if curr_reset_cmd:
self.log(f"执行复位/收尾...")
execute_multiline_adb(curr_reset_cmd)
time.sleep(self.s_delay)
else:
self.log("警告:该位置未配置复位指令,可能影响后续操作")
class OrderTask(GameScriptBase):
LABEL = "5. 订单 (自动交付)"
PARAM_CONFIG = {
# === 进场 ===
"area_adb": {
"label": "区域选中ADB (点击主界面订单图标)",
"type": "text",
# 使用你之前提供的 reset_pos_order 坐标
"default": "input tap 1273 829\nsleep 2"
},
# === 业务逻辑 ===
"slot_select_adbs": {
"label": "订单栏位选中ADB列表 (从左到右依次点击)",
"type": "text",
# 根据截图估算的5个栏位坐标,你可以根据实际情况调整
"default": "['input tap 285 430','input tap 405 430','input tap 530 430','input tap 660 430','input tap 780 430','input tap 900 430','input tap 1020 430','input tap 1140 430','input tap 1260 430','input tap 1380 430']"
},
"deliver_btn_adb": {
"label": "交付按钮ADB (绿色大按钮)",
"type": "text",
# 根据截图右下角估算
"default": "input tap 1335 761"
},
# === 退场 ===
"quit_adb": {
"label": "退出界面ADB",
"type": "text",
# 使用你之前提供的 reset_pos_exit_order 坐标
"default": "input tap 1263 860"
},
}
def execute(self):
# 1. 解析参数
slots = self.parse_list("slot_select_adbs")
deliver_cmd = self.params.get("deliver_btn_adb")
self.log(f"开始处理订单,共 {len(slots)} 个栏位")
# 2. 循环处理每个栏位
for i, slot_cmd in enumerate(slots):
# 2.1 选中订单
# self.log(f"检查订单 {i+1}...")
execute_multiline_adb(slot_cmd)
time.sleep(self.s_delay) # 等待右侧详情刷新
# 2.2 尝试交付
# 无论是否满足条件,点一下总是没错的。
# 如果满足,就交了;如果不满足,点了也没反应。
execute_multiline_adb(deliver_cmd)
# 稍微停顿,防止点击过快系统反应不过来
time.sleep(self.s_delay)
class DandelionTask(GameScriptBase):
LABEL = "6. 蒲公英 (自动收发)"
PARAM_CONFIG = {
# === 进场 ===
"area_adb": {
"label": "进入蒲公英ADB (对应全局复位坐标)",
"type": "text",
"default": "input tap 1153 826\nsleep 2.5"
},
# === 核心动作 ===
"harvest_btn_adb": {
"label": "一键收获按钮ADB (图1右上青色按钮)",
"type": "text",
# 根据截图估算:右侧靠下上方一点
"default": "input tap 1424 725"
},
"fill_btn_adb": {
"label": "快速装填按钮ADB",
"type": "text",
# 根据截图估算:右侧最下方
"default": "input tap 1407 825"
},
"submit_btn_adb": {
"label": "弹窗提交按钮ADB",
"type": "text",
# 根据截图估算:弹窗的右下角
"default": "input tap 974 703"
},
# === 退场 ===
"quit_adb": {
"label": "退出界面ADB (对应全局复位退出坐标)",
"type": "text",
"default": "input tap 100 224"
}
}
def execute(self):
# 1. 进场 (Base类会自动调用 area_adb,但这里为了逻辑连贯清晰,我们显式打印一下)
self.log(">>> 进入蒲公英小队...")
# 2. 尝试收获
# 无论有没有东西,点一下总是安全的
self.log("动作: 点击一键收获")
execute_multiline_adb(self.params.get("harvest_btn_adb"))
time.sleep(self.s_delay) # 等待收获动画或提示消失
# 3. 尝试装填
self.log("动作: 点击快速装填")
execute_multiline_adb(self.params.get("fill_btn_adb"))
time.sleep(self.s_delay) # 等待弹窗出现
# 4. 确认提交
# 如果上一步没弹出窗(比如没东西可填),这一步点在空地上也没事
self.log("动作: 点击确认提交")
execute_multiline_adb(self.params.get("submit_btn_adb"))
time.sleep(self.s_delay) # 等待提交动画
=== 注册表 ===
SCRIPT_REGISTRY = {
t.LABEL: t for t in [
FarmingTask,
GatheringTask,
ProcessingTask,
CookingTask,
OrderTask,
DandelionTask
]
}
作为地宫的主体部分,来实现所有的功能,它的体量是庞大的。光鲜亮丽的ui背后,最终辛苦劳动的劳动者就是task们。
task是六位一体。因为task是相似而不相同,所以我让它们共用体GameScriptBase。
从我们在上一层拿到的run来看,里面有复位进场退场三个通用的步骤。
还有一个独特的执行步骤。
每个execute各有不同,就模拟人去实现就好啦。
那再里面呢?怎么落实到真正的游戏里面真正模拟到操作 呢?
4.地下四层分支一------模拟操作层adb_utils:如同手臂一样把task想要的东西拿到手
文件: MyProject/src/adb_utils.py
import subprocess
from PIL import Image
from io import BytesIO
import os
import time
全局变量:存储当前选中的设备 ID
CURRENT_DEVICE_ID = None
def get_adb_path():
"""获取 ADB 路径,带默认值防止报错"""
return os.environ.get('adb_path', 'adb')
def auto_select_device():
"""
核心逻辑\] 自动寻找并锁定第一个可用的设备
如果列表为空,会自动尝试连接 MuMu 的常见端口
"""
global CURRENT_DEVICE_ID
if CURRENT_DEVICE_ID:
return CURRENT_DEVICE_ID
adb_path = get_adb_path()
# === 定义常见模拟器端口 ===
# 7555: MuMu 6 / MuMu Pro
# 16384: MuMu 12 (nx_main)
# 5555: 蓝叠 / 雷电等通用端口
mumu_ports = ["127.0.0.1:16384", "127.0.0.1:7555", "127.0.0.1:5555"]
def check_devices():
"""内部函数:运行 adb devices 并解析"""
try:
res = subprocess.run(
[adb_path, "devices"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=5
)
output = res.stdout.decode('utf-8', errors='ignore')
lines = output.strip().split('\n')
for line in lines:
if "List of devices" in line or not line.strip():
continue
parts = line.split()
if len(parts) >= 2 and parts[1] == 'device':
return parts[0] # 返回设备ID
except:
pass
return None
# 1. 第一次检查
device_id = check_devices()
if device_id:
CURRENT_DEVICE_ID = device_id
print(f"【ADB】已自动锁定设备: {CURRENT_DEVICE_ID}")
return CURRENT_DEVICE_ID
# 2. 如果没找到,尝试主动连接 MuMu 端口
print("【ADB】未找到设备,尝试自动连接 MuMu 模拟器端口...")
for port in mumu_ports:
print(f" -> 尝试连接 {port} ...")
subprocess.run([adb_path, "connect", port], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# 3. 连接后再次检查
time.sleep(1) # 等一秒让连接生效
device_id = check_devices()
if device_id:
CURRENT_DEVICE_ID = device_id
print(f"【ADB】重连成功!锁定设备: {CURRENT_DEVICE_ID}")
return CURRENT_DEVICE_ID
print("【ADB警告】尝试连接失败,请检查模拟器是否启动,或手动执行 adb connect")
return None
## ================= 基础 ADB 封装 =================
def run_adb_cmd(cmd_list):
"""
执行 ADB 命令。
自动添加 -s \
hold_time: 起始按住的时间(ms)
"""
if not point_list: return
# --- 调速参数 (觉得慢可以改小,觉得快可以改大) ---
STEPS_PER_SEGMENT = 40 # 两点之间插值的数量 (原10 -> 改40)
STEP_WAIT_MS = 5 # 每移动一小步等待的毫秒数 (新增)
# Monkey 脚本头
script_content = "type= user\ncount= 10\nspeed= 1.0\nstart data >>\n"
# 1. 在起点按下
start_x, start_y = point_list[0]
script_content += f"DispatchPointer(0, 0, 0, {start_x}, {start_y}, 0, 0, 0, 0, 0, 0, 0)\n"
# 2. 原地等待 (抓取镰刀)
script_content += f"UserWait({hold_time})\n"
# 发送一个原地 Move 激活判定
script_content += f"DispatchPointer(0, 0, 2, {start_x}, {start_y}, 0, 0, 0, 0, 0, 0, 0)\n"
# 3. 连续移动 (慢速插值)
for i in range(len(point_list) - 1):
p1 = point_list[i]
p2 = point_list[i+1]
# 线性插值
for step in range(1, STEPS_PER_SEGMENT + 1):
t = step / STEPS_PER_SEGMENT
x = int(p1[0] + (p2[0] - p1[0]) * t)
y = int(p1[1] + (p2[1] - p1[1]) * t)
script_content += f"DispatchPointer(0, 0, 2, {x}, {y}, 0, 0, 0, 0, 0, 0, 0)\n"
# [关键] 增加微小延迟,防止跑太快
script_content += f"UserWait({STEP_WAIT_MS})\n"
# 4. 抬起
end_x, end_y = point_list[-1]
script_content += f"DispatchPointer(0, 0, 1, {end_x}, {end_y}, 0, 0, 0, 0, 0, 0, 0)\n"
# --- 下面是写入文件和执行逻辑 (保持不变) ---
local_path = "temp_drag.mks"
remote_path = "/sdcard/temp_drag.mks"
with open(local_path, "w", encoding="utf-8") as f:
f.write(script_content)
adb_path = get_adb_path()
device_id = auto_select_device()
# Push
cmd_push = [adb_path]
if device_id: cmd_push.extend(["-s", device_id])
cmd_push.extend(["push", local_path, remote_path])
subprocess.run(cmd_push)
# Run
print(f"【ADB】执行慢速拖拽 (Steps={STEPS_PER_SEGMENT})...")
cmd_monkey = [adb_path]
if device_id: cmd_monkey.extend(["-s", device_id])
cmd_monkey.extend(["shell", "monkey", "-f", remote_path, "1"])
subprocess.run(cmd_monkey, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try: os.remove(local_path)
except: pass
================= 更新:解析器支持 drag_path =================
def execute_multiline_adb(text_block):
"""
解析器更新:支持 'drag_path' 指令
语法: drag_path x1 y1 x2 y2 x3 y3 ...
"""
if not text_block: return
lines = text_block.strip().split('\n')
for line in lines:
line = line.strip()
if not line or line.startswith("#"): continue
print(f"执行: {line}")
parts = line.split()
cmd = parts[0].lower()
# === 新增指令: drag_path ===
if cmd == "drag_path":
# 解析后面的坐标点
# 格式: drag_path 100 100 200 200 300 300
coords = []
try:
raw_nums = [int(x) for x in parts[1:]]
# 两个一组转成 [(x,y), (x,y)]
coords = list(zip(raw_nums[::2], raw_nums[1::2]))
if coords:
run_continuous_drag(coords)
except Exception as e:
print(f"指令解析错误: {e}")
continue
# ... (下面是之前的 sleep 和 adb shell 逻辑,保持不变) ...
if cmd in ["sleep", "wait", "timeout"]:
try: time.sleep(float(parts[1]))
except: time.sleep(1)
continue
if line.startswith("adb "): line = line[4:]
adb_args = ["shell"] + line.split() if not line.startswith("shell") else line.split()
run_adb_cmd(adb_args)
time.sleep(0.1)
恍然大悟,原来这里面封装的是模拟器提供的接口,再里面的底层就给模拟器去达到了。
另外为了识别状态,我们常常看到task调用了问ai功能,这要用到vlm大模型做另一个工具。(题外话:以前脚本一般找一些规律来实现)
5.地下四层分支二------查看状态层ai_client:如同眼睛一样提供task需要了解的信息。
文件: MyProject/src/ai_client.py
import os
import base64
import io
from openai import OpenAI
from PIL import Image
=== 注意:删除了全局的 _CLIENT 变量,不再缓存客户端 ===
def get_client():
"""
每次调用都创建一个新的 OpenAI 客户端实例。
这能有效防止长时间运行后,智谱AI的 JWT Token 过期导致的 401 错误。
"""
api_key = os.environ.get("OPENAI_API_KEY")
base_url = os.environ.get("ai_base_url", "https://open.bigmodel.cn/api/paas/v4")
if not api_key:
print("【错误】未检测到 API Key,请先在配置页填写并保存!")
# 返回 None 而不是直接报错,让调用者处理
return None
# 每次实例化都会根据 Key 重新计算签名,保证不过期
return OpenAI(api_key=api_key, base_url=base_url)
def convert_image_to_webp_base64(input_image_path: str, quality: int = 80) -> str:
"""
将本地图片压缩为 WebP 并转为 Base64。
"""
try:
with Image.open(input_image_path) as img:
针对大分辨率进行缩放,节省 token 并加快速度
如果图片宽或高超过 1024,按比例缩小
if img.width > 1024 or img.height > 1024:
img.thumbnail((1024, 1024))
byte_arr = io.BytesIO()
img.save(byte_arr, format='WEBP', quality=quality)
return base64.b64encode(byte_arr.getvalue()).decode('utf-8')
except Exception as e:
print(f"【图片处理错误】无法读取或转换图片: {input_image_path}, 错误: {e}")
return None
def build_messages(image_path: str, prompt_text: str):
"""构建发送给大模型的标准消息体"""
b64_image = convert_image_to_webp_base64(image_path)
if not b64_image:
return None
return [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:image/webp;base64,{b64_image}"
}
},
{
"type": "text",
"text": prompt_text
}
]
}
]
def query_vlm(image_path: str, prompt: str, model: str = 'glm-4v-flash'):
"""
核心对外接口
输入:图片路径、提示词
输出:大模型的文本回答
"""
print(f"【AI调用】正在询问图片: {prompt[:20]}...") # 只打印前20个字,防止日志太长
try:
# 1. 获取新客户端
client = get_client()
if not client:
return "错误:API Key 未设置"
# 2. 构建消息
messages = build_messages(image_path, prompt)
if not messages:
return "错误:图片转换失败"
# 3. 发起请求
response = client.chat.completions.create(
model=model,
messages=messages,
stream=False,
temperature=0.1, # 降低随机性
max_tokens=1024
)
result = response.choices[0].message.content
# print(f"【AI回复】{result}") # 调试时可以打开,平时太吵可以注释
return result
except Exception as e:
err_msg = f"AI 请求异常: {str(e)}"
print(f"【错误】{err_msg}")
# 如果是 401 错误,特意提醒一下
if "401" in str(e):
print(">>> 提示:API Key 可能过期或余额不足,或者 Key 填写错误。")
return err_msg
=== 单元测试 ===
if name == "main ":
pass
原来是智谱ai的正常调用。
这样子就完成啦!!!
我们的地宫之旅到此结束。
完整项目见
https://github.com/childofcuriosity/taoyuan-auto-assistant
欢迎技术交流!
禁止商业用途!