Merge pull request #99 from LmeSzinc/dev

Dev
This commit is contained in:
LmeSzinc 2023-09-20 13:37:01 +08:00 committed by GitHub
commit 8f2bfa625d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 510 additions and 74 deletions

BIN
assets/character/FuXuan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
assets/character/Lynx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 226 KiB

View File

@ -152,20 +152,21 @@ class ModuleBase:
prev_image = image prev_image = image
timer.reset() timer.reset()
def image_crop(self, button): def image_crop(self, button, copy=True):
"""Extract the area from image. """Extract the area from image.
Args: Args:
button(Button, tuple): Button instance or area tuple. button(Button, tuple): Button instance or area tuple.
copy:
""" """
if isinstance(button, Button): if isinstance(button, Button):
return crop(self.device.image, button.area) return crop(self.device.image, button.area, copy=copy)
elif isinstance(button, ButtonWrapper): elif isinstance(button, ButtonWrapper):
return crop(self.device.image, button.area) return crop(self.device.image, button.area, copy=copy)
elif hasattr(button, 'area'): elif hasattr(button, 'area'):
return crop(self.device.image, button.area) return crop(self.device.image, button.area, copy=copy)
else: else:
return crop(self.device.image, button) return crop(self.device.image, button, copy=copy)
def image_color_count(self, button, color, threshold=221, count=50): def image_color_count(self, button, color, threshold=221, count=50):
""" """
@ -178,9 +179,14 @@ class ModuleBase:
Returns: Returns:
bool: bool:
""" """
image = self.image_crop(button) if isinstance(button, np.ndarray):
mask = color_similarity_2d(image, color=color) > threshold image = button
return np.sum(mask) > count else:
image = self.image_crop(button, copy=False)
mask = color_similarity_2d(image, color=color)
cv2.inRange(mask, threshold, 255, dst=mask)
sum_ = cv2.countNonZero(mask)
return sum_ > count
def image_color_button(self, area, color, color_threshold=250, encourage=5, name='COLOR_BUTTON'): def image_color_button(self, area, color, color_threshold=250, encourage=5, name='COLOR_BUTTON'):
""" """

View File

@ -350,6 +350,7 @@
"Clara", "Clara",
"DanHeng", "DanHeng",
"DanHengImbibitorLunae", "DanHengImbibitorLunae",
"FuXuan",
"Gepard", "Gepard",
"Herta", "Herta",
"Himeko", "Himeko",
@ -358,6 +359,7 @@
"Kafka", "Kafka",
"Luka", "Luka",
"Luocha", "Luocha",
"Lynx",
"March7th", "March7th",
"Natasha", "Natasha",
"Pela", "Pela",

View File

@ -53,7 +53,7 @@ class GeneratedConfig:
# Group `DungeonSupport` # Group `DungeonSupport`
DungeonSupport_Use = 'when_daily' # always_use, when_daily, do_not_use DungeonSupport_Use = 'when_daily' # always_use, when_daily, do_not_use
DungeonSupport_Character = 'FirstCharacter' # FirstCharacter, Arlan, Asta, Bailu, Blade, Bronya, Clara, DanHeng, DanHengImbibitorLunae, Gepard, Herta, Himeko, Hook, JingYuan, Kafka, Luka, Luocha, March7th, Natasha, Pela, Qingque, Sampo, Seele, Serval, SilverWolf, Sushang, Tingyun, TrailblazerDestruction, TrailblazerPreservation, Welt, Yanqing, Yukong DungeonSupport_Character = 'FirstCharacter' # FirstCharacter, Arlan, Asta, Bailu, Blade, Bronya, Clara, DanHeng, DanHengImbibitorLunae, FuXuan, Gepard, Herta, Himeko, Hook, JingYuan, Kafka, Luka, Luocha, Lynx, March7th, Natasha, Pela, Qingque, Sampo, Seele, Serval, SilverWolf, Sushang, Tingyun, TrailblazerDestruction, TrailblazerPreservation, Welt, Yanqing, Yukong
# Group `DungeonStorage` # Group `DungeonStorage`
DungeonStorage_TrailblazePower = {} DungeonStorage_TrailblazePower = {}

View File

@ -87,7 +87,7 @@ class ConfigGenerator:
options=[dungeon.name for dungeon in DungeonList.instances.values() if dungeon.is_Cavern_of_Corrosion]) options=[dungeon.name for dungeon in DungeonList.instances.values() if dungeon.is_Cavern_of_Corrosion])
# Insert characters # Insert characters
from tasks.character.keywords import CharacterList from tasks.character.keywords import CharacterList
unsupported_characters = ["FuXuan", "Lynx"] unsupported_characters = []
characters = [character.name for character in CharacterList.instances.values() characters = [character.name for character in CharacterList.instances.values()
if character.name not in unsupported_characters] if character.name not in unsupported_characters]
option_add(keys='DungeonSupport.Character.option', options=characters) option_add(keys='DungeonSupport.Character.option', options=characters)

