概念
Novella 遊戲就是資料,由一個執行時來播放。本頁面介紹這些資料和這個執行時:文件其餘部分所使用的術語,以及決定玩家看到什麼的規則。
遊戲定義
關於遊戲的一切都在一個遊戲定義中:
| 部分 | 包含的內容 |
|---|---|
| 配置 | 遊戲的行為:標題、解析度、入口標籤、回滾和存檔設定、偏好設定的預設值。用 define config.key = value 設定。 |
| 主題 | 遊戲的外觀:字型、顏色、對話視窗、選項、選單。用 define theme.key = value 設定。 |
| 故事 | 各個標籤以及它們引用的一切。 |
| 資源 | 檔案的來源:磁碟上的資料夾、記憶體中的檔案、Divooka 文件的庫。 |
指令碼、C# 程式和 Divooka 圖填寫的都是同一個定義,因此它們可以混合使用:程式可以載入指令碼、修改其中的某項設定,並新增用 C# 故事構建器編寫的標籤;圖可以用節點構建故事,再向其中新增一個指令碼檔案。主題設定列出了每一個配置鍵和主題鍵。
資源路徑是相對路徑,使用正斜槓,匹配時不區分大小寫,因此遊戲可以在資料夾和文件庫之間遷移而無需修改。當多個來源包含相同路徑時,最後新增的那個優先,補丁資料夾就是這樣覆蓋文件中的檔案的。
故事和標籤
故事是一組有序的標籤,每個標籤都是一個具名的語句塊;此外還包括故事的定義:角色、影像、分層影像、變換、轉場、音訊別名和通道、變數預設值和常量、畫廊、音樂空間、成就以及翻譯。
多個指令碼會合併為一個故事。標籤不能衝突(定義了兩次的標籤是一個錯誤,錯誤資訊會指出兩處位置),而後面指令碼中的定義會替換前面同名的定義。從資料夾載入的指令碼按路徑順序載入,因此名為 options.novella 或 script_01_definitions.novella 的檔案可以排在使用其定義的章節之前。
標籤是一個入口點。遊戲從 start 開始;jump 轉到另一個標籤繼續執行;call 執行一個標籤後再返回,把實參繫結到標籤的參數上(label chapter_title(number, title):),並在 _return 中接收 return 的值。與 Ren'Py 一樣,執行到標籤末尾時會落入同一檔案中寫在其後的下一個標籤;從檔案最後一個標籤的末尾落出則會結束遊戲,如果該標籤是被呼叫的,則返回到呼叫方。沒有呼叫方的 return 會結束遊戲。用 C# 或節點構建的標籤不來自任何檔案,因此它們永遠不會落入下一個標籤:執行到末尾時會結束遊戲,或返回到呼叫方。當一個用程式碼構建的標籤可能執行到末尾、而其後的標籤也是用程式碼構建時,lint 會發出警告,因為指令碼中的標籤在這種位置本會落入下一個標籤。
故事還有引擎會尋找的入口標籤:splashscreen 在啟動時、主選單之前執行一次,main_menu 替換內建主選單(它返回時開始新遊戲),after_load 在載入存檔之後被呼叫。它們都是可選的,並且可以在配置中重新命名。
場景
場景是一種無需編寫控制流就能編寫故事的方式。它是 Novella One 構建遊戲所用的配置模型:場景有一個名稱、一個背景(通過轉場引入,如果沒有指定其他轉場則為 fade)、音樂、臺詞,以及一個說明故事接下來去向的結尾:
| 結尾 | 發生什麼 |
|---|---|
| 轉到場景 | 故事在另一個場景繼續。 |
| 選擇場景 | 向玩家提供若干選項,每個選項通向一個場景;選項可以只在某個條件成立時提供,並且在被選中時可以修改變數。 |
| 分支到場景 | 由一個條件決定接下來是兩個場景中的哪一個。 |
| 結束 | 遊戲結束(如果是被呼叫的場景,則返回)。 |
遊戲就是其場景的集合,名為 start 的場景是遊戲的開始。對執行時來說,場景並不是一種新東西:它會變成一個與其同名的普通標籤,依次包含背景、音樂、臺詞,最後是一個跳轉、一個由跳轉組成的選單,或一個分支。因此,場景的存檔、回滾和驗證方式與其他任何標籤相同,其他標籤可以跳轉到它,場景、標籤和指令碼也可以混合在同一個故事中。場景在 Divooka 中用 Scenes 節點編寫,在 C# 中用 SceneDefinition 編寫。
語句和臺詞 id
語句沿用 Ren'Py 的:對話(say)、旁白、extend、show、hide、scene、with、pause、jump、call、return、menu、if/elif/else、while、賦值($)、play/queue/stop、voice、movie、input、window、nvl clear、notify、achieve、介面(show screen、call screen)、自訂命令(run),以及在選擇啟用 Python 的遊戲中的 python: 程式碼塊。介面也是故事資料,可以用一種由盒子、條件和迴圈組成的介面語言編寫,每一幀都根據故事的變數重新佈局;也可以是遊戲註冊的 C# 類。指令碼語言頁面列出了全部語句。
每句對話臺詞和每個選單選項都有一個穩定的 id:標籤名稱加上說話者和文字的雜湊值,如果同一句臺詞在一個標籤中出現兩次,還會附加一個計數器;也可以是用 id name 寫明的 id。id 由內容派生,而不是由位置決定,因此在某句臺詞上方插入一句新臺詞,不會改變那句臺詞的 id,也就不會改變它的已讀標記和翻譯。改寫一句臺詞會改變它的 id,這是正確的:改寫後的臺詞是未讀的,需要重新翻譯。需要翻譯的遊戲通常會寫明 id,這樣編輯英文原文時就不會讓翻譯失去對應。
角色
角色表示誰在說話,以及其臺詞的外觀:指令碼使用的 id(q);顯示的名稱("Qinglan",其中可以插入變數,例如 "[player_name]");名稱顏色和文字顏色;字型和字型大小;包裹臺詞的引號;影像標記;該影像是否作為側邊影像顯示在對話視窗中;打字音效;以及一種型別:adv(對話視窗)、nvl(逐行填滿的全屏頁面)或 centered(顯示在螢幕中央、沒有視窗的文字)。角色還可以為其臺詞的每個部分指定專屬的樣式:與其他所有人都不同的視窗、名稱標籤、名稱和臺詞。
內建了三個角色:narrator、centered 和 nvl_narrator。沒有說話者的臺詞屬於旁白(narrator)。
帶有影像標記的角色可以在說話時更換圖片:q smile "..." 顯示 qinglan smile 並保持顯示,q @ smile "..." 只在這一句臺詞期間顯示它。
影像
影像的名稱由若干個片語成:第一個詞是它的標記(tag),其餘的是屬性(attribute)。qinglan smile 的標記是 qinglan,屬性是 smile。同一標記在舞臺上同時只能有一個影像,因此顯示 qinglan sad 會替換 qinglan smile。

