jjyaoao 8 месяцев назад
Родитель
Сommit
2a82556156
85 измененных файлов с 8415 добавлено и 0 удалено
  1. 387 0
      code/chapter15/Helloagents-AI-Town/AFFINITY_SYSTEM_GUIDE.md
  2. 370 0
      code/chapter15/Helloagents-AI-Town/DIALOGUE_LOG_GUIDE.md
  3. 377 0
      code/chapter15/Helloagents-AI-Town/MEMORY_SYSTEM_GUIDE.md
  4. 38 0
      code/chapter15/Helloagents-AI-Town/README.md
  5. 161 0
      code/chapter15/Helloagents-AI-Town/SETUP_GUIDE.md
  6. 56 0
      code/chapter15/Helloagents-AI-Town/backend/.env.example
  7. 225 0
      code/chapter15/Helloagents-AI-Town/backend/README.md
  8. 483 0
      code/chapter15/Helloagents-AI-Town/backend/agents.py
  9. 211 0
      code/chapter15/Helloagents-AI-Town/backend/batch_generator.py
  10. 42 0
      code/chapter15/Helloagents-AI-Town/backend/config.py
  11. 115 0
      code/chapter15/Helloagents-AI-Town/backend/logger.py
  12. 393 0
      code/chapter15/Helloagents-AI-Town/backend/main.py
  13. BIN
      code/chapter15/Helloagents-AI-Town/backend/memory_data/张三/memory.db
  14. BIN
      code/chapter15/Helloagents-AI-Town/backend/memory_data/李四/memory.db
  15. BIN
      code/chapter15/Helloagents-AI-Town/backend/memory_data/王五/memory.db
  16. 69 0
      code/chapter15/Helloagents-AI-Town/backend/models.py
  17. 324 0
      code/chapter15/Helloagents-AI-Town/backend/relationship_manager.py
  18. 15 0
      code/chapter15/Helloagents-AI-Town/backend/requirements.txt
  19. 134 0
      code/chapter15/Helloagents-AI-Town/backend/state_manager.py
  20. 113 0
      code/chapter15/Helloagents-AI-Town/backend/view_logs.py
  21. 4 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/.editorconfig
  22. 2 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/.gitattributes
  23. 3 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/.gitignore
  24. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/audio/BGM.ogg
  25. 19 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/audio/BGM.ogg.import
  26. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/audio/Running.mp3
  27. 19 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/audio/Running.mp3.import
  28. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/audio/interact.mp3
  29. 19 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/audio/interact.mp3.import
  30. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_1.png
  31. 40 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_1.png.import
  32. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_2.png
  33. 40 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_2.png.import
  34. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_3.png
  35. 40 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_3.png.import
  36. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_4.png
  37. 40 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_4.png.import
  38. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/13_Conference_Hall_48x48.png
  39. 40 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/13_Conference_Hall_48x48.png.import
  40. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/1_Generic_48x48.png
  41. 40 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/1_Generic_48x48.png.import
  42. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/Japanese_Home_1_preview_48x48.png
  43. 40 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/Japanese_Home_1_preview_48x48.png.import
  44. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/Room_Builder_48x48.png
  45. 40 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/Room_Builder_48x48.png.import
  46. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/小鲸鱼.png
  47. 40 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/小鲸鱼.png.import
  48. BIN
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/ui/UI_48x48.png
  49. 40 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/ui/UI_48x48.png.import
  50. 1 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/icon.svg
  51. 43 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/icon.svg.import
  52. 75 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/project.godot
  53. 69 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scenes/dialogue_ui.tscn
  54. 478 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scenes/main.tscn
  55. 326 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scenes/npc.tscn
  56. 343 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scenes/player.tscn
  57. 287 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/README.md
  58. 144 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/api_client.gd
  59. 1 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/api_client.gd.uid
  60. 41 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/config.gd
  61. 1 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/config.gd.uid
  62. 206 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/dialogue_ui.gd
  63. 1 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/dialogue_ui.gd.uid
  64. 61 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/main.gd
  65. 1 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/main.gd.uid
  66. 250 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/npc.gd
  67. 1 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/npc.gd.uid
  68. 195 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/player.gd
  69. 1 0
      code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/player.gd.uid
  70. 1911 0
      docs/chapter15/第十五章 构建赛博小镇.md
  71. BIN
      docs/images/15-figures/15-1.png
  72. BIN
      docs/images/15-figures/15-10.png
  73. BIN
      docs/images/15-figures/15-11.png
  74. BIN
      docs/images/15-figures/15-12.png
  75. BIN
      docs/images/15-figures/15-13.png
  76. BIN
      docs/images/15-figures/15-14.png
  77. BIN
      docs/images/15-figures/15-15.png
  78. BIN
      docs/images/15-figures/15-2.png
  79. BIN
      docs/images/15-figures/15-3.png
  80. BIN
      docs/images/15-figures/15-4.png
  81. BIN
      docs/images/15-figures/15-5.png
  82. BIN
      docs/images/15-figures/15-6.png
  83. BIN
      docs/images/15-figures/15-7.png
  84. BIN
      docs/images/15-figures/15-8.png
  85. BIN
      docs/images/15-figures/15-9.png

+ 387 - 0
code/chapter15/Helloagents-AI-Town/AFFINITY_SYSTEM_GUIDE.md

@@ -0,0 +1,387 @@
+# 💖 NPC好感度系统使用指南
+
+## 📚 概述
+
+赛博小镇的NPC现在拥有了**好感度系统**,能够根据与玩家的对话内容自动调整好感度,并影响后续对话的风格和态度!
+
+---
+
+## ✨ 核心功能
+
+### 1. **自动情感分析**
+- 🤖 使用LLM Agent分析对话情感
+- 📊 判断玩家态度 (友好/中立/不友好)
+- 🎯 评估对话质量 (深入/一般/敷衍)
+- 💬 识别情感倾向 (赞美/批评/中性)
+
+### 2. **好感度动态调整**
+- 📈 友好对话提升好感度 (+1 到 +10)
+- 📉 批评对话降低好感度 (-3 到 -15)
+- 🔄 自动更新好感度值 (0-100)
+- 🎚️ 限制在合理范围内
+
+### 3. **关系等级系统**
+- 🥶 **陌生** (0-20): 冷淡疏离,不太愿意多说
+- 😐 **熟悉** (20-40): 礼貌但略显生疏
+- 😊 **友好** (40-60): 礼貌友善,正常交流
+- 🤗 **亲密** (60-80): 友好热情,愿意多聊
+- 💕 **挚友** (80-100): 非常热情,像老朋友一样
+
+### 4. **对话风格调整**
+- 🎭 好感度影响NPC的对话风格
+- 💬 高好感度: 更热情、更愿意分享
+- 😶 低好感度: 更冷淡、回答简短
+- 🔄 动态调整,实时生效
+
+---
+
+## 🎯 使用示例
+
+### 示例1: 好感度提升
+
+```
+初始好感度: 50 (友好)
+
+第一次对话:
+玩家: "你好,很高兴认识你!"
+张三: "你好!我也很高兴认识你。"
+📈 好感度: 50 -> 55 (友好问候)
+
+第二次对话:
+玩家: "你的代码写得真棒!"
+张三: "谢谢!我最近在研究新技术,你对这个感兴趣吗?"
+📈 好感度: 55 -> 63 (赞美工作) → 关系等级提升: 友好 -> 亲密
+
+第三次对话:
+玩家: "能教教我吗?"
+张三: "当然可以!我很乐意分享。你想从哪里开始?"
+📈 好感度: 63 -> 69 (请教学习)
+```
+
+### 示例2: 好感度降低
+
+```
+当前好感度: 69 (亲密)
+
+批评对话:
+玩家: "你这个代码写得太烂了!"
+张三: "抱歉,我会改进的..."
+📉 好感度: 69 -> 61 (批评工作) → 关系等级降低: 亲密 -> 友好
+```
+
+### 示例3: 对话风格变化
+
+```
+好感度: 30 (熟悉)
+玩家: "你好,最近怎么样?"
+李四: "还行吧。" (简短回答)
+
+好感度: 70 (亲密)
+玩家: "你好,最近怎么样?"
+李四: "挺好的!最近在做一个很有意思的项目,你要不要听听?" (热情详细)
+
+好感度: 90 (挚友)
+玩家: "你好,最近怎么样?"
+李四: "哈哈,老朋友!最近忙得不行,但很充实。对了,上次你问的那个问题,我找到答案了!" (亲切主动)
+```
+
+---
+
+## 🔧 技术实现
+
+### 架构设计
+
+```
+RelationshipManager
+├── affinity_scores: Dict[str, Dict[str, float]]  # NPC好感度存储
+├── analyzer_agent: SimpleAgent                   # 情感分析Agent
+├── get_affinity(npc_name, player_id)            # 获取好感度
+├── analyze_and_update_affinity(...)             # 分析并更新好感度
+├── get_affinity_level(affinity)                 # 获取关系等级
+└── get_affinity_modifier(affinity)              # 获取对话风格修饰词
+```
+
+### 情感分析流程
+
+```
+1. 玩家发送消息
+   ↓
+2. NPC生成回复
+   ↓
+3. 情感分析Agent分析对话
+   ├── 分析玩家态度
+   ├── 评估对话内容
+   ├── 判断情感倾向
+   └── 计算好感度变化量
+   ↓
+4. 更新好感度
+   ├── 当前好感度 + 变化量
+   ├── 限制在0-100范围
+   └── 检查等级变化
+   ↓
+5. 保存到记忆系统
+   └── 记录好感度和情感信息
+```
+
+### 好感度变化规则
+
+| 对话类型 | 变化量 | 示例 |
+|---------|--------|------|
+| 赞美、感谢、请教 | +3 到 +8 | "你真棒!" "谢谢你!" "能教教我吗?" |
+| 友好问候、正常交流 | +1 到 +3 | "你好!" "最近怎么样?" |
+| 普通闲聊、中性话题 | 0 | "今天天气不错" |
+| 批评、质疑、不耐烦 | -3 到 -8 | "这个不太好" "真的吗?" |
+| 侮辱、攻击、恶意 | -8 到 -15 | "你太烂了!" |
+
+---
+
+## 🚀 API接口
+
+### 1. 获取NPC好感度
+
+```http
+GET /npcs/张三/affinity?player_id=player
+```
+
+**响应:**
+```json
+{
+    "npc_name": "张三",
+    "player_id": "player",
+    "affinity": 65.0,
+    "level": "亲密",
+    "modifier": "友好热情,愿意多聊,会主动关心对方"
+}
+```
+
+### 2. 获取所有NPC好感度
+
+```http
+GET /affinities?player_id=player
+```
+
+**响应:**
+```json
+{
+    "player_id": "player",
+    "affinities": {
+        "张三": {
+            "affinity": 65.0,
+            "level": "亲密",
+            "modifier": "友好热情,愿意多聊,会主动关心对方"
+        },
+        "李四": {
+            "affinity": 50.0,
+            "level": "友好",
+            "modifier": "礼貌友善,正常交流,保持专业"
+        },
+        "王五": {
+            "affinity": 72.0,
+            "level": "亲密",
+            "modifier": "友好热情,愿意多聊,会主动关心对方"
+        }
+    }
+}
+```
+
+### 3. 设置NPC好感度 (测试用)
+
+```http
+PUT /npcs/张三/affinity?affinity=80&player_id=player
+```
+
+**响应:**
+```json
+{
+    "message": "已设置张三对玩家的好感度",
+    "npc_name": "张三",
+    "player_id": "player",
+    "affinity": 80.0,
+    "level": "挚友",
+    "modifier": "非常热情友好,像老朋友一样亲切,愿意分享私人话题"
+}
+```
+
+---
+
+## 🧪 测试方法
+
+### 方法1: 使用测试脚本
+
+```bash
+cd backend
+python test_affinity.py
+```
+
+**测试内容:**
+- ✅ 基本好感度功能
+- ✅ 好感度提升/降低
+- ✅ 关系等级变化
+- ✅ 对话风格调整
+- ✅ 好感度渐进提升
+
+### 方法2: 使用API测试
+
+1. 启动后端服务:
+```bash
+cd backend
+python main.py
+```
+
+2. 访问API文档: http://localhost:8000/docs
+
+3. 测试好感度接口:
+   - 对话: POST /chat
+   - 查看好感度: GET /npcs/张三/affinity
+   - 查看所有好感度: GET /affinities
+
+---
+
+## 📊 好感度系统配置
+
+### 情感分析提示词
+
+情感分析Agent使用精心设计的提示词来分析对话:
+
+```python
+【分析维度】
+1. 玩家态度: 友好/中立/不友好
+2. 对话内容: 积极/中立/消极
+3. 互动质量: 深入/一般/敷衍
+4. 情感倾向: 赞美/批评/中性
+
+【输出格式】
+{
+    "should_change": true/false,
+    "change_amount": -15到+10之间的整数,
+    "reason": "简短说明原因",
+    "sentiment": "positive/neutral/negative"
+}
+```
+
+### 调整建议
+
+如果想调整好感度变化的敏感度,可以修改 `relationship_manager.py` 中的提示词:
+
+- **更敏感**: 增加变化量范围 (例如: -20 到 +15)
+- **更保守**: 减少变化量范围 (例如: -5 到 +5)
+- **更细腻**: 添加更多分析维度
+- **更简单**: 简化分析规则
+
+---
+
+## 🎓 教学价值
+
+### 学习要点
+
+1. **LLM情感分析**
+   - 如何使用LLM分析对话情感
+   - 如何设计情感分析提示词
+   - 如何解析LLM的JSON响应
+
+2. **好感度系统设计**
+   - 如何设计好感度变化规则
+   - 如何实现关系等级系统
+   - 如何将好感度与对话风格关联
+
+3. **系统集成**
+   - 如何将好感度系统集成到Agent
+   - 如何与记忆系统协同工作
+   - 如何通过API暴露功能
+
+4. **用户体验设计**
+   - 如何让NPC更有人情味
+   - 如何提升对话的连贯性
+   - 如何增强游戏的沉浸感
+
+---
+
+## 🔍 调试技巧
+
+### 1. 查看好感度变化日志
+
+```python
+# 在agents.py的chat方法中
+📈 张三对玩家的好感度: 50.0 -> 55.0 (友好问候)
+🎉 关系等级提升: 友好 -> 亲密
+```
+
+### 2. 检查情感分析结果
+
+```python
+# 在relationship_manager.py中添加调试输出
+print(f"情感分析结果: {analysis}")
+```
+
+### 3. 测试不同对话类型
+
+```python
+# 使用test_affinity.py测试不同类型的对话
+friendly_messages = ["你好!", "你真棒!", "能教教我吗?"]
+critical_messages = ["这个不好", "你太烂了"]
+neutral_messages = ["今天天气不错", "嗯"]
+```
+
+---
+
+## ❓ 常见问题
+
+### Q1: 好感度为什么没有变化?
+
+**可能原因:**
+- 对话内容过于中性
+- 情感分析Agent判断为不需要改变
+- LLM响应解析失败
+
+**解决方法:**
+- 使用更明确的情感表达
+- 检查日志中的情感分析结果
+- 调整情感分析提示词
+
+### Q2: 好感度变化太快/太慢?
+
+**解决方法:**
+- 修改 `relationship_manager.py` 中的变化量范围
+- 调整情感分析提示词中的规则
+- 使用 `set_npc_affinity` 手动设置初始值
+
+### Q3: 对话风格没有明显变化?
+
+**可能原因:**
+- 好感度差异不够大
+- NPC的system_prompt没有充分利用好感度修饰词
+
+**解决方法:**
+- 增大好感度差异 (例如: 20 vs 80)
+- 在system_prompt中强调对话风格的重要性
+
+---
+
+## 🎉 总结
+
+✅ NPC好感度系统已成功集成到赛博小镇!
+
+**核心特性:**
+- 🤖 自动情感分析
+- 📊 动态好感度调整
+- 🎚️ 关系等级系统
+- 💬 对话风格调整
+- 💾 与记忆系统集成
+
+**教学价值:**
+- LLM情感分析的实战应用
+- 好感度系统的设计与实现
+- 多系统协同工作
+- 用户体验优化
+
+**下一步:**
+- 在Godot中显示好感度UI
+- 添加好感度相关的游戏机制
+- 优化情感分析算法
+
+---
+
+**作者:** HelloAgents团队  
+**日期:** 2024-01-15  
+**版本:** v1.0
+

+ 370 - 0
code/chapter15/Helloagents-AI-Town/DIALOGUE_LOG_GUIDE.md

