Merge pull request #101 from LmeSzinc/dev

Dev
This commit is contained in:
LmeSzinc 2023-09-23 19:45:21 +08:00 committed by GitHub
commit 7eb6f6a377
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 757 additions and 378 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -35,7 +35,7 @@
},
"Dungeon": {
"Scheduler": {
"Enable": false,
"Enable": true,
"NextRun": "2020-01-01 00:00:00",
"Command": "Dungeon",
"ServerUpdate": "04:00"
@ -64,29 +64,29 @@
},
"DailyQuest": {
"Scheduler": {
"Enable": false,
"Enable": true,
"NextRun": "2020-01-01 00:00:00",
"Command": "DailyQuest",
"ServerUpdate": "04:00"
},
"AchievableQuest": {
"Complete_1_Daily_Mission": "not_supported",
"Clear_Calyx_Golden_1_times": "not_set",
"Complete_Calyx_Crimson_1_time": "not_set",
"Clear_Stagnant_Shadow_1_times": "not_set",
"Clear_Cavern_of_Corrosion_1_times": "not_set",
"Clear_Calyx_Golden_1_times": "achievable",
"Complete_Calyx_Crimson_1_time": "achievable",
"Clear_Stagnant_Shadow_1_times": "achievable",
"Clear_Cavern_of_Corrosion_1_times": "achievable",
"In_a_single_battle_inflict_3_Weakness_Break_of_different_Types": "not_supported",
"Inflict_Weakness_Break_5_times": "not_supported",
"Defeat_a_total_of_20_enemies": "not_supported",
"Enter_combat_by_attacking_enemy_Weakness_and_win_3_times": "not_supported",
"Use_Technique_2_times": "achievable",
"Go_on_assignment_1_time": "not_set",
"Go_on_assignment_1_time": "achievable",
"Take_1_photo": "achievable",
"Destroy_3_destructible_objects": "not_supported",
"Complete_Forgotten_Hall_1_time": "not_supported",
"Complete_Forgotten_Hall_1_time": "achievable",
"Complete_Echo_of_War_1_times": "not_supported",
"Complete_1_stage_in_Simulated_Universe_Any_world": "not_supported",
"Obtain_victory_in_combat_with_support_characters_1_time": "not_set",
"Obtain_victory_in_combat_with_support_characters_1_time": "achievable",
"Use_an_Ultimate_to_deal_the_final_blow_1_time": "not_supported",
"Level_up_any_character_1_time": "not_supported",
"Level_up_any_Light_Cone_1_time": "not_supported",
@ -103,7 +103,7 @@
},
"BattlePass": {
"Scheduler": {
"Enable": false,
"Enable": true,
"NextRun": "2020-01-01 00:00:00",
"Command": "BattlePass",
"ServerUpdate": "04:00"
@ -115,7 +115,7 @@
},
"Assignment": {
"Scheduler": {
"Enable": false,
"Enable": true,
"NextRun": "2020-01-01 00:00:00",
"Command": "Assignment",
"ServerUpdate": "04:00"

View File

@ -112,7 +112,7 @@ class KeywordExtract:
def iter_guide(self) -> t.Iterable[int]:
file = os.path.join(TextMap.DATA_FOLDER, './ExcelOutput/GameplayGuideData.json')
visited = set()
# visited = set()
temp_save = ""
for data in read_file(file).values():
hash_ = deep_get(data, keys='Name.Hash')
@ -121,11 +121,14 @@ class KeywordExtract:
temp_save = hash_
continue
if '忘却之庭' in name:
if name in visited:
continue
visited.add(name)
continue
# if name in visited:
# continue
# visited.add(name)
yield hash_
yield temp_save
# 'Memory of Chaos' is not a real dungeon, but represents a group
yield '混沌回忆'
def find_keyword(self, keyword, lang) -> tuple[int, str]:
"""

View File

@ -20,8 +20,22 @@ class Filter:
self.filter = []
def load(self, string):
"""
Load a filter string, filters are connected with ">"
There are also tons of unicode characters similar to ">"
> \u003E correct
\uFF1E
\uFE65
\u203a
˃ \u02c3
\u1433
\u276F
"""
string = str(string)
self.filter_raw = [f.strip(' \t\r\n') for f in string.split('>')]
string = re.sub(r'[ \t\r\n]', '', string)
string = re.sub(r'[>﹥›˃ᐳ❯]', '>', string)
self.filter_raw = string.split('>')
self.filter = [self.parse_filter(f) for f in self.filter_raw]
def is_preset(self, filter):

View File

@ -166,7 +166,7 @@
"Scheduler": {
"Enable": {
"type": "checkbox",
"value": false,
"value": true,
"option": [
true,
false
@ -407,7 +407,7 @@
"Scheduler": {
"Enable": {
"type": "checkbox",
"value": false,
"value": true,
"option": [
true,
false
@ -827,7 +827,7 @@
"Scheduler": {
"Enable": {
"type": "checkbox",
"value": false,
"value": true,
"option": [
true,
false
@ -870,7 +870,7 @@
"Scheduler": {
"Enable": {
"type": "checkbox",
"value": false,
"value": true,
"option": [
true,
false

View File

@ -5,10 +5,17 @@
# ==================== Alas ====================
# ==================== Farm ====================
# ==================== Daily ====================
# ==================== Tools ====================
Dungeon:
Scheduler:
Enable: true
DailyQuest:
Scheduler:
Enable: true
BattlePass:
Scheduler:
Enable: true
Assignment:
Scheduler:
Enable: true

View File

@ -10,7 +10,7 @@ class ManualConfig:
SCHEDULER_PRIORITY = """
Restart
> BattlePass > DailyQuest > Dungeon > Assignment
> BattlePass > DailyQuest > Assignment > Dungeon
"""
"""

View File

@ -677,7 +677,7 @@ class ConfigUpdater:
set_daily('Go_on_assignment_1_time', deep_get(data, 'Assignment.Scheduler.Enable'))
set_daily('Take_1_photo', 'achievable')
set_daily('Destroy_3_destructible_objects', 'not_supported')
set_daily('Complete_Forgotten_Hall_1_time', 'not_supported')
set_daily('Complete_Forgotten_Hall_1_time', 'achievable')
set_daily('Complete_Echo_of_War_1_times', 'not_supported')
set_daily('Complete_1_stage_in_Simulated_Universe_Any_world', 'not_supported')
set_daily('Obtain_victory_in_combat_with_support_characters_1_time',

View File

@ -50,7 +50,7 @@
},
"Enable": {
"name": "Enable Task",
"help": "Join this task to scheduler.\nTask commission, research, reward are force to enable.",
"help": "Join this task to scheduler.",
"True": "Enabled",
"False": "False"
},
@ -493,7 +493,7 @@
},
"Complete_Forgotten_Hall_1_time": {
"name": "Complete Forgotten Hall 1 time",
"help": "",
"help": "Choose the first four characters to do stage 1 once, please ensure that account build is sufficient to do stage 1",
"achievable": "Achievable",
"not_set": "Not Set",
"not_supported": "Not Supported Yet"

View File

@ -493,7 +493,7 @@
},
"Complete_Forgotten_Hall_1_time": {
"name": "完成1次「忘却之庭」",
"help": "",
"help": "选前四个角色打一次深渊一,请保证帐号练度足够打深渊一",
"achievable": "可完成",
"not_set": "未设置",
"not_supported": "暂未支持"

View File

@ -50,7 +50,7 @@
},
"Enable": {
"name": "啟用該功能",
"help": "將這個任務加入調度器\n委託、科研、收穫任務是強制打開的",
"help": "將這個任務加入調度器",
"True": "已啟用",
"False": "False"
},
@ -493,7 +493,7 @@
},
"Complete_Forgotten_Hall_1_time": {
"name": "完成1次「忘卻之庭」",
"help": "",
"help": "選前四個角色打一次深淵一,請確保帳號練度足夠打深淵一",
"achievable": "可完成",
"not_set": "未設定",
"not_supported": "暫未支援"

View File

@ -0,0 +1,35 @@
from module.logger import logger
from tasks.base.assets.assets_base_popup import POPUP_SINGLE
from tasks.dungeon.keywords import KEYWORDS_DUNGEON_LIST
from tasks.forgotten_hall.keywords import KEYWORDS_FORGOTTEN_HALL_STAGE
from tasks.forgotten_hall.ui import ForgottenHallUI
from tasks.map.control.waypoint import Waypoint
from tasks.map.keywords.plane import Jarilo_BackwaterPass
from tasks.map.route.base import RouteBase
class Route(RouteBase, ForgottenHallUI):
def combat_execute(self, expected_end=None):
# Challenge completed, return button appears
def combat_ended():
return self.appear(POPUP_SINGLE)
return super().combat_execute(expected_end=combat_ended)
def route(self):
"""
Pages:
in: Any
out: page_forgotten_hall
"""
logger.hr('Forgotten hall stage 1')
self.stage_goto(KEYWORDS_DUNGEON_LIST.The_Last_Vestiges_of_Towering_Citadel,
KEYWORDS_FORGOTTEN_HALL_STAGE.Stage_1)
self.team_choose_first_4()
self.enter_forgotten_hall_dungeon()
self.map_init(plane=Jarilo_BackwaterPass, position=(369.4, 643.4))
self.clear_enemy(
Waypoint((313.4, 643.4)).run_2x()
)
self.exit_dungeon()

View File

@ -253,6 +253,16 @@ MAP_CHECK = ButtonWrapper(
button=(46, 19, 68, 54),
),
)
MAP_EXIT = ButtonWrapper(
name='MAP_EXIT',
share=Button(
file='./assets/share/base/page/MAP_EXIT.png',
area=(27, 46, 44, 74),
search=(7, 26, 64, 94),
color=(142, 144, 148),
button=(27, 46, 44, 74),
),
)
MAP_GOTO_WORLD = ButtonWrapper(
name='MAP_GOTO_WORLD',
share=Button(

View File

@ -48,7 +48,7 @@ POPUP_CANCEL = ButtonWrapper(
share=Button(
file='./assets/share/base/popup/POPUP_CANCEL.png',
area=(428, 537, 452, 561),
search=(334, 453, 594, 653),
search=(339, 446, 482, 653),
color=(98, 92, 80),
button=(419, 531, 512, 567),
),
@ -58,7 +58,7 @@ POPUP_CONFIRM = ButtonWrapper(
share=Button(
file='./assets/share/base/popup/POPUP_CONFIRM.png',
area=(776, 537, 800, 561),
search=(683, 464, 943, 644),
search=(686, 446, 829, 644),
color=(95, 90, 78),
button=(767, 531, 862, 567),
),
@ -68,7 +68,7 @@ POPUP_SINGLE = ButtonWrapper(
share=Button(
file='./assets/share/base/popup/POPUP_SINGLE.png',
area=(602, 458, 626, 482),
search=(511, 365, 771, 575),
search=(512, 446, 655, 644),
color=(95, 90, 77),
button=(578, 451, 705, 489),
),

View File

@ -4,7 +4,7 @@ from module.base.timer import Timer
from module.exception import GameNotRunningError, GamePageUnknownError
from module.logger import logger
from module.ocr.ocr import Ocr
from tasks.base.assets.assets_base_page import CLOSE
from tasks.base.assets.assets_base_page import CLOSE, MAP_EXIT
from tasks.base.main_page import MainPage
from tasks.base.page import Page, page_main
from tasks.base.popup import PopupHandler
@ -271,7 +271,7 @@ class UI(PopupHandler, MainPage):
continue
def is_in_main(self):
return self.appear(page_main.check_button)
return self.appear(page_main.check_button) or self.appear(MAP_EXIT)
def ui_goto_main(self):
return self.ui_ensure(destination=page_main)

View File

@ -118,8 +118,11 @@ class Combat(CombatInteract, CombatPrepare, CombatState, CombatTeam, CombatSuppo
if self.handle_ascension_dungeon_prepare():
continue
def combat_execute(self):
def combat_execute(self, expected_end=None):
"""
Args:
expected_end: A function returns bool, True represents end.
Pages:
in: is_combat_executing
out: COMBAT_AGAIN
@ -135,7 +138,14 @@ class Combat(CombatInteract, CombatPrepare, CombatState, CombatTeam, CombatSuppo
self.device.screenshot()
# End
if callable(expected_end) and expected_end():
logger.info(f'Combat execute ended at {expected_end.__name__}')
break
if self.appear(COMBAT_AGAIN):
logger.info(f'Combat execute ended at {COMBAT_AGAIN}')
break
if self.is_in_main():
logger.info(f'Combat execute ended at page_main')
break
# Daemon

View File

@ -22,6 +22,7 @@ from tasks.dungeon.keywords import KEYWORDS_DUNGEON_TAB
from tasks.dungeon.ui import DungeonUI
from tasks.item.consumable_usage import ConsumableUsageUI
from tasks.item.relics import RelicsUI
from tasks.map.route.loader import RouteLoader
class DailyQuestOcr(Ocr):
@ -52,6 +53,8 @@ class DailyQuestOcr(Ocr):
result = result.replace('wor(d', 'world')
# Echo/ofWar
result = result.replace('cho/of', 'cho of')
# Catyx(Golden).1.times
result = result.replace('atyx', 'alyx')
if "progress" in result.lower():
result = "In Progress"
if "claimed" in result.lower():
@ -59,7 +62,7 @@ class DailyQuestOcr(Ocr):
return result
class DailyQuestUI(DungeonUI):
class DailyQuestUI(DungeonUI, RouteLoader):
def _ensure_position(self, direction: str, skip_first_screenshot=True):
interval = Timer(5)
if direction == 'left':
@ -256,6 +259,9 @@ class DailyQuestUI(DungeonUI):
if KEYWORDS_DAILY_QUEST.Salvage_any_Relic in quests:
if RelicsUI(self.config, self.device).salvage_relic():
done += 1
if KEYWORDS_DAILY_QUEST.Complete_Forgotten_Hall_1_time in quests:
self.route_run('daily.forgotten_hall.stage_1')
done += 1
return done

View File

@ -56,8 +56,8 @@ class UseTechniqueUI(MapControlJoystick, ForgottenHallUI):
logger.hr('Use techniques', level=2)
self.stage_goto(KEYWORDS_DUNGEON_LIST.The_Last_Vestiges_of_Towering_Citadel,
KEYWORDS_FORGOTTEN_HALL_STAGE.Stage_1)
self._choose_first_character()
self._enter_forgotten_hall_dungeon()
self.team_choose_first()
self.enter_forgotten_hall_dungeon()
self._use_technique(count, skip_first_screenshot=skip_first_screenshot)
self.exit_dungeon()
self.ui_goto_main()

View File

@ -50,11 +50,15 @@ class DungeonList(Keyword):
@cached_property
def is_Forgotten_Hall(self):
return ('Forgotten_Hall' in self.name) or ('Last_Vestiges' in self.name)
@cached_property
def is_Last_Vestiges(self):
return 'Last_Vestiges' in self.name
for word in [
'Forgotten_Hall',
'Memory_of_Chaos',
'Last_Vestiges',
'Navis_Astriger',
]:
if word in self.name:
return True
return False
@cached_property
def is_daily_dungeon(self):

View File

@ -315,107 +315,19 @@ The_Voyage_of_Navis_Astriger = DungeonList(
en='The Voyage of Navis Astriger',
jp='天艟求仙放浪記',
)
Favor_of_Amber = DungeonList(
id=40,
name='Favor_of_Amber',
cn='琥珀恩赐•忘却之庭',
cht='琥珀恩賜•忘卻之庭',
en='Favor of Amber',
jp='琥珀の賜物・忘却の庭',
)
Frostscar_Reverie = DungeonList(
id=41,
name='Frostscar_Reverie',
cn='霜痕旧梦•忘却之庭',
cht='霜痕舊夢•忘卻之庭',
en='Frostscar Reverie',
jp='霜跡に旧夢・忘却の庭',
)
Everwinter_Trials = DungeonList(
id=42,
name='Everwinter_Trials',
cn='永冬试炼•忘却之庭',
cht='永冬試煉•忘卻之庭',
en='Everwinter Trials',
jp='常冬の試練・忘却の庭',
)
Coldiron_Tribulation = DungeonList(
id=43,
name='Coldiron_Tribulation',
cn='寒铁砥砺•忘却之庭',
cht='寒鐵砥礪•忘卻之庭',
en='Coldiron Tribulation',
jp='寒鉄練磨・忘却の庭',
)
Hyperborean_Search_for_Warmth = DungeonList(
id=44,
name='Hyperborean_Search_for_Warmth',
cn='蹈冰寻火•忘却之庭',
cht='蹈冰尋火•忘卻之庭',
en='Hyperborean Search for Warmth',
jp='氷踏みて炎求む・忘却の庭',
)
Stormquell = DungeonList(
id=45,
name='Stormquell',
cn='风暴止息•忘却之庭',
cht='風暴止息•忘卻之庭',
en='Stormquell',
jp='止息せし嵐・忘却の庭',
)
Adrift_in_Astral_Seas = DungeonList(
id=46,
name='Adrift_in_Astral_Seas',
cn='孤航天海•忘却之庭',
cht='孤航太海•忘卻之庭',
en='Adrift in Astral Seas',
jp='天海の孤航・忘却の庭',
)
Raintear_Strife = DungeonList(
id=47,
name='Raintear_Strife',
cn='泪雨长战•忘却之庭',
cht='淚雨長戰•忘卻之庭',
en='Raintear Strife',
jp='涙雨戦争・忘却の庭',
)
Traces_of_Sanctus_Medicus = DungeonList(
id=48,
name='Traces_of_Sanctus_Medicus',
cn='药王垂迹•忘却之庭',
cht='藥王垂跡•忘卻之庭',
en='Traces of Sanctus Medicus',
jp='薬王の垂迹・忘却の庭',
)
Ethereal_Shipcraft_Forgotten_Hall = DungeonList(
id=49,
name='Ethereal_Shipcraft_Forgotten_Hall',
cn='迷梦造舸•忘却之庭',
cht='迷夢造舸•忘卻之庭',
en='Ethereal Shipcraft — Forgotten Hall',
jp='迷夢造舟・忘却の庭',
)
A_Shot_From_the_Sky_Forgotten_Hall = DungeonList(
id=50,
name='A_Shot_From_the_Sky_Forgotten_Hall',
cn='天裂一射•忘却之庭',
cht='天裂一射•忘卻之庭',
en='A Shot From the Sky — Forgotten Hall',
jp='天裂の一射・忘却の庭',
)
Mara_and_Null_Forgotten_Hall = DungeonList(
id=51,
name='Mara_and_Null_Forgotten_Hall',
cn='魔阴空劫•忘却之庭',
cht='魔陰空劫•忘卻之庭',
en='Mara and Null — Forgotten Hall',
jp='魔陰空劫・忘却の庭',
)
The_Last_Vestiges_of_Towering_Citadel = DungeonList(
id=52,
id=40,
name='The_Last_Vestiges_of_Towering_Citadel',
cn='永屹之城遗秘',
cht='永屹之城遺秘',
en='The Last Vestiges of Towering Citadel',
jp='永屹の城の秘密',
)
Memory_of_Chaos = DungeonList(
id=41,
name='Memory_of_Chaos',
cn='混沌回忆',
cht='混沌回憶',
en='Memory of Chaos',
jp='混沌の記憶',
)

View File

@ -9,7 +9,6 @@ from module.ocr.ocr import DigitCounter, Ocr, OcrResultButton
from module.ocr.utils import split_and_pair_button_attr
from module.ui.draggable_list import DraggableList
from module.ui.switch import Switch
from tasks.base.assets.assets_base_page import FORGOTTEN_HALL_CHECK
from tasks.base.page import page_guide
from tasks.base.ui import UI
from tasks.combat.assets.assets_combat_prepare import COMBAT_PREPARE
@ -219,12 +218,15 @@ class DungeonUI(UI):
DUNGEON_NAV_LIST.load_rows(main=self)
# End
button = DUNGEON_NAV_LIST.keyword2button(KEYWORDS_DUNGEON_NAV.Forgotten_Hall)
button = DUNGEON_NAV_LIST.keyword2button(KEYWORDS_DUNGEON_NAV.Forgotten_Hall, show_warning=False)
if button:
# 513 is the top of the last row of DungeonNav
if button.area[1] > 513:
logger.info('DungeonNav row Forgotten_Hall stabled')
return True
else:
logger.info('No Forgotten_Hall in list skip waiting')
return False
def dungeon_get_simuni_point(self) -> int:
"""
@ -431,11 +433,6 @@ class DungeonUI(UI):
self._dungeon_insight(dungeon)
self._dungeon_enter(dungeon)
return True
if dungeon.is_Forgotten_Hall:
self._dungeon_nav_goto(dungeon)
self._dungeon_insight(dungeon)
self._dungeon_enter(dungeon, enter_check_button=FORGOTTEN_HALL_CHECK)
return True
logger.error(f'Goto dungeon {dungeon} is not supported')
return False

View File

@ -1,95 +0,0 @@
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 ```
DUNGEON_ENTER_CHECKED = ButtonWrapper(
name='DUNGEON_ENTER_CHECKED',
share=Button(
file='./assets/share/forgotten_hall/DUNGEON_ENTER_CHECKED.png',
area=(29, 242, 43, 251),
search=(9, 222, 63, 271),
color=(173, 173, 173),
button=(29, 242, 43, 251),
),
)
EFFECT_NOTIFICATION = ButtonWrapper(
name='EFFECT_NOTIFICATION',
share=Button(
file='./assets/share/forgotten_hall/EFFECT_NOTIFICATION.png',
area=(237, 302, 315, 380),
search=(217, 282, 335, 400),
color=(128, 114, 85),
button=(237, 302, 315, 380),
),
)
ENTER_FORGOTTEN_HALL_DUNGEON = ButtonWrapper(
name='ENTER_FORGOTTEN_HALL_DUNGEON',
share=Button(
file='./assets/share/forgotten_hall/ENTER_FORGOTTEN_HALL_DUNGEON.png',
area=(989, 649, 1231, 684),
search=(969, 629, 1251, 704),
color=(214, 214, 217),
button=(989, 649, 1231, 684),
),
)
ENTRANCE_CHECKED = ButtonWrapper(
name='ENTRANCE_CHECKED',
share=Button(
file='./assets/share/forgotten_hall/ENTRANCE_CHECKED.png',
area=(55, 632, 71, 650),
search=(35, 612, 91, 670),
color=(156, 156, 157),
button=(55, 632, 71, 650),
),
)
EXIT_CONFIRM = ButtonWrapper(
name='EXIT_CONFIRM',
share=Button(
file='./assets/share/forgotten_hall/EXIT_CONFIRM.png',
area=(776, 458, 800, 482),
search=(756, 438, 820, 502),
color=(94, 88, 76),
button=(776, 458, 800, 482),
),
)
EXIT_DUNGEON = ButtonWrapper(
name='EXIT_DUNGEON',
share=Button(
file='./assets/share/forgotten_hall/EXIT_DUNGEON.png',
area=(15, 45, 45, 75),
search=(0, 25, 65, 95),
color=(113, 117, 124),
button=(15, 45, 45, 75),
),
)
FIRST_CHARACTER = ButtonWrapper(
name='FIRST_CHARACTER',
share=Button(
file='./assets/share/forgotten_hall/FIRST_CHARACTER.png',
area=(54, 100, 150, 212),
search=(34, 80, 170, 232),
color=(145, 125, 103),
button=(54, 100, 150, 212),
),
)
LAST_VERTIGES = ButtonWrapper(
name='LAST_VERTIGES',
share=Button(
file='./assets/share/forgotten_hall/LAST_VERTIGES.png',
area=(1163, 105, 1203, 125),
search=(1143, 85, 1223, 145),
color=(221, 185, 225),
button=(1163, 105, 1203, 125),
),
)
OCR_STAGE = ButtonWrapper(
name='OCR_STAGE',
share=Button(
file='./assets/share/forgotten_hall/OCR_STAGE.png',
area=(0, 281, 1280, 581),
search=(0, 261, 1280, 601),
color=(29, 48, 92),
button=(0, 0, 1000, 100),
),
)

View File

@ -0,0 +1,105 @@
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 ```
DUNGEON_ENTER_CHECKED = ButtonWrapper(
name='DUNGEON_ENTER_CHECKED',
share=Button(
file='./assets/share/forgotten_hall/ui/DUNGEON_ENTER_CHECKED.png',
area=(29, 242, 43, 251),
search=(9, 222, 63, 271),
color=(173, 173, 173),
button=(29, 242, 43, 251),
),
)
EFFECT_NOTIFICATION = ButtonWrapper(
name='EFFECT_NOTIFICATION',
share=Button(
file='./assets/share/forgotten_hall/ui/EFFECT_NOTIFICATION.png',
area=(237, 302, 315, 380),
search=(217, 282, 335, 400),
color=(128, 114, 85),
button=(237, 302, 315, 380),
),
)
ENTER_FORGOTTEN_HALL_DUNGEON = ButtonWrapper(
name='ENTER_FORGOTTEN_HALL_DUNGEON',
share=Button(
file='./assets/share/forgotten_hall/ui/ENTER_FORGOTTEN_HALL_DUNGEON.png',
area=(989, 649, 1231, 684),
search=(969, 629, 1251, 704),
color=(214, 214, 217),
button=(989, 649, 1231, 684),
),
)
ENTRANCE_CHECKED = ButtonWrapper(
name='ENTRANCE_CHECKED',
share=Button(
file='./assets/share/forgotten_hall/ui/ENTRANCE_CHECKED.png',
area=(55, 632, 71, 650),
search=(35, 612, 91, 670),
color=(156, 156, 157),
button=(55, 632, 71, 650),
),
)
LAST_VASTIGES_CHECK = ButtonWrapper(
name='LAST_VASTIGES_CHECK',
share=Button(
file='./assets/share/forgotten_hall/ui/LAST_VASTIGES_CHECK.png',
area=(25, 190, 96, 262),
search=(11, 73, 111, 461),
color=(94, 126, 162),
button=(25, 190, 96, 262),
),
)
LAST_VASTIGES_CLICK = ButtonWrapper(
name='LAST_VASTIGES_CLICK',
share=Button(
file='./assets/share/forgotten_hall/ui/LAST_VASTIGES_CLICK.png',
area=(32, 196, 90, 254),
search=(11, 73, 111, 461),
color=(49, 64, 87),
button=(32, 196, 90, 254),
),
)
MEMORY_OF_CHAOS_CHECK = ButtonWrapper(
name='MEMORY_OF_CHAOS_CHECK',
share=Button(
file='./assets/share/forgotten_hall/ui/MEMORY_OF_CHAOS_CHECK.png',
area=(24, 90, 96, 162),
search=(4, 70, 116, 182),
color=(140, 116, 159),
button=(24, 90, 96, 162),
),
)
MEMORY_OF_CHAOS_CLICK = ButtonWrapper(
name='MEMORY_OF_CHAOS_CLICK',
share=Button(
file='./assets/share/forgotten_hall/ui/MEMORY_OF_CHAOS_CLICK.png',
area=(32, 96, 90, 154),
search=(12, 76, 110, 174),
color=(74, 60, 96),
button=(32, 96, 90, 154),
),
)
OCR_STAGE = ButtonWrapper(
name='OCR_STAGE',
share=Button(
file='./assets/share/forgotten_hall/ui/OCR_STAGE.png',
area=(0, 281, 1280, 581),
search=(0, 261, 1280, 601),
color=(29, 48, 92),
button=(0, 0, 1000, 100),
),
)
TELEPORT = ButtonWrapper(
name='TELEPORT',
share=Button(
file='./assets/share/forgotten_hall/ui/TELEPORT.png',
area=(1019, 451, 1037, 470),
search=(993, 176, 1088, 658),
color=(166, 165, 166),
button=(1019, 451, 1037, 470),
),
)

View File

@ -0,0 +1,74 @@
import cv2
import numpy as np
from module.base.utils import color_similarity_2d, get_color
from module.logger import logger
from tasks.base.ui import UI
from tasks.forgotten_hall.assets.assets_forgotten_hall_team import *
from tasks.forgotten_hall.assets.assets_forgotten_hall_ui import ENTER_FORGOTTEN_HALL_DUNGEON, ENTRANCE_CHECKED
class ForgottenHallTeam(UI):
def team_prepared(self):
# White button, with a color of (214, 214, 214)
color = get_color(self.device.image, ENTER_FORGOTTEN_HALL_DUNGEON.area)
return np.mean(color) > 180
def team_choose_first(self, skip_first_screenshot=True):
"""
A temporary method used to choose the first character only
"""
logger.info('Team choose first')
self.interval_clear(ENTRANCE_CHECKED)
while 1:
if skip_first_screenshot:
skip_first_screenshot = False
else:
self.device.screenshot()
if self.team_prepared():
logger.info("First character is chosen")
break
if self.appear(ENTRANCE_CHECKED, interval=2):
self.device.click(CHARACTER_1)
continue
def is_character_chosen(self, button: ButtonWrapper) -> bool:
image = color_similarity_2d(self.image_crop(button), color=(255, 255, 255))
color = cv2.mean(image)[0]
# print(button, color)
# Chosen:
# CHARACTER_1 210.0230034722222
# CHARACTER_2 210.12022569444443
# CHARACTER_3 211.09244791666666
# CHARACTER_4 210.48046875
# Not chosen
# CHARACTER_1 122.38671875
# CHARACTER_2 124.72960069444444
# CHARACTER_3 136.55989583333331
# CHARACTER_4 129.76432291666666
return color > 180
def team_choose_first_4(self, skip_first_screenshot=True):
"""
Choose the first 4 characters in list.
"""
logger.info('Team choose first 4')
self.interval_clear(ENTRANCE_CHECKED)
characters = [CHARACTER_1, CHARACTER_2, CHARACTER_3, CHARACTER_4]
while 1:
if skip_first_screenshot:
skip_first_screenshot = False
else:
self.device.screenshot()
chosen_list = [self.is_character_chosen(c) for c in characters]
if all(chosen_list):
logger.info("First 4 characters are chosen")
break
if self.appear(ENTRANCE_CHECKED, interval=2):
for character, chosen in zip(characters, chosen_list):
if not chosen:
self.device.click(character)
# Casual sleep, game may not respond that fast
self.device.sleep((0.1, 0.2))

View File

@ -4,16 +4,17 @@ from pponnxcr.predict_system import BoxedResult
from module.base.base import ModuleBase
from module.base.timer import Timer
from module.base.utils import area_offset, color_similarity_2d, crop, get_color
from module.base.utils import area_offset, color_similarity_2d, crop
from module.logger.logger import logger
from module.ocr.keyword import Keyword
from module.ocr.ocr import Ocr, OcrResultButton
from module.ui.draggable_list import DraggableList
from tasks.base.assets.assets_base_page import FORGOTTEN_HALL_CHECK
from tasks.dungeon.keywords import DungeonList, KEYWORDS_DUNGEON_TAB
from tasks.base.assets.assets_base_page import FORGOTTEN_HALL_CHECK, MAP_EXIT
from tasks.dungeon.keywords import DungeonList, KEYWORDS_DUNGEON_LIST, KEYWORDS_DUNGEON_TAB
from tasks.dungeon.ui import DungeonUI
from tasks.forgotten_hall.assets.assets_forgotten_hall import *
from tasks.forgotten_hall.keywords import *
from tasks.forgotten_hall.assets.assets_forgotten_hall_ui import *
from tasks.forgotten_hall.keywords import ForgottenHallStage
from tasks.forgotten_hall.team import ForgottenHallTeam
from tasks.map.control.joystick import MapControlJoystick
@ -103,8 +104,60 @@ STAGE_LIST = DraggableStageList("ForgottenHallStageList", keyword_class=Forgotte
check_row_order=False, drag_direction="right")
class ForgottenHallUI(DungeonUI):
def stage_goto(self, forgotten_hall: DungeonList, stage_keyword: ForgottenHallStage):
class ForgottenHallUI(DungeonUI, ForgottenHallTeam):
def handle_effect_popup(self):
if self.appear(EFFECT_NOTIFICATION, interval=2):
if self.appear_then_click(MEMORY_OF_CHAOS_CHECK):
return True
if self.appear_then_click(MEMORY_OF_CHAOS_CLICK):
return True
# No match, click whatever
MEMORY_OF_CHAOS_CHECK.clear_offset()
self.device.click(MEMORY_OF_CHAOS_CHECK)
return True
return False
def stage_choose(self, dungeon: DungeonList, skip_first_screenshot=True):
"""
Pages:
in: page_forgotten_hall, FORGOTTEN_HALL_CHECK
or page_guide, Survival_Index, Forgotten_Hall
out: page_forgotten_hall, FORGOTTEN_HALL_CHECK, selected at the given dungeon tab
"""
logger.info(f'Stage choose {dungeon}')
if dungeon == KEYWORDS_DUNGEON_LIST.Memory_of_Chaos:
check_button = MEMORY_OF_CHAOS_CHECK
click_button = MEMORY_OF_CHAOS_CLICK
elif dungeon == KEYWORDS_DUNGEON_LIST.The_Last_Vestiges_of_Towering_Citadel:
check_button = LAST_VASTIGES_CHECK
click_button = LAST_VASTIGES_CLICK
else:
logger.error(f'Choosing {dungeon} in forgotten hall is not supported')
return
while 1:
if skip_first_screenshot:
skip_first_screenshot = False
else:
self.device.screenshot()
# interval used in end condition
# After clicking `click_button`, `click_button` appears, then screen goes black for a little while
# interval prevents `check_button` being triggered in the next 0.3s
if self.match_template_color(check_button, interval=0.3):
logger.info(f'Stage chose at {dungeon}')
break
if self.handle_effect_popup():
continue
if self.appear_then_click(TELEPORT, interval=2):
continue
if self.match_template_color(click_button, interval=1):
self.device.click(click_button)
self.interval_reset(check_button)
continue
def stage_goto(self, dungeon: DungeonList, stage_keyword: ForgottenHallStage):
"""
Examples:
self = ForgottenHallUI('alas')
@ -112,19 +165,34 @@ class ForgottenHallUI(DungeonUI):
self.stage_goto(KEYWORDS_DUNGEON_LIST.The_Last_Vestiges_of_Towering_Citadel,
KEYWORDS_FORGOTTEN_HALL_STAGE.Stage_8)
"""
if not forgotten_hall.is_Forgotten_Hall:
logger.warning("DungeonList Chosen is not a forgotten hall")
if not dungeon in [
KEYWORDS_DUNGEON_LIST.Memory_of_Chaos,
KEYWORDS_DUNGEON_LIST.The_Last_Vestiges_of_Towering_Citadel,
]:
logger.error(f'DungeonList Chosen is not a forgotten hall: {dungeon}')
return
if not forgotten_hall.is_Last_Vestiges and stage_keyword.id > 10:
logger.warning(f"This dungeon does not have stage that greater than 10. {stage_keyword.id} is chosen")
if dungeon == KEYWORDS_DUNGEON_LIST.Memory_of_Chaos and stage_keyword.id > 10:
logger.error(f'This dungeon "{dungeon}" does not have stage that greater than 10. '
f'{stage_keyword.id} is chosen')
return
if not self.appear(FORGOTTEN_HALL_CHECK):
if self.appear(FORGOTTEN_HALL_CHECK):
logger.info('Already in forgotten hall')
else:
self.dungeon_tab_goto(KEYWORDS_DUNGEON_TAB.Survival_Index)
self.dungeon_goto(forgotten_hall)
self._dungeon_nav_goto(dungeon)
self.stage_choose(dungeon)
STAGE_LIST.select_row(stage_keyword, main=self)
def exit_dungeon(self, skip_first_screenshot=True):
"""
Pages:
in: page_main, in forgotten hall map
out: page_forgotten_hall, FORGOTTEN_HALL_CHECK
"""
logger.info('Exit dungeon')
while 1:
if skip_first_screenshot:
skip_first_screenshot = False
@ -135,37 +203,20 @@ class ForgottenHallUI(DungeonUI):
logger.info("Forgotten hall dungeon exited")
break
if self.appear_then_click(EXIT_DUNGEON):
if self.appear_then_click(MAP_EXIT):
continue
if self.appear_then_click(EXIT_CONFIRM):
if self.handle_popup_confirm():
continue
if self.handle_popup_single():
continue
def _choose_first_character(self, skip_first_screenshot=True):
"""
A temporary method used to choose the first character only
"""
interval = Timer(1)
while 1:
if skip_first_screenshot:
skip_first_screenshot = False
else:
self.device.screenshot()
if self._forgotten_hall_enter_appear():
logger.info("First character is chosen")
break
if interval.reached():
self.device.click(FIRST_CHARACTER)
interval.reset()
def _forgotten_hall_enter_appear(self):
# White button, with a color of (214, 214, 214)
color = get_color(self.device.image, ENTER_FORGOTTEN_HALL_DUNGEON.area)
return np.mean(color) > 180
def _enter_forgotten_hall_dungeon(self, skip_first_screenshot=True):
def enter_forgotten_hall_dungeon(self, skip_first_screenshot=True):
"""
called after team is set
Pages:
in: ENTRANCE_CHECKED, ENTER_FORGOTTEN_HALL_DUNGEON
out: page_main, in forgotten hall map
"""
interval = Timer(3)
timeout = Timer(3)
@ -184,7 +235,7 @@ class ForgottenHallUI(DungeonUI):
else:
timeout.reset()
if interval.reached() and self._forgotten_hall_enter_appear():
if interval.reached() and self.team_prepared():
self.device.click(ENTER_FORGOTTEN_HALL_DUNGEON)
interval.reset()
@ -199,4 +250,4 @@ class ForgottenHallUI(DungeonUI):
if self.match_template_color(DUNGEON_ENTER_CHECKED):
logger.info("Forgotten hall dungeon entered")
break
joystick.handle_map_2x_run()
joystick.handle_map_run_2x()

View File

@ -2,14 +2,16 @@ from functools import cached_property
from module.base.timer import Timer
from module.logger import logger
from tasks.combat.combat import Combat
from tasks.map.assets.assets_map_control import ROTATION_SWIPE_AREA
from tasks.map.control.joystick import JoystickContact, MapControlJoystick
from tasks.map.control.waypoint import Waypoint, ensure_waypoint
from tasks.map.control.joystick import JoystickContact
from tasks.map.control.waypoint import Waypoint, ensure_waypoints
from tasks.map.interact.aim import AimDetectorMixin
from tasks.map.minimap.minimap import Minimap
from tasks.map.resource.const import diff_to_180_180
class MapControl(MapControlJoystick):
class MapControl(Combat, AimDetectorMixin):
@cached_property
def minimap(self) -> Minimap:
return Minimap()
@ -78,7 +80,7 @@ class MapControl(MapControlJoystick):
waypoint: Waypoint,
end_opt=True,
skip_first_screenshot=False
):
) -> list[str]:
"""
Point to point walk.
@ -92,6 +94,9 @@ class MapControl(MapControlJoystick):
True to enable endpoint optimizations,
character will smoothly approach target position
skip_first_screenshot:
Returns:
list[str]: A list of walk result
"""
logger.hr('Goto', level=2)
logger.info(f'Goto {waypoint}')
@ -99,51 +104,120 @@ class MapControl(MapControlJoystick):
self.device.click_record_clear()
end_opt = end_opt and waypoint.end_opt
allow_2x_run = waypoint.speed in ['2x_run']
allow_straight_run = waypoint.speed in ['2x_run', 'straight_run']
allow_run = waypoint.speed in ['2x_run', 'straight_run', 'run']
allow_run_2x = waypoint.speed in ['run_2x']
allow_straight_run = waypoint.speed in ['run_2x', 'straight_run']
allow_run = waypoint.speed in ['run_2x', 'straight_run', 'run']
allow_walk = True
allow_rotation_set = True
last_rotation = 0
result = []
direction_interval = Timer(0.5, count=1)
rotation_interval = Timer(0.3, count=1)
aim_interval = Timer(0.3, count=1)
attacked_enemy = Timer(1.2, count=4)
attacked_item = Timer(0.6, count=2)
while 1:
if skip_first_screenshot:
skip_first_screenshot = False
else:
self.device.screenshot()
# End
for expected in waypoint.expected_end:
if callable(expected):
if expected():
logger.info(f'Walk result add: {expected.__name__}')
result.append(expected.__name__)
return result
if self.is_combat_executing():
logger.info('Walk result add: enemy')
result.append('enemy')
contact.up()
logger.hr('Combat', level=2)
self.combat_execute()
if waypoint.early_stop:
return result
# The following detection require page_main
if not self.is_in_main():
attacked_enemy.clear()
attacked_item.clear()
continue
# Update
self.minimap.update(self.device.image)
if aim_interval.reached_and_reset():
self.aim.predict(self.device.image)
diff = self.minimap.position_diff(waypoint.position)
direction = self.minimap.position2direction(waypoint.position)
rotation_diff = self.minimap.direction_diff(direction)
logger.info(f'Position diff: {diff}, rotation: {rotation_diff}')
# Interact
if self.aim.aimed_enemy:
if 'enemy' in waypoint.expected_end:
if self.handle_map_A():
allow_run_2x = allow_straight_run = allow_run = allow_walk = False
attacked_enemy.reset()
direction_interval.reset()
rotation_interval.reset()
if attacked_enemy.started():
attacked_enemy.reset()
if self.aim.aimed_item:
if 'item' in waypoint.expected_end:
if self.handle_map_A():
allow_run_2x = allow_straight_run = allow_run = allow_walk = False
attacked_item.reset()
direction_interval.reset()
rotation_interval.reset()
if attacked_item.started():
attacked_item.reset()
else:
if attacked_item.started() and attacked_item.reached():
logger.info('Walk result add: item')
result.append('item')
if waypoint.early_stop:
return result
# Arrive
if self.minimap.is_position_near(waypoint.position, threshold=waypoint.get_threshold(end_opt)):
logger.info(f'Arrive {waypoint}')
break
if not attacked_enemy.started() and not attacked_item.started():
if self.minimap.is_position_near(waypoint.position, threshold=waypoint.get_threshold(end_opt)):
if not waypoint.expected_end or waypoint.match_results(result):
logger.info(f'Arrive waypoint: {waypoint}')
return result
else:
if waypoint.unexpected_confirm.reached():
logger.info(f'Arrive waypoint with unexpected result: {waypoint}')
return result
else:
waypoint.unexpected_confirm.reset()
# Switch run case
diff = self.minimap.position_diff(waypoint.position)
if end_opt:
if allow_2x_run and diff < 20:
logger.info(f'Approaching target, diff={round(diff, 1)}, disallow 2x_run')
allow_2x_run = False
if allow_straight_run and diff < 15:
if allow_run_2x and diff < 20:
logger.info(f'Approaching target, diff={round(diff, 1)}, disallow run_2x')
allow_run_2x = False
if allow_straight_run and diff < 15 and not allow_rotation_set:
logger.info(f'Approaching target, diff={round(diff, 1)}, disallow straight_run')
direction_interval = Timer(0.2)
self.map_2x_run_timer.reset()
aim_interval = Timer(0.1)
self.map_run_2x_timer.reset()
allow_straight_run = False
if allow_run and diff < 7:
logger.info(f'Approaching target, diff={round(diff, 1)}, disallow run')
direction_interval = Timer(0.2)
aim_interval = Timer(0.2)
allow_run = False
# Control
direction = self.minimap.position2direction(waypoint.position)
if allow_2x_run:
# Run with 2x_run button
if allow_run_2x:
# Run with run_2x button
# - Set rotation once
# - Continuous fine-tuning direction
# - Enable 2x_run
# - Enable run_2x
if allow_rotation_set:
# Cache rotation cause rotation detection has a higher error rate
last_rotation = self.minimap.rotation
@ -158,12 +232,12 @@ class MapControl(MapControlJoystick):
if direction_interval.reached():
contact.set(direction=diff_to_180_180(direction - last_rotation), run=True)
direction_interval.reset()
self.handle_map_2x_run(run=True)
self.handle_map_run_2x(run=True)
elif allow_straight_run:
# Run straight forward
# - Set rotation once
# - Continuous fine-tuning direction
# - Disable 2x_run
# - Disable run_2x
if allow_rotation_set:
# Cache rotation cause rotation detection has a higher error rate
last_rotation = self.minimap.rotation
@ -178,67 +252,102 @@ class MapControl(MapControlJoystick):
if direction_interval.reached():
contact.set(direction=diff_to_180_180(direction - last_rotation), run=True)
direction_interval.reset()
self.handle_map_2x_run(run=False)
self.handle_map_run_2x(run=False)
elif allow_run:
# Run
# - No rotation set
# - Continuous fine-tuning direction
# - Disable 2x_run
# - Disable run_2x
if allow_rotation_set:
last_rotation = self.minimap.rotation
allow_rotation_set = False
if direction_interval.reached():
contact.set(direction=diff_to_180_180(direction - last_rotation), run=True)
self.handle_map_2x_run(run=False)
else:
direction_interval.reset()
self.handle_map_run_2x(run=False)
elif allow_walk:
# Walk
# - Continuous fine-tuning direction
# - Disable 2x_run
# - Disable run_2x
if allow_rotation_set:
last_rotation = self.minimap.rotation
allow_rotation_set = False
if direction_interval.reached():
contact.set(direction=diff_to_180_180(direction - last_rotation), run=False)
direction_interval.reset()
self.handle_map_2x_run(run=False)
self.handle_map_run_2x(run=False)
else:
contact.up()
def goto(
self,
*waypoints,
skip_first_screenshot=True
):
def goto(self, *waypoints):
"""
Go along a list of position, or goto target position
Go along a list of position, or goto target position.
Args:
waypoints:
position (x, y) to goto, or a list of position to go along.
Waypoint object to goto, or a list of Waypoint objects to go along.
skip_first_screenshot:
waypoints: position (x, y), a list of position to go along,
or a list of Waypoint objects to go along.
"""
logger.hr('Goto', level=1)
waypoints = [ensure_waypoint(point) for point in waypoints]
self.map_A_timer.clear()
self.map_E_timer.clear()
self.map_run_2x_timer.clear()
waypoints = ensure_waypoints(waypoints)
logger.info(f'Go along {len(waypoints)} waypoints')
end_list = [False for _ in waypoints]
end_list[-1] = True
with JoystickContact(self) as contact:
for point, end in zip(waypoints, end_list):
point: Waypoint
self._goto(
for waypoint, end in zip(waypoints, end_list):
waypoint: Waypoint
result = self._goto(
contact=contact,
waypoint=point,
waypoint=waypoint,
end_opt=end,
skip_first_screenshot=skip_first_screenshot
skip_first_screenshot=True,
)
skip_first_screenshot = True
expected = waypoint.expected_to_str(waypoint.expected_end)
logger.info(f'Arrive waypoint, expected: {expected}, result: {result}')
matched = waypoint.match_results(result)
if not waypoint.expected_end or matched:
logger.info(f'Arrive waypoint with expected result: {matched}')
else:
logger.warning(f'Arrive waypoint with unexpected result: {result}')
end_point = waypoints[-1]
if end_point.end_rotation is not None:
logger.hr('End rotation', level=1)
logger.hr('End rotation', level=2)
self.rotation_set(end_point.end_rotation, threshold=end_point.end_rotation_threshold)
def clear_item(self, *waypoints):
"""
Go along a list of position and clear destructive object at last.
Args:
waypoints: position (x, y), a list of position to go along.
or a list of Waypoint objects to go along.
"""
logger.hr('Clear item', level=1)
waypoints = ensure_waypoints(waypoints)
end_point = waypoints[-1]
end_point.expected_end.append('item')
self.goto(*waypoints)
def clear_enemy(self, *waypoints):
"""
Go along a list of position and enemy at last.
Args:
waypoints: position (x, y), a list of position to go along.
or a list of Waypoint objects to go along.
"""
logger.hr('Clear item', level=1)
waypoints = ensure_waypoints(waypoints)
end_point = waypoints[-1]
end_point.expected_end.append('enemy')
self.goto(*waypoints)
if __name__ == '__main__':
# Control test in Himeko trial
@ -248,17 +357,14 @@ if __name__ == '__main__':
self.device.screenshot()
self.minimap.init_position((519, 359))
# Visit 3 items
self.goto(
Waypoint((577.6, 363.4)),
self.clear_item(
Waypoint((587.6, 366.9)).run_2x(),
)
self.goto(
Waypoint((577.5, 369.4), end_rotation=200),
)
self.goto(
Waypoint((581.5, 387.3)),
Waypoint((577.4, 411.5)),
self.clear_item((575.5, 377.4))
self.clear_item(
# Go through arched door
Waypoint((581.5, 383.3)).run().set_threshold(3),
Waypoint((575.7, 417.2)).run(),
)
# Goto boss
self.goto(
Waypoint((607.6, 425.3)),
)
self.clear_enemy((613.5, 427.3))

View File

@ -16,7 +16,7 @@ from tasks.map.assets.assets_map_control import *
class JoystickContact:
CENTER = (JOYSTICK.area[0] + JOYSTICK.area[2]) / 2, (JOYSTICK.area[1] + JOYSTICK.area[3]) / 2
# Minimum radius 49px
RADIUS_WALK = (55, 65)
RADIUS_WALK = (25, 40)
# Minimum radius 103px
RADIUS_RUN = (105, 115)
@ -114,7 +114,7 @@ class JoystickContact:
direction (int, float): Direction to goto (0~360)
run: True for character running, False for walking
"""
logger.info(f'JoystickContact set to {direction}')
logger.info(f'JoystickContact set to {direction}, run={run}')
point = JoystickContact.direction2screen(direction, run=run)
builder = self.builder
@ -136,7 +136,7 @@ class JoystickContact:
# Character starts moving, RUN button is still unavailable in a short time.
# Assume available in 0.3s
# We still have reties if 0.3s is incorrect.
self.main.map_2x_run_timer.set_current(0.7)
self.main.map_run_2x_timer.set_current(0.7)
self.main.joystick_lost_timer.reset()
self.prev_point = point
@ -145,7 +145,7 @@ class JoystickContact:
class MapControlJoystick(UI):
map_A_timer = Timer(1)
map_E_timer = Timer(1)
map_2x_run_timer = Timer(1)
map_run_2x_timer = Timer(1)
joystick_lost_timer = Timer(1, count=2)
@ -235,7 +235,7 @@ class MapControlJoystick(UI):
return False
def handle_map_2x_run(self, run=True):
def handle_map_run_2x(self, run=True):
"""
Keep character running.
Note that RUN button can only be clicked when character is moving.
@ -245,13 +245,13 @@ class MapControlJoystick(UI):
"""
is_running = self.image_color_count(RUN_BUTTON, color=(208, 183, 138), threshold=221, count=100)
if run and not is_running and self.map_2x_run_timer.reached():
if run and not is_running and self.map_run_2x_timer.reached():
self.device.click(RUN_BUTTON)
self.map_2x_run_timer.reset()
self.map_run_2x_timer.reset()
return True
if not run and is_running and self.map_2x_run_timer.reached():
if not run and is_running and self.map_run_2x_timer.reached():
self.device.click(RUN_BUTTON)
self.map_2x_run_timer.reset()
self.map_run_2x_timer.reset()
return True
return False

View File

@ -1,4 +1,6 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from module.base.timer import Timer
@dataclass
@ -11,7 +13,7 @@ class Waypoint:
# If `threshold` is not set, `waypoint_threshold` and `endpoint_threshold` are used
waypoint_threshold: int = 10
endpoint_threshold: int = 3
# Max move speed, '2x_run', 'straight_run', 'run', 'walk'
# Max move speed, 'run_2x', 'straight_run', 'run', 'walk'
# See MapControl._goto() for details of each speed level
speed: str = 'straight_run'
@ -25,11 +27,50 @@ class Waypoint:
end_rotation: int = None
end_rotation_threshold: int = 15
"""
Walk
"""
# A list of expected events, e.g. ['enemy', 'item']
# - "enemy", finished any combat
# - "item", destroyed any destructive objects
# - "interact", have map interact option (interact is not handled)
# - callable, A function that returns bool, True represents stop
# Or empty list [] for just walking
expected_end: list = field(default_factory=lambda: [])
# If triggered any expected event, consider arrive and stop walking
early_stop: bool = True
# Confirm timer if arrived but didn't trigger any expected event
unexpected_confirm: Timer = field(default_factory=lambda: Timer(2, count=6))
def __str__(self):
return f'Waypoint({self.position})'
__repr__ = __str__
def run_2x(self) -> "Waypoint":
"""
Product a Waypoint object with overridden "speed",
see Waypoint class for args.
"""
self.speed = 'run_2x'
return self
def straight_run(self) -> "Waypoint":
self.speed = 'straight_run'
return self
def run(self) -> "Waypoint":
self.speed = 'run'
return self
def walk(self) -> "Waypoint":
self.speed = 'walk'
return self
def set_threshold(self, threshold) -> "Waypoint":
self.threshold = threshold
return self
def get_threshold(self, end):
"""
Args:
@ -45,6 +86,27 @@ class Waypoint:
else:
return self.waypoint_threshold
@staticmethod
def expected_to_str(results: list) -> list[str]:
return [result.__name__ if callable(result) else str(result) for result in results]
def match_results(self, results) -> list[str]:
"""
Args:
results:
Returns:
list[str]: A list if matched results
"""
if not results and not self.expected_end:
return []
results = set(self.expected_to_str(results))
expected_end = set(self.expected_to_str(self.expected_end))
same = results.intersection(expected_end)
return list(same)
def ensure_waypoint(point) -> Waypoint:
"""
@ -54,26 +116,13 @@ def ensure_waypoint(point) -> Waypoint:
Returns:
Waypoint:
"""
if isinstance(point, Waypoint):
return point
return Waypoint(point)
@dataclass(repr=False)
class Waypoint2xRun(Waypoint):
speed: str = '2x_run'
@dataclass(repr=False)
class WaypointStraightRun(Waypoint):
speed: str = 'straight_run'
@dataclass(repr=False)
class WaypointRun(Waypoint):
speed: str = 'run'
@dataclass(repr=False)
class WaypointWalk(Waypoint):
speed: str = 'walk'
def ensure_waypoints(points) -> list[Waypoint]:
if not isinstance(points, (list, tuple)):
points = [points]
return [ensure_waypoint(point) for point in points]

View File

@ -46,8 +46,8 @@ class PositionPredictState:
class Minimap(MapResource):
def init_position(self, position: tuple[int, int]):
logger.info(f"init_position:{position}")
def init_position(self, position: tuple[int | float, int | float]):
logger.info(f"init_position: {position}")
self.position = position
def _predict_position(self, image, scale=1.0):
@ -388,9 +388,9 @@ if __name__ == '__main__':
# MapResource.SRCMAP = '../srcmap/srcmap'
self = Minimap()
# Set plane, assume starting from Jarilo_AdministrativeDistrict
self.set_plane('Jarilo_AdministrativeDistrict', floor='F1')
self.set_plane('Jarilo_BackwaterPass', floor='F1')
ui = UI('alas')
ui = UI('src')
ui.device.disable_stuck_detection()
# Set starter point. Starter point will be calculated if it's missing but may contain errors.
# With starter point set, position is only searched around starter point and new position becomes new starter point.

View File

@ -101,7 +101,9 @@ class ResourceConst:
Returns:
float: Distance to current position
"""
return np.linalg.norm(np.subtract(target, self.position))
diff = np.linalg.norm(np.subtract(target, self.position))
diff = round(diff, 3)
return diff
def is_position_near(self, target, threshold=5):
return self.position_diff(target) <= threshold
@ -118,6 +120,7 @@ class ResourceConst:
theta = np.rad2deg(np.arccos(-diff[1] / np.linalg.norm(diff)))
if diff[0] < 0:
theta = 360 - theta
theta = round(theta, 3)
return theta
def direction_diff(self, target):

50
tasks/map/route/base.py Normal file
View File

@ -0,0 +1,50 @@
from tasks.map.control.control import MapControl
from tasks.map.control.waypoint import Waypoint
from tasks.map.keywords import MapPlane
class RouteBase(MapControl):
"""
Base class of `Route`
Every `Route` class must implement method `route()`
"""
def route_example(self):
"""
Pages:
in: page_main
out: page_main
Doesn't matter if in/out are not page_main, just be clear what you're doing
"""
self.map_init(
plane=...,
floor=...,
position=...,
)
self.clear_enemy(
Waypoint(...).run_2x(),
Waypoint(...),
)
def map_init(
self,
plane: MapPlane | str,
floor: str = 'F1',
position: tuple[int | float, int | float] = None
):
"""
Args:
plane (MapPlane, str): Such as Jarilo_AdministrativeDistrict
floor (str):
position: Initialize the starter point of minimap tracking
Leaving None will trigger brute-force starter point finding.
"""
try:
if self.device.image is None:
self.device.screenshot()
except AttributeError:
self.device.screenshot()
self.minimap.set_plane(plane, floor=floor)
if position is not None:
self.minimap.init_position(position)

38
tasks/map/route/loader.py Normal file
View File

@ -0,0 +1,38 @@
import importlib
import os
from module.exception import RequestHumanTakeover
from module.logger import logger
from tasks.base.ui import UI
from tasks.map.route.base import RouteBase
class RouteLoader(UI):
route: RouteBase
def route_run(self, route: str):
"""
Args:
route: .py module path such as `daily.forgotten_hall.stage1`
which will load `./route/daily/forgotten_hall/stage1.py`
"""
folder, name = route.rsplit('.', maxsplit=1)
path = f'./route/{route.replace(".", "/")}.py'
try:
module = importlib.import_module(f'route.{folder}.{name}')
except ModuleNotFoundError:
logger.critical(f'Route file not found: {route} ({path})')
if not os.path.exists(path):
logger.critical(f'Route file not exists: {path}')
raise RequestHumanTakeover
# config = copy.deepcopy(self.config).merge(module.Config())
config = self.config
device = self.device
try:
self.route = module.Route(config=config, device=device)
return self.route.route()
except AttributeError as e:
logger.critical(e)
logger.critical(f'Route file {route} ({path}) must define Route.route()')
raise RequestHumanTakeover