影像有三種定義方式:
- 根據檔名自動定義。資源中的每個影像檔案都會定義一個影像,除非故事自己定義了該名稱:名稱為去掉副檔名的路徑,資料夾和檔名之間用空格連線,全部小寫,並去掉名為
images、sprites或characters的頂層資料夾。bg/plum_garden.jpg即bg plum_garden;sprites/qinglan/smile.png即qinglan smile。 - 在指令碼中用
image name = source定義,其中來源可以是檔案、顏色、影片、佔點陣圖、作用於另一個來源的濾鏡、合成影像或條件切換。 - 作為分層影像定義,由按屬性選擇的圖層組裝而成,因此
show qinglan dress smile blush可以組合三個圖層,而無需為這種組合單獨準備檔案。
內建了兩個影像:black 和 white,它們是填滿螢幕的純色,使 scene black 無需任何定義即可使用。如果故事定義了其中任一名稱,則使用故事自己的定義。
當指令碼請求某些屬性時,引擎會尋找具有所有這些屬性的影像,並優先選擇能保留目前已顯示屬性的影像:在 show qinglan dress smile 之後,如果存在 qinglan dress sad,show qinglan sad 就會找到它。如果沒有匹配項,該影像會繪製為佔點陣圖(一個剪影,背景則為漸層),並由驗證器報告,因此在美術素材完成之前就可以編寫和遊玩故事。
可識別的影像檔案格式有 .png、.jpg、.jpeg、.webp、.bmp、.gif、.tga 和 .qoi。
舞臺
舞臺是邏輯上的畫面:顯示了哪些影像,位於哪個圖層,以什麼順序,使用哪些變換,以及顯示了哪些自訂介面。舞臺歸執行器所有,表現層只是鏡像它,因此存檔能精確還原畫面,測試也能在不繪製任何東西的情況下檢查畫面上有什麼。

