Compare commits

..
9 Commits
80 changed files with 2630 additions and 90 deletions

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ obj/
gen/ gen/
obj/ obj/
bin/ bin/
art_sources/
*.tres~ *.tres~
*.tscn~ *.tscn~
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+103
View File
@@ -0,0 +1,103 @@
# Dialog Engine — the dialog box and the scenario language
Dialog Engine is what the player reads and clicks: the dialog box with its lines, portraits and answers. Behind it is the **scenario language** — small `.scenario` text files in which a story is written line by line: what is said, which answers there are, where each answer leads, and what happens in the game (a new background, music, an item, a quest step, another scene). Every game in the collection, from the novel template to RPG Plus, tells its story with it.
## What the player sees
A scene's scenario starts by itself when the player enters the scene. Lines appear in the box at the bottom; a click shows the next one:
![The town of My Novel: the evening street, the line "Evening town. The street lamps are already lit and a cool draught runs along the cobbles." in the dialog box.](assets/town-text.jpg)
When the story asks something, the answers appear on the right. The story waits until one is chosen:
![The question "Where to go?" in the dialog box and its two answers on the right: "Go into the bar" and "Go back home".](assets/town-choice.jpg)
"Go into the bar" takes the player to the bar scene, which starts its own scenario:
![The bar of My Novel with its first line in the dialog box.](assets/bar-text.jpg)
## The scenario behind these pictures
This is the whole town scenario of the novel template (`my_novel_game/scenarios/my_novel_town.scenario`):
```scenario
scene: my_novel_town
priority: 10
---
# The town is the hub: every visit shows the same choice.
text "Evening town. The street lamps are already lit and a cool draught runs along the cobbles." key=my_novel/town/intro
select "Where to go?" key=my_novel/town/prompt
option "Go into the bar" key=my_novel/town/opt_bar -> :bar
option "Go back home" key=my_novel/town/opt_home -> :home
:bar
switch_scene my_novel_bar
end
:home
switch_scene my_novel_home
end
```
- **The header** above `---` says when the scenario plays: `scene:` names the scene, `priority:` orders several scenarios of one scene (lower plays first). More header lines are below.
- `text "…"` shows a line and waits for a click. `key=` is the translation key of the line; the quoted text is what is shown when no translation is found.
- `select` asks a question, and each `option` is an answer that jumps to a **label** (`-> :bar`).
- `:bar` is a label; `switch_scene` goes to another scene; `end` finishes the scenario.
- A line starting with `#` is a note for the author.
To add a story to a scene, put a `.scenario` file anywhere in your mod's `scenarios/` folder and name the scene in its header.
## Writing a story
**Lines and portraits.** `text "…" first=marao/sad` shows a portrait in the first place, `second=…` in the second (see Characters Engine). `ptext` is the same as `text`, and also replaces `${name}` with the value of a variable. `title "…"` shows a chapter title.
**Choices and jumps.** An answer can be shown only when a condition holds: `option "Pay" "if=item('money/coin') >= 5" -> :pay` (a condition with spaces is quoted together with `if=`). `goto -> :label` jumps without asking. `when "<condition>" -> :label` jumps if the condition holds at that moment:
```scenario
when "rpg_wolf_dead == 1" -> :lantern
```
**Remembering things.** `flag rpg_quest_taken 1` sets a story flag that is saved with the game; conditions read it as `rpg_quest_taken == 1`. `set name value` sets a variable of the running dialog; `ptext` shows it as `${name}` and actions can read it.
**Conditions** are one shared language: flags, `item('id')` from Items Base, quest checks from Quests Engine, stats and equipment from their mods, joined with `&&`, `||` and `!`.
**The scene.** `background`, `foreground`, `background_color`, `music`, `sound`, `effect`, `delay`, `light_set`, `hide_dialog`/`show_dialog`, `interactive_add`/`interactive_remove`, `notify`.
**Game actions.** `action <name> key=value …` runs an action of another mod, for example `action item.add id=money/coin count=3` or `action quest.set_status quest_id=woodland_ward status=Progress`. The schema files of the mods list the actions, and Editor Tools offers them in its scenario editor and command palette.
## When a scenario plays
The header decides it:
```scenario
scene: prolog_alley
priority: 15
condition: AwakeningQuest.notHasTag("cloak_taken")
is_one_run: true
save_allowed: true
---
```
- `scene:` — the scene where it plays when the player arrives.
- `priority:` — order among scenarios of the same scene; the lowest number that may play goes first, and the next one starts when the dialog ends.
- `condition:` — it plays only while the condition holds; when a flag or quest changes, the scene checks again.
- `is_one_run: true` — it plays only once per game.
- `after:` — it waits until the named scenario has played.
- `save_allowed: true` — the player may save in this scene.
A scenario without a `scene:` header is not started by a scene; it is run by something else, such as a click on an interactive object or a map event.
## Other mods add and replace scenarios
Scenarios of all mods are collected together. A mod adds a story to someone else's scene simply by naming that scene. A mod that depends on another and ships a scenario with the same file name replaces it.
## If something goes wrong
1. **A scenario never plays.** Its `scene:` name is wrong, its `condition` is false, or it has `is_one_run` and has already played.
2. **The line is shown in the wrong language.** The `key=` is missing from the mod's `locale` files for that language; the quoted text is shown instead.
3. **An answer leads nowhere.** The label after `->` must exist in the same file, written with a colon (`:bar`).
4. **An `action` does nothing.** The mod that owns the action is not part of the game (the log says the action "is not registered"), or a parameter name is wrong — compare it with the action in the scenario editor of Editor Tools.
## Shared expressions
Numeric expressions are supplied by `expressions_base`. Use `set money=rnd(3,5)` or `cset total "1+2+${money}"`; reuse `${money}` in localized text and action parameters. `set name value` still stores a literal string. See the [Expression Engine guide](../../expressions_base/docs/en.md) for syntax, strict assignments and mod-defined strategies.
+99
View File
@@ -0,0 +1,99 @@
# Dialog Engine — la caja de diálogo y el lenguaje de escenarios
Dialog Engine es lo que el jugador lee y pulsa: la caja de diálogo con sus líneas, retratos y respuestas. Detrás está el **lenguaje de escenarios** — pequeños archivos de texto `.scenario` en los que la historia se escribe línea a línea: quién dice qué, qué respuestas hay, adónde conduce cada una y qué ocurre en el juego (un fondo nuevo, música, un objeto, un paso de misión, otra escena). Con él cuentan su historia todos los juegos de la colección, desde la plantilla de novela hasta RPG Plus.
## Lo que ve el jugador
El escenario de una escena arranca solo cuando el jugador entra en ella. Las líneas aparecen en la caja inferior; un clic muestra la siguiente:
![El pueblo de My Novel: la calle de tarde, y en la caja de diálogo la línea «El pueblo, al anochecer. Las farolas ya están encendidas y un aire fresco corre entre los adoquines».](assets/town-text.jpg)
Cuando la historia hace una pregunta, las respuestas aparecen a la derecha, y espera a que se elija una:
![La pregunta «¿A dónde ir?» en la caja de diálogo y sus dos respuestas a la derecha: «Entrar al bar» y «Volver a casa».](assets/town-choice.jpg)
«Entrar al bar» lleva al jugador a la escena del bar, que arranca su propio escenario:
![El bar de My Novel con su primera línea en la caja de diálogo.](assets/bar-text.jpg)
## El escenario detrás de estas imágenes
Este es el escenario completo del pueblo de la plantilla de novela (`my_novel_game/scenarios/my_novel_town.scenario`):
```scenario
scene: my_novel_town
priority: 10
---
# El pueblo es el centro: en cada visita aparece la misma elección.
text "El pueblo, al anochecer. Las farolas ya están encendidas y un aire fresco corre entre los adoquines." key=my_novel/town/intro
select "¿A dónde ir?" key=my_novel/town/prompt
option "Entrar al bar" key=my_novel/town/opt_bar -> :bar
option "Volver a casa" key=my_novel/town/opt_home -> :home
:bar
switch_scene my_novel_bar
end
:home
switch_scene my_novel_home
end
```
- **La cabecera**, sobre `---`, dice cuándo se reproduce el escenario: `scene:` nombra la escena y `priority:` ordena varios escenarios de una misma escena (se reproduce antes el número más bajo). Más adelante hay más líneas de cabecera.
- `text "…"` muestra una línea y espera un clic. `key=` es la clave de traducción de la línea; el texto entrecomillado es lo que se muestra cuando no hay traducción.
- `select` plantea una pregunta, y cada `option` es una respuesta que salta a una **etiqueta** (`-> :bar`).
- `:bar` es una etiqueta; `switch_scene` pasa a otra escena; `end` termina el escenario.
- Una línea que empieza por `#` es una nota para el autor.
Para añadir una historia a una escena, coloca un archivo `.scenario` en cualquier punto de la carpeta `scenarios/` de tu mod y nombra la escena en la cabecera.
## Escribir una historia
**Líneas y retratos.** `text "…" first=marao/sad` muestra un retrato en el primer puesto, `second=…` en el segundo (véase Characters Engine). `ptext` hace lo mismo que `text` y además sustituye `${name}` por el valor de una variable. `title "…"` muestra el título de un capítulo.
**Opciones y saltos.** Una respuesta puede mostrarse solo mientras se cumpla una condición: `option "Pay" "if=item('money/coin') >= 5" -> :pay` (una condición con espacios se entrecomilla junto con `if=`). `goto -> :label` salta sin preguntar. `when "<condition>" -> :label` salta si la condición se cumple en ese momento:
```scenario
when "rpg_wolf_dead == 1" -> :lantern
```
**Recordar cosas.** `flag rpg_quest_taken 1` activa un marcador argumental que se guarda con la partida; las condiciones lo leen como `rpg_quest_taken == 1`. `set name value` asigna una variable del diálogo en curso: `ptext` la muestra como `${name}` y las acciones pueden leerla.
**Las condiciones** son un único lenguaje común: marcadores, `item('id')` de Items Base, comprobaciones de misiones de Quests Engine, atributos y equipo de sus respectivos mods, unidos con `&&`, `||` y `!`.
**La escena.** `background`, `foreground`, `background_color`, `music`, `sound`, `effect`, `delay`, `light_set`, `hide_dialog`/`show_dialog`, `interactive_add`/`interactive_remove`, `notify`.
**Acciones de juego.** `action <name> key=value …` ejecuta una acción de otro mod, por ejemplo `action item.add id=money/coin count=3` o `action quest.set_status quest_id=woodland_ward status=Progress`. Los archivos schema de los mods enumeran las acciones, y Editor Tools las ofrece en su editor de escenarios y en la paleta de comandos.
## Cuándo se reproduce un escenario
Lo decide la cabecera:
```scenario
scene: prolog_alley
priority: 15
condition: AwakeningQuest.notHasTag("cloak_taken")
is_one_run: true
save_allowed: true
---
```
- `scene:` — la escena donde se reproduce cuando el jugador llega.
- `priority:` — el orden entre los escenarios de una misma escena; va primero el número más bajo que pueda reproducirse, y el siguiente empieza cuando termina el diálogo.
- `condition:` — solo se reproduce mientras se cumple la condición; cuando cambia un marcador o una misión, la escena vuelve a comprobar.
- `is_one_run: true` — se reproduce una sola vez por partida.
- `after:` — espera a que se haya reproducido el escenario indicado.
- `save_allowed: true` — el jugador puede guardar en esta escena.
Un escenario sin cabecera `scene:` no lo arranca ninguna escena: lo ejecuta otra cosa, como un clic sobre un objeto interactivo o un evento del mapa.
## Los demás mods añaden y reemplazan escenarios
Los escenarios de todos los mods se reúnen en un mismo conjunto. Un mod añade una historia a la escena de otro con solo nombrarla. Y un mod que depende de otro e incluye un escenario con el mismo nombre de archivo lo reemplaza.
## Si algo falla
1. **El escenario nunca se reproduce.** El nombre de `scene:` es incorrecto, la `condition` es falsa, o tiene `is_one_run` y ya se reprodujo.
2. **La línea aparece en otro idioma.** Falta la clave `key=` en los archivos `locale` del mod para ese idioma, y se muestra el texto entrecomillado.
3. **Una respuesta no lleva a ninguna parte.** La etiqueta tras `->` debe existir en el mismo archivo, escrita con dos puntos (`:bar`).
4. **Una `action` no hace nada.** El mod dueño de la acción no forma parte del juego (el registro dice que la acción "is not registered") o hay un nombre de parámetro equivocado: compáralo con la acción en el editor de escenarios de Editor Tools.
+99
View File
@@ -0,0 +1,99 @@
# Dialog Engine — ダイアログボックスとシナリオ言語
Dialog Engine は、プレイヤーが読んでクリックする部分すべてを担います。台詞・ポートレート・選択肢を表示するダイアログボックスであり、その背後には**シナリオ言語**があります。`.scenario` という小さなテキストファイルに物語を一行ずつ書いていく言語で、誰が何を言うか、どんな選択肢があり、それぞれがどこへ続くか、ゲーム内で何が起きるか(背景の切り替え、音楽、アイテム、クエストの進行、別シーンへの移動)をすべて記述します。コレクションに収録されたすべてのゲームが、小説テンプレートから RPG Plus まで、この言語で物語を語ります。
## プレイヤーに見えるもの
シーンに入ると、そのシーンのシナリオは自動的に始まります。台詞が画面下部のボックスに表示され、クリックで次の台詞に進みます。
![My Novel の町: 夕暮れの通り。ダイアログボックスには「夕暮れの町。街灯はすでにともり、石畳を涼しい風が抜けていく」という台詞が表示されています。](assets/town-text.jpg)
物語が何かを尋ねるときは、選択肢が右側に現れます。選ばれるまで物語は待ちます。
![ダイアログボックスに表示された質問「どこへ行く?」と、右側の二つの選択肢「バーに入る」「家に帰る」。](assets/town-choice.jpg)
「バーに入る」を選ぶと、プレイヤーはバーのシーンへ移動し、そこのシナリオが始まります。
![My Novel のバー。ダイアログボックスには最初の台詞が表示されています。](assets/bar-text.jpg)
## これらの画面の裏にあるシナリオ
小説テンプレートの町のシナリオ全文がこちらです(`my_novel_game/scenarios/my_novel_town.scenario`)。
```scenario
scene: my_novel_town
priority: 10
---
# 町はハブ:訪れるたびに同じ選択肢が表示される。
text "夕暮れの町。街灯はすでにともり、石畳を涼しい風が抜けていく。" key=my_novel/town/intro
select "どこへ行く?" key=my_novel/town/prompt
option "バーに入る" key=my_novel/town/opt_bar -> :bar
option "家に帰る" key=my_novel/town/opt_home -> :home
:bar
switch_scene my_novel_bar
end
:home
switch_scene my_novel_home
end
```
- `---` より上の**ヘッダー**は、シナリオがいつ再生されるかを決めます。`scene:` はシーン名、`priority:` は同じシーンの複数シナリオの順序(小さいほうが先)です。ヘッダー行はこのほかにもあります。
- `text "…"` は台詞を表示してクリックを待ちます。`key=` は台詞の翻訳キーで、引用したテキストは翻訳が見つからないときに表示されます。
- `select` は質問を出し、各 `option` は**ラベル**(`-> :bar`)へ飛ぶ選択肢です。
- `:bar` はラベル、`switch_scene` は別のシーンへの移動、`end` はシナリオの終了です。
- `#` で始まる行は作者向けのメモです。
シーンに物語を追加するには、`.scenario` ファイルを自分の mod の `scenarios/` フォルダーのどこかに置き、ヘッダーでシーン名を指定します。
## 物語を書く
**台詞とポートレート。** `text "…" first=marao/sad` は一つ目の位置にポートレートを表示し、`second=…` は二つ目に表示します(Characters Engine を参照)。`ptext` は `text` と同じ動作に加え、`${name}` を変数の値で置き換えます。`title "…"` は章タイトルを表示します。
**選択肢とジャンプ。** 条件が満たされたときだけ選択肢を表示できます。`option "Pay" "if=item('money/coin') >= 5" -> :pay`(空白を含む条件は `if=` ごと引用符で囲みます)。`goto -> :label` は尋ねずにジャンプします。`when "<condition>" -> :label` は、その時点で条件が満たされていればジャンプします。
```scenario
when "rpg_wolf_dead == 1" -> :lantern
```
**状態の記憶。** `flag rpg_quest_taken 1` は、ゲームのセーブと一緒に保存される物語フラグを立てます。条件では `rpg_quest_taken == 1` と読みます。`set name value` は実行中のダイアログの変数を設定します。`ptext` はこれを `${name}` として表示し、アクションからも読めます。
**条件**は共通の言語です。フラグ、Items Base の `item('id')`、Quests Engine のクエスト判定、各 mod のステータスや装備を、`&&`、`||`、`!` で組み合わせられます。
**シーンの演出。** `background`、`foreground`、`background_color`、`music`、`sound`、`effect`、`delay`、`light_set`、`hide_dialog`/`show_dialog`、`interactive_add`/`interactive_remove`、`notify`。
**ゲームアクション。** `action <name> key=value …` は他の mod のアクションを実行します。例は `action item.add id=money/coin count=3` や `action quest.set_status quest_id=woodland_ward status=Progress` です。アクションの一覧は各 mod のスキーマファイルにあり、Editor Tools のシナリオエディターとコマンドパレットからも入力できます。
## シナリオが再生されるタイミング
決めるのはヘッダーです。
```scenario
scene: prolog_alley
priority: 15
condition: AwakeningQuest.notHasTag("cloak_taken")
is_one_run: true
save_allowed: true
---
```
- `scene:` — プレイヤーが到着したときに再生されるシーン。
- `priority:` — 同じシーンのシナリオ同士の順序。再生できる最小の番号が先になり、ダイアログが終わると次が始まります。
- `condition:` — 条件が満たされている間だけ再生します。フラグやクエストが変わると、シーンは再度判定します。
- `is_one_run: true` — 1 回のゲームで一度だけ再生。
- `after:` — 指定したシナリオが再生されるのを待ちます。
- `save_allowed: true` — このシーンではセーブできます。
`scene:` ヘッダーのないシナリオはシーンからは始まりません。インタラクティブオブジェクトのクリックやマップイベントなど、別のきっかけで実行されます。
## 他の mod による追加と置き換え
すべての mod のシナリオは一つにまとめられます。mod は、シーン名を書くだけで他人のシーンに物語を追加できます。別の mod に依存し、同じファイル名のシナリオを同梱した mod は、それを置き換えます。
## うまく動かないとき
1. **シナリオが再生されない。** `scene:` の名前が違う、`condition` が偽、あるいは `is_one_run` 付きで既に再生済みです。
2. **台詞が別の言語で表示される。** その言語の翻訳キー `key=` が mod の `locale` ファイルになく、引用したテキストがそのまま表示されています。
3. **選択肢がどこにも行かない。** `->` の後のラベルは、同じファイル内にコロン付き(`:bar`)で存在しなければなりません。
4. **`action` が何もしない。** アクションを持つ mod がゲームに含まれていない(ログに "is not registered" と出ます)か、パラメーター名が間違っています。Editor Tools のシナリオエディターで該当アクションと見比べてください。
+99
View File
@@ -0,0 +1,99 @@
# Dialog Engine — 대화 상자와 시나리오 언어
Dialog Engine은 플레이어가 읽고 클릭하는 모든 것을 담당합니다. 대사·초상화·선택지가 표시되는 대화 상자이며, 그 뒤에는 **시나리오 언어**가 있습니다. `.scenario`라는 짧은 텍스트 파일에 이야기를 한 줄씩 적어 나가는 언어로, 누가 무엇을 말하는지, 어떤 선택지가 있는지, 각 선택지가 어디로 이어지는지, 게임 안에서 무슨 일이 일어나는지(배경 전환, 음악, 아이템, 퀘스트 진행, 다른 장면으로 이동)를 모두 적습니다. 컬렉션의 모든 게임이 소설 템플릿부터 RPG Plus까지 이 언어로 이야기를 전합니다.
## 플레이어가 보는 것
장면에 들어가면 그 장면의 시나리오가 저절로 시작됩니다. 대사는 화면 아래 상자에 나타나고, 클릭하면 다음 대사로 넘어갑니다.
![My Novel의 마을: 저녁 거리. 대화 상자에 "저녁 마을. 가로등이 이미 켜졌고, 돌바닥을 따라 차가운 바람이 불어 옵니다."라는 대사가 표시되어 있습니다.](assets/town-text.jpg)
이야기가 무언가를 물으면 선택지가 오른쪽에 나타나고, 플레이어가 고를 때까지 기다립니다.
![대화 상자의 질문 "어디로 갈까요?"와 오른쪽의 두 선택지 "술집에 들어가기", "집으로 돌아가기".](assets/town-choice.jpg)
"술집에 들어가기"를 고르면 플레이어는 술집 장면으로 이동하고, 그곳의 시나리오가 시작됩니다.
![My Novel의 술집. 대화 상자에는 첫 대사가 표시되어 있습니다.](assets/bar-text.jpg)
## 이 화면 뒤의 시나리오
소설 템플릿 마을 시나리오의 전문입니다(`my_novel_game/scenarios/my_novel_town.scenario`).
```scenario
scene: my_novel_town
priority: 10
---
# 마을은 허브:방문할 때마다 같은 선택지가 표시된다.
text "저녁 마을. 가로등은 이미 켜졌고, 돌바닥을 따라 차가운 바람이 불어 옵니다." key=my_novel/town/intro
select "어디로 갈까요?" key=my_novel/town/prompt
option "술집에 들어가기" key=my_novel/town/opt_bar -> :bar
option "집으로 돌아가기" key=my_novel/town/opt_home -> :home
:bar
switch_scene my_novel_bar
end
:home
switch_scene my_novel_home
end
```
- `---` 위의 **헤더**는 시나리오가 언제 재생되는지를 정합니다. `scene:`은 장면 이름, `priority:`는 같은 장면의 여러 시나리오 순서(작은 쪽이 먼저)입니다. 헤더 행은 아래에 더 있습니다.
- `text "…"`는 대사를 보여 주고 클릭을 기다립니다. `key=`는 대사의 번역 키이며, 따옴표 안의 텍스트는 번역이 없을 때 표시됩니다.
- `select`는 질문을 던지고, 각 `option`은 **레이블**( `-> :bar` )로 이동하는 선택지입니다.
- `:bar`는 레이블, `switch_scene`는 다른 장면으로 이동, `end`는 시나리오 종료입니다.
- `#`으로 시작하는 줄은 작성자를 위한 메모입니다.
장면에 이야기를 추가하려면 `.scenario` 파일을 자신의 mod `scenarios/` 폴더 어디에나 두고 헤더에 장면 이름을 적으면 됩니다.
## 이야기 쓰기
**대사와 초상화.** `text "…" first=marao/sad`는 첫 번째 자리에 초상화를, `second=…`는 두 번째 자리에 표시합니다(Characters Engine 참조). `ptext`는 `text`와 같으며 `${name}`을 변수 값으로 바꾸는 기능이 하나 더 있습니다. `title "…"`는 장 제목을 표시합니다.
**선택지와 점프.** 조건이 맞을 때만 선택지를 보이게 할 수 있습니다. `option "Pay" "if=item('money/coin') >= 5" -> :pay` (공백이 있는 조건은 `if=`까지 따옴표로 묶습니다). `goto -> :label`은 묻지 않고 건너뜁니다. `when "<condition>" -> :label`은 그 순간 조건이 맞으면 이동합니다.
```scenario
when "rpg_wolf_dead == 1" -> :lantern
```
**상태 기억.** `flag rpg_quest_taken 1`은 게임 저장에 함께 저장되는 스토리 플래그를 설정합니다. 조건에서는 `rpg_quest_taken == 1`로 읽습니다. `set name value`는 실행 중인 대화의 변수를 정합니다. `ptext`는 이를 `${name}`으로 표시하고 액션에서도 읽을 수 있습니다.
**조건**은 하나의 공용 언어입니다. 플래그, Items Base의 `item('id')`, Quests Engine의 퀘스트 확인, 각 mod의 스탯과 장비를 `&&`, `||`, `!`로 조합합니다.
**장면 연출.** `background`, `foreground`, `background_color`, `music`, `sound`, `effect`, `delay`, `light_set`, `hide_dialog`/`show_dialog`, `interactive_add`/`interactive_remove`, `notify`.
**게임 액션.** `action <name> key=value …`는 다른 mod의 액션을 실행합니다. 예를 들어 `action item.add id=money/coin count=3`, `action quest.set_status quest_id=woodland_ward status=Progress`입니다. 액션 목록은 각 mod의 스키마 파일에 있으며, Editor Tools의 시나리오 편집기와 커맨드 팔레트에서 바로 쓸 수 있습니다.
## 시나리오가 재생되는 시점
헤더가 결정합니다.
```scenario
scene: prolog_alley
priority: 15
condition: AwakeningQuest.notHasTag("cloak_taken")
is_one_run: true
save_allowed: true
---
```
- `scene:` — 플레이어가 도착했을 때 시나리오가 재생되는 장면.
- `priority:` — 같은 장면 시나리오끼리의 순서. 재생 가능한 가장 작은 번호가 먼저이며, 대화가 끝나면 다음이 시작됩니다.
- `condition:` — 조건이 유지되는 동안에만 재생. 플래그나 퀘스트가 바뀌면 장면이 다시 확인합니다.
- `is_one_run: true` — 게임당 한 번만 재생.
- `after:` — 지정한 시나리오가 재생될 때까지 기다립니다.
- `save_allowed: true` — 이 장면에서 저장할 수 있습니다.
`scene:` 헤더가 없는 시나리오는 장면이 시작하지 않습니다. 인터랙티브 오브젝트 클릭이나 맵 이벤트 같은 다른 계기가 실행합니다.
## 다른 mod의 추가와 교체
모든 mod의 시나리오는 한데 모입니다. mod는 장면 이름만 적으면 다른 mod의 장면에 이야기를 추가할 수 있습니다. 다른 mod에 의존하면서 같은 파일 이름의 시나리오를 포함한 mod는 그 시나리오를 교체합니다.
## 문제가 생겼을 때
1. **시나리오가 재생되지 않습니다.** `scene:` 이름이 틀렸거나, `condition`이 거짓이거나, `is_one_run`이 붙어 있어 이미 재생된 경우입니다.
2. **대사가 다른 언어로 표시됩니다.** 해당 언어의 번역 키 `key=`가 mod의 `locale` 파일에 없어서 따옴표 안의 텍스트가 그대로 보입니다.
3. **선택지가 어디로도 이어지지 않습니다.** `->` 뒤의 레이블은 같은 파일 안에 콜론을 붙여(`:bar`) 존재해야 합니다.
4. **`action`이 아무것도 하지 않습니다.** 액션을 소유한 mod가 게임에 없거나(로그에 "is not registered"라고 기록됨), 매개변수 이름이 틀렸습니다. Editor Tools의 시나리오 편집기에서 해당 액션과 비교해 보세요.
+103
View File
@@ -0,0 +1,103 @@
# Dialog Engine — окно диалога и язык сценариев
Dialog Engine — это то, что игрок читает и по чему щёлкает: окно диалога с репликами, портретами и вариантами ответа. За ним стоит **язык сценариев** — небольшие текстовые файлы `.scenario`, в которых история записана строка за строкой: кто что говорит, какие есть варианты ответа, куда ведёт каждый из них и что при этом происходит в игре (новый фон, музыка, предмет, шаг задания, другая сцена). С его помощью рассказывает свою история каждая игра коллекции — от шаблона новеллы до RPG Plus.
## Что видит игрок
Сценарий сцены запускается сам, как только игрок входит в неё. Реплики появляются в окне внизу экрана; щелчок показывает следующую:
![Город из My Novel: вечерняя улица, в окне диалога — реплика «Вечерний город. Фонари уже горят, по мостовой тянет прохладой».](assets/town-text.jpg)
Когда история о чём-то спрашивает, справа появляются варианты ответа — и она ждёт, пока игрок выберет один из них:
![Вопрос «Куда пойти?» в окне диалога и два варианта справа: «Зайти в бар» и «Вернуться домой».](assets/town-choice.jpg)
Вариант «Зайти в бар» переносит игрока в сцену бара, где запускается уже его собственный сценарий:
![Бар из My Novel и его первая реплика в окне диалога.](assets/bar-text.jpg)
## Сценарий этих кадров
Вот весь сценарий города из шаблона новеллы (`my_novel_game/scenarios/my_novel_town.scenario`):
```scenario
scene: my_novel_town
priority: 10
---
# Город — хаб: при каждом визите игрок видит один и тот же выбор.
text "Вечерний город. Фонари уже горят, по мостовой тянет прохладой." key=my_novel/town/intro
select "Куда пойти?" key=my_novel/town/prompt
option "Зайти в бар" key=my_novel/town/opt_bar -> :bar
option "Вернуться домой" key=my_novel/town/opt_home -> :home
:bar
switch_scene my_novel_bar
end
:home
switch_scene my_novel_home
end
```
- **Заголовок** над `---` говорит, когда сценарий играет: `scene:` называет сцену, `priority:` упорядочивает несколько сценариев одной сцены (с меньшим числом играет первым). Ниже встречаются и другие строки заголовка.
- `text "…"` показывает реплику и ждёт щелчка. `key=` — ключ перевода реплики; текст в кавычках показывается, если перевода не нашлось.
- `select` задаёт вопрос, и каждый `option` — вариант ответа, который ведёт к **метке** (`-> :bar`).
- `:bar` — метка; `switch_scene` уходит в другую сцену; `end` завершает сценарий.
- Строка, начинающаяся с `#`, — заметка для автора.
Чтобы добавить историю в сцену, положите файл `.scenario` в любое место папки `scenarios/` своего мода и назовите сцену в заголовке.
## Как писать историю
**Реплики и портреты.** `text "…" first=marao/sad` показывает портрет на первом месте, `second=…` — на втором (см. Characters Engine). `ptext` делает то же, что `text`, и вдобавок подставляет вместо `${name}` значение переменной. `title "…"` показывает заголовок главы.
**Выбор и переходы.** Вариант ответа можно показывать только при выполнении условия: `option "Pay" "if=item('money/coin') >= 5" -> :pay` (условие с пробелами берётся в кавычки вместе с `if=`). `goto -> :label` перескакивает без вопросов. `when "<condition>" -> :label` выполняет переход, если условие выполнено в этот момент:
```scenario
when "rpg_wolf_dead == 1" -> :lantern
```
**Запоминание.** `flag rpg_quest_taken 1` ставит сюжетный флаг, который сохраняется вместе с игрой; условия читают его как `rpg_quest_taken == 1`. `set name value` задаёт переменную текущего диалога: `ptext` покажет её как `${name}`, а действия смогут её прочитать.
**Условия** — один общий язык: флаги, `item('id')` из Items Base, проверки заданий из Quests Engine, характеристики и экипировка из своих модов, соединённые через `&&`, `||` и `!`.
**Сцена.** `background`, `foreground`, `background_color`, `music`, `sound`, `effect`, `delay`, `light_set`, `hide_dialog`/`show_dialog`, `interactive_add`/`interactive_remove`, `notify`.
**Игровые действия.** `action <name> key=value …` запускает действие другого мода, например `action item.add id=money/coin count=3` или `action quest.set_status quest_id=woodland_ward status=Progress`. Все действия перечислены в schema-файлах модов, а Editor Tools предлагает их в редакторе сценариев и палитре команд.
## Когда сценарий запускается
Решает заголовок:
```scenario
scene: prolog_alley
priority: 15
condition: AwakeningQuest.notHasTag("cloak_taken")
is_one_run: true
save_allowed: true
---
```
- `scene:` — сцена, в которой сценарий играет, когда игрок приходит.
- `priority:` — порядок среди сценариев одной сцены; первым играет тот, у кого наименьшее допустимое число, а следующий стартует, когда диалог закончится.
- `condition:` — сценарий играет, только пока условие выполнено; когда флаг или задание меняется, сцена проверяет снова.
- `is_one_run: true` — играется один раз за игру.
- `after:` — ждёт, пока названный сценарий не будет пройден.
- `save_allowed: true` — в этой сцене можно сохраняться.
Сценарий без строки `scene:` не запускается сценой — его запускает что-то другое: например, щелчок по интерактивному объекту или событие на карте.
## Чужие моды добавляют и заменяют сценарии
Сценарии всех модов собираются в общий набор. Добавить историю в чужую сцену просто: назовите эту сцену в заголовке. А мод, который зависит от владельца и кладёт в себя сценарий с тем же именем файла, заменяет его.
## Если что-то пошло не так
1. **Сценарий никогда не играет.** Ошибочное имя в `scene:`, ложное условие в `condition` — или у сценария есть `is_one_run`, и он уже играл.
2. **Реплика показывается не на том языке.** Ключа `key=` нет в файлах `locale` мода для этого языка, и вместо перевода показывается текст в кавычках.
3. **Ответ ведёт в никуда.** Метка после `->` должна существовать в том же файле и записываться с двоеточием (`:bar`).
4. **`action` ничего не делает.** Мод — владелец действия не входит в игру (в логе написано, что действие "is not registered"), или имя параметра неверное — сверьте его с действием в редакторе сценариев Editor Tools.
## Общие выражения
Вычисления предоставляет `expressions_base`. `set money=rnd(3,5)` выбирает целое число; `cset total "1+2+${money}"` вычисляет выражение. `${money}` используется в локализованном тексте и параметрах действий. `set имя значение` сохраняет строку как раньше. Синтаксис и расширение стратегиями описаны в [руководстве движка выражений](../../expressions_base/docs/ru.md).
+99
View File
@@ -0,0 +1,99 @@
# Dialog Engine — hộp hội thoại và ngôn ngữ kịch bản
Dialog Engine là mọi thứ người chơi đọc và nhấp vào: hộp hội thoại với các câu thoại, chân dung và phương án trả lời. Đằng sau đó là **ngôn ngữ kịch bản** — những tệp văn bản `.scenario` nhỏ, nơi câu chuyện được viết từng dòng: ai nói gì, có những lựa chọn nào, mỗi lựa chọn dẫn đi đâu, và chuyện gì xảy ra trong game (đổi nền, bật nhạc, thêm vật phẩm, tiến nhiệm vụ, sang cảnh khác). Mọi trò chơi trong bộ sưu tập, từ mẫu truyện đến RPG Plus, đều kể câu chuyện của mình bằng ngôn ngữ này.
## Những gì người chơi nhìn thấy
Kịch bản của một cảnh tự bắt đầu khi người chơi bước vào. Các câu thoại hiện trong hộp ở đáy màn hình; mỗi cú nhấp hiện câu tiếp theo.
![Thị trấn của My Novel: con phố buổi chiều, trong hộp hội thoại là câu "Buổi chiều ở thị trấn. Đèn đường đã sáng, một cơn gió mát thổi qua đường lát đá".](assets/town-text.jpg)
Khi câu chuyện hỏi điều gì đó, các lựa chọn hiện ra bên phải, và câu chuyện chờ cho đến khi người chơi chọn xong.
![Câu hỏi "Đi đâu đây?" trong hộp hội thoại cùng hai lựa chọn bên phải: "Ghé vào quán bar" và "Về nhà".](assets/town-choice.jpg)
"Ghé vào quán bar" đưa người chơi sang cảnh quán bar, nơi bắt đầu kịch bản riêng của nó.
![Quán bar của My Novel với câu thoại đầu tiên trong hộp hội thoại.](assets/bar-text.jpg)
## Kịch bản đứng sau những hình này
Đây là toàn bộ kịch bản thị trấn của mẫu truyện (`my_novel_game/scenarios/my_novel_town.scenario`).
```scenario
scene: my_novel_town
priority: 10
---
# Thị trấn là điểm tụ: lần nào ghé thăm cũng thấy cùng một lựa chọn.
text "Buổi chiều ở thị trấn. Đèn đường đã sáng, một cơn gió mát thổi qua đường lát đá." key=my_novel/town/intro
select "Đi đâu đây?" key=my_novel/town/prompt
option "Ghé vào quán bar" key=my_novel/town/opt_bar -> :bar
option "Về nhà" key=my_novel/town/opt_home -> :home
:bar
switch_scene my_novel_bar
end
:home
switch_scene my_novel_home
end
```
- **Phần đầu** phía trên `---` cho biết kịch bản chạy khi nào: `scene:` nêu tên cảnh, `priority:` sắp thứ tự cho nhiều kịch bản của cùng một cảnh (số nhỏ chạy trước). Bên dưới còn vài dòng đầu khác sẽ được giới thiệu.
- `text "…"` hiện một câu thoại và chờ cú nhấp. `key=` là khóa dịch của câu thoại; văn bản trong ngoặc kép được hiển thị khi không tìm thấy bản dịch.
- `select` đặt một câu hỏi, mỗi `option` là một lựa chọn nhảy tới một **nhãn** (`-> :bar`).
- `:bar` là nhãn; `switch_scene` chuyển sang cảnh khác; `end` kết thúc kịch bản.
- Dòng bắt đầu bằng `#` là ghi chú dành cho tác giả.
Muốn thêm câu chuyện vào một cảnh, bạn chỉ cần đặt tệp `.scenario` vào bất kỳ đâu trong thư mục `scenarios/` của mod và nêu tên cảnh trong phần đầu.
## Viết câu chuyện
**Câu thoại và chân dung.** `text "…" first=marao/sad` hiện chân dung ở vị trí thứ nhất, `second=…` ở vị trí thứ hai (xem Characters Engine). `ptext` giống `text` và thay thêm `${name}` bằng giá trị của biến. `title "…"` hiện tiêu đề chương.
**Lựa chọn và bước nhảy.** Có thể chỉ hiện một lựa chọn khi điều kiện thỏa mãn: `option "Pay" "if=item('money/coin') >= 5" -> :pay` (điều kiện có dấu cách thì bọc cả `if=` trong ngoặc kép). `goto -> :label` nhảy luôn không hỏi. `when "<condition>" -> :label` nhảy nếu điều kiện thỏa tại thời điểm đó.
```scenario
when "rpg_wolf_dead == 1" -> :lantern
```
**Ghi nhớ thông tin.** `flag rpg_quest_taken 1` đặt một cờ cốt truyện được lưu cùng game; điều kiện đọc nó dưới dạng `rpg_quest_taken == 1`. `set name value` đặt biến của phiên hội thoại đang chạy: `ptext` hiển thị nó dưới dạng `${name}` và các hành động cũng đọc được.
**Điều kiện** là một ngôn ngữ chung: cờ, `item('id')` từ Items Base, phép kiểm tra nhiệm vụ từ Quests Engine, chỉ số và trang bị từ mod tương ứng, nối với nhau bằng `&&`, `||` và `!`.
**Tổ chức cảnh.** `background`, `foreground`, `background_color`, `music`, `sound`, `effect`, `delay`, `light_set`, `hide_dialog`/`show_dialog`, `interactive_add`/`interactive_remove`, `notify`.
**Hành động của game.** `action <name> key=value …` chạy một hành động của mod khác, ví dụ `action item.add id=money/coin count=3` hay `action quest.set_status quest_id=woodland_ward status=Progress`. Các tệp schema của mod liệt kê hành động, còn Editor Tools đưa chúng vào trình soạn thảo kịch bản và bảng lệnh.
## Khi nào kịch bản chạy
Phần đầu quyết định điều đó.
```scenario
scene: prolog_alley
priority: 15
condition: AwakeningQuest.notHasTag("cloak_taken")
is_one_run: true
save_allowed: true
---
```
- `scene:` — cảnh mà kịch bản chạy khi người chơi tới.
- `priority:` — thứ tự giữa các kịch bản của cùng một cảnh; số nhỏ nhất được phép chạy sẽ chạy trước, kịch bản kế tiếp bắt đầu khi hội thoại kết thúc.
- `condition:` — chỉ chạy khi điều kiện còn thỏa mãn; khi cờ hoặc nhiệm vụ đổi, cảnh sẽ kiểm tra lại.
- `is_one_run: true` — chỉ chạy một lần mỗi ván chơi.
- `after:` — chờ cho đến khi kịch bản được nêu đã chạy xong.
- `save_allowed: true` — người chơi được lưu trong cảnh này.
Kịch bản không có dòng `scene:` thì không do cảnh khởi chạy; nó do thứ khác chạy, chẳng hạn cú nhấp vào vật thể tương tác hay sự kiện bản đồ.
## Mod khác thêm và thay thế kịch bản
Kịch bản của mọi mod được gom chung với nhau. Một mod thêm câu chuyện vào cảnh của mod khác đơn giản chỉ bằng cách nêu tên cảnh đó. Mod phụ thuộc vào mod khác và kèm theo một tệp kịch bản trùng tên sẽ thay thế tệp ấy.
## Khi có trục trặc
1. **Kịch bản không bao giờ chạy.** Tên trong `scene:` sai, `condition` sai, hoặc kịch bản có `is_one_run` và đã chạy rồi.
2. **Câu thoại hiện sai ngôn ngữ.** Thiếu khóa `key=` trong các tệp `locale` của mod cho ngôn ngữ đó; văn bản trong ngoặc kép được hiển thị thay thế.
3. **Lựa chọn dẫn đến hư không.** Nhãn sau `->` phải tồn tại trong cùng tệp, viết kèm dấu hai chấm (`:bar`).
4. **`action` không làm gì cả.** Mod sở hữu hành động không có trong game (log ghi hành động "is not registered"), hoặc tên tham số sai — hãy so với hành động trong trình soạn thảo kịch bản của Editor Tools.
+99
View File
@@ -0,0 +1,99 @@
# Dialog Engine — 对话框与剧本语言
Dialog Engine 负责玩家阅读和点击的一切:带台词、肖像和选项的对话框。它背后是**剧本语言**——一些简短的 `.scenario` 文本文件,故事在其中一行一行地写下来:谁说了什么、有哪些选项、每个选项通向哪里、游戏里会发生什么(换背景、放音乐、给物品、推进任务、切换场景)。合集里的每一款游戏,从小说模板到 RPG Plus,都用它讲述自己的故事。
## 玩家看到什么
进入场景时,场景的剧本会自动开始。台词出现在屏幕下方的对话框里,点击一下就显示下一句:
![My Novel 的小镇:傍晚的街道,对话框中显示台词"傍晚的小镇。路灯已经亮起,石板路上吹过一阵凉风。"。](assets/town-text.jpg)
当故事向玩家提问时,选项会出现在右侧,并等待玩家做出选择:
![对话框中的问题"接下来去哪儿?"以及右侧的两个选项:"去酒吧"和"回家"。](assets/town-choice.jpg)
选择"去酒吧"后,玩家来到酒吧场景,那里开始播放自己的剧本:
![My Novel 的酒吧,对话框中是它的第一句台词。](assets/bar-text.jpg)
## 这些截图背后的剧本
这就是小说模板小镇场景的完整剧本(`my_novel_game/scenarios/my_novel_town.scenario`):
```scenario
scene: my_novel_town
priority: 10
---
# 小镇是枢纽:每次到访都显示同样的选择。
text "傍晚的小镇。路灯已经亮起,石板路上吹过一阵凉风。" key=my_novel/town/intro
select "接下来去哪儿?" key=my_novel/town/prompt
option "去酒吧" key=my_novel/town/opt_bar -> :bar
option "回家" key=my_novel/town/opt_home -> :home
:bar
switch_scene my_novel_bar
end
:home
switch_scene my_novel_home
end
```
- `---` 之上的**头部**说明剧本何时播放:`scene:` 指明场景,`priority:` 排定同一场景多个剧本的先后(数字小的先播)。后文还会介绍其他头部行。
- `text "…"` 显示一句台词并等待点击。`key=` 是这句台词的翻译键;引号里的文本在找不到翻译时显示。
- `select` 提出一个问题,每个 `option` 都是一个选项,跳转到某个**标签**(`-> :bar`)。
- `:bar` 是标签;`switch_scene` 切换到另一场景;`end` 结束剧本。
- 以 `#` 开头的行是给作者看的注释。
要给场景添加故事,只需把 `.scenario` 文件放进自己 mod 的 `scenarios/` 文件夹(位置不限),并在头部写明场景名。
## 编写故事
**台词与肖像。** `text "…" first=marao/sad` 在第一个位置显示肖像,`second=…` 在第二个位置(参见 Characters Engine)。`ptext` 与 `text` 相同,还会把 `${name}` 替换为变量的值。`title "…"` 显示章节标题。
**选项与跳转。** 选项可以只在条件成立时显示:`option "Pay" "if=item('money/coin') >= 5" -> :pay`(条件里有空格时,连 `if=` 一起加引号)。`goto -> :label` 不作询问直接跳转。`when "<condition>" -> :label` 在条件此刻成立时跳转:
```scenario
when "rpg_wolf_dead == 1" -> :lantern
```
**记住状态。** `flag rpg_quest_taken 1` 设置一个随游戏存档保存的剧情标记;条件写作 `rpg_quest_taken == 1` 即可读取。`set name value` 设置当前对话的变量:`ptext` 把它显示为 `${name}`,动作也能读取它。
**条件**是一门统一的语言:标记、来自 Items Base 的 `item('id')`、来自 Quests Engine 的任务检查、各 mod 自带的属性与装备,用 `&&`、`||` 和 `!` 组合起来。
**场景演出。** `background`、`foreground`、`background_color`、`music`、`sound`、`effect`、`delay`、`light_set`、`hide_dialog`/`show_dialog`、`interactive_add`/`interactive_remove`、`notify`。
**游戏动作。** `action <name> key=value …` 执行另一个 mod 的动作,例如 `action item.add id=money/coin count=3` 或 `action quest.set_status quest_id=woodland_ward status=Progress`。各 mod 的 schema 文件列出了这些动作,Editor Tools 会在剧本编辑器和命令面板中提供它们。
## 剧本何时播放
这由头部决定:
```scenario
scene: prolog_alley
priority: 15
condition: AwakeningQuest.notHasTag("cloak_taken")
is_one_run: true
save_allowed: true
---
```
- `scene:` — 玩家到达时播放剧本的场景。
- `priority:` — 同一场景中各剧本的先后顺序;可播放的编号最小者先播,对话结束后下一个才开始。
- `condition:` — 仅在条件成立时播放;标记或任务发生变化时,场景会重新检查。
- `is_one_run: true` — 每局游戏只播放一次。
- `after:` — 等待指定剧本播放完毕。
- `save_allowed: true` — 玩家可以在该场景存档。
没有 `scene:` 头部的剧本不会由场景启动,而是由其他东西触发,比如点击场景中的交互物或地图事件。
## 其他 mod 的添加与替换
所有 mod 的剧本汇总在一起。mod 只要在头部写明场景名,就能把自己的故事加进别人的场景。声明了依赖并附带同名剧本文件的 mod 会替换原剧本。
## 出问题时
1. **剧本从不播放。** `scene:` 的场景名写错了、`condition` 为假,或者剧本带 `is_one_run` 且已经播放过。
2. **台词显示成了别的语言。** 该语言的翻译键 `key=` 在 mod 的 `locale` 文件里缺失,于是显示引号里的原文。
3. **选项跳了个空。** `->` 后面的标签必须存在于同一文件中,并带冒号(`:bar`)。
4. **`action` 没有反应。** 动作所属的 mod 不在游戏内(日志提示动作 "is not registered"),或者参数名写错——与 Editor Tools 剧本编辑器中的动作对照一下。
+1 -1
View File
@@ -1,2 +1,2 @@
mod_dialog_base_name = Dialog Engine mod_dialog_base_name = Dialog Engine
mod_dialog_base_desc = Engine dialog subsystem: DialogBox/DialogCanvas/AnswerBox UI, the dialog queue with the full DSL action set, the .scenario parser, executable event chains and the /root/DialogRunner for interactive dialogs. mod_dialog_base_desc = Dialog engine: the dialog box with portraits and answers, and the .scenario language in which every game of the collection tells its story.
+2
View File
@@ -0,0 +1,2 @@
mod_dialog_base_name = Motor de diálogos
mod_dialog_base_desc = Motor de diálogos: la caja de diálogo con retratos y respuestas, y el lenguaje .scenario con el que cada juego de la colección cuenta su historia.
+2
View File
@@ -0,0 +1,2 @@
mod_dialog_base_name = ダイアログエンジン
mod_dialog_base_desc = ダイアログエンジン:ポートレートと選択肢を備えたダイアログボックス、そしてコレクションのすべてのゲームが物語を語る .scenario 言語。
+2
View File
@@ -0,0 +1,2 @@
mod_dialog_base_name = 대화 엔진
mod_dialog_base_desc = 대화 엔진: 초상화와 선택지가 있는 대화 상자, 그리고 컬렉션의 모든 게임이 이야기를 전하게 하는 .scenario 언어.
+1 -1
View File
@@ -1,2 +1,2 @@
mod_dialog_base_name = Движок диалогов mod_dialog_base_name = Движок диалогов
mod_dialog_base_desc = Подсистема диалогов движка: UI DialogBox/DialogCanvas/AnswerBox, очередь диалогов с полным набором DSL-действий, парсер .scenario, цепочки событий и /root/DialogRunner для интерактивных диалогов. mod_dialog_base_desc = Движок диалогов: окно диалога с портретами и вариантами ответов и язык .scenario, на котором рассказывает свою история каждая игра коллекции.
+35
View File
@@ -0,0 +1,35 @@
dialog/schema/builtin/text = Line
dialog/schema/builtin/text_desc = A dialog line; the locale key is optional
dialog/schema/field/text = Text
dialog/schema/field/locale_key = Locale key
dialog/schema/builtin/ptext = Narration
dialog/schema/builtin/ptext_desc = The narrator text, fills ${var}
dialog/schema/builtin/flag = Story flag
dialog/schema/builtin/flag_desc = A saved mark that conditions react to
dialog/schema/field/flag = Flag
dialog/schema/field/value = Value
dialog/schema/builtin/switch_scene = Switch scene
dialog/schema/builtin/switch_scene_desc = A novel scene or a map — a map is a scene too
dialog/schema/field/scene = Scene
dialog/schema/builtin/goto = Go to a label
dialog/schema/builtin/goto_desc = An unconditional jump
dialog/schema/field/label = Label
dialog/schema/builtin/end = End the dialog
dialog/schema/builtin/end_desc = Closes the dialog box
dialog/schema/checks/when = When — jump
dialog/schema/checks/when_desc = A conditional jump on the shared condition language: flags, quests, stats, gear
dialog/schema/checks/arrow_true = When true, go to
dialog/schema/field/condition = Condition
dialog/schema/field/else = Else label
dialog/schema/builtin/select = Choice
dialog/schema/builtin/select_desc = The question before the answers; option lines follow
dialog/schema/builtin/option = Answer
dialog/schema/builtin/option_desc = An answer and the label it leads to; with a condition it is shown only while it holds
dialog/schema/field/option_if = Show if
dialog/schema/builtin/option_if_desc = For example archetype(rogue) or "stat(cha) >= 5"; empty — always
dialog/schema/builtin/set = Set variable
dialog/schema/builtin/set_desc = Store a literal string (name value); name=expression calculates a numeric value.
dialog/schema/builtin/cset = Calculate variable
dialog/schema/builtin/cset_desc = Calculate a numeric expression at execution; also accepts name=expression.
dialog/schema/field/variable = Variable
dialog/schema/field/expression = Numeric expression
+35
View File
@@ -0,0 +1,35 @@
dialog/schema/builtin/text = Réplica
dialog/schema/builtin/text_desc = Una línea de diálogo; la clave de traducción es opcional
dialog/schema/field/text = Texto
dialog/schema/field/locale_key = Clave de traducción
dialog/schema/builtin/ptext = Narración
dialog/schema/builtin/ptext_desc = El texto del narrador, rellena ${var}
dialog/schema/builtin/flag = Marca de historia
dialog/schema/builtin/flag_desc = Una marca guardada a la que reaccionan las condiciones
dialog/schema/field/flag = Marca
dialog/schema/field/value = Valor
dialog/schema/builtin/switch_scene = Cambiar de escena
dialog/schema/builtin/switch_scene_desc = Una escena de novela o un mapa: un mapa también es una escena
dialog/schema/field/scene = Escena
dialog/schema/builtin/goto = Ir a una etiqueta
dialog/schema/builtin/goto_desc = Un salto incondicional
dialog/schema/field/label = Etiqueta
dialog/schema/builtin/end = Fin del diálogo
dialog/schema/builtin/end_desc = Cierra el cuadro de diálogo
dialog/schema/checks/when = Si — saltar
dialog/schema/checks/when_desc = Un salto condicional según el lenguaje común de condiciones: marcas, misiones, estadísticas, equipo
dialog/schema/checks/arrow_true = Si es cierto, ir a
dialog/schema/field/condition = Condición
dialog/schema/field/else = Etiqueta si no
dialog/schema/builtin/select = Elección
dialog/schema/builtin/select_desc = La pregunta antes de las respuestas; siguen las líneas option
dialog/schema/builtin/option = Respuesta
dialog/schema/builtin/option_desc = Una respuesta y la etiqueta a la que lleva; con condición solo se muestra mientras se cumple
dialog/schema/field/option_if = Mostrar si
dialog/schema/builtin/option_if_desc = Por ejemplo archetype(rogue) o "stat(cha) >= 5"; vacío: siempre
dialog/schema/builtin/set = Asignar variable
dialog/schema/builtin/set_desc = Guarda texto (nombre valor); nombre=expresión calcula un número.
dialog/schema/builtin/cset = Calcular variable
dialog/schema/builtin/cset_desc = Calcula una expresión numérica al ejecutar; admite nombre=expresión.
dialog/schema/field/variable = Variable
dialog/schema/field/expression = Expresión numérica
+35
View File
@@ -0,0 +1,35 @@
dialog/schema/builtin/text = セリフ
dialog/schema/builtin/text_desc = 会話の一行。翻訳キーは任意
dialog/schema/field/text = テキスト
dialog/schema/field/locale_key = 翻訳キー
dialog/schema/builtin/ptext = 地の文
dialog/schema/builtin/ptext_desc = 語りの文。${var} を埋め込む
dialog/schema/builtin/flag = ストーリーフラグ
dialog/schema/builtin/flag_desc = 保存される印。条件がそれに反応する
dialog/schema/field/flag = フラグ
dialog/schema/field/value = 値
dialog/schema/builtin/switch_scene = シーン切り替え
dialog/schema/builtin/switch_scene_desc = ノベルのシーンかマップ——マップもシーン
dialog/schema/field/scene = シーン
dialog/schema/builtin/goto = ラベルへ移動
dialog/schema/builtin/goto_desc = 無条件のジャンプ
dialog/schema/field/label = ラベル
dialog/schema/builtin/end = 会話の終わり
dialog/schema/builtin/end_desc = 会話ウィンドウを閉じる
dialog/schema/checks/when = もし——ジャンプ
dialog/schema/checks/when_desc = 共通の条件言語による条件ジャンプ:フラグ、クエスト、ステータス、装備
dialog/schema/checks/arrow_true = 真のときのラベル
dialog/schema/field/condition = 条件
dialog/schema/field/else = それ以外のラベル
dialog/schema/builtin/select = 選択肢
dialog/schema/builtin/select_desc = 選択肢の前の問い。続けて option 行
dialog/schema/builtin/option = 選択肢
dialog/schema/builtin/option_desc = 答えと行き先のラベル。条件付きなら成り立つ間だけ表示
dialog/schema/field/option_if = 表示条件
dialog/schema/builtin/option_if_desc = 例:archetype(rogue) や "stat(cha) >= 5"。空なら常に
dialog/schema/builtin/set = 変数を設定
dialog/schema/builtin/set_desc = 文字列を保存(名前 値)。名前=式 は数値を計算します。
dialog/schema/builtin/cset = 変数を計算
dialog/schema/builtin/cset_desc = 実行時に数式を計算。名前=式 も使用できます。
dialog/schema/field/variable = 変数
dialog/schema/field/expression = 数式
+35
View File
@@ -0,0 +1,35 @@
dialog/schema/builtin/text = 대사
dialog/schema/builtin/text_desc = 대화 한 줄. 번역 키는 선택
dialog/schema/field/text = 텍스트
dialog/schema/field/locale_key = 번역 키
dialog/schema/builtin/ptext = 서술
dialog/schema/builtin/ptext_desc = 서술자 텍스트. ${var}를 채움
dialog/schema/builtin/flag = 스토리 플래그
dialog/schema/builtin/flag_desc = 저장되는 표시. 조건이 반응합니다
dialog/schema/field/flag = 플래그
dialog/schema/field/value = 값
dialog/schema/builtin/switch_scene = 장면 전환
dialog/schema/builtin/switch_scene_desc = 노벨 장면이나 맵 — 맵도 장면입니다
dialog/schema/field/scene = 장면
dialog/schema/builtin/goto = 라벨로 이동
dialog/schema/builtin/goto_desc = 무조건 이동
dialog/schema/field/label = 라벨
dialog/schema/builtin/end = 대화 끝
dialog/schema/builtin/end_desc = 대화창을 닫음
dialog/schema/checks/when = 조건 — 이동
dialog/schema/checks/when_desc = 공통 조건 언어로 하는 조건 이동: 플래그, 퀘스트, 능력치, 장비
dialog/schema/checks/arrow_true = 참이면 이동
dialog/schema/field/condition = 조건
dialog/schema/field/else = 아니면 라벨
dialog/schema/builtin/select = 선택
dialog/schema/builtin/select_desc = 답 앞의 질문. 뒤에 option 줄이 옵니다
dialog/schema/builtin/option = 선택지
dialog/schema/builtin/option_desc = 답과 이어지는 라벨. 조건이 있으면 참일 때만 보입니다
dialog/schema/field/option_if = 표시 조건
dialog/schema/builtin/option_if_desc = 예: archetype(rogue) 또는 "stat(cha) >= 5". 비우면 항상
dialog/schema/builtin/set = 변수 설정
dialog/schema/builtin/set_desc = 문자열 저장(이름 값). 이름=수식은 숫자를 계산합니다.
dialog/schema/builtin/cset = 변수 계산
dialog/schema/builtin/cset_desc = 실행 시 수식을 계산합니다. 이름=수식도 지원합니다.
dialog/schema/field/variable = 변수
dialog/schema/field/expression = 수식
+35
View File
@@ -0,0 +1,35 @@
dialog/schema/builtin/text = Реплика
dialog/schema/builtin/text_desc = Строка диалога; ключ перевода — по желанию
dialog/schema/field/text = Текст
dialog/schema/field/locale_key = Ключ перевода
dialog/schema/builtin/ptext = Повествование
dialog/schema/builtin/ptext_desc = Текст рассказчика, подставляет ${var}
dialog/schema/builtin/flag = Флаг сюжета
dialog/schema/builtin/flag_desc = Сохраняемая отметка, по ней срабатывают условия
dialog/schema/field/flag = Флаг
dialog/schema/field/value = Значение
dialog/schema/builtin/switch_scene = Сменить сцену
dialog/schema/builtin/switch_scene_desc = Сцена новеллы или карта — карта тоже сцена
dialog/schema/field/scene = Сцена
dialog/schema/builtin/goto = Перейти к метке
dialog/schema/builtin/goto_desc = Безусловный переход
dialog/schema/field/label = Метка
dialog/schema/builtin/end = Конец диалога
dialog/schema/builtin/end_desc = Закрывает окно диалога
dialog/schema/checks/when = Если — переход
dialog/schema/checks/when_desc = Условный переход по общему языку условий: флаги, квесты, статы, снаряжение
dialog/schema/checks/arrow_true = Если верно — метка
dialog/schema/field/condition = Условие
dialog/schema/field/else = Иначе — метка
dialog/schema/builtin/select = Выбор
dialog/schema/builtin/select_desc = Вопрос перед вариантами ответа; дальше идут строки option
dialog/schema/builtin/option = Вариант ответа
dialog/schema/builtin/option_desc = Вариант выбора и метка, куда он ведёт; с условием — показывается, только пока оно верно
dialog/schema/field/option_if = Показывать, если
dialog/schema/builtin/option_if_desc = Например archetype(rogue) или "stat(cha) >= 5"; пусто — всегда
dialog/schema/builtin/set = Задать переменную
dialog/schema/builtin/set_desc = Сохранить строку (имя значение); имя=выражение вычисляет число.
dialog/schema/builtin/cset = Вычислить переменную
dialog/schema/builtin/cset_desc = Вычислить числовое выражение при выполнении; также принимает имя=выражение.
dialog/schema/field/variable = Переменная
dialog/schema/field/expression = Числовое выражение
+35
View File
@@ -0,0 +1,35 @@
dialog/schema/builtin/text = Lời thoại
dialog/schema/builtin/text_desc = Một dòng hội thoại; khóa dịch tùy chọn
dialog/schema/field/text = Văn bản
dialog/schema/field/locale_key = Khóa dịch
dialog/schema/builtin/ptext = Lời dẫn
dialog/schema/builtin/ptext_desc = Lời người kể, điền ${var}
dialog/schema/builtin/flag = Cờ cốt truyện
dialog/schema/builtin/flag_desc = Một dấu được lưu, điều kiện sẽ phản ứng theo
dialog/schema/field/flag = Cờ
dialog/schema/field/value = Giá trị
dialog/schema/builtin/switch_scene = Chuyển cảnh
dialog/schema/builtin/switch_scene_desc = Cảnh tiểu thuyết hoặc bản đồ — bản đồ cũng là cảnh
dialog/schema/field/scene = Cảnh
dialog/schema/builtin/goto = Đến nhãn
dialog/schema/builtin/goto_desc = Bước nhảy vô điều kiện
dialog/schema/field/label = Nhãn
dialog/schema/builtin/end = Kết thúc hội thoại
dialog/schema/builtin/end_desc = Đóng hộp hội thoại
dialog/schema/checks/when = Nếu — nhảy
dialog/schema/checks/when_desc = Bước nhảy có điều kiện theo ngôn ngữ điều kiện chung: cờ, nhiệm vụ, chỉ số, trang bị
dialog/schema/checks/arrow_true = Nếu đúng, đến
dialog/schema/field/condition = Điều kiện
dialog/schema/field/else = Nhãn nếu không
dialog/schema/builtin/select = Lựa chọn
dialog/schema/builtin/select_desc = Câu hỏi trước các đáp án; sau đó là các dòng option
dialog/schema/builtin/option = Đáp án
dialog/schema/builtin/option_desc = Một đáp án và nhãn nó dẫn tới; có điều kiện thì chỉ hiện khi điều kiện đúng
dialog/schema/field/option_if = Hiện nếu
dialog/schema/builtin/option_if_desc = Ví dụ archetype(rogue) hoặc "stat(cha) >= 5"; để trống — luôn hiện
dialog/schema/builtin/set = Gán biến
dialog/schema/builtin/set_desc = Lưu chuỗi (tên giá trị); tên=biểu_thức tính giá trị số.
dialog/schema/builtin/cset = Tính biến
dialog/schema/builtin/cset_desc = Tính biểu thức số khi chạy; cũng hỗ trợ tên=biểu_thức.
dialog/schema/field/variable = Biến
dialog/schema/field/expression = Biểu thức số
+35
View File
@@ -0,0 +1,35 @@
dialog/schema/builtin/text = 台词
dialog/schema/builtin/text_desc = 一句对白;翻译键可选
dialog/schema/field/text = 文本
dialog/schema/field/locale_key = 翻译键
dialog/schema/builtin/ptext = 旁白
dialog/schema/builtin/ptext_desc = 旁白文本,会代入 ${var}
dialog/schema/builtin/flag = 剧情标记
dialog/schema/builtin/flag_desc = 会被保存的标记,条件据此触发
dialog/schema/field/flag = 标记
dialog/schema/field/value = 数值
dialog/schema/builtin/switch_scene = 切换场景
dialog/schema/builtin/switch_scene_desc = 小说场景或地图——地图也是场景
dialog/schema/field/scene = 场景
dialog/schema/builtin/goto = 跳到标签
dialog/schema/builtin/goto_desc = 无条件跳转
dialog/schema/field/label = 标签
dialog/schema/builtin/end = 结束对话
dialog/schema/builtin/end_desc = 关闭对话框
dialog/schema/checks/when = 如果——跳转
dialog/schema/checks/when_desc = 依据通用条件语言的条件跳转:标记、任务、属性、装备
dialog/schema/checks/arrow_true = 为真时跳转
dialog/schema/field/condition = 条件
dialog/schema/field/else = 否则跳转
dialog/schema/builtin/select = 选择
dialog/schema/builtin/select_desc = 回答前的问题;后面跟着 option 行
dialog/schema/builtin/option = 回答选项
dialog/schema/builtin/option_desc = 一个回答及其跳转标签;带条件时仅在条件成立时显示
dialog/schema/field/option_if = 显示条件
dialog/schema/builtin/option_if_desc = 例如 archetype(rogue) 或 "stat(cha) >= 5";留空——总是显示
dialog/schema/builtin/set = 设置变量
dialog/schema/builtin/set_desc = 保存字符串(名称 值);名称=表达式 用于计算数值。
dialog/schema/builtin/cset = 计算变量
dialog/schema/builtin/cset_desc = 执行时计算数值表达式;也支持 名称=表达式。
dialog/schema/field/variable = 变量
dialog/schema/field/expression = 数值表达式
+2
View File
@@ -0,0 +1,2 @@
mod_dialog_base_name = Engine hội thoại
mod_dialog_base_desc = Engine hội thoại: hộp hội thoại với chân dung và các lựa chọn, cùng ngôn ngữ .scenario mà mọi trò chơi trong bộ sưu tập dùng để kể câu chuyện của mình.
+2
View File
@@ -0,0 +1,2 @@
mod_dialog_base_name = 对话引擎
mod_dialog_base_desc = 对话引擎:带肖像与选项的对话框,以及让合集中的每个游戏讲述自身故事的 .scenario 语言。
+28 -4
View File
@@ -3,7 +3,7 @@
"name": "Dialog Engine", "name": "Dialog Engine",
"version": "1.0.0", "version": "1.0.0",
"author": "ct", "author": "ct",
"description": "Engine dialog subsystem: DialogBox/DialogCanvas/AnswerBox UI, the dialog queue runtime with the full DSL action set, the .scenario parser with command strategies, the executable event chains and the /root/DialogRunner for interactive dialogs. Replaces the former Core/UI/Dialog, Core/UI/Executable and Core/Scenarios implementations — SceneManager and SaveService keep driving chains and events through the core contracts (IDialogBox, IExecutableEvent, IExecutableEventChain, IScenarioEventFactory).", "description": "Dialog engine: the dialog box with portraits and answers, and the .scenario language in which every game of the collection tells its story.",
"category": "engine, library", "category": "engine, library",
"sources": [ "sources": [
"src/**/*.cs" "src/**/*.cs"
@@ -11,10 +11,34 @@
"test_sources": [ "test_sources": [
"tests/*.cs" "tests/*.cs"
], ],
"depends": ["shell2d_base>=1", "interactive_base>=1", "effects_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1"], "depends": [
"shell2d_base>=1",
"interactive_base>=1",
"effects_base>=1",
"time_base>=1",
"ui_base>=1",
"expressions_base>=1"
],
"ui": [], "ui": [],
"test_suites": [ "test_suites": [
{"id": "autoplay", "activate": ["--choices=", "--timeout="]}, {
{"id": "gdscript", "activate": ["--itest=gdscript"]} "id": "autoplay",
"activate": [
"--choices=",
"--timeout="
]
},
{
"id": "gdscript",
"activate": [
"--itest=gdscript"
]
},
{
"id": "doc_shots",
"activate": [
"--itest=doc_shots"
]
}
] ]
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 207 KiB

+236
View File
@@ -0,0 +1,236 @@
{
"Commands": [
{
"Id": "text",
"Name": "dialog/schema/builtin/text",
"Description": "dialog/schema/builtin/text_desc",
"Category": "dialog",
"Positional": [
{
"Key": "text",
"Type": "text",
"Required": true,
"Name": "dialog/schema/field/text"
}
],
"Params": [
{
"Key": "key",
"Type": "text",
"Name": "dialog/schema/field/locale_key"
},
{
"Key": "who",
"Type": "text"
},
{
"Key": "duration",
"Type": "number"
},
{
"Key": "cue",
"Type": "text"
}
]
},
{
"Id": "ptext",
"Name": "dialog/schema/builtin/ptext",
"Description": "dialog/schema/builtin/ptext_desc",
"Category": "dialog",
"Positional": [
{
"Key": "text",
"Type": "text",
"Required": true,
"Name": "dialog/schema/field/text"
}
],
"Params": [
{
"Key": "key",
"Type": "text",
"Name": "dialog/schema/field/locale_key"
},
{
"Key": "who",
"Type": "text"
},
{
"Key": "duration",
"Type": "number"
},
{
"Key": "cue",
"Type": "text"
}
]
},
{
"Id": "flag",
"Name": "dialog/schema/builtin/flag",
"Description": "dialog/schema/builtin/flag_desc",
"Category": "flow",
"Positional": [
{
"Key": "name",
"Type": "flag",
"Required": true,
"Name": "dialog/schema/field/flag"
},
{
"Key": "value",
"Type": "text",
"Required": true,
"Default": "1",
"Name": "dialog/schema/field/value"
}
]
},
{
"Id": "switch_scene",
"Name": "dialog/schema/builtin/switch_scene",
"Description": "dialog/schema/builtin/switch_scene_desc",
"Category": "flow",
"Positional": [
{
"Key": "scene",
"Type": "scene",
"Required": true,
"Name": "dialog/schema/field/scene"
}
]
},
{
"Id": "goto",
"Name": "dialog/schema/builtin/goto",
"Description": "dialog/schema/builtin/goto_desc",
"Category": "flow",
"Arrow": "dialog/schema/field/label"
},
{
"Id": "end",
"Name": "dialog/schema/builtin/end",
"Description": "dialog/schema/builtin/end_desc",
"Category": "flow"
},
{
"Id": "when",
"Name": "dialog/schema/checks/when",
"Description": "dialog/schema/checks/when_desc",
"Category": "flow",
"Arrow": "dialog/schema/checks/arrow_true",
"Positional": [
{
"Key": "condition",
"Type": "condition",
"Required": true,
"Name": "dialog/schema/field/condition"
}
],
"Params": [
{
"Key": "else",
"Type": "label",
"Name": "dialog/schema/field/else"
},
{
"Key": "wait",
"Type": "bool"
},
{
"Key": "timeout",
"Type": "number"
}
]
},
{
"Id": "select",
"Name": "dialog/schema/builtin/select",
"Description": "dialog/schema/builtin/select_desc",
"Category": "dialog",
"Positional": [
{
"Key": "text",
"Type": "text",
"Name": "dialog/schema/field/text"
}
],
"Params": [
{
"Key": "key",
"Type": "text",
"Name": "dialog/schema/field/locale_key"
}
]
},
{
"Id": "option",
"Name": "dialog/schema/builtin/option",
"Description": "dialog/schema/builtin/option_desc",
"Category": "dialog",
"Arrow": "dialog/schema/field/label",
"Positional": [
{
"Key": "text",
"Type": "text",
"Required": true,
"Name": "dialog/schema/field/text"
}
],
"Params": [
{
"Key": "key",
"Type": "text",
"Name": "dialog/schema/field/locale_key"
},
{
"Key": "if",
"Type": "condition",
"Name": "dialog/schema/field/option_if",
"Description": "dialog/schema/builtin/option_if_desc"
}
]
},
{
"Id": "set",
"Name": "dialog/schema/builtin/set",
"Description": "dialog/schema/builtin/set_desc",
"Category": "flow",
"Positional": [
{
"Key": "name",
"Type": "text",
"Required": true,
"Name": "dialog/schema/field/variable"
},
{
"Key": "value",
"Type": "text",
"Required": true,
"Name": "dialog/schema/field/value"
}
]
},
{
"Id": "cset",
"Name": "dialog/schema/builtin/cset",
"Description": "dialog/schema/builtin/cset_desc",
"Category": "flow",
"Positional": [
{
"Key": "name",
"Type": "text",
"Required": true,
"Name": "dialog/schema/field/variable"
},
{
"Key": "value",
"Type": "expression",
"Required": true,
"Name": "dialog/schema/field/expression"
}
]
}
]
}
+9 -7
View File
@@ -13,7 +13,6 @@ using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios; using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes; using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive; using Cthangover.Core.Interactive;
using Cthangover.Mods.Map;
using Cthangover.Mods.Time; using Cthangover.Mods.Time;
namespace Cthangover.Mods.Dialog namespace Cthangover.Mods.Dialog
@@ -28,10 +27,13 @@ namespace Cthangover.Mods.Dialog
/// it may not exist at the moment of context creation (e.g. before game data /// it may not exist at the moment of context creation (e.g. before game data
/// is fully loaded). /// is fully loaded).
/// </summary> /// </summary>
internal class ScenarioActionContext : IActionContext internal class ScenarioActionContext : IActionContext, IHostedScenarioContext
{ {
private readonly DialogRuntime runtime; private readonly DialogRuntime runtime;
/// <summary>Host services belong to the runtime that invoked this action.</summary>
public IScenarioExecutionHost Host => runtime.Host;
/// <summary> /// <summary>
/// Service accessor for quest operations. Resolved from the kernel /// Service accessor for quest operations. Resolved from the kernel
/// quest registry — the implementation (QuestServiceImpl) lives in /// quest registry — the implementation (QuestServiceImpl) lives in
@@ -70,12 +72,12 @@ namespace Cthangover.Mods.Dialog
/// </summary> /// </summary>
public IInventory Inventory { get; } public IInventory Inventory { get; }
/// <summary> /// <summary>
/// Map zone control. Stateless adapter over the /// Map zone control, resolved from the kernel slot
/// <c>MapZoneService</c> singleton, which owns the actual state; /// <see cref="MapZoneServiceRegistry"/> that the map_base engine mod
/// safe to instantiate per-context like the quest service, because the /// fills (the dialog engine does not depend on the map). <c>null</c>
/// state does not live in the adapter. /// in games without the world map, where no <c>map.*</c> action exists.
/// </summary> /// </summary>
public IMapZoneService Map { get; } = new MapZoneServiceImpl(); public IMapZoneService Map => MapZoneServiceRegistry.Provider;
/// <summary> /// <summary>
/// Creates a context wired to the given DialogRuntime. The /// Creates a context wired to the given DialogRuntime. The
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.Mods.Dialog
{
using System.Collections.Generic;
/// <summary>A detached UI snapshot; no tool retains mutable actions or a gameplay session.</summary>
public sealed class BackgroundRunInfo
{
public string RunId { get; init; }
public string ScenarioId { get; init; }
public int Line { get; init; }
public string Label { get; init; }
public bool Paused { get; init; }
public string Wait { get; init; }
public double Remaining { get; init; }
public string Text { get; init; }
public string Speaker { get; init; }
public string Body { get; init; }
public string Details { get; init; }
public IReadOnlyDictionary<string, string> Variables { get; init; }
public IReadOnlyDictionary<string, string> Roles { get; init; }
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Mods.Dialog
{
/// <summary>Catalog source plus a current, human-inspectable admission decision.</summary>
public sealed class BackgroundScenarioInfo
{
public string Id { get; init; }
public string ModId { get; init; }
public string Path { get; init; }
public string Map { get; init; }
public string Source { get; init; }
public string Body { get; init; }
public string Status { get; init; }
public string Details { get; init; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Cthangover.Mods.Dialog
{
/// <summary>Optional background-host capability shared by authoring tools without a dependency on a particular gameplay mod.</summary>
public static class BackgroundScenarios
{
public static IBackgroundScenarioTools Provider { get; set; }
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.Mods.Dialog
{
using System.Collections.Generic;
using Godot;
/// <summary>Live authoring seam. Runtime IDs select one execution, scenario IDs select source and breakpoints.</summary>
public interface IBackgroundScenarioTools
{
IReadOnlyList<BackgroundScenarioInfo> Catalog();
IReadOnlyList<BackgroundRunInfo> Executions();
IReadOnlyList<string> Events { get; }
bool WorldPaused { get; set; }
void Observe(Node owner, bool enabled);
bool Validate(string modId, string path, string text, out string error);
bool Reload(out string error);
bool Play(string modId, string path, string text, bool company, out string error);
bool Command(string runId, string command, out string error);
bool HasBreakpoint(string scenarioId, int line);
void ToggleBreakpoint(string scenarioId, int line);
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace Cthangover.Mods.Dialog
{
using System;
using System.Globalization;
using Cthangover.Mods.Expressions;
using Cthangover.Mods.Stats.Formulas;
/// <summary>Numeric reads from the current dialog only; strings never become executable expression fragments.</summary>
public class DialogExpressionScope : IFormulaScope
{
private readonly DialogRuntime runtime;
public DialogExpressionScope(DialogRuntime runtime){this.runtime=runtime;}
public Random Random=>runtime.ExpressionRandom;
public bool TryGet(string name,out double value)
{
var text=runtime.GetVariable(name);value=0;
if(text==null)return false;
if(!double.TryParse(text,NumberStyles.Float,CultureInfo.InvariantCulture,out value))
throw new ExpressionEvaluationException("Variable '"+name+"' is not numeric.");
return true;
}
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ namespace Cthangover.Mods.Dialog
// Deferred: OnModResourcesReady fires while the root is still // Deferred: OnModResourcesReady fires while the root is still
// setting up autoload children, so a direct AddChild is rejected. // setting up autoload children, so a direct AddChild is rejected.
root.CallDeferred("add_child", new DialogScriptRunner { Name = "DialogRunner" }); ModRoot.AddDeferred(new DialogScriptRunner { Name = "DialogRunner" });
} }
} }
} }
+8
View File
@@ -0,0 +1,8 @@
namespace Cthangover.Mods.Dialog
{
/// <summary>Optional action-context capability exposing the execution owner without a global current-scenario singleton.</summary>
public interface IHostedScenarioContext
{
IScenarioExecutionHost Host { get; }
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Cthangover.Mods.Dialog
{
using Cthangover.Mods.Dialog.Action.Impls;
/// <summary>Per-execution presentation and wait services. A host never owns another runtime's mutable actions.</summary>
public interface IScenarioExecutionHost
{
/// <summary>Displays timed speech without occupying the modal DialogBox.</summary>
void ShowText(ActionText action, DialogRuntime runtime);
/// <summary>Starts a host-clock delay; completion advances only this execution.</summary>
void Delay(ActionDelay action, DialogRuntime runtime);
/// <summary>Waits for a live condition or its deadline.</summary>
void WaitCondition(ActionWaitCondition action, DialogRuntime runtime);
/// <summary>Evaluates against this execution's roles and scope.</summary>
bool EvaluateCondition(string condition);
/// <summary>Releases waits and presentation. Aborts do not count as completed scenarios.</summary>
void Finished(bool aborted);
}
}
+9
View File
@@ -24,6 +24,15 @@ namespace Cthangover.Mods.Dialog.Scenarios
/// </summary> /// </summary>
public interface IScenarioCommandSpec public interface IScenarioCommandSpec
{ {
/// <summary>Assignment variable names retain their case; ordinary named parameters remain case insensitive.</summary>
bool PreserveNamedArgumentCase => false;
/// <summary>Optional command-owned semantic validation, shared by runtime construction and static author tools.</summary>
string ValidateArguments(List<string> positional, Dictionary<string,string> named, string arrowTarget) => null;
/// <summary>Normalize a copy of parsed arguments to the canonical schema form; runtime syntax stays untouched.</summary>
void NormalizeArguments(List<string> positional, Dictionary<string, string> named) { }
/// <summary>Minimum number of positional arguments. Default: 0.</summary> /// <summary>Minimum number of positional arguments. Default: 0.</summary>
int MinPositional => 0; int MinPositional => 0;
+1 -1
View File
@@ -38,7 +38,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
foreach (var kv in named) foreach (var kv in named)
{ {
if (kv.Key is "name" or "key") continue; if (kv.Key is "name" or "key") continue;
dlg.Set(kv.Key, kv.Value); dlg.Set(kv.Key, kv.Value).UseProcessText = true;
} }
dlg.Action(actionName); dlg.Action(actionName);
@@ -0,0 +1,8 @@
namespace Cthangover.Mods.Dialog.Scenarios
{
/// <summary>Explicit numeric assignment; calculation happens when the queue reaches the action, never while parsing.</summary>
public class CsetCommandStrategy : NumericAssignmentCommandStrategy
{
public override string Command => "cset";
}
}
+12 -1
View File
@@ -29,7 +29,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
/// <inheritdoc/> /// <inheritdoc/>
public int MaxPositional => 1; public int MaxPositional => 1;
/// <inheritdoc/> /// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait" }; public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait", "who", "duration", "cue" };
/// <inheritdoc/> /// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale) public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
@@ -46,6 +46,17 @@ namespace Cthangover.Mods.Dialog.Scenarios
var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true"; var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true";
var action = dlg.PText(textVal, first, second, hideColor); var action = dlg.PText(textVal, first, second, hideColor);
action.FallbackText = positional.Count > 0 ? positional[0] : "";
action.LocaleKey = key;
action.Speaker = named.TryGetValue("who", out var who) ? who : null;
action.Cue = named.TryGetValue("cue", out var cue) ? cue : null;
if (named.TryGetValue("duration", out var duration))
{
if (!double.TryParse(duration, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds)
|| !double.IsFinite(seconds) || seconds <= 0)
throw new System.FormatException("duration must be a positive finite number");
action.Duration = seconds;
}
if (named.TryGetValue("wait", out var wait) && wait == "false") if (named.TryGetValue("wait", out var wait) && wait == "false")
action.WaitType = WaitType.NoWait; action.WaitType = WaitType.NoWait;
+4 -34
View File
@@ -1,39 +1,9 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
using Cthangover.Core.UI.Event;
using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.Scenarios namespace Cthangover.Mods.Dialog.Scenarios
{ {
/// <summary> /// <summary>Legacy set name value remains literal. The previously unsupported name=expression form calculates a number.</summary>
/// Handles the <c>set</c> DSL command. Stores an arbitrary key-value pair public class SetCommandStrategy : NumericAssignmentCommandStrategy
/// into the dialog session's variable state via <see cref="DialogQueue.Set"/>.
/// Values are read back later through <c>${var}</c> substitution in
/// <c>ptext</c> lines and via <see cref="Cthangover.Core.Actions.IActionContext.GetParam"/>
/// in scenario actions.
/// </summary>
public class SetCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{ {
/// <inheritdoc/> public override string Command => "set";
public string Command => "set"; protected override bool CalculatePositional => false;
/// <inheritdoc/>
public int MinPositional => 2;
/// <inheritdoc/>
public int MaxPositional => 2;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
var name = positional.Count > 0 ? positional[0] : "";
var value = positional.Count > 1 ? positional[1] : "";
dlg.Set(name, value);
}
} }
} }
+12 -1
View File
@@ -29,7 +29,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
/// <inheritdoc/> /// <inheritdoc/>
public int MaxPositional => 1; public int MaxPositional => 1;
/// <inheritdoc/> /// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait" }; public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait", "who", "duration", "cue" };
/// <inheritdoc/> /// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale) public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
@@ -46,6 +46,17 @@ namespace Cthangover.Mods.Dialog.Scenarios
var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true"; var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true";
var action = dlg.Text(textVal, first, second, hideColor); var action = dlg.Text(textVal, first, second, hideColor);
action.FallbackText = positional.Count > 0 ? positional[0] : "";
action.LocaleKey = key;
action.Speaker = named.TryGetValue("who", out var who) ? who : null;
action.Cue = named.TryGetValue("cue", out var cue) ? cue : null;
if (named.TryGetValue("duration", out var duration))
{
if (!double.TryParse(duration, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds)
|| !double.IsFinite(seconds) || seconds <= 0)
throw new System.FormatException("duration must be a positive finite number");
action.Duration = seconds;
}
if (named.TryGetValue("wait", out var wait) && wait == "false") if (named.TryGetValue("wait", out var wait) && wait == "false")
action.WaitType = WaitType.NoWait; action.WaitType = WaitType.NoWait;
@@ -0,0 +1,57 @@
namespace Cthangover.Mods.Dialog.Scenarios
{
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Core.Scenes;
using Cthangover.Mods.Dialog;
/// <summary>
/// A conditional jump in the middle of a dialog:
/// <code>when "stat(cha) &gt;= 5 &amp;&amp; door == 'open'" -> :persuaded [else=refused]</code>
/// evaluates the shared condition language (flags, quests, stats, equipment, GDScript
/// conditions — everything mods registered) at the moment the line runs, and jumps to
/// the arrow label when it holds, to <c>else</c> when it does not (or goes on).
/// <para/>
/// It is the RPG Maker "Conditional Branch" without a new syntax: the condition is the
/// same text a scenario header's <c>condition:</c> takes. Several words without quotes
/// are joined back with spaces, so short conditions need no quotes.
/// </summary>
public class WhenCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc />
public string Command => "when";
/// <inheritdoc />
public int MinPositional => 1;
/// <inheritdoc />
public IReadOnlyList<string> AllowedNamed => new[] { "else", "wait", "timeout" };
/// <inheritdoc />
public string JumpTargetNamedKey => "else";
/// <inheritdoc />
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
var condition = string.Join(" ", positional);
named.TryGetValue("else", out var otherwise);
if (named.TryGetValue("wait", out var wait) && wait == "true")
{
if (!named.TryGetValue("timeout", out var timeout) || !double.TryParse(timeout,
System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds)
|| !double.IsFinite(seconds) || seconds <= 0)
throw new System.FormatException("when wait=true requires a positive finite timeout");
dlg.Queue.Add(new Cthangover.Mods.Dialog.Action.Impls.ActionWaitCondition
{
ConditionText = condition, Timeout = seconds,
TrueTarget = Label(arrowTarget), TimeoutTarget = Label(otherwise),
});
return;
}
var branch = dlg.IfGoTo(null, Label(arrowTarget), Label(otherwise));
branch.ConditionText = condition;
}
private static string Label(string target) => string.IsNullOrWhiteSpace(target) ? null : target.Trim().TrimStart(':');
}
}
@@ -0,0 +1,48 @@
namespace Cthangover.Mods.Dialog.Scenarios
{
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Stats.Formulas;
/// <summary>Shared assignment syntax and validation; each command strategy chooses literal or calculated positional values.</summary>
public abstract class NumericAssignmentCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
public abstract string Command { get; }
protected virtual bool CalculatePositional => true;
public int MinArguments => 1;
public int MaxPositional => 2;
public bool PreserveNamedArgumentCase => true;
public string ValidateArguments(List<string> positional, Dictionary<string,string> named, string arrowTarget)
{
if(!string.IsNullOrEmpty(arrowTarget))return "Assignments do not accept a jump target.";
if(!TryAssignment(positional,named,out var name,out var value))return "Use name value or name=expression, with one assignment per line.";
if(string.IsNullOrWhiteSpace(name))return "Assignment name is empty.";
if((CalculatePositional||named.Count>0)&&!Formula.TryParse(value,out _,out var error))return error;
return null;
}
public void Execute(DialogQueue dialog,List<string> positional,Dictionary<string,string> named,string arrowTarget,ILocalizationProvider locale)
{
var error=ValidateArguments(positional,named,arrowTarget);
TryAssignment(positional,named,out var name,out var value);
if(error!=null||CalculatePositional||named.Count>0)dialog.Calculate(name,value,error);
else dialog.Set(name,value);
}
public void NormalizeArguments(List<string> positional, Dictionary<string, string> named)
{
if (positional.Count == 0 && named.Count == 1 && TryAssignment(positional, named, out var name, out var value))
{
positional.Add(name);
positional.Add(value);
named.Clear();
}
}
private static bool TryAssignment(List<string> positional,Dictionary<string,string> named,out string name,out string value)
{
name=value=null;
if(positional.Count==2&&named.Count==0){name=positional[0];value=positional[1];return true;}
if(positional.Count==0&&named.Count==1)
foreach(var pair in named){name=pair.Key;value=pair.Value;return true;}
return false;
}
}
}
+21 -22
View File
@@ -103,7 +103,10 @@ namespace Cthangover.Mods.Dialog.Scenarios
if (pendingSelect.Variants == null) if (pendingSelect.Variants == null)
pendingSelect.Variants = new List<SelectVariant>(); pendingSelect.Variants = new List<SelectVariant>();
pendingSelect.Variants.Add(SelectVariant.New(optText, goTo)); var variant = SelectVariant.New(optText, goTo);
if (named.TryGetValue("if", out var condition))
variant.Condition = condition;
pendingSelect.Variants.Add(variant);
continue; continue;
} }
@@ -141,6 +144,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
return null; return null;
var cmd = tokens[0].ToLowerInvariant(); var cmd = tokens[0].ToLowerInvariant();
var spec = ScenarioCommandStrategyFactory.Get(cmd) as IScenarioCommandSpec;
for (var i = 1; i < tokens.Count; i++) for (var i = 1; i < tokens.Count; i++)
{ {
var t = tokens[i]; var t = tokens[i];
@@ -160,7 +164,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
var name = t.Substring(0, eqIdx); var name = t.Substring(0, eqIdx);
if (IsValidIdent(name)) if (IsValidIdent(name))
{ {
named[name.ToLowerInvariant()] = t.Substring(eqIdx + 1); named[spec?.PreserveNamedArgumentCase == true ? name : name.ToLowerInvariant()] = t.Substring(eqIdx + 1);
continue; continue;
} }
} }
@@ -239,39 +243,34 @@ namespace Cthangover.Mods.Dialog.Scenarios
private static List<string> Tokenize(string line) private static List<string> Tokenize(string line)
{ {
var result = new List<string>(); var tokens=new List<string>();
var i=0; var i=0;
while(i<line.Length) while(i<line.Length)
{ {
if (char.IsWhiteSpace(line[i])) if(char.IsWhiteSpace(line[i])){i++;continue;}
var token=new System.Text.StringBuilder();
while(i<line.Length&&!char.IsWhiteSpace(line[i]))
{ {
i++; // Preserve quotes embedded in JSON. Quotes at the start of a value, including name="...",
continue; // delimit one DSL argument and allow spaces without changing existing JSON commands.
} var quoted=false;
if(line[i]=='"') if(line[i]=='"')
{ {
var prefix=token.ToString();
quoted=token.Length==0||prefix.EndsWith("=")&&IsValidIdent(prefix.Substring(0,prefix.Length-1));
}
if(!quoted){token.Append(line[i++]);continue;}
i++; i++;
var start = i;
while(i<line.Length&&line[i]!='"') while(i<line.Length&&line[i]!='"')
{ {
if (line[i] == '\\' && i + 1 < line.Length) if(line[i]==(char)92&&i+1<line.Length&&line[i+1]=='"'){token.Append('"');i+=2;}
i++; else token.Append(line[i++]);
i++;
} }
result.Add(line[start..i].Replace("\\\"", "\""));
if(i<line.Length)i++; if(i<line.Length)i++;
continue;
} }
tokens.Add(token.ToString());
var tokenStart = i;
while (i < line.Length && !char.IsWhiteSpace(line[i]))
i++;
result.Add(line[tokenStart..i]);
} }
return tokens;
return result;
} }
private static bool IsValidIdent(string s) private static bool IsValidIdent(string s)
@@ -0,0 +1,24 @@
namespace Cthangover.Mods.Dialog.Action.Impls
{
using System;
using System.Globalization;
using Cthangover.Mods.Stats.Formulas;
/// <summary>Evaluate once at the queue position; dependent actions are aborted on invalid input instead of seeing a stale result.</summary>
public class ActionCalculate : ActionCommand
{
public string Name {get;set;}
public string Expression {get;set;}
public string ArgumentError {get;set;}
public override WaitType WaitType {get;set;}=WaitType.NoWait;
public override void DoRun(DialogRuntime runtime)
{
try
{
if(ArgumentError!=null)throw new ArgumentException(ArgumentError);
var value=Formula.Parse(Expression).EvaluateStrict(new DialogExpressionScope(runtime));
runtime.SetVariable(Name,value.ToString("G17",CultureInfo.InvariantCulture));
}
catch(Exception ex){runtime.FailExpression($"Assignment '{Name}': {ex.Message}",SourceLine);}
}
}
}
@@ -32,6 +32,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Shows the optional <see cref="ShowText"/> and, in <see cref="HiddenMode"/>, hides the dialog body for the pause duration.</summary> /// <summary>Shows the optional <see cref="ShowText"/> and, in <see cref="HiddenMode"/>, hides the dialog body for the pause duration.</summary>
public override void DoRun(DialogRuntime runtime) public override void DoRun(DialogRuntime runtime)
{ {
if (runtime.Host != null)
{
runtime.Host.Delay(this, runtime);
return;
}
var dialogBox = runtime.DialogBox; var dialogBox = runtime.DialogBox;
if (dialogBox == null) if (dialogBox == null)
return; return;
+12 -2
View File
@@ -23,6 +23,15 @@ namespace Cthangover.Mods.Dialog.Action.Impls
{ {
/// <summary>Delegate evaluated at runtime. Must return true/false to select the branch target.</summary> /// <summary>Delegate evaluated at runtime. Must return true/false to select the branch target.</summary>
public Func<bool> Condition { get; set; } public Func<bool> Condition { get; set; }
/// <summary>Source expression evaluated by the runtime's own context, not a captured global role.</summary>
public string ConditionText { get; set; }
/// <summary>
/// Used instead of <see cref="Condition"/> when set: evaluated with the instance scope of the
/// running dialog (<see cref="DialogQueue.Scope"/>), so <c>when "self.open == 1"</c> reads the flag of
/// the object that started the dialog, even when the line was parsed into another queue first.
/// </summary>
public Func<string, bool> ScopedCondition { get; set; }
/// <summary>Target action ID if <see cref="Condition"/> returns true. Empty or null causes fall-through.</summary> /// <summary>Target action ID if <see cref="Condition"/> returns true. Empty or null causes fall-through.</summary>
public string TrueGoTo { get; set; } public string TrueGoTo { get; set; }
/// <summary>Target action ID if <see cref="Condition"/> returns false. Empty or null causes fall-through.</summary> /// <summary>Target action ID if <see cref="Condition"/> returns false. Empty or null causes fall-through.</summary>
@@ -34,13 +43,14 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Evaluates <see cref="Condition"/> and jumps to <see cref="TrueGoTo"/> or <see cref="FalseGoTo"/>; an empty target falls through to the next action.</summary> /// <summary>Evaluates <see cref="Condition"/> and jumps to <see cref="TrueGoTo"/> or <see cref="FalseGoTo"/>; an empty target falls through to the next action.</summary>
public override void DoRun(DialogRuntime runtime) public override void DoRun(DialogRuntime runtime)
{ {
if (Condition == null) if (ConditionText == null && Condition == null && ScopedCondition == null)
{ {
GameLogger.Log("DIALOG", "ActionIf: Condition delegate is null", LogLevel.Error); GameLogger.Log("DIALOG", "ActionIf: Condition delegate is null", LogLevel.Error);
return; return;
} }
var result = Condition(); var result = ConditionText != null ? runtime.EvaluateCondition(ConditionText)
: ScopedCondition != null ? ScopedCondition(runtime?.Dialog?.Scope) : Condition();
var goTo = result ? TrueGoTo : FalseGoTo; var goTo = result ? TrueGoTo : FalseGoTo;
if (!string.IsNullOrEmpty(goTo)) if (!string.IsNullOrEmpty(goTo))
+18 -2
View File
@@ -1,4 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Utils;
using Cthangover.Mods.Dialog.Action; using Cthangover.Mods.Dialog.Action;
using Godot; using Godot;
using Cthangover.Core.UI; using Cthangover.Core.UI;
@@ -35,16 +37,30 @@ namespace Cthangover.Mods.Dialog.Action.Impls
public override WaitType WaitType { get; set; } = WaitType.WaitSelect; public override WaitType WaitType { get; set; } = WaitType.WaitSelect;
/// <summary>Substitutes variables in the variants and context text, then shows the answer box; the runtime stays paused until a variant is picked.</summary> /// <summary>Substitutes variables in the variants and context text, then shows the answer box; the runtime stays paused until a variant is picked.</summary>
/// <summary>
/// Offers the options whose conditions hold now. When none does, the choice is
/// skipped and the dialog goes on with the next line, so a choice of only gated
/// options never leaves the player stuck.
/// </summary>
public override void DoRun(DialogRuntime runtime) public override void DoRun(DialogRuntime runtime)
{ {
var dialogBox = runtime.DialogBox; var dialogBox = runtime.DialogBox;
if (dialogBox == null) if (dialogBox == null)
return; return;
foreach (var variant in Variants) var scope = runtime.Dialog?.Scope;
var shown = Variants.Where(v => v.IsShown(scope)).ToList();
WaitType = shown.Count == 0 ? WaitType.NoWait : WaitType.WaitSelect;
if (shown.Count == 0)
{
GameLogger.Log("DIALOG", "select: no option is shown (all conditions false), going on", LogLevel.Debug);
return;
}
foreach (var variant in shown)
variant.Text = runtime.ProcessText(variant.Text); variant.Text = runtime.ProcessText(variant.Text);
dialogBox.SetVariants(Variants); dialogBox.SetVariants(shown);
dialogBox.SetText(runtime.ProcessText(Text)); dialogBox.SetText(runtime.ProcessText(Text));
} }
} }
+8
View File
@@ -25,6 +25,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls
public string Value { get; set; } public string Value { get; set; }
/// <summary>Lazy evaluation delegate. When non-null, invoked at runtime to compute the value dynamically.</summary> /// <summary>Lazy evaluation delegate. When non-null, invoked at runtime to compute the value dynamically.</summary>
public Func<string> Callback { get; set; } public Func<string> Callback { get; set; }
/// <summary>Only action parameter templates expand references; ordinary set literals retain their historical behavior.</summary>
public bool UseProcessText { get; set; }
/// <summary>Variable assignment is instant — the dialog continues without pause.</summary> /// <summary>Variable assignment is instant — the dialog continues without pause.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait; public override WaitType WaitType { get; set; } = WaitType.NoWait;
@@ -33,6 +35,12 @@ namespace Cthangover.Mods.Dialog.Action.Impls
public override void DoRun(DialogRuntime runtime) public override void DoRun(DialogRuntime runtime)
{ {
var val = Callback != null ? Callback() : Value; var val = Callback != null ? Callback() : Value;
if (UseProcessText)
{
try { val = runtime.ProcessParameter(val); }
catch (Cthangover.Mods.Expressions.ExpressionEvaluationException ex)
{ runtime.FailExpression(ex.Message, SourceLine); return; }
}
runtime.SetVariable(Name, val ?? string.Empty); runtime.SetVariable(Name, val ?? string.Empty);
} }
} }
+8 -1
View File
@@ -8,6 +8,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// flag is game state: it is saved, read by scenario conditions /// flag is game state: it is saved, read by scenario conditions
/// (<c>condition: door == "open"</c>) and its change rescans the scene's /// (<c>condition: door == "open"</c>) and its change rescans the scene's
/// event chain, so a scenario waiting for it can start right after. /// event chain, so a scenario waiting for it can start right after.
/// <c>flag self.open 1</c> writes the flag of the dialog's instance scope
/// (<see cref="DialogQueue.Scope"/>), e.g. <c>rpg_village/supply_1:open</c>.
/// </summary> /// </summary>
public class ActionSetFlag : ActionCommand public class ActionSetFlag : ActionCommand
{ {
@@ -20,6 +22,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls
public override WaitType WaitType { get; set; } = WaitType.NoWait; public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <inheritdoc/> /// <inheritdoc/>
public override void DoRun(DialogRuntime runtime) => ScenarioCondition.SetFlag(Name, Value ?? string.Empty); public override void DoRun(DialogRuntime runtime)
{
var key = ScenarioCondition.FlagKey(Name, runtime?.Dialog?.Scope);
if (key != null)
ScenarioCondition.SetFlag(key, Value ?? string.Empty);
}
} }
} }
+15
View File
@@ -21,6 +21,16 @@ namespace Cthangover.Mods.Dialog.Action.Impls
{ {
/// <summary>The dialog text to display. Supports ${var} substitution when <see cref="UseProcessText"/> is true.</summary> /// <summary>The dialog text to display. Supports ${var} substitution when <see cref="UseProcessText"/> is true.</summary>
public string Text { get; set; } public string Text { get; set; }
/// <summary>Untranslated source retained for live locale changes in hosted speech.</summary>
public string FallbackText { get; set; }
/// <summary>Stable translation key, independent of speech event cues.</summary>
public string LocaleKey { get; set; }
/// <summary>Host role naming the concrete speaker.</summary>
public string Speaker { get; set; }
/// <summary>Optional semantic event emitted when this utterance finishes normally.</summary>
public string Cue { get; set; }
/// <summary>Explicit host-clock duration; null uses translated text length.</summary>
public double? Duration { get; set; }
/// <summary>Left avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary> /// <summary>Left avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary>
public string FirstAvatar { get; set; } public string FirstAvatar { get; set; }
/// <summary>Right avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary> /// <summary>Right avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary>
@@ -36,6 +46,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Sets the dialog text (optionally variable-substituted) and both avatar slots, then waits for the player's click.</summary> /// <summary>Sets the dialog text (optionally variable-substituted) and both avatar slots, then waits for the player's click.</summary>
public override void DoRun(DialogRuntime runtime) public override void DoRun(DialogRuntime runtime)
{ {
if (runtime.Host != null)
{
runtime.Host.ShowText(this, runtime);
return;
}
var dialogBox = runtime.DialogBox; var dialogBox = runtime.DialogBox;
if (dialogBox == null) if (dialogBox == null)
return; return;
@@ -0,0 +1,22 @@
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>An explicit asynchronous condition wait; ordinary when remains a one-shot branch.</summary>
public class ActionWaitCondition : ActionCommand
{
/// <summary>Evaluated against the execution's live context.</summary>
public string ConditionText { get; set; }
/// <summary>Maximum host-clock wait.</summary>
public double Timeout { get; set; }
/// <summary>Label selected when the condition becomes true.</summary>
public string TrueTarget { get; set; }
/// <summary>Label selected on timeout, or fall-through when absent.</summary>
public string TimeoutTarget { get; set; }
public override WaitType WaitType { get; set; } = WaitType.WaitEvent;
public override void DoRun(DialogRuntime runtime)
{
if (runtime.Host == null)
runtime.FailExpression("when wait=true requires an execution host", SourceLine);
else runtime.Host.WaitCondition(this, runtime);
}
}
}
@@ -23,6 +23,16 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Target action ID to jump to via <see cref="DialogRuntime.TryGoTo"/> when this variant is selected.</summary> /// <summary>Target action ID to jump to via <see cref="DialogRuntime.TryGoTo"/> when this variant is selected.</summary>
public string GoTo { get; set; } public string GoTo { get; set; }
/// <summary>
/// Condition of the shared condition language (<c>option "…" if=… -> :label</c>); the
/// option is shown only while it holds. Null = always shown. Evaluated each time the
/// choice appears, so a stat, a flag or an item gained a moment ago counts.
/// </summary>
public string Condition { get; set; }
/// <summary>Whether the option is offered now; <paramref name="scope"/> is the dialog's instance scope (<see cref="DialogQueue.Scope"/>, may be null).</summary>
public bool IsShown(string scope) => string.IsNullOrWhiteSpace(Condition) || ScenarioCondition.Evaluate(Condition, scope);
/// <summary>Generates a new unique action ID for use as a GoTo target (e.g. with <see cref="DialogQueue.Point"/>).</summary> /// <summary>Generates a new unique action ID for use as a GoTo target (e.g. with <see cref="DialogQueue.Point"/>).</summary>
public static string Point => Guid.NewGuid().ToString(); public static string Point => Guid.NewGuid().ToString();
+22
View File
@@ -18,6 +18,7 @@ using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog namespace Cthangover.Mods.Dialog
{ {
using Cthangover.Core.Events;
/// <summary> /// <summary>
/// The visible dialog UI: dual avatars with shader-based hide-color mode, /// The visible dialog UI: dual avatars with shader-based hide-color mode,
/// answer box spawning, and click/keyboard advance. Validates that no existing /// answer box spawning, and click/keyboard advance. Validates that no existing
@@ -47,6 +48,7 @@ namespace Cthangover.Mods.Dialog
protected override void OnReady() protected override void OnReady()
{ {
MouseFilter = MouseFilterEnum.Ignore; MouseFilter = MouseFilterEnum.Ignore;
ModEventBus.Subscribe<SceneExitedEvent>(OnSceneExited);
if (Body == null) if (Body == null)
{ {
@@ -373,6 +375,26 @@ namespace Cthangover.Mods.Dialog
Locker = null; Locker = null;
} }
/// <summary>Detaches the scene-exit hook when the box leaves the tree.</summary>
protected override void OnExitTree()
{
ModEventBus.Unsubscribe<SceneExitedEvent>(OnSceneExited);
}
/// <summary>
/// Leaving a logical scene ends a dialog that is still running in it. The box belongs to the shell and
/// survives scene switches, so nothing else would release its locker: a switch in the middle of a dialog
/// would keep the box busy, and the next scene's events and the player's walking would wait for it. The
/// dialog is aborted, not finished: its end actions and <see cref="DialogEndedEvent"/> do not run.
/// </summary>
private void OnSceneExited(SceneExitedEvent evt)
{
if (!IsRunning)
return;
GameLogger.Log("DIALOG", $"scene '{evt.SceneName}' left during a dialog ('{Locker?.ID ?? "click dialog"}'): dialog aborted", LogLevel.Warning);
Abort();
}
/// <summary> /// <summary>
/// Whether a dialog occupies the box: one started by an event (Locker is not null) or a locker-less queue /// Whether a dialog occupies the box: one started by an event (Locker is not null) or a locker-less queue
/// that has not ended yet (an interactive's click dialog runs without a locker). Event chains and the click /// that has not ended yet (an interactive's click dialog runs without a locker). Event chains and the click
+17
View File
@@ -33,6 +33,15 @@ namespace Cthangover.Mods.Dialog
/// <summary>The ordered sequence of dialog actions. Appended via fluent builder methods; consumed by <see cref="DialogRuntime"/>.</summary> /// <summary>The ordered sequence of dialog actions. Appended via fluent builder methods; consumed by <see cref="DialogRuntime"/>.</summary>
public List<IActionCommand> Queue { get; } = new(); public List<IActionCommand> Queue { get; } = new();
/// <summary>
/// The instance scope of this dialog — the placement id of the object whose interaction runs it —
/// or null. Inside it <c>self.&lt;name&gt;</c> in <c>flag</c>, <c>when</c> and option conditions means
/// the story flag <c>&lt;scope&gt;:&lt;name&gt;</c> (<see cref="ScenarioCondition.FlagKey"/>). Set by
/// whoever starts the dialog (the RPG runtime) before it runs; a queue with no scope rejects
/// <c>self.</c> with an ERROR instead of writing a global flag.
/// </summary>
public string Scope { get; set; }
/// <summary>Spawns a visual effect identified by <paramref name="effectID"/> through <see cref="EffectFactory"/>. Returns the action for further configuration.</summary> /// <summary>Spawns a visual effect identified by <paramref name="effectID"/> through <see cref="EffectFactory"/>. Returns the action for further configuration.</summary>
public ActionEffect Effect(string effectID) public ActionEffect Effect(string effectID)
{ {
@@ -130,6 +139,14 @@ namespace Cthangover.Mods.Dialog
return action; return action;
} }
/// <summary>Enqueues a strict numeric assignment; expressions and random functions are evaluated at execution.</summary>
public ActionCalculate Calculate(string name, string expression, string argumentError = null)
{
var action = new ActionCalculate { Name = name, Expression = expression, ArgumentError = argumentError };
Queue.Add(action);
return action;
}
/// <summary>Sets a story flag of the game (saved, read by scenario conditions) when the queue reaches this point.</summary> /// <summary>Sets a story flag of the game (saved, read by scenario conditions) when the queue reaches this point.</summary>
public ActionSetFlag Flag(string name, string value) public ActionSetFlag Flag(string name, string value)
{ {
+48 -1
View File
@@ -51,6 +51,13 @@ namespace Cthangover.Mods.Dialog
/// <summary>The currently active dialog queue. Set by <see cref="SetDialogQueueAndRun"/>.</summary> /// <summary>The currently active dialog queue. Set by <see cref="SetDialogQueueAndRun"/>.</summary>
public DialogQueue Dialog { get; set; } public DialogQueue Dialog { get; set; }
/// <summary>Optional per-execution host; null preserves modal dialog behavior.</summary>
public IScenarioExecutionHost Host { get; set; }
/// <summary>Conditions use live host roles when present, otherwise the existing instance scope.</summary>
public bool EvaluateCondition(string text) => Host != null
? Host.EvaluateCondition(text) : ScenarioCondition.Evaluate(text, Dialog?.Scope);
/// <summary>The action at the current queue index. Automatically constructs it if <see cref="ConstructType"/> is OnStartAction and it hasn't been constructed yet.</summary> /// <summary>The action at the current queue index. Automatically constructs it if <see cref="ConstructType"/> is OnStartAction and it hasn't been constructed yet.</summary>
public IActionCommand CurrentAction => GetByIndex(index); public IActionCommand CurrentAction => GetByIndex(index);
@@ -71,6 +78,9 @@ namespace Cthangover.Mods.Dialog
/// </summary> /// </summary>
public static IDialogDebugger Debugger { get; set; } public static IDialogDebugger Debugger { get; set; }
/// <summary>A host may debug its own execution independently of the modal studio hook.</summary>
public IDialogDebugger ExecutionDebugger { get; set; }
/// <summary> /// <summary>
/// True while the debugger holds the runtime before <see cref="CurrentAction"/>: /// True while the debugger holds the runtime before <see cref="CurrentAction"/>:
/// that action has not run yet, so advancing must wait for /// that action has not run yet, so advancing must wait for
@@ -81,6 +91,35 @@ namespace Cthangover.Mods.Dialog
/// <summary>Queue index of <see cref="CurrentAction"/>.</summary> /// <summary>Queue index of <see cref="CurrentAction"/>.</summary>
public int Index => index; public int Index => index;
/// <summary>Inject a seeded generator for reproducible scripts and tests; expressions never roll while parsing.</summary>
public System.Random ExpressionRandom { get; set; } = System.Random.Shared;
public string LastExpressionError { get; private set; }
/// <summary>Abort a malformed calculation without completing a one-run scenario or running later reward actions.</summary>
public void FailExpression(string message, int sourceLine = 0)
{
var scenario = DialogBox?.Locker as Cthangover.Mods.Dialog.Executable.ScenarioEvent;
var origin = scenario?.SourcePath ?? DialogBox?.Locker?.ID ?? "<inline>";
LastExpressionError = origin + ":" + sourceLine + ": " + message;
GameLogger.Log("DIALOG", LastExpressionError, LogLevel.Error);
if (DialogBox != null) DialogBox.Abort(); else Abort();
}
/// <summary>Resolve action parameters at execution, preserving string values and rejecting undefined references.</summary>
public string ProcessParameter(string text)
{
if (text == null) return null;
var names = text.GetIncludesInQuotes("${", "}");
if (names != null)
foreach (var name in names)
{
if (!variables.TryGetValue(name, out var value))
throw new Cthangover.Mods.Expressions.ExpressionEvaluationException("Undefined action parameter variable: " + name);
text = text.Replace("${" + name + "}", value);
}
return text;
}
/// <summary>Current <c>${name}</c> variables of the dialog, for inspection.</summary> /// <summary>Current <c>${name}</c> variables of the dialog, for inspection.</summary>
public IReadOnlyDictionary<string, string> Variables => (IReadOnlyDictionary<string, string>)variables; public IReadOnlyDictionary<string, string> Variables => (IReadOnlyDictionary<string, string>)variables;
@@ -153,6 +192,7 @@ namespace Cthangover.Mods.Dialog
generation++; generation++;
variables = new Dictionary<string, string>(); variables = new Dictionary<string, string>();
LastExpressionError = null;
DialogBox = dialogBox; DialogBox = dialogBox;
IsHeld = false; IsHeld = false;
isLastGoTo = false; isLastGoTo = false;
@@ -246,7 +286,7 @@ namespace Cthangover.Mods.Dialog
GameLogger.Log("DIALOG", $"Run: idx={index}/{dialogQueue.Count-1} type={action.GetType().Name} wait={action.WaitType}", LogLevel.Debug); GameLogger.Log("DIALOG", $"Run: idx={index}/{dialogQueue.Count-1} type={action.GetType().Name} wait={action.WaitType}", LogLevel.Debug);
if (Debugger != null && Debugger.ShouldHold(this, action)) if ((ExecutionDebugger ?? Debugger)?.ShouldHold(this, action) == true)
{ {
IsHeld = true; IsHeld = true;
return; return;
@@ -256,6 +296,11 @@ namespace Cthangover.Mods.Dialog
if (runGeneration != generation) if (runGeneration != generation)
return; return;
if (Host != null && iteration > 256)
{
FailExpression("Hosted scenario exceeded the immediate-action budget");
return;
}
if (iteration++ > 100000) if (iteration++ > 100000)
{ {
GameLogger.Log("DIALOG", "Run: infinite loop protection (100k iterations)", LogLevel.Warning); GameLogger.Log("DIALOG", "Run: infinite loop protection (100k iterations)", LogLevel.Warning);
@@ -368,6 +413,7 @@ namespace Cthangover.Mods.Dialog
dialogQueue = null; dialogQueue = null;
endDialogQueue = null; endDialogQueue = null;
index = 0; index = 0;
Host?.Finished(false);
if (DialogBox != null) if (DialogBox != null)
{ {
@@ -397,6 +443,7 @@ namespace Cthangover.Mods.Dialog
index = 0; index = 0;
isLastGoTo = false; isLastGoTo = false;
IsHeld = false; IsHeld = false;
Host?.Finished(true);
GameLogger.Log("DIALOG", "Abort: queue dropped without ending"); GameLogger.Log("DIALOG", "Abort: queue dropped without ending");
} }
@@ -205,6 +205,10 @@ namespace Cthangover.Mods.Dialog.Executable
if (!IsInstanceValid(this) || !IsInsideTree() || !isActive) if (!IsInstanceValid(this) || !IsInsideTree() || !isActive)
return; return;
// Shell rebuilds after battles may make the early _Ready lookup miss.
// Resolve again on the deferred scan, after SceneManager clears scene caches.
if (dialogBox == null || !IsInstanceValid(dialogBox))
dialogBox = SceneContextNode.FindNode<DialogBox>("DialogBox");
if (dialogBox == null || !IsInstanceValid(dialogBox) || !dialogBox.IsActive || dialogBox.IsRunning) if (dialogBox == null || !IsInstanceValid(dialogBox) || !dialogBox.IsActive || dialogBox.IsRunning)
return; return;
+2 -4
View File
@@ -1,10 +1,8 @@
{ {
"name": "Test Runner", "name": "Test Runner",
"description": "Test harness mod: ports the integration DialogAutoDriver into an engine mod. Activated by --choices=/--timeout= user args; auto-plays the active dialog, prints [TEST_RESULT] and quits. Depends on cthangover_game and dialog_base.", "description": "Test harness mod: ports the integration DialogAutoDriver into an engine mod. Activated by --choices=/--timeout= user args; auto-plays the active dialog, prints [TEST_RESULT] and quits. Depends only on its host dialog_base, so a game run (--game=<id> --timeout=) keeps that game's own mod set.",
"sources": [ "sources": [
"src/*.cs" "src/*.cs"
], ],
"depends": [ "depends": []
"cthangover_game>=1"
]
} }
@@ -193,7 +193,7 @@ namespace Mods.TestRunner
if (reason != null) if (reason != null)
GameLogger.Log("TEST", $"DialogAutoDriverNode: {reason}"); GameLogger.Log("TEST", $"DialogAutoDriverNode: {reason}");
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}"); GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");
GetTree().Quit(failed == 0 ? 0 : 1); Cthangover.Core.Bootstrap.ApplicationExit.Quit(GetTree(), failed == 0 ? 0 : 1);
} }
} }
} }
@@ -63,7 +63,7 @@ namespace Mods.TestRunner
// Deferred: сцена ещё строится; драйвер внутри сам выдерживает // Deferred: сцена ещё строится; драйвер внутри сам выдерживает
// стартовую паузу и лениво ищет DialogBox, поэтому порядок с // стартовую паузу и лениво ищет DialogBox, поэтому порядок с
// загрузкой сцены не важен. // загрузкой сцены не важен.
root.CallDeferred("add_child", new DialogAutoDriverNode(choices, timeout, dialogs)); ModRoot.AddDeferred(new DialogAutoDriverNode(choices, timeout, dialogs));
} }
private static List<int> ParseChoices(string[] args) private static List<int> ParseChoices(string[] args)
+7
View File
@@ -0,0 +1,7 @@
{
"name": "Documentation Screenshots",
"description": "Plays a steps file (scene switches, clock, dialog answers, panels, keys, inventory/party seeding) in the running game and saves a screenshot at every 'shot' step for mod documentation. Activated by --itest=doc_shots with --doc-steps=<file> and --shots=<dir> (windowed runs only).",
"sources": [
"src/*.cs"
]
}
+415
View File
@@ -0,0 +1,415 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Interactive;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Tool;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Mods.Interface.View;
using Godot;
namespace Cthangover.Mods.Dialog.DocShots
{
/// <summary>
/// Plays a plain-text steps file in a running game and saves screenshots for
/// mod documentation, so a page's pictures come from the real application
/// without one driver per mod. One step per line, <c>#</c> starts a comment:
/// <list type="bullet">
/// <item><c>scene &lt;name&gt;</c> — aborts the running dialog (as "Go to scene" does) and switches; waits for the scene, then settles.</item>
/// <item><c>time HH:MM</c> — sets the clock of the current day.</item>
/// <item><c>wait &lt;ticks&gt;</c> — 0.1 s ticks.</item>
/// <item><c>abort</c>, <c>next [n]</c>, <c>choose &lt;i&gt;</c>, <c>waitanswer</c> — the dialog box.</item>
/// <item><c>panel &lt;id&gt;</c> — toggles a toolbar panel like its button.</item>
/// <item><c>key &lt;Key&gt; [ticks]</c> — presses a key (Godot name, e.g. <c>E</c>, <c>W</c>, <c>Escape</c>) and releases it after the ticks.</item>
/// <item><c>tap &lt;node&gt; &lt;i&gt;</c> — left-clicks child <c>i</c> of the first node with that name (an inventory cell); <c>press &lt;text&gt;</c> — presses the visible button with that text or with a label of that text inside.</item>
/// <item><c>give &lt;item&gt; [count]</c>, <c>party &lt;character&gt;</c> — seeds the inventory and the party.</item>
/// <item><c>signal &lt;node&gt; &lt;signal&gt; [arg]</c> — emits a signal of the first node with that name, as its own input handling would (a world map zone click).</item>
/// <item><c>hover &lt;x&gt; &lt;y&gt;</c>, <c>click &lt;x&gt; &lt;y&gt;</c> — moves the mouse to (and left-clicks at) a point given as fractions of the window, through the normal input routing (scene interactives).</item>
/// <item><c>drag &lt;x1&gt; &lt;y1&gt; &lt;x2&gt; &lt;y2&gt;</c> — presses the left button at the first point, moves to the second with it held and releases there (dragging a battle card onto a target).</item>
/// <item><c>hurt &lt;character&gt; &lt;hp&gt;</c> — sets a party member's current health (to show healing).</item>
/// <item><c>tool &lt;id&gt;</c> — opens a registered tool window (<c>schema_palette</c>, <c>items_editor</c>…) the way the toolbar's tool list does.</item>
/// <item><c>type &lt;text&gt;</c> — replaces the text of the focused line edit (click it first).</item>
/// <item><c>call &lt;tool&gt; &lt;method&gt; [args…]</c> — calls a public method with string arguments on a window opened by <c>tool</c>.</item>
/// <item><c>run &lt;scenario line&gt;</c> — runs one scenario line through the dialog runner, as an interactive object's inline commands do (<c>run action rpg.battle enemies=rpg_wolf</c>).</item>
/// <item><c>shot &lt;name&gt;</c> — saves <c>&lt;name&gt;.png</c> (windowed runs only).</item>
/// </list>
/// Waits are tick counts, not conditions on the picture; only reaching each
/// scene and the dialog states is checked. Result line:
/// <c>[TEST_RESULT] name=doc_shots passed=N failed=M</c>, exit 0/1, 300 s watchdog.
/// </summary>
public partial class DocShotsDriver : Node
{
private const float TickSeconds = 0.1f;
private const float TimeoutSeconds = 300f;
private const int SettleTicks = 30;
private const int NextGapTicks = 5;
private const long MinutesPerDay = 24 * 60;
private readonly string shotsDir;
private readonly List<string[]> steps = new();
private readonly List<string> failures = new();
private int passed;
private int index;
private int waitTicks;
private int repeat;
private double elapsed;
private bool started;
private string pendingScene;
private Key heldKey = Key.None;
private readonly Dictionary<string, Window> tools = new();
private Window lastTool;
/// <summary>Reads the steps; a missing file fails the run at the first tick.</summary>
public DocShotsDriver(string stepsFile, string shotsDir)
{
this.shotsDir = shotsDir;
Name = "DocShotsDriver";
if (stepsFile != null && File.Exists(stepsFile))
{
foreach (var line in File.ReadAllLines(stepsFile))
{
var text = line.Split('#')[0].Trim();
if (text.Length > 0)
steps.Add(text.Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
}
else
{
failures.Add($"steps file '{stepsFile}' not found");
}
var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
AddChild(timer);
timer.Timeout += Tick;
}
private SceneManager Scenes => GetTree().Root.GetNodeOrNull<SceneManager>("SceneManager");
private static DialogBox Box => SceneContextNode.FindNode<DialogBox>("DialogBox");
private void Tick()
{
if (index < 0)
return;
elapsed += TickSeconds;
if (failures.Count > 0 && !started)
{
Finish();
return;
}
if (elapsed > TimeoutSeconds)
{
Fail($"timeout at step {index + 1} (scene='{SceneManager.GetCurrentSceneName()}')");
Finish();
return;
}
if (waitTicks > 0)
{
waitTicks--;
return;
}
if (heldKey != Key.None)
{
Input.ParseInputEvent(new InputEventKey { PhysicalKeycode = heldKey, Keycode = heldKey, Pressed = false });
heldKey = Key.None;
waitTicks = NextGapTicks;
return;
}
if (!started)
{
if (!Boot.IsReady || string.IsNullOrEmpty(SceneManager.GetCurrentSceneName()))
return;
started = true;
GameLogger.Log("TEST", $"DocShotsDriver: started in '{SceneManager.GetCurrentSceneName()}', {steps.Count} steps");
waitTicks = SettleTicks;
return;
}
if (index >= steps.Count)
{
Finish();
return;
}
try
{
if (RunStep(steps[index]))
{
index++;
repeat = 0;
}
}
catch (Exception ex)
{
Fail($"step {index + 1} '{string.Join(' ', steps[index])}': {ex.GetType().Name}: {ex.Message}");
Finish();
}
}
/// <summary>Runs one step; false keeps it for the next tick.</summary>
private bool RunStep(string[] step)
{
var arg = step.Length > 1 ? step[1] : null;
switch (step[0])
{
case "scene":
if (pendingScene == null)
{
pendingScene = arg;
Box?.Abort();
Scenes.SwitchScene(arg);
return false;
}
if (SceneManager.GetCurrentSceneName() != pendingScene)
return false;
Check(true, $"entered '{pendingScene}'");
pendingScene = null;
waitTicks = SettleTicks;
return true;
case "time":
var parts = arg.Split(':');
var time = GameData.Instance.Runtime.Time;
time.SetTime(time.Tick - time.Tick % MinutesPerDay + int.Parse(parts[0]) * 60 + int.Parse(parts[1]));
waitTicks = NextGapTicks;
return true;
case "wait":
waitTicks = int.Parse(arg);
return true;
case "abort":
Box?.Abort();
waitTicks = NextGapTicks;
return true;
case "next":
Box?.NextAction();
waitTicks = NextGapTicks;
repeat++;
return repeat >= (arg == null ? 1 : int.Parse(arg));
case "waitanswer":
if (Box?.IsWaitingAnswer != true)
{
if (Box != null && Box.IsRunning)
Box.NextAction();
waitTicks = NextGapTicks;
return false;
}
Check(true, "dialog waits for an answer");
return true;
case "choose":
Check(Box != null && Box.TrySelectVariant(int.Parse(arg)), $"option {arg} taken");
waitTicks = SettleTicks;
return true;
case "panel":
UiRegistry.Toggle(arg);
waitTicks = SettleTicks / 2;
return true;
case "key":
heldKey = Enum.Parse<Key>(arg);
Input.ParseInputEvent(new InputEventKey { PhysicalKeycode = heldKey, Keycode = heldKey, Pressed = true });
waitTicks = step.Length > 2 ? int.Parse(step[2]) : 1;
return true;
case "tap":
Check(Tap(arg, int.Parse(step[2])), $"tap {arg} {step[2]}");
waitTicks = NextGapTicks;
return true;
case "press":
var label = string.Join(" ", step.Skip(1));
Check(Press(GetTree().Root, label), $"press '{label}'");
waitTicks = NextGapTicks;
return true;
case "give":
GameData.Instance.Runtime.Inventory.Add(arg, step.Length > 2 ? int.Parse(step[2]) : 1);
return true;
case "party":
GameData.Instance.Runtime.CharacterData.AddCharacterToParty(arg);
return true;
case "signal":
var source = GetTree().Root.FindChild(arg, true, false);
Check(source != null && source.HasSignal(step[2]), $"signal {arg} {step[2]}");
if (source != null && source.HasSignal(step[2]))
{
if (step.Length > 3)
source.EmitSignal(step[2], step[3]);
else
source.EmitSignal(step[2]);
}
waitTicks = NextGapTicks;
return true;
case "hover":
case "click":
Mouse(ParseFraction(arg), ParseFraction(step[2]), step[0] == "click");
waitTicks = NextGapTicks;
return true;
case "drag":
Drag(ParseFraction(arg), ParseFraction(step[2]), ParseFraction(step[3]), ParseFraction(step[4]));
waitTicks = NextGapTicks;
return true;
case "hurt":
Check(GameData.Instance.Runtime.CharacterData.Characters.TryGetValue(arg, out var hurt) && hurt.Attributes?.Health != null, $"hurt {arg}");
if (hurt?.Attributes?.Health != null)
hurt.Attributes.Health.Value = int.Parse(step[2]);
return true;
case "tool":
var provider = ToolFactory.Instance.GetAll().FirstOrDefault(t => t.Id == arg);
Check(provider != null, $"tool {arg}");
if (provider != null)
tools[arg] = lastTool = ToolWindow.ShowWindow(provider.CreateWindow());
waitTicks = SettleTicks;
return true;
case "type":
TypeText(string.Join(' ', step.Skip(1)));
waitTicks = SettleTicks / 3;
return true;
case "call":
CallTool(step);
waitTicks = SettleTicks / 2;
return true;
case "run":
var runner = GetTree().Root.GetNodeOrNull<Node>("/root/DialogRunner") as IInteractiveDialogRunner;
Check(runner != null, $"run {string.Join(' ', step.Skip(1))}");
runner?.ExecuteInlineCommands(string.Join(' ', step.Skip(1)));
waitTicks = NextGapTicks;
return true;
case "shot":
Shot(arg);
waitTicks = NextGapTicks;
return true;
default:
throw new InvalidOperationException("unknown step");
}
}
private void Check(bool condition, string what)
{
if (condition)
{
passed++;
GameLogger.Log("TEST", $"[PASS] {what}");
}
else
{
Fail(what);
}
}
private void Fail(string what)
{
failures.Add(what);
GameLogger.Log("TEST", $"[FAIL] {what}", LogLevel.Error);
}
private bool Tap(string nodeName, int child)
{
var parent = GetTree().Root.FindChild(nodeName, true, false);
if (parent == null || child >= parent.GetChildCount() || parent.GetChild(child) is not Control target)
return false;
target.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true });
return true;
}
private static bool Press(Node node, string label)
{
if (node is BaseButton button && button.IsVisibleInTree() && !button.Disabled
&& (((button as Button)?.Text ?? string.Empty).Trim() == label || HasLabel(button, label)))
{
button.EmitSignal(BaseButton.SignalName.Pressed);
return true;
}
foreach (var child in node.GetChildren())
if (Press(child, label))
return true;
return false;
}
private static bool HasLabel(Node node, string label)
{
foreach (var child in node.GetChildren())
if ((child is Label text && text.Text.Trim() == label) || HasLabel(child, label))
return true;
return false;
}
private void Mouse(float x, float y, bool click)
{
var viewport = GetViewport();
var position = new Vector2(x, y) * viewport.GetVisibleRect().Size;
viewport.WarpMouse(position);
viewport.PushInput(new InputEventMouseMotion { Position = position, GlobalPosition = position });
if (!click)
return;
viewport.PushInput(new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true, Position = position, GlobalPosition = position });
viewport.PushInput(new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = false, Position = position, GlobalPosition = position });
}
/// <summary>
/// Replaces the text of the focused line edit (in the last opened tool window, else in the game) and emits its
/// change signal, as a player's typing would; focus it first with <c>click</c>.
/// </summary>
private void TypeText(string text)
{
var viewport = GodotObject.IsInstanceValid(lastTool) ? (Viewport)lastTool : GetViewport();
var edit = viewport.GuiGetFocusOwner() as LineEdit;
Check(edit != null, $"type {text}");
if (edit == null)
return;
edit.Text = text;
edit.EmitSignal(LineEdit.SignalName.TextChanged, text);
}
/// <summary>
/// Calls a public method of a tool window opened by an earlier <c>tool</c> step, by name and with string
/// arguments: <c>call schema_palette SetValue stat str</c>. The tool types live in mods this suite does not
/// depend on, so the method is found by reflection; a missing window or method is a failure.
/// </summary>
private void CallTool(string[] step)
{
var window = step.Length > 2 && tools.TryGetValue(step[1], out var w) ? w : null;
var args = step.Skip(3).Cast<object>().ToArray();
var method = window?.GetType().GetMethods().FirstOrDefault(m => m.Name == step[2]
&& m.GetParameters().Length == args.Length && m.GetParameters().All(p => p.ParameterType == typeof(string)));
Check(method != null, $"call {string.Join(' ', step.Skip(1))}");
method?.Invoke(window, args);
}
private void Drag(float x1, float y1, float x2, float y2)
{
var viewport = GetViewport();
var size = viewport.GetVisibleRect().Size;
var from = new Vector2(x1, y1) * size;
var to = new Vector2(x2, y2) * size;
viewport.WarpMouse(from);
viewport.PushInput(new InputEventMouseMotion { Position = from, GlobalPosition = from });
viewport.PushInput(new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true, Position = from, GlobalPosition = from, ButtonMask = MouseButtonMask.Left });
for (var i = 1; i <= 8; i++)
{
var point = from.Lerp(to, i / 8f);
viewport.PushInput(new InputEventMouseMotion { Position = point, GlobalPosition = point, ButtonMask = MouseButtonMask.Left, Relative = (to - from) / 8f });
}
viewport.WarpMouse(to);
viewport.PushInput(new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = false, Position = to, GlobalPosition = to });
}
private static float ParseFraction(string text)
{
return float.Parse(text, System.Globalization.CultureInfo.InvariantCulture);
}
private void Shot(string name)
{
if (string.IsNullOrEmpty(shotsDir) || DisplayServer.GetName() == "headless")
return;
DirAccess.MakeDirRecursiveAbsolute(shotsDir);
ViewportCapture.SavePng(GetViewport(), shotsDir.TrimEnd('/', '\\') + "/" + name + ".png");
GameLogger.Log("TEST", $"DocShotsDriver: saved {name}.png");
}
private void Finish()
{
index = -1;
GameLogger.Log("TEST", $"[TEST_RESULT] name=doc_shots passed={passed} failed={failures.Count}");
Cthangover.Core.Bootstrap.ApplicationExit.Quit(GetTree(), failures.Count == 0 ? 0 : 1);
}
}
}
@@ -0,0 +1,41 @@
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Mods.Dialog.DocShots
{
/// <summary>
/// Entry point of the doc_shots suite: adds the driver when the exact flag
/// <c>--itest=doc_shots</c> is given. <c>--doc-steps=&lt;file&gt;</c> is the
/// steps file, <c>--shots=&lt;dir&gt;</c> where the pictures go.
/// </summary>
public class DocShotsTestInitializer : IModInitializer
{
private static bool driverQueued;
/// <summary>Nothing per mod.</summary>
public void OnModLoaded(string modId)
{
}
/// <summary>Queues the driver once.</summary>
public void OnModResourcesReady()
{
var args = OS.GetCmdlineUserArgs();
if (driverQueued || !args.Any(a => a == "--itest=doc_shots"))
return;
driverQueued = true;
var steps = ArgValue(args, "--doc-steps=");
var shots = ArgValue(args, "--shots=");
GameLogger.Log("TEST", $"DocShotsTestInitializer: steps={steps ?? "none"}, shots={shots ?? "off"}");
ModRoot.AddDeferred(new DocShotsDriver(steps, shots));
}
private static string ArgValue(string[] args, string prefix) =>
args.Where(a => a.StartsWith(prefix, System.StringComparison.Ordinal))
.Select(a => a.Substring(prefix.Length))
.FirstOrDefault();
}
}
@@ -0,0 +1,2 @@
mod_dialog_base__gdscript_name = Mod de prueba de integración con GDScript
mod_dialog_base__gdscript_desc = Pruebas de integración de acciones de mods en GDScript: carga de .gd, compilación, registro de acciones y ejecución de escenarios.
@@ -0,0 +1,2 @@
mod_dialog_base__gdscript_name = GDScript 統合テスト MOD
mod_dialog_base__gdscript_desc = GDScript による MOD アクションの統合テスト:.gd の読み込み、コンパイル、アクション登録、シナリオ実行。
@@ -0,0 +1,2 @@
mod_dialog_base__gdscript_name = GDScript 통합 테스트 모드
mod_dialog_base__gdscript_desc = GDScript 모드 액션의 통합 테스트: .gd 로드, 컴파일, 액션 등록, 시나리오 실행.
@@ -0,0 +1,2 @@
mod_dialog_base__gdscript_name = Mod kiểm thử tích hợp GDScript
mod_dialog_base__gdscript_desc = Kiểm thử tích hợp các hành động mod bằng GDScript: tải .gd, biên dịch, đăng ký hành động và chạy kịch bản.
@@ -0,0 +1,2 @@
mod_dialog_base__gdscript_name = GDScript 集成测试模组
mod_dialog_base__gdscript_desc = GDScript 模组动作的集成测试:.gd 加载、编译、动作注册和剧本执行。
+2 -2
View File
@@ -24,7 +24,7 @@ namespace Mods.DialogBase.Tests
return; return;
queued = true; queued = true;
GameLogger.Log("TEST", "GdscriptTest: activating"); GameLogger.Log("TEST", "GdscriptTest: activating");
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new GdscriptTestDriverNode()); ModRoot.AddDeferred(new GdscriptTestDriverNode());
} }
} }
@@ -123,7 +123,7 @@ namespace Mods.DialogBase.Tests
{ {
done = true; done = true;
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}"); GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");
GetTree().Quit(failed == 0 ? 0 : 1); Cthangover.Core.Bootstrap.ApplicationExit.Quit(GetTree(), failed == 0 ? 0 : 1);
} }
} }
} }
+8 -3
View File
@@ -19,9 +19,9 @@ namespace Cthangover.Mods.Dialog.Tests
var stage = await DialogStage.Create(); var stage = await DialogStage.Create();
var view = stage.AddViewBox(); var view = stage.AddViewBox();
await DialogStage.Frames(1); await DialogStage.Frames(1);
var frames = Frames(3);
try try
{ {
var frames = Frames(3);
var anim = new ActionAnimatedForeground { Textures = frames, Speed = 20f, NextFrameSpeed = 20f, IsLoop = false }; var anim = new ActionAnimatedForeground { Textures = frames, Speed = 20f, NextFrameSpeed = 20f, IsLoop = false };
var dlg = new DialogQueue(); var dlg = new DialogQueue();
dlg.Queue.Add(anim); dlg.Queue.Add(anim);
@@ -38,6 +38,7 @@ namespace Cthangover.Mods.Dialog.Tests
finally finally
{ {
stage.Free(); stage.Free();
foreach (var texture in frames) texture.Dispose();
} }
} }
@@ -47,9 +48,9 @@ namespace Cthangover.Mods.Dialog.Tests
var stage = await DialogStage.Create(); var stage = await DialogStage.Create();
var view = stage.AddViewBox(); var view = stage.AddViewBox();
await DialogStage.Frames(1); await DialogStage.Frames(1);
var frames = Frames(2);
try try
{ {
var frames = Frames(2);
var anim = new ActionAnimatedForeground { Textures = frames, Speed = 20f, NextFrameSpeed = 20f, IsLoop = true }; var anim = new ActionAnimatedForeground { Textures = frames, Speed = 20f, NextFrameSpeed = 20f, IsLoop = true };
var dlg = new DialogQueue(); var dlg = new DialogQueue();
dlg.Queue.Add(anim); dlg.Queue.Add(anim);
@@ -68,6 +69,7 @@ namespace Cthangover.Mods.Dialog.Tests
finally finally
{ {
stage.Free(); stage.Free();
foreach (var texture in frames) texture.Dispose();
} }
} }
@@ -75,7 +77,10 @@ namespace Cthangover.Mods.Dialog.Tests
{ {
var list = new List<Texture2D>(); var list = new List<Texture2D>();
for (var i = 0; i < count; i++) for (var i = 0; i < count; i++)
list.Add(ImageTexture.CreateFromImage(Image.CreateEmpty(2, 2, false, Image.Format.Rgba8))); {
using var image = Image.CreateEmpty(2, 2, false, Image.Format.Rgba8);
list.Add(ImageTexture.CreateFromImage(image));
}
return list; return list;
} }
} }
+25
View File
@@ -2,6 +2,7 @@ namespace Cthangover.Mods.Dialog.Tests
{ {
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Cthangover.Core.Events;
using Cthangover.Core.Mods.Testing; using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Settings; using Cthangover.Core.Settings;
using Cthangover.Mods.Dialog.Action.Impls; using Cthangover.Mods.Dialog.Action.Impls;
@@ -49,6 +50,30 @@ namespace Cthangover.Mods.Dialog.Tests
} }
} }
[ModTest("box/scene_exit_ends_running_dialog", TimeoutSeconds = 10)]
static async Task SceneExitEndsRunningDialog(ModTestContext ctx)
{
var stage = await DialogStage.Create();
try
{
var running = new DialogQueue();
running.Queue.Add(new ProbeAction());
ModAssert.True(stage.Box.SetDialogQueueAndRun(running, null, 0, null), "a dialog starts");
ModAssert.True(stage.Box.IsRunning, "the dialog occupies the box");
ModEventBus.Publish(new SceneExitedEvent { SceneName = "dialog_base_test_scene" });
ModAssert.False(stage.Box.IsRunning, "leaving the scene releases the box");
var next = new DialogQueue();
next.Queue.Add(new ProbeAction());
ModAssert.True(stage.Box.SetDialogQueueAndRun(next, null, 0, null), "the next scene's dialog starts on the freed box");
}
finally
{
stage.Free();
}
}
[ModTest("box/held_delay_is_not_skipped", TimeoutSeconds = 10)] [ModTest("box/held_delay_is_not_skipped", TimeoutSeconds = 10)]
static async Task HeldDelayIsNotSkipped(ModTestContext ctx) static async Task HeldDelayIsNotSkipped(ModTestContext ctx)
{ {
+52
View File
@@ -0,0 +1,52 @@
namespace Cthangover.Mods.Dialog.Tests
{
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Scenes.TscnBuild;
using Cthangover.Core.Scenes.TscnBuild.PropertyResolvers;
using Cthangover.Core.Scenes.TscnParsing;
using Cthangover.Mods.Interface.View;
using Godot;
/// <summary>Packing a nested prefab must release the temporary tree while preserving its children in the packed result.</summary>
public class ExtResourcePropertyResolverTests
{
private static Node temporary;
[ModTest("prefabs/packing_releases_the_temporary_tree")]
static void PackingReleasesTemporaryTree(ModTestContext ctx)
{
var target = new DialogBox();
try
{
var scene = new ParsedScene();
scene.ExtResources.Add(new ExtResourceRef { Id = "nested", Type = "PackedScene", Path = "test_nested.tscn" });
var context = new PropertyContext(scene, CreateTemporary, null);
new ExtResourcePropertyResolver().Apply(target, "answerBoxScene", TscnPropertyValue.FromExtResource("nested"), context);
ModAssert.False(GodotObject.IsInstanceValid(temporary), "packing frees its input instance immediately");
using var packed = target.Get("answerBoxScene").AsGodotObject() as PackedScene;
ModAssert.NotNull(packed, "the target retains the packed prefab");
var instance = packed.Instantiate();
try
{
ModAssert.Equal("PackedRoot", instance.Name.ToString(), "packed root survives freeing the temporary tree");
ModAssert.Equal(1, instance.GetChildCount(), "packed child is retained");
ModAssert.Equal("PackedChild", instance.GetChild(0).Name.ToString(), "child owners were assigned before packing");
}
finally { instance.Free(); }
}
finally
{
target.Free();
if (GodotObject.IsInstanceValid(temporary)) temporary.Free();
temporary = null;
}
}
private static Node CreateTemporary(string path)
{
temporary = new Control { Name = "PackedRoot" };
temporary.AddChild(new Label { Name = "PackedChild", Text = "nested prefab" });
return temporary;
}
}
}
+107
View File
@@ -0,0 +1,107 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.Utils;
using Cthangover.Mods.Dialog.Scenarios;
/// <summary>
/// <c>self.</c> in a dialog (contract <c>rpg-model-v2.md</c> 5.5): the queue's
/// <see cref="DialogQueue.Scope"/> — the placement that started it — turns <c>self.open</c> into the
/// story flag <c>&lt;placement&gt;:open</c> in <c>flag</c>, <c>when</c> and option conditions. Two
/// instances of one object keep their own state, which is an ordinary saved flag.
/// </summary>
public class InstanceScopeTests
{
private const string First = "test_scope_map/barrel_1";
private const string Second = "test_scope_map/barrel_2";
/// <summary>The shared scenario of both barrels: open once, then say so.</summary>
private const string Barrel = "when \"self.open == 1\" -> :opened\nflag self.open 1\nset outcome opened_now\nend\n:opened\nset outcome already_open\nend\n";
[ModTest("scope/two_instances_are_independent_and_survive_a_save", TimeoutSeconds = 10)]
static async Task TwoInstancesAreIndependentAndSurviveASave(ModTestContext ctx)
{
var stage = await DialogStage.Create();
var runtime = GameData.Instance.Runtime;
var savedFlags = runtime.Flags;
runtime.Flags = new Dictionary<string, string>();
try
{
ModAssert.Equal("opened_now", Play(stage, Barrel, First), "the first barrel opens");
ModAssert.Equal("already_open", Play(stage, Barrel, First), "the first barrel remembers it");
ModAssert.Equal("opened_now", Play(stage, Barrel, Second), "the second barrel is still closed");
ModAssert.Equal("1", runtime.Flags.TryGetValue(First + ":open", out var value) ? value : null, "the flag has the placement's full name");
ModAssert.Equal(2, runtime.Flags.Count, "no global 'open' flag is written");
ModAssert.True(ScenarioCondition.Evaluate(First + ":open == 1"), "other scenarios read it by its full name");
var slot = RuntimeSerializer.BuildCoreSlot(runtime, "1.0.0", "test", new HashSet<string>());
var json = JsonSerializer.Serialize(slot, JsonOptionsProvider.WriteCompactOptions);
runtime.Flags = new Dictionary<string, string>();
ModAssert.False(ScenarioCondition.Evaluate("self.open == 1", First), "state cleared before the load");
RuntimeSerializer.RestoreCoreState(JsonSerializer.Deserialize<ModSlot>(json, JsonOptionsProvider.ReadOptions), runtime);
ModAssert.True(ScenarioCondition.Evaluate("self.open == 1", First), "the first barrel is open after the load");
ModAssert.True(ScenarioCondition.Evaluate("self.open == 1", Second), "the second barrel is open after the load");
ModAssert.Equal("already_open", Play(stage, Barrel, Second), "the loaded state drives the dialog");
}
finally
{
runtime.Flags = savedFlags;
stage.Free();
}
}
[ModTest("scope/option_conditions_read_the_instance", TimeoutSeconds = 10)]
static async Task OptionConditionsReadTheInstance(ModTestContext ctx)
{
var stage = await DialogStage.Create();
var runtime = GameData.Instance.Runtime;
var savedFlags = runtime.Flags;
runtime.Flags = new Dictionary<string, string> { [First + ":open"] = "1" };
try
{
var dlg = ScenarioParser.Parse("select \"?\"\noption \"Close\" \"if=self.open == 1\" -> :a\noption \"Open\" \"if=self.open != 1\" -> :b\noption \"Leave\" -> :c\n:a\nend\n:b\nend\n:c\nend\n");
dlg.Scope = First;
ModAssert.True(stage.Box.SetDialogQueueAndRun(dlg, null, 0, null), "dialog starts");
ModAssert.Equal(2, stage.Box.AnswerVariantCount, "only the option of the open instance and the plain one");
}
finally
{
runtime.Flags = savedFlags;
stage.Free();
}
}
[ModTest("scope/self_without_a_scope_writes_nothing", TimeoutSeconds = 10)]
static async Task SelfWithoutAScopeWritesNothing(ModTestContext ctx)
{
var stage = await DialogStage.Create();
var runtime = GameData.Instance.Runtime;
var savedFlags = runtime.Flags;
runtime.Flags = new Dictionary<string, string>();
try
{
ModAssert.Equal("opened_now", Play(stage, Barrel, null), "the dialog still runs to its end");
ModAssert.Equal(0, runtime.Flags.Count, "self. outside a scope is never a global flag");
}
finally
{
runtime.Flags = savedFlags;
stage.Free();
}
}
private static string Play(DialogStage stage, string scenario, string scope)
{
var dlg = new ScenarioConverter().Convert(scenario);
dlg.Scope = scope;
ModAssert.True(stage.Box.SetDialogQueueAndRun(dlg, null, 0, null), "dialog starts");
return stage.Box.Runtime.GetVariable("outcome");
}
}
}
+107
View File
@@ -0,0 +1,107 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.Dialog.Scenarios;
using Godot;
/// <summary>Old string scripts, calculated assignments and action arguments share the existing DSL strategy pipeline.</summary>
public class NumericAssignmentTests
{
[ModTest("numeric_assignment/literals_case_and_assignment_syntax")]
static void Assignments(ModTestContext ctx)
{
var runtime=Run("set greeting Hello\nset literal 1+2\nset Value 7\ncset total \"1 + 2 + ${Value}\"\nset Money=rnd(3,3)\ncset quoted=\"${total} * 2\"\nend\n");
ModAssert.Equal("Hello",runtime.GetVariable("greeting"),"old strings stay strings");
ModAssert.Equal("1+2",runtime.GetVariable("literal"),"set does not guess expressions");
ModAssert.Equal("10",runtime.GetVariable("total"),"earlier numeric string is read at runtime");
ModAssert.Equal("3",runtime.GetVariable("Money"),"assigned variable keeps its case");
ModAssert.Equal("20",runtime.GetVariable("quoted"),"quoted assignment expression retains spaces");
ModAssert.Null(runtime.LastExpressionError,"valid assignments have no errors");
}
[ModTest("numeric_assignment/errors_abort_dependents_without_overwriting")]
static void Errors(ModTestContext ctx)
{
foreach(var expression in new[]{"1/0","${missing}+1","rnd(5,3)","max()"})
{
var runtime=Run("set amount 9\ncset amount \""+expression+"\"\nset after wrong\nend\n");
ModAssert.Equal("9",runtime.GetVariable("amount"),"bad assignment preserves the previous value for inspection");
ModAssert.Null(runtime.GetVariable("after"),"dependent actions never run after a bad calculation");
ModAssert.True(runtime.LastExpressionError?.Contains(":2:")==true,"error reports the source line");
}
var text=Run("set value abc\ncset result ${value}+1\nset after wrong\nend\n");
ModAssert.True(text.LastExpressionError?.Contains("not numeric")==true,"non-numeric variables are diagnosed");
}
[ModTest("numeric_assignment/invariant_format_and_json_tokens")]
static void Formatting(ModTestContext ctx)
{
var culture=CultureInfo.CurrentCulture;
try
{
// A hand-built comma culture: ru-RU/es-ES cannot be created where ICU is missing
// (invariant globalization), and the rule must hold for any culture anyway.
var comma=(CultureInfo)CultureInfo.InvariantCulture.Clone();
comma.NumberFormat.NumberDecimalSeparator=",";
comma.NumberFormat.NumberGroupSeparator=" ";
CultureInfo.CurrentCulture=comma;
ModAssert.Equal("2.5",Run("cset amount 5/2\nend\n").GetVariable("amount"),"stored numeric syntax does not depend on UI culture");
}
finally{CultureInfo.CurrentCulture=culture;}
var positional=new List<string>();var named=new Dictionary<string,string>();
ScenarioParser.SplitLine("light_set [{\"x\":0.2,\"color\":\"#fff\"}]",positional,named,out _);
ModAssert.Equal("[{\"x\":0.2,\"color\":\"#fff\"}]",positional[0],"JSON quotes are not stripped by assignment quoting");
ModAssert.Equal(0,named.Count,"JSON never becomes a named argument");
}
[ModTest("numeric_assignment/command_owned_validation")]
static void Validation(ModTestContext ctx)
{
IScenarioCommandSpec spec=new CsetCommandStrategy();
ModAssert.NotNull(spec.ValidateArguments(new(){"amount"},new(),""),"missing operand diagnosed");
ModAssert.NotNull(spec.ValidateArguments(new(){"amount","rnd(1)"},new(),""),"function arity checked without rolling");
ModAssert.Null(spec.ValidateArguments(new(),new(){["Money"]="rnd(3,5)"},""),"assignment form recognized by strategy contract");
var positional=new List<string>();var named=new Dictionary<string,string>{["Money"]="rnd(3,5)"};
spec.NormalizeArguments(positional,named);
ModAssert.Equal("Money",positional[0],"schema normalization preserves case");
ModAssert.Equal("rnd(3,5)",positional[1],"schema receives the unevaluated expression");
ModAssert.Equal(0,named.Count,"normalized arguments are canonical");
}
[ModTest("numeric_assignment/localized_text_and_action_share_one_value",TimeoutSeconds=10)]
static async Task TextAndAction(ModTestContext ctx)
{
var stage=await DialogStage.Create();var capture=new NumericCaptureAction();
ScenarioActionFactory.Instance.Register(capture);
try
{
var dialog=ScenarioParser.Parse("cset money=rnd(3,3)\naction dsl_test.capture id=money/coin count=${money}\nptext \"Fallback\" key=mod_dialog_base_name\nend\n",new NumericTextLocale());
stage.Box.SetDialogQueueAndRun(dialog,null,0,null);
ModAssert.Equal("3",capture.Amount,"action receives a number, never the placeholder");
ModAssert.Equal("money/coin",capture.Item,"ordinary string parameters still work");
ModAssert.Equal("Received 3 coins.",stage.Box.GetNode<Label>("DialogBody/TextField").Text,"localized ptext displays the same stored value");
stage.Box.NextAction();
ModAssert.Equal("3",stage.Box.Runtime.GetVariable("money"),"continuing the text does not reroll");
}
finally{ScenarioActionFactory.Instance.UnregisterAssembly(typeof(NumericCaptureAction).Assembly);stage.Free();}
}
[ModTest("numeric_assignment/undefined_action_parameter_stops_action")]
static void MissingParameter(ModTestContext ctx)
{
var capture=new NumericCaptureAction();ScenarioActionFactory.Instance.Register(capture);
try
{
var runtime=Run("set marker kept\naction dsl_test.capture count=${missing}\nset marker wrong\nend\n");
ModAssert.Null(capture.Amount,"invalid parameter is never delivered to the action");
ModAssert.Equal("kept",runtime.GetVariable("marker"),"later state changes are skipped");
ModAssert.True(runtime.LastExpressionError?.Contains("missing")==true,"undefined parameter is reported");
}
finally{ScenarioActionFactory.Instance.UnregisterAssembly(typeof(NumericCaptureAction).Assembly);}
}
private static DialogRuntime Run(string script)
{
var dialog=ScenarioParser.Parse(script);var runtime=new DialogRuntime();
runtime.SetDialogQueueAndRun(null,dialog.Queue,null);runtime.Run();return runtime;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Cthangover.Mods.Dialog.Tests
{
using Cthangover.Core.Actions;
internal class NumericCaptureAction : IScenarioAction
{
public string Name=>"dsl_test.capture";
public string Amount {get;private set;}
public string Item {get;private set;}
public void Run(IActionContext context){Amount=context.GetParam("count");Item=context.GetParam("id");}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Cthangover.Mods.Dialog.Tests
{
using Cthangover.Core.Localization;
internal class NumericTextLocale : ILocalizationProvider
{
public string Get(string key)=>key=="mod_dialog_base_name"?"Received ${money} coins.":null;
}
}
+59
View File
@@ -0,0 +1,59 @@
namespace Cthangover.Mods.Dialog.Tests
{
using System.Linq;
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.Dialog.Action.Impls;
using Cthangover.Mods.Dialog.Scenarios;
/// <summary>
/// <c>option … if=…</c>: an option is offered only while its condition holds; a choice whose
/// options are all hidden is skipped, so the dialog never waits for an answer nobody can give.
/// </summary>
public class OptionConditionTests
{
[ModTest("options/parser_keeps_the_condition")]
static void ParserKeepsTheCondition(ModTestContext ctx)
{
var dlg = ScenarioParser.Parse("select \"?\"\noption \"A\" -> :a\noption \"B\" \"if=door == 'open' && !false\" -> :b\n:a\nend\n:b\nend\n");
var select = dlg.Queue.OfType<ActionSelect>().Single();
ModAssert.Null(select.Variants[0].Condition, "a plain option has no condition");
ModAssert.Equal("door == 'open' && !false", select.Variants[1].Condition, "the quoted pair keeps spaces");
ModAssert.Equal("b", select.Variants[1].GoTo, "the jump target stays");
}
[ModTest("options/hidden_options_are_not_offered", TimeoutSeconds = 10)]
static async Task HiddenOptionsAreNotOffered(ModTestContext ctx)
{
var stage = await DialogStage.Create();
try
{
var dlg = ScenarioParser.Parse("select \"?\"\noption \"A\" if=false -> :a\noption \"B\" if=true -> :b\noption \"C\" -> :c\n:a\nend\n:b\nend\n:c\nend\n");
ModAssert.True(stage.Box.SetDialogQueueAndRun(dlg, null, 0, null), "dialog starts");
ModAssert.True(stage.Box.IsWaitingAnswer, "waits for an answer");
ModAssert.Equal(2, stage.Box.AnswerVariantCount, "the false option is hidden");
}
finally
{
stage.Free();
}
}
[ModTest("options/all_hidden_skips_the_choice", TimeoutSeconds = 10)]
static async Task AllHiddenSkipsTheChoice(ModTestContext ctx)
{
var stage = await DialogStage.Create();
try
{
var dlg = ScenarioParser.Parse("select \"?\"\noption \"A\" if=false -> :a\n\nset outcome skipped\nend\n:a\nset outcome a\nend\n");
stage.Box.SetDialogQueueAndRun(dlg, null, 0, null);
ModAssert.False(stage.Box.IsWaitingAnswer, "no answer is awaited");
ModAssert.Equal("skipped", stage.Box.Runtime.GetVariable("outcome"), "the dialog went on past the choice");
}
finally
{
stage.Free();
}
}
}
}
+42
View File
@@ -0,0 +1,42 @@
namespace Cthangover.Mods.Dialog.Tests
{
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Scenes;
using Cthangover.Mods.Dialog.Scenarios;
/// <summary><c>when</c> jumps on the shared condition language, evaluated when the line runs.</summary>
public class WhenCommandStrategyTests
{
private static string Outcome(string scenario)
{
var dlg = ScenarioParser.Parse(scenario);
var runtime = new DialogRuntime();
runtime.SetDialogQueueAndRun(null, dlg.Queue, null);
runtime.Run();
return runtime.GetVariable("outcome");
}
[ModTest("when/true_false_and_else")]
static void TrueFalseAndElse(ModTestContext ctx)
{
ModAssert.Equal("yes", Outcome("when true -> :yes\nset outcome no\nend\n:yes\nset outcome yes\nend\n"), "true jumps");
ModAssert.Equal("no", Outcome("when false -> :yes\nset outcome no\nend\n:yes\nset outcome yes\nend\n"), "false falls through");
ModAssert.Equal("other", Outcome("when false -> :yes else=other\nset outcome no\nend\n:yes\nset outcome yes\nend\n:other\nset outcome other\nend\n"), "false takes else");
}
[ModTest("when/reads_state_when_the_line_runs")]
static void ReadsStateWhenTheLineRuns(ModTestContext ctx)
{
ScenarioCondition.SetFlag("test_when_flag", "0");
try
{
const string scenario = "flag test_when_flag 2\nwhen \"test_when_flag >= 2 && !false\" -> :yes\nset outcome no\nend\n:yes\nset outcome yes\nend\n";
ModAssert.Equal("yes", Outcome(scenario), "a flag set two lines earlier counts");
}
finally
{
ScenarioCondition.SetFlag("test_when_flag", "0");
}
}
}
}