Skip to content
Open

desktop #1887

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions assets/i18n/zh-CN.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
{
"desktop": "桌面版设置",
"desktop_enable_help": "开启此项则控制电脑上的阴阳师桌面版,窗口可以互相遮挡,但不能最小化。\n游戏用管理员启动时,OAS 也需要用管理员启动,否则会点不动。\n高分辨率屏幕需在游戏目录 bin\\onmyoji.exe 的 属性-兼容性-高DPI设置 中勾选“替代高DPI缩放行为”,否则点击位置会偏。",
"game_path": "游戏安装目录",
"game_path_help": "游戏安装目录(如 D:\\yys),留空自动查找。",
"launch_instance": "启动客户端实例",
"launch_instance_help": "一次性按钮:点击后按上面的设置启动本配置对应的客户端,已在运行则跳过。\n它是按钮不是开关,点击后自动弹回,不会保存为开启。",
"profile_dir": "档案目录",
"profile_dir_help": "多开账号隔离:每个配置填一个不同目录(如 D:/yys-profiles/p1),无需手动创建,OAS 会自动准备好。\n填了之后每个实例拥有独立的游戏数据和账号。\n留空 = 和直接双击打开的游戏共享账号。",
"window_pos": "窗口位置",
"window_pos_help": "多开时自动把窗口移到指定位置,格式 横,竖(如 0,0 / 660,0);留空不移动。",
"window_title": "窗口标题",
"window_title_help": "多开时用来在任务栏区分实例:OAS 绑定该客户端时会把窗口标题改成它。\n留空 = 保持客户端默认标题,改动在下次启动或认领客户端时生效。识别和点击都不依赖标题,填不填都能正常跑。",
"boss_limit_time": "单局Boss限制时间",
"kirin_battle_config": "麒麟战斗配置",
"netherworld_battle_config": "阴界之门战斗配置",
Expand Down
8 changes: 8 additions & 0 deletions config/template.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
"emulator_window_minimize": false,
"run_background_only": false
},
"desktop": {
"enable": false,
"game_path": "",
"profile_dir": "",
"window_pos": "",
"window_title": "",
"launch_instance": false
},
"error": {
"handle_error": true,
"error_repeated": false,
Expand Down
68 changes: 68 additions & 0 deletions dev_tools/bind_desktop_window.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""桌面客户端模式配置工具
============================
把指定 OAS 配置切到桌面客户端模式(script.desktop.enable=True、
screenshot_method='window_background'、control_method='window_message'),
并可顺带启动一个实例验证安装目录与档案配置是否可用。

PID 不再写进配置:客户端进程 PID 每次重启都会变,OAS 启动/认领时自动绑定,
档案目录内会写 oas_instance.json 运行时状态供下次认领(多开账号隔离)。

用法:
./toolkit/python.exe -m dev_tools.bind_desktop_window --config oas1
./toolkit/python.exe -m dev_tools.bind_desktop_window --config oas1 --path D:\\yys
./toolkit/python.exe -m dev_tools.bind_desktop_window --config oas1 --profile D:\\yys-profiles\\p1 --launch
"""
import argparse

from module.config.config import Config
from module.logger import logger


def bind_desktop_mode(config_name: str, game_path: str = '', profile_dir: str = '') -> None:
"""把配置切到桌面客户端模式(幂等,可重复执行)。"""
config = Config(config_name)
config.script.desktop.enable = True
if game_path:
config.script.desktop.game_path = game_path
if profile_dir:
config.script.desktop.profile_dir = profile_dir
# PrintWindow 对客户端的 DirectX 渲染窗口返回纯黑,桌面模式统一用 BitBlt
config.script.device.screenshot_method = 'window_background'
config.script.device.control_method = 'window_message'
config.save()
logger.info(f'Config [{config_name}] switched to desktop client mode '
f'(game_path={config.script.desktop.game_path or "auto"}, '
f'profile_dir={config.script.desktop.profile_dir or "-"})')


def launch_and_check(config_name: str) -> None:
"""按当前 desktop 配置启动一个实例并校准窗口尺寸,验证配置可用。"""
from module.device.device import Device
from module.device.method.desktop_impl import desktop_launch_instance

config = Config(config_name)
if not desktop_launch_instance(config):
logger.error('启动失败,请检查 游戏安装目录 / 档案目录 配置')
return
device = Device(config)
device.desktop_window_set_size()
logger.info('桌面客户端已启动并完成窗口校准,可以启动脚本了')


def main():
parser = argparse.ArgumentParser(description='把 OAS 配置切换到桌面客户端模式')
parser.add_argument('--config', required=True, help='配置实例名,如 oas1')
parser.add_argument('--path', default='', help='游戏安装目录(含 bin\\onmyoji.exe 的根目录)')
parser.add_argument('--profile', default='', help='多开档案目录(每个配置一个,实现账号隔离)')
parser.add_argument('--launch', action='store_true',
help='切换后立即启动一个客户端实例并校准窗口尺寸')
args = parser.parse_args()

bind_desktop_mode(args.config, args.path, args.profile)
if args.launch:
launch_and_check(args.config)


if __name__ == '__main__':
main()
62 changes: 47 additions & 15 deletions module/atom/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@
from module.base.utils import is_approx_rectangle
from module.base.utils.utils import random_normal_distribution_int

# 模板匹配容差:把搜索区域(roi_back)四边各外扩该像素数,用于吸收非原生渲染
# 环境相对模板截图环境的整体相位差(桌面客户端受 DPI 缩放重采样,约 1px)。
# 仓库里约 1/3 的模板 roi_back 与模板等宽,此时 matchTemplate 结果只剩 1 列、
# 横向一格都平移不了,1px 相位差就足以把「元素存在」判成「不存在」。
# 桌面模式由 Device 置为 2,模拟器保持 0、行为逐位不变。
ROI_MATCH_PAD = 0
DESKTOP_ROI_MATCH_PAD = 2


def set_roi_match_padding(pad: int) -> None:
"""设置模板匹配的 roi_back 外扩容差(像素,四边同值)。0 表示不启用。"""
global ROI_MATCH_PAD
ROI_MATCH_PAD = max(0, int(pad))


class RuleImage(RuleImageMallResourceMixin):
debug_mode: bool = False
Expand Down Expand Up @@ -140,6 +154,23 @@ def des(self):
self.load_kp_des()
return self._des

@staticmethod
def _pad_roi(roi) -> tuple:
"""
把 ROI 四边各外扩 ROI_MATCH_PAD 像素,返回 (x0, y0, w, h)。

左上侧必须夹到 0:numpy 对负索引按「倒数」语义取值,会把裁剪位置算错,
并连带把回算出的 roi_front 算偏;右/下侧越界由切片自然截断。
返回的 x0/y0 是外扩后的原点,命中回算必须以它做基准。
"""
x, y, w, h = [int(v) for v in roi]
pad = ROI_MATCH_PAD
if pad <= 0:
return x, y, w, h
x0 = max(0, x - pad)
y0 = max(0, y - pad)
return x0, y0, x + w + pad - x0, y + h + pad - y0

@property
def mask(self):
"""
Expand Down Expand Up @@ -209,11 +240,7 @@ def corp(self, image: np.array, roi: list = None) -> np.array:
:param roi
:return:
"""
if roi is None:
x, y, w, h = self.roi_back
else:
x, y, w, h = roi
x, y, w, h = int(x), int(y), int(w), int(h)
x, y, w, h = self._pad_roi(self.roi_back if roi is None else roi)
return image[y:y + h, x:x + w]

def match(self, image: np.array, threshold: float = None) -> bool:
Expand Down Expand Up @@ -258,8 +285,9 @@ def match(self, image: np.array, threshold: float = None) -> bool:
logger.attr(self.name, f'matching score {max_val:.5f}')

if max_val > threshold:
self.roi_front[0] = max_loc[0] + self.roi_back[0]
self.roi_front[1] = max_loc[1] + self.roi_back[1]
x0, y0, _, _ = self._pad_roi(self.roi_back)
self.roi_front[0] = max_loc[0] + x0
self.roi_front[1] = max_loc[1] + y0
return True
else:
return False
Expand Down Expand Up @@ -340,8 +368,9 @@ def match_multi_scale(self, image: np.array, threshold: float = None,
self.last_scale = float(best_scale)

if best_score > threshold and best_loc is not None and best_shape is not None:
self.roi_front[0] = best_loc[0] + self.roi_back[0]
self.roi_front[1] = best_loc[1] + self.roi_back[1]
x0, y0, _, _ = self._pad_roi(self.roi_back)
self.roi_front[0] = best_loc[0] + x0
self.roi_front[1] = best_loc[1] + y0
self.roi_front[2] = best_shape[0]
self.roi_front[3] = best_shape[1]
return True
Expand Down Expand Up @@ -377,11 +406,12 @@ def match_all(self, image: np.array, threshold: float = None, roi: list = None)
self.last_score = float(results.max())
locations = np.where(results >= threshold)
matches = []
x0, y0, _, _ = self._pad_roi(self.roi_back)
for pt in zip(*locations[::-1]): # (x, y) coordinates
score = results[pt[1], pt[0]]
# 得分, x, y, w, h
x = self.roi_back[0] + pt[0]
y = self.roi_back[1] + pt[1]
x = x0 + pt[0]
y = y0 + pt[1]
matches.append((score, x, y, mat.shape[1], mat.shape[0]))
return matches

Expand Down Expand Up @@ -414,11 +444,12 @@ def match_all_any(self, image: np.array, threshold: float = None, roi: list = No
self.last_score = float(results.max())
locations = np.where(results >= threshold)
matches = []
x0, y0, _, _ = self._pad_roi(self.roi_back)
for pt in zip(*locations[::-1]): # (x, y) coordinates
score = results[pt[1], pt[0]]
# 得分, x, y, w, h
x = self.roi_back[0] + pt[0]
y = self.roi_back[1] + pt[1]
x = x0 + pt[0]
y = y0 + pt[1]
matches.append((score, x, y, mat.shape[1], mat.shape[0]))
if len(matches) > 0:
scores = np.array([m[0] for m in matches])
Expand Down Expand Up @@ -501,8 +532,9 @@ def sift_match(self, image: np.array, show=False) -> bool:
result = False
else:
dst = int32(cv2.perspectiveTransform(pts, m))
self.roi_front[0] = dst[0, 0, 0] + self.roi_back[0]
self.roi_front[1] = dst[0, 0, 1] + self.roi_back[1]
x0, y0, _, _ = self._pad_roi(self.roi_back)
self.roi_front[0] = dst[0, 0, 0] + x0
self.roi_front[1] = dst[0, 0, 1] + y0
if show:
cv2.polylines(source, [dst], isClosed=True, color=(0, 0, 255), thickness=2)
if not is_approx_rectangle(np.array([pos[0] for pos in dst])):
Expand Down
31 changes: 31 additions & 0 deletions module/base/cBezier.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,5 +156,36 @@ def trackArray(cls, start, end, numberList, le=1, deviation=0, bias=0.5, type=0,
# return {"trackArray": np.array(s), "P": w}
return [[int(s[0]), int(s[1])] for s in s]


def human_track(start, end, interval=10):
"""
生成 start → end 的拟人贝塞尔轨迹点,相邻两点间隔约 interval 像素。
轨迹阶数、波动幅度、速度分布随机,供模拟器滑动与桌面客户端滑动共用。

两种退化输入单独处理:起点终点 x 相同时贝塞尔以 x 做参数化会除零,
纯垂直移动退化为直线插值;距离不足以生成一个点时直接返回终点,
避免调用方拿到空轨迹再去取 [0]。
"""
number_list = int(math.dist(start, end) / (1 * interval))
if number_list < 1:
return [tuple(end)]
if start[0] == end[0]:
dy = end[1] - start[1]
return [[int(start[0]), int(start[1] + dy * i / number_list)]
for i in range(1, number_list + 1)]
le = random.randint(2, 4)
deviation = random.randint(20, 40)
obbs_type = random.random()
# 0.8 概率先快中间慢后面快,0.1 先快后慢,0.1 先慢后快
if 0 < obbs_type <= 0.8:
b_type = 3
elif obbs_type < 0.9:
b_type = 2
else:
b_type = 1
return BezierTrajectory.trackArray(start=list(start), end=list(end), numberList=number_list,
le=le, deviation=deviation, bias=0.5, type=b_type,
cbb=0, yhh=20)

# print(BezierTrajectory.trackArray(start=[0, 0], end=[100, 100], numberList=50, le=2,
# deviation=0, bias=0.5, type=0, cbb=0, yhh=10))
8 changes: 8 additions & 0 deletions module/config/config_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,14 @@ def script_set_arg(self, task: str, group: str, argument: str, value) -> bool:
logger.info(f"reset_task_datetime={date_time}")
self.reset_datetime_for_all_enabled_tasks(date_time)

# 桌面客户端「启动实例」按钮:点击置 True → 主进程直接拉起该配置的客户端
# 实例,完成后改写 False 再走通用保存
if (task == 'script' and group == 'desktop' and argument == 'launch_instance'
and value is True):
from module.device.method.desktop_impl import desktop_launch_instance
desktop_launch_instance(self)
value = False

# 设置参数
try:
setattr(group_object, argument, value)
Expand Down
5 changes: 5 additions & 0 deletions module/config/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,11 @@ def parse_tomorrow_server(server_update: time, delay_date: int = 1, float_second

return next_run

def desktop_mode_enabled(config) -> bool:
"""是否启用桌面客户端模式(script.desktop.enable)。"""
return bool(config.script.desktop.enable)


def deep_get(d, keys, default=None):
"""
Get values in dictionary safely.
Expand Down
3 changes: 3 additions & 0 deletions module/device/app_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ class AppControl(Adb, Uiautomator2):
_app_u2_family = ['uiautomator2', 'minitouch', 'scrcpy']

def app_is_running(self) -> bool:
# 桌面模式:窗口存在 且 已完成登录(自动启动的客户端停在登录页,需先走登录流程)
if self.is_desktop:
return self.desktop_window_exists() and getattr(self, '_desktop_login_done', True)
method = self.config.script.device.control_method
# if self.is_wsa:
# package = self.app_current_wsa()
Expand Down
9 changes: 9 additions & 0 deletions module/device/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ def __init__(self, config):
config (AzurLaneConfig, str): Name of the user config under ./config
"""
super().__init__(config)
# 桌面客户端模式:不经过 adb 设备/包检测,包名直接用配置值
if self.is_desktop:
self.package = self.config.script.device.package_name.value
logger.attr('PackageName', self.package)
return
if not self.is_over_http:
self.detect_device()

Expand Down Expand Up @@ -691,6 +696,10 @@ def get_orientation(self):
2: 'HOME key on the top'
3: 'HOME key on the left'
"""
# 桌面模式:窗口即横屏,方向恒为 0(Normal),不经过 adb dumpsys
if self.is_desktop:
self.orientation = 0
return 0
_DISPLAY_RE = re.compile(
r'.*DisplayViewport{.*valid=true, .*orientation=(?P<orientation>\d+), .*deviceWidth=(?P<width>\d+), deviceHeight=(?P<height>\d+).*'
)
Expand Down
17 changes: 14 additions & 3 deletions module/device/connection_attr.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from module.base.decorator import cached_property
from module.config.config import Config
from module.config.utils import deep_iter
from module.config.utils import deep_iter, desktop_mode_enabled
from module.exception import RequestHumanTakeover
from module.logger import logger

Expand Down Expand Up @@ -60,8 +60,10 @@ def __init__(self, config):
if 'eri' in k[0].split('_')[-1]:
print(k, v)
su.__setattr__(k[0], chr(8) + v)
# Cache adb_client
_ = self.adb_client
# 桌面客户端模式不需要 adb,跳过 adb server 拉起
if not self.is_desktop:
# Cache adb_client
_ = self.adb_client

# Parse custom serial
# self.serial = str(self.config.Emulator_Serial)
Expand Down Expand Up @@ -127,6 +129,15 @@ def is_mumu_family(self):
def is_emulator(self):
return self.serial.startswith('emulator-') or self.serial.startswith('127.0.0.1:')

@cached_property
def is_desktop(self):
"""桌面客户端模式:script.desktop.enable 显式开关。

刻意不兜底:读不到配置时抛 AttributeError 比静默 False 好——后者会把
桌面模式当成模拟器模式继续跑,报错指向完全无关的地方。
"""
return desktop_mode_enabled(self.config)

@cached_property
def is_network_device(self):
return bool(re.match(r'\d+\.\d+\.\d+\.\d+:\d+', self.serial))
Expand Down
Loading
Loading