@@ -0,0 +1,370 @@
+# 对话日志系统使用指南
+
+## 📝 概述
+
+为了方便学习者查看和分析NPC对话过程,我们实现了一个完整的日志系统,将所有对话信息同时输出到:
+- ✅ **控制台** - 实时查看
+- ✅ **日志文件** - 持久化保存,方便回顾
+
+---
+
+## 🎯 功能特性
+
+### 1. 自动记录对话信息
+
+日志系统会自动记录:
+- 💬 对话开始/结束
+- 📝 玩家消息
+- 💖 当前好感度和关系等级
+- 🧠 检索到的相关记忆
+- 🤖 NPC回复内容
+- 📊 好感度变化分析
+- 🎉 关系等级变化
+- 💾 记忆保存确认
+
+### 2. 双重输出
+
+- **控制台输出** - 实时查看,方便调试
+- **文件输出** - 持久化保存,方便回顾和分析
+
+### 3. 按日期分类
+
+日志文件按日期自动分类:
+```
+backend/logs/
+├── dialogue_2025-01-15.log
+├── dialogue_2025-01-16.log
+└── dialogue_2025-01-17.log
+```
+
+---
+
+## 📂 文件结构
+
+```
+code/chapter15/backend/
+├── logger.py              # 日志系统核心模块
+├── view_logs.py           # 日志查看工具
+├── agents.py              # ✅ 已集成日志系统
+└── logs/                  # 日志文件目录 (自动创建)
+    └── dialogue_YYYY-MM-DD.log
+```
+
+---
+
+## 🚀 使用方法
+
+### 方法1: 启动后端服务 (自动记录)
+
+```bash
+cd code/chapter15/backend
+python main.py
+```
+
+**日志会自动记录到:**
+- 控制台 (实时显示)
+- `logs/dialogue_YYYY-MM-DD.log` (持久化保存)
+
+**启动时会显示日志文件位置:**
+```
+📝 对话日志文件: D:\code\...\backend\logs\dialogue_2025-01-15.log
+📂 日志目录: D:\code\...\backend\logs
+```
+
+---
+
+### 方法2: 实时查看日志文件
+
+**在另一个终端窗口运行:**
+```bash
+cd code/chapter15/backend
+python view_logs.py tail
+```
+
+**效果:**
+- 实时显示日志内容 (类似 `tail -f`)
+- 新的对话会立即显示
+- 按 `Ctrl+C` 停止查看
+
+---
+
+### 方法3: 查看完整日志
+
+```bash
+cd code/chapter15/backend
+python view_logs.py view
+```
+
+**效果:**
+- 显示今天的完整日志内容
+- 一次性显示所有对话记录
+
+---
+
+### 方法4: 列出所有日志文件
+
+```bash
+cd code/chapter15/backend
+python view_logs.py list
+```
+
+**效果:**
+```
+============================================================
+📂 日志文件列表
+📁 目录: D:\code\...\backend\logs
+============================================================
+
+1. dialogue_2025-01-15.log
+   大小: 12.34 KB
+   修改时间: 2025-01-15 14:30:25
+
+2. dialogue_2025-01-14.log
+   大小: 8.56 KB
+   修改时间: 2025-01-14 18:45:12
+```
+
+---
+
+## 📊 日志格式示例
+
+### 完整对话流程
+
+```
+14:30:25 - ============================================================
+14:30:25 - 💬 对话开始: 张三 <-> 玩家
+14:30:25 - ============================================================
+14:30:25 - 📝 玩家消息: 你好,很高兴认识你!
+14:30:25 - 💖 当前好感度: 50.0/100 (友好)
+14:30:25 - 🧠 检索到0条相关记忆
+14:30:26 - 🤖 正在生成回复...
+14:30:28 - 💬 张三回复: 你好!我也很高兴认识你。我是Python工程师张三,最近在研究多智能体系统。
+14:30:28 - 📊 正在分析好感度变化...
+14:30:30 - 📈 好感度变化: 50.0 -> 56.0 (+6.0)
+14:30:30 -   原因: 友好问候
+14:30:30 -   情感: positive
+14:30:30 -   💾 对话已保存到张三的记忆中
+14:30:30 - ============================================================
+14:30:30 - ✅ 对话完成
+
+```
+
+### 好感度提升 + 等级变化
+
+```
+14:35:12 - ============================================================
+14:35:12 - 💬 对话开始: 张三 <-> 玩家
+14:35:12 - ============================================================
+14:35:12 - 📝 玩家消息: 你的代码写得真棒!我很佩服你!
+14:35:12 - 💖 当前好感度: 56.0/100 (友好)
+14:35:12 - 🧠 检索到1条相关记忆
+14:35:12 -   📚 相关记忆:
+14:35:12 -     1. 玩家说: 你好,很高兴认识你!
+14:35:13 - 🤖 正在生成回复...
+14:35:15 - 💬 张三回复: 谢谢夸奖!写代码确实让我很有成就感...
+14:35:15 - 📊 正在分析好感度变化...
+14:35:17 - 📈 好感度变化: 56.0 -> 64.0 (+8.0)
+14:35:17 -   原因: 赞美工作
+14:35:17 -   情感: positive
+14:35:17 -   🎉 关系等级变化: 友好 -> 亲密
+14:35:17 -   💾 对话已保存到张三的记忆中
+14:35:17 - ============================================================
+14:35:17 - ✅ 对话完成
+
+```
+
+---
+
+## 🎓 教学价值
+
+### 1. 完整的对话流程可视化
+
+学习者可以清楚地看到:
+- 📝 玩家输入
+- 🧠 记忆检索过程
+- 🤖 NPC回复生成
+- 📊 好感度分析
+- 💾 记忆保存
+
+### 2. 好感度系统验证
+
+- 看到好感度如何根据对话内容变化
+- 理解情感分析的结果
+- 观察关系等级的变化
+
+### 3. 记忆系统验证
+
+- 看到NPC检索到的历史记忆
+- 理解记忆如何影响对话
+- 验证记忆保存是否成功
+
+### 4. 调试和优化
+
+- 快速定位问题
+- 分析对话质量
+- 优化系统参数
+
+---
+
+## 🔧 技术实现
+
+### logger.py 核心功能
+
+```python
+# 创建logger
+dialogue_logger = logging.getLogger("dialogue")
+
+# 文件handler - 保存到文件
+file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
+
+# 控制台handler - 输出到控制台
+console_handler = logging.StreamHandler()
+
+# 添加handlers
+dialogue_logger.addHandler(file_handler)
+dialogue_logger.addHandler(console_handler)
+```
+
+### agents.py 集成方式
+
+```python
+from logger import (
+    log_dialogue_start, log_affinity, log_memory_retrieval,
+    log_generating_response, log_npc_response, log_analyzing_affinity,
+    log_affinity_change, log_memory_saved, log_dialogue_end
+)
+
+def chat(self, npc_name: str, message: str, player_id: str = "player") -> str:
+    # 记录对话开始
+    log_dialogue_start(npc_name, message)
+    
+    # 记录好感度
+    log_affinity(npc_name, affinity, affinity_level)
+    
+    # 记录记忆检索
+    log_memory_retrieval(npc_name, len(relevant_memories), relevant_memories)
+    
+    # 记录NPC回复
+    log_npc_response(npc_name, response)
+    
+    # 记录好感度变化
+    log_affinity_change(affinity_result)
+    
+    # 记录对话结束
+    log_dialogue_end()
+```
+
+---
+
+## 📋 常见问题
+
+### Q1: 日志文件在哪里?
+
+**A:** 日志文件保存在 `backend/logs/` 目录下,按日期命名:
+```
+backend/logs/dialogue_YYYY-MM-DD.log
+```
+
+启动后端服务时会显示日志文件的完整路径。
+
+---
+
+### Q2: 如何实时查看日志?
+
+**A:** 有两种方法:
+
+**方法1: 查看控制台输出**
+```bash
+cd code/chapter15/backend
+python main.py
+```
+
+**方法2: 使用日志查看工具**
+```bash
+# 在另一个终端窗口
+cd code/chapter15/backend
+python view_logs.py tail
+```
+
+---
+
+### Q3: 日志文件会占用很多空间吗?
+
+**A:** 不会。日志文件按日期分类,每天一个文件。一般情况下:
+- 每次对话约 0.5-1 KB
+- 100次对话约 50-100 KB
+- 一天的日志通常不超过 1 MB
+
+---
+
+### Q4: 可以查看历史日志吗?
+
+**A:** 可以!使用以下命令:
+
+```bash
+# 列出所有日志文件
+python view_logs.py list
+
+# 查看特定日期的日志
+python view_logs.py view
+```
+
+或者直接打开日志文件:
+```
+backend/logs/dialogue_2025-01-15.log
+```
+
+---
+
+## ✅ 总结
+
+### 优势
+
+1. ✅ **双重输出** - 控制台 + 文件,方便实时查看和回顾
+2. ✅ **自动记录** - 无需手动操作,自动记录所有对话
+3. ✅ **格式清晰** - 使用emoji和分隔线,易于阅读
+4. ✅ **按日期分类** - 方便管理和查找
+5. ✅ **实时查看** - 提供实时查看工具
+6. ✅ **教学友好** - 完整展示对话流程,方便学习
+
+### 使用建议
+
+1. **开发调试时** - 查看控制台输出,实时调试
+2. **学习分析时** - 查看日志文件,详细分析
+3. **演示教学时** - 使用 `view_logs.py tail` 实时展示
+
+---
+
+## 🎉 开始使用
+
+### 步骤1: 启动后端服务
+
+```bash
+cd code/chapter15/backend
+python main.py
+```
+
+### 步骤2: 运行Godot游戏
+
+在Godot编辑器中运行游戏
+
+### 步骤3: 与NPC对话
+
+走到NPC附近,按E键开始对话
+
+### 步骤4: 查看日志
+
+**选项A: 查看控制台**
+- 在运行 `python main.py` 的终端窗口查看
+
+**选项B: 查看日志文件**
+```bash
+# 在另一个终端窗口
+cd code/chapter15/backend
+python view_logs.py tail
+```
+
+---
+
+**祝你使用愉快!** 🎮✨📝
+

+ 377 - 0
code/chapter15/Helloagents-AI-Town/MEMORY_SYSTEM_GUIDE.md

@@ -0,0 +1,377 @@
+# 🧠 NPC记忆系统使用指南
+
+## 📚 概述
+
+赛博小镇的NPC现在拥有了**记忆系统**,能够记住与玩家的对话历史,并在后续对话中引用之前的内容,让NPC更加智能和真实!
+
+---
+
+## ✨ 核心功能
+
+### 1. **工作记忆 (Working Memory)** - 短期记忆
+- 📝 存储最近的10条对话
+- ⏰ 2小时后自动过期
+- 🚀 快速检索,用于当前对话上下文
+
+### 2. **情景记忆 (Episodic Memory)** - 长期记忆
+- 💾 持久化存储重要对话
+- 🔍 支持语义检索 (基于Qdrant向量数据库)
+- 📊 最多存储100条记忆
+- 🧹 自动遗忘重要性低于0.3的记忆
+
+### 3. **记忆隔离**
+- 🔒 每个NPC拥有独立的记忆系统
+- 🚫 NPC之间的记忆不会互相干扰
+- 👤 每个玩家的对话独立存储
+
+---
+
+## 🎯 使用示例
+
+### 示例1: 基本对话记忆
+
+```
+第一次对话:
+玩家: "你好,你是做什么的?"
+张三: "你好!我是Python工程师,主要负责多智能体系统开发。"
+
+第二次对话 (5分钟后):
+玩家: "还记得我刚才问你什么吗?"
+张三: "当然记得!你问我是做什么的,我说我是Python工程师。最近在研究HelloAgents框架。"
+```
+
+### 示例2: 长期记忆
+
+```
+第一天:
+玩家: "你最喜欢的编程语言是什么?"
+张三: "我最喜欢Python,简洁优雅,生态丰富。"
+
+第二天:
+玩家: "我们之前聊过编程语言吗?"
+张三: "聊过!我记得我说过我最喜欢Python,你对这个感兴趣吗?"
+```
+
+### 示例3: 记忆隔离
+
+```
+与张三对话:
+玩家: "我最近在学习多智能体系统"
+张三: "太好了!我正好在研究这个,有什么问题可以问我。"
+
+与李四对话:
+玩家: "我刚才和张三聊了什么?"
+李四: "抱歉,我不知道你和张三聊了什么,我只负责产品方面的工作。"
+```
+
+---
+
+## 🔧 技术实现
+
+### 架构设计
+
+```
+NPCAgentManager
+├── agents: Dict[str, SimpleAgent]          # NPC Agent
+├── memories: Dict[str, MemoryManager]      # NPC记忆管理器
+└── chat(npc_name, message, player_id)      # 对话接口
+    ├── 1. 检索相关记忆
+    ├── 2. 构建增强提示词
+    ├── 3. 调用Agent生成回复
+    └── 4. 保存对话到记忆
+```
+
+### 记忆存储结构
+
+```
+backend/memory_data/
+├── 张三/
+│   ├── sqlite_store.db          # SQLite数据库 (权威存储)
+│   └── qdrant_collection/       # Qdrant向量索引 (语义检索)
+├── 李四/
+│   ├── sqlite_store.db
+│   └── qdrant_collection/
+└── 王五/
+    ├── sqlite_store.db
+    └── qdrant_collection/
+```
+
+### 记忆数据格式
+
+```python
+{
+    "id": "memory_uuid",
+    "content": "玩家说: 你好,你是做什么的?",
+    "type": "working",  # working/episodic
+    "importance": 0.5,  # 0-1之间
+    "timestamp": "2024-01-15T10:30:00",
+    "metadata": {
+        "speaker": "player",
+        "player_id": "player",
+        "session_id": "player",
+        "context": {
+            "interaction_type": "dialogue",
+            "npc_name": "张三"
+        }
+    }
+}
+```
+
+---
+
+## 🚀 API接口
+
+### 1. 对话接口 (支持记忆)
+
+```http
+POST /chat
+Content-Type: application/json
+
+{
+    "npc_name": "张三",
+    "message": "你好,你是做什么的?"
+}
+```
+
+**响应:**
+```json
+{
+    "npc_name": "张三",
+    "npc_title": "Python工程师",
+    "message": "你好!我是Python工程师,主要负责多智能体系统开发。",
+    "success": true
+}
+```
+
+### 2. 获取NPC记忆
+
+```http
+GET /npcs/张三/memories?limit=10
+```
+
+**响应:**
+```json
+{
+    "npc_name": "张三",
+    "memories": [
+        {
+            "id": "uuid-1",
+            "content": "玩家说: 你好,你是做什么的?",
+            "type": "working",
+            "importance": 0.5,
+            "timestamp": "2024-01-15T10:30:00",
+            "metadata": {...}
+        },
+        ...
+    ],
+    "total": 10
+}
+```
+
+### 3. 清空NPC记忆 (测试用)
+
+```http
+DELETE /npcs/张三/memories?memory_type=working
+```
+
+**响应:**
+```json
+{
+    "message": "已清空张三的记忆",
+    "npc_name": "张三",
+    "memory_type": "working"
+}
+```
+
+---
+
+## 🧪 测试方法
+
+### 方法1: 使用测试脚本
+
+```bash
+cd backend
+python test_memory.py
+```
+
+**测试内容:**
+- ✅ 基本对话记忆
+- ✅ 长期记忆检索
+- ✅ 记忆隔离
+- ✅ 相关性检索
+
+### 方法2: 使用API测试
+
+1. 启动后端服务:
+```bash
+cd backend
+python main.py
+```
+
+2. 访问API文档: http://localhost:8000/docs
+
+3. 测试对话接口:
+   - 发送第一条消息: "你好,你是做什么的?"
+   - 发送第二条消息: "还记得我刚才问你什么吗?"
+   - 查看记忆列表: GET /npcs/张三/memories
+
+### 方法3: 在Godot中测试
+
+1. 启动后端服务
+2. 运行Godot游戏
+3. 与NPC对话多次
+4. 观察NPC是否能记住之前的对话
+
+---
+
+## 📊 记忆系统配置
+
+### 配置参数 (agents.py)
+
+```python
+memory_config = MemoryConfig(
+    storage_path=f"./memory_data/{npc_name}",  # 存储路径
+    working_memory_capacity=10,                # 工作记忆容量
+    working_memory_tokens=2000,                # 工作记忆token限制
+    episodic_memory_capacity=100,              # 情景记忆容量
+    enable_forgetting=True,                    # 启用遗忘机制
+    forgetting_threshold=0.3                   # 遗忘阈值
+)
+```
+
+### 调整建议
+
+| 参数 | 默认值 | 建议范围 | 说明 |
+|------|--------|----------|------|
+| working_memory_capacity | 10 | 5-20 | 工作记忆容量,越大越占内存 |
+| working_memory_tokens | 2000 | 1000-4000 | Token限制,影响上下文长度 |
+| episodic_memory_capacity | 100 | 50-500 | 长期记忆容量,越大越占磁盘 |
+| forgetting_threshold | 0.3 | 0.1-0.5 | 遗忘阈值,越低越容易遗忘 |
+
+---
+
+## 🎓 教学价值
+
+### 学习要点
+
+1. **MemoryManager的使用**
+   - 如何初始化记忆管理器
+   - 如何配置不同类型的记忆
+   - 如何添加和检索记忆
+
+2. **记忆检索策略**
+   - 工作记忆: 快速检索最近对话
+   - 情景记忆: 语义检索相关历史
+   - 混合检索: 结合时间和相关性
+
+3. **记忆存储机制**
+   - SQLite: 权威数据存储
+   - Qdrant: 向量语义检索
+   - 双存储保证数据一致性
+
+4. **记忆遗忘机制**
+   - 基于重要性的自动遗忘
+   - 基于时间的TTL过期
+   - 容量限制的优先级淘汰
+
+---
+
+## 🔍 调试技巧
+
+### 1. 查看记忆日志
+
+```python
+# 在agents.py的chat方法中
+print(f"🧠 {npc_name}检索到{len(relevant_memories)}条相关记忆")
+print(f"💾 对话已保存到{npc_name}的记忆中")
+```
+
+### 2. 检查记忆文件
+
+```bash
+# 查看SQLite数据库
+cd backend/memory_data/张三
+sqlite3 sqlite_store.db
+> SELECT * FROM memories;
+```
+
+### 3. 清空记忆重新测试
+
+```python
+# 使用API清空记忆
+DELETE /npcs/张三/memories
+
+# 或者直接删除文件
+rm -rf backend/memory_data/张三
+```
+
+---
+
+## ❓ 常见问题
+
+### Q1: NPC为什么记不住对话?
+
+**可能原因:**
+- 记忆系统未正确初始化
+- 存储路径权限问题
+- 记忆被遗忘机制清除
+
+**解决方法:**
+- 检查日志中是否有"记忆系统已初始化"
+- 检查memory_data目录是否存在
+- 调高forgetting_threshold参数
+
+### Q2: 记忆检索不准确?
+
+**可能原因:**
+- 查询语句与记忆内容相似度低
+- 记忆重要性太低被过滤
+
+**解决方法:**
+- 降低min_importance参数
+- 增加检索limit数量
+- 使用更具体的查询语句
+
+### Q3: 记忆占用空间太大?
+
+**解决方法:**
+- 降低episodic_memory_capacity
+- 提高forgetting_threshold
+- 定期清理旧记忆
+
+---
+
+## 🎉 下一步
+
+现在记忆系统已经完成,接下来我们将实现:
+
+1. ✅ **好感度系统** - NPC与玩家的关系管理
+2. ✅ **情感分析** - 使用LLM分析对话情感
+3. ✅ **关系等级** - 陌生、熟悉、友好、亲密、挚友
+
+---
+
+## 📝 总结
+
+✅ NPC记忆系统已成功集成到赛博小镇!
+
+**核心特性:**
+- 🧠 短期记忆 (工作记忆)
+- 💾 长期记忆 (情景记忆)
+- 🔍 语义检索
+- 🔒 记忆隔离
+- 🧹 自动遗忘
+
+**教学价值:**
+- HelloAgents Memory系统的实战应用
+- 多智能体记忆管理
+- 向量数据库的使用
+- 记忆检索策略
+
+**下一步:**
+- 实现好感度系统
+- 集成情感分析
+- 完善NPC交互体验
+
+---
+

+ 38 - 0
code/chapter15/Helloagents-AI-Town/README.md

@@ -0,0 +1,38 @@
+# 赛博小镇 - AI NPC对话系统
+
+基于HelloAgents框架的AI小镇模拟游戏,展示多智能体系统在游戏中的应用。
+
+## 🎮 功能特性
+
+- ✅ 3个AI NPC (张三、李四、王五)
+- ✅ 智能对话系统
+- ✅ 记忆系统 (短期+长期记忆)
+- ✅ 好感度系统 (5个等级)
+- ✅ NPC自主行为 (闲逛、工作)
+- ✅ 完整的日志系统
+
+## 🛠️ 技术栈
+
+- **游戏引擎:** Godot 4.x
+- **后端框架:** FastAPI + Python 3.10+
+- **AI框架:** HelloAgents
+- **LLM:** OpenAI GPT-4 (可配置其余的LLM服务)
+
+## 📦 快速开始
+
+详见 [SETUP_GUIDE.md](SETUP_GUIDE.md)
+
+## 📚 文档
+
+- [安装配置指南](SETUP_GUIDE.md)
+- [对话日志系统](DIALOGUE_LOG_GUIDE.md)
+- [好感度系统](AFFINITY_SYSTEM_GUIDE.md)
+- [记忆系统](MEMORY_SYSTEM_GUIDE.md)
+
+## 📖 教程
+
+本项目是《Hello-agents》教材第15章的配套案例。
+
+## 📄 许可证
+
+CC BY-NC-SA 4.0

+ 161 - 0
code/chapter15/Helloagents-AI-Town/SETUP_GUIDE.md

