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

21 KiB
Raw Permalink Blame History

50 · 叙事设计系统Narrative Design System

命名空间 BaseGames.Narrative
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.Dialogue(对话系统)· BaseGames.Quest(任务链)· BaseGames.WorldWorldStateRegistry
关联 15_DialogueSystem · 34_EventChainSystem · 38_QuestSystem · 18_CutsceneSystem


目录

  1. 叙事设计哲学
  2. 故事状态机NarrativeStateMachine
  3. 世界状态注册表WorldStateRegistry
  4. NPC 响应层:台词版本系统
  5. NPC 位置迁移系统
  6. Lore 碎片系统
  7. 隐性叙事Environmental Storytelling
  8. 分支结局门控
  9. 叙事时序图(世界事件一览)
  10. SaveData 集成
  11. 事件频道
  12. 编辑器友好设计

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