最早想要自己写MUD客户端的念头,还是在几年前。但前几年事情太多,人太忙,我记得自20年疫情之后,到今年年初就没有再登陆过北侠了。23年春节之后空闲一些,于2023年2月19日重启MUD客户端的计划,5月29日形成第一个发布版(0.05b),12月5日发布首个支持pip安装的package版本(0.15),目前发布pip安装的最新版为0.17.3。
在自己写客户端之前,我主要用过zmud和mushclient两个客户端,北大侠客行一直是用mushclient(玩的那会儿还没有mudlet)。我认为mushclient是一个功能非常强大的客户端,唯一缺点是不支持跨平台。由于工作原因,上班的地方不能上网,手机玩的话,确实没有特别适合的跨平台客户端(tintint++倒是支持,但一直不想重学然后重写我在mushclient里的所有python脚本),加上我是一个程序爱好者,所以决定自己干起,正好在游戏之中学习了。
因为我要综合平衡工作、生活、写代码、当然还有自己玩,所以整个更新节奏不会很快,但我认为我会一直更新下去的。感谢北大侠客行巫师团队的努力,北侠吸引我玩的动力,也是我不断更新完善客户端的动力!
本版对模块功能进行了整体调整,支持加载/卸载/重载/预加载多个模块,具体内容如下:
名称 | 类型 | 状态 | 含义 |
---|---|---|---|
PLUGIN_NAME | str | 必须有 | 插件唯一名称 |
PLUGIN_DESC | dict | 必须有 | 插件描述信息的详情,必要关键字包含VERSION(版本)、AUTHOR(作者)、RELEASE_DATE(发布日期)、DESCRIPTION(简要描述) |
PLUGIN_PYMUD_START | func(app) | 函数定义必须有,函数体可以为空 | PYMUD自动读取并加载插件时自动调用的函数, app为PyMudApp(pymud管理类)。该函数仅会在程序运行时,自动加载一次 |
PLUGIN_SESSION_CREATE | func(session) | 函数定义必须有,函数体可以为空 | 在会话中加载插件时自动调用的函数, session为加载插件的会话。该函数在每一个会话创建时均被自动加载一次 |
PLUGIN_SESSION_DESTROY | func(session) | 函数定义必须有,函数体可以为空 | 在会话中卸载插件时自动调用的函数, session为卸载插件的会话。卸载在每一个会话关闭时均被自动运行一次。 |
PyMUD具有以下特点:
PyMUD是一个原生基于Python语言的MUD客户端,因此最基本的环境是Python环境而非操作系统环境。理论上,只要你的操作系统下可以运行Python,就可以运行PyMUD。另外,本客户端的UI设计是基于控制台的,因此也不需要有图形环境的支持。
pip install pymud pip install --upgrade pymud
使用pip安装的pymud,可以通过标准模块调用语法:python -m pymud调用模块执行。
python -m pymud
下图是主要界面,用1-7标注出7个主要内容,含义如下:
连接到服务器有以下四种方式:
使用命令行命令:在命令行内输入:
#session {session_name} {host} {port} {encoding}
大括号内容为,分别代表会话名称、服务器地址、端口、编码方式(编码方式可不指定,此时默认为utf-8编码)。例如,使用下列命令可以创建一个名为newstart的会话并连接到北侠。
#session newstart mud.pkuxkx.com 8081
在settings.py中加入常用的角色会话。此时会自动生成菜单,鼠标选择对应的菜单可自动连接到会话。
sessions = { "pkuxkx" : { # 一个服务器作为一个一级菜单项,所有角色放在下面 "host" : "mud.pkuxkx.com", # 服务器地址 "port" : "8081", # 服务器端口 "encoding" : "utf8", # 服务器编码 "autologin" : "{0};{1}", # 自动登录命令,此处{0}、{1}是两个参数,实际运用时会自动指代下面的角色id和密码进行替换。分号指示两次命令输入。当不使用自动登录时,该参数可以设置为None,或空字符串"" "default_script": "pkuxkx", # 角色登录后默认加载的脚本(省略.py扩展名)。此处指默认加载当前目录下的pkuxkx.py文件 "chars" : { "newstart" : ["newstart", "mypassword"], # 创建的角色菜单,会话名、角色ID、角色密码 "your_session" :["char_id", "char_pass"], # 可保存多个角色,使用,隔开,即在字典中使用多个key。 } }, "evennia" : { # 也可以创建多个服务器的菜单,按规则依次填写即可。 "host" : "192.168.220.128", "port" : "4000", "encoding" : "utf8", "autologin" : "connect {0} {1}", "default_script": None, "chars" : { "evennia" : ("admin", "admin"), } } }
在当前目录下新建pymud.cfg文件,并使用json格式按settings.py中样式覆盖sessions等部分,也可以覆盖settings.py中的其他设置。如(注:此处json文件不支持注释):
{ "client" : { "status_height" : 4, "interval" : 10, "auto_connect" : 1, "echo_input" : 0, "status_display" : 1 }, "sessions": { "pkuxkx" : { "host" : "mud.pkuxkx.com", "port" : "8081", "encoding" : "utf8", "autologin" : "{0};{1}", "default_script": "myscript", "chars" : { "newstart" : ["newstart", "password"], "your_session" : ["char_id", "password"] } } } }
可以支持同时打开多个会话,此时在顶部中央会显示不同的会话名称和当前会话。可以使用鼠标切换会话,或者使用快捷键Ctrl+左右箭头实现会话向左和向右切换。
PyMUD是一个基于控制台UI的MUD客户端,因此不能像zmud/mushclient/mudlet等客户端那样,在窗口中添加触发、别名等内容,所有上述实现都依赖脚本。又因为是Python语言实现,因此也支持Python语言实现。
PyMUD使用#load命令加载脚本文件,调用了importlib库的import_module方法。除此之外,为了满足会话的使用要求,加载的脚本文件中,必须有一个命名为:Configuration的类,构造函数必须接受一个Session类型的参数(传递的是当前会话)。
一个可加载的基本的脚本文件内容参考如下:
class Configuration: def __init__(self, session): self.session = session
PyMUD接受在命令行使用命令进行交互。PyMUD内置命令均使用#开头。目前支持以下命令:
命令 | 简介 | 示例 |
---|---|---|
help | 获取帮助 | . #help, #help session |
exit | 退出app | |
close | 关闭当前会话 | |
session | 创建新会话 | . #session mysession mud.pkuxkx.com 8080 gbk |
{session_name} | 可以直接使用会话名加#,将该会话切换为当前会话 | . #mysession |
{num} | 重复num次向mud服务器发送命令 | . #3 get b1a from jin nang |
all | 向所有活动的会话发送同样的命令 | . #all quit |
connect, con | 连接或重新连接一个断开状态的会话 | . #con |
info | 在当前会话中输出一行蓝色的信息 | . #info this is an information |
warning | 在当前会话中输出一行黄色的信息 | |
error | 在当前会话中输出一行红色的信息 | |
clear, cls | 清除当前会话中的所有文本缓存 | |
load | 加载制定脚本文件,省略.py扩展名 | . #load myscript |
reload | 重新加载脚本文件,为了修改脚本后快速加载 | |
alias, ali | 别名命令,详见#help alias | |
trigger, tri | 触发器命令,详见#help trigger | |
gmcp | gmcp命令,详见#help gmcp | |
command, cmd | 命令命令,详见#help command | |
timer, ti | 定时器命令,详见#help timer | |
variable, var | 变量命令,详见#help variable | |
global | 全局变量命令 | |
test | 测试触发器命令,后面的内容会发送到触发器测试环境,会执行触发 | . #test %copy |
wait, wa | 延时命令,用于单行多命令输入,或者SimpleTrigger等其中,延时指定ms数 | xixi;#wa 1000;haha |
message, mess | 消息命令,产生一个弹出窗口,显示指定消息 | . #mess %line |
gag | 隐藏当前行,用于触发器中 | . #gag |
replace | 替换当前行显示,用于触发器中 | . #replace %line-[这是增加的内容] |
repeat, #rep | 重复输出上一次的命令 | |
save | 将当前会话变量保存到文件,即时生效 |
内置变量以%开头,可以在触发器命令中直接使用变量名进行访问,例如“#mess %line”。PyMUD的会话支持以下内置变量:
变量 | 含义 |
---|---|
%line | 当前行内容(纯文本) |
%raw | 当前行信息(带有ANSI字符的原始格式) |
%copy | 当复制内容后,复制内容信息 |
%1~%9 | 别名、触发器的正则捕获内容 |
脚本中所需使用的有关基本对象已经内置在objects.py中,可以直接import使用。基本对象含义如下:
类型 | 简介 | 用途 |
---|---|---|
Alias | 别名的基本类型 | 可以用来实现基本/复杂别名,必须全代码实现 |
Trigger | 触发器的基本类型 | 可以用来实现基本/复杂触发器,必须全代码实现 |
Timer | 定时器的基本类型 | 可以用来实现基本/复杂定时器,必须全代码实现 |
SimpleAlias | 简单别名 | 可以使用mud命令和脚本而非代码的方法来实现的别名,类似zmud别名 |
SimpleTrigger | 简单触发器 | 可以使用mud命令和脚本而非代码的方法来实现的触发器,类似zmud触发器 |
SimpleTimer | 简单定时器 | 可以使用mud命令和脚本而非代码的方法来实现的定时器,类似zmud定时器 |
Command | 命令的基本类型 | 命令是PyMUD的一大原创特色,在高级脚本中会详细讲到 |
SimpleCommand | 简单命令 | 命令是PyMUD的一大原创特色,在高级脚本中会详细讲到 |
GMCPTrigger | GMCP触发器 | 专门用于处理MUD服务器GMCP数据,操作方法类似于触发器 |
CodeBlock | 可以运行的代码块 | 用于SimpleTrigger、SimpleAlias、SimpleTimer以及命令行输入处理的模块 |
在脚本中要使用上述类型的import:
from pymud import Alias, Trigger, Timer, SimpleTrigger, SimpleAlias, SimpleTimer, Command, SimpleCommand, GMCPTrigger
与MUD服务器的所有交互操作,均由Session类所实现,因此,其与服务器的交互方法是编写脚本所需要的必要内容。Session类的常用方法如下。
方法名称 | 参数:类型 | 返回值 | 简介 |
---|---|---|---|
writeline | line:str | None | 向服务器中写入一行。如果参数使用;分隔(使用Settings.client.seperator指定),将分行后逐行写入 |
exec_command | line:str | None | 执行一段命令,并将数据发送到服务器。本函数和writeline的区别在于,本函数会先进行Command和Alias解析,若不是再使用writeline发送。当line不包含Command和Alias时,等同于writeline |
exec_command_after | wait:float, line:str | None | 等待wait秒后,再执行line所代表的命令 |
exec_command_async | line:str | None | exec_command的异步(async)版本。在每个命令发送前会等待上一个执行完成。 |
getUniqueNumber | 无 | int | 生成一个本会话的唯一序号 |
getUniqueID | prefix | str | 生成一个类似{prefix}_{uniqueID}的唯一序号 |
enableGroup | group:str, enabled=True | None | 使能/禁用指定组名为group的所有对象,包括Alias、Trigger、Timer、Command、GMCPTrigger |
addAliases | alis:dict | None | 向会话中添加一组Alias对象,对象由字典alis包含 |
addAlias | ali:Alias | None | 向会话中添加一个Alias对象 |
delAlias | ali:str | None | 删除会话中id为ali的Alias对象 |
addTriggers | tris:dict | None | 添加一组Trigger对象 |
addTrigger | tri:Trigger | None | 添加一个Trigger |
delTrigger | tri:str | None | 删除id为tri的Trigger |
addCommands | cmds:dict | None | 添加一组Command对象 |
addCommand | cmd:Command | None | 添加一个Command |
delCommand | cmd:str | None | 删除id为cmd的Command |
addTimers | tis:dict | None | 添加一组Timer对象 |
addTimer | ti:Timer | None | 添加一个Timer |
delTimer | ti:str | None | 删除id为ti的Timer |
addGMCPs | gmcp:dict | None | 添加一组GMCPTrigger对象 |
addGMCP | gmcp:GMCPTrigger | None | 添加一个GMCPTrigger |
delGMCP | gmcp:str | None | 删除id为gmcp的GMCPTrigger |
setVariable | name:str,value | None | 设置(或新增)一个变量,变量名为name,值为value |
setVariables | names,value | None | 设置(或新增)一组变量,变量名为names(元组或列表),值为values(元组或列表) |
updateVariables | kvdict:dict | None | 更新一组变量,与setVariables的区别为,接受的参数为dict键值对 |
getVariable | name:str,default=None | 任意类型 | 获取名为name的变量值,当name不存在时,返回default |
getVariables | names | tuple | 获取names列表中所有的变量值。对每一个,当name不存在时,获取内容为None值 |
setGlobal | name:str,value | None | 设置(或新增)一个全局变量,变量名为name,值为value(全局变量是可以跨session进行访问的变量) |
getGlobal | name:str,default=None | 任意类型 | 获取名为name的全局变量值,当name不存在时,返回default |
replace | newstr:str | None | 在触发器中使用,将会话中当前行的显示内容修改为newstr。使用replace可以在触发器时将额外信息增加到当前行中 |
info | msg,title,style | None | 向Session窗体中输出一串信息,会以[title] msg的形式输出,title默认PYMUD INFO,颜色(style指定)默认绿色 |
warning | msg,title,style | None | 向Session窗体中输出一串信息,会以[title] msg的形式输出,title默认PYMUD WARNING,颜色(style指定)默认黄色 |
error | msg,title,style | None | 向Session窗体中输出一串信息,会以[title] msg的形式输出,title默认PYMUD ERROR,颜色(style指定)默认红色 |
Session类型有部分直接访问的属性,如下表
属性名称 | 简介 |
---|---|
connected | bool类型,指示会话连接状态 |
duration | float类型,返回服务器端连接的时间,以秒为单位 |
status_maker | 引用类型,指向状态栏显示的函数或字符串 |
vars | variable变量的点访问器。即可以使用session.vars.neili直接访问名为neili的变量。下同 |
globals | 全局变量的点访问器 |
tris | 触发器的点访问器 |
alis | 别名的点访问器 |
cmds | 命令的点访问器 |
timers | 定时器的点访问器 |
gmcp | gmcp的点访问器 |
进入MUD游戏,第一个要使用的必然是触发器(Trigger)。PyMUD支持多种特性的触发器,并内置实现了Trigger和SimpleTrigger两个基础类。 要在会话中使用触发器,要做的两件事是:
Trigger是触发器的基础类,继承自MatchObject类(可以不用理会)。SimpleTrigger继承自Trigger,可以直接用命令而非函数来实现触发器触发时的操作。 Trigger与SimpleTrigger的构造函数分别如下:
class Trigger: def __init__(self, session, patterns, *args, **kwargs): pass class SimpleTrigger: def __init__(self, session, patterns, code, *args, **kwargs): pass
为了使用统一的函数语法,除重要的参数session(指定会话)、patterns(指定匹配模式)、code(SimpleTrigger)的执行代码之外,其余所有触发器的参数都通过命名参数在kwargs中指定。触发器支持和使用的命名参数、默认值及其含义如下:
构造函数中的其他参数含义如下:
例如,在新手任务(平一指配药)任务中,要在要到任务后,自动n一步,并在延时500ms后进行配药;配药完成后自动s,并提交配好的药,并再次接下一个任务,则可以如此建立触发器:
tri1 = SimpleTrigger(self.session, "^[> ]*你向平一指打听有关『工作』的消息。", "n;#wa 500;peiyao") self.session.addTrigger(tri1) tri2 = SimpleTrigger(self.session, "^[> ]*不知过了多久,你终于把药配完。", "s;#wa 500;give ping yao;#wa 500;ask ping about 工作") self.session.addTrigger(tri2)
例如,当收到有关fullme或者其他图片任务的链接信息时,自动调用浏览器打开该网址,则可以建立一个标准触发器(示例中同时指定了触发器id):
def initTriggers(self): tri = Trigger(self.session, id = 'tri_webpage', patterns = r'^http://fullme.pkuxkx.com/robot.php.+$', onSuccess = self.ontri_webpage) self.session.addTrigger(tri) def ontri_webpage(self, name, line, wildcards): webbrowser.open(line) # 在开头之前已经 import webbrowser
例如,在set hpbrief long的情况下,hpbrief显示三行内容。此时进行多行触发(3行),并将触发中获取的参数值保存到variable变量中:
HP_KEYS = ( "exp", "pot", "maxneili", "neili", "maxjingli", "jingli", "maxqi", "effqi", "qi", "maxjing", "effjing", "jing", "zhenqi", "zhenyuan", "food", "water", "fighting", "busy" ) def initTriggers(self): tri = Trigger(self.session, id = "tri_hpbrief", patterns = (r'^[> ]*#(\d+.?\d*[KM]?),(\d+),(\d+),(\d+),(\d+),(\d+)$', r'^[> ]*#(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)$', r'^[> ]*#(\d+),(\d+),(-?\d+),(-?\d+),(\d+),(\d+)$',), onSuccess = self.ontri_hpbrief) self.session.addTrigger(tri) def ontri_hpbrief(self, name, line, wildcards): self.session.setVariables(self.HP_KEYS, wildcards)
如果要捕获文字中的颜色、闪烁等特性,则可以使用触发器的raw属性。例如,在长安爵位任务中,要同时判断路人身上的衣服和鞋子的颜色和类型时,可以使用如下触发:
def initTriggers(self): tri = Trigger(self.session, patterns = r"^.+□(?:\x1b\[[\d;]+m)?(身|脚)\S+一[双|个|件|把](?:\x1b\[([\d;]+)m)?([^\x1b\(\)]+)(?:\x1b\[[\d;]+m)?\(.+\)", onSuccess = self.judgewear, raw = True) self.session.addTrigger(tri) def judgewear(self, name, line, wildcards): buwei = wildcards[0] # 身体部位,身/脚 color = wildcards[1] # 颜色,30,31,34,35为深色,32,33,36,37为浅色 wear = wildcards[2] # 着装是布衣/丝绸衣服、凉鞋/靴子等等 # 对捕获结果的进一步判断,此处省略
当要简化一些输入的MUD命令,或者代入一些参数时,会使用到别名(Alias)。PyMUD支持多种特性的别名,并内置实现了Alias和SimpleAlias两个基础类。 要在会话中使用别名,要做的两件事是:
Alias是别名的基础类,与触发器一样,也是继承自MatchObject类。事实上,在PyMUD设计时,我认为别名和触发器是完全相同的东西,只不过一个对输入的命令进行匹配处理(别名),而另一个对MUD服务器端的消息进行匹配处理(触发器),因而在代码上,这两个类除了名称不同之外,差异也是极小的。主要差异是在于触发器增加了异步触发功能(高级内容中会讲到)。SimpleAlias继承自Alias,可以直接用命令而非函数来实现别名触发时的操作。 Alias与SimpleAlias的构造函数与Trigger和SimpleTrigger完全相同,此处不再列举: 别名支持的命名参数、默认值及其含义与上一节触发器的完全相同,但以下命名参数在别名中虽然支持,但不使用:
例如,要将从扬州中央广场到信阳小广场的路径设置为别名,可以如此建立别名:
ali = SimpleAlias(self.session, "yz_xy", "#4 w;nw;#5 w") self.session.addAlias(ali)
例如,每次慕容信件任务完成后都要从尸体中取出信件,另外还有可能有黄金、白银,每次都输入get letter fromc corpse等等命令太长,想进行缩写,则可以如此建立别名:
ali = SimpleAlias(self.session, "^gp\s(.+)$", "get %1 from corpse") self.session.addAlias(ali)
建立别名之后,可以使用gp silver, gp gold, gp letter代替 get silver/gold/letter from corpse
例如,要将gan che to 方向建立成别名,并且在方向既可以使用缩写(e代表east之类),也可以使用全称,则可以建立一个标准别名:
DIRS_ABBR = { "e": "east", "w": "west", "s": "south", "n": "north", "u": "up", "d": "down", "se": "southeast", "sw": "southwest", "ne": "northeast", "nw": "northwest", "eu": "eastup", "wu": "westup", "su": "southup", "nu": "northup", "ed": "eastdown", "wd": "westdown", "sd": "southdown", "nd": "northdown", } def initAliases(self): ali = Alias(self.session, "^gc\s(.+)$", onSuccess = self.ganche) self.addAlias(ali) def ganche(self, name, line, wildcards): dir = wildcards[0] if dir in self.DIRS_ABBR.keys(): cmd = "gan che to {}".format(self.DIRS_ABBR[dir] else: cmd = f"gan che to {dir}" self.session.writeline(cmd)
要周期性的执行某段代码,会使用到定时器(Timer)。PyMUD支持多种特性的定时器,并内置实现了Timer和SimpleTimer两个基础类。 要在会话中使用定时器,要做的两件事是:
Timer与SimpleTimer的构造函数分别如下:
class Timer: def __init__(self, session, *args, **kwargs): pass class SimpleTimer: def __init__(self, session, code, *args, **kwargs): pass
定时器支持以下几个命名参数,其默认值及其含义为:
例如,在莫高窟冥想时,每隔5s发送一次mingxiang命令,则可以这样实现定时器
tm = SimpleTimer(self.session, timeout = 5, id = "tm_mingxiang", code = "mingxiang") self.session.addTimers(tm)
上述定时器的标准实现版本如下
def initTimers(self): tm = Timer(self.session, timeout = 5, id = "tm_mingxiang", onSuccess = self.timer_mingxiang) self.session.addTimers(tm) def timer_mingxiang(self, id, *args, **kwargs): self.session.writeline("mingxiang")
由于PyMUD需要完整的Python脚本进行实现,在许多情况下,如zmud/mushclient里面的变量是可以完全使用Python自己的变量来进行替代的。但PyMUD保留了变量这一功能,我设计脚本的思路为,只有单个模块使用的变量,多使用Python变量实现,而需要跨模块调用的变量,才使用PyMUD变量进行存储。 事实上,PyMUD变量本质上仍是Python的变量,只不过赋值给了对应的session会话所有。也基于此特点,PyMUD的变量支持任意值类型,不是一定为简单类型,也可以为列表、字典,也可以为复杂的对象类型。 在触发器、别名的使用过程中,存在以下系统变量: %line:即触发的行本身。对于多行触发,%line会返回多行 %1 ~ %9: 触发器、别名使用时的正则匹配的匹配组
变量可以直接在SimpleTrigger、SimpleAlias、SimpleTimer中调用。系统变量直接调用(如%line),自定义变量前面加@调用。 例如,在收到“xxx告诉你:”之类的消息时,使用#message弹窗显示可以:
tri = SimpleTrigger(self.session, r".+告诉你:.+", "#message %line") self.session.addTrigger(tri)
例如,通过触发器捕获到身上的钱的数量,可以将数量与类型联合为字典,再添加到赋值给1个变量
money = {'cash': 0, 'gold': 1, 'silver': 50, 'coin': 77} self.session.setVariable("money", money) # 在使用时,则这样获取 money = self.session.getVariable("money")
也可以将上述变量分别存在不同的名称中,并一同读写:
money_key = ('cash', 'gold', 'silver', 'coin') money_count = (0, 1, 50, 77) self.session.setVariables(money_key, money_count) # 在使用时,则这样获取 silver = self.session.getVariable("silver")
PyMUD的高级脚本使用,必须要掌握基于asyncio库的async/await使用方法。PyMUD使用异步架构构建了整个程序的核心框架。当然,异步在脚本中不是必须的,它只是一种可选的实现方式而已,其他任何客户端都不像PyMUD一样原生完全支持异步操作,这也是PyMUD客户端与其他所有客户端的最核心差异所在。
可以先在以下帖子看看我和HellClient客户端大佬jarlyyn的讨论帖: https://www.pkuxkx.com/forum/thread-47989-2-1.html
另以下有几篇参考文档可供学习。
python的asyncio库介绍:https://docs.python.org/zh-cn/3.10/library/asyncio.html
Python异步协程(asyncio详解):https://www.cnblogs.com/Red-Sun/p/16934843.html
由于MUD是基于网咯IO的程序,在程序运行的过程中,大部分时间是空闲在等待服务器数据或客户端输入的。也正是如此,同步模式下的等待都会造成阻塞。
基于async/await的异步可以带来不少的好处,包括:
试想以下场景:当我们在北侠中完成一次dazuo之后,正常的服务器回应消息是:你运功完毕,深深吸了口气,站了起来。 那么,我们有可能是想持续打坐修炼内力,也可能是只要执行一次dazuo max指令;也可能时tuna过程中yun regenerate之后的打坐;在持续打坐过程中,也考虑要每隔多少次补满食物饮水; 要在一个服务器回应消息下进行不同种类的处理,在以往同步模式下,可以有很多种实现,例如可以:
上述第1种方式下,多个模块功能的代码都集中在同一个触发器下,一个出错会影响其他,要新增功能则又要对其进行修改; 第2中方式下,同样模式匹配的触发过多,代码会显得臃肿。
如果使用异步,则可以仅使用一个触发器,我们在等待该触发器触发事件后再执行对应代码。此时,触发器的触发结果与执行的内容结果是解耦的,即触发器本身不包含触发器被触发后应该执行的代码,这部分代码由功能实现函数进行完成。这也是异步触发器的由来。
PyMUD的Trigger类同时支持同步和异步模式。当使用异步触发器时,有以下两个建议:
Trigger类的triggered方法是一个async定义的协程函数。在不指定code、不指定onSuccess时,其默认的触发函数仅设置Trigger的Event标识,该标识是一个asyncio.Event对象。可以使用下面代码来异步等待触发器的执行。
await tri.triggered()
以之前打坐的触发示例:
class Configuration: def __init__(self, session): self.session = session self.initTriggers() def initTriggers(self): self._triggers = {} self._triggers['tri_dazuo'] = Trigger(self.session, r"^[> ]*你运功完毕,深深吸了口气,站了起来。", id = "tri_dazuo") self.session.addTriggers(self._triggers) async def dazuo_always(self): # 此处仅为了说明异步触发器的使用,假设气是无限的,可以无限打坐 # 每打坐100次,吃干粮,喝酒袋 time = 0 while True: # 永久循环 self.session.writeline("dazuo 10") # 发送打坐命令 await self._triggers["tri_dazuo"].triggered() # 等待dazuo触发 times += 1 if times > 100: self.session.writeline("eat liang") self.session.writeline("drink jiudai") times = 0
从上面的异步示例中可以看出,dazuo/eat/drink代码不是放在Trigger的触发中的,而且该代码逻辑一目了然(因为是以同步思维实现的异步)。当然,上面的代码仅是一个异步触发的使用示例,实际dazuo远比此复杂。
有了异步Trigger之后,命令就有了实现的基础。命令是什么?可以这么理解,PyMUD的命令,就是将MUD的命令输入、返回响应等封装在一起的一种对象,基于Command可以实现从最基本的MUD命令响应,到最复杂的完整的任务辅助脚本。
要在会话中使用触发器,要做的事是:
此时,调用该Command,只需在命令行与输入该Command匹配模式匹配的命令即可
Command与Trigger、Alias一样,也是继承自MathObject,也是通过模式匹配进行调用。因此,Command的构造函数与Trigger、Alias相同:
class Command: def __init__(self, session, patterns, *args, **kwargs): pass
与Alias、Trigger的差异是,Command包含几个新的会经常被使用的方法调用,见下表。
方法 | 参数 | 返回值 | 含义 |
---|---|---|---|
create_task | coro, args, name | asyncio.Task | 实际是asyncio.create_task的包装,在创建任务的同时,将其加入了session的task清单和本Command的Task清单,可以保证执行,也可以供后续操作使用 |
reset | 无 | 无 | 复位该任务。复位除了清除标识位之外,还会清除所有未完成的task。在Command的多次调用时,要手动调用reset方法,以防止同一个命令被多次触发。 |
execute | cmd, *args, **kwargs | 无 | async定义的异步方法,在Command被执行时会自动调用该方法 |
先用一个简单的示例来说明Command的应用。在张金敖任务(机关人起源)中,当到达对应节点时,需要使用walk命令行走指定步数然后等待线索。现在想在行走指定步数之后自动使用walk -p停止下来,可以使用以下一个Command类来实现。此处所有逻辑在CmdWalk类override的execute方法中实现,对已经行走步数的技术是通过while循环实现。这种实现方式代码可读性好,其逻辑思维符合正常同步思维模式。
class CmdWalk(Command): "北侠节点处的Walk指令,控制指定步数" _help = """ 定制walk命令使用参考,可接受正常walk命令,也可以使用诸如walk yangzhou 8来控制从节点向yangzhou节点行走,8步之后停下来 正常指令 含义 walk xxx: 标准walk指令 walk -c xxx: 显示路径 walk -p: 暂停行走 walk: 继续行走 特殊指令: walk xxx 3: 第二个参数的数字,控制行走的步数,用于张金敖任务 """ def __init__(self, session, *args, **kwargs): self.tri_room = Trigger(self.session, id = 'tri_room', patterns = r'^[>]*(?:\s)?(\S.+)\s-\s*[★|☆|∞]?$', isRegExp = True) self.session.addTrigger(self.tri_room) super().__init__(session, "^walk(?:\s(\S+))?(?:\s(\S+))?(?:\s(\S+))?", *args, **kwargs) async def execute(self, cmd, *args, **kwargs): try: m = re.match(self.patterns, cmd) if m: para = list() for i in range(1, 4): if m[i] != None: para.append(m[i]) # 如果是步数设置,需要人工控制 # 例如walk yangzhou 8 if (len(para) > 0) and para[-1].isnumeric(): cnt = int(para[-1]) step = "walk " + " ".join(para[:-1]) self.info(f"即将通过walk行走,目的地{para[-2]},步数{para[-1]}步...", "walk") self.session.writeline("set walk_speed 2") # 调节速度 await asyncio.sleep(1) self.session.writeline(step) # 使用循环控制步数 while cnt > 0: cnt = cnt - 1 await self.tri_room.triggered() # 等待房间名被触发cnt次 self.session.writeline("walk -p") self.session.writeline("unset walk_speed") # 恢复速度 self.info(f"通过walk行走,目的地{para[-2]},步数{para[-1]}步完毕", "walk") # 否则直接命令发送 else: self.session.writeline(cmd) except Exception as e: self.error(f"异步执行中遇到异常, {e}, 类型为 {type(e)}") self.error(f"异常追踪为: {traceback.format_exc()}")
Command类也可以支持复杂逻辑带不同参数的指令,直至可以实现一个完整的任务辅助机器人。此处再举一个dazuo的例子。在北侠中,打坐是最长使用的命令之一。我们可能需要打坐到双倍内力即停止以进行后续任务,或者专门打坐修炼内力,或者使用dz命令进行打坐以打通任督二脉。在专门修炼内力时,还需要定期补充食物饮水。打坐的时候,也要统筹考虑气不足、精不足等各种可能遇到的情况。以下是一个完整实现的打坐命令,可以使用dzt xxx来执行上述所有操作。详细代码如下: 注:此Command调用了支持jifa/enable命令的CmdEnable,支持hpbrief的命令CmdHpbrief,以及支持食物饮水等生活的命令CmdLifMisc,具体代码未列出,但在阅读dazuoto命令代码时不影响可读性。
class CmdDazuoto(Command): """ 各种打坐的统一命令, 使用方法: dzt 0 或 dzt always: 一直打坐 dzt 1 或 dzt once: 执行一次dazuo max dzt 或 dzt max: 持续执行dazuo max,直到内力到达接近2*maxneili后停止 dzt dz: 使用dz命令一直dz dzt stop: 安全终止一直打坐命令 """ def __init__(self, session, cmdEnable, cmdHpbrief, cmdLifeMisc, *args, **kwargs): super().__init__(session, "^(dzt)(?:\s+(\S+))?$", *args, **kwargs) self._cmdEnable = cmdEnable self._cmdHpbrief = cmdHpbrief self._cmdLifeMisc = cmdLifeMisc self._triggers = {} self._initTriggers() self._force_level = 0 self._dazuo_point = 10 self._halted = False def _initTriggers(self): self._triggers["tri_dz_done"] = self.tri_dz_done = Trigger(self.session, r'^[> ]*你运功完毕,深深吸了口气,站了起来。$', id = "tri_dz_done", keepEval = True, group = "dazuoto") self._triggers["tri_dz_noqi"] = self.tri_dz_noqi = Trigger(self.session, r'^[> ]*你现在的气太少了,无法产生内息运行全身经脉。|^[> ]*你现在气血严重不足,无法满足打坐最小要求。|^[> ]*你现在的气太少了,无法产生内息运行小周天。$', id = "tri_dz_noqi", group = "dazuoto") self._triggers["tri_dz_nojing"] = self.tri_dz_nojing = Trigger(self.session, r'^[> ]*你现在精不够,无法控制内息的流动!$', id = "tri_dz_nojing", group = "dazuoto") self._triggers["tri_dz_wait"] = self.tri_dz_wait = Trigger(self.session, r'^[> ]*你正在运行内功加速全身气血恢复,无法静下心来搬运真气。$', id = "tri_dz_wait", group = "dazuoto") self._triggers["tri_dz_halt"] = self.tri_dz_halt = Trigger(self.session, r'^[> ]*你把正在运行的真气强行压回丹田,站了起来。', id = "tri_dz_halt", group = "dazuoto") self._triggers["tri_dz_finish"] = self.tri_dz_finish = Trigger(self.session, r'^[> ]*你现在内力接近圆满状态。', id = "tri_dz_finish", group = "dazuoto") self._triggers["tri_dz_dz"] = self.tri_dz_dz = Trigger(self.session, r'^[> ]*你将运转于全身经脉间的内息收回丹田,深深吸了口气,站了起来。|^[> ]*你的内力增加了!!', id = "tri_dz_dz", group = "dazuoto") self.session.addTriggers(self._triggers) def stop(self): self.tri_dz_done.enabled = False self._halted = True self._always = False async def dazuo_to(self, to): # 开始打坐 dazuo_times = 0 self.tri_dz_done.enabled = True if not self._force_level: await self._cmdEnable.execute("enable") force_info = self.session.getVariable("eff-force", ("none", 0)) self._force_level = force_info[1] self._dazuo_point = (self._force_level - 5) // 10 if self._dazuo_point < 10: self._dazuo_point = 10 await self._cmdHpbrief.execute("hpbrief") neili = int(self.session.getVariable("neili", 0)) maxneili = int(self.session.getVariable("maxneili", 0)) force_info = self.session.getVariable("eff-force", ("none", 0)) if to == "dz": cmd_dazuo = "dz" self.tri_dz_dz.enabled = True self.info('即将开始进行dz,以实现小周天循环', '打坐') elif to == "max": cmd_dazuo = "dazuo max" need = math.floor(1.90 * maxneili) self.info('当前内力:{},需打坐到:{},还需{}, 打坐命令{}'.format(neili, need, need - neili, cmd_dazuo), '打坐') elif to == "once": cmd_dazuo = "dazuo max" self.info('将打坐1次 {dazuo max}.', '打坐') else: cmd_dazuo = f"dazuo {self._dazuo_point}" self.info('开始持续打坐, 打坐命令 {}'.format(cmd_dazuo), '打坐') while (to == "dz") or (to == "always") or (neili / maxneili < 1.90): if self._halted: self.info("打坐任务已被手动中止。", '打坐') break waited_tris = [] waited_tris.append(self.create_task(self.tri_dz_done.triggered())) waited_tris.append(self.create_task(self.tri_dz_noqi.triggered())) waited_tris.append(self.create_task(self.tri_dz_nojing.triggered())) waited_tris.append(self.create_task(self.tri_dz_wait.triggered())) waited_tris.append(self.create_task(self.tri_dz_halt.triggered())) if to != "dz": waited_tris.append(self.create_task(self.tri_dz_finish.triggered())) else: waited_tris.append(self.create_task(self.tri_dz_dz.triggered())) self.session.writeline(cmd_dazuo) done, pending = await asyncio.wait(waited_tris, timeout =100, return_when = "FIRST_COMPLETED") tasks_done = list(done) tasks_pending = list(pending) for t in tasks_pending: t.cancel() if len(tasks_done) == 1: task = tasks_done[0] _, name, _, _ = task.result() if name in (self.tri_dz_done.id, self.tri_dz_dz.id): if (to == "always"): dazuo_times += 1 if dazuo_times > 100: # 此处,每打坐200次,补满水食物 self.info('该吃东西了', '打坐') await self._cmdLifeMisc.execute("feed") dazuo_times = 0 elif (to == "dz"): dazuo_times += 1 if dazuo_times > 50: # 此处,每打坐50次,补满水食物 self.info('该吃东西了', '打坐') await self._cmdLifeMisc.execute("feed") dazuo_times = 0 elif (to == "max"): await self._cmdHpbrief.execute("hpbrief") neili = int(self.session.getVariable("neili", 0)) if self._force_level >= 161: self.session.writeline("exert recover") await asyncio.sleep(0.2) elif (to == "once"): self.info('打坐1次任务已成功完成.', '打坐') break elif name == self.tri_dz_noqi.id: if self._force_level >= 161: await asyncio.sleep(0.1) self.session.writeline("exert recover") await asyncio.sleep(0.1) else: await asyncio.sleep(15) elif name == self.tri_dz_nojing.id: await asyncio.sleep(1) self.session.writeline("exert regenerate") await asyncio.sleep(1) elif name == self.tri_dz_wait.id: await asyncio.sleep(5) elif name == self.tri_dz_halt.id: self.info("打坐已被手动halt中止。", '打坐') break elif name == self.tri_dz_finish.id: self.info("内力已最大,将停止打坐。", '打坐') break else: self.info("命令执行中发生错误,请人工检查", '打坐') return self.FAILURE self.info('已成功完成', '打坐') self.tri_dz_done.enabled = False self.tri_dz_dz.enabled = False self._onSuccess() return self.SUCCESS async def execute(self, cmd, *args, **kwargs): try: self.reset() if cmd: m = re.match(self.patterns, cmd) if m: cmd_type = m[1] param = m[2] self._halted = False if param == "stop": self._halted = True self.info('已被人工终止,即将在本次打坐完成后结束。', '打坐') return self.SUCCESS elif param in ("dz",): return await self.dazuo_to("dz") elif param in ("0", "always"): return await self.dazuo_to("always") elif param in ("1", "once"): return await self.dazuo_to("once") elif not param or param == "max": return await self.dazuo_to("max") except Exception as e: self.error(f"异步执行中遇到异常, {e}, 类型为 {type(e)}") self.error(f"异常追踪为: {traceback.format_exc()}")
GMCP(Generic Mud Communication Protocol)是一种用于传递非显示字符的MUD通信协议,在标准telnet协议的基础上定义了特定的选项协商和子协商(命令为0xC9)。有关GMCP协议的详细信息参见 https://tintin.mudhalla.net/protocols/gmcp/
北侠是支持GMCP进行数据通信的,详细可以在游戏中使用tune gmcp进行查看可以支持的具体GMCP种类。
PyMUD使用GMCPTrigger类来进行GMCP消息的处理。其使用方法与标准Trigger基本相同,也同样支持同步与异步两种方式。与Trigger的最大差异在于,GMCPTrigger使用name参数作为触发条件,该name必须与MUD服务器发送的GMCP消息的名称完全一致(区分大小写,因此大小写也必须一致)。
要在会话中使用GMCP触发器,要做的两件事是:
GMCPTrigger的构造函数如下:
class GMCPTrigger(BaseObject): """ 支持GMCP收到数据的处理,可以类似于Trigger的使用用法 GMCP必定以指定name为触发,触发时,其值直接传递给对象本身 """ def __init__(self, session, name, *args, **kwargs): pass
GMCPTrigger也使用了统一的函数语法,必须指定的位置参数包括session(指定会话)、name(为GMCPTrigger服务器发送的消息名称),其余的参数都通过命名参数在kwargs中指定,与Trigger基本相同。具体如下:
(由于GMCPTrigger在0.16.2版本发生变更,此处待更新)(by newstart, 2023-12-19)
可以通过脚本定制状态窗口内容。要定制状态窗口的显示内容,将session.status_maker属性赋值为一个返回支持显示结果的函数即可。可以支持标准字符串或者prompt_toolkit所支持的格式化显示内容。 有关prompt_toolkit的格式化字符串显示,可以参见该库的官方帮助页面: https://python-prompt-toolkit.readthedocs.io/en/master/pages/printing_text.html
以下是一个实现状态窗口的示例:
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType from settings import Settings class Configuration: def __init__(self, session) -> None: session.status_maker = self.status_window def status_window(self): formatted_list = list() (jing, effjing, maxjing, jingli, maxjingli, qi, effqi, maxqi, neili, maxneili) = self.session.getVariables(("jing", "effjing", "maxjing", "jingli", "maxjingli", "qi", "effqi", "maxqi", "neili", "maxneili")) ins_loc = self.session.getVariable("ins_loc", None) tm_locs = self.session.getVariable("tm_locs", None) ins = False if isinstance(ins_loc, dict) and (len(ins_loc) >= 1): ins = True loc = ins_loc elif isinstance(tm_locs, list) and (len(tm_locs) == 1): ins = True loc = tm_locs[0] # line 1. char, menpai, deposit, food, water, exp, pot formatted_list.append((Settings.styles["title"], "【角色】")) formatted_list.append((Settings.styles["value"], "{0}({1})".format(self.session.getVariable('charname'), self.session.getVariable('char')))) formatted_list.append(("", " ")) formatted_list.append((Settings.styles["title"], "【门派】")) formatted_list.append((Settings.styles["value"], "{}".format(self.session.getVariable('menpai')))) formatted_list.append(("", " ")) formatted_list.append((Settings.styles["title"], "【存款】")) formatted_list.append((Settings.styles["value"], "{}".format(self.session.getVariable('deposit')))) formatted_list.append(("", " ")) formatted_list.append((Settings.styles["title"], "【食物】")) food = int(self.session.getVariable('food', '0')) if food < 100: style = Settings.styles["red"] elif food < 200: style = Settings.styles["yellow"] elif food < 350: style = Settings.styles["green"] else: style = Settings.styles["skyblue"] formatted_list.append((style, "{}".format(food))) formatted_list.append(("", " ")) formatted_list.append((Settings.styles["title"], "【饮水】")) water = int(self.session.getVariable('water', '0')) if water < 100: style = Settings.styles["red"] elif water < 200: style = Settings.styles["yellow"] elif water < 350: style = Settings.styles["green"] else: style = Settings.styles["skyblue"] formatted_list.append((style, "{}".format(water))) formatted_list.append(("", " ")) formatted_list.append((Settings.styles["title"], "【经验】")) formatted_list.append((Settings.styles["value"], "{}".format(self.session.getVariable('exp')))) formatted_list.append(("", " ")) formatted_list.append((Settings.styles["title"], "【潜能】")) formatted_list.append((Settings.styles["value"], "{}".format(self.session.getVariable('pot')))) formatted_list.append(("", " ")) formatted_list.append((Settings.styles["title"], "【惯导】")) if ins: formatted_list.append((Settings.styles["skyblue"], "正常")) formatted_list.append(("", " ")) formatted_list.append((Settings.styles["title"], "【位置】")) formatted_list.append((Settings.styles["green"], f"{loc['city']} {loc['name']}({loc['id']})")) else: formatted_list.append((Settings.styles["red"], "丢失")) formatted_list.append(("", " ")) formatted_list.append((Settings.styles["title"], "【位置】")) formatted_list.append((Settings.styles["value"], f"{self.session.getVariable('%room')}")) # a new-line formatted_list.append(("", "\n")) # line 2. hp if jing != None: formatted_list.append((Settings.styles["title"], "【精神】")) if int(effjing) < int(maxjing): style = Settings.styles["red"] elif int(jing) < 0.8 * int(effjing): style = Settings.styles["yellow"] else: style = Settings.styles["green"] formatted_list.append((style, "{0}[{1:3.0f}%] / {2}[{3:3.0f}%]".format(jing, 100.0*float(jing)/float(maxjing), effjing, 100.0*float(effjing)/float(maxjing),))) formatted_list.append(("", " ")) formatted_list.append((Settings.styles["title"], "【气血】")) if int(effqi) < int(maxqi): style = Settings.styles["red"] elif int(qi) < 0.8 * int(effqi): style = Settings.styles["yellow"] else: style = Settings.styles["green"] formatted_list.append((style, "{0}[{1:3.0f}%] / {2}[{3:3.0f}%]".format(qi, 100.0*float(qi)/float(maxqi), effqi, 100.0*float(effqi)/float(maxqi),))) formatted_list.append(("", " ")) formatted_list.append((Settings.styles["title"], "【精力】")) if int(jingli) < 0.6 * int(maxjingli): style = Settings.styles["red"] elif int(jingli) < 0.8 * int(maxjingli): style = Settings.styles["yellow"] elif int(jingli) < 1.2 * int(maxjingli): style = Settings.styles["green"] else: style = Settings.styles["skyblue"] formatted_list.append((style, "{0} / {1}[{2:3.0f}%]".format(jingli, maxjingli, 100.0*float(jingli)/float(maxjingli)))) formatted_list.append(("", " ")) formatted_list.append((Settings.styles["title"], "【内力】")) if int(neili) < 0.6 * int(maxneili): style = Settings.styles["red"] elif int(neili) < 0.8 * int(maxneili): style = Settings.styles["yellow"] elif int(neili) < 1.2 * int(maxneili): style = Settings.styles["green"] else: style = Settings.styles["skyblue"] formatted_list.append((style, "{0} / {1}[{2:3.0f}%]".format(neili, maxneili, 100.0*float(neili)/float(maxneili)))) formatted_list.append(("", " ")) # a new-line formatted_list.append(("", "\n")) # line 3. GPS info def go_direction(dir, mouse_event: MouseEvent): if mouse_event.event_type == MouseEventType.MOUSE_UP: self.session.exec_command(dir) if ins: formatted_list.append((Settings.styles["title"], "【路径】")) # formatted_list.append(("", " ")) links = self.mapper.FindRoomLinks(loc['id']) for link in links: dir = link.path dir_cmd = dir if dir in Configuration.DIRS_ABBR.keys(): dir = Configuration.DIRS_ABBR[dir] else: m = re.match(r'(\S+)\((.+)\)', dir) if m: dir_cmd = m[2] formatted_list.append((Settings.styles["link"], f"{dir}: {link.city} {link.name}({link.linkto})", functools.partial(go_direction, dir_cmd))) formatted_list.append(("", " ")) return formatted_list