@@ -0,0 +1,161 @@
+# 赛博小镇 - 安装配置指南
+
+## 📋 系统要求
+
+- **操作系统:** Windows 10/11, macOS, Linux
+- **Godot:** 4.2+ (推荐4.3)
+- **Python:** 3.10+
+- **Git:** (可选,用于克隆项目)
+
+## 🚀 安装步骤
+
+### 步骤1: 下载项目
+
+**方法A: 使用Git**
+```bash
+git clone https://github.com/datawhalechina/hello-agents
+cd chapter15
+```
+
+**方法B: 下载ZIP**
+1. 下载项目ZIP文件
+2. 解压到任意目录
+
+### 步骤2: 安装Godot
+
+1. 访问 [Godot官网](https://godotengine.org/download)
+2. 下载Godot 4.2+版本
+3. 解压并运行Godot
+
+### 步骤3: 配置Python环境
+
+#### 3.1 创建虚拟环境
+```bash
+cd backend
+python -m venv venv
+```
+
+#### 3.2 激活虚拟环境
+**Windows:**
+```bash
+venv\Scripts\activate
+```
+
+**macOS/Linux:**
+```bash
+source venv/bin/activate
+```
+
+#### 3.3 安装依赖
+```bash
+pip install -r requirements.txt
+```
+
+#### 3.4 安装HelloAgents
+```bash
+cd ../HelloAgents
+pip install -e .
+cd ../backend
+```
+
+### 步骤4: 配置环境变量
+
+#### 4.1 复制环境变量文件
+```bash
+cp .env.example .env
+```
+
+#### 4.2 编辑.env文件
+```env
+# API配置
+API_HOST=0.0.0.0
+API_PORT=8000
+
+# LLM配置 - 请填写你的API密钥
+LLM_API_KEY=sk-your-api-key-here
+LLM_BASE_URL=https://api.openai.com/v1
+LLM_MODEL=gpt-4
+
+# NPC更新间隔(秒)
+NPC_UPDATE_INTERVAL=30
+```
+
+**重要:** 请将 `LLM_API_KEY` 替换为你的实际API密钥!
+
+### 步骤5: 启动后端服务
+
+```bash
+cd backend
+python main.py
+```
+
+**预期输出:**
+```
+📝 对话日志文件: .../backend/logs/dialogue_2025-10-15.log
+📂 日志目录: .../backend/logs
+
+============================================================
+🎮 赛博小镇后端服务启动中...
+============================================================
+...
+✅ 所有服务已启动!
+📡 API地址: http://0.0.0.0:8000
+📚 API文档: http://0.0.0.0:8000/docs
+============================================================
+```
+
+### 步骤6: 打开Godot项目
+
+1. 启动Godot
+2. 点击"导入"
+3. 选择 `chapter15/Game/project.godot`
+4. 点击"导入并编辑"
+
+### 步骤7: 运行游戏
+
+1. 在Godot编辑器中,点击右上角的"运行"按钮 (或按F5)
+2. 游戏窗口应该打开
+3. 使用WASD移动,E键与NPC交互
+
+## 🎮 游戏操作
+
+- **WASD** - 移动玩家
+- **E** - 与NPC交互
+- **Enter** - 发送消息
+- **ESC** - 关闭对话框
+
+## 🧪 测试
+
+### 测试后端API
+访问: http://localhost:8000/docs
+
+### 查看对话日志
+```bash
+cd backend
+python view_logs.py tail
+```
+
+## ❓ 常见问题
+
+### Q1: 后端启动失败?
+**A:** 检查:
+1. Python版本是否>=3.10
+2. 是否激活了虚拟环境
+3. 是否安装了所有依赖
+4. .env文件是否配置正确
+
+### Q2: Godot无法打开项目?
+**A:** 检查:
+1. Godot版本是否>=4.2
+2. project.godot文件是否存在
+3. 是否选择了正确的目录
+
+### Q3: 游戏运行但无法对话?
+**A:** 检查:
+1. 后端服务是否正在运行
+2. 后端地址是否正确 (默认http://localhost:8000)
+3. 查看Godot控制台的错误信息
+
+## 🎉 开始体验!
+
+现在你可以在游戏中与NPC对话了!

+ 56 - 0
code/chapter15/Helloagents-AI-Town/backend/.env.example

@@ -0,0 +1,56 @@
+# HelloAgents LLM配置
+# 使用ModelScope API (推荐)
+LLM_MODEL_ID=Qwen/Qwen2.5-72B-Instruct
+LLM_API_KEY=your-modelscope-api-key-here
+LLM_BASE_URL=https://api-inference.modelscope.cn/v1/
+
+# 其他可选模型:
+# LLM_MODEL_ID=Qwen/Qwen2.5-7B-Instruct
+# LLM_MODEL_ID=deepseek-ai/DeepSeek-V3
+
+# 如果使用其他兼容OpenAI的服务
+# LLM_BASE_URL=https://api.deepseek.com/v1
+# LLM_MODEL_ID=deepseek-chat
+
+# ================================
+# Qdrant 向量数据库配置
+# ================================
+# 使用Qdrant云服务 (推荐)
+# https://cloud.qdrant.io/
+QDRANT_URL=https://xxxxxx.aws.cloud.qdrant.io:6333
+QDRANT_API_KEY=xxxxx
+
+# 或使用本地Qdrant (需要Docker)
+# QDRANT_URL=http://localhost:6333
+# QDRANT_API_KEY=
+
+# Qdrant集合配置
+QDRANT_COLLECTION=hello_agents_vectors
+QDRANT_VECTOR_SIZE=384
+QDRANT_DISTANCE=cosine
+QDRANT_TIMEOUT=30
+
+# ================================
+# Neo4j 图数据库配置
+# ================================
+# 使用Neo4j Aura云服务 (推荐)
+NEO4J_URI=neo4j+s://xxxxx.databases.neo4j.io
+NEO4J_USERNAME=neo4j
+NEO4J_PASSWORD=xxxxx
+
+# 或使用本地Neo4j (需要Docker)
+# NEO4J_URI=bolt://localhost:7687
+# NEO4J_USERNAME=neo4j
+# NEO4J_PASSWORD=hello-agents-password
+
+# Neo4j连接配置
+NEO4J_DATABASE=neo4j
+NEO4J_MAX_CONNECTION_LIFETIME=3600
+NEO4J_MAX_CONNECTION_POOL_SIZE=50
+NEO4J_CONNECTION_TIMEOUT=60
+
+EMBED_MODEL_TYPE="local"
+# EMBED_MODEL_NAME="text-embedding-v3"
+# # - 若为空,dashscope 默认 text-embedding-v3;local 默认 sentence-transformers/all-MiniLM-L6-v2
+# EMBED_API_KEY=""
+# EMBED_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"

+ 225 - 0
code/chapter15/Helloagents-AI-Town/backend/README.md

@@ -0,0 +1,225 @@
+# 赛博小镇 - FastAPI后端
+
+基于HelloAgents框架的AI NPC对话系统后端服务。
+
+## 🎯 功能特性
+
+### 核心功能
+- ✅ **单个NPC对话**: 玩家与NPC实时对话,使用独立Agent处理
+- ✅ **批量对话生成**: 定时批量生成所有NPC的自主对话,降低API成本66%
+- ✅ **状态管理**: 自动更新和缓存NPC状态
+- ✅ **CORS支持**: 支持Godot HTML5导出跨域访问
+
+### NPC角色
+1. **张三** - Python工程师 (工位区)
+2. **李四** - 产品经理 (会议室)
+3. **王五** - UI设计师 (休息区)
+
+## 📦 安装依赖
+
+### 1. 安装Python依赖
+```bash
+cd backend
+pip install -r requirements.txt
+```
+
+### 2. 配置环境变量
+创建`.env`文件或设置环境变量:
+
+**注意**: 如果不配置API密钥,系统将使用预设对话模式运行。
+
+## 🚀 启动服务
+
+### 方法1: 直接运行
+```bash
+python main.py
+```
+
+### 方法2: 使用uvicorn
+```bash
+uvicorn main:app --reload --host 0.0.0.0 --port 8000
+```
+
+启动成功后访问:
+- **API文档**: http://localhost:8000/docs
+- **根路径**: http://localhost:8000/
+
+## 🧪 测试API
+
+运行测试脚本:
+```bash
+python test_api.py
+```
+
+测试内容包括:
+1. ✅ 根路径访问
+2. ✅ 健康检查
+3. ✅ 获取NPC列表
+4. ✅ 获取NPC状态
+5. ✅ 与NPC对话
+6. ✅ 获取NPC详情
+7. ✅ 强制刷新状态
+
+## 📡 API接口
+
+### 1. 获取NPC列表
+```http
+GET /npcs
+```
+
+响应示例:
+```json
+{
+  "npcs": [
+    {
+      "name": "张三",
+      "title": "Python工程师",
+      "location": "工位区",
+      "activity": "写代码",
+      "available": true
+    }
+  ],
+  "total": 3
+}
+```
+
+### 2. 与NPC对话
+```http
+POST /chat
+Content-Type: application/json
+
+{
+  "npc_name": "张三",
+  "message": "你好,你在做什么?"
+}
+```
+
+响应示例:
+```json
+{
+  "npc_name": "张三",
+  "npc_title": "Python工程师",
+  "message": "你好!我正在优化一个多智能体系统的性能,挺有意思的。",
+  "success": true,
+  "timestamp": "2024-01-15T10:30:00"
+}
+```
+
+### 3. 获取NPC状态
+```http
+GET /npcs/status
+```
+
+响应示例:
+```json
+{
+  "dialogues": {
+    "张三": "终于把这个bug修复了,测试通过!",
+    "李四": "下周的产品评审会需要准备一下资料。",
+    "王五": "这个配色方案看起来不错,再调整一下细节。"
+  },
+  "last_update": "2024-01-15T10:30:00",
+  "next_update_in": 25
+}
+```
+
+### 4. 强制刷新状态
+```http
+POST /npcs/status/refresh
+```
+
+## 🏗️ 项目结构
+
+```
+backend/
+├── main.py              # FastAPI主程序
+├── config.py            # 配置文件
+├── models.py            # 数据模型(Pydantic)
+├── agents.py            # NPC Agent系统
+├── batch_generator.py   # 批量对话生成器
+├── state_manager.py     # NPC状态管理器
+├── test_api.py          # API测试脚本
+├── requirements.txt     # Python依赖
+└── README.md           # 本文件
+```
+
+## 🎨 核心设计
+
+### 批量对话生成
+为了降低API成本和延迟,系统采用批量生成策略:
+
+**传统方式**:
+- 3个NPC × 每30秒 = 6次API调用/分钟
+- 每小时: 360次调用
+
+**批量方式**:
+- 1次批量调用/30秒 = 2次API调用/分钟
+- 每小时: 120次调用
+- **成本降低66%!**
+
+### 工作流程
+```
+1. 定时器触发(30秒)
+   ↓
+2. 批量生成器构建提示词
+   ↓
+3. 一次LLM调用生成所有NPC对话
+   ↓
+4. 解析JSON响应
+   ↓
+5. 更新状态管理器缓存
+   ↓
+6. Godot客户端定时获取状态
+```
+
+## 🔧 配置说明
+
+### config.py
+```python
+# NPC更新间隔(秒)
+NPC_UPDATE_INTERVAL = 30
+
+# LLM配置
+OPENAI_MODEL = "gpt-4o-mini"  # 推荐使用mini版本降低成本
+```
+
+### 调整更新频率
+修改`config.py`中的`NPC_UPDATE_INTERVAL`:
+- 开发测试: 10秒
+- 正式运行: 30-60秒
+- 低成本模式: 120秒
+
+## 🐛 故障排查
+
+### 问题1: 启动失败
+```
+❌ LLM初始化失败
+```
+**解决**: 检查OPENAI_API_KEY环境变量是否设置
+
+### 问题2: 对话无响应
+```
+⚠️ 将使用预设对话模式
+```
+**解决**: 系统自动降级到预设对话,不影响基本功能
+
+### 问题3: CORS错误
+**解决**: 检查`config.py`中的`CORS_ORIGINS`配置
+
+## 📝 开发建议
+
+### 添加新NPC
+1. 在`agents.py`的`NPC_ROLES`中添加配置
+2. 在`batch_generator.py`的`preset_dialogues`中添加预设对话
+3. 重启服务
+
+### 自定义对话风格
+修改`agents.py`中的`create_system_prompt`函数
+
+### 调整批量生成提示词
+修改`batch_generator.py`中的`_build_batch_prompt`函数
+
+## 📄 许可证
+
+本项目遵循 HelloAgents 项目的开源协议。
+

+ 483 - 0
code/chapter15/Helloagents-AI-Town/backend/agents.py

@@ -0,0 +1,483 @@
+"""NPC Agent系统 - 支持记忆功能"""
+
+import sys
+import os
+
+# 添加HelloAgents到Python路径
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'HelloAgents'))
+
+from hello_agents import SimpleAgent, HelloAgentsLLM
+from hello_agents.memory import MemoryManager, MemoryConfig, MemoryItem
+from typing import Dict, List, Optional
+from datetime import datetime
+from relationship_manager import RelationshipManager
+from logger import (
+    log_dialogue_start, log_affinity, log_memory_retrieval,
+    log_generating_response, log_npc_response, log_analyzing_affinity,
+    log_affinity_change, log_memory_saved, log_dialogue_end, log_info
+)
+
+# NPC角色配置
+NPC_ROLES = {
+    "张三": {
+        "title": "Python工程师",
+        "location": "工位区",
+        "activity": "写代码",
+        "personality": "技术宅,喜欢讨论算法和框架",
+        "expertise": "多智能体系统、HelloAgents框架、Python开发、代码优化",
+        "style": "简洁专业,喜欢用技术术语,偶尔吐槽bug",
+        "hobbies": "看技术博客、刷LeetCode、研究新框架"
+    },
+    "李四": {
+        "title": "产品经理",
+        "location": "会议室",
+        "activity": "整理需求",
+        "personality": "外向健谈,善于沟通协调",
+        "expertise": "需求分析、产品规划、用户体验、项目管理",
+        "style": "友好热情,善于引导对话,喜欢用比喻",
+        "hobbies": "看产品分析、研究竞品、思考用户需求"
+    },
+    "王五": {
+        "title": "UI设计师",
+        "location": "休息区",
+        "activity": "喝咖啡",
+        "personality": "细腻敏感,注重美感",
+        "expertise": "界面设计、交互设计、视觉呈现、用户体验",
+        "style": "优雅简洁,喜欢用艺术化的表达,追求完美",
+        "hobbies": "看设计作品、逛Dribbble、品咖啡"
+    }
+}
+
+def create_system_prompt(name: str, role: Dict[str, str]) -> str:
+    """创建NPC的系统提示词"""
+    return f"""你是Datawhale办公室的{role['title']}{name}。
+
+【角色设定】
+- 职位: {role['title']}
+- 性格: {role['personality']}
+- 专长: {role['expertise']}
+- 说话风格: {role['style']}
+- 爱好: {role['hobbies']}
+- 当前位置: {role['location']}
+- 当前活动: {role['activity']}
+
+【行为准则】
+1. 保持角色一致性,用第一人称"我"回答
+2. 回复简洁自然,控制在30-50字以内
+3. 可以适当提及你的工作内容和兴趣爱好
+4. 对玩家友好,但保持专业和真实感
+5. 如果问题超出专长,可以推荐其他同事
+6. 偶尔展现一些个性化的小习惯或口头禅
+
+【对话示例】
+玩家: "你好,你是做什么的?"
+{name}: "你好!我是{role['title']},主要负责{role['expertise'].split('、')[0]}。最近在忙{role['activity']},挺有意思的。"
+
+玩家: "最近在做什么项目?"
+{name}: "最近在做一个多智能体系统的项目,用HelloAgents框架。你对这个感兴趣吗?"
+
+【重要】
+- 不要说"我是AI"或"我是语言模型"
+- 要像真实的办公室同事一样自然对话
+- 可以表达情绪(开心、疲惫、兴奋等)
+- 回复要有人情味,不要太机械
+"""
+
+class NPCAgentManager:
+    """NPC Agent管理器 - 支持记忆功能"""
+
+    def __init__(self):
+        """初始化所有NPC Agent"""
+        print("🤖 正在初始化NPC Agent系统...")
+
+        try:
+            self.llm = HelloAgentsLLM()
+            print("✅ LLM初始化成功")
+        except Exception as e:
+            print(f"❌ LLM初始化失败: {e}")
+            print("⚠️  将使用模拟模式运行")
+            self.llm = None
+
+        self.agents: Dict[str, SimpleAgent] = {}
+        self.memories: Dict[str, MemoryManager] = {}  # ⭐ NPC记忆管理器
+        self.relationship_manager: Optional[RelationshipManager] = None  # ⭐ 好感度管理器
+
+        # 初始化好感度管理器
+        if self.llm:
+            self.relationship_manager = RelationshipManager(self.llm)
+
+        self._create_agents()
+    
+    def _create_agents(self):
+        """创建所有NPC Agent和记忆系统"""
+        for name, role in NPC_ROLES.items():
+            try:
+                system_prompt = create_system_prompt(name, role)
+
+                if self.llm:
+                    agent = SimpleAgent(
+                        name=f"{name}-{role['title']}",
+                        llm=self.llm,
+                        system_prompt=system_prompt
+                    )
+                else:
+                    # 模拟模式
+                    agent = None
+
+                self.agents[name] = agent
+
+                # ⭐ 创建记忆管理器
+                memory_manager = self._create_memory_manager(name)
+                self.memories[name] = memory_manager
+
+                print(f"✅ {name}({role['title']}) Agent创建成功 (记忆系统已启用)")
+
+            except Exception as e:
+                print(f"❌ {name} Agent创建失败: {e}")
+                self.agents[name] = None
+                self.memories[name] = None
+
+    def _create_memory_manager(self, npc_name: str) -> MemoryManager:
+        """为NPC创建记忆管理器"""
+        # 创建记忆存储目录
+        memory_dir = os.path.join(os.path.dirname(__file__), 'memory_data', npc_name)
+        os.makedirs(memory_dir, exist_ok=True)
+
+        # 配置记忆系统
+        memory_config = MemoryConfig(
+            storage_path=memory_dir,
+            working_memory_capacity=10,  # 最近10条对话
+            working_memory_tokens=2000,  # 最多2000个token
+            episodic_memory_capacity=100,  # 最多100条长期记忆
+            enable_forgetting=True,  # 启用遗忘机制
+            forgetting_threshold=0.3  # 重要性低于0.3的记忆会被遗忘
+        )
+
+        # 创建记忆管理器
+        memory_manager = MemoryManager(
+            config=memory_config,
+            user_id=npc_name,  # 使用NPC名字作为user_id
+            enable_working=True,  # 启用工作记忆 (短期)
+            enable_episodic=True,  # 启用情景记忆 (长期)
+            enable_semantic=False,  # 不需要语义记忆
+            enable_perceptual=False  # 不需要感知记忆
+        )
+
+        print(f"  💾 {npc_name}的记忆系统已初始化 (存储路径: {memory_dir})")
+
+        return memory_manager
+    
+    def chat(self, npc_name: str, message: str, player_id: str = "player") -> str:
+        """与指定NPC对话 (支持记忆功能和好感度系统)"""
+        if npc_name not in self.agents:
+            return f"错误: NPC '{npc_name}' 不存在"
+
+        agent = self.agents[npc_name]
+        memory_manager = self.memories.get(npc_name)
+
+        if agent is None:
+            # 模拟模式回复
+            role = NPC_ROLES[npc_name]
+            return f"你好!我是{npc_name},一名{role['title']}。(当前为模拟模式,请配置API_KEY以启用AI对话)"
+
+        try:
+            # 记录对话开始 ⭐ 使用日志系统
+            log_dialogue_start(npc_name, message)
+
+            # ⭐ 1. 获取当前好感度
+            affinity_context = ""
+            if self.relationship_manager:
+                affinity = self.relationship_manager.get_affinity(npc_name, player_id)
+                affinity_level = self.relationship_manager.get_affinity_level(affinity)
+                affinity_modifier = self.relationship_manager.get_affinity_modifier(affinity)
+
+                affinity_context = f"""【当前关系】
+你与玩家的关系: {affinity_level} (好感度: {affinity:.0f}/100)
+【对话风格】{affinity_modifier}
+
+"""
+                log_affinity(npc_name, affinity, affinity_level)
+
+            # ⭐ 2. 检索相关记忆
+            relevant_memories = []
+            if memory_manager:
+                relevant_memories = memory_manager.retrieve_memories(
+                    query=message,
+                    memory_types=["working", "episodic"],
+                    limit=5,
+                    min_importance=0.3  # 只检索重要性>=0.3的记忆
+                )
+                log_memory_retrieval(npc_name, len(relevant_memories), relevant_memories)
+
+            # ⭐ 3. 构建增强的提示词 (包含好感度和记忆上下文)
+            memory_context = self._build_memory_context(relevant_memories)
+
+            enhanced_message = affinity_context
+            if memory_context:
+                enhanced_message += f"{memory_context}\n\n"
+            enhanced_message += f"【当前对话】\n玩家: {message}"
+
+            # ⭐ 4. 调用Agent生成回复
+            log_generating_response()
+            response = agent.run(enhanced_message)
+            log_npc_response(npc_name, response)
+
+            # ⭐ 5. 分析并更新好感度
+            log_analyzing_affinity()
+            if self.relationship_manager:
+                affinity_result = self.relationship_manager.analyze_and_update_affinity(
+                    npc_name=npc_name,
+                    player_message=message,
+                    npc_response=response,
+                    player_id=player_id
+                )
+
+                # 记录好感度变化详情 ⭐ 使用日志系统
+                log_affinity_change(affinity_result)
+            else:
+                affinity_result = {"changed": False, "affinity": 50.0}
+
+            # ⭐ 6. 保存对话到记忆 (包含好感度信息)
+            if memory_manager:
+                self._save_conversation_to_memory(
+                    memory_manager=memory_manager,
+                    npc_name=npc_name,
+                    player_message=message,
+                    npc_response=response,
+                    player_id=player_id,
+                    affinity_info=affinity_result
+                )
+                log_memory_saved(npc_name)
+
+            # 记录对话结束 ⭐ 使用日志系统
+            log_dialogue_end()
+
+            return response
+
+        except Exception as e:
+            print(f"❌ {npc_name}对话失败: {e}")
+            import traceback
+            traceback.print_exc()
+            return f"抱歉,我现在有点忙,等会儿再聊吧。(错误: {str(e)})"
+    
+    def _build_memory_context(self, memories: List[MemoryItem]) -> str:
+        """构建记忆上下文"""
+        if not memories:
+            return ""
+
+        context_parts = ["【之前的对话记忆】"]
+        for memory in memories:
+            # 格式化时间
+            time_str = memory.timestamp.strftime("%H:%M")
+            # 添加记忆内容
+            context_parts.append(f"[{time_str}] {memory.content}")
+
+        context_parts.append("")  # 空行分隔
+        return "\n".join(context_parts)
+
+    def _save_conversation_to_memory(
+        self,
+        memory_manager: MemoryManager,
+        npc_name: str,
+        player_message: str,
+        npc_response: str,
+        player_id: str,
+        affinity_info: Optional[Dict] = None
+    ):
+        """保存对话到记忆系统 (包含好感度信息)"""
+        current_time = datetime.now()
+
+        # 获取好感度信息
+        affinity = affinity_info.get("new_affinity", affinity_info.get("affinity", 50.0)) if affinity_info else 50.0
+        affinity_change = affinity_info.get("change_amount", 0) if affinity_info else 0
+        sentiment = affinity_info.get("sentiment", "neutral") if affinity_info else "neutral"
+
+        # 保存玩家消息
+        memory_manager.add_memory(
+            content=f"玩家说: {player_message}",
+            memory_type="working",  # 先存入工作记忆
+            importance=0.5,  # 中等重要性
+            metadata={
+                "speaker": "player",
+                "player_id": player_id,
+                "session_id": player_id,
+                "timestamp": current_time.isoformat(),
+                "affinity": affinity,  # ⭐ 记录当时的好感度
+                "affinity_change": affinity_change,  # ⭐ 记录好感度变化
+                "sentiment": sentiment,  # ⭐ 记录情感倾向
+                "context": {
+                    "interaction_type": "dialogue",
+                    "npc_name": npc_name
+                }
+            }
+        )
+
+        # 保存NPC回复
+        memory_manager.add_memory(
+            content=f"我说: {npc_response}",
+            memory_type="working",  # 先存入工作记忆
+            importance=0.6,  # 稍高重要性
+            metadata={
+                "speaker": npc_name,
+                "player_id": player_id,
+                "session_id": player_id,
+                "timestamp": current_time.isoformat(),
+                "affinity": affinity,  # ⭐ 记录当时的好感度
+                "sentiment": sentiment,  # ⭐ 记录情感倾向
+                "context": {
+                    "interaction_type": "dialogue",
+                    "npc_name": npc_name
+                }
+            }
+        )
+
+        print(f"  💾 对话已保存到{npc_name}的记忆中")
+
+    def get_npc_info(self, npc_name: str) -> Dict[str, str]:
+        """获取NPC信息"""
+        if npc_name not in NPC_ROLES:
+            return {}
+
+        role = NPC_ROLES[npc_name]
+        return {
+            "name": npc_name,
+            "title": role["title"],
+            "location": role["location"],
+            "activity": role["activity"],
+            "available": self.agents.get(npc_name) is not None
+        }
+    
+    def get_all_npcs(self) -> list:
+        """获取所有NPC信息"""
+        return [self.get_npc_info(name) for name in NPC_ROLES.keys()]
+
+    def get_npc_memories(self, npc_name: str, player_id: str = "player", limit: int = 10) -> List[Dict]:
+        """获取NPC的记忆列表 (用于调试和展示)"""
+        if npc_name not in self.memories:
+            return []
+
+        memory_manager = self.memories[npc_name]
+        if not memory_manager:
+            return []
+
+        try:
+            # 检索所有记忆
+            memories = memory_manager.retrieve_memories(
+                query="",  # 空查询返回所有记忆
+                memory_types=["working", "episodic"],
+                limit=limit
+            )
+
+            # 转换为字典格式
+            memory_list = []
+            for memory in memories:
+                memory_list.append({
+                    "id": memory.id,
+                    "content": memory.content,
+                    "type": memory.memory_type,
+                    "importance": memory.importance,
+                    "timestamp": memory.timestamp.isoformat(),
+                    "metadata": memory.metadata
+                })
+
+            return memory_list
+
+        except Exception as e:
+            print(f"❌ 获取{npc_name}记忆失败: {e}")
+            return []
+
+    def clear_npc_memory(self, npc_name: str, memory_type: Optional[str] = None):
+        """清空NPC的记忆 (用于测试)"""
+        if npc_name not in self.memories:
+            print(f"❌ NPC '{npc_name}' 不存在")
+            return
+
+        memory_manager = self.memories[npc_name]
+        if not memory_manager:
+            print(f"❌ {npc_name}没有记忆系统")
+            return
+
+        try:
+            if memory_type:
+                # 清空指定类型的记忆
+                memory_manager.clear_memory_type(memory_type)
+                print(f"✅ 已清空{npc_name}的{memory_type}记忆")
+            else:
+                # 清空所有记忆
+                for mem_type in ["working", "episodic"]:
+                    try:
+                        memory_manager.clear_memory_type(mem_type)
+                    except:
+                        pass
+                print(f"✅ 已清空{npc_name}的所有记忆")
+
+        except Exception as e:
+            print(f"❌ 清空{npc_name}记忆失败: {e}")
+
+    def get_npc_affinity(self, npc_name: str, player_id: str = "player") -> Dict:
+        """获取NPC对玩家的好感度信息
+
+        Args:
+            npc_name: NPC名称
+            player_id: 玩家ID
+
+        Returns:
+            好感度信息字典
+        """
+        if not self.relationship_manager:
+            return {
+                "affinity": 50.0,
+                "level": "熟悉",
+                "modifier": "礼貌友善,正常交流,保持专业"
+            }
+
+        affinity = self.relationship_manager.get_affinity(npc_name, player_id)
+        level = self.relationship_manager.get_affinity_level(affinity)
+        modifier = self.relationship_manager.get_affinity_modifier(affinity)
+
+        return {
+            "affinity": affinity,
+            "level": level,
+            "modifier": modifier
+        }
+
+    def get_all_affinities(self, player_id: str = "player") -> Dict[str, Dict]:
+        """获取所有NPC的好感度信息
+
+        Args:
+            player_id: 玩家ID
+
+        Returns:
+            所有NPC的好感度信息
+        """
+        if not self.relationship_manager:
+            return {}
+
+        return self.relationship_manager.get_all_affinities(player_id)
+
+    def set_npc_affinity(self, npc_name: str, affinity: float, player_id: str = "player"):
+        """设置NPC对玩家的好感度 (用于测试)
+
+        Args:
+            npc_name: NPC名称
+            affinity: 好感度值 (0-100)
+            player_id: 玩家ID
+        """
+        if not self.relationship_manager:
+            print("❌ 好感度系统未初始化")
+            return
+
+        self.relationship_manager.set_affinity(npc_name, affinity, player_id)
+        level = self.relationship_manager.get_affinity_level(affinity)
+        print(f"✅ 已设置{npc_name}对玩家的好感度: {affinity:.1f} ({level})")
+
+# 全局单例
+_npc_manager = None
+
+def get_npc_manager() -> NPCAgentManager:
+    """获取NPC管理器单例"""
+    global _npc_manager
+    if _npc_manager is None:
+        _npc_manager = NPCAgentManager()
+    return _npc_manager
+

