简体中文
English 简体中文 繁體中文
Methodox 首页

开发

本页是 C# 指南。C# 程序使用的就是 Divooka 图所使用的东西:Divooka 节点就是这个 API 的方法,以其 XML 文档命名。C# 有一样自己独有的东西,即故事构建器,它用流式 C# 写出整个故事,不需要任何脚本;与之对应的节点是 Story Writing、Conditions、Effects、Scenes、Animation、Images、Transitions、Screens 和 Story 节点。项目引用的方法见安装。

标准示例是 The Red Thread,位于 Parcel NExT 源码树中的 TestPrograms/Novella.RedThread:它的故事、主题和设置都作为数据写在脚本中,而它的 C# 代码只负责指明文件所在位置、配置游戏,并实现故事要求由代码完成的那一条命令。本页通篇引用这个示例。

程序集

程序集 包含内容
Novella.Core 模型(Story、Label、语句、定义、GameConfig、Theme、GameDefinition)、脚本解析器和写出器、表达式、文本标记、变换、StoryRunner、存档、持久数据、本地化以及验证器。除 .NET 外没有任何依赖;无头运行且结果确定。
Novella NovellaGame、会话及其界面、舞台和转场渲染器、音频混音器、影片、在窗口中或以无头方式托管(支持键盘和游戏手柄)、AutoPlayer,以及 Divooka 所显示的节点类。
Novella.Python 供选择使用 Python 的故事使用,通过 Parcel NExT 的 Divooka.Scripting.Python 实现,后者在游戏旁运行带标准库的 IronPython 3.4.2。不会有代码直接调用它:当故事包含 Python 时,引擎会按名称在游戏旁边找到它,因此从不编写 Python 的游戏无需引用它。

NovellaGame

NovellaGame 是入口点:创建它、配置它、为它提供内容,然后运行它。

using Novella;
using Novella.Values;

NovellaGame game = new("The Garden", 1920, 1080);
game.AddAssetFolder("Assets");
game.LoadScriptsFromAssets("scripts");
game.SetTheme("text_font", "EB Garamond, LXGW WenKai Lite");
game.RegisterCommand("riddle", arguments => Value.FromBoolean(arguments[0].AsText() == "moon"));
game.Run();
成员 作用
NovellaGame(title, width, height) 一个具有标题和分辨率(默认 1280×720)的游戏。
NovellaGame(GameDefinition) 由别处构建的定义(例如由函数式图构建)创建的游戏。
SetResolution、SetTitle 所有内容据以布局的分辨率,以及标题。
SetConfig(key, value)、SetTheme(key, value) 一项设置,写法与脚本中相同。
AddAssetFolder(folder) 一个文件夹中的文件:图像、音频、视频、字体、脚本。
AddAsset(path, bytes) 来自内存的单个文件,放在脚本所使用的路径下。
AddAssetSource(IAssetSource) 任何其他文件来源。
LoadScript(text, name)、LoadScriptFile(path) 来自文本或磁盘的脚本。
LoadScriptsFromAssets(folder) 资源中的每个 .novella 文件(某个文件夹下的,或所有位置的),按路径顺序加载。返回加载的数量。
AddStory(Story) 由代码或节点构建的故事。
WriteStory(story => ...) 用故事构建器写入游戏自身的故事,无需脚本。
AddScene(SceneDefinition) 一个场景,以其名称作为标签添加。
DefineCharacter(id, name, color, image)、DefineImage(name, file)、SetDefault(variable, value) 无需脚本的定义。
RegisterCommand、RegisterAction、RegisterFunction、RegisterCommandFunction、RegisterExpressionFunction、RegisterScreen 故事所调用的扩展。
UseLiveScript(Action) 一个本身就是故事的过程(实时脚本)。
Validate() 静态检查(lint):每个问题一行,例如 error: script_07.novella:84: There is no command 'fortune'; the game must register it.
Run() 打开窗口并一直运行到玩家退出;如果将 NOVELLA_HEADLESS 环境变量设为一个数字,则改为以无头方式进行该数量的交互,并打印对话记录。
RunHeadless(storage) 不打开窗口运行,用于测试;返回需要驱动的宿主。
Definition、Config、Theme、Story、Assets、Session 数据,以及游戏运行期间正在运行的会话。

用 SetConfig、SetTheme、SetResolution 或 SetTitle 所做的设置,优先于脚本中对同一键的 define,无论脚本是在其之前还是之后加载,因此程序可以加载游戏的脚本,然后更改其中的某项设置。传给构造函数的标题和分辨率只是默认值,脚本的 define 会替换它们。

以类形式编写游戏

从 NovellaGame 派生并重写其回调:

回调 何时运行
Setup() 在窗口打开之前运行一次:用于放置设置、资源、脚本和定义。
Script() 如果重写了它,开始游戏时会将其作为实时脚本运行,而不是从起始标签开始。
Command(name, arguments) 用于没有任何人按名称注册的 run 命令。它不返回任何值:实参以文本形式传入,用 SetResult(value) 给出故事读作 _return 的应答(未设置时为空值)。
LabelEntered(label) 每当故事进入一个标签时。
GameEnded() 故事结束时,在返回主菜单之前。

The Red Thread 就是这样一个类(有删节):

public sealed class RedThreadGame : NovellaGame
{
    private static readonly string[] Fortunes =
    [
        "Upper fortune: the thread you are tied by is already being drawn tight.",
        "Middle fortune: a gift chosen with care will be kept with care.",
        "Great fortune: the old man under the moon has written both your names."
    ];

    public RedThreadGame(string assetsFolder)
        : base("The Red Thread", 1920, 1080)
    {
        AssetsFolder = assetsFolder;
    }

    public string AssetsFolder { get; }

    protected override void Setup()
    {
        SetResolution(1920, 1080);
        AddAssetFolder(AssetsFolder);
        LoadScriptsFromAssets("scripts");
        // The scripts set everything else; a setting made here holds over theirs.
        SetConfig("autosave_slots", "8");
    }

    // The story's `run fortune`: draws a slip at the Lantern Festival.
    protected override void Command(string name, string[] arguments)
    {
        if (name != "fortune")
        {
            base.Command(name, arguments);
            return;
        }
        SetResult(TranslateText(Fortunes[RandomInteger(0, Fortunes.Length - 1)]));
    }
}

它使用的调用与 Divooka 图所用的相同,其中有两个细节值得效仿。RandomInteger 从故事自身的随机数生成器中抽取,而该生成器是存档和回滚状态的一部分,因此回滚后再次选择会抽到同一张签;若使用 System.Random,玩家就能通过回滚重新抽签。另外,签文经过 TranslateText 处理,因此当故事的字符串表对其进行了翻译时,它会以玩家的语言显示。

