├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.yml └── workflows │ └── GiteeMirror.yml ├── .gitignore ├── FGO-ExpBall ├── fgoAndroid.py ├── fgoConst.py ├── fgoDetect.py ├── fgoDevice.py ├── fgoExpBall.py ├── fgoFuse.py ├── fgoImage │ ├── filter.png │ ├── lock.png │ ├── main.png │ ├── result.png │ ├── sort.png │ ├── special │ │ ├── 1754.png │ │ ├── habetrot.png │ │ └── unused │ │ │ ├── maryanning.png │ │ │ └── saberlily.png │ ├── summon_continue.png │ ├── summon_sale.png │ ├── summon_submit.png │ └── synthesis.png ├── fgoKernel.py ├── fgoLog │ └── .gitkeep ├── fgoLogging.py ├── fgoSchedule.py ├── fgoWindows.py └── fgoWsa.py ├── LICENSE ├── doc └── example.png └── readme.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: hgjazhgj 2 | patreon: hgjazhgj 3 | custom: ["https://raw.githubusercontent.com/hgjazhgj/FGO-py/master/doc/alipay.png", "https://raw.githubusercontent.com/hgjazhgj/FGO-py/master/doc/wechat.png"] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us improve 3 | labels: [] 4 | body: 5 | - type: checkboxes 6 | id: checks 7 | attributes: 8 | label: 在提问之前... 9 | options: 10 | - label: 我已经搜索了现有的 issues 和 discussions 11 | required: true 12 | - label: 我已经阅读了readme中除版本记录以外的所有内容 13 | required: true 14 | - label: 我在提问题之前至少花费了 5 分钟来思考和准备 15 | required: true 16 | - label: 我正在使用最新版的 FGO-ExpBall 17 | required: false 18 | - label: 这个问题出现了至少3次 19 | required: false 20 | - label: 我使用过旧版的 FGO-ExpBall 并且没有出现过这个问题 21 | required: false 22 | - type: textarea 23 | id: describe 24 | attributes: 25 | label: 描述你的问题 26 | description: 简要描述你的问题 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: reproduce 31 | attributes: 32 | label: 如何复现 33 | description: 重现该行为的步骤 34 | value: | 35 | 1. 前往 '...' 36 | 2. 点击 '....' 37 | 3. 滑动到 '....' 38 | 4. 出现问题 39 | validations: 40 | required: false 41 | - type: textarea 42 | id: expected 43 | attributes: 44 | label: 预期行为 45 | description: 简要描述你期望发生的事情 46 | validations: 47 | required: false 48 | - type: textarea 49 | id: logs 50 | attributes: 51 | label: 相关 Logs 52 | description: | 53 | 请复制并粘贴任何相关的日志输出。 54 | 可以把文件拖入这个区域以添加日志文件。 55 | 日志文件在fgoLog目录下。 56 | render: Text 57 | validations: 58 | required: false 59 | - type: textarea 60 | id: screenshots 61 | attributes: 62 | label: 截图 63 | description: | 64 | 如果有,添加屏幕截图以帮助解释你的问题。 65 | 可以复制图片后在此区域内粘贴以添加图片。 66 | 对于游戏画面,需要提交由本项目创建的截图,如使用「screenshot」命令。 67 | 如有必要,使用色块遮盖个人信息。 68 | validations: 69 | required: false 70 | - type: textarea 71 | id: others 72 | attributes: 73 | label: 还有别的吗? 74 | description: | 75 | 相关的配置?链接?参考资料? 76 | 任何能让我们对你所遇到的问题有更多了解的东西。 77 | validations: 78 | required: false 79 | - type: textarea 80 | id: setu 81 | attributes: 82 | label: 来点色图 83 | description: 来点色图 84 | validations: 85 | required: true 86 | -------------------------------------------------------------------------------- /.github/workflows/GiteeMirror.yml: -------------------------------------------------------------------------------- 1 | name: Gitee Mirror on Push 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | jobs: 7 | main: 8 | name: Gitee Mirror on Push 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Single repo mirror 12 | uses: Yikun/hub-mirror-action@master 13 | with: 14 | src: github/hgjazhgj 15 | dst: gitee/hgjazhgj 16 | dst_key: ${{ secrets.GITEE_PRIVATE_KEY }} 17 | dst_token: ${{ secrets.GITEE_TOKEN }} 18 | static_list: 'FGO-ExpBall' 19 | force_update: true 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | __pycache__/ 3 | backup/ 4 | build/ 5 | dist/ 6 | help/ 7 | temp/ 8 | *.pyc 9 | *.jpg 10 | *.7z 11 | *.tar.gz 12 | *.exe 13 | *.dll 14 | *.ipynb 15 | Fuse*.png 16 | Capture*.png 17 | Special*.png 18 | Log*.txt 19 | -------------------------------------------------------------------------------- /FGO-ExpBall/fgoAndroid.py: -------------------------------------------------------------------------------- 1 | import re,threading,time,cv2,numpy 2 | from airtest.core.android.adb import ADB 3 | from airtest.core.android.android import Android as Airtest 4 | from airtest.core.android.constant import CAP_METHOD 5 | from fgoConst import KEYMAP 6 | from fgoLogging import getLogger 7 | logger=getLogger('Android') 8 | 9 | class Android(Airtest): 10 | def __init__(self,serial=None,package="com.bilibili.fatego",**kwargs): 11 | self.lock=threading.Lock() 12 | self.package=package 13 | if serial is None or serial=='None': 14 | self.name=None 15 | return 16 | try: 17 | super().__init__(serial,**{'cap_method':CAP_METHOD.JAVACAP}|kwargs) 18 | self.rotation_watcher.reg_callback(lambda _:self.adjustOffset()) 19 | except Exception as e: 20 | logger.exception(e) 21 | self.name=None 22 | else:self.name=self.serialno 23 | @property 24 | def available(self): 25 | if not self.name:return False 26 | if self.touch_proxy.server_proc.poll()is None:return True # Only compatible with minitouch & maxtouch 27 | self.name=None 28 | return False 29 | @staticmethod 30 | def enumDevices():return[i for i,_ in ADB().devices('device')] 31 | def adjustOffset(self): 32 | self.render=[round(i)for i in self.get_render_resolution(True)] # ,self.package)] 33 | self.scale,self.border=(720/self.render[3],(round(self.render[2]-self.render[3]*16/9)>>1,0))if self.render[2]*9>self.render[3]*16 else(1280/self.render[2],(0,round(self.render[3]-self.render[2]*9/16)>>1)) 34 | self.key={c:[round(p[i]/self.scale+self.border[i]+self.render[i])for i in range(2)]for c,p in KEYMAP.items()} 35 | def touch(self,pos): 36 | with self.lock:super().touch([round(pos[i]/self.scale+self.border[i]+self.render[i])for i in range(2)]) 37 | # def swipe(self,rect): 38 | # with self.lock:super().swipe(*[[rect[i<<1|j]/self.scale+self.border[j]+self.render[j]for j in range(2)]for i in range(2)]) 39 | def swipe(self,rect): # If this doesn't work, use the above one instead 40 | p1,p2=[numpy.array(self._touch_point_by_orientation([rect[i<<1|j]/self.scale+self.border[j]+self.render[j]for j in range(2)]))for i in range(2)] 41 | vd=p2-p1 42 | lvd=numpy.linalg.norm(vd) 43 | vd/=.2*self.scale*lvd 44 | vx=numpy.array([0.,0.]) 45 | def send(method,pos):self.touch_proxy.handle(' '.join((method,'0',*[str(i)for i in self.touch_proxy.transform_xy(*pos)],'50\nc\n'))) 46 | with self.lock: 47 | send('d',p1) 48 | time.sleep(.01) 49 | for _ in range(2): 50 | send('m',p1+vx) 51 | vx+=vd 52 | time.sleep(.02) 53 | vd*=5 54 | while numpy.linalg.norm(vx)frame.f_lasti))(inspect.currentframe().f_back) 18 | self.center=center 19 | if img is not None: 20 | img=cv2.imread(f'fgoImage/{img}.png') 21 | self.img=img[center[1]-size[1]:center[1]+size[1], 22 | center[0]-size[0]:center[0]+size[0]] 23 | self.threshold=threshold 24 | self.slice=(slice(center[1]-size[1]-padding,center[1]+size[1]+padding), 25 | slice(center[0]-size[0]-padding,center[0]+size[0]+padding)) 26 | 27 | def wait(self,afterDelay=.5,interval=.2): 28 | while not self.appear(interval):pass 29 | schedule.sleep(afterDelay) 30 | return self 31 | 32 | def appear(self,afterDelay=0): 33 | screen=self.device.screenshot() 34 | schedule.sleep(afterDelay) 35 | # logger.debug(numpy.min(cv2.matchTemplate(screen[self.slice],self.img,cv2.TM_SQDIFF_NORMED))) 36 | if numpy.min(cv2.matchTemplate(screen[self.slice],self.img,cv2.TM_SQDIFF_NORMED))>1),rect[1]+loc[2][1]+(img.shape[0]>>1)),fuse.reset(self))[0]if loc[0] ' 19 | return wrapper 20 | def countdown(x): 21 | timer=time.time()+x 22 | while(rest:=timer-time.time())>0: 23 | print((lambda sec:f'{sec//3600:02}:{sec%3600//60:02}:{sec%60:02}')(round(rest)),end=' \r') 24 | time.sleep(min(1,max(0,rest))) 25 | 26 | class Cmd(cmd.Cmd,metaclass=lambda name,bases,attrs:type(name,bases,{i:wrapTry(j)if i.startswith('do_')else j for i,j in attrs.items()})): 27 | intro=f''' 28 | FGO-ExpBall {VERSION}, Copyright (c) 2019-2022 hgjazhgj 29 | 30 | Connect device first, then type main to make ExpBall. 31 | Type help or ? to list commands, help to get more information. 32 | Some commands support [ ...] {{-h, --help}} for further information. 33 | ''' 34 | prompt='FGO-ExpBall@Device> ' 35 | def __init__(self): 36 | super().__init__() 37 | fgoDevice.Device.enumDevices() 38 | def emptyline(self):return 39 | def precmd(self,line): 40 | if line:logger.info(line) 41 | return line 42 | def completenames(self,text,*ignored):return[f'{i} 'for i in super().completenames(text,*ignored)] 43 | def completecommands(self,table,text,line,begidx,endidx):return sum([[f'{k} 'for k in j if k.startswith(text)]for i,j in table.items()if re.match(f'{i}$',' '.join(line.split()[1:None if begidx==endidx else -1]))],[]) 44 | def do_exec(self,line):exec(line) 45 | def do_shell(self,line):os.system(line) 46 | def do_exit(self,line): 47 | 'Exit FGO-ExpBall' 48 | return True 49 | def do_EOF(self,line):return self.do_exit(line) 50 | def do_version(self,line): 51 | 'Show FGO-ExpBall version' 52 | print(VERSION) 53 | def do_connect(self,line): 54 | 'Connect to a device' 55 | arg=parser_connect.parse_args(line.split()) 56 | if arg.list:return print(*fgoDevice.Device.enumDevices(),sep='\n') 57 | fgoDevice.device=fgoDevice.Device(arg.name) 58 | def complete_connect(self,text,line,begidx,endidx): 59 | return self.completecommands({ 60 | '':['wsa','win']+[f'/{i}'for i in fgoDevice.helpers]+fgoDevice.Device.enumDevices() 61 | },text,line,begidx,endidx) 62 | def do_main(self,line): 63 | 'Make several ExpBalls endlessly' 64 | arg=parser_main.parse_args(line.split()) 65 | self.work=fgoKernel.ExpBall(arg.appoint,dict(arg.count)) 66 | self.do_continue(f'-s {arg.sleep}') 67 | def do_continue(self,line): 68 | 'Continue execution after abnormal break' 69 | arg=parser_main.parse_args(line.split()) 70 | assert fgoDevice.device.available 71 | countdown(arg.sleep) 72 | try: 73 | signal.signal(signal.SIGINT,lambda*_:schedule.stop()) 74 | if platform.system()=='Windows':signal.signal(signal.SIGBREAK,lambda*_:schedule.pause()) 75 | self.work() 76 | except ScriptStop as e: 77 | logger.critical(e) 78 | except KeyboardInterrupt: 79 | raise 80 | except BaseException as e: 81 | logger.exception(e) 82 | finally: 83 | signal.signal(signal.SIGINT,signal.SIG_DFL) 84 | if platform.system()=='Windows':signal.signal(signal.SIGBREAK,signal.SIG_DFL) 85 | fuse.reset() 86 | schedule.reset() 87 | def do_screenshot(self,line): 88 | 'Take a screenshot' 89 | assert fgoDevice.device.available 90 | fgoKernel.Detect(0).save() 91 | def do_169(self,line): 92 | 'Adapt none 16:9 screen' 93 | arg=parser_169.parse_args(line.split()) 94 | assert fgoDevice.device.available 95 | getattr(fgoDevice.device,f'{arg.action}169')() 96 | def complete_169(self,text,line,begidx,endidx): 97 | return self.completecommands({ 98 | '':['invoke','revoke'] 99 | },text,line,begidx,endidx) 100 | 101 | ArgError=type('ArgError',(Exception,),{}) 102 | def validator(type,func,desc='\b'): 103 | def f(x): 104 | if not func(x:=type(x)):raise ValueError 105 | return x 106 | f.__name__=desc 107 | return f 108 | class ArgParser(argparse.ArgumentParser): 109 | def exit(self,status=0,message=None):raise ArgError(message) 110 | class ArgStruct: 111 | def __init__(self,*args): 112 | def infIter(iterable): 113 | while True:yield from iterable 114 | self.it=infIter(args) 115 | self.repr=f'{type(self).__name__}{args}' 116 | def __call__(self,x): 117 | return next(self.it)(x) 118 | def __repr__(self): 119 | return self.repr 120 | 121 | parser_main=ArgParser(prog='main',description=Cmd.do_main.__doc__) 122 | parser_main.add_argument('-s','--sleep',help='Sleep before run (default: %(default)s)',type=validator(float,lambda x:x>=0,'nonnegative'),default=0) 123 | parser_main.add_argument('-a','--appoint',help='Cycle limit (default: %(default)s for no limit)',type=validator(int,lambda x:x>=0,'nonnegative int'),default=0) 124 | parser_main.add_argument('-c','--count',help='Stop after Special Drop count',action='append',type=ArgStruct(str,validator(int,lambda x:x>0,'positive int')),default=[],nargs=2) 125 | 126 | parser_connect=ArgParser(prog='connect',description=Cmd.do_connect.__doc__) 127 | parser_connect.add_argument('-l','--list',help='List all available devices',action='store_true') 128 | parser_connect.add_argument('name',help='Device name (default to the last connected one)',default='',nargs='?') 129 | 130 | parser_169=ArgParser(prog='169',description=Cmd.do_169.__doc__) 131 | parser_169.add_argument('action',help='Action',type=str.lower,choices=['invoke','revoke']) 132 | 133 | def main(args):Cmd().cmdloop() 134 | 135 | if __name__=='__main__': 136 | # fgoLogging.logging.getLogger('fgo').handlers[-1].setLevel('DEBUG') 137 | main([]) 138 | -------------------------------------------------------------------------------- /FGO-ExpBall/fgoFuse.py: -------------------------------------------------------------------------------- 1 | from fgoSchedule import ScriptStop 2 | from fgoLogging import getLogger 3 | logger=getLogger('Fuse') 4 | 5 | class Fuse: 6 | def __init__(self,fv=100,logsize=10): 7 | self.value=0 8 | self.max=fv 9 | self.logsize=logsize 10 | self.log=[None]*logsize 11 | self.logptr=0 12 | def increase(self): 13 | logger.debug(f'{self.value}') 14 | if self.value>self.max: 15 | self.save() 16 | raise ScriptStop('Fused') 17 | self.value+=1 18 | def reset(self,detect=None): 19 | self.value=0 20 | if detect is not None and detect is not self.log[(self.logptr-1)%self.logsize]: 21 | self.log[self.logptr]=detect 22 | self.logptr=(self.logptr+1)%self.logsize 23 | return True 24 | def save(self,path='fgoLog'):[self.log[(i+self.logptr)%self.logsize].save(f'{path}/Fuse_{i:02}') for i in range(self.logsize)if self.log[(i+self.logptr)%self.logsize]] 25 | fuse=Fuse() 26 | -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/filter.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/lock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/lock.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/main.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/result.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/sort.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/sort.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/special/1754.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/special/1754.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/special/habetrot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/special/habetrot.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/special/unused/maryanning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/special/unused/maryanning.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/special/unused/saberlily.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/special/unused/saberlily.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/summon_continue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/summon_continue.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/summon_sale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/summon_sale.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/summon_submit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/summon_submit.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoImage/synthesis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoImage/synthesis.png -------------------------------------------------------------------------------- /FGO-ExpBall/fgoKernel.py: -------------------------------------------------------------------------------- 1 | from fgoConst import * 2 | from fgoDetect import * 3 | from fgoLogging import getLogger 4 | from fgoSchedule import schedule,ScriptStop 5 | logger=getLogger('Kernel') 6 | 7 | class ExpBall: 8 | def __init__(self,appoint=0,special=None): 9 | self.runOnce=True 10 | self.appoint=appoint 11 | self.special={}if special is None else special 12 | 13 | def __call__(self): 14 | while True: 15 | logger.info('FP summon') 16 | SPACE.click(.5) 17 | if MAIN_SUMMON.appear(): 18 | MAIN_SUMMON.click(5) 19 | BACK.wait() 20 | else: 21 | SPACE.click(.8) 22 | while not SUMMON_FP.appear(): 23 | SUMMON_SWITCH.click(1.5) 24 | SUMMON_SUMMON.click(.8) 25 | while not SUMMON_SALE.appear(): 26 | SUMMON_SUBMIT.click(3) 27 | while not SUMMON_CONTINUE.appear(): 28 | SPACE.click(.4) 29 | for i in range(10): 30 | if not(t:=Detect().findSpecial(i)): 31 | continue 32 | Detect.cache.save('fgoLog/Special') 33 | logger.warning(f'Special Summoned {t[0]}') 34 | Button(t[1]).click(2) 35 | Button((32,180)).click(1) 36 | if self.special.setdefault(t[0],0)==1: 37 | raise ScriptStop(f'Special Summoned {t[0]} count achieved') 38 | self.special[t[0]]-=1 39 | BACK.click(1.5) 40 | SUMMON_CONTINUE.click(.8) 41 | logger.info('Sell servant') 42 | SUMMON_SALE.click(3) 43 | SELECT_FINISH.wait() 44 | SELECT_SERVANT.click(2) 45 | if self.runOnce: # 每行7个 获得顺序 智能筛选 46 | while not SELECT_GIRD.appear(): 47 | SELECT_GIRD.click(2) 48 | SORT_SORT.click(.8) 49 | SORT_BYTIME.click(.5) 50 | while not SORT_FILTER_ON.appear(): 51 | SORT_FILTER_ON.click(.8) 52 | SORT_SUBMIT.click(.8) 53 | self.sell() 54 | logger.info('Sell reisou') 55 | SELECT_REISOU.click(2) 56 | if self.runOnce: # 每行7个 3星 智能筛选 57 | while not SELECT_GIRD.appear(): 58 | SELECT_GIRD.click(2) 59 | FILTER_FILTER.click(.8) 60 | FILTER_RESET.click(.5) 61 | FILTER_STAR_3.click(.5) 62 | FILTER_SUBMIT.click(.8) 63 | SORT_SORT.click(.8) 64 | while not SORT_FILTER_ON.appear(): 65 | SORT_FILTER_ON.click(.8) 66 | SORT_SUBMIT.click(.8) 67 | self.sell() 68 | logger.info('Sell command code') 69 | SELECT_CODE.click(2) 70 | if self.runOnce: # 每行7个 12星 71 | while not SELECT_GIRD.appear(): 72 | SELECT_GIRD.click(2) 73 | FILTER_FILTER.click(.8) 74 | FILTER_RESET.click(.5) 75 | FILTER_STAR_1.click(.5) 76 | FILTER_STAR_2.click(.5) 77 | FILTER_SUBMIT.click(.8) 78 | self.sell() 79 | BACK.click(3) 80 | logger.info('Reisou select') 81 | SPACE.click(.5) 82 | MAIN_SYNTHESIS.click(3) 83 | BACK.wait() 84 | SYNTHESIS_SYNTHESIS.click(3) 85 | SYNTHESIS_LOAD.wait().click(3) 86 | if self.runOnce: # 每行7个 全部 1星 等级顺序 智能筛选 降序 87 | while not SELECT_GIRD.appear(): 88 | SELECT_GIRD.click(2) 89 | while FILTER_EVENT.appear(): 90 | FILTER_EVENT.click(.8) 91 | FILTER_FILTER.click(.8) 92 | FILTER_RESET.click(.5) 93 | FILTER_STAR_1.click(.5) 94 | FILTER_SUBMIT.click(.8) 95 | SORT_SORT.click(.8) 96 | SORT_BYLEVEL.click(.5) 97 | while not SORT_FILTER_ON.appear(): 98 | SORT_FILTER_ON.click(.8) 99 | SORT_SUBMIT.click(.8) 100 | while not SORT_DEC.appear(): 101 | SORT_DEC.click(.8) 102 | for i,j in((i,j)for i in range(4)for j in range(7)): 103 | if not SELECT_LOCK.offset(133*j,142*i).appear(): 104 | SYNTHESIS_LOCK.click(1) 105 | SELECT_LOCK.offset(133*j,142*i).offset(60,0).click(1) 106 | SYNTHESIS_SELECT.click(1) 107 | SELECT_LOCK.offset(133*j,142*i).offset(60,0).click(1.5) 108 | BACK.wait() 109 | if not SORT_DEC.appear(): 110 | break 111 | else: 112 | raise ScriptStop('No Synthesis Material') 113 | logger.info('Reisou synthesis') 114 | SYNTHESIS_ENTER.click(1) 115 | SELECT_FINISH.wait() 116 | if self.runOnce: # 每行7个 全部 12星 稀有度顺序 智能筛选 降序 117 | while not SELECT_GIRD.appear(): 118 | SELECT_GIRD.click(2) 119 | while FILTER_EVENT.appear(): 120 | FILTER_EVENT.click(.8) 121 | FILTER_FILTER.click(.8) 122 | FILTER_RESET.click(.5) 123 | FILTER_STAR_1.click(.5) 124 | FILTER_STAR_2.click(.5) 125 | FILTER_SUBMIT.click(.8) 126 | SORT_SORT.click(.8) 127 | SORT_BYRANK.click(.5) 128 | while not SORT_FILTER_ON.appear(): 129 | SORT_FILTER_ON.click(.8) 130 | SORT_SUBMIT.click(.8) 131 | while not SORT_DEC.appear(): 132 | SORT_DEC.click(.8) 133 | while True: 134 | self.selectAll() 135 | if SELECT_FINISH.appear(): 136 | break 137 | SELECT_FINISH.click(1) 138 | SELECT_FINISH.click(.5) 139 | SUMMON_SUBMIT.click(2) 140 | while not BACK.appear(): 141 | SPACE.click(.4) 142 | if SYNTHESIS_LOAD.appear(): 143 | logger.warning('ExpBall Created') 144 | break 145 | SYNTHESIS_ENTER.click(1) 146 | SELECT_FINISH.wait() 147 | BACK.click(1).wait().click(1).wait() 148 | logger.info('Archive') 149 | SPACE.click(.5) 150 | MAIN_ARCHIVE.click(3) 151 | BACK.wait() 152 | ARCHIVE_ARCHIVE.click(3) 153 | SELECT_FINISH.wait() 154 | # if self.runOnce: # 每行7个 经验值 芙芙 155 | # while not SELECT_GIRD.appear(): 156 | # SELECT_GIRD.click(2) 157 | # FILTER_FILTER.click(.8) 158 | # FILTER_RESET.click(.5) 159 | # FILTER_SCROLL.click(.5) 160 | # FILTER_EXP.click(.5) 161 | # FILTER_FOU.click(.5) 162 | # FILTER_SUBMIT.click(.8) 163 | while True: 164 | self.selectAll() 165 | if SELECT_FINISH.appear(): 166 | break 167 | SELECT_FINISH.click(1) 168 | ARCHIVE_SUBMIT.click(2) 169 | SELECT_FINISH.wait() 170 | ARCHIVE_RESULT.click(1) 171 | BACK.click(1).wait() 172 | logger.info('Garbage Collection') 173 | SPACE.click(.5) 174 | MAIN_MAIN.click(6) 175 | BACK.wait(4) 176 | self.appoint-=1 177 | logger.info(f'Cycle left {"infinity"if self.appoint<0 else self.appoint}') 178 | if not self.appoint: 179 | break 180 | self.runOnce=False 181 | logger.warning('Done') 182 | 183 | def selectAll(self): 184 | for i,j in((i,j)for i in range(4)for j in range(7)): 185 | Button((133+133*j,253+142*i)).click(.15) 186 | schedule.sleep(1) 187 | 188 | def sell(self): 189 | while True: 190 | self.selectAll() 191 | if SELECT_FINISH.appear(): 192 | break 193 | SELECT_FINISH.click(1) 194 | SORT_SUBMIT.click(1) 195 | SELL_RESULT.wait().click(1) 196 | SELECT_FINISH.wait() 197 | -------------------------------------------------------------------------------- /FGO-ExpBall/fgoLog/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hgjazhgj/FGO-ExpBall/aa500cf1cc085073adfebe1009e2528078570bf8/FGO-ExpBall/fgoLog/.gitkeep -------------------------------------------------------------------------------- /FGO-ExpBall/fgoLogging.py: -------------------------------------------------------------------------------- 1 | import logging,platform,time 2 | from copy import copy 3 | from functools import wraps 4 | from fgoConst import VERSION 5 | if platform.system()=='Windows':(lambda k:k.SetConsoleMode(k.GetStdHandle(-11),7))(__import__('ctypes').windll.kernel32) # -11:STD_OUTPUT_HANDLE, 7:ENABLE_VIRTUAL_TERMINAL_PROCESSING 6 | def color(c,f='38'):return f'\033[{f};2;{c>>16&0xFF};{c>>8&0xFF};{c&0xFF}m' 7 | logging.root.addHandler((lambda handler:(handler.setFormatter(logging.Formatter('[%(asctime)s][%(levelname)s]<%(name)s> %(message)s')),handler.setLevel(logging.DEBUG),handler)[-1])(logging.FileHandler(time.strftime('fgoLog/Log_%Y-%m-%d_%H.%M.%S.txt')))) 8 | (lambda logger:(logger.setLevel(logging.DEBUG),logger.addHandler((lambda handler:(handler.setFormatter(type('ColoredFormatter',(logging.Formatter,),{'__init__':lambda self,*args,**kwargs:logging.Formatter.__init__(self,*args,**kwargs),'format':lambda self,record:((lambda record:(setattr(record,'levelname','\033[{}m[{}]'.format({'DEBUG':'37','INFO':'34','WARNING':'33','CRITICAL':'35','ERROR':'31'}.get(record.levelname,'0'),record.levelname)),logging.Formatter.format(self,record))[-1])(copy(record)))})('\033[32m[%(asctime)s]%(levelname)s\033[36m<%(name)s>\033[0m %(message)s')),handler.setLevel(logging.INFO),handler)[-1])(logging.StreamHandler()))))(logging.getLogger('fgo')) 9 | (lambda handler:(handler.setLevel(logging.INFO),handler.setFormatter(logging.getLogger('fgo').handlers[-1].formatter)))((lambda logger:(logger.setLevel(logging.DEBUG),logger)[-1])(logging.getLogger('airtest')).handlers[0]) 10 | def getLogger(name):return logging.getLogger('fgo.'+name) 11 | def logit(logger,level=logging.DEBUG):return lambda func:wraps(func)(lambda*args,**kwargs:(lambda x:(logger.log(level,' '.join((func.__name__,str(x)[:100].split('\n',1)[0]))),x)[-1]if x is not None else x)(func(*args,**kwargs))) 12 | def logMeta(logger):return lambda name,bases,attrs:type(name,bases,{i:logit(logger)(j)if callable(j)and i[0]!='_'else j for i,j in attrs.items()}) 13 | logging.getLogger('fgo').info(f'FGO-ExpBall {VERSION}') 14 | -------------------------------------------------------------------------------- /FGO-ExpBall/fgoSchedule.py: -------------------------------------------------------------------------------- 1 | import time 2 | ScriptStop=type('ScriptStop',(Exception,),{'__init__':lambda self,msg='Unknown Reason':Exception.__init__(self,f'Script Stopped: {msg}')}) 3 | class Schedule: 4 | speed=1 5 | def __init__(self): 6 | self.reset() 7 | self.__stopOnDefeatedFlag=False 8 | self.__stopOnKizunaReisouFlag=False 9 | self.__stopOnSpecialDropCount=0 10 | def reset(self): 11 | self.__stopMsg='' 12 | self.__pauseFlag=False 13 | self.__stopLaterCount=0 14 | def stop(self,msg='Stopped'):self.__stopMsg=msg 15 | def checkStop(self): 16 | if self.__stopMsg:raise ScriptStop(self.__stopMsg) 17 | def pause(self):self.__pauseFlag=not self.__pauseFlag 18 | def checkSuspend(self): 19 | while self.__pauseFlag: 20 | self.checkStop() 21 | time.sleep(.07) 22 | def stopLater(self,count=0):self.__stopLaterCount=count 23 | def checkStopLater(self): 24 | self.__stopLaterCount-=1 25 | if not self.__stopLaterCount:raise ScriptStop('Stop Appointment Effected') 26 | def sleep(self,x,part=.07): 27 | timer=time.time()+(x-part)/self.speed 28 | while time.time()>1,0))if self.width*9>self.height*16 else(1280/self.width,(0,round(self.height-self.width*9/16)>>1)) 26 | return True 27 | @staticmethod 28 | def enumDevices(): 29 | wnds=[win32gui.WindowFromPoint(win32api.GetCursorPos())] 30 | win32gui.EnumChildWindows(wnds[0],lambda hWnd,_:wnds.append(hWnd),0) 31 | result=None 32 | for hWnd in wnds: 33 | w=Window(hWnd) 34 | cv2.imshow(f'{hex(hWnd)} - press i to test mouse input, q to quit, y to confirm, any other key to next',cv2.resize(w.screenshot(),(0,0),fx=.6,fy=.6)) 35 | while True: 36 | key=cv2.waitKey(0) 37 | if key==ord('y'): 38 | result=hWnd 39 | break 40 | elif key==ord('i'):w.press(' ') 41 | elif key==ord('q'): 42 | result=0 43 | break 44 | else:break 45 | cv2.destroyAllWindows() 46 | if result is not None:return[result] 47 | return[0] 48 | # def adjustIterableForDpi(self,pos): # when SetProcessDpiAwarenessContext failed, use this instead 49 | # self.dpi=user32.GetDpiForWindow(self.hWnd) # win10 1607 50 | # if self.dpiAwareness==2:return pos # 2:DPI_AWARENESS_PER_MONITOR_AWARE 51 | # # elif self.dpiAwareness==1: # 1:DPI_AWARENESS_SYSTEM_AWARE current unable to handle this 52 | # elif self.dpiAwareness==0:return[i*self.dpi//96 for i in pos] # 0:DPI_AWARENESS_UNAWARE 53 | def screenshot(self): 54 | if not self.available:return BLACK 55 | hBmp=win32ui.CreateBitmap() 56 | hBmp.CreateCompatibleBitmap(self.hMfcDc,self.width,self.height) 57 | self.hMemDc.SelectObject(hBmp) 58 | self.hMemDc.BitBlt((0,0),(self.width,self.height),self.hMfcDc,(0,0),win32con.SRCCOPY) 59 | result=numpy.frombuffer(hBmp.GetBitmapBits(True),dtype=numpy.uint8) 60 | win32gui.DeleteObject(hBmp.GetHandle()) 61 | return cv2.resize(result.reshape(self.height,self.width,4)[slice(self.border[1],-self.border[1])if self.border[1]else slice(None),slice(self.border[0],-self.border[0])if self.border[0]else slice(None),:3],(1280,720),interpolation=cv2.INTER_CUBIC) 62 | def touch(self,pos): 63 | lParam=round(pos[1]/self.scale+self.border[1])<<16|round(pos[0]/self.scale+self.border[0]) 64 | win32api.PostMessage(self.hWnd,win32con.WM_LBUTTONDOWN,win32con.MK_LBUTTON,lParam) 65 | win32api.PostMessage(self.hWnd,win32con.WM_LBUTTONUP,0,lParam) 66 | def swipe(self,rect): 67 | p1,p2=[numpy.array([rect[i<<1|j]/self.scale+self.border[j]for j in range(2)])for i in range(2)] 68 | vd=p2-p1 69 | lvd=numpy.linalg.norm(vd) 70 | vd/=.2*self.scale*lvd 71 | vx=numpy.array([0.,0.]) 72 | def makeLParam(p):return int(p[1])<<16|int(p[0]) 73 | win32api.PostMessage(self.hWnd,win32con.WM_LBUTTONDOWN,win32con.MK_LBUTTON,makeLParam(p1)) 74 | time.sleep(.01) 75 | for _ in range(2): 76 | win32api.PostMessage(self.hWnd,win32con.WM_MOUSEMOVE,win32con.MK_LBUTTON,makeLParam(p1+vx)) 77 | vx+=vd 78 | time.sleep(.02) 79 | vd*=5 80 | while numpy.linalg.norm(vx) 连接设备,见FGO-py的[连接到设备](https://github.com/hgjazhgj/FGO-py#连接到设备-connect-your-device)章节 32 | - main 搓丸子,**在任意游戏画面右下角显示「菜单」按钮的界面启动** 33 | 34 | ![example](doc/example.png) 35 | 其他命令请请自己help查看 36 | 37 | 如果已有安装好的portable FGO-py(可在[FGO-py的release](https://github.com/hgjazhgj/FGO-py/releases/tag/v2022.06.12)或[FGO-py官网](https://fgo-py.hgjazhgj.top/)获取),可按本项目的release操作,或是直接在portable FGO-py的根目录下clone本项目 38 | 由于搓丸子需要由程序本身而非玩家对游戏中的设置进行调整以保证账户资产安全从而并不符合FGO-py「小而美」的一贯设计风格,所以做成了一个独立项目 39 | **在游戏内设定友情池的自动变还选项/商店贩卖从者的类别筛选/从者灵基保管室的放入筛选**,别处的选项会自动设定 40 | 程序会贩卖三星礼装所以不要在游戏内勾选自动变还三星礼装以防活动礼装被贩卖 41 | 建议的选项是「自动变还-12星经验值」「商店-从者」 42 | 程序暂无典型的结束标志,以下情况会导致程序空转或效率低下 43 | 44 | - 从者仓库被\<被排除在贩卖筛选之外的对象\>占满(e.g.一仓库狗粮),灵基保管室也没有多余空间 45 | - 同一礼装的丸子数量太多(e.g.选择「饥饿」作为强化对象但是仓库里已经有28张lv.50的「饥饿」) 46 | 47 | 由于上述情况需要相当长的时间(数小时至数日不等,依照不同的变还设定)才会出现,所以请在发现这种情况时手动停止运行~~更多的情况是游戏先闪退然后程序熔断~~ 48 | 49 | ## 自动上锁 50 | 51 | 将你认为重要的召唤物自动上锁,默认配置为哈贝喵,同时unused中有Saber lily备用 52 | 通常,每次活动,如果开发者本人需要当期卡池中的礼装之类的东西,在卡池开启前数日会将其加入本项目的自动上锁中并在不再需要它们之后的下次更新时移除 53 | 对自动筛选图片的更改一般不会写在更新日志中,你要是急着用就自己制作 54 | 安哥拉曼纽会由游戏上锁,就像5星从者一样,不要将安哥拉曼纽放入自动上锁中 55 | 四星礼装在本项目设定的筛选范围之外不会被贩卖或使用,不必将四星礼装放入自动上锁中 56 | 本功能原理同FGO-py的助战筛选,你可以设置其他重要召唤物,**使用方法具体见FGO-py readme**,但是有以下区别: 57 | 58 | - 没有透明度通道,黑色就会被认为是黑色 59 | - 不能热更改图片,需要在程序运行前调整好 60 | 61 | ## 特殊召唤数量统计/限制 62 | 63 | 在抽活动三星礼装时,我通常会抽4张,和活动主线送的一张凑满破就不抽了 64 | 为此,main命令增加`-c`选项,该选项带两个参数,第一个参数为`fgoImage/special`下图片的文件名,第二个参数为抽取的数量,此选项可以附加多次,任何一个条件达到了都会停止运行 65 | e.g. `main -s 5 -a 5 -c habetrot 2 -c 1488 4`代表在5秒后搓5轮丸子,途中出现两次habetrot.png或者四次1488.png(万圣四期活动礼装)就提前停止运行 66 | 67 | ## 一些数据 68 | 69 | 内容有点长,所以发到[B站专栏](https://www.bilibili.com/read/cv18100391)了,如果能有点赞和投币我会很开心 70 | 71 | ## 更新日志 72 | 73 | ## 2024/07/16 v2.0.0 74 | 75 | Opt:logging 76 | Upd:utf8 77 | 78 | ## 2024/02/10 v1.9.0 79 | 80 | Opt:禁用从者灵基保管室的放入筛选 81 | 82 | ## 2023/08/03 v1.8.1 83 | 84 | Fix:[issue #9](https://github.com/hgjazhgj/FGO-ExpBall/issues/9) 85 | 86 | ## 2022/11.12 v1.8.0 87 | 88 | Opt:[iussue #8](https://github.com/hgjazhgj/FGO-ExpBall/issues/8) 89 | Opt:四星礼装在本项目设定的筛选范围之外不会被贩卖或使用,不必将四星礼装放入自动上锁中 90 | 91 | ### 2022/11/10 v1.7.1 92 | 93 | Fix:特殊召唤数量统计/限制 94 | 95 | ### 2022/11/8 v1.7.0 96 | 97 | Add:特殊召唤数量统计/限制 98 | 99 | ### 2022/10/21 v1.6.2 100 | 101 | Fix:merge [pr #6](https://github.com/hgjazhgj/FGO-ExpBall/pull/6) 102 | 103 | ### 2022/10/20 v1.6.1 104 | 105 | Upd:万圣活动礼装 106 | 我经常忘记更改版本号(其实根本不需要版本号这种东西但我还是想有一个),不知道有没有什么技术手段提醒我或者帮我检查版本号是否更新 107 | Opt:试图缓解强化卡顿造成的熔断 108 | 109 | ### 2022/10/18 v1.6.0 110 | 111 | Add:万圣活动礼装 112 | 往期万圣的活动礼装加入备用 113 | [issue #3](https://github.com/hgjazhgj/FGO-ExpBall/issues/3)怎么修比较优雅还没想好,感觉各种方案都不如仓库里留一个上了锁的低星好 114 | 115 | ### 2022/09/09 v1.5.3 116 | 117 | Add:反活动加成筛选 118 | Opt:部分延时调整 119 | 120 | ### 2022/09/08 v1.5.2 121 | 122 | Add:泳装活动礼装 123 | 124 | ### 2022/08/30 v1.5.1 125 | 126 | Opt:移除summon_fp 127 | 128 | ### 2022/08/27 v1.5.0 129 | 130 | Add:screenshot命令 131 | 132 | ### 2022/08/22 v1.4.3 133 | 134 | Fix:Windows调试下仅点击时的成员缺失 135 | 136 | ### 2022/08/20 v1.4.2 137 | 138 | Fix:把狗粮塞进灵基保管室时筛选设定滚动条至底部 139 | 同时应对并非完全滚动到底部的情形 140 | 141 | ### 2022/08/20 v1.4.1 142 | 143 | Fix:offset 144 | 145 | ### 2022/08/20 v1.4.0 146 | 147 | Fix:当把满级丸子编入队伍或助战导致即使开启智能筛选而强化对象列表中前几个仍不能选择时选择后面可选的作为强化对象 148 | 如果一整页都是这样的情况,那你搓nm的丸子呢 149 | Opt:掉落截图放进fgoLog中 150 | 151 | ### 2022/08/15 v1.3.0 152 | 153 | Opt:贩卖时增加对贩卖结果弹窗的等待以防网络状态不那么好的时候熔断 154 | 155 | ### 2022/08/14 v1.2.0 156 | 157 | Opt:在搓完一轮丸子后返回主界面以触发FGO的GC 158 | 真不愧是你啊FGO,连个基本的垃圾回收都写得一坨屎,我几年前C语言大作业都写得比这好... 159 | 此前最多一次积攒了大约27G的垃圾内存把我页面文件都撑大了1/3(我VM heap开得很大),这样GC一下硬错误少了游戏都流畅得多 160 | 相关特性有待进一步研究,尚不清楚FGO-py是否也需要这样的操作 161 | Opt:延时调整 162 | 163 | ### 2022/08/14 v1.1.0 164 | 165 | Add:将狗粮芙芙放进灵基保管室 166 | 使搓丸子能够更长时间地持续运行,如果自动贩卖掉芙芙,就能持续更长时间 167 | 168 | ### 2022/08/13 v1.0.0 169 | 170 | Add:main -a参数轮数限制 171 | 「抽友情-贩卖-强化」为一轮 172 | Fix:特殊召唤识别阈值调整 173 | Del:备用的安哥拉曼纽特殊召唤模板 174 | Opt:熔断器计数减少 175 | Opt:增加日志 176 | Opt:全选后加延时 177 | Add:特殊召唤后截图 178 | 179 | ### 2022/08/12 v0.1.2 180 | 181 | Fix:文本与版本号 182 | 183 | ### 2022/08/11 v0.1.1 184 | 185 | Fix:丸子搓满级就完成强化前往召唤 186 | 脑测修复 187 | Opt:优化按钮生成 188 | 189 | ### 2022/08/11 v0.1.0 190 | 191 | Add:.github 192 | 193 | ### 2022/08/10 v0.0.2 194 | 195 | Fix:将所有的FGO-py替换为FGO-ExpBall 196 | 197 | ### 2022/08/10 v0.0.1 198 | 199 | init 200 | 好消息:FGO-py终于有搓丸子了 201 | 坏消息:搓丸子不属于FGO-py 202 | 本项目复用了FGO-py中许多已经成熟的代码,同时依照需要自动更改游戏内设置的需求引入了一个简陋的按钮系统~~没错我跟alas学的~~ 203 | 事实证明这个框架完美胜任了当前的工作,使得本项目在保证代码质量的同时从新建文件夹到全部完工只用了8小时(上午8点-下午4点),并且几乎一遍完工,测试才花了半小时不到,只不过代码看起来有些又臭又长,并且全是大写字母...这也是为什么不直接写在FGO-py里面的原因 204 | 本次是一个成功的尝试,后续的想法是进一步将FGO-py的模块通用化出来,但还是先看看有没有bug吧 205 | 另外,本人尚未通关2.6,所以本人暂时无法进行大量测试 206 | --------------------------------------------------------------------------------