|
@@ -1,3 +1,1914 @@
|
|
|
# 第十五章 构建赛博小镇
|
|
# 第十五章 构建赛博小镇
|
|
|
|
|
|
|
|
本章内容待补充...
|
|
本章内容待补充...
|
|
|
|
|
+# 第十五章 构建赛博小镇
|
|
|
|
|
+
|
|
|
|
|
+这一章,我们将探索一个全新的方向:<strong>将智能体技术与游戏引擎结合,构建一个充满生命力的AI小镇</strong>。
|
|
|
|
|
+
|
|
|
|
|
+还记得《模拟人生》或《动物森友会》中那些栩栩如生的NPC吗?他们有自己的性格、记忆和社交关系。本章的赛博小镇将是一个类似的项目,但与传统游戏不同的是,我们的NPC拥有真正的"智能"——他们能够理解玩家的对话,记住过去的互动,并根据好感度做出不同的反应。本章的赛博小镇包含以下核心功能:
|
|
|
|
|
+
|
|
|
|
|
+<strong>(1)智能NPC对话系统</strong>:玩家可以与NPC进行自然语言对话,NPC会根据自己的角色设定和记忆做出回应。
|
|
|
|
|
+
|
|
|
|
|
+<strong>(2)记忆系统</strong>:NPC拥有短期记忆和长期记忆,能够记住与玩家的互动历史。
|
|
|
|
|
+
|
|
|
|
|
+<strong>(3)好感度系统</strong>:NPC对玩家的态度会随着互动而变化,从陌生到熟悉,从友好到亲密。
|
|
|
|
|
+
|
|
|
|
|
+<strong>(4)游戏化交互</strong>:玩家可以在2D像素风格的办公室场景中自由移动,与不同的NPC互动。
|
|
|
|
|
+
|
|
|
|
|
+<strong>(5)实时日志系统</strong>:所有对话和互动都会被记录,方便调试和分析。
|
|
|
|
|
+
|
|
|
|
|
+## 15.1 项目概述与架构设计
|
|
|
|
|
+
|
|
|
|
|
+### 15.1.1 为什么要构建AI小镇
|
|
|
|
|
+
|
|
|
|
|
+传统游戏中的NPC通常只能说固定的台词,或者通过预设的对话树进行有限的互动。即使是最复杂的RPG游戏,NPC的对话也是由编剧事先写好的。这种方式虽然可控,但缺乏真正的"智能"和"生命力"。
|
|
|
|
|
+
|
|
|
|
|
+想象一下,如果游戏中的NPC能够理解你说的任何话,不再局限于预设的选项,你可以用自然语言与NPC交流。NPC会记得你上次说了什么,你们的关系如何,甚至你的喜好。每个NPC都有自己的职业、性格和说话风格。NPC对你的态度会随着互动而变化,从陌生人到朋友,甚至挚友。
|
|
|
|
|
+
|
|
|
|
|
+这就是AI技术为游戏带来的新可能。通过将大语言模型与游戏引擎结合,我们可以创造出真正"活着"的NPC。这不仅仅是一个技术演示,更是对未来游戏形态的探索。在教育游戏中,NPC可以扮演历史人物、科学家,与学生进行互动式教学。在虚拟办公室中,NPC可以扮演同事、导师,提供帮助和建议。NPC还可以作为陪伴者,与用户进行情感交流,应用于心理健康领域。当然,最直接的应用就是为传统游戏增加AI NPC,提升玩家体验。
|
|
|
|
|
+
|
|
|
|
|
+### 15.1.2 技术架构概览
|
|
|
|
|
+
|
|
|
|
|
+赛博小镇采用<strong>游戏引擎+后端服务</strong>的分离架构,分为四个层次,如图15.1所示。
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-1.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.1 赛博小镇技术架构</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+前端层使用Godot 4.5游戏引擎,负责游戏渲染、玩家控制、NPC显示和对话UI。Godot是一个开源的2D/3D游戏引擎,非常适合快速开发像素风格的游戏。后端层使用FastAPI框架,负责API路由、NPC状态管理、对话处理和日志记录。FastAPI是一个现代化的Python Web框架,性能优秀且易于开发。智能体层使用我们自己构建的HelloAgents框架,负责NPC智能、记忆管理和好感度计算。每个NPC都是一个SimpleAgent实例,拥有独立的记忆和状态。外部服务层提供LLM能力、向量存储和数据持久化,包括OpenAI API、Qdrant向量数据库和SQLite关系数据库。
|
|
|
|
|
+
|
|
|
|
|
+数据流转过程如图15.2所示:
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-2.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.2 数据流转过程</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+玩家在Godot中按E键与NPC互动,Godot通过HTTP API发送对话请求到FastAPI后端。后端调用HelloAgents的SimpleAgent处理对话,Agent从记忆系统中检索相关历史,然后调用LLM生成回复。后端更新NPC状态和好感度,记录日志到控制台和文件,最后返回回复给Godot前端。Godot显示NPC回复并更新UI,完成一次完整的交互循环。
|
|
|
|
|
+
|
|
|
|
|
+项目的结构如下,方便你定位源码:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+Helloagents-AI-Town/
|
|
|
|
|
+├── helloagents-ai-town/ # Godot游戏项目
|
|
|
|
|
+│ ├── project.godot # Godot项目配置
|
|
|
|
|
+│ ├── scenes/ # 游戏场景
|
|
|
|
|
+│ │ ├── main.tscn # 主场景(办公室)
|
|
|
|
|
+│ │ ├── player.tscn # 玩家角色
|
|
|
|
|
+│ │ ├── npc.tscn # NPC角色
|
|
|
|
|
+│ │ └── dialogue_ui.tscn # 对话UI
|
|
|
|
|
+│ ├── scripts/ # GDScript脚本
|
|
|
|
|
+│ │ ├── main.gd # 主场景逻辑
|
|
|
|
|
+│ │ ├── player.gd # 玩家控制
|
|
|
|
|
+│ │ ├── npc.gd # NPC行为
|
|
|
|
|
+│ │ ├── dialogue_ui.gd # 对话UI逻辑
|
|
|
|
|
+│ │ ├── api_client.gd # API客户端
|
|
|
|
|
+│ │ └── config.gd # 配置管理
|
|
|
|
|
+│ └── assets/ # 游戏资源
|
|
|
|
|
+│ ├── characters/ # 角色精灵图
|
|
|
|
|
+│ ├── interiors/ # 室内场景
|
|
|
|
|
+│ ├── ui/ # UI素材
|
|
|
|
|
+│ └── audio/ # 音效音乐
|
|
|
|
|
+│
|
|
|
|
|
+└── backend/ # Python后端
|
|
|
|
|
+ ├── main.py # FastAPI主程序
|
|
|
|
|
+ ├── agents.py # NPC Agent系统
|
|
|
|
|
+ ├── relationship_manager.py # 好感度管理
|
|
|
|
|
+ ├── state_manager.py # 状态管理
|
|
|
|
|
+ ├── logger.py # 日志系统
|
|
|
|
|
+ ├── config.py # 配置管理
|
|
|
|
|
+ ├── models.py # 数据模型
|
|
|
|
|
+ ├── requirements.txt # Python依赖
|
|
|
|
|
+ └── .env.example # 环境变量示例
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+详细的架构设计和数据流转将在后续章节中介绍。
|
|
|
|
|
+
|
|
|
|
|
+### 15.1.3 快速体验:5分钟运行项目
|
|
|
|
|
+
|
|
|
|
|
+在深入学习实现细节之前,让我们先把项目跑起来,看看最终的效果。这样你会对整个系统有一个直观的认识。
|
|
|
|
|
+
|
|
|
|
|
+<strong>环境要求:</strong>
|
|
|
|
|
+
|
|
|
|
|
+- Godot 4.2或更高版本
|
|
|
|
|
+- Python 3.10或更高版本
|
|
|
|
|
+- LLM API密钥(OpenAI、DeepSeek、智谱等)
|
|
|
|
|
+
|
|
|
|
|
+<strong>获取项目:</strong>
|
|
|
|
|
+
|
|
|
|
|
+你可以下载`code/chapter15/Helloagents-AI-Town.zip`分发包并解压,或者从GitHub克隆完整仓库。
|
|
|
|
|
+
|
|
|
|
|
+<strong>启动后端:</strong>
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+# 1. 进入backend目录
|
|
|
|
|
+cd Helloagents-AI-Town/backend
|
|
|
|
|
+
|
|
|
|
|
+# 2. 安装依赖
|
|
|
|
|
+pip install -r requirements.txt
|
|
|
|
|
+
|
|
|
|
|
+# 3. 配置环境变量
|
|
|
|
|
+cp .env.example .env
|
|
|
|
|
+# 编辑.env文件,填写你的API密钥
|
|
|
|
|
+
|
|
|
|
|
+# 4. 启动后端服务
|
|
|
|
|
+python main.py
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+成功启动后,你会看到如下输出:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+============================================================
|
|
|
|
|
+🎮 赛博小镇后端服务启动中...
|
|
|
|
|
+============================================================
|
|
|
|
|
+✅ 所有服务已启动!
|
|
|
|
|
+📡 API地址: http://0.0.0.0:8000
|
|
|
|
|
+📚 API文档: http://0.0.0.0:8000/docs
|
|
|
|
|
+============================================================
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+<strong>启动Godot:</strong>
|
|
|
|
|
+
|
|
|
|
|
+Godot的安装非常简单,Windows提供了直接打开的`.exe`文件,Mac也提供了`.dmg`文件。可直接在官网下载([Windows](https://godotengine.org/download/windows/) / [Mac](https://godotengine.org/download/macos/))
|
|
|
|
|
+
|
|
|
|
|
+打开Godot引擎,点击"导入"按钮,浏览到`Helloagents-AI-Town/helloagents-ai-town/project.godot`,点击"导入并编辑"。等待Godot导入资源后,按`F5`或点击"运行"按钮启动游戏。
|
|
|
|
|
+
|
|
|
|
|
+<strong>体验核心功能:</strong>
|
|
|
|
|
+
|
|
|
|
|
+游戏启动后,你会看到一个像素风格的Datawhale办公室场景,如图15.3所示。
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-3.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.3 赛博小镇游戏场景</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+使用WASD键移动玩家角色,走到NPC附近时,屏幕上会显示"按E键交互"的提示。按下E键后,会弹出对话框,你可以输入任何想说的话,如图15.4所示。
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-4.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.4 与NPC对话界面</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+NPC会根据自己的角色设定(Python工程师、产品经理、UI设计师)和你们的互动历史做出回应。随着对话的进行,NPC对你的好感度会逐渐提升,从"陌生"到"熟悉",再到"友好"、"亲密"甚至"挚友"。
|
|
|
|
|
+
|
|
|
|
|
+<strong>好感度系统在后端实现</strong>,每次对话都会根据玩家的消息内容和情感分析来调整好感度值。虽然前端游戏界面中没有直接显示好感度数值,但所有的好感度变化都会被详细记录在后端日志中。你可以在`backend/logs/dialogue_YYYY-MM-DD.log`文件中查看每次对话的好感度变化。日志文件会记录每次对话的详细信息,包括:当前好感度值、检索到的相关记忆、NPC的回复、好感度变化量(+2.0、+3.0等)、变化原因(友好问候、正常交流等)以及情感分析结果(positive、neutral等)。这种设计让开发者可以清晰地追踪NPC与玩家的关系发展,也为后续在前端添加好感度UI提供了数据基础。
|
|
|
|
|
+
|
|
|
|
|
+所有的对话都会被记录在后端的日志文件中,你可以通过以下命令实时查看:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+# 在backend目录下
|
|
|
|
|
+python view_logs.py
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这个简单的体验展示了AI小镇的核心功能。接下来,我们将深入学习如何实现这些功能。
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+## 15.2 NPC智能体系统
|
|
|
|
|
+
|
|
|
|
|
+### 15.2.1 基于HelloAgents的SimpleAgent
|
|
|
|
|
+
|
|
|
|
|
+在赛博小镇中,每个NPC都是一个独立的智能体。我们使用HelloAgents框架中的SimpleAgent来实现NPC的智能。SimpleAgent是一个轻量级的智能体实现,它封装了LLM调用、消息管理和工具调用等核心功能。
|
|
|
|
|
+
|
|
|
|
|
+回顾一下第七章中我们学习的SimpleAgent,它的核心是一个简单的对话循环:接收用户消息,调用LLM生成回复,返回结果。在赛博小镇中,我们需要为每个NPC创建一个SimpleAgent实例,并为其配置独特的系统提示词,让每个NPC拥有不同的性格和角色设定。
|
|
|
|
|
+
|
|
|
|
|
+让我们看看如何创建一个NPC Agent。首先,我们需要定义NPC的基本信息,包括ID、名称、职业和性格。然后,我们根据这些信息构建系统提示词,让LLM扮演这个NPC的角色。最后,我们创建SimpleAgent实例,并配置记忆系统。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from hello_agents import SimpleAgent, HelloAgentsLLM
|
|
|
|
|
+from hello_agents.memory import MemoryManager, WorkingMemory, EpisodicMemory
|
|
|
|
|
+
|
|
|
|
|
+def create_npc_agent(npc_id: str, name: str, role: str, personality: str):
|
|
|
|
|
+ """创建NPC Agent"""
|
|
|
|
|
+ # 构建系统提示词
|
|
|
|
|
+ system_prompt = f"""你是{name},一位{role}。
|
|
|
|
|
+你的性格特点:{personality}
|
|
|
|
|
+
|
|
|
|
|
+你在Datawhale办公室工作,与同事们一起推动开源社区的发展。
|
|
|
|
|
+请根据你的角色和性格,自然地与玩家对话。
|
|
|
|
|
+记住你们之前的对话内容,保持对话的连贯性。
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+ # 创建LLM实例
|
|
|
|
|
+ llm = HelloAgentsLLM()
|
|
|
|
|
+
|
|
|
|
|
+ # 创建记忆管理器
|
|
|
|
|
+ memory_manager = MemoryManager(
|
|
|
|
|
+ working_memory=WorkingMemory(capacity=10, ttl_minutes=120),
|
|
|
|
|
+ episodic_memory=EpisodicMemory(
|
|
|
|
|
+ db_path=f"memory_data/{npc_id}_episodic.db",
|
|
|
|
|
+ collection_name=f"{npc_id}_memories"
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 创建Agent
|
|
|
|
|
+ agent = SimpleAgent(
|
|
|
|
|
+ name=name,
|
|
|
|
|
+ llm=llm,
|
|
|
|
|
+ system_prompt=system_prompt,
|
|
|
|
|
+ memory_manager=memory_manager
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return agent
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这段代码展示了如何创建一个NPC Agent。系统提示词定义了NPC的身份和性格,记忆管理器让NPC能够记住与玩家的对话历史。WorkingMemory是短期记忆,容量为10条消息,保留时间为120分钟。EpisodicMemory是长期记忆,使用SQLite数据库和Qdrant向量数据库存储,可以检索相关的历史对话。
|
|
|
|
|
+
|
|
|
|
|
+NPC Agent的工作流程如图15.5所示:
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-5.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.5 NPC Agent工作流程</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+### 15.2.2 NPC角色设定与Prompt设计
|
|
|
|
|
+
|
|
|
|
|
+一个好的NPC需要有鲜明的性格和角色设定。在赛博小镇中,我们设计了三个NPC,分别代表不同的职业和性格。
|
|
|
|
|
+
|
|
|
|
|
+<strong>张三 - Python工程师</strong>
|
|
|
|
|
+
|
|
|
|
|
+张三是一位资深的Python工程师,负责HelloAgents框架的核心开发。他性格严谨,说话直接,喜欢用技术术语。他对代码质量有很高的要求,经常会分享一些编程技巧和最佳实践。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+npc_zhang = {
|
|
|
|
|
+ "npc_id": "zhang_san",
|
|
|
|
|
+ "name": "张三",
|
|
|
|
|
+ "role": "Python工程师",
|
|
|
|
|
+ "personality": "严谨、专业、喜欢分享技术知识。说话直接,注重代码质量。"
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+<strong>李四 - 产品经理</strong>
|
|
|
|
|
+
|
|
|
|
|
+李四是一位经验丰富的产品经理,负责HelloAgents框架的产品规划和用户体验设计。他性格外向,善于沟通,总是能从用户的角度思考问题。他喜欢讨论产品设计和用户需求,经常会问"为什么"。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+npc_li = {
|
|
|
|
|
+ "npc_id": "li_si",
|
|
|
|
|
+ "name": "李四",
|
|
|
|
|
+ "role": "产品经理",
|
|
|
|
|
+ "personality": "外向、善于沟通、注重用户体验。喜欢从用户角度思考问题。"
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+<strong>王五 - UI设计师</strong>
|
|
|
|
|
+
|
|
|
|
|
+王五是一位富有创意的UI设计师,负责HelloAgents框架的界面设计和视觉呈现。他性格温和,审美独特,对色彩和布局有敏锐的感知。他喜欢讨论设计理念和美学,经常会分享一些设计灵感。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+npc_wang = {
|
|
|
|
|
+ "npc_id": "wang_wu",
|
|
|
|
|
+ "name": "王五",
|
|
|
|
|
+ "role": "UI设计师",
|
|
|
|
|
+ "personality": "温和、富有创意、审美独特。注重视觉呈现和用户体验。"
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这三个NPC的设定各有特色,玩家可以根据自己的兴趣选择与不同的NPC互动。张三可以教你编程技巧,李四可以和你讨论产品设计,王五可以分享设计灵感。
|
|
|
|
|
+
|
|
|
|
|
+### 15.2.3 记忆系统集成
|
|
|
|
|
+
|
|
|
|
|
+记忆系统是NPC智能的关键。一个能够记住过去对话的NPC,会让玩家感觉更加真实和有趣。我们采用helloagents的`WorkingMemory`和`EpisodicMemory`构造短期记忆和长期记忆。
|
|
|
|
|
+
|
|
|
|
|
+短期记忆存储最近的对话内容,容量有限,会随着时间自动清理。它的作用是保持对话的连贯性,让NPC能够理解上下文。比如,当玩家说"它是什么颜色的?"时,NPC需要从短期记忆中找到"它"指的是什么。
|
|
|
|
|
+
|
|
|
|
|
+长期记忆存储所有的对话历史,使用向量数据库进行语义检索。当玩家提到某个话题时,NPC可以从长期记忆中检索相关的历史对话,回忆起之前讨论过的内容。比如,当玩家说"还记得我们上次讨论的那个项目吗?",NPC可以从长期记忆中找到相关的对话记录。
|
|
|
|
|
+
|
|
|
|
|
+记忆系统的架构如图15.6所示:
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-6.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.6 记忆系统架构</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+在实际使用中,Agent会先从短期记忆中获取最近的对话,然后从长期记忆中检索相关的历史对话,将这些信息一起发送给LLM,生成更加准确和个性化的回复。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# Agent处理对话的流程
|
|
|
|
|
+def process_dialogue(agent, player_message):
|
|
|
|
|
+ # 1. 从短期记忆获取最近对话
|
|
|
|
|
+ recent_messages = agent.memory_manager.working_memory.get_recent_messages(5)
|
|
|
|
|
+
|
|
|
|
|
+ # 2. 从长期记忆检索相关历史
|
|
|
|
|
+ relevant_memories = agent.memory_manager.episodic_memory.search(
|
|
|
|
|
+ query=player_message,
|
|
|
|
|
+ top_k=3
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 3. 构建上下文
|
|
|
|
|
+ context = {
|
|
|
|
|
+ "recent": recent_messages,
|
|
|
|
|
+ "relevant": relevant_memories
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ # 4. 调用Agent生成回复
|
|
|
|
|
+ reply = agent.run(player_message, context=context)
|
|
|
|
|
+
|
|
|
|
|
+ # 5. 保存到记忆系统
|
|
|
|
|
+ agent.memory_manager.add_interaction(player_message, reply)
|
|
|
|
|
+
|
|
|
|
|
+ return reply
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这个流程确保了NPC能够记住与玩家的互动历史,并在对话中体现出来。
|
|
|
|
|
+
|
|
|
|
|
+### 15.2.4 批量对话生成:轻负载模式
|
|
|
|
|
+
|
|
|
|
|
+在实际运行中,很快就会发现了一个问题:当多个玩家同时与不同的NPC对话时,后端需要并发处理多个LLM请求。每个请求都需要调用API,这不仅增加了成本,还可能因为并发限制导致请求失败或延迟。
|
|
|
|
|
+
|
|
|
|
|
+为了解决这个问题,我们设计了一个<strong>批量对话生成系统</strong>。核心思想是:将多个NPC的对话请求合并成一次LLM调用,让LLM一次性生成所有NPC的回复。这就像餐厅的"预制菜"一样,提前批量准备好,需要时直接使用,大大降低了成本和延迟。
|
|
|
|
|
+
|
|
|
|
|
+批量生成的工作流程如图15.7所示:
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-7.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.7 批量生成vs传统模式</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+批量生成器的实现非常巧妙。我们构建一个特殊的提示词,要求LLM一次性生成所有NPC的对话,并以JSON格式返回。这样,一次API调用就能获得所有NPC的回复,成本降低到原来的1/3,延迟也大幅减少。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+class NPCBatchGenerator:
|
|
|
|
|
+ """批量生成NPC对话的生成器"""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.llm = HelloAgentsLLM()
|
|
|
|
|
+ self.npc_configs = NPC_ROLES # 所有NPC的配置
|
|
|
|
|
+
|
|
|
|
|
+ def generate_batch_dialogues(self, context: Optional[str] = None) -> Dict[str, str]:
|
|
|
|
|
+ """批量生成所有NPC的对话
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ context: 场景上下文(如"上午工作时间"、"午餐时间"等)
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ Dict[str, str]: NPC名称到对话内容的映射
|
|
|
|
|
+ """
|
|
|
|
|
+ # 构建批量生成提示词
|
|
|
|
|
+ prompt = self._build_batch_prompt(context)
|
|
|
|
|
+
|
|
|
|
|
+ # 一次LLM调用生成所有对话
|
|
|
|
|
+ response = self.llm.invoke([
|
|
|
|
|
+ {"role": "system", "content": "你是一个游戏NPC对话生成器,擅长创作自然真实的办公室对话。"},
|
|
|
|
|
+ {"role": "user", "content": prompt}
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ # 解析JSON响应
|
|
|
|
|
+ dialogues = json.loads(response)
|
|
|
|
|
+ # 返回格式: {"张三": "...", "李四": "...", "王五": "..."}
|
|
|
|
|
+
|
|
|
|
|
+ return dialogues
|
|
|
|
|
+
|
|
|
|
|
+ def _build_batch_prompt(self, context: Optional[str] = None) -> str:
|
|
|
|
|
+ """构建批量生成提示词"""
|
|
|
|
|
+ # 根据时间自动推断场景
|
|
|
|
|
+ if context is None:
|
|
|
|
|
+ context = self._get_current_context()
|
|
|
|
|
+
|
|
|
|
|
+ # 构建NPC描述
|
|
|
|
|
+ npc_descriptions = []
|
|
|
|
|
+ for name, cfg in self.npc_configs.items():
|
|
|
|
|
+ desc = f"- {name}({cfg['title']}): 在{cfg['location']}{cfg['activity']},性格{cfg['personality']}"
|
|
|
|
|
+ npc_descriptions.append(desc)
|
|
|
|
|
+
|
|
|
|
|
+ npc_desc_text = "\n".join(npc_descriptions)
|
|
|
|
|
+
|
|
|
|
|
+ prompt = f"""请为Datawhale办公室的3个NPC生成当前的对话或行为描述。
|
|
|
|
|
+
|
|
|
|
|
+【场景】{context}
|
|
|
|
|
+
|
|
|
|
|
+【NPC信息】
|
|
|
|
|
+{npc_desc_text}
|
|
|
|
|
+
|
|
|
|
|
+【生成要求】
|
|
|
|
|
+1. 每个NPC生成1句话(20-40字)
|
|
|
|
|
+2. 内容要符合角色设定、当前活动和场景氛围
|
|
|
|
|
+3. 可以是自言自语、工作状态描述、或简单的思考
|
|
|
|
|
+4. 要自然真实,像真实的办公室同事
|
|
|
|
|
+5. **必须严格按照JSON格式返回**
|
|
|
|
|
+
|
|
|
|
|
+【输出格式】(严格遵守)
|
|
|
|
|
+{{"张三": "...", "李四": "...", "王五": "..."}}
|
|
|
|
|
+
|
|
|
|
|
+【示例输出】
|
|
|
|
|
+{{"张三": "这个bug真是见鬼了,已经调试两小时了...", "李四": "嗯,这个功能的优先级需要重新评估一下。", "王五": "这杯咖啡的拉花真不错,灵感来了!"}}
|
|
|
|
|
+
|
|
|
|
|
+请生成(只返回JSON,不要其他内容):
|
|
|
|
|
+"""
|
|
|
|
|
+ return prompt
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这个设计的关键在于提示词的构建。我们明确要求LLM返回JSON格式,并提供了示例输出。LLM会严格按照这个格式生成回复,我们只需要解析JSON就能获得所有NPC的对话。
|
|
|
|
|
+
|
|
|
|
|
+批量生成还有一个额外的好处:所有NPC的对话是在同一个上下文中生成的,因此它们之间会有一定的关联性。比如,如果张三在调试bug,李四可能会提到要帮忙看看;如果王五在设计界面,张三可能会说等会儿去看看设计稿。这让整个办公室的氛围更加真实和连贯。
|
|
|
|
|
+
|
|
|
|
|
+当然,批量生成也有一些限制。它更适合生成NPC的"背景对话"或"自言自语",而不是与玩家的直接互动。对于玩家发起的对话,我们仍然使用单独的Agent来处理,以保证回复的个性化和准确性。批量生成主要用于以下场景:
|
|
|
|
|
+
|
|
|
|
|
+1. <strong>NPC背景对话</strong>:玩家进入场景时,NPC正在做什么、说什么
|
|
|
|
|
+2. <strong>定时更新</strong>:每隔一段时间更新NPC的状态和对话
|
|
|
|
|
+3. <strong>场景氛围</strong>:根据时间(早上、中午、晚上)生成不同的对话
|
|
|
|
|
+4. <strong>降低成本</strong>:在高并发场景下,使用批量生成降低API调用次数
|
|
|
|
|
+
|
|
|
|
|
+<strong>混合模式:批量生成+即时响应</strong>
|
|
|
|
|
+
|
|
|
|
|
+在实际实现中,我们采用了一种混合模式,将批量生成和即时响应结合起来。这个设计非常巧妙,既保证了效率,又保证了交互的质量。
|
|
|
|
|
+
|
|
|
|
|
+具体来说,系统会在后台定期运行批量生成,为所有NPC生成当前场景下的"背景对话"。这些对话会被缓存起来,当玩家靠近NPC但还没有发起交互时,NPC会显示这些背景对话,比如"正在调试代码..."、"在看产品文档..."等。这让NPC看起来是"活着的",而不是静止的模型。
|
|
|
|
|
+
|
|
|
|
|
+但是,当玩家按下E键发起交互时,系统会立即切换到即时响应模式。此时,后端会调用该NPC的专属Agent,根据玩家的具体消息、历史记忆和好感度,生成个性化的回复。这个过程是实时的,确保NPC的回复与玩家的输入高度相关。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# 在main.py中的混合模式实现
|
|
|
|
|
+@app.post("/dialogue")
|
|
|
|
|
+async def dialogue(request: DialogueRequest):
|
|
|
|
|
+ """处理玩家与NPC的对话(即时响应模式)"""
|
|
|
|
|
+ npc_id = request.npc_id
|
|
|
|
|
+ player_message = request.player_message
|
|
|
|
|
+ player_name = request.player_name
|
|
|
|
|
+
|
|
|
|
|
+ # 获取NPC Agent(每个NPC有独立的Agent)
|
|
|
|
|
+ agent = npc_agents.get(npc_id)
|
|
|
|
|
+ if not agent:
|
|
|
|
|
+ raise HTTPException(status_code=404, detail="NPC not found")
|
|
|
|
|
+
|
|
|
|
|
+ # 即时生成个性化回复
|
|
|
|
|
+ # 这里不使用批量生成,而是调用Agent的run方法
|
|
|
|
|
+ reply = agent.run(player_message)
|
|
|
|
|
+
|
|
|
|
|
+ # 更新好感度
|
|
|
|
|
+ affinity_change = relationship_manager.update_affinity(
|
|
|
|
|
+ npc_id, player_name, player_message, reply
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "npc_reply": reply,
|
|
|
|
|
+ "affinity_score": affinity_change["score"],
|
|
|
|
|
+ "affinity_level": affinity_change["level"]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+# 后台任务:定期批量生成背景对话
|
|
|
|
|
+async def background_dialogue_update():
|
|
|
|
|
+ """后台任务:每5分钟更新一次NPC背景对话"""
|
|
|
|
|
+ while True:
|
|
|
|
|
+ try:
|
|
|
|
|
+ # 使用批量生成器生成所有NPC的背景对话
|
|
|
|
|
+ batch_generator = get_batch_generator()
|
|
|
|
|
+ dialogues = batch_generator.generate_batch_dialogues()
|
|
|
|
|
+
|
|
|
|
|
+ # 更新到状态管理器
|
|
|
|
|
+ for npc_name, dialogue in dialogues.items():
|
|
|
|
|
+ state_manager.update_npc_background_dialogue(npc_name, dialogue)
|
|
|
|
|
+
|
|
|
|
|
+ print(f"✅ 背景对话更新完成: {len(dialogues)}个NPC")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ print(f"❌ 背景对话更新失败: {e}")
|
|
|
|
|
+
|
|
|
|
|
+ # 等待5分钟
|
|
|
|
|
+ await asyncio.sleep(300)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这种混合模式的优势非常明显:
|
|
|
|
|
+
|
|
|
|
|
+1. <strong>降低成本</strong>:背景对话使用批量生成,一次调用生成所有NPC的对话,成本低
|
|
|
|
|
+2. <strong>保证质量</strong>:玩家交互使用即时响应,每个回复都是个性化的,质量高
|
|
|
|
|
+3. <strong>提升体验</strong>:NPC始终有"背景对话",看起来很生动;玩家交互时回复准确,体验好
|
|
|
|
|
+4. <strong>灵活调整</strong>:可以根据服务器负载动态调整批量生成的频率
|
|
|
|
|
+
|
|
|
|
|
+通过批量生成和即时响应的结合,我们实现了一个既高效又智能的NPC系统。在正常情况下,玩家感受不到任何差异,但后端的成本和性能得到了显著优化。这个设计思路也可以应用到其他需要大量AI调用的场景中。
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+## 15.3 好感度系统设计
|
|
|
|
|
+
|
|
|
|
|
+### 15.3.1 好感度等级划分
|
|
|
|
|
+
|
|
|
|
|
+在赛博小镇中,NPC对玩家的态度会随着互动而变化。我们设计了一个五级好感度系统,从陌生到挚友,每个等级都有不同的分数范围和对应的行为表现。
|
|
|
|
|
+
|
|
|
|
|
+好感度系统的核心思想是:通过量化NPC与玩家的关系,让NPC的回复更加真实和有层次感。当玩家刚进入游戏时,所有NPC对玩家都是陌生的态度,回复比较礼貌但疏远。随着对话的进行,如果玩家表现友好,NPC的好感度会逐渐提升,回复也会变得更加亲切和详细。
|
|
|
|
|
+
|
|
|
|
|
+我们将好感度分为五个等级,每个等级对应一个分数范围,如图15.8所示:
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-8.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.8 好感度等级划分</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+- <strong>陌生(0-20分)</strong>:NPC刚认识玩家,态度礼貌但保持距离。回复简短,不会主动分享个人信息。
|
|
|
|
|
+
|
|
|
|
|
+- <strong>熟悉(21-40分)</strong>:NPC开始记住玩家,愿意进行简单的交流。回复变得更加自然,偶尔会分享一些工作相关的信息。
|
|
|
|
|
+
|
|
|
|
|
+- <strong>友好(41-60分)</strong>:NPC把玩家当作朋友,愿意分享更多信息。回复更加详细,会主动询问玩家的情况。
|
|
|
|
|
+
|
|
|
|
|
+- <strong>亲密(61-80分)</strong>:NPC非常信任玩家,愿意分享私人话题。回复充满热情,会给玩家提供帮助和建议。
|
|
|
|
|
+
|
|
|
|
|
+- <strong>挚友(81-100分)</strong>:NPC把玩家当作最好的朋友,无话不谈。回复非常亲切,会分享内心的想法和感受。
|
|
|
|
|
+
|
|
|
|
|
+这个设计让玩家能够清晰地感受到与NPC关系的变化,也为后续的游戏玩法提供了基础。比如,只有达到一定好感度,NPC才会分享某些特殊信息或提供特殊任务。
|
|
|
|
|
+
|
|
|
|
|
+### 15.3.2 好感度计算逻辑
|
|
|
|
|
+
|
|
|
|
|
+好感度的计算需要考虑多个因素。我们不能简单地让每次对话都增加固定的分数,这样会让系统显得机械和不真实。一个好的好感度系统应该能够识别玩家的态度,并根据对话内容动态调整分数。
|
|
|
|
|
+
|
|
|
|
|
+在赛博小镇中,我们使用LLM来分析对话内容,判断玩家的态度是友好、中立还是不友好。然后根据判断结果调整好感度分数。这个过程是自动的,不需要玩家刻意选择选项,让互动更加自然。
|
|
|
|
|
+
|
|
|
|
|
+好感度计算流程如图15.9所示:
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-9.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.9 好感度计算流程</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+class RelationshipManager:
|
|
|
|
|
+ """好感度管理器"""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.affinity_data = {} # 存储好感度数据
|
|
|
|
|
+ self.llm = HelloAgentsLLM() # 用于分析对话
|
|
|
|
|
+
|
|
|
|
|
+ def analyze_sentiment(self, player_message: str, npc_reply: str) -> int:
|
|
|
|
|
+ """分析对话情感,返回好感度变化值"""
|
|
|
|
|
+ prompt = f"""分析以下对话中玩家的态度:
|
|
|
|
|
+玩家: {player_message}
|
|
|
|
|
+NPC: {npc_reply}
|
|
|
|
|
+
|
|
|
|
|
+请判断玩家的态度是:
|
|
|
|
|
+1. 友好(+5分): 礼貌、热情、表示感谢或赞同
|
|
|
|
|
+2. 中立(+2分): 普通的询问或陈述
|
|
|
|
|
+3. 不友好(-3分): 粗鲁、冷漠、批评或否定
|
|
|
|
|
+
|
|
|
|
|
+只返回数字,不要其他内容。"""
|
|
|
|
|
+
|
|
|
|
|
+ response = self.llm.think([{"role": "user", "content": prompt}])
|
|
|
|
|
+ try:
|
|
|
|
|
+ score_change = int(response.strip())
|
|
|
|
|
+ return max(-3, min(5, score_change)) # 限制在-3到5之间
|
|
|
|
|
+ except:
|
|
|
|
|
+ return 2 # 默认中立
|
|
|
|
|
+
|
|
|
|
|
+ def update_affinity(self, npc_id: str, player_name: str,
|
|
|
|
|
+ player_message: str, npc_reply: str) -> dict:
|
|
|
|
|
+ """更新好感度"""
|
|
|
|
|
+ key = f"{npc_id}_{player_name}"
|
|
|
|
|
+
|
|
|
|
|
+ # 获取当前好感度
|
|
|
|
|
+ if key not in self.affinity_data:
|
|
|
|
|
+ self.affinity_data[key] = {
|
|
|
|
|
+ "score": 0,
|
|
|
|
|
+ "level": "陌生",
|
|
|
|
|
+ "interaction_count": 0
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ # 分析对话情感
|
|
|
|
|
+ score_change = self.analyze_sentiment(player_message, npc_reply)
|
|
|
|
|
+
|
|
|
|
|
+ # 更新分数
|
|
|
|
|
+ current_score = self.affinity_data[key]["score"]
|
|
|
|
|
+ new_score = max(0, min(100, current_score + score_change))
|
|
|
|
|
+
|
|
|
|
|
+ # 更新等级
|
|
|
|
|
+ level = self.get_affinity_level(new_score)
|
|
|
|
|
+
|
|
|
|
|
+ # 更新数据
|
|
|
|
|
+ self.affinity_data[key].update({
|
|
|
|
|
+ "score": new_score,
|
|
|
|
|
+ "level": level,
|
|
|
|
|
+ "interaction_count": self.affinity_data[key]["interaction_count"] + 1
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return self.affinity_data[key]
|
|
|
|
|
+
|
|
|
|
|
+ def get_affinity_level(self, score: int) -> str:
|
|
|
|
|
+ """根据分数获取好感度等级"""
|
|
|
|
|
+ if score <= 20:
|
|
|
|
|
+ return "陌生"
|
|
|
|
|
+ elif score <= 40:
|
|
|
|
|
+ return "熟悉"
|
|
|
|
|
+ elif score <= 60:
|
|
|
|
|
+ return "友好"
|
|
|
|
|
+ elif score <= 80:
|
|
|
|
|
+ return "亲密"
|
|
|
|
|
+ else:
|
|
|
|
|
+ return "挚友"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这个实现使用LLM来分析对话内容,自动判断玩家的态度并调整好感度。这样的设计让好感度系统更加智能和自然,玩家不需要刻意讨好NPC,只需要正常交流即可。
|
|
|
|
|
+
|
|
|
|
|
+### 15.3.3 好感度影响对话
|
|
|
|
|
+
|
|
|
|
|
+好感度不仅仅是一个数字,它应该真正影响NPC的行为。在赛博小镇中,我们通过修改NPC的系统提示词,让NPC根据当前的好感度等级调整回复风格。
|
|
|
|
|
+
|
|
|
|
|
+当好感度较低时,NPC会保持礼貌但疏远的态度。当好感度提升后,NPC会变得更加热情和健谈。这种变化是通过动态调整系统提示词实现的。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+def create_npc_agent_with_affinity(npc_id: str, name: str, role: str,
|
|
|
|
|
+ personality: str, affinity_level: str):
|
|
|
|
|
+ """创建带好感度的NPC Agent"""
|
|
|
|
|
+
|
|
|
|
|
+ # 根据好感度等级调整提示词
|
|
|
|
|
+ affinity_prompts = {
|
|
|
|
|
+ "陌生": "你刚认识这位玩家,保持礼貌但不要过于热情。回复简短专业。",
|
|
|
|
|
+ "熟悉": "你已经认识这位玩家,可以进行正常的交流。回复自然友好。",
|
|
|
|
|
+ "友好": "你把这位玩家当作朋友,愿意分享更多信息。回复详细热情。",
|
|
|
|
|
+ "亲密": "你非常信任这位玩家,可以分享私人话题。回复充满关心。",
|
|
|
|
|
+ "挚友": "你把这位玩家当作最好的朋友,无话不谈。回复亲切真诚。"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ system_prompt = f"""你是{name},一位{role}。
|
|
|
|
|
+你的性格特点:{personality}
|
|
|
|
|
+
|
|
|
|
|
+当前与玩家的关系:{affinity_level}
|
|
|
|
|
+{affinity_prompts.get(affinity_level, affinity_prompts["陌生"])}
|
|
|
|
|
+
|
|
|
|
|
+你在Datawhale办公室工作,与同事们一起推动开源社区的发展。
|
|
|
|
|
+请根据你的角色、性格和与玩家的关系,自然地回复。
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+ # 创建Agent
|
|
|
|
|
+ llm = HelloAgentsLLM()
|
|
|
|
|
+ agent = SimpleAgent(
|
|
|
|
|
+ name=name,
|
|
|
|
|
+ llm=llm,
|
|
|
|
|
+ system_prompt=system_prompt
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return agent
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这个设计让NPC的行为随着好感度动态变化。玩家可以明显感受到,随着互动的增加,NPC对自己的态度在逐渐改变,这大大增强了游戏的沉浸感和趣味性。
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+## 15.4 后端服务实现
|
|
|
|
|
+
|
|
|
|
|
+### 15.4.1 FastAPI应用结构
|
|
|
|
|
+
|
|
|
|
|
+赛博小镇的后端使用FastAPI框架构建,负责处理Godot前端的请求,调用HelloAgents的NPC Agent,管理NPC状态和好感度,以及记录日志。一个清晰的应用结构能够让代码更易于维护和扩展。
|
|
|
|
|
+
|
|
|
|
|
+我们的FastAPI应用采用模块化设计,将不同的功能分离到不同的文件中,如图15.10所示:
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-10.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.10 后端应用结构</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+让我们从`main.py`开始,这是FastAPI应用的入口文件:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from fastapi import FastAPI, HTTPException
|
|
|
|
|
+from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
+from pydantic import BaseModel, Field
|
|
|
|
|
+from typing import Optional
|
|
|
|
|
+import uvicorn
|
|
|
|
|
+
|
|
|
|
|
+from agents import NPCAgentManager
|
|
|
|
|
+from relationship_manager import RelationshipManager
|
|
|
|
|
+from state_manager import StateManager
|
|
|
|
|
+from logger import DialogueLogger
|
|
|
|
|
+from config import settings
|
|
|
|
|
+
|
|
|
|
|
+# 创建FastAPI应用
|
|
|
|
|
+app = FastAPI(
|
|
|
|
|
+ title="赛博小镇后端服务",
|
|
|
|
|
+ description="基于HelloAgents的AI NPC对话系统",
|
|
|
|
|
+ version="1.0.0"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+# 配置CORS,允许Godot前端访问
|
|
|
|
|
+app.add_middleware(
|
|
|
|
|
+ CORSMiddleware,
|
|
|
|
|
+ allow_origins=["*"], # 生产环境应该限制具体域名
|
|
|
|
|
+ allow_credentials=True,
|
|
|
|
|
+ allow_methods=["*"],
|
|
|
|
|
+ allow_headers=["*"],
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+# 初始化各个管理器
|
|
|
|
|
+agent_manager = NPCAgentManager()
|
|
|
|
|
+relationship_manager = RelationshipManager()
|
|
|
|
|
+state_manager = StateManager()
|
|
|
|
|
+dialogue_logger = DialogueLogger()
|
|
|
|
|
+
|
|
|
|
|
+@app.on_event("startup")
|
|
|
|
|
+async def startup_event():
|
|
|
|
|
+ """应用启动时的初始化"""
|
|
|
|
|
+ print("=" * 60)
|
|
|
|
|
+ print("🎮 赛博小镇后端服务启动中...")
|
|
|
|
|
+ print("=" * 60)
|
|
|
|
|
+
|
|
|
|
|
+ # 初始化NPC Agents
|
|
|
|
|
+ agent_manager.initialize_npcs()
|
|
|
|
|
+ print("✅ NPC Agents已初始化")
|
|
|
|
|
+
|
|
|
|
|
+ # 初始化状态管理器
|
|
|
|
|
+ state_manager.initialize_npcs()
|
|
|
|
|
+ print("✅ 状态管理器已初始化")
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/")
|
|
|
|
|
+async def root():
|
|
|
|
|
+ """健康检查"""
|
|
|
|
|
+ return {
|
|
|
|
|
+ "status": "running",
|
|
|
|
|
+ "message": "赛博小镇后端服务正在运行",
|
|
|
|
|
+ "version": "1.0.0",
|
|
|
|
|
+ "npcs": state_manager.get_npc_count()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ uvicorn.run(
|
|
|
|
|
+ app,
|
|
|
|
|
+ host=settings.HOST,
|
|
|
|
|
+ port=settings.PORT,
|
|
|
|
|
+ log_level="info"
|
|
|
|
|
+ )
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这个主程序文件定义了FastAPI应用的基本结构,配置了CORS中间件以允许跨域请求,并在启动时初始化各个管理器。接下来我们将实现具体的API路由。
|
|
|
|
|
+
|
|
|
|
|
+### 15.4.2 API路由设计
|
|
|
|
|
+
|
|
|
|
|
+赛博小镇的后端需要提供几个核心API端点,用于处理Godot前端的请求。我们将这些路由添加到`main.py`中。
|
|
|
|
|
+
|
|
|
|
|
+<strong>获取NPC状态</strong>
|
|
|
|
|
+
|
|
|
|
|
+这个API返回所有NPC的当前状态,包括位置、是否忙碌等信息:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from models import NPCStatusResponse
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/npcs/status", response_model=NPCStatusResponse)
|
|
|
|
|
+async def get_npc_status():
|
|
|
|
|
+ """获取所有NPC的状态"""
|
|
|
|
|
+ npcs = state_manager.get_all_npc_states()
|
|
|
|
|
+ return {"npcs": npcs}
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/npcs/{npc_id}/status")
|
|
|
|
|
+async def get_single_npc_status(npc_id: str):
|
|
|
|
|
+ """获取单个NPC的状态"""
|
|
|
|
|
+ npc = state_manager.get_npc_state(npc_id)
|
|
|
|
|
+ if not npc:
|
|
|
|
|
+ raise HTTPException(status_code=404, detail=f"NPC {npc_id} 不存在")
|
|
|
|
|
+ return npc
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+<strong>对话接口</strong>
|
|
|
|
|
+
|
|
|
|
|
+这是最核心的API,处理玩家与NPC的对话:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from models import DialogueRequest, DialogueResponse
|
|
|
|
|
+
|
|
|
|
|
+@app.post("/dialogue", response_model=DialogueResponse)
|
|
|
|
|
+async def dialogue(request: DialogueRequest):
|
|
|
|
|
+ """处理玩家与NPC的对话"""
|
|
|
|
|
+ # 1. 验证NPC是否存在
|
|
|
|
|
+ if not agent_manager.has_npc(request.npc_id):
|
|
|
|
|
+ raise HTTPException(status_code=404, detail=f"NPC {request.npc_id} 不存在")
|
|
|
|
|
+
|
|
|
|
|
+ # 2. 检查NPC是否忙碌
|
|
|
|
|
+ if state_manager.is_npc_busy(request.npc_id):
|
|
|
|
|
+ raise HTTPException(status_code=409, detail=f"NPC {request.npc_id} 正在与其他玩家对话")
|
|
|
|
|
+
|
|
|
|
|
+ # 3. 标记NPC为忙碌状态
|
|
|
|
|
+ state_manager.set_npc_busy(request.npc_id, True)
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ # 4. 获取当前好感度
|
|
|
|
|
+ affinity_info = relationship_manager.get_affinity(
|
|
|
|
|
+ request.npc_id,
|
|
|
|
|
+ request.player_name
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 5. 调用Agent生成回复
|
|
|
|
|
+ agent = agent_manager.get_agent(request.npc_id, affinity_info["level"])
|
|
|
|
|
+ reply = agent.run(request.player_message)
|
|
|
|
|
+
|
|
|
|
|
+ # 6. 更新好感度
|
|
|
|
|
+ new_affinity = relationship_manager.update_affinity(
|
|
|
|
|
+ request.npc_id,
|
|
|
|
|
+ request.player_name,
|
|
|
|
|
+ request.player_message,
|
|
|
|
|
+ reply
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 7. 记录日志
|
|
|
|
|
+ dialogue_logger.log_dialogue(
|
|
|
|
|
+ npc_id=request.npc_id,
|
|
|
|
|
+ player_name=request.player_name,
|
|
|
|
|
+ player_message=request.player_message,
|
|
|
|
|
+ npc_reply=reply,
|
|
|
|
|
+ affinity_info=new_affinity
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 8. 返回回复
|
|
|
|
|
+ return DialogueResponse(
|
|
|
|
|
+ npc_reply=reply,
|
|
|
|
|
+ affinity_level=new_affinity["level"],
|
|
|
|
|
+ affinity_score=new_affinity["score"]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ dialogue_logger.log_error(f"对话处理失败: {str(e)}")
|
|
|
|
|
+ raise HTTPException(status_code=500, detail=f"对话处理失败: {str(e)}")
|
|
|
|
|
+
|
|
|
|
|
+ finally:
|
|
|
|
|
+ # 9. 释放NPC状态
|
|
|
|
|
+ state_manager.set_npc_busy(request.npc_id, False)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+<strong>好感度查询</strong>
|
|
|
|
|
+
|
|
|
|
|
+这个API允许查询玩家与NPC的好感度:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from models import AffinityInfo
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/affinity/{npc_id}/{player_name}", response_model=AffinityInfo)
|
|
|
|
|
+async def get_affinity(npc_id: str, player_name: str):
|
|
|
|
|
+ """获取玩家与NPC的好感度"""
|
|
|
|
|
+ if not agent_manager.has_npc(npc_id):
|
|
|
|
|
+ raise HTTPException(status_code=404, detail=f"NPC {npc_id} 不存在")
|
|
|
|
|
+
|
|
|
|
|
+ affinity = relationship_manager.get_affinity(npc_id, player_name)
|
|
|
|
|
+ return affinity
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+API路由的调用流程如图15.11所示:
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-11.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.11 API调用流程</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+### 15.4.3 状态管理与日志系统
|
|
|
|
|
+
|
|
|
|
|
+<strong>状态管理器</strong>
|
|
|
|
|
+
|
|
|
|
|
+状态管理器负责跟踪每个NPC的当前状态,包括位置、是否忙碌、当前动作等。这对于防止并发问题很重要,比如避免一个NPC同时与多个玩家对话。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# state_manager.py
|
|
|
|
|
+from typing import Dict, List, Optional
|
|
|
|
|
+from datetime import datetime
|
|
|
|
|
+
|
|
|
|
|
+class StateManager:
|
|
|
|
|
+ """NPC状态管理器"""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.npc_states: Dict[str, dict] = {}
|
|
|
|
|
+
|
|
|
|
|
+ def initialize_npcs(self):
|
|
|
|
|
+ """初始化NPC状态"""
|
|
|
|
|
+ npcs = [
|
|
|
|
|
+ {
|
|
|
|
|
+ "npc_id": "zhang_san",
|
|
|
|
|
+ "name": "张三",
|
|
|
|
|
+ "role": "Python工程师",
|
|
|
|
|
+ "position": {"x": 300, "y": 200}
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "npc_id": "li_si",
|
|
|
|
|
+ "name": "李四",
|
|
|
|
|
+ "role": "产品经理",
|
|
|
|
|
+ "position": {"x": 500, "y": 200}
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "npc_id": "wang_wu",
|
|
|
|
|
+ "name": "王五",
|
|
|
|
|
+ "role": "UI设计师",
|
|
|
|
|
+ "position": {"x": 700, "y": 200}
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ for npc in npcs:
|
|
|
|
|
+ self.npc_states[npc["npc_id"]] = {
|
|
|
|
|
+ **npc,
|
|
|
|
|
+ "is_busy": False,
|
|
|
|
|
+ "current_action": "idle",
|
|
|
|
|
+ "last_interaction": None
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def get_npc_state(self, npc_id: str) -> Optional[dict]:
|
|
|
|
|
+ """获取NPC状态"""
|
|
|
|
|
+ return self.npc_states.get(npc_id)
|
|
|
|
|
+
|
|
|
|
|
+ def get_all_npc_states(self) -> List[dict]:
|
|
|
|
|
+ """获取所有NPC状态"""
|
|
|
|
|
+ return list(self.npc_states.values())
|
|
|
|
|
+
|
|
|
|
|
+ def is_npc_busy(self, npc_id: str) -> bool:
|
|
|
|
|
+ """检查NPC是否忙碌"""
|
|
|
|
|
+ npc = self.npc_states.get(npc_id)
|
|
|
|
|
+ return npc["is_busy"] if npc else False
|
|
|
|
|
+
|
|
|
|
|
+ def set_npc_busy(self, npc_id: str, busy: bool):
|
|
|
|
|
+ """设置NPC忙碌状态"""
|
|
|
|
|
+ if npc_id in self.npc_states:
|
|
|
|
|
+ self.npc_states[npc_id]["is_busy"] = busy
|
|
|
|
|
+ if busy:
|
|
|
|
|
+ self.npc_states[npc_id]["last_interaction"] = datetime.now().isoformat()
|
|
|
|
|
+
|
|
|
|
|
+ def get_npc_count(self) -> int:
|
|
|
|
|
+ """获取NPC数量"""
|
|
|
|
|
+ return len(self.npc_states)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+<strong>日志系统</strong>
|
|
|
|
|
+
|
|
|
|
|
+日志系统实现了双输出:控制台和文件。这样既方便实时查看,又能保存历史记录。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# logger.py
|
|
|
|
|
+import logging
|
|
|
|
|
+from datetime import datetime
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+class DialogueLogger:
|
|
|
|
|
+ """对话日志记录器"""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self, log_dir: str = "logs"):
|
|
|
|
|
+ self.log_dir = Path(log_dir)
|
|
|
|
|
+ self.log_dir.mkdir(exist_ok=True)
|
|
|
|
|
+
|
|
|
|
|
+ # 创建日志文件名(按日期)
|
|
|
|
|
+ today = datetime.now().strftime("%Y-%m-%d")
|
|
|
|
|
+ log_file = self.log_dir / f"dialogue_{today}.log"
|
|
|
|
|
+
|
|
|
|
|
+ # 配置日志
|
|
|
|
|
+ self.logger = logging.getLogger("DialogueLogger")
|
|
|
|
|
+ self.logger.setLevel(logging.INFO)
|
|
|
|
|
+
|
|
|
|
|
+ # 控制台处理器
|
|
|
|
|
+ console_handler = logging.StreamHandler()
|
|
|
|
|
+ console_handler.setLevel(logging.INFO)
|
|
|
|
|
+ console_formatter = logging.Formatter(
|
|
|
|
|
+ '%(asctime)s - %(levelname)s - %(message)s',
|
|
|
|
|
+ datefmt='%H:%M:%S'
|
|
|
|
|
+ )
|
|
|
|
|
+ console_handler.setFormatter(console_formatter)
|
|
|
|
|
+
|
|
|
|
|
+ # 文件处理器
|
|
|
|
|
+ file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
|
|
|
|
+ file_handler.setLevel(logging.INFO)
|
|
|
|
|
+ file_formatter = logging.Formatter(
|
|
|
|
|
+ '%(asctime)s - %(levelname)s - %(message)s',
|
|
|
|
|
+ datefmt='%Y-%m-%d %H:%M:%S'
|
|
|
|
|
+ )
|
|
|
|
|
+ file_handler.setFormatter(file_formatter)
|
|
|
|
|
+
|
|
|
|
|
+ # 添加处理器
|
|
|
|
|
+ self.logger.addHandler(console_handler)
|
|
|
|
|
+ self.logger.addHandler(file_handler)
|
|
|
|
|
+
|
|
|
|
|
+ def log_dialogue(self, npc_id: str, player_name: str,
|
|
|
|
|
+ player_message: str, npc_reply: str,
|
|
|
|
|
+ affinity_info: dict):
|
|
|
|
|
+ """记录对话"""
|
|
|
|
|
+ log_message = f"""
|
|
|
|
|
+{'='*60}
|
|
|
|
|
+NPC: {npc_id}
|
|
|
|
|
+玩家: {player_name}
|
|
|
|
|
+玩家消息: {player_message}
|
|
|
|
|
+NPC回复: {npc_reply}
|
|
|
|
|
+好感度: {affinity_info['level']} ({affinity_info['score']}/100)
|
|
|
|
|
+互动次数: {affinity_info['interaction_count']}
|
|
|
|
|
+{'='*60}
|
|
|
|
|
+"""
|
|
|
|
|
+ self.logger.info(log_message)
|
|
|
|
|
+
|
|
|
|
|
+ def log_error(self, error_message: str):
|
|
|
|
|
+ """记录错误"""
|
|
|
|
|
+ self.logger.error(error_message)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这个日志系统会在控制台实时显示对话内容,同时保存到文件中。每天的日志会保存在单独的文件中,方便后续分析。
|
|
|
|
|
+
|
|
|
|
|
+### 15.4.4 理解Godot的场景系统
|
|
|
|
|
+
|
|
|
|
|
+在开始构建游戏场景之前,我们需要先理解Godot的核心概念——场景(Scene)和节点(Node)。这是Godot与其他游戏引擎最大的不同之处,也是它最强大的特性之一。
|
|
|
|
|
+
|
|
|
|
|
+<strong>什么是节点?</strong>
|
|
|
|
|
+
|
|
|
|
|
+节点是Godot中最基本的构建块。你可以把节点想象成乐高积木,每个节点都有特定的功能。比如,Sprite2D节点用于显示图片,AudioStreamPlayer节点用于播放音频,CharacterBody2D节点用于处理角色的物理移动。Godot提供了上百种不同类型的节点,每种节点都专注于做好一件事。
|
|
|
|
|
+
|
|
|
|
|
+节点之间可以形成父子关系,构成一个树状结构。父节点可以影响子节点,比如移动父节点会同时移动所有子节点,隐藏父节点会同时隐藏所有子节点。这种层级关系让我们可以轻松地组织和管理复杂的游戏对象。
|
|
|
|
|
+
|
|
|
|
|
+<strong>什么是场景?</strong>
|
|
|
|
|
+
|
|
|
|
|
+场景是一组节点的集合,保存在一个.tscn文件中。你可以把场景理解为一个"预制件"。比如,我们可以创建一个"玩家"场景,包含角色的精灵、碰撞体、音效等所有相关节点。然后在游戏中多次使用这个场景,每次使用都会创建一个独立的实例。
|
|
|
|
|
+
|
|
|
|
|
+场景的强大之处在于它的可复用性和模块化。我们可以在一个场景中实例化另一个场景,形成嵌套结构。比如,主场景可以包含玩家场景、多个NPC场景和UI场景。修改NPC场景会自动影响所有NPC实例,这大大简化了开发和维护。
|
|
|
|
|
+
|
|
|
|
|
+<strong>一个简单的例子</strong>
|
|
|
|
|
+
|
|
|
|
|
+让我们用一个简单的例子来理解场景和节点。假设我们要创建一个"玩家"场景:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+Player (CharacterBody2D) ← 根节点,负责物理移动
|
|
|
|
|
+├─ AnimatedSprite2D ← 子节点,显示角色动画
|
|
|
|
|
+├─ CollisionShape2D ← 子节点,定义碰撞形状
|
|
|
|
|
+└─ Camera2D ← 子节点,摄像机跟随玩家
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这个场景包含4个节点,形成树状结构。CharacterBody2D是根节点,其他三个是它的子节点。我们可以给每个节点添加脚本来控制它的行为,也可以给根节点添加脚本来协调所有子节点。
|
|
|
|
|
+
|
|
|
|
|
+当我们在主场景中实例化这个Player场景时,Godot会创建这整个节点树的一个副本。我们可以创建多个玩家实例,每个实例都是独立的,有自己的位置、状态和行为。
|
|
|
|
|
+
|
|
|
|
|
+<strong>场景实例化的优势</strong>
|
|
|
|
|
+
|
|
|
|
|
+在赛博小镇中,我们有三个NPC:张三、李四和王五。如果不使用场景系统,我们需要为每个NPC分别创建节点、设置属性、编写脚本,这会导致大量重复工作。而使用场景系统,我们只需要创建一个通用的NPC场景,然后实例化三次,通过脚本参数设置不同的名称和角色信息即可。
|
|
|
|
|
+
|
|
|
|
|
+这种设计的好处是:如果我们想给所有NPC添加一个新功能(比如头顶显示对话气泡),只需要修改NPC场景,所有实例都会自动获得这个功能。
|
|
|
|
|
+
|
|
|
|
|
+## 15.5 Godot游戏场景构建
|
|
|
|
|
+
|
|
|
|
|
+<strong>为什么选择Godot作为游戏引擎?</strong>
|
|
|
|
|
+
|
|
|
|
|
+在众多游戏引擎中,我们选择Godot 4.5作为前端引擎,主要基于以下几个考虑:
|
|
|
|
|
+
|
|
|
|
|
+(1)Godot在2D游戏开发上有着天然的优势</strong>。赛博小镇是一个俯视角的2D像素风格游戏,Godot的2D引擎非常成熟,提供了TileMap、AnimatedSprite2D、CharacterBody2D等专门为2D游戏设计的节点类型,开发效率远高于Unity等引擎。Godot的场景系统(Scene System)让我们可以将玩家、NPC、UI等元素封装成独立的场景,然后在主场景中实例化,这种组件化的设计非常适合我们的需求。
|
|
|
|
|
+
|
|
|
|
|
+(2)<strong>Godot是完全开源且免费的</strong>。Godot使用MIT许可证,没有任何版权费用或收入分成,这对于教学项目和开源项目非常友好。你可以自由地修改引擎源码,也可以将游戏商业化而不用担心授权问题。相比之下,Unity虽然功能强大,但在2024年引入了运行时费用政策,引发了开发者社区的广泛争议。
|
|
|
|
|
+
|
|
|
|
|
+(3)<strong>Godot的学习成本极低</strong>。Godot使用GDScript作为主要脚本语言,这是一种类似Python的动态类型语言,语法简洁易懂,学习曲线非常平缓。对于已经熟悉Python的读者来说,学习GDScript几乎没有门槛——变量声明、函数定义、控制流程等语法都与Python高度相似,你甚至可以在几小时内就上手编写游戏脚本。Godot的节点树结构也非常直观,你可以在编辑器中直观地看到场景的层级关系,这对于初学者非常友好。
|
|
|
|
|
+
|
|
|
|
|
+(4)<strong>Godot与Python后端的集成非常简单</strong>。Godot内置了HTTPRequest节点,可以轻松地与FastAPI后端进行HTTP通信。我们只需要创建一个API客户端脚本,封装所有的API调用,就可以在游戏中调用后端的AI能力。这种前后端分离的架构让我们可以独立开发和测试游戏逻辑和AI逻辑,大大提高了开发效率。
|
|
|
|
|
+
|
|
|
|
|
+当然,Godot也有一些局限性。比如,Godot的3D能力相比Unreal Engine和Unity还有差距,如果你要开发大型3D游戏,可能需要考虑其他引擎。但对于2D游戏、独立游戏和教学项目,Godot是一个非常优秀的选择。
|
|
|
|
|
+
|
|
|
|
|
+### 15.5.1 场景设计与资源组织
|
|
|
|
|
+
|
|
|
|
|
+理解了Godot的场景系统后,我们来看看赛博小镇的场景设计。整个游戏由四个核心场景组成:Main(主场景)、Player(玩家)、NPC(非玩家角色)和DialogueUI(对话界面)。每个场景都是一个独立的模块,可以单独编辑和测试,然后组合在一起形成完整的游戏。
|
|
|
|
|
+
|
|
|
|
|
+赛博小镇的场景组织采用了模块化设计。我们首先创建三个基础场景:Player(玩家)、NPC(非玩家角色)和DialogueUI(对话界面)。然后在Main(主场景)中将这些场景实例化并组合起来。特别值得注意的是,三个NPC(张三、李四、王五)都是同一个NPC场景的实例,只是通过脚本参数设置了不同的角色信息。
|
|
|
|
|
+
|
|
|
|
|
+让我们先看看四个核心场景的结构,如图15.12所示:
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-12.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.12 赛博小镇的四个核心场景</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+这个图展示了四个独立的场景及其内部结构。<strong>场景1(Main)</strong>是主场景,它包含了背景图片(Sprite2D)、玩家实例、NPCs组织节点(下面有三个NPC实例)、对话界面实例、墙体组织节点和背景音乐。注意,这里的Player、NPC_Zhang、NPC_Li、NPC_Wang和DialogueUI都是场景实例,不是普通节点。<strong>场景2(Player)</strong>定义了玩家角色的结构,包含动画、碰撞、摄像机和两个音效节点。<strong>场景3(NPC)</strong>是一个通用模板,张三、李四、王五都是这个场景的实例,包含碰撞、动画、交互区域和两个标签。<strong>场景4(DialogueUI)</strong>是一个CanvasLayer节点,包含Panel和各种UI元素。
|
|
|
|
|
+
|
|
|
|
|
+场景实例化的过程可以这样理解:我们在Godot编辑器中创建了NPC.tscn这个场景文件,定义了NPC的节点结构。然后在Main场景中,我们三次"实例化"这个NPC场景,创建了三个独立的副本,分别命名为NPC_Zhang、NPC_Li和NPC_Wang。每个副本都有自己的位置和状态,但它们共享相同的节点结构。如果我们修改NPC.tscn,比如给NPC添加一个新的音效节点,那么所有三个实例都会自动获得这个音效。
|
|
|
|
|
+
|
|
|
|
|
+在Godot中创建这些场景的步骤如下:
|
|
|
|
|
+
|
|
|
|
|
+1. <strong>创建Player场景</strong>:新建场景,选择CharacterBody2D作为根节点,添加AnimatedSprite2D、CollisionShape2D、Camera2D、InteractSound和RunningSound子节点,保存为Player.tscn。
|
|
|
|
|
+
|
|
|
|
|
+2. <strong>创建NPC场景</strong>:新建场景,选择CharacterBody2D作为根节点,添加CollisionShape2D、AnimatedSprite2D、InteractionArea(Area2D,下面有CollisionShape2D)、NameLabel和DialogueLabel子节点,保存为NPC.tscn。
|
|
|
|
|
+
|
|
|
|
|
+3. <strong>创建DialogueUI场景</strong>:新建场景,选择CanvasLayer作为根节点,添加Panel子节点,在Panel下添加NPCName、NPCTitle、DialogueText(RichTextLabel)、PlayerInput(LineEdit)、SendButton和CloseButton,保存为DialogueUI.tscn。
|
|
|
|
|
+
|
|
|
|
|
+4. <strong>创建Main场景</strong>:新建场景,选择Node2D作为根节点,添加Background(Sprite2D)作为背景图,在Background下添加小鲸鱼装饰,然后实例化Player场景,创建NPCs节点并在其下三次实例化NPC场景,实例化DialogueUI场景,创建Walls节点用于组织墙体碰撞,最后添加AudioStreamPlayer播放背景音乐。
|
|
|
|
|
+
|
|
|
|
|
+这种场景组织方式的优势在于:每个场景都是独立的,可以单独测试;NPC使用同一个场景的实例,修改一次就能影响所有NPC;场景之间通过信号通信,耦合度低,易于维护和扩展。
|
|
|
|
|
+
|
|
|
|
|
+### 15.5.2 玩家控制实现
|
|
|
|
|
+
|
|
|
|
|
+玩家角色是游戏中最重要的元素之一。我们需要实现WASD移动控制、动画切换、碰撞检测、与NPC的交互,以及音效系统。
|
|
|
|
|
+
|
|
|
|
|
+玩家场景的结构包括:一个CharacterBody2D作为根节点,负责物理移动和碰撞;一个AnimatedSprite2D显示角色动画;一个CollisionShape2D定义碰撞形状;一个Camera2D跟随玩家;两个AudioStreamPlayer分别播放交互音效和走路音效。
|
|
|
|
|
+
|
|
|
|
|
+玩家控制脚本`player.gd`实现了移动、交互和音效逻辑:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+extends CharacterBody2D
|
|
|
|
|
+
|
|
|
|
|
+# 移动速度
|
|
|
|
|
+@export var speed: float = 200.0
|
|
|
|
|
+
|
|
|
|
|
+# 当前可交互的NPC
|
|
|
|
|
+var nearby_npc: Node = null
|
|
|
|
|
+
|
|
|
|
|
+# 交互状态(交互时禁用移动)
|
|
|
|
|
+var is_interacting: bool = false
|
|
|
|
|
+
|
|
|
|
|
+# 节点引用
|
|
|
|
|
+@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
|
|
|
|
|
+@onready var camera: Camera2D = $Camera2D
|
|
|
|
|
+
|
|
|
|
|
+# 音效引用
|
|
|
|
|
+@onready var interact_sound: AudioStreamPlayer = null
|
|
|
|
|
+@onready var running_sound: AudioStreamPlayer = null
|
|
|
|
|
+
|
|
|
|
|
+# 走路音效状态
|
|
|
|
|
+var is_playing_running_sound: bool = false
|
|
|
|
|
+
|
|
|
|
|
+func _ready():
|
|
|
|
|
+ # 添加到player组(重要!NPC需要通过这个组来识别玩家)
|
|
|
|
|
+ add_to_group("player")
|
|
|
|
|
+
|
|
|
|
|
+ # 获取音效节点(可选,如果不存在也不会报错)
|
|
|
|
|
+ interact_sound = get_node_or_null("InteractSound")
|
|
|
|
|
+ running_sound = get_node_or_null("RunningSound")
|
|
|
|
|
+
|
|
|
|
|
+ # 启用相机
|
|
|
|
|
+ camera.enabled = true
|
|
|
|
|
+
|
|
|
|
|
+ # 播放默认动画
|
|
|
|
|
+ if animated_sprite.sprite_frames != null and animated_sprite.sprite_frames.has_animation("idle"):
|
|
|
|
|
+ animated_sprite.play("idle")
|
|
|
|
|
+
|
|
|
|
|
+func _physics_process(_delta: float):
|
|
|
|
|
+ # 如果正在交互,禁用移动
|
|
|
|
|
+ if is_interacting:
|
|
|
|
|
+ velocity = Vector2.ZERO
|
|
|
|
|
+ move_and_slide()
|
|
|
|
|
+ # 播放idle动画
|
|
|
|
|
+ if animated_sprite.sprite_frames != null and animated_sprite.sprite_frames.has_animation("idle"):
|
|
|
|
|
+ animated_sprite.play("idle")
|
|
|
|
|
+ # 停止走路音效
|
|
|
|
|
+ stop_running_sound()
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # 获取输入方向
|
|
|
|
|
+ var input_direction = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
|
|
|
|
|
+
|
|
|
|
|
+ # 设置速度
|
|
|
|
|
+ velocity = input_direction * speed
|
|
|
|
|
+
|
|
|
|
|
+ # 移动
|
|
|
|
|
+ move_and_slide()
|
|
|
|
|
+
|
|
|
|
|
+ # 更新动画和朝向
|
|
|
|
|
+ update_animation(input_direction)
|
|
|
|
|
+
|
|
|
|
|
+ # 更新走路音效
|
|
|
|
|
+ update_running_sound(input_direction)
|
|
|
|
|
+
|
|
|
|
|
+func update_animation(direction: Vector2):
|
|
|
|
|
+ """更新角色动画(支持4方向)"""
|
|
|
|
|
+ if animated_sprite.sprite_frames == null:
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # 根据移动方向播放动画
|
|
|
|
|
+ if direction.length() > 0:
|
|
|
|
|
+ # 移动中 - 判断主要方向
|
|
|
|
|
+ if abs(direction.x) > abs(direction.y):
|
|
|
|
|
+ # 左右移动
|
|
|
|
|
+ if direction.x > 0:
|
|
|
|
|
+ # 向右
|
|
|
|
|
+ if animated_sprite.sprite_frames.has_animation("walk_right"):
|
|
|
|
|
+ animated_sprite.play("walk_right")
|
|
|
|
|
+ animated_sprite.flip_h = false
|
|
|
|
|
+ elif animated_sprite.sprite_frames.has_animation("walk"):
|
|
|
|
|
+ animated_sprite.play("walk")
|
|
|
|
|
+ animated_sprite.flip_h = false
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 向左
|
|
|
|
|
+ if animated_sprite.sprite_frames.has_animation("walk_left"):
|
|
|
|
|
+ animated_sprite.play("walk_left")
|
|
|
|
|
+ animated_sprite.flip_h = false
|
|
|
|
|
+ elif animated_sprite.sprite_frames.has_animation("walk"):
|
|
|
|
|
+ animated_sprite.play("walk")
|
|
|
|
|
+ animated_sprite.flip_h = true
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 上下移动
|
|
|
|
|
+ if direction.y > 0:
|
|
|
|
|
+ # 向下
|
|
|
|
|
+ if animated_sprite.sprite_frames.has_animation("walk_down"):
|
|
|
|
|
+ animated_sprite.play("walk_down")
|
|
|
|
|
+ elif animated_sprite.sprite_frames.has_animation("walk"):
|
|
|
|
|
+ animated_sprite.play("walk")
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 向上
|
|
|
|
|
+ if animated_sprite.sprite_frames.has_animation("walk_up"):
|
|
|
|
|
+ animated_sprite.play("walk_up")
|
|
|
|
|
+ elif animated_sprite.sprite_frames.has_animation("walk"):
|
|
|
|
|
+ animated_sprite.play("walk")
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 静止
|
|
|
|
|
+ if animated_sprite.sprite_frames.has_animation("idle"):
|
|
|
|
|
+ animated_sprite.play("idle")
|
|
|
|
|
+
|
|
|
|
|
+func _input(event: InputEvent):
|
|
|
|
|
+ # 按E键与NPC交互
|
|
|
|
|
+ if event is InputEventKey:
|
|
|
|
|
+ if event.pressed and not event.echo:
|
|
|
|
|
+ if event.keycode == KEY_E or event.keycode == KEY_ENTER:
|
|
|
|
|
+ if nearby_npc != null:
|
|
|
|
|
+ interact_with_npc()
|
|
|
|
|
+
|
|
|
|
|
+func interact_with_npc():
|
|
|
|
|
+ """与附近的NPC交互"""
|
|
|
|
|
+ if nearby_npc != null:
|
|
|
|
|
+ # 播放交互音效
|
|
|
|
|
+ if interact_sound:
|
|
|
|
|
+ interact_sound.play()
|
|
|
|
|
+
|
|
|
|
|
+ # 发送信号给对话系统
|
|
|
|
|
+ get_tree().call_group("dialogue_system", "start_dialogue", nearby_npc.npc_name)
|
|
|
|
|
+
|
|
|
|
|
+func set_nearby_npc(npc: Node):
|
|
|
|
|
+ """设置附近的NPC"""
|
|
|
|
|
+ nearby_npc = npc
|
|
|
|
|
+
|
|
|
|
|
+func set_interacting(interacting: bool):
|
|
|
|
|
+ """设置交互状态"""
|
|
|
|
|
+ is_interacting = interacting
|
|
|
|
|
+ if interacting:
|
|
|
|
|
+ # 停止走路音效
|
|
|
|
|
+ stop_running_sound()
|
|
|
|
|
+
|
|
|
|
|
+func update_running_sound(direction: Vector2):
|
|
|
|
|
+ """更新走路音效"""
|
|
|
|
|
+ if running_sound == null:
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # 如果正在移动
|
|
|
|
|
+ if direction.length() > 0:
|
|
|
|
|
+ # 如果音效还没播放,开始播放
|
|
|
|
|
+ if not is_playing_running_sound:
|
|
|
|
|
+ running_sound.play()
|
|
|
|
|
+ is_playing_running_sound = true
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 如果停止移动,停止音效
|
|
|
|
|
+ stop_running_sound()
|
|
|
|
|
+
|
|
|
|
|
+func stop_running_sound():
|
|
|
|
|
+ """停止走路音效"""
|
|
|
|
|
+ if running_sound and is_playing_running_sound:
|
|
|
|
|
+ running_sound.stop()
|
|
|
|
|
+ is_playing_running_sound = false
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这个脚本实现了完整的玩家控制。玩家使用WASD键(或方向键)移动,角色会根据移动方向播放相应的4方向动画(walk_up/down/left/right)。当玩家靠近NPC时,NPC会调用`set_nearby_npc()`设置自己为可交互对象,玩家按E键即可触发交互。交互时会播放音效,并通过`call_group()`通知对话系统开始对话。对话期间,`set_interacting(true)`会禁用玩家移动,对话结束后恢复移动。走路音效会在玩家移动时自动播放,停止时自动停止。
|
|
|
|
|
+
|
|
|
|
|
+### 15.5.3 NPC行为与交互
|
|
|
|
|
+
|
|
|
|
|
+NPC需要实现三个核心功能:在场景中随机巡逻游走、响应玩家的交互、显示对话气泡。我们使用Area2D来检测玩家是否靠近NPC,当玩家进入交互范围时通知玩家,玩家按E键即可开始对话。
|
|
|
|
|
+
|
|
|
|
|
+NPC场景的结构包括:CharacterBody2D作为根节点;CollisionShape2D定义NPC的碰撞形状;AnimatedSprite2D显示NPC动画;InteractionArea(Area2D)检测玩家进入交互范围,下面有CollisionShape2D定义交互范围;NameLabel显示NPC名字;DialogueLabel显示对话气泡。
|
|
|
|
|
+
|
|
|
|
|
+NPC脚本`npc.gd`实现了巡逻、交互和对话气泡逻辑:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+extends CharacterBody2D
|
|
|
|
|
+
|
|
|
|
|
+# NPC信息
|
|
|
|
|
+@export var npc_name: String = "张三"
|
|
|
|
|
+@export var npc_title: String = "Python工程师"
|
|
|
|
|
+
|
|
|
|
|
+# NPC外观配置
|
|
|
|
|
+@export var sprite_frames: SpriteFrames = null # 自定义精灵帧资源
|
|
|
|
|
+
|
|
|
|
|
+# NPC移动配置
|
|
|
|
|
+@export var move_speed: float = 50.0 # 移动速度
|
|
|
|
|
+@export var wander_enabled: bool = true # 是否启用巡逻
|
|
|
|
|
+@export var wander_range: float = 200.0 # 巡逻范围
|
|
|
|
|
+@export var wander_interval_min: float = 3.0 # 最小巡逻间隔(秒)
|
|
|
|
|
+@export var wander_interval_max: float = 8.0 # 最大巡逻间隔(秒)
|
|
|
|
|
+
|
|
|
|
|
+# 当前对话内容(从后端获取)
|
|
|
|
|
+var current_dialogue: String = ""
|
|
|
|
|
+
|
|
|
|
|
+# 节点引用
|
|
|
|
|
+@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
|
|
|
|
|
+@onready var interaction_area: Area2D = $InteractionArea
|
|
|
|
|
+@onready var name_label: Label = $NameLabel
|
|
|
|
|
+@onready var dialogue_label: Label = $DialogueLabel
|
|
|
|
|
+
|
|
|
|
|
+# 玩家引用
|
|
|
|
|
+var player: Node = null
|
|
|
|
|
+
|
|
|
|
|
+# 巡逻相关变量
|
|
|
|
|
+var wander_target: Vector2 = Vector2.ZERO # 巡逻目标位置
|
|
|
|
|
+var wander_timer: float = 0.0 # 巡逻计时器
|
|
|
|
|
+var is_wandering: bool = false # 是否正在巡逻
|
|
|
|
|
+var is_interacting: bool = false # 是否正在与玩家交互
|
|
|
|
|
+var spawn_position: Vector2 = Vector2.ZERO # 出生位置
|
|
|
|
|
+
|
|
|
|
|
+func _ready():
|
|
|
|
|
+ # 添加到npcs组
|
|
|
|
|
+ add_to_group("npcs")
|
|
|
|
|
+
|
|
|
|
|
+ # 设置NPC名字
|
|
|
|
|
+ name_label.text = npc_name
|
|
|
|
|
+
|
|
|
|
|
+ # 连接交互区域信号
|
|
|
|
|
+ interaction_area.body_entered.connect(_on_body_entered)
|
|
|
|
|
+ interaction_area.body_exited.connect(_on_body_exited)
|
|
|
|
|
+
|
|
|
|
|
+ # 初始化对话标签
|
|
|
|
|
+ dialogue_label.text = ""
|
|
|
|
|
+ dialogue_label.visible = false
|
|
|
|
|
+
|
|
|
|
|
+ # 设置自定义精灵帧(如果有)
|
|
|
|
|
+ if sprite_frames != null:
|
|
|
|
|
+ animated_sprite.sprite_frames = sprite_frames
|
|
|
|
|
+
|
|
|
|
|
+ # 播放默认动画
|
|
|
|
|
+ if animated_sprite.sprite_frames != null and animated_sprite.sprite_frames.has_animation("idle"):
|
|
|
|
|
+ animated_sprite.play("idle")
|
|
|
|
|
+
|
|
|
|
|
+ # 记录出生位置
|
|
|
|
|
+ spawn_position = global_position
|
|
|
|
|
+
|
|
|
|
|
+ # 初始化巡逻计时器
|
|
|
|
|
+ if wander_enabled:
|
|
|
|
|
+ wander_timer = randf_range(wander_interval_min, wander_interval_max)
|
|
|
|
|
+ choose_new_wander_target()
|
|
|
|
|
+
|
|
|
|
|
+func _on_body_entered(body: Node2D):
|
|
|
|
|
+ """玩家进入交互范围"""
|
|
|
|
|
+ if body.is_in_group("player"):
|
|
|
|
|
+ player = body
|
|
|
|
|
+
|
|
|
|
|
+ if player.has_method("set_nearby_npc"):
|
|
|
|
|
+ player.set_nearby_npc(self)
|
|
|
|
|
+
|
|
|
|
|
+func _on_body_exited(body: Node2D):
|
|
|
|
|
+ """玩家离开交互范围"""
|
|
|
|
|
+ if body.is_in_group("player"):
|
|
|
|
|
+ if player != null and player.has_method("set_nearby_npc"):
|
|
|
|
|
+ player.set_nearby_npc(null)
|
|
|
|
|
+ player = null
|
|
|
|
|
+
|
|
|
|
|
+func update_dialogue(dialogue: String):
|
|
|
|
|
+ """更新NPC对话内容"""
|
|
|
|
|
+ current_dialogue = dialogue
|
|
|
|
|
+ dialogue_label.text = dialogue
|
|
|
|
|
+ dialogue_label.visible = true
|
|
|
|
|
+
|
|
|
|
|
+ # 10秒后隐藏对话
|
|
|
|
|
+ await get_tree().create_timer(10.0).timeout
|
|
|
|
|
+ dialogue_label.visible = false
|
|
|
|
|
+
|
|
|
|
|
+func _physics_process(delta: float):
|
|
|
|
|
+ """物理更新 - 处理移动"""
|
|
|
|
|
+ # 如果正在与玩家交互,停止移动
|
|
|
|
|
+ if is_interacting:
|
|
|
|
|
+ velocity = Vector2.ZERO
|
|
|
|
|
+ move_and_slide()
|
|
|
|
|
+ # 播放idle动画
|
|
|
|
|
+ if animated_sprite.sprite_frames != null and animated_sprite.sprite_frames.has_animation("idle"):
|
|
|
|
|
+ animated_sprite.play("idle")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # 如果未启用巡逻,不移动
|
|
|
|
|
+ if not wander_enabled:
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # 更新巡逻计时器
|
|
|
|
|
+ wander_timer -= delta
|
|
|
|
|
+
|
|
|
|
|
+ # 如果计时器结束,选择新目标并开始移动
|
|
|
|
|
+ if wander_timer <= 0:
|
|
|
|
|
+ choose_new_wander_target()
|
|
|
|
|
+ wander_timer = randf_range(wander_interval_min, wander_interval_max)
|
|
|
|
|
+
|
|
|
|
|
+ # 如果正在巡逻,移动到目标
|
|
|
|
|
+ if is_wandering:
|
|
|
|
|
+ # 检查是否到达目标
|
|
|
|
|
+ if global_position.distance_to(wander_target) < 10:
|
|
|
|
|
+ # 到达目标,停止移动
|
|
|
|
|
+ is_wandering = false
|
|
|
|
|
+ velocity = Vector2.ZERO
|
|
|
|
|
+ move_and_slide()
|
|
|
|
|
+ # 播放idle动画
|
|
|
|
|
+ if animated_sprite.sprite_frames != null and animated_sprite.sprite_frames.has_animation("idle"):
|
|
|
|
|
+ animated_sprite.play("idle")
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 继续移动到目标
|
|
|
|
|
+ var direction = (wander_target - global_position).normalized()
|
|
|
|
|
+ velocity = direction * move_speed
|
|
|
|
|
+ move_and_slide()
|
|
|
|
|
+ # 更新动画
|
|
|
|
|
+ update_animation(direction)
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 停止移动
|
|
|
|
|
+ velocity = Vector2.ZERO
|
|
|
|
|
+ move_and_slide()
|
|
|
|
|
+ # 播放idle动画
|
|
|
|
|
+ if animated_sprite.sprite_frames != null and animated_sprite.sprite_frames.has_animation("idle"):
|
|
|
|
|
+ animated_sprite.play("idle")
|
|
|
|
|
+
|
|
|
|
|
+func choose_new_wander_target():
|
|
|
|
|
+ """选择新的巡逻目标"""
|
|
|
|
|
+ # 在出生位置附近随机选择一个点
|
|
|
|
|
+ var offset = Vector2(
|
|
|
|
|
+ randf_range(-wander_range, wander_range),
|
|
|
|
|
+ randf_range(-wander_range, wander_range)
|
|
|
|
|
+ )
|
|
|
|
|
+ wander_target = spawn_position + offset
|
|
|
|
|
+ is_wandering = true
|
|
|
|
|
+
|
|
|
|
|
+func update_animation(direction: Vector2):
|
|
|
|
|
+ """更新动画"""
|
|
|
|
|
+ if animated_sprite.sprite_frames == null:
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ if direction.length() > 0:
|
|
|
|
|
+ # 移动动画
|
|
|
|
|
+ if abs(direction.x) > abs(direction.y):
|
|
|
|
|
+ # 左右移动
|
|
|
|
|
+ if direction.x > 0:
|
|
|
|
|
+ if animated_sprite.sprite_frames.has_animation("walk_right"):
|
|
|
|
|
+ animated_sprite.play("walk_right")
|
|
|
|
|
+ elif animated_sprite.sprite_frames.has_animation("walk"):
|
|
|
|
|
+ animated_sprite.play("walk")
|
|
|
|
|
+ animated_sprite.flip_h = false
|
|
|
|
|
+ else:
|
|
|
|
|
+ if animated_sprite.sprite_frames.has_animation("walk_left"):
|
|
|
|
|
+ animated_sprite.play("walk_left")
|
|
|
|
|
+ elif animated_sprite.sprite_frames.has_animation("walk"):
|
|
|
|
|
+ animated_sprite.play("walk")
|
|
|
|
|
+ animated_sprite.flip_h = true
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 上下移动
|
|
|
|
|
+ if direction.y > 0:
|
|
|
|
|
+ if animated_sprite.sprite_frames.has_animation("walk_down"):
|
|
|
|
|
+ animated_sprite.play("walk_down")
|
|
|
|
|
+ elif animated_sprite.sprite_frames.has_animation("walk"):
|
|
|
|
|
+ animated_sprite.play("walk")
|
|
|
|
|
+ else:
|
|
|
|
|
+ if animated_sprite.sprite_frames.has_animation("walk_up"):
|
|
|
|
|
+ animated_sprite.play("walk_up")
|
|
|
|
|
+ elif animated_sprite.sprite_frames.has_animation("walk"):
|
|
|
|
|
+ animated_sprite.play("walk")
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 静止动画
|
|
|
|
|
+ if animated_sprite.sprite_frames.has_animation("idle"):
|
|
|
|
|
+ animated_sprite.play("idle")
|
|
|
|
|
+
|
|
|
|
|
+func set_interacting(interacting: bool):
|
|
|
|
|
+ """设置交互状态"""
|
|
|
|
|
+ is_interacting = interacting
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这个脚本实现了NPC的完整行为。NPC会在出生位置附近的`wander_range`范围内随机巡逻,每隔`wander_interval_min`到`wander_interval_max`秒选择一个新的目标点并移动过去。移动时会播放4方向动画(walk_up/down/left/right),到达目标后停止并播放idle动画。当玩家进入InteractionArea时,NPC会调用玩家的`set_nearby_npc(self)`方法,将自己设置为可交互对象。玩家按E键后,对话系统会调用NPC的`set_interacting(true)`方法,NPC停止移动。对话结束后调用`set_interacting(false)`,NPC恢复巡逻。主场景会定时调用`update_dialogue()`方法更新NPC的对话气泡,显示NPC之间的自主对话内容。
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+## 15.6 前后端通信实现
|
|
|
|
|
+
|
|
|
|
|
+### 15.6.1 API客户端封装
|
|
|
|
|
+
|
|
|
|
|
+Godot前端需要与FastAPI后端进行HTTP通信。我们创建一个API客户端脚本`api_client.gd`,封装所有的API调用,并将其设置为AutoLoad(自动加载)单例,让其他脚本可以方便地使用。
|
|
|
|
|
+
|
|
|
|
|
+API客户端使用Godot的HTTPRequest节点来发送HTTP请求。HTTPRequest是一个异步节点,发送请求后不会阻塞游戏,而是通过信号通知请求完成。这样可以保证游戏的流畅性,即使网络延迟较高也不会卡顿。我们使用信号机制来通知其他脚本API响应,而不是使用await,这样可以让多个脚本同时监听同一个API响应。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# api_client.gd
|
|
|
|
|
+extends Node
|
|
|
|
|
+
|
|
|
|
|
+# 信号定义
|
|
|
|
|
+signal chat_response_received(npc_name: String, message: String)
|
|
|
|
|
+signal chat_error(error_message: String)
|
|
|
|
|
+signal npc_status_received(dialogues: Dictionary)
|
|
|
|
|
+signal npc_list_received(npcs: Array)
|
|
|
|
|
+
|
|
|
|
|
+# HTTP请求节点
|
|
|
|
|
+var http_chat: HTTPRequest
|
|
|
|
|
+var http_status: HTTPRequest
|
|
|
|
|
+var http_npcs: HTTPRequest
|
|
|
|
|
+
|
|
|
|
|
+func _ready():
|
|
|
|
|
+ # 创建HTTP请求节点
|
|
|
|
|
+ http_chat = HTTPRequest.new()
|
|
|
|
|
+ http_status = HTTPRequest.new()
|
|
|
|
|
+ http_npcs = HTTPRequest.new()
|
|
|
|
|
+
|
|
|
|
|
+ add_child(http_chat)
|
|
|
|
|
+ add_child(http_status)
|
|
|
|
|
+ add_child(http_npcs)
|
|
|
|
|
+
|
|
|
|
|
+ # 连接信号
|
|
|
|
|
+ http_chat.request_completed.connect(_on_chat_request_completed)
|
|
|
|
|
+ http_status.request_completed.connect(_on_status_request_completed)
|
|
|
|
|
+ http_npcs.request_completed.connect(_on_npcs_request_completed)
|
|
|
|
|
+
|
|
|
|
|
+# ==================== 对话API ====================
|
|
|
|
|
+func send_chat(npc_name: String, message: String) -> void:
|
|
|
|
|
+ """发送对话请求"""
|
|
|
|
|
+ var data = {
|
|
|
|
|
+ "npc_name": npc_name,
|
|
|
|
|
+ "message": message
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var json_string = JSON.stringify(data)
|
|
|
|
|
+ var headers = ["Content-Type: application/json"]
|
|
|
|
|
+
|
|
|
|
|
+ var error = http_chat.request(
|
|
|
|
|
+ Config.API_CHAT,
|
|
|
|
|
+ headers,
|
|
|
|
|
+ HTTPClient.METHOD_POST,
|
|
|
|
|
+ json_string
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if error != OK:
|
|
|
|
|
+ print("[ERROR] 发送对话请求失败: ", error)
|
|
|
|
|
+ chat_error.emit("网络请求失败")
|
|
|
|
|
+
|
|
|
|
|
+func _on_chat_request_completed(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
|
|
|
+ """处理对话响应"""
|
|
|
|
|
+ if response_code != 200:
|
|
|
|
|
+ print("[ERROR] 对话请求失败: HTTP ", response_code)
|
|
|
|
|
+ chat_error.emit("服务器错误: " + str(response_code))
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ var json = JSON.new()
|
|
|
|
|
+ var parse_result = json.parse(body.get_string_from_utf8())
|
|
|
|
|
+
|
|
|
|
|
+ if parse_result != OK:
|
|
|
|
|
+ print("[ERROR] 解析响应失败")
|
|
|
|
|
+ chat_error.emit("响应解析失败")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ var response = json.data
|
|
|
|
|
+
|
|
|
|
|
+ if response.has("success") and response["success"]:
|
|
|
|
|
+ var npc_name = response["npc_name"]
|
|
|
|
|
+ var msg = response["message"]
|
|
|
|
|
+ print("[INFO] 收到NPC回复: ", npc_name, " -> ", msg)
|
|
|
|
|
+ chat_response_received.emit(npc_name, msg)
|
|
|
|
|
+ else:
|
|
|
|
|
+ chat_error.emit("对话失败")
|
|
|
|
|
+
|
|
|
|
|
+# ==================== NPC状态API ====================
|
|
|
|
|
+func get_npc_status() -> void:
|
|
|
|
|
+ """获取NPC状态"""
|
|
|
|
|
+ # 检查是否正在处理请求
|
|
|
|
|
+ if http_status.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
|
|
|
|
+ print("[WARN] NPC状态请求正在处理中,跳过本次请求")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ var error = http_status.request(Config.API_NPC_STATUS)
|
|
|
|
|
+
|
|
|
|
|
+ if error != OK:
|
|
|
|
|
+ print("[ERROR] 获取NPC状态失败: ", error)
|
|
|
|
|
+
|
|
|
|
|
+func _on_status_request_completed(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
|
|
|
+ """处理NPC状态响应"""
|
|
|
|
|
+ if response_code != 200:
|
|
|
|
|
+ print("[ERROR] NPC状态请求失败: HTTP ", response_code)
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ var json = JSON.new()
|
|
|
|
|
+ var parse_result = json.parse(body.get_string_from_utf8())
|
|
|
|
|
+
|
|
|
|
|
+ if parse_result != OK:
|
|
|
|
|
+ print("[ERROR] 解析NPC状态失败")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ var response = json.data
|
|
|
|
|
+
|
|
|
|
|
+ if response.has("dialogues"):
|
|
|
|
|
+ var dialogues = response["dialogues"]
|
|
|
|
|
+ print("[INFO] 收到NPC状态更新: ", dialogues.size(), "个NPC")
|
|
|
|
|
+ npc_status_received.emit(dialogues)
|
|
|
|
|
+
|
|
|
|
|
+# ==================== NPC列表API ====================
|
|
|
|
|
+func get_npc_list() -> void:
|
|
|
|
|
+ """获取NPC列表"""
|
|
|
|
|
+ var error = http_npcs.request(Config.API_NPCS)
|
|
|
|
|
+
|
|
|
|
|
+ if error != OK:
|
|
|
|
|
+ print("[ERROR] 获取NPC列表失败: ", error)
|
|
|
|
|
+
|
|
|
|
|
+func _on_npcs_request_completed(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
|
|
|
+ """处理NPC列表响应"""
|
|
|
|
|
+ if response_code != 200:
|
|
|
|
|
+ print("[ERROR] NPC列表请求失败: HTTP ", response_code)
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ var json = JSON.new()
|
|
|
|
|
+ var parse_result = json.parse(body.get_string_from_utf8())
|
|
|
|
|
+
|
|
|
|
|
+ if parse_result != OK:
|
|
|
|
|
+ print("[ERROR] 解析NPC列表失败")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ var response = json.data
|
|
|
|
|
+
|
|
|
|
|
+ if response.has("npcs"):
|
|
|
|
|
+ var npcs = response["npcs"]
|
|
|
|
|
+ print("[INFO] 收到NPC列表: ", npcs.size(), "个NPC")
|
|
|
|
|
+ npc_list_received.emit(npcs)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这个API客户端封装了三个核心功能:发送对话请求(`send_chat`)、获取NPC状态(`get_npc_status`)和获取NPC列表(`get_npc_list`)。所有的HTTP请求都是异步的,通过信号通知响应结果。我们为每个API创建了独立的HTTPRequest节点,这样可以同时发送多个请求而不会互相干扰。API的URL从Config单例中获取,方便统一管理。对话系统监听`chat_response_received`信号来接收NPC回复,主场景监听`npc_status_received`信号来更新NPC对话气泡。
|
|
|
|
|
+
|
|
|
|
|
+### 15.6.2 对话UI实现
|
|
|
|
|
+
|
|
|
|
|
+对话UI是玩家与NPC交互的界面。我们需要设计一个简洁美观的对话框,包含NPC名称、职位、对话内容显示、输入框和按钮。
|
|
|
|
|
+
|
|
|
|
|
+对话UI的结构如图15.13所示:
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-13.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.13 对话UI结构</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+对话UI的设计非常简洁。DialogueUI是一个CanvasLayer节点,这意味着它会始终显示在游戏画面的最上层,不会被其他游戏对象遮挡。Panel是对话框的背景,锚定在屏幕底部。Panel下直接放置了6个UI元素:NPCName显示NPC的名字,NPCTitle显示职位,DialogueText使用RichTextLabel显示对话内容(支持富文本格式),PlayerInput是一个LineEdit用于玩家输入,SendButton和CloseButton分别用于发送消息和关闭对话框。
|
|
|
|
|
+
|
|
|
|
|
+对话UI脚本`dialogue_ui.gd`实现了对话界面的逻辑:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# dialogue_ui.gd
|
|
|
|
|
+extends CanvasLayer
|
|
|
|
|
+
|
|
|
|
|
+# UI节点引用
|
|
|
|
|
+@onready var panel = $Panel
|
|
|
|
|
+@onready var npc_name_label = $Panel/NPCName
|
|
|
|
|
+@onready var npc_title_label = $Panel/NPCTitle
|
|
|
|
|
+@onready var dialogue_text = $Panel/DialogueText
|
|
|
|
|
+@onready var input_field = $Panel/PlayerInput
|
|
|
|
|
+@onready var send_button = $Panel/SendButton
|
|
|
|
|
+@onready var close_button = $Panel/CloseButton
|
|
|
|
|
+
|
|
|
|
|
+# API客户端
|
|
|
|
|
+var api_client: Node = null
|
|
|
|
|
+
|
|
|
|
|
+# 当前对话的NPC
|
|
|
|
|
+var current_npc_name: String = ""
|
|
|
|
|
+
|
|
|
|
|
+func _ready():
|
|
|
|
|
+ # 初始化时隐藏对话框
|
|
|
|
|
+ visible = false
|
|
|
|
|
+
|
|
|
|
|
+ # 连接按钮信号
|
|
|
|
|
+ send_button.pressed.connect(_on_send_button_pressed)
|
|
|
|
|
+ close_button.pressed.connect(_on_close_button_pressed)
|
|
|
|
|
+ input_field.text_submitted.connect(_on_text_submitted)
|
|
|
|
|
+
|
|
|
|
|
+ # 获取API客户端
|
|
|
|
|
+ api_client = get_node_or_null("/root/APIClient")
|
|
|
|
|
+
|
|
|
|
|
+func start_dialogue(npc_name: String):
|
|
|
|
|
+ """开始与NPC对话"""
|
|
|
|
|
+ current_npc_name = npc_name
|
|
|
|
|
+
|
|
|
|
|
+ # 设置NPC信息
|
|
|
|
|
+ npc_name_label.text = npc_name
|
|
|
|
|
+ npc_title_label.text = get_npc_title(npc_name)
|
|
|
|
|
+
|
|
|
|
|
+ # 清空对话内容
|
|
|
|
|
+ dialogue_text.clear()
|
|
|
|
|
+ dialogue_text.append_text("[color=gray]与 " + npc_name + " 的对话开始...[/color]\n")
|
|
|
|
|
+
|
|
|
|
|
+ # 清空输入框
|
|
|
|
|
+ input_field.text = ""
|
|
|
|
|
+
|
|
|
|
|
+ # 显示对话框
|
|
|
|
|
+ show_dialogue()
|
|
|
|
|
+
|
|
|
|
|
+ # 聚焦输入框
|
|
|
|
|
+ input_field.grab_focus()
|
|
|
|
|
+
|
|
|
|
|
+func show_dialogue():
|
|
|
|
|
+ """显示对话框"""
|
|
|
|
|
+ visible = true
|
|
|
|
|
+
|
|
|
|
|
+ # 通知玩家进入交互状态(禁用移动)
|
|
|
|
|
+ var player = get_tree().get_first_node_in_group("player")
|
|
|
|
|
+ if player and player.has_method("set_interacting"):
|
|
|
|
|
+ player.set_interacting(true)
|
|
|
|
|
+
|
|
|
|
|
+func hide_dialogue():
|
|
|
|
|
+ """隐藏对话框"""
|
|
|
|
|
+ visible = false
|
|
|
|
|
+ current_npc_name = ""
|
|
|
|
|
+
|
|
|
|
|
+ # 通知玩家退出交互状态(启用移动)
|
|
|
|
|
+ var player = get_tree().get_first_node_in_group("player")
|
|
|
|
|
+ if player and player.has_method("set_interacting"):
|
|
|
|
|
+ player.set_interacting(false)
|
|
|
|
|
+
|
|
|
|
|
+func _on_send_button_pressed():
|
|
|
|
|
+ """发送按钮点击"""
|
|
|
|
|
+ send_message()
|
|
|
|
|
+
|
|
|
|
|
+func _on_close_button_pressed():
|
|
|
|
|
+ """关闭按钮点击"""
|
|
|
|
|
+ hide_dialogue()
|
|
|
|
|
+
|
|
|
|
|
+func _on_text_submitted(_text: String):
|
|
|
|
|
+ """输入框回车"""
|
|
|
|
|
+ send_message()
|
|
|
|
|
+
|
|
|
|
|
+func send_message():
|
|
|
|
|
+ """发送消息"""
|
|
|
|
|
+ var message = input_field.text.strip_edges()
|
|
|
|
|
+
|
|
|
|
|
+ if message.is_empty():
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ if current_npc_name.is_empty():
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # 显示玩家消息
|
|
|
|
|
+ dialogue_text.append_text("\n[color=cyan]玩家:[/color] " + message + "\n")
|
|
|
|
|
+
|
|
|
|
|
+ # 清空输入框
|
|
|
|
|
+ input_field.text = ""
|
|
|
|
|
+
|
|
|
|
|
+ # 禁用输入
|
|
|
|
|
+ input_field.editable = false
|
|
|
|
|
+ send_button.disabled = true
|
|
|
|
|
+
|
|
|
|
|
+ # 发送API请求
|
|
|
|
|
+ if api_client:
|
|
|
|
|
+ api_client.send_chat_request(current_npc_name, message)
|
|
|
|
|
+
|
|
|
|
|
+func on_chat_response_received(npc_name: String, response: String):
|
|
|
|
|
+ """收到NPC回复"""
|
|
|
|
|
+ if npc_name == current_npc_name:
|
|
|
|
|
+ # 显示NPC回复
|
|
|
|
|
+ dialogue_text.append_text("[color=yellow]" + npc_name + ":[/color] " + response + "\n")
|
|
|
|
|
+
|
|
|
|
|
+ # 启用输入
|
|
|
|
|
+ input_field.editable = true
|
|
|
|
|
+ send_button.disabled = false
|
|
|
|
|
+ input_field.grab_focus()
|
|
|
|
|
+
|
|
|
|
|
+func get_npc_title(npc_name: String) -> String:
|
|
|
|
|
+ """获取NPC职位"""
|
|
|
|
|
+ var titles = {
|
|
|
|
|
+ "张三": "Python工程师",
|
|
|
|
|
+ "李四": "产品经理",
|
|
|
|
|
+ "王五": "UI设计师"
|
|
|
|
|
+ }
|
|
|
|
|
+ return titles.get(npc_name, "")
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+这个对话UI实现了完整的对话功能。玩家可以输入消息并发送,UI使用RichTextLabel的append_text方法显示对话内容,支持富文本格式(颜色、粗体等)。所有的API调用都是异步的,在等待响应时会禁用输入框,防止重复发送。对话框显示时会通知玩家进入交互状态,禁用移动,关闭时恢复移动。
|
|
|
|
|
+
|
|
|
|
|
+### 15.6.3 主场景整合
|
|
|
|
|
+
|
|
|
|
|
+最后,我们需要在主场景中整合所有的功能:玩家控制、NPC交互、对话UI和NPC状态更新。主场景脚本`main.gd`负责协调这些组件,并定时从后端获取NPC状态,更新NPC的对话气泡。
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+# main.gd
|
|
|
|
|
+extends Node2D
|
|
|
|
|
+
|
|
|
|
|
+# NPC节点引用
|
|
|
|
|
+@onready var npc_zhang: Node2D = $NPCs/NPC_Zhang
|
|
|
|
|
+@onready var npc_li: Node2D = $NPCs/NPC_Li
|
|
|
|
|
+@onready var npc_wang: Node2D = $NPCs/NPC_Wang
|
|
|
|
|
+
|
|
|
|
|
+# API客户端
|
|
|
|
|
+var api_client: Node = null
|
|
|
|
|
+
|
|
|
|
|
+# NPC状态更新计时器
|
|
|
|
|
+var status_update_timer: float = 0.0
|
|
|
|
|
+
|
|
|
|
|
+func _ready():
|
|
|
|
|
+ print("[INFO] 主场景初始化")
|
|
|
|
|
+
|
|
|
|
|
+ # 获取API客户端
|
|
|
|
|
+ api_client = get_node_or_null("/root/APIClient")
|
|
|
|
|
+ if api_client:
|
|
|
|
|
+ api_client.npc_status_received.connect(_on_npc_status_received)
|
|
|
|
|
+
|
|
|
|
|
+ # 立即获取一次NPC状态
|
|
|
|
|
+ api_client.get_npc_status()
|
|
|
|
|
+ else:
|
|
|
|
|
+ print("[ERROR] API客户端未找到")
|
|
|
|
|
+
|
|
|
|
|
+func _process(delta: float):
|
|
|
|
|
+ # 定时更新NPC状态
|
|
|
|
|
+ status_update_timer += delta
|
|
|
|
|
+ if status_update_timer >= Config.NPC_STATUS_UPDATE_INTERVAL:
|
|
|
|
|
+ status_update_timer = 0.0
|
|
|
|
|
+ if api_client:
|
|
|
|
|
+ api_client.get_npc_status()
|
|
|
|
|
+
|
|
|
|
|
+func _on_npc_status_received(dialogues: Dictionary):
|
|
|
|
|
+ """收到NPC状态更新"""
|
|
|
|
|
+ print("[INFO] 更新NPC状态: ", dialogues)
|
|
|
|
|
+
|
|
|
|
|
+ # 更新各个NPC的对话
|
|
|
|
|
+ for npc_name in dialogues:
|
|
|
|
|
+ var dialogue = dialogues[npc_name]
|
|
|
|
|
+ update_npc_dialogue(npc_name, dialogue)
|
|
|
|
|
+
|
|
|
|
|
+func update_npc_dialogue(npc_name: String, dialogue: String):
|
|
|
|
|
+ """更新指定NPC的对话"""
|
|
|
|
|
+ var npc_node = get_npc_node(npc_name)
|
|
|
|
|
+ if npc_node and npc_node.has_method("update_dialogue"):
|
|
|
|
|
+ npc_node.update_dialogue(dialogue)
|
|
|
|
|
+
|
|
|
|
|
+func get_npc_node(npc_name: String) -> Node2D:
|
|
|
|
|
+ """根据名字获取NPC节点"""
|
|
|
|
|
+ match npc_name:
|
|
|
|
|
+ "张三":
|
|
|
|
|
+ return npc_zhang
|
|
|
|
|
+ "李四":
|
|
|
|
|
+ return npc_li
|
|
|
|
|
+ "王五":
|
|
|
|
|
+ return npc_wang
|
|
|
|
|
+ _:
|
|
|
|
|
+ return null
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+主场景脚本的核心功能是定时从后端获取NPC状态。在`_ready()`中,我们获取APIClient单例的引用,并连接`npc_status_received`信号。然后立即调用`get_npc_status()`获取一次NPC状态。在`_process()`中,我们使用计时器每隔`Config.NPC_STATUS_UPDATE_INTERVAL`秒(默认30秒)调用一次`get_npc_status()`。当收到NPC状态更新时,`_on_npc_status_received()`回调函数会遍历所有NPC,调用它们的`update_dialogue()`方法更新对话气泡。这样,即使玩家不与NPC交互,也能看到NPC之间的自主对话。
|
|
|
|
|
+
|
|
|
|
|
+整个前后端通信流程如图15.14所示:
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-14.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.14 前后端通信完整流程</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+至此,前后端通信的所有功能都已实现。玩家可以在游戏中自由移动,与NPC互动,进行自然语言对话。同时,主场景会定时从后端获取NPC状态,更新NPC的对话气泡,展示NPC之间的自主对话。整个系统使用信号机制进行通信,各个组件之间松耦合,易于维护和扩展。
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+## 15.7 总结与展望
|
|
|
|
|
+
|
|
|
|
|
+### 15.7.1 本章回顾
|
|
|
|
|
+
|
|
|
|
|
+在本章中,我们完成了一个完整的AI小镇项目——赛博小镇。这个项目将HelloAgents框架与Godot游戏引擎结合,创造出了一个充满生命力的虚拟世界。让我们回顾一下我们学到的核心内容。
|
|
|
|
|
+
|
|
|
|
|
+<strong>技术架构设计</strong>
|
|
|
|
|
+
|
|
|
|
|
+我们采用了游戏引擎+后端服务的分离架构,将前端渲染、后端逻辑和AI智能分离到不同的层次。Godot负责游戏画面和玩家交互,FastAPI负责API服务和状态管理,HelloAgents负责NPC智能和记忆系统。这种分层设计让每个部分都可以独立开发和测试,也为后续的扩展提供了良好的基础。
|
|
|
|
|
+
|
|
|
|
|
+<strong>NPC智能体系统</strong>
|
|
|
|
|
+
|
|
|
|
|
+我们使用HelloAgents的SimpleAgent为每个NPC创建了独立的智能体。每个NPC都有自己的角色设定、性格特点和记忆系统。通过精心设计的系统提示词,我们让张三成为了一位严谨的Python工程师,李四成为了一位善于沟通的产品经理,王五成为了一位富有创意的UI设计师。这些NPC不仅能够理解玩家的对话,还能根据自己的角色特点做出相应的回复。
|
|
|
|
|
+
|
|
|
|
|
+<strong>记忆与好感度系统</strong>
|
|
|
|
|
+
|
|
|
|
|
+我们实现了两层记忆系统:短期记忆保持对话的连贯性,长期记忆存储所有的互动历史。通过向量数据库的语义检索,NPC可以回忆起之前讨论过的话题。好感度系统让NPC对玩家的态度随着互动而变化,从陌生到挚友,每个等级都有不同的行为表现。这些设计让NPC显得更加真实和有趣。
|
|
|
|
|
+
|
|
|
|
|
+<strong>游戏场景构建</strong>
|
|
|
|
|
+
|
|
|
|
|
+我们使用Godot创建了一个像素风格的办公室场景,实现了玩家控制、NPC游走、交互检测和对话UI。通过场景系统的模块化设计,我们可以轻松地添加新的NPC、新的场景和新的功能。GDScript的简洁语法让游戏逻辑的实现变得直观和高效。
|
|
|
|
|
+
|
|
|
|
|
+<strong>前后端通信</strong>
|
|
|
|
|
+
|
|
|
|
|
+我们使用HTTP REST API实现了Godot前端与FastAPI后端的通信。通过异步请求和信号系统,我们保证了游戏的流畅性,即使网络延迟较高也不会影响玩家体验。API客户端的封装让其他脚本可以方便地调用后端服务,对话UI的实现让玩家可以自然地与NPC交流。
|
|
|
|
|
+
|
|
|
|
|
+整个项目的技术栈如图15.15所示:
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <img src="https://raw.githubusercontent.com/datawhalechina/Hello-Agents/main/docs/images/15-figures/15-15.png" alt="" width="85%"/>
|
|
|
|
|
+ <p>图 15.15 赛博小镇技术栈</p>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+### 15.7.2 扩展方向
|
|
|
|
|
+
|
|
|
|
|
+赛博小镇只是一个起点,还有很多可以扩展的方向。这些扩展不仅能够增强游戏的趣味性,也能探索AI技术在游戏中的更多可能性。
|
|
|
|
|
+
|
|
|
|
|
+<strong>(1)多人在线支持</strong>
|
|
|
|
|
+
|
|
|
|
|
+目前的赛博小镇是单人游戏,但我们可以将其扩展为多人在线游戏。多个玩家可以同时进入同一个办公室,与NPC和其他玩家互动。这需要引入WebSocket进行实时通信,以及数据库来持久化玩家数据和NPC状态。NPC可以记住与不同玩家的互动,对每个玩家保持独立的好感度。
|
|
|
|
|
+
|
|
|
|
|
+<strong>(2)任务系统</strong>
|
|
|
|
|
+
|
|
|
|
|
+我们可以为NPC设计任务系统。当玩家与NPC的好感度达到一定程度时,NPC会提供特殊任务。比如张三可能会请玩家帮忙调试一段代码,李四可能会请玩家收集用户反馈,王五可能会请玩家评价设计方案。完成任务可以获得奖励,也能进一步提升好感度。
|
|
|
|
|
+
|
|
|
|
|
+<strong>(3)NPC之间的互动</strong>
|
|
|
|
|
+
|
|
|
|
|
+目前NPC只与玩家互动,但我们可以让NPC之间也能互动。张三可以和李四讨论产品需求,李四可以和王五讨论界面设计,王五可以和张三讨论技术实现。这些互动可以在后台自动进行,玩家可以观察到NPC之间的对话,让整个世界显得更加生动。
|
|
|
|
|
+
|
|
|
|
|
+<strong>(4)情感系统</strong>
|
|
|
|
|
+
|
|
|
|
|
+除了好感度,我们还可以为NPC添加更复杂的情感系统。NPC可以有开心、难过、生气、兴奋等不同的情绪状态,这些情绪会影响NPC的回复风格和行为。比如当NPC心情好的时候,会更愿意分享信息;当NPC心情不好的时候,可能会比较冷淡。
|
|
|
|
|
+
|
|
|
|
|
+<strong>(5)动态事件系统</strong>
|
|
|
|
|
+
|
|
|
|
|
+我们可以设计一些动态事件,让游戏世界更加丰富。比如定期举办团队会议,所有NPC和玩家聚在一起讨论项目进展;或者举办生日派对,庆祝某个NPC的生日;或者突发紧急任务,需要大家协作完成。这些事件可以增加游戏的变化性和趣味性。
|
|
|
|
|
+
|
|
|
|
|
+<strong>(6)更大的世界</strong>
|
|
|
|
|
+
|
|
|
|
|
+目前的赛博小镇只有一个办公室场景,但我们可以扩展到更大的世界。可以添加咖啡厅、图书馆、公园等不同的场景,每个场景有不同的NPC和互动方式。玩家可以在不同场景之间移动,探索更广阔的虚拟世界。
|
|
|
|
|
+
|
|
|
|
|
+<strong>(7)个性化学习</strong>
|
|
|
|
|
+
|
|
|
|
|
+NPC可以学习每个玩家的偏好和习惯。比如如果玩家经常和张三讨论Python,NPC会记住玩家对编程感兴趣,以后会主动分享相关的内容。如果玩家喜欢在晚上玩游戏,NPC会记住这个时间习惯,在晚上更加活跃。
|
|
|
|
|
+
|
|
|
|
|
+### 15.7.3 思考与展望
|
|
|
|
|
+
|
|
|
|
|
+赛博小镇展示了AI技术在游戏中的巨大潜力。传统游戏中的NPC受限于预设的对话树和脚本,而AI NPC可以理解和生成自然语言,与玩家进行真正的对话。这不仅提升了游戏的沉浸感,也为游戏设计带来了新的可能性。
|
|
|
|
|
+
|
|
|
|
|
+但AI NPC也面临一些挑战。首先是成本问题,每次对话都需要调用LLM API,这会产生一定的费用。对于大型多人在线游戏,这个成本可能会很高。其次是延迟问题,LLM的推理需要时间,如果网络延迟较高,玩家可能需要等待几秒才能看到NPC的回复。最后是内容控制问题,LLM生成的内容可能不完全可控,需要设计好的提示词和内容过滤机制。
|
|
|
|
|
+
|
|
|
|
|
+尽管有这些挑战,AI NPC的未来仍然充满希望。随着LLM技术的发展,推理速度会越来越快,成本会越来越低。本地化的小型LLM也在快速发展,未来可能可以在玩家的设备上直接运行,完全不需要网络请求。AI技术与游戏的结合,将为玩家带来前所未有的体验。
|
|
|
|
|
+
|
|
|
|
|
+在本项目中,我们从零开始构建了HelloAgents框架,并用它实现了多个实用的应用。这些项目展示了智能体技术的强大能力和广阔前景。希望通过这本教程,你不仅学会了如何使用现有的智能体框架,更重要的是理解了智能体的核心原理,能够根据自己的需求设计和实现智能体系统。
|
|
|
|
|
+
|
|
|
|
|
+智能体技术正在快速发展,新的模型、新的框架、新的应用不断涌现。但无论技术如何变化,核心的思想是不变的:让AI能够感知环境、做出决策、执行任务,并从经验中学习。掌握了这些核心思想,你就能够跟上技术的发展,创造出更加智能和有用的应用。
|
|
|
|
|
+
|
|
|
|
|
+最后,感谢你完整阅读了本项目。希望你在学习的过程中有所收获,也希望你能够将所学应用到实际项目中,创造出令人惊叹的智能体应用。AI的未来充满无限可能,让我们一起探索和创造!
|
|
|
|
|
+
|
|
|
|
|
+在第五部分的毕业设计章节,我们将会学习如何用单智能体和多智能体构造通用智能体,这将是你的创作时间,敬请期待!
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+<div align="center">
|
|
|
|
|
+ <strong>第十五章 完</strong>
|
|
|
|
|
+</div>
|