Files
zeling_v2/Docs/Design/50_NarrativeDesignSystem.md
2026-05-08 11:04:00 +08:00

548 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 50 · 叙事设计系统Narrative Design System
> **命名空间** `BaseGames.Narrative`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.Core.Events` · `BaseGames.Dialogue`(对话系统)· `BaseGames.Quest`(任务链)· `BaseGames.World`WorldStateRegistry
> **关联** 15_DialogueSystem · 34_EventChainSystem · 38_QuestSystem · 18_CutsceneSystem
---
## 目录
1. [叙事设计哲学](#1-叙事设计哲学)
2. [故事状态机NarrativeStateMachine](#2-故事状态机narrativestatemachine)
3. [世界状态注册表WorldStateRegistry](#3-世界状态注册表worldstateregistry)
4. [NPC 响应层:台词版本系统](#4-npc-响应层台词版本系统)
5. [NPC 位置迁移系统](#5-npc-位置迁移系统)
6. [Lore 碎片系统](#6-lore-碎片系统)
7. [隐性叙事Environmental Storytelling](#7-隐性叙事environmental-storytelling)
8. [分支结局门控](#8-分支结局门控)
9. [叙事时序图(世界事件一览)](#9-叙事时序图世界事件一览)
10. [SaveData 集成](#10-savedata-集成)
11. [事件频道](#11-事件频道)
12. [编辑器友好设计](#12-编辑器友好设计)
---
## 1. 叙事设计哲学
泽灵的叙事体系以《空洞骑士》为精神基础,以**隐性叙事优先、台词碎片化、世界响应动态化**为三大原则:
| 原则 | 含义 | 实现方式 |
|------|------|---------|
| **隐性叙事优先** | 故事通过场景、尸骸、环境物件传达,而非大段台词 | `EnvNarrativeMarker`(见 §7 |
| **台词碎片化** | NPC 每次对话只透露一点信息,多次交互拼凑完整故事 | `DialogueSequenceSO` 多版本 |
| **世界响应动态化** | 击败 Boss、完成任务、拿到关键道具后世界会发生可见变化 | `WorldStateRegistry` + NPC 迁移 |
**刻意不做的设计**
- ❌ 不设主线任务追踪(类 RPG 的"前往X地点"箭头)——探索本身是奖励
- ❌ 不设全程语音——文字台词更具像素风氛围,也节省本地化成本
- ❌ 不设强制叙事打断——所有对话均由玩家主动触发
---
## 2. 故事状态机NarrativeStateMachine
### 2.1 主线故事节点
游戏世界的叙事分为若干**故事节点NarrativeNodeSO**,按玩家行动依次解锁:
```csharp
[CreateAssetMenu(menuName = "Narrative/NarrativeNode")]
public class NarrativeNodeSO : ScriptableObject
{
[Header("标识")]
public string nodeId; // 如 "Node_ForestBossDefeated"
public string displayName; // 编辑器显示名(不出现在游戏中)
[Header("解锁条件")]
public string[] prerequisiteNodeIds; // 所有前置节点必须已激活
public string requiredBossDefeated; // 可选:需击败某 Boss
public string requiredItemCollected; // 可选:需获取某道具
public string requiredQuestId; // 可选:需完成某任务
[Header("激活效果")]
public NarrativeEffect[] effects; // 激活时执行的效果列表(见下方)
[Header("关联")]
[TextArea(2, 5)]
public string designerNotes; // 设计师批注(不进游戏)
}
[Serializable]
public class NarrativeEffect
{
public NarrativeEffectType type;
public string parameter; // 根据 type 含义不同
// type 枚举:
// SetWorldState → parameter = "BossX_Defeated"
// MigrateNPC → parameter = "NPC_MerchantA → Location_Cave_HiddenRoom"
// UnlockLoreItem → parameter = "Lore_ForestAncientTablet_01"
// TriggerEventChain → parameter = eventChain ID
// OpenSecretArea → parameter = scene address + 门 lockId
}
```
### 2.2 主线节点时序
```
[游戏开始]
Node_GameStart
│ 玩家进入 Forest 区域
Node_ForestExplored
│ 击败蛛网守卫
Node_ForestBossDefeated ──→ 幻境碎片 #1 可收集
│ 森林 NPC 迁移(商人移至洞穴入口)
│ 进入 Cave 区域
Node_CaveEntered ──→ NPC 台词版本切换
│ 击败蚀骨蠕虫
Node_CaveBossDefeated ──→ 幻境碎片 #2 可收集
│ 解锁废墟冲刺能力房间
Node_DashAbilityUnlocked
│ 进入 Ruins 区域
Node_RuinsEntered
│ 击败废墟遗骑士
Node_RuinsBossDefeated ──→ 幻境碎片 #3触发隐藏记忆序列
Node_AbyssRevealed ──→ 开启深渊地图显示
│ 击败深渊之喉
Node_AbyssBossDefeated ──→ 分支节点(见 §8
Node_CoreUnlocked ──→ 最终区域开放
│ 击败最终 Boss
Node_TrueEndingGate需满足额外条件见 §8
Node_NormalEnding
```
---
## 3. 世界状态注册表WorldStateRegistry
`WorldStateRegistry` 是全局单例SO 形式,零耦合注入),存储所有已激活的世界状态标志:
```csharp
[CreateAssetMenu(menuName = "Narrative/WorldStateRegistry")]
public class WorldStateRegistry : ScriptableObject
{
// 运行时标志集合(字符串 Key
private readonly HashSet<string> _activeFlags = new();
// 设置标志(由 NarrativeStateMachine 调用)
public void SetFlag(string flagKey) => _activeFlags.Add(flagKey);
// 查询标志NPC、环境物件等查询
public bool HasFlag(string flagKey) => _activeFlags.Contains(flagKey);
// 运行时重置(场景卸载时不重置,存档后持久化)
public void LoadFromSave(IEnumerable<string> flags)
{
_activeFlags.Clear();
foreach (var f in flags) _activeFlags.Add(f);
}
public IReadOnlyCollection<string> GetAllFlags() => _activeFlags;
}
```
### 标志命名规范
```
<类别>_<对象>_<状态>
Boss_SpiderGuard_Defeated
NPC_MerchantA_Migrated_Cave
Area_AbyssBottomLake_Revealed
Item_ForestFragmentStone_Collected
Quest_FindMushroom_Completed
Lore_CaveAncientTablet_01_Read
```
---
## 4. NPC 响应层:台词版本系统
每个 NPC 根据当前 `WorldStateRegistry` 标志,动态选择对话版本:
```csharp
/// <summary>
/// 扩展 InteractableNPC支持根据世界状态标志切换对话版本
/// </summary>
public class NarrativeNPC : InteractableNPC
{
[Header("台词版本集")]
[SerializeField] DialogueVersion[] _dialogueVersions; // 从高到低优先级排列
[SerializeField] DialogueSequenceSO _defaultDialogue; // 无条件满足时的默认台词
[SerializeField] WorldStateRegistry _worldState; // SO 注入
protected override DialogueSequenceSO GetCurrentDialogue()
{
// 从最高优先级版本开始检查,返回第一个满足条件的版本
foreach (var version in _dialogueVersions)
{
if (version.CheckConditions(_worldState))
return version.dialogue;
}
return _defaultDialogue;
}
}
[Serializable]
public class DialogueVersion
{
public string versionLabel; // 编辑器显示名(如"森林Boss击败后"
public DialogueSequenceSO dialogue;
public string[] requiredFlags; // 全部满足才激活此版本AND 关系)
public string[] blockedByFlags; // 有任意一个 = 此版本不激活NOT 关系)
public bool CheckConditions(WorldStateRegistry registry)
{
foreach (var f in requiredFlags)
if (!registry.HasFlag(f)) return false;
foreach (var f in blockedByFlags)
if (registry.HasFlag(f)) return false;
return true;
}
}
```
### NPC 台词版本示例:流浪商人 MerchantA
| 版本 | 触发条件 | 台词主题 |
|------|---------|---------|
| `default` | 无条件 | 初见寒暄,暗示森林危险 |
| `forest_boss_dead` | `Boss_SpiderGuard_Defeated` | 感谢玩家,给出洞穴线索 |
| `cave_entered` | `Area_Cave_Entered` | 已迁移到洞穴,新的商品 |
| `ruins_boss_dead` | `Boss_RuinsKnight_Defeated` | 讲述古代遗迹的秘密碎片 |
| `all_bosses_dead` | 四大 Boss 均击败 | 最终台词,暗示结局方向 |
---
## 5. NPC 位置迁移系统
部分 NPC 会随着故事进程**物理移动到地图的不同位置**,增强世界活性感:
```csharp
[CreateAssetMenu(menuName = "Narrative/NPCMigration")]
public class NPCMigrationConfigSO : ScriptableObject
{
public string npcId;
[Serializable]
public struct MigrationStep
{
public string triggerFlag; // 触发迁移的 WorldState 标志
public string targetSceneAddress; // 目标场景
public Vector2 spawnPosition; // 目标房间内的具体位置
[TextArea(1, 2)]
public string migrationNote; // 设计注释
}
public MigrationStep[] steps;
}
```
**NPC 迁移流程**
```
1. NarrativeStateMachine 激活 Node如 Node_ForestBossDefeated
2. 执行 NarrativeEffect[type=MigrateNPC]
3. NPCMigrationManager 查询该 NPC 的 MigrationConfigSO
4. 找到 triggerFlag 匹配的 MigrationStep
5. 更新 SaveData.npcLocations[npcId] = targetSceneAddress + position
6. 下次玩家进入目标场景时NPCSpawner 从 SaveData 读取位置实例化 NPC
```
### NPC 迁移时序表
| NPC | 初始位置 | 第一次迁移 | 第二次迁移 | 最终位置 |
|-----|---------|-----------|-----------|---------|
| 流浪商人 MerchantA | Forest_Main树干旁 | Forest Boss 击败后 → Cave_Entrance洞穴入口灯旁 | Ruins Boss 击败后 → Ruins_Hub废墟核心广场 | Core_Antechamber最终区域前厅|
| 神秘老者 ElderSage | Forest_Shrine森林神社 | 获得冲刺后 → Ruins_Library废墟图书馆废墟| — | 同位置,台词持续更新 |
| 流亡骑士 KnightExile | — (初始不存在)| Cave Boss 击败后解锁 → Cave_HiddenRoom | Abyss Boss 击败后 → Abyss_Plateau | 仅在 True Ending 路线出现最终台词 |
---
## 6. Lore 碎片系统
碎片化叙事道具——分散在世界各处的铭文、遗物、幻境记忆,拼凑世界背景故事:
### 6.1 LoreItemSO 数据结构
```csharp
[CreateAssetMenu(menuName = "Narrative/LoreItem")]
public class LoreItemSO : ScriptableObject
{
[Header("标识")]
public string loreId; // 如 "Lore_Cave_BonePillar_01"
public string displayTitle; // 如 "锈蚀的骸骨柱"
public Sprite thumbnail; // 图鉴中的缩略图
[Header("分类")]
public LoreCategory category; // Inscription / Relic / Memory / EchoFragment
public string regionId; // 所属区域
[Header("内容(本地化 Key")]
public string descLocKey; // 一般描述(拾取时弹出)
public string[] loreParagraphKeys; // 详细 Lore 文段(图鉴中逐段展开)
[Header("解锁条件")]
public string requiredWorldFlag; // 空 = 无条件可收集
// 非空 = 需要某世界状态才可见/可收集
[Header("图鉴集成")]
public string codexSectionId; // 对应图鉴章节(见 §6.3
public int codexOrderIndex; // 章节内排序
}
```
### 6.2 Lore 类型与分布
| 类型 | 数量目标 | 分布规则 | 收集方式 |
|------|---------|---------|---------|
| **Inscription铭文** | 25~30 块 | 每区域 5~6 块,墙壁/地面/石碑 | 靠近自动触发 |
| **Relic遗物** | 15~20 件 | 隐藏房间/精英怪掉落 | 拾取入背包 |
| **Memory幻境记忆** | 5 段 | 每个主线 Boss 击败后解锁 | 与特定地点互动 |
| **EchoFragment幻境碎片** | 10 个 | 散布全图秘密位置 | 拾取(与 §8 真结局联动)|
### 6.3 Lore 图鉴Codex
玩家收集的 Lore 道具自动归入图鉴界面(暂停菜单 → 图鉴):
```
图鉴章节结构:
├─ 泽灵的历史 (Memory 类型,共 5 段)
├─ 扎根森林的传说 (Forest 区域 Inscription + Relic共 8 条)
├─ 腐蚀洞穴的过去 (Cave 区域,共 7 条)
├─ 坍塌废墟的秘密 (Ruins 区域,共 8 条)
├─ 深渊裂隙的秘密 (Abyss 区域,共 6 条)
└─ 幻境碎片 (EchoFragment共 10 个,与真结局联动)
```
图鉴中已收集的条目显示完整文字,未收集的显示 `???`(同时显示所在区域提示)。
---
## 7. 隐性叙事Environmental Storytelling
不通过对话或文字,而通过**场景中的视觉摆放**传达叙事信息:
### 7.1 EnvNarrativeMarker
场景中的叙事标记组件,供关卡设计师放置在场景物件上,标注其叙事含义:
```csharp
/// <summary>
/// 仅在编辑器中存在,用于标记场景中具有叙事意义的物件
/// 运行时不执行任何逻辑,纯粹供团队沟通和 QA 检视用
/// </summary>
public class EnvNarrativeMarker : MonoBehaviour
{
#if UNITY_EDITOR
[TextArea(2, 6)]
public string narrativeMeaning; // 这个物件传达什么叙事信息
public NarrativeImportance importance; // 核心/支线/氛围
public string relatedLoreId; // 关联的 LoreItemSO可选
void OnDrawGizmos()
{
// 在 Scene 视图中用半透明图标标记叙事物件
Gizmos.color = importance == NarrativeImportance.Core
? new Color(1f, 0.3f, 0.3f, 0.8f)
: new Color(0.3f, 0.8f, 1f, 0.6f);
Gizmos.DrawIcon(transform.position + Vector3.up * 0.5f, "NarrativeMarker.png", true);
}
#endif
}
```
### 7.2 隐性叙事设计规范
| 规范 | 说明 |
|------|------|
| **尸体方向** | 倒在洞穴入口的人类骸骨,脸朝外(说明是从里面跑出来的,不是进去的)|
| **武器磨损程度** | 废墟中的武器架,越靠近 Boss 房越锈蚀/越破损 |
| **蜡烛的点燃状态** | 商人驻扎过的房间会有蜡烛燃烧,表示曾有人居住 |
| **植物入侵** | 废墟区域越靠近核心,植物侵蚀越严重(时间流逝的隐喻)|
| **涂鸦/铭刻** | 普通壁刻用中性色调,紧迫的警告用红色/破损字体 |
---
## 8. 分支结局门控
### 8.1 结局类型
| 结局 | 触发条件 | 描述 |
|------|---------|------|
| **普通结局** | 击败最终 Boss | 泽灵完成使命,但世界的真相未被揭示 |
| **真结局** | 普通结局条件 + 收集全部 10 个 EchoFragment + 完成 `Quest_KnightExile_Final` | 揭示世界背景真相,展示泽灵的真实身份 |
| **隐藏结局** | 真结局条件 + 与全部 NPC 达到最高好感度 + 图鉴全收集 | 额外epilogue完整故事补完 |
### 8.2 EndingGate 组件
```csharp
public class EndingGate : MonoBehaviour
{
[Header("结局配置")]
[SerializeField] EndingType _endingType;
[Header("真结局条件")]
[SerializeField] int _requiredEchoFragments = 10; // 需要的幻境碎片数量
[SerializeField] string _requiredQuestId = "Quest_KnightExile_Final";
[Header("隐藏结局额外条件")]
[SerializeField] int _requiredNPCMaxAffinity = 4; // 需要达到满好感的 NPC 数
[SerializeField] bool _requireFullCodex = true; // 是否需要图鉴全收集
[Header("依赖")]
[SerializeField] WorldStateRegistry _worldState;
[SerializeField] SaveData _saveData; // 注入
public EndingType EvaluateEnding()
{
bool trueConditions =
_saveData.CollectedLoreIds.Count(id => id.StartsWith("Lore_Echo_")) >= _requiredEchoFragments
&& _saveData.CompletedQuestIds.Contains(_requiredQuestId);
if (trueConditions)
{
bool hiddenConditions =
_saveData.NPCMaxAffinityCount >= _requiredNPCMaxAffinity
&& (!_requireFullCodex || _saveData.IsCodexComplete());
return hiddenConditions ? EndingType.Hidden : EndingType.True;
}
return EndingType.Normal;
}
}
```
### 8.3 结局提示设计规则
- **不明示**:游戏中不告诉玩家"收集X个碎片解锁真结局",仅通过 NPC 台词暗示("据说幻境碎片记录着世界诞生之初的秘密……"
- **图鉴暗示**:图鉴章节"幻境碎片"收集到 5/10 时出现文字:"似乎还有另一半……"
- **过场动画差异**:普通结局 vs 真结局的过场动画在相同骨架上有额外镜头和台词
---
## 9. 叙事时序图(世界事件一览)
```
故事时间线(按玩家行动推进)
══════════════════════════════════════════════════════════════
开始
│ ──────────────────────────────────────── Forest 时期 ─────
├ 玩家习得基础移动技能
├ 遇见 MerchantA首次
├ 收集 Forest 铭文 1-5可选
├ 击败蛛网守卫
│ → MerchantA 迁移至 Cave 入口
│ → Memory #1 解锁(森林守护的来源)
│ → Cave 大门开启
│ ──────────────────────────────────────── Cave 时期 ────────
├ ElderSage可选先遇见
├ 收集 Cave 铭文 1-6可选
├ 击败蚀骨蠕虫
│ → Memory #2 解锁(洞穴的腐蚀起源)
│ → KnightExile 出现在 Cave 隐秘房间
│ → 冲刺能力 Ruins 入口解锁
│ ──────────────────────────────────────── Ruins 时期 ───────
├ 获得冲刺能力
│ → ElderSage 迁移至 Ruins 图书馆
├ EchoFragment #1-3 可收集(隐藏位置)
├ 击败废墟遗骑士
│ → Memory #3 解锁(触发隐藏幻境序列)
│ → 深渊地图出现
│ ──────────────────────────────────────── Abyss 时期 ───────
├ EchoFragment #4-7 可收集
├ KnightExile 迁移至 Abyss 高台(若已完成 Cave 任务链)
├ 击败深渊之喉
│ → Memory #4 解锁
│ → 分支:已收集 ≥ 8 EchoFragments → 解锁真结局隐藏路线
│ ──────────────────────────────────────── Core 时期 ────────
├ EchoFragment #8-10 可收集(核心区域秘密房间)
├ KnightExile 在 Core 前厅等待True Ending 路线)
├ 击败最终 Boss
│ → 根据 EndingGate 判断 → Normal / True / Hidden 结局
└ 游戏结束
```
---
## 10. SaveData 集成
新增叙事专属存档字段:
```csharp
public class NarrativeSaveData
{
// 已激活的故事节点
[JsonProperty("activeNarrativeNodes")]
public List<string> ActiveNarrativeNodes { get; set; } = new();
// 已激活的世界状态标志
[JsonProperty("worldStateFlags")]
public List<string> WorldStateFlags { get; set; } = new();
// NPC 当前位置(覆盖默认生成位置)
[JsonProperty("npcLocations")]
public Dictionary<string, NPCLocationData> NpcLocations { get; set; } = new();
// 已收集的 Lore 道具 ID 集合
[JsonProperty("collectedLoreIds")]
public HashSet<string> CollectedLoreIds { get; set; } = new();
// 幻境碎片计数True Ending 门控)
[JsonProperty("echoFragmentCount")]
public int EchoFragmentCount { get; set; } = 0;
}
[Serializable]
public class NPCLocationData
{
[JsonProperty("sceneAddress")] public string SceneAddress { get; set; }
[JsonProperty("position")] public SerializableVector2 Position { get; set; }
}
```
---
## 11. 事件频道
| 频道 SO | 类型 | 触发时机 |
|---------|------|---------|
| `OnNarrativeNodeActivated` | `StringEventChannelSO` | 故事节点激活(传入 nodeId|
| `OnWorldFlagSet` | `StringEventChannelSO` | WorldState 标志被设置(传入 flagKey|
| `OnLoreItemCollected` | `StringEventChannelSO` | Lore 道具被收集(传入 loreId|
| `OnNPCMigrated` | `StringEventChannelSO` | NPC 完成位置迁移(传入 npcId|
| `OnEchoFragmentCollected` | `IntEventChannelSO` | 幻境碎片收集(传入当前总数)|
---
## 12. 编辑器友好设计
- **NarrativeNodeSO** Inspector显示依赖图prerequisites 可视化有向图UI Toolkit 实现)
- **NarrativeNPC** Inspector实时预览当前世界状态下会显示哪个台词版本Play Mode 中显示生效版本名称)
- **WorldStateRegistry** Inspector列出所有已激活标志Play Mode 只读,支持手动强制设置标志用于测试)
- **LoreItem 场景标记**:在 Scene View 中用书本图标标记已放置 Lore 的位置,鼠标悬停显示 loreId 和收集状态
- **Codex 完成度 HUD**:编辑器模式在 Scene 右下角显示"Lore: 12/45"实时统计
---
*本文档版本 1.0 · 2026-04 · 关联 15_DialogueSystem / 34_EventChainSystem / 38_QuestSystem / 31_SaveDataSchema*