View File

@ -350,6 +350,7 @@
"Clara": "Clara", "Clara": "Clara",
"DanHeng": "Dan Heng", "DanHeng": "Dan Heng",
"DanHengImbibitorLunae": "Dan Heng • Imbibitor Lunae", "DanHengImbibitorLunae": "Dan Heng • Imbibitor Lunae",
"FuXuan": "Fu Xuan",
"Gepard": "Gepard", "Gepard": "Gepard",
"Herta": "Herta", "Herta": "Herta",
"Himeko": "Himeko", "Himeko": "Himeko",
@ -358,6 +359,7 @@
"Kafka": "Kafka", "Kafka": "Kafka",
"Luka": "Luka", "Luka": "Luka",
"Luocha": "Luocha", "Luocha": "Luocha",
"Lynx": "Lynx",
"March7th": "March 7th", "March7th": "March 7th",
"Natasha": "Natasha", "Natasha": "Natasha",
"Pela": "Pela", "Pela": "Pela",

View File

@ -350,6 +350,7 @@
"Clara": "クラーラ", "Clara": "クラーラ",
"DanHeng": "丹恒", "DanHeng": "丹恒",
"DanHengImbibitorLunae": "丹恒・飲月", "DanHengImbibitorLunae": "丹恒・飲月",
"FuXuan": "符玄",
"Gepard": "ジェパード", "Gepard": "ジェパード",
"Herta": "ヘルタ", "Herta": "ヘルタ",
"Himeko": "姫子", "Himeko": "姫子",
@ -358,6 +359,7 @@
"Kafka": "カフカ", "Kafka": "カフカ",
"Luka": "ルカ", "Luka": "ルカ",
"Luocha": "羅刹", "Luocha": "羅刹",
"Lynx": "リンクス",
"March7th": "三月なのか", "March7th": "三月なのか",
"Natasha": "ナターシャ", "Natasha": "ナターシャ",
"Pela": "ペラ", "Pela": "ペラ",

View File

@ -350,6 +350,7 @@
"Clara": "克拉拉", "Clara": "克拉拉",
"DanHeng": "丹恒", "DanHeng": "丹恒",
"DanHengImbibitorLunae": "丹恒•饮月", "DanHengImbibitorLunae": "丹恒•饮月",
"FuXuan": "符玄",
"Gepard": "杰帕德", "Gepard": "杰帕德",
"Herta": "黑塔", "Herta": "黑塔",
"Himeko": "姬子", "Himeko": "姬子",
@ -358,6 +359,7 @@
"Kafka": "卡芙卡", "Kafka": "卡芙卡",
"Luka": "卢卡", "Luka": "卢卡",
"Luocha": "罗刹", "Luocha": "罗刹",
"Lynx": "玲可",
"March7th": "三月七", "March7th": "三月七",
"Natasha": "娜塔莎", "Natasha": "娜塔莎",
"Pela": "佩拉", "Pela": "佩拉",

View File

@ -350,6 +350,7 @@
"Clara": "克拉拉", "Clara": "克拉拉",
"DanHeng": "丹恆", "DanHeng": "丹恆",
"DanHengImbibitorLunae": "丹恆•飲月", "DanHengImbibitorLunae": "丹恆•飲月",
"FuXuan": "符玄",
"Gepard": "傑帕德", "Gepard": "傑帕德",
"Herta": "黑塔", "Herta": "黑塔",
"Himeko": "姬子", "Himeko": "姬子",
@ -358,6 +359,7 @@
"Kafka": "卡芙卡", "Kafka": "卡芙卡",
"Luka": "盧卡", "Luka": "盧卡",
"Luocha": "羅剎", "Luocha": "羅剎",
"Lynx": "玲可",
"March7th": "三月七", "March7th": "三月七",
"Natasha": "娜塔莎", "Natasha": "娜塔莎",
"Pela": "佩拉", "Pela": "佩拉",

View File

@ -389,6 +389,11 @@ class Duration(Ocr):
}[lang] }[lang]
return re.compile(regex_str) return re.compile(regex_str)
def after_process(self, result):
result = super().after_process(result)
result = result.strip('.,。,')
return result
def format_result(self, result: str) -> timedelta: def format_result(self, result: str) -> timedelta:
""" """
Do OCR on a duration, such as `18d 2h 13m 30s`, `2h`, `13m 30s`, `9s` Do OCR on a duration, such as `18d 2h 13m 30s`, `2h`, `13m 30s`, `9s`

View File

