21 KiB
50 · 叙事设计系统(Narrative Design System)
命名空间
BaseGames.Narrative
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.Dialogue(对话系统)·BaseGames.Quest(任务链)·BaseGames.World(WorldStateRegistry)
关联 15_DialogueSystem · 34_EventChainSystem · 38_QuestSystem · 18_CutsceneSystem
目录
- 叙事设计哲学
- 故事状态机(NarrativeStateMachine)
- 世界状态注册表(WorldStateRegistry)
- NPC 响应层:台词版本系统
- NPC 位置迁移系统
- Lore 碎片系统
- 隐性叙事(Environmental Storytelling)
- 分支结局门控
- 叙事时序图(世界事件一览)
- SaveData 集成
- 事件频道
- 编辑器友好设计
1. 叙事设计哲学
泽灵的叙事体系以《空洞骑士》为精神基础,以隐性叙事优先、台词碎片化、世界响应动态化为三大原则:
| 原则 | 含义 | 实现方式 |
|---|---|---|
| 隐性叙事优先 | 故事通过场景、尸骸、环境物件传达,而非大段台词 | EnvNarrativeMarker(见 §7) |
| 台词碎片化 | NPC 每次对话只透露一点信息,多次交互拼凑完整故事 | DialogueSequenceSO 多版本 |
| 世界响应动态化 | 击败 Boss、完成任务、拿到关键道具后,世界会发生可见变化 | WorldStateRegistry + NPC 迁移 |
刻意不做的设计:
- ❌ 不设主线任务追踪(类 RPG 的"前往X地点"箭头)——探索本身是奖励
- ❌ 不设全程语音——文字台词更具像素风氛围,也节省本地化成本
- ❌ 不设强制叙事打断——所有对话均由玩家主动触发
2. 故事状态机(NarrativeStateMachine)
2.1 主线故事节点
游戏世界的叙事分为若干故事节点(NarrativeNodeSO),按玩家行动依次解锁:
[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 形式,零耦合注入),存储所有已激活的世界状态标志:
[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 标志,动态选择对话版本:
/// <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 会随着故事进程物理移动到地图的不同位置,增强世界活性感:
[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 数据结构
[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
场景中的叙事标记组件,供关卡设计师放置在场景物件上,标注其叙事含义:
/// <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 组件
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 集成
新增叙事专属存档字段:
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