# 第十五章 构建赛博小镇

这一章，我们将探索一个全新的方向：<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 能力、向量存储和数据持久化，包括 LLM 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`中查看，或者从 GitHub 克隆完整的 hello-agents 仓库。

<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 技术与游戏的结合，将为玩家带来前所未有的体验。

在第五部分的毕业设计章节，我们将会学习如何用单智能体和多智能体构造通用智能体，这将是你的创作时间，敬请期待！