@ -106,7 +106,7 @@ class Combat(CombatInteract, CombatPrepare, CombatState, CombatTeam, CombatSuppo
continue continue
if self.appear(COMBAT_TEAM_PREPARE): if self.appear(COMBAT_TEAM_PREPARE):
self.interval_reset(COMBAT_PREPARE) self.interval_reset(COMBAT_PREPARE)
self._map_A_timer.reset() self.map_A_timer.reset()
if self.appear(COMBAT_PREPARE, interval=2): if self.appear(COMBAT_PREPARE, interval=2):
if not self.handle_combat_prepare(): if not self.handle_combat_prepare():
return False return False

View File

@ -210,13 +210,23 @@ DAILY_QUEST_RIGHT_END = ButtonWrapper(
button=(401, 259, 411, 632), button=(401, 259, 411, 632),
), ),
) )
MASK_DAILY_QUEST = ButtonWrapper(
name='MASK_DAILY_QUEST',
share=Button(
file='./assets/share/daily/reward/MASK_DAILY_QUEST.png',
area=(117, 308, 1173, 630),
search=(97, 288, 1193, 650),
color=(208, 208, 208),
button=(117, 308, 1173, 630),
),
)
OCR_DAILY_QUEST = ButtonWrapper( OCR_DAILY_QUEST = ButtonWrapper(
name='OCR_DAILY_QUEST', name='OCR_DAILY_QUEST',
share=Button( share=Button(
file='./assets/share/daily/reward/OCR_DAILY_QUEST.png', file='./assets/share/daily/reward/OCR_DAILY_QUEST.png',
area=(117, 257, 1173, 630), area=(117, 308, 1173, 630),
search=(97, 237, 1193, 650), search=(97, 288, 1193, 650),
color=(208, 206, 202), color=(204, 202, 199),
button=(117, 257, 1173, 630), button=(117, 308, 1173, 630),
), ),
) )

View File

