Skip to content

小说世界模式 ​

灵泛引擎支持两种完全平权的游戏模式:叙事模式(默认)和小说世界模式。本章深入讲解小说世界模式的设计哲学和使用方法。

两种模式对比 ​

叙事模式小说世界模式
时间系统关闭(EnableTimeSystem=false)开启(EnableTimeSystem=true)
世界状态静态——玩家推动才变化动态——时间驱动自行运转
回溯方式检查点(逐句/逐选择)时间锚点(场景级)
存档本质保存选择路径保存世界状态快照
推荐工具DSL(轻量简单)C# StoryScript(表达力强)

两种模式互斥

EnableTimeSystem 是硬开关。两种模式的逻辑系统根本互斥,不能同时启用。选了一个模式的逻辑,就不能用另一个模式的逻辑。

双模平权的真正含义

"双模平权"指的是 DSL 和 C# 两种开发语言平权(可以混合使用),不是指叙事模式和小说世界模式平权可混用。

  • 叙事模式 ↔ 小说世界模式:互斥(EnableTimeSystem 硬开关)
  • DSL ↔ C#:始终平权可混合,不受模式影响

启用小说世界模式 ​

在 LingFanEngineOptions 中配置:

csharp
var options = new LingFanEngineOptions
{
    EnableTimeSystem = true,
    SecondsPerGameMinute = 1.0,   // 1 现实秒 = 1 游戏分钟
    TimeStartDay = 1,              // 起始第 1 天
    TimeStartHour = 8,             // 起始早上 8 点
    TimeStartMinute = 0,
};

核心理念 ​

小说世界 = 一个会自己运转的世界。

时间系统是整个小说世界的"心跳",提供统一的时间基准。世界不等待玩家操作——NPC 有自己的时间表,天气会变化,商店会开门关门,事件会按时发生。

时间事件生命周期 ​

小说世界的运行本质是时间事件生命周期,而非"场景生命周期":

  1. 场景只是触发挂载器——场景负责声明和注册时间事件,但场景本身不是事件的运行主体
  2. 事件一旦注册即独立——时间事件被场景挂载注册后,就脱离场景成为全局存在
  3. 事件可以跨场景影响——一个事件不限于影响它被注册时的那个场景

事件来源 ​

来源说明示例
全局事件开局即注册,管理宏观世界概念春夏秋冬、日夜循环
场景挂载事件由场景声明注册,一旦挂载就独立运行酒馆的"午后繁忙"事件,玩家离开后仍按时触发

全局事件的本质

春夏秋冬、日夜更替这些"宏观世界规则"本质上也是时间事件,只是在开局时就通过 set_time_event / SetTimeEventAsync / InTimeEvents() 注册到 EventScheduler,并非引擎内部有另一套"自适应计算"系统。它们和场景挂载事件走的是同一套调度机制,区别只在于挂载时机——全局事件开局即注册,场景挂载事件由场景运行时注册。

时间控制 ​

启动哲学 ​

启用时间系统后,时间默认不会自动运行,需要显式启动:

dsl
time_resume

为什么默认不启动

确保从正确的起始时间开始世界运转:

  • 从主菜单进入游戏时从起始时间开始
  • 读档时恢复到精确的锚点时间
  • 避免时间系统的混乱和不一致性

暂停 / 恢复 / 跳过 ​

dsl
time_pause          # 暂停世界时间
time_resume         # 恢复世界时间
skip_time 60        # 跳过 60 分钟
skip_time 1440      # 跳过 1 天

skip_time 逐分钟推进,确保中间时间点的事件被检查——不会跳过 NPC 换班、天气变化等。

C# API ​

csharp
// 暂停 / 恢复
Ctrl.PauseGameTime();
Ctrl.ResumeGameTime();

// 跳过时间
Ctrl.SkipTime(30);  // 跳过 30 分钟

// 获取当前时间(天数可直接从状态容器读取;当前小时由 IGameTimeService 提供,
// 命令处理器/上下文中通过 TimeService.CurrentHour 访问)
var day = _state.Get<int>(StateKeys.GameTime.CurrentDay);

// 重置时间 + 清空所有事件
Ctrl.ResetGameTime();

场景名称 = 坐标 ​