有四個內建圖層,onlayer 加上任何其他名稱會建立一個同名圖層。從下到上依次為:
| 圖層 | 繪製位置 | 典型用途 |
|---|---|---|
master |
對話視窗之下 | 背景和角色:show 和 scene 的預設圖層。 |
| 任何其他名稱 | 位於 master 之上、transient 之下,多個圖層按名稱順序排列 |
故事自己建立的圖層,例如位於角色和視窗之間的天氣效果。 |
transient |
對話視窗之下 | 只顯示片刻的圖片:與 Ren'Py 一樣,在每句臺詞、每個選單、每次輸入或每次點選繼續的暫停之後自動清除。 |
screens |
對話視窗之上 | 必須位於文字之上的圖片。 |
overlay |
對話視窗之上 | 邊框、暗角、天氣效果。 |
在同一圖層內,影像按顯示的先後順序堆疊,按 zorder 排序,並可用 behind 調整;scene 清空一個圖層。
變換
變換用於放置影像併為其新增動畫。帶小數點的位置值表示比例(位置相對於螢幕,錨點相對於影像),整數表示像素,因此 xpos 0.5 是正中間,xpos 640 是距左邊 640 像素。內建位置有 left、right、center、midleft、midright、truecenter、top、topleft、topright、offscreenleft、offscreenright 和 fullscreen;沒有指定變換時使用 default(底部置中)。
變換也可以是 Ren'Py ATL 風格的動畫時間線:屬性設定、帶緩動函式的插值、暫停、更改影像顯示內容的圖片行、repeat、block、parallel、加權的 choice、time、針對影像被顯示、被替換或被隱藏的處理程式、contains 以及 function。影像本身也可以是一條時間線,幀動畫就是這樣實現的:一支無論顯示在哪裡都會閃爍的蠟燭。時間線被當作一個純函式來求值,其輸入是影像顯示以來經過的時間;每個顯示的影像都帶有自己的隨機種子(儲存在存檔中),其隨機選擇都從這個種子中抽取,因此在讀檔或回滾後,動畫能精確還原。時間線中的表示式可以讀取變換自身的參數和內建函式,但不能讀取遊戲的變數,因為動畫不能依賴存檔無法還原的狀態。當為一個已經在顯示的影像指定新變換時,新時間線會從影像目前所在的位置開始,因此像 ease 1.0 xalign 0.0 這樣的插值會從舊位置平滑移動,而不是先跳回預設位置。
轉場
轉場為畫面的變化新增動畫。它可以寫成 show、hide、scene 或 window 語句上的 with,寫在一句對話臺詞的末尾(此時它在臺詞出現之前播放),寫在 show screen 或 hide screen 上(被呼叫的介面則改為在轉場時長內淡入),也可以單獨成行;它作用於自玩家上次互動以來發生變化的所有內容,從變化前的畫面過渡到變化後的畫面。轉場在模型中指定(dissolve、fade、pixellate、move、slide、push、wipe、iris、image dissolve、zoom、punch、flash、blinds、squares),由表現層繪製。玩家可以在偏好設定中關閉轉場,快進時也會跳過轉場。
執行器和互動
執行器是故事的虛擬機器。它是單執行緒且同步的:它會一直執行語句,直到某條語句需要玩家參與,然後等待這次互動。互動共有七種:
| 互動 | 由誰產生 | 以什麼作答 |
|---|---|---|
| Say | 對話和旁白 | 一次點選 |
| Menu | menu |
所選選項的索引 |
| Input | input |
輸入的文字 |
| Pause | pause |
一次點選,或時間耗盡 |
| Transition | with |
轉場結束 |
| Movie | movie |
影片結束,或在可跳過時的一次點選 |
| Screen | call screen |
介面返回的值;如果玩家關閉介面,則為空 |
show、hide、scene、音訊和賦值會更新狀態並繼續執行,而不會等待。執行器中沒有任何部分進行繪製、播放聲音或讀取時鐘,因此同一個執行器既可以位於視窗之下,也可以用於無頭測試和自動通關。一個故事如果在不等待的情況下執行了一百萬條語句,就會以錯誤停止,這樣可以捕獲其中沒有臺詞或選單的 while 迴圈。
變數
故事的變數存在於遊戲狀態中,通過 $、default、input 以及選單的 set 列表設定。以 persistent. 開頭的名稱則存在於持久儲存中(見下文),被呼叫標籤的參數則是該次呼叫的區域變數。有幾個變數由引擎設定:
| 變數 | 設定者 |
|---|---|
_return |
return value、run 命令的結果、被呼叫介面的結果 |
_choice、_choice_index |
剛剛做出的選單選擇的文字和索引 |
default name = value 在開始新遊戲時設定變數。define name = value 在啟動時設定常量,並在每次讀檔後再次設定,因此修改後的常量在舊存檔中也會生效。
變數可以儲存數字、文字、布林值、空值或列表。在選擇啟用 Python 的遊戲中,python: 程式碼塊會獲得故事的變數,並把它設定的、屬於上述型別的值交還回來;Python 持有的其他任何資料,例如字典、集合或 init python: 所定義類的物件,每當程式碼塊修改它們時,都會被序列化(pickle)到一個隱藏的故事變數 _python 中,因此它們會與故事自身的變數一起存檔、讀檔和回滾。函式和類是程式碼,每次遊戲啟動時都會重新定義,因此它們應放在 init python: 中。那裡的 Python 可以使用其標準庫(random、os、json、pickle、datetime 等都可以匯入),但涉及隨機時,故事仍然應使用自己的 random、chance 和 choice,因為它們的抽取來自遊戲狀態,回滾時會重放;而 Python 的 random 會重新抽取。
檢查點和回滾
每句臺詞、每個選單、每次輸入、每次點選繼續的暫停以及每個被呼叫的介面都是一個檢查點:執行器會儲存它之前的狀態快照,即變數、舞臺、每個音訊通道正在播放的內容、呼叫堆疊、歷史記錄、NVL 頁面以及隨機數生成器的狀態。回滾(滑鼠滾輪向上、Page Up,或快捷選單上的 Back)會還原上一個檢查點並再次顯示其互動;向前回滾會重放玩家之前給出的回答。不等待點選的臺詞(以 {nw} 結尾的臺詞)在顯示的瞬間就會再次前進,因此回滾時會越過它,回到它之前的那句臺詞。當沒有可以回滾到的位置時,快捷選單的 Back 會變暗:位於第一句臺詞時、回滾已關閉時、處於即時指令碼中時,或者位於受 rollback_past_choices = false 保護的選單時。

由於隨機數生成器是狀態的一部分,當玩家回滾並重新選擇時,隨機抽取會被重放:回滾不能用來重新擲骰子。config.rollback_limit 設定玩家最多能回滾多遠,config.allow_rollback 關閉回滾,config.rollback_past_choices = false 使回滾在選單處停止,從而無法重新做出選擇。
存檔和持久資料
存檔是最新的檢查點,加上讀檔介面所顯示的資訊:時間、標籤、存檔時所在的臺詞或選單、遊玩時間、遊戲版本以及一張縮圖。讀取存檔會精確還原畫面,重新應用指令碼的常量,呼叫 after_load 標籤(如果有),並恢復當時正在播放的音樂。存檔位命名為 page-slot(1-3)、auto-n 和 quick-1;每個存檔位都是一個 JSON 檔案,旁邊附有一張 PNG 縮圖,在 Windows 上位於 %AppData%/Novella/<game id>。遊戲會在選單處(最多每三十秒一次)以及玩家退出或返回主選單時自動存檔。


存檔以“標籤加語句索引路徑”的方式記錄位置,因此在某個標籤內建立的存檔不受其他標籤修改的影響;但如果在同一標籤中、存檔位置之前新增或刪除了語句,讀檔時可能會落在另一句臺詞上。如果存檔所在的標籤已不存在,該存檔就無法讀取,並且會告知玩家。
持久資料是在多次通關之間保留下來、永遠不會被回滾或被讀檔替換的所有內容:已讀的臺詞(用於跳過已讀文字)、看過的影像(用於畫廊)、聽過的音訊(用於音樂空間)、到達過的標籤、在之前通關中做出的選擇(顯示得較暗)、成就、persistent. 變數以及玩家的偏好設定。它儲存在存檔旁的 persistent.json 中。凡是玩家在讀取舊存檔後仍應保留的內容,例如已看過的結局,都應使用持久資料。
用程式碼編寫的故事
用 C# 故事構建器編寫或由節點構建的故事,與解析後的指令碼完全一樣都是資料:由語句組成的標籤,其中條件是表示式,對變數的更改是賦值。它的任何部分都不是正在執行的過程,因此它的存檔、讀檔、回滾、跳過已讀文字和獲得臺詞 id 的方式都與指令碼編寫的故事相同。
存檔並不包含故事。它儲存的是一個位置(一個標籤和一條語句索引路徑)、變數以及其餘狀態,讀檔時會在遊戲目前的故事中找到這個位置。指令碼每次都提供相同的故事,因為它是同一個檔案;程式之所以能提供相同的故事,是因為它每次啟動都執行相同的程式碼。由此得出的規則是:每次執行都要構建相同的故事。如果故事的結構取決於時鐘、隨機數或會變化的檔案,讀取舊存檔時就會落到錯誤的位置。
條件或效果可以是一個作用於故事變數的 C# lambda,而不是表示式;在由節點構建的故事中,交給 From Function 節點的 Function 圖起著同樣的作用,下文所述對它同樣適用。lambda 本身永遠不會被儲存;下次啟動時構建的故事會再次持有它,而它讀寫的是遊戲狀態中的故事變數,因此它的更改會與其他一切一起回滾和存檔。這帶來兩個結果:
- lambda 應當只觸及故事的變數。它在故事之外修改的任何東西(程式的某個欄位、某個檔案),在玩家回滾時都不會回滾,讀檔時也不會還原。
- lambda 沒有指令碼形式。由各部分構建的條件(變數與某個值比較、存取過某個標籤、多個條件的組合)與指令碼中
if所包含的表示式相同,因此故事可以列印為指令碼,再讀回來也完全一樣;而 lambda 只會列印為諸如a C# condition之類的描述,無法讀回。
即時指令碼
故事也可以是一個過程,而不是標籤。一個 C# 方法或 Divooka 過程式圖實現遊戲的 Script 回撥(或傳給 UseLiveScript),開始遊戲時會在單獨的執行緒上執行它;在這個執行緒中,Say、Show、Scene、Menu、Input 等都是阻塞呼叫:每個呼叫都會立即通過執行器播放,與指令碼語句完全一樣,並在玩家作答後返回。Menu 返回所選選項的索引,可直接用於 Branch 或 Switch。這是用節點編寫小遊戲最直接的方式。
它的限制源自它的本質。正在執行的過程所處的位置不是資料,因此即時指令碼無法存檔、讀檔或回滾:存檔介面仍然可以開啟,但選擇存檔位或快速存檔時,會顯示通知 The game cannot be saved here.,並且不寫入任何內容;自動存檔也會被跳過。Call Label 從即時指令碼中執行一個故事標籤,之後再返回即時指令碼;該標籤在其他方面的行為與編寫的一致,但只要下方有即時指令碼,它同樣無法存檔或回滾。對於玩家需要存檔的遊戲,請把故事寫成標籤或場景(用指令碼、C# 故事構建器或節點),而用程式碼實現命令。
一次會話的流程
遊戲開啟時,如果有 splashscreen 標籤,就先執行它,然後顯示主選單(或執行 main_menu 標籤)。Start 從開始標籤開始新遊戲(或執行即時指令碼),Continue 讀取最近的存檔,遊戲選單(右鍵按一下或 Escape)包含 History、Save、Load、Preferences,以及在故事定義了它們時的 Gallery、Music Room 和 Achievements。故事結束時,遊戲會通過 config.end_game_transition 返回主選單。
控制
按一下、Space 或 Enter 前進;滾輪向上或 Page Up 回滾,滾輪向下或 Page Down 向前回滾;右鍵按一下或 Escape 開啟遊戲選單;按住 Ctrl 時快進,Tab 切換快進,A 切換自動前進;H 或按一下滑鼠中鍵隱藏視窗;F5 和 F9 分別快速存檔和快速讀檔;S 儲存螢幕截圖;F、F11 或 Alt+Enter 切換全屏;數字鍵選擇選單選項。在開發者模式下,Shift+O 開啟主控台,Shift+R 重新載入指令碼。
手柄操作遊戲的方式與鍵盤相同:
| 手柄 | 作用 |
|---|---|
| A | 繼續,並按下所選按鈕。 |
| B | 返回,或開啟遊戲選單。 |
| Start | 開啟遊戲選單。 |
| 方向鍵、左搖桿 | 在按鈕之間移動,按住時重複。 |
| 左右肩鍵 | 回滾和向前回滾。 |
| X | 隱藏視窗。 |
| Y | 切換自動前進。 |
| Back | 切換快進。 |
每個介面都可以用手柄操作,包括遊戲自己的介面,因為手柄的按鈕對應的正是介面原本就會響應的按鍵:無需為此編寫任何程式碼。