@ -1,9 +1,12 @@
import cv2
import numpy as np import numpy as np
from module.base.timer import Timer from module.base.timer import Timer
from module.logger import * from module.base.utils import crop
from module.logger import logger
from module.ocr.ocr import Ocr, OcrResultButton from module.ocr.ocr import Ocr, OcrResultButton
from module.ocr.utils import split_and_pair_buttons from module.ocr.utils import split_and_pair_buttons
from tasks.battle_pass.keywords import KEYWORD_BATTLE_PASS_QUEST
from tasks.daily.assets.assets_daily_reward import * from tasks.daily.assets.assets_daily_reward import *
from tasks.daily.camera import CameraUI from tasks.daily.camera import CameraUI
from tasks.daily.keywords import ( from tasks.daily.keywords import (
@ -19,12 +22,18 @@ from tasks.dungeon.keywords import KEYWORDS_DUNGEON_TAB
from tasks.dungeon.ui import DungeonUI from tasks.dungeon.ui import DungeonUI
from tasks.item.consumable_usage import ConsumableUsageUI from tasks.item.consumable_usage import ConsumableUsageUI
from tasks.item.relics import RelicsUI from tasks.item.relics import RelicsUI
from tasks.battle_pass.keywords import KEYWORD_BATTLE_PASS_QUEST
class DailyQuestOcr(Ocr): class DailyQuestOcr(Ocr):
def __init__(self, button: ButtonWrapper, lang=None, name=None): merge_thres_y = 20
super().__init__(button, lang, name)
def pre_process(self, image):
image = super().pre_process(image)
image = crop(image, OCR_DAILY_QUEST.area)
mask = MASK_DAILY_QUEST.matched_button.image
# Remove "+200Activity"
cv2.bitwise_and(image, mask, dst=image)
return image
def after_process(self, result): def after_process(self, result):
result = super().after_process(result) result = super().after_process(result)
@ -91,8 +100,7 @@ class DailyQuestUI(DungeonUI):
def _ocr_single_page(self) -> list[OcrResultButton]: def _ocr_single_page(self) -> list[OcrResultButton]:
ocr = DailyQuestOcr(OCR_DAILY_QUEST) ocr = DailyQuestOcr(OCR_DAILY_QUEST)
ocr.merge_thres_y = 20 results = ocr.matched_ocr(self.device.image, [DailyQuestState, DailyQuest], direct_ocr=True)
results = ocr.matched_ocr(self.device.image, [DailyQuestState, DailyQuest])
if len(results) < 8: if len(results) < 8:
logger.warning(f"Recognition failed at {8 - len(results)} quests on one page") logger.warning(f"Recognition failed at {8 - len(results)} quests on one page")

View File

@ -248,7 +248,7 @@ class Dungeon(DungeonUI, DungeonEvent, Combat):
area = area_offset((-50, -150, 0, 0), offset=self.config.ASSETS_RESOLUTION) area = area_offset((-50, -150, 0, 0), offset=self.config.ASSETS_RESOLUTION)
skip_first_screenshot = True skip_first_screenshot = True
self._map_A_timer.reset() self.map_A_timer.reset()
handled = False handled = False
while 1: while 1:
if skip_first_screenshot: if skip_first_screenshot:
@ -265,7 +265,7 @@ class Dungeon(DungeonUI, DungeonEvent, Combat):
logger.info(f'No destructible object') logger.info(f'No destructible object')
if not handled: if not handled:
break break
if self._map_A_timer.reached(): if self.map_A_timer.reached():
break break
return handled return handled

View File

@ -4,7 +4,7 @@ from module.base.timer import Timer
from module.logger import logger from module.logger import logger
from tasks.map.assets.assets_map_control import ROTATION_SWIPE_AREA from tasks.map.assets.assets_map_control import ROTATION_SWIPE_AREA
from tasks.map.control.joystick import JoystickContact, MapControlJoystick from tasks.map.control.joystick import JoystickContact, MapControlJoystick
from tasks.map.control.waypoint import Waypoint, WaypointRun, WaypointStraightRun, ensure_waypoint from tasks.map.control.waypoint import Waypoint, ensure_waypoint
from tasks.map.minimap.minimap import Minimap from tasks.map.minimap.minimap import Minimap
from tasks.map.resource.const import diff_to_180_180 from tasks.map.resource.const import diff_to_180_180
@ -76,7 +76,7 @@ class MapControl(MapControlJoystick):
self, self,
contact: JoystickContact, contact: JoystickContact,
waypoint: Waypoint, waypoint: Waypoint,
end_point_opt=True, end_opt=True,
skip_first_screenshot=False skip_first_screenshot=False
): ):
""" """
@ -88,7 +88,7 @@ class MapControl(MapControlJoystick):
`with JoystickContact(self) as contact:` `with JoystickContact(self) as contact:`
waypoint: waypoint:
Position to goto, (x, y) Position to goto, (x, y)
end_point_opt: end_opt:
True to enable endpoint optimizations, True to enable endpoint optimizations,
character will smoothly approach target position character will smoothly approach target position
skip_first_screenshot: skip_first_screenshot:
@ -98,7 +98,7 @@ class MapControl(MapControlJoystick):
self.device.stuck_record_clear() self.device.stuck_record_clear()
self.device.click_record_clear() self.device.click_record_clear()
end_point_opt = end_point_opt and waypoint.end_point_opt end_opt = end_opt and waypoint.end_opt
allow_2x_run = waypoint.speed in ['2x_run'] allow_2x_run = waypoint.speed in ['2x_run']
allow_straight_run = waypoint.speed in ['2x_run', 'straight_run'] allow_straight_run = waypoint.speed in ['2x_run', 'straight_run']
allow_run = waypoint.speed in ['2x_run', 'straight_run', 'run'] allow_run = waypoint.speed in ['2x_run', 'straight_run', 'run']
@ -117,20 +117,20 @@ class MapControl(MapControlJoystick):
self.minimap.update(self.device.image) self.minimap.update(self.device.image)
# Arrive # Arrive
if self.minimap.is_position_near(waypoint.position, threshold=waypoint.get_threshold(end_point_opt)): if self.minimap.is_position_near(waypoint.position, threshold=waypoint.get_threshold(end_opt)):
logger.info(f'Arrive {waypoint}') logger.info(f'Arrive {waypoint}')
break break
# Switch run case # Switch run case
diff = self.minimap.position_diff(waypoint.position) diff = self.minimap.position_diff(waypoint.position)
if end_point_opt: if end_opt:
if allow_2x_run and diff < 20: if allow_2x_run and diff < 20:
logger.info(f'Approaching target, diff={round(diff, 1)}, disallow 2x_run') logger.info(f'Approaching target, diff={round(diff, 1)}, disallow 2x_run')
allow_2x_run = False allow_2x_run = False
if allow_straight_run and diff < 15: if allow_straight_run and diff < 15:
logger.info(f'Approaching target, diff={round(diff, 1)}, disallow straight_run') logger.info(f'Approaching target, diff={round(diff, 1)}, disallow straight_run')
direction_interval = Timer(0.2) direction_interval = Timer(0.2)
self._map_2x_run_timer.reset() self.map_2x_run_timer.reset()
allow_straight_run = False allow_straight_run = False
if allow_run and diff < 7: if allow_run and diff < 7:
logger.info(f'Approaching target, diff={round(diff, 1)}, disallow run') logger.info(f'Approaching target, diff={round(diff, 1)}, disallow run')
@ -160,7 +160,7 @@ class MapControl(MapControlJoystick):
direction_interval.reset() direction_interval.reset()
self.handle_map_2x_run(run=True) self.handle_map_2x_run(run=True)
elif allow_straight_run: elif allow_straight_run:
# Run with 2x_run button # Run straight forward
# - Set rotation once # - Set rotation once
# - Continuous fine-tuning direction # - Continuous fine-tuning direction
# - Disable 2x_run # - Disable 2x_run
@ -204,7 +204,7 @@ class MapControl(MapControlJoystick):
def goto( def goto(
self, self,
waypoints, *waypoints,
skip_first_screenshot=True skip_first_screenshot=True
): ):
""" """
@ -218,8 +218,6 @@ class MapControl(MapControlJoystick):
skip_first_screenshot: skip_first_screenshot:
""" """
logger.hr('Goto', level=1) logger.hr('Goto', level=1)
if not isinstance(waypoints, list):
waypoints = [waypoints]
waypoints = [ensure_waypoint(point) for point in waypoints] waypoints = [ensure_waypoint(point) for point in waypoints]
logger.info(f'Go along {len(waypoints)} waypoints') logger.info(f'Go along {len(waypoints)} waypoints')
end_list = [False for _ in waypoints] end_list = [False for _ in waypoints]
@ -231,35 +229,36 @@ class MapControl(MapControlJoystick):
self._goto( self._goto(
contact=contact, contact=contact,
waypoint=point, waypoint=point,
end_point_opt=end, end_opt=end,
skip_first_screenshot=skip_first_screenshot skip_first_screenshot=skip_first_screenshot
) )
skip_first_screenshot = True skip_first_screenshot = True
end_point = waypoints[-1] end_point = waypoints[-1]
if end_point.end_point_rotation is not None: if end_point.end_rotation is not None:
self.rotation_set(end_point.end_point_rotation, threshold=end_point.end_point_rotation_threshold) logger.hr('End rotation', level=1)
self.rotation_set(end_point.end_rotation, threshold=end_point.end_rotation_threshold)
if __name__ == '__main__': if __name__ == '__main__':
# Control test in Himeko trail # Control test in Himeko trial
# Must manually enter Himeko trail first and dismiss popup # Must manually enter Himeko trial first and dismiss popup
self = MapControl('alas') self = MapControl('src')
self.minimap.set_plane('Jarilo_BackwaterPass', floor='F1') self.minimap.set_plane('Jarilo_BackwaterPass', floor='F1')
self.device.screenshot() self.device.screenshot()
self.minimap.init_position((519, 359)) self.minimap.init_position((519, 359))
# Visit 3 items # Visit 3 items
self.goto([ self.goto(
WaypointRun((577.6, 363.4)), Waypoint((577.6, 363.4)),
]) )
self.goto([ self.goto(
WaypointStraightRun((577.5, 369.4), end_point_rotation=200), Waypoint((577.5, 369.4), end_rotation=200),
]) )
self.goto([ self.goto(
WaypointRun((581.5, 387.3)), Waypoint((581.5, 387.3)),
WaypointRun((577.4, 411.5)), Waypoint((577.4, 411.5)),
]) )
# Goto boss # Goto boss
self.goto([ self.goto(
WaypointStraightRun((607.6, 425.3)), Waypoint((607.6, 425.3)),
]) )

