StarRailCopilot/tasks/map/control/joystick.py

263 lines
8.3 KiB
Python
Raw Normal View History

2023-08-20 17:28:30 +00:00
import math
2023-06-27 18:03:23 +00:00
from functools import cached_property
2023-09-19 17:20:52 +00:00
import cv2
import numpy as np
2023-06-27 18:03:23 +00:00
from module.base.timer import Timer
2023-08-20 17:28:30 +00:00
from module.device.method.maatouch import MaatouchBuilder
from module.device.method.minitouch import CommandBuilder, insert_swipe, random_normal_distribution
from module.exception import ScriptError
2023-06-27 18:03:23 +00:00
from module.logger import logger
from tasks.base.ui import UI
from tasks.map.assets.assets_map_control import *
2023-08-20 17:28:30 +00:00
class JoystickContact:
CENTER = (JOYSTICK.area[0] + JOYSTICK.area[2]) / 2, (JOYSTICK.area[1] + JOYSTICK.area[3]) / 2
# Minimum radius 49px
2023-09-21 16:23:00 +00:00
RADIUS_WALK = (25, 40)
2023-08-20 17:28:30 +00:00
# Minimum radius 103px
RADIUS_RUN = (105, 115)
def __init__(self, main):
"""
Args:
main (MapControlJoystick):
"""
self.main = main
self.prev_point = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""
Lift finger when:
- Walk event ends, JoystickContact ends
- Any error is raised
Can not lift finger when:
- Process is force terminated
"""
if self.is_downed:
2023-09-19 17:20:52 +00:00
self.up()
2023-08-20 17:28:30 +00:00
logger.info('JoystickContact ends')
else:
logger.info('JoystickContact ends but it was never downed')
@property
def is_downed(self):
return self.prev_point is not None
@cached_property
def builder(self):
"""
Initialize a command builder
"""
method = self.main.config.Emulator_ControlMethod
if method == 'MaaTouch':
# Get the very first builder to initialize MaaTouch
_ = self.main.device.maatouch_builder
builder = MaatouchBuilder(self.main.device, contact=1)
elif method == 'minitouch':
# Get the very first builder to initialize minitouch
_ = self.main.device.minitouch_builder
builder = CommandBuilder(self.main.device, contact=1)
else:
raise ScriptError(f'Control method {method} does not support multi-finger, '
f'please use MaaTouch or minitouch instead')
# def empty_func():
# pass
#
# # No clear()
# builder.clear = empty_func
# No delay
builder.DEFAULT_DELAY = 0.
return builder
@classmethod
def direction2screen(cls, direction, run=True):
"""
Args:
2023-10-04 17:58:14 +00:00
direction (int, float): Direction to goto (-180~180)
2023-08-20 17:28:30 +00:00
run: True for character running, False for walking
Returns:
tuple[int, int]: Position on screen to control joystick
"""
direction += random_normal_distribution(-5, 5, n=5)
radius = cls.RADIUS_RUN if run else cls.RADIUS_WALK
radius = random_normal_distribution(*radius, n=5)
direction = math.radians(direction)
# Contact at the lower is limited within `cls.CENTER[1] - half_run_radius`
# or will exceed the joystick area
# Random radius * multiplier makes the point randomly approaching the lower bound
for multiplier in [1.0, 0.95, 0.90, 0.85, 0.80, 0.75]:
point = (
cls.CENTER[0] + radius * multiplier * math.sin(direction),
cls.CENTER[1] - radius * multiplier * math.cos(direction),
)
point = (int(round(point[0])), int(round(point[1])))
if point[1] <= cls.CENTER[1] - 101:
return point
2023-08-20 17:28:30 +00:00
return point
2023-09-19 17:20:52 +00:00
def up(self):
builder = self.builder
builder.up().commit()
builder.send()
2023-09-19 17:20:52 +00:00
self.prev_point = None
2023-08-20 17:28:30 +00:00
def set(self, direction, run=True):
"""
Set joystick to given position
Args:
2023-10-04 17:58:14 +00:00
direction (int, float): Direction to goto (-180~180)
2023-08-20 17:28:30 +00:00
run: True for character running, False for walking
"""
2023-09-21 16:23:00 +00:00
logger.info(f'JoystickContact set to {direction}, run={run}')
2023-08-20 17:28:30 +00:00
point = JoystickContact.direction2screen(direction, run=run)
builder = self.builder
2023-09-19 17:20:52 +00:00
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()
2023-08-20 17:28:30 +00:00
if self.is_downed:
points = insert_swipe(p0=self.prev_point, p3=point, speed=20)
for point in points[1:]:
builder.move(*point).commit().wait(10)
builder.send()
else:
builder.down(*point).commit()
builder.send()
# 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.
2023-09-21 16:23:00 +00:00
self.main.map_run_2x_timer.set_current(0.7)
2023-09-19 17:20:52 +00:00
self.main.joystick_lost_timer.reset()
2023-08-20 17:28:30 +00:00
self.prev_point = point
2023-06-27 18:03:23 +00:00
class MapControlJoystick(UI):
2023-09-19 17:20:52 +00:00
map_A_timer = Timer(1)
map_E_timer = Timer(1)
2023-09-21 16:23:00 +00:00
map_run_2x_timer = Timer(1)
2023-09-19 17:20:52 +00:00
2023-10-02 09:17:06 +00:00
joystick_lost_timer = Timer(1, count=1)
2023-06-27 18:03:23 +00:00
@cached_property
2023-09-19 17:20:52 +00:00
def joystick_center(self) -> tuple[int, int]:
2023-06-27 18:03:23 +00:00
x1, y1, x2, y2 = JOYSTICK.area
2023-09-19 17:20:52 +00:00
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 ''
2023-06-27 18:03:23 +00:00
def map_get_technique_points(self):
"""
Returns:
int: 0 to 5.
"""
points = [
self.image_color_count(button, color=(255, 255, 255), threshold=221, count=20)
for button in [
TECHNIQUE_POINT_1,
TECHNIQUE_POINT_2,
TECHNIQUE_POINT_3,
TECHNIQUE_POINT_4,
TECHNIQUE_POINT_5,
]
]
count = sum(points)
logger.attr('TechniquePoints', count)
return count
def handle_map_A(self):
"""
Simply clicking A with an interval of 1s, no guarantee of success.
Returns:
bool: If clicked.
"""
2023-09-19 17:20:52 +00:00
if self.map_A_timer.reached():
2023-06-27 18:03:23 +00:00
self.device.click(A_BUTTON)
2023-09-19 17:20:52 +00:00
self.map_A_timer.reset()
2023-06-27 18:03:23 +00:00
return True
return False
def handle_map_E(self):
"""
Simply clicking E with an interval of 1s, no guarantee of success.
Note that E cannot be released if technique points ran out.
Returns:
bool: If clicked.
"""
2023-09-19 17:20:52 +00:00
if self.map_E_timer.reached():
2023-06-27 18:03:23 +00:00
self.device.click(E_BUTTON)
2023-09-19 17:20:52 +00:00
self.map_E_timer.reset()
2023-06-27 18:03:23 +00:00
return True
return False
2023-09-21 16:23:00 +00:00
def handle_map_run_2x(self, run=True):
2023-06-27 18:03:23 +00:00
"""
Keep character running.
Note that RUN button can only be clicked when character is moving.
Returns:
bool: If clicked.
"""
is_running = self.image_color_count(RUN_BUTTON, color=(208, 183, 138), threshold=221, count=100)
2023-09-21 16:23:00 +00:00
if run and not is_running and self.map_run_2x_timer.reached():
2023-08-20 17:28:30 +00:00
self.device.click(RUN_BUTTON)
2023-09-21 16:23:00 +00:00
self.map_run_2x_timer.reset()
2023-08-20 17:28:30 +00:00
return True
2023-09-21 16:23:00 +00:00
if not run and is_running and self.map_run_2x_timer.reached():
2023-06-27 18:03:23 +00:00
self.device.click(RUN_BUTTON)
2023-09-21 16:23:00 +00:00
self.map_run_2x_timer.reset()
2023-06-27 18:03:23 +00:00
return True
return False