AI驱动文字冒险游戏
github地址:https://github.com/thornbsj/ImmenseSimGame
虽然游戏比较简陋,但是由于笔者不想对游戏做过多的“剧透”,因此本文只粗略讲一下大致逻辑以及部分代码,有兴趣的朋友可以看上面的仓库获得更详细的部分。
一、状态机改变设计
在传统游戏中,状态机是控制剧情走向的核心机制。笔者通过将大语言模型与状态机结合,构建了一个动态响应系统:
interaction_att:处理玩家想要和特定物品互动
attack:触发玩家想要破坏物品或攻击npc
goto:实现场景迁移
necromancy:玩家的特殊技能,通灵术,可以触发特定内容。
self.tools=[{"type": "function","function": {"name": "interaction_att","description": "与物品进行非破坏性的交互时,调用此函数","parameters": {"obj":{"type": "string","description": "玩家交互的对象,一定是中文"}}}},{"type": "function","function": {"name": "attack","description": "当玩家想要攻击某个NPC或者破坏某样东西时,调用此函数,但是请注意撬门这件事不算破坏性行为,不应当调用这一函数","parameters": {"obj":{"type": "string","description": "玩家要攻击或破坏的对象"}}}},{"type": "function","function": {"name": "goto","description": "玩家要进入另一个场景时调用此函数,需要注明要去的场景名称","parameters": {"s":{"type": "string","description": "玩家希望能够进入的场景"}}}},{"type": "function","function": {"name": "necromancy","description": "当玩家想要对某个事物或人物使用通灵术时,调用此函数;人物的愤怒不会影响通灵术的使用","parameters": {"obj":{"type": "string","description": "使用通灵术的对象"}}}}]
每个函数调用都会触发一个特定的“追踪”函数,此函数会特别地将玩家想要互动的对象在当前环境中进行搜寻,返回对应最接近的那个对象,以此触发后续脚本。
def similarest_obj(self,obj,type="object"):def get_embeddings(sentences):print(sentences)completion = similar_client.embeddings.create(model=similar_model,input=sentences,dimensions=1024,encoding_format="float")return [i["embedding"] for i in json.loads(completion.model_dump_json())['data']]def cosine_similarity(vec1, vec2):dot_product = np.dot(vec1, vec2)norm1 = np.linalg.norm(vec1)norm2 = np.linalg.norm(vec2)return dot_product / (norm1 * norm2)if type in {"object","object_necromancy"}:obj_list = self.available_objects.copy()if not obj_list:return None #没有可以互动的物品if type == "object_necromancy":obj_list.append("自我")encoded_input = get_embeddings([obj]+obj_list)else:encoded_input = get_embeddings([obj]+obj_list)elif type == "escape_ending":obj_list = [obj,"逃离此地"]encoded_input = get_embeddings([obj,"逃离此地"])elif type == "location":obj_list = self.available_locationsif not obj_list:return None #没有可以互动的物品encoded_input = get_embeddings([obj]+obj_list)else:# NPCnames = {'牙戌':'狱卒','妖怪':'狱卒','殷晦':'客栈老板','墨聆':'画匠'}if self.location in ["深层意识","恐怖分子基地","处决场","记忆圣所"]:names["队友"] = "鬓狗"elif self.location in ["壁画窟","九渊地宫外侧","九渊地宫","主墓室","主墓室"]:names["队友"] = "鬣狗"if obj in names.keys():obj = names[obj]obj_list = [j for i,j in self.available_npcs.items()]if not obj_list:return None #没有可以互动的NPCencoded_input = get_embeddings([obj]+[i.name for i in obj_list])similar_res = [cosine_similarity(encoded_input[0],j) for j in encoded_input[1:]]print({i:j for i,j in zip(obj_list,similar_res)})if max(similar_res)<self.threshold:return Noneelse:return obj_list[np.argmax(similar_res)]def similarest_action(self,obj):#按action_keyword->不带action的顺序进行判定#每种情况# action_keyworddef get_embeddings(sentences):completion = similar_client.embeddings.create(model=similar_model,input=sentences,dimensions=1024,encoding_format="float")return [i["embedding"] for i in json.loads(completion.model_dump_json())['data']]def cosine_similarity(vec1, vec2):dot_product = np.dot(vec1, vec2)norm1 = np.linalg.norm(vec1)norm2 = np.linalg.norm(vec2)return dot_product / (norm1 * norm2)sub_df_events = self.events[(self.events["ItemID"].isin(self.available_object_id.values()))]idxs = []for i in sub_df_events.index:if self.object_status[sub_df_events.loc[i,"ItemID"]] == sub_df_events.loc[i,"StatusID"]:idxs.append(i)sub_df_events = sub_df_events.loc[idxs,:]# 带特定动作的obj_list = [i for i in sub_df_events[(~pd.isna(sub_df_events["action_keyword"]))]["action_keyword"]]if len(obj_list)>0:encoded_input = get_embeddings([self.current_command]+obj_list)similar_res = [cosine_similarity(encoded_input[0],j) for j in encoded_input[1:]]print(similar_res,[self.current_command]+obj_list)if max(similar_res)>=self.threshold:return sub_df_events[sub_df_events["action_keyword"]==obj_list[np.argmax(similar_res)]]#conditionobj_res = self.similarest_obj(obj)if obj_res is not None:sub_df = sub_df_events[(sub_df_events["item"]==obj_res) & pd.isna(self.events["action_keyword"])]return sub_dfreturn None
同样的,对于NPC而言,也有函数用以判断NPC是否会因为玩家对他的话而感到愤怒:
def build_tool_prompt(self):broadcast_prompt = "如果输入信息的角色是system,则代表系统记录的玩家行动或玩家与其他npc的对话或者系统对你的提示,不是玩家本人对你说话时的输入。"if pd.isna(self.anger_condition):return broadcast_prompttool_prompt = f"""## 工具你有以下的工具可以使用:### 感到愤怒less_patient: {self.anger_condition}时调用此函数## 注意调用函数时只需要看用户的“上一个输入”,不要将用户的所有历史信息作为调用函数的依据。"""return "\n".join([i.strip() for i in tool_prompt.splitlines()]+[broadcast_prompt])
二、上下文感知的Prompt Engineering
为了使得大模型给到的反馈更加真实以及NPC能够看到、听到玩家的行为,还需要再Prompt中下功夫:
每当调用上述函数后导致状态机改变时都会引起提示词的改变,以NPC对话后改变状态为例:
def generate_PROMPT(self,wordview):ORIGNINAL_PROMPT = f"""你是一个基于文本的冒险游戏系统,用户就是玩家。用户会输入他们会进行的操作,你的目标是要将游戏中的反馈回给用户。{wordview}注意你需要使用第二人称进行回复,将“你”作为输出的主语。不要输出除了游戏内容以外的任何信息,不要写解释也不要输出命令,除非用户让你这么做。如果用户输入的命令没有触发任何函数,那么回复需要遵循以下规则:1、不能影响以下物品:{self.available_objects},如果后续新增了物品,也应该在这一范畴中。2、用户不能获得任何新的物品3、用户不可以用到{self.bakcpack_items}以外的物品4、用户所在场景没有{self.available_objects}以外的物品,如果后续新增了物品,也应该在这一范畴中。5、如果有涉及到以下物品时,需要调用函数{self.available_objects},如果后续新增了物品,也应该在这一范畴中。6、用户可以对任何事物或人物进行攻击,并且为此会调用后续说明的攻击函数,无需参考任何对话历史的情况,哪怕人物被玩家说服了或者人物是玩家的队友。同时需要注意,后续玩家做出的操作导致的结果以及场景变化都会在对话中使用system角色来输入"""return "\n".join([i.strip() for i in ORIGNINAL_PROMPT.splitlines()])def merge_system_info(self):# 由于OpenAI接口获得多个system+user输入后会直接stop返回空,所以此处将所有的system提示放在一起res = self.history[0]["content"] #最初写的promptflag = Falsefor d in self.history[1:]:if "role" in d.keys() and d["role"] == "system":if flag:res += "\n以下是现在游戏的重要信息记录:\n"flag = Falseres += d["content"]+"\n"return {"role":"system","content":res[0:-1]} #最后一个换行符不要def attack(self,obj):"""如果想要破坏或者攻击某个事物"""if self.location == "大漠地下":self.display("这里强烈的安心感竟盖过了你原本内心的暴戾之气,你不再想要做出任何破坏性的行为。")returnif self.location == "恐怖分子基地":self.display("在恐怖分子的基地里进行攻击操作显然不是什么明智之举;周围的恐怖分子立刻举枪向你射击。")self.die()returnif self.location == "另一个世界":self.display("你不知道眼前这个人拿的像火铳一样的东西是什么,但是你觉得你还有机会能够进行反击。随着眼前人轻轻的一个扣击动作,你感到了剧烈的疼痛。")self.die()return# 首先判断是物品还是npctgt = self.similarest_obj(obj=obj,type="object")if not tgt:# 是npcnpc = self.similarest_obj(obj=obj,type="NPC")if not npc:# 既不是物品也不是npcself.display(f"虽然心中对{obj}积攒了许多怒火,但是眼下不是发泄它们的时候")returnif self.location == "处决场":self.display("你扣下扳机,发现枪里的竟是哑弹。沙利叶微笑着掏出了他的枪,火焰绽成一朵诡艳的花。")self.die()returnif npc.id == "7":self.display("在这个你毫不熟悉的世界里,最好先和这个“好人”合作一下吧。")returnif npc.id == "99":self.display("尽管你已失去躯体,但是你仍然朝着绿衣人挥出了实际上不存在的拳头。然而拳头径直穿过了绿衣人的脸庞。")self.display("<div class=\"Walter\">真有意思,你还是没搞清楚啊,我已经和梵脉融为一体,我已经赢了!</div>")if self.sacrifice:self.end(flag="sacrifice")returnelse:self.ending_text = Trueself.display("尽管如此,你依然还有最后一个机会:<br>梵脉,你决心在绿衣人的意志广播前,也融合进梵脉,或许能够在他的基础上,做出一些能够补救的许愿。")self.display("于是,这便是你的最后一个念头,这便是给到梵脉的意志:")return# 根据双方感知判定是否袭击is_sneak=int(npc.status["status_ID"]==3 or npc.status["sense"]*2<=self.status["sense"])self.battle(is_sneak,npc)else:if self.location == "处决场" and tgt!="鬓狗":self.display("你扣下扳机,发现枪里的竟是哑弹。沙利叶微笑着掏出了他的枪,火焰绽成一朵诡艳的花。")self.die()if tgt == "23":self.display("在这个你毫不熟悉的世界里,最好先和这个“好人”合作一下吧。")returnobj = self.available_object_id[tgt]obj = self.events[(self.events["ItemID"] == obj) & (self.events["StatusID"] == self.object_status[obj])].reset_index().loc[0,:]if obj["StatusID"] == '-1':res = obj["display"]self.display(res)# self.add_history(self.current_command,res)returnif obj["is_breakable"] == 1:# 不足以破坏的情况if not pd.isna(obj["break_condition"]):if self.status[obj["break_condition"]]<obj["break_threshold"]:res = f"【失败:力量<{int(obj['break_threshold'])}】"+obj["break_fail"]self.display(res)self.history.append({"role":"system","content":f"玩家尝试破坏{tgt},但是失败了"})self.parse_action_cause(obj["break_fail_result"])returnres = obj["break"]if not pd.isna(obj['break_threshold']):res = f"【成功:力量>={int(obj['break_threshold'])}】"+resself.display(res)self.history.append({"role":"system","content":f"玩家破坏了{tgt}"})self.parse_action_cause(obj["break_result"])if "goto" not in obj["break_result"] and obj['ItemID'] not in {"46","47","64","65"}: # 这2个item破坏后npc会有自动战斗,战斗结束会goto函数self.change_status(f"{obj['ItemID']},{-1}")# self.add_history(self.current_command,res)return# self.add_history(self.current_command,res)else:# 不可破坏res = f"尽管你看{obj['item']}十分不爽,但是很明显,拿{obj['item']}泄愤是不理智的。"self.display(res)
而对于NPC,也应该增加“视觉”以及“听觉”上的历史:玩家对外界的交互会影响NPC,与其他NPC的对话也会影响到本身:
def chat(self,command):if self.status["status_ID"] == 0:self.display(f"{self.name}已经死亡")return None,Noneself.current_command = commandif len([i for i in list(self.induce.keys()) if self.induce[i][2]==0])>0:self.similarest_induce(command)if not pd.isna(self.persuade_value) and not pd.isna(self.persuade_key) and self.persuade_value > 0 and self.status["status_ID"]!=3: #说服if self.similarest_persuade(command):self.persuade_value -= 1if "<p style=\"visibility:hidden\">(清醒)</p>" in self.name:rpl = "<p style=\"visibility:hidden\">(清醒)</p>"self.display(f"{self.name.replace(rpl,'')}产生了一丝动摇")else:if self.id in {"12","2"}:self.display(f"{self.name}产生了一丝动摇")# self.history.append({"role":"system","content": f"{self.name}感到了一丝动摇"})if self.persuade_value == 0:#self.display(self.persuation_result_txt)self.status["status_ID"] = 3#更新promptif pd.isna(self.persuated_prompt):self.persuated_prompt = self.promptself.history[0] = {"role":"system","content":self.persuated_prompt+self.tool_prompt}self.prompt=self.persuated_promptif len(self.tools)>0:completion = client.chat.completions.create(model=model,messages=self.history+[{"role": "user", "content": command}],tools=self.tools)else:completion = client.chat.completions.create(model=model,messages=self.history+[{"role": "user", "content": command}])res = completion.choices[0].message.content# 有函数调用的情况if completion.choices[0].message.tool_calls is not None:self.less_patient()return f"玩家:{command}",f"这使得{self.name}感到一丝愤怒"self.add_history(command,res)self.display(res)return f"玩家:{command}",f"{self.name}:{res}"def add_chat_history(self,npc_id,content):# 广播将当前谈话内容增加给所有其他NPCfor k,v in self.available_npcs.items():if k != npc_id and v.status["status_ID"]!=0:v.history.append({"role":"system","content":content})
三、反思与展望
在开发过程中,笔者意识到了这样的两个问题:
有许多剧情是我设计好系统后,才反应过来没有做对应功能。因此有许多“特殊处理”的场景被写死在代码中了。这样的行为并不利于长期开发与维护。除此以外,由于是第一次开发,很多设计上有不足之处,比如数值设计上,虽然参考了辐射的Special系统,但是后续除了过判定以外几乎没有太大用处,有些场景过于简陋,只是进入场景过判断这种简单粗暴的情景。
除去笔者在游戏设计上由于是第一款游戏而并不成熟外,这样“状态机转换”的游戏方式依然是传统游戏的思路,并没有因为AI而变得更“自由”或者有趣。而使用AI所产生的费用问题也意味着“AI驱动游戏”需要比普通游戏有更大的亮点,否则用户不会愿意为这部分费用买单。
如果有条件的话或许可以想办法限制模型输出的内容格式,将输出的部分通过代码解析后给到另一个对话中,让模型为我们设计游戏的下一个场景;或者想办法将每个场景下给到模型的提示词动态生成,让玩家感受到更自由的游戏体验。
总而言之,这对我个人而言是一次有意思的尝试,只是对于游戏开发者而言,这样的设计或许并不能称得上是“值得借鉴”的。