TL;DR: 本文介绍一个课程大作业级别的 NLP 文字冒险游戏项目。核心架构采用"规则层锁定核心判定,大模型只负责叙事表现"的混合策略。实现完整的战斗状态机、生存系统(饱食度/理智/士气)、地图移动约束、物品白名单、环境影响和 AI 图片生成,配套 Gradio 交互界面和完整的 pytest 测试体系。
一、为什么做这个项目
纯 LLM 驱动的文字冒险存在一个根本问题:大模型无法保证核心游戏机制的确定性。战斗结果、物品获取、剧情推进都可能因模型"发挥"而偏离设计预期,这对课程答辩和可复现测试是致命的。
StoryWeaver 的核心设计原则:用规则层接管所有数值判定,用大模型只做叙事层的语言生成。
玩家自由输入 → 规则层做确定性判定 → 大模型生成叙事表现二、系统架构
┌──────────────────────────────────────────────────────────────────────────┐
│ app.py (Gradio UI) │
│ 用户输入 / 按钮事件 / 状态面板渲染 │
└──────────────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────────────┐
│ nlu_engine.py (NLU) │
│ 玩家文本 → 结构化意图(战斗/移动/拾取/对话等) │
└──────────────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────────────┐
│ rules_engine.py (规则引擎) │
│ 战斗状态机 | 生存系数 | 环境修正 | 移动约束 | 地图迷雾 | 场景图生成 │
└──────────────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────────────┐
│ state_manager.py (状态管理) │
│ GameState 唯一数据源 | 状态校验 | 状态更新 | 事件日志 │
└──────────────────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────────────────┐
│ story_engine.py (叙事引擎) │
│ 规则判定结果 → 大模型生成自然语言叙事 / 受控文案回退 │
└──────────────────────────────────────────────────────────────────────────┘三、核心模块实现
3.1 战斗状态机:规则层固定判定
这是本项目最关键的设计。所有战斗结果由规则层计算,大模型只生成战斗表现文案:
# rules_engine.py
def resolve_combat(self, game_state: "GameState", monster_name: str) -> dict[str, Any]:
monster = self.config.monsters.get(monster_name)
if monster is None:
return {"outcome_tier": "invalid", "model_result_locked": True, ...}
# 构建玩家战斗快照(含装备加成、生存状态影响)
player_snapshot = self._build_player_combat_snapshot(game_state)
# 计算战斗功率
monster_power = self._combat_power(monster.attack, monster.defense, monster.stamina, 1.0)
player_power = self._combat_power(
player_snapshot["attack"],
player_snapshot["defense"],
player_snapshot["stamina"],
player_snapshot["state_coefficient"],
)
ratio = player_power / monster_power if monster_power else 0.0
# 依据功率比划分胜负等级
if threshold_failed or effective_ratio < self.config.combat.impossible_ratio:
outcome_tier = "impossible" # 必败
player_victory = False
elif effective_ratio < self.config.combat.normal_ratio:
outcome_tier = "pyrrhic" # 惨胜
elif effective_ratio < self.config.combat.crushing_ratio:
outcome_tier = "normal" # 普通胜利
else:
outcome_tier = "crushing" # 压倒性胜利
# 计算资源消耗(HP、体力、饱食度、士气、理智)
resource_changes = self._combat_resource_changes(outcome_tier, closeness, armor_break_blocked)
return {
"outcome_tier": outcome_tier,
"player_victory": player_victory,
"model_result_locked": True, # 关键标志:模型不可更改结果
"resource_changes": resource_changes,
"loot": list(monster.loot) if player_victory else [],
"experience_reward": monster.experience_reward if player_victory else 0,
...
}战斗功率公式:
def _combat_power(self, attack, defense, stamina, coefficient):
return (
(attack * self.config.combat.attack_weight) +
(defense * self.config.combat.defense_weight) +
(stamina * self.config.combat.stamina_weight)
) * coefficient3.2 生存系统:梯度系数替代阈值判断
def recalculate_survival_state(self, game_state: "GameState") -> dict[str, Any]:
# 梯度系数:饱和区间增益,极低区间惩罚
hunger_coeff = self._gradient_multiplier(game_state.player.hunger)
sanity_coeff = self._gradient_multiplier(game_state.player.sanity)
morale_coeff = self._gradient_multiplier(game_state.player.morale)
combat_multiplier = (hunger_coeff + sanity_coeff + morale_coeff) / 3
# 状态标志
flags = {
"peak_state": all(v > 80 for v in (hunger, sanity, morale)),
"near_death": sum(v < 10 for v in (...)) >= 2,
"heroic_state": any(v >= 80 for v in (...)),
"extreme_weakness": any(v < 10 for v in (...)),
}
# 不同状态对应不同战斗加成/惩罚
if flags["peak_state"]:
combat_multiplier *= 1.15
hit_rate_bonus += 6
dodge_rate_bonus += 5
if flags["near_death"]:
combat_multiplier *= 0.5
hit_rate_bonus -= 10梯度系数计算(非线性映射,避免一刀切阈值):
def _gradient_multiplier(self, value: int) -> float:
if value >= 80:
return 1.2 + ((value - 80) / 20) * 0.3 # 1.2 ~ 1.5
if value >= 30:
return 1.0 # 正常区间
if value >= 10:
return 0.7 + ((value - 10) / 20) * 0.2 # 0.7 ~ 0.9
return 0.3 + (value / 10) * 0.3 # 0.3 ~ 0.63.3 地图移动约束:BFS 最短路径
def resolve_movement(self, game_state, target_location, *, simulate_events=False):
current = game_state.player.location
# BFS 查找最短路径
path = self._shortest_path(game_state, current, target_location)
if not path:
return {"allowed": False, "reason": f"无法从 {current} 前往 {target_location}"}
# 路径必须相邻,不允许跳跃
if len(path) > 1:
return {
"allowed": False,
"reason": "移动必须经过相邻节点,不允许跳跃",
"required_path": path[1:],
}
# 结算耗时、体力消耗、随机遭遇
for scene_id in path[1:]:
time_cost_units += node.travel_time_cost
stamina_cost += node.travel_stamina_cost
if simulate_events and node.encounter_chance > 0:
if random.random() < node.encounter_chance:
encounter_triggered = True
break3.4 物品白名单与拾取闭环
def build_scene_loot(self, game_state, defeated_monster=None) -> list[str]:
# 1. 优先检查场景专属掉落
scene_specific = self.config.scene_specific_loot.get(game_state.player.location, ())
for item_name in scene_specific:
if item_name not in owned:
return [item_name]
# 2. 检查怪物掉落(击败怪物后)
if defeated_monster and defeated_monster in self.config.monsters:
candidates = list(self.config.monsters[defeated_monster].loot)
else:
# 3. 场景绑定物品(白名单)
candidates = list(self.config.scene_item_bindings.get(binding_group, ()))
return [item for item in candidates if item not in owned][:1]拾取必须二次确认(防止自动入包破坏策略性):
# 待拾取物品进入 pending_pickups
# UI 提供选项:拾取 / 放弃 / 查看3.5 环境影响系统
def _combat_environment_modifiers(self, game_state):
hit_rate_bonus = 0
power_multiplier = 1.0
# 黑暗环境
if light_level in {"dark", "black"} and not self._has_light_mitigation(game_state):
hit_rate_bonus -= 20 if light_level == "dark" else 30
dodge_rate_bonus += 8 if light_level == "dark" else 15
power_multiplier *= 0.92
# 雨雪天气
if weather in {"小雨", "暴风雨"} and not self._has_rain_mitigation(game_state):
hit_rate_bonus -= 10
power_multiplier *= 0.95
# 火把可抵消黑暗,雨衣可抵消雨雪
return {"hit_rate_bonus": hit_rate_bonus, "power_multiplier": power_multiplier}3.6 AI 图片生成:本地优先 + SVG 回退
def build_scene_image_payload(self, game_state) -> dict | None:
# 1. 优先加载本地同名图片
local_payload = self._build_local_image_payload("scene", scene_name)
if local_payload is not None:
return local_payload
# 2. 白名单场景生成 SVG 回退图
node = self.config.scene_nodes.get(scene_name)
if node is None or node.scene_type not in self.config.scene_image_whitelist:
return None # 不在白名单则不展示图片
svg = self._build_scene_svg(
title=scene_name,
subtitle=f"{time} | {weather} | {light_level}",
scene_type=node.scene_type,
)
image_data_uri = "data:image/svg+xml;base64," + base64.b64encode(svg).decode()
return {"kind": "scene", "mode": "svg_fallback", "image_data_uri": image_data_uri}白名单控制确保只有高频重要场景才生成图片,避免无意义调用。
3.7 UI 状态面板去重优化
def build_scene_info(self, game_state) -> dict:
summary_parts = []
# 收集描述片段
if location.description:
summary_parts.append(location.description)
if location.ambient_description:
summary_parts.append(location.ambient_description)
if npcs:
summary_parts.append(f"可见人物:{'、'.join(npcs)}。")
if pending_pickups:
summary_parts.append(f"地面上留有待处理物品:{'、'.join(pending_pickups)}。")
# 去重:移除与环境标签重复的内容
summary = " ".join(summary_parts)
for tag in (game_state.world.time_of_day, game_state.world.weather, game_state.player.location):
summary = summary.replace(tag, "")
summary = " ".join(summary.split())
return {"tags": tags, "summary": summary.strip()}四、运行链路
玩家输入 → NLU 意图识别 → 预校验 → 规则层判定 → 状态更新 → 叙事生成/受控文案 → UI同步 → 日志记录关键原则:
GameState是唯一状态源RulesEngine是核心规则执行器StoryEngine只在允许范围内调用模型生成文本app.py只负责交互编排,不负责核心玩法判定
五、测试覆盖
完整的 pytest 测试套件覆盖所有规则:
pytest tests/test_rules_config.py \
tests/test_combat_rules.py \
tests/test_survival_rules.py \
tests/test_movement_rules.py \
tests/test_item_rules.py \
tests/test_environment_rules.py \
tests/test_ui_scene_state.py -v验证点示例:
低战力不能越级秒杀高防怪(不可能战斗)
高战力可碾压基础怪(压倒性胜利)
徒手对高防目标强制最低伤害且消耗翻倍
饥饿半天扣血是否闭环执行
生命值归零是否锁死游戏流程
非相邻移动是否被拒绝
黑暗环境扣理智和士气,火把是否抵消 debuff
六、Trade-offs 与局限性
七、项目结构
StoryWeaver/
├── app.py # Gradio UI、输入处理、状态渲染
├── nlu_engine.py # 玩家文本 → 结构化意图
├── story_engine.py # 叙事生成 + 受控文案
├── state_manager.py # GameState 唯一数据源
├── rules_engine.py # 规则执行器(战斗/生存/移动/环境)
├── rules_config.py # 固化配置(怪物/地图/物品白名单)
├── utils.py # 通用工具(clamp 等)
├── requirements.txt
├── .env # QWEN_API_KEY(可选)
├── image/ # 本地场景/NPC 图片
├── tests/ # pytest 测试套件
└── docs/superpowers/ # 设计说明文档八、快速开始
# 1. 安装依赖
pip install -r requirements.txt
# 2. 配置 API Key(可选)
echo "QWEN_API_KEY=your_key" > .env
# 3. 启动
python app.py
# 访问 http://127.0.0.1:7860
# 4. 运行测试
pytest tests/ -v
评论区