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_locations
        if not obj_list:
            return None #没有可以互动的物品
        encoded_input = get_embeddings([obj]+obj_list)
    else:# NPC
        names = {'牙戌':'狱卒',
                '妖怪':'狱卒',
                '殷晦':'客栈老板',
                '墨聆':'画匠'}
        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 #没有可以互动的NPC
        encoded_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 None
    else:
        return obj_list[np.argmax(similar_res)]

def similarest_action(self,obj):
    #按action_keyword->不带action的顺序进行判定
    #每种情况
    # action_keyword
    def 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)]]
    #condition
    obj_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_df
    return None

同样的,对于NPC而言,也有函数用以判断NPC是否会因为玩家对他的话而感到愤怒:

def build_tool_prompt(self):
    broadcast_prompt = "如果输入信息的角色是system,则代表系统记录的玩家行动或玩家与其他npc的对话或者系统对你的提示,不是玩家本人对你说话时的输入。"
    if pd.isna(self.anger_condition):
        return broadcast_prompt
    tool_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"] #最初写的prompt
        flag = False
        for d in self.history[1:]:
            if "role" in d.keys() and d["role"] == "system":
                if flag:
                    res += "\n以下是现在游戏的重要信息记录:\n"
                    flag = False
                res += d["content"]+"\n"
        return {"role":"system","content":res[0:-1]} #最后一个换行符不要

def attack(self,obj):
    """
    如果想要破坏或者攻击某个事物
    """
    if self.location == "大漠地下":
        self.display("这里强烈的安心感竟盖过了你原本内心的暴戾之气,你不再想要做出任何破坏性的行为。")
        return

    if self.location == "恐怖分子基地":
        self.display("在恐怖分子的基地里进行攻击操作显然不是什么明智之举;周围的恐怖分子立刻举枪向你射击。")
        self.die()
        return
    if self.location == "另一个世界":
        self.display("你不知道眼前这个人拿的像火铳一样的东西是什么,但是你觉得你还有机会能够进行反击。随着眼前人轻轻的一个扣击动作,你感到了剧烈的疼痛。")
        self.die()
        return
        
    # 首先判断是物品还是npc
    tgt = self.similarest_obj(obj=obj,type="object")
    if not tgt:
        # 是npc
        npc = self.similarest_obj(obj=obj,type="NPC")
        if not npc:
            #  既不是物品也不是npc
            self.display(f"虽然心中对{obj}积攒了许多怒火,但是眼下不是发泄它们的时候")
            return
        if self.location == "处决场":
            self.display("你扣下扳机,发现枪里的竟是哑弹。沙利叶微笑着掏出了他的枪,火焰绽成一朵诡艳的花。")
            self.die()
            return
        if npc.id == "7":
            self.display("在这个你毫不熟悉的世界里,最好先和这个“好人”合作一下吧。")
            return
        if npc.id == "99":
            self.display("尽管你已失去躯体,但是你仍然朝着绿衣人挥出了实际上不存在的拳头。然而拳头径直穿过了绿衣人的脸庞。")
            self.display("<div class=\"Walter\">真有意思,你还是没搞清楚啊,我已经和梵脉融为一体,我已经赢了!</div>")
            if self.sacrifice:
                self.end(flag="sacrifice")
                return
            else:
                self.ending_text = True
                self.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("在这个你毫不熟悉的世界里,最好先和这个“好人”合作一下吧。")
            return
        obj = 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)
            return
        if 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"])
                    return
            res = obj["break"]
            if not pd.isna(obj['break_threshold']):
                res = f"【成功:力量>={int(obj['break_threshold'])}】"+res
            self.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,None
	self.current_command = command
	if 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 -= 1
			if "<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
				#更新prompt
				if pd.isna(self.persuated_prompt):
					self.persuated_prompt = self.prompt
				self.history[0] = {"role":"system","content":self.persuated_prompt+self.tool_prompt}
				self.prompt=self.persuated_prompt
	if 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):
    # 广播将当前谈话内容增加给所有其他NPC
    for 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驱动游戏”需要比普通游戏有更大的亮点,否则用户不会愿意为这部分费用买单。
如果有条件的话或许可以想办法限制模型输出的内容格式,将输出的部分通过代码解析后给到另一个对话中,让模型为我们设计游戏的下一个场景;或者想办法将每个场景下给到模型的提示词动态生成,让玩家感受到更自由的游戏体验。

总而言之,这对我个人而言是一次有意思的尝试,只是对于游戏开发者而言,这样的设计或许并不能称得上是“值得借鉴”的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值