繁體中文
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 將存檔儲存在記憶體中,預覽和測試就是這樣做的。