Skip to content
Open
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
1 change: 0 additions & 1 deletion deploy/module_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@
import win32clipboard
import win32con
import win32gui
import win32ui
import winreg
import yaml
import zipfile
Expand Down
157 changes: 97 additions & 60 deletions docs/develop/one_dragon/background_mode_design.md

Large diffs are not rendered by default.

19 changes: 13 additions & 6 deletions src/one_dragon/base/controller/pc_controller_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class PcControllerBase(ControllerBase):

def __init__(self,
screenshot_method: str,
force_active_window: bool = False,
standard_width: int = 1920,
standard_height: int = 1080):
ControllerBase.__init__(self)
Expand All @@ -57,6 +58,7 @@ def __init__(self,
self.btn_controller: PcButtonController = self.keyboard_controller
self.screenshot_controller: PcScreenshotController = PcScreenshotController(self.game_win, standard_width, standard_height)
self.screenshot_method: str = screenshot_method
self.force_active_window: bool = force_active_window
self.background_mode: bool = False
self.mouse_flash_duration: float = 0.05 # 闪切键鼠模式时每步等待时长
self.gamepad_action_keys: dict[str, list[str]] = {}
Expand Down Expand Up @@ -90,13 +92,15 @@ def cleanup_after_app_shutdown(self) -> None:
self.btn_controller.reset()
self.screenshot_controller.cleanup()

def active_window(self) -> None:
"""
前置窗口
"""
def active_window(self) -> bool:
"""尝试一次将游戏窗口切到前台。"""
self.game_win.init_win()
if not self.background_mode:
self.game_win.active()
return self.background_mode or self.game_win.active()

def ensure_active_window(self) -> bool:
"""运行期间按配置恢复游戏窗口焦点。"""
self.game_win.init_win()
return self.background_mode or self.game_win.active(retry_until_active=self.force_active_window)

def set_window_title(self, new_title: str) -> None:
"""设置窗口标题。
Expand Down Expand Up @@ -158,6 +162,9 @@ def close_game(self) -> None:

def get_screenshot(self, independent: bool = False) -> MatLike | None:
if self.is_game_window_ready:
if self.force_active_window and not self.background_mode and not self.game_win.is_win_active:
if not self.ensure_active_window():
raise RuntimeError('游戏窗口激活失败,已停止本轮操作以避免误点其他窗口')
# 确保截图器已初始化
if not independent and self.screenshot_controller.active_strategy_name is None:
self.screenshot_controller.init_screenshot(self.screenshot_method)
Expand Down
138 changes: 88 additions & 50 deletions src/one_dragon/base/controller/pc_game_window.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import ctypes
import time
from ctypes.wintypes import RECT

import pyautogui
import win32ui
import win32con
import win32gui
from pygetwindow import Win32Window

from one_dragon.base.geometry.point import Point
Expand All @@ -12,6 +14,8 @@

class PcGameWindow:

MAX_ACTIVE_ATTEMPTS = 30
Comment thread
Usagi-wusaqi marked this conversation as resolved.

def __init__(self,
standard_width: int = 1920,
standard_height: int = 1080):
Expand All @@ -21,7 +25,7 @@ def __init__(self,
self.standard_game_rect: Rect = Rect(0, 0, standard_width, standard_height)

self._win: Win32Window | None = None
self._hWnd = None
self._hWnd: int | None = None

def _clear_cached_window(self) -> None:
self._win = None
Expand All @@ -32,18 +36,16 @@ def init_win(self) -> None:
初始化窗口
:return:
"""
self._clear_cached_window()
if self.win_title is None:
return

windows = pyautogui.getWindowsWithTitle(self.win_title)
if len(windows) > 0:
for win in windows:
if win.title == self.win_title:
self._win = win
self._hWnd = win._hWnd
else:
self._win = None
self._hWnd = None
for win in windows:
if win.title == self.win_title:
self._win = win
self._hWnd = win._hWnd
return

def update_win_title(self, new_title: str) -> None:
"""
Expand All @@ -55,24 +57,18 @@ def update_win_title(self, new_title: str) -> None:
self._clear_cached_window()

def refresh_win(self) -> None:
self._clear_cached_window()
self.init_win()

def get_win(self) -> Win32Window | None:
if self._win is None:
self.init_win()
return self._win

def get_hwnd(self) -> int:
def get_hwnd(self) -> int | None:
if self._hWnd is None:
self.init_win()
return self._hWnd

def _reset_cached_window(self) -> None:
"""清空缓存窗口对象与句柄,触发后续重新查找窗口。"""
self._win = None
self._hWnd = None

@staticmethod
def _try_get_client_rect(hwnd: int) -> tuple[bool, RECT]:
client_rect = RECT()
Expand All @@ -90,17 +86,19 @@ def is_win_valid(self) -> bool:
:return:
"""
win = self.get_win()
hwnd = self.get_hwnd()
return win is not None and hwnd is not None and ctypes.windll.user32.IsWindow(hwnd) != 0
hwnd = self._hWnd
is_valid = win is not None and hwnd is not None and win32gui.IsWindow(hwnd)
if not is_valid:
self._clear_cached_window()
return is_valid

@property
def is_win_active(self) -> bool:
"""
是否当前激活的窗口
:return:
"""
win = self.get_win()
return win.isActive if win is not None else False
return self.is_win_valid and win32gui.GetForegroundWindow() == self._hWnd

@property
def is_win_scale(self) -> bool:
Expand All @@ -114,43 +112,83 @@ def is_win_scale(self) -> bool:
else:
return not (win_rect.width == self.standard_width and win_rect.height == self.standard_height)

def active(self) -> bool:
def active(self, retry_until_active: bool = False) -> bool:
"""
显示并激活当前窗口
:return:
:param retry_until_active: 是否最多重试 30 次,并在多次失败后最小化其他窗口
:return: 是否已确认游戏窗口位于前台
"""
win = self.get_win()
if win is None:
if not self.is_win_valid:
return False
if self.is_win_active:
return True

try:
win.restore()
win.activate()
return True
except Exception as error:
if getattr(error, 'args', None) and '1400' in str(error.args[0]):
log.warning('无效的窗口句柄,尝试重置窗口')
self._reset_cached_window()
attempt = 0
while self.is_win_valid:
if retry_until_active and attempt >= 10 and attempt % 10 == 0:
log.info('多次尝试未恢复,尝试最小化其他窗口后激活游戏窗口')
self._minimize_other_windows()
else:
log.info('游戏窗口未获得焦点,尝试恢复窗口')

self._focus_window()
time.sleep(0.05)
if win32gui.GetForegroundWindow() == self._hWnd:
log.info('游戏窗口已恢复前台焦点')
return True
if not retry_until_active:
log.error('切换到游戏窗口失败,Windows 未允许窗口获得前台焦点')
return False
if isinstance(error, win32ui.error):
log.error('激活窗口失败', exc_info=True)
attempt += 1
if attempt >= self.MAX_ACTIVE_ATTEMPTS:
log.error('多次尝试仍未恢复游戏窗口前台焦点')
return False
time.sleep(1)

log.error('游戏窗口已失效,无法恢复前台焦点')
return False

def _minimize_other_windows(self) -> None:
"""通过任务栏命令最小化其他窗口。"""
try:
shell_hwnd = win32gui.FindWindow('Shell_TrayWnd', None)
if shell_hwnd:
win32gui.PostMessage(shell_hwnd, win32con.WM_COMMAND, 419, 0)
time.sleep(0.5)
except Exception:
log.debug('最小化其他窗口失败', exc_info=True)

def _focus_window(self) -> None:
"""恢复并激活窗口。"""
hwnd = self._hWnd
if hwnd is None or not win32gui.IsWindow(hwnd):
self._clear_cached_window()
return

try:
win32gui.PostMessage(hwnd, win32con.WM_SYSCOMMAND, win32con.SC_RESTORE, 0)

try:
# 直接 activate 偶发失败,最小化再恢复可提高成功率
win.minimize()
win.restore()
win.activate()
return True
except Exception as fallback_error:
if getattr(fallback_error, 'args', None) and '1400' in str(fallback_error.args[0]):
log.warning('无效的窗口句柄,尝试重置窗口')
self._reset_cached_window()
return False
log.error('切换到游戏窗口失败', exc_info=True)
return False
win32gui.SetForegroundWindow(hwnd)
except Exception:
log.debug('SetForegroundWindow 调用失败,继续尝试其他激活方式', exc_info=True)

for _ in range(10):
if not win32gui.IsIconic(hwnd):
break
time.sleep(0.05)

if win32gui.IsIconic(hwnd):
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)

