diff --git a/assets/share/map/bigmap/OCR_PLANE.png b/assets/share/map/bigmap/OCR_PLANE.png new file mode 100644 index 000000000..68e439f72 Binary files /dev/null and b/assets/share/map/bigmap/OCR_PLANE.png differ diff --git a/assets/share/map/bigmap/PLANE_SCROLL.png b/assets/share/map/bigmap/PLANE_SCROLL.png new file mode 100644 index 000000000..fedf9f66e Binary files /dev/null and b/assets/share/map/bigmap/PLANE_SCROLL.png differ diff --git a/assets/share/map/bigmap/WORLD_HERTA.png b/assets/share/map/bigmap/WORLD_HERTA.png new file mode 100644 index 000000000..294f90a09 Binary files /dev/null and b/assets/share/map/bigmap/WORLD_HERTA.png differ diff --git a/assets/share/map/bigmap/WORLD_JARILO.png b/assets/share/map/bigmap/WORLD_JARILO.png new file mode 100644 index 000000000..6951c6184 Binary files /dev/null and b/assets/share/map/bigmap/WORLD_JARILO.png differ diff --git a/assets/share/map/bigmap/WORLD_LUOFU.png b/assets/share/map/bigmap/WORLD_LUOFU.png new file mode 100644 index 000000000..5591dee71 Binary files /dev/null and b/assets/share/map/bigmap/WORLD_LUOFU.png differ diff --git a/dev_tools/keyword_extract.py b/dev_tools/keyword_extract.py index 21079aabe..c5cf0bf6d 100644 --- a/dev_tools/keyword_extract.py +++ b/dev_tools/keyword_extract.py @@ -119,37 +119,51 @@ class KeywordExtract: def load_keywords(self, keywords: list[str | int], lang='cn'): text_map = self.text_map[lang] - self.keywords_id = [text_map.find(keyword)[0] for keyword in keywords] - self.keywords_id = [keyword for keyword in self.keywords_id if keyword != 0] + keywords_id = [text_map.find(keyword)[0] for keyword in keywords] + self.keywords_id = [keyword for keyword in keywords_id if keyword != 0] + + def clear_keywords(self): + self.keywords_id = [] def write_keywords( self, keyword_class, - output_file: str, + output_file: str = '', text_convert=text_to_variable, + generator: CodeGenerator = None ): """ Args: keyword_class: output_file: text_convert: + generator: Reuse an existing code generator """ - gen = CodeGenerator() - gen.Import(f""" - from .classes import {keyword_class} - """) - gen.CommentAutoGenerage('dev_tools.keyword_extract') + if generator is None: + gen = CodeGenerator() + gen.Import(f""" + from .classes import {keyword_class} + """) + gen.CommentAutoGenerage('dev_tools.keyword_extract') + else: + gen = generator + + last_id = getattr(gen, 'last_id', 0) for index, keyword in enumerate(self.keywords_id): _, name = self.find_keyword(keyword, lang='en') name = text_convert(replace_templates(name)) with gen.Object(key=name, object_class=keyword_class): - gen.ObjectAttr(key='id', value=index + 1) + gen.ObjectAttr(key='id', value=index + last_id + 1) gen.ObjectAttr(key='name', value=name) for lang in UI_LANGUAGES: gen.ObjectAttr(key=lang, value=replace_templates(self.find_keyword(keyword, lang=lang)[1])) + gen.last_id = index + last_id + 1 - print(f'Write {output_file}') - gen.write(output_file) + if output_file: + print(f'Write {output_file}') + gen.write(output_file) + self.clear_keywords() + return gen def load_daily_quests_keywords(self, lang='cn'): daily_quest = read_file(os.path.join(TextMap.DATA_FOLDER, 'ExcelOutput', 'DailyQuest.json')) @@ -176,6 +190,7 @@ class KeywordExtract: print(f'Write {output_file}') gen.write(output_file) + self.clear_keywords() def generate_assignment_keywords(self): KeywordFromFile = namedtuple('KeywordFromFile', ('file', 'class_name', 'output_file')) @@ -187,6 +202,29 @@ class KeywordExtract: self.load_keywords(deep_get(data, 'Name.Hash') for data in read_file(file).values()) self.write_keywords(keyword_class=keyword.class_name, output_file=keyword.output_file) + def generate_map_planes(self): + planes = { + 'Herta': ['观景车厢', '主控舱段', '基座舱段', '收容舱段', '支援舱段'], + 'Jarilo': ['行政区', '城郊雪原', '边缘通路', '铁卫禁区', '残响回廊', '永冬岭', + '磐岩镇', '大矿区', '铆钉镇', '机械聚落'], + 'Luofu': ['星槎海中枢', '长乐天', '流云渡', '迴星港', '太卜司', '工造司'], + } + + def text_convert(world_): + def text_convert_wrapper(name): + name = text_to_variable(name).replace('_', '') + name = f'{world_}_{name}' + return name + + return text_convert_wrapper + + gen = None + for world, plane in planes.items(): + self.load_keywords(plane) + gen = self.write_keywords(keyword_class='MapPlane', output_file='', + text_convert=text_convert(world), generator=gen) + gen.write('./tasks/map/keywords/plane.py') + def generate(self): self.load_keywords(['模拟宇宙', '拟造花萼(金)', '拟造花萼(赤)', '凝滞虚影', '侵蚀隧洞', '历战余响', '忘却之庭']) self.write_keywords(keyword_class='DungeonNav', output_file='./tasks/dungeon/keywords/nav.py') @@ -205,6 +243,7 @@ class KeywordExtract: self.write_keywords(keyword_class='BattlePassTab', output_file='./tasks/battle_pass/keywords/tab.py') self.generate_assignment_keywords() self.generate_forgotten_hall_stages() + self.generate_map_planes() if __name__ == '__main__': diff --git a/module/ocr/keyword.py b/module/ocr/keyword.py index 1781ca6c1..4891d6c3d 100644 --- a/module/ocr/keyword.py +++ b/module/ocr/keyword.py @@ -6,7 +6,7 @@ from typing import ClassVar from module.exception import ScriptError import module.config.server as server -REGEX_PUNCTUATION = re.compile(r'[ ,.\'",。·•\-—/\\\n\t()()「」『』【】]') +REGEX_PUNCTUATION = re.compile(r'[ ,.\'"“”,。·•\-—/\\\n\t()()「」『』【】]') def parse_name(n): diff --git a/module/ui/draggable_list.py b/module/ui/draggable_list.py index b9540f877..89c38a2ba 100644 --- a/module/ui/draggable_list.py +++ b/module/ui/draggable_list.py @@ -190,20 +190,32 @@ class DraggableList: return False - def select_row(self, row: Keyword, main: ModuleBase, skip_first_screenshot=True): + def get_selected_row(self, main: ModuleBase) -> Optional[OcrResultButton]: + """ + `load_rows()` must be called before `get_selected_row()`. + """ + for row in self.cur_buttons: + if self.is_row_selected(row, main=main): + return row + return None + + def select_row(self, row: Keyword, main: ModuleBase, insight=True, skip_first_screenshot=True): """ Args: row: main: + insight: If call `insight_row()` before selecting skip_first_screenshot: Returns: If success """ - result = self.insight_row( - row, main=main, skip_first_screenshot=skip_first_screenshot) - if not result: - return False + if insight: + result = self.insight_row( + row, main=main, skip_first_screenshot=skip_first_screenshot) + if not result: + return False + logger.info(f'Select row: {row}') skip_first_screenshot = True interval = Timer(5) diff --git a/tasks/base/ui.py b/tasks/base/ui.py index 50702c4c9..3dc5241c0 100644 --- a/tasks/base/ui.py +++ b/tasks/base/ui.py @@ -1,3 +1,4 @@ +from module.base.button import ButtonWrapper from module.base.decorator import run_once from module.base.timer import Timer from module.exception import GameNotRunningError, GamePageUnknownError @@ -203,6 +204,62 @@ class UI(PopupHandler, StateMixin): self.device.click(button) retry.reset() + def ui_click( + self, + click_button, + check_button, + appear_button=None, + additional=None, + retry_wait=5, + skip_first_screenshot=True, + ): + """ + Args: + click_button (ButtonWrapper): + check_button (ButtonWrapper, callable, list[ButtonWrapper], tuple[ButtonWrapper]): + appear_button (ButtonWrapper, callable, list[ButtonWrapper], tuple[ButtonWrapper]): + additional (callable): + retry_wait (int, float): + skip_first_screenshot (bool): + """ + if appear_button is None: + appear_button = click_button + logger.info(f'UI click: {appear_button} -> {check_button}') + + def process_appear(button): + if isinstance(button, ButtonWrapper): + return self.appear(button) + elif callable(button): + return button() + elif isinstance(button, (list, tuple)): + for b in button: + if self.appear(b): + return True + return False + else: + return self.appear(button) + + click_timer = Timer(retry_wait, count=retry_wait // 0.5) + while 1: + if skip_first_screenshot: + skip_first_screenshot = False + else: + self.device.screenshot() + + # End + if process_appear(check_button): + break + + # Click + if click_timer.reached(): + if process_appear(appear_button): + self.device.click(click_button) + click_timer.reset() + continue + if additional is not None: + if additional(): + continue + def is_in_main(self): return self.appear(page_main.check_button) diff --git a/tasks/map/assets/assets_map_bigmap.py b/tasks/map/assets/assets_map_bigmap.py new file mode 100644 index 000000000..b9ea3f2cb --- /dev/null +++ b/tasks/map/assets/assets_map_bigmap.py @@ -0,0 +1,55 @@ +from module.base.button import Button, ButtonWrapper + +# This file was auto-generated, do not modify it manually. To generate: +# ``` python -m dev_tools.button_extract ``` + +OCR_PLANE = ButtonWrapper( + name='OCR_PLANE', + share=Button( + file='./assets/share/map/bigmap/OCR_PLANE.png', + area=(872, 138, 1252, 688), + search=(852, 118, 1272, 708), + color=(199, 199, 199), + button=(872, 138, 1252, 688), + ), +) +PLANE_SCROLL = ButtonWrapper( + name='PLANE_SCROLL', + share=Button( + file='./assets/share/map/bigmap/PLANE_SCROLL.png', + area=(1252, 138, 1256, 688), + search=(1232, 118, 1276, 708), + color=(103, 103, 103), + button=(1252, 138, 1256, 688), + ), +) +WORLD_HERTA = ButtonWrapper( + name='WORLD_HERTA', + share=Button( + file='./assets/share/map/bigmap/WORLD_HERTA.png', + area=(201, 332, 273, 394), + search=(181, 312, 293, 414), + color=(115, 122, 130), + button=(201, 332, 273, 394), + ), +) +WORLD_JARILO = ButtonWrapper( + name='WORLD_JARILO', + share=Button( + file='./assets/share/map/bigmap/WORLD_JARILO.png', + area=(638, 138, 706, 203), + search=(618, 118, 726, 223), + color=(104, 113, 121), + button=(638, 138, 706, 203), + ), +) +WORLD_LUOFU = ButtonWrapper( + name='WORLD_LUOFU', + share=Button( + file='./assets/share/map/bigmap/WORLD_LUOFU.png', + area=(983, 549, 1051, 612), + search=(963, 529, 1071, 632), + color=(103, 121, 105), + button=(983, 549, 1051, 612), + ), +) diff --git a/tasks/map/bigmap/plane.py b/tasks/map/bigmap/plane.py new file mode 100644 index 000000000..a9424214d --- /dev/null +++ b/tasks/map/bigmap/plane.py @@ -0,0 +1,164 @@ +import re +from typing import Optional + +from module.base.base import ModuleBase +from module.exception import ScriptError +from module.logger import logger +from module.ocr.ocr import Ocr, OcrResultButton +from module.ui.draggable_list import DraggableList +from module.ui.scroll import Scroll +from tasks.base.page import page_map, page_world +from tasks.base.ui import UI +from tasks.map.assets.assets_map_bigmap import * +from tasks.map.keywords import MapPlane, KEYWORDS_MAP_PLANE +from module.base.timer import Timer + + +def world_entrance(plane: MapPlane) -> ButtonWrapper: + if plane.is_HertaSpaceStation: + return WORLD_HERTA + if plane.is_JariloVI: + return WORLD_JARILO + if plane.is_Luofu: + return WORLD_LUOFU + raise ScriptError(f'world_entrance() got unknown plane: {plane}') + + +class OcrMapPlane(Ocr): + merge_thres_y = 20 + + def after_process(self, result): + result = super().after_process(result) + result = re.sub(r'[+→★“”,.,、。]', '', result).strip() + if self.lang == 'ch': + result = result.replace('迎星港', '迴星港') + return result + + +class DraggablePlaneList(DraggableList): + def is_row_selected(self, button: OcrResultButton, main: ModuleBase) -> bool: + # Items have an animation to be selected, check if the rightmost become black. + x = OCR_PLANE.area[2] + area = (x - 20, button.area[1], x, button.area[3]) + if main.image_color_count(area, color=(40, 40, 40), threshold=221, count=100): + return True + + return False + + +SCROLL_PLANE = Scroll(PLANE_SCROLL, color=(67, 67, 67), name='SCROLL_PLANE') +PLANE_LIST = DraggablePlaneList('PlaneList', keyword_class=MapPlane, ocr_class=OcrMapPlane, search_button=OCR_PLANE) + + +class BigmapPlane(UI): + def _bigmap_world_set(self, plane: MapPlane): + """ + Pages: + in: Any + out: page_map + """ + self.ui_goto(page_world) + self.ui_click(appear_button=page_world.check_button, + click_button=world_entrance(plane), + check_button=page_map.check_button) + + def _bigmap_get_current_plane(self) -> Optional[MapPlane]: + """ + Get current plane. + After entering page_map, the current plane is selected by default. + + Pages: + in: page_map + """ + PLANE_LIST.load_rows(main=self) + selected = PLANE_LIST.get_selected_row(main=self) + if selected is None: + return None + else: + return selected.matched_keyword + + def _bigmap_get_current_plane_wrapped(self) -> MapPlane: + """ + Get current plane with reties. + """ + for n in range(2): + self.ui_ensure(page_map) + + # Wait select animation + timeout = Timer(2).start() + skip_first_screenshot = True + while 1: + if skip_first_screenshot: + skip_first_screenshot = False + else: + self.device.screenshot() + current = self._bigmap_get_current_plane() + if current is not None: + return current + if timeout.reached(): + logger.warning('No plane was selected') + if n == 0: + # Nothing selected, probably because it has been switched to page_map before running. + # Exit and re-enter should fix it. + self.ui_goto_main() + break + + logger.error('Cannot find current plane, return the first plane in list instead') + try: + first = PLANE_LIST.cur_buttons[0].matched_keyword + if first is not None: + return first + else: + return KEYWORDS_MAP_PLANE.Herta_ParlorCar + except IndexError: + return KEYWORDS_MAP_PLANE.Herta_ParlorCar + + def bigmap_plane_set(self, plane: MapPlane): + """ + Set map ti given plane. + + Args: + plane: + + Returns: + bool: If success. + + Pages: + in: Any + out: page_map + """ + logger.info(f'Bigmap plane set: {plane}') + current = self._bigmap_get_current_plane_wrapped() + logger.attr('CurrentPlane', current) + + if current.world != plane.world: + logger.info(f'Switch to world {plane.world}') + self._bigmap_world_set(plane) + PLANE_LIST.load_rows(main=self) + + if plane.is_HertaSpaceStation: + PLANE_LIST.select_row(plane, main=self, insight=False) + return True + elif plane.is_JariloVI: + if plane in [ + KEYWORDS_MAP_PLANE.Jarilo_AdministrativeDistrict, + KEYWORDS_MAP_PLANE.Jarilo_OutlyingSnowPlains, + KEYWORDS_MAP_PLANE.Jarilo_BackwaterPass, + KEYWORDS_MAP_PLANE.Jarilo_SilvermaneGuardRestrictedZone, + KEYWORDS_MAP_PLANE.Jarilo_CorridorofFadingEchoes, + KEYWORDS_MAP_PLANE.Jarilo_EverwinterHill, + ]: + if SCROLL_PLANE.set_top(main=self): + PLANE_LIST.load_rows(main=self) + else: + if SCROLL_PLANE.set_bottom(main=self): + PLANE_LIST.load_rows(main=self) + + PLANE_LIST.select_row(plane, main=self, insight=False) + return True + elif plane.is_Luofu: + PLANE_LIST.select_row(plane, main=self, insight=False) + return True + + logger.error(f'Goto plane {plane} is not supported') + return False diff --git a/tasks/map/keywords/__init__.py b/tasks/map/keywords/__init__.py new file mode 100644 index 000000000..14bbb358a --- /dev/null +++ b/tasks/map/keywords/__init__.py @@ -0,0 +1,2 @@ +import tasks.map.keywords.plane as KEYWORDS_MAP_PLANE +from tasks.map.keywords.classes import MapPlane diff --git a/tasks/map/keywords/classes.py b/tasks/map/keywords/classes.py new file mode 100644 index 000000000..8288a9efa --- /dev/null +++ b/tasks/map/keywords/classes.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from functools import cached_property +from typing import ClassVar + +from module.ocr.keyword import Keyword + + +@dataclass(repr=False) +class MapPlane(Keyword): + instances: ClassVar = {} + + @cached_property + def world(self) -> str: + """ + Returns: + str: World name. Note that "Parlor Car" is considered as a plane of Herta. + "Herta" for Herta Space Station + "Jarilo" for Jarilo-VI + "Luofu" for The Xianzhou Luofu + "" for unknown + """ + for world in ['Herta', 'Jarilo', 'Luofu']: + if self.name.startswith(world): + return world + + return '' + + @cached_property + def is_HertaSpaceStation(self): + return self.world == 'Herta' + + @cached_property + def is_JariloVI(self): + return self.world == 'Jarilo' + + @cached_property + def is_Luofu(self): + return self.world == 'Luofu' diff --git a/tasks/map/keywords/plane.py b/tasks/map/keywords/plane.py new file mode 100644 index 000000000..7c607102b --- /dev/null +++ b/tasks/map/keywords/plane.py @@ -0,0 +1,173 @@ +from .classes import MapPlane + +# This file was auto-generated, do not modify it manually. To generate: +# ``` python -m dev_tools.keyword_extract ``` + +Herta_ParlorCar = MapPlane( + id=1, + name='Herta_ParlorCar', + cn='观景车厢', + cht='觀景車廂', + en='Parlor Car', + jp='列車のラウンジ', +) +Herta_MasterControlZone = MapPlane( + id=2, + name='Herta_MasterControlZone', + cn='主控舱段', + cht='主控艙段', + en='Master Control Zone', + jp='主制御部分', +) +Herta_BaseZone = MapPlane( + id=3, + name='Herta_BaseZone', + cn='基座舱段', + cht='基座艙段', + en='Base Zone', + jp='ベース部分', +) +Herta_StorageZone = MapPlane( + id=4, + name='Herta_StorageZone', + cn='收容舱段', + cht='收容艙段', + en='Storage Zone', + jp='収容部分', +) +Herta_SupplyZone = MapPlane( + id=5, + name='Herta_SupplyZone', + cn='支援舱段', + cht='支援艙段', + en='Supply Zone', + jp='サポート部分', +) +Jarilo_AdministrativeDistrict = MapPlane( + id=6, + name='Jarilo_AdministrativeDistrict', + cn='行政区', + cht='行政區', + en='Administrative District', + jp='行政区', +) +Jarilo_OutlyingSnowPlains = MapPlane( + id=7, + name='Jarilo_OutlyingSnowPlains', + cn='城郊雪原', + cht='城郊雪原', + en='Outlying Snow Plains', + jp='郊外雪原', +) +Jarilo_BackwaterPass = MapPlane( + id=8, + name='Jarilo_BackwaterPass', + cn='边缘通路', + cht='邊緣通道', + en='Backwater Pass', + jp='外縁通路', +) +Jarilo_SilvermaneGuardRestrictedZone = MapPlane( + id=9, + name='Jarilo_SilvermaneGuardRestrictedZone', + cn='铁卫禁区', + cht='鐵衛禁區', + en='Silvermane Guard Restricted Zone', + jp='シルバーメイン禁区', +) +Jarilo_CorridorofFadingEchoes = MapPlane( + id=10, + name='Jarilo_CorridorofFadingEchoes', + cn='残响回廊', + cht='殘響迴廊', + en='Corridor of Fading Echoes', + jp='残響回廊', +) +Jarilo_EverwinterHill = MapPlane( + id=11, + name='Jarilo_EverwinterHill', + cn='永冬岭', + cht='永冬嶺', + en='Everwinter Hill', + jp='常冬峰', +) +Jarilo_BoulderTown = MapPlane( + id=12, + name='Jarilo_BoulderTown', + cn='磐岩镇', + cht='磐岩鎮', + en='Boulder Town', + jp='ボルダータウン', +) +Jarilo_GreatMine = MapPlane( + id=13, + name='Jarilo_GreatMine', + cn='大矿区', + cht='大礦區', + en='Great Mine', + jp='大鉱区', +) +Jarilo_RivetTown = MapPlane( + id=14, + name='Jarilo_RivetTown', + cn='铆钉镇', + cht='鉚釘鎮', + en='Rivet Town', + jp='リベットタウン', +) +Jarilo_RobotSettlement = MapPlane( + id=15, + name='Jarilo_RobotSettlement', + cn='机械聚落', + cht='機械聚落', + en='Robot Settlement', + jp='機械集落', +) +Luofu_CentralStarskiffHaven = MapPlane( + id=16, + name='Luofu_CentralStarskiffHaven', + cn='星槎海中枢', + cht='星槎海中樞', + en='Central Starskiff Haven', + jp='星槎海中枢', +) +Luofu_ExaltingSanctum = MapPlane( + id=17, + name='Luofu_ExaltingSanctum', + cn='长乐天', + cht='長樂天', + en='Exalting Sanctum', + jp='長楽天', +) +Luofu_Cloudford = MapPlane( + id=18, + name='Luofu_Cloudford', + cn='流云渡', + cht='流雲渡', + en='Cloudford', + jp='流雲渡し', +) +Luofu_StargazerNavalia = MapPlane( + id=19, + name='Luofu_StargazerNavalia', + cn='迴星港', + cht='迴星港', + en='Stargazer Navalia', + jp='廻星港', +) +Luofu_DivinationCommission = MapPlane( + id=20, + name='Luofu_DivinationCommission', + cn='太卜司', + cht='太卜司', + en='Divination Commission', + jp='太卜司', +) +Luofu_ArtisanshipCommission = MapPlane( + id=21, + name='Luofu_ArtisanshipCommission', + cn='工造司', + cht='工造司', + en='Artisanship Commission', + jp='工造司', +)