+ 211 - 0
code/chapter15/Helloagents-AI-Town/backend/batch_generator.py

@@ -0,0 +1,211 @@
+"""批量NPC对话生成器"""
+
+import sys
+import os
+import json
+from datetime import datetime
+from typing import Dict, Optional
+
+# 添加HelloAgents到Python路径
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'HelloAgents'))
+
+from hello_agents import HelloAgentsLLM
+from agents import NPC_ROLES
+
+class NPCBatchGenerator:
+    """批量生成NPC对话的生成器
+    
+    核心思路: 一次LLM调用生成所有NPC的对话,降低API成本和延迟
+    """
+    
+    def __init__(self):
+        """初始化批量生成器"""
+        print("🎨 正在初始化批量对话生成器...")
+        
+        try:
+            self.llm = HelloAgentsLLM()
+            self.enabled = True
+            print("✅ 批量生成器初始化成功")
+        except Exception as e:
+            print(f"❌ 批量生成器初始化失败: {e}")
+            print("⚠️  将使用预设对话模式")
+            self.llm = None
+            self.enabled = False
+        
+        self.npc_configs = NPC_ROLES
+        
+        # 预设对话库(当LLM不可用时使用)
+        self.preset_dialogues = {
+            "morning": {
+                "张三": "早上好!今天要继续优化那个多智能体系统的性能。",
+                "李四": "新的一天开始了,先整理一下今天的会议安排。",
+                "王五": "早!先来杯咖啡提提神,然后开始设计新界面。"
+            },
+            "noon": {
+                "张三": "写了一上午代码,终于把那个bug修复了!",
+                "李四": "上午的需求评审会很顺利,下午继续推进。",
+                "王五": "这个配色方案看起来不错,再调整一下细节。"
+            },
+            "afternoon": {
+                "张三": "下午继续写代码,这个算法还需要优化一下。",
+                "李四": "正在准备下周的产品规划会,需求文档快完成了。",
+                "王五": "设计稿基本完成了,等会儿发给大家看看。"
+            },
+            "evening": {
+                "张三": "今天的代码提交完成,明天继续!",
+                "李四": "今天的工作差不多了,整理一下明天的待办事项。",
+                "王五": "设计工作告一段落,明天再继续优化。"
+            }
+        }
+    
+    def generate_batch_dialogues(self, context: Optional[str] = None) -> Dict[str, str]:
+        """批量生成所有NPC的对话
+        
+        Args:
+            context: 场景上下文(如"上午工作时间"、"午餐时间"等)
+        
+        Returns:
+            Dict[str, str]: NPC名称到对话内容的映射
+        """
+        if not self.enabled or self.llm is None:
+            # 使用预设对话
+            return self._get_preset_dialogues()
+        
+        try:
+            # 构建批量生成提示词
+            prompt = self._build_batch_prompt(context)
+
+            # 一次LLM调用生成所有对话
+            # 使用invoke方法而不是chat方法
+            response = self.llm.invoke([
+                {"role": "system", "content": "你是一个游戏NPC对话生成器,擅长创作自然真实的办公室对话。"},
+                {"role": "user", "content": prompt}
+            ])
+
+            # 解析JSON响应
+            dialogues = self._parse_response(response)
+
+            if dialogues:
+                print(f"✅ 批量生成成功: {len(dialogues)}个NPC对话")
+                return dialogues
+            else:
+                print("⚠️  解析失败,使用预设对话")
+                return self._get_preset_dialogues()
+
+        except Exception as e:
+            print(f"❌ 批量生成失败: {e}")
+            return self._get_preset_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. 可以体现一些个性化特点和情绪
+6. **必须严格按照JSON格式返回**
+
+【输出格式】(严格遵守)
+{{"张三": "...", "李四": "...", "王五": "..."}}
+
+【示例输出】
+{{"张三": "这个bug真是见鬼了,已经调试两小时了...", "李四": "嗯,这个功能的优先级需要重新评估一下。", "王五": "这杯咖啡的拉花真不错,灵感来了!"}}
+
+请生成(只返回JSON,不要其他内容):
+"""
+        return prompt
+    
+    def _parse_response(self, response: str) -> Optional[Dict[str, str]]:
+        """解析LLM响应"""
+        try:
+            # 尝试直接解析JSON
+            dialogues = json.loads(response)
+            
+            # 验证格式
+            if isinstance(dialogues, dict) and all(name in dialogues for name in self.npc_configs.keys()):
+                return dialogues
+            else:
+                print(f"⚠️  JSON格式不正确: {dialogues}")
+                return None
+                
+        except json.JSONDecodeError:
+            # 尝试提取JSON部分
+            try:
+                # 查找第一个{和最后一个}
+                start = response.find('{')
+                end = response.rfind('}') + 1
+                
+                if start != -1 and end > start:
+                    json_str = response[start:end]
+                    dialogues = json.loads(json_str)
+                    
+                    if isinstance(dialogues, dict):
+                        return dialogues
+            except:
+                pass
+            
+            print(f"⚠️  无法解析响应: {response[:100]}...")
+            return None
+    
+    def _get_current_context(self) -> str:
+        """根据当前时间推断场景上下文"""
+        hour = datetime.now().hour
+        
+        if 6 <= hour < 9:
+            return "清晨时分,大家陆续到达办公室,准备开始新的一天"
+        elif 9 <= hour < 12:
+            return "上午工作时间,大家都在专注工作,办公室氛围专注而忙碌"
+        elif 12 <= hour < 14:
+            return "午餐时间,大家在休息放松,聊聊天或者看看手机"
+        elif 14 <= hour < 17:
+            return "下午工作时间,继续推进项目,偶尔需要喝杯咖啡提神"
+        elif 17 <= hour < 19:
+            return "傍晚时分,准备收尾今天的工作,整理明天的计划"
+        else:
+            return "夜晚时分,办公室安静下来,偶尔还有人在加班"
+    
+    def _get_preset_dialogues(self) -> Dict[str, str]:
+        """获取预设对话(根据时间)"""
+        hour = datetime.now().hour
+        
+        if 6 <= hour < 12:
+            period = "morning"
+        elif 12 <= hour < 14:
+            period = "noon"
+        elif 14 <= hour < 18:
+            period = "afternoon"
+        else:
+            period = "evening"
+        
+        return self.preset_dialogues.get(period, self.preset_dialogues["morning"])
+
+# 全局单例
+_batch_generator = None
+
+def get_batch_generator() -> NPCBatchGenerator:
+    """获取批量生成器单例"""
+    global _batch_generator
+    if _batch_generator is None:
+        _batch_generator = NPCBatchGenerator()
+    return _batch_generator
+

+ 42 - 0
code/chapter15/Helloagents-AI-Town/backend/config.py

@@ -0,0 +1,42 @@
+"""配置文件"""
+
+import os
+from typing import Optional
+
+class Settings:
+    """应用配置"""
+    
+    # API配置
+    API_TITLE = "赛博小镇 API"
+    API_VERSION = "1.0.0"
+    API_HOST = "0.0.0.0"
+    API_PORT = 8000
+    
+    # NPC配置
+    NPC_UPDATE_INTERVAL = 30  # NPC状态更新间隔(秒)
+    
+    # LLM配置 (从环境变量读取)
+    # HelloAgents框架使用自定义LLM配置,不需要OPENAI_API_KEY
+    LLM_MODEL_ID: str = os.getenv("LLM_MODEL_ID", "Qwen/Qwen2.5-72B-Instruct")
+    LLM_API_KEY: Optional[str] = os.getenv("LLM_API_KEY")
+    LLM_BASE_URL: str = os.getenv("LLM_BASE_URL", "https://api-inference.modelscope.cn/v1/")
+
+    # CORS配置
+    CORS_ORIGINS = ["*"]  # 生产环境应限制具体域名
+
+    @classmethod
+    def validate(cls):
+        """验证配置"""
+        if not cls.LLM_API_KEY:
+            print("⚠️  警告: 未设置LLM_API_KEY环境变量")
+            print("   请在.env文件中配置LLM_API_KEY")
+            print("   示例: LLM_API_KEY=\"your-api-key\"")
+            return False
+
+        print(f"✅ LLM配置:")
+        print(f"   模型: {cls.LLM_MODEL_ID}")
+        print(f"   服务地址: {cls.LLM_BASE_URL}")
+        return True
+
+settings = Settings()
+

+ 115 - 0
code/chapter15/Helloagents-AI-Town/backend/logger.py

@@ -0,0 +1,115 @@
+"""对话日志系统"""
+
+import logging
+import os
+from datetime import datetime
+from pathlib import Path
+
+# 创建logs目录
+LOGS_DIR = Path(__file__).parent / "logs"
+LOGS_DIR.mkdir(exist_ok=True)
+
+# 创建日志文件名 (按日期)
+today = datetime.now().strftime("%Y-%m-%d")
+LOG_FILE = LOGS_DIR / f"dialogue_{today}.log"
+
+# 配置日志格式
+LOG_FORMAT = "%(asctime)s - %(message)s"
+DATE_FORMAT = "%H:%M:%S"
+
+# 创建logger
+dialogue_logger = logging.getLogger("dialogue")
+dialogue_logger.setLevel(logging.INFO)
+
+# 移除已有的handlers (避免重复)
+dialogue_logger.handlers.clear()
+
+# 创建文件handler
+file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
+file_handler.setLevel(logging.INFO)
+file_handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT))
+
+# 创建控制台handler
+console_handler = logging.StreamHandler()
+console_handler.setLevel(logging.INFO)
+console_handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT))
+
+# 添加handlers
+dialogue_logger.addHandler(file_handler)
+dialogue_logger.addHandler(console_handler)
+
+# 防止日志传播到root logger
+dialogue_logger.propagate = False
+
+def log_dialogue_start(npc_name: str, player_message: str):
+    """记录对话开始"""
+    dialogue_logger.info("=" * 60)
+    dialogue_logger.info(f"💬 对话开始: {npc_name} <-> 玩家")
+    dialogue_logger.info("=" * 60)
+    dialogue_logger.info(f"📝 玩家消息: {player_message}")
+
+def log_affinity(npc_name: str, affinity: float, level: str):
+    """记录当前好感度"""
+    dialogue_logger.info(f"💖 当前好感度: {affinity:.1f}/100 ({level})")
+
+def log_memory_retrieval(npc_name: str, count: int, memories: list = None):
+    """记录记忆检索"""
+    dialogue_logger.info(f"🧠 检索到{count}条相关记忆")
+    if memories:
+        dialogue_logger.info("  📚 相关记忆:")
+        for i, mem in enumerate(memories[:3], 1):
+            content = mem.content[:50] + "..." if len(mem.content) > 50 else mem.content
+            dialogue_logger.info(f"    {i}. {content}")
+
+def log_generating_response():
+    """记录正在生成回复"""
+    dialogue_logger.info("🤖 正在生成回复...")
+
+def log_npc_response(npc_name: str, response: str):
+    """记录NPC回复"""
+    dialogue_logger.info(f"💬 {npc_name}回复: {response}")
+
+def log_analyzing_affinity():
+    """记录正在分析好感度"""
+    dialogue_logger.info("📊 正在分析好感度变化...")
+
+def log_affinity_change(affinity_result: dict):
+    """记录好感度变化"""
+    if affinity_result.get("changed"):
+        change_symbol = "📈" if affinity_result["change_amount"] > 0 else "📉"
+        dialogue_logger.info(
+            f"{change_symbol} 好感度变化: {affinity_result['old_affinity']:.1f} -> "
+            f"{affinity_result['new_affinity']:.1f} ({affinity_result['change_amount']:+.1f})"
+        )
+        dialogue_logger.info(f"  原因: {affinity_result['reason']}")
+        dialogue_logger.info(f"  情感: {affinity_result['sentiment']}")
+        
+        if affinity_result['old_level'] != affinity_result['new_level']:
+            dialogue_logger.info(
+                f"  🎉 关系等级变化: {affinity_result['old_level']} -> {affinity_result['new_level']}"
+            )
+    else:
+        dialogue_logger.info(f"  ➡️ 好感度未变化 (当前: {affinity_result.get('affinity', 50.0):.1f})")
+        dialogue_logger.info(f"  原因: {affinity_result.get('reason', '无')}")
+
+def log_memory_saved(npc_name: str):
+    """记录记忆保存"""
+    dialogue_logger.info(f"  💾 对话已保存到{npc_name}的记忆中")
+
+def log_dialogue_end():
+    """记录对话结束"""
+    dialogue_logger.info("=" * 60)
+    dialogue_logger.info("✅ 对话完成\n")
+
+def log_info(message: str):
+    """记录普通信息"""
+    dialogue_logger.info(message)
+
+def log_error(message: str):
+    """记录错误信息"""
+    dialogue_logger.error(message)
+
+# 启动时记录日志文件位置
+print(f"\n📝 对话日志文件: {LOG_FILE}")
+print(f"📂 日志目录: {LOGS_DIR}\n")
+

+ 393 - 0
code/chapter15/Helloagents-AI-Town/backend/main.py

@@ -0,0 +1,393 @@
+"""赛博小镇 FastAPI 后端主程序"""
+
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from contextlib import asynccontextmanager
+import uvicorn
+
+from config import settings
+from models import (
+    ChatRequest, ChatResponse, 
+    NPCStatusResponse, NPCListResponse, NPCInfo
+)
+from agents import get_npc_manager
+from state_manager import get_state_manager
+
+# 生命周期管理
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    """应用生命周期管理"""
+    # 启动时
+    print("\n" + "="*60)
+    print("🎮 赛博小镇后端服务启动中...")
+    print("="*60)
+    
+    # 验证配置
+    settings.validate()
+    
+    # 初始化NPC管理器
+    npc_manager = get_npc_manager()
+    
+    # 初始化并启动状态管理器
+    state_manager = get_state_manager(settings.NPC_UPDATE_INTERVAL)
+    await state_manager.start()
+    
+    print("\n✅ 所有服务已启动!")
+    print(f"📡 API地址: http://{settings.API_HOST}:{settings.API_PORT}")
+    print(f"📚 API文档: http://{settings.API_HOST}:{settings.API_PORT}/docs")
+    print("="*60 + "\n")
+    
+    yield
+    
+    # 关闭时
+    print("\n🛑 正在关闭服务...")
+    await state_manager.stop()
+    print("✅ 服务已关闭\n")
+
+# 创建FastAPI应用
+app = FastAPI(
+    title=settings.API_TITLE,
+    version=settings.API_VERSION,
+    description="赛博小镇 - 基于HelloAgents的AI NPC对话系统",
+    lifespan=lifespan
+)
+
+# CORS配置
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=settings.CORS_ORIGINS,
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+# 获取全局实例
+npc_manager = None
+state_manager = None
+
+def get_managers():
+    """获取管理器实例"""
+    global npc_manager, state_manager
+    if npc_manager is None:
+        npc_manager = get_npc_manager()
+    if state_manager is None:
+        state_manager = get_state_manager()
+    return npc_manager, state_manager
+
+# ==================== API路由 ====================
+
+@app.get("/")
+async def root():
+    """根路径 - API信息"""
+    return {
+        "service": settings.API_TITLE,
+        "version": settings.API_VERSION,
+        "status": "running",
+        "features": ["AI对话", "NPC记忆系统", "好感度系统", "批量状态更新"],
+        "endpoints": {
+            "docs": "/docs",
+            "chat": "/chat",
+            "npcs": "/npcs",
+            "npcs_status": "/npcs/status",
+            "npc_memories": "/npcs/{npc_name}/memories",
+            "npc_affinity": "/npcs/{npc_name}/affinity",
+            "all_affinities": "/affinities"
+        }
+    }
+
+@app.get("/health")
+async def health_check():
+    """健康检查"""
+    return {"status": "healthy", "timestamp": "now"}
+
+@app.post("/chat", response_model=ChatResponse)
+async def chat_with_npc(request: ChatRequest):
+    """与NPC对话接口
+    
+    玩家与指定NPC进行实时对话,使用独立的Agent处理
+    """
+    npc_mgr, _ = get_managers()
+    
+    # 验证NPC是否存在
+    npc_info = npc_mgr.get_npc_info(request.npc_name)
+    if not npc_info:
+        raise HTTPException(
+            status_code=404,
+            detail=f"NPC '{request.npc_name}' 不存在"
+        )
+    
+    try:
+        # 调用NPC Agent处理对话
+        response_text = npc_mgr.chat(request.npc_name, request.message)
+        
+        return ChatResponse(
+            npc_name=request.npc_name,
+            npc_title=npc_info["title"],
+            message=response_text,
+            success=True
+        )
+        
+    except Exception as e:
+        raise HTTPException(
+            status_code=500,
+            detail=f"对话处理失败: {str(e)}"
+        )
+
+@app.get("/npcs", response_model=NPCListResponse)
+async def list_npcs():
+    """获取所有NPC列表"""
+    npc_mgr, _ = get_managers()
+    
+    npcs_data = npc_mgr.get_all_npcs()
+    npcs = [NPCInfo(**npc) for npc in npcs_data]
+    
+    return NPCListResponse(
+        npcs=npcs,
+        total=len(npcs)
+    )
+
+@app.get("/npcs/status", response_model=NPCStatusResponse)
+async def get_npcs_status():
+    """获取所有NPC的当前状态
+    
+    返回批量生成的NPC对话内容,用于显示NPC的自主行为
+    """
+    _, state_mgr = get_managers()
+    
+    state = state_mgr.get_current_state()
+    
+    return NPCStatusResponse(
+        dialogues=state["dialogues"],
+        last_update=state["last_update"],
+        next_update_in=state["next_update_in"]
+    )
+
+@app.post("/npcs/status/refresh")
+async def refresh_npcs_status():
+    """强制刷新NPC状态
+    
+    立即触发一次批量对话生成
+    """
+    _, state_mgr = get_managers()
+    
+    await state_mgr.force_update()
+    state = state_mgr.get_current_state()
+    
+    return {
+        "message": "NPC状态已刷新",
+        "dialogues": state["dialogues"]
+    }
+
+@app.get("/npcs/{npc_name}")
+async def get_npc_info(npc_name: str):
+    """获取指定NPC的详细信息"""
+    npc_mgr, state_mgr = get_managers()
+
+    npc_info = npc_mgr.get_npc_info(npc_name)
+    if not npc_info:
+        raise HTTPException(
+            status_code=404,
+            detail=f"NPC '{npc_name}' 不存在"
+        )
+
+    # 添加当前对话
+    current_dialogue = state_mgr.get_npc_dialogue(npc_name)
+    npc_info["current_dialogue"] = current_dialogue
+
+    return npc_info
+
+@app.get("/npcs/{npc_name}/memories")
+async def get_npc_memories(npc_name: str, limit: int = 10):
+    """获取NPC的记忆列表
+
+    Args:
+        npc_name: NPC名称
+        limit: 返回的记忆数量限制 (默认10条)
+
+    Returns:
+        NPC的记忆列表
+    """
+    npc_mgr, _ = get_managers()
+
+    # 验证NPC是否存在
+    npc_info = npc_mgr.get_npc_info(npc_name)
+    if not npc_info:
+        raise HTTPException(
+            status_code=404,
+            detail=f"NPC '{npc_name}' 不存在"
+        )
+
+    try:
+        memories = npc_mgr.get_npc_memories(npc_name, limit=limit)
+
+        return {
+            "npc_name": npc_name,
+            "memories": memories,
+            "total": len(memories)
+        }
+
+    except Exception as e:
+        raise HTTPException(
+            status_code=500,
+            detail=f"获取记忆失败: {str(e)}"
+        )
+
+@app.delete("/npcs/{npc_name}/memories")
+async def clear_npc_memories(npc_name: str, memory_type: str = None):
+    """清空NPC的记忆 (用于测试)
+
+    Args:
+        npc_name: NPC名称
+        memory_type: 记忆类型 (working/episodic), 不指定则清空所有
+
+    Returns:
+        操作结果
+    """
+    npc_mgr, _ = get_managers()
+
+    # 验证NPC是否存在
+    npc_info = npc_mgr.get_npc_info(npc_name)
+    if not npc_info:
+        raise HTTPException(
+            status_code=404,
+            detail=f"NPC '{npc_name}' 不存在"
+        )
+
+    try:
+        npc_mgr.clear_npc_memory(npc_name, memory_type)
+
+        return {
+            "message": f"已清空{npc_name}的记忆",
+            "npc_name": npc_name,
+            "memory_type": memory_type or "all"
+        }
+
+    except Exception as e:
+        raise HTTPException(
+            status_code=500,
+            detail=f"清空记忆失败: {str(e)}"
+        )
+
+@app.get("/npcs/{npc_name}/affinity")
+async def get_npc_affinity(npc_name: str, player_id: str = "player"):
+    """获取NPC对玩家的好感度
+
+    Args:
+        npc_name: NPC名称
+        player_id: 玩家ID (默认为"player")
+
+    Returns:
+        好感度信息
+    """
+    npc_mgr, _ = get_managers()
+
+    # 验证NPC是否存在
+    npc_info = npc_mgr.get_npc_info(npc_name)
+    if not npc_info:
+        raise HTTPException(
+            status_code=404,
+            detail=f"NPC '{npc_name}' 不存在"
+        )
+
+    try:
+        affinity_info = npc_mgr.get_npc_affinity(npc_name, player_id)
+
+        return {
+            "npc_name": npc_name,
+            "player_id": player_id,
+            **affinity_info
+        }
+
+    except Exception as e:
+        raise HTTPException(
+            status_code=500,
+            detail=f"获取好感度失败: {str(e)}"
+        )
+
+@app.get("/affinities")
+async def get_all_affinities(player_id: str = "player"):
+    """获取所有NPC对玩家的好感度
+
+    Args:
+        player_id: 玩家ID (默认为"player")
+
+    Returns:
+        所有NPC的好感度信息
+    """
+    npc_mgr, _ = get_managers()
+
+    try:
+        affinities = npc_mgr.get_all_affinities(player_id)
+
+        return {
+            "player_id": player_id,
+            "affinities": affinities
+        }
+
+    except Exception as e:
+        raise HTTPException(
+            status_code=500,
+            detail=f"获取好感度失败: {str(e)}"
+        )
+
+@app.put("/npcs/{npc_name}/affinity")
+async def set_npc_affinity(npc_name: str, affinity: float, player_id: str = "player"):
+    """设置NPC对玩家的好感度 (用于测试)
+
+    Args:
+        npc_name: NPC名称
+        affinity: 好感度值 (0-100)
+        player_id: 玩家ID (默认为"player")
+
+    Returns:
+        操作结果
+    """
+    npc_mgr, _ = get_managers()
+
+    # 验证NPC是否存在
+    npc_info = npc_mgr.get_npc_info(npc_name)
+    if not npc_info:
+        raise HTTPException(
+            status_code=404,
+            detail=f"NPC '{npc_name}' 不存在"
+        )
+
+    # 验证好感度范围
+    if affinity < 0 or affinity > 100:
+        raise HTTPException(
+            status_code=400,
+            detail="好感度必须在0-100之间"
+        )
+
+    try:
+        npc_mgr.set_npc_affinity(npc_name, affinity, player_id)
+        affinity_info = npc_mgr.get_npc_affinity(npc_name, player_id)
+
+        return {
+            "message": f"已设置{npc_name}对玩家的好感度",
+            "npc_name": npc_name,
+            "player_id": player_id,
+            **affinity_info
+        }
+
+    except Exception as e:
+        raise HTTPException(
+            status_code=500,
+            detail=f"设置好感度失败: {str(e)}"
+        )
+
+# ==================== 主程序入口 ====================
+
+if __name__ == "__main__":
+    print("\n🚀 启动赛博小镇后端服务...")
+    print(f"📍 监听地址: {settings.API_HOST}:{settings.API_PORT}")
+    print(f"📖 访问文档: http://localhost:{settings.API_PORT}/docs\n")
+    
+    uvicorn.run(
+        "main:app",
+        host=settings.API_HOST,
+        port=settings.API_PORT,
+        reload=True,  # 开发模式自动重载
+        log_level="info"
+    )
+

BIN
code/chapter15/Helloagents-AI-Town/backend/memory_data/张三/memory.db


BIN
code/chapter15/Helloagents-AI-Town/backend/memory_data/李四/memory.db


BIN
code/chapter15/Helloagents-AI-Town/backend/memory_data/王五/memory.db


+ 69 - 0
code/chapter15/Helloagents-AI-Town/backend/models.py

@@ -0,0 +1,69 @@
+"""数据模型定义"""
+
+from pydantic import BaseModel, Field
+from typing import Dict, List, Optional
+from datetime import datetime
+
+class ChatRequest(BaseModel):
+    """单个NPC对话请求"""
+    npc_name: str = Field(..., description="NPC名称")
+    message: str = Field(..., description="玩家消息")
+    
+    class Config:
+        json_schema_extra = {
+            "example": {
+                "npc_name": "张三",
+                "message": "你好,你在做什么?"
+            }
+        }
+
+class ChatResponse(BaseModel):
+    """单个NPC对话响应"""
+    npc_name: str = Field(..., description="NPC名称")
+    npc_title: str = Field(..., description="NPC职位")
+    message: str = Field(..., description="NPC回复")
+    success: bool = Field(default=True, description="是否成功")
+    timestamp: Optional[datetime] = Field(default_factory=datetime.now, description="时间戳")
+    
+    class Config:
+        json_schema_extra = {
+            "example": {
+                "npc_name": "张三",
+                "npc_title": "Python工程师",
+                "message": "你好!我正在写代码,调试一个多智能体系统的bug。",
+                "success": True
+            }
+        }
+
+class NPCInfo(BaseModel):
+    """NPC信息"""
+    name: str = Field(..., description="NPC名称")
+    title: str = Field(..., description="NPC职位")
+    location: str = Field(..., description="NPC位置")
+    activity: str = Field(..., description="当前活动")
+    available: bool = Field(default=True, description="是否可对话")
+
+class NPCStatusResponse(BaseModel):
+    """NPC状态响应"""
+    dialogues: Dict[str, str] = Field(..., description="NPC当前对话内容")
+    last_update: Optional[datetime] = Field(None, description="上次更新时间")
+    next_update_in: int = Field(..., description="下次更新倒计时(秒)")
+    
+    class Config:
+        json_schema_extra = {
+            "example": {
+                "dialogues": {
+                    "张三": "终于把这个bug修复了,测试通过!",
+                    "李四": "下周的产品评审会需要准备一下资料。",
+                    "王五": "这个界面的配色方案还需要优化一下。"
+                },
+                "last_update": "2024-01-15T10:30:00",
+                "next_update_in": 25
+            }
+        }
+
+class NPCListResponse(BaseModel):
+    """NPC列表响应"""
+    npcs: List[NPCInfo] = Field(..., description="NPC列表")
+    total: int = Field(..., description="NPC总数")
+

+ 324 - 0
code/chapter15/Helloagents-AI-Town/backend/relationship_manager.py

@@ -0,0 +1,324 @@
+"""NPC好感度管理系统"""
+
+import sys
+import os
+
+# 添加HelloAgents到Python路径
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'HelloAgents'))
+
+from hello_agents import SimpleAgent, HelloAgentsLLM
+from typing import Dict, Optional, Tuple
+import json
+import re
+
+class RelationshipManager:
+    """NPC好感度管理器
+    
+    功能:
+    - 管理NPC与玩家的好感度 (0-100)
+    - 使用LLM分析对话情感
+    - 自动更新好感度
+    - 提供好感度等级和修饰词
+    """
+    
+    def __init__(self, llm: HelloAgentsLLM):
+        """初始化好感度管理器
+        
+        Args:
+            llm: HelloAgentsLLM实例
+        """
+        self.llm = llm
+        
+        # 存储每个NPC与玩家的好感度
+        # 格式: {npc_name: {player_id: affinity_score}}
+        self.affinity_scores: Dict[str, Dict[str, float]] = {}
+        
+        # 创建好感度分析Agent
+        self.analyzer_agent = SimpleAgent(
+            name="AffinityAnalyzer",
+            llm=llm,
+            system_prompt=self._create_analyzer_prompt()
+        )
+        
+        print("💖 好感度管理系统已初始化")
+    
+    def _create_analyzer_prompt(self) -> str:
+        """创建情感分析Agent的系统提示词"""
+        return """你是一个情感分析专家,负责分析对话中的情感倾向,判断是否应该改变NPC对玩家的好感度。
+
+【任务】
+分析玩家与NPC的对话,判断是否应该改变好感度,以及改变的幅度。
+
+【分析维度】
+1. **玩家态度**: 友好/中立/不友好
+2. **对话内容**: 积极/中立/消极
+3. **互动质量**: 深入/一般/敷衍
+4. **情感倾向**: 赞美/批评/中性
+
+【好感度变化规则】
+- 赞美、感谢、请教: +3 到 +8
+- 友好问候、正常交流: +1 到 +3
+- 普通闲聊、中性话题: 0
+- 批评、质疑、不耐烦: -3 到 -8
+- 侮辱、攻击、恶意: -8 到 -15
+
+【输出格式】(严格遵守JSON格式,不要添加任何其他文字)
+{
+    "should_change": true/false,
+    "change_amount": -15到+10之间的整数,
+    "reason": "简短说明原因(10字以内)",
+    "sentiment": "positive/neutral/negative"
+}
+
+【示例1】
+玩家: "你好,很高兴认识你!"
+NPC: "你好!我也很高兴认识你。"
+输出: {"should_change": true, "change_amount": 5, "reason": "友好问候", "sentiment": "positive"}
+
+【示例2】
+玩家: "你这个设计太丑了!"
+NPC: "抱歉,我会改进的..."
+输出: {"should_change": true, "change_amount": -8, "reason": "批评工作", "sentiment": "negative"}
+
+【示例3】
+玩家: "今天天气不错"
+NPC: "是啊,挺好的。"
+输出: {"should_change": false, "change_amount": 0, "reason": "普通闲聊", "sentiment": "neutral"}
+
+【示例4】
+玩家: "你的代码写得真棒!"
+NPC: "谢谢!我最近在研究新技术。"
+输出: {"should_change": true, "change_amount": 8, "reason": "赞美工作", "sentiment": "positive"}
+
+【示例5】
+玩家: "能教教我吗?"
+NPC: "当然可以!我很乐意分享。"
+输出: {"should_change": true, "change_amount": 6, "reason": "请教学习", "sentiment": "positive"}
+
+【重要】
+- 只输出JSON,不要添加任何解释或其他文字
+- change_amount必须是整数
+- reason必须简短(10字以内)
+- sentiment必须是positive/neutral/negative之一
+"""
+    
+    def get_affinity(self, npc_name: str, player_id: str = "player") -> float:
+        """获取好感度 (0-100)
+        
+        Args:
+            npc_name: NPC名称
+            player_id: 玩家ID
+            
+        Returns:
+            好感度值 (0-100)
+        """
+        if npc_name not in self.affinity_scores:
+            self.affinity_scores[npc_name] = {}
+        
+        if player_id not in self.affinity_scores[npc_name]:
+            self.affinity_scores[npc_name][player_id] = 50.0  # 初始好感度50
+        
+        return self.affinity_scores[npc_name][player_id]
+    
+    def set_affinity(self, npc_name: str, affinity: float, player_id: str = "player"):
+        """设置好感度
+        
+        Args:
+            npc_name: NPC名称
+            affinity: 好感度值 (0-100)
+            player_id: 玩家ID
+        """
+        if npc_name not in self.affinity_scores:
+            self.affinity_scores[npc_name] = {}
+        
+        # 限制在0-100范围内
+        affinity = max(0.0, min(100.0, affinity))
+        self.affinity_scores[npc_name][player_id] = affinity
+    
+    def analyze_and_update_affinity(
+        self,
+        npc_name: str,
+        player_message: str,
+        npc_response: str,
+        player_id: str = "player"
+    ) -> Dict:
+        """分析对话并更新好感度
+        
+        Args:
+            npc_name: NPC名称
+            player_message: 玩家消息
+            npc_response: NPC回复
+            player_id: 玩家ID
+            
+        Returns:
+            分析结果字典
+        """
+        # 构建分析提示
+        prompt = f"""请分析以下对话:
+
+玩家: {player_message}
+{npc_name}: {npc_response}
+
+请判断是否应该改变好感度,并给出变化量。
+"""
+        
+        try:
+            # 调用分析Agent
+            response = self.analyzer_agent.run(prompt)
+            
+            # 解析JSON响应
+            analysis = self._parse_analysis(response)
+            
+            if analysis["should_change"]:
+                # 更新好感度
+                current_affinity = self.get_affinity(npc_name, player_id)
+                new_affinity = current_affinity + analysis["change_amount"]
+                new_affinity = max(0.0, min(100.0, new_affinity))  # 限制在0-100
+
+                self.set_affinity(npc_name, new_affinity, player_id)
+
+                # 获取好感度等级
+                old_level = self.get_affinity_level(current_affinity)
+                new_level = self.get_affinity_level(new_affinity)
+
+                # 注意: 打印日志已移到agents.py中,避免重复输出
+
+                return {
+                    "changed": True,
+                    "old_affinity": current_affinity,
+                    "new_affinity": new_affinity,
+                    "change_amount": analysis["change_amount"],
+                    "reason": analysis["reason"],
+                    "sentiment": analysis.get("sentiment", "neutral"),
+                    "old_level": old_level,
+                    "new_level": new_level
+                }
+            else:
+                return {
+                    "changed": False,
+                    "affinity": self.get_affinity(npc_name, player_id),
+                    "reason": analysis["reason"],
+                    "sentiment": analysis.get("sentiment", "neutral")
+                }
+        
+        except Exception as e:
+            print(f"❌ 好感度分析失败: {e}")
+            import traceback
+            traceback.print_exc()
+            return {
+                "changed": False,
+                "affinity": self.get_affinity(npc_name, player_id),
+                "reason": "分析失败",
+                "sentiment": "neutral"
+            }
+    
+    def _parse_analysis(self, response: str) -> Dict:
+        """解析分析结果
+        
+        Args:
+            response: LLM响应
+            
+        Returns:
+            解析后的字典
+        """
+        try:
+            # 尝试直接解析JSON
+            analysis = json.loads(response)
+            return analysis
+        except json.JSONDecodeError:
+            # 尝试提取JSON部分
+            # 查找第一个 { 和最后一个 }
+            start = response.find('{')
+            end = response.rfind('}') + 1
+            
+            if start != -1 and end > start:
+                json_str = response[start:end]
+                try:
+                    analysis = json.loads(json_str)
+                    return analysis
+                except json.JSONDecodeError:
+                    pass
+            
+            # 尝试使用正则表达式提取
+            # 匹配 "should_change": true/false
+            should_change_match = re.search(r'"should_change"\s*:\s*(true|false)', response, re.IGNORECASE)
+            change_amount_match = re.search(r'"change_amount"\s*:\s*(-?\d+)', response)
+            reason_match = re.search(r'"reason"\s*:\s*"([^"]+)"', response)
+            sentiment_match = re.search(r'"sentiment"\s*:\s*"([^"]+)"', response)
+            
+            if should_change_match and change_amount_match:
+                return {
+                    "should_change": should_change_match.group(1).lower() == "true",
+                    "change_amount": int(change_amount_match.group(1)),
+                    "reason": reason_match.group(1) if reason_match else "未知",
+                    "sentiment": sentiment_match.group(1) if sentiment_match else "neutral"
+                }
+            
+            # 解析失败,返回默认值
+            print(f"⚠️  JSON解析失败,使用默认值。原始响应: {response[:100]}...")
+            return {
+                "should_change": False,
+                "change_amount": 0,
+                "reason": "解析失败",
+                "sentiment": "neutral"
+            }
+    
+    def get_affinity_level(self, affinity: float) -> str:
+        """获取好感度等级
+        
+        Args:
+            affinity: 好感度值 (0-100)
+            
+        Returns:
+            好感度等级名称
+        """
+        if affinity >= 80:
+            return "挚友"
+        elif affinity >= 60:
+            return "亲密"
+        elif affinity >= 40:
+            return "友好"
+        elif affinity >= 20:
+            return "熟悉"
+        else:
+            return "陌生"
+    
+    def get_affinity_modifier(self, affinity: float) -> str:
+        """获取好感度修饰词 (用于调整对话风格)
+        
+        Args:
+            affinity: 好感度值 (0-100)
+            
+        Returns:
+            对话风格修饰词
+        """
+        if affinity >= 80:
+            return "非常热情友好,像老朋友一样亲切,愿意分享私人话题"
+        elif affinity >= 60:
+            return "友好热情,愿意多聊,会主动关心对方"
+        elif affinity >= 40:
+            return "礼貌友善,正常交流,保持专业"
+        elif affinity >= 20:
+            return "礼貌但略显生疏,回答简洁"
+        else:
+            return "冷淡疏离,不太愿意多说,回答简短"
+    
+    def get_all_affinities(self, player_id: str = "player") -> Dict[str, Dict]:
+        """获取所有NPC的好感度信息
+        
+        Args:
+            player_id: 玩家ID
+            
+        Returns:
+            所有NPC的好感度信息
+        """
+        result = {}
+        for npc_name in self.affinity_scores:
+            affinity = self.get_affinity(npc_name, player_id)
+            result[npc_name] = {
+                "affinity": affinity,
+                "level": self.get_affinity_level(affinity),
+                "modifier": self.get_affinity_modifier(affinity)
+            }
+        return result
+

+ 15 - 0
code/chapter15/Helloagents-AI-Town/backend/requirements.txt

@@ -0,0 +1,15 @@
+# FastAPI后端依赖
+fastapi>=0.104.0
+uvicorn[standard]>=0.24.0
+pydantic>=2.0.0
+requests>=2.31.0
+
+# CORS支持
+python-multipart>=0.0.6
+
+# 测试工具
+pytest>=7.4.0
+httpx>=0.25.0
+
+# HelloAgents框架
+hello-agents>=0.2.4

+ 134 - 0
code/chapter15/Helloagents-AI-Town/backend/state_manager.py

@@ -0,0 +1,134 @@
+"""NPC状态管理器 - 定时批量更新NPC对话"""
+
+import asyncio
+from datetime import datetime
+from typing import Dict, Optional
+from batch_generator import get_batch_generator
+
+class NPCStateManager:
+    """NPC状态管理器
+    
+    功能:
+    1. 定时批量生成NPC对话(降低API成本)
+    2. 缓存当前NPC状态
+    3. 提供状态查询接口
+    """
+    
+    def __init__(self, update_interval: int = 30):
+        """初始化状态管理器
+        
+        Args:
+            update_interval: 更新间隔(秒),默认30秒
+        """
+        self.update_interval = update_interval
+        self.batch_generator = get_batch_generator()
+        
+        # 当前状态
+        self.current_dialogues: Dict[str, str] = {}
+        self.last_update: Optional[datetime] = None
+        self.next_update_time: Optional[datetime] = None
+        
+        # 后台任务
+        self._update_task: Optional[asyncio.Task] = None
+        self._running = False
+        
+        print(f"📊 NPC状态管理器初始化完成 (更新间隔: {update_interval}秒)")
+    
+    async def start(self):
+        """启动后台更新任务"""
+        if self._running:
+            print("⚠️  状态管理器已在运行")
+            return
+        
+        self._running = True
+        print("🚀 启动NPC状态自动更新...")
+        
+        # 立即执行一次更新
+        await self._update_npc_states()
+        
+        # 启动定时更新任务
+        self._update_task = asyncio.create_task(self._auto_update_loop())
+    
+    async def stop(self):
+        """停止后台更新任务"""
+        if not self._running:
+            return
+        
+        self._running = False
+        
+        if self._update_task:
+            self._update_task.cancel()
+            try:
+                await self._update_task
+            except asyncio.CancelledError:
+                pass
+        
+        print("🛑 NPC状态自动更新已停止")
+    
+    async def _auto_update_loop(self):
+        """自动更新循环"""
+        while self._running:
+            try:
+                await asyncio.sleep(self.update_interval)
+                await self._update_npc_states()
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                print(f"❌ 自动更新失败: {e}")
+                # 继续运行,不中断
+    
+    async def _update_npc_states(self):
+        """更新NPC状态"""
+        try:
+            print(f"\n🔄 [{datetime.now().strftime('%H:%M:%S')}] 开始批量更新NPC对话...")
+            
+            # 批量生成对话
+            new_dialogues = self.batch_generator.generate_batch_dialogues()
+            
+            # 更新状态
+            self.current_dialogues = new_dialogues
+            self.last_update = datetime.now()
+            self.next_update_time = datetime.now()
+            
+            # 打印更新结果
+            print("📝 NPC对话已更新:")
+            for npc_name, dialogue in new_dialogues.items():
+                print(f"   - {npc_name}: {dialogue}")
+            
+        except Exception as e:
+            print(f"❌ 更新NPC状态失败: {e}")
+    
+    def get_current_state(self) -> Dict:
+        """获取当前状态"""
+        # 计算下次更新倒计时
+        if self.last_update:
+            elapsed = (datetime.now() - self.last_update).total_seconds()
+            next_update_in = max(0, int(self.update_interval - elapsed))
+        else:
+            next_update_in = self.update_interval
+        
+        return {
+            "dialogues": self.current_dialogues,
+            "last_update": self.last_update,
+            "next_update_in": next_update_in
+        }
+    
+    def get_npc_dialogue(self, npc_name: str) -> Optional[str]:
+        """获取指定NPC的当前对话"""
+        return self.current_dialogues.get(npc_name)
+    
+    async def force_update(self):
+        """强制立即更新"""
+        print("⚡ 强制更新NPC状态...")
+        await self._update_npc_states()
+
+# 全局单例
+_state_manager = None
+
+def get_state_manager(update_interval: int = 30) -> NPCStateManager:
+    """获取状态管理器单例"""
+    global _state_manager
+    if _state_manager is None:
+        _state_manager = NPCStateManager(update_interval)
+    return _state_manager
+

+ 113 - 0
code/chapter15/Helloagents-AI-Town/backend/view_logs.py

@@ -0,0 +1,113 @@
+"""实时查看对话日志"""
+
+import os
+import time
+from pathlib import Path
+from datetime import datetime
+
+# 日志目录
+LOGS_DIR = Path(__file__).parent / "logs"
+today = datetime.now().strftime("%Y-%m-%d")
+LOG_FILE = LOGS_DIR / f"dialogue_{today}.log"
+
+def tail_log_file(filename, interval=1):
+    """实时查看日志文件 (类似tail -f)"""
+    
+    print("\n" + "="*60)
+    print(f"📝 实时查看对话日志")
+    print(f"📂 日志文件: {filename}")
+    print("="*60)
+    print("\n按 Ctrl+C 停止查看\n")
+    
+    # 如果文件不存在,等待创建
+    while not filename.exists():
+        print(f"⏳ 等待日志文件创建: {filename}")
+        time.sleep(interval)
+    
+    # 打开文件
+    with open(filename, 'r', encoding='utf-8') as f:
+        # 移动到文件末尾
+        f.seek(0, 2)
+        
+        try:
+            while True:
+                line = f.readline()
+                if line:
+                    print(line, end='')
+                else:
+                    time.sleep(interval)
+        except KeyboardInterrupt:
+            print("\n\n✅ 停止查看日志")
+
+def view_full_log(filename):
+    """查看完整日志"""
+    
+    print("\n" + "="*60)
+    print(f"📝 查看完整对话日志")
+    print(f"📂 日志文件: {filename}")
+    print("="*60 + "\n")
+    
+    if not filename.exists():
+        print(f"❌ 日志文件不存在: {filename}")
+        return
+    
+    with open(filename, 'r', encoding='utf-8') as f:
+        content = f.read()
+        print(content)
+    
+    print("\n" + "="*60)
+    print("✅ 日志查看完成")
+    print("="*60 + "\n")
+
+def list_log_files():
+    """列出所有日志文件"""
+    
+    print("\n" + "="*60)
+    print(f"📂 日志文件列表")
+    print(f"📁 目录: {LOGS_DIR}")
+    print("="*60 + "\n")
+    
+    if not LOGS_DIR.exists():
+        print("❌ 日志目录不存在")
+        return
+    
+    log_files = sorted(LOGS_DIR.glob("dialogue_*.log"), reverse=True)
+    
+    if not log_files:
+        print("📭 暂无日志文件")
+        return
+    
+    for i, log_file in enumerate(log_files, 1):
+        size = log_file.stat().st_size
+        size_kb = size / 1024
+        mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
+        print(f"{i}. {log_file.name}")
+        print(f"   大小: {size_kb:.2f} KB")
+        print(f"   修改时间: {mtime.strftime('%Y-%m-%d %H:%M:%S')}")
+        print()
+
+if __name__ == "__main__":
+    import sys
+    
+    if len(sys.argv) > 1:
+        command = sys.argv[1]
+        
+        if command == "tail":
+            # 实时查看
+            tail_log_file(LOG_FILE)
+        elif command == "view":
+            # 查看完整日志
+            view_full_log(LOG_FILE)
+        elif command == "list":
+            # 列出所有日志
+            list_log_files()
+        else:
+            print(f"❌ 未知命令: {command}")
+            print("\n使用方法:")
+            print("  python view_logs.py tail   # 实时查看日志")
+            print("  python view_logs.py view   # 查看完整日志")
+            print("  python view_logs.py list   # 列出所有日志文件")
+    else:
+        # 默认实时查看
+        tail_log_file(LOG_FILE)
+

+ 4 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/.editorconfig

@@ -0,0 +1,4 @@
+root = true
+
+[*]
+charset = utf-8

+ 2 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/.gitattributes

@@ -0,0 +1,2 @@
+# Normalize EOL for all files that Git considers text files.
+* text=auto eol=lf

+ 3 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/.gitignore

@@ -0,0 +1,3 @@
+# Godot 4+ specific ignores
+.godot/
+/android/

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/audio/BGM.ogg


+ 19 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/audio/BGM.ogg.import

@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://brhbet2ll8icy"
+path="res://.godot/imported/BGM.ogg-48e9be9edbb7e9faa2e68033877bd741.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/BGM.ogg"
+dest_files=["res://.godot/imported/BGM.ogg-48e9be9edbb7e9faa2e68033877bd741.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/audio/Running.mp3


+ 19 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/audio/Running.mp3.import

@@ -0,0 +1,19 @@
+[remap]
+
+importer="mp3"
+type="AudioStreamMP3"
+uid="uid://cwuhr7gm6elc"
+path="res://.godot/imported/Running.mp3-c7f1b4cd39739540c98f65cd63899845.mp3str"
+
+[deps]
+
+source_file="res://assets/audio/Running.mp3"
+dest_files=["res://.godot/imported/Running.mp3-c7f1b4cd39739540c98f65cd63899845.mp3str"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/audio/interact.mp3


+ 19 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/audio/interact.mp3.import

@@ -0,0 +1,19 @@
+[remap]
+
+importer="mp3"
+type="AudioStreamMP3"
+uid="uid://30oruin1tqai"
+path="res://.godot/imported/interact.mp3-6cec6020f11633ca49bd89ffd34d9777.mp3str"
+
+[deps]
+
+source_file="res://assets/audio/interact.mp3"
+dest_files=["res://.godot/imported/interact.mp3-6cec6020f11633ca49bd89ffd34d9777.mp3str"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_1.png


+ 40 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_1.png.import

@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c40a533uqalcb"
+path="res://.godot/imported/character_1.png-b3991027e2b40108560fd75cde5981a0.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/characters/character_1.png"
+dest_files=["res://.godot/imported/character_1.png-b3991027e2b40108560fd75cde5981a0.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_2.png


+ 40 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_2.png.import

@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c4eg1isjbtsp"
+path="res://.godot/imported/character_2.png-9d412d60d56731efd52843781cca8a39.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/characters/character_2.png"
+dest_files=["res://.godot/imported/character_2.png-9d412d60d56731efd52843781cca8a39.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_3.png


+ 40 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_3.png.import

@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dcljwh6jlvwaq"
+path="res://.godot/imported/character_3.png-70b9cd8dc46f85938e22fec39100d00e.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/characters/character_3.png"
+dest_files=["res://.godot/imported/character_3.png-70b9cd8dc46f85938e22fec39100d00e.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_4.png


+ 40 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/characters/character_4.png.import

@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://ev8s67j8speg"
+path="res://.godot/imported/character_4.png-0c917384f8f05c2ff2f38fc4c39fe59a.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/characters/character_4.png"
+dest_files=["res://.godot/imported/character_4.png-0c917384f8f05c2ff2f38fc4c39fe59a.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/13_Conference_Hall_48x48.png


+ 40 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/13_Conference_Hall_48x48.png.import

@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dr0mmb2l40ajb"
+path="res://.godot/imported/13_Conference_Hall_48x48.png-fcc101e4c512733c82a9621dd0522623.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/interiors/13_Conference_Hall_48x48.png"
+dest_files=["res://.godot/imported/13_Conference_Hall_48x48.png-fcc101e4c512733c82a9621dd0522623.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/1_Generic_48x48.png


+ 40 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/1_Generic_48x48.png.import

@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bq8ppihl0f53j"
+path="res://.godot/imported/1_Generic_48x48.png-21e86ef1c35713e94c1237e66b49da28.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/interiors/1_Generic_48x48.png"
+dest_files=["res://.godot/imported/1_Generic_48x48.png-21e86ef1c35713e94c1237e66b49da28.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/Japanese_Home_1_preview_48x48.png


+ 40 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/Japanese_Home_1_preview_48x48.png.import

@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c6guopaun4bag"
+path="res://.godot/imported/Japanese_Home_1_preview_48x48.png-c26c975943429dc79d1be550720dab4f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/interiors/Japanese_Home_1_preview_48x48.png"
+dest_files=["res://.godot/imported/Japanese_Home_1_preview_48x48.png-c26c975943429dc79d1be550720dab4f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/Room_Builder_48x48.png


+ 40 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/Room_Builder_48x48.png.import

@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://q8lvt1mmh37h"
+path="res://.godot/imported/Room_Builder_48x48.png-75b214e121e24239177e928597e4a243.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/interiors/Room_Builder_48x48.png"
+dest_files=["res://.godot/imported/Room_Builder_48x48.png-75b214e121e24239177e928597e4a243.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/小鲸鱼.png


+ 40 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/interiors/小鲸鱼.png.import

@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://be80ipi13u6xb"
+path="res://.godot/imported/小鲸鱼.png-df7a33c283f5237baa13a635643a5185.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/interiors/小鲸鱼.png"
+dest_files=["res://.godot/imported/小鲸鱼.png-df7a33c283f5237baa13a635643a5185.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

BIN
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/ui/UI_48x48.png


+ 40 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/assets/ui/UI_48x48.png.import

@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://g6rsrup6l7cc"
+path="res://.godot/imported/UI_48x48.png-24c09ca26a68a741a091c352313f4945.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/ui/UI_48x48.png"
+dest_files=["res://.godot/imported/UI_48x48.png-24c09ca26a68a741a091c352313f4945.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1

+ 1 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/icon.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>

+ 43 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/icon.svg.import

@@ -0,0 +1,43 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://b5v6clns4ynxe"
+path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://icon.svg"
+dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false

+ 75 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/project.godot

@@ -0,0 +1,75 @@
+; Engine configuration file.
+; It's best edited using the editor UI and not directly,
+; since the parameters that go here are not all obvious.
+;
+; Format:
+;   [section] ; section goes between []
+;   param=value ; assign values to parameters
+
+config_version=5
+
+[application]
+
+config/name="Helloagents-AI-Town"
+run/main_scene="uid://b21hnf84ctx7e"
+config/features=PackedStringArray("4.5", "Mobile")
+config/icon="res://icon.svg"
+
+[autoload]
+
+Config="*res://scripts/config.gd"
+APIClient="*res://scripts/api_client.gd"
+
+[display]
+
+window/size/viewport_width=1280
+window/size/viewport_height=720
+window/size/mode=3
+
+[input]
+
+ui_accept={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194309,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194310,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
+]
+}
+ui_left={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194319,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":13,"pressure":0.0,"pressed":false,"script":null)
+, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":0,"axis_value":-1.0,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
+]
+}
+ui_right={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194321,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":14,"pressure":0.0,"pressed":false,"script":null)
+, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":0,"axis_value":1.0,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
+]
+}
+ui_up={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194320,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
+, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":1,"axis_value":-1.0,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
+]
+}
+ui_down={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194322,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":12,"pressure":0.0,"pressed":false,"script":null)
+, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":1,"axis_value":1.0,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
+]
+}
+
+[rendering]
+
+textures/canvas_textures/default_texture_filter=0
+renderer/rendering_method="mobile"

+ 69 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scenes/dialogue_ui.tscn

@@ -0,0 +1,69 @@
+[gd_scene load_steps=2 format=3 uid="uid://cm0yi2d074t64"]
+
+[ext_resource type="Script" uid="uid://dk1f7x00sdtru" path="res://scripts/dialogue_ui.gd" id="1_dwk8m"]
+
+[node name="DialogueUI" type="CanvasLayer"]
+visible = false
+script = ExtResource("1_dwk8m")
+
+[node name="Panel" type="Panel" parent="."]
+anchors_preset = 12
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -218.0
+grow_horizontal = 2
+grow_vertical = 0
+
+[node name="NPCName" type="Label" parent="Panel"]
+layout_mode = 0
+offset_left = 20.0
+offset_top = 10.0
+offset_right = 85.0
+offset_bottom = 33.0
+theme_override_font_sizes/font_size = 24
+text = "NPC名字"
+
+[node name="NPCTitle" type="Label" parent="Panel"]
+layout_mode = 0
+offset_left = 20.0
+offset_top = 40.0
+offset_right = 60.0
+offset_bottom = 63.0
+theme_override_colors/font_color = Color(0.45452422, 0.45452428, 0.45452422, 1)
+text = "职位"
+
+[node name="DialogueText" type="RichTextLabel" parent="Panel"]
+layout_mode = 0
+offset_left = 20.0
+offset_top = 70.0
+offset_right = 1260.0
+offset_bottom = 170.0
+bbcode_enabled = true
+scroll_following = true
+scroll_following_visible_characters = true
+
+[node name="PlayerInput" type="LineEdit" parent="Panel"]
+layout_mode = 0
+offset_left = 20.0
+offset_top = 180.0
+offset_right = 1020.0
+offset_bottom = 220.0
+placeholder_text = "输入消息..."
+clear_button_enabled = true
+
+[node name="SendButton" type="Button" parent="Panel"]
+layout_mode = 0
+offset_left = 1030.0
+offset_top = 180.0
+offset_right = 1130.0
+offset_bottom = 220.0
+text = "发送"
+
+[node name="CloseButton" type="Button" parent="Panel"]
+layout_mode = 0
+offset_left = 1140.0
+offset_top = 180.0
+offset_right = 1240.0
+offset_bottom = 220.0
+text = "关闭"

+ 478 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scenes/main.tscn

@@ -0,0 +1,478 @@
+[gd_scene load_steps=65 format=3 uid="uid://vd6f817st20r"]
+
+[ext_resource type="Script" uid="uid://dyfhfmncwhby0" path="res://scripts/main.gd" id="1_tbgi4"]
+[ext_resource type="PackedScene" uid="uid://dob8a2h4f6gt8" path="res://scenes/player.tscn" id="2_sugp2"]
+[ext_resource type="PackedScene" uid="uid://dxcvuxgvdsx7" path="res://scenes/npc.tscn" id="3_jyhfs"]
+[ext_resource type="Texture2D" uid="uid://dcljwh6jlvwaq" path="res://assets/characters/character_3.png" id="5_o6xl0"]
+[ext_resource type="Texture2D" uid="uid://c6guopaun4bag" path="res://assets/interiors/Japanese_Home_1_preview_48x48.png" id="5_tbgi4"]
+[ext_resource type="PackedScene" uid="uid://cm0yi2d074t64" path="res://scenes/dialogue_ui.tscn" id="5_tefeu"]
+[ext_resource type="Texture2D" uid="uid://ev8s67j8speg" path="res://assets/characters/character_4.png" id="6_o6xl0"]
+[ext_resource type="Texture2D" uid="uid://be80ipi13u6xb" path="res://assets/interiors/小鲸鱼.png" id="8_tipki"]
+[ext_resource type="AudioStream" uid="uid://brhbet2ll8icy" path="res://assets/audio/BGM.ogg" id="9_85g3d"]
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_tipki"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2016, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_85g3d"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2064, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_choun"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2112, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_ya4ey"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2160, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_eb6dy"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2208, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_trceg"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2256, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_dp3eg"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2304, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_0ld40"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2352, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_gqmmt"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2400, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_yc10j"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2448, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_jscy8"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2496, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_pm3ni"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2544, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_y6deb"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2592, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_og1vs"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(2640, 1086, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_2wyq8"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(864, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_vxglm"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(912, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_2f3dj"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(960, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_yq6so"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(1008, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_fv21b"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(1056, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_tel4y"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(1104, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_qkpxi"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(576, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_5q0nq"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(624, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_dgi5k"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(672, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_j8jky"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(720, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_kmb1v"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(768, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_fuf3a"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(816, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_pibwh"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(0, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_c6pm6"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(48, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_5he1u"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(96, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_5poiv"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(144, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_2cjbq"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(192, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_chjal"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(240, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_cjqg0"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(288, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_vchkt"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(336, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_txyw0"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(384, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_vc5cj"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(432, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_nvyfr"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(480, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_ty1g6"]
+atlas = ExtResource("5_o6xl0")
+region = Rect2(528, 792, 48, 70)
+
+[sub_resource type="SpriteFrames" id="SpriteFrames_tbgi4"]
+animations = [{
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_tipki")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_85g3d")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_choun")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_ya4ey")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_eb6dy")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_trceg")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_dp3eg")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_0ld40")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_gqmmt")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_yc10j")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_jscy8")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_pm3ni")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_y6deb")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_og1vs")
+}],
+"loop": true,
+"name": &"idle",
+"speed": 5.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_2wyq8")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_vxglm")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_2f3dj")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_yq6so")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_fv21b")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_tel4y")
+}],
+"loop": true,
+"name": &"walk_down",
+"speed": 5.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_qkpxi")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_5q0nq")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_dgi5k")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_j8jky")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_kmb1v")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_fuf3a")
+}],
+"loop": true,
+"name": &"walk_left",
+"speed": 5.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_pibwh")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_c6pm6")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_5he1u")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_5poiv")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_2cjbq")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_chjal")
+}],
+"loop": true,
+"name": &"walk_right",
+"speed": 5.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_cjqg0")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_vchkt")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_txyw0")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_vc5cj")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_nvyfr")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_ty1g6")
+}],
+"loop": true,
+"name": &"walk_up",
+"speed": 5.0
+}]
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_tefeu"]
+atlas = ExtResource("6_o6xl0")
+region = Rect2(288, 410, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_o6xl0"]
+atlas = ExtResource("6_o6xl0")
+region = Rect2(336, 410, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_a8y0u"]
+atlas = ExtResource("6_o6xl0")
+region = Rect2(384, 410, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_jkv2x"]
+atlas = ExtResource("6_o6xl0")
+region = Rect2(432, 410, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_jbj1t"]
+atlas = ExtResource("6_o6xl0")
+region = Rect2(480, 410, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_muem4"]
+atlas = ExtResource("6_o6xl0")
+region = Rect2(528, 410, 48, 70)
+
+[sub_resource type="SpriteFrames" id="SpriteFrames_dp3eg"]
+animations = [{
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_tefeu")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_o6xl0")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_a8y0u")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_jkv2x")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_jbj1t")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_muem4")
+}],
+"loop": true,
+"name": &"default",
+"speed": 5.0
+}]
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_tipki"]
+size = Vector2(712, 20)
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_85g3d"]
+size = Vector2(414.5, 20)
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_choun"]
+size = Vector2(75.5, 20)
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_ya4ey"]
+size = Vector2(14.5, 18.5)
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_eb6dy"]
+size = Vector2(1258, 20)
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_trceg"]
+size = Vector2(23.5, 484)
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_a8y0u"]
+size = Vector2(779, 75)
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_jkv2x"]
+size = Vector2(302, 67)
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_jbj1t"]
+size = Vector2(107, 65)
+
+[node name="Main" type="Node2D"]
+script = ExtResource("1_tbgi4")
+
+[node name="Background" type="Sprite2D" parent="."]
+position = Vector2(644.49994, 371.25)
+scale = Vector2(1.4747808, 1.1394081)
+texture = ExtResource("5_tbgi4")
+metadata/_edit_lock_ = true
+
+[node name="小鲸鱼" type="Sprite2D" parent="Background"]
+position = Vector2(96.62457, -220.50922)
+scale = Vector2(0.09239753, 0.12666555)
+texture = ExtResource("8_tipki")
+
+[node name="Player" parent="." instance=ExtResource("2_sugp2")]
+position = Vector2(453, 492)
+
+[node name="NPCs" type="Node2D" parent="."]
+
+[node name="NPC_Zhang" parent="NPCs" instance=ExtResource("3_jyhfs")]
+position = Vector2(367, 172)
+
+[node name="NPC_Li" parent="NPCs" instance=ExtResource("3_jyhfs")]
+position = Vector2(1071, 164)
+npc_name = "李四"
+npc_title = "产品经理"
+sprite_frames = SubResource("SpriteFrames_tbgi4")
+move_speed = 20.0
+
+[node name="NPC_Wang" parent="NPCs" instance=ExtResource("3_jyhfs")]
+z_index = 1
+position = Vector2(206, 423)
+npc_name = "王五"
+npc_title = "UI设计师"
+sprite_frames = SubResource("SpriteFrames_dp3eg")
+wander_range = 0.0
+
+[node name="DialogueUI" parent="." instance=ExtResource("5_tefeu")]
+
+[node name="Walls" type="Node2D" parent="."]
+
+[node name="TopWall" type="StaticBody2D" parent="Walls"]
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="Walls/TopWall"]
+position = Vector2(396, 86)
+shape = SubResource("RectangleShape2D_tipki")
+
+[node name="CollisionShape2D2" type="CollisionShape2D" parent="Walls/TopWall"]
+position = Vector2(1034, 86)
+shape = SubResource("RectangleShape2D_85g3d")
+
+[node name="CollisionShape2D3" type="CollisionShape2D" parent="Walls/TopWall"]
+position = Vector2(787, 113)
+shape = SubResource("RectangleShape2D_choun")
+
+[node name="CollisionShape2D4" type="CollisionShape2D" parent="Walls/TopWall"]
+position = Vector2(747, 112.75)
+shape = SubResource("RectangleShape2D_ya4ey")
+
+[node name="CollisionShape2D5" type="CollisionShape2D" parent="Walls/TopWall"]
+position = Vector2(827, 113)
+shape = SubResource("RectangleShape2D_ya4ey")
+
+[node name="BottomWall" type="StaticBody2D" parent="Walls"]
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="Walls/BottomWall"]
+position = Vector2(641, 567)
+shape = SubResource("RectangleShape2D_eb6dy")
+
+[node name="CollisionShape2D2" type="CollisionShape2D" parent="Walls/BottomWall"]
+position = Vector2(26.75, 328)
+shape = SubResource("RectangleShape2D_trceg")
+
+[node name="CollisionShape2D3" type="CollisionShape2D" parent="Walls/BottomWall"]
+position = Vector2(1260, 332)
+shape = SubResource("RectangleShape2D_trceg")
+
+[node name="MiddleWall" type="StaticBody2D" parent="Walls"]
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="Walls/MiddleWall"]
+position = Vector2(428.5, 268.5)
+shape = SubResource("RectangleShape2D_a8y0u")
+
+[node name="CollisionShape2D2" type="CollisionShape2D" parent="Walls/MiddleWall"]
+position = Vector2(907, 368.5)
+shape = SubResource("RectangleShape2D_jkv2x")
+
+[node name="CollisionShape2D3" type="CollisionShape2D" parent="Walls/MiddleWall"]
+position = Vector2(1197.5, 369.5)
+shape = SubResource("RectangleShape2D_jbj1t")
+
+[node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="."]
+stream = ExtResource("9_85g3d")
+volume_db = -6.679
+autoplay = true

+ 326 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scenes/npc.tscn

@@ -0,0 +1,326 @@
+[gd_scene load_steps=42 format=3 uid="uid://dxcvuxgvdsx7"]
+
+[ext_resource type="Script" uid="uid://cedfqqodwcl2a" path="res://scripts/npc.gd" id="1_abqhh"]
+[ext_resource type="Texture2D" uid="uid://c4eg1isjbtsp" path="res://assets/characters/character_2.png" id="1_nh2m4"]
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_abqhh"]
+size = Vector2(37, 58)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_abqhh"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(0, 595, 48, 80)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_rv78h"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(48, 595, 48, 80)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_7n8xq"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(96, 595, 48, 80)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_4wlns"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(144, 595, 48, 80)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_tl2vt"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(192, 595, 48, 80)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_hwkja"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(240, 595, 48, 80)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_0tygy"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(288, 595, 48, 80)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_sptji"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(336, 595, 48, 80)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_v4e37"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(384, 595, 48, 80)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_1h837"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(432, 595, 48, 80)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_gl1un"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(480, 595, 48, 80)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_wuru7"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(528, 595, 48, 80)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_nyaq3"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(864, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_yf2ql"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(912, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_8gbmn"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(960, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_pvipr"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(1008, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_n0klm"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(1056, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_u2tho"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(1104, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_ca6vw"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(576, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_t8u8m"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(624, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_d56e8"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(672, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_ewlk4"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(720, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_bw2c7"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(768, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_gc4mq"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(816, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_2gr28"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(0, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_tkdcp"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(48, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_15fp6"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(96, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_bll4x"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(144, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_46t2e"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(192, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_u8ld7"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(240, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_jwdfw"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(288, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_y1ua7"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(336, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_8hjb1"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(384, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_ub7sa"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(432, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_fdkk8"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(480, 792, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_iwn7c"]
+atlas = ExtResource("1_nh2m4")
+region = Rect2(528, 792, 48, 70)
+
+[sub_resource type="SpriteFrames" id="SpriteFrames_nyaq3"]
+animations = [{
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_abqhh")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_rv78h")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_7n8xq")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_4wlns")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_tl2vt")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_hwkja")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_0tygy")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_sptji")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_v4e37")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_1h837")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_gl1un")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_wuru7")
+}],
+"loop": true,
+"name": &"idle",
+"speed": 5.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_nyaq3")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_yf2ql")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_8gbmn")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_pvipr")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_n0klm")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_u2tho")
+}],
+"loop": true,
+"name": &"walk_down",
+"speed": 12.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_ca6vw")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_t8u8m")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_d56e8")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_ewlk4")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_bw2c7")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_gc4mq")
+}],
+"loop": true,
+"name": &"walk_left",
+"speed": 12.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_2gr28")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_tkdcp")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_15fp6")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_bll4x")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_46t2e")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_u8ld7")
+}],
+"loop": true,
+"name": &"walk_right",
+"speed": 12.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_jwdfw")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_y1ua7")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_8hjb1")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_ub7sa")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_fdkk8")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_iwn7c")
+}],
+"loop": true,
+"name": &"walk_up",
+"speed": 12.0
+}]
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_nh2m4"]
+size = Vector2(65, 86)
+
+[node name="NPC" type="CharacterBody2D"]
+script = ExtResource("1_abqhh")
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
+position = Vector2(0.5, 1)
+shape = SubResource("RectangleShape2D_abqhh")
+
+[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="."]
+sprite_frames = SubResource("SpriteFrames_nyaq3")
+animation = &"walk_up"
+autoplay = "idle"
+
+[node name="InteractionArea" type="Area2D" parent="."]
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="InteractionArea"]
+position = Vector2(-0.5, 0)
+shape = SubResource("RectangleShape2D_nh2m4")
+
+[node name="NameLabel" type="Label" parent="."]
+offset_left = -20.0
+offset_top = -58.0
+offset_right = 20.0
+offset_bottom = -35.0
+theme_override_colors/font_color = Color(1, 1, 0.3764706, 1)
+text = "张三"
+horizontal_alignment = 1
+
+[node name="DialogueLabel" type="Label" parent="."]
+visible = false
+offset_left = 29.0
+offset_top = -29.0
+offset_right = 122.0
+offset_bottom = 30.0
+theme_override_font_sizes/font_size = 14
+autowrap_mode = 2

+ 343 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scenes/player.tscn

@@ -0,0 +1,343 @@
+[gd_scene load_steps=47 format=3 uid="uid://dob8a2h4f6gt8"]
+
+[ext_resource type="Texture2D" uid="uid://c40a533uqalcb" path="res://assets/characters/character_1.png" id="1_3vyb7"]
+[ext_resource type="Script" uid="uid://cr0rf00w5q53d" path="res://scripts/player.gd" id="1_g2els"]
+[ext_resource type="AudioStream" uid="uid://csvkvrv8ndh5x" path="res://assets/Audio/interact.mp3" id="3_dqkch"]
+[ext_resource type="AudioStream" uid="uid://bd650j6lpf34f" path="res://assets/Audio/Running.mp3" id="4_qlg0r"]
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_g2els"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(0, 697, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_qhqgy"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(48, 697, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_dqkch"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(96, 697, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_qlg0r"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(144, 697, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_tuyoq"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(192, 697, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_fjrip"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(240, 697, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_smehm"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(288, 697, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_ur7pv"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(336, 697, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_y4r1p"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(384, 697, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_d2wvv"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(432, 697, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_3v2ag"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(480, 697, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_jej6c"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(528, 697, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_f1ej7"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(0, 24, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_oprun"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(48, 24, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_a8ls1"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(96, 24, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_qfm1y"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(144, 24, 48, 76)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_fulsm"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(864, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_4r5pv"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(912, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_60mlk"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(960, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_i4ail"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(1008, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_a38lo"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(1056, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_4ni07"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(1104, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_wqfne"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(576, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_wnwbv"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(624, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_gl8cc"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(672, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_487ah"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(720, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_md1ol"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(768, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_bj30b"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(816, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_jc3p3"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(0, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_hax0n"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(48, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_t4otl"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(96, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_j2b1d"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(144, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_cs1tg"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(192, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_2dvfe"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(240, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_l71n6"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(288, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_ke2ow"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(336, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_ujl30"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(384, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_31cv2"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(432, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_pf23h"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(480, 794, 48, 70)
+
+[sub_resource type="AtlasTexture" id="AtlasTexture_dt7fs"]
+atlas = ExtResource("1_3vyb7")
+region = Rect2(528, 794, 48, 70)
+
+[sub_resource type="SpriteFrames" id="SpriteFrames_fulsm"]
+animations = [{
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_g2els")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_qhqgy")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_dqkch")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_qlg0r")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_tuyoq")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_fjrip")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_smehm")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_ur7pv")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_y4r1p")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_d2wvv")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_3v2ag")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_jej6c")
+}],
+"loop": true,
+"name": &"idle",
+"speed": 12.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_f1ej7")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_oprun")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_a8ls1")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_qfm1y")
+}],
+"loop": true,
+"name": &"turn",
+"speed": 5.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_fulsm")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_4r5pv")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_60mlk")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_i4ail")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_a38lo")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_4ni07")
+}],
+"loop": true,
+"name": &"walk_down",
+"speed": 5.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_wqfne")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_wnwbv")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_gl8cc")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_487ah")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_md1ol")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_bj30b")
+}],
+"loop": true,
+"name": &"walk_left",
+"speed": 5.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_jc3p3")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_hax0n")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_t4otl")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_j2b1d")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_cs1tg")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_2dvfe")
+}],
+"loop": true,
+"name": &"walk_right",
+"speed": 5.0
+}, {
+"frames": [{
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_l71n6")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_ke2ow")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_ujl30")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_31cv2")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_pf23h")
+}, {
+"duration": 1.0,
+"texture": SubResource("AtlasTexture_dt7fs")
+}],
+"loop": true,
+"name": &"walk_up",
+"speed": 12.0
+}]
+
+[sub_resource type="RectangleShape2D" id="RectangleShape2D_4r5pv"]
+size = Vector2(43, 68)
+
+[node name="Player" type="CharacterBody2D"]
+script = ExtResource("1_g2els")
+
+[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="."]
+sprite_frames = SubResource("SpriteFrames_fulsm")
+animation = &"walk_right"
+autoplay = "idle"
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
+position = Vector2(-0.5, -1)
+shape = SubResource("RectangleShape2D_4r5pv")
+
+[node name="Camera2D" type="Camera2D" parent="."]
+zoom = Vector2(1.5, 1.5)
+
+[node name="InteractSound" type="AudioStreamPlayer" parent="."]
+stream = ExtResource("3_dqkch")
+
+[node name="RunningSound" type="AudioStreamPlayer" parent="."]
+stream = ExtResource("4_qlg0r")

+ 287 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/README.md

@@ -0,0 +1,287 @@
+# 🎮 赛博小镇 - GDScript脚本说明
+
+## 📁 脚本文件列表
+
+```
+scripts/
+├── config.gd          # 全局配置
+├── api_client.gd      # API通信客户端
+├── player.gd          # 玩家控制
+├── npc.gd            # NPC行为
+├── dialogue_ui.gd    # 对话UI
+└── main.gd           # 主场景逻辑
+```
+
+---
+
+## 📄 脚本详解
+
+### 1. config.gd (全局配置)
+**用途**: 存储全局常量和配置
+
+**关键配置**:
+```gdscript
+const API_BASE_URL = "http://localhost:8000"  # 后端API地址
+const PLAYER_SPEED = 200.0                     # 玩家速度
+const NPC_STATUS_UPDATE_INTERVAL = 30.0        # NPC更新间隔
+```
+
+**使用方法**:
+```gdscript
+# 在任何脚本中访问
+Config.log_info("消息")
+var speed = Config.PLAYER_SPEED
+```
+
+---
+
+### 2. api_client.gd (API客户端)
+**用途**: 与FastAPI后端通信
+
+**主要方法**:
+- `send_chat(npc_name, message)` - 发送对话
+- `get_npc_status()` - 获取NPC状态
+- `get_npc_list()` - 获取NPC列表
+
+**信号**:
+- `chat_response_received(npc_name, message)` - 收到对话回复
+- `chat_error(error_message)` - 对话错误
+- `npc_status_received(dialogues)` - 收到NPC状态
+
+**使用示例**:
+```gdscript
+# 获取API客户端
+var api = get_node("/root/APIClient")
+
+# 发送对话
+api.send_chat("张三", "你好")
+
+# 监听回复
+api.chat_response_received.connect(_on_response)
+
+func _on_response(npc_name, message):
+    print(npc_name + ": " + message)
+```
+
+---
+
+### 3. player.gd (玩家控制)
+**用途**: 处理玩家移动和交互
+
+**关键功能**:
+- WASD/方向键移动
+- E键与NPC交互
+- 检测附近的NPC
+
+**节点要求**:
+```
+Player (CharacterBody2D)
+├── Sprite2D
+├── CollisionShape2D
+└── Camera2D
+```
+
+**自定义参数**:
+```gdscript
+@export var speed: float = 200.0  # 在Inspector中可调整
+```
+
+---
+
+### 4. npc.gd (NPC行为)
+**用途**: NPC交互和状态显示
+
+**关键功能**:
+- 检测玩家进入/离开交互范围
+- 显示NPC名字和对话
+- 更新NPC状态
+
+**节点要求**:
+```
+NPC (Node2D)
+├── Sprite2D
+├── InteractionArea (Area2D)
+│   └── CollisionShape2D
+├── NameLabel (Label)
+└── DialogueLabel (Label)
+```
+
+**导出参数**:
+```gdscript
+@export var npc_name: String = "张三"
+@export var npc_title: String = "Python工程师"
+```
+
+**使用方法**:
+1. 在Inspector中设置NPC名字和职位
+2. 脚本会自动处理交互逻辑
+
+---
+
+### 5. dialogue_ui.gd (对话UI)
+**用途**: 对话界面管理
+
+**关键功能**:
+- 显示/隐藏对话框
+- 处理玩家输入
+- 显示对话历史
+- 与API通信
+
+**节点要求**:
+```
+DialogueUI (CanvasLayer)
+└── Panel
+    ├── NPCName (Label)
+    ├── NPCTitle (Label)
+    ├── DialogueText (RichTextLabel)
+    ├── PlayerInput (LineEdit)
+    ├── SendButton (Button)
+    └── CloseButton (Button)
+```
+
+**使用方法**:
+```gdscript
+# 开始对话
+get_tree().call_group("dialogue_system", "start_dialogue", "张三")
+```
+
+---
+
+### 6. main.gd (主场景)
+**用途**: 管理整个游戏场景
+
+**关键功能**:
+- 定时更新NPC状态
+- 分发NPC对话到各个NPC节点
+- 协调各个系统
+
+**节点要求**:
+```
+Main (Node2D)
+├── TileMapLayer (地图)
+├── Player (实例化)
+├── NPCs (Node2D)
+│   ├── NPC_Zhang (实例化)
+│   ├── NPC_Li (实例化)
+│   └── NPC_Wang (实例化)
+└── DialogueUI (实例化)
+```
+
+---
+
+## 🔧 如何使用这些脚本
+
+### 步骤1: 设置AutoLoad
+在 `Project -> Project Settings -> AutoLoad` 中添加:
+- `config.gd` -> 名称: `Config`
+- `api_client.gd` -> 名称: `APIClient`
+
+### 步骤2: 附加脚本到场景
+- `player.tscn` -> 附加 `player.gd`
+- `npc.tscn` -> 附加 `npc.gd`
+- `dialogue_ui.tscn` -> 附加 `dialogue_ui.gd`
+- `main.tscn` -> 附加 `main.gd`
+
+### 步骤3: 配置节点
+确保每个场景的节点结构与脚本要求一致。
+
+### 步骤4: 设置参数
+在Inspector中设置导出参数(如NPC名字、速度等)。
+
+---
+
+## 🐛 调试技巧
+
+### 查看日志
+所有脚本都使用 `Config.log_info()` 输出日志,在Godot的 **Output** 面板查看。
+
+### 常见日志:
+```
+[INFO] API客户端初始化完成
+[INFO] 玩家初始化完成
+[INFO] NPC初始化: 张三
+[INFO] 进入NPC范围: 张三
+[API] POST /chat -> {"npc_name":"张三","message":"你好"}
+[INFO] 收到NPC回复: 张三 -> 你好!我是Python工程师...
+```
+
+### 启用调试模式
+在 `config.gd` 中:
+```gdscript
+const DEBUG_MODE = true  # 显示详细日志
+const SHOW_INTERACTION_RANGE = true  # 显示交互范围
+```
+
+---
+
+## 📊 信号流程图
+
+```
+玩家按E键
+    ↓
+player.gd: interact_with_npc()
+    ↓
+发送信号到 dialogue_system 组
+    ↓
+dialogue_ui.gd: start_dialogue(npc_name)
+    ↓
+显示对话框,玩家输入消息
+    ↓
+dialogue_ui.gd: send_message()
+    ↓
+api_client.gd: send_chat(npc_name, message)
+    ↓
+HTTP请求到FastAPI后端
+    ↓
+api_client.gd: _on_chat_request_completed()
+    ↓
+发出信号: chat_response_received
+    ↓
+dialogue_ui.gd: _on_chat_response_received()
+    ↓
+显示NPC回复
+```
+
+---
+
+## 🎯 扩展建议
+
+### 添加新NPC
+1. 在 `main.tscn` 中实例化 `npc.tscn`
+2. 设置NPC名字和位置
+3. 在 `main.gd` 的 `get_npc_node()` 中添加映射
+
+### 添加新功能
+1. 在 `config.gd` 中添加配置
+2. 在 `api_client.gd` 中添加新API方法
+3. 在相应脚本中实现逻辑
+
+### 优化性能
+1. 减少 `NPC_STATUS_UPDATE_INTERVAL` 的更新频率
+2. 使用对象池管理UI元素
+3. 优化TileMap的碰撞层
+
+---
+
+## 📚 参考资源
+
+- **Godot文档**: https://docs.godotengine.org/
+- **GDScript教程**: https://gdscript.com/
+- **FastAPI文档**: https://fastapi.tiangolo.com/
+
+---
+
+## ❓ 常见问题
+
+**Q: 如何修改API地址?**
+A: 编辑 `config.gd` 中的 `API_BASE_URL`
+
+**Q: 如何添加更多NPC?**
+A: 实例化 `npc.tscn`,设置参数,在 `main.gd` 中添加引用
+
+**Q: 如何自定义对话框样式?**
+A: 编辑 `dialogue_ui.tscn`,修改Panel和Label的主题
+
+**Q: 如何禁用调试日志?**
+A: 在 `config.gd` 中设置 `DEBUG_MODE = false`
+

+ 144 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/api_client.gd

@@ -0,0 +1,144 @@
+# API客户端 - 与FastAPI后端通信
+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)
+	
+	print("[INFO] API客户端初始化完成")
+
+# ==================== 对话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"]
+	
+	print("[API] POST /chat -> ", data)
+	
+	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
+
+	print("[API] GET /npcs/status")
+
+	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列表"""
+	print("[API] GET /npcs")
+	
+	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)