win32gui.BringWindowToTop(hwnd)
win32gui.SetActiveWindow(hwnd)
except Exception as error:
if error.args and error.args[0] == 1400:
log.warning('无效的窗口句柄,尝试重置窗口')
self._clear_cached_window()
return
log.debug('请求激活游戏窗口时出现异常,继续确认前台状态', exc_info=True)

@property
def win_rect(self) -> Rect | None:
Expand All @@ -160,7 +198,7 @@ def win_rect(self) -> Rect | None:
:return: 游戏窗口信息
"""
win = self.get_win()
hwnd = self.get_hwnd()
hwnd = self._hWnd
if win is None or hwnd is None:
return None

Expand All @@ -169,9 +207,9 @@ def win_rect(self) -> Rect | None:
# 句柄失效时重置缓存并重试一次,避免永久复用坏句柄
if not got_rect and ctypes.windll.user32.IsWindow(hwnd) == 0:
log.warning('检测到失效窗口句柄,重置缓存后重试')
self._reset_cached_window()
self._clear_cached_window()
win = self.get_win()
hwnd = self.get_hwnd()
hwnd = self._hWnd
if win is None or hwnd is None:
return None
got_rect, client_rect = self._try_get_client_rect(hwnd)
Expand Down
8 changes: 8 additions & 0 deletions src/one_dragon/envs/env_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,14 @@ def screenshot_method(self) -> str:
def screenshot_method(self, new_value: str) -> None:
self.update('screenshot_method', new_value)

@property
def force_active_window(self) -> bool:
return self.get('force_active_window', False)

@force_active_window.setter
def force_active_window(self, new_value: bool) -> None:
self.update('force_active_window', new_value)

@property
def key_start_running(self) -> str:
"""
Expand Down
8 changes: 8 additions & 0 deletions src/one_dragon_qt/view/setting/setting_env_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ def _init_basic_group(self) -> SettingCardGroup:
self.screenshot_method_opt.value_changed.connect(lambda: self.ctx.init_controller())
basic_group.addSettingCard(self.screenshot_method_opt)

