From 2c6aae472236a0f60acc6945c36d92efc2010f98 Mon Sep 17 00:00:00 2001 From: Wattls Date: Fri, 22 May 2026 16:21:13 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=93=BE?= =?UTF-8?q?=E5=BC=8F=E6=88=98=E6=96=97=E7=B3=BB=E7=BB=9F=E4=B8=8E=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E8=A7=92=E8=89=B2=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增ChainExecutor实现链式战斗流程管理 2. 为多个角色添加链式战斗子类:HotoriChain、ZeroChain、JiuyuanChain、NanallyChain 3. 新增CharFactory链式角色注册配置 4. 为BaseChar添加链式执行支持与辅助方法 5. 为DodgeCounterTrigger添加闪避抑制功能 6. 扩展BaseCombatTask以支持链式战斗流程切换 --- src/char/BaseChar.py | 30 +++ src/char/CharFactory.py | 8 + src/char/HotoriChain.py | 272 +++++++++++++++++++++++ src/char/JiuyuanChain.py | 67 ++++++ src/char/NanallyChain.py | 209 +++++++++++++++++ src/char/ZeroChain.py | 74 ++++++ src/combat/BaseCombatTask.py | 43 +++- src/combat/ChainExecutor.py | 81 +++++++ src/sound_trigger/DodgeCounterTrigger.py | 12 + 9 files changed, 794 insertions(+), 2 deletions(-) create mode 100644 src/char/HotoriChain.py create mode 100644 src/char/JiuyuanChain.py create mode 100644 src/char/NanallyChain.py create mode 100644 src/char/ZeroChain.py create mode 100644 src/combat/ChainExecutor.py diff --git a/src/char/BaseChar.py b/src/char/BaseChar.py index c28faed..977e047 100644 --- a/src/char/BaseChar.py +++ b/src/char/BaseChar.py @@ -125,6 +125,14 @@ def __eq__(self, other): def perform(self): """执行当前角色的主要战斗行动序列。""" self.last_perform = time.time() + if hasattr(self, '_chain_method') and self._chain_method: + method_name = self._chain_method + self._chain_method = None + if hasattr(self, method_name): + getattr(self, method_name)() + else: + self.task.chain_executor.step_complete() + return if self.has_intro: self.add_intro_motion_freeze(self.last_perform) if self.need_fast_perform(): @@ -880,3 +888,25 @@ def switch_other_char(self): self.send_key(next_char) self.sleep(0.2, sleep_check=False) self.logger.debug(f"switch_other_char on_combat_end {self.index} switch end") + + def is_anchor(self) -> bool: + return False + + def _get_char_key(self, char_name): + for c in self.task.chars: + if c is not None and c.__class__.__name__ == char_name: + return c.index + 1 + return None + + def _send_chain_key(self): + if self.task.chain_executor.active: + target_char, _ = self.task.chain_executor.target + if target_char is not None and target_char != self: + self.task.send_key(target_char.index + 1) + return True + else: + anchor = getattr(self.task.chain_executor, '_pending_anchor', None) + if anchor is not None and anchor != self: + self.task.send_key(anchor.index + 1) + return True + return False diff --git a/src/char/CharFactory.py b/src/char/CharFactory.py index fa9767a..40ade28 100644 --- a/src/char/CharFactory.py +++ b/src/char/CharFactory.py @@ -6,11 +6,15 @@ from src.char.BaseChar import BaseChar, Element from src.char.Chiz import Chiz from src.char.Hotori import Hotori +from src.char.HotoriChain import HotoriChain from src.char.Jiuyuan import Jiuyuan +from src.char.JiuyuanChain import JiuyuanChain from src.char.Mint import Mint from src.char.Nanally import Nanally +from src.char.NanallyChain import NanallyChain from src.char.Sakiri import Sakiri from src.char.Zero import Zero +from src.char.ZeroChain import ZeroChain if TYPE_CHECKING: import numpy as np @@ -28,6 +32,10 @@ "char_nanally": {"cls": Nanally, "cn_name": "娜娜莉", "element": Element.GREEN}, "char_hotori": {"cls": Hotori, "cn_name": "浔", "element": Element.WHITE}, "char_chiz": {"cls": Chiz, "cn_name": "小吱", "element": Element.WHITE}, + "char_chain_hotori": {"cls": HotoriChain, "cn_name": "浔创生链式-浔", "element": Element.WHITE}, + "char_chain_zero": {"cls": ZeroChain, "cn_name": "浔创生链式-零", "element": Element.WHITE}, + "char_chain_jiuyuan": {"cls": JiuyuanChain, "cn_name": "浔创生链式-九原", "element": Element.GREEN}, + "char_chain_nanally": {"cls": NanallyChain, "cn_name": "浔创生链式-娜娜莉", "element": Element.GREEN}, } char_names = char_dict.keys() diff --git a/src/char/HotoriChain.py b/src/char/HotoriChain.py new file mode 100644 index 0000000..99e4f0c --- /dev/null +++ b/src/char/HotoriChain.py @@ -0,0 +1,272 @@ +import time + +from src.char.Hotori import Hotori + + +class HotoriChain(Hotori): + STARTUP_CHAIN = [ + ("HotoriChain", "chain_e_start_chain"), + ("ZeroChain", "chain_q_e_wait"), + ("JiuyuanChain", "chain_intro_only"), + ("NanallyChain", "chain_e_q_6s_swap"), + ("JiuyuanChain", "chain_q_e_heavy"), + ("HotoriChain", "chain_q_na"), + ] + WARMUP_CHAIN = [ + ("ZeroChain", "chain_nop"), + ("JiuyuanChain", "chain_intro_only"), + ("ZeroChain", "chain_e_only"), + ("NanallyChain", "chain_intro_e_q_10s_swap"), + ("JiuyuanChain", "chain_q_e_heavy"), + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.team_skill_records = {} + self._chain_cycle = 0 + self._e_used = False + self._e_lockdown = False + + def is_anchor(self) -> bool: + return True + + def _get_teammate(self, cls_name): + for c in self.task.chars: + if c is not None and c.__class__.__name__ == cls_name: + return c + return None + + def _build_steps(self, axis_type): + chain_def = self.STARTUP_CHAIN if axis_type == "startup" else self.WARMUP_CHAIN + steps = [] + for cls_name, method in chain_def: + char = self._get_teammate(cls_name) + if char is None and cls_name == self.__class__.__name__: + char = self + if char: + steps.append((char, method)) + else: + self.logger.warning(f"Chain step skipped: {cls_name}.{method} (char not found)") + return steps + + def _build_next_chain(self): + if self._chain_cycle == 0: + self._chain_cycle = 1 + return self._build_steps("startup") + if self._chain_cycle == 1: + self._chain_cycle = 2 + return self._build_steps("warmup") + self._chain_cycle = 0 + self.task.chain_executor._pending_anchor = self + return None + + def do_perform(self): + self.wait_intro() + if self._e_lockdown: + self.continues_normal_attack(0.2) + return + if self._e_used: + if self.ready_for_ultimate() and self.click_ultimate(): + self._e_used = False + self.clear_team_skill_records() + return + self.continues_normal_attack(0.2) + return + self.task.chain_executor.loop(self._build_next_chain) + + def count_ultimate_priority(self): + if not self.ultimate_available(): + return 0 + return 1 + + def _confirm_skill_cd(self): + start = time.time() + while time.time() - start < 0.3: + if self.has_cd("skill"): + return True + self.sleep(0.03) + return False + + def chain_e_start_chain(self): + self.logger.info(f"chain_e_start_chain: entering, has_intro={self.has_intro}") + + if self.has_intro: + start = time.time() + while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: + clicked, _, _ = self.click_skill() + if clicked: + self.logger.info("chain_e_start_chain: E cast during intro") + break + self.click() + self.sleep(0.1) + + fail_count = 0 + last_attempt = 0 + while True: + self.task.sleep_check() + + if self.has_cd("skill"): + self.logger.info("chain_e_start_chain: E already in CD, proceeding") + self._e_used = True + self._e_lockdown = True + self.start_team_skill_window() + self.sleep(0.1) + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() + return + + now = time.time() + if now - last_attempt < 0.5: + self.sleep(0.05) + continue + + available = self.skill_available() + self.logger.debug(f"chain_e_start_chain: attempt {fail_count+1}, skill_available={available}") + + if available: + clicked, _, _ = self.click_skill(time_out=1.5) + if clicked: + if self._confirm_skill_cd(): + self.logger.info(f"chain_e_start_chain: E cast confirmed via CD after {fail_count} failures") + elif self.skill_available(): + self.logger.warning("chain_e_start_chain: E interrupted (skill still available), retrying") + fail_count += 1 + last_attempt = now + self.sleep(0.05) + continue + else: + self.logger.info(f"chain_e_start_chain: E cast assumed (no CD but skill unavailable) after {fail_count} failures") + self._e_used = True + self._e_lockdown = True + self.start_team_skill_window() + self.sleep(0.1) + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() + return + else: + fail_count += 1 + self.logger.warning(f"chain_e_start_chain: E click failed ({fail_count} consecutive)") + if self.has_cd("skill"): + self.logger.info("chain_e_start_chain: E CD detected after failed click, proceeding") + self._e_used = True + self._e_lockdown = True + self.start_team_skill_window() + self.sleep(0.1) + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() + return + else: + self.logger.debug("chain_e_start_chain: skill not available, waiting") + + last_attempt = now + self.sleep(0.05) + + def chain_q_na(self): + q_done = False + if self.has_intro: + start = time.time() + while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: + self.click() + if self.ultimate_available() and self.click_ultimate(send_click=True): + q_done = True + break + self.sleep(0.1) + if not q_done: + self.task.sleep(1) + self.task._combat_settle.time = None + while True: + self.task.sleep_check() + if self.ultimate_available(): + if self.click_ultimate(send_click=True): + self.logger.info("chain_q_na Q done") + break + self.click() + self.sleep(0.1) + self._e_used = False + self.clear_team_skill_records() + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() + + + def start_team_skill_window(self): + self.team_skill_window_start = ( + self.last_skill_time if self.last_skill_time > 0 else time.time() + ) + self.team_skill_records.clear() + + def clear_team_skill_records(self): + self.team_skill_window_start = 0 + self.team_skill_records.clear() + + def required_team_skill_records(self): + return min(self.MAX_TEAM_SKILL_RECORDS, max(0, len(self.task.chars) - 1)) + + def team_skill_window_elapsed(self): + return self.time_elapsed_accounting_for_freeze(self.team_skill_window_start) + + def expire_team_skill_window(self): + elapsed = self.team_skill_window_elapsed() + self.logger.info( + f"team skill window expired after {elapsed:.1f}s " + f"records={{{','.join(str(k) for k in self.team_skill_records)}}}" + ) + self.clear_team_skill_records() + + def update_team_skill_records(self): + if self.team_skill_window_start <= 0: + return + if self.ready_for_ultimate(): + return + + if self.team_skill_window_elapsed() > self.TEAM_SKILL_WINDOW: + self.expire_team_skill_window() + return + + for char in self.task.chars: + if char is None or char == self: + continue + if self.team_skill_window_start <= char.last_skill_time: + prev_time = self.team_skill_records.get(char.index) + if prev_time == char.last_skill_time: + continue + self.team_skill_records[char.index] = char.last_skill_time + self.logger.info( + f"record team skill {char} {len(self.team_skill_records)}/" + f"{self.required_team_skill_records()}" + ) + if self.ready_for_ultimate(): + return + + def ready_for_ultimate(self): + required = self.required_team_skill_records() + return required > 0 and len(self.team_skill_records) >= required + + def has_team_skill_records(self): + return len(self.team_skill_records) > 0 + + def can_ultimate_with_records(self): + return self.ready_for_ultimate() or ( + self.has_team_skill_records() and not self.waiting_for_team_skills() + ) + + def waiting_for_team_skills(self): + if self.team_skill_window_start <= 0 or self.ready_for_ultimate(): + return False + if self.team_skill_window_elapsed() > self.TEAM_SKILL_WINDOW: + self.expire_team_skill_window() + return False + return True + + def reset_state(self): + super().reset_state() + self.clear_team_skill_records() + self._e_used = False + self._e_lockdown = False + self._chain_cycle = 0 + + def on_combat_end(self, chars): + self.clear_team_skill_records() diff --git a/src/char/JiuyuanChain.py b/src/char/JiuyuanChain.py new file mode 100644 index 0000000..a023441 --- /dev/null +++ b/src/char/JiuyuanChain.py @@ -0,0 +1,67 @@ +import time + +from src.char.Jiuyuan import Jiuyuan + + +class JiuyuanChain(Jiuyuan): + def do_perform(self): + if self.task.chain_executor.active: + self.continues_normal_attack(0.2) + return + self.wait_intro() + self.click_ultimate() + if self.click_skill()[0]: + self.continues_normal_attack(1.4) + self.sleep(0.1) + self.fire_bullets() + + def chain_intro_only(self): + if self.has_intro: + start = time.time() + while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: + self.click() + if self.skill_available() or self.ultimate_available(): + break + self.sleep(0.1) + + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() + + def chain_q_e_heavy(self): + if self.has_intro: + start = time.time() + while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: + self.click() + if self.ultimate_available(): + break + self.sleep(0.1) + q_deadline = time.time() + 0.3 + self.task._combat_settle.time = None + while time.time() < q_deadline: + self.task.sleep_check() + self.click() + if self.ultimate_available(): + if self.click_ultimate(send_click=True): + break + self.sleep(0.05) + while True: + self.task.sleep_check() + clicked, _, _ = self.click_skill() + if clicked: + self.task.suppress_dodge() + self.sleep(1.3) + self.task.mouse_down() + self.sleep(0.6) + self.task.mouse_up() + self.task.unsuppress_dodge() + hotori = next((c for c in self.task.chars if c.__class__.__name__ == "HotoriChain"), None) + if hotori and hotori._chain_cycle == 2: + hotori._e_lockdown = False + self.logger.info("E lock released by warmup chain Jiuyuan heavy") + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() + return + self.click() + self.sleep(0.05) diff --git a/src/char/NanallyChain.py b/src/char/NanallyChain.py new file mode 100644 index 0000000..fcdccf2 --- /dev/null +++ b/src/char/NanallyChain.py @@ -0,0 +1,209 @@ +import time + +from src.char.Nanally import Nanally + + +class NanallyChain(Nanally): + def do_perform(self): + if self.task.chain_executor.active: + self.continues_normal_attack(0.2) + return + self.wait_intro() + self.click_skill() + self.click_ultimate() + + def chain_e_q_6s_swap(self): + self.task.suppress_dodge() + q_casted = False + + hotori = next((c for c in self.task.chars if c.__class__.__name__ == "HotoriChain"), None) + skip_e = hotori and hotori.team_skill_window_elapsed() > 5 + + if skip_e: + self.task.unsuppress_dodge() + else: + self.logger.info(f"chain_e_q_6s_swap: trying to cast E, skip_e={skip_e}") + e_deadline = time.time() + 5 + while time.time() < e_deadline: + self.task.sleep_check() + if not self.task.is_char_at_index(self.index): + self.click() + self.sleep(0.01) + continue + + clicked, _, _ = self.click_skill() + if clicked: + self.logger.info("chain_e_q_6s_swap: E cast success") + cd_start = time.time() + while time.time() - cd_start < 2: + self.task.sleep_check() + self.click() + if self.has_cd("skill"): + break + self.sleep(0.05) + self.click() + self.sleep(0.3) + if self.ultimate_available() and self.task.is_char_at_index(self.index): + self.task._combat_settle.time = None + self.click_ultimate() + q_casted = True + self.task.unsuppress_dodge() + break + self.click() + self.sleep(0.05) + else: + self.logger.info("chain_e_q_6s_swap: E cast timeout") + self.task.unsuppress_dodge() + + total_start = time.time() + total_deadline = total_start + 15 + last_log = 0 + + for i in range(3): + na_start = time.time() + while time.time() - na_start < 1.2 and time.time() < total_deadline: + if not q_casted and self.ultimate_available() and self.task.is_char_at_index(self.index): + self.task._combat_settle.time = None + self.click_ultimate() + q_casted = True + na_start = time.time() + + self.click() + self.sleep(0.1) + self.task.next_frame() + + now = time.time() + if now - last_log >= 2: + self.logger.debug(f"chain_e_q_6s NA running, elapsed={now - total_start:.1f}s") + last_log = now + + if time.time() >= total_deadline: + break + + hotori_key = self._get_char_key("HotoriChain") + hotori_ok = False + if hotori_key: + self.task.send_key(hotori_key) + + if hotori: + switch_deadline = time.time() + 1.0 + while (not self.task.is_char_at_index(hotori.index) + and time.time() < switch_deadline + and time.time() < total_deadline): + self.click() + self.sleep(0.01) + if self.task.is_char_at_index(hotori.index): + hotori_ok = True + + if time.time() >= total_deadline: + break + + if hotori_ok: + hotori_start = time.time() + while time.time() - hotori_start < 0.8 and time.time() < total_deadline: + hotori.click() + hotori.sleep(0.1) + self.task.next_frame() + + now = time.time() + if now - last_log >= 2: + self.logger.debug(f"chain_e_q_6s NA running, elapsed={now - total_start:.1f}s") + last_log = now + + if time.time() >= total_deadline or i >= 2: + break + + nanally_key = self._get_char_key("NanallyChain") + if nanally_key: + self.task.send_key(nanally_key) + + switch_deadline = time.time() + 1.0 + while (not self.task.is_char_at_index(self.index) + and time.time() < switch_deadline + and time.time() < total_deadline): + self.click() + self.sleep(0.01) + + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() + + def chain_intro_e_q_10s_swap(self): + if self.has_intro: + start = time.time() + while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: + self.click() + if self.skill_available(): + break + self.sleep(0.1) + + total_deadline = time.time() + 12 + e_casted = False + e_cast_time = 0 + hotori = next((c for c in self.task.chars if c.__class__.__name__ == "HotoriChain"), None) + + while time.time() + 1.5 < total_deadline: + na_start = time.time() + while time.time() - na_start < 1.2 and time.time() < total_deadline: + self.click() + if not e_casted and self.skill_available() and self.task.is_char_at_index(self.index): + self.task.suppress_dodge() + clicked, _, _ = self.click_skill() + self.task.unsuppress_dodge() + if clicked: + e_casted = True + e_cast_time = time.time() + if self.ultimate_available() and self.task.is_char_at_index(self.index) and ( + not e_casted or time.time() - e_cast_time > 0.3 + ): + q_anim_start = time.time() + self.task._combat_settle.time = None + self.click_ultimate() + total_deadline += time.time() - q_anim_start + self.sleep(0.1) + + if time.time() >= total_deadline: + break + + hotori_key = self._get_char_key("HotoriChain") + hotori_ok = False + if hotori_key: + self.task.send_key(hotori_key) + + if hotori: + switch_deadline = time.time() + 1.0 + while (not self.task.is_char_at_index(hotori.index) + and time.time() < switch_deadline + and time.time() < total_deadline): + self.click() + self.sleep(0.01) + if self.task.is_char_at_index(hotori.index): + hotori_ok = True + + if time.time() >= total_deadline: + break + + if hotori_ok: + hotori_start = time.time() + while time.time() - hotori_start < 0.8 and time.time() < total_deadline: + hotori.click() + hotori.sleep(0.1) + self.task.next_frame() + + if time.time() + 2.5 >= total_deadline: + break + + nanally_key = self._get_char_key("NanallyChain") + if nanally_key: + self.task.send_key(nanally_key) + + switch_deadline = time.time() + 1.0 + while (not self.task.is_char_at_index(self.index) + and time.time() < switch_deadline + and time.time() < total_deadline): + self.click() + self.sleep(0.01) + + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() diff --git a/src/char/ZeroChain.py b/src/char/ZeroChain.py new file mode 100644 index 0000000..4b5f872 --- /dev/null +++ b/src/char/ZeroChain.py @@ -0,0 +1,74 @@ +import time + +from src.char.Zero import Zero + + +class ZeroChain(Zero): + def do_perform(self): + if self.task.chain_executor.active: + self.continues_normal_attack(0.2) + return + self._do_perform_legacy() + + def _do_perform_legacy(self): + self.wait_intro() + self.click_ultimate() + self.click_skill() + self.continues_normal_attack(0.5, interval=0.01) + + def chain_q_e_wait(self): + if self.has_intro: + start = time.time() + while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: + self.click() + if self.ultimate_available(): + break + self.sleep(0.1) + + q_deadline = time.time() + 0.3 + self.task._combat_settle.time = None + while time.time() < q_deadline: + self.task.sleep_check() + self.click() + if self.ultimate_available(): + if self.click_ultimate(send_click=True): + break + self.sleep(0.05) + + while True: + self.task.sleep_check() + clicked, _, _ = self.click_skill() + if clicked: + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() + return + self.click() + self.sleep(0.05) + + def chain_nop(self): + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() + + def chain_e_only(self): + if self.has_intro: + start = time.time() + while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: + self.click() + if self.skill_available(): + break + self.sleep(0.1) + + deadline = time.time() + 5 + while time.time() < deadline: + self.task.sleep_check() + self.click() + if self.skill_available(): + self.click_skill() + break + self.sleep(0.05) + + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() diff --git a/src/combat/BaseCombatTask.py b/src/combat/BaseCombatTask.py index 0097988..271e159 100644 --- a/src/combat/BaseCombatTask.py +++ b/src/combat/BaseCombatTask.py @@ -13,6 +13,7 @@ from src.char.custom.CustomCharManager import CustomCharManager from src.char.Healer import Healer from src.combat.CombatCheck import CombatCheck +from src.combat.ChainExecutor import ChainExecutor from src.sound_trigger.SoundCombatContext import SoundCombatContext from src.utils import game_filters as gf from src.utils import image_utils as iu @@ -61,6 +62,7 @@ def __init__(self, *args, **kwargs): self.chars: list[BaseChar] = [] self.mouse_pos = None # 当前鼠标位置 self.combat_start = 0 # 战斗开始时间戳 + self.chain_executor = ChainExecutor(self) self.add_text_fix({"E": "e"}) self.use_ultimate = True @@ -298,6 +300,7 @@ def combat_once(self, wait_combat_time=200, raise_if_not_found=True): self.wait_until( self.in_combat, time_out=wait_combat_time, raise_if_not_found=raise_if_not_found ) + self.chain_executor.reset() self.load_chars() self.switch_to_combat_start_char() self.info["Combat Count"] = self.info.get("Combat Count", 0) + 1 @@ -478,7 +481,20 @@ def switch_next_char(self, current_char: "BaseChar", post_action=None, free_intr current_char.wait_switch_cd() - switch_to, has_intro = self._find_switch_target(current_char, free_intro) + if self.chain_executor.active: + switch_to, _ = self.chain_executor.target + if switch_to is not None and switch_to != current_char: + has_intro = free_intro or (switch_to.element != current_char.element and current_char.is_cycle_full()) + else: + return + else: + anchor = getattr(self.chain_executor, '_pending_anchor', None) + if anchor is not None and anchor != current_char: + switch_to = anchor + has_intro = free_intro or (switch_to.element != current_char.element and current_char.is_cycle_full()) + self.chain_executor._pending_anchor = None + else: + switch_to, has_intro = self._find_switch_target(current_char, free_intro) if switch_to is None or switch_to == current_char: logger.warning(f"{current_char} failed to find a valid switch target") @@ -490,10 +506,23 @@ def switch_next_char(self, current_char: "BaseChar", post_action=None, free_intr has_intro=has_intro, post_action=post_action, free_intro=free_intro, - retry_intro=True, + retry_intro=not self.chain_executor.active, log_prefix="switch_next_char", ) + def switch_to_char(self, target_char, has_intro=False): + """切换到指定角色""" + current_char = self.get_current_char(raise_exception=False) + if current_char == target_char: + return + self._switch_to_char( + target_char, + current_char=current_char, + has_intro=has_intro, + log_prefix="switch_to_char", + time_out=self.switch_char_time_out + ) + def switch_to_combat_start_char(self): start_chars = [ char for char in self.chars if char is not None and getattr(char, "start_combat", False) @@ -603,6 +632,16 @@ def sleep_check(self): if not self.in_combat(): self.raise_not_in_combat("sleep check not in combat") + def suppress_dodge(self): + ctx = SoundCombatContext() + if ctx and ctx.trigger: + ctx.trigger.dodge_suppressed = True + + def unsuppress_dodge(self): + ctx = SoundCombatContext() + if ctx and ctx.trigger: + ctx.trigger.dodge_suppressed = False + def _apply_sound_config(self): if self.sound_config: enable = self.sound_config.get("Enable Sound Trigger", True) diff --git a/src/combat/ChainExecutor.py b/src/combat/ChainExecutor.py new file mode 100644 index 0000000..9560cb9 --- /dev/null +++ b/src/combat/ChainExecutor.py @@ -0,0 +1,81 @@ +class ChainExecutor: + def __init__(self, task): + self.task = task + self.steps = [] + self.current_index = 0 + self.active = False + self._builder = None + self._pending_anchor = None + self._chain_start_time = 0 + self._last_step_time = 0 + + @property + def target(self): + if not self.active or self.current_index >= len(self.steps): + return None, None + char, method = self.steps[self.current_index] + if char is not None and 0 <= char.index < len(self.task.chars): + char = self.task.chars[char.index] or char + return char, method + + def reset(self): + self.active = False + self.steps = [] + self.current_index = 0 + self._builder = None + self._pending_anchor = None + + def loop(self, builder): + import time + self._builder = builder + steps = builder() + self._chain_start_time = time.time() + self._last_step_time = time.time() + self._start(steps) + first_char, first_method = self.steps[0] + current_char = self.task.get_current_char(raise_exception=False) + if current_char != first_char: + self.task.switch_to_char(first_char) + self.task.log_info(f"chain step 0: {first_char.__class__.__name__}.{first_method} will be executed by perform") + + + def _start(self, steps): + self.steps = steps + self.current_index = 0 + self.active = True + first_char, first_method = steps[0] + first_char._chain_method = first_method + names = [(c.__class__.__name__, m) for c, m in steps] + self.task.log_info(f"start_chain with {len(steps)} steps: {names}") + + def step_complete(self): + import time + now = time.time() + if self.active and self._last_step_time > 0: + wait_time = now - self._last_step_time + if wait_time > 0.1: + self.task.add_freeze_duration(self._last_step_time, wait_time) + self._last_step_time = now + + self.current_index += 1 + hotori = next((c for c in self.task.chars if c is not None and c.__class__.__name__ == "HotoriChain"), None) + + if hotori and getattr(hotori, 'team_skill_window_start', 0) > 0: + hotori.update_team_skill_records() + + if self.current_index >= len(self.steps): + if self._builder: + next_steps = self._builder() + if next_steps: + self.task.log_info("chain cycle complete, building next") + self._start(next_steps) + return + self._builder = None + self.active = False + self.task.log_info("chain finished, waiting for anchor restart") + return + next_char, next_method = self.steps[self.current_index] + next_char._chain_method = next_method + self.task.log_info( + f"chain step {self.current_index}: -> {next_char.__class__.__name__}.{next_method}" + ) diff --git a/src/sound_trigger/DodgeCounterTrigger.py b/src/sound_trigger/DodgeCounterTrigger.py index 8e958e6..12370b1 100644 --- a/src/sound_trigger/DodgeCounterTrigger.py +++ b/src/sound_trigger/DodgeCounterTrigger.py @@ -32,8 +32,20 @@ def __init__( self._last_counter_time = 0.0 self._min_dodge_interval = 0.5 self._min_counter_interval = 1.0 + self._dodge_suppressed = False + + @property + def dodge_suppressed(self): + return self._dodge_suppressed + + @dodge_suppressed.setter + def dodge_suppressed(self, value): + self._dodge_suppressed = value def execute_dodge(self): + if self._dodge_suppressed: + logger.debug("Dodge suppressed by chain method") + return now = time.time() if now - self._last_dodge_time < self._min_dodge_interval: logger.debug(f"Dodge skipped, too soon: {now - self._last_dodge_time:.3f}s") From 239e77de7996223a5ffaecdfe3955911db99e983 Mon Sep 17 00:00:00 2001 From: Wattls <97113548+Wattls@users.noreply.github.com> Date: Fri, 22 May 2026 17:36:10 +0800 Subject: [PATCH 02/12] Update src/char/HotoriChain.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/char/HotoriChain.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/char/HotoriChain.py b/src/char/HotoriChain.py index 99e4f0c..8fcc359 100644 --- a/src/char/HotoriChain.py +++ b/src/char/HotoriChain.py @@ -23,6 +23,7 @@ class HotoriChain(Hotori): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.team_skill_records = {} + self.team_skill_window_start = 0 self._chain_cycle = 0 self._e_used = False self._e_lockdown = False From 1ed1b69c797b1f4f5bca2bd0b565542dcac5f19d Mon Sep 17 00:00:00 2001 From: Wattls Date: Wed, 27 May 2026 15:36:53 +0800 Subject: [PATCH 03/12] =?UTF-8?q?refactor(combat):=20=E5=BD=BB=E5=BA=95?= =?UTF-8?q?=E8=A7=A3=E8=80=A6=E5=8F=8C=E8=BD=B4=E8=BF=9E=E6=90=BA=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=EF=BC=8C=E5=BC=95=E5=85=A5=E6=97=B6=E9=97=B4=E8=A3=81?= =?UTF-8?q?=E5=88=A4=E4=B8=8E=E9=98=B2=E5=B9=B2=E6=89=B0=E9=A9=BB=E5=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [重构] ChainExecutor: 清除所有角色硬编码(如 HotoriChain 特判),回归纯粹的链式调度器职责。 - [重构] HotoriChain: 引入 `time_to_next_burst` 时间 API,接管 15s/20s 轴的时间裁判权,精准扣除大招时停。同时通过 `on_chain_step_complete` 实现控制反转,自主管理 E 技能锁定。 - [重构] NanallyChain: 移除死循环与硬编码倒计时,重构为基于状态机的 1.2s/0.8s 无阻塞极限合轴。新增 `is_char_at_index` 真实在场判定,解决hotori错误释放E技能的问题。 - [重构] JiuyuanChain: 剥离跨角色越权操作,移除对 Hotori 私有变量的直接修改。 --- src/char/BaseChar.py | 3 + src/char/Hotori.py | 37 +++++- src/char/HotoriChain.py | 51 +++++++- src/char/JiuyuanChain.py | 4 - src/char/NanallyChain.py | 234 ++++++++++-------------------------- src/combat/ChainExecutor.py | 10 +- 6 files changed, 156 insertions(+), 183 deletions(-) diff --git a/src/char/BaseChar.py b/src/char/BaseChar.py index bee60ee..eaf6cec 100644 --- a/src/char/BaseChar.py +++ b/src/char/BaseChar.py @@ -599,6 +599,9 @@ def on_combat_end(self, chars): """ pass + def on_chain_step_complete(self): + pass + @property def add_freeze_duration(self): """添加冻结持续时间 (代理到 task.add_freeze_duration)。""" diff --git a/src/char/Hotori.py b/src/char/Hotori.py index fff4322..7d12db7 100644 --- a/src/char/Hotori.py +++ b/src/char/Hotori.py @@ -10,17 +10,23 @@ class Hotori(BaseChar): TEAM_SKILL_WINDOW = 5 MAX_TEAM_SKILL_RECORDS = 3 ULT_ATTACK_DURATION = 6 + E_RECOVERY = 10 + Q_RECOVERY = 20 + SLOP = 0 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.start_combat = True self.team_skill_window_start = 0 + self._e_cast_time = 0 + self._q_cast_time = 0 def do_perform(self): self.wait_intro() if self.can_ultimate_with_records(): if self.click_ultimate(): + self._q_cast_time = time.time() self.clear_team_skill_records() else: self.continues_normal_attack(0.2) @@ -35,6 +41,7 @@ def do_perform(self): return if self.click_skill(time_out=1.5)[0]: + self._e_cast_time = time.time() self.start_team_skill_window() def do_get_switch_priority(self, current_char, has_intro=False): @@ -59,6 +66,28 @@ def required_team_skill_records(self): def team_skill_window_elapsed(self): return self.time_elapsed_accounting_for_freeze(self.team_skill_window_start) + @property + def e_remaining(self): + if self._e_cast_time <= 0: + return 0 + return self.E_RECOVERY + self.TEAM_SKILL_WINDOW + self.SLOP - self.time_elapsed_accounting_for_freeze(self._e_cast_time) + + @property + def q_remaining(self): + if self._q_cast_time <= 0: + return 0 + return self.Q_RECOVERY + self.SLOP - self.time_elapsed_accounting_for_freeze(self._q_cast_time) + + def time_to_cashout(self): + if self._e_cast_time > 0: + return self.e_remaining + return 999 + + def time_to_startup(self): + if self._q_cast_time > 0: + return self.q_remaining + return 999 + def expire_team_skill_window(self): self.team_skill_window_start = 0 @@ -110,7 +139,7 @@ def can_ultimate_with_records(self): def waiting_for_team_skills(self): if self.team_skill_window_start <= 0 or self.ready_for_ultimate(): return False - if self.team_skill_window_elapsed() > self.TEAM_SKILL_WINDOW: + if self.time_elapsed_accounting_for_freeze(self.team_skill_window_start) > self.TEAM_SKILL_WINDOW: self.expire_team_skill_window() return False return True @@ -118,10 +147,16 @@ def waiting_for_team_skills(self): def reset_state(self): super().reset_state() self.clear_team_skill_records() + self._e_cast_time = 0 + self._q_cast_time = 0 def on_combat_end(self, chars): self.clear_team_skill_records() + def on_chain_step_complete(self): + if self.team_skill_window_start > 0: + pass + # def skill_available(self, check_color=True): # available = super().skill_available(check_color=check_color) # box = self.task.box_of_screen(0.3590, 0.9299, 0.3641, 0.9444, diff --git a/src/char/HotoriChain.py b/src/char/HotoriChain.py index 8fcc359..cea00e7 100644 --- a/src/char/HotoriChain.py +++ b/src/char/HotoriChain.py @@ -1,5 +1,7 @@ import time +from src.char.custom.BuiltinComboRegistry import BuiltinComboRegistry +from src.char.custom.CustomCharManager import CustomCharManager from src.char.Hotori import Hotori @@ -8,7 +10,7 @@ class HotoriChain(Hotori): ("HotoriChain", "chain_e_start_chain"), ("ZeroChain", "chain_q_e_wait"), ("JiuyuanChain", "chain_intro_only"), - ("NanallyChain", "chain_e_q_6s_swap"), + ("NanallyChain", "chain_dynamic_standby"), ("JiuyuanChain", "chain_q_e_heavy"), ("HotoriChain", "chain_q_na"), ] @@ -16,18 +18,37 @@ class HotoriChain(Hotori): ("ZeroChain", "chain_nop"), ("JiuyuanChain", "chain_intro_only"), ("ZeroChain", "chain_e_only"), - ("NanallyChain", "chain_intro_e_q_10s_swap"), + ("NanallyChain", "chain_dynamic_standby"), ("JiuyuanChain", "chain_q_e_heavy"), ] + STARTUP_DURATION = 15.0 + WARMUP_DURATION = 20.0 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.team_skill_records = {} - self.team_skill_window_start = 0 self._chain_cycle = 0 self._e_used = False self._e_lockdown = False + def current_axis(self): + if self._e_cast_time >= self._q_cast_time: + return "STARTUP" + return "WARMUP" + + def time_to_next_burst(self): + axis = self.current_axis() + if axis == "STARTUP": + if self._e_cast_time <= 0: + return 999 + elapsed = self.time_elapsed_accounting_for_freeze(self._e_cast_time) + return max(0.0, self.STARTUP_DURATION - elapsed) + else: + if self._q_cast_time <= 0: + return 999 + elapsed = self.time_elapsed_accounting_for_freeze(self._q_cast_time) + return max(0.0, self.WARMUP_DURATION - elapsed) + def is_anchor(self) -> bool: return True @@ -63,6 +84,20 @@ def _build_next_chain(self): def do_perform(self): self.wait_intro() + ft = CustomCharManager().get_fixed_team() + if not ft.get("enabled", False): + Hotori.do_perform(self) + return + slots = ft.get("slots", []) + if len(slots) < 4: + Hotori.do_perform(self) + return + chain_keys = ["char_chain_hotori", "char_chain_zero", "char_chain_jiuyuan", "char_chain_nanally"] + for i, slot in enumerate(slots): + key = CustomCharManager().get_builtin_key(slot.get("combo_ref", "")) + if key != chain_keys[i]: + Hotori.do_perform(self) + return if self._e_lockdown: self.continues_normal_attack(0.2) return @@ -173,6 +208,7 @@ def chain_q_na(self): self.click() if self.ultimate_available() and self.click_ultimate(send_click=True): q_done = True + self._q_cast_time = time.time() break self.sleep(0.1) if not q_done: @@ -183,6 +219,7 @@ def chain_q_na(self): if self.ultimate_available(): if self.click_ultimate(send_click=True): self.logger.info("chain_q_na Q done") + self._q_cast_time = time.time() break self.click() self.sleep(0.1) @@ -198,6 +235,7 @@ def start_team_skill_window(self): self.last_skill_time if self.last_skill_time > 0 else time.time() ) self.team_skill_records.clear() + self._e_cast_time = time.time() def clear_team_skill_records(self): self.team_skill_window_start = 0 @@ -271,3 +309,10 @@ def reset_state(self): def on_combat_end(self, chars): self.clear_team_skill_records() + + def on_chain_step_complete(self): + super().on_chain_step_complete() + ce = self.task.chain_executor + if ce.active and ce.current_index >= len(ce.steps) and self._chain_cycle == 2: + self._e_lockdown = False + self.logger.info("Hotori E lock auto-released for next startup") diff --git a/src/char/JiuyuanChain.py b/src/char/JiuyuanChain.py index a023441..6c06c6e 100644 --- a/src/char/JiuyuanChain.py +++ b/src/char/JiuyuanChain.py @@ -55,10 +55,6 @@ def chain_q_e_heavy(self): self.sleep(0.6) self.task.mouse_up() self.task.unsuppress_dodge() - hotori = next((c for c in self.task.chars if c.__class__.__name__ == "HotoriChain"), None) - if hotori and hotori._chain_cycle == 2: - hotori._e_lockdown = False - self.logger.info("E lock released by warmup chain Jiuyuan heavy") self.task.chain_executor.step_complete() self._send_chain_key() self.switch_next_char() diff --git a/src/char/NanallyChain.py b/src/char/NanallyChain.py index fcdccf2..8de185e 100644 --- a/src/char/NanallyChain.py +++ b/src/char/NanallyChain.py @@ -12,198 +12,96 @@ def do_perform(self): self.click_skill() self.click_ultimate() - def chain_e_q_6s_swap(self): - self.task.suppress_dodge() - q_casted = False - + def chain_dynamic_standby(self): hotori = next((c for c in self.task.chars if c.__class__.__name__ == "HotoriChain"), None) - skip_e = hotori and hotori.team_skill_window_elapsed() > 5 + _last_e_time = 0 - if skip_e: - self.task.unsuppress_dodge() - else: - self.logger.info(f"chain_e_q_6s_swap: trying to cast E, skip_e={skip_e}") - e_deadline = time.time() + 5 + if not self.has_cd("skill"): + e_deadline = time.time() + 2.0 while time.time() < e_deadline: self.task.sleep_check() - if not self.task.is_char_at_index(self.index): - self.click() - self.sleep(0.01) - continue - - clicked, _, _ = self.click_skill() - if clicked: - self.logger.info("chain_e_q_6s_swap: E cast success") - cd_start = time.time() - while time.time() - cd_start < 2: - self.task.sleep_check() - self.click() - if self.has_cd("skill"): - break - self.sleep(0.05) - self.click() - self.sleep(0.3) - if self.ultimate_available() and self.task.is_char_at_index(self.index): - self.task._combat_settle.time = None - self.click_ultimate() - q_casted = True - self.task.unsuppress_dodge() - break + if self.skill_available(): + if self.click_skill(): + _last_e_time = time.time() + self.logger.info("Nanally E released for copy") + break self.click() self.sleep(0.05) - else: - self.logger.info("chain_e_q_6s_swap: E cast timeout") - self.task.unsuppress_dodge() - - total_start = time.time() - total_deadline = total_start + 15 - last_log = 0 - - for i in range(3): - na_start = time.time() - while time.time() - na_start < 1.2 and time.time() < total_deadline: - if not q_casted and self.ultimate_available() and self.task.is_char_at_index(self.index): - self.task._combat_settle.time = None - self.click_ultimate() - q_casted = True - na_start = time.time() - - self.click() - self.sleep(0.1) - self.task.next_frame() - - now = time.time() - if now - last_log >= 2: - self.logger.debug(f"chain_e_q_6s NA running, elapsed={now - total_start:.1f}s") - last_log = now - if time.time() >= total_deadline: - break - - hotori_key = self._get_char_key("HotoriChain") - hotori_ok = False - if hotori_key: - self.task.send_key(hotori_key) - - if hotori: - switch_deadline = time.time() + 1.0 - while (not self.task.is_char_at_index(hotori.index) - and time.time() < switch_deadline - and time.time() < total_deadline): + if _last_e_time > 0: + q_deadline = time.time() + 1.0 + while time.time() < q_deadline: + self.task.sleep_check() + if self.ultimate_available() and time.time() - _last_e_time >= 0.4: + self.task._combat_settle.time = None + self.click_ultimate() + self.logger.info("Nanally Q released after E") + break self.click() - self.sleep(0.01) - if self.task.is_char_at_index(hotori.index): - hotori_ok = True - - if time.time() >= total_deadline: - break - - if hotori_ok: - hotori_start = time.time() - while time.time() - hotori_start < 0.8 and time.time() < total_deadline: - hotori.click() - hotori.sleep(0.1) - self.task.next_frame() - - now = time.time() - if now - last_log >= 2: - self.logger.debug(f"chain_e_q_6s NA running, elapsed={now - total_start:.1f}s") - last_log = now - - if time.time() >= total_deadline or i >= 2: - break + self.sleep(0.05) + else: + self.logger.info("Nanally E in CD, skip initial release, entering standby directly") - nanally_key = self._get_char_key("NanallyChain") - if nanally_key: - self.task.send_key(nanally_key) + while True: + self.task.sleep_check() - switch_deadline = time.time() + 1.0 - while (not self.task.is_char_at_index(self.index) - and time.time() < switch_deadline - and time.time() < total_deadline): - self.click() - self.sleep(0.01) + if hotori and hotori.time_to_next_burst() <= 2.0: + self.logger.info("Nanally bail, handing off to Jiuyuan") + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() + return - self.task.chain_executor.step_complete() - self._send_chain_key() - self.switch_next_char() + nanally_start = time.time() + while time.time() - nanally_start < 1.2: + self.task.sleep_check() - def chain_intro_e_q_10s_swap(self): - if self.has_intro: - start = time.time() - while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: - self.click() - if self.skill_available(): + if hotori and hotori.time_to_next_burst() <= 2.0: break - self.sleep(0.1) - total_deadline = time.time() + 12 - e_casted = False - e_cast_time = 0 - hotori = next((c for c in self.task.chars if c.__class__.__name__ == "HotoriChain"), None) + if self.task.is_char_at_index(self.index): + if self.skill_available(): + if self.click_skill(): + _last_e_time = time.time() + if self.ultimate_available() and time.time() - _last_e_time >= 0.4: + self.task._combat_settle.time = None + self.click_ultimate() + self.click() + else: + self.click() + nanally_key = self._get_char_key("NanallyChain") + if nanally_key: + self.task.send_key(nanally_key) - while time.time() + 1.5 < total_deadline: - na_start = time.time() - while time.time() - na_start < 1.2 and time.time() < total_deadline: - self.click() - if not e_casted and self.skill_available() and self.task.is_char_at_index(self.index): - self.task.suppress_dodge() - clicked, _, _ = self.click_skill() - self.task.unsuppress_dodge() - if clicked: - e_casted = True - e_cast_time = time.time() - if self.ultimate_available() and self.task.is_char_at_index(self.index) and ( - not e_casted or time.time() - e_cast_time > 0.3 - ): - q_anim_start = time.time() - self.task._combat_settle.time = None - self.click_ultimate() - total_deadline += time.time() - q_anim_start - self.sleep(0.1) - - if time.time() >= total_deadline: - break + self.sleep(0.05) + + if hotori and hotori.time_to_next_burst() <= 2.0: + continue hotori_key = self._get_char_key("HotoriChain") - hotori_ok = False if hotori_key: self.task.send_key(hotori_key) - if hotori: - switch_deadline = time.time() + 1.0 - while (not self.task.is_char_at_index(hotori.index) - and time.time() < switch_deadline - and time.time() < total_deadline): + switch_start = time.time() + while not self.task.is_char_at_index(hotori.index) and time.time() - switch_start < 1.0: + self.task.sleep_check() self.click() - self.sleep(0.01) - if self.task.is_char_at_index(hotori.index): - hotori_ok = True - - if time.time() >= total_deadline: - break + self.sleep(0.05) - if hotori_ok: - hotori_start = time.time() - while time.time() - hotori_start < 0.8 and time.time() < total_deadline: - hotori.click() - hotori.sleep(0.1) - self.task.next_frame() - - if time.time() + 2.5 >= total_deadline: - break + if self.task.is_char_at_index(hotori.index): + hotori_start = time.time() + while time.time() - hotori_start < 0.8: + self.task.sleep_check() + hotori.click() + hotori.sleep(0.1) + self.task.next_frame() nanally_key = self._get_char_key("NanallyChain") if nanally_key: self.task.send_key(nanally_key) - switch_deadline = time.time() + 1.0 - while (not self.task.is_char_at_index(self.index) - and time.time() < switch_deadline - and time.time() < total_deadline): - self.click() - self.sleep(0.01) - - self.task.chain_executor.step_complete() - self._send_chain_key() - self.switch_next_char() + switch_back_start = time.time() + while not self.task.is_char_at_index(self.index) and time.time() - switch_back_start < 1.0: + self.task.sleep_check() + self.click() + self.sleep(0.05) diff --git a/src/combat/ChainExecutor.py b/src/combat/ChainExecutor.py index 9560cb9..c120123 100644 --- a/src/combat/ChainExecutor.py +++ b/src/combat/ChainExecutor.py @@ -51,17 +51,13 @@ def _start(self, steps): def step_complete(self): import time now = time.time() - if self.active and self._last_step_time > 0: - wait_time = now - self._last_step_time - if wait_time > 0.1: - self.task.add_freeze_duration(self._last_step_time, wait_time) self._last_step_time = now self.current_index += 1 - hotori = next((c for c in self.task.chars if c is not None and c.__class__.__name__ == "HotoriChain"), None) - if hotori and getattr(hotori, 'team_skill_window_start', 0) > 0: - hotori.update_team_skill_records() + for c in self.task.chars: + if c is not None and hasattr(c, 'on_chain_step_complete'): + c.on_chain_step_complete() if self.current_index >= len(self.steps): if self._builder: From f03a7c2a45f946ba9cad73d80698d80dd830218b Mon Sep 17 00:00:00 2001 From: Wattls Date: Wed, 27 May 2026 20:59:20 +0800 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E8=BF=9E=E6=8B=9B=E7=AD=96=E7=95=A5=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E8=A7=92=E8=89=B2=E8=BF=9E=E6=90=BA?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增ChainLoader实现角色策略化加载与替换 2. 新增全局连招策略配置UI与后端逻辑 3. 为多个角色链添加角色就位等待逻辑 4. 优化内置连招注册表过滤规则 5. 取消九原连招的闪避压制问题 6. 优化娜娜莉连招的技能释放时机判断 --- src/char/HotoriChain.py | 13 +++++ src/char/JiuyuanChain.py | 19 ++++--- src/char/NanallyChain.py | 16 ++++-- src/char/ZeroChain.py | 21 ++++++++ src/char/custom/BuiltinComboRegistry.py | 6 ++- src/char/custom/CustomCharManager.py | 16 +++++- src/combat/BaseCombatTask.py | 12 +++++ src/combat/ChainLoader.py | 72 +++++++++++++++++++++++++ src/tasks/trigger/AutoCombatTask.py | 35 +++++++++++- src/ui/TeamManagerTab.py | 36 +++++++++++++ 10 files changed, 228 insertions(+), 18 deletions(-) create mode 100644 src/combat/ChainLoader.py diff --git a/src/char/HotoriChain.py b/src/char/HotoriChain.py index cea00e7..4464e13 100644 --- a/src/char/HotoriChain.py +++ b/src/char/HotoriChain.py @@ -125,6 +125,12 @@ def _confirm_skill_cd(self): def chain_e_start_chain(self): self.logger.info(f"chain_e_start_chain: entering, has_intro={self.has_intro}") + wait_start = time.time() + while time.time() - wait_start < 1.0: + self.task.sleep_check() + if self.task.is_char_at_index(self.index): + break + self.sleep(0.05) if self.has_intro: start = time.time() @@ -201,6 +207,13 @@ def chain_e_start_chain(self): self.sleep(0.05) def chain_q_na(self): + wait_start = time.time() + while time.time() - wait_start < 1.0: + self.task.sleep_check() + if self.task.is_char_at_index(self.index): + break + self.sleep(0.05) + q_done = False if self.has_intro: start = time.time() diff --git a/src/char/JiuyuanChain.py b/src/char/JiuyuanChain.py index 6c06c6e..6a126e2 100644 --- a/src/char/JiuyuanChain.py +++ b/src/char/JiuyuanChain.py @@ -16,6 +16,13 @@ def do_perform(self): self.fire_bullets() def chain_intro_only(self): + wait_start = time.time() + while time.time() - wait_start < 1.0: + self.task.sleep_check() + if self.task.is_char_at_index(self.index): + break + self.sleep(0.05) + if self.has_intro: start = time.time() while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: @@ -49,15 +56,11 @@ def chain_q_e_heavy(self): self.task.sleep_check() clicked, _, _ = self.click_skill() if clicked: - self.task.suppress_dodge() self.sleep(1.3) self.task.mouse_down() self.sleep(0.6) self.task.mouse_up() - self.task.unsuppress_dodge() - self.task.chain_executor.step_complete() - self._send_chain_key() - self.switch_next_char() - return - self.click() - self.sleep(0.05) + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() + return diff --git a/src/char/NanallyChain.py b/src/char/NanallyChain.py index 8de185e..e426021 100644 --- a/src/char/NanallyChain.py +++ b/src/char/NanallyChain.py @@ -1,6 +1,6 @@ import time - from src.char.Nanally import Nanally +from src.combat.ChainLoader import ChainLoader class NanallyChain(Nanally): @@ -13,7 +13,15 @@ def do_perform(self): self.click_ultimate() def chain_dynamic_standby(self): - hotori = next((c for c in self.task.chars if c.__class__.__name__ == "HotoriChain"), None) + wait_start = time.time() + while time.time() - wait_start < 1.0: + self.task.sleep_check() + if self.task.is_char_at_index(self.index): + break + self.sleep(0.05) + + hotori = next((c for c in self.task.chars if ChainLoader._resolve_role(c) == "hotori"), None) + _last_e_time = 0 if not self.has_cd("skill"): @@ -32,7 +40,7 @@ def chain_dynamic_standby(self): q_deadline = time.time() + 1.0 while time.time() < q_deadline: self.task.sleep_check() - if self.ultimate_available() and time.time() - _last_e_time >= 0.4: + if self.ultimate_available() and time.time() - _last_e_time >= 0.5: self.task._combat_settle.time = None self.click_ultimate() self.logger.info("Nanally Q released after E") @@ -63,7 +71,7 @@ def chain_dynamic_standby(self): if self.skill_available(): if self.click_skill(): _last_e_time = time.time() - if self.ultimate_available() and time.time() - _last_e_time >= 0.4: + if self.ultimate_available() and time.time() - _last_e_time >= 0.5: self.task._combat_settle.time = None self.click_ultimate() self.click() diff --git a/src/char/ZeroChain.py b/src/char/ZeroChain.py index 4b5f872..6b2190a 100644 --- a/src/char/ZeroChain.py +++ b/src/char/ZeroChain.py @@ -17,6 +17,13 @@ def _do_perform_legacy(self): self.continues_normal_attack(0.5, interval=0.01) def chain_q_e_wait(self): + wait_start = time.time() + while time.time() - wait_start < 1.0: + self.task.sleep_check() + if self.task.is_char_at_index(self.index): + break + self.sleep(0.05) + if self.has_intro: start = time.time() while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: @@ -47,11 +54,25 @@ def chain_q_e_wait(self): self.sleep(0.05) def chain_nop(self): + wait_start = time.time() + while time.time() - wait_start < 1.0: + self.task.sleep_check() + if self.task.is_char_at_index(self.index): + break + self.sleep(0.05) + self.task.chain_executor.step_complete() self._send_chain_key() self.switch_next_char() def chain_e_only(self): + wait_start = time.time() + while time.time() - wait_start < 1.0: + self.task.sleep_check() + if self.task.is_char_at_index(self.index): + break + self.sleep(0.05) + if self.has_intro: start = time.time() while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: diff --git a/src/char/custom/BuiltinComboRegistry.py b/src/char/custom/BuiltinComboRegistry.py index 676945b..2ace3b4 100644 --- a/src/char/custom/BuiltinComboRegistry.py +++ b/src/char/custom/BuiltinComboRegistry.py @@ -11,8 +11,10 @@ class BuiltinComboRegistry: def _get_builtin_entries(cls) -> dict: # Late import to avoid module cycles. from src.char.CharFactory import char_dict - - return {k: v for k, v in char_dict.items() if k != "char_default"} + return { + k: v for k, v in char_dict.items() + if k != "char_default" and not k.startswith("char_chain_") + } @classmethod def _legacy_prefix(cls) -> str: diff --git a/src/char/custom/CustomCharManager.py b/src/char/custom/CustomCharManager.py index 2b6640e..db1b430 100644 --- a/src/char/custom/CustomCharManager.py +++ b/src/char/custom/CustomCharManager.py @@ -52,7 +52,7 @@ def __init__(self): @staticmethod def _default_fixed_team(): - return {"enabled": False, "slots": [{"char_name": "", "combo_ref": ""} for _ in range(4)]} + return {"enabled": False, "team_strategy": "", "slots": [{"char_name": "", "combo_ref": ""} for _ in range(4)]} @classmethod def _normalize_fixed_team_slot(cls, slot) -> dict: @@ -73,6 +73,7 @@ def _normalize_fixed_team_config(cls, config) -> dict: return normalized normalized["enabled"] = bool(config.get("enabled", False)) + normalized["team_strategy"] = str(config.get("team_strategy", "")).strip() raw_slots = config.get("slots", []) if isinstance(raw_slots, list): for i in range(min(4, len(raw_slots))): @@ -725,19 +726,30 @@ def get_fixed_team(self): fixed_team = self._normalize_fixed_team_config(self.db.get("fixed_team")) return { "enabled": fixed_team["enabled"], + "team_strategy": fixed_team["team_strategy"], "slots": [dict(slot) for slot in fixed_team["slots"]], } - def set_fixed_team(self, enabled: bool, slots): + def set_fixed_team(self, enabled: bool, slots, team_strategy: str = None): with self._data_lock: + existing = self._normalize_fixed_team_config(self.db.get("fixed_team")) + new_team_strategy = existing["team_strategy"] if team_strategy is None else team_strategy self.db["fixed_team"] = self._normalize_fixed_team_config( { "enabled": enabled, + "team_strategy": new_team_strategy, "slots": slots, } ) self.save_db() + def set_team_strategy(self, team_strategy: str): + with self._data_lock: + fixed_team = self._normalize_fixed_team_config(self.db.get("fixed_team")) + fixed_team["team_strategy"] = team_strategy + self.db["fixed_team"] = fixed_team + self.save_db() + def clear_fixed_team(self): with self._data_lock: self.db["fixed_team"] = self._default_fixed_team() diff --git a/src/combat/BaseCombatTask.py b/src/combat/BaseCombatTask.py index 0d5944e..9161368 100644 --- a/src/combat/BaseCombatTask.py +++ b/src/combat/BaseCombatTask.py @@ -732,6 +732,18 @@ def load_chars(self) -> bool: elements = [char.element for char in new_chars] self.chars = new_chars + from src.combat.ChainLoader import ChainLoader + team_strategy = fixed_team.get("team_strategy", "NONE") + if team_strategy != "NONE": + ChainLoader.replace_chars_with_strategy(self, team_strategy) + if hasattr(self, 'chain_executor') and self.chain_executor and self.chain_executor.active: + for i in range(len(self.chain_executor.steps)): + old_char, method = self.chain_executor.steps[i] + if 0 <= old_char.index < len(self.chars): + new_char = self.chars[old_char.index] + self.chain_executor.steps[i] = (new_char, method) + if hasattr(old_char, '_chain_method'): + new_char._chain_method = old_char._chain_method self.info_set("char elements", elements) healer_count = 0 diff --git a/src/combat/ChainLoader.py b/src/combat/ChainLoader.py new file mode 100644 index 0000000..05c460f --- /dev/null +++ b/src/combat/ChainLoader.py @@ -0,0 +1,72 @@ +class ChainLoader: +# 角色名称映射:支持中文名、英文名、链式类名,统一解析为内部 role_id + CHAR_NAME_MAP = { + "浔": "hotori", "Hotori": "hotori", "HotoriChain": "hotori", + "零": "zero", "Zero": "zero", "ZeroChain": "zero", + "九原": "jiuyuan", "Jiuyuan": "jiuyuan", "JiuyuanChain": "jiuyuan", + "娜娜莉": "nanally", "Nanally": "nanally", "NanallyChain": "nanally", + } + + @staticmethod + def replace_chars_with_strategy(task, strategy_name): + if strategy_name == "HOTORI_CREATION_CHAIN": + from src.char.HotoriChain import HotoriChain + from src.char.ZeroChain import ZeroChain + from src.char.JiuyuanChain import JiuyuanChain + from src.char.NanallyChain import NanallyChain + + chain_map = { + "hotori": {"cls": HotoriChain, "key": "char_chain_hotori"}, + "zero": {"cls": ZeroChain, "key": "char_chain_zero"}, + "jiuyuan": {"cls": JiuyuanChain, "key": "char_chain_jiuyuan"}, + "nanally": {"cls": NanallyChain, "key": "char_chain_nanally"} + } + + team_instances = {} + + for i, c in enumerate(task.chars): + role_id = ChainLoader._resolve_role(c) + if role_id in chain_map: + mapping = chain_map[role_id] + target_cls = mapping["cls"] + + if c.__class__.__name__ != target_cls.__name__: + new_char = target_cls(task, c.index, char_name=c.char_name, confidence=c.confidence) + new_char.element = c.element + new_char.builtin_key = mapping["key"] + task.chars[i] = new_char + team_instances[role_id] = new_char + else: + team_instances[role_id] = c + + hotori = team_instances.get("hotori") + zero = team_instances.get("zero") + jiuyuan = team_instances.get("jiuyuan") + nanally = team_instances.get("nanally") + + if not all([hotori, zero, jiuyuan, nanally]): + task.log_error("队伍缺少浔/零/九原/娜娜莉其中之一,无法使用浔创生链式轴!") + return None + + return (hotori, zero, jiuyuan, nanally) + return None + + @staticmethod + def _resolve_role(c): + """通过 char_name 或类名查 CHAR_NAME_MAP,返回内部 role_id""" + cn = getattr(c, "char_name", "") + if cn in ChainLoader.CHAR_NAME_MAP: + return ChainLoader.CHAR_NAME_MAP[cn] + cls_name = c.__class__.__name__ + return ChainLoader.CHAR_NAME_MAP.get(cls_name, "") + + @staticmethod + def load_strategy(task, strategy_name: str): + result = ChainLoader.replace_chars_with_strategy(task, strategy_name) + if result is None: + return None + hotori, _, _, _ = result + def builder(): + return hotori._build_next_chain() + return builder + \ No newline at end of file diff --git a/src/tasks/trigger/AutoCombatTask.py b/src/tasks/trigger/AutoCombatTask.py index b06fbbc..9bc4672 100644 --- a/src/tasks/trigger/AutoCombatTask.py +++ b/src/tasks/trigger/AutoCombatTask.py @@ -7,10 +7,10 @@ from src.char.CharFactory import get_char_feature_by_pos from src.char.custom.CustomCharManager import CustomCharManager from src.combat.BaseCombatTask import BaseCombatTask, CharDeadException, NotInCombatException +from src.combat.ChainLoader import ChainLoader class ScannerSignals(QObject): - # Sends list of dicts: {"index": i, "feat_id": tmp_id, "mat": ndarray, "match": str|None} scan_done = Signal(list, str) @@ -50,18 +50,49 @@ def run(self): if not self.scene.is_in_team(self.is_in_team): return + manager = CustomCharManager() + fixed_team = manager.get_fixed_team() + team_strategy = fixed_team.get("team_strategy", "NONE") + chain_builder = None + combat_start = time.time() while self.in_combat(): try: if not ret: ret = True + if team_strategy == "NONE": + has_residual_chain = any( + c.__class__.__name__.endswith("Chain") for c in self.chars if c + ) + if has_residual_chain: + self.log_info("检测到残留的 Chain 类角色,重新加载基础角色配置。") + self.load_chars() self.switch_to_combat_start_char() - self.get_current_char().perform() + + if team_strategy != "NONE" and self.chain_executor: + if not self.chain_executor.active: + chain_builder = ChainLoader.load_strategy(self, team_strategy) + if chain_builder: + self.log_info(f"启用连携策略:{team_strategy}") + self.chain_executor.reset() + self.chain_executor.loop(chain_builder) + + if self.chain_executor and self.chain_executor.active: + current_char, _ = self.chain_executor.target + if current_char: + current_char.perform() + else: + self.get_current_char().perform() + else: + self.get_current_char().perform() except CharDeadException: self.log_error("Characters dead", notify=True) break except NotInCombatException as e: logger.info(f"auto_combat_task_out_of_combat {int(time.time() - combat_start)} {e}") + ret = False + if self.chain_executor: + self.chain_executor.reset() break if ret: self.combat_end() diff --git a/src/ui/TeamManagerTab.py b/src/ui/TeamManagerTab.py index eedd3cc..468fd37 100644 --- a/src/ui/TeamManagerTab.py +++ b/src/ui/TeamManagerTab.py @@ -8,6 +8,7 @@ from qfluentwidgets import ( BodyLabel, CardWidget, + ComboBox, FluentIcon, Flyout, ImageLabel, @@ -389,6 +390,27 @@ def __init__(self, manager: CustomCharManager = None, owner=None): self.vbox.addWidget(self.scan_card) + self.strategy_card = CardWidget(self) + self.strategy_layout = QVBoxLayout(self.strategy_card) + self.strategy_layout.setContentsMargins(16, 16, 16, 16) + self.strategy_layout.setSpacing(12) + + self.strategy_header = QHBoxLayout() + self.strategy_header_text = QVBoxLayout() + self.strategy_title = SubtitleLabel(og.app.tr("全局连招策略")) + self.strategy_desc = BodyLabel(og.app.tr("选择全局连招策略,将覆盖所有角色的独立连招配置")) + self.strategy_header_text.addWidget(self.strategy_title) + self.strategy_header_text.addWidget(self.strategy_desc) + self.strategy_header.addLayout(self.strategy_header_text, 1) + + self.strategy_combo = ComboBox(self) + self.strategy_combo.setMinimumWidth(200) + self.strategy_combo.addItem(og.app.tr("默认自由战斗"), userData="NONE") + self.strategy_combo.addItem(og.app.tr("浔-零-九原-娜莉 浔创生链式轴"), userData="HOTORI_CREATION_CHAIN") + self.strategy_header.addWidget(self.strategy_combo) + self.strategy_layout.addLayout(self.strategy_header) + self.vbox.addWidget(self.strategy_card) + self.fixed_team_card = CardWidget(self) self.fixed_team_layout = QVBoxLayout(self.fixed_team_card) self.fixed_team_layout.setContentsMargins(16, 16, 16, 16) @@ -449,6 +471,7 @@ def __init__(self, manager: CustomCharManager = None, owner=None): scanner_signals.scan_done.connect(self.on_scan_done) char_manager_signals.refresh_tab.connect(self.reload_fixed_team_options) + self.strategy_combo.currentIndexChanged.connect(self.on_strategy_changed) self.refresh_fixed_team_state() @property @@ -510,6 +533,11 @@ def refresh_fixed_team_state(self): for i, card in enumerate(self.fixed_team_slots): slot = slots[i] if i < len(slots) else {} card.set_data(slot.get("char_name", ""), slot.get("combo_ref", "")) + + saved_strategy = fixed_team.get("team_strategy", "NONE") + idx = self.strategy_combo.findData(saved_strategy) + if idx >= 0: + self.strategy_combo.setCurrentIndex(idx) filled_count = sum(1 for slot in slots if slot.get("char_name")) if fixed_team.get("enabled") and filled_count: @@ -525,6 +553,14 @@ def refresh_fixed_team_state(self): self.save_fixed_team_btn.setText(self.tr_save_fixed_team) self.disable_fixed_team_btn.setEnabled(False) + def on_strategy_changed(self, index): + strategy_data = self.strategy_combo.itemData(index) + self.manager.set_team_strategy(strategy_data) + if strategy_data != "NONE": + self._show_bar(og.app.tr("策略已切换"), og.app.tr(f"已启用全局连招策略:{self.strategy_combo.currentText()}")) + else: + self._show_bar(og.app.tr("策略已切换"), og.app.tr("已关闭全局连招策略")) + def on_scan_clicked(self): og.app.start_controller.handler.post(self.scan_team) From 87affdec68792c6d775699b2d06c803f4c019985 Mon Sep 17 00:00:00 2001 From: Wattls Date: Wed, 27 May 2026 21:19:19 +0800 Subject: [PATCH 05/12] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E4=BA=86?= =?UTF-8?q?=E5=86=97=E4=BD=99=E7=9A=84=E6=97=A7=E4=BB=A3=E7=A0=81=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/combat/BaseCombatTask.py | 10 +--------- src/combat/ChainLoader.py | 1 - 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/combat/BaseCombatTask.py b/src/combat/BaseCombatTask.py index 9161368..dadecaa 100644 --- a/src/combat/BaseCombatTask.py +++ b/src/combat/BaseCombatTask.py @@ -730,20 +730,12 @@ def load_chars(self) -> bool: for i in indices_to_detect: new_chars[i].element = detected_elements.get(i, Element.DEFAULT) - elements = [char.element for char in new_chars] self.chars = new_chars from src.combat.ChainLoader import ChainLoader team_strategy = fixed_team.get("team_strategy", "NONE") if team_strategy != "NONE": ChainLoader.replace_chars_with_strategy(self, team_strategy) - if hasattr(self, 'chain_executor') and self.chain_executor and self.chain_executor.active: - for i in range(len(self.chain_executor.steps)): - old_char, method = self.chain_executor.steps[i] - if 0 <= old_char.index < len(self.chars): - new_char = self.chars[old_char.index] - self.chain_executor.steps[i] = (new_char, method) - if hasattr(old_char, '_chain_method'): - new_char._chain_method = old_char._chain_method + elements = [char.element for char in self.chars] self.info_set("char elements", elements) healer_count = 0 diff --git a/src/combat/ChainLoader.py b/src/combat/ChainLoader.py index 05c460f..6723265 100644 --- a/src/combat/ChainLoader.py +++ b/src/combat/ChainLoader.py @@ -69,4 +69,3 @@ def load_strategy(task, strategy_name: str): def builder(): return hotori._build_next_chain() return builder - \ No newline at end of file From b66543b900ae207f801509aa891187fd59768877 Mon Sep 17 00:00:00 2001 From: Wattls Date: Thu, 28 May 2026 00:38:49 +0800 Subject: [PATCH 06/12] =?UTF-8?q?refactor(chain):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E9=93=BE=E6=89=A7=E8=A1=8C=E9=80=BB=E8=BE=91=E4=B8=8E=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E9=93=BE=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 统一链内常量配置,替换硬编码 2. 新增CD确认机制,避免技能释放误判 3. 优化切人等待逻辑,增加超时保护 4. 重构NanallyChain动态驻场逻辑,修复切人循环问题 --- src/char/HotoriChain.py | 71 ++++++++----- src/char/JiuyuanChain.py | 58 ++++++++--- src/char/NanallyChain.py | 203 +++++++++++++++++++++--------------- src/char/ZeroChain.py | 66 ++++++++---- src/combat/ChainExecutor.py | 4 +- 5 files changed, 261 insertions(+), 141 deletions(-) diff --git a/src/char/HotoriChain.py b/src/char/HotoriChain.py index 4464e13..b3d3d12 100644 --- a/src/char/HotoriChain.py +++ b/src/char/HotoriChain.py @@ -21,8 +21,19 @@ class HotoriChain(Hotori): ("NanallyChain", "chain_dynamic_standby"), ("JiuyuanChain", "chain_q_e_heavy"), ] - STARTUP_DURATION = 15.0 - WARMUP_DURATION = 20.0 + STARTUP_DURATION = 15.0 # 启动轴时长 + WARMUP_DURATION = 20.0 # 暖机轴时长 + SWITCH_TIMEOUT = 1.0 # 切人超时 + E_RETRY_INTERVAL = 1.0 # E重试间隔 + E_CLICK_TIMEOUT = 0.5 # E点击超时 + CD_CONFIRM_TIMEOUT = 0.5 # CD确认超时 + CD_CONFIRM_TICK = 0.03 # CD确认轮询间隔 + POST_ACTION_PAUSE = 0.1 # 动作后暂停 + CHAIN_NA_INTERVAL = 0.2 # 链内普攻间隔 + Q_PRE_SLEEP = 1.0 # Q前等待 + Q_RECOVERY_MARGIN = 0.3 # Q恢复余量 + LOOP_TICK = 0.05 # 通用轮询间隔 + INTRO_LOOP_INTERVAL = 0.1 # 入场动画轮询间隔 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -99,14 +110,17 @@ def do_perform(self): Hotori.do_perform(self) return if self._e_lockdown: - self.continues_normal_attack(0.2) + self.continues_normal_attack(self.CHAIN_NA_INTERVAL) return if self._e_used: if self.ready_for_ultimate() and self.click_ultimate(): self._e_used = False self.clear_team_skill_records() return - self.continues_normal_attack(0.2) + self.continues_normal_attack(self.CHAIN_NA_INTERVAL) + return + if self._chain_cycle == 0 and self._q_cast_time > 0 and self.time_to_next_burst() > self.Q_RECOVERY_MARGIN: + self.continues_normal_attack(self.CHAIN_NA_INTERVAL) return self.task.chain_executor.loop(self._build_next_chain) @@ -117,20 +131,28 @@ def count_ultimate_priority(self): def _confirm_skill_cd(self): start = time.time() - while time.time() - start < 0.3: + while time.time() - start < self.CD_CONFIRM_TIMEOUT: if self.has_cd("skill"): return True - self.sleep(0.03) + self.sleep(self.CD_CONFIRM_TICK) + return False + + def _confirm_q_cd(self): + start = time.time() + while time.time() - start < self.CD_CONFIRM_TIMEOUT: + if self.has_cd("ultimate"): + return True + self.sleep(self.CD_CONFIRM_TICK) return False def chain_e_start_chain(self): self.logger.info(f"chain_e_start_chain: entering, has_intro={self.has_intro}") wait_start = time.time() - while time.time() - wait_start < 1.0: + while time.time() - wait_start < self.SWITCH_TIMEOUT: self.task.sleep_check() if self.task.is_char_at_index(self.index): break - self.sleep(0.05) + self.sleep(self.LOOP_TICK) if self.has_intro: start = time.time() @@ -140,7 +162,7 @@ def chain_e_start_chain(self): self.logger.info("chain_e_start_chain: E cast during intro") break self.click() - self.sleep(0.1) + self.sleep(self.INTRO_LOOP_INTERVAL) fail_count = 0 last_attempt = 0 @@ -152,22 +174,22 @@ def chain_e_start_chain(self): self._e_used = True self._e_lockdown = True self.start_team_skill_window() - self.sleep(0.1) + self.sleep(self.POST_ACTION_PAUSE) self.task.chain_executor.step_complete() self._send_chain_key() self.switch_next_char() return now = time.time() - if now - last_attempt < 0.5: - self.sleep(0.05) + if now - last_attempt < self.E_RETRY_INTERVAL: + self.sleep(self.LOOP_TICK) continue available = self.skill_available() self.logger.debug(f"chain_e_start_chain: attempt {fail_count+1}, skill_available={available}") if available: - clicked, _, _ = self.click_skill(time_out=1.5) + clicked, _, _ = self.click_skill(time_out=self.E_CLICK_TIMEOUT) if clicked: if self._confirm_skill_cd(): self.logger.info(f"chain_e_start_chain: E cast confirmed via CD after {fail_count} failures") @@ -175,14 +197,14 @@ def chain_e_start_chain(self): self.logger.warning("chain_e_start_chain: E interrupted (skill still available), retrying") fail_count += 1 last_attempt = now - self.sleep(0.05) + self.sleep(self.LOOP_TICK) continue else: self.logger.info(f"chain_e_start_chain: E cast assumed (no CD but skill unavailable) after {fail_count} failures") self._e_used = True self._e_lockdown = True self.start_team_skill_window() - self.sleep(0.1) + self.sleep(self.POST_ACTION_PAUSE) self.task.chain_executor.step_complete() self._send_chain_key() self.switch_next_char() @@ -195,7 +217,7 @@ def chain_e_start_chain(self): self._e_used = True self._e_lockdown = True self.start_team_skill_window() - self.sleep(0.1) + self.sleep(self.POST_ACTION_PAUSE) self.task.chain_executor.step_complete() self._send_chain_key() self.switch_next_char() @@ -204,38 +226,37 @@ def chain_e_start_chain(self): self.logger.debug("chain_e_start_chain: skill not available, waiting") last_attempt = now - self.sleep(0.05) + self.sleep(self.LOOP_TICK) def chain_q_na(self): wait_start = time.time() - while time.time() - wait_start < 1.0: + while time.time() - wait_start < self.SWITCH_TIMEOUT: self.task.sleep_check() if self.task.is_char_at_index(self.index): break - self.sleep(0.05) + self.sleep(self.LOOP_TICK) q_done = False if self.has_intro: start = time.time() while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: self.click() - if self.ultimate_available() and self.click_ultimate(send_click=True): + if self.ultimate_available() and self.click_ultimate(send_click=True) and self._confirm_q_cd(): q_done = True self._q_cast_time = time.time() break - self.sleep(0.1) + self.sleep(self.INTRO_LOOP_INTERVAL) if not q_done: - self.task.sleep(1) + self.task.sleep(self.Q_PRE_SLEEP) self.task._combat_settle.time = None while True: self.task.sleep_check() if self.ultimate_available(): - if self.click_ultimate(send_click=True): - self.logger.info("chain_q_na Q done") + if self.click_ultimate(send_click=True) and self._confirm_q_cd(): self._q_cast_time = time.time() break self.click() - self.sleep(0.1) + self.sleep(self.INTRO_LOOP_INTERVAL) self._e_used = False self.clear_team_skill_records() self.task.chain_executor.step_complete() diff --git a/src/char/JiuyuanChain.py b/src/char/JiuyuanChain.py index 6a126e2..e24091a 100644 --- a/src/char/JiuyuanChain.py +++ b/src/char/JiuyuanChain.py @@ -4,24 +4,54 @@ class JiuyuanChain(Jiuyuan): + SWITCH_TIMEOUT = 1.0 # 切人超时 + Q_DEADLINE = 0.3 # Q释放截止 + E_POST_CAST_WAIT = 1.3 # E后等待重击 + HEAVY_CHARGE_TIME = 0.6 # 重击蓄力时间 + CHAIN_NA_INTERVAL = 0.2 # 链内普攻间隔 + NA_DURATION = 0.3 # 普攻持续 + NA_POST_PAUSE = 0.1 # 普攻后暂停 + LOOP_TICK = 0.05 # 通用轮询间隔 + INTRO_LOOP_INTERVAL = 0.1 # 入场动画轮询间隔 + E_CD_CONFIRM_TIMEOUT = 0.5 # E CD确认超时 + E_CD_CONFIRM_TICK = 0.03 # E CD确认轮询间隔 + Q_CD_CONFIRM_TIMEOUT = 0.5 # Q CD确认超时 + Q_CD_CONFIRM_TICK = 0.03 # Q CD确认轮询间隔 + + def _confirm_e_cd(self): + start = time.time() + while time.time() - start < self.E_CD_CONFIRM_TIMEOUT: + if self.has_cd("skill"): + return True + self.sleep(self.E_CD_CONFIRM_TICK) + return False + + def _confirm_q_cd(self): + start = time.time() + while time.time() - start < self.Q_CD_CONFIRM_TIMEOUT: + if self.has_cd("ultimate"): + return True + self.sleep(self.Q_CD_CONFIRM_TICK) + return False + def do_perform(self): if self.task.chain_executor.active: - self.continues_normal_attack(0.2) + self.continues_normal_attack(self.CHAIN_NA_INTERVAL) return self.wait_intro() self.click_ultimate() if self.click_skill()[0]: - self.continues_normal_attack(1.4) - self.sleep(0.1) + self.continues_normal_attack(self.NA_DURATION) + self.sleep(self.NA_POST_PAUSE) self.fire_bullets() def chain_intro_only(self): wait_start = time.time() - while time.time() - wait_start < 1.0: + while time.time() - wait_start < self.SWITCH_TIMEOUT: self.task.sleep_check() if self.task.is_char_at_index(self.index): break - self.sleep(0.05) + self.sleep(self.LOOP_TICK) if self.has_intro: start = time.time() @@ -29,7 +59,7 @@ def chain_intro_only(self): self.click() if self.skill_available() or self.ultimate_available(): break - self.sleep(0.1) + self.sleep(self.INTRO_LOOP_INTERVAL) self.task.chain_executor.step_complete() self._send_chain_key() @@ -42,23 +72,25 @@ def chain_q_e_heavy(self): self.click() if self.ultimate_available(): break - self.sleep(0.1) - q_deadline = time.time() + 0.3 + self.sleep(self.INTRO_LOOP_INTERVAL) + q_deadline = time.time() + self.Q_DEADLINE self.task._combat_settle.time = None while time.time() < q_deadline: self.task.sleep_check() self.click() if self.ultimate_available(): if self.click_ultimate(send_click=True): - break - self.sleep(0.05) + if self._confirm_q_cd(): + break + self.logger.warning("chain_q_e_heavy: Q clicked but CD not detected, retrying") + self.sleep(self.LOOP_TICK) while True: self.task.sleep_check() clicked, _, _ = self.click_skill() - if clicked: - self.sleep(1.3) + if clicked and self._confirm_e_cd(): + self.sleep(self.E_POST_CAST_WAIT) self.task.mouse_down() - self.sleep(0.6) + self.sleep(self.HEAVY_CHARGE_TIME) self.task.mouse_up() self.task.chain_executor.step_complete() self._send_chain_key() diff --git a/src/char/NanallyChain.py b/src/char/NanallyChain.py index e426021..d328553 100644 --- a/src/char/NanallyChain.py +++ b/src/char/NanallyChain.py @@ -1,9 +1,20 @@ import time from src.char.Nanally import Nanally -from src.combat.ChainLoader import ChainLoader - class NanallyChain(Nanally): + HANDOFF_MARGIN = 2.0 # 九原交接提前量 + NANALLY_STAY_TIME = 1.2 # 娜娜莉循环时长 + HOTORI_STEAL_TIME = 0.8 # 浔循环时长 + SWITCH_TIMEOUT = 1.0 # 切人超时 + INITIAL_E_TIMEOUT = 2.0 # 开场等E超时 + INITIAL_Q_TIMEOUT = 1.0 # E后等Q超时 + EQ_MIN_INTERVAL = 0.5 # E→Q最小间隔 + LOOP_TICK = 0.05 # 通用轮询间隔 + E_CD_CONFIRM_TIMEOUT = 0.5 # E CD确认超时 + E_CD_CONFIRM_TICK = 0.03 # E CD确认轮询间隔 + Q_CD_CONFIRM_TIMEOUT = 0.5 # Q CD确认超时 + Q_CD_CONFIRM_TICK = 0.03 # Q CD确认轮询间隔 + def do_perform(self): if self.task.chain_executor.active: self.continues_normal_attack(0.2) @@ -12,104 +23,130 @@ def do_perform(self): self.click_skill() self.click_ultimate() - def chain_dynamic_standby(self): - wait_start = time.time() - while time.time() - wait_start < 1.0: - self.task.sleep_check() - if self.task.is_char_at_index(self.index): - break - self.sleep(0.05) - - hotori = next((c for c in self.task.chars if ChainLoader._resolve_role(c) == "hotori"), None) - + def _confirm_e_cd(self): + start = time.time() + while time.time() - start < self.E_CD_CONFIRM_TIMEOUT: + if self.has_cd("skill"): + return True + self.sleep(self.E_CD_CONFIRM_TICK) + return False + + def _confirm_q_cd(self): + start = time.time() + while time.time() - start < self.Q_CD_CONFIRM_TIMEOUT: + if self.has_cd("ultimate"): + return True + self.sleep(self.Q_CD_CONFIRM_TICK) + return False + + def _try_initial_e_release(self): _last_e_time = 0 - if not self.has_cd("skill"): - e_deadline = time.time() + 2.0 + e_deadline = time.time() + self.INITIAL_E_TIMEOUT while time.time() < e_deadline: self.task.sleep_check() - if self.skill_available(): - if self.click_skill(): - _last_e_time = time.time() - self.logger.info("Nanally E released for copy") - break + if self.skill_available() and self.click_skill() and self._confirm_e_cd(): + _last_e_time = time.time() + self.logger.info("Nanally E released for copy") + break self.click() - self.sleep(0.05) + self.sleep(self.LOOP_TICK) + else: + self.logger.info("Nanally E in CD, skip initial release, entering standby directly") + return _last_e_time - if _last_e_time > 0: - q_deadline = time.time() + 1.0 - while time.time() < q_deadline: - self.task.sleep_check() - if self.ultimate_available() and time.time() - _last_e_time >= 0.5: - self.task._combat_settle.time = None - self.click_ultimate() + def _try_q_after_e(self, last_e_time): + if last_e_time > 0: + q_deadline = time.time() + self.INITIAL_Q_TIMEOUT + while time.time() < q_deadline: + self.task.sleep_check() + if self.ultimate_available() and time.time() - last_e_time >= self.EQ_MIN_INTERVAL: + self.task._combat_settle.time = None + if self.click_ultimate() and self._confirm_q_cd(): self.logger.info("Nanally Q released after E") break - self.click() - self.sleep(0.05) - else: - self.logger.info("Nanally E in CD, skip initial release, entering standby directly") + self.click() + self.sleep(self.LOOP_TICK) - while True: + def _standby_loop_iteration(self, hotori, last_e_time): + nanally_start = time.time() + while time.time() - nanally_start < self.NANALLY_STAY_TIME: self.task.sleep_check() + if hotori and hotori.time_to_next_burst() <= self.HANDOFF_MARGIN: + return last_e_time, True - if hotori and hotori.time_to_next_burst() <= 2.0: + if self.task.is_char_at_index(self.index): + if self.skill_available() and self.click_skill() and self._confirm_e_cd(): + last_e_time = time.time() + if self.ultimate_available() and time.time() - last_e_time >= self.EQ_MIN_INTERVAL: + self.task._combat_settle.time = None + if self.click_ultimate() and not self._confirm_q_cd(): + self.logger.warning("Nanally standby Q CD not detected") + self.click() + else: + self.click() + nanally_key = self._get_char_key("NanallyChain") + if nanally_key: + self.task.send_key(nanally_key) + self.sleep(self.LOOP_TICK) + + handoff = hotori and hotori.time_to_next_burst() <= self.HANDOFF_MARGIN + return last_e_time, handoff + + def _switch_to_hotori_and_back(self, hotori): + hotori_key = self._get_char_key("HotoriChain") + if hotori_key: + self.task.send_key(hotori_key) + switch_start = time.time() + while not self.task.is_char_at_index(hotori.index) and time.time() - switch_start < self.SWITCH_TIMEOUT: + self.task.sleep_check() + self.click() + self.sleep(self.LOOP_TICK) + + if self.task.is_char_at_index(hotori.index): + hotori_start = time.time() + while time.time() - hotori_start < self.HOTORI_STEAL_TIME: + self.task.sleep_check() + hotori.click() + hotori.sleep(self.LOOP_TICK) + self.task.next_frame() + + nanally_key = self._get_char_key("NanallyChain") + if nanally_key: + self.task.send_key(nanally_key) + switch_back_start = time.time() + while not self.task.is_char_at_index(self.index) and time.time() - switch_back_start < self.SWITCH_TIMEOUT: + self.task.sleep_check() + self.click() + self.sleep(self.LOOP_TICK) + + def chain_dynamic_standby(self): + wait_start = time.time() + while time.time() - wait_start < self.SWITCH_TIMEOUT: + self.task.sleep_check() + if self.task.is_char_at_index(self.index): + break + self.sleep(self.LOOP_TICK) + + + hotori = next((c for c in self.task.chars if c.__class__.__name__ == "HotoriChain"), None) + if not hotori: + hotori = next((c for c in self.task.chars if hasattr(c, 'name') and c.name == "Hotori"), None) + _last_e_time = self._try_initial_e_release() + self._try_q_after_e(_last_e_time) + + while True: + self.task.sleep_check() + if hotori and hotori.time_to_next_burst() <= self.HANDOFF_MARGIN: self.logger.info("Nanally bail, handing off to Jiuyuan") self.task.chain_executor.step_complete() self._send_chain_key() self.switch_next_char() return - nanally_start = time.time() - while time.time() - nanally_start < 1.2: - self.task.sleep_check() - - if hotori and hotori.time_to_next_burst() <= 2.0: - break + _last_e_time, handoff = self._standby_loop_iteration(hotori, _last_e_time) - if self.task.is_char_at_index(self.index): - if self.skill_available(): - if self.click_skill(): - _last_e_time = time.time() - if self.ultimate_available() and time.time() - _last_e_time >= 0.5: - self.task._combat_settle.time = None - self.click_ultimate() - self.click() - else: - self.click() - nanally_key = self._get_char_key("NanallyChain") - if nanally_key: - self.task.send_key(nanally_key) - - self.sleep(0.05) - - if hotori and hotori.time_to_next_burst() <= 2.0: + if handoff: continue - hotori_key = self._get_char_key("HotoriChain") - if hotori_key: - self.task.send_key(hotori_key) - - switch_start = time.time() - while not self.task.is_char_at_index(hotori.index) and time.time() - switch_start < 1.0: - self.task.sleep_check() - self.click() - self.sleep(0.05) - - if self.task.is_char_at_index(hotori.index): - hotori_start = time.time() - while time.time() - hotori_start < 0.8: - self.task.sleep_check() - hotori.click() - hotori.sleep(0.1) - self.task.next_frame() - - nanally_key = self._get_char_key("NanallyChain") - if nanally_key: - self.task.send_key(nanally_key) - - switch_back_start = time.time() - while not self.task.is_char_at_index(self.index) and time.time() - switch_back_start < 1.0: - self.task.sleep_check() - self.click() - self.sleep(0.05) + self._switch_to_hotori_and_back(hotori) \ No newline at end of file diff --git a/src/char/ZeroChain.py b/src/char/ZeroChain.py index 6b2190a..b09c44a 100644 --- a/src/char/ZeroChain.py +++ b/src/char/ZeroChain.py @@ -4,9 +4,22 @@ class ZeroChain(Zero): + SWITCH_TIMEOUT = 1.0 # 切人超时 + Q_DEADLINE = 0.3 # Q释放截止 + E_TIMEOUT = 1.0 # E释放超时 + E_CD_CONFIRM_TIMEOUT = 0.5 # E CD确认超时 + E_CD_CONFIRM_TICK = 0.03 # E CD确认轮询间隔 + Q_CD_CONFIRM_TIMEOUT = 0.5 # Q CD确认超时 + Q_CD_CONFIRM_TICK = 0.03 # Q CD确认轮询间隔 + CHAIN_NA_INTERVAL = 0.2 # 链内普攻间隔 + NA_DURATION = 0.3 # 普攻持续 + NA_TICK = 0.1 # 普攻轮询间隔 + LOOP_TICK = 0.05 # 通用轮询间隔 + INTRO_LOOP_INTERVAL = 0.1 # 入场动画轮询间隔 + def do_perform(self): if self.task.chain_executor.active: - self.continues_normal_attack(0.2) + self.continues_normal_attack(self.CHAIN_NA_INTERVAL) return self._do_perform_legacy() @@ -14,15 +27,31 @@ def _do_perform_legacy(self): self.wait_intro() self.click_ultimate() self.click_skill() - self.continues_normal_attack(0.5, interval=0.01) + self.continues_normal_attack(self.NA_DURATION, interval=self.NA_TICK) + + def _confirm_e_cd(self): + start = time.time() + while time.time() - start < self.E_CD_CONFIRM_TIMEOUT: + if self.has_cd("skill"): + return True + self.sleep(self.E_CD_CONFIRM_TICK) + return False + + def _confirm_q_cd(self): + start = time.time() + while time.time() - start < self.Q_CD_CONFIRM_TIMEOUT: + if self.has_cd("ultimate"): + return True + self.sleep(self.Q_CD_CONFIRM_TICK) + return False def chain_q_e_wait(self): wait_start = time.time() - while time.time() - wait_start < 1.0: + while time.time() - wait_start < self.SWITCH_TIMEOUT: self.task.sleep_check() if self.task.is_char_at_index(self.index): break - self.sleep(0.05) + self.sleep(self.LOOP_TICK) if self.has_intro: start = time.time() @@ -30,36 +59,36 @@ def chain_q_e_wait(self): self.click() if self.ultimate_available(): break - self.sleep(0.1) + self.sleep(self.INTRO_LOOP_INTERVAL) - q_deadline = time.time() + 0.3 + q_deadline = time.time() + self.Q_DEADLINE self.task._combat_settle.time = None while time.time() < q_deadline: self.task.sleep_check() self.click() if self.ultimate_available(): - if self.click_ultimate(send_click=True): + if self.click_ultimate(send_click=True) and self._confirm_q_cd(): break - self.sleep(0.05) + self.sleep(self.LOOP_TICK) while True: self.task.sleep_check() clicked, _, _ = self.click_skill() - if clicked: + if clicked and self._confirm_e_cd(): self.task.chain_executor.step_complete() self._send_chain_key() self.switch_next_char() return self.click() - self.sleep(0.05) + self.sleep(self.LOOP_TICK) def chain_nop(self): wait_start = time.time() - while time.time() - wait_start < 1.0: + while time.time() - wait_start < self.SWITCH_TIMEOUT: self.task.sleep_check() if self.task.is_char_at_index(self.index): break - self.sleep(0.05) + self.sleep(self.LOOP_TICK) self.task.chain_executor.step_complete() self._send_chain_key() @@ -67,11 +96,11 @@ def chain_nop(self): def chain_e_only(self): wait_start = time.time() - while time.time() - wait_start < 1.0: + while time.time() - wait_start < self.SWITCH_TIMEOUT: self.task.sleep_check() if self.task.is_char_at_index(self.index): break - self.sleep(0.05) + self.sleep(self.LOOP_TICK) if self.has_intro: start = time.time() @@ -79,16 +108,17 @@ def chain_e_only(self): self.click() if self.skill_available(): break - self.sleep(0.1) + self.sleep(self.INTRO_LOOP_INTERVAL) - deadline = time.time() + 5 + deadline = time.time() + self.E_TIMEOUT while time.time() < deadline: self.task.sleep_check() self.click() if self.skill_available(): self.click_skill() - break - self.sleep(0.05) + if self._confirm_e_cd(): + break + self.sleep(self.LOOP_TICK) self.task.chain_executor.step_complete() self._send_chain_key() diff --git a/src/combat/ChainExecutor.py b/src/combat/ChainExecutor.py index c120123..3ebef13 100644 --- a/src/combat/ChainExecutor.py +++ b/src/combat/ChainExecutor.py @@ -43,7 +43,7 @@ def _start(self, steps): self.steps = steps self.current_index = 0 self.active = True - first_char, first_method = steps[0] + first_char, first_method = self.target first_char._chain_method = first_method names = [(c.__class__.__name__, m) for c, m in steps] self.task.log_info(f"start_chain with {len(steps)} steps: {names}") @@ -70,7 +70,7 @@ def step_complete(self): self.active = False self.task.log_info("chain finished, waiting for anchor restart") return - next_char, next_method = self.steps[self.current_index] + next_char, next_method = self.target next_char._chain_method = next_method self.task.log_info( f"chain step {self.current_index}: -> {next_char.__class__.__name__}.{next_method}" From 5e63e5aa64be0e5df387aaedc2c4c46ccd547b80 Mon Sep 17 00:00:00 2001 From: Wattls Date: Thu, 28 May 2026 02:09:14 +0800 Subject: [PATCH 07/12] =?UTF-8?q?refactor:=20=E5=B0=81=E8=A3=85=E9=93=BE?= =?UTF-8?q?=E6=88=98=E6=96=97=E7=A7=81=E6=9C=89=E5=B1=9E=E6=80=A7=E4=B8=BA?= =?UTF-8?q?=E5=85=AC=E5=85=B1=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/char/BaseChar.py | 5 ++++- src/char/HotoriChain.py | 16 +++++++++++++--- src/char/JiuyuanChain.py | 2 +- src/char/NanallyChain.py | 4 ++-- src/char/ZeroChain.py | 2 +- src/combat/BaseCombatTask.py | 3 +++ src/combat/ChainExecutor.py | 7 +++++-- 7 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/char/BaseChar.py b/src/char/BaseChar.py index eaf6cec..d922af3 100644 --- a/src/char/BaseChar.py +++ b/src/char/BaseChar.py @@ -122,6 +122,9 @@ def __eq__(self, other): return self.name == other.name and self.index == other.index return False + def set_chain_action(self, method_name): + self._chain_method = method_name + def perform(self): """执行当前角色的主要战斗行动序列。""" self.last_perform = time.time() @@ -908,7 +911,7 @@ def _send_chain_key(self): self.task.send_key(target_char.index + 1) return True else: - anchor = getattr(self.task.chain_executor, '_pending_anchor', None) + anchor = self.task.chain_executor._pending_anchor if anchor is not None and anchor != self: self.task.send_key(anchor.index + 1) return True diff --git a/src/char/HotoriChain.py b/src/char/HotoriChain.py index b3d3d12..dae1971 100644 --- a/src/char/HotoriChain.py +++ b/src/char/HotoriChain.py @@ -90,7 +90,7 @@ def _build_next_chain(self): self._chain_cycle = 2 return self._build_steps("warmup") self._chain_cycle = 0 - self.task.chain_executor._pending_anchor = self + self.task.chain_executor.set_axis_anchor(self) return None def do_perform(self): @@ -200,7 +200,17 @@ def chain_e_start_chain(self): self.sleep(self.LOOP_TICK) continue else: - self.logger.info(f"chain_e_start_chain: E cast assumed (no CD but skill unavailable) after {fail_count} failures") + self.sleep(0.3) + if self.has_cd("skill"): + self.logger.info(f"chain_e_start_chain: CD confirmed after wait (ddg/interrupted) after {fail_count} failures") + elif self.skill_available(): + self.logger.warning("chain_e_start_chain: skill recovered after wait, retrying") + fail_count += 1 + last_attempt = now + self.sleep(self.LOOP_TICK) + continue + else: + self.logger.info(f"chain_e_start_chain: E cast assumed after wait ({fail_count} failures)") self._e_used = True self._e_lockdown = True self.start_team_skill_window() @@ -248,7 +258,7 @@ def chain_q_na(self): self.sleep(self.INTRO_LOOP_INTERVAL) if not q_done: self.task.sleep(self.Q_PRE_SLEEP) - self.task._combat_settle.time = None + self.task.allow_ultimate_during_settle() while True: self.task.sleep_check() if self.ultimate_available(): diff --git a/src/char/JiuyuanChain.py b/src/char/JiuyuanChain.py index e24091a..4d683e0 100644 --- a/src/char/JiuyuanChain.py +++ b/src/char/JiuyuanChain.py @@ -74,7 +74,7 @@ def chain_q_e_heavy(self): break self.sleep(self.INTRO_LOOP_INTERVAL) q_deadline = time.time() + self.Q_DEADLINE - self.task._combat_settle.time = None + self.task.allow_ultimate_during_settle() while time.time() < q_deadline: self.task.sleep_check() self.click() diff --git a/src/char/NanallyChain.py b/src/char/NanallyChain.py index d328553..7f932e7 100644 --- a/src/char/NanallyChain.py +++ b/src/char/NanallyChain.py @@ -61,7 +61,7 @@ def _try_q_after_e(self, last_e_time): while time.time() < q_deadline: self.task.sleep_check() if self.ultimate_available() and time.time() - last_e_time >= self.EQ_MIN_INTERVAL: - self.task._combat_settle.time = None + self.task.allow_ultimate_during_settle() if self.click_ultimate() and self._confirm_q_cd(): self.logger.info("Nanally Q released after E") break @@ -79,7 +79,7 @@ def _standby_loop_iteration(self, hotori, last_e_time): if self.skill_available() and self.click_skill() and self._confirm_e_cd(): last_e_time = time.time() if self.ultimate_available() and time.time() - last_e_time >= self.EQ_MIN_INTERVAL: - self.task._combat_settle.time = None + self.task.allow_ultimate_during_settle() if self.click_ultimate() and not self._confirm_q_cd(): self.logger.warning("Nanally standby Q CD not detected") self.click() diff --git a/src/char/ZeroChain.py b/src/char/ZeroChain.py index b09c44a..5ed2585 100644 --- a/src/char/ZeroChain.py +++ b/src/char/ZeroChain.py @@ -62,7 +62,7 @@ def chain_q_e_wait(self): self.sleep(self.INTRO_LOOP_INTERVAL) q_deadline = time.time() + self.Q_DEADLINE - self.task._combat_settle.time = None + self.task.allow_ultimate_during_settle() while time.time() < q_deadline: self.task.sleep_check() self.click() diff --git a/src/combat/BaseCombatTask.py b/src/combat/BaseCombatTask.py index dadecaa..99924c2 100644 --- a/src/combat/BaseCombatTask.py +++ b/src/combat/BaseCombatTask.py @@ -642,6 +642,9 @@ def unsuppress_dodge(self): if ctx and ctx.trigger: ctx.trigger.dodge_suppressed = False + def allow_ultimate_during_settle(self): + self._combat_settle.time = None + def _apply_sound_config(self): if self.sound_config: enable = self.sound_config.get("Enable Sound Trigger", True) diff --git a/src/combat/ChainExecutor.py b/src/combat/ChainExecutor.py index 3ebef13..97cddaf 100644 --- a/src/combat/ChainExecutor.py +++ b/src/combat/ChainExecutor.py @@ -25,6 +25,9 @@ def reset(self): self._builder = None self._pending_anchor = None + def set_axis_anchor(self, char): + self._pending_anchor = char + def loop(self, builder): import time self._builder = builder @@ -44,7 +47,7 @@ def _start(self, steps): self.current_index = 0 self.active = True first_char, first_method = self.target - first_char._chain_method = first_method + first_char.set_chain_action(first_method) names = [(c.__class__.__name__, m) for c, m in steps] self.task.log_info(f"start_chain with {len(steps)} steps: {names}") @@ -71,7 +74,7 @@ def step_complete(self): self.task.log_info("chain finished, waiting for anchor restart") return next_char, next_method = self.target - next_char._chain_method = next_method + next_char.set_chain_action(next_method) self.task.log_info( f"chain step {self.current_index}: -> {next_char.__class__.__name__}.{next_method}" ) From bbc8eaf910419d01f4fcbafb9c9359afe7842a7d Mon Sep 17 00:00:00 2001 From: Wattls Date: Thu, 28 May 2026 03:13:19 +0800 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20=5Fpending=5Fanchor=20=E6=94=B9?= =?UTF-8?q?=E5=85=AC=E5=85=B1=E5=B1=9E=E6=80=A7=E6=B6=88=E9=99=A4=E5=8F=AF?= =?UTF-8?q?=E9=9D=A0=E6=80=A7=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/char/BaseChar.py | 2 +- src/combat/ChainExecutor.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/char/BaseChar.py b/src/char/BaseChar.py index d922af3..cc028b1 100644 --- a/src/char/BaseChar.py +++ b/src/char/BaseChar.py @@ -911,7 +911,7 @@ def _send_chain_key(self): self.task.send_key(target_char.index + 1) return True else: - anchor = self.task.chain_executor._pending_anchor + anchor = self.task.chain_executor.pending_anchor if anchor is not None and anchor != self: self.task.send_key(anchor.index + 1) return True diff --git a/src/combat/ChainExecutor.py b/src/combat/ChainExecutor.py index 97cddaf..983fbcc 100644 --- a/src/combat/ChainExecutor.py +++ b/src/combat/ChainExecutor.py @@ -28,6 +28,10 @@ def reset(self): def set_axis_anchor(self, char): self._pending_anchor = char + @property + def pending_anchor(self): + return self._pending_anchor + def loop(self, builder): import time self._builder = builder From d3abf37a5e5c168afe0c7189d67be49a5cf1637b Mon Sep 17 00:00:00 2001 From: Wattls Date: Thu, 28 May 2026 03:34:03 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20BaseCombatTask.=5Fpending=5Fanchor?= =?UTF-8?q?=20=E6=94=B9=E7=94=A8=E5=85=AC=E5=85=B1=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/combat/BaseCombatTask.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/combat/BaseCombatTask.py b/src/combat/BaseCombatTask.py index 99924c2..1629bfc 100644 --- a/src/combat/BaseCombatTask.py +++ b/src/combat/BaseCombatTask.py @@ -488,11 +488,11 @@ def switch_next_char(self, current_char: "BaseChar", post_action=None, free_intr else: return else: - anchor = getattr(self.chain_executor, '_pending_anchor', None) + anchor = self.chain_executor.pending_anchor if anchor is not None and anchor != current_char: switch_to = anchor has_intro = free_intro or (switch_to.element != current_char.element and current_char.is_cycle_full()) - self.chain_executor._pending_anchor = None + self.chain_executor.set_axis_anchor(None) else: switch_to, has_intro = self._find_switch_target(current_char, free_intro) From c7e94b89936319cfb302c5b89886de1970e96bbf Mon Sep 17 00:00:00 2001 From: Wattls Date: Thu, 28 May 2026 04:13:25 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DSonarQube?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=B4=A8=E9=87=8F=E9=97=AE=E9=A2=98=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96Hotori=20Q=E9=87=8A=E6=94=BE=E8=8A=82?= =?UTF-8?q?=E5=A5=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/char/BaseChar.py | 3 ++- src/char/Hotori.py | 1 + src/char/HotoriChain.py | 2 +- src/char/JiuyuanChain.py | 22 ++++++++++------------ src/combat/BaseCombatTask.py | 32 ++++++++++++++++++-------------- src/ui/TeamManagerTab.py | 4 ++-- 6 files changed, 34 insertions(+), 30 deletions(-) diff --git a/src/char/BaseChar.py b/src/char/BaseChar.py index cc028b1..bc095c8 100644 --- a/src/char/BaseChar.py +++ b/src/char/BaseChar.py @@ -603,7 +603,8 @@ def on_combat_end(self, chars): pass def on_chain_step_complete(self): - pass + """链式步骤完成回调;默认无操作,子类可按需覆盖。""" + return None @property def add_freeze_duration(self): diff --git a/src/char/Hotori.py b/src/char/Hotori.py index 7d12db7..c73ffa8 100644 --- a/src/char/Hotori.py +++ b/src/char/Hotori.py @@ -155,6 +155,7 @@ def on_combat_end(self, chars): def on_chain_step_complete(self): if self.team_skill_window_start > 0: + # 基类中无需处理,由子类 HotoriChain 覆盖实现 pass # def skill_available(self, check_color=True): diff --git a/src/char/HotoriChain.py b/src/char/HotoriChain.py index dae1971..2c2cfba 100644 --- a/src/char/HotoriChain.py +++ b/src/char/HotoriChain.py @@ -30,7 +30,7 @@ class HotoriChain(Hotori): CD_CONFIRM_TICK = 0.03 # CD确认轮询间隔 POST_ACTION_PAUSE = 0.1 # 动作后暂停 CHAIN_NA_INTERVAL = 0.2 # 链内普攻间隔 - Q_PRE_SLEEP = 1.0 # Q前等待 + Q_PRE_SLEEP = 0.3 # Q前等待 Q_RECOVERY_MARGIN = 0.3 # Q恢复余量 LOOP_TICK = 0.05 # 通用轮询间隔 INTRO_LOOP_INTERVAL = 0.1 # 入场动画轮询间隔 diff --git a/src/char/JiuyuanChain.py b/src/char/JiuyuanChain.py index 4d683e0..3564e25 100644 --- a/src/char/JiuyuanChain.py +++ b/src/char/JiuyuanChain.py @@ -84,15 +84,13 @@ def chain_q_e_heavy(self): break self.logger.warning("chain_q_e_heavy: Q clicked but CD not detected, retrying") self.sleep(self.LOOP_TICK) - while True: - self.task.sleep_check() - clicked, _, _ = self.click_skill() - if clicked and self._confirm_e_cd(): - self.sleep(self.E_POST_CAST_WAIT) - self.task.mouse_down() - self.sleep(self.HEAVY_CHARGE_TIME) - self.task.mouse_up() - self.task.chain_executor.step_complete() - self._send_chain_key() - self.switch_next_char() - return + self.task.sleep_check() + clicked, _, _ = self.click_skill() + if clicked and self._confirm_e_cd(): + self.sleep(self.E_POST_CAST_WAIT) + self.task.mouse_down() + self.sleep(self.HEAVY_CHARGE_TIME) + self.task.mouse_up() + self.task.chain_executor.step_complete() + self._send_chain_key() + self.switch_next_char() diff --git a/src/combat/BaseCombatTask.py b/src/combat/BaseCombatTask.py index 1629bfc..18ada40 100644 --- a/src/combat/BaseCombatTask.py +++ b/src/combat/BaseCombatTask.py @@ -467,6 +467,20 @@ def _switch_to_char( logger.info(f"{log_prefix} end {(time.time() - start_time):.3f}s") + def _resolve_switch_target(self, current_char, free_intro): + if self.chain_executor.active: + switch_to, _ = self.chain_executor.target + if switch_to is not None and switch_to != current_char: + has_intro = free_intro or (switch_to.element != current_char.element and current_char.is_cycle_full()) + return switch_to, has_intro + return None + anchor = self.chain_executor.pending_anchor + if anchor is not None and anchor != current_char: + has_intro = free_intro or (anchor.element != current_char.element and current_char.is_cycle_full()) + self.chain_executor.set_axis_anchor(None) + return anchor, has_intro + return self._find_switch_target(current_char, free_intro) + def switch_next_char(self, current_char: "BaseChar", post_action=None, free_intro=False): """切换到下一个最优角色。 @@ -481,20 +495,10 @@ def switch_next_char(self, current_char: "BaseChar", post_action=None, free_intr current_char.wait_switch_cd() - if self.chain_executor.active: - switch_to, _ = self.chain_executor.target - if switch_to is not None and switch_to != current_char: - has_intro = free_intro or (switch_to.element != current_char.element and current_char.is_cycle_full()) - else: - return - else: - anchor = self.chain_executor.pending_anchor - if anchor is not None and anchor != current_char: - switch_to = anchor - has_intro = free_intro or (switch_to.element != current_char.element and current_char.is_cycle_full()) - self.chain_executor.set_axis_anchor(None) - else: - switch_to, has_intro = self._find_switch_target(current_char, free_intro) + result = self._resolve_switch_target(current_char, free_intro) + if result is None: + return + switch_to, has_intro = result if switch_to is None or switch_to == current_char: logger.warning(f"{current_char} failed to find a valid switch target") diff --git a/src/ui/TeamManagerTab.py b/src/ui/TeamManagerTab.py index 468fd37..dd602e6 100644 --- a/src/ui/TeamManagerTab.py +++ b/src/ui/TeamManagerTab.py @@ -398,7 +398,7 @@ def __init__(self, manager: CustomCharManager = None, owner=None): self.strategy_header = QHBoxLayout() self.strategy_header_text = QVBoxLayout() self.strategy_title = SubtitleLabel(og.app.tr("全局连招策略")) - self.strategy_desc = BodyLabel(og.app.tr("选择全局连招策略,将覆盖所有角色的独立连招配置")) + self.strategy_desc = BodyLabel(og.app.tr("全局连招策略:覆盖角色独立配置,无需固定角色位置,不匹配时回退通用脚本")) self.strategy_header_text.addWidget(self.strategy_title) self.strategy_header_text.addWidget(self.strategy_desc) self.strategy_header.addLayout(self.strategy_header_text, 1) @@ -406,7 +406,7 @@ def __init__(self, manager: CustomCharManager = None, owner=None): self.strategy_combo = ComboBox(self) self.strategy_combo.setMinimumWidth(200) self.strategy_combo.addItem(og.app.tr("默认自由战斗"), userData="NONE") - self.strategy_combo.addItem(og.app.tr("浔-零-九原-娜莉 浔创生链式轴"), userData="HOTORI_CREATION_CHAIN") + self.strategy_combo.addItem(og.app.tr("浔-零-九原-娜娜莉 浔创生链式轴"), userData="HOTORI_CREATION_CHAIN") self.strategy_header.addWidget(self.strategy_combo) self.strategy_layout.addLayout(self.strategy_header) self.vbox.addWidget(self.strategy_card) From 8da66e92d340c2a2f222e189a5303bc02f918c99 Mon Sep 17 00:00:00 2001 From: Wattls Date: Thu, 28 May 2026 04:40:37 +0800 Subject: [PATCH 11/12] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4Hotori?= =?UTF-8?q?=E5=92=8CNanally=E5=86=97=E4=BD=99=E7=9A=84Q=20CD=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/char/HotoriChain.py | 12 ++---------- src/char/NanallyChain.py | 15 ++------------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/src/char/HotoriChain.py b/src/char/HotoriChain.py index 2c2cfba..dc0709f 100644 --- a/src/char/HotoriChain.py +++ b/src/char/HotoriChain.py @@ -137,14 +137,6 @@ def _confirm_skill_cd(self): self.sleep(self.CD_CONFIRM_TICK) return False - def _confirm_q_cd(self): - start = time.time() - while time.time() - start < self.CD_CONFIRM_TIMEOUT: - if self.has_cd("ultimate"): - return True - self.sleep(self.CD_CONFIRM_TICK) - return False - def chain_e_start_chain(self): self.logger.info(f"chain_e_start_chain: entering, has_intro={self.has_intro}") wait_start = time.time() @@ -251,7 +243,7 @@ def chain_q_na(self): start = time.time() while time.time() - start < self.INTRO_MOTION_FREEZE_DURATION: self.click() - if self.ultimate_available() and self.click_ultimate(send_click=True) and self._confirm_q_cd(): + if self.ultimate_available() and self.click_ultimate(send_click=True): q_done = True self._q_cast_time = time.time() break @@ -262,7 +254,7 @@ def chain_q_na(self): while True: self.task.sleep_check() if self.ultimate_available(): - if self.click_ultimate(send_click=True) and self._confirm_q_cd(): + if self.click_ultimate(send_click=True): self._q_cast_time = time.time() break self.click() diff --git a/src/char/NanallyChain.py b/src/char/NanallyChain.py index 7f932e7..d4b4646 100644 --- a/src/char/NanallyChain.py +++ b/src/char/NanallyChain.py @@ -12,8 +12,6 @@ class NanallyChain(Nanally): LOOP_TICK = 0.05 # 通用轮询间隔 E_CD_CONFIRM_TIMEOUT = 0.5 # E CD确认超时 E_CD_CONFIRM_TICK = 0.03 # E CD确认轮询间隔 - Q_CD_CONFIRM_TIMEOUT = 0.5 # Q CD确认超时 - Q_CD_CONFIRM_TICK = 0.03 # Q CD确认轮询间隔 def do_perform(self): if self.task.chain_executor.active: @@ -31,14 +29,6 @@ def _confirm_e_cd(self): self.sleep(self.E_CD_CONFIRM_TICK) return False - def _confirm_q_cd(self): - start = time.time() - while time.time() - start < self.Q_CD_CONFIRM_TIMEOUT: - if self.has_cd("ultimate"): - return True - self.sleep(self.Q_CD_CONFIRM_TICK) - return False - def _try_initial_e_release(self): _last_e_time = 0 if not self.has_cd("skill"): @@ -62,7 +52,7 @@ def _try_q_after_e(self, last_e_time): self.task.sleep_check() if self.ultimate_available() and time.time() - last_e_time >= self.EQ_MIN_INTERVAL: self.task.allow_ultimate_during_settle() - if self.click_ultimate() and self._confirm_q_cd(): + if self.click_ultimate(): self.logger.info("Nanally Q released after E") break self.click() @@ -80,8 +70,7 @@ def _standby_loop_iteration(self, hotori, last_e_time): last_e_time = time.time() if self.ultimate_available() and time.time() - last_e_time >= self.EQ_MIN_INTERVAL: self.task.allow_ultimate_during_settle() - if self.click_ultimate() and not self._confirm_q_cd(): - self.logger.warning("Nanally standby Q CD not detected") + self.click_ultimate() self.click() else: self.click() From 0b9237045dd49bdc251866ba2f21989d7f653e3c Mon Sep 17 00:00:00 2001 From: Litrim Date: Fri, 29 May 2026 23:02:10 +0800 Subject: [PATCH 12/12] =?UTF-8?q?fix(ui):=20=E6=96=B0=E5=A2=9E=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E5=85=B3=E8=81=94=E8=A7=92=E8=89=B2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 get_all_character_names() 合并内置+自定义角色名 - 新增重新关联角色功能 --- src/char/custom/CustomCharManager.py | 19 ++++++++ src/ui/TeamManagerTab.py | 73 +++++++++++++++++++++++----- 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/char/custom/CustomCharManager.py b/src/char/custom/CustomCharManager.py index db1b430..608d3b6 100644 --- a/src/char/custom/CustomCharManager.py +++ b/src/char/custom/CustomCharManager.py @@ -700,6 +700,25 @@ def get_all_characters(self): characters[char_name] = char_data return characters + def get_all_character_names(self): + with self._data_lock: + names = set() + for char_id, char_data in self.db["characters"].items(): + if isinstance(char_data, dict): + names.add(self._character_name_from_record(char_id, char_data)) + else: + name = self._normalize_char_name(char_id) + if name: + names.add(name) + + from src.char.custom.BuiltinComboRegistry import BuiltinComboRegistry + + for key, meta in BuiltinComboRegistry._get_builtin_entries().items(): + if isinstance(meta, dict) and meta.get("cn_name"): + names.add(meta["cn_name"]) + + return sorted(names) + def get_character_combo_ref(self, char_name: str) -> str: info = self.get_character_info(char_name) or {} return self.to_combo_ref(info.get("combo_ref", "")) diff --git a/src/ui/TeamManagerTab.py b/src/ui/TeamManagerTab.py index dd602e6..153f17f 100644 --- a/src/ui/TeamManagerTab.py +++ b/src/ui/TeamManagerTab.py @@ -22,6 +22,9 @@ TransparentToolButton, ) +import threading +import time + from src.char.custom.CustomCharManager import CustomCharManager from src.tasks.trigger.AutoCombatTask import AutoCombatTask, scanner_signals from src.ui.common import ( @@ -33,6 +36,8 @@ ) + + def tr_fmt(text_id, **kwargs): t = og.app.tr(text_id) for k, v in kwargs.items(): @@ -64,7 +69,7 @@ def __init__(self, mat, manager: CustomCharManager, parent=None): ) self.viewLayout.addWidget(img_label, alignment=Qt.AlignmentFlag.AlignCenter) - self.existing_chars = list(self.manager.get_all_characters().keys()) + self.existing_chars = list(self.manager.get_all_character_names()) self.char_combo = SearchableComboBox() self.char_combo.setPlaceholderText(self.tr_name_ph) self.char_combo.addItems([""] + self.existing_chars) @@ -128,7 +133,7 @@ def __init__(self, index, manager: CustomCharManager, parent=None): self.vbox = QVBoxLayout(self) self.title = SubtitleLabel(self.tr_slot_title.format(index + 1)) self.image = ImageLabel() - self.image.setFixedSize(120, 120) + self.image.setFixedSize(120, 80) self.status = BodyLabel(self.tr_scan_prompt) self.btn_act = PrimaryPushButton(self.tr_action_btn, self) self.btn_act.hide() @@ -137,6 +142,7 @@ def __init__(self, index, manager: CustomCharManager, parent=None): self.vbox.addWidget(self.image, alignment=Qt.AlignmentFlag.AlignCenter) self.vbox.addWidget(self.status, alignment=Qt.AlignmentFlag.AlignCenter) self.vbox.addWidget(self.btn_act, alignment=Qt.AlignmentFlag.AlignCenter) + self.vbox.addSpacing(2) self.btn_act.clicked.connect(self.on_action) self.current_mat = None @@ -152,13 +158,13 @@ def update_result(self, mat, w, h, match_name): self.image.setImage( pixmap.scaled( 120, - 120, + 80, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) ) else: - empty_pixmap = QPixmap(120, 120) + empty_pixmap = QPixmap(120, 80) empty_pixmap.fill(Qt.GlobalColor.transparent) self.image.setImage(empty_pixmap) @@ -220,6 +226,7 @@ def __init__(self, index, manager: CustomCharManager, parent=None): self.combo_list.setPlaceholderText(self.tr_combo_ph) self.vbox.addWidget(self.title, alignment=Qt.AlignmentFlag.AlignCenter) + self.vbox.addSpacing(6) self.vbox.addWidget(self.char_combo) self.vbox.addWidget(self.combo_list) @@ -252,7 +259,7 @@ def reload_options(self): self.char_combo.blockSignals(True) self.char_combo.clear() self.char_combo.addItem("") - for name in self.manager.get_all_characters().keys(): + for name in self.manager.get_all_character_names(): self.char_combo.addItem(name) self.char_combo.setCurrentText(current_char) self.char_combo.blockSignals(False) @@ -302,7 +309,12 @@ def __init__(self, manager: CustomCharManager = None, owner=None): self._executor = None self.tr_scan_btn = og.app.tr("扫描队伍") self.tr_scanning = og.app.tr("扫描中...") + self.tr_rematch_btn = og.app.tr("重新关联角色") + self.tr_rematch_confirm_title = og.app.tr("确认重新关联") + self.tr_rematch_confirm_desc = og.app.tr("重新关联将清空当前匹配到的4位角色归属,你可以重新修正角色归属。") + self.tr_rematch_disabled_tooltip = og.app.tr("请先扫描队伍后再重新关联角色") self.tr_no_feature = og.app.tr("未获取到特征") + self.tr_scan_prompt = og.app.tr("点击上方按钮扫描...") self.tr_name_tab = TEAM_MANAGEMENT self.tr_scan_desc = og.app.tr("不扫描也可自动战斗,将使用通用脚本") self.tr_fixed_team_title = og.app.tr("固定队伍") @@ -351,6 +363,7 @@ def __init__(self, manager: CustomCharManager = None, owner=None): self.manager = manager or CustomCharManager() self.icon = FluentIcon.CAMERA self.last_scan_results = [] + self._capture_started = False self.logger.info("Init TeamManagerTab") self.vbox = QVBoxLayout(self) @@ -371,6 +384,12 @@ def __init__(self, manager: CustomCharManager = None, owner=None): self.scan_header.addWidget(self.scan_info_btn, alignment=Qt.AlignmentFlag.AlignLeft) self.scan_header.addStretch(1) + self.rematch_btn = PrimaryPushButton(FluentIcon.UPDATE, self.tr_rematch_btn) + self.rematch_btn.clicked.connect(self.on_rematch_clicked) + self.rematch_btn.setEnabled(False) + self.rematch_btn.setToolTip(self.tr_rematch_disabled_tooltip) + self.scan_header.addWidget(self.rematch_btn) + self.scan_btn = PrimaryPushButton(FluentIcon.SYNC, self.tr_scan_btn) self.scan_btn.clicked.connect(self.on_scan_clicked) self.scan_header.addWidget(self.scan_btn) @@ -562,16 +581,22 @@ def on_strategy_changed(self, index): self._show_bar(og.app.tr("策略已切换"), og.app.tr("已关闭全局连招策略")) def on_scan_clicked(self): - og.app.start_controller.handler.post(self.scan_team) - - def scan_team(self): - og.app.start_controller.do_start() self.scan_btn.setEnabled(False) self.scan_btn.setText(self.tr_scanning) + self.rematch_btn.setEnabled(False) + self.rematch_btn.setToolTip(self.tr_rematch_disabled_tooltip) for card in self.slots: - # card.status.setText(self.tr_analyzing) card.btn_act.hide() - self.get_task(AutoCombatTask).scan_team() + + if not self._capture_started: + self._capture_started = True + threading.Thread(target=og.app.start_controller.do_start, daemon=True).start() + + def _scan_worker(): + time.sleep(0.5) + self.get_task(AutoCombatTask).scan_team() + + threading.Thread(target=_scan_worker, daemon=True).start() def on_fill_from_scan(self): if not self.last_scan_results: @@ -620,10 +645,36 @@ def on_clear_fixed_team(self): char_manager_signals.refresh_tab.emit() self._show_bar(self.tr_clear_success_title, self.tr_clear_success_desc) + def on_rematch_clicked(self): + if not self.last_scan_results: + self._show_bar( + self.tr_rematch_confirm_title, + self.tr_rematch_disabled_tooltip, + success=False, + ) + return + + dialog = MessageBoxBase(self.window()) + dialog.viewLayout.addWidget(SubtitleLabel(self.tr_rematch_confirm_title)) + content_label = BodyLabel(self.tr_rematch_confirm_desc) + content_label.setWordWrap(True) + dialog.viewLayout.addWidget(content_label) + dialog.yesButton.setText(og.app.tr("确认重新关联")) + dialog.cancelButton.setText(og.app.tr("取消")) + if not dialog.exec(): + return + + for res in self.last_scan_results: + res["match"] = None + + self.on_scan_done(self.last_scan_results) + def on_scan_done(self, results, error_msg=""): self.last_scan_results = results or [] self.scan_btn.setEnabled(True) self.scan_btn.setText(self.tr_scan_btn) + self.rematch_btn.setEnabled(True) + self.rematch_btn.setToolTip("") if error_msg: self._show_bar("", error_msg, success=False)