+ 1 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/api_client.gd.uid

@@ -0,0 +1 @@
+uid://qwyca7sf0u5l

+ 41 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/config.gd

@@ -0,0 +1,41 @@
+# 赛博小镇 - 全局配置
+extends Node
+
+# ==================== API配置 ====================
+const API_BASE_URL = "http://localhost:8000"
+const API_CHAT = API_BASE_URL + "/chat"
+const API_NPCS = API_BASE_URL + "/npcs"
+const API_NPC_STATUS = API_BASE_URL + "/npcs/status"
+
+# ==================== NPC配置 ====================
+const NPC_NAMES = ["张三", "李四", "王五"]
+const NPC_TITLES = {
+	"张三": "Python工程师",
+	"李四": "产品经理",
+	"王五": "UI设计师"
+}
+
+# ==================== 游戏配置 ====================
+const PLAYER_SPEED = 200.0  # 玩家移动速度
+const INTERACTION_DISTANCE = 80.0  # 交互距离
+const NPC_STATUS_UPDATE_INTERVAL = 30.0  # NPC状态更新间隔(秒)
+
+# ==================== UI配置 ====================
+const DIALOGUE_FADE_TIME = 0.3  # 对话框淡入淡出时间
+const NPC_LABEL_OFFSET = Vector2(0, -60)  # NPC名字标签偏移
+
+# ==================== 调试配置 ====================
+const DEBUG_MODE = true  # 调试模式
+const SHOW_INTERACTION_RANGE = true  # 显示交互范围
+
+# ==================== 工具函数 ====================
+func log_info(message: String) -> void:
+	if DEBUG_MODE:
+		print("[INFO] ", message)
+
+func log_error(message: String) -> void:
+	print("[ERROR] ", message)
+
+func log_api(endpoint: String, data: Dictionary) -> void:
+	if DEBUG_MODE:
+		print("[API] ", endpoint, " -> ", JSON.stringify(data))

