开发
本页是 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 将存档保存在内存中,预览和测试就是这样做的。