Files
metin2-client/src/platform/ScriptLib/BattlePassScript.inc
T
shenandshen 507e687867 feat: add MeshPort overlay networking, item actions window, and AVA2 client enhancements
- add MeshPort peer-to-peer overlay networking SDK, C++ integration, and Python bindings
- add item actions quick use/split/delete/lock overlay window (mt_item_actions)
- add shop name range and NPC name visibility settings to system display options
- support battlepass ticket activation and month validation in mt_battlepass
- support boss tracking teleport destination button in mt_bosses
- support asynchronous account registration cancellation and pending login cancellation
- update AVA2 item conversion for attr lock scrolls and battlepass tickets
- update .gitignore for local build outputs and caches
2026-10-16 01:29:57 -07:00

421 lines
18 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PORT: battle pass (战令), from AVA2's uibattlepassex.py. The server runs monthly seasons of up to ten missions
// counted from real events (kills, fishing, refines, levels, NPC trades, play time); each mission has a reward and
// a final reward follows once all are done. This window asks with /mt_bp, shows progress live while open and
// claims rewards. Entry points: F6 and a "战令" button by the minimap. Protocol: metin2-server
// docs/FEATURE-BATTLEPASS.md.
static const char c_szBattlePassScript[] = R"PY(# -*- coding: utf-8 -*-
import app
import chat
import game
import grp
import item
import net
import nonplayer
import player
import ui
import weakref
from mt_biolog import Action, Text, L
g_window=None
g_entry=None
SEND_GAP=.4
TIMEOUT=3.
PAGE=5
ROW=56
WIDTH=620
MAX_SLOT=10
# type: (name without sub, name with a sub vnum: kind of the vnum)
TYPES={1:('击杀怪物','击杀 ','mob'),2:('击杀 Boss','击杀 Boss ','mob'),3:('摧毁陨石','摧毁 ','mob'),4:('累计在线',None,None),
5:('钓鱼','钓到 ','item'),6:('成功强化装备','强化出 ','item'),7:('提升等级',None,None),8:('在 NPC 商店消费金币',None,None),
9:(None,'向 NPC 出售 ','item')}
ERRORS={'loading':'数据加载中,请稍后再试','slot':'没有这个任务','claimed':'奖励已经领取过了','incomplete':'任务尚未完成',
'space':'背包空间不足,请整理后再领取','config':'奖励配置有误,请联系管理员','db':'服务器繁忙,请稍后再试',
'rate':'操作过快,请稍后再试','invalid':'参数无效','ticket':'请使用当月战令票激活','month':'这张战令票不属于当前月份',
'activated':'本月战令已经激活过了','busy':'请先结束交易、商店、仓库或传送'}
def Number(text,low=0,high=10**12):
if not text.isdigit() or len(text)>13:raise ValueError(text)
value=int(text)
if not low<=value<=high:raise ValueError(text)
return value
def Rewards(words):
if len(words)%2:raise ValueError(words)
return [(Number(words[i],1,2**32-1),Number(words[i+1],1,10000)) for i in range(0,len(words),2)]
def Grouped(value):
text=str(int(value));parts=[]
while len(text)>3:parts.insert(0,text[-3:]);text=text[:-3]
return ','.join([text]+parts)
def Amount(kind,value,target):
# Play time is counted in seconds and shown in hours, or in minutes for a target under an hour.
if kind==4:
unit=3600. if target>=3600 else 60.
amount=value/unit
return ('%d'%amount) if amount==int(amount) else ('%.1f'%amount)
return Grouped(value)
def Unit(kind,target):
if kind!=4:return ''
return ' 小时' if target>=3600 else ' 分钟'
def SeasonName(season):
year,month=divmod(season,100)
if 2000<=year<=2999 and 1<=month<=12:return '%d 年 %d 月'%(year,month)
return '第 %d 期'%season
def Left(seconds):
seconds=int(seconds)
if seconds<=0:return '已结束'
days,rest=divmod(seconds,86400);hours,rest=divmod(rest,3600)
if days:return '剩余 %d 天 %d 小时'%(days,hours)
return '剩余 %d 小时 %d 分'%(hours,max(1,rest//60))
class Pass(object):
# Protocol state only. send(text) posts a chat command, now() is seconds.
def __init__(self,send,now,listener=None):
self.send=send;self.now=now;self.listener=listener
self.waiting=None;self.nextSend=0;self.unsupported=False
self.Clear()
def Clear(self):
self.season=None;self.missions={};self.final=None;self.reading=None;self.none=False;self.error=None;self.claiming=None;self.access=None;self.month=None
def Ready(self):
return self.waiting is None or self.now()>=self.waiting
def Post(self,text,expect=True):
now=self.now()
if now<self.nextSend:return False
self.send(text);self.nextSend=now+SEND_GAP
if expect:self.waiting=now+TIMEOUT
return True
def Query(self):
self.Tick()
if self.waiting is not None:return False
return self.Post('/mt_bp 1 info')
def Claim(self,slot):
if self.claiming is not None or not self.season or self.access not in (None,1):return False
if self.Post('/mt_bp 1 claim %d'%slot):self.claiming=slot;self.error=None;return True
return False
def Activate(self,cell,vnum):
if self.access!=0 or self.waiting is not None or not 25150<=vnum<=25161:return False
if player.GetItemIndex(cell)!=vnum:return False
return self.Post('/mt_bp 1 activate %d %d'%(cell,vnum))
def Close(self):
self.send('/mt_bp 1 close')
def Tick(self):
if self.waiting is not None and self.now()>=self.waiting:
self.waiting=None;self.claiming=None
if self.season is None:self.unsupported=True
self.Notify('timeout')
def SecondsLeft(self):
if not self.season:return 0
_,_,end,now,at=self.season
return end-(now+(self.now()-at))
def Notify(self,kind,value=None):
if self.listener:self.listener(kind,value)
def Receive(self,line):
if not line.startswith('MTBP1 '):return False
words=line.split()
try:self.Parse(words[1],words[2:])
except (ValueError,IndexError):self.reading=None
return True
def Parse(self,kind,args):
if kind=='season' and len(args)==5:
seasonId,start,end,now=[Number(w,0,2**32-1) for w in args[:4]];count=Number(args[4],1,MAX_SLOT)
self.reading=dict(season=(seasonId,start,end,now,self.now()),count=count,missions={},final=None,access=None,month=None)
elif kind=='access' and len(args) in (1,2) and self.reading is not None:
self.reading['access']=Number(args[0],0,2)
if len(args)==2:self.reading['month']=Number(args[1],1,12)
elif kind=='mission' and self.reading is not None and len(args)>=6:
slot=Number(args[0],1,MAX_SLOT);kind=Number(args[1],1,9)
self.reading['missions'][slot]=dict(slot=slot,type=kind,sub=Number(args[2],0,2**32-1),target=Number(args[3],1),
value=Number(args[4]),claimed=Number(args[5],0,1)==1,rewards=Rewards(args[6:]))
elif kind=='final' and self.reading is not None and len(args)>=4:
self.reading['final']=dict(claimed=Number(args[0],0,1)==1,all=Number(args[1],0,1)==1,rewards=Rewards(args[2:]))
elif kind=='end' and not args and self.reading is not None:
r=self.reading;self.reading=None
if len(r['missions'])!=r['count'] or r['final'] is None:return
self.season=r['season'];self.missions=r['missions'];self.final=r['final'];self.access=r['access'];self.month=r['month'];self.none=False
self.Answered('info')
elif kind=='none' and not args:
self.Clear();self.none=True;self.Answered('none')
elif kind=='progress' and len(args)==2:
slot=Number(args[0],1,MAX_SLOT);m=self.missions.get(slot)
if m:m['value']=min(m['target'],Number(args[1]));self.Notify('progress',slot)
elif kind=='done' and len(args)==1:
slot=Number(args[0],1,MAX_SLOT);m=self.missions.get(slot)
if m:m['value']=m['target']
self.Notify('done',slot)
elif kind=='claimed' and len(args)==1:
slot=Number(args[0],0,MAX_SLOT)
if slot:
if slot in self.missions:self.missions[slot]['claimed']=True
elif self.final:self.final['claimed']=True
self.claiming=None;self.Notify('claimed',slot) # a full info follows
elif kind=='error' and len(args)==1 and args[0] in ERRORS:
self.error=args[0];self.claiming=None;self.Answered('error')
def Answered(self,kind):
self.waiting=None;self.unsupported=False;self.Notify(kind)
def AllDone(self):
return bool(self.missions) and all(m['value']>=m['target'] for m in self.missions.values())
def ItemName(vnum):
try:
item.SelectItem(vnum);name=item.GetItemName()
except Exception:name=''
return name if name else L('物品 %d'%vnum)
def MobName(vnum):
try:name=nonplayer.GetMonsterName(vnum)
except Exception:name=''
return name if name else L('怪物 %d'%vnum)
def MissionName(m):
# Locale text: the utf-8 parts go through L, game names are already in the locale's code page.
plain,prefix,kind=TYPES.get(m['type'],('任务',None,None))
if m['sub'] and prefix:return L(prefix)+(MobName(m['sub']) if kind=='mob' else ItemName(m['sub']))
return L(plain or '任务')
def RewardText(rewards):
return L(',').join([ItemName(vnum)+(' x%d'%count) for vnum,count in rewards])
class BattlePassWindow(ui.Window):
def __init__(self,state):
ui.Window.__init__(self);self.SetWindowName('BattlePassWindow');self.AddFlag('float');self.AddFlag('movable')
self.state=state;self.page=0
self.SetSize(WIDTH,74+PAGE*ROW+ROW+20+56)
self.heading=Text(self,18,18,'战令',0xffffd68b)
self.season=Text(self,80,18,'',0xffc3b7a1)
self.closeButton=Action(self,WIDTH-76,6,64,'关闭',self.Hide)
self.rows=[]
for i in range(PAGE):
y=64+i*ROW
self.rows.append(dict(name=Text(self,18,y+4,''),value=Text(self,330,y+4,''),
reward=Text(self,18,y+26,'',0xffb3aa98),button=Action(self,WIDTH-118,y,100,'领取',lambda i=i:self.ClaimRow(i))))
y=64+PAGE*ROW+8
self.finalName=Text(self,18,y+4,'最终奖励',0xffffd68b)
self.finalState=Text(self,330,y+4,'')
self.finalReward=Text(self,18,y+26,'',0xffb3aa98)
self.finalButton=Action(self,WIDTH-118,y,100,'领取',lambda:self.state.Claim(0) and self.Refresh())
self.activateButton=Action(self,WIDTH-218,y,200,'使用当月战令票激活',self.AskActivate)
self.activateButton.Hide();self.confirm=None;self.pendingTicket=None
y=self.GetHeight()-56
self.prev=Action(self,18,y,80,'上一页',lambda:self.Turn(-1))
self.pageText=Text(self,140,y+24,'');self.pageText.SetHorizontalAlignCenter()
self.next=Action(self,182,y,80,'下一页',lambda:self.Turn(1))
self.note=Text(self,280,y+16,'',0xffc3b7a1)
self.second=-1
self.Hide()
def Open(self):
self.state.error=None;self.state.Query();self.Refresh();self.SetCenterPosition();ui.Window.Show(self);self.SetTop()
def Toggle(self):
if self.IsShow():self.Hide()
else:self.Open()
def Hide(self):
state=self.__dict__.get('state') # ui.Window.__init__ hides the window before state is set
if self.IsShow() and state and state.season:state.Close()
self.pendingTicket=None
self.CloseConfirm()
ui.Window.Hide(self)
def CloseConfirm(self):
confirm=self.__dict__.get('confirm')
if confirm:
confirm.Close();confirm.SetAcceptEvent(None);confirm.SetCancelEvent(None);self.confirm=None
def OfferTicket(self,cell,vnum):
self.pendingTicket=(cell,vnum)
self.Open()
def AskTicket(self,cell,vnum):
if self.state.access!=0 or player.GetItemIndex(cell)!=vnum:return
item.SelectItem(vnum)
if item.GetValue(0)!=self.state.month:self.state.error='month';self.Refresh();return
import uiCommon
self.CloseConfirm();self.confirm=uiCommon.QuestionDialog()
self.confirm.SetText(L('消耗战令票并激活本月战令?'))
self.confirm.SetAcceptEvent(lambda:self.ConfirmActivate(cell,vnum))
self.confirm.SetCancelEvent(self.CloseConfirm);self.confirm.Open()
def AskActivate(self):
if self.state.access!=0:return
for cell in range(player.INVENTORY_PAGE_SIZE*player.INVENTORY_PAGE_COUNT):
vnum=player.GetItemIndex(cell)
if not 25150<=vnum<=25161:continue
item.SelectItem(vnum)
# Display uses server time, not the phone's local month/time zone.
if item.GetValue(0)!=self.state.month:continue
self.AskTicket(cell,vnum);return
self.state.error='ticket';self.Refresh()
def ConfirmActivate(self,cell,vnum):
self.CloseConfirm();self.state.Activate(cell,vnum);self.Refresh()
def Slots(self):
return sorted(self.state.missions)
def Pages(self):
return max(1,(len(self.Slots())+PAGE-1)//PAGE)
def Turn(self,delta):
self.page=max(0,min(self.Pages()-1,self.page+delta));self.Refresh()
def ClaimRow(self,i):
slots=self.Slots()[self.page*PAGE:]
if i<len(slots) and self.state.Claim(slots[i]):self.Refresh()
def Refresh(self):
s=self.state;self.page=min(self.page,self.Pages()-1)
slots=self.Slots()[self.page*PAGE:(self.page+1)*PAGE]
for i,row in enumerate(self.rows):
widgets=(row['name'],row['value'],row['reward'],row['button'])
if i>=len(slots):
for w in widgets:w.Hide()
continue
for w in widgets:w.Show()
m=s.missions[slots[i]];done=m['value']>=m['target']
row['name'].SetText(MissionName(m))
row['value'].SetText(Amount(m['type'],m['value'],m['target'])+' / '+Amount(m['type'],m['target'],m['target'])+L(Unit(m['type'],m['target'])))
row['value'].SetPackedFontColor(0xff9fe08b if done else 0xffe8dec8)
row['reward'].SetText(L('奖励:')+RewardText(m['rewards']) if m['rewards'] else L('无奖励'))
if m['claimed']:row['button'].SetText('已领取')
elif s.claiming==m['slot']:row['button'].SetText('领取中')
else:row['button'].SetText('领取' if done else '未完成')
row['button'].SetEnabled(s.access in (None,1) and done and not m['claimed'] and bool(m['rewards']) and s.claiming is None)
f=s.final
for w in (self.finalName,self.finalState,self.finalReward,self.finalButton):
if f:w.Show()
else:w.Hide()
if f:
ready=s.AllDone()
self.finalState.SetText(L('已领取' if f['claimed'] else '全部任务完成' if ready else '完成全部任务后可领取'))
self.finalReward.SetText(L('奖励:')+RewardText(f['rewards']))
self.finalButton.SetText('已领取' if f['claimed'] else '领取中' if s.claiming==0 else '领取')
self.finalButton.SetEnabled(s.access in (None,1) and ready and not f['claimed'] and s.claiming is None)
if s.access==0:
self.finalButton.Hide();self.finalState.Hide();self.finalReward.Hide();self.activateButton.Show()
self.activateButton.SetEnabled(s.waiting is None)
else:self.activateButton.Hide()
many=self.Pages()>1
for w in (self.prev,self.next,self.pageText):
if many and s.season:w.Show()
else:w.Hide()
self.prev.SetEnabled(self.page>0);self.next.SetEnabled(self.page<self.Pages()-1)
self.pageText.SetText('%d / %d'%(self.page+1,self.Pages()))
if s.season:self.season.SetText(L(SeasonName(s.season[0])+' '+Left(s.SecondsLeft())))
else:self.season.SetText('')
if s.unsupported:note='!服务器尚未启用战令'
elif s.error:note='!'+ERRORS[s.error]
elif s.none:note='当前没有进行中的战令赛季'
elif not s.season:note='正在查询……'
elif s.claiming is not None:note='正在领取……'
elif s.access==0:note='激活后才开始累计进度'
elif s.access==2:note='本月战令已完成'
else:note='击杀、钓鱼、强化等都会自动计入进度'
self.note.SetText(L(note))
def OnUpdate(self):
self.state.Tick()
second=int(app.GetTime())
if second!=self.second:self.second=second;self.Refresh()
def OnRender(self):
x,y=self.GetGlobalPosition();w=self.GetWidth()
grp.SetColor(0xf51b1916);grp.RenderBar(x,y,w,self.GetHeight());grp.SetColor(0xff9b7b48);grp.RenderBox(x,y,w,self.GetHeight())
grp.SetColor(0xff4a3d2a)
for i in range(PAGE+1):grp.RenderLine(x+12,y+60+i*ROW,w-24,0)
def OnPressEscapeKey(self):self.Hide();return True
def Destroy(self):
self.Hide();self.closeButton.event=self.finalButton.event=self.activateButton.event=self.prev.event=self.next.event=None
for row in self.rows:row['button'].event=None
self.state=None
class Entry(Action):
def __init__(self,window):
Action.__init__(self,None,0,0,64,'战令',Toggle)
self.minimap=weakref.proxy(window.interface.wndMiniMap);self.SetWindowName('BattlePassEntry');self.OnUpdate()
def OnUpdate(self):
x,y=self.minimap.GetGlobalPosition();self.SetPosition(max(4,x-68),y+460)
def OnPass(kind,value):
s=g_pass
if kind=='done':
m=s.missions.get(value)
name=MissionName(m) if m else L('一项任务')
chat.AppendChat(chat.CHAT_TYPE_INFO,L('战令任务完成:')+name+L(',按 F6 领取奖励'))
elif kind=='claimed':chat.AppendChat(chat.CHAT_TYPE_INFO,L('战令奖励已放入背包' if value else '战令最终奖励已放入背包'))
elif kind=='error':chat.AppendChat(chat.CHAT_TYPE_INFO,L(ERRORS[s.error]))
if g_window and g_window.IsShow():
if kind=='info' and g_window.pendingTicket:
ticket=g_window.pendingTicket;g_window.pendingTicket=None;g_window.AskTicket(*ticket)
g_window.Refresh()
g_pass=Pass(net.SendChatPacket,app.GetTime,OnPass)
def Toggle():
if g_window:g_window.Toggle()
_open=game.GameWindow.Open
_close=game.GameWindow.Close
_command=game.GameWindow.BINARY_ServerCommand_Run
def Open(self):
global g_window,g_entry
_open(self);g_pass.waiting=None;g_pass.unsupported=False;g_pass.Clear()
g_window=BattlePassWindow(g_pass);g_entry=Entry(self)
keys=getattr(self,'onPressKeyDict',None)
if keys is not None and app.DIK_F6 not in keys:keys[app.DIK_F6]=lambda:Toggle()
def Close(self):
global g_window,g_entry
if g_entry:g_entry.Hide();g_entry.event=None;g_entry=None
if g_window:g_window.Destroy();g_window=None
_close(self)
def Command(self,line):
if g_pass.Receive(line):return 1
return _command(self,line)
game.GameWindow.Open=Open
game.GameWindow.Close=Close
game.GameWindow.BINARY_ServerCommand_Run=Command
)PY";