基类的 Command 遇到未知名称时会抛出异常,从而以一个指明该命令名称的错误停止故事。

故事构建器

The Red Thread 把故事放在脚本中。游戏也可以改用 StoryBuilder(命名空间 Novella.Building,位于 Novella.Core 中)以 C# 编写故事,完全不需要脚本。

构建器描述故事,而不播放故事。每次调用所添加的标签和语句,与脚本解析后得到的完全相同,因此以这种方式编写的游戏,在存档、读档、回滚、跳过已读文本、验证和翻译方面,都与用脚本编写的游戏完全一样。之所以成立,是因为程序每次启动时都会构建相同的故事,而代码天然就是如此。这与实时脚本正好相反:在实时脚本中,代码就是正在运行的故事,因此无法存档;原因见概念。

完整示例

一个简短的游戏,包含一个谜语、一个会改变变量的选项、一次对该变量的判断,以及一份取决于该变量的礼物:

using Novella;
using Novella.Building;

public sealed class LanternGame : NovellaGame
{
    public LanternGame()
        : base("The Lantern", 1920, 1080)
    {
    }

    protected override void Setup()
    {
        AddAssetFolder("Assets");
        WriteStory(story =>
        {
            // Definitions: a character, a colour image, variables and an achievement.
            story.Character("q", "Qinglan", color: "#c0392b", image: "qinglan")
                .Image("bg night", "#101828")
                .Default("riddles", 0)
                .Default("gift", "")
                .Default("lit", false)
                .Achievement("riddler", "Riddler", "Solved Qinglan's riddle.");

            story.Label("start", s => s
                .Scene("bg night", with: "fade")
                .Music("audio/bgm/lanterns.ogg")
                .Show("qinglan smile", at: "right", with: "dissolve")
                .Say("q", "Guess my riddle.")
                .Menu("What has a face but no mouth?", m => m
                    .Choice("A clock.", c => c
                        .Add("riddles", 1)
                        .Achieve("riddler")
                        .Say("q", "Right.", "laugh"))
                    .Choice("A drum.", c => c.Say("q", "No.")))
                // A lambda effect, and a lambda condition.
                .Do(v => v["gift"] = v.Number("riddles") > 0 ? "lantern" : "nothing")
                .If(v => v.Text("gift") == "lantern",
                    then => then.Say("q", "The lantern is yours."),
                    otherwise => otherwise.Say("q", "Next year, then."))
                .Jump("walk"));

            // Built conditions and effects instead of lambdas: these print as script.
            story.Label("walk", s => s
                .Narrate("You walk on along the river.")
                .Menu(m => m
                    .Choice("Light the lantern.", c => c.Apply(Effect.Set("lit", true)), when: Condition.Compare("gift", "==", "lantern"))
                    .Choice("Keep walking."))
                .If(Condition.IsSet("lit") & Condition.AtLeast("riddles", 1),
                    then => then.Narrate("The river fills with light."))
                .Centered("The End")
                .Return());
        });
    }
}

new LanternGame().Run() 即可运行它。每个构建器方法都返回构建器本身,因此一个标签从上到下读起来就像脚本;主体也可以是语句块 lambda,每行一个调用。文本支持常规的文本标签和 [variable] 插值。值(Default、Set、Add、Run 的实参)的类型是 Novella.Values.Value,它可以从数字、文本和布尔值隐式转换。

WriteStory 写入的是游戏自身的故事,因此可以与 LoadScriptsFromAssets 和 AddScene 结合使用:脚本编写的章节可以跳转到构建出的标签,再跳回来。同一个标签定义两次(无论在脚本中还是代码中)都是错误。游戏的配置和主题仍由游戏本身负责(SetConfig、SetTheme);构建器负责写故事。StoryBuilder 也可以单独创建(new StoryBuilder(),其 Story 交给 AddStory,或在测试中直接交给 StoryRunner)。

用构建器编写的标签不会落入下一个标签,因为它们不来自任何文件:执行到一个标签的末尾即结束游戏,或返回到其调用方。应当继续往下走的标签要以 Jump 结尾,就像示例中的 start 那样;应当结束的标签则以 Return 结尾。当一个以代码构建的标签可能执行到末尾、而它后面的标签也是以代码构建的时,验证器会发出警告,因为脚本中的标签在那里会落入下一个标签,作者可能原本就期望如此。

脚本语言能表达的一切,构建器都能表达:除了台词、菜单和逻辑之外,它还能写出变换、分层图像和图像源、具名转场、翻译、音频名称、通道和音乐空间曲目、常量、配置和主题设置、标签参数和返回值、跳转到和调用在故事运行时才确定的标签,show、scene、hide、play、queue、voice、movie、input、pause 和 window 的每个选项,以及界面。Novella 的测试以此约束它:用脚本和构建器两种方式构建同一个故事,并要求两者打印出相同的脚本。唯一的区别在于实参:构建器传给标签、命令或界面的实参(Call、Run、ShowScreen、CallScreen、Use)是在构建故事时就固定下来的值,而脚本中的实参是在播放时才求值的表达式。

条件与效果

条件或效果有两种写法,凡是接受它们的地方(If、While、Choice、场景结尾),两种写法都适用:

lambda 构建出的值
条件 v => v.Number("affection") > 2 Condition.Compare("affection", ">", 2)
效果 v => v["gift"] = "fan",配合 Do Effect.Set("gift", "fan"),配合 Apply
打印为脚本 一段描述,例如 a C# condition,无法读回 脚本中会写的表达式,读回后完全相同
是否由验证器检查 否:其代码是不透明的 是,与脚本一样:未知函数、没有默认值的变量
在 Divooka 中是否可用 以 Function 图的形式交给 From Function 节点,同样没有脚本形式 是,即 Conditions 和 Effects 节点

lambda 接收的是 StoryVariables,即游戏状态的一个视图:它在其中读写的内容会随故事一起回滚和存档。它应当只操作故事的变量。它在故事之外更改的任何东西(例如游戏类的某个字段),在玩家回滚时都不会回滚,读档时也不会恢复。需要随机数时,请使用 v.Random(minimum, maximum),它从故事自身的生成器中抽取,因此回滚后再次选择会抽到相同的数;若使用 System.Random,玩家就能通过回滚重新抽取。

构建出的条件可以用 &、| 和 !(或 And、Or、Not)组合,并打印出人会写的那种脚本:Condition.AtLeast("affection", 6) & (Condition.Compare("gift", "==", "fan") | !Condition.Visited("market")) 会打印为 (affection >= 6) and ((gift == "fan") or (not visited("market")))。当用脚本文本表达最清楚时,可以用 Condition.FromScript 和 Effect.FromScript 接受脚本文本。