在小说世界模式下,场景名称本身就是玩家坐标,包含时间、空间、状态信息:

csharp
// 场景名称编码了完整信息
"小镇_清晨_广场"        // Day 1 早晨的广场
"酒馆_午后_繁忙"         // Day 3 下午的酒馆
"教堂_黄昏_安静"         // 傍晚的教堂

坐标判断替代 flag ​

csharp
// ❌ 传统方式:维护一堆 flag
bool hasVisitedTavern = _state.Get<bool>("has_visited_tavern");
int day = _state.Get<int>("current_day");
bool isRaining = _state.Get<bool>("is_raining");
if (hasVisitedTavern && day > 3 && !isRaining) { ... }

// ✅ 坐标方式:直接判断场景名称
string scene = _state.Get<string>(StateKeys.Scene.CurrentName);
if (scene == "酒馆_午后_繁忙") { ... }

// ✅ 或判断命名规则
if (scene.Contains("酒馆") && scene.Contains("午后")) { ... }

命名约定 ​

推荐格式:{地点}_{时间}_{状态}

广场_清晨_宁静
酒馆_午后_繁忙
教堂_黄昏_祷告
森林_夜晚_篝火
码头_暴雨_关闭

时间锚点 ​

定义:时间轴上的一个标记点,用于保存世界在特定时刻的状态快照。

特性说明
选择性存储不是全时间线存档,只有创作者放置锚点的地方才可回溯
场景级粒度保存整个场景的状态快照,不是逐句回溯
离散分布锚点之间无法用滚轮平滑穿梭

C# 场景作为锚点 ​

csharp
public class TavernScene : StoryScript
{
    public override string SceneName => "酒馆_午后_繁忙";

    public override async Task RunAsync()
    {
        // 这个场景本身就是一个时间锚点
        // 当玩家进入时,引擎会自动标记该时刻

        await SayAsync("你走进了酒馆。", speaker: "旁白");
        // ... 场景逻辑
    }

    // 通过 InDefines() 注入场景级状态
    // 这些状态会在锚点中被保存
    public override Dictionary<string, object?> InDefines()
    {
        return new()
        {
            ["tavern_crowd_level"] = "繁忙",
            ["tailor_present"] = true,
            ["owner_mood"] = "正常"
        };
    }
}

时间事件 ​

DSL 注册 ​

dsl
set_time_event "morning_event" 8 once=false desc="每天早上"
  say "早上 8 点,阳光照进窗户。" speaker="旁白"
  set "player.energy" 100

set_time_event "lunch" 12 minute=30 once=false
  say "正午的钟声敲响了。" speaker="旁白"

C# 注册 ​

csharp
// 动态注册(返回 void,无需 await)
Ctrl.SetTimeEventAsync("lunch", 12, async () =>
{
    await Ctrl.SayAsync("该吃午饭了。", "旁白");
}, once: false, minute: 30);

// 声明式注册(InTimeEvents,自动注册到全局注册表)
public override IReadOnlyList<TimeEventRegistration> InTimeEvents() => [
    new() { Id = "morning", Hour = 7, Callback = async () =>
    {
        await Ctrl.SayAsync("天亮了。", "旁白");
    }}
];

三模式注销 ​

模式DSLC#说明
正常注销unregister_time_event "id"UnregisterEvent(id)场景重执行时自然恢复
永久销毁unregister_time_event "id" permanentUnregisterEvent(id, permanent: true)永不注册(即使代码再次执行)
暂时销毁unregister_time_event "id" temporaryUnregisterEvent(id, temporary: true)不重注册,但可恢复
恢复restore_time_event "id"RestoreEvent(id)从全局注册表查回重新注册

Permanent 的预防性

Permanent 模式对未注册的事件 ID 也生效——可以提前"封印"一个事件,阻止未来注册。

4 层 AND 过滤匹配 ​

事件触发需同时满足:

  1. 小时——Hour == 当前小时
  2. 分钟——null=整点触发,指定值=精确匹配
  3. 天——null=不限制;once=true→绝对天;once=false→间隔天
  4. 星期——null/空=每天,非空=匹配星期

NPC 时间尊严 ​

在小说世界中,NPC 拥有自己的时间表,玩家只是闯入者:

csharp
// NPC 有自己的作息
// 6:00 起床 → 7:00 出门 → 8:00 上班 → 17:00 下班 → 22:00 睡觉

// 如果玩家在下班后还缠着 NPC
if (currentHour > 17 && IsInteractingWith(npc))
{
    npc.Mood -= 10;
    npc.Friendship -= 5;
    // 后续对话分支自动变化
}

存档与读档 ​

存档 ​

小说世界模式的存档保存世界状态快照:

  • 全量用户状态(所有变量)
  • 场景堆栈快照
  • DSL 执行位置
  • 时间事件状态(已注册 ID + 已触发单次事件 ID)
  • 游戏时间

两种读档模式 ​

csharp
// 用户向读档:加载存档槽。引擎内部根据存档元数据自动决定
// 「继续游戏」(精确恢复)或「锚点读取」(从头执行)
await Ctrl.LoadAsync("存档槽名");

事件重注入 ​

读档时,引擎会扫描存档位置之前的 SetTimeEventCommand,手动重注册回调事件——确保跨场景事件不丢失。

DSL + C# 混合使用 ​

小说世界模式下,DSL 和 C# 可以混合使用:

C# 场景调用 DSL ​

csharp
public class TownIntro : StoryScript
{
    public override async Task RunAsync()
    {
        await SayAsync("你来到了小镇入口。", speaker: "旁白");
        // C# 场景跳转到 DSL 场景
        await NavigateAsync("town_entrance");
    }
}

DSL 场景调用 C# ​

dsl
scene "town_entrance"
  say "你走进了小镇。" speaker="旁白"
  menu "去哪里?"
    "去酒馆" -> cs_tavern_day1    # 跳转到 C# 场景
    "去教堂" -> cs_church_day1

完整示例 ​

一个小说世界模式的 C# 场景示例:

csharp
public class TavernAfternoon : StoryScript
{
    public override string SceneName => "酒馆_午后_繁忙";

    public override async Task RunAsync()
    {
        // 场景级状态注入
        // 这些状态会被时间锚点保存
    }

    public override Dictionary<string, object?> InDefines() => new()
    {
        ["tavern_crowd_level"] = "繁忙",
        ["bartender_present"] = true
    };

    // 声明式时间事件——自动注册到全局注册表
    public override IReadOnlyList<TimeEventRegistration> InTimeEvents() => [
        new()
        {
            Id = "tavern_evening_shift",
            Hour = 18,
            Description = "酒馆换晚班",
            Callback = async () =>
            {
                await Ctrl.SayAsync("酒保换班了,夜班的伙计走了出来。", "旁白");
                Ctrl.Set("tavern_crowd_level", "热闹");
            }
        },
        new()
        {
            Id = "tavern_close",
            Hour = 23,
            Description = "酒馆打烊",
            Callback = async () =>
            {
                await Ctrl.SayAsync("酒馆要打烊了。", "旁白");
                Ctrl.Set("tavern_crowd_level", "打烊");
                Ctrl.Set("bartender_present", false);
            }
        }
    ];
}

设计哲学备忘 ​

"默认你是叙事者,直到你主动选择成为造物主。"

五条基本法则 ​

  1. 时间驱动而非事件驱动——世界自行运转,玩家选择见证或干预
  2. 坐标即场景名称——用最简方式定位玩家
  3. 时间锚点代替逐句回溯——场景级粒度的状态快照
  4. DSL 和 C# 平权——两种语言可以混合使用
  5. 时间事件独立生命周期——事件一旦注册就脱离场景独立运转

当前实现状态 ​

功能状态
时间系统基础✅ 已实现
场景坐标系统✅ 已实现
时间锚点基础✅ 已实现
跳过时间批量 Tick✅ 已实现
回调驱动时间事件✅ 已实现
时间事件手动注销✅ 已实现
时间事件存档持久化✅ 已实现
读档事件重注入✅ 已实现
三模式注销 + restore✅ 已实现
亵渎机制完整实现⬜ 规划中
世界树多态性⬜ 规划中
NPC 时间表系统⬜ 规划中

相关文档 ​

基于 .NET 10 + Avalonia 12 · Apache-2.0 License