小说世界模式
灵泛引擎支持两种完全平权的游戏模式:叙事模式(默认)和小说世界模式。本章深入讲解小说世界模式的设计哲学和使用方法。
两种模式对比
| 叙事模式 | 小说世界模式 | |
|---|---|---|
| 时间系统 | 关闭(EnableTimeSystem=false) | 开启(EnableTimeSystem=true) |
| 世界状态 | 静态——玩家推动才变化 | 动态——时间驱动自行运转 |
| 回溯方式 | 检查点(逐句/逐选择) | 时间锚点(场景级) |
| 存档本质 | 保存选择路径 | 保存世界状态快照 |
| 推荐工具 | DSL(轻量简单) | C# StoryScript(表达力强) |
两种模式互斥
EnableTimeSystem 是硬开关。两种模式的逻辑系统根本互斥,不能同时启用。选了一个模式的逻辑,就不能用另一个模式的逻辑。
双模平权的真正含义
"双模平权"指的是 DSL 和 C# 两种开发语言平权(可以混合使用),不是指叙事模式和小说世界模式平权可混用。
- 叙事模式 ↔ 小说世界模式:互斥(
EnableTimeSystem硬开关) - DSL ↔ C#:始终平权可混合,不受模式影响
启用小说世界模式
在 LingFanEngineOptions 中配置:
var options = new LingFanEngineOptions
{
EnableTimeSystem = true,
SecondsPerGameMinute = 1.0, // 1 现实秒 = 1 游戏分钟
TimeStartDay = 1, // 起始第 1 天
TimeStartHour = 8, // 起始早上 8 点
TimeStartMinute = 0,
};核心理念
小说世界 = 一个会自己运转的世界。
时间系统是整个小说世界的"心跳",提供统一的时间基准。世界不等待玩家操作——NPC 有自己的时间表,天气会变化,商店会开门关门,事件会按时发生。
时间事件生命周期
小说世界的运行本质是时间事件生命周期,而非"场景生命周期":
- 场景只是触发挂载器——场景负责声明和注册时间事件,但场景本身不是事件的运行主体
- 事件一旦注册即独立——时间事件被场景挂载注册后,就脱离场景成为全局存在
- 事件可以跨场景影响——一个事件不限于影响它被注册时的那个场景
事件来源
| 来源 | 说明 | 示例 |
|---|---|---|
| 全局事件 | 开局即注册,管理宏观世界概念 | 春夏秋冬、日夜循环 |
| 场景挂载事件 | 由场景声明注册,一旦挂载就独立运行 | 酒馆的"午后繁忙"事件,玩家离开后仍按时触发 |
全局事件的本质
春夏秋冬、日夜更替这些"宏观世界规则"本质上也是时间事件,只是在开局时就通过 set_time_event / SetTimeEventAsync / InTimeEvents() 注册到 EventScheduler,并非引擎内部有另一套"自适应计算"系统。它们和场景挂载事件走的是同一套调度机制,区别只在于挂载时机——全局事件开局即注册,场景挂载事件由场景运行时注册。
时间控制
启动哲学
启用时间系统后,时间默认不会自动运行,需要显式启动:
time_resume为什么默认不启动
确保从正确的起始时间开始世界运转:
- 从主菜单进入游戏时从起始时间开始
- 读档时恢复到精确的锚点时间
- 避免时间系统的混乱和不一致性
暂停 / 恢复 / 跳过
time_pause # 暂停世界时间
time_resume # 恢复世界时间
skip_time 60 # 跳过 60 分钟
skip_time 1440 # 跳过 1 天skip_time 逐分钟推进,确保中间时间点的事件被检查——不会跳过 NPC 换班、天气变化等。
C# API
// 暂停 / 恢复
Ctrl.PauseGameTime();
Ctrl.ResumeGameTime();
// 跳过时间
Ctrl.SkipTime(30); // 跳过 30 分钟
// 获取当前时间(天数可直接从状态容器读取;当前小时由 IGameTimeService 提供,
// 命令处理器/上下文中通过 TimeService.CurrentHour 访问)
var day = _state.Get<int>(StateKeys.GameTime.CurrentDay);
// 重置时间 + 清空所有事件
Ctrl.ResetGameTime();场景名称 = 坐标
在小说世界模式下,场景名称本身就是玩家坐标,包含时间、空间、状态信息:
// 场景名称编码了完整信息
"小镇_清晨_广场" // Day 1 早晨的广场
"酒馆_午后_繁忙" // Day 3 下午的酒馆
"教堂_黄昏_安静" // 傍晚的教堂坐标判断替代 flag
// ❌ 传统方式:维护一堆 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# 场景作为锚点
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 注册
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# 注册
// 动态注册(返回 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("天亮了。", "旁白");
}}
];三模式注销
| 模式 | DSL | C# | 说明 |
|---|---|---|---|
| 正常注销 | unregister_time_event "id" | UnregisterEvent(id) | 场景重执行时自然恢复 |
| 永久销毁 | unregister_time_event "id" permanent | UnregisterEvent(id, permanent: true) | 永不注册(即使代码再次执行) |
| 暂时销毁 | unregister_time_event "id" temporary | UnregisterEvent(id, temporary: true) | 不重注册,但可恢复 |
| 恢复 | restore_time_event "id" | RestoreEvent(id) | 从全局注册表查回重新注册 |
Permanent 的预防性
Permanent 模式对未注册的事件 ID 也生效——可以提前"封印"一个事件,阻止未来注册。
4 层 AND 过滤匹配
事件触发需同时满足:
- 小时——
Hour == 当前小时 - 分钟——null=整点触发,指定值=精确匹配
- 天——null=不限制;once=true→绝对天;once=false→间隔天
- 星期——null/空=每天,非空=匹配星期
NPC 时间尊严
在小说世界中,NPC 拥有自己的时间表,玩家只是闯入者:
// 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)
- 游戏时间
两种读档模式
// 用户向读档:加载存档槽。引擎内部根据存档元数据自动决定
// 「继续游戏」(精确恢复)或「锚点读取」(从头执行)
await Ctrl.LoadAsync("存档槽名");事件重注入
读档时,引擎会扫描存档位置之前的 SetTimeEventCommand,手动重注册回调事件——确保跨场景事件不丢失。
DSL + C# 混合使用
小说世界模式下,DSL 和 C# 可以混合使用:
C# 场景调用 DSL
public class TownIntro : StoryScript
{
public override async Task RunAsync()
{
await SayAsync("你来到了小镇入口。", speaker: "旁白");
// C# 场景跳转到 DSL 场景
await NavigateAsync("town_entrance");
}
}DSL 场景调用 C#
scene "town_entrance"
say "你走进了小镇。" speaker="旁白"
menu "去哪里?"
"去酒馆" -> cs_tavern_day1 # 跳转到 C# 场景
"去教堂" -> cs_church_day1完整示例
一个小说世界模式的 C# 场景示例:
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);
}
}
];
}设计哲学备忘
"默认你是叙事者,直到你主动选择成为造物主。"
五条基本法则
- 时间驱动而非事件驱动——世界自行运转,玩家选择见证或干预
- 坐标即场景名称——用最简方式定位玩家
- 时间锚点代替逐句回溯——场景级粒度的状态快照
- DSL 和 C# 平权——两种语言可以混合使用
- 时间事件独立生命周期——事件一旦注册就脱离场景独立运转
当前实现状态
| 功能 | 状态 |
|---|---|
| 时间系统基础 | ✅ 已实现 |
| 场景坐标系统 | ✅ 已实现 |
| 时间锚点基础 | ✅ 已实现 |
| 跳过时间批量 Tick | ✅ 已实现 |
| 回调驱动时间事件 | ✅ 已实现 |
| 时间事件手动注销 | ✅ 已实现 |
| 时间事件存档持久化 | ✅ 已实现 |
| 读档事件重注入 | ✅ 已实现 |
| 三模式注销 + restore | ✅ 已实现 |
| 亵渎机制完整实现 | ⬜ 规划中 |
| 世界树多态性 | ⬜ 规划中 |
| NPC 时间表系统 | ⬜ 规划中 |
相关文档
- 时间事件系统(教程)——
set_time_event的 DSL 语法详解 - 用 C# 扩展——StoryScript 基类用法
- DSL 语法参考——时间相关命令速查
- C# API 参考——
IGameController时间方法