场景

场景是 Novella One 用来构建游戏的配置式模型,如今保留下来,作为一种不带控制流编写故事的方式:一个名称、一个背景、音乐、若干台词,以及一个说明故事接下来去向的结尾。名为 start 的场景开始游戏。SceneDefinition 会变成一个以其名称命名的普通标签,因此场景可以像任何标签一样存档、回滚和验证,并且可以与标签和脚本混用。

WriteStory(story =>
{
    story.Default("courage", 0);
    story.Scene(new SceneDefinition("start", s => s.Narrate("A bridge in the rain."),
        SceneEnding.Choose("Cross it?",
        [
            new SceneChoice("Cross.", "far_side", effect: Effect.Add("courage", 1)),
            new SceneChoice("Wait.", "near_side")
        ]),
        background: "bg bridge", music: "audio/bgm/rain.ogg"));
    story.Scene(new SceneDefinition("far_side", s => s.Narrate("The far side."),
        SceneEnding.Branch(Condition.AtLeast("courage", 1), "brave", "near_side")));
    story.Scene(new SceneDefinition("near_side", s => s.Narrate("Still here.")));
    story.Scene(new SceneDefinition("brave", s => s.Narrate("Brave.")));
});

SceneDefinition(name, lines, ending, background, transition, music) 以语句块构建器 lambda(或语句)的形式接受台词。背景以 transition 进入,除非另行指定或为 null,否则为 fade;背景为 null 时保留当前舞台,音乐为 null 时保留正在播放的音乐。结尾为以下之一:

结尾 变成
SceneEnding.GoTo(scene) 跳转(jump)到该场景。
SceneEnding.Choose(caption, choices) 一个菜单。每个 SceneChoice(text, scene, when, effect) 仅在其条件成立时(如果有条件)才提供,被选中时应用其效果(如果有),并跳转到其场景。
SceneEnding.Branch(condition, then, otherwise) 一个 if,跳转到两个场景之一。
SceneEnding.End,或没有结尾 一个 return:游戏结束,或被调用的场景返回。

story.Scene(scene) 和 NovellaGame.AddScene(scene) 都可以添加场景。

动画、图像、转场和界面

脚本在顶层编写的定义,也都有对应的构建器调用。下面的代码写出了一个带参数的变换、一个具名转场、一个经过滤镜处理的图像、一个分层图像和一个界面,并使用了它们:

WriteStory(story =>
{
    story.Character("q", "Qinglan", color: "#c0392b", image: "qinglan")
        // As `transform bob(height=20):` would write it.
        .Transform("bob", a => a
            .Set("yoffset", 0)
            .Ease(1.0, "yoffset", AnimationValue.FromScript("-height"))
            .Ease(1.0, "yoffset", 0)
            .Repeat(), StoryBuilder.Parameter("height", 20))
        .Transition("slow", Transitions.Dissolve(2))
        .Image("room", ImageSources.Grayscale(ImageSources.File("bg/room.jpg")))
        .LayeredImage("qinglan", l => l
            .Always("sprites/qinglan/base.png")
            .Group("face", g => g
                .Attribute("neutral", "sprites/qinglan/neutral.png", isDefault: true)
                .Attribute("smile", "sprites/qinglan/smile.png")))
        .Screen("riddle", s => s
            .Parameters(StoryBuilder.Parameter("question", "Who?"))
            .Dim()
            .Text("[question]", 0.5, 0.3, size: 44)
            .Button("A mountain", 0.35, 0.6, ScreenActions.Return("mountain"))
            .Button("A river", 0.65, 0.6, ScreenActions.Return("river"))
            .Timer(10, ScreenActions.Return("timeout")));

    story.Label("start", s => s
        .Scene("room", with: "slow")
        .Show("qinglan smile", at: "bob(30), left", with: "dissolve")
        .Show("cloud", animation: a => a.Set("xalign", 0.0).Linear(3.0, "xalign", 1.0).Repeat())
        .CallScreen("riddle", null, "What has roots that nobody sees?")
        .If(Condition.Compare("_return", "==", "mountain"),
            then => then.Say("q", "Correct.", "smile"),
            otherwise => otherwise.Say("q", "Look up, scholar."))
        .Return());
});

用 ScriptWriter.Write 写出时,它就是脚本中会有的 transform、transition、image、layeredimage 和 screen 定义以及标签,并且读回后是同一个故事。动画值遵循脚本的规则:整数表示像素,带小数部分的数表示舞台尺寸的比例,因此 100 和 1.0 含义不同;AnimationValue.Pixels、Fraction、Parameter(name) 和 FromScript(expression) 可以明确指定是哪一种。界面位置遵循同样的规则,并且每个元素都以其位置为中心。

参考

StoryBuilder:故事的标签和定义。