+ 1 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/config.gd.uid

@@ -0,0 +1 @@
+uid://d151bcim8i2qt

+ 206 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/dialogue_ui.gd

@@ -0,0 +1,206 @@
+# 对话UI脚本
+extends CanvasLayer
+
+# 节点引用
+@onready var panel: Panel = $Panel
+@onready var npc_name_label: Label = $Panel/NPCName
+@onready var npc_title_label: Label = $Panel/NPCTitle
+@onready var dialogue_text: RichTextLabel = $Panel/DialogueText
+@onready var player_input: LineEdit = $Panel/PlayerInput
+@onready var send_button: Button = $Panel/SendButton
+@onready var close_button: Button = $Panel/CloseButton
+
+# 当前对话的NPC
+var current_npc_name: String = ""
+
+# API客户端引用
+var api_client: Node = null
+
+func _ready():
+	# 添加到对话系统组
+	add_to_group("dialogue_system")
+
+	# 初始隐藏
+	visible = false
+
+	# 连接按钮信号
+	send_button.pressed.connect(_on_send_button_pressed)
+	close_button.pressed.connect(_on_close_button_pressed)
+	player_input.text_submitted.connect(_on_text_submitted)
+
+	# 获取API客户端
+	api_client = get_node_or_null("/root/APIClient")
+	if api_client:
+		api_client.chat_response_received.connect(_on_chat_response_received)
+		api_client.chat_error.connect(_on_chat_error)
+
+	print("[INFO] 对话UI初始化完成")
+
+# ⭐ 处理对话框快捷键
+func _input(event: InputEvent):
+	# 如果对话框不可见,不处理
+	if not visible:
+		return
+
+	if event is InputEventKey and event.pressed and not event.echo:
+		# ESC键 - 关闭对话框 
+		if event.keycode == KEY_ESCAPE:
+			hide_dialogue()
+			get_viewport().set_input_as_handled()
+			print("[DEBUG] ESC键关闭对话框")
+			return
+
+		# 回车键 - 发送消息 (仅当输入框有焦点时) 
+		# 注意: LineEdit的text_submitted信号已经处理了回车,这里只是额外保险
+		if event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER:
+			# 如果输入框有焦点,让LineEdit自己处理
+			if player_input.has_focus():
+				return
+			# 否则手动发送
+			send_message()
+			get_viewport().set_input_as_handled()
+			print("[DEBUG] 回车键发送消息")
+			return
+
+		# 屏蔽移动键和交互键,防止触发游戏操作 ⭐ WASD键
+		if event.keycode in [KEY_E, KEY_SPACE, KEY_W, KEY_A, KEY_S, KEY_D]:
+			get_viewport().set_input_as_handled()
+			# 只在第一次屏蔽时打印,避免刷屏
+			match event.keycode:
+				KEY_E:
+					print("[DEBUG] 对话框中屏蔽了E键输入")
+				KEY_SPACE:
+					print("[DEBUG] 对话框中屏蔽了空格键输入")
+				KEY_W:
+					print("[DEBUG] 对话框中屏蔽了W键输入")
+				KEY_A:
+					print("[DEBUG] 对话框中屏蔽了A键输入")
+				KEY_S:
+					print("[DEBUG] 对话框中屏蔽了S键输入")
+				KEY_D:
+					print("[DEBUG] 对话框中屏蔽了D键输入")
+
+func start_dialogue(npc_name: String):
+	"""开始与NPC对话"""
+	current_npc_name = npc_name
+
+	# 通知NPC进入交互状态 (停止移动) 
+	var npc = get_npc_by_name(npc_name)
+	if npc and npc.has_method("set_interacting"):
+		npc.set_interacting(true)
+
+	# 设置NPC信息
+	npc_name_label.text = npc_name
+	npc_title_label.text = Config.NPC_TITLES.get(npc_name, "")
+
+	# 清空对话内容
+	dialogue_text.clear()
+	dialogue_text.append_text("[color=gray]与 " + npc_name + " 的对话开始...[/color]\n")
+
+	# 清空输入框
+	player_input.text = ""
+
+	# 显示对话框
+	show_dialogue()
+
+	# 聚焦输入框
+	player_input.grab_focus()
+
+	print("[INFO] 开始对话: ", npc_name)
+
+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
+
+	# 通知NPC退出交互状态 (恢复移动) 
+	if current_npc_name != "":
+		var npc = get_npc_by_name(current_npc_name)
+		if npc and npc.has_method("set_interacting"):
+			npc.set_interacting(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_text_submitted(_text: String):
+	"""输入框回车"""
+	send_message()
+
+func send_message():
+	"""发送消息"""
+	var message = player_input.text.strip_edges()
+	
+	if message.is_empty():
+		return
+	
+	if current_npc_name.is_empty():
+		print("[ERROR] 没有选择NPC")
+		return
+	
+	# 显示玩家消息
+	dialogue_text.append_text("\n[color=cyan]玩家:[/color] " + message + "\n")
+	
+	# 清空输入框
+	player_input.text = ""
+	
+	# 显示等待提示
+	dialogue_text.append_text("[color=gray]等待回复...[/color]\n")
+	
+	# 发送API请求
+	if api_client:
+		api_client.send_chat(current_npc_name, message)
+	else:
+		print("[ERROR] API客户端未找到")
+
+func _on_chat_response_received(npc_name: String, message: String):
+	"""收到NPC回复"""
+	if npc_name != current_npc_name:
+		return
+	
+	# 移除"等待回复..."
+	var text = dialogue_text.get_parsed_text()
+	if text.ends_with("等待回复...\n"):
+		# 清除最后一行
+		dialogue_text.clear()
+		var lines = text.split("\n")
+		for i in range(lines.size() - 2):
+			dialogue_text.append_text(lines[i] + "\n")
+	
+	# 显示NPC回复
+	dialogue_text.append_text("[color=yellow]" + npc_name + ":[/color] " + message + "\n")
+	
+	# 滚动到底部
+	dialogue_text.scroll_to_line(dialogue_text.get_line_count() - 1)
+
+func _on_chat_error(error_message: String):
+	"""对话错误"""
+	dialogue_text.append_text("[color=red]错误: " + error_message + "[/color]\n")
+
+func _on_close_button_pressed():
+	"""关闭按钮点击"""
+	hide_dialogue()
+
+# ⭐ 根据名字获取NPC节点
+func get_npc_by_name(npc_name: String) -> Node:
+	"""根据名字获取NPC节点"""
+	var npcs = get_tree().get_nodes_in_group("npcs")
+	for npc in npcs:
+		if npc.npc_name == npc_name:
+			return npc
+	return null

+ 1 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/dialogue_ui.gd.uid

@@ -0,0 +1 @@
+uid://dk1f7x00sdtru

+ 61 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/main.gd

@@ -0,0 +1,61 @@
+# 主场景脚本
+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

+ 1 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/main.gd.uid

@@ -0,0 +1 @@
+uid://dyfhfmncwhby0

+ 250 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/npc.gd

@@ -0,0 +1,250 @@
+# NPC脚本
+extends CharacterBody2D  # ⭐ 改为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 interaction_hint: Label = null
+
+# 玩家引用
+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
+
+	# 尝试获取交互提示节点 (可选)
+	interaction_hint = get_node_or_null("InteractionHint")
+	if interaction_hint:
+		interaction_hint.text = "按E交互"
+		interaction_hint.visible = false
+		print("[INFO] NPC交互提示已启用: ", npc_name)
+	else:
+		print("[WARN] NPC没有InteractionHint节点,交互提示已禁用: ", npc_name)
+
+	# 设置自定义精灵帧 (如果有)
+	if sprite_frames != null:
+		animated_sprite.sprite_frames = sprite_frames
+		print("[INFO] NPC使用自定义精灵: ", npc_name)
+
+	# 播放默认动画
+	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()
+
+	Config.log_info("NPC初始化: " + npc_name)
+
+func _on_body_entered(body: Node2D):
+	"""玩家进入交互范围"""
+	print("[DEBUG] NPC ", npc_name, " 检测到物体进入: ", body.name, " 是否在player组: ", body.is_in_group("player"))
+
+	if body.is_in_group("player"):
+		player = body
+		print("[INFO] ✅ 玩家进入NPC范围: ", npc_name)
+
+		if player.has_method("set_nearby_npc"):
+			player.set_nearby_npc(self)
+		else:
+			print("[ERROR] 玩家没有set_nearby_npc方法!")
+
+		# 显示提示
+		show_interaction_hint()
+
+func _on_body_exited(body: Node2D):
+	"""玩家离开交互范围"""
+	print("[DEBUG] NPC ", npc_name, " 检测到物体离开: ", body.name)
+
+	if body.is_in_group("player"):
+		print("[INFO] ❌ 玩家离开NPC范围: ", npc_name)
+
+		if player != null and player.has_method("set_nearby_npc"):
+			player.set_nearby_npc(null)
+		player = null
+
+		# 隐藏提示
+		hide_interaction_hint()
+
+func show_interaction_hint():
+	"""显示交互提示"""
+	if interaction_hint:
+		interaction_hint.visible = true
+		print("[INFO] 显示交互提示: ", npc_name)
+
+func hide_interaction_hint():
+	"""隐藏交互提示"""
+	if interaction_hint:
+		interaction_hint.visible = false
+		print("[INFO] 隐藏交互提示: ", npc_name)
+
+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 get_npc_name() -> String:
+	return npc_name
+
+func get_npc_title() -> String:
+	return npc_title
+
+# ⭐ 物理更新 - 处理移动
+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
+
+	Config.log_info("NPC %s 选择新目标: %s" % [npc_name, wander_target])
+
+# ⭐ 更新动画
+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
+	if interacting:
+		Config.log_info("NPC %s 进入交互状态,停止移动" % npc_name)
+	else:
+		Config.log_info("NPC %s 退出交互状态,恢复移动" % npc_name)

+ 1 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/npc.gd.uid

@@ -0,0 +1 @@
+uid://cedfqqodwcl2a

+ 195 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/player.gd

@@ -0,0 +1,195 @@
+# 玩家控制脚本
+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")
+
+	if interact_sound:
+		print("[INFO] 玩家交互音效已启用")
+	else:
+		print("[WARN] 玩家没有InteractSound节点,交互音效已禁用")
+
+	if running_sound:
+		print("[INFO] 玩家走路音效已启用")
+	else:
+		print("[WARN] 玩家没有RunningSound节点,走路音效已禁用")
+
+	Config.log_info("玩家初始化完成")
+	# 启用相机
+	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交互
+	# 检查E键 (KEY_E = 69)
+	if event is InputEventKey:
+		if event.pressed and not event.echo:
+			# 调试: 打印所有按键
+			print("[DEBUG] 按键: ", event.keycode, " (E=69, Enter=4194309)")
+
+			if event.keycode == KEY_E or event.keycode == KEY_ENTER or event.is_action_pressed("ui_accept"):
+				print("[DEBUG] 检测到E键, nearby_npc=", nearby_npc)
+				if nearby_npc != null:
+					interact_with_npc()
+					print("[INFO] E键触发交互")
+				else:
+					print("[WARN] 没有附近的NPC可以交互")
+
+func interact_with_npc():
+	"""与附近的NPC交互"""
+	if nearby_npc != null:
+		# 播放交互音效 ⭐ 
+		if interact_sound:
+			interact_sound.play()
+
+		Config.log_info("与NPC交互: " + nearby_npc.npc_name)
+		# 发送信号给对话系统
+		get_tree().call_group("dialogue_system", "start_dialogue", nearby_npc.npc_name)
+
+func set_nearby_npc(npc: Node):
+	"""设置附近的NPC"""
+	nearby_npc = npc
+	if npc != null:
+		print("[INFO] ✅ 进入NPC范围: ", npc.npc_name)
+		Config.log_info("进入NPC范围: " + npc.npc_name)
+	else:
+		print("[INFO] ❌ 离开NPC范围")
+		Config.log_info("离开NPC范围")
+
+func get_nearby_npc() -> Node:
+	"""获取附近的NPC"""
+	return nearby_npc
+
+func set_interacting(interacting: bool):
+	"""设置交互状态"""
+	is_interacting = interacting
+	if interacting:
+		print("[INFO] 🔒 玩家进入交互状态,移动已禁用")
+		# 停止走路音效 ⭐ 
+		stop_running_sound()
+	else:
+		print("[INFO] 🔓 玩家退出交互状态,移动已启用")
+
+# ⭐ 更新走路音效
+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
+			print("[INFO] 🎵 开始播放走路音效")
+	else:
+		# 如果停止移动,停止音效
+		stop_running_sound()
+
+# ⭐ 停止走路音效
+func stop_running_sound():
+	"""停止走路音效"""
+	if running_sound and is_playing_running_sound:
+		running_sound.stop()
+		is_playing_running_sound = false
+		print("[INFO] 🔇 停止走路音效")

+ 1 - 0
code/chapter15/Helloagents-AI-Town/helloagents-ai-town/scripts/player.gd.uid

@@ -0,0 +1 @@
+uid://cr0rf00w5q53d

+ 1911 - 0
docs/chapter15/第十五章 构建赛博小镇.md

@@ -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>

BIN
docs/images/15-figures/15-1.png


BIN
docs/images/15-figures/15-10.png


BIN
docs/images/15-figures/15-11.png


BIN
docs/images/15-figures/15-12.png


BIN
docs/images/15-figures/15-13.png


BIN
docs/images/15-figures/15-14.png


BIN
docs/images/15-figures/15-15.png


BIN
docs/images/15-figures/15-2.png


BIN
docs/images/15-figures/15-3.png


BIN
docs/images/15-figures/15-4.png


BIN
docs/images/15-figures/15-5.png


BIN
docs/images/15-figures/15-6.png


BIN
docs/images/15-figures/15-7.png


BIN
docs/images/15-figures/15-8.png


BIN
docs/images/15-figures/15-9.png