山东大学软件工程2023级创新项目实训 | 八、StoryEcho结局系统——从条件判定到命运的最终回响 时间2026年6月在存档系统完善之后叙事游戏的最后一个关键拼图——结局系统摆在了我的面前。如果说存档系统回答的是“玩家现在在哪里”那么结局系统回答的就是“玩家最终走向何方”。本文将从结局系统的设计思路出发介绍条件判定、结局触发、结局画廊等核心功能的实现过程并讨论当前实现的局限性与未来的优化方向。目录一、结局系统的设计目标二、结局的数据结构设计2.1 结局配置——单一数据源2.2 结局存储表2.3 结局记录的核心函数三、结局触发机制3.1 结局类型的划分3.2 结局触发时机3.3 结局条件的判定四、结局画廊——收集与展示4.1 画廊的数据获取4.2 画廊的前端实现4.3 结局筛选功能五、开发中遇到的问题与解决问题1结局触发后未被正确记录问题2回合数达到80的结局判定时机问题3结局画廊数据不刷新问题4结局记录的防重复机制六、与成就系统的联动七、当前实现的局限性与未来优化7.1 条件判定的局限性7.2 多结局分支的实现7.3 结局CG和动画7.4 结局统计数据7.5 结局与NGP的联动7.6 隐藏结局的提示机制7.7 结局回放功能八、总结与展示8.1 结局功能完成情况8.2 功能演示8.3 技术收获一、结局系统的设计目标在动手实现之前我梳理了结局系统需要满足的核心需求功能需求需求说明结局触发根据游戏状态自动触发对应的结局结局记录解锁的结局持久化保存跨周目保留结局画廊展示所有可能的结局及解锁状态结局详情查看已解锁结局的详细描述和达成时间成就联动结局触发时应同时检查相关成就设计原则条件驱动的触发机制——结局不应由LLM“随机生成”而应由明确的游戏状态条件触发一次游戏一次结局——一旦触发结局游戏流程结束不再响应玩家输入跨周目收集——已解锁的结局永久保留激励玩家多周目探索结局与成就分离但联动——结局是故事的终点成就是玩家行为的勋章二、结局的数据结构设计2.1 结局配置——单一数据源为了避免配置分散导致的不一致我创建了ending_manager.py作为结局配置的统一管理模块# backend/ending_manager.py STORY_ENDINGS_CONFIG { fantasy_001: { # 失败结局 bad_ending_death: { name: 牺牲, type: failure, rarity: common, description: 为了保护森林你献出了自己的生命, unlock_condition: 生命值归零 }, # 主线结局 - 普通 normal_ending: { name: 未竟的旅程, type: main, rarity: common, description: 故事告一段落但迷雾森林的秘密仍未完全揭开, unlock_condition: 回合数达到80 }, # 主线结局 - 好结局 good_ending_true: { name: 生命之树的守护者, type: main, rarity: rare, description: 你成功找到了生命之树并成为了它的守护者, unlock_condition: 智力≥30 且 抵达生命之树 }, # 隐藏结局 dark_ending: { name: 暗影之王, type: hidden, rarity: hidden, description: 你吸收了暗影力量成为了新的暗影领主, unlock_condition: 邪恶值≥60选择吸收暗影力量 } }, scifi_001: { bad_ending_death: { name: 系统崩溃, type: failure, rarity: common, description: 你在任务中倒下意识永远沉入了数据深渊, unlock_condition: 生命值归零 }, normal_ending: { name: 数据湮灭, type: main, rarity: common, description: 你完成了任务但真相永远被掩埋, unlock_condition: 回合数达到80 }, good_ending_true: { name: 意识觉醒, type: main, rarity: rare, description: 你揭开了真相获得了真正的自由, unlock_condition: 智力≥30 且 科技技能≥25 } } }为什么这样设计所有结局定义集中在一个文件中便于维护ending_key作为唯一标识便于代码中引用包含结局类型、稀有度、描述等元数据供画廊展示使用支持为不同故事配置不同的结局集2.2 结局存储表结局数据存储在数据库的endings表中CREATE TABLE endings ( ending_id TEXT PRIMARY KEY, user_id TEXT, story_id TEXT, ending_name TEXT, ending_type TEXT, rarity TEXT, achieved_at TEXT, stats_json TEXT, playthrough INTEGER DEFAULT 1, ending_key TEXT, character_id TEXT );关键字段说明ending_key结局的唯一标识如good_ending_true用于代码判断stats_json存储达成结局时的游戏状态快照回合数、属性等playthrough记录该结局是在第几周目达成的character_id记录是哪个角色达成了这个结局2.3 结局记录的核心函数def record_ending(user_id, story_id, ending_key, character_idNone, playthrough1, statsNone) - bool: 记录结局到数据库 story_config STORY_ENDINGS_CONFIG.get(story_id, {}) ending_config story_config.get(ending_key) if not ending_config: print(f[ENDING] 未找到结局配置: {ending_key}) return False # 检查是否已解锁避免重复记录 if is_ending_unlocked(user_id, story_id, ending_key): print(f[ENDING] 结局已解锁跳过记录: {ending_key}) return False conn sqlite3.connect(DB_PATH) cursor conn.cursor() ending_id str(uuid.uuid4()) now datetime.now().isoformat() stats_json json.dumps(stats or {}, ensure_asciiFalse) cursor.execute( INSERT INTO endings (ending_id, user_id, story_id, ending_name, ending_type, rarity, achieved_at, stats_json, playthrough, ending_key, character_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) , (ending_id, user_id, story_id, ending_config[name], ending_config[type], ending_config[rarity], now, stats_json, playthrough, ending_key, character_id)) conn.commit() conn.close() return True三、结局触发机制3.1 结局类型的划分根据游戏设计我划分了以下几种结局类型类型说明示例main主线结局推进故事的主要结局生命之树的守护者、意识觉醒failure失败结局玩家角色死亡牺牲、系统崩溃hidden隐藏结局需要满足特殊条件暗影之王3.2 结局触发时机结局的判定放在两个关键位置1. 每次状态更新后_apply_state_update游戏状态的每次变化属性变化、节点解锁、回合增加都可能触发结局条件因此在状态更新后立即检查# story_engine.py - _apply_state_update 方法结尾 if not state.get(ending_triggered): self._check_and_trigger_ending(state)2. 每次行动处理后process_action确保玩家每次行动后都会检查结局条件def process_action(self, state, user_input, thread_idNone): # ... 处理行动 ... result self.graph.invoke(state, configconfig) # 强制检查结局条件 self._check_and_trigger_ending(result) return result3.3 结局条件的判定核心判定逻辑如下# story_engine.py def _check_and_trigger_ending(self, state): hp state.get(attributes, {}).get(hp, 100) turn_count state.get(turn_count, 0) intelligence state.get(attributes, {}).get(intelligence, 0) unlocked_nodes state.get(unlocked_nodes, []) story_id state.get(story_id, ) print(f[ENDING] 检查结局条件:) print(f - hp{hp} (0: {hp 0})) print(f - turn_count{turn_count} (80: {turn_count 80})) print(f - intelligence{intelligence} (30: {intelligence 30})) print(f - has_life_tree{node_tree_of_life in unlocked_nodes}) # 1. 优先检查生命值归零死亡结局 if hp 0: triggered_ending bad_ending_death ending_reason 生命值归零 # 2. 检查普通结局80回合 elif turn_count 80 and not state.get(ending_triggered): triggered_ending normal_ending ending_reason f回合数达到{turn_count} # 3. 检查 Good Ending智力≥30 且 抵达生命之树 elif (intelligence 30 and node_tree_of_life in unlocked_nodes and story_id fantasy_001): triggered_ending good_ending_true ending_reason 智力≥30 且 抵达生命之树 # 触发结局时记录 if triggered_ending and not state.get(ending_triggered): print(f[ENDING] 触发结局: {triggered_ending}) state[ending_triggered] triggered_ending from .ending_manager import record_ending record_ending( user_idstate.get(user_id), story_idstory_id, ending_keytriggered_ending, character_idstate.get(character_id), playthroughstate.get(playthrough, 1), stats{ turn_count: turn_count, attributes: dict(state.get(attributes, {})), unlocked_nodes: list(unlocked_nodes), current_node: state.get(current_node), ending_reason: ending_reason } )判定顺序的设计考虑死亡结局的优先级最高——玩家生命值归零时立即触发这是最自然的失败条件。然后是普通结局80回合作为“保底”结局最后是好结局作为奖励性结局。这种优先级设置确保了玩家不会因为同时满足多个条件而产生歧义。四、结局画廊——收集与展示4.1 画廊的数据获取结局画廊需要展示故事的所有可能结局及其解锁状态# ending_manager.py def get_all_endings_with_status(user_id: str, story_id: str) - List[Dict]: 获取故事的所有可能结局并标记用户是否已解锁 story_config STORY_ENDINGS_CONFIG.get(story_id, {}) if not story_config: print(f[ENDING] 警告: 故事 {story_id} 没有结局配置) return [] # 获取用户已解锁的结局 unlocked_endings get_user_endings(user_id, story_id) # 构建已解锁映射 unlocked_map {e[ending_key]: e for e in unlocked_endings} result [] for ending_key, config in story_config.items(): unlocked unlocked_map.get(ending_key) result.append({ ending_id: f{story_id}_{ending_key}, ending_key: ending_key, name: config[name], type: config[type], rarity: config[rarity], description: config[description], unlock_condition: config.get(unlock_condition, 达成特定条件), achieved: unlocked is not None, achieved_at: unlocked.get(achieved_at) if unlocked else None, playthrough: unlocked.get(playthrough) if unlocked else None, story_id: story_id }) return result4.2 画廊的前端实现结局画廊采用左右分区的设计左侧展示故事标签页和筛选器右侧以网格形式展示结局卡片!-- 故事标签页切换 -- div classstory-tabs div v-forstory in storyList :keystory.id classstory-tab :class{active: activeStoryTab story.id} clickactiveStoryTab story.id span classtab-icon{{ story.icon }}/span span classtab-name{{ story.name }}/span span classtab-progress{{ getStoryEndingProgress(story.id) }}/span /div /div !-- 结局卡片 -- div classendings-grid div v-forending in filteredEndings :keyending.ending_id classending-card :class{unlocked: ending.achieved, locked: !ending.achieved} clickviewEndingDetail(ending) !-- 已解锁结局显示名称和信息 -- div v-ifending.achieved classending-preview div classending-image :classending.type {{ getEndingEmoji(ending.type) }} /div div classending-info h4{{ ending.name }}/h4 div classending-tags span classtype-tag :classending.type {{ getEndingTypeText(ending.type) }} /span span classrarity-tag :classending.rarity {{ getRarityText(ending.rarity) }} /span /div /div div classending-status span classunlocked-badge✅ 已解锁/span /div /div !-- 未解锁结局显示神秘问号 -- div v-else classhidden-ending div classmystery-icon❓/div p classmystery-text???/p div classhint-text{{ ending.unlock_condition }}/div /div /div /div4.3 结局筛选功能为了帮助玩家更好地浏览结局我实现了按结局类型筛选的功能// 结局筛选器 const endingFilters ref([ { value: all, label: 全部 }, { value: main, label: 主线 }, { value: side, label: 支线 }, { value: failure, label: 失败 }, { value: hidden, label: 隐藏 }, { value: achieved, label: 已解锁 } ]); // 筛选后的结局列表 const filteredEndings computed(() { let filtered endings.value.filter(e { const endingStoryId e.storyId || e.story_id; return endingStoryId activeStoryTab.value; }); if (activeFilter.value achieved) { filtered filtered.filter(e e.achieved); } else if (activeFilter.value ! all) { filtered filtered.filter(e e.type activeFilter.value); } return filtered; });五、开发中遇到的问题与解决问题1结局触发后未被正确记录现象玩家满足结局条件后游戏显示了结局界面但数据库中没有记录结局画廊中对应的结局仍显示为“未解锁”。原因_apply_state_update方法中虽然触发了结局但没有调用record_ending函数。解决在_apply_state_update中添加结局记录逻辑if triggered_ending and not state.get(ending_triggered): state[ending_triggered] triggered_ending from .ending_manager import record_ending record_ending( user_idstate.get(user_id), story_idstory_id, ending_keytriggered_ending, character_idstate.get(character_id), playthroughstate.get(playthrough, 1), stats{...} )问题2回合数达到80的结局判定时机现象当turn_count恰好等于80时有时能触发结局有时不能。原因结局检查在_apply_state_update中进行但turn_count的更新可能在检查之后才发生。解决在process_action处理完成后强制调用一次结局检查def process_action(self, state, user_input, thread_idNone): # ... 处理行动 ... result self.graph.invoke(state, configconfig) # 强制检查结局条件 self._check_and_trigger_ending(result) return result问题3结局画廊数据不刷新现象解锁新结局后关闭结局画廊再打开画廊中仍显示未解锁状态。原因结局画廊在打开时从后端获取数据但showEndingGallery变为true时没有重新加载。解决添加watch监听当画廊打开时重新加载数据watch(showEndingGallery, async (open) { if (open) { console.log([结局] 画廊打开加载数据); await loadEndings(activeStoryTab.value); } });问题4结局记录的防重复机制现象同一个结局条件满足时可能会触发多次记录。原因多次状态更新都检测到同一个结局条件导致重复调用record_ending。解决在record_ending中添加防重复检查def record_ending(user_id, story_id, ending_key, ...): # 检查是否已解锁避免重复记录 if is_ending_unlocked(user_id, story_id, ending_key): print(f[ENDING] 结局已解锁跳过记录: {ending_key}) return False # ... 执行记录 ...六、与成就系统的联动结局触发时往往也对应着某些成就的达成。例如“达成 Good Ending”本身就是一个成就。系统应该确保结局触发时自动检查相关成就。在story_engine.py的结局触发逻辑中我在记录结局后也调用了成就检查if triggered_ending and not state.get(ending_triggered): # 记录结局 record_ending(...) # 检查相关的故事成就 try: new_achievements evaluate_story_achievements_from_state(state) if new_achievements: # 成就将通过API返回给前端显示通知 pass except Exception as e: logger.warning(f结局相关成就检查失败: {e})这种设计保证了结局和成就的一致性——达成结局时相关联的成就会自动解锁。七、当前实现的局限性与未来优化虽然当前的结局系统已经能够正常工作但在实现过程中我也发现了不少可以改进的地方。以下是我计划在未来版本中优化的方向7.1 条件判定的局限性当前问题目前的结局判定依赖硬编码的条件判断if hp 0、if turn_count 80等。这种方式存在几个问题扩展性差——每增加一个结局就需要修改核心代码条件组合能力弱——无法灵活支持复杂的条件组合如“智力≥30 OR 力量≥40”条件类型固定——目前只支持hp、turn_count、intelligence等少数几种条件优化方案计划引入配置化的条件表达式系统类似成就系统已有的condition_json结构# 未来的结局配置示例 good_ending_true: { name: 生命之树的守护者, type: main, condition: { type: all_of, conditions: [ {type: attribute, attribute: intelligence, operator: , value: 30}, {type: node_visited, node: node_tree_of_life} ] } }这样新增结局时只需要修改配置文件无需改动核心代码。7.2 多结局分支的实现当前问题目前玩家进入结局的方式是“达到条件自动触发”缺少玩家在关键时刻的选择。一个好的叙事游戏应该在故事的关键节点提供选择让玩家自己决定走向哪个结局。优化方案计划实现“选择分支系统”在关键节点弹出选择对话框// 未来实现关键选择的弹窗 const showChoiceDialog (choices) { // choices 示例 // [ // { text: 与暗影领主战斗, leads_to: good_ending }, // { text: 接受黑暗力量, leads_to: dark_ending }, // { text: 尝试谈判, leads_to: hidden_ending, condition: intelligence25 } // ] }7.3 结局CG和动画当前问题目前的结局只有文字描述和状态栏缺少视觉上的冲击力。优化方案计划为不同类型的结局添加不同的视觉元素good_ending金色光芒、森林重生的描述性动画dark_ending暗色渐变、力量涌动的特效hidden_ending神秘符号、特殊的视觉风格7.4 结局统计数据当前问题虽然记录了stats_json但前端并未展示这些信息如达成时的属性、回合数等。优化方案在结局详情弹窗中展示更多达成时的数据div classending-stats h5达成时的状态/h5 div classstat-summary div classstat-item回合数24/div div classstat-item好感度最高艾莉西亚 (85)/div div classstat-item持有金币1,250/div div classstat-item已击败敌人5种/div /div /div7.5 结局与NGP的联动当前问题当前的多周目系统NGP尚未与结局系统深度整合。玩家解锁的结局数量应该影响下一周目的继承加成。优化方案# 未来实现根据结局数量计算继承加成 def calculate_ngp_bonus(user_id, story_id): unlocked_endings get_user_ending_stats(user_id, story_id)[unlocked] bonus { hp: unlocked_endings * 5, strength: unlocked_endings // 2, money: unlocked_endings * 100 } return bonus7.6 隐藏结局的提示机制当前问题隐藏结局的解锁条件对玩家来说完全是黑箱玩家可能需要反复尝试才能发现。优化方案设计“命运指引”系统在达成某些前置条件后给予提示 命运的丝线轻轻颤动...你感觉如果选择不同的道路可能会走向不同的终点。或者在NPC对话中埋下伏笔“我曾听说...如果你能同时赢得所有人的信任森林的命运或许会有所不同。”7.7 结局回放功能当前问题已经解锁的结局无法再次观看玩家只能从画廊看到名称和描述。优化方案计划实现结局回放功能——点击已解锁的结局卡片可以重新观看完整的结局剧情从触发点到结束。八、总结与展示8.1 结局功能完成情况功能状态说明结局触发✅基于生命值、回合数、属性、节点等条件结局记录✅持久化存储跨周目保留结局画廊✅展示所有结局及解锁状态结局筛选✅按类型主线/支线/失败/隐藏筛选结局详情✅点击查看结局描述和达成时间成就联动✅结局触发时检查相关成就条件配置化计划中将硬编码改为配置选择分支计划中增加玩家主动选择结局CG计划中增强视觉体验NGP联动计划中结局数量影响继承加成8.2 功能演示结局画廊界面触发结局弹窗8.3 技术收获这次结局系统的开发让我在几个方面有了新的认识1. 条件驱动的设计模式结局触发本质上是“条件满足时执行动作”的模式。将条件集中管理、触发时机统一处理避免了分散在各个代码位置的结局判定造成的混乱。这种模式也可以推广到其他系统如成就系统、事件触发系统。2. 数据的一致性维护结局数据需要在多个地方使用结局画廊、NGP统计、成就检查。通过ending_manager.py作为单一数据源保证了各处使用的结局定义是一致的。这启示我任何跨模块使用的数据都应该有唯一的数据源。3. UI的统一设计语言结局画廊和成就面板采用了相似的设计风格——卡片式布局、标签页切换、锁定/解锁状态的可视化。这种统一性让玩家更容易理解和使用这些功能也减少了前端的维护成本。4. 调试信息的价值在结局触发逻辑中添加详细的print日志对于定位问题非常有帮助print(f[ENDING] 检查结局条件:) print(f - hp{hp} (0: {hp 0})) print(f - turn_count{turn_count} (80: {turn_count 80})) print(f - has_life_tree{node_tree_of_life in unlocked_nodes})这些日志在开发阶段节省了大量调试时间未来上线后也会作为错误排查的依据。5. 当前方案的认知与改进空间必须承认当前的结局实现是一个最小可行产品MVP它满足了基本需求但在条件灵活性、玩家选择权、视觉表现等方面还有很大的提升空间。这种“先实现后优化”的策略是正确的——先让系统跑起来再根据实际使用体验逐步完善。回顾整个结局系统的开发从最初的“结局触发不了”到现在的“稳定判定 完整收集 跨周目保留”每一步都在验证“让玩家的每个选择都有意义”的设计理念。结局不仅是故事的终点更是玩家在游戏中所作所为的最终反馈。同时我也清楚地认识到当前实现的不足。在接下来的开发中我将重点解决以下几个问题条件配置化——将硬编码的结局条件改为配置化的表达式系统选择分支系统——在关键节点给予玩家主动选择的权利结局与NGP的深度联动——让多周目体验更加丰富视觉体验增强——为不同结局添加差异化的视觉表现这些优化将让结局系统从一个“能工作”的系统变成一个“让人印象深刻”的系统。至此StoryEcho的核心功能——游戏引擎、角色系统、成就系统、存档系统、结局系统——已经全部完成。下一步将进入整体的优化以及。