self.force_active_window_switch = SwitchSettingCard(
icon=FluentIcon.FULL_SCREEN, title='运行时恢复游戏窗口焦点',
content='仅前台模式运行期间生效;需要切出游戏时请先暂停,多次恢复失败会最小化其他窗口',
)
Comment thread
Usagi-wusaqi marked this conversation as resolved.
self.force_active_window_switch.value_changed.connect(lambda: self.ctx.init_controller())
basic_group.addSettingCard(self.force_active_window_switch)

self.debug_opt = SwitchSettingCard(
icon=FluentIcon.SEARCH, title='调试模式', content='正常无需开启'
)
Expand Down Expand Up @@ -206,6 +213,7 @@ def on_interface_shown(self) -> None:
VerticalScrollInterface.on_interface_shown(self)

self.screenshot_method_opt.init_with_adapter(self.ctx.env_config.get_prop_adapter('screenshot_method'))
self.force_active_window_switch.init_with_adapter(self.ctx.env_config.get_prop_adapter('force_active_window'))
self.debug_opt.init_with_adapter(self.ctx.env_config.get_prop_adapter('is_debug'))
self.copy_screenshot_opt.init_with_adapter(self.ctx.env_config.get_prop_adapter('copy_screenshot'))

Expand Down
1 change: 1 addition & 0 deletions src/zzz_od/context/zzz_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def init_controller(self) -> None:
self.controller: ZPcController = ZPcController(
game_config=self.game_config,
screenshot_method=self.env_config.screenshot_method,
force_active_window=self.env_config.force_active_window,
standard_width=self.project_config.screen_standard_width,
standard_height=self.project_config.screen_standard_height
)
Expand Down
3 changes: 3 additions & 0 deletions src/zzz_od/controller/zzz_pc_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ def __init__(
self,
game_config: GameConfig,
screenshot_method: str,
force_active_window: bool = False,
standard_width: int = 1920,
standard_height: int = 1080
):
PcControllerBase.__init__(self,
screenshot_method=screenshot_method,
force_active_window=force_active_window,
standard_width=standard_width,
standard_height=standard_height)

Expand All @@ -38,6 +40,7 @@ def sync_game_config(self, game_config: GameConfig) -> None:
self.enable_background_mode(self.game_config.background_gamepad_type)
else:
self.enable_foreground_mode()
# 切换实例可能从后台转为前台模式,先激活游戏窗口以免后续键鼠输入误发
self.active_window()

def init_before_context_run(self) -> bool:
Expand Down
Loading