View File

@ -1,6 +1,9 @@
import math import math
from functools import cached_property from functools import cached_property
import cv2
import numpy as np
from module.base.timer import Timer from module.base.timer import Timer
from module.device.method.maatouch import MaatouchBuilder from module.device.method.maatouch import MaatouchBuilder
from module.device.method.minitouch import CommandBuilder, insert_swipe, random_normal_distribution from module.device.method.minitouch import CommandBuilder, insert_swipe, random_normal_distribution
@ -36,10 +39,8 @@ class JoystickContact:
Can not lift finger when: Can not lift finger when:
- Process is force terminated - Process is force terminated
""" """
builder = self.builder
if self.is_downed: if self.is_downed:
builder.up().commit() self.up()
builder.send()
logger.info('JoystickContact ends') logger.info('JoystickContact ends')
else: else:
logger.info('JoystickContact ends but it was never downed') logger.info('JoystickContact ends but it was never downed')
@ -98,6 +99,13 @@ class JoystickContact:
point = (int(round(point[0])), int(round(point[1]))) point = (int(round(point[0])), int(round(point[1])))
return point return point
def up(self):
builder = self.builder
if self.is_downed:
builder.up().commit()
builder.send()
self.prev_point = None
def set(self, direction, run=True): def set(self, direction, run=True):
""" """
Set joystick to given position Set joystick to given position
@ -110,6 +118,13 @@ class JoystickContact:
point = JoystickContact.direction2screen(direction, run=run) point = JoystickContact.direction2screen(direction, run=run)
builder = self.builder builder = self.builder
if self.is_downed and not self.main.joystick_speed():
if self.main.joystick_lost_timer.reached():
logger.warning(f'Joystick contact lost: {self.main.joystick_lost_timer}, re-down')
self.up()
else:
self.main.joystick_lost_timer.reset()
if self.is_downed: if self.is_downed:
points = insert_swipe(p0=self.prev_point, p3=point, speed=20) points = insert_swipe(p0=self.prev_point, p3=point, speed=20)
for point in points[1:]: for point in points[1:]:
@ -121,20 +136,56 @@ class JoystickContact:
# Character starts moving, RUN button is still unavailable in a short time. # Character starts moving, RUN button is still unavailable in a short time.
# Assume available in 0.3s # Assume available in 0.3s
# We still have reties if 0.3s is incorrect. # We still have reties if 0.3s is incorrect.
self.main._map_2x_run_timer.set_current(0.7) self.main.map_2x_run_timer.set_current(0.7)
self.main.joystick_lost_timer.reset()
self.prev_point = point self.prev_point = point
class MapControlJoystick(UI): class MapControlJoystick(UI):
_map_A_timer = Timer(1) map_A_timer = Timer(1)
_map_E_timer = Timer(1) map_E_timer = Timer(1)
_map_2x_run_timer = Timer(1) map_2x_run_timer = Timer(1)
joystick_lost_timer = Timer(1, count=2)
@cached_property @cached_property
def joystick_center(self) -> tuple[float, float]: def joystick_center(self) -> tuple[int, int]:
x1, y1, x2, y2 = JOYSTICK.area x1, y1, x2, y2 = JOYSTICK.area
return (x1 + x2) / 2, (y1 + y2) / 2 return int((x1 + x2) // 2), int((y1 + y2) // 2)
@cached_property
def DirectionRemapData(self):
d = JoystickContact.RADIUS_RUN[1] * 2
mx = np.zeros((d, d), dtype=np.float32)
my = np.zeros((d, d), dtype=np.float32)
for i in range(d):
for j in range(d):
mx[i, j] = d / 2 + i / 2 * np.cos(2 * np.pi * j / d)
my[i, j] = d / 2 + i / 2 * np.sin(2 * np.pi * j / d)
return mx, my
def joystick_speed(self) -> str:
"""
Returns:
str: 'run', 'walk', ''
"""
# About 1.5ms
x, y = self.joystick_center
radius = JoystickContact.RADIUS_RUN[1]
image = self.image_crop((x - radius, y - radius, x + radius, y + radius), copy=False)
image = cv2.remap(image, *self.DirectionRemapData, cv2.INTER_CUBIC)
# 190~205
run = image[185:210, :]
if self.image_color_count(run, color=(223, 199, 145), threshold=221, count=100):
return 'run'
# 90~100
walk = image[85:105, :]
if self.image_color_count(walk, color=(235, 235, 235), threshold=221, count=50):
return 'walk'
return ''
def map_get_technique_points(self): def map_get_technique_points(self):
""" """
@ -162,9 +213,9 @@ class MapControlJoystick(UI):
Returns: Returns:
bool: If clicked. bool: If clicked.
""" """
if self._map_A_timer.reached(): if self.map_A_timer.reached():
self.device.click(A_BUTTON) self.device.click(A_BUTTON)
self._map_A_timer.reset() self.map_A_timer.reset()
return True return True
return False return False
@ -177,9 +228,9 @@ class MapControlJoystick(UI):
Returns: Returns:
bool: If clicked. bool: If clicked.
""" """
if self._map_E_timer.reached(): if self.map_E_timer.reached():
self.device.click(E_BUTTON) self.device.click(E_BUTTON)
self._map_E_timer.reset() self.map_E_timer.reset()
return True return True
return False return False
@ -194,13 +245,13 @@ class MapControlJoystick(UI):
""" """
is_running = self.image_color_count(RUN_BUTTON, color=(208, 183, 138), threshold=221, count=100) 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_2x_run_timer.reached():
self.device.click(RUN_BUTTON) self.device.click(RUN_BUTTON)
self._map_2x_run_timer.reset() self.map_2x_run_timer.reset()
return True return True
if not run and is_running and self._map_2x_run_timer.reached(): if not run and is_running and self.map_2x_run_timer.reached():
self.device.click(RUN_BUTTON) self.device.click(RUN_BUTTON)
self._map_2x_run_timer.reset() self.map_2x_run_timer.reset()
return True return True
return False return False

View File

@ -13,17 +13,17 @@ class Waypoint:
endpoint_threshold: int = 3 endpoint_threshold: int = 3
# Max move speed, '2x_run', 'straight_run', 'run', 'walk' # Max move speed, '2x_run', 'straight_run', 'run', 'walk'
# See MapControl._goto() for details of each speed level # See MapControl._goto() for details of each speed level
speed: str = '2x_run' speed: str = 'straight_run'
""" """
The following attributes are only be used if this waypoint is the end point of goto() The following attributes are only be used if this waypoint is the end point of goto()
""" """
# True to enable endpoint optimizations, character will smoothly approach target position # True to enable endpoint optimizations, character will smoothly approach target position
# False to stop all controls at arrive # False to stop all controls at arrive
end_point_opt: bool = True end_opt: bool = True
# Set rotation after arrive, 0~360 # Set rotation after arrive, 0~360
end_point_rotation: int = None end_rotation: int = None
end_point_rotation_threshold: int = 15 end_rotation_threshold: int = 15
def __str__(self): def __str__(self):
return f'Waypoint({self.position})' return f'Waypoint({self.position})'

347
tasks/map/interact/aim.py Normal file
View File

@ -0,0 +1,347 @@
import cv2
import numpy as np
from module.base.decorator import cached_property, del_cached_property
from module.base.utils import Points, image_size, load_image
from module.config.utils import dict_to_kv
from module.logger import logger
from tasks.base.ui import UI
def inrange(image, lower=0, upper=255):
"""
Get the coordinates of pixels in range.
Equivalent to `np.array(np.where(lower <= image <= upper))` but faster.
Note that this method will change `image`.
`cv2.findNonZero()` is faster than `np.where`
points = np.array(np.where(y > 24)).T[:, ::-1]
points = np.array(cv2.findNonZero((y > 24).astype(np.uint8)))[:, 0, :]
`cv2.inRange(y, 24)` is faster than `y > 24`
cv2.inRange(y, 24, 255, dst=y)
y = y > 24
Returns:
np.ndarray: Shape (N, 2)
E.g. [[x1, y1], [x2, y2], ...]
"""
cv2.inRange(image, lower, upper, dst=image)
try:
return np.array(cv2.findNonZero(image))[:, 0, :]
except IndexError:
# Empty result
# IndexError: too many indices for array: array is 0-dimensional, but 3 were indexed
return np.array([])
def subtract_blur(image, radius=3, negative=False):
"""
If you care performance more than quality:
- radius=3, use medianBlur
- radius=5,7,9,11, use GaussianBlur
- radius>11, use stackBlur (requires opencv >= 4.7.0)
Args:
image:
radius:
negative:
Returns:
np.ndarray:
"""
if radius <= 3:
blur = cv2.medianBlur(image, radius)
elif radius <= 11:
blur = cv2.GaussianBlur(image, (radius, radius), 0)
else:
blur = cv2.stackBlur(image, (radius, radius), 0)
if negative:
cv2.subtract(blur, image, dst=blur)
else:
cv2.subtract(image, blur, dst=blur)
return blur
def remove_border(image, radius):
"""
Paint edge pixels black.
No returns, changes are written to `image`
Args:
image:
radius:
"""
width, height = image_size(image)
image[:, :radius + 1] = 0
image[:, width - radius:] = 0
image[:radius + 1, :] = 0
image[height - radius:, :] = 0
def create_circle(min_radius, max_radius):
"""
Create a circle with min_radius <= R <= max_radius.
1 represents circle, 0 represents background
Args:
min_radius:
max_radius:
Returns:
np.ndarray:
"""
circle = np.ones((max_radius * 2 + 1, max_radius * 2 + 1), dtype=np.uint8)
center = np.array((max_radius, max_radius))
points = np.array(np.meshgrid(np.arange(circle.shape[0]), np.arange(circle.shape[1]))).T
distance = np.linalg.norm(points - center, axis=2)
circle[distance < min_radius] = 0
circle[distance > max_radius] = 0
return circle
def draw_circle(image, circle, points):
"""
Add a circle onto image.
No returns, changes are written to `image`
Args:
image:
circle: Created from create_circle()
points: (x, y), center of the circle to draw
"""
width, height = image_size(circle)
x1 = -int(width // 2)
y1 = -int(height // 2)
x2 = width + x1
y2 = height + y1
for point in points:
x, y = point
# Fancy index is faster
index = image[y + y1:y + y2, x + x1:x + x2]
# print(index.shape)
cv2.add(index, circle, dst=index)
class Aim:
radius_enemy = (24, 25)
radius_item = (8, 10)
def __init__(self):
self.debug = False
self.draw_item = None
self.draw_enemy = None
self.points_item = None
self.points_enemy = None
def clear_image_cache(self):
self.draw_item = None
self.draw_enemy = None
self.points_item = None
self.points_enemy = None
del_cached_property(self, 'aimed_enemy')
del_cached_property(self, 'aimed_item')
@cached_property
def mask_interact(self):
return load_image('./assets/mask/MASK_MAP_INTERACT.png')
@cached_property
def circle_enemy(self):
return create_circle(*self.radius_enemy)
@cached_property
def circle_item(self):
return create_circle(*self.radius_item)
# @timer
def predict_enemy(self, h, v):
min_radius, max_radius = self.radius_enemy
width, height = image_size(v)
# Get white circle `y`
y = subtract_blur(h, 3, negative=False)
cv2.inRange(h, 168, 255, h)
cv2.bitwise_and(y, h, dst=y)
# Get red glow `v`
cv2.inRange(v, 168, 255, dst=v)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
cv2.dilate(v, kernel, dst=v)
# Remove noise and leave red circle only
cv2.bitwise_and(y, v, dst=y)
# cv2.imshow('predict_enemy', y)
# Remove game UI
cv2.bitwise_and(y, self.mask_interact, dst=y)
# Remove points on the edge, or draw_circle() will overflow
remove_border(y, max_radius)
# Get all pixels
points = inrange(y, lower=18)
if points.shape[0] > 1000:
logger.warning(f'AimDetector.predict_enemy() too many points to draw: {points.shape}')
# Draw circles
draw = np.zeros((height, width), dtype=np.uint8)
draw_circle(draw, self.circle_enemy, points)
if self.debug:
self.draw_enemy = cv2.multiply(draw, 4)
subtract_blur(draw, 3)
# Find peaks
points = inrange(draw, lower=36)
points = Points(points).group(threshold=10)
if points.shape[0] > 3:
logger.warning(f'AimDetector.predict_enemy() too many peaks: {points.shape}')
self.points_enemy = points
# print(points)
return points
# @timer
def predict_item(self, v):
min_radius, max_radius = self.radius_item
width, height = image_size(v)
# Get white circle `y`
y = subtract_blur(v, 9)
white = cv2.inRange(v, 112, 144)
cv2.bitwise_and(y, white, dst=y)
# Get cyan glow `v`
cv2.inRange(v, 0, 84, dst=v)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
cv2.dilate(v, kernel, dst=v)
# Remove noise and leave cyan circle only
cv2.bitwise_and(y, v, dst=y)
# Remove game UI
cv2.bitwise_and(y, self.mask_interact, dst=y)
# Remove points on the edge, or draw_circle() will overflow
remove_border(y, max_radius)
# Get all pixels
points = inrange(y, lower=18)
# print(points.shape)
if points.shape[0] > 1000:
logger.warning(f'AimDetector.predict_item() too many points to draw: {points.shape}')
# Draw circles
draw = np.zeros((height, width), dtype=np.uint8)
draw_circle(draw, self.circle_item, points)
if self.debug:
self.draw_item = cv2.multiply(draw, 2)
subtract_blur(draw, 7)
# Find peaks
points = inrange(draw, lower=64)
points = Points(points).group(threshold=10)
if points.shape[0] > 3:
logger.warning(f'AimDetector.predict_item() too many peaks: {points.shape}')
self.points_item = points
# print(points)
return points
# @timer
def predict(self, image, enemy=True, item=True, show_log=True, debug=False):
"""
Predict `aim` on image, costs about 10.0~10.5ms.
Args:
image:
enemy: True to predict enemy
item: True to predict item
show_log:
debug: True to show AimDetector image
"""
self.debug = debug
self.clear_image_cache()
if isinstance(image, str):
image = load_image(image)
# 1.5~2.0ms
yuv = cv2.cvtColor(image, cv2.COLOR_RGB2YUV)
v = yuv[:, :, 2]
h = yuv[:, :, 0]
# 4.0~4.5ms
if enemy:
self.predict_enemy(h.copy(), v.copy())
# 3.0~3.5ms
if item:
self.predict_item(v.copy())
if show_log:
kv = {}
if self.aimed_enemy:
kv['enemy'] = self.aimed_enemy
if self.aimed_item:
kv['item'] = self.aimed_item
if kv:
logger.info(f'Aimed: {dict_to_kv(kv)}')
if debug:
self.show_aim()
def show_aim(self):
if self.draw_enemy is None:
if self.draw_item is None:
return
else:
r = g = b = self.draw_item
else:
if self.draw_item is None:
r = g = b = self.draw_enemy
else:
r = self.draw_enemy
g = b = self.draw_item
image = cv2.merge([b, g, r])
cv2.imshow('AimDetector', image)
cv2.waitKey(1)
@cached_property
def aimed_enemy(self) -> tuple[int, int] | None:
if self.points_enemy is None:
return None
try:
_ = self.points_enemy[1]
logger.warning(f'Multiple aimed enemy found, using first point of {self.points_enemy}')
except IndexError:
pass
try:
point = self.points_enemy[0]
return tuple(point)
except IndexError:
return None
@cached_property
def aimed_item(self) -> tuple[int, int] | None:
if self.points_item is None:
return None
try:
_ = self.points_item[1]
logger.warning(f'Multiple aimed item found, using first point of {self.points_item}')
except IndexError:
pass
try:
point = self.points_item[0]
return tuple(point)
except IndexError:
return None
class AimDetectorMixin(UI):
@cached_property
def aim(self):
return Aim()
if __name__ == '__main__':
"""
Test
"""
self = AimDetectorMixin('src')
self.device.disable_stuck_detection()
while 1:
self.device.screenshot()
self.aim.predict(self.device.image, debug=True)