本文节选自《LPC语言基础教程:从零学习游戏开发》,版权归@mudren,欢迎转载,但必须注明来源(mud.ren)。
除了介绍的通过enable_commands();add_action("command_hook", "", 1);实现的action指令系统外,游戏驱动还提供了一套完整的谓词(verb)指令系统,这是一套规范而强大的自然语言解析器。
简介
关于谓词指令系统,自然语言解析是它的优势,但不是唯一的作用,对国内MUD,很多指令没有严格按英文语法规范,自然语言解析显的有一些鸡肋,但并不能因此而否定这种指令实现方式,严格的说,自然语言分析只是它的功能之一,它的核心功能还是提供了一套标准的指令系统,内置验证规则和别名功能,规范了指令接口(但也因此,让代码实现变的复杂很多,具体在游戏开发中是否使用看开发者自己的选择,这里只是全面的介绍相关知识)。
比如战斗指令,如果用action系统实现,代码类似这样:
int main(object me, string arg)
{
object ob;
if (!me->can_fight())
{
return notify_fail("你还没有学会战斗。\n");
}
if (file_name(environment(me)) == START_ROOM)
{
return notify_fail("这里禁止战斗。\n");
}
if (!arg || !objectp(ob = present(arg, environment(me))))
{
return notify_fail("你想攻击谁?\n");
}
if (ob == me)
{
return notify_fail("你不能攻击自己。\n");
}
if (!ob->can_fight())
{
return notify_fail("对方还不会战斗。\n");
}
if (ob->is_fighting(me))
{
return notify_fail("加油!加油!加油!\n");
}
msg("warning", "$ME大喝一声,对$YOU发起了攻击。", me, ob);
me->fight(ob);
return 1;
}
但用verb系统实现,代码类似这样:
protected void create()
{
verb::create();
setVerb("fight");
setSynonyms("kill", "hit");
setRules("LIV");
setErrorMessage("你想和谁战斗?");
}
mixed can_fight_liv(mixed *data...)
{
object me = this_player();
// debug(sprintf("%O", data));
if (file_name(environment(me)) == START_ROOM)
{
return "这里禁止战斗。";
}
return me->can_fight() || HIY "你无法战斗。" NOR;
}
mixed direct_fight_liv(object ob, string arg)
{
if (ob == this_player())
return HIM "你真有想法,可惜你做不到自己和自己战斗。" NOR;
return ob->can_fight();
}
int do_fight_liv(object ob, string arg)
{
object me = this_player();
msg("warning", "$ME大喝一声,对$YOU发起了攻击。", me, ob);
me->fight(ob);
return 1;
}
action指令只是要求成功返回1,失败返回0,具体代码功能没有任何要求,而verb指令自带规则和业务逻辑,提供了一系列的apply方法:can_*()、direct_*()、indirect_*()、do_*(),自动调用做权限校验和执行。如上指令:设置了指令名称为fight,别名为kill和hit,目标对象限制为生物(LIV)。当fight ob时,系统会自动查找生物ob,不需要写代码判断对象是否是生物,同时也会自动调用can_fight_liv方法判断玩家是否可以战斗,调用direct_fight_liv方法判断是否允许和目标对象战斗,然后调用do_fight_liv方法战斗。
功能实现
下面我们具体讲解代码实现。我们以look指令为列,玩家可能有以下的行为:
look sb
look at sb
look in sth
look on sth
look sb on sth
look sth in sth
在我们的指令模式下,需要用sscanf()处理玩家的输入,然后做判断并处理,这套实现方式大家都很熟悉了,看起来简单,但并不灵活,得在代码实现所有可能的匹配。而使用谓词系统,我们有不一样的实现方式,它更灵活,更强大(嗯……代码写的也会更麻烦)。
在我们自然语言中分分主谓宾定状补等,在游戏指令中我们主语就是指令的执行者,也就是玩家自己,直接省略,核心就是谓语verb和宾语(动作对象),另外还有介词修饰。如look at sb,谓词look,介词at,对象宾语sb,而宾语对象在某些指令中还分直接对象(the direct object)和间接对象(the indirect object),如read page in book,直接对象是page,间接对象是book,从语义上理解你要读的是page,不是book(不过MudOS并不是根据真实语义来的,而是简单的优化为指令中第一个标记是直接对象,第二个标记是间接对象)。
在游戏开发中我们要实现的除了主语对象(玩家)、宾语对象(指令目标)外,重点是需要实现指令对象(the verb handler),另外还有主控对象(master ob)提供必要的apply方法。除主控对象外,所有需要调用verb的对象都必须调用parse_init()方法初始化。
指令规则
指令系统的谓词和规则通过parse_add_rule外部函数增加在指令对象中,如:
parse_add_rule("look", ""); // 不指定目标
parse_add_rule("look", "STR"); // 查看字符串目标
parse_add_rule("look", "OBJ"); // 查看对象目标
parse_add_rule("look", "on OBJ");
parse_add_rule("look", "at OBJ");
parse_add_rule("look", "in OBJ");
parse_add_rule("look", "OBJ in OBJ");
parse_add_rule("look", "at OBJ on OBJ");
parse_add_rule("look", "at STR on OBJ");
以上规则中的大写字母OBJ和STR是驱动语法限定的标记(token),可用token有:
LIV - one living thing
LVS - one or more living things
OBJ - one object
OBS - one or more objects
STR - a string of characters
WRD - a generic thing that may be a string, object, or living thing
请注意:LIV和LVS是根据对象的apply方法is_living判断的(1是0否)。在一条规则中不要超过2个标记,且只能存在1个复数标记。
那么,系统是怎么自动找到对象的呢?在action指令系统中我们输入look sb,需要在代码中使用present()函数查找id为sb的对象并返回,这里重点是要提供一个apply方法int id(string id),同样,verb系统中要提供一个apply方法string *parse_command_id_list(),系统自动根据这个查找对象sb,只是我们在指令中不需要再写代码查找对象。
需要注意的是,语法规则的介词(如look a on b中的on)并不是随意都可以,而是主控对象中的apply方法parse_command_prepos_list()限定,只能使用这个方法返回的数组中的介词。
指令别名
相对action指令系统别名功能需要自己实现,verb系统中这个是内置的功能,无需任何开发,使用非常容易,直接用parse_add_synonym为谓词指令增加同义词(别名),如parse_add_synonym("kan", "l")。
标准接口方法
在action指令中,具体的功能完全由开发者发挥,但分析所有指令,都有一个基本的内在逻辑:1.判断,2.执行。判断包括判断对象是否存在,是否有权限等等,只是有的指令不需要条件判断,只用执行。
而在谓词指令系统中,这些逻辑被具体为can_*()和do_*()这二类apply方法,实现指令权限的校验和执行指令的操作内容。相关方法即可以实现在玩家对象中,也可以实现在指令对象中,优先调用玩家对象中的方法,不存在时再调用指令对象中的方法。另外还可以在指令目标对象中实现direct_*和indirect_*这二种apply方法做相关权限处理。
这里*代表具体verb指令的rule,根据不同指令和规则自动替换,如:can_look、can_look_at_obj、do_look_at_obs_in_obj等,最基础的通用方法是xxx_verb_rule,如:can_verb_rule。
关于以上方法的调用,我们以玩家输入look xxx为例看看执行的顺序:
- 调用玩家对象中的
can_*(),如果找不到,调用指令对象中的can_*() - 调用目标对象中的
direct_*(),如果找不到,调用指令对象中的direct_*() - 调用指令对象中的
do_*()
请注意:以上几个方法执行都是前面的成功才会继续执行后面的。
如果只是使用look没有具体对象,不会调用direct_*()方法,而如果是look x in y,除了调用对象x中的direct_*()方法还会调用对象y中的indirect_*()方法。方法后缀*是有多种写法的,如果一个不存再,继续调用另一个,具体看下图。
look box示例:

look in box示例:

look at gold in box示例:

这里比较奇怪,为什么
direct_*()和indirect_*()调用了二次?
需要注意的是,对复数标记规则的can_*方法一样是使用单数apply方法,而复数标记规则的do_*方法是使用复数形式。
另外,自然语法中针对容器有以下二种常用情况:look in sth和get all from sth,驱动内置了二个apply方法来控制对象是否可见和是否有权访问:
- inventory_accessible()
- inventory_visible()
比如,对一个上锁的箱子,是即不能look in也不能get from,而对一个上锁的透明玻璃盒子,你可以look in但不能get from。
功能接入
上面介绍了指令需要实现的方法,但怎么使用呢?需要通过parse_sentence()方法。
玩家对象在执行指令时会调用parse_sentence()外部函数做语法解析。和add_action()执行指令类似,parse_sentence()在执行后也会返回不同的结果:
1 谓词指令执行成功
0 未找到谓词指令
-1 语法规则不匹配
-2 语法规则匹配,但是无权执行
字符串 语法规则匹配但执行失败的原因(如:目标不对)
需要注意的是,执行错误字符串是主控对象中的apply方法parser_error_message返回的错误内容。
提示:mixed parse_sentence(string cmd, int flag)方法第二个参数flag为1则开始调试模式,会显示verb查找顺序。
因为游戏开发中我们可以verb指令和action指令系统结合使用,如果我们可以直接把parse_sentence()写在apply方法process_input或action指令系统的command_hook中。
具体示例
现在我们把上面介绍的理论转为实操,看看怎么开发verb指令功能。在最新的LPC-TEST项目中,已经把look、go和fight使用verb指令系统实现,指令在/verbs/目录中,这里根据项目做介绍。
指令入口
在动作(action)指令系统教程中我们介绍过动作指令系统的入口是add_action(),谓词(verb)指令系统的入口是parse_sentence(),我们改造command_hook()函数,当动作指令执行失败时调用parse_sentence()来使用verb指令:
if (cmd_ob = load_object(cmd))
{
return (int)cmd_ob->main(me, arg);
}
else
{
mixed err = parse_sentence(arg ? verb + " " + arg : verb, 0);
if (intp(err))
{
switch (err)
{
case 1: // verb 匹配成功
return 1;
default:
return 0;
}
}
return notify_fail(err);
}
配置MASTER_OB的apply方法
谓词指令系统因为语义灵活,更类似自然语言,指令包括谓语、宾语,还有介词和形容词等,系统可以根据玩家的输入自动解析。这些需要在MASTER_OB中增加一些apply方法做配置,这些配置对中文MUD意义不大,只是其中的parse_command_prepos_list限制指令可用介词得正确配置,另外parser_error_message返回错误也得根据自己需要返回。
#include <parser_error.h>
string *parse_command_id_list()
{
return ({"thing"});
}
string *parse_command_adjective_id_list()
{
return ({"the", "a", "an"});
}
string *parse_command_plural_id_list()
{
return ({"things", "them", "everything"});
}
string *parse_command_prepos_list()
{
return ({"in", "on", "at", "by", "under", "behind", "with", "into", "onto", "inside", "within", "from"});
}
string parse_command_all_word()
{
return "all";
}
object *parse_command_users()
{
return users();
}
string parser_error_message(int type, object ob, mixed arg, int flag)
{
switch (type)
{
case ERR_NOT_LIVING:
return sprintf("%s 不是生物。\n", arg);
case ERR_THERE_IS_NO:
return sprintf("这里没有 %s 。\n", arg);
case ERR_ALLOCATED:
return sprintf("%s\n", arg);
case ERR_IS_NOT:
case ERR_NOT_ACCESSIBLE:
case ERR_AMBIG:
case ERR_ORDINAL:
case ERR_BAD_MULTIPLE:
case ERR_MANY_PATHS:
default:
return sprintf("parser_error_message : type = %d, ob = %O, arg = %O, flag = %d\n", type, ob, arg, flag);
}
}
void parseRefresh() { parse_refresh(); }
以上最核心的二个方法是parse_command_prepos_list和parser_error_message,前者指定了允许使用的介词,后者指定了在指令错误时返回的错误提示。
配置对象apply方法
在指令规则中可以指定宾语是STR/OBJ/LIV等,如果是STR很好理解,如果是OBJ和LIV有什么不同呢?对谓词指令系统中如果规则是OBJ,会自动返回对象,而不需要使用present(str)来查找,但类似present(str)需要实现id()方法指定返回的对象,需要使用parse_command_id_list()方法来返回对象,而对LIV还需要增加int is_living() { return 1; }方法来确认对象是生物,具体代码实现请参考lpc-test项目的/inherit/living.c和/inherit/object.c。
封装VERB基础方法
另外,所有对象要使用谓词指令功能必须先调用parse_init();做初始化,这里我们实现一个VERB模块类来封装相关功能并完成初始化,所有需要使用指令的生物继承这个类。具体代码见/inherit/verb.c:
// Parser 指令公共方法
private nosave string Verb, ErrorMessage;
private nosave string *Synonyms, *Rules;
protected void create()
{
parse_init();
Verb = 0;
ErrorMessage = 0;
Rules = ({});
Synonyms = ({});
}
protected string setErrorMessage(string str) { return (ErrorMessage = str); }
string getErrorMessage() { return ErrorMessage; }
varargs protected string *setRules(mixed *args...)
{
if (sizeof(Rules))
error("Cannot reset rules list.");
foreach (mixed arg in args)
{
if (stringp(arg))
Rules += ({arg});
else
Rules += arg;
}
if (Verb)
{
foreach (string rule in Rules)
parse_add_rule(Verb, rule);
if (sizeof(Synonyms))
foreach (string cmd in Synonyms)
parse_add_synonym(cmd, Verb);
}
return Rules;
}
string *getRules() { return copy(Rules); }
varargs protected string *setSynonyms(mixed *args...)
{
if (sizeof(Synonyms))
error("Cannot reset synonym list.\n");
foreach (mixed arg in args)
{
if (stringp(arg))
Synonyms += ({arg});
else
Synonyms += arg;
}
if (Verb && sizeof(Rules))
foreach (string cmd in Synonyms)
parse_add_synonym(cmd, Verb);
return Synonyms;
}
string *getSynonyms() { return copy(Synonyms); }
protected string setVerb(string str)
{
if (!stringp(str))
error("Bad argument 1 to setVerb().\n");
Verb = str;
if (sizeof(Rules))
foreach (string rule in Rules)
parse_add_rule(Verb, rule);
if (sizeof(Synonyms))
foreach (string cmd in Synonyms)
parse_add_synonym(cmd, Verb);
return Verb;
}
string getVerb() { return Verb; }
string *getVerbs() { return ({Verb}); }
系统相关功能接入后,就可以直接和cmd指令一样写verb指令了,根据需要随意增加。
具体指令示例
现在回去看看文章最开头的fight指令示例,应该很容易理解了,这里再实现一个更复杂的look指令。
现在看看look指令的代码/verbs/look.c:
inherit VERB;
#include <ansi.h>
int look_room(object me, object env);
string desc_of_objects(object *obs);
string list_all_inventory_of_object(object me, object env);
int look_living(object me, object ob);
protected void create()
{
verb::create();
setVerb("look");
setSynonyms("l");
setRules("", "STR", "OBJ", "at STR", "at OBJ", "on OBJ", "in OBJ", "inside OBJ",
"at OBJ in OBJ", "OBJ inside OBJ", "at OBJ on OBJ", "at STR on OBJ");
setErrorMessage("你想看什么?");
}
mixed can_look()
{
object env = environment(this_player());
if (!env || !env->query("short") && !env->is_area())
return "你的四周灰蒙蒙地一片,什么也没有。";
else
return 1;
}
mixed can_verb_rule(mixed *data...)
{
// debug_message(sprintf("can_verb_rule : %O", data));
return can_look();
}
mixed can_verb_word_str(mixed *data...)
{
// debug_message(sprintf("can_verb_word_str : %O", data));
return can_look();
}
mixed direct_look_obj(object ob, string id)
{
return environment(this_player()) == environment(ob);
}
mixed direct_verb_rule(mixed *data...)
{
// debug_message(sprintf("direct_verb_rule : %O", data));
return can_look();
}
mixed direct_verb_word_obj(mixed *data...)
{
// debug_message(sprintf("direct_verb_word_obj : %O", data));
return can_look();
}
mixed do_look()
{
object me = this_player();
object env = environment(me);
return look_room(me, env);
}
mixed do_look_at_obj(object ob)
{
object me = this_player();
if (living(ob))
{
look_living(me, ob);
}
else
{
printf("%s\n", ob->long());
}
return 1;
}
mixed do_look_at_obj_in_obj(object ob1, object ob2, string id1, string id2)
{
printf("%s\n", ob1->long());
return 1;
}
mixed do_look_obj(object ob)
{
return do_look_at_obj(ob);
}
mixed do_look_in_obj(object ob)
{
if (sizeof(all_inventory(ob)))
{
debug(sprintf("%s里有:\n%s", ob->short(), list_all_inventory_of_object(ob, ob)));
}
else
{
debug(sprintf("%s里什么也没有。", ob->short()));
}
return 1;
}
mixed do_look_at_str(string str, string arg)
{
object me = this_player();
object env = environment(me);
mapping exits = env->query("exits");
object ob;
mixed item_desc;
if (str == "here")
{
return do_look();
}
else if (stringp(exits[str]))
return look_room(me, load_object(exits[str]));
else if (mapp(exits[str]))
debug("此方向是区域环境,无法观察。");
else if (item_desc = env->query("items/" + str))
debug(evaluate(item_desc));
else if (ob = present(arg, env))
return do_look_obj(ob);
else
debug(getErrorMessage());
return 1;
}
mixed do_look_str(string str, string arg)
{
return do_look_at_str(str, arg);
}
mixed do_verb_rule(mixed *data...)
{
debug(sprintf("do_verb_rule : %O", data));
return 1;
}
// 查看环境(此方法推荐放在环境中)
int look_room(object me, object env)
{
string str, *dirs;
mapping exits;
if (env->is_area())
{
return env->do_look(me);
}
str = sprintf(HIC + "%s" + NOR + " <%s>\n%s" + NOR,
env->query("short"), file_name(env), env->query("long"));
if (mapp(exits = env->query("exits")))
{
dirs = keys(exits);
if (sizeof(dirs) == 0)
str += " 这里没有任何明显的出路。\n";
else if (sizeof(dirs) == 1)
str += " 这里唯一的出口是 " + BOLD + dirs[0] + NOR + "。\n";
else
str += sprintf(" 这里明显的出口是 " + BOLD + "%s" + NOR + " 和 " + BOLD + "%s" + NOR + "。\n",
implode(dirs[0..sizeof(dirs)-2], "、"), dirs[sizeof(dirs) - 1]);
}
else
{
str += " 这里没有任何出路。\n";
}
str += list_all_inventory_of_object(me, env);
tell_object(me, str);
return 1;
}
string desc_of_objects(object *obs)
{
int i;
string str;
mapping list, unit;
string short_name;
string *ob;
if (obs && sizeof(obs) > 0)
{
str = "";
list = ([]);
unit = ([]);
for (i = 0; i < sizeof(obs); i++)
{
short_name = obs[i]->short();
list[short_name] += obs[i]->query_temp("amount") ? obs[i]->query_temp("amount") : 1;
unit[short_name] = obs[i]->query("unit") ? obs[i]->query("unit") : "个";
}
ob = sort_array(keys(list), 1);
for (i = 0; i < sizeof(ob); i++)
{
str += " ";
if (list[ob[i]] > 1)
str += list[ob[i]] + unit[ob[i]] + ob[i] + "\n";
else
str += ob[i] + "\n";
}
return str;
}
return "";
}
string list_all_inventory_of_object(object me, object env)
{
object *inv;
object *obs;
string str = "";
if (!env || !me)
return "";
inv = all_inventory(env);
if (!sizeof(inv))
return str;
obs = filter_array(inv, (: $(me) != $1 :));
str += desc_of_objects(obs);
return str;
}
int look_living(object me, object ob)
{
string msg;
string line = repeat_string("-*-", 12) + "\n";
if (ob != this_player())
{
msg = "$ME看了看$YOU,好像对$YOU很感兴趣对样子。";
msg("vision", msg, me, ob);
}
msg = sprintf("%s 是一位 %d 级的%s性生物。\n", ob->short(), ob->query("lv"), ob->query("gender") || "??");
msg += line;
msg += sprintf(" %-36s\n", "HP:" + ob->query("hp") + " / " + ob->query("max_hp"));
msg += sprintf(" %-12s%-12s%-12s\n", "力量:" + ob->query("str"), "敏捷:" + ob->query("agi"), "防御:" + ob->query("def"));
msg += line;
tell_object(me, msg);
return 1;
}
int help(object me)
{
write(@HELP
指令格式 : look | l
最基本的look指令,让你睁眼看世界,可以查看对象了解信息。
当存在多个同名对象时,比如有4个NPC,你要查看指定对象可以使用以下格式:
l 1st n 或 l n 1
l 2nd n 或 l n 2
l 3rd n 或 l n 3
l 4th n 或 l n 4
HELP );
return 1;
}
代码看起来很复杂,但很合理,通过can_*方法判断有没有权限使用look指令,通过direct_*和indirect_*方法判断对象让不让你看,通过do_*方法执行查看,而且这个判断不只是判断look指令中的方法,还会判断玩家对象和目前对象的相关方法,这样可以非常灵活的实现功能,比如:有一个特别的对象需要特别条件才能查看,只需要在这个对象中实现一个indirect_*方法处理权限,而不需要对look指令做任何修改,这样比使用action指令系统把所有处理都放在look指令中让look指令变的超级复杂要更好。
使用verb指令系统对多个对象,可以使用1st、2nd、3rt、4th来指定序号,类似present方法的id n,当然,你也可以在指令的STR规则中增加present方法实现id n的序号查找方式。
补充说明
规则还支持以下格式:TOKEN:xyz,如:at OBJ:v in OBJ,这里xyz必须是以下值:
- l - must be living
- v - need not be accessible (visible only)
"OBJ:l" is the same as "LIV", and "LIV:v" is someone you can see.
另外补充说明TOKEN中WRD和STR的一个重要区别,WRD只能匹配单个单词。