方法 添加的内容
Label(name, s => ...) 一个标签:故事中一个具名的位置。游戏从 start 开始。
Label(name, [Parameter("who"), Parameter("times", 1)], s => ...) 一个带参数的标签,由 Call(label, arguments...) 绑定参数,标签将其作为变量读取;StoryBuilder.Parameter(name, defaultValue) 用于创建参数。
Scene(SceneDefinition) 一个场景,作为以其名称命名的标签。
Character(id, name, color, image, sideImage, textColor, kind, prefix, suffix, font, size, voice, beep) 台词通过其 id 指明的角色,支持脚本 character 行的每个选项:显示的名称、名称颜色(#rrggbb)、图像标记(这样台词的属性可以改变表情)、图像是否显示在其台词旁边、文本颜色、CharacterKind.Adv、Nvl 或 Centered、引号、字体和字号、语音标记以及打字音效。Character(CharacterDefinition) 接受一个完整定义的角色。
Image(name, fileOrColor)、Image(name, ImageSource) 一个图像,来自游戏文件中的某个文件或某种颜色,或来自用 ImageSources(见下文)构建的图像源。即使不这样做,图像文件本身也已经是图像,以其路径命名(见概念)。
Image(name, a => ...) 一个本身是时间线的图像,相当于脚本的 image name: 块:Image("candle", a => a.Picture("candle a").Pause(0.5).Picture("candle b", "dissolve").Pause(0.5).Repeat()) 无论显示在哪里都会闪烁。
LayeredImage(tag, l => ...) 一个分层图像,用 LayeredImageBuilder(见下文)构建。
Transform(name, a => ..., parameters...) 一个具名动画,用 AnimationBuilder(见下文)构建,可带有由 show 的 at 传入的参数:at: "bob(30)"。
Transition(name, transition) 一个具名转场:Transition("slow", Transitions.Dissolve(2))。
Audio(name, file) 一个音频名称,相当于脚本的 audio 行。
Channel(name, mixer, loop, restoreOnLoad, fadeOut) 故事自己的音频通道,相当于脚本的 channel 行。
Track(file, title, when) 一首音乐空间曲目,在听到时解锁,或者在给定 Condition 时于其成立时解锁。
Default(variable, value) 游戏开始时变量的值。
Constant(name, value) 一个常量,相当于 define:在开始时设置,每次读档后再次设置。
Config(key, value)、Theme(key, value) 写入故事的一项 define config. 或 define theme. 设置。
Gallery(id, title, images...)、Gallery(id, title, when, images...) 一个画廊条目,在其所有图片都被看到后解锁,或在某个 Condition 成立时解锁。
Achievement(id, title, description, hidden) 故事通过 Achieve 授予的成就。
Translate(language, t => ...) 一份翻译,用 TranslationBuilder(见下文)构建。
Screen(name, s => ...) 故事显示或调用的一个界面。
Style(name, s => ...) 一个样式,用 StyleBuilder(见下文)构建,相当于脚本的 style name is parent:。如果与 Novella 自带的某个样式同名(say_dialogue、choice_button),它会细化游戏的相应部分。
CharacterStyle(id, part, style, properties) 已定义角色台词某一部分(window、namebox、who 或 what)的外观:为其指定一个样式,或用 StyleBuilder 仅为该角色在其上设置属性,或两者兼有,相当于脚本中的 window_style "ghost_window" 和 what_size 40。
Python(code) 游戏开始时运行一次的 Python,相当于 init python:;其中定义的函数可以在任何表达式中调用。游戏需要 Novella.Python。
Story 正在编写的故事。

BlockBuilder:标签、选项或分支中的语句,按顺序排列。每个方法添加一条语句(对于效果,则是其对应的若干语句)并返回构建器。省略某个可选参数,就会在语句中省略相应的子句。

方法 语句
Say(character, text, attributes...) 一句台词;属性会改变说话者的图像,并保持改变后的状态。
Say(character, text, new LineOptions { Attributes, Briefly, With, Id, SpeakerName }) 一句带有脚本台词所能携带的一切的台词:持续的属性、仅对本句有效的属性、台词出现时播放的转场、作者自选的 id、一次性的说话者名称。
SayBriefly(character, text, attributes...) 一句属性仅对本句有效的台词(@)。
Narrate(text) 旁白。
SayAs(speakerName, text) 来自一个非已定义角色的一次性说话者的台词。
Centered(text) 屏幕正中的文本,例如用作章节标题。
Extend(text) 接续上一句台词。
NvlClear() 清空 NVL 页面。
Input(variable, prompt, defaultText, maximumLength, allowed) 请玩家输入文字到一个变量中(除非另行指定,否则最多 24 个字符);如果给出了 allowed,则只接受其中列出的字符。
Scene(image, with, layer, at, animation) 清空一个图层并显示背景;没有图像时清为黑色。
Show(image, at, with, behind, zorder, as, layer, animation) 显示或更改一个图像,支持脚本 show 的每个子句。at 为 left、center、right 或故事中带实参的变换,以逗号分隔("bob(30), left");animation 是一个内联时间线(a => a.Set("alpha", 0.0).Linear(1.0, "alpha", 1.0))。
Hide(tag, with, layer) 隐藏一个图像。
With(transition) 对自上一句台词以来发生变化的所有内容运行一个转场。转场的写法与脚本中相同(dissolve、fade、dissolve(1.5)),或用 Transitions 创建。
Pause(seconds, hard) 等待若干秒,未给出秒数时等待点击;hard 暂停无法通过点击跳过。
Window(WindowMode, with) 显示或隐藏对话窗口,或将其恢复为仅在有台词时显示(Show、Hide、Auto),可选带一个转场。
ShowScreen(name, with, arguments...)、HideScreen(name, with) 在游戏上方显示一个界面,或将其隐藏。
CallScreen(name, with, arguments...) 显示一个界面并等待其应答,应答随后位于 _return 中。
Music(file, fadeIn, fadeOut, volume) 循环播放音乐,替换正在播放的音乐(除非是同一首);默认在一秒内淡入。
Sound(file, volume) 播放一次音效。
Play(channel, file or files, fadeIn, fadeOut, loop, volume, ifChanged) 在任意通道上播放,支持脚本 play 的每个选项;多个文件按顺序播放。
Queue(channel, file or files, loop, volume) 在当前播放内容之后播放。
Stop(channel, fadeOut) 停止一个通道(默认为 music)。
Voice(file)、VoiceSustain() 下一句台词的语音文件;让当前语音在下一句台词期间继续播放。
Movie(file, skippable, keepMusic) 全屏影片。
Menu(m => ...)、Menu(caption, m => ...) 提供选项,可选在其上方显示一句台词。执行完某个选项的语句后,故事从菜单之后继续。
If(condition, then, otherwise) 运行两个语句块之一;条件为 Condition 或 lambda,otherwise 可以省略。
If((a, then), (b, then), (null, otherwise)) 一个 if/elif/else 链:运行第一个 Condition 成立的分支,没有条件的分支即为 else。
While(condition, body) 在条件成立时重复执行一个语句块。
Jump(label) 在某个标签(或具名菜单)处继续。
JumpTo(v => ...)、JumpToVariable(name) 在故事执行到此处时才确定的标签处继续:由 lambda 求出,或由变量指明(jump expression)。
Call(label, arguments...) 运行一个标签后返回;实参传给该标签的参数。
CallTo(v => ...)、CallVariable(name) 调用一个在故事执行到此处时才确定的标签;这两者不接受实参。
Return()、Return(value)、Return(v => ...) 从调用中返回,或结束游戏;可带一个值,调用方将其读作 _return。
Set(variable, value) 将变量设为某个值。
Set(variable, v => ...) 将变量设为由 lambda 根据其他变量计算出的值。
Add(variable, amount)、Subtract(variable, amount) 对数字做加法或向列表追加;对数字做减法或从列表中移除。
Apply(effect) 应用一个构建出的 Effect。
Do(v => ...) 用 lambda 更改故事的变量。
Run(command, arguments...) 运行游戏实现的一个命令;其结果位于 _return 中。
Notify(text)、Achieve(achievement)、Pass() 一条通知、授予一项成就、什么也不做。
Python(code) 一个 Python 块,相当于 python:,针对故事的变量运行;它所设置的、故事能够保存的内容会写回故事。游戏需要 Novella.Python。
Script(text) 以脚本形式编写的语句,用于用脚本更容易表达的段落。

MenuBuilder:菜单的选项。

方法 作用
Choice(text, c => ..., when, id) 一个选项以及选中它时发生的事;如果给出了 Condition when,则仅在其成立时显示;如果给出了 id,则带有作者自选的 id。没有主体的选项什么也不做,故事继续往下进行。
Choice(text, v => ..., c => ...) 仅在 lambda 判断成立时显示的选项。
Caption(character, text) 与选项一同显示的一句台词,由某个角色说出(角色为 null 时为旁白)。
Once(listVariable) 将已选过的选项记录到一个列表变量中以将其隐藏,与 menu set 的做法相同:用于“每个话题只问一次”的菜单。
Name(name) 为菜单命名,使跳转或调用可以像到达标签一样到达它。

Condition:构建出的条件。

成员 何时成立
Condition.Always 始终成立。
Condition.Compare(variable, comparison, value) 变量与值的比较结果成立:==、!=、<、<=、> 或 >=。
Condition.CompareVariables(first, comparison, second) 两个变量的比较结果成立:CompareVariables("trust", ">", "doubt")。
Condition.AtLeast(variable, number) 一个数字变量至少为该数。
Condition.IsSet(variable) 变量有值:为真、非零、非空。
Condition.Visited(label)、Condition.VisitedAtLeast(label, times) 在本周目中已进入过该标签,或至少进入过指定次数。
Condition.EverReached(label) 在任意周目中曾到达过该标签,例如一个曾经见过的结局。
Condition.SeenImage(image)、Condition.Heard(file) 在任意周目中曾看到过某个图像,或听到过某段音频。
Condition.Achieved(achievement) 已获得某项成就。
Condition.Showing(tag) 带有该标记的图像此刻在舞台上。
Condition.InLanguage(language) 玩家以某种语言阅读游戏,例如 zh-Hans。
Condition.Chance(probability) 按概率(0 到 1)随机成立,从故事自身的生成器中抽取,因此回滚后结果相同。
Condition.Contains(listVariable, item) 一个列表变量包含该项。
Condition.FromScript(expression) 该脚本表达式成立,例如 affection >= 3 and gift == "fan"。
Condition.FromCode(v => ..., description) 由 lambda 判定;在错误消息和写出的脚本中以描述代替它。
a & b、a \| b、!a、And、Or、Not 两者都成立、任一成立、取反。

Effect:构建出的效果。

成员 更改的内容
Effect.Set(variable, value) 设置一个变量。
Effect.Add(variable, amount) 对数字做加法,或向列表追加。
Effect.Subtract(variable, amount) 对数字做减法,或从列表中移除。
Effect.Change(variable, assignment, value) 用运算符更改变量:=、+=、-=、*=、/=、//= 或 %=。
Effect.ChangeBy(variable, assignment, other) 用另一个变量的值、以相同的运算符更改变量:ChangeBy("bag", "-=", "chosen") 从包中取出所选的礼物。
Effect.Copy(variable, from) 将变量设为另一个变量的值。
Effect.Toggle(variable) 将为真的变量变为假,为假的变为真。
Effect.Random(variable, minimum, maximum) 将变量设为从故事自身的生成器中抽取的整数,包含两端。
Effect.SetToCondition(variable, condition) 将变量设为某个条件此刻是否成立。
Effect.None 什么也不做。
Effect.FromScript(code) 执行脚本所写的内容,相当于 $ 之后的部分:affection += 1。
Effect.FromCode(v => ..., description) lambda 所做的更改。
Effect.SetFromCode(variable, v => ...) 将变量设为由 lambda 计算出的值。
a.Then(b) 先执行一个效果,再执行另一个。

StoryVariables:lambda 所看到的故事变量。

成员 作用
v["name"] 按完整的点分名称读写变量(affection、persistent.endings);未设置的变量读取为空值。
Number、Integer、Text、Flag、List 将变量读取为数字、整数、文本、布尔值或列表。
ListContains(name, item) 列表变量是否包含某一项,按文本比较。
SetNumber、SetText、SetFlag 将变量设为数字、文本或布尔值。
Add(name, amount) 对数字做加法。
Visited(label) 在本周目中是否进入过某个标签。
Random(minimum, maximum) 从故事自身的生成器中抽取的整数,包含两端。

读取类成员(Number、Integer、Text、Flag、List、ListContains、Visited)在其文档中带有 <const>true</const>,因此 Divooka 会在 Function 图中提供它们;Function 图是数据流,只接受不改变任何东西的成员。写入类成员,以及会推进生成器的 Random,只在过程式图中提供。

定义用以下类型构建:

类型 成员
AnimationBuilder Set(property, values...);Interpolate(warper, seconds, property, values...),常用缓动函数可用 Linear(...) 和 Ease(...);And(property, values...),在同一步中改变另一个属性;Pause(seconds);Repeat(times);Block(a => ...);Parallel(a => ..., b => ...);Include(transform, arguments...);Picture(image, with),一个图片行;Choose((weight, a => ...)...),按权重选择;Time(seconds);On("show, replace", a => ...),图像被显示、替换或隐藏时的处理程序;Contains(image, a => ...);Function(name),用 RegisterTransformFunction 注册的函数;Script(timeline),用于以脚本形式编写的行。各项的作用见脚本参考。
AnimationValue 整数表示像素,带小数部分的数表示舞台尺寸的比例;Pixels、Fraction、Parameter(name) 和 FromScript(expression) 用于明确指定。
ImageSources File(path)、Color(color, width, height)、Movie(path, loop)、Filtered(source, zoom, flipHorizontal, flipVertical, crop, tint, saturation, brightness, blur, opacity)、Grayscale(source)、Composite(width, height, (x, y, source)...)、Frame(source, left, top, right, bottom) 和 Frame(path, xBorder, yBorder)(一张按自身边框拉伸的图片,用作样式的背景)、Switch((when, source)...)(第一个 Condition 成立的情形;没有条件的情形放在最后)、FromScript(source)。
LayeredImageBuilder Always(file)、Group(name, g => g.Attribute(name, file, isDefault, when), multiple)、位于任何组之外的 Attribute(...)、Size(width, height)。
Transitions Dissolve(seconds)、Fade(out, hold, in, color)、Flash(...)、ImageDissolve(mask, seconds, ramp, reverse)、Move(seconds, warper)、Named(name, seconds):每个都返回 with 所接受的转场文本。
TranslationBuilder Line(label, speaker, original, translated) 通过所在标签、说话者和内容查找一句台词;LineById(id, translated, speaker) 通过用 LineOptions.Id 指定的 id 查找;String(original, translated) 翻译选项、名称、提示或通知。
StyleBuilder Is(parent);Set(property, value),按脚本名称设置任意属性;When(StyleState.Hover, s => ...),用于某个状态的属性;以及 Font、Size、Color、Bold、TextAlign、Outline、Shadow、Background、Borders、BorderColor、CornerRadius、Padding、BoxSize 和 Spacing。拼错的属性名会在设置时抛出异常。见样式。
ScreenBuilder、ScreenActions 见以数据形式编写的界面。

StoryText.ParseValue 和 StoryText.ParseTransition 按脚本的写法读取一个值或一个转场,供以文本形式接受它们的代码使用。

命令与函数

故事通过两种方式调用代码:run 语句调用命令,表达式调用函数。

using Novella.Runtime;
using Novella.Values;

// run riddle answer   ->  _return is true or false
game.RegisterCommand("riddle", arguments => Value.FromBoolean(arguments[0].AsText() == "moon"));

// A command that works with the runner: variables, the random generator, achievements.
game.RegisterCommand("pay", (StoryRunner runner, IReadOnlyList<Value> arguments) =>
{
    double gold = runner.GetVariable("gold").AsNumber() - arguments[0].AsNumber();
    runner.SetVariable("gold", Value.FromNumber(gold));
    if (gold <= 0)
        runner.GrantAchievement("broke");
    return Value.FromNumber(gold);
});

// A command with no arguments and no result.
game.RegisterAction("shake_controller", () => Rumble());

// if is_full_moon(day): ...
game.RegisterFunction("is_full_moon", arguments => Value.FromBoolean(arguments[0].AsInteger() % 30 == 15));

值的类型是 Novella.Values.Value:数字、文本、布尔值、列表或 null,用 Value.FromNumber、FromText、FromBoolean 和 FromList 创建,用 AsNumber、AsInteger、AsText、AsBoolean 和 AsList 读取。命令的结果放入 _return。命令在帧线程上、在两条语句之间同步运行:它们应当及时返回;需要玩家参与的命令应改为被调用的界面。

RegisterCommandFunction(name, arguments => ...) 和 RegisterExpressionFunction(name, arguments => ...) 是同样的两种扩展,只是输入和输出都是文本:实参以文本形式传入,应答按脚本读取设置的方式解读(数字、true 或 false、列表或带引号的文本,否则就是文本本身)。Divooka 节点 Register Command Function 和 Register Expression Function 调用的就是它们,这样图就可以实现命令或函数;C# 代码则更适合使用上面的 Value 形式。

动画中的 function name 语句调用变换函数,变换函数用 RegisterTransformFunction(name, (state, seconds) => ..., duration) 注册:给定图像的 TransformState 以及该语句开始以来经过的秒数,它返回要显示的状态,持续 duration 秒后时间线继续;未给出持续时间时则一直如此。它必须只依赖这两个输入,这样读档或回滚时显示的才是当时所显示的内容。

// transform wobble:
//     function wobble
game.RegisterTransformFunction("wobble", (state, seconds) => state with { Rotate = 4 * Math.Sin(seconds * 6) }, duration: 2.0);

验证器知道游戏注册了哪些命令、函数、变换函数和界面,因此 Validate() 能在玩家遇到之前就报告对不存在命令的 run。重写了 Command 的游戏会将每个 run 名称都视为已处理。

故事中的 Python 无需注册:引用 Novella.Python,当故事包含 python: 或 init python: 块时,引擎就会找到它,并且 init python: 定义的函数会与游戏注册的函数并列可用。如果没有引用它,Validate() 会报告故事包含 Python 而游戏不支持。

实时脚本

实时脚本是一个本身就是故事的过程。重写 Script(或向 UseLiveScript 传入一个委托),开始游戏时就会在其自己的线程上运行它,其中每个调用都会立即播放,并在玩家作出应答后返回:

public sealed class RiddleGame : NovellaGame
{
    public RiddleGame()
        : base("A Riddle", 1280, 720)
    {
    }

    protected override void Setup()
    {
        DefineCharacter("q", "Qinglan", "#c0392b", "qinglan");
    }

    protected override void Script()
    {
        Scene("bg garden", "fade");
        Show("qinglan smile", "right", "dissolve");
        Say("q", "Sometimes it rests on the mountainside, sometimes it hangs from the treetop. What is it?");
        int answer = Menu(["A gong", "The moon", "A lantern"]);
        Say("q", answer == 1 ? "The moon. Correct." : "Look up, scholar.");
        string name = Input("And your name?", "Lu Wenjing");
        Say("q", "Goodnight, " + name + ".");
    }
}

实时调用包括 Say、Narrate、Show、Scene、Hide、With、Pause、Play、Stop、Menu(返回所选项的索引)、Input(返回输入的文本)、CallLabel、Notify 和 Achieve,以及用于故事状态的 SetVariable、GetVariable、GetNumber、RandomInteger 和 TranslateText(这些在命令中也可使用)。每个调用都与对应的脚本语句完全一样地经由运行器执行,因此转场、历史记录和跳过都照常工作。

实时脚本无法存档、读档或回滚,因为它的执行位置存在于一个正在运行的过程中,而不在故事里。CallLabel 会运行一个故事标签,并在其返回后继续,但在该标签运行期间,底层的实时脚本仍会阻止存档和回滚。当玩家返回主菜单时,脚本中尚未完成的调用会抛出 LiveScriptCancelledException,以展开该脚本;不要捕获它。实时脚本适用于小游戏和原型,凡是玩家会想要存档的内容都应使用标签;故事构建器同样可以直接用 C# 编写这些标签。

自定义界面

自定义界面是故事用 show screen 显示或用 call screen 调用的东西。它有两种写法:以数据形式,用一种由元素、容器、条件和循环组成的界面语言编写;或者以 C# 类的形式编写,可以绘制任何东西。当两者以同一名称同时存在时,在 C# 中注册的类优先,因此游戏可以用手工编写的界面替换已定义的界面,而无需更改故事。

以数据形式编写的界面

用 ScreenBuilder 编写的界面与脚本中的 screen 定义是同一种东西,Divooka 中的 Screens 节点构建的也是它。它会与故事的其余部分一起存档、验证并打印为脚本。

story.Screen("map", s => s
    .Background("bg town_map")
    .Text("Where to?", 0.5, 0.08, size: 40)
    .ImageButton("gui/map_market.png", 420, 300, "gui/map_market_hover.png", null, ScreenActions.Jump("market"))
    .Button("The river", 0.7, 0.6, Condition.Visited("market"), ScreenActions.Set("went_river", true), ScreenActions.Jump("river"))
    .Button("Stay home", 0.5, 0.9, ScreenActions.Return("home")));

容器、分支和循环接受一个构建器来构建其所包含的内容。下面是脚本参考中以脚本形式写出的那个物品栏:

story.Screen("inventory", s => s
    .Modal()
    .Variable("sorted_view", false)
    .Frame(f => f
        .VBox(v => v
            .Text("Your bag", size: 44).Align(0.5, 0.0)
            .Viewport(640, 300, list => list
                .For("item", "bag", row => row
                    .Button("[item]", ScreenActions.SetTo("chosen", "item"), ScreenActions.Hide())), spacing: 8)
            .If(Condition.FromScript("not bag"), empty => empty.Text("Nothing yet."))
            .HBox(h => h
                .Button("Sort", ScreenActions.Toggle("sorted_view"))
                .Button("Close", ScreenActions.Hide()), spacing: 20), spacing: 14),
        padding: 30, width: 700, height: 560).Align(0.5, 0.5)
    .Key("escape", ScreenActions.Hide()));
ScreenBuilder 添加的内容
Dim()、Modal()、Background(image) 界面后方的游戏画面变暗;一个截获所有点击的显示界面;一张覆盖元素后方舞台的图像。
Parameters(StoryBuilder.Parameter(...)...) 界面实参所绑定的参数,可在其文本和条件中读取。
Variable(name, value) 界面自己的变量,相当于界面中的 default:在界面打开时设置,由其按钮更改,与界面同存续。
Text(text, [x, y,] size, color, width, when) 文本,可插值 [variable]。
Image(image, [x, y,] zoom, when) 一张图片:图像名称或文件。
Button(text, [x, y, [when,]] actions...) 一个带文字的按钮。
Sensitive(condition) 使最后添加的按钮仅在条件成立时可按;否则显示为变暗。
ImageButton(image, x, y, hoverImage, when, actions...) 一张可按的图片,指针悬停时显示另一张图片。
Bar(variable, maximum, [x, y,] width, height, when) 一个相对于最大值显示变量的进度条。
Input(variable, maximumLength, allowed, width, when) 一个将输入写入变量的文本框。
Space(width, height) 空白区域,用于在容器中隔开各项内容。
Timer(seconds, [repeat,] actions...) 时间到达后执行一次,或每次到达时都执行。
Key(key, actions...) 界面显示期间按下某个键时执行:"escape"、"enter"、"left"、"f1"。
VBox、HBox、Grid(columns, ...)、Frame、Fixed、Viewport(width, height, ...) 一个容器,每种都接受一个用于其子元素的构建器,并视种类接受间距、内边距、背景、尺寸和条件。
If((condition, s => ...), (condition, s => ...), (null, s => ...))、If(condition, then, otherwise) 第一个条件成立的分支中的元素;没有条件的分支即为 else。
For(variable, list, s => ...) 对列表中的每一项各生成一次元素,该项放在其自己的变量中。
Use(screen, arguments...) 就地使用另一个界面的元素,并为其参数提供实参。
At(x, y)、Align(x, y)、Anchor(x, y) 最后添加的元素的放置方式:以某个位置为中心;将元素自身的某个比例点放在其区域的相同比例处(Align(1.0, 1.0) 为右下角,在容器中则是其在容器横向上的对齐);将元素的哪个点放在其位置上。
StylePrefix(prefix) 界面上所有内容的样式前缀,相当于 style_prefix:使用 "gold" 时,如果故事中有 gold_button,按钮就用它绘制。
Style(name, prefix) 最后添加的元素绘制时使用的样式,相当于 style "card";对于容器,还可指定其所含内容的前缀。
StyleProperty(property, value) 在最后添加的元素本身上设置的样式属性,叠加在其样式之上:StyleProperty("outline_width", 2);在按钮上,以 text_ 为前缀的名称用于设置其标签文字的样式。
ScreenActions 作用
Return(value) 应答被调用的界面;故事将该值读作 _return。
Jump(label) 关闭界面,并在某个标签处继续故事。
Set(variable, value) 设置一个变量:如果界面自己有同名变量则设置它,否则设置故事的变量。
SetTo(variable, expression) 在按下时将变量设为某个表达式的结果,例如 For 行中的项。
Toggle(variable) 将为真的变量变为假,为假的变为真。
Notify(text) 显示一条通知。
Play(file) 播放一次音效。
Open(menu) 打开游戏的某个菜单:save、load、preferences、history、gallery、music_room、achievements 或 main_menu。
Hide() 关闭界面;被调用的界面应答为空值。
ShowScreen(name)、HideScreen(name) 显示或隐藏另一个界面。
Run(command) 运行游戏注册的一个命令。

位置是 AnimationValue,与动画中的读法相同:0.5 是区域的比例,420 是像素。界面每一帧都会根据故事的变量和它自己的变量重新布局,而按钮的悬停状态、文本框的输入内容和视口的滚动位置则按元素在界面中的位置保留。脚本参考描述了每种元素、容器和动作的行为。

以类形式编写的界面

数据无法描述的界面(例如带拖放功能的界面)是一个派生自 NovellaScreen 的类,按名称注册。

using Divooka.Graphics.Canvas.Primitives;
using Novella.Hosting;
using Novella.Presentation.Screens;
using Novella.Presentation.Ui;
using Novella.Values;

/// <summary>call screen gift_picker(["hairpin", "fan", "book"]): returns the gift chosen.</summary>
public sealed class GiftPicker : NovellaScreen
{
    private readonly ScreenRequest _request;

    public GiftPicker(ScreenRequest request)
    {
        _request = request;
    }

    // Draw the game behind this screen.
    public override bool IsOpaque
        => false;

    public override void Opened()
    {
        IReadOnlyList<Value> gifts = _request.Arguments.Count > 0 ? _request.Arguments[0].AsList() : [];
        for (int index = 0; index < gifts.Count; index++)
        {
            Value gift = gifts[index];
            Ui.Add(new UiButton(gift.AsText(), ButtonKind.Choice, () => Session.Game?.ReturnFromScreen(this, gift))
            {
                Bounds = RectF.FromXYWH(Session.Width / 2f - Style.Px(400), Style.Px(320) + index * Style.Px(100), Style.Px(800), Style.Px(80))
            });
        }
    }
}
game.RegisterScreen("gift_picker", request => new GiftPicker(request));
    call screen gift_picker(["hairpin", "fan", "book"])
    $ gift = _return
  • 工厂会收到一个 ScreenRequest,其中包含界面的 Name、已求值的 Arguments 以及 IsCall。
  • 被调用的界面用 Session.Game.ReturnFromScreen(this, value) 应答,这会关闭界面并将该值放入 _return。如果以其他方式关闭(按 Escape,默认的 Back 处理方式是关闭界面;或调用界面自身的 Close),它会应答空值,因此故事永远不会等待一个已经消失的界面。
  • 显示的界面绘制在游戏上方,接收指针输入,并一直保留到 hide screen;由于舞台会记录它,存档读档或回滚时会将其恢复。
  • 界面在 Opened 中用 Ui.Add(UiButton、UiSlider、UiTextField、UiDrawing)构建其元素,通过重写 Draw 绘制额外内容,在 Update 中实现动画,并在读档、回滚或切换语言后于 Refresh 中重建。Style 提供主题的字体、颜色和缩放后的尺寸(Style.Px),并使用游戏的样式进行绘制,包括故事自己的样式:Style.TextLook("say_dialogue") 给出对话文本的外观,Style.Box(canvas, rect, "frame") 绘制一个面板,UiButton 的 StyleName 选择其绘制时使用的样式(其标签文字随后使用该名称加 _text 的样式绘制)。Session.Translate 给出某个词的界面翻译。

以代码表示的故事

脚本所能表达的一切都可以构建为对象。故事构建器是在 C# 中做到这一点的便捷方式;Divooka 节点底层的节点类是另一种方式,也是函数式节点所调用的:

using Novella.Model;
using Novella.Nodes;
using Novella.Scripting;

Sequence scene = SequenceNodes.StartSequence();
scene = SequenceNodes.Scene(scene, "bg garden", "fade");
scene = SequenceNodes.Say(scene, "q", "You came.", "smile");
Story story = StoryNodes.AddLabel(StoryNodes.StartStory(), StoryNodes.MakeLabel("start", scene));
story = StoryNodes.AddCharacter(story, CharacterNodes.MakeCharacter("q", "Qinglan", "#c0392b", "qinglan"));

// Or parse script text, and write a story back as script.
Story parsed = ScriptParser.Parse(File.ReadAllText("game.novella"), "game.novella");
string text = ScriptWriter.Write(story);

GameDefinition 包含一个故事及其配置、主题和资源来源;new NovellaGame(definition) 可以运行它。

无头运行与测试

单独使用运行器

Novella.Core 可以在没有窗口、没有音频、没有时钟的情况下运行故事,故事逻辑的单元测试就是这样进行的:

using Novella.Model;
using Novella.Runtime;
using Novella.Scripting;

const string Script = """
    character q "Qinglan"
    default affection = 0
    label start:
        q "You came."
        menu:
            "I did.":
                $ affection += 1
            "I was passing.":
                pass
        q "Listen, then."
    """;

StoryRunner runner = new(ScriptParser.Parse(Script, "test.novella")) { Seed = 12345 };
runner.Start();
Assert.Equal("You came.", ((SayInteraction)runner.Pending!).Marked.PlainText);
runner.Continue();
MenuInteraction menu = (MenuInteraction)runner.Pending!;
runner.Choose(menu.Options[0].Index);
Assert.Equal(1.0, runner.GetVariable("affection").AsNumber());
Assert.True(runner.Rollback());

运行器在 Pending(一个 SayInteraction、MenuInteraction、InputInteraction、PauseInteraction、TransitionInteraction、MovieInteraction 或 ScreenInteraction)上等待,并通过 Continue、Choose、SubmitInput 或 Respond 应答;转场或定时暂停用 Continue 应答。Rollback、RollForward、CanSave、SnapshotForSave 和 Load 用于测试回滚和存档,Seed 使随机抽取可复现。图像不必真实存在:缺失的图像会以占位名称出现在舞台上,可通过 State.Stage 查看。运行器会引发 LabelEntered、AudioChanged、Notified、AchievementUnlocked、ChoiceMade、ScreenChanged、StateRestored 和 Ended 事件。

以无头方式运行整个游戏

RunHeadless 在 ShadowGUI 的无头宿主上构建真实的会话,AutoPlayer 则通过真实的控件来游玩(按 Space 推进,按数字键选择,为输入框键入文字,按 Escape 关闭被调用的界面),因此一次完整游玩会测试到玩家所使用的那些界面:

using Divooka.Graphics.ShadowGUI.Headless;
using Novella.Hosting;
using Novella.Persistence;

HeadlessHost host = game.RunHeadless(new MemorySaveStorage());
NovellaSession session = game.Session!;
AutoPlayer player = new(session, host)
{
    ScreenshotFolder = "shots",
    ScreenshotEvery = 20,
    InputText = "Lu Wenjing"
};
player.QueueChoices([0, 1, 1]);
PlaythroughReport report = player.Play(900);
File.WriteAllLines(Path.Combine("shots", "transcript.txt"), report.Transcript);
session.Dispose();
return report.Problems.Count > 0 ? 3 : 0;

每张截图旁边都有一个文本文件,写明步骤、标签、故事当时在等待什么,以及舞台上有哪些内容和每个图像的矩形区域,因此无需猜测图片原本应显示什么,就能核对并复现它。报告列出访问过的标签、各次交互、是否到达了结尾,以及遇到的每个问题:缺失的图像或声音、脚本错误。排队的选项按顺序使用,用完后选择第一个选项。影片和其他步骤一样会被截图,可跳过的影片会在 1.5 秒(MovieTime)后点击跳过;转场、定时暂停或不可跳过的影片如果让故事停留超过 120 秒(WaitLimit),会被报告为卡住。

更快捷的检查无需编写代码:将 NOVELLA_HEADLESS 设为交互次数后,Run 会通过真实的控件以无头方式运行游戏,在每个菜单中选择第一个选项,打印所说的内容,并在错误流上报告问题。在没有桌面环境的情况下检查 Divooka 示例文档或构建,用的就是这种方式。

The Red Thread 在其命令行上提供了所有这些功能:

TheRedThread [assets folder]
TheRedThread --lint [assets folder]
TheRedThread --shoot out-folder [--choices 0,1,2] [--language zh-Hans] [--steps 900] [--every 20] [assets folder]

NovellaPlayer lint、shoot 和 bench 对任何脚本文件夹都能做同样的事。

会话与托管

Run 等于 CreateSession 加上 NovellaHosting.RunWindow。需要更多控制权的宿主可以自行创建会话:NovellaSession 负责流程(启动画面、主菜单、游戏、游戏菜单)、存档(Saves)、持久数据、音频混音器、界面和通知,并引发 GameEnded 和 QuitRequested 事件。除非提供了另一个 ISaveStorage,否则存档保存到 FolderSaveStorage.ForGame(config id),位于 %AppData%/Novella 下;MemorySaveStorage 将存档保存在内存中,预览和测试就是这样做的。