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驱动游戏”需要比普通游戏有更大的亮点,否则用户不会愿意为这部分费用买单。
如果有条件的话或许可以想办法限制模型输出的内容格式,将输出的部分通过代码解析后给到另一个对话中,让模型为我们设计游戏的下一个场景;或者想办法将每个场景下给到模型的提示词动态生成,让玩家感受到更自由的游戏体验。
总而言之,这对我个人而言是一次有意思的尝试,只是对于游戏开发者而言,这样的设计或许并不能称得上是“值得借鉴”的。