Compare commits

..
24 Commits
Author SHA1 Message Date
cthangover 0d55b357eb chore: align mod versions and dependencies to 0.1.0
Complete the 0.1.0 demo version and dependency migration in the remaining manifests. Use LF line endings under the repository text policy.
2026-11-13 18:12:10 +01:00
cthangover 877ae0386f feat: use authored category names for mods
Store categories as authored strings. Trim and deduplicate without changing case, compare without case sensitivity, and keep all observed categories available to filters and editors.
2026-11-13 17:47:13 +01:00
cthangover 869a15875e test: replace fixed waits with observable GUI readiness
Advance integration drivers only after their requested controls, game state or file effects are observable. Preserve the behavioral assertions and shared viewport capture pipeline.
2026-11-13 17:47:11 +01:00
cthangover af5f17a4ee chore: align mod versions and dependencies to 0.1.0
Set the demo release floor to 0.1.0 in manifests, dependency requirements and authoring examples. Keep category and runtime changes separate.
2026-11-13 17:47:09 +01:00
cthangover 6d89a19696 chore: align game versions and dependencies with 0.1.0 2026-11-06 18:01:04 +01:00
cthangover cf993e0d1e docs: update tool ownership and scenario validation guidance
Describe the independent editor mods and compatibility package, RPG objects, and rejection of invalid ambient programs before saving.
2026-11-04 08:45:55 +01:00
cthangover f8a0cb588f test: cover scenario editor timer reattachment
Open a scenario, edit its buffer while the window is detached and verify refreshed metadata after reattachment without saving the file.
2026-11-04 08:44:50 +01:00
cthangover b9507b80c3 Update editor_tools implementation, assets and documentation 2026-10-31 15:28:50 +03:00
cthangover f5cf990d72 Turn editor_tools into a package of the split tool mods
The code moved to tools_base, scenario_tools_base and the seven editor mods;
editor_tools depends on them and keeps the tool windows suite. Docs list all
seven tool box entries with a new tool box screenshot.
2026-10-29 13:22:26 +01:00
cthangover 152096337b Add mod documentation in seven languages 2026-10-29 09:25:54 +01:00
iisaev 8944ba756c gitignore file 2026-10-28 15:19:56 +03:00
cthangover 5be5bed0c5 Support schema field options in entity editors 2026-10-27 01:07:29 +01:00
cthangover cbc9472738 Highlight JSON, TSCN and scenario fields across editor tools 2026-10-26 19:44:26 +03:00
cthangover 84894b1e98 Test: toggle states keep their margins 2026-10-24 18:39:39 +02:00
cthangover 80c86063c4 Entity list icons shrink to the list width 2026-10-24 12:56:09 +02:00
cthangoverandClaude Opus 5.5 93ebb12c58 Root nodes join through ModRoot.AddDeferred, so a reload that unloads the mod first never hands Godot a freed node
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:29:12 +02:00
cthangoverandClaude Opus 5.5 033192dcc9 Schema field editors (scalar, list, map, locale); entity editors show fields that other mods' schemas add to their kind
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:01:34 +02:00
cthangover 3ddb61d61e Localization in es, zh, ja, ko, vi; novel editor hub removed (its collection is gone, tools open from the menu and the toolbar) 2026-10-23 20:54:37 +02:00
cthangover e1a1d770cf Data editor base: EntityEditorWindow (searchable list, form over JSON entities, texts in every language of the mod, other fields as JSON), EntityDocument, LocaleFileService, PropertiesText; mod tests 2026-10-23 19:09:39 +02:00
cthangover 6ca1fff35f Test screenshots through ViewportCapture: sRGB instead of the dark linear HDR frame 2026-10-23 09:03:55 +02:00
cthangover 3b2c1ddb95 Scene re-read through SceneManager.ReloadContent 2026-10-22 19:14:55 +02:00
cthangover 972df5ccff Scene editor for logical scenes; scenario editor Save refreshes the running game; novel_flow itest; mod cover 2026-10-22 17:09:38 +02:00
cthangover 2b8eaa5452 Line endings: LF everywhere
.gitattributes forces LF (CRLF only for .bat/.cmd), core.autocrlf is off;
files that differed only by CRLF are normalized.
2026-10-21 13:29:40 +02:00
cthangover 5f3f4b0d82 Commit pending working-tree changes (local edits, line endings) 2026-10-21 12:31:53 +02:00
99 changed files with 1275 additions and 11236 deletions

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
* text=auto eol=lf
# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf
# Binaries: never converted, never diffed as text.
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.svg text
*.ogg binary
*.wav binary
*.mp3 binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.dll binary
*.so binary
*.dylib binary
*.pdb binary
*.exe binary
*.zip binary
*.pck binary
*.moc3 binary
*.res binary
*.scn binary
+38
View File
@@ -0,0 +1,38 @@
# Godot 4.x
.godot/
.idea/
sdk/
bin/
obj/
#ai
.omo/
.opencode/
gen/
obj/
bin/
art_sources/
*.tres~
*.tscn~
# GDScript LSP
.godot/
# Export
*.exe
*.dmg
*.apk
*.aab
*.pck
*.zip
*.log
# OS
.DS_Store
Thumbs.db
# Editor
*.import
*.godot.uid
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+72
View File
@@ -0,0 +1,72 @@
# Editor Tools — making scenes and scenarios inside the running game
Editor Tools are the author's windows that open over the running game: the scene editor, the scenario editor, editors for the world map, interactive objects and lights, a builder of Godot scenes and the catalog export of a mod. Everything they save is ordinary mod files, and the game picks the change up at once, so an author writes a place, walks into it and plays it without restarting.
Compatibility package: the seven windows now live in their own mods (`scenario_editor`, `scene_editor`, `scene_builder`, `map_editor`, `interactive_editor`, `light_editor`, `catalog_export`). Shared forms and files are in `tools_base`, scenario services in `scenario_tools_base`. All windows open from the in-game toolbar; no title-screen buttons are added.
## Opening the tools
In a game, the hammer button on the toolbar opens the tool box:
![The toolbar of the novel template with the hammer pressed and the tool box open: Export Catalog Entry, Interactive Editor, Light Editor, Map Editor, Scenario Editor, Scene Builder, Scene editor.](assets/toolbox.jpg)
- All seven windows are in the tool box: **Scene editor**, **Scenario Editor**, **Map Editor**, **Interactive Editor**, **Light Editor**, **Scene Builder** and **Export Catalog Entry**. Other tool mods add their own entries there (the item, character, RPG map and object editors, the Data Editor).
- The **scenario editor** also opens from the scene editor: select a scene, pick one of its scenarios and press **Open**, or **+ New scenario**.
The windows stay open across scene changes, can be moved and resized, and close with the cross.
## A whole small edit: a new place
The novel template has a town, a bar and a home. Here an author adds a park that can be reached from the town, using only the editors.
**1. A new scene.** In the scene editor press **+ New scene**, type the name, choose the mod to save it to and add a background:
![The scene editor: the scenes my_novel_bar, my_novel_home and my_novel_town of my_novel_game on the left; in the middle the background home/vestibule added to the scene; on the right Name my_novel_park, Save to my_novel_game, Sound none, Default scenario none.](assets/new-scene.jpg)
Save (Ctrl+S) writes `scenes/my_novel_park.json` into `my_novel_game`; the game knows the scene at once.
**2. Its scenario.** **+ New scenario** opens the scenario editor with a file for the scene. The park offers one way back:
![The scenario editor with my_novel_park.scenario: scene my_novel_park, priority 10, the narration "A quiet park behind the town. Wet leaves, an empty bench.", the choice "What next?" with "Back to the town" leading to switch_scene my_novel_town.](assets/park-scenario.jpg)
**3. A way there.** In the town's scenario one answer and one label are added:
![The scenario editor with my_novel_town.scenario: the choice "Where to go?" now has the answer "Go to the park" -> :park, and the label :park does switch_scene my_novel_park.](assets/town-scenario.jpg)
```scenario
option "Go to the park" -> :park
:park
switch_scene my_novel_park
end
```
Saving a scenario drops the game's scenario caches, so the next entry into the town plays the new text.
**4. Play it.** **Go to scene** in the scene editor, or simply playing on, enters the town, and the new answer is there:
![The town in the running game: "Where to go?" with Go into the bar, Go to the park, Go back home.](assets/town-choice.jpg)
![The new park after choosing Go to the park: "What next?" with Back to the town.](assets/park.jpg)
## The editors
- **Scene editor** — the places of a novel: name, the mod to save to, backgrounds (one is picked at random on entry), ambient sound with a listen button, the default scenario and the scene's scenarios. **Go to scene** ends the running dialog and enters the scene.
- **Scenario editor** — the files of every mod, a summary and a graph of scenes and transitions; DSL highlighting; Ctrl+S saves; **Play** (F5) saves and plays the scenario inside the running game, and Stop returns to where the game was; **Open in external editor** opens the file in the system's editor.
- **Map Editor** — the zones of the world map.
- **Interactive Editor** — clickable objects of the current scene.
- **Light Editor** — the lights of the current scene.
- **Scene builder** — Godot `.tscn` scenes for mods.
- **Catalog export** — packs a mod into a zip and writes its entry for a mod catalog with checksums.
The Validator checks cross-mod references. Modal scenario saving does not perform a full Validator pass. With `npc_barks_base` loaded, invalid ambient programs and ambient patches are refused before saving; the status line reports the compilation error.
## Whose files are written
The tools write only into mods installed as folders. A scene is the sum of the `scenes/<name>.json` files of all mods, so the scene editor writes the file of the mod chosen in **Save to**. Changing a scene that belongs to another or a packed mod creates a file in your mod that adds to it; backgrounds that come from other files are shown locked. Scenarios are saved in the mod they belong to.
## If something goes wrong
1. **The hammer button is missing.** The game does not include the tools; add the Developer Tools toolset in its collection or run with `--with-mods=editor_tools`.
2. **A new scenario does not play.** Its header names another scene, or another scenario of the scene is chosen first (compare their `priority`).
3. **Save to lists no mods.** Only mods installed as folders can be written to.
+74
View File
@@ -0,0 +1,74 @@
# Editor Tools — crear escenas y escenarios dentro del juego en marcha
`editor_tools` es un paquete de compatibilidad sin código: instala `scenario_editor`, `scene_editor`, `scene_builder`, `map_editor`, `interactive_editor`, `light_editor` y `catalog_export`. Las bibliotecas compartidas son `tools_base` y `scenario_tools_base`.
Editor Tools son las ventanas de autor que se abren sobre el juego en marcha: el editor de escenas, el de escenarios, los editores del mapa del mundo, de objetos interactivos y de luces, un constructor de escenas de Godot y la exportación al catálogo de un mod. Todo lo que guardan son archivos de mod normales, y el juego toma el cambio al instante, así que puedes escribir un lugar, entrar en él y jugarlo sin reiniciar.
Paquete de compatibilidad: las siete ventanas viven en mods independientes. `tools_base` contiene los componentes comunes y `scenario_tools_base` los servicios de escenarios. Todas se abren desde la barra del juego, sin botones en el menú principal.
## Cómo abrir las herramientas
Dentro de un juego, el botón del martillo de la barra de herramientas abre la caja de herramientas:
![La barra de herramientas de la plantilla de novela con el martillo pulsado y la caja de herramientas abierta: Export Catalog Entry, Interactive Editor, Light Editor, Map Editor, Scenario Editor, Scene Builder, Scene editor.](assets/toolbox.jpg)
- Las siete ventanas están en la caja: **Editor de escenas**, **Editor de escenarios**, **Editor de mapas**, **Editor de interactivos**, **Editor de luces**, **Constructor de escenas** y **Exportar entrada del catálogo**. Otros mods de herramientas añaden ahí sus propias entradas (los editores de objetos, de personajes, de mapas RPG y de objetos RPG, el editor de datos).
- El **editor de escenarios** también se abre desde el editor de escenas: selecciona una escena, elige uno de sus escenarios y pulsa **Abrir**, o **+ Nuevo escenario**.
Las ventanas siguen abiertas al cambiar de escena, se pueden mover y redimensionar, y se cierran con la cruz.
## Una edición pequeña completa: un lugar nuevo
La plantilla de novela tiene un pueblo, un bar y una casa. Aquí añadiremos un parque al que se llega desde el pueblo usando solo los editores.
**1. Una escena nueva.** En el editor de escenas pulsa **+ Nueva escena**, escribe el nombre, elige el mod donde guardarla y añade un fondo:
![El editor de escenas: a la izquierda, las escenas my_novel_bar, my_novel_home y my_novel_town de my_novel_game; en el centro, el fondo home/vestibule añadido a la escena; a la derecha, Nombre my_novel_park, Guardar en my_novel_game, Sonido ninguno, Escenario por defecto ninguno.](assets/new-scene.jpg)
Guardar (Ctrl+S) escribe `scenes/my_novel_park.json` en `my_novel_game`; el juego conoce la escena al instante.
**2. Su escenario.** **+ Nuevo escenario** abre el editor de escenarios con un archivo para la escena. El parque ofrece una única salida:
![El editor de escenarios con my_novel_park.scenario: escena my_novel_park, prioridad 10, la narración «Un parque tranquilo detrás del pueblo. Hojas mojadas, un banco vacío.», la elección «¿Qué hago ahora?» con «Volver al pueblo» que lleva a switch_scene my_novel_town.](assets/park-scenario.jpg)
**3. Un camino hasta allí.** En el escenario del pueblo se añaden una respuesta y una etiqueta:
![El editor de escenarios con my_novel_town.scenario: la elección «¿Adónde ir?» tiene ahora la respuesta «Ir al parque» -> :park, y la etiqueta :park hace switch_scene my_novel_park.](assets/town-scenario.jpg)
```scenario
option "Ir al parque" -> :park
:park
switch_scene my_novel_park
end
```
Guardar un escenario descarta las cachés de escenarios del juego, así que la próxima entrada al pueblo ya reproduce el texto nuevo.
**4. Juégalo.** **Ir a la escena** en el editor de escenas, o simplemente seguir jugando, entra en el pueblo, y la nueva respuesta ya está ahí:
![El pueblo en el juego en marcha: «¿Adónde ir?» con Entrar en el bar, Ir al parque, Volver a casa.](assets/town-choice.jpg)
![El parque nuevo tras elegir Ir al parque: «¿Qué hago ahora?» con Volver al pueblo.](assets/park.jpg)
## Los editores
- **Editor de escenas**: los lugares de una novela: nombre, el mod donde guardar, fondos (al entrar se elige uno al azar), sonido ambiente con botón de escucha, el escenario por defecto y los escenarios de la escena. **Ir a la escena** termina el diálogo en curso y entra en la escena.
- **Editor de escenarios**: los archivos de todos los mods, un resumen y un grafo de escenas y transiciones; resaltado de DSL; Ctrl+S guarda; **Jugar** (F5) guarda y reproduce el escenario dentro del juego en marcha, y Detener vuelve a donde estaba el juego; **Abrir en un editor externo** abre el archivo en el editor del sistema.
- **Editor de mapas**: las zonas del mapa del mundo.
- **Editor de interactivos**: los objetos clicables de la escena actual.
- **Editor de luces**: las luces de la escena actual.
- **Constructor de escenas**: escenas `.tscn` de Godot para los mods.
- **Exportación al catálogo**: empaqueta un mod en un zip y escribe su entrada para un catálogo de mods con sumas de comprobación.
Validator comprueba referencias entre mods; guardar un escenario modal no ejecuta su revisión completa. Con `npc_barks_base`, los programas de ambiente y sus parches inválidos se rechazan antes de guardar; el estado muestra el error de compilación.
## De quién son los archivos que se escriben
Las herramientas solo escriben en mods instalados como carpetas. Una escena es la suma de los archivos `scenes/<name>.json` de todos los mods, así que el editor de escenas escribe el archivo del mod elegido en **Guardar en**. Si cambias una escena que pertenece a otro mod o a un mod empaquetado, se crea un archivo en tu mod que la amplía; los fondos que vienen de otros archivos se muestran bloqueados. Los escenarios se guardan en el mod al que pertenecen.
## Si algo sale mal
1. **No aparece el botón del martillo.** El juego no incluye las herramientas; añade el conjunto Developer Tools a su colección o ejecútalo con `--with-mods=editor_tools`.
2. **Un escenario nuevo no se reproduce.** Su cabecera nombra otra escena, o antes se elige otro escenario de la escena (compara sus `priority`).
3. **Guardar en no muestra ningún mod.** Solo se puede escribir en mods instalados como carpetas.
+74
View File
@@ -0,0 +1,74 @@
# Editor Tools — 実行中のゲーム内でシーンとシナリオを作る
`editor_tools` はコードを持たない互換パッケージです。`scenario_editor`、`scene_editor`、`scene_builder`、`map_editor`、`interactive_editor`、`light_editor`、`catalog_export` を導入します。共有ライブラリは `tools_base` と `scenario_tools_base` です。
Editor Tools は、実行中のゲームの上に開く作者向けのウィンドウ群です。シーンエディター、シナリオエディター、ワールドマップ・インタラクティブ・ライトの各エディター、Godot シーンのビルダー、MOD カタログの書き出しが含まれます。保存されるのはどれも普通の MOD ファイルで、ゲームは変更をすぐに取り込みます。そのため、場所を書いて、その場に入って遊ぶまでを、再起動なしで行えます。
互換パッケージです。7つのウィンドウは独立したMODになり、共通機能は `tools_base`、シナリオ機能は `scenario_tools_base` にあります。すべてゲーム内ツールバーから開き、メインメニューにはボタンを追加しません。
## ツールを開く
ゲーム内では、ツールバーのハンマーのボタンでツールボックスが開きます:
![ノベルのテンプレートのツールバー。ハンマーが押され、ツールボックスが開いている: Export Catalog Entry、Interactive Editor、Light Editor、Map Editor、Scenario Editor、Scene Builder、Scene editor。](assets/toolbox.jpg)
- 7 つのウィンドウはすべてツールボックスにあります: **シーンエディター**、**シナリオエディター**、**マップエディター**、**インタラクティブエディター**、**ライトエディター**、**シーンビルダー**、**カタログ項目を書き出す**。ほかのツール MOD もそこに独自の項目を追加します(アイテム、キャラクター、RPG マップ、RPG オブジェクトのエディター、データエディター)。
- **シナリオエディター**はシーンエディターからも開けます。シーンを選び、そのシナリオのひとつを選んで**開く**を押すか、**+ 新しいシナリオ**を押します。
ウィンドウはシーンを切り替えても開いたまま残り、移動やサイズ変更ができ、×で閉じます。
## 小さな編集をひと通り: 新しい場所
ノベルのテンプレートには、町、バー、家があります。ここではエディターだけを使って、町から行ける公園を追加します。
**1. 新しいシーン。** シーンエディターで **+ 新しいシーン**を押し、名前を入力し、保存先の MOD を選んで背景を追加します:
![シーンエディター: 左に my_novel_game の my_novel_bar、my_novel_home、my_novel_town。中央にはシーンに追加した背景 home/vestibule。右は名前 my_novel_park、保存先 my_novel_game、音 なし、既定のシナリオ なし。](assets/new-scene.jpg)
保存(Ctrl+S)すると `scenes/my_novel_park.json` が `my_novel_game` に書き込まれ、ゲームはそのシーンをすぐに認識します。
**2. そのシナリオ。** **+ 新しいシナリオ**を押すと、そのシーンのファイルを開いたシナリオエディターが立ち上がります。公園からは戻る道が一本だけあります:
![my_novel_park.scenario を開いたシナリオエディター: シーン my_novel_park、優先度 10、ナレーション「町の裏手にある静かな公園。濡れた落ち葉、誰もいないベンチ。」、選択肢「次はどうする?」の「町へ戻る」が switch_scene my_novel_town へ続く。](assets/park-scenario.jpg)
**3. そこへ行く道。** 町のシナリオに、答えをひとつとラベルをひとつ追加します:
![my_novel_town.scenario を開いたシナリオエディター: 選択肢「どこへ行く?」に「公園へ行く」-> :park が加わり、ラベル :park が switch_scene my_novel_park を実行する。](assets/town-scenario.jpg)
```scenario
option "公園へ行く" -> :park
:park
switch_scene my_novel_park
end
```
シナリオを保存するとゲームのシナリオキャッシュが破棄されるので、次に町へ入ったときには新しいテキストが再生されます。
**4. 遊んでみる。** シーンエディターの**シーンへ移動**を押すか、そのまま遊び続けると町に入り、新しい答えがそこにあります:
![実行中のゲームの町:「どこへ行く?」に、バーに入る、公園へ行く、家に帰る。](assets/town-choice.jpg)
![「公園へ行く」を選んだあとの新しい公園:「次はどうする?」に、町へ戻る。](assets/park.jpg)
## 各エディター
- **シーンエディター** — ノベルの場所: 名前、保存先の MOD、背景(入るたびにひとつがランダムで選ばれます)、試聴ボタン付きの環境音、既定のシナリオ、そのシーンのシナリオ。**シーンへ移動**は進行中のダイアログを終わらせてシーンに入ります。
- **シナリオエディター** — すべての MOD のファイル、概要、シーンと遷移のグラフ、DSL のハイライト、Ctrl+S で保存。**プレイ**(F5)は保存して実行中のゲーム内でシナリオを再生し、停止するとゲームが元いた場所へ戻ります。**外部エディターで開く**はシステムのエディターでファイルを開きます。
- **マップエディター** — ワールドマップのゾーン。
- **インタラクティブエディター** — 現在のシーンのクリックできるオブジェクト。
- **ライトエディター** — 現在のシーンのライト。
- **シーンビルダー** — MOD 用の Godot `.tscn` シーン。
- **カタログの書き出し** — MOD を zip にまとめ、チェックサム付きで MOD カタログ用のエントリーを書き出します。
Validator が MOD 間の参照を検査します。通常会話の保存では全検査は行いません。`npc_barks_base` があれば、不正な環境シナリオとそのパッチは保存前に拒否され、コンパイルエラーが状態欄に表示されます。
## どの MOD のファイルが書かれるか
ツールが書き込むのは、フォルダーとしてインストールされた MOD だけです。シーンは全 MOD の `scenes/<name>.json` の合計なので、シーンエディターは**保存先**で選んだ MOD のファイルを書き込みます。他の MOD やパッケージ済みの MOD に属するシーンを変更すると、それを補うファイルがあなたの MOD に作られ、他のファイル由来の背景はロックされて表示されます。シナリオは所属する MOD に保存されます。
## うまくいかないとき
1. **ハンマーのボタンがない。** そのゲームにツールが入っていません。コレクションに Developer Tools ツールセットを追加するか、`--with-mods=editor_tools` で起動してください。
2. **新しいシナリオが再生されない。** ヘッダーが別のシーンを指しているか、そのシーンの別のシナリオが先に選ばれています(`priority` を比べてください)。
3. **保存先に MOD が出ない。** 書き込めるのはフォルダーとしてインストールされた MOD だけです。
+74
View File
@@ -0,0 +1,74 @@
# Editor Tools — 실행 중인 게임 안에서 장면과 시나리오 만들기
`editor_tools`는 코드가 없는 호환 패키지입니다. `scenario_editor`, `scene_editor`, `scene_builder`, `map_editor`, `interactive_editor`, `light_editor`, `catalog_export`를 설치합니다. 공통 라이브러리는 `tools_base`, `scenario_tools_base`입니다.
Editor Tools는 실행 중인 게임 위에 열리는 창작용 창 모음입니다. 장면 에디터, 시나리오 에디터, 월드 맵·상호작용·조명 에디터, Godot 장면 빌더, 모드 카탈로그 내보내기가 들어 있습니다. 저장되는 것은 모두 평범한 모드 파일이고 게임은 변경을 곧바로 받아들입니다. 그래서 장소를 쓰고, 바로 들어가 플레이하는 일을 재시작 없이 할 수 있습니다.
호환 패키지입니다. 일곱 도구 창은 독립 모드로 분리되었으며 공통 기능은 `tools_base`, 시나리오 기능은 `scenario_tools_base`에 있습니다. 모든 창은 게임 도구 모음에서 열며 주 메뉴에는 버튼을 추가하지 않습니다.
## 도구 열기
게임 안에서 도구 모음의 망치 버튼을 누르면 도구 상자가 열립니다:
![소설 템플릿의 도구 모음. 망치가 눌려 있고 도구 상자가 열려 있습니다: Export Catalog Entry, Interactive Editor, Light Editor, Map Editor, Scenario Editor, Scene Builder, Scene editor.](assets/toolbox.jpg)
- 일곱 창 모두 도구 상자에 있습니다: **장면 에디터**, **시나리오 에디터**, **맵 에디터**, **상호작용 에디터**, **조명 에디터**, **장면 빌더**, **카탈로그 항목 내보내기**. 다른 도구 모드도 그곳에 자기 항목을 추가합니다(아이템, 캐릭터, RPG 맵, RPG 물체 에디터, 데이터 에디터).
- **시나리오 에디터**는 장면 에디터에서도 엽니다. 장면을 고르고 그 시나리오 중 하나를 선택한 뒤 **열기**를 누르거나 **+ 새 시나리오**를 누릅니다.
창은 장면을 바꿔도 열린 채로 남고, 옮기고 크기를 바꿀 수 있으며 ×로 닫습니다.
## 작은 편집 하나를 끝까지: 새 장소
소설 템플릿에는 마을, 술집, 집이 있습니다. 여기서는 에디터만 써서 마을에서 갈 수 있는 공원을 추가해 보겠습니다.
**1. 새 장면.** 장면 에디터에서 **+ 새 장면**을 누르고 이름을 입력하고 저장할 모드를 고른 다음 배경을 추가합니다:
![장면 에디터: 왼쪽에 my_novel_game의 my_novel_bar, my_novel_home, my_novel_town 장면. 가운데에는 장면에 추가한 배경 home/vestibule. 오른쪽은 이름 my_novel_park, 저장할 곳 my_novel_game, 소리 없음, 기본 시나리오 없음.](assets/new-scene.jpg)
저장(Ctrl+S)하면 `scenes/my_novel_park.json`이 `my_novel_game`에 기록되고, 게임은 그 장면을 곧바로 인식합니다.
**2. 그 장면의 시나리오.** **+ 새 시나리오**를 누르면 그 장면의 파일이 열린 시나리오 에디터가 뜹니다. 공원에서는 돌아가는 길이 하나뿐입니다:
![my_novel_park.scenario를 연 시나리오 에디터: 장면 my_novel_park, 우선순위 10, 내레이션 "마을 뒤의 조용한 공원. 젖은 낙엽, 비어 있는 벤치.", 선택지 "이제 뭘 하지?"의 "마을로 돌아가기"가 switch_scene my_novel_town으로 이어집니다.](assets/park-scenario.jpg)
**3. 그곳으로 가는 길.** 마을 시나리오에 답 하나와 레이블 하나를 추가합니다:
![my_novel_town.scenario를 연 시나리오 에디터: 선택지 "어디로 갈까?"에 "공원으로 가기" -> :park가 생겼고, :park 레이블이 switch_scene my_novel_park를 실행합니다.](assets/town-scenario.jpg)
```scenario
option "공원으로 가기" -> :park
:park
switch_scene my_novel_park
end
```
시나리오를 저장하면 게임의 시나리오 캐시가 비워지므로, 다음에 마을에 들어가면 새 텍스트가 재생됩니다.
**4. 플레이해 보기.** 장면 에디터의 **장면으로 이동**을 누르거나 그냥 계속 플레이하면 마을로 들어가고, 새 답이 그 자리에 있습니다:
![실행 중인 게임의 마을: "어디로 갈까?"에 술집으로 들어가기, 공원으로 가기, 집으로 돌아가기.](assets/town-choice.jpg)
!["공원으로 가기"를 고른 뒤의 새 공원: "이제 뭘 하지?"에 마을로 돌아가기.](assets/park.jpg)
## 각 에디터
- **장면 에디터** — 소설의 장소: 이름, 저장할 모드, 배경(들어갈 때 하나가 무작위로 선택됩니다), 청취 버튼이 있는 환경음, 기본 시나리오, 장면의 시나리오. **장면으로 이동**은 진행 중인 대화를 끝내고 장면으로 들어갑니다.
- **시나리오 에디터** — 모든 모드의 파일, 요약, 장면과 전환의 그래프, DSL 강조 표시, Ctrl+S 저장. **플레이**(F5)는 저장하고 실행 중인 게임 안에서 시나리오를 재생하며, 정지는 게임이 있던 곳으로 돌아갑니다. **외부 에디터로 열기**는 시스템 편집기로 파일을 엽니다.
- **맵 에디터** — 월드 맵의 구역.
- **상호작용 에디터** — 현재 장면의 클릭 가능한 객체.
- **조명 에디터** — 현재 장면의 조명.
- **장면 빌더** — 모드용 Godot `.tscn` 장면.
- **카탈로그 내보내기** — 모드를 zip으로 묶고 체크섬과 함께 모드 카탈로그용 항목을 기록합니다.
Validator가 모드 간 참조를 검사하며 일반 대화 저장은 전체 검사를 실행하지 않습니다. `npc_barks_base`가 있으면 잘못된 배경 프로그램과 패치는 저장 전에 거부하고 상태 줄에 컴파일 오류를 표시합니다.
## 어떤 모드의 파일이 기록되는가
도구는 폴더로 설치된 모드에만 씁니다. 장면은 모든 모드의 `scenes/<name>.json` 파일을 합친 것이므로, 장면 에디터는 **저장할 곳**에서 고른 모드의 파일을 씁니다. 다른 모드나 압축된 모드에 속한 장면을 바꾸면 그 장면을 보충하는 파일이 당신의 모드에 만들어지고, 다른 파일에서 온 배경은 잠긴 상태로 표시됩니다. 시나리오는 속한 모드에 저장됩니다.
## 문제가 생겼을 때
1. **망치 버튼이 없습니다.** 그 게임에 도구가 들어 있지 않습니다. 컬렉션에 Developer Tools 도구 모음을 추가하거나 `--with-mods=editor_tools`로 실행하세요.
2. **새 시나리오가 재생되지 않습니다.** 머리말이 다른 장면을 가리키고 있거나, 그 장면의 다른 시나리오가 먼저 선택됩니다(`priority`를 비교해 보세요).
3. **저장할 곳에 모드가 없습니다.** 폴더로 설치된 모드에만 쓸 수 있습니다.
+72
View File
@@ -0,0 +1,72 @@
# Editor Tools — сцены и сценарии прямо в запущенной игре
Editor Tools — это авторские окна поверх работающей игры: редактор сцен, редактор сценариев, редакторы карты мира, интерактивных объектов и света, конструктор сцен Godot и экспорт каталога мода. Всё, что они сохраняют, — обычные файлы модов, и игра подхватывает правки сразу: можно описать место, тут же войти в него и сыграть, ничего не перезапуская.
Пакет совместимости: семь окон выделены в отдельные моды (`scenario_editor`, `scene_editor`, `scene_builder`, `map_editor`, `interactive_editor`, `light_editor`, `catalog_export`). Общие формы и файлы находятся в `tools_base`, сценарные сервисы — в `scenario_tools_base`. Все окна открываются с игрового тулбара; кнопок редакторов в главном меню нет.
## Как открыть инструменты
Молоток на панели инструментов в игре открывает набор инструментов:
![Панель шаблона новеллы: нажат молоток, открыт набор инструментов — Export Catalog Entry, Interactive Editor, Light Editor, Map Editor, Scenario Editor, Scene Builder, Scene editor.](assets/toolbox.jpg)
- Все семь окон есть в наборе: **Редактор сцен**, **Редактор сценариев**, **Редактор карты**, **Редактор интерактивов**, **Редактор освещения**, **Конструктор сцен** и **Экспорт записи каталога**. Другие инструментальные моды добавляют туда свои пункты (редакторы предметов, персонажей, RPG-карт и RPG-объектов, редактор данных).
- **Редактор сценариев** открывается и из редактора сцен: выберите сцену, укажите один из её сценариев и нажмите **Открыть** — или **+ Новый сценарий**.
Окна остаются открытыми при смене сцен, их можно двигать и растягивать, а закрываются они крестиком.
## Целая небольшая правка: новое место
В шаблоне новеллы есть город, бар и дом. Добавим туда парк, до которого можно дойти из города, — только средствами редакторов.
**1. Новая сцена.** В редакторе сцен нажмите **+ Новая сцена**, введите имя, выберите мод для сохранения и добавьте фон:
![Редактор сцен: слева сцены my_novel_bar, my_novel_home и my_novel_town мода my_novel_game; в центре добавленный в сцену фон home/vestibule; справа Имя my_novel_park, Сохранять в my_novel_game, Звук нет, Сценарий по умолч. нет.](assets/new-scene.jpg)
Сохранение (Ctrl+S) записывает `scenes/my_novel_park.json` в `my_novel_game` — и игра знает сцену сразу.
**2. Её сценарий.** **+ Новый сценарий** открывает редактор сценариев с файлом для сцены. Из парка есть один путь назад:
![Редактор сценариев с my_novel_park.scenario: сцена my_novel_park, приоритет 10, повествование «Тихий парк за городом. Мокрая листва, пустая скамейка.», выбор «Что дальше?» с ответом «Обратно в город», ведущим к switch_scene my_novel_town.](assets/park-scenario.jpg)
**3. Путь туда.** В сценарии города добавляются один ответ и одна метка:
![Редактор сценариев с my_novel_town.scenario: у выбора «Куда пойти?» появился ответ «Пойти в парк» -> :park, а метка :park выполняет switch_scene my_novel_park.](assets/town-scenario.jpg)
```scenario
option "Пойти в парк" -> :park
:park
switch_scene my_novel_park
end
```
Сохранение сценария сбрасывает кэш сценариев игры, так что при следующем входе в город сыграет уже новый текст.
**4. Сыграйте.** **Перейти в сцену** в редакторе сцен — или просто продолжайте играть — и вы попадёте в город, где новый ответ уже на месте:
![Город в запущенной игре: «Куда пойти?» с ответами «Зайти в бар», «Пойти в парк», «Вернуться домой».](assets/town-choice.jpg)
![Новый парк после выбора «Пойти в парк»: «Что дальше?» с ответом «Обратно в город».](assets/park.jpg)
## Редакторы
- **Редактор сцен** — места новеллы: имя, мод для сохранения, фоны (при входе один выбирается случайно), звук окружения с кнопкой прослушивания, сценарий по умолчанию и сценарии сцены. **Перейти в сцену** завершает текущий диалог и входит в сцену.
- **Редактор сценариев** — файлы всех модов, сводка и граф сцен и переходов; подсветка DSL; Ctrl+S сохраняет; **Запустить** (F5) сохраняет и проигрывает сценарий прямо в запущенной игре, а «Стоп» возвращает туда, где игра была; **Открыть во внешнем редакторе** открывает файл в системном редакторе.
- **Редактор карты** — зоны карты мира.
- **Редактор интерактивов** — кликабельные объекты текущей сцены.
- **Редактор освещения** — источники света текущей сцены.
- **Конструктор сцен** — сцены Godot `.tscn` для модов.
- **Экспорт каталога** — упаковывает мод в zip и пишет его запись для каталога модов с контрольными суммами.
Validator проверяет ссылки между модами. Сохранение обычного сценария не запускает полную проверку Validator. При загруженном `npc_barks_base` некорректные фоновые программы и их патчи отклоняются до записи; ошибка компиляции выводится в строке состояния.
## Чьи файлы пишутся
Инструменты пишут только в моды, установленные папками. Сцена — это сумма файлов `scenes/<name>.json` всех модов, поэтому редактор сцен пишет файл того мода, который выбран в **Сохранять в**. Если вы меняете сцену, принадлежащую другому моду или упакованному моду, создаётся файл в вашем моде, который её дополняет; фоны из чужих файлов показываются заблокированными. Сценарии сохраняются в том моде, которому принадлежат.
## Если что-то пошло не так
1. **Нет кнопки-молотка.** В игре нет инструментов — добавьте набор Developer Tools в её коллекцию или запустите с `--with-mods=editor_tools`.
2. **Новый сценарий не играет.** В его заголовке указана другая сцена, или вперёд выбран другой сценарий этой сцены (сравните их `priority`).
3. **В «Сохранять в» нет модов.** Писать можно только в моды, установленные папками.
+74
View File
@@ -0,0 +1,74 @@
# Editor Tools — dựng cảnh và kịch bản ngay trong trò chơi đang chạy
`editor_tools` là gói tương thích không có mã: cài `scenario_editor`, `scene_editor`, `scene_builder`, `map_editor`, `interactive_editor`, `light_editor`, `catalog_export`. Thư viện chung là `tools_base`, `scenario_tools_base`.
Editor Tools là những cửa sổ dành cho tác giả mở trên trò chơi đang chạy: trình sửa cảnh, trình sửa kịch bản, trình sửa bản đồ thế giới, trình sửa vật tương tác và ánh sáng, trình dựng cảnh Godot và phần xuất danh mục của mod. Mọi thứ chúng lưu đều là tệp mod bình thường, và trò chơi nhận thay đổi ngay lập tức, nên bạn viết ra một địa điểm, bước vào đó và chơi luôn mà không phải khởi động lại.
Gói tương thích: bảy cửa sổ đã tách thành các mod riêng. `tools_base` chứa thành phần chung và `scenario_tools_base` chứa dịch vụ kịch bản. Mọi cửa sổ mở từ thanh công cụ trong trò chơi, không thêm nút ở menu chính.
## Mở các công cụ
Trong game, nút chiếc búa trên thanh công cụ mở hộp công cụ:
![Thanh công cụ của mẫu tiểu thuyết với chiếc búa đang được nhấn và hộp công cụ mở ra: Export Catalog Entry, Interactive Editor, Light Editor, Map Editor, Scenario Editor, Scene Builder, Scene editor.](assets/toolbox.jpg)
- Cả bảy cửa sổ đều có trong hộp công cụ: **Trình sửa cảnh**, **Trình sửa kịch bản**, **Trình sửa bản đồ**, **Trình sửa tương tác**, **Trình sửa ánh sáng**, **Trình dựng cảnh** và **Xuất mục danh mục**. Các mod công cụ khác thêm mục của riêng chúng vào đó (trình sửa vật phẩm, nhân vật, bản đồ RPG và vật thể RPG, trình sửa dữ liệu).
- **Trình sửa kịch bản** cũng mở được từ trình sửa cảnh: chọn một cảnh, chọn một kịch bản của cảnh rồi nhấn **Mở**, hoặc **+ Kịch bản mới**.
Các cửa sổ vẫn mở khi chuyển cảnh, có thể di chuyển và đổi kích thước, và đóng bằng dấu ×.
## Một chỉnh sửa nhỏ trọn vẹn: một địa điểm mới
Mẫu tiểu thuyết có một thị trấn, một quán bar và một ngôi nhà. Ở đây ta thêm một công viên đi được từ thị trấn, chỉ bằng các trình sửa.
**1. Một cảnh mới.** Trong trình sửa cảnh nhấn **+ Cảnh mới**, gõ tên, chọn mod để lưu và thêm phông nền:
![Trình sửa cảnh: bên trái là các cảnh my_novel_bar, my_novel_home và my_novel_town của my_novel_game; ở giữa là phông nền home/vestibule đã thêm vào cảnh; bên phải là Tên my_novel_park, Lưu vào my_novel_game, Âm thanh không, Kịch bản mặc định không.](assets/new-scene.jpg)
Lưu (Ctrl+S) ghi `scenes/my_novel_park.json` vào `my_novel_game`; trò chơi biết cảnh này ngay.
**2. Kịch bản của cảnh.** **+ Kịch bản mới** mở trình sửa kịch bản với một tệp cho cảnh. Công viên chỉ có một lối về:
![Trình sửa kịch bản với my_novel_park.scenario: cảnh my_novel_park, ưu tiên 10, lời dẫn "Một công viên yên tĩnh sau thị trấn. Lá ướt, chiếc ghế trống.", lựa chọn "Làm gì tiếp?" với "Về thị trấn" dẫn tới switch_scene my_novel_town.](assets/park-scenario.jpg)
**3. Một lối đến đó.** Trong kịch bản của thị trấn, thêm một câu trả lời và một nhãn:
![Trình sửa kịch bản với my_novel_town.scenario: lựa chọn "Đi đâu?" giờ có câu trả lời "Ra công viên" -> :park, và nhãn :park thực hiện switch_scene my_novel_park.](assets/town-scenario.jpg)
```scenario
option "Ra công viên" -> :park
:park
switch_scene my_novel_park
end
```
Lưu kịch bản sẽ xóa bộ nhớ đệm kịch bản của game, nên lần vào thị trấn tiếp theo sẽ chơi đoạn văn bản mới.
**4. Chơi thử.** **Đến cảnh** trong trình sửa cảnh, hoặc cứ chơi tiếp, sẽ vào thị trấn, và câu trả lời mới đã ở đó:
![Thị trấn trong game đang chạy: "Đi đâu?" với Vào quán bar, Ra công viên, Về nhà.](assets/town-choice.jpg)
![Công viên mới sau khi chọn Ra công viên: "Làm gì tiếp?" với Về thị trấn.](assets/park.jpg)
## Các trình sửa
- **Trình sửa cảnh** — các địa điểm của tiểu thuyết: tên, mod để lưu, phông nền (mỗi lần vào chọn ngẫu nhiên một), âm thanh nền có nút nghe thử, kịch bản mặc định và các kịch bản của cảnh. **Đến cảnh** kết thúc đoạn hội thoại đang chạy và vào cảnh.
- **Trình sửa kịch bản** — tệp của mọi mod, bản tóm tắt và đồ thị cảnh cùng chuyển cảnh; tô sáng DSL; Ctrl+S để lưu; **Chơi** (F5) lưu và phát kịch bản ngay trong game đang chạy, còn Dừng đưa game về chỗ cũ; **Mở bằng trình sửa ngoài** mở tệp bằng chương trình mặc định của hệ thống.
- **Trình sửa bản đồ** — các vùng của bản đồ thế giới.
- **Trình sửa tương tác** — những vật bấm được của cảnh hiện tại.
- **Trình sửa ánh sáng** — đèn của cảnh hiện tại.
- **Trình dựng cảnh** — cảnh `.tscn` của Godot cho các mod.
- **Xuất danh mục** — đóng gói mod thành zip và ghi mục danh mục kèm tổng kiểm tra.
Validator kiểm tra tham chiếu giữa mod; lưu kịch bản thường không chạy toàn bộ kiểm tra. Khi có `npc_barks_base`, chương trình nền và bản vá không hợp lệ bị từ chối trước khi lưu; dòng trạng thái báo lỗi biên dịch.
## Tệp của ai được ghi
Các công cụ chỉ ghi vào mod được cài dưới dạng thư mục. Một cảnh là tổng các tệp `scenes/<name>.json` của mọi mod, nên trình sửa cảnh ghi tệp của mod được chọn trong **Lưu vào**. Khi sửa một cảnh thuộc mod khác hoặc mod đã đóng gói, một tệp bổ sung cho nó sẽ được tạo trong mod của bạn; những phông nền đến từ tệp khác hiện ở trạng thái khóa. Kịch bản được lưu vào mod mà chúng thuộc về.
## Khi có trục trặc
1. **Không thấy nút chiếc búa.** Trò chơi không có công cụ; hãy thêm bộ Developer Tools vào bộ sưu tập của nó hoặc chạy với `--with-mods=editor_tools`.
2. **Kịch bản mới không phát.** Phần đầu của nó ghi cảnh khác, hoặc một kịch bản khác của cảnh được chọn trước (so sánh `priority` của chúng).
3. **Mục Lưu vào không có mod nào.** Chỉ ghi được vào mod cài dưới dạng thư mục.
+74
View File
@@ -0,0 +1,74 @@
# Editor Tools — 在运行中的游戏里制作场景与剧本
`editor_tools` 是不含代码的兼容包,安装 `scenario_editor`、`scene_editor`、`scene_builder`、`map_editor`、`interactive_editor`、`light_editor`、`catalog_export`。共享库为 `tools_base` 和 `scenario_tools_base`。
Editor Tools 是叠在运行中游戏之上的作者工具窗口:场景编辑器、剧本编辑器、世界地图编辑器、交互物编辑器和光照编辑器,还有一个 Godot 场景构建器与模组目录导出。它们保存的全是普通的模组文件,游戏会立刻读到改动,所以作者可以写下一个地点,马上走进去试玩,无需重启。
这是兼容工具包:七个工具窗口已拆分为独立模组。公共表单和文件服务位于 `tools_base`,剧本服务位于 `scenario_tools_base`。所有窗口从游戏工具栏打开,不添加主菜单按钮。
## 打开工具
在游戏中,工具栏上的锤子按钮会打开工具箱:
![小说模板的工具栏:锤子按钮已按下,工具箱已打开:Export Catalog Entry、Interactive Editor、Light Editor、Map Editor、Scenario Editor、Scene Builder、Scene editor。](assets/toolbox.jpg)
- 七个窗口都在工具箱里:**场景编辑器**、**剧本编辑器**、**地图编辑器**、**交互编辑器**、**光照编辑器**、**场景构建器**和**导出目录条目**。其他工具模组会往里面添加自己的条目(物品、角色、RPG 地图和RPG 对象编辑器,以及数据编辑器)。
- **剧本编辑器**也可以从场景编辑器打开:选中一个场景,挑一个它的剧本,按**打开**,或者按 **+ 新剧本**。
这些窗口在切换场景后依然开着,可以拖动和调整大小,点叉号关闭。
## 一次完整的小改动:一个新地点
小说模板里有小镇、酒吧和家。下面只用编辑器,给小镇加一个能走过去的公园。
**1. 新场景。** 在场景编辑器里按 **+ 新场景**,输入名称,选择保存到哪个模组,再添加一个背景:
![场景编辑器:左侧是 my_novel_game 的 my_novel_bar、my_novel_home 和 my_novel_town 三个场景;中间是添加到场景的背景 home/vestibule;右侧是名称 my_novel_park、保存到 my_novel_game、声音 无、默认剧本 无。](assets/new-scene.jpg)
保存(Ctrl+S)会把 `scenes/my_novel_park.json` 写进 `my_novel_game`,游戏马上就知道这个场景了。
**2. 它的剧本。** **+ 新剧本**会打开剧本编辑器,里面是该场景的一个文件。公园只有一条回去的路:
![剧本编辑器中的 my_novel_park.scenario:场景 my_novel_park,优先级 10,旁白「小镇后面的一座静园。湿漉漉的落叶,空着的长椅。」,选项「接下来做什么?」里的「回小镇」指向 switch_scene my_novel_town。](assets/park-scenario.jpg)
**3. 通往那里的路。** 在小镇的剧本里加一个回答和一个标签:
![剧本编辑器中的 my_novel_town.scenario:选项「去哪里?」现在多了回答「去公园」-> :park,而 :park 标签执行 switch_scene my_novel_park。](assets/town-scenario.jpg)
```scenario
option "去公园" -> :park
:park
switch_scene my_novel_park
end
```
保存剧本会清掉游戏的剧本缓存,所以下次进小镇时播放的就是新文本。
**4. 试玩。** 在场景编辑器里点**前往场景**,或者干脆继续玩下去,就会进入小镇,新的回答已经在那里了:
![运行中的游戏里的小镇:「去哪里?」下面是 进酒吧、去公园、回家。](assets/town-choice.jpg)
![选择「去公园」后出现的新公园:「接下来做什么?」下面是 回小镇。](assets/park.jpg)
## 各个编辑器
- **场景编辑器** —— 小说的地点:名称、保存到的模组、背景(进入时随机选一个)、带试听按钮的环境音、默认剧本和场景的剧本。**前往场景**会结束当前对话并进入该场景。
- **剧本编辑器** —— 所有模组的文件、一份概要,以及场景与转场的图;DSL 高亮;Ctrl+S 保存;**试玩**(F5)保存并在运行中的游戏里播放剧本,停止则回到游戏原来的位置;**在外部编辑器中打开**会用系统默认程序打开文件。
- **地图编辑器** —— 世界地图上的区域。
- **交互编辑器** —— 当前场景里可点击的对象。
- **光照编辑器** —— 当前场景的灯光。
- **场景构建器** —— 为模组制作 Godot `.tscn` 场景。
- **目录导出** —— 把模组打包成 zip,并写出带校验和的模组目录条目。
Validator 检查模组间引用;保存普通对话脚本不执行完整检查。加载 `npc_barks_base` 时,无效后台程序及其补丁会在写入前被拒绝,状态栏显示编译错误。
## 写的是谁的文件
这些工具只写安装成文件夹的模组。一个场景是所有模组 `scenes/<name>.json` 文件的总和,所以场景编辑器写的是**保存到**里选中的那个模组的文件。改动属于别的模组或打包模组的场景时,会在你的模组里新建一个文件来补充它;来自其他文件的背景会显示为锁定。剧本则保存在它所属的模组里。
## 出问题了怎么办
1. **找不到锤子按钮。** 这个游戏没带工具;在它的集合里加上 Developer Tools 工具集,或者用 `--with-mods=editor_tools` 启动。
2. **新剧本不播放。** 它的头部写的是另一个场景,或者这个场景里有别的剧本先被选中了(比较它们的 `priority`)。
3. **「保存到」里没有模组。** 只有安装成文件夹的模组才能写入。
-5
View File
@@ -1,5 +0,0 @@
# Catalog export tool — extra keys
mods/catalog_select_mod = Select a mod:
mods/catalog_add_locale = Add language
mods/catalog_locale_name = Name in this language
mods/catalog_locale_desc = Description in this language
-5
View File
@@ -1,5 +0,0 @@
# Catalog export tool — дополнительные ключи
mods/catalog_select_mod = Выберите мод:
mods/catalog_add_locale = Добавить язык
mods/catalog_locale_name = Название на этом языке
mods/catalog_locale_desc = Описание на этом языке
+1 -249
View File
@@ -1,250 +1,2 @@
mod_editor_tools_name = Editor Tools
mod_editor_tools_desc = Developer toolbox: scenario graph editor, map editor with zones, scene builder with inspector, interactive and light editors, and the mod catalog export window.
tools/common/save = Save
tools/common/save_tip = Save the file (Ctrl+S)
tools/common/saved = Saved: {0}
tools/common/loaded = Opened: {0}
tools/common/load_error = Load error
tools/common/readonly = Cannot save: the mod is read-only (zip) or the path is invalid.
tools/common/no_writable_mods = No writable mods (only folder mods can be edited).
tools/common/no_files = Mod "{0}" has no such files yet — create one with "+".
tools/common/no_file_to_save = Pick or create a file first ("+" next to the file list).
tools/common/new_file = New file
tools/common/new_file_hint = The file is created in {0}/{1}/ with the {2} extension on first save.
tools/common/new_file_status = New file {0} — created on save.
tools/common/mod_tip = Mod (mods that already have such files first; · — none yet)
tools/common/file_tip = File inside the mod
tools/common/search = Search…
tools/common/json = JSON
tools/common/json_tip = Show the resulting file JSON
tools/common/background_missing = Background "{0}" of scene "{1}" is not provided by any mod — pick one by hand.
tools/map_editor/title = Map Editor
tools/map_editor/hint = LMB — select / drag · RMB — delete vertex · double-click, Enter or Esc — finish region · Del — delete zone · Ctrl+S — save
tools/map_editor/reload_map = Refresh in-game map
tools/map_editor/reload_map_tip = Re-read saved map files in the running game
tools/map_editor/reloaded = In-game map refreshed.
tools/map_editor/select_mode = Select
tools/map_editor/draw_region = Draw
tools/map_editor/draw_started = Click the map to place vertices. Double-click, Enter or Esc finishes, RMB removes the last one.
tools/map_editor/background = Preview background
tools/map_editor/zones = Zones
tools/map_editor/add_region = + Region
tools/map_editor/add_region_tip = New region: draw the polygon by clicking the map
tools/map_editor/add_marker = + Marker
tools/map_editor/add_label = + Label
tools/map_editor/delete = Delete zone
tools/map_editor/delete_tip = Delete the selected zone (Del)
tools/map_editor/delete_confirm = Delete the selected zone? The change reaches the file on save.
tools/map_editor/deleted = Zone "{0}" deleted.
tools/map_editor/props = Zone properties
tools/map_editor/zone_missing = Pick a zone in the list or on the map.
tools/map_editor/id = ID
tools/map_editor/id_empty = Zone ID cannot be empty.
tools/map_editor/duplicate_id = Zone ID already used:
tools/map_editor/title_key = Title
tools/map_editor/description = Description
tools/map_editor/no_translation = key not found in localization
tools/map_editor/scene = Scene
tools/map_editor/scene_none = (none)
tools/map_editor/availability = Availability
tools/map_editor/locked = Locked for travel
tools/map_editor/hidden = Hidden from the map
tools/map_editor/effective = In game:
tools/map_editor/effective_open = open
tools/map_editor/effective_locked = locked (Locked flag)
tools/map_editor/effective_no_scene = locked (no scene)
tools/map_editor/effective_unknown_scene = locked (scene not found)
tools/map_editor/effective_hidden = hidden from the map
tools/map_editor/marker = Marker
tools/map_editor/image = Icon
tools/map_editor/image_placeholder = texture ID
tools/map_editor/icon_search = Find icon…
tools/map_editor/label_section = Label
tools/map_editor/label = Text label on the map
tools/map_editor/x = X (0–1)
tools/map_editor/y = Y (0–1)
tools/map_editor/vertices = Polygon vertices
tools/map_editor/vertices_hint = Drag vertices on the map; RMB on a vertex deletes it.
tools/map_editor/add_vertex = + Vertex
tools/map_editor/delete_vertex = Delete vertex
tools/map_editor/preview = ▶ As in game
tools/map_editor/preview_tip = View the map as in game: hover, zone card, markers (unsaved edits included)
tools/map_editor/preview_on = Preview: Esc or ✕ returns to the editor.
tools/map_editor/preview_off = Editing mode.
tools/map_editor/preview_missing = Cannot load the map scene (map_widget.tscn).
tools/scenario_editor/title = Scenario Editor
tools/scenario_editor/hint = Ctrl+S — save · F5 — play the scenario · click a scene in the graph — its scenarios
tools/scenario_editor/play = ▶ Play
tools/scenario_editor/play_tooltip = Save and play the scenario in game (F5)
tools/scenario_editor/play_stopped = Test play stopped.
tools/scenario_editor/stop = ■ Stop
tools/scenario_editor/open_file = Open in external editor
tools/scenario_editor/open_file_tip = Open the file with the system default program
tools/scenario_editor/open_file_error = Cannot open the file
tools/scenario_editor/rescan = Rescan
tools/scenario_editor/rescan_tip = Re-read the scenario list and graph (after adding files)
tools/scenario_editor/rescanned = Scenario list refreshed.
tools/scenario_editor/search_files = Find file…
tools/scenario_editor/tab_files = Files
tools/scenario_editor/tab_info = Summary
tools/scenario_editor/tab_graph = Scene graph
tools/scenario_editor/graph_select_node = Select a scene in the graph to see its scenarios and transitions.
tools/scenario_editor/graph_node = mod {0} · scenarios {1}
tools/scenario_editor/graph_node_bg = background: {0}
tools/scenario_editor/graph_detail_mod = mod: {0}
tools/scenario_editor/graph_detail_scenarios = Scenarios ({0}), by priority:
tools/scenario_editor/info_scene = Scene
tools/scenario_editor/info_priority = Priority
tools/scenario_editor/info_condition = Condition
tools/scenario_editor/info_switch_targets = Scene switches
tools/scenario_editor/info_locale_keys = Locale keys
tools/scenario_editor/info_quest_refs = Quests
tools/scenario_editor/bg_previews = Scenario backgrounds
tools/scenario_editor/bg_missing = Background not found: {0}
tools/scenario_editor/no_backgrounds = The scenario does not change the background
tools/scenario_editor/save_error = Save error: {0}
tools/scenario_editor/readonly_zip = Files of zip mods are read-only
tools/scenario_editor/no_scene_error = The scenario header has no scene (scene:) — nothing to play.
tools/scenario_editor/unsaved_title = Unsaved changes
tools/scenario_editor/unsaved_switch_text = This tab has unsaved changes. Close it without saving?
tools/scene_builder/title = Scene Builder
tools/scene_builder/hint = Drag a node by its body or handles · click — pick the node under the cursor · Del — delete node · Ctrl+S — save and rebuild
tools/scene_builder/base_scene = Base scene the mod scene is shown over
tools/scene_builder/mod = Mod
tools/scene_builder/scene = Mod scene (.tscn)
tools/scene_builder/empty_scene = (empty base scene)
tools/scene_builder/select_mod = — pick a mod —
tools/scene_builder/select_scene = — pick a scene —
tools/scene_builder/save_tip = Save the .tscn and rebuild the preview (Ctrl+S)
tools/scene_builder/reload = Reload from disk
tools/scene_builder/reload_tip = Drop unsaved edits and re-read the scene file
tools/scene_builder/reloaded = Scene reloaded from disk.
tools/scene_builder/saved_ok = Saved.
tools/scene_builder/saved_rebuilt = Saved, preview rebuilt.
tools/scene_builder/autosaved = Autosaved.
tools/scene_builder/error_save = Save error: the mod is read-only.
tools/scene_builder/error_no_scene = Pick a mod scene first.
tools/scene_builder/hierarchy = Hierarchy
tools/scene_builder/hierarchy_hint = Gold — mod scene root, bright — editable nodes, faint — base scene (read-only).
tools/scene_builder/node_type_tip = Type of the new node
tools/scene_builder/add_node_tip = Add a node of the chosen type into the selected node (or the scene root)
tools/scene_builder/delete_node_tip = Delete the selected mod scene node
tools/scene_builder/delete_node_title = Delete node
tools/scene_builder/delete_node_confirm = Delete the selected node with its children? The file is saved automatically.
tools/scene_builder/refresh_tip = Refresh the node tree
tools/scene_builder/tab_inspector = Inspector
tools/scene_builder/tab_tscn = .tscn source
tools/scene_builder/tscn_hint = Inspector and handle edits save themselves; hand edits of the text save with Ctrl+S, which also rebuilds the preview.
tools/scene_builder/inspector_cat_container = In container
tools/scene_builder/inspector_select = Select a node of the mod scene to edit its properties
tools/scene_builder/inspector_readonly = Read-only: node belongs to the base scene
tools/scene_builder/inspector_missing_section = Node section not found in tscn (nodes of instanced sub-scenes are not editable yet)
tools/scene_builder/inspector_preset = Anchors preset
tools/scene_builder/inspector_cat_layout = Layout
tools/scene_builder/inspector_cat_rect = Rect
tools/scene_builder/inspector_cat_visibility = Visibility
tools/scene_builder/inspector_cat_misc = Misc
tools/scene_builder/inspector_bad_value = Value cannot be written to tscn.
tools/scene_builder/inspector_in_file_tooltip = Stored in tscn file
tools/scene_builder/inspector_container_banner = This node is managed by a container: anchors and offsets are recomputed by the parent and will have no effect here.
tools/scene_builder/err_add_failed = Error: cannot create node of this type.
tools/scene_builder/err_add_parent_missing = Error: parent section not found in tscn (instanced sub-scene parents are not supported).
tools/scene_builder/err_del_root = Error: the scene root cannot be deleted.
tools/scene_builder/err_del_none = Error: select a mod node to delete.
tools/interactive_editor/title = Interactive Editor
tools/interactive_editor/hint = Click a collider — select · drag handles · in polygon mode a click on the background adds a vertex, RMB on a vertex deletes it · Del — delete object · Ctrl+S — save
tools/interactive_editor/items = Objects in file
tools/interactive_editor/add = + New
tools/interactive_editor/duplicate = Duplicate
tools/interactive_editor/delete = Delete object
tools/interactive_editor/delete_confirm = Remove the selected interactive from the file? The change reaches the file on save.
tools/interactive_editor/deleted = Object "{0}" removed.
tools/interactive_editor/background = Preview background
tools/interactive_editor/props = Properties
tools/interactive_editor/none_selected = Pick an object in the list or on the preview.
tools/interactive_editor/label_id = ID
tools/interactive_editor/label_texture_key = Texture
tools/interactive_editor/texture_placeholder = no sprite
tools/interactive_editor/texture_tip = Sprite texture ID; empty — an invisible clickable area
tools/interactive_editor/label_layer = Layer
tools/interactive_editor/label_z = Z order
tools/interactive_editor/label_cursor = Cursor
tools/interactive_editor/cursor_none = (default)
tools/interactive_editor/enabled = Enabled
tools/interactive_editor/visible = Visible
tools/interactive_editor/collider = Collider
tools/interactive_editor/label_collider_type = Shape
tools/interactive_editor/hit_rect = rectangle
tools/interactive_editor/hit_circle = circle
tools/interactive_editor/hit_polygon = polygon
tools/interactive_editor/label_x = X
tools/interactive_editor/label_y = Y
tools/interactive_editor/label_w = Width
tools/interactive_editor/label_h = Height
tools/interactive_editor/label_cx = Centre X
tools/interactive_editor/label_cy = Centre Y
tools/interactive_editor/label_r = Radius
tools/interactive_editor/polygon_hint = A click on the background adds a vertex, RMB on a vertex deletes it. At least 3 vertices.
tools/interactive_editor/polygon_too_small = Polygon "{0}" has fewer than 3 vertices.
tools/interactive_editor/highlight = Highlight
tools/interactive_editor/label_highlight_color = Colour
tools/interactive_editor/label_highlight_scale = Scale
tools/interactive_editor/actions = Actions
tools/interactive_editor/label_onclick_scenario = Scenario
tools/interactive_editor/label_onclick_commands = Commands on click (one per line)
tools/interactive_editor/label_hover_enter = Hover in
tools/interactive_editor/label_hover_leave = Hover out
tools/interactive_editor/id_empty = An object has an empty ID.
tools/interactive_editor/duplicate_id = Duplicate object ID:
tools/light_editor/title = Light Editor
tools/light_editor/hint = Double-click the background — new light · drag lights · Del — delete · Ctrl+S — write light_set into the scenario
tools/light_editor/scenario_tip = Scenario whose light_set line is edited (● — already sets lights)
tools/light_editor/pick_scenario = — pick a scenario —
tools/light_editor/save_tip = Write the light_set line into the scenario (Ctrl+S)
tools/light_editor/copy_line = Copy line
tools/light_editor/copy_line_tip = Copy the light_set command to the clipboard
tools/light_editor/copied = light_set line copied.
tools/light_editor/show_line = light_set
tools/light_editor/loaded = Lights of {0} loaded.
tools/light_editor/no_lights_yet = {0} has no light_set yet — add lights, the line is inserted after the header.
tools/light_editor/static_lights = Lights
tools/light_editor/add_light = + Light
tools/light_editor/delete = Delete
tools/light_editor/limit = At most {0} static lights.
tools/light_editor/selected_props = Selected light
tools/light_editor/none_selected = Pick a light in the list or on the preview.
tools/light_editor/x = X (0–1)
tools/light_editor/y = Y (0–1)
tools/light_editor/radius = Radius
tools/light_editor/radius_tip = Radius in pixels of the 1920 px game screen
tools/light_editor/influence = Strength
tools/light_editor/influence_tip = Light influence (0–1)
tools/light_editor/color = Colour
tools/light_editor/background = Preview background
tools/catalog_export/hint = The archive and hashes are computed on export; the JSON is written next to the mod archive
tools/catalog_export/mod_info = Mod
tools/catalog_export/id = ID
tools/catalog_export/name = Name
tools/catalog_export/metadata = Catalog
tools/catalog_export/locales = Localizations
tools/catalog_export/locales_hint = With no rows the English name and description of the manifest are used.
tools/catalog_export/remove_locale = Remove language
tools/catalog_export/preview = Resulting JSON
tools/catalog_export/copied = JSON copied to the clipboard.
tools/catalog_export/export_tip = Build the archive, compute hashes and write the catalog entry JSON
tools/catalog_export/exported = Exported: {0}
tools/catalog_export/export_error = Export error
tools/catalog_export/url_required = Set the download URL — a catalog entry is useless without it.
tools/hub/title = Novel Editor
tools/hub/hint = Pick an editor — windows open over this screen. A scenario can be played right from the editor (F5).
tools/hub/editors = Editors
tools/hub/empty = No editors are installed.
tools/hub/main_menu = Main menu
mod_editor_tools_desc = Compatibility toolset: installs the individual scene, scenario, map, interactive, light, Godot scene and catalog tools.
+2
View File
@@ -0,0 +1,2 @@
mod_editor_tools_name = Herramientas de edición
mod_editor_tools_desc = Paquete compatible de editores de escenas, escenarios, mapas, interactivos, luces, escenas Godot y exportación.
+2
View File
@@ -0,0 +1,2 @@
mod_editor_tools_name = エディターツール
mod_editor_tools_desc = シーン、シナリオ、マップ、操作対象、照明、Godotシーン、カタログ出力の互換ツールセット。
+2
View File
@@ -0,0 +1,2 @@
mod_editor_tools_name = 에디터 도구
mod_editor_tools_desc = 장면, 시나리오, 지도, 상호작용, 조명, Godot 장면 및 카탈로그 내보내기 도구의 호환 모음.
+1 -249
View File
@@ -1,250 +1,2 @@
mod_editor_tools_name = Редакторы
mod_editor_tools_desc = Инструменты разработчика: редактор сценариев (граф), редактор карты с зонами, конструктор сцен с инспектором, редакторы интерактива и света, экспорт каталога модов.
tools/common/save = Сохранить
tools/common/save_tip = Сохранить файл (Ctrl+S)
tools/common/saved = Сохранено: {0}
tools/common/loaded = Открыт: {0}
tools/common/load_error = Ошибка загрузки
tools/common/readonly = Не удалось сохранить: мод доступен только для чтения (zip) или путь неверен.
tools/common/no_writable_mods = Нет модов, доступных для записи (только папочные моды можно редактировать).
tools/common/no_files = В моде «{0}» таких файлов пока нет — создайте новый кнопкой «+».
tools/common/no_file_to_save = Сначала выберите или создайте файл («+» рядом со списком файлов).
tools/common/new_file = Новый файл
tools/common/new_file_hint = Файл будет создан в {0}/{1}/ с расширением {2} при первом сохранении.
tools/common/new_file_status = Новый файл {0} — будет создан при сохранении.
tools/common/mod_tip = Мод (сначала — моды, где такие файлы уже есть; · — пока нет)
tools/common/file_tip = Файл внутри мода
tools/common/search = Поиск…
tools/common/json = JSON
tools/common/json_tip = Показать итоговый JSON файла
tools/common/background_missing = Фон «{0}» сцены «{1}» не найден ни в одном моде — выберите фон вручную.
tools/map_editor/title = Редактор карты
tools/map_editor/hint = ЛКМ — выбрать / тянуть · ПКМ — удалить вершину · двойной клик, Enter или Esc — закончить регион · Del — удалить зону · Ctrl+S — сохранить
tools/map_editor/reload_map = Обновить карту в игре
tools/map_editor/reload_map_tip = Перечитать сохранённые файлы карты в запущенной игре
tools/map_editor/reloaded = Карта в игре обновлена.
tools/map_editor/select_mode = Выбор
tools/map_editor/draw_region = Рисование
tools/map_editor/draw_started = Кликайте по карте, чтобы ставить вершины. Двойной клик, Enter или Esc — закончить, ПКМ — убрать последнюю.
tools/map_editor/background = Фон превью
tools/map_editor/zones = Зоны
tools/map_editor/add_region = + Регион
tools/map_editor/add_region_tip = Новый регион: нарисуйте полигон кликами по карте
tools/map_editor/add_marker = + Метка
tools/map_editor/add_label = + Надпись
tools/map_editor/delete = Удалить зону
tools/map_editor/delete_tip = Удалить выбранную зону (Del)
tools/map_editor/delete_confirm = Удалить выбранную зону? Изменение попадёт в файл при сохранении.
tools/map_editor/deleted = Зона «{0}» удалена.
tools/map_editor/props = Свойства зоны
tools/map_editor/zone_missing = Выберите зону в списке или на карте.
tools/map_editor/id = ID
tools/map_editor/id_empty = ID зоны не может быть пустым.
tools/map_editor/duplicate_id = ID зоны уже занят:
tools/map_editor/title_key = Название
tools/map_editor/description = Описание
tools/map_editor/no_translation = ключ не найден в локализации
tools/map_editor/scene = Сцена
tools/map_editor/scene_none = (нет)
tools/map_editor/availability = Доступность
tools/map_editor/locked = Закрыта для посещения
tools/map_editor/hidden = Скрыта с карты
tools/map_editor/effective = В игре:
tools/map_editor/effective_open = доступна
tools/map_editor/effective_locked = закрыта (флаг «Закрыта»)
tools/map_editor/effective_no_scene = закрыта (сцена не задана)
tools/map_editor/effective_unknown_scene = закрыта (сцена не найдена)
tools/map_editor/effective_hidden = скрыта с карты
tools/map_editor/marker = Метка
tools/map_editor/image = Иконка
tools/map_editor/image_placeholder = ID текстуры
tools/map_editor/icon_search = Поиск иконки…
tools/map_editor/label_section = Надпись
tools/map_editor/label = Текстовая надпись на карте
tools/map_editor/x = X (0–1)
tools/map_editor/y = Y (0–1)
tools/map_editor/vertices = Вершины полигона
tools/map_editor/vertices_hint = Тяните вершины на карте; ПКМ по вершине — удалить.
tools/map_editor/add_vertex = + Вершина
tools/map_editor/delete_vertex = Удалить вершину
tools/map_editor/preview = ▶ Как в игре
tools/map_editor/preview_tip = Посмотреть карту как в игре: наведение, карточка зоны, метки (несохранённые правки тоже)
tools/map_editor/preview_on = Просмотр: Esc или ✕ — вернуться к редактору.
tools/map_editor/preview_off = Режим редактирования.
tools/map_editor/preview_missing = Не удалось загрузить сцену карты (map_widget.tscn).
tools/scenario_editor/title = Редактор сценариев
tools/scenario_editor/hint = Ctrl+S — сохранить · F5 — запустить сценарий · клик по сцене в графе — список её сценариев
tools/scenario_editor/play = ▶ Запустить
tools/scenario_editor/play_tooltip = Сохранить и запустить сценарий в игре (F5)
tools/scenario_editor/play_stopped = Тестовый запуск остановлен.
tools/scenario_editor/stop = ■ Стоп
tools/scenario_editor/open_file = Открыть во внешнем редакторе
tools/scenario_editor/open_file_tip = Открыть файл программой, назначенной в системе
tools/scenario_editor/open_file_error = Не удалось открыть файл
tools/scenario_editor/rescan = Пересканировать
tools/scenario_editor/rescan_tip = Перечитать список сценариев и граф (после добавления файлов)
tools/scenario_editor/rescanned = Список сценариев обновлён.
tools/scenario_editor/search_files = Поиск файла…
tools/scenario_editor/tab_files = Файлы
tools/scenario_editor/tab_info = Сводка
tools/scenario_editor/tab_graph = Граф сцен
tools/scenario_editor/graph_select_node = Выберите сцену в графе, чтобы увидеть её сценарии и переходы.
tools/scenario_editor/graph_node = мод {0} · сценариев {1}
tools/scenario_editor/graph_node_bg = фон: {0}
tools/scenario_editor/graph_detail_mod = мод: {0}
tools/scenario_editor/graph_detail_scenarios = Сценарии ({0}), по приоритету:
tools/scenario_editor/info_scene = Сцена
tools/scenario_editor/info_priority = Приоритет
tools/scenario_editor/info_condition = Условие
tools/scenario_editor/info_switch_targets = Переходы на сцены
tools/scenario_editor/info_locale_keys = Ключи локализации
tools/scenario_editor/info_quest_refs = Квесты
tools/scenario_editor/bg_previews = Фоны сценария
tools/scenario_editor/bg_missing = Фон не найден: {0}
tools/scenario_editor/no_backgrounds = Сценарий не меняет фон
tools/scenario_editor/save_error = Ошибка сохранения: {0}
tools/scenario_editor/readonly_zip = Файл из zip-мода доступен только для чтения
tools/scenario_editor/no_scene_error = В заголовке сценария не указана сцена (scene:) — запускать некуда.
tools/scenario_editor/unsaved_title = Несохранённые изменения
tools/scenario_editor/unsaved_switch_text = В этой вкладке есть несохранённые изменения. Закрыть её без сохранения?
tools/scene_builder/title = Конструктор сцен
tools/scene_builder/hint = Тяните узел за тело или маркеры · клик — выбрать узел под курсором · Del — удалить узел · Ctrl+S — сохранить и пересобрать
tools/scene_builder/base_scene = Базовая сцена, поверх которой показывается мод-сцена
tools/scene_builder/mod = Мод
tools/scene_builder/scene = Сцена мода (.tscn)
tools/scene_builder/empty_scene = (пустая базовая сцена)
tools/scene_builder/select_mod = — выберите мод —
tools/scene_builder/select_scene = — выберите сцену —
tools/scene_builder/save_tip = Сохранить .tscn и пересобрать превью (Ctrl+S)
tools/scene_builder/reload = Перечитать с диска
tools/scene_builder/reload_tip = Отбросить несохранённое и перечитать сцену из файла
tools/scene_builder/reloaded = Сцена перечитана с диска.
tools/scene_builder/saved_ok = Сохранено.
tools/scene_builder/saved_rebuilt = Сохранено, превью пересобрано.
tools/scene_builder/autosaved = Автосохранено.
tools/scene_builder/error_save = Ошибка сохранения: мод доступен только для чтения.
tools/scene_builder/error_no_scene = Сначала выберите сцену мода.
tools/scene_builder/hierarchy = Иерархия
tools/scene_builder/hierarchy_hint = Золотой — корень мод-сцены, светлые — редактируемые узлы, тусклые — базовая сцена (только чтение).
tools/scene_builder/node_type_tip = Тип нового узла
tools/scene_builder/add_node_tip = Добавить узел выбранного типа в выделенный узел (или в корень сцены)
tools/scene_builder/delete_node_tip = Удалить выбранный узел мод-сцены
tools/scene_builder/delete_node_title = Удалить узел
tools/scene_builder/delete_node_confirm = Удалить выбранный узел вместе с дочерними? Файл сохранится автоматически.
tools/scene_builder/refresh_tip = Обновить дерево узлов
tools/scene_builder/tab_inspector = Инспектор
tools/scene_builder/tab_tscn = Исходник .tscn
tools/scene_builder/tscn_hint = Правки инспектора и маркеров сохраняются сами; ручные правки текста — по Ctrl+S, после чего превью пересобирается.
tools/scene_builder/inspector_cat_container = В контейнере
tools/scene_builder/inspector_select = Выберите узел мод-сцены для правки свойств
tools/scene_builder/inspector_readonly = Только чтение: узел принадлежит базовой сцене
tools/scene_builder/inspector_missing_section = Секция узла не найдена в tscn (узлы вложенных сцен пока не редактируются)
tools/scene_builder/inspector_preset = Пресет якорей
tools/scene_builder/inspector_cat_layout = Расположение
tools/scene_builder/inspector_cat_rect = Прямоугольник
tools/scene_builder/inspector_cat_visibility = Видимость
tools/scene_builder/inspector_cat_misc = Прочее
tools/scene_builder/inspector_bad_value = Значение не может быть записано в tscn.
tools/scene_builder/inspector_in_file_tooltip = Записано в tscn-файле
tools/scene_builder/inspector_container_banner = Узлом управляет контейнер: якоря и отступы пересчитываются родителем и здесь не действуют.
tools/scene_builder/err_add_failed = Ошибка: не удалось создать узел этого типа.
tools/scene_builder/err_add_parent_missing = Ошибка: родительская секция не найдена в tscn (родители из вложенных сцен не поддерживаются).
tools/scene_builder/err_del_root = Ошибка: корень сцены удалить нельзя.
tools/scene_builder/err_del_none = Ошибка: выберите узел мод-сцены для удаления.
tools/interactive_editor/title = Редактор интерактивов
tools/interactive_editor/hint = Клик по коллайдеру — выбрать · тяните маркеры · в режиме полигона клик по фону добавляет вершину, ПКМ по вершине — удаляет · Del — удалить объект · Ctrl+S — сохранить
tools/interactive_editor/items = Объекты файла
tools/interactive_editor/add = + Новый
tools/interactive_editor/duplicate = Дублировать
tools/interactive_editor/delete = Удалить объект
tools/interactive_editor/delete_confirm = Удалить выбранный интерактив из файла? Изменение попадёт в файл при сохранении.
tools/interactive_editor/deleted = Объект «{0}» удалён.
tools/interactive_editor/background = Фон превью
tools/interactive_editor/props = Свойства
tools/interactive_editor/none_selected = Выберите объект в списке или на превью.
tools/interactive_editor/label_id = ID
tools/interactive_editor/label_texture_key = Текстура
tools/interactive_editor/texture_placeholder = без спрайта
tools/interactive_editor/texture_tip = ID текстуры спрайта объекта; пусто — невидимая кликабельная зона
tools/interactive_editor/label_layer = Слой
tools/interactive_editor/label_z = Z-порядок
tools/interactive_editor/label_cursor = Курсор
tools/interactive_editor/cursor_none = (обычный)
tools/interactive_editor/enabled = Активен
tools/interactive_editor/visible = Видим
tools/interactive_editor/collider = Коллайдер
tools/interactive_editor/label_collider_type = Форма
tools/interactive_editor/hit_rect = прямоугольник
tools/interactive_editor/hit_circle = круг
tools/interactive_editor/hit_polygon = полигон
tools/interactive_editor/label_x = X
tools/interactive_editor/label_y = Y
tools/interactive_editor/label_w = Ширина
tools/interactive_editor/label_h = Высота
tools/interactive_editor/label_cx = Центр X
tools/interactive_editor/label_cy = Центр Y
tools/interactive_editor/label_r = Радиус
tools/interactive_editor/polygon_hint = Клик по фону добавляет вершину, ПКМ по вершине удаляет её. Нужно минимум 3 вершины.
tools/interactive_editor/polygon_too_small = У полигона «{0}» меньше 3 вершин.
tools/interactive_editor/highlight = Подсветка
tools/interactive_editor/label_highlight_color = Цвет
tools/interactive_editor/label_highlight_scale = Масштаб
tools/interactive_editor/actions = Действия
tools/interactive_editor/label_onclick_scenario = Сценарий
tools/interactive_editor/label_onclick_commands = Команды по клику (по одной в строке)
tools/interactive_editor/label_hover_enter = Наведение
tools/interactive_editor/label_hover_leave = Уход
tools/interactive_editor/id_empty = У объекта пустой ID.
tools/interactive_editor/duplicate_id = ID объекта повторяется:
tools/light_editor/title = Редактор освещения
tools/light_editor/hint = Двойной клик по фону — новый источник · тяните источники · Del — удалить · Ctrl+S — записать light_set в сценарий
tools/light_editor/scenario_tip = Сценарий, чья строка light_set редактируется (● — уже задаёт свет)
tools/light_editor/pick_scenario = — выберите сценарий —
tools/light_editor/save_tip = Записать строку light_set в сценарий (Ctrl+S)
tools/light_editor/copy_line = Копировать строку
tools/light_editor/copy_line_tip = Скопировать команду light_set в буфер обмена
tools/light_editor/copied = Строка light_set скопирована.
tools/light_editor/show_line = light_set
tools/light_editor/loaded = Свет сценария {0} загружен.
tools/light_editor/no_lights_yet = В {0} ещё нет light_set — добавьте источники, строка будет вставлена после заголовка.
tools/light_editor/static_lights = Источники света
tools/light_editor/add_light = + Источник
tools/light_editor/delete = Удалить
tools/light_editor/limit = Не больше {0} статичных источников.
tools/light_editor/selected_props = Выбранный источник
tools/light_editor/none_selected = Выберите источник в списке или на превью.
tools/light_editor/x = X (0–1)
tools/light_editor/y = Y (0–1)
tools/light_editor/radius = Радиус
tools/light_editor/radius_tip = Радиус в пикселях игрового экрана 1920 px
tools/light_editor/influence = Сила
tools/light_editor/influence_tip = Влияние источника (0–1)
tools/light_editor/color = Цвет
tools/light_editor/background = Фон превью
tools/catalog_export/hint = Архив и хэши считаются при экспорте; JSON кладётся рядом с архивом мода
tools/catalog_export/mod_info = Мод
tools/catalog_export/id = ID
tools/catalog_export/name = Название
tools/catalog_export/metadata = Каталог
tools/catalog_export/locales = Локализации
tools/catalog_export/locales_hint = Без строк будет использовано английское название и описание из манифеста.
tools/catalog_export/remove_locale = Убрать язык
tools/catalog_export/preview = Итоговый JSON
tools/catalog_export/copied = JSON скопирован в буфер обмена.
tools/catalog_export/export_tip = Собрать архив, посчитать хэши и записать JSON записи каталога
tools/catalog_export/exported = Экспортировано: {0}
tools/catalog_export/export_error = Ошибка экспорта
tools/catalog_export/url_required = Укажите URL скачивания — без него запись каталога бесполезна.
tools/hub/title = Редактор новелл
tools/hub/hint = Выберите редактор — окна открываются поверх этого экрана. Сценарий можно проиграть прямо из редактора (F5).
tools/hub/editors = Редакторы
tools/hub/empty = Не установлено ни одного редактора.
tools/hub/main_menu = Главное меню
mod_editor_tools_desc = Совместимый набор отдельных редакторов сцен, сценариев, карт, интерактивов, света, Godot-сцен и экспорта каталога.
+2
View File
@@ -0,0 +1,2 @@
mod_editor_tools_name = Công cụ chỉnh sửa
mod_editor_tools_desc = Bộ công cụ tương thích gồm trình biên tập cảnh, kịch bản, bản đồ, tương tác, ánh sáng, cảnh Godot và xuất danh mục.
+2
View File
@@ -0,0 +1,2 @@
mod_editor_tools_name = 编辑工具
mod_editor_tools_desc = 兼容工具包,安装场景、剧本、地图、交互、灯光、Godot 场景和目录导出工具。
+24 -8
View File
@@ -1,16 +1,32 @@
{
"id": "editor_tools",
"name": "Editor Tools",
"version": "1.0.0",
"version": "0.1.0",
"author": "ct",
"description": "Developer toolbox of ToolBox windows: scenario graph editor, map editor with zones, scene builder with inspector, interactive and light editors, and the mod catalog export window.",
"category": "tools, editor",
"sources": [
"src/**/*.cs"
"description": "Compatibility toolset: installs the individual scene, scenario, map, interactive, light, Godot scene and catalog tools.",
"categories": ["tools", "editor"],
"sources": [],
"depends": [
"scenario_editor>=0.1.0",
"scene_editor>=0.1.0",
"scene_builder>=0.1.0",
"map_editor>=0.1.0",
"interactive_editor>=0.1.0",
"light_editor>=0.1.0",
"catalog_export>=0.1.0"
],
"depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1"],
"test_suites": [
{"id": "windows", "activate": ["--itest=tools_windows"]},
{"id": "ingame_play", "activate": ["--itest=ingame_play"]}
{
"id": "windows",
"activate": [
"--itest=tools_windows"
]
},
{
"id": "novel_flow",
"activate": [
"--itest=novel_flow"
]
}
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

-12
View File
@@ -1,12 +0,0 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/Hub/NovelEditorHub.cs" id="NovelEditorHubScript"]
[node name="NovelEditorHub" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("NovelEditorHubScript")
-16
View File
@@ -1,16 +0,0 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/ScenarioEditor/ScenarioFileTreePanel.cs" id="1_script"]
[node name="ScenarioFileTreePanel" type="VBoxContainer" script=ExtResource("1_script")]
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 4
[node name="Search" type="LineEdit" parent="."]
clear_button_enabled = true
[node name="FileTree" type="Tree" parent="."]
size_flags_horizontal = 3
size_flags_vertical = 3
hide_root = true
-48
View File
@@ -1,48 +0,0 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/ScenarioEditor/ScenarioInfoPanel.cs" id="1_script"]
[node name="ScenarioInfoPanel" type="VBoxContainer" script=ExtResource("1_script")]
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 6
[node name="Scroll" type="ScrollContainer" parent="."]
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="VBox" type="VBoxContainer" parent="Scroll"]
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="HintScene" type="Label" parent="Scroll/VBox"]
[node name="InfoScene" type="Label" parent="Scroll/VBox"]
text = "-"
[node name="HintPriority" type="Label" parent="Scroll/VBox"]
[node name="InfoPriority" type="Label" parent="Scroll/VBox"]
text = "-"
[node name="HintCondition" type="Label" parent="Scroll/VBox"]
[node name="InfoCondition" type="Label" parent="Scroll/VBox"]
text = "-"
[node name="HSep" type="HSeparator" parent="Scroll/VBox"]
[node name="HintSwitchTargets" type="Label" parent="Scroll/VBox"]
[node name="InfoSwitchTargets" type="Label" parent="Scroll/VBox"]
text = "-"
[node name="HintLocaleKeys" type="Label" parent="Scroll/VBox"]
[node name="InfoLocaleKeys" type="Label" parent="Scroll/VBox"]
text = "-"
[node name="HintQuestRefs" type="Label" parent="Scroll/VBox"]
[node name="InfoQuestRefs" type="Label" parent="Scroll/VBox"]
text = "-"
-10
View File
@@ -1,10 +0,0 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/ScenarioEditor/ScenarioThumbnailPanel.cs" id="1_script"]
[node name="ScenarioThumbnailPanel" type="ScrollContainer" script=ExtResource("1_script")]
custom_minimum_size = Vector2(0, 110)
horizontal_scroll_mode = 1
vertical_scroll_mode = 3
[node name="ThumbContainer" type="HBoxContainer" parent="."]
@@ -1,27 +0,0 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.CatalogExport
{
/// <summary>
/// Registers the catalog export window as a developer tool. Moved here from
/// the old mods menu when mod management UI was delegated to the
/// <c>mod_manager</c> mod — exporting a catalog entry is an authoring task,
/// not part of day-to-day mod management.
/// </summary>
public class CatalogExportToolProvider : IToolProvider
{
/// <summary>Unique tool identifier.</summary>
public string Id => "catalog_export";
/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey => "mods/catalog_title";
/// <summary>Creates the export window instance.</summary>
public Window CreateWindow() => new CatalogExportWindow();
/// <summary>Adds the catalog export launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
}
}
-367
View File
@@ -1,367 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Cthangover.Core.Mods;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Core.UI.Tool.CatalogExport
{
/// <summary>
/// Developer tool that builds a catalog entry (JSON for the online mod
/// catalog) from a locally installed mod's manifest. Ported out of the old
/// mods menu so that mod-management UI can live in the <c>mod_manager</c>
/// mod while this authoring utility stays a tool.
/// <para/>
/// Left: the form — read-only manifest data, catalog-only metadata
/// (download URL, categories, tags, changelog, status, minimum game
/// version, icon URL) and localized names. Right: the live JSON preview.
/// Actions: copy the JSON or export it next to the mod's zip via
/// <see cref="ModCatalogExporter"/> (archive and hashes are computed there).
/// </summary>
public partial class CatalogExportWindow : ToolWindow
{
private static readonly string[] Statuses = { "stable", "beta", "deprecated", "archived" };
private static readonly CategoryType[] Categories = Enum.GetValues<CategoryType>();
private OptionButton _modSelect;
private VBoxContainer _formHost;
private CodeEdit _previewEdit;
private ModInfo _modInfo;
private ModManifest _manifest;
private LineEdit _urlEdit;
private readonly List<CheckBox> _categoryChecks = new();
private LineEdit _tagsEdit;
private TextEdit _changelogEdit;
private OptionButton _statusOpt;
private LineEdit _minGameVerEdit;
private LineEdit _iconUrlEdit;
private VBoxContainer _localeContainer;
public CatalogExportWindow() : base("mods/catalog_title")
{
Size = new Vector2I(1200, 820);
CthangoverTheme.Apply(this);
var outer = CreateFillContainer();
AddChild(outer);
var toolbar = CreateToolbar();
outer.AddChild(toolbar);
_modSelect = new OptionButton { CustomMinimumSize = new Vector2(320, 0), TooltipText = ToolUi.Tr("mods/catalog_select_mod") };
_modSelect.ItemSelected += OnModSelected;
toolbar.AddChild(_modSelect);
toolbar.AddChild(UiKit.Spacer());
toolbar.AddChild(ToolUi.Button("mods/catalog_copy", OnCopyPressed));
toolbar.AddChild(ToolUi.Button("mods/catalog_export_file", OnExportPressed, ButtonKind.Primary, "tools/catalog_export/export_tip"));
var split = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outer.AddChild(split);
_formHost = new VBoxContainer();
var form = CreateSidebar(_formHost, 520);
form.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
split.AddChild(form);
var previewColumn = new VBoxContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
previewColumn.AddChild(ToolUi.Section("tools/catalog_export/preview"));
_previewEdit = ToolUi.Code(editable: false);
previewColumn.AddChild(_previewEdit);
split.AddChild(previewColumn);
SetHint(ToolUi.Tr("tools/catalog_export/hint"));
PopulateModList();
}
private void PopulateModList()
{
var ordered = ModRegistry.Instance.GetOrderedModIds(filterDisabled: false);
foreach (var id in ordered)
{
var info = ModRegistry.Instance.GetMod(id);
if (info == null)
continue;
_modSelect.AddItem($"{info.DisplayTitle} ({id})");
_modSelect.SetItemMetadata(_modSelect.ItemCount - 1, id);
}
if (_modSelect.ItemCount > 0)
{
_modSelect.Selected = 0;
BuildForm(_modSelect.GetItemMetadata(0).AsString());
}
}
private void OnModSelected(long index)
{
BuildForm(_modSelect.GetItemMetadata((int)index).AsString());
}
private void BuildForm(string modId)
{
foreach (var child in _formHost.GetChildren())
child.QueueFree();
_categoryChecks.Clear();
_modInfo = ModRegistry.Instance.GetMod(modId) as ModInfo;
_manifest = _modInfo?.Manifest;
if (_modInfo == null || _manifest == null)
return;
_formHost.AddChild(ToolUi.Section("tools/catalog_export/mod_info"));
AddReadOnly("tools/catalog_export/id", _modInfo.Id);
AddReadOnly("tools/catalog_export/name", _modInfo.DisplayTitle);
AddReadOnly("mods/version", _modInfo.Version ?? "—");
AddReadOnly("mods/author", _modInfo.Author ?? "—");
_formHost.AddChild(ToolUi.Section("tools/catalog_export/metadata"));
_urlEdit = ToolUi.Field(_formHost, "mods/catalog_download_url", ToolUi.Text("https://…/" + modId + ".zip", _ => UpdatePreview()));
_statusOpt = ToolUi.Field(_formHost, "mods/catalog_status", new OptionButton());
foreach (var status in Statuses)
_statusOpt.AddItem(status);
_statusOpt.ItemSelected += _ => UpdatePreview();
_minGameVerEdit = ToolUi.Field(_formHost, "mods/catalog_min_game_version", ToolUi.Text("1.0.0", _ => UpdatePreview()));
_iconUrlEdit = ToolUi.Field(_formHost, "mods/catalog_icon_url", ToolUi.Text("https://…/icon.png", _ => UpdatePreview()));
_tagsEdit = ToolUi.Field(_formHost, "mods/catalog_tags", ToolUi.Text("story, ru", _ => UpdatePreview()));
_formHost.AddChild(ToolUi.Section("mods/catalog_category"));
var grid = new GridContainer { Columns = 3 };
grid.AddThemeConstantOverride("h_separation", UiTokens.Space5);
var preselected = _manifest.Category ?? CategoryType.Other;
foreach (var category in Categories)
{
var check = new CheckBox { Text = GetCategoryDisplayName(category), ButtonPressed = (preselected & category) != 0 };
check.Toggled += _ => UpdatePreview();
_categoryChecks.Add(check);
grid.AddChild(check);
}
_formHost.AddChild(grid);
_formHost.AddChild(ToolUi.Section("mods/catalog_changelog"));
_changelogEdit = new TextEdit
{
CustomMinimumSize = new Vector2(0, 80),
WrapMode = TextEdit.LineWrappingMode.Boundary,
};
_changelogEdit.TextChanged += UpdatePreview;
_formHost.AddChild(_changelogEdit);
_formHost.AddChild(ToolUi.Section("tools/catalog_export/locales"));
_formHost.AddChild(ToolUi.Hint(ToolUi.Tr("tools/catalog_export/locales_hint")));
_localeContainer = new VBoxContainer();
_localeContainer.AddThemeConstantOverride("separation", UiTokens.Space1);
_formHost.AddChild(_localeContainer);
_formHost.AddChild(ToolUi.Button("mods/catalog_add_locale", () => AddLocaleRow(string.Empty, string.Empty, string.Empty)));
UpdatePreview();
SetStatus(string.Empty);
}
private void AddReadOnly(string labelKey, string value)
{
var label = UiKit.Label(value, UiTokens.FontBody, UiTokens.Body);
label.AddThemeFontOverride("font", ToolUi.Mono());
ToolUi.Field(_formHost, labelKey, label);
}
private static string GetCategoryDisplayName(CategoryType cat) => cat switch
{
CategoryType.Story => ToolUi.Tr("mods/category_story"),
CategoryType.Characters => ToolUi.Tr("mods/category_characters"),
CategoryType.Gameplay => ToolUi.Tr("mods/category_gameplay"),
CategoryType.Visual => ToolUi.Tr("mods/category_visual"),
CategoryType.Audio => ToolUi.Tr("mods/category_audio"),
CategoryType.UI => ToolUi.Tr("mods/category_ui"),
CategoryType.Locale => ToolUi.Tr("mods/category_locale"),
CategoryType.Library => ToolUi.Tr("mods/category_library"),
CategoryType.Engine => ToolUi.Tr("mods/category_engine"),
CategoryType.Overhaul => ToolUi.Tr("mods/category_overhaul"),
CategoryType.Tools => ToolUi.Tr("mods/category_tools"),
CategoryType.Editor => ToolUi.Tr("mods/category_editor"),
CategoryType.Debug => ToolUi.Tr("mods/category_debug"),
CategoryType.Other => ToolUi.Tr("mods/category_other"),
_ => cat.ToString(),
};
private void AddLocaleRow(string lang, string name, string description)
{
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space1);
var langEdit = new LineEdit { CustomMinimumSize = new Vector2(52, 0), PlaceholderText = "en", MaxLength = 5, Text = lang };
row.AddChild(langEdit);
var nameEdit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, PlaceholderText = ToolUi.Tr("mods/catalog_locale_name"), Text = name };
row.AddChild(nameEdit);
var descEdit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, PlaceholderText = ToolUi.Tr("mods/catalog_locale_desc"), Text = description };
row.AddChild(descEdit);
var removeBtn = UiKit.IconButton("✕", ToolUi.Tr("tools/catalog_export/remove_locale"));
removeBtn.Pressed += () => RemoveLocaleRow(row);
row.AddChild(removeBtn);
langEdit.TextChanged += _ => UpdatePreview();
nameEdit.TextChanged += _ => UpdatePreview();
descEdit.TextChanged += _ => UpdatePreview();
_localeContainer.AddChild(row);
UpdatePreview();
}
private void RemoveLocaleRow(HBoxContainer row)
{
_localeContainer.RemoveChild(row);
row.QueueFree();
UpdatePreview();
}
private void UpdatePreview()
{
if (_previewEdit == null || _modInfo == null || _manifest == null || _changelogEdit == null)
return;
var entry = BuildExportEntry();
// Hashes are computed by the exporter from the real archive.
entry.Downloads[0].ArchiveSha256 = "computed-on-export";
entry.Downloads[0].InstallSha256 = "computed-on-export";
_previewEdit.Text = JsonSerializer.Serialize(entry, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
}
private List<CatalogLocaleEntry> CollectLocales()
{
var list = new List<CatalogLocaleEntry>();
foreach (var child in _localeContainer.GetChildren())
{
if (child is not HBoxContainer row || row.IsQueuedForDeletion() || row.GetChildCount() < 3)
continue;
var lang = ((LineEdit)row.GetChild(0)).Text.Trim();
if (lang.Length == 0)
continue;
list.Add(new CatalogLocaleEntry
{
Lang = lang,
Name = NullIfEmpty(((LineEdit)row.GetChild(1)).Text),
Description = NullIfEmpty(((LineEdit)row.GetChild(2)).Text),
});
}
if (list.Count == 0)
list.Add(new CatalogLocaleEntry { Lang = "en", Name = _manifest.Name, Description = _manifest.Description });
return list;
}
private CatalogExportEntry BuildExportEntry()
{
var entry = BuildEntryFromManifest(_manifest);
entry.Downloads = new List<CatalogEntryDownload>
{
new()
{
Version = entry.Version,
Urls = new List<string> { _urlEdit.Text.Trim() },
SizeBytes = 0,
Changelog = NullIfEmpty(_changelogEdit.Text),
},
};
entry.Category = CollectSelectedCategories();
entry.Tags = ParseTags(_tagsEdit.Text);
entry.Status = Statuses[Math.Max(0, _statusOpt.Selected)];
entry.MinGameVersion = NullIfEmpty(_minGameVerEdit.Text);
entry.IconUrl = NullIfEmpty(_iconUrlEdit.Text);
entry.Locale = CollectLocales();
return entry;
}
private void OnCopyPressed()
{
DisplayServer.ClipboardSet(_previewEdit.Text);
SetStatus(ToolUi.Tr("tools/catalog_export/copied"), ToolStatusKind.Ok);
}
private void OnExportPressed()
{
if (_modInfo == null)
return;
if (string.IsNullOrWhiteSpace(_urlEdit.Text))
{
SetStatus(ToolUi.Tr("tools/catalog_export/url_required"), ToolStatusKind.Error);
return;
}
try
{
var entry = BuildExportEntry();
var modsRoot = ModRegistry.ModsPath;
var isZip = _modInfo.FileProvider.RootPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase);
var path = isZip
? ModCatalogExporter.ExportFromZip(_modInfo, entry, modsRoot)
: ModCatalogExporter.ExportFromFolder(_modInfo, entry, modsRoot);
SetStatus(string.Format(ToolUi.Tr("tools/catalog_export/exported"), path), ToolStatusKind.Ok);
}
catch (Exception ex)
{
GameLogger.Log("CATALOG_EXPORT", $"export of '{_modInfo.Id}' failed: {ex.Message}", LogLevel.Error);
SetStatus($"{ToolUi.Tr("tools/catalog_export/export_error")}: {ex.Message}", ToolStatusKind.Error);
}
}
private static List<string> ParseTags(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
return text.Split(',').Select(t => t.Trim()).Where(t => t.Length > 0).ToList();
}
private static string NullIfEmpty(string text)
{
var trimmed = text?.Trim();
return string.IsNullOrEmpty(trimmed) ? null : trimmed;
}
private static CatalogExportEntry BuildEntryFromManifest(ModManifest manifest)
{
var depends = manifest.Depends?.Select(d => d.ToString()).Where(s => s.Length > 0).ToList();
return new CatalogExportEntry
{
Id = manifest.Id,
Name = manifest.Name,
Author = manifest.Author,
Version = manifest.Version,
Description = manifest.Description,
Category = manifest.Category ?? CategoryType.Other,
Depends = depends,
ConflictsWith = manifest.ConflictsWith,
};
}
private CategoryType CollectSelectedCategories()
{
var selected = (CategoryType)0;
for (var i = 0; i < Categories.Length && i < _categoryChecks.Count; i++)
{
if (_categoryChecks[i].ButtonPressed)
selected |= Categories[i];
}
return selected != 0 ? selected : CategoryType.Other;
}
}
}
-137
View File
@@ -1,137 +0,0 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Base of the editors that place things over a scene background (map
/// zones, interactive colliders, lights). Draws the background with the
/// same aspect-preserving "fit" layout the game uses and converts between
/// canvas pixels and the normalised [0..1] coordinates stored in the data.
/// Getting this rectangle wrong is what made saved positions drift: the
/// data means "fraction of the background", not "fraction of the panel".
/// <para/>
/// Subclasses paint their overlay in <see cref="DrawContent"/> and rebuild
/// their <see cref="DragHandle"/>s in <see cref="RebuildHandles"/>, which is
/// also called whenever the canvas is resized or the background changes.
/// </summary>
public abstract partial class BackgroundCanvas : Control
{
private static readonly Vector2 FallbackSize = new(1920, 1024);
/// <summary>Live handles of the current selection.</summary>
protected readonly List<DragHandle> _handles = new();
/// <summary>Reference background; <c>null</c> draws a flat placeholder.</summary>
public Texture2D Background { get; private set; }
protected BackgroundCanvas()
{
MouseFilter = MouseFilterEnum.Stop;
ClipContents = true;
Resized += OnResized;
Connect("draw", Callable.From((Action)DrawAll));
}
/// <summary>Assigns the background texture and relayouts handles.</summary>
public void SetBackground(Texture2D texture)
{
Background = texture;
RebuildHandles();
QueueRedraw();
}
/// <summary>Recreates the handles from the current selection.</summary>
public abstract void RebuildHandles();
/// <summary>Paints the overlay above the background inside <paramref name="rect"/>.</summary>
protected abstract void DrawContent(Rect2 rect);
/// <summary>
/// Aspect-preserving rectangle the background occupies inside the
/// canvas — identical to the runtime "fit" layout.
/// </summary>
public Rect2 GetBackgroundRect()
{
var controlSize = Size;
if (controlSize.X <= 0f || controlSize.Y <= 0f)
return new Rect2();
var textureSize = Background?.GetSize() ?? FallbackSize;
if (textureSize.X <= 0f || textureSize.Y <= 0f)
textureSize = FallbackSize;
var scale = Mathf.Min(controlSize.X / textureSize.X, controlSize.Y / textureSize.Y);
var size = textureSize * scale;
return new Rect2((controlSize - size) / 2f, size);
}
/// <summary>Canvas-local pixel of a normalised point.</summary>
public Vector2 NormToLocal(Vector2 normalized)
{
var rect = GetBackgroundRect();
return rect.Position + normalized * rect.Size;
}
/// <summary>Normalised point of a canvas-local pixel (not clamped).</summary>
public Vector2 LocalToNorm(Vector2 local)
{
var rect = GetBackgroundRect();
if (rect.Size.X <= 0f || rect.Size.Y <= 0f)
return Vector2.Zero;
return (local - rect.Position) / rect.Size;
}
/// <summary>Clamps both axes into [0..1].</summary>
public static Vector2 Clamp01(Vector2 value)
{
return new Vector2(Mathf.Clamp(value.X, 0f, 1f), Mathf.Clamp(value.Y, 0f, 1f));
}
/// <summary>Frees every handle.</summary>
protected void ClearHandles()
{
foreach (var handle in _handles)
handle.QueueFree();
_handles.Clear();
}
/// <summary>
/// Adds a handle centred on a normalised point. The handle enters the
/// tree before it is positioned (setting a global position on a
/// detached Control lands it in the wrong place). <paramref name="drag"/>
/// receives the clamped normalised point while dragging.
/// </summary>
protected DragHandle AddHandle(Vector2 normalized, Color color, int index, Action<Vector2> drag, float diameter = 14f)
{
var handle = new DragHandle(color, index, diameter);
handle.DragUpdate = globalCenter => drag(Clamp01(LocalToNorm(globalCenter - GlobalPosition)));
AddChild(handle);
handle.CenterAt(GlobalPosition + NormToLocal(Clamp01(normalized)));
_handles.Add(handle);
return handle;
}
private void OnResized()
{
RebuildHandles();
QueueRedraw();
}
private void DrawAll()
{
var rect = GetBackgroundRect();
if (rect.Size.X <= 0f || rect.Size.Y <= 0f)
return;
if (Background != null)
DrawTextureRect(Background, rect, false);
else
DrawRect(rect, UiTokens.Surface2);
DrawContent(rect);
}
}
}
-62
View File
@@ -1,62 +0,0 @@
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Sidebar section with a clickable uppercase caption (style guide
/// <c>.ct-section-title</c> plus a ▸/▾ marker) that shows or hides its
/// <see cref="Body"/>. Editors expand the sections relevant to the selected
/// object and collapse the rest, so a long property panel stays readable
/// without losing access to rarely used fields.
/// </summary>
public partial class CollapsibleSection : VBoxContainer
{
private readonly Button _header;
private readonly string _title;
/// <summary>Container for the section's controls.</summary>
public VBoxContainer Body { get; }
/// <summary>Whether the body is shown.</summary>
public bool Expanded
{
get => Body.Visible;
set
{
Body.Visible = value;
_header.Text = (value ? "▾ " : "▸ ") + _title;
}
}
/// <summary>Creates a section titled by <paramref name="titleKey"/>.</summary>
public CollapsibleSection(string titleKey, bool expanded = true)
{
_title = ToolUi.Tr(titleKey).ToUpperInvariant();
AddThemeConstantOverride("separation", UiTokens.Space2);
_header = new Button
{
Flat = true,
Alignment = HorizontalAlignment.Left,
FocusMode = FocusModeEnum.None,
MouseDefaultCursorShape = CursorShape.PointingHand
};
_header.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
_header.AddThemeColorOverride("font_color", UiTokens.Muted);
_header.AddThemeColorOverride("font_hover_color", UiTokens.AccentBright);
_header.AddThemeColorOverride("font_pressed_color", UiTokens.Muted);
_header.AddThemeStyleboxOverride("normal", new StyleBoxEmpty());
_header.AddThemeStyleboxOverride("hover", new StyleBoxEmpty());
_header.AddThemeStyleboxOverride("pressed", new StyleBoxEmpty());
_header.Pressed += () => Expanded = !Expanded;
AddChild(_header);
Body = new VBoxContainer();
Body.AddThemeConstantOverride("separation", UiTokens.Space2);
AddChild(Body);
Expanded = expanded;
}
}
}
-102
View File
@@ -1,102 +0,0 @@
using System;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Round draggable point used by every canvas editor (polygon vertices,
/// rect corners, markers, lights). It is a small Control placed over its
/// canvas: the left button drags it and reports the new centre in global
/// coordinates, the right button reports a delete request. Input arrives
/// through the <c>GuiInput</c> event and drawing through the <c>draw</c>
/// signal — the forms that dispatch reliably for mod assemblies.
/// <para/>
/// <see cref="Index"/> is free for the owner: a vertex index or a negative
/// role constant (marker, label, rect corner...).
/// </summary>
public partial class DragHandle : Control
{
/// <summary>Owner-defined identity of the handle.</summary>
public int Index;
/// <summary>Called while dragging with the handle centre in global coordinates.</summary>
public Action<Vector2> DragUpdate;
/// <summary>Called on left press.</summary>
public Action Clicked;
/// <summary>Called on left release after a press.</summary>
public Action DragEnd;
/// <summary>Called on right press.</summary>
public Action RightClicked;
private readonly Color _fill;
private bool _dragging;
private bool _hovered;
private Vector2 _dragOffset;
/// <summary>Creates a handle of the given fill colour, role index and diameter.</summary>
public DragHandle(Color fill, int index, float diameter = 14f)
{
_fill = fill;
Index = index;
Size = new Vector2(diameter, diameter);
MouseFilter = MouseFilterEnum.Stop;
MouseDefaultCursorShape = CursorShape.Drag;
GuiInput += OnGuiInput;
MouseEntered += () => { _hovered = true; QueueRedraw(); };
MouseExited += () => { _hovered = false; QueueRedraw(); };
Connect("draw", Callable.From((Action)Paint));
}
/// <summary>Places the handle so that its centre sits at <paramref name="globalCenter"/>; call after it is in the tree.</summary>
public void CenterAt(Vector2 globalCenter)
{
GlobalPosition = globalCenter - Size / 2f;
}
private void Paint()
{
var center = Size / 2f;
var radius = Size.X / 2f - 1f;
DrawCircle(center, radius, UiTokens.Surface0);
DrawCircle(center, radius - 2f, _fill);
DrawArc(center, radius, 0f, Mathf.Tau, 24, _hovered || _dragging ? UiTokens.AccentHover : UiTokens.Ink, 1.5f, true);
}
private void OnGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb)
{
if (mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
{
_dragging = true;
_dragOffset = GetGlobalMousePosition() - GlobalPosition;
Clicked?.Invoke();
}
else if (_dragging)
{
_dragging = false;
DragEnd?.Invoke();
}
AcceptEvent();
}
else if (mb.ButtonIndex == MouseButton.Right && mb.Pressed)
{
RightClicked?.Invoke();
AcceptEvent();
}
}
else if (@event is InputEventMouseMotion && _dragging)
{
GlobalPosition = GetGlobalMousePosition() - _dragOffset;
DragUpdate?.Invoke(GlobalPosition + Size / 2f);
AcceptEvent();
}
}
}
}
-136
View File
@@ -1,136 +0,0 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Search field + list used wherever an editor picks from a long catalogue
/// (backgrounds, icons, files, definitions). Items are identified by a
/// stable id, not by row index: filtering reorders and hides rows, so the
/// list keeps the id↔row mapping itself and raises <see cref="Chosen"/> /
/// <see cref="Activated"/> with the id. <see cref="Select"/> is silent so
/// windows can mirror their model into the list without feedback loops.
/// </summary>
public partial class FilteredList : VBoxContainer
{
private readonly LineEdit _search;
private readonly ItemList _list;
private readonly List<(string Id, string Text, Texture2D Icon)> _items = new();
private readonly List<string> _visibleIds = new();
private string _selectedId;
private bool _silent;
/// <summary>Raised when the user selects a row (click or keyboard).</summary>
public event Action<string> Chosen;
/// <summary>Raised on double-click / Enter.</summary>
public event Action<string> Activated;
/// <summary>Currently selected id, or <c>null</c>.</summary>
public string SelectedId => _selectedId;
/// <summary>Creates the list; <paramref name="minHeight"/> is the list part's minimum height.</summary>
public FilteredList(float minHeight = 140f, string placeholderKey = "tools/common/search")
{
AddThemeConstantOverride("separation", UiTokens.Space1);
_search = new LineEdit
{
PlaceholderText = TranslationServer.Translate(placeholderKey),
ClearButtonEnabled = true
};
_search.TextChanged += _ => Rebuild();
AddChild(_search);
_list = new ItemList
{
CustomMinimumSize = new Vector2(0, minHeight),
SizeFlagsVertical = SizeFlags.ExpandFill,
AllowReselect = true
};
_list.ItemSelected += OnItemSelected;
_list.ItemActivated += index => Activated?.Invoke(IdAt((int)index));
AddChild(_list);
}
/// <summary>Turns rows into an icon grid of <paramref name="iconSize"/> px thumbnails.</summary>
public void UseIconGrid(int iconSize)
{
_list.FixedIconSize = new Vector2I(iconSize, iconSize);
_list.IconMode = ItemList.IconModeEnum.Top;
_list.MaxColumns = 0;
_list.SameColumnWidth = true;
_list.FixedColumnWidth = iconSize + 24;
}
/// <summary>Replaces all items; keeps the current selection when its id is still present.</summary>
public void SetItems(IEnumerable<(string Id, string Text, Texture2D Icon)> items)
{
_items.Clear();
_items.AddRange(items);
Rebuild();
}
/// <summary>Replaces all items with text-only rows where text equals id.</summary>
public void SetItems(IEnumerable<string> ids)
{
_items.Clear();
foreach (var id in ids)
_items.Add((id, id, null));
Rebuild();
}
/// <summary>Selects the row with <paramref name="id"/> (null clears) without raising events.</summary>
public void Select(string id)
{
_selectedId = id;
_silent = true;
_list.DeselectAll();
var index = _visibleIds.IndexOf(id);
if (index >= 0)
{
_list.Select(index);
_list.EnsureCurrentIsVisible();
}
_silent = false;
}
/// <summary>Number of items before filtering.</summary>
public int Count => _items.Count;
private void Rebuild()
{
var filter = _search.Text.Trim();
_silent = true;
_list.Clear();
_visibleIds.Clear();
foreach (var item in _items)
{
if (filter.Length > 0 && item.Text.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0
&& item.Id.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
continue;
var index = item.Icon != null ? _list.AddItem(item.Text, item.Icon) : _list.AddItem(item.Text);
_list.SetItemTooltip(index, item.Id);
_visibleIds.Add(item.Id);
}
_silent = false;
Select(_selectedId);
}
private void OnItemSelected(long index)
{
if (_silent)
return;
_selectedId = IdAt((int)index);
Chosen?.Invoke(_selectedId);
}
private string IdAt(int index) => index >= 0 && index < _visibleIds.Count ? _visibleIds[index] : null;
}
}
-180
View File
@@ -1,180 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Toolbar group "mod ▾ file ▾ +" for editors that own one kind of file
/// inside a mod folder (<c>map/*.json</c>, <c>interactives/*.json</c>).
/// Only writable (folder-backed) mods are offered, and mods that already
/// contain such files come first, so the author lands on real data instead
/// of an arbitrary engine mod with nothing to edit.
/// <para/>
/// The selector never loads anything itself: it raises
/// <see cref="FileRequested"/> and the window decides (usually after an
/// "unsaved changes" confirmation). When the window declines, it calls
/// <see cref="Show"/> with the file still open to roll the dropdowns back.
/// A <c>null</c> path means "this mod has no such file yet".
/// </summary>
public partial class ModFileSelector : HBoxContainer
{
private readonly string _directory;
private readonly string _extension;
private readonly OptionButton _modSelect;
private readonly OptionButton _fileSelect;
private readonly List<string> _mods = new();
private readonly List<string> _files = new();
private bool _silent;
/// <summary>Raised with (modId, mod-relative path or null) when the author picks or creates a file.</summary>
public event Action<string, string> FileRequested;
/// <summary>Mod shown in the selector.</summary>
public string CurrentMod => _modSelect.Selected >= 0 && _modSelect.Selected < _mods.Count ? _mods[_modSelect.Selected] : null;
/// <summary>File shown in the selector, or <c>null</c>.</summary>
public string CurrentPath => _fileSelect.Selected >= 0 && _fileSelect.Selected < _files.Count ? _files[_fileSelect.Selected] : null;
/// <summary>Creates the selector for files in <paramref name="directory"/> with <paramref name="extension"/>.</summary>
public ModFileSelector(string directory, string extension)
{
_directory = directory;
_extension = extension;
AddThemeConstantOverride("separation", UiTokens.Space2);
_modSelect = new OptionButton { CustomMinimumSize = new Vector2(170, 0), TooltipText = ToolUi.Tr("tools/common/mod_tip") };
_modSelect.ItemSelected += _ => OnModPicked();
AddChild(_modSelect);
_fileSelect = new OptionButton { CustomMinimumSize = new Vector2(220, 0), TooltipText = ToolUi.Tr("tools/common/file_tip") };
_fileSelect.ItemSelected += _ => OnFilePicked();
AddChild(_fileSelect);
var newBtn = UiKit.IconButton("+", ToolUi.Tr("tools/common/new_file"));
newBtn.Pressed += OnNewPressed;
AddChild(newBtn);
}
/// <summary>Re-reads mods and files and requests the first file of the first mod that has any.</summary>
public void Refresh()
{
var writable = ModFileService.ListWritableMods();
_mods.Clear();
_mods.AddRange(writable.Where(HasFiles));
_mods.AddRange(writable.Where(m => !HasFiles(m)));
_silent = true;
_modSelect.Clear();
foreach (var mod in _mods)
_modSelect.AddItem(HasFiles(mod) ? mod : mod + " ·");
_silent = false;
if (_mods.Count == 0)
{
FileRequested?.Invoke(null, null);
return;
}
_modSelect.Select(0);
FillFiles(_mods[0]);
FileRequested?.Invoke(_mods[0], CurrentPath);
}
/// <summary>Silently shows <paramref name="modId"/>/<paramref name="path"/> (a not-yet-saved path is added).</summary>
public void Show(string modId, string path)
{
_silent = true;
var modIndex = _mods.IndexOf(modId);
if (modIndex >= 0)
{
_modSelect.Select(modIndex);
FillFiles(modId);
if (path != null && !_files.Contains(path))
{
_files.Add(path);
_fileSelect.AddItem(ShortName(path));
}
_fileSelect.Select(path != null ? _files.IndexOf(path) : -1);
}
_silent = false;
}
private bool HasFiles(string modId) => ModFileService.ListFiles(modId, _directory, _extension).Count > 0;
private void FillFiles(string modId)
{
_files.Clear();
_files.AddRange(ModFileService.ListFiles(modId, _directory, _extension));
var silent = _silent;
_silent = true;
_fileSelect.Clear();
foreach (var file in _files)
_fileSelect.AddItem(ShortName(file));
_fileSelect.Disabled = _files.Count == 0;
if (_files.Count > 0)
_fileSelect.Select(0);
_silent = silent;
}
private string ShortName(string path)
{
var prefix = _directory + "/";
return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? path.Substring(prefix.Length) : path;
}
private void OnModPicked()
{
if (_silent)
return;
var mod = CurrentMod;
FillFiles(mod);
FileRequested?.Invoke(mod, CurrentPath);
}
private void OnFilePicked()
{
if (!_silent)
FileRequested?.Invoke(CurrentMod, CurrentPath);
}
private void OnNewPressed()
{
if (CurrentMod == null)
return;
var dialog = new ConfirmationDialog { Title = ToolUi.Tr("tools/common/new_file") };
var column = new VBoxContainer();
column.AddChild(UiKit.Hint(string.Format(ToolUi.Tr("tools/common/new_file_hint"), CurrentMod, _directory, _extension)));
var nameEdit = new LineEdit { PlaceholderText = "new_file", CustomMinimumSize = new Vector2(320, 0) };
column.AddChild(nameEdit);
dialog.AddChild(column);
dialog.RegisterTextEnter(nameEdit);
dialog.Confirmed += () =>
{
var name = SanitizeName(nameEdit.Text);
dialog.QueueFree();
if (name.Length == 0)
return;
FileRequested?.Invoke(CurrentMod, _directory + "/" + name + _extension);
};
dialog.Canceled += () => dialog.QueueFree();
AddChild(dialog);
dialog.PopupCentered();
nameEdit.GrabFocus();
}
private string SanitizeName(string raw)
{
var name = (raw ?? string.Empty).Trim().Replace('\\', '/').Trim('/');
if (name.EndsWith(_extension, StringComparison.OrdinalIgnoreCase))
name = name.Substring(0, name.Length - _extension.Length);
var chars = name.Where(c => char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '/').ToArray();
return new string(chars);
}
}
}
-145
View File
@@ -1,145 +0,0 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Building blocks shared by every editor window of the tools mod, so all
/// of them read the same way: uppercase muted section captions, "label +
/// control" rows with a fixed label column, numeric spin boxes and token
/// buttons from <see cref="UiKit"/>. Editors are built in code (the
/// skill-code exception for utility tools), so this is the place where
/// their look is kept consistent with the style guide instead of each
/// window inventing its own spacing and label styles.
/// </summary>
public static class ToolUi
{
/// <summary>Width of the label column in <see cref="Field"/> rows.</summary>
public const int LabelWidth = 110;
/// <summary>Translates a locale key.</summary>
public static string Tr(string key) => TranslationServer.Translate(key);
/// <summary>Uppercase muted section caption (style guide <c>.ct-section-title</c>) with a little air above.</summary>
public static Control Section(string titleKey)
{
var box = new MarginContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
box.AddThemeConstantOverride("margin_top", UiTokens.Space2);
box.AddChild(UiKit.SectionTitle(Tr(titleKey), UiTokens.FontSmall));
return box;
}
/// <summary>
/// Adds a "label | control" row to <paramref name="parent"/>: the label is
/// muted, fixed width and carries the optional tooltip; the control fills
/// the rest. Returns the control for chaining.
/// </summary>
public static T Field<T>(Container parent, string labelKey, T control, string tooltipKey = null) where T : Control
{
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space3);
var label = new Label
{
Text = Tr(labelKey),
CustomMinimumSize = new Vector2(LabelWidth, 0),
VerticalAlignment = VerticalAlignment.Center,
ClipText = true,
MouseFilter = Control.MouseFilterEnum.Pass,
TooltipText = tooltipKey != null ? Tr(tooltipKey) : string.Empty
};
label.AddThemeColorOverride("font_color", UiTokens.Muted);
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
row.AddChild(label);
control.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(control);
parent.AddChild(row);
return control;
}
/// <summary>
/// Numeric field. Values are typed and shown culture-independently by
/// <see cref="SpinBox"/>, which also clamps and steps them, so the editors
/// no longer parse free text ("0,5" vs "0.5").
/// </summary>
public static SpinBox Number(double min, double max, double step, Action<double> changed)
{
var spin = new SpinBox
{
MinValue = min,
MaxValue = max,
Step = step,
SelectAllOnFocus = true,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
CustomArrowStep = step
};
spin.GetLineEdit().AddThemeFontOverride("font", Mono());
if (changed != null)
spin.ValueChanged += value => changed(value);
return spin;
}
/// <summary>Single-line text field with a placeholder and an optional change handler.</summary>
public static LineEdit Text(string placeholder = "", Action<string> changed = null)
{
var edit = new LineEdit { PlaceholderText = placeholder ?? string.Empty };
if (changed != null)
edit.TextChanged += text => changed(text);
return edit;
}
/// <summary>Token button with a localized caption, optional tooltip and kind.</summary>
public static Button Button(string textKey, Action pressed, ButtonKind kind = ButtonKind.Secondary, string tooltipKey = null)
{
var button = UiKit.Button(Tr(textKey), kind);
if (tooltipKey != null)
button.TooltipText = Tr(tooltipKey);
if (pressed != null)
button.Pressed += pressed;
return button;
}
/// <summary>Thin vertical divider between toolbar groups.</summary>
public static VSeparator Divider() => new() { CustomMinimumSize = new Vector2(UiTokens.Space3, 0) };
/// <summary>Faint helper text that wraps inside narrow sidebars.</summary>
public static Label Hint(string text)
{
var label = UiKit.Hint(text);
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
label.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
label.CustomMinimumSize = new Vector2(80, 0);
return label;
}
/// <summary>Monospace font for IDs, numbers and code (style guide <c>--ct-mono</c>).</summary>
public static Font Mono() => ToolWindow.GetMonospaceFont();
/// <summary>Code editor preset: monospace, line numbers, current-line highlight, no wrap.</summary>
public static CodeEdit Code(bool editable = true)
{
var edit = new CodeEdit
{
Editable = editable,
GuttersDrawLineNumbers = true,
HighlightCurrentLine = true,
WrapMode = TextEdit.LineWrappingMode.None,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
IndentUseSpaces = true,
IndentSize = 2,
AutoBraceCompletionEnabled = false
};
edit.AddThemeFontOverride("font", Mono());
edit.AddThemeFontSizeOverride("font_size", UiTokens.FontBody);
edit.AddThemeColorOverride("current_line_color", UiTokens.WhiteTrace);
edit.AddThemeColorOverride("line_number_color", UiTokens.Faint);
edit.AddThemeColorOverride("font_readonly_color", UiTokens.Body);
return edit;
}
}
}
-79
View File
@@ -1,79 +0,0 @@
namespace Cthangover.Tools.Hub
{
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.UI.Tool;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// Launcher screen of the <c>novel_editor</c> collection, entered as its
/// logical entry scene. Lists every registered tool the same way the main
/// menu does — each <see cref="IToolProvider"/> appends its own button via
/// <see cref="IToolProvider.AddMenuButton"/> — so a new editor shows up here
/// without touching this screen. Tool windows open on the tree root and
/// survive scene switches (playing a scenario, returning to the menu).
/// </summary>
public partial class NovelEditorHub : Control
{
/// <summary>Logical scene name; the collection's <c>entry.scene</c>.</summary>
public const string SceneName = "novel_editor";
private const int PanelWidth = 420;
public override void _Ready()
{
CthangoverTheme.Apply(this);
AddChild(new ColorRect
{
Color = UiTokens.Page,
MouseFilter = MouseFilterEnum.Ignore,
AnchorRight = 1f,
AnchorBottom = 1f,
});
var center = new CenterContainer { AnchorRight = 1f, AnchorBottom = 1f };
AddChild(center);
var panel = new PanelContainer { CustomMinimumSize = new Vector2(PanelWidth, 0) };
var frame = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Surface1, UiTokens.Line);
frame.SetContentMarginAll(UiTokens.Space8);
panel.AddThemeStyleboxOverride("panel", frame);
center.AddChild(panel);
var column = UiKit.Column(UiTokens.Space5);
panel.AddChild(column);
column.AddChild(UiKit.Label(Tr("tools/hub/title").ToUpperInvariant(), UiTokens.FontTitle, UiTokens.InkBright));
var hint = UiKit.Hint(Tr("tools/hub/hint"));
hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
column.AddChild(hint);
column.AddChild(new HSeparator());
column.AddChild(UiKit.SectionTitle(Tr("tools/hub/editors"), UiTokens.FontSmall));
var tools = UiKit.Column(UiTokens.Space2);
tools.MouseFilter = MouseFilterEnum.Pass;
column.AddChild(tools);
foreach (var tool in ToolFactory.Instance.GetAll())
tool.AddMenuButton(tools);
if (tools.GetChildCount() == 0)
column.AddChild(UiKit.EmptyState(Tr("tools/hub/empty")));
column.AddChild(new HSeparator());
var menu = UiKit.Button(Tr("tools/hub/main_menu"));
menu.Pressed += OnMainMenuPressed;
column.AddChild(menu);
GameLogger.Log("TOOLS", $"Novel editor hub: {tools.GetChildCount()} tool button(s)");
}
private void OnMainMenuPressed()
{
GetNodeOrNull<GodotSceneService>("/root/GodotSceneService")?.SwitchToMenu();
}
}
}
-26
View File
@@ -1,26 +0,0 @@
namespace Cthangover.Tools.Hub
{
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
/// <summary>
/// Lifecycle entry point of the tools mod. Publishes the logical scene
/// <see cref="NovelEditorHub.SceneName"/>, the entry scene of the
/// <c>novel_editor</c> collection: launching that collection from the game
/// picker goes through the regular new-game flow and lands on the editor
/// launcher instead of a story scene.
/// </summary>
public class ToolsInitializer : IModInitializer
{
private const string HubScenePath = "scenes/novel_editor_hub.tscn";
public void OnModLoaded(string modId)
{
}
public void OnModResourcesReady()
{
SceneRouteRegistry.Register(NovelEditorHub.SceneName, HubScenePath, fade: false);
}
}
}
@@ -1,260 +0,0 @@
using System;
using System.Linq;
using Cthangover.Core.Interactive;
using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Tools.InteractiveEditor
{
/// <summary>
/// Preview of the interactives of one file over a scene background. Every
/// collider is drawn (others faint, the selected one filled), so the author
/// sees how objects of the same scene sit next to each other; clicking a
/// collider selects it. The selected collider gets handles:
/// <list type="bullet">
/// <item>rect — top-left and bottom-right corners plus a centre handle
/// that moves the whole rect;</item>
/// <item>circle — centre and a radius handle;</item>
/// <item>polygon — one handle per vertex (right-click deletes it); a click
/// on empty background appends a vertex.</item>
/// </list>
/// Geometry is in the same normalised background space as
/// <c>InteractiveObject</c>: x/y/width/height are fractions of the
/// background size, a circle radius is a fraction of its shorter side.
/// </summary>
public partial class InteractiveEditorCanvas : BackgroundCanvas
{
private const int TopLeft = 0;
private const int BottomRight = 1;
private const int Move = 2;
private const int RadiusHandle = 1;
private const float MinExtent = 0.005f;
/// <summary>Data being edited; set once by the window.</summary>
public InteractiveEditorController Controller;
/// <summary>Raised whenever geometry of the selected item changes.</summary>
public event Action Changed;
/// <summary>Raised when a click on the canvas selects another item (-1: none).</summary>
public event Action<int> ItemPicked;
public InteractiveEditorCanvas()
{
GuiInput += OnGuiInput;
}
/// <summary>Recreates the handles of the selected item's collider.</summary>
public override void RebuildHandles()
{
ClearHandles();
var hit = Controller?.Selected?.HitArea;
if (hit == null || GetBackgroundRect().Size.X <= 0f)
return;
switch (hit.Type)
{
case "circle":
AddHandle(new Vector2(hit.X, hit.Y), UiTokens.Info, Move, p => { hit.X = p.X; hit.Y = p.Y; Moved(); });
AddHandle(RadiusPoint(hit), UiTokens.Info, RadiusHandle, p =>
{
var shortSide = ShortSide();
var centre = NormToLocal(new Vector2(hit.X, hit.Y));
hit.Radius = Mathf.Max(MinExtent, (NormToLocal(p) - centre).Length() / shortSide);
Moved();
});
break;
case "polygon":
var vertices = hit.Vertices ?? Array.Empty<Vector2>();
for (var i = 0; i < vertices.Length; i++)
{
var index = i;
var handle = AddHandle(vertices[i], UiTokens.Accent, i, p => { hit.Vertices[index] = p; Moved(); });
handle.RightClicked = () => RemoveVertex(index);
}
break;
default:
AddHandle(new Vector2(hit.X, hit.Y), UiTokens.Success, TopLeft, p =>
{
var right = hit.X + hit.Width;
var bottom = hit.Y + hit.Height;
hit.X = Mathf.Min(p.X, right - MinExtent);
hit.Y = Mathf.Min(p.Y, bottom - MinExtent);
hit.Width = right - hit.X;
hit.Height = bottom - hit.Y;
Moved();
});
AddHandle(new Vector2(hit.X + hit.Width, hit.Y + hit.Height), UiTokens.Success, BottomRight, p =>
{
hit.Width = Mathf.Max(MinExtent, p.X - hit.X);
hit.Height = Mathf.Max(MinExtent, p.Y - hit.Y);
Moved();
});
AddHandle(new Vector2(hit.X + hit.Width / 2f, hit.Y + hit.Height / 2f), UiTokens.AccentBright, Move, p =>
{
hit.X = Mathf.Clamp(p.X - hit.Width / 2f, 0f, 1f - hit.Width);
hit.Y = Mathf.Clamp(p.Y - hit.Height / 2f, 0f, 1f - hit.Height);
Moved();
}, 12f);
break;
}
foreach (var handle in _handles)
handle.DragEnd = () => { RebuildHandles(); Changed?.Invoke(); };
}
/// <summary>Keeps the other handles glued to the shape while one is dragged.</summary>
private void Moved()
{
var hit = Controller.Selected.HitArea;
foreach (var handle in _handles)
{
Vector2? target = hit.Type switch
{
"circle" => handle.Index == Move ? new Vector2(hit.X, hit.Y) : RadiusPoint(hit),
"polygon" => null,
_ => handle.Index switch
{
TopLeft => new Vector2(hit.X, hit.Y),
BottomRight => new Vector2(hit.X + hit.Width, hit.Y + hit.Height),
_ => new Vector2(hit.X + hit.Width / 2f, hit.Y + hit.Height / 2f)
}
};
if (target.HasValue)
handle.CenterAt(GlobalPosition + NormToLocal(target.Value));
}
QueueRedraw();
Changed?.Invoke();
}
private Vector2 RadiusPoint(HitAreaDefinition hit)
{
var rect = GetBackgroundRect();
var offset = rect.Size.X > 0f ? hit.Radius * ShortSide() / rect.Size.X : 0f;
return new Vector2(hit.X + offset, hit.Y);
}
private float ShortSide()
{
var rect = GetBackgroundRect();
return Mathf.Max(1f, Mathf.Min(rect.Size.X, rect.Size.Y));
}
private void RemoveVertex(int index)
{
var hit = Controller.Selected?.HitArea;
if (hit?.Vertices == null || index >= hit.Vertices.Length)
return;
hit.Vertices = hit.Vertices.Where((_, i) => i != index).ToArray();
RebuildHandles();
QueueRedraw();
Changed?.Invoke();
}
private void OnGuiInput(InputEvent @event)
{
if (Controller == null || @event is not InputEventMouseButton mb || !mb.Pressed || mb.ButtonIndex != MouseButton.Left)
return;
var norm = LocalToNorm(mb.Position);
var hitIndex = HitTest(norm);
var selected = Controller.Selected;
if (hitIndex >= 0 && hitIndex != Controller.SelectedIndex)
{
Controller.Select(hitIndex);
RebuildHandles();
QueueRedraw();
ItemPicked?.Invoke(hitIndex);
}
else if (hitIndex < 0 && selected?.HitArea.Type == "polygon")
{
var vertices = selected.HitArea.Vertices ?? Array.Empty<Vector2>();
selected.HitArea.Vertices = vertices.Append(Clamp01(norm)).ToArray();
RebuildHandles();
QueueRedraw();
Changed?.Invoke();
}
AcceptEvent();
}
/// <summary>Topmost item whose collider contains the normalised point, or -1.</summary>
private int HitTest(Vector2 norm)
{
var rect = GetBackgroundRect();
var local = NormToLocal(norm);
for (var i = Controller.Items.Count - 1; i >= 0; i--)
{
var hit = Controller.Items[i].HitArea;
if (hit == null)
continue;
var inside = hit.Type switch
{
"circle" => (local - NormToLocal(new Vector2(hit.X, hit.Y))).Length() <= hit.Radius * Mathf.Min(rect.Size.X, rect.Size.Y),
"polygon" => hit.Vertices is { Length: >= 3 } && Geometry2D.IsPointInPolygon(norm, hit.Vertices),
_ => new Rect2(hit.X, hit.Y, hit.Width, hit.Height).HasPoint(norm)
};
if (inside)
return i;
}
return -1;
}
protected override void DrawContent(Rect2 rect)
{
if (Controller == null)
return;
for (var i = 0; i < Controller.Items.Count; i++)
DrawCollider(Controller.Items[i], rect, i == Controller.SelectedIndex);
}
private void DrawCollider(InteractiveDefinition def, Rect2 rect, bool selected)
{
var hit = def.HitArea;
if (hit == null)
return;
var line = selected ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.Ink, 0.55f);
var fill = selected ? UiTokens.Alpha(UiTokens.Accent, 0.22f) : UiTokens.Alpha(UiTokens.Ink, 0.06f);
Vector2 labelAt;
switch (hit.Type)
{
case "circle":
var centre = NormToLocal(new Vector2(hit.X, hit.Y));
var radius = hit.Radius * Mathf.Min(rect.Size.X, rect.Size.Y);
DrawCircle(centre, radius, fill);
DrawArc(centre, radius, 0f, Mathf.Tau, 48, line, selected ? 2f : 1f, true);
labelAt = centre + new Vector2(-radius, -radius);
break;
case "polygon":
var vertices = hit.Vertices ?? Array.Empty<Vector2>();
var points = vertices.Select(NormToLocal).ToArray();
if (points.Length >= 3 && Geometry2D.TriangulatePolygon(points).Length > 0)
DrawColoredPolygon(points, fill);
if (points.Length >= 2)
DrawPolyline(points.Append(points[0]).ToArray(), line, selected ? 2f : 1f, true);
labelAt = points.Length > 0 ? points[0] : rect.Position;
break;
default:
var box = new Rect2(NormToLocal(new Vector2(hit.X, hit.Y)), new Vector2(hit.Width, hit.Height) * rect.Size);
DrawRect(box, fill);
DrawRect(box, line, false, selected ? 2f : 1f);
labelAt = box.Position;
break;
}
var font = GetThemeDefaultFont();
var size = UiTokens.FontSmall;
DrawString(font, labelAt + new Vector2(4f, -4f), def.ID ?? "?", HorizontalAlignment.Left, -1, size,
selected ? UiTokens.AccentBright : UiTokens.Body);
}
}
}
@@ -1,206 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Cthangover.Core.Interactive;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.InteractiveEditor
{
/// <summary>
/// Data model of the interactive editor: every definition of one
/// <c>interactives/*.json</c> file plus the selection. Definitions are
/// edited in place (the same objects the game deserialises), so the file
/// round-trips without a parallel field-by-field copy that could forget a
/// property. <see cref="Ensure"/> fills the optional sub-objects the editor
/// needs (hit area, highlight, actions) before a definition is shown.
/// </summary>
public class InteractiveEditorController
{
/// <summary>Collider kinds understood by <c>InteractiveObject</c>.</summary>
public static readonly string[] HitTypes = { "rect", "circle", "polygon" };
/// <summary>Render layers understood by the interactive layer host.</summary>
public static readonly string[] Layers = { "foreground", "background", "ui" };
/// <summary>Definitions of the open file, in file order.</summary>
public List<InteractiveDefinition> Items { get; private set; } = new();
/// <summary>Index of the selected definition, or -1.</summary>
public int SelectedIndex { get; private set; } = -1;
/// <summary>The selected definition, or <c>null</c>.</summary>
public InteractiveDefinition Selected => SelectedIndex >= 0 && SelectedIndex < Items.Count ? Items[SelectedIndex] : null;
/// <summary>Replaces the items with the contents of a <c>{"Items": [...]}</c> file. Throws on malformed JSON.</summary>
public void FromJson(string json)
{
var data = string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<FileData<InteractiveDefinition>>(json, JsonOptionsProvider.ReadOptions);
Items = data?.Items ?? new List<InteractiveDefinition>();
foreach (var item in Items)
Ensure(item);
SelectedIndex = Items.Count > 0 ? 0 : -1;
}
/// <summary>Serialises all items into the standard envelope (indented, nulls omitted).</summary>
public string ToJson()
{
var data = new FileData<InteractiveDefinition> { Items = Items };
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
}
/// <summary>Drops all items.</summary>
public void Clear()
{
Items = new List<InteractiveDefinition>();
SelectedIndex = -1;
}
/// <summary>Selects by index; out of range clears the selection.</summary>
public void Select(int index)
{
SelectedIndex = index >= 0 && index < Items.Count ? index : -1;
}
/// <summary>Appends a new clickable rect in the middle of the background and selects it.</summary>
public InteractiveDefinition Add()
{
var def = new InteractiveDefinition
{
ID = MakeUniqueId("interactive"),
Cursor = "PointingHand",
HitArea = new HitAreaDefinition { Type = "rect", X = 0.45f, Y = 0.45f, Width = 0.1f, Height = 0.1f }
};
Ensure(def);
Items.Add(def);
SelectedIndex = Items.Count - 1;
return def;
}
/// <summary>Appends a deep copy of the selected definition with a fresh ID, slightly offset.</summary>
public InteractiveDefinition DuplicateSelected()
{
var source = Selected;
if (source == null)
return null;
var json = JsonSerializer.Serialize(source, JsonOptionsProvider.WriteCompactOptions);
var copy = JsonSerializer.Deserialize<InteractiveDefinition>(json, JsonOptionsProvider.ReadOptions);
copy.ID = MakeUniqueId(source.ID);
Ensure(copy);
Offset(copy.HitArea, new Vector2(0.02f, 0.02f));
Items.Add(copy);
SelectedIndex = Items.Count - 1;
return copy;
}
/// <summary>Removes the selected definition and keeps a sensible selection.</summary>
public void RemoveSelected()
{
if (Selected == null)
return;
Items.RemoveAt(SelectedIndex);
SelectedIndex = Items.Count == 0 ? -1 : Math.Min(SelectedIndex, Items.Count - 1);
}
/// <summary>
/// Changes the collider kind of a definition, converting the current
/// shape so it stays roughly where it was (rect ↔ circle by bounds,
/// polygon from the bounds' corners).
/// </summary>
public static void SetHitType(InteractiveDefinition def, string type)
{
var hit = def.HitArea;
if (hit.Type == type)
return;
var bounds = Bounds(hit);
hit.Type = type;
switch (type)
{
case "circle":
hit.X = bounds.GetCenter().X;
hit.Y = bounds.GetCenter().Y;
hit.Radius = Mathf.Max(0.01f, Mathf.Min(bounds.Size.X, bounds.Size.Y) / 2f);
break;
case "polygon":
hit.Vertices = new[]
{
bounds.Position,
new Vector2(bounds.End.X, bounds.Position.Y),
bounds.End,
new Vector2(bounds.Position.X, bounds.End.Y)
};
break;
default:
hit.X = bounds.Position.X;
hit.Y = bounds.Position.Y;
hit.Width = bounds.Size.X;
hit.Height = bounds.Size.Y;
break;
}
}
/// <summary>
/// Normalised bounding box of a hit area (circle radius taken as a
/// fraction of both axes — an approximation good enough for
/// conversions and duplication offsets).
/// </summary>
public static Rect2 Bounds(HitAreaDefinition hit)
{
switch (hit?.Type)
{
case "circle":
return new Rect2(hit.X - hit.Radius, hit.Y - hit.Radius, hit.Radius * 2f, hit.Radius * 2f);
case "polygon" when hit.Vertices is { Length: > 0 }:
var min = hit.Vertices[0];
var max = hit.Vertices[0];
foreach (var v in hit.Vertices)
{
min = new Vector2(Mathf.Min(min.X, v.X), Mathf.Min(min.Y, v.Y));
max = new Vector2(Mathf.Max(max.X, v.X), Mathf.Max(max.Y, v.Y));
}
return new Rect2(min, max - min);
case null:
return new Rect2(0.45f, 0.45f, 0.1f, 0.1f);
default:
return new Rect2(hit.X, hit.Y, hit.Width, hit.Height);
}
}
/// <summary>Makes sure the optional parts the editor binds to exist.</summary>
public static void Ensure(InteractiveDefinition def)
{
def.HitArea ??= new HitAreaDefinition();
def.HitArea.Type = HitTypes.Contains(def.HitArea.Type) ? def.HitArea.Type : "rect";
def.Highlight ??= new HighlightDefinition();
def.Actions ??= new InteractiveActionDefinition();
}
/// <summary>ID not used by another item: <c>base</c>, <c>base_2</c>, <c>base_3</c>…</summary>
public string MakeUniqueId(string baseId)
{
var used = new HashSet<string>(Items.Select(i => i.ID ?? string.Empty), StringComparer.OrdinalIgnoreCase);
if (!used.Contains(baseId))
return baseId;
var suffix = 2;
while (used.Contains($"{baseId}_{suffix}"))
suffix++;
return $"{baseId}_{suffix}";
}
private static void Offset(HitAreaDefinition hit, Vector2 delta)
{
hit.X += delta.X;
hit.Y += delta.Y;
if (hit.Vertices != null)
hit.Vertices = hit.Vertices.Select(v => v + delta).ToArray();
}
}
}
@@ -1,39 +0,0 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Tools.InteractiveEditor
{
/// <summary>
/// Registers the interactive editor as a tool window in the developer toolbox.
/// Implements <see cref="IToolProvider"/> so the tool system can instantiate
/// <see cref="InteractiveEditorWindow"/> on demand.
/// </summary>
public class InteractiveEditorToolProvider : IToolProvider
{
/// <summary>Unique tool identifier referenced by the toolbox UI.</summary>
public string Id => "interactive_editor";
/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey => "tools/interactive_editor/title";
/// <summary>Creates the editor window instance.</summary>
public Window CreateWindow() => new InteractiveEditorWindow();
/// <summary>Adds the interactive editor's launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
}
/// <summary>
/// Provides a button in the developer toolbox that opens the interactive editor.
/// Controls visibility via the <see cref="IsVisible"/> policy.
/// </summary>
public class InteractiveEditorToolBoxButton : IToolBoxButton
{
/// <summary>Matches <see cref="InteractiveEditorToolProvider.Id"/>.</summary>
public string ToolId => "interactive_editor";
/// <summary>No custom icon — uses the tool title text.</summary>
public string IconPath => "";
/// <summary>Translation key for the button label.</summary>
public string LocaleKey => "tools/interactive_editor/title";
/// <summary>Always visible in the toolbox.</summary>
public bool IsVisible() => true;
}
}
@@ -1,644 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Interactive;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.InteractiveEditor
{
/// <summary>
/// Editor of interactive object definitions (<c>interactives/*.json</c>).
/// The author opens a file of a writable mod, sees every object of the
/// file over its scene background, picks one in the list or on the canvas
/// and edits its collider by dragging handles or typing numbers, plus its
/// look (texture, layer, highlight) and actions (scenario, commands, hover
/// commands). Saving writes the whole file back, other objects included.
/// <para/>
/// The background is chosen automatically from the scene whose scenario
/// adds the selected object (<c>interactive_add</c>), when that is known.
/// </summary>
public partial class InteractiveEditorWindow : ToolWindow
{
private const string InteractivesDir = "interactives";
private readonly InteractiveEditorController _controller = new();
private readonly List<string> _backgroundIds = new();
private Dictionary<string, string> _interactiveScenes;
private string _modId;
private string _path;
private bool _suppress;
private ModFileSelector _fileSelector;
private FilteredList _itemList;
private FilteredList _bgList;
private InteractiveEditorCanvas _canvas;
private CodeEdit _jsonEdit;
private Label _propsEmpty;
private VBoxContainer _propsBody;
private LineEdit _idEdit;
private LineEdit _textureEdit;
private OptionButton _layerSelect;
private SpinBox _zIndex;
private CheckBox _enabledCheck;
private CheckBox _visibleCheck;
private OptionButton _cursorSelect;
private OptionButton _hitTypeSelect;
private VBoxContainer _rectParams;
private VBoxContainer _circleParams;
private VBoxContainer _polyParams;
private SpinBox _rectX, _rectY, _rectW, _rectH;
private SpinBox _circleX, _circleY, _circleR;
private VBoxContainer _vertexList;
private ColorPickerButton _highlightColor;
private SpinBox _highlightScale;
private LineEdit _clickScenario;
private CodeEdit _clickCommands;
private LineEdit _hoverEnter;
private LineEdit _hoverLeave;
public InteractiveEditorWindow() : base("tools/interactive_editor/title")
{
CthangoverTheme.Apply(this);
BuildUI();
LoadBackgrounds();
SetHint(ToolUi.Tr("tools/interactive_editor/hint"));
_fileSelector.Refresh();
}
// ── UI construction ────────────────────────────────────────
private void BuildUI()
{
var outer = CreateFillContainer();
AddChild(outer);
var toolbar = CreateToolbar();
outer.AddChild(toolbar);
_fileSelector = new ModFileSelector(InteractivesDir, ".json");
_fileSelector.FileRequested += OnFileRequested;
toolbar.AddChild(_fileSelector);
toolbar.AddChild(ToolUi.Button("tools/common/save", Save, ButtonKind.Primary, "tools/common/save_tip"));
toolbar.AddChild(UiKit.Spacer());
var jsonToggle = ToolUi.Button("tools/common/json", null, ButtonKind.Tab, "tools/common/json_tip");
jsonToggle.ToggleMode = true;
jsonToggle.Toggled += on => _jsonEdit.Visible = on;
toolbar.AddChild(jsonToggle);
var main = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outer.AddChild(main);
var left = new VBoxContainer();
main.AddChild(CreateSidebar(left, 260));
BuildLeft(left);
var centerSplit = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
main.AddChild(centerSplit);
var canvasColumn = new VSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
centerSplit.AddChild(canvasColumn);
var canvasPanel = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
canvasPanel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line));
canvasColumn.AddChild(canvasPanel);
_canvas = new InteractiveEditorCanvas { Controller = _controller };
_canvas.Changed += OnCanvasChanged;
_canvas.ItemPicked += _ => RefreshAll();
canvasPanel.AddChild(_canvas);
_jsonEdit = ToolUi.Code(editable: false);
_jsonEdit.CustomMinimumSize = new Vector2(0, 160);
_jsonEdit.Visible = false;
canvasColumn.AddChild(_jsonEdit);
var right = new VBoxContainer();
centerSplit.AddChild(CreateSidebar(right, 330));
BuildProperties(right);
}
private void BuildLeft(VBoxContainer column)
{
column.AddChild(ToolUi.Section("tools/interactive_editor/items"));
_itemList = new FilteredList(180);
_itemList.Chosen += OnItemChosen;
column.AddChild(_itemList);
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space1);
column.AddChild(row);
AddExpanding(row, ToolUi.Button("tools/interactive_editor/add", OnAdd));
AddExpanding(row, ToolUi.Button("tools/interactive_editor/duplicate", OnDuplicate));
column.AddChild(ToolUi.Button("tools/interactive_editor/delete", OnDelete, ButtonKind.Danger));
column.AddChild(ToolUi.Section("tools/interactive_editor/background"));
_bgList = new FilteredList(160);
_bgList.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
_bgList.Chosen += ShowBackground;
column.AddChild(_bgList);
}
private static void AddExpanding(Container parent, Control control)
{
control.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
parent.AddChild(control);
}
private void BuildProperties(VBoxContainer column)
{
column.AddChild(ToolUi.Section("tools/interactive_editor/props"));
_propsEmpty = ToolUi.Hint(ToolUi.Tr("tools/interactive_editor/none_selected"));
column.AddChild(_propsEmpty);
var body = new VBoxContainer();
body.AddThemeConstantOverride("separation", UiTokens.Space2);
column.AddChild(body);
_propsBody = body;
_idEdit = ToolUi.Field(body, "tools/interactive_editor/label_id", ToolUi.Text("crypt_spot", _ => OnIdChanged()));
_idEdit.AddThemeFontOverride("font", ToolUi.Mono());
_textureEdit = ToolUi.Field(body, "tools/interactive_editor/label_texture_key", ToolUi.Text(ToolUi.Tr("tools/interactive_editor/texture_placeholder"), _ => OnFieldsChanged()),
"tools/interactive_editor/texture_tip");
_layerSelect = ToolUi.Field(body, "tools/interactive_editor/label_layer", new OptionButton());
foreach (var layer in InteractiveEditorController.Layers)
_layerSelect.AddItem(layer);
_layerSelect.ItemSelected += _ => OnFieldsChanged();
_zIndex = ToolUi.Field(body, "tools/interactive_editor/label_z", ToolUi.Number(-100, 100, 1, _ => OnFieldsChanged()));
_cursorSelect = ToolUi.Field(body, "tools/interactive_editor/label_cursor", new OptionButton());
_cursorSelect.AddItem(ToolUi.Tr("tools/interactive_editor/cursor_none"));
_cursorSelect.AddItem("PointingHand");
_cursorSelect.ItemSelected += _ => OnFieldsChanged();
_enabledCheck = new CheckBox { Text = ToolUi.Tr("tools/interactive_editor/enabled") };
_enabledCheck.Toggled += _ => OnFieldsChanged();
body.AddChild(_enabledCheck);
_visibleCheck = new CheckBox { Text = ToolUi.Tr("tools/interactive_editor/visible") };
_visibleCheck.Toggled += _ => OnFieldsChanged();
body.AddChild(_visibleCheck);
body.AddChild(ToolUi.Section("tools/interactive_editor/collider"));
_hitTypeSelect = ToolUi.Field(body, "tools/interactive_editor/label_collider_type", new OptionButton());
foreach (var type in InteractiveEditorController.HitTypes)
_hitTypeSelect.AddItem(HitTypeLabel(type));
_hitTypeSelect.ItemSelected += OnHitTypeChanged;
_rectParams = new VBoxContainer();
body.AddChild(_rectParams);
_rectX = ToolUi.Field(_rectParams, "tools/interactive_editor/label_x", ToolUi.Number(0, 1, 0.001, _ => OnGeometryTyped()));
_rectY = ToolUi.Field(_rectParams, "tools/interactive_editor/label_y", ToolUi.Number(0, 1, 0.001, _ => OnGeometryTyped()));
_rectW = ToolUi.Field(_rectParams, "tools/interactive_editor/label_w", ToolUi.Number(0.001, 1, 0.001, _ => OnGeometryTyped()));
_rectH = ToolUi.Field(_rectParams, "tools/interactive_editor/label_h", ToolUi.Number(0.001, 1, 0.001, _ => OnGeometryTyped()));
_circleParams = new VBoxContainer();
body.AddChild(_circleParams);
_circleX = ToolUi.Field(_circleParams, "tools/interactive_editor/label_cx", ToolUi.Number(0, 1, 0.001, _ => OnGeometryTyped()));
_circleY = ToolUi.Field(_circleParams, "tools/interactive_editor/label_cy", ToolUi.Number(0, 1, 0.001, _ => OnGeometryTyped()));
_circleR = ToolUi.Field(_circleParams, "tools/interactive_editor/label_r", ToolUi.Number(0.001, 1, 0.001, _ => OnGeometryTyped()));
_polyParams = new VBoxContainer();
body.AddChild(_polyParams);
_polyParams.AddChild(ToolUi.Hint(ToolUi.Tr("tools/interactive_editor/polygon_hint")));
_vertexList = new VBoxContainer();
_vertexList.AddThemeConstantOverride("separation", UiTokens.Space1);
_polyParams.AddChild(_vertexList);
body.AddChild(ToolUi.Section("tools/interactive_editor/highlight"));
_highlightColor = ToolUi.Field(body, "tools/interactive_editor/label_highlight_color", new ColorPickerButton { CustomMinimumSize = new Vector2(0, 28) });
_highlightColor.ColorChanged += _ => OnFieldsChanged();
_highlightScale = ToolUi.Field(body, "tools/interactive_editor/label_highlight_scale", ToolUi.Number(0.5, 2, 0.01, _ => OnFieldsChanged()));
body.AddChild(ToolUi.Section("tools/interactive_editor/actions"));
_clickScenario = ToolUi.Field(body, "tools/interactive_editor/label_onclick_scenario", ToolUi.Text("scenarios/…/file.scenario", _ => OnFieldsChanged()));
body.AddChild(UiKit.Label(ToolUi.Tr("tools/interactive_editor/label_onclick_commands"), UiTokens.FontBodySmall, UiTokens.Muted));
_clickCommands = ToolUi.Code();
_clickCommands.GuttersDrawLineNumbers = false;
_clickCommands.CustomMinimumSize = new Vector2(0, 70);
_clickCommands.SyntaxHighlighter = ScenarioSyntaxService.CreateHighlighter();
_clickCommands.TextChanged += OnFieldsChanged;
body.AddChild(_clickCommands);
_hoverEnter = ToolUi.Field(body, "tools/interactive_editor/label_hover_enter", ToolUi.Text("", _ => OnFieldsChanged()));
_hoverLeave = ToolUi.Field(body, "tools/interactive_editor/label_hover_leave", ToolUi.Text("", _ => OnFieldsChanged()));
}
// ── loading & saving ───────────────────────────────────────
private void LoadBackgrounds()
{
_backgroundIds.AddRange(ModResourceService.GetBackgroundIds());
_bgList.SetItems(_backgroundIds);
}
private void ShowBackground(string id)
{
if (string.IsNullOrEmpty(id))
return;
_bgList.Select(id);
_canvas.SetBackground(ModResourceService.LoadBackgroundTexture(id));
}
/// <summary>Shows the background of the scene that adds the selected object, when known.</summary>
private void ShowSceneBackground()
{
var id = _controller.Selected?.ID;
if (id == null)
return;
_interactiveScenes ??= SceneBackgroundService.MapInteractivesToScenes();
if (!_interactiveScenes.TryGetValue(id, out var scene))
return;
var background = SceneBackgroundService.ForScene(scene);
if (background == null || background == _bgList.SelectedId)
return;
if (ModResourceService.LoadBackgroundTexture(background) == null)
SetStatus(string.Format(ToolUi.Tr("tools/common/background_missing"), background, scene), ToolStatusKind.Error);
else
ShowBackground(background);
}
private void OnFileRequested(string modId, string path)
{
RunDiscardingChanges(() => Load(modId, path), () => _fileSelector.Show(_modId, _path));
}
private void Load(string modId, string path)
{
_modId = modId;
_path = path;
_controller.Clear();
MarkClean();
if (modId == null)
SetStatus(ToolUi.Tr("tools/common/no_writable_mods"), ToolStatusKind.Error);
else if (path == null)
SetStatus(string.Format(ToolUi.Tr("tools/common/no_files"), modId));
else
{
try
{
var text = ModFileService.ReadText(modId, path);
_controller.FromJson(text);
SetStatus(string.Format(ToolUi.Tr(string.IsNullOrEmpty(text) ? "tools/common/new_file_status" : "tools/common/loaded"), modId + "/" + path));
}
catch (Exception ex)
{
SetStatus($"{ToolUi.Tr("tools/common/load_error")}: {ex.Message}", ToolStatusKind.Error);
GameLogger.Log("INTERACTIVE_EDITOR", $"load failed for '{modId}/{path}': {ex.Message}", LogLevel.Error);
}
}
_fileSelector.Show(_modId, _path);
RefreshAll();
ShowSceneBackground();
}
/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut() => Save();
private void Save()
{
if (_path == null)
{
SetStatus(ToolUi.Tr("tools/common/no_file_to_save"), ToolStatusKind.Error);
return;
}
var problem = Validate();
if (problem != null)
{
SetStatus(problem, ToolStatusKind.Error);
return;
}
if (!ModFileService.WriteText(_modId, _path, _controller.ToJson()))
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return;
}
MarkClean();
_fileSelector.Show(_modId, _path);
SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), _modId + "/" + _path), ToolStatusKind.Ok);
}
/// <summary>First blocking problem of the file (empty/duplicate ID, polygon with &lt; 3 vertices), or null.</summary>
private string Validate()
{
foreach (var item in _controller.Items)
{
if (string.IsNullOrWhiteSpace(item.ID))
return ToolUi.Tr("tools/interactive_editor/id_empty");
if (item.HitArea.Type == "polygon" && (item.HitArea.Vertices?.Length ?? 0) < 3)
return string.Format(ToolUi.Tr("tools/interactive_editor/polygon_too_small"), item.ID);
}
var duplicate = _controller.Items.GroupBy(i => i.ID, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1);
return duplicate != null ? $"{ToolUi.Tr("tools/interactive_editor/duplicate_id")} {duplicate.Key}" : null;
}
// ── list actions ───────────────────────────────────────────
private void OnItemChosen(string indexText)
{
if (_suppress || !int.TryParse(indexText, out var index))
return;
_controller.Select(index);
RefreshAll();
ShowSceneBackground();
}
private void OnAdd()
{
if (_path == null)
{
SetStatus(ToolUi.Tr("tools/common/no_file_to_save"), ToolStatusKind.Error);
return;
}
_controller.Add();
Changed();
RefreshAll();
}
private void OnDuplicate()
{
if (_controller.DuplicateSelected() == null)
return;
Changed();
RefreshAll();
}
private void OnDelete()
{
var item = _controller.Selected;
if (item == null)
return;
ShowConfirm("tools/interactive_editor/delete", "tools/interactive_editor/delete_confirm", () =>
{
_controller.RemoveSelected();
Changed();
RefreshAll();
SetStatus(string.Format(ToolUi.Tr("tools/interactive_editor/deleted"), item.ID));
});
}
/// <summary>Delete removes the selected object (with confirmation).</summary>
protected override bool HandleKey(InputEventKey key)
{
if (key.Keycode != Key.Delete || _controller.Selected == null)
return false;
OnDelete();
return true;
}
// ── property editing ───────────────────────────────────────
private void OnIdChanged()
{
var item = _controller.Selected;
if (_suppress || item == null)
return;
item.ID = _idEdit.Text.Trim();
Changed();
RefreshItemList();
}
private void OnFieldsChanged()
{
var item = _controller.Selected;
if (_suppress || item == null)
return;
item.Texture = NullIfEmpty(_textureEdit.Text);
item.Layer = InteractiveEditorController.Layers[Math.Max(0, _layerSelect.Selected)];
item.ZIndex = (int)_zIndex.Value;
item.Enabled = _enabledCheck.ButtonPressed;
item.Visible = _visibleCheck.ButtonPressed;
item.Cursor = _cursorSelect.Selected == 1 ? "PointingHand" : null;
item.Highlight.ColorHex = "#" + _highlightColor.Color.ToHtml(true);
item.Highlight.Scale = (float)_highlightScale.Value;
var commands = _clickCommands.Text.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray();
var scenario = NullIfEmpty(_clickScenario.Text);
item.Actions.OnClick = scenario != null || commands.Length > 0
? new ClickAction { Scenario = scenario, Commands = commands.Length > 0 ? commands : null }
: null;
item.Actions.OnHoverEnter = NullIfEmpty(_hoverEnter.Text);
item.Actions.OnHoverLeave = NullIfEmpty(_hoverLeave.Text);
Changed();
}
private void OnHitTypeChanged(long index)
{
var item = _controller.Selected;
if (_suppress || item == null)
return;
InteractiveEditorController.SetHitType(item, InteractiveEditorController.HitTypes[index]);
Changed();
RefreshAll();
}
private void OnGeometryTyped()
{
var hit = _controller.Selected?.HitArea;
if (_suppress || hit == null)
return;
if (hit.Type == "circle")
{
hit.X = (float)_circleX.Value;
hit.Y = (float)_circleY.Value;
hit.Radius = (float)_circleR.Value;
}
else if (hit.Type == "rect")
{
hit.X = (float)_rectX.Value;
hit.Y = (float)_rectY.Value;
hit.Width = (float)_rectW.Value;
hit.Height = (float)_rectH.Value;
}
Changed();
_canvas.RebuildHandles();
_canvas.QueueRedraw();
}
private void OnCanvasChanged()
{
Changed();
_suppress = true;
WriteGeometry(_controller.Selected?.HitArea);
_suppress = false;
}
private void Changed()
{
SetDirty();
if (_jsonEdit.Visible)
_jsonEdit.Text = _controller.ToJson();
}
// ── refresh ────────────────────────────────────────────────
private void RefreshAll()
{
RefreshItemList();
_suppress = true;
WriteSidebar();
_suppress = false;
_jsonEdit.Text = _controller.ToJson();
_canvas.RebuildHandles();
_canvas.QueueRedraw();
}
private void RefreshItemList()
{
var items = _controller.Items
.Select((item, i) => (i.ToString(), (string.IsNullOrEmpty(item.ID) ? "?" : item.ID) + " · " + HitTypeLabel(item.HitArea.Type), (Texture2D)null));
_itemList.SetItems(items);
_itemList.Select(_controller.SelectedIndex >= 0 ? _controller.SelectedIndex.ToString() : null);
}
private void WriteSidebar()
{
var item = _controller.Selected;
_propsEmpty.Visible = item == null;
_propsBody.Visible = item != null;
if (item == null)
return;
_idEdit.Text = item.ID ?? string.Empty;
_textureEdit.Text = item.Texture ?? string.Empty;
_layerSelect.Select(Math.Max(0, Array.IndexOf(InteractiveEditorController.Layers, item.Layer)));
_zIndex.Value = item.ZIndex;
_enabledCheck.ButtonPressed = item.Enabled;
_visibleCheck.ButtonPressed = item.Visible;
_cursorSelect.Select(string.IsNullOrEmpty(item.Cursor) ? 0 : 1);
_hitTypeSelect.Select(Math.Max(0, Array.IndexOf(InteractiveEditorController.HitTypes, item.HitArea.Type)));
_highlightColor.Color = item.Highlight.Color;
_highlightScale.Value = item.Highlight.Scale;
_clickScenario.Text = item.Actions.OnClick?.Scenario ?? string.Empty;
_clickCommands.Text = item.Actions.OnClick?.Commands != null ? string.Join("\n", item.Actions.OnClick.Commands) : string.Empty;
_hoverEnter.Text = item.Actions.OnHoverEnter ?? string.Empty;
_hoverLeave.Text = item.Actions.OnHoverLeave ?? string.Empty;
WriteGeometry(item.HitArea);
}
private void WriteGeometry(HitAreaDefinition hit)
{
if (hit == null)
return;
_rectParams.Visible = hit.Type == "rect";
_circleParams.Visible = hit.Type == "circle";
_polyParams.Visible = hit.Type == "polygon";
_rectX.Value = hit.X;
_rectY.Value = hit.Y;
_rectW.Value = hit.Width;
_rectH.Value = hit.Height;
_circleX.Value = hit.X;
_circleY.Value = hit.Y;
_circleR.Value = hit.Radius;
RefreshVertexList(hit);
}
/// <summary>X/Y spin rows per polygon vertex; handlers read the vertex at change time.</summary>
private void RefreshVertexList(HitAreaDefinition hit)
{
var vertices = hit.Type == "polygon" ? hit.Vertices ?? Array.Empty<Vector2>() : Array.Empty<Vector2>();
var rows = _vertexList.GetChildren();
// Reuse rows while dragging so focus and scroll are not lost on every motion.
if (rows.Count == vertices.Length)
{
for (var i = 0; i < vertices.Length; i++)
{
var row = rows[i];
((SpinBox)row.GetChild(1)).SetValueNoSignal(vertices[i].X);
((SpinBox)row.GetChild(2)).SetValueNoSignal(vertices[i].Y);
}
return;
}
foreach (var child in rows)
child.QueueFree();
for (var i = 0; i < vertices.Length; i++)
{
var index = i;
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space1);
var number = UiKit.Label((i + 1).ToString(), UiTokens.FontSmall, UiTokens.Faint);
number.CustomMinimumSize = new Vector2(20, 0);
row.AddChild(number);
var x = ToolUi.Number(0, 1, 0.001, null);
x.Value = vertices[i].X;
x.ValueChanged += v => OnVertexTyped(index, (float)v, null);
row.AddChild(x);
var y = ToolUi.Number(0, 1, 0.001, null);
y.Value = vertices[i].Y;
y.ValueChanged += v => OnVertexTyped(index, null, (float)v);
row.AddChild(y);
_vertexList.AddChild(row);
}
}
private void OnVertexTyped(int index, float? x, float? y)
{
var hit = _controller.Selected?.HitArea;
if (_suppress || hit?.Vertices == null || index >= hit.Vertices.Length)
return;
var v = hit.Vertices[index];
hit.Vertices[index] = new Vector2(x ?? v.X, y ?? v.Y);
Changed();
_canvas.RebuildHandles();
_canvas.QueueRedraw();
}
private static string HitTypeLabel(string type) => ToolUi.Tr(type switch
{
"circle" => "tools/interactive_editor/hit_circle",
"polygon" => "tools/interactive_editor/hit_polygon",
_ => "tools/interactive_editor/hit_rect"
});
private static string NullIfEmpty(string text)
{
var trimmed = text?.Trim();
return string.IsNullOrEmpty(trimmed) ? null : trimmed;
}
}
}
-129
View File
@@ -1,129 +0,0 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>
/// Night-time preview of a scene with its static lights. The background is
/// drawn through the game's <c>timed_sprite</c> shader (set as this
/// control's material), so the preview lights the picture exactly like the
/// game; the lights' positions are pushed to the shader in this canvas'
/// coordinates and their radii scaled from the 1920-px game width to the
/// drawn background width. Radius rings and handles live in a separate
/// child overlay — anything drawn by this control itself would be shaded.
/// <para/>
/// A click on a handle selects that light and drags it; a double-click on
/// empty background adds a light there.
/// </summary>
public partial class LightEditorCanvas : BackgroundCanvas
{
private readonly Control _overlay;
/// <summary>Data being edited; set once by the window.</summary>
public LightEditorController Controller;
/// <summary>Preview material (may be null when the shader is missing).</summary>
public ShaderMaterial PreviewMaterial
{
get => Material as ShaderMaterial;
set => Material = value;
}
/// <summary>Raised when a light moved or was added on the canvas.</summary>
public event Action Changed;
/// <summary>Raised when the selection changed on the canvas.</summary>
public event Action SelectionChanged;
public LightEditorCanvas()
{
_overlay = new Control { MouseFilter = MouseFilterEnum.Ignore };
AddChild(_overlay);
_overlay.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_overlay.Connect("draw", Callable.From((Action)DrawRings));
GuiInput += OnGuiInput;
}
/// <summary>One handle per light, coloured like the light.</summary>
public override void RebuildHandles()
{
ClearHandles();
if (Controller == null || GetBackgroundRect().Size.X <= 0f)
return;
for (var i = 0; i < Controller.Lights.Count; i++)
{
var index = i;
var light = Controller.Lights[i];
var handle = AddHandle(new Vector2(light.X, light.Y), light.ToColor(), i, p =>
{
light.X = p.X;
light.Y = p.Y;
Refresh();
Changed?.Invoke();
}, index == Controller.SelectedIndex ? 20f : 16f);
handle.Clicked = () => SelectLight(index);
}
Refresh();
}
/// <summary>Redraws the preview and re-uploads the shader uniforms.</summary>
public void Refresh()
{
QueueRedraw();
_overlay.QueueRedraw();
}
private void SelectLight(int index)
{
if (index == Controller.SelectedIndex)
return;
Controller.Select(index);
SelectionChanged?.Invoke();
CallDeferred(nameof(RebuildHandles));
}
private void OnGuiInput(InputEvent @event)
{
if (Controller == null || @event is not InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left } mb)
return;
if (mb.DoubleClick && Controller.Add(Clamp01(LocalToNorm(mb.Position))) != null)
{
RebuildHandles();
Changed?.Invoke();
SelectionChanged?.Invoke();
}
AcceptEvent();
}
protected override void DrawContent(Rect2 rect)
{
// The shader compares against world_pos (MODEL_MATRIX * VERTEX), i.e.
// canvas coordinates of the window, not this control's local ones.
Controller?.UpdatePreview(PreviewMaterial, n => GlobalPosition + NormToLocal(n), rect.Size.X / LightEditorController.GameWidth);
}
private void DrawRings()
{
var rect = GetBackgroundRect();
if (Controller == null || rect.Size.X <= 0f)
return;
var scale = rect.Size.X / LightEditorController.GameWidth;
for (var i = 0; i < Controller.Lights.Count; i++)
{
var light = Controller.Lights[i];
var selected = i == Controller.SelectedIndex;
var centre = NormToLocal(new Vector2(light.X, light.Y));
var color = selected ? UiTokens.AccentBright : UiTokens.Alpha(light.ToColor(), 0.45f);
_overlay.DrawArc(centre, light.Radius * scale, 0f, Mathf.Tau, 64, color, selected ? 1.5f : 1f, true);
}
}
}
}
-202
View File
@@ -1,202 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.Mods.Time;
using Godot;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>
/// Data of the light editor: the static lights of one scenario and their
/// place in the scenario text. Static lights live in a <c>light_set</c>
/// line (<c>light_set [{"x":…,"y":…,"radius":…,"influence":…,"color":…}]</c>)
/// that <c>UiLightController.SetStaticLights</c> reads at runtime: x/y are
/// fractions of the viewport, radius is in viewport pixels at the game
/// resolution. The controller parses that line, edits the list and writes
/// the line back (or inserts one right after the scenario header), and
/// feeds the <c>timed_sprite</c> shader of the preview the same way the
/// game does, so the preview shows the real night lighting.
/// </summary>
public class LightEditorController
{
/// <summary>Static light slots the shader has (slot 0 is the dynamic light).</summary>
public const int MaxLights = 10;
/// <summary>Viewport width the stored radii refer to.</summary>
public const float GameWidth = 1920f;
private const string Command = "light_set";
private const int ShaderSlots = 11;
private static readonly Vector2 Offscreen = new(-1000f, -1000f);
/// <summary>The edited lights, in file order.</summary>
public List<LightDef> Lights { get; private set; } = new();
/// <summary>Index of the selected light, or -1.</summary>
public int SelectedIndex { get; private set; } = -1;
/// <summary>The selected light, or <c>null</c>.</summary>
public LightDef Selected => SelectedIndex >= 0 && SelectedIndex < Lights.Count ? Lights[SelectedIndex] : null;
/// <summary>Selects by index; out of range clears the selection.</summary>
public void Select(int index) => SelectedIndex = index >= 0 && index < Lights.Count ? index : -1;
/// <summary>Adds a warm light at the given normalised point and selects it; null when all slots are used.</summary>
public LightDef Add(Vector2 position)
{
if (Lights.Count >= MaxLights)
return null;
var light = new LightDef { X = position.X, Y = position.Y, Radius = 400f, Influence = 1f, ColorHex = "#ffcc66" };
Lights.Add(light);
SelectedIndex = Lights.Count - 1;
return light;
}
/// <summary>Removes the selected light.</summary>
public void RemoveSelected()
{
if (Selected == null)
return;
Lights.RemoveAt(SelectedIndex);
SelectedIndex = Math.Min(SelectedIndex, Lights.Count - 1);
}
/// <summary>Compact JSON array exactly as written into the <c>light_set</c> line.</summary>
public string ToJson() => JsonSerializer.Serialize(Lights, JsonOptionsProvider.WriteCompactOptions);
/// <summary>The scenario line for the current lights.</summary>
public string ToScenarioLine() => Command + " " + ToJson();
/// <summary>
/// Loads the lights of the first <c>light_set</c> line of a scenario.
/// Returns <c>false</c> when the scenario has none (the list is then
/// empty and saving inserts a new line). Throws on malformed JSON.
/// </summary>
public bool LoadFromScenario(string text)
{
Lights = new List<LightDef>();
SelectedIndex = -1;
var index = FindLine(text, out var lines);
if (index < 0)
return false;
var json = ArgumentOf(lines[index]);
Lights = JsonSerializer.Deserialize<List<LightDef>>(json, JsonOptionsProvider.ReadOptions) ?? new List<LightDef>();
SelectedIndex = Lights.Count > 0 ? 0 : -1;
return true;
}
/// <summary>
/// Returns the scenario text with its <c>light_set</c> line replaced by
/// the current lights, or — when it has none — with a new line inserted
/// right after the <c>---</c> header separator (at the top otherwise).
/// </summary>
public string WriteToScenario(string text)
{
var index = FindLine(text, out var lines);
var newline = text.Contains("\r\n") ? "\r\n" : "\n";
if (index >= 0)
{
var indent = lines[index].Substring(0, lines[index].Length - lines[index].TrimStart().Length);
lines[index] = indent + ToScenarioLine();
}
else
{
var separator = lines.FindIndex(l => l.Trim() == "---");
lines.Insert(separator + 1, ToScenarioLine());
}
return string.Join(newline, lines);
}
/// <summary>True when the scenario text contains a <c>light_set</c> line.</summary>
public static bool HasLights(string text) => FindLine(text, out _) >= 0;
private static int FindLine(string text, out List<string> lines)
{
lines = new List<string>((text ?? string.Empty).Replace("\r\n", "\n").Split('\n'));
return lines.FindIndex(l => l.TrimStart().StartsWith(Command + " ", StringComparison.Ordinal));
}
/// <summary>Argument of a <c>light_set</c> line; the old quoted form (<c>"[{\"x\":…}]"</c>) is unescaped.</summary>
private static string ArgumentOf(string line)
{
var arg = line.Trim().Substring(Command.Length).Trim();
if (arg.Length >= 2 && arg[0] == '"' && arg[arg.Length - 1] == '"')
arg = arg.Substring(1, arg.Length - 2).Replace("\\\"", "\"");
return arg;
}
// ── shader preview ─────────────────────────────────────────
/// <summary>
/// Material of the preview: the game's <c>timed_sprite</c> shader at
/// night weights. <c>null</c> when the shader is not installed.
/// </summary>
public static ShaderMaterial CreatePreviewMaterial()
{
var shader = Shaders.Resolve("timed_sprite");
if (shader == null)
return null;
var material = new ShaderMaterial { Shader = shader };
material.SetShaderParameter("time_weights", new Vector4(0, 0, 0, 1));
material.SetShaderParameter("use_time", true);
return material;
}
/// <summary>Binds the background and its optional depth/albedo masks to the preview material.</summary>
public static void SetPreviewTextures(ShaderMaterial material, Texture2D depth, Texture2D albedo)
{
if (material == null)
return;
material.SetShaderParameter("depth_mask", depth);
material.SetShaderParameter("albedo_map", albedo);
}
/// <summary>
/// Uploads the lights into the shader uniforms. <paramref name="toCanvas"/>
/// maps a normalised position into the preview's canvas coordinates
/// (the space of the shader's <c>world_pos</c>); <paramref name="radiusScale"/>
/// shrinks game-resolution radii to the preview size.
/// </summary>
public void UpdatePreview(ShaderMaterial material, Func<Vector2, Vector2> toCanvas, float radiusScale)
{
if (material == null)
return;
var positions = new Godot.Collections.Array<Vector2>();
var colors = new Godot.Collections.Array<Color>();
var radii = new Godot.Collections.Array<float>();
var influence = new Godot.Collections.Array<float>();
// Slot 0 is the dynamic (cursor) light at runtime; keep it off.
positions.Add(Offscreen);
colors.Add(UiTokens.White);
radii.Add(0f);
influence.Add(0f);
var count = Math.Min(Lights.Count, MaxLights);
for (var i = 0; i < ShaderSlots - 1; i++)
{
var on = i < count;
positions.Add(on ? toCanvas(new Vector2(Lights[i].X, Lights[i].Y)) : Offscreen);
colors.Add(on ? Lights[i].ToColor() : UiTokens.White);
radii.Add(on ? Lights[i].Radius : 0f);
influence.Add(on ? Lights[i].Influence : 0f);
}
material.SetShaderParameter("light_count", count + 1);
material.SetShaderParameter("light_positions", positions);
material.SetShaderParameter("light_colors", colors);
material.SetShaderParameter("light_radii", radii);
material.SetShaderParameter("light_influence", influence);
material.SetShaderParameter("light_radius_scale", radiusScale);
}
}
}
@@ -1,32 +0,0 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>Registers the light editor as a tool window. Creates <see cref="LightEditorWindow"/> instances.</summary>
public class LightEditorToolProvider : IToolProvider
{
/// <summary>Unique tool identifier.</summary>
public string Id => "light_editor";
/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey => "tools/light_editor/title";
/// <summary>Creates the editor window instance.</summary>
public Window CreateWindow() => new LightEditorWindow();
/// <summary>Adds the light editor's launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
}
/// <summary>Toolbox button that opens the light editor. Always visible.</summary>
public class LightEditorToolBoxButton : IToolBoxButton
{
/// <summary>Matches <see cref="LightEditorToolProvider.Id"/>.</summary>
public string ToolId => "light_editor";
/// <summary>No custom icon — uses text label.</summary>
public string IconPath => "";
/// <summary>Translation key for the button label.</summary>
public string LocaleKey => "tools/light_editor/title";
/// <summary>Always visible in the toolbox.</summary>
public bool IsVisible() => true;
}
}
-386
View File
@@ -1,386 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>
/// Editor of the static lights of a scene. The author picks a scenario —
/// the ones that already set lights come first — and edits its
/// <c>light_set</c> line visually: drag lights on a night-time preview of
/// the scene background, double-click to add one, tune radius, influence
/// and colour. Saving rewrites that line in the scenario (or inserts it
/// after the header). The background follows the scenario's scene.
/// </summary>
public partial class LightEditorWindow : ToolWindow
{
private readonly LightEditorController _controller = new();
private readonly List<ScenarioFileInfo> _scenarios = new();
private readonly List<string> _backgroundIds = new();
private ScenarioFileInfo _scenario;
private string _scenarioText;
private bool _suppress;
private OptionButton _scenarioSelect;
private LightEditorCanvas _canvas;
private ItemList _lightList;
private Button _addBtn;
private Button _deleteBtn;
private VBoxContainer _propsBody;
private Label _propsEmpty;
private SpinBox _x;
private SpinBox _y;
private SpinBox _radius;
private SpinBox _influence;
private ColorPickerButton _color;
private FilteredList _bgList;
private CodeEdit _lineEdit;
public LightEditorWindow() : base("tools/light_editor/title")
{
CthangoverTheme.Apply(this);
BuildUI();
LoadBackgrounds();
LoadScenarioList();
SetHint(ToolUi.Tr("tools/light_editor/hint"));
}
// ── UI construction ────────────────────────────────────────
private void BuildUI()
{
var outer = CreateFillContainer();
AddChild(outer);
var toolbar = CreateToolbar();
outer.AddChild(toolbar);
_scenarioSelect = new OptionButton { CustomMinimumSize = new Vector2(420, 0), TooltipText = ToolUi.Tr("tools/light_editor/scenario_tip") };
_scenarioSelect.ItemSelected += OnScenarioPicked;
toolbar.AddChild(_scenarioSelect);
toolbar.AddChild(ToolUi.Button("tools/common/save", Save, ButtonKind.Primary, "tools/light_editor/save_tip"));
toolbar.AddChild(ToolUi.Button("tools/light_editor/copy_line", CopyLine, ButtonKind.Secondary, "tools/light_editor/copy_line_tip"));
toolbar.AddChild(UiKit.Spacer());
var lineToggle = ToolUi.Button("tools/light_editor/show_line", null, ButtonKind.Tab);
lineToggle.ToggleMode = true;
lineToggle.Toggled += on => _lineEdit.Visible = on;
toolbar.AddChild(lineToggle);
var main = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outer.AddChild(main);
var side = new VBoxContainer();
main.AddChild(CreateSidebar(side, 300));
BuildSidebar(side);
var canvasColumn = new VSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
main.AddChild(canvasColumn);
var canvasPanel = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
canvasPanel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line));
canvasColumn.AddChild(canvasPanel);
_canvas = new LightEditorCanvas
{
Controller = _controller,
PreviewMaterial = LightEditorController.CreatePreviewMaterial()
};
_canvas.Changed += OnCanvasChanged;
_canvas.SelectionChanged += RefreshAll;
canvasPanel.AddChild(_canvas);
_lineEdit = ToolUi.Code(editable: false);
_lineEdit.CustomMinimumSize = new Vector2(0, 70);
_lineEdit.WrapMode = TextEdit.LineWrappingMode.Boundary;
_lineEdit.Visible = false;
canvasColumn.AddChild(_lineEdit);
}
private void BuildSidebar(VBoxContainer column)
{
column.AddChild(ToolUi.Section("tools/light_editor/static_lights"));
_lightList = new ItemList { CustomMinimumSize = new Vector2(0, 150) };
_lightList.ItemSelected += index => { _controller.Select((int)index); RefreshAll(); };
column.AddChild(_lightList);
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space1);
column.AddChild(row);
_addBtn = ToolUi.Button("tools/light_editor/add_light", OnAdd);
_addBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(_addBtn);
_deleteBtn = ToolUi.Button("tools/light_editor/delete", OnDelete, ButtonKind.Danger);
_deleteBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(_deleteBtn);
column.AddChild(ToolUi.Section("tools/light_editor/selected_props"));
_propsEmpty = ToolUi.Hint(ToolUi.Tr("tools/light_editor/none_selected"));
column.AddChild(_propsEmpty);
_propsBody = new VBoxContainer();
_propsBody.AddThemeConstantOverride("separation", UiTokens.Space2);
column.AddChild(_propsBody);
_x = ToolUi.Field(_propsBody, "tools/light_editor/x", ToolUi.Number(0, 1, 0.001, _ => OnFieldsChanged()));
_y = ToolUi.Field(_propsBody, "tools/light_editor/y", ToolUi.Number(0, 1, 0.001, _ => OnFieldsChanged()));
_radius = ToolUi.Field(_propsBody, "tools/light_editor/radius", ToolUi.Number(10, 3000, 10, _ => OnFieldsChanged()), "tools/light_editor/radius_tip");
_influence = ToolUi.Field(_propsBody, "tools/light_editor/influence", ToolUi.Number(0, 1, 0.05, _ => OnFieldsChanged()), "tools/light_editor/influence_tip");
_color = ToolUi.Field(_propsBody, "tools/light_editor/color", new ColorPickerButton { CustomMinimumSize = new Vector2(0, 28), EditAlpha = false });
_color.ColorChanged += _ => OnFieldsChanged();
column.AddChild(ToolUi.Section("tools/light_editor/background"));
_bgList = new FilteredList(180);
_bgList.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
_bgList.Chosen += ShowBackground;
column.AddChild(_bgList);
}
// ── loading ────────────────────────────────────────────────
private void LoadBackgrounds()
{
_backgroundIds.AddRange(ModResourceService.GetBackgroundIds());
_bgList.SetItems(_backgroundIds);
}
/// <summary>Scenarios with a <c>light_set</c> line first (marked ●), then all others.</summary>
private void LoadScenarioList()
{
var withLights = new List<ScenarioFileInfo>();
var others = new List<ScenarioFileInfo>();
foreach (var file in ScenarioFileService.ScanScenarioFiles().SelectMany(m => m.Groups).SelectMany(g => g.Files))
{
var text = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath);
(text != null && LightEditorController.HasLights(text) ? withLights : others).Add(file);
}
_scenarios.Clear();
_scenarios.AddRange(withLights);
_scenarios.AddRange(others);
_scenarioSelect.Clear();
_scenarioSelect.AddItem(ToolUi.Tr("tools/light_editor/pick_scenario"));
foreach (var file in _scenarios)
_scenarioSelect.AddItem((withLights.Contains(file) ? "● " : " ") + file.ModId + ": " + file.RelativePath);
_scenarioSelect.Select(0);
if (withLights.Count > 0)
{
_scenarioSelect.Select(1);
OpenScenario(withLights[0]);
}
else
{
RefreshAll();
}
}
private void OnScenarioPicked(long index)
{
var file = index > 0 && index - 1 < _scenarios.Count ? _scenarios[(int)index - 1] : null;
RunDiscardingChanges(() => OpenScenario(file), () => _scenarioSelect.Select(_scenario == null ? 0 : _scenarios.IndexOf(_scenario) + 1));
}
private void OpenScenario(ScenarioFileInfo file)
{
_scenario = file;
_scenarioText = null;
MarkClean();
if (file != null)
{
_scenarioText = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath) ?? string.Empty;
try
{
var found = _controller.LoadFromScenario(_scenarioText);
SetStatus(string.Format(ToolUi.Tr(found ? "tools/light_editor/loaded" : "tools/light_editor/no_lights_yet"), file.RelativePath));
}
catch (Exception ex)
{
SetStatus($"{ToolUi.Tr("tools/common/load_error")}: {ex.Message}", ToolStatusKind.Error);
GameLogger.Log("LIGHT_EDITOR", $"light_set parse failed in '{file.Key}': {ex.Message}", LogLevel.Error);
}
var scene = ScenarioFileService.ParseMetadata(_scenarioText).Scene;
var background = SceneBackgroundService.ForScene(scene);
if (background != null && ModResourceService.LoadBackgroundTexture(background) == null)
SetStatus(string.Format(ToolUi.Tr("tools/common/background_missing"), background, scene), ToolStatusKind.Error);
else if (background != null)
ShowBackground(background);
}
else
{
_controller.LoadFromScenario(string.Empty);
}
RefreshAll();
}
/// <summary>Shows a background with its optional <c>_depth</c>/<c>_albedo</c> masks in the shader.</summary>
private void ShowBackground(string id)
{
if (string.IsNullOrEmpty(id))
return;
_bgList.Select(id);
_canvas.SetBackground(ModResourceService.LoadBackgroundTexture(id));
LightEditorController.SetPreviewTextures(_canvas.PreviewMaterial,
ModResourceService.LoadBackgroundTexture(id + "_depth"),
ModResourceService.LoadBackgroundTexture(id + "_albedo"));
_canvas.Refresh();
}
// ── saving ─────────────────────────────────────────────────
/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut() => Save();
private void Save()
{
if (_scenario == null)
{
SetStatus(ToolUi.Tr("tools/light_editor/pick_scenario"), ToolStatusKind.Error);
return;
}
var updated = _controller.WriteToScenario(_scenarioText);
if (!ScenarioFileService.WriteAllText(_scenario.ModId, _scenario.RelativePath, updated))
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return;
}
_scenarioText = updated;
MarkClean();
SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), _scenario.ModId + "/" + _scenario.RelativePath), ToolStatusKind.Ok);
}
private void CopyLine()
{
DisplayServer.ClipboardSet(_controller.ToScenarioLine());
SetStatus(ToolUi.Tr("tools/light_editor/copied"), ToolStatusKind.Ok);
}
// ── editing ────────────────────────────────────────────────
private void OnAdd()
{
if (_controller.Add(new Vector2(0.5f, 0.5f)) == null)
{
SetStatus(string.Format(ToolUi.Tr("tools/light_editor/limit"), LightEditorController.MaxLights), ToolStatusKind.Error);
return;
}
SetDirty();
RefreshAll();
}
private void OnDelete()
{
if (_controller.Selected == null)
return;
_controller.RemoveSelected();
SetDirty();
RefreshAll();
}
/// <summary>Delete removes the selected light.</summary>
protected override bool HandleKey(InputEventKey key)
{
if (key.Keycode != Key.Delete || _controller.Selected == null)
return false;
OnDelete();
return true;
}
private void OnFieldsChanged()
{
var light = _controller.Selected;
if (_suppress || light == null)
return;
light.X = (float)_x.Value;
light.Y = (float)_y.Value;
light.Radius = (float)_radius.Value;
light.Influence = (float)_influence.Value;
light.ColorHex = "#" + _color.Color.ToHtml(false);
SetDirty();
RefreshList();
_lineEdit.Text = _controller.ToScenarioLine();
_canvas.RebuildHandles();
}
private void OnCanvasChanged()
{
SetDirty();
_suppress = true;
WriteProperties();
_suppress = false;
RefreshList();
_lineEdit.Text = _controller.ToScenarioLine();
}
// ── refresh ────────────────────────────────────────────────
private void RefreshAll()
{
RefreshList();
_suppress = true;
WriteProperties();
_suppress = false;
_lineEdit.Text = _controller.ToScenarioLine();
_addBtn.Disabled = _controller.Lights.Count >= LightEditorController.MaxLights;
_deleteBtn.Disabled = _controller.Selected == null;
_canvas.RebuildHandles();
}
private void RefreshList()
{
_lightList.Clear();
for (var i = 0; i < _controller.Lights.Count; i++)
{
var light = _controller.Lights[i];
var index = _lightList.AddItem($"● {i + 1}. {light.X:0.000}, {light.Y:0.000} r {light.Radius:0}");
_lightList.SetItemCustomFgColor(index, light.ToColor());
}
if (_controller.SelectedIndex >= 0)
_lightList.Select(_controller.SelectedIndex);
}
private void WriteProperties()
{
var light = _controller.Selected;
_propsEmpty.Visible = light == null;
_propsBody.Visible = light != null;
if (light == null)
return;
_x.Value = light.X;
_y.Value = light.Y;
_radius.Value = light.Radius;
_influence.Value = light.Influence;
_color.Color = light.ToColor();
}
}
}
-397
View File
@@ -1,397 +0,0 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Map;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Tools.MapEditor
{
/// <summary>
/// Interactive preview of the edited map: the background (laid out by
/// <see cref="BackgroundCanvas"/> exactly like <c>MapWidget</c>), every zone
/// polygon, marker and text label, and the draggable handles of the
/// selected zone. Two modes:
/// <list type="bullet">
/// <item><b>select</b> — a click picks the topmost zone (polygon first,
/// then marker/label dots); handles move vertices and positions;</item>
/// <item><b>draw</b> — clicks append polygon vertices to the selected
/// region while a dashed rubber band shows the next edge; a double-click
/// (or Enter/Esc via the window) finishes the polygon.</item>
/// </list>
/// Right-click deletes the polygon vertex nearest to the pointer; while
/// drawing, a click that misses every vertex removes the last one ("undo
/// point"), in select mode only a targeted vertex is deleted.
/// <para/>
/// The canvas owns no data: it reads and mutates
/// <see cref="MapEditorController"/> and raises <see cref="Changed"/> so
/// the window can refresh the sidebar and JSON. Zone fills and outlines are
/// painted through <see cref="MapZoneStyle"/> — the definition the game's
/// <c>MapZonesLayer</c> uses — so the editor and the live map look the same.
/// </summary>
public partial class MapEditorCanvas : BackgroundCanvas
{
/// <summary>Mode constant: clicking selects zones and drags handles.</summary>
public const string ModeSelect = "select";
/// <summary>Mode constant: clicking appends polygon vertices.</summary>
public const string ModeDraw = "draw";
/// <summary>Handle index of the marker position.</summary>
public const int MarkerHandle = -2;
/// <summary>Handle index of the label position.</summary>
public const int LabelHandle = -3;
private const float PickRadius = 24f;
private static readonly Color VertexColor = MapZoneStyle.VertexHandle;
private static readonly Color MarkerColor = MapZoneStyle.MarkerHandle;
private static readonly Color LabelColor = MapZoneStyle.LabelHandle;
/// <summary>Data being edited; set once by the window.</summary>
public MapEditorController Controller;
/// <summary>Current interaction mode, one of <see cref="ModeSelect"/> / <see cref="ModeDraw"/>.</summary>
public string Mode { get; private set; } = ModeSelect;
/// <summary>Raised whenever the canvas mutates controller data (drag, draw, delete).</summary>
public event Action Changed;
/// <summary>Raised with the newly picked zone index (-1 for empty space).</summary>
public event Action<int> ZonePicked;
/// <summary>Raised when the canvas leaves draw mode on its own (double-click finishing a polygon).</summary>
public event Action ModeChanged;
private Vector2? _pointer;
public MapEditorCanvas()
{
MapZoneStyle.Prepare(this);
GuiInput += OnGuiInput;
MouseExited += OnMouseExited;
}
/// <summary>Switches the interaction mode and refreshes the handles.</summary>
public void SetMode(string mode)
{
Mode = mode == ModeDraw ? ModeDraw : ModeSelect;
MouseDefaultCursorShape = Mode == ModeDraw ? CursorShape.Cross : CursorShape.Arrow;
RebuildHandles();
QueueRedraw();
}
/// <summary>Leaves draw mode (double-click, Enter, Esc) and tells the window.</summary>
public void FinishDrawing()
{
if (Mode != ModeDraw)
return;
SetMode(ModeSelect);
_pointer = null;
ModeChanged?.Invoke();
}
/// <summary>Recreates the handles from the current selection. Call after structural changes.</summary>
public override void RebuildHandles()
{
ClearHandles();
var zone = Controller?.Selected;
if (zone == null || GetBackgroundRect().Size.X <= 0f)
return;
if (zone.Points != null)
{
for (var i = 0; i < zone.Points.Count; i++)
{
var index = i;
var handle = AddHandle(zone.Points[i], VertexColor, i, p => OnHandleDrag(index, p));
handle.RightClicked = () => RemoveVertexAt(index);
handle.DragEnd = () => Changed?.Invoke();
}
}
if (HasMarker(zone))
AddHandle(zone.ImagePos, MarkerColor, MarkerHandle, p => OnHandleDrag(MarkerHandle, p), 18f).DragEnd = () => Changed?.Invoke();
if (zone.LabelPos.HasValue)
AddHandle(zone.LabelPos.Value, LabelColor, LabelHandle, p => OnHandleDrag(LabelHandle, p), 18f).DragEnd = () => Changed?.Invoke();
}
private void OnHandleDrag(int index, Vector2 normalized)
{
if (Controller == null)
return;
if (index == MarkerHandle)
Controller.SetImagePos(normalized);
else if (index == LabelHandle)
Controller.SetLabelPos(normalized);
else
Controller.SetVertex(index, normalized);
QueueRedraw();
Changed?.Invoke();
}
private void OnGuiInput(InputEvent @event)
{
if (Controller == null)
return;
if (@event is InputEventMouseMotion motion)
{
if (Mode == ModeDraw)
{
_pointer = motion.Position;
QueueRedraw();
}
return;
}
if (@event is not InputEventMouseButton mb || !mb.Pressed)
return;
if (mb.ButtonIndex == MouseButton.Left)
{
if (Mode == ModeDraw)
{
// The first click of a double-click already placed the
// vertex; the double-click itself only finishes the polygon.
if (mb.DoubleClick)
{
FinishDrawing();
}
else
{
Controller.AddVertex(Clamp01(LocalToNorm(mb.Position)));
RebuildHandles();
Changed?.Invoke();
QueueRedraw();
}
}
else
{
var hit = HitTest(mb.Position);
if (hit != Controller.SelectedIndex)
{
Controller.Select(hit);
RebuildHandles();
ZonePicked?.Invoke(hit);
QueueRedraw();
}
}
AcceptEvent();
}
else if (mb.ButtonIndex == MouseButton.Right)
{
RemoveVertexAtPointer(mb.Position, Mode == ModeDraw);
AcceptEvent();
}
}
private void OnMouseExited()
{
_pointer = null;
QueueRedraw();
}
/// <summary>Topmost zone under a canvas-local point: polygons win over marker/label dots.</summary>
private int HitTest(Vector2 local)
{
var zones = Controller.Zones;
var rect = GetBackgroundRect();
for (var i = zones.Count - 1; i >= 0; i--)
{
var points = zones[i].Points;
if (points != null && points.Count >= 3 && Geometry2D.IsPointInPolygon(local, ToPixels(points, rect)))
return i;
}
for (var i = zones.Count - 1; i >= 0; i--)
{
var zone = zones[i];
if (HasMarker(zone) && (local - ToPixel(zone.ImagePos, rect)).Length() <= PickRadius)
return i;
if (zone.LabelPos.HasValue && (local - ToPixel(zone.LabelPos.Value, rect)).Length() <= PickRadius)
return i;
}
return -1;
}
protected override void DrawContent(Rect2 rect)
{
var zones = Controller?.Zones;
if (zones == null)
return;
for (var i = 0; i < zones.Count; i++)
DrawZone(zones[i], rect, i == Controller.SelectedIndex);
DrawRubberBand(rect);
}
/// <summary>
/// In draw mode shows where the next vertex goes: a dashed segment from
/// the last vertex to the pointer and back to the first one, so the
/// author sees the polygon closing before clicking.
/// </summary>
private void DrawRubberBand(Rect2 rect)
{
var points = Controller.Selected?.Points;
if (Mode != ModeDraw || !_pointer.HasValue || points == null || points.Count == 0)
return;
var pointer = _pointer.Value;
DrawDashedLine(ToPixel(points[points.Count - 1], rect), pointer, UiTokens.AccentBright, 1.5f, 6f);
if (points.Count >= 2)
DrawDashedLine(pointer, ToPixel(points[0], rect), UiTokens.AccentLine, 1f, 6f);
}
private void DrawZone(MapZone zone, Rect2 rect, bool selected)
{
if (zone.Points != null && zone.Points.Count >= 2)
{
var locked = MapZoneRules.IsLocked(zone, !zone.Locked);
MapZoneStyle.Draw(this, ToPixels(zone.Points, rect), selected, false, locked, zone.Hidden);
}
if (HasMarker(zone))
DrawMarker(zone, ToPixel(zone.ImagePos, rect), selected);
if (zone.LabelPos.HasValue)
DrawLabelPill(zone, ToPixel(zone.LabelPos.Value, rect), selected);
}
/// <summary>
/// Draws the marker emblem. A marker without an icon is rendered as a
/// placeholder crosshair instead of disappearing, so a freshly created
/// marker stays visible, selectable and draggable while the author is
/// still choosing an icon.
/// </summary>
private void DrawMarker(MapZone zone, Vector2 center, bool selected)
{
var outline = selected ? UiTokens.Accent : UiTokens.AccentLine;
var accent = selected ? UiTokens.Accent : MarkerColor;
var icon = string.IsNullOrEmpty(zone.Image) ? null : Textures.Resolve(zone.Image);
if (icon == null)
{
DrawCircle(center, 18f, UiTokens.Alpha(UiTokens.Surface0, 0.55f));
DrawArc(center, 18f, 0f, Mathf.Tau, 32, accent, 2f, true);
DrawLine(center - new Vector2(7f, 0f), center + new Vector2(7f, 0f), accent, 2f);
DrawLine(center - new Vector2(0f, 7f), center + new Vector2(0f, 7f), accent, 2f);
return;
}
DrawCircle(center, 18f, UiTokens.Alpha(UiTokens.White, 0.85f));
DrawArc(center, 18f, 0f, Mathf.Tau, 32, outline, 1.5f, true);
DrawTextureRect(icon, new Rect2(center - new Vector2(14f, 14f), new Vector2(28f, 28f)), false);
}
private void DrawLabelPill(MapZone zone, Vector2 center, bool selected)
{
var text = string.IsNullOrEmpty(zone.Title) ? zone.ID : TranslationServer.Translate(zone.Title).ToString();
if (string.IsNullOrEmpty(text))
text = zone.ID;
var font = GetThemeDefaultFont();
var fontSize = GetThemeDefaultFontSize();
var textSize = font.GetStringSize(text, HorizontalAlignment.Left, -1, fontSize);
var pill = new Rect2(center - new Vector2(textSize.X / 2f + 8f, textSize.Y / 2f + 3f), textSize + new Vector2(16f, 6f));
DrawRect(pill, UiTokens.Alpha(UiTokens.Surface0, 0.85f));
DrawRect(pill, selected ? UiTokens.Accent : UiTokens.Line, false, 1f);
DrawString(font, new Vector2(pill.Position.X + 8f, pill.Position.Y + pill.Size.Y / 2f + fontSize / 3f),
text, HorizontalAlignment.Left, -1, fontSize, UiTokens.Ink);
}
private static Vector2[] ToPixels(IReadOnlyList<Vector2> normalized, Rect2 rect)
{
var result = new Vector2[normalized.Count];
for (var i = 0; i < result.Length; i++)
result[i] = rect.Position + normalized[i] * rect.Size;
return result;
}
private static Vector2 ToPixel(Vector2 normalized, Rect2 rect)
{
return rect.Position + normalized * rect.Size;
}
/// <summary>
/// A zone is marker-capable when it has an icon, or when it is still a
/// bare marker placeholder — no polygon and no text label. The
/// placeholder keeps a freshly created marker visible and draggable
/// before an icon is picked; once the zone grows a region or becomes a
/// pure inscription, the marker is only shown when an icon exists. The
/// region currently being drawn is excluded while it has no vertices.
/// </summary>
private bool HasMarker(MapZone zone)
{
if (!string.IsNullOrEmpty(zone.Image))
return true;
if (Mode == ModeDraw && zone == Controller?.Selected)
return false;
return (zone.Points == null || zone.Points.Count == 0) && !zone.LabelPos.HasValue;
}
/// <summary>
/// Right-click on empty canvas: removes the polygon vertex nearest to
/// the pointer. While drawing, a click that misses every vertex removes
/// the last one instead, which makes right-click a natural "undo
/// point"; in select mode only a targeted vertex is deleted, so a stray
/// click cannot destroy geometry.
/// </summary>
private void RemoveVertexAtPointer(Vector2 local, bool fallbackToLast)
{
var zone = Controller?.Selected;
if (zone?.Points == null || zone.Points.Count == 0)
return;
var rect = GetBackgroundRect();
var index = FindNearestVertex(zone, local, rect);
if (index < 0 && fallbackToLast)
index = zone.Points.Count - 1;
if (index >= 0)
RemoveVertexAt(index);
}
private void RemoveVertexAt(int index)
{
if (Controller == null)
return;
Controller.RemoveVertex(index);
RebuildHandles();
Changed?.Invoke();
QueueRedraw();
}
private static int FindNearestVertex(MapZone zone, Vector2 local, Rect2 rect)
{
var best = -1;
var bestDistance = PickRadius;
for (var i = 0; i < zone.Points.Count; i++)
{
var distance = (local - ToPixel(zone.Points[i], rect)).Length();
if (distance <= bestDistance)
{
bestDistance = distance;
best = i;
}
}
return best;
}
}
}
-241
View File
@@ -1,241 +0,0 @@
using System;
using System.Collections.Generic;
using Cthangover.Mods.Map;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.MapEditor
{
/// <summary>
/// Data model for the map editor. Holds the zone list being edited, the
/// current selection and all structural operations (add/remove zone,
/// add/remove/move vertex, move marker and label, toggle label mode).
/// Contains no UI and no runtime state: it edits raw authoring data that
/// <see cref="MapFileService"/> serialises back into
/// <c>mods/&lt;mod&gt;/map/*.json</c>, while the live game state belongs to
/// <c>MapZoneService</c>.
/// <para/>
/// New zones get unique IDs automatically and a sensible default shape
/// (a small square for regions, centre position for markers and labels),
/// so an author can place an element first and refine it afterwards. All
/// coordinates are normalised [0..1] and clamped by the canvas, not here.
/// </summary>
public class MapEditorController
{
private const float DefaultRegionHalfSize = 0.06f;
private static readonly Vector2 DefaultCenter = new(0.5f, 0.5f);
/// <summary>Zones currently being edited, in map file order.</summary>
public List<MapZone> Zones { get; private set; } = new();
/// <summary>Index of the selected zone, or -1 when nothing is selected.</summary>
public int SelectedIndex { get; private set; } = -1;
/// <summary>The selected zone, or <c>null</c> when the selection is empty.</summary>
public MapZone Selected => SelectedIndex >= 0 && SelectedIndex < Zones.Count ? Zones[SelectedIndex] : null;
/// <summary>Replaces all zones with the contents of a map file JSON envelope.</summary>
public void FromJson(string json)
{
Zones = MapFileService.ParseZones(json);
SelectedIndex = Zones.Count > 0 ? 0 : -1;
}
/// <summary>Serialises the current zone list into the map file JSON envelope.</summary>
public string ToJson() => MapFileService.BuildJson(Zones);
/// <summary>Drops all zones and the selection.</summary>
public void Clear()
{
Zones.Clear();
SelectedIndex = -1;
}
/// <summary>Creates a rectangular region around the map centre and selects it.</summary>
public MapZone AddRegion()
{
var zone = new MapZone
{
ID = MakeUniqueId("region"),
Title = string.Empty,
Points = new List<Vector2>
{
DefaultCenter + new Vector2(-DefaultRegionHalfSize, -DefaultRegionHalfSize),
DefaultCenter + new Vector2(DefaultRegionHalfSize, -DefaultRegionHalfSize),
DefaultCenter + new Vector2(DefaultRegionHalfSize, DefaultRegionHalfSize),
DefaultCenter + new Vector2(-DefaultRegionHalfSize, DefaultRegionHalfSize),
},
};
return Append(zone);
}
/// <summary>
/// Creates a region without vertices and selects it — the start of
/// drawing a polygon point by point on the canvas.
/// </summary>
public MapZone StartRegion()
{
var zone = new MapZone
{
ID = MakeUniqueId("region"),
Title = string.Empty,
Points = new List<Vector2>(),
};
return Append(zone);
}
/// <summary>
/// Creates an icon marker at the map centre with no icon chosen yet and
/// selects it. The author assigns <c>image</c> and <c>scene</c> in the
/// sidebar.
/// </summary>
public MapZone AddMarker()
{
var zone = new MapZone
{
ID = MakeUniqueId("marker"),
Title = string.Empty,
ImagePos = DefaultCenter,
};
return Append(zone);
}
/// <summary>
/// Creates a free-standing text label at the map centre and selects it.
/// The label exists as soon as <c>label_pos</c> is set; <c>MapWidget</c>
/// draws zones without an icon as inscriptions.
/// </summary>
public MapZone AddLabel()
{
var zone = new MapZone
{
ID = MakeUniqueId("label"),
Title = string.Empty,
LabelPos = DefaultCenter,
};
return Append(zone);
}
/// <summary>Removes the zone at the given index and adjusts the selection.</summary>
public void RemoveAt(int index)
{
if (index < 0 || index >= Zones.Count)
return;
Zones.RemoveAt(index);
if (Zones.Count == 0)
SelectedIndex = -1;
else if (SelectedIndex > index)
SelectedIndex--;
else if (SelectedIndex == index)
SelectedIndex = Math.Min(index, Zones.Count - 1);
}
/// <summary>Selects the zone at the given index; out-of-range clears the selection.</summary>
public void Select(int index)
{
SelectedIndex = index >= 0 && index < Zones.Count ? index : -1;
}
/// <summary>Appends a vertex to the selected region's polygon.</summary>
public void AddVertex(Vector2 normalized)
{
var zone = Selected;
if (zone == null)
return;
zone.Points ??= new List<Vector2>();
zone.Points.Add(normalized);
}
/// <summary>Moves one vertex of the selected region.</summary>
public void SetVertex(int index, Vector2 normalized)
{
var zone = Selected;
if (zone?.Points == null || index < 0 || index >= zone.Points.Count)
return;
zone.Points[index] = normalized;
}
/// <summary>Removes one vertex of the selected region.</summary>
public void RemoveVertex(int index)
{
var zone = Selected;
if (zone?.Points == null || index < 0 || index >= zone.Points.Count)
return;
zone.Points.RemoveAt(index);
}
/// <summary>Moves the icon marker of the selected zone.</summary>
public void SetImagePos(Vector2 normalized)
{
var zone = Selected;
if (zone != null)
zone.ImagePos = normalized;
}
/// <summary>Moves the text label of the selected zone.</summary>
public void SetLabelPos(Vector2 normalized)
{
var zone = Selected;
if (zone != null)
zone.LabelPos = normalized;
}
/// <summary>
/// Enables or disables the free-standing label of the selected zone.
/// Enabling without a known position places it at the map centre.
/// </summary>
public void SetLabelEnabled(bool enabled)
{
var zone = Selected;
if (zone == null)
return;
zone.LabelPos = enabled ? zone.LabelPos ?? DefaultCenter : null;
}
/// <summary>ID set of every zone in the list, for duplicate validation.</summary>
public HashSet<string> UsedIds()
{
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var zone in Zones)
{
if (!string.IsNullOrEmpty(zone.ID))
ids.Add(zone.ID);
}
return ids;
}
/// <summary>
/// Builds an ID that is not used yet by appending a numeric suffix
/// (<c>region</c>, <c>region_2</c>, <c>region_3</c>, ...).
/// </summary>
public string MakeUniqueId(string baseId)
{
var used = UsedIds();
if (!used.Contains(baseId))
return baseId;
var suffix = 2;
while (used.Contains($"{baseId}_{suffix}"))
suffix++;
return $"{baseId}_{suffix}";
}
private MapZone Append(MapZone zone)
{
Zones.Add(zone);
SelectedIndex = Zones.Count - 1;
return zone;
}
}
}
-24
View File
@@ -1,24 +0,0 @@
using Cthangover.Core.UI.Tool;
namespace Cthangover.Tools.MapEditor
{
/// <summary>
/// Toolbox button that opens the map editor from the in-game toolbar.
/// Matches the tool id registered by <see cref="MapEditorToolProvider"/>;
/// always visible because map authoring is part of the normal dev workflow.
/// </summary>
public class MapEditorToolBoxButton : IToolBoxButton
{
/// <summary>Matches <see cref="MapEditorToolProvider.Id"/>.</summary>
public string ToolId => "map_editor";
/// <summary>No dedicated icon — the button uses its text label.</summary>
public string IconPath => "";
/// <summary>Translation key for the button label.</summary>
public string LocaleKey => "tools/map_editor/title";
/// <summary>Always visible in the toolbox.</summary>
public bool IsVisible() => true;
}
}
-28
View File
@@ -1,28 +0,0 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Tools.MapEditor
{
/// <summary>
/// Registers the map editor as a discoverable tool window. Discovery is
/// automatic: <c>ToolFactory</c> scans mod assemblies for
/// <see cref="IToolProvider"/> implementations, so no manifest entry is
/// needed. The provider only knows how to build the window; all editing
/// logic lives in <see cref="MapEditorWindow"/>.
/// </summary>
public class MapEditorToolProvider : IToolProvider
{
/// <summary>Unique tool identifier used by the toolbox button.</summary>
public string Id => "map_editor";
/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey => "tools/map_editor/title";
/// <summary>Creates the editor window instance.</summary>
public Window CreateWindow() => new MapEditorWindow();
/// <summary>Adds the map editor's launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
}
}
File diff suppressed because it is too large. Load diff
-315
View File
@@ -1,315 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// Tabbed editor area of the scenario editor: a permanent graph tab plus
/// one text tab per opened .scenario file, with dirty tracking and a
/// monospace syntax-highlighted code editor with line numbers. Only one
/// <see cref="CodeEdit"/> exists; switching tabs swaps its text with the
/// tab's <see cref="TabData.Buffer"/>, which therefore holds unsaved edits
/// of background tabs.
/// </summary>
public partial class ScenarioEditTabs : VBoxContainer
{
private const int GraphTabIndex = 0;
private TabBar _tabBar;
private CodeEdit _textEdit;
private ScenarioGraphView _graphView;
private readonly List<TabData> _tabs = new();
private int _activeTabIndex = -1;
private bool _suppressTextChanged;
public CodeEdit CodeEditor => _textEdit;
public string CurrentText => _textEdit.Text;
public string CurrentKey =>
_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count ? _tabs[_activeTabIndex].Key : null;
public string CurrentModId =>
_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count ? _tabs[_activeTabIndex].ModId : null;
public string CurrentRelativePath =>
_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count ? _tabs[_activeTabIndex].RelativePath : null;
public bool HasActiveFile => _activeTabIndex >= 0 && _activeTabIndex < _tabs.Count;
public bool IsCurrentDirty =>
_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count && _tabs[_activeTabIndex].IsDirty;
public event Action<string> TextTabSelected;
public event Action GraphTabSelected;
public event Action<bool> DirtyChanged;
public event Action<string> GraphFileRequested;
/// <summary>Raised on every user edit of the active text tab.</summary>
public event Action BufferChanged;
public ScenarioEditTabs(ScenarioGraphView graphView)
{
_graphView = graphView;
SizeFlagsHorizontal = SizeFlags.ExpandFill;
SizeFlagsVertical = SizeFlags.ExpandFill;
_tabBar = new TabBar();
_tabBar.TabCloseDisplayPolicy = TabBar.CloseButtonDisplayPolicy.ShowActiveOnly;
_tabBar.TabSelected += OnTabSelected;
_tabBar.TabClosePressed += OnTabClosePressed;
_tabBar.AddTab(TranslationServer.Translate("tools/scenario_editor/tab_graph"));
AddChild(_tabBar);
_textEdit = ToolUi.Code();
_textEdit.SyntaxHighlighter = ScenarioSyntaxService.CreateHighlighter();
_textEdit.TextChanged += OnTextChanged;
AddChild(_textEdit);
_graphView.ScenarioFileRequested += OnGraphScenarioFileRequested;
AddChild(_graphView);
}
/// <summary>Opens the scenario in a new tab, or just activates its existing tab.</summary>
public void OpenFile(string key)
{
for (int i = 0; i < _tabs.Count; i++)
{
if (_tabs[i].Key == key)
{
SwitchToTextTab(i);
return;
}
}
if (!ScenarioFileService.TryParseKey(key, out var modId, out var relativePath))
return;
try
{
var text = ScenarioFileService.ReadAllText(modId, relativePath);
if (text == null)
{
GameLogger.Log("SCENARIO_EDITOR", $"Failed to read scenario '{key}'", LogLevel.Error);
return;
}
var tab = new TabData
{
Key = key,
ModId = modId,
RelativePath = relativePath,
FileName = Path.GetFileNameWithoutExtension(relativePath),
Buffer = text,
IsDirty = false
};
_tabs.Add(tab);
_tabBar.AddTab(tab.FileName);
_tabBar.SetTabTooltip(_tabBar.TabCount - 1, ScenarioFileService.GetDisplayPath(modId, relativePath));
SwitchToTextTab(_tabs.Count - 1);
}
catch (Exception ex)
{
GameLogger.Log("SCENARIO_EDITOR", $"Failed to load file: {ex.Message}", LogLevel.Error);
}
}
/// <summary>Marks the active tab clean, remembering the editor text as the saved baseline.</summary>
public void SaveCurrentFile()
{
if (_activeTabIndex < 0 || _activeTabIndex >= _tabs.Count)
return;
var tab = _tabs[_activeTabIndex];
tab.Buffer = _textEdit.Text;
if (tab.IsDirty)
{
tab.IsDirty = false;
UpdateTabTitle(_activeTabIndex);
UpdateGlobalDirty();
}
}
/// <summary>Replaces the editor text without firing the text-changed handler.</summary>
public void SetTextSilent(string text)
{
_suppressTextChanged = true;
_textEdit.Text = text;
_suppressTextChanged = false;
}
/// <summary>Switches to the graph tab when no file tabs are open.</summary>
public void SetViewToGraph()
{
if (_tabs.Count > 0)
return;
_tabBar.CurrentTab = GraphTabIndex;
_textEdit.Visible = false;
_graphView.Visible = true;
GraphTabSelected?.Invoke();
}
/// <summary>Rebuilds the graph from the current scenario files, if the graph tab is visible.</summary>
public void RefreshGraph()
{
if (_graphView.Visible)
{
var data = ScenarioFileService.BuildSceneGraph();
_graphView.Populate(data);
}
}
/// <summary>Returns false when any tab still has unsaved changes.</summary>
public bool ConfirmCloseAll()
{
for (int i = _tabs.Count - 1; i >= 0; i--)
{
if (_tabs[i].IsDirty)
return false;
}
return true;
}
private void OnTabSelected(long index)
{
if (index == GraphTabIndex)
{
SaveActiveTextEditContent();
_activeTabIndex = -1;
_textEdit.Visible = false;
_graphView.Visible = true;
GraphTabSelected?.Invoke();
}
else
{
_graphView.Visible = false;
_textEdit.Visible = true;
SwitchToTextTab((int)index - 1);
}
}
private void OnTabClosePressed(long index)
{
if (index == GraphTabIndex)
return;
CloseTextTab((int)index - 1);
}
private void SwitchToTextTab(int index)
{
if (index < 0 || index >= _tabs.Count || index == _activeTabIndex)
return;
if (_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count)
_tabs[_activeTabIndex].Buffer = _textEdit.Text;
_activeTabIndex = index;
_tabBar.CurrentTab = index + 1;
var tab = _tabs[index];
SetTextSilent(tab.Buffer);
TextTabSelected?.Invoke(tab.Buffer);
}
private void CloseTextTab(int index)
{
if (index < 0 || index >= _tabs.Count)
return;
if (_tabs[index].IsDirty)
{
var dialog = new ConfirmationDialog();
dialog.Title = TranslationServer.Translate("tools/scenario_editor/unsaved_title");
dialog.DialogText = TranslationServer.Translate("tools/scenario_editor/unsaved_switch_text");
int capturedIndex = index;
dialog.Confirmed += () =>
{
dialog.QueueFree();
DoCloseTextTab(capturedIndex);
};
dialog.Canceled += () => dialog.QueueFree();
AddChild(dialog);
dialog.PopupCentered();
return;
}
DoCloseTextTab(index);
}
private void DoCloseTextTab(int index)
{
_tabs.RemoveAt(index);
_tabBar.RemoveTab(index + 1);
if (_tabs.Count == 0)
{
_activeTabIndex = -1;
SetTextSilent("");
TextTabSelected?.Invoke("");
UpdateGlobalDirty();
return;
}
if (index == _activeTabIndex)
{
var newIndex = Math.Min(index, _tabs.Count - 1);
_activeTabIndex = -1;
SwitchToTextTab(newIndex);
}
else if (index < _activeTabIndex)
{
_activeTabIndex--;
_tabBar.CurrentTab = _activeTabIndex + 1;
}
UpdateGlobalDirty();
}
private void OnTextChanged()
{
if (_suppressTextChanged || _activeTabIndex < 0 || _activeTabIndex >= _tabs.Count)
return;
var tab = _tabs[_activeTabIndex];
if (!tab.IsDirty)
{
tab.IsDirty = true;
UpdateTabTitle(_activeTabIndex);
UpdateGlobalDirty();
}
BufferChanged?.Invoke();
}
private void UpdateTabTitle(int index)
{
if (index < 0 || index >= _tabs.Count)
return;
var tab = _tabs[index];
_tabBar.SetTabTitle(index + 1, tab.IsDirty ? tab.FileName + " *" : tab.FileName);
}
private void UpdateGlobalDirty()
{
bool anyDirty = _tabs.Any(t => t.IsDirty);
DirtyChanged?.Invoke(anyDirty);
}
private void SaveActiveTextEditContent()
{
if (_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count)
_tabs[_activeTabIndex].Buffer = _textEdit.Text;
}
private void OnGraphScenarioFileRequested(string filePath)
{
GraphFileRequested?.Invoke(filePath);
}
}
}
@@ -1,19 +0,0 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Registers the scenario editor as a tool window. Creates <see cref="ScenarioEditorWindow"/> instances.</summary>
public class ScenarioEditorToolProvider : IToolProvider
{
/// <summary>Unique tool identifier.</summary>
public string Id => "scenario_editor";
/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey => "tools/scenario_editor/title";
/// <summary>Creates the editor window instance.</summary>
public Window CreateWindow() => new ScenarioEditorWindow();
/// <summary>Adds the scenario editor's launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
}
}
-386
View File
@@ -1,386 +0,0 @@
using System;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// Tool window hosting the scenario editor: a searchable tree of every
/// mod's .scenario files, tabbed code/graph editors, a metadata panel that
/// follows the text as it is typed, background thumbnails, plus a
/// play-test mode that runs the current scenario in-game.
/// <para/>
/// Shortcuts: Ctrl+S saves the active file, F5 play-tests it.
/// </summary>
public partial class ScenarioEditorWindow : ToolWindow
{
private const double InfoRefreshDelay = 0.4;
private Control _editLayout;
private ScenarioFileTreePanel _fileTreePanel;
private ScenarioEditTabs _editTabs;
private ScenarioInfoPanel _infoPanel;
private ScenarioThumbnailPanel _thumbPanel;
private Timer _infoTimer;
private Button _saveBtn;
private Button _openFileBtn;
private Button _playBtn;
private Label _filePathLabel;
private bool _playActive;
private string _returnRoute;
private string _returnScene;
private CanvasLayer _stopOverlay;
public ScenarioEditorWindow() : base("tools/scenario_editor/title")
{
CthangoverTheme.Apply(this);
BuildUI();
SetHint(ToolUi.Tr("tools/scenario_editor/hint"));
}
/// <summary>Opens with the graph view so the editor starts on the cross-scene map.</summary>
public override void _Ready()
{
_editTabs.SetViewToGraph();
}
/// <summary>Returns from play-test mode as soon as the in-game test play ends.</summary>
public override void _Process(double delta)
{
if (_playActive && !SceneManager.IsTestPlayActive)
ReturnFromPlay();
}
private void BuildUI()
{
_editLayout = new Control
{
AnchorRight = 1f,
AnchorBottom = 1f
};
AddChild(_editLayout);
var outerVBox = CreateFillContainer();
_editLayout.AddChild(outerVBox);
var toolbar = CreateToolbar();
outerVBox.AddChild(toolbar);
_saveBtn = ToolUi.Button("tools/common/save", OnSavePressed, ButtonKind.Primary, "tools/common/save_tip");
toolbar.AddChild(_saveBtn);
_playBtn = ToolUi.Button("tools/scenario_editor/play", OnPlayPressed, ButtonKind.Secondary, "tools/scenario_editor/play_tooltip");
toolbar.AddChild(_playBtn);
_openFileBtn = ToolUi.Button("tools/scenario_editor/open_file", OnOpenFilePressed, ButtonKind.Secondary, "tools/scenario_editor/open_file_tip");
toolbar.AddChild(_openFileBtn);
toolbar.AddChild(ToolUi.Divider());
toolbar.AddChild(ToolUi.Button("tools/scenario_editor/rescan", OnRescanPressed, ButtonKind.Secondary, "tools/scenario_editor/rescan_tip"));
_filePathLabel = new Label
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis
};
_filePathLabel.AddThemeFontOverride("font", ToolUi.Mono());
_filePathLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
_filePathLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
toolbar.AddChild(_filePathLabel);
var mainSplit = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outerVBox.AddChild(mainSplit);
var leftVBox = new VBoxContainer { SizeFlagsVertical = Control.SizeFlags.ExpandFill };
var leftPanel = CreateSidebar(leftVBox, 320);
mainSplit.AddChild(leftPanel);
var tabsContainer = new TabContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(0, 420)
};
leftVBox.AddChild(tabsContainer);
_fileTreePanel = (ScenarioFileTreePanel)TscnScenes.LoadAndBuild("scenes/scenario_file_tree_panel.tscn");
_fileTreePanel.FileSelected += OnFileSelected;
tabsContainer.AddChild(_fileTreePanel);
tabsContainer.SetTabTitle(0, ToolUi.Tr("tools/scenario_editor/tab_files"));
_infoPanel = (ScenarioInfoPanel)TscnScenes.LoadAndBuild("scenes/scenario_info_panel.tscn");
tabsContainer.AddChild(_infoPanel);
tabsContainer.SetTabTitle(1, ToolUi.Tr("tools/scenario_editor/tab_info"));
leftVBox.AddChild(ToolUi.Section("tools/scenario_editor/bg_previews"));
_thumbPanel = (ScenarioThumbnailPanel)TscnScenes.LoadAndBuild("scenes/scenario_thumbnail_panel.tscn");
leftVBox.AddChild(_thumbPanel);
_editTabs = new ScenarioEditTabs(new ScenarioGraphView());
_editTabs.TextTabSelected += OnTextTabSelected;
_editTabs.GraphTabSelected += OnGraphTabSelected;
_editTabs.DirtyChanged += OnDirtyChanged;
_editTabs.GraphFileRequested += OnGraphFileRequested;
_editTabs.BufferChanged += () => _infoTimer.Start();
mainSplit.AddChild(_editTabs);
_infoTimer = new Timer { OneShot = true, WaitTime = InfoRefreshDelay };
_infoTimer.Timeout += () => _infoPanel.Update(_editTabs.CurrentText);
AddChild(_infoTimer);
}
private void OnFileSelected(string filePath)
{
_editTabs.OpenFile(filePath);
}
private void OnGraphFileRequested(string filePath)
{
_fileTreePanel.SelectFile(filePath);
}
private void OnRescanPressed()
{
_fileTreePanel.Reload();
_editTabs.RefreshGraph();
SetStatus(ToolUi.Tr("tools/scenario_editor/rescanned"));
}
private void OnTextTabSelected(string text)
{
var hasFile = _editTabs.CurrentKey != null;
_filePathLabel.Text = hasFile
? ScenarioFileService.GetDisplayPath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath) : "";
SetFileButtonsEnabled(hasFile);
_infoPanel.Update(text);
_thumbPanel.Load(text);
}
private void OnGraphTabSelected()
{
_filePathLabel.Text = "";
SetFileButtonsEnabled(false);
_infoPanel.Clear();
_thumbPanel.Clear();
_editTabs.RefreshGraph();
}
private void SetFileButtonsEnabled(bool enabled)
{
_saveBtn.Disabled = !enabled;
_openFileBtn.Disabled = !enabled;
_playBtn.Disabled = !enabled;
}
private void OnDirtyChanged(bool anyDirty)
{
if (anyDirty)
SetDirty();
else
MarkClean();
}
/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut() => OnSavePressed();
/// <summary>F5 play-tests the active scenario.</summary>
protected override bool HandleKey(InputEventKey key)
{
if (key.Keycode != Key.F5 || !_editTabs.HasActiveFile)
return false;
OnPlayPressed();
return true;
}
private void OnSavePressed()
{
if (!_editTabs.HasActiveFile)
return;
var modId = _editTabs.CurrentModId;
var relativePath = _editTabs.CurrentRelativePath;
var text = _editTabs.CurrentText;
try
{
if (!ScenarioFileService.WriteAllText(modId, relativePath, text))
{
SetStatus(ToolUi.Tr("tools/scenario_editor/readonly_zip"), ToolStatusKind.Error);
return;
}
_editTabs.SaveCurrentFile();
SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), ScenarioFileService.GetDisplayPath(modId, relativePath)), ToolStatusKind.Ok);
_infoPanel.Update(text);
_thumbPanel.Load(text);
GameLogger.Log("SCENARIO_EDITOR", $"Saved: {modId}/{relativePath}");
}
catch (Exception ex)
{
GameLogger.Log("SCENARIO_EDITOR", $"Save failed: {ex.Message}", LogLevel.Error);
SetStatus(string.Format(ToolUi.Tr("tools/scenario_editor/save_error"), ex.Message), ToolStatusKind.Error);
}
}
private void OnOpenFilePressed()
{
if (_editTabs.CurrentKey == null)
return;
var path = ScenarioFileService.GetAbsolutePath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath);
if (string.IsNullOrEmpty(path))
{
SetStatus(ToolUi.Tr("tools/scenario_editor/readonly_zip"), ToolStatusKind.Error);
return;
}
var err = OS.ShellOpen(path);
if (err != Error.Ok)
{
GameLogger.Log("SCENARIO_EDITOR", $"ShellOpen '{path}' failed: {err}", LogLevel.Error);
SetStatus($"{ToolUi.Tr("tools/scenario_editor/open_file_error")}: {err}", ToolStatusKind.Error);
}
}
private void OnPlayPressed()
{
if (!_editTabs.HasActiveFile)
return;
var text = _editTabs.CurrentText;
var metadata = ScenarioFileService.ParseMetadata(text);
if (metadata.Scene == "-")
{
SetStatus(ToolUi.Tr("tools/scenario_editor/no_scene_error"), ToolStatusKind.Error);
return;
}
var modId = _editTabs.CurrentModId;
var relativePath = _editTabs.CurrentRelativePath;
try
{
if (ScenarioFileService.WriteAllText(modId, relativePath, text))
{
_editTabs.SaveCurrentFile();
}
else
{
GameLogger.Log("SCENARIO_EDITOR",
$"Test play: '{modId}/{relativePath}' is read-only, playing from the editor buffer",
LogLevel.Warning);
}
}
catch (Exception ex)
{
SetStatus(string.Format(ToolUi.Tr("tools/scenario_editor/save_error"), ex.Message), ToolStatusKind.Error);
return;
}
SceneManager.IsTestPlayActive = true;
SceneManager.TestScenarioText = text;
GameLogger.Log("SCENARIO_EDITOR", $"Test play START: scene='{metadata.Scene}', queue={text.Split('\n').Length} lines");
Visible = false;
// The screen the editor was opened from (the novel_editor hub, a
// mod menu) is freed by the shell load below; remember its route so
// the play-test returns there instead of to the engine's main menu.
var sceneService = GetNodeOrNull<GodotSceneService>("/root/GodotSceneService");
var current = sceneService?.CurrentGodotSceneName;
_returnRoute = !string.IsNullOrEmpty(current) && SceneRouteRegistry.TryGet(current, out _) ? current : null;
// Opened inside a running game: come back into the scene the
// player was in, not to the main menu (there is no way back to the
// editor from there).
var sceneManager = GetNodeOrNull<SceneManager>("/root/SceneManager");
var gameScene = sceneManager?.CurrentSceneName;
_returnScene = _returnRoute == null && current != "mainmenu" && !string.IsNullOrEmpty(gameScene) && sceneManager.HasScene(gameScene)
? gameScene
: null;
_stopOverlay = CreateStopOverlay();
var tree = Godot.Engine.GetMainLoop() as SceneTree;
tree?.Root.AddChild(_stopOverlay);
if (sceneManager != null)
{
sceneManager.Initialize();
sceneManager.PendingSceneName = metadata.Scene;
}
sceneService?.LoadShell();
_playActive = true;
}
private void OnStopPressed()
{
SceneManager.IsTestPlayActive = false;
ReturnFromPlay();
}
private void ReturnFromPlay()
{
_playActive = false;
Visible = true;
if (_stopOverlay != null)
{
_stopOverlay.QueueFree();
_stopOverlay = null;
}
var sceneService = GetNodeOrNull<GodotSceneService>("/root/GodotSceneService");
var sceneManager = GetNodeOrNull<SceneManager>("/root/SceneManager");
if (_returnRoute != null && SceneRouteRegistry.TryGet(_returnRoute, out var route))
{
sceneService?.SwitchRoute(route);
}
else if (_returnScene != null && sceneManager != null)
{
sceneManager.PendingSceneName = _returnScene;
sceneService?.LoadShell();
}
else
{
sceneService?.SwitchToMenu();
}
_returnRoute = null;
_returnScene = null;
SetStatus(ToolUi.Tr("tools/scenario_editor/play_stopped"));
}
/// <summary>Stop button shown over the running game while the scenario is play-tested.</summary>
private CanvasLayer CreateStopOverlay()
{
var layer = new CanvasLayer { Layer = 128 };
var btn = ToolUi.Button("tools/scenario_editor/stop", OnStopPressed, ButtonKind.Danger);
btn.Theme = ToolTheme.Get();
btn.CustomMinimumSize = new Vector2(120, 36);
var margin = new MarginContainer();
margin.AddThemeConstantOverride("margin_top", UiTokens.Space3);
margin.AddThemeConstantOverride("margin_left", UiTokens.Space3);
margin.AddChild(btn);
layer.AddChild(margin);
return layer;
}
}
}
-156
View File
@@ -1,156 +0,0 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// Left-hand tree of every mod's scenario files, grouped by mod and
/// subdirectory, with a search field that keeps only matching files (and
/// expands their groups). Raises <see cref="FileSelected"/> with the
/// scenario key on picks.
/// <para/>
/// Built from <c>scenario_file_tree_panel.tscn</c>: the tscn builder creates
/// the script node through its constructor before the child nodes exist,
/// so children are looked up lazily (<see cref="Bind"/>) — not in the
/// constructor, which is what used to crash the whole editor on open.
/// </summary>
public partial class ScenarioFileTreePanel : VBoxContainer
{
private Tree _fileTree;
private LineEdit _search;
private List<ModScenarioTree> _mods = new();
private readonly Dictionary<TreeItem, string> _itemToKey = new();
private readonly Dictionary<string, TreeItem> _keyToItem = new();
private bool _isProgrammaticSelection;
private string _selectedKey;
public event Action<string> FileSelected;
/// <summary>Binds child nodes and fills the tree once the panel enters the tree.</summary>
public override void _Ready()
{
Reload();
}
/// <summary>Re-scans the mods' scenario files and rebuilds the tree.</summary>
public void Reload()
{
Bind();
_mods = ScenarioFileService.ScanScenarioFiles();
Populate();
}
private void Bind()
{
if (_fileTree != null)
return;
_fileTree = GetNode<Tree>("FileTree");
_fileTree.CellSelected += OnCellSelected;
_search = GetNode<LineEdit>("Search");
_search.PlaceholderText = TranslationServer.Translate("tools/scenario_editor/search_files");
_search.TextChanged += _ => Populate();
}
private void Populate()
{
_fileTree.Clear();
_itemToKey.Clear();
_keyToItem.Clear();
var filter = _search.Text.Trim();
var root = _fileTree.CreateItem();
foreach (var mod in _mods)
{
TreeItem modItem = null;
foreach (var group in mod.Groups)
{
TreeItem groupItem = null;
foreach (var file in group.Files)
{
if (filter.Length > 0 && file.RelativePath.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
continue;
if (modItem == null)
{
modItem = _fileTree.CreateItem(root);
modItem.SetText(0, mod.ModId);
modItem.SetSelectable(0, false);
modItem.SetCustomColor(0, UiTokens.AccentBright);
}
if (groupItem == null)
{
if (string.IsNullOrEmpty(group.DirectoryPath))
{
groupItem = modItem;
}
else
{
groupItem = _fileTree.CreateItem(modItem);
groupItem.SetText(0, group.DirectoryPath + "/");
groupItem.SetSelectable(0, false);
groupItem.SetCustomColor(0, UiTokens.Muted);
groupItem.Collapsed = filter.Length == 0;
}
}
var fileItem = _fileTree.CreateItem(groupItem);
fileItem.SetText(0, file.Name);
fileItem.SetTooltipText(0, ScenarioFileService.GetDisplayPath(file.ModId, file.RelativePath));
_itemToKey[fileItem] = file.Key;
_keyToItem[file.Key] = fileItem;
}
}
}
if (_selectedKey != null && _keyToItem.TryGetValue(_selectedKey, out var selected))
SelectSilently(selected);
}
/// <summary>Selects the tree row of a scenario key and raises <see cref="FileSelected"/>.</summary>
public void SelectFile(string key)
{
Bind();
_selectedKey = key;
if (!_keyToItem.TryGetValue(key, out var item))
{
_search.Text = string.Empty;
Populate();
if (!_keyToItem.TryGetValue(key, out item))
return;
}
SelectSilently(item);
FileSelected?.Invoke(key);
}
private void SelectSilently(TreeItem item)
{
for (var parent = item.GetParent(); parent != null; parent = parent.GetParent())
parent.Collapsed = false;
_isProgrammaticSelection = true;
item.Select(0);
_fileTree.ScrollToItem(item);
_isProgrammaticSelection = false;
}
private void OnCellSelected()
{
if (_isProgrammaticSelection)
return;
var item = _fileTree.GetSelected();
if (item == null || !_itemToKey.TryGetValue(item, out var key))
return;
_selectedKey = key;
FileSelected?.Invoke(key);
}
}
}
-74
View File
@@ -1,74 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// Layered left-to-right layout of the scene graph: a scene's column is
/// its distance (in scene switches) from the start scenes — the
/// <c>start_scene</c> node, or every scene nothing switches to when there
/// is none — so the story reads from left to right. Scenes no start
/// reaches are stacked in one extra column on the right. Inside a column
/// scenes are sorted by name, which keeps the picture stable between
/// rebuilds.
/// </summary>
public static class ScenarioGraphLayout
{
private const float ColumnWidth = 300f;
private const float RowHeight = 130f;
private const float Margin = 40f;
/// <summary>Canvas position per lower-cased scene name.</summary>
public static Dictionary<string, Vector2> Compute(SceneGraphData data)
{
var names = data.Nodes.Select(n => n.SceneName.ToLowerInvariant()).Distinct().ToList();
var outgoing = names.ToDictionary(n => n, _ => new List<string>());
var incoming = new HashSet<string>();
foreach (var edge in data.Edges)
{
var from = edge.FromScene.ToLowerInvariant();
var to = edge.ToScene.ToLowerInvariant();
if (!outgoing.ContainsKey(from) || !outgoing.ContainsKey(to) || from == to)
continue;
outgoing[from].Add(to);
incoming.Add(to);
}
var starts = data.Nodes.Where(n => n.IsStartScene).Select(n => n.SceneName.ToLowerInvariant()).ToList();
if (starts.Count == 0)
starts = names.Where(n => !incoming.Contains(n)).ToList();
var depth = new Dictionary<string, int>();
var queue = new Queue<string>();
foreach (var start in starts)
{
depth[start] = 0;
queue.Enqueue(start);
}
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var next in outgoing[current])
{
if (depth.ContainsKey(next))
continue;
depth[next] = depth[current] + 1;
queue.Enqueue(next);
}
}
var unreachableColumn = depth.Count > 0 ? depth.Values.Max() + 1 : 0;
var result = new Dictionary<string, Vector2>();
foreach (var column in names.GroupBy(n => depth.TryGetValue(n, out var d) ? d : unreachableColumn).OrderBy(g => g.Key))
{
var row = 0;
foreach (var name in column.OrderBy(n => n, StringComparer.Ordinal))
result[name] = new Vector2(Margin + column.Key * ColumnWidth, Margin + row++ * RowHeight);
}
return result;
}
}
}
-205
View File
@@ -1,205 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Services;
using Godot;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Cross-scene graph of all scenario files: a GraphEdit of scene nodes linked by switch targets, with a detail panel for the selected scenario.</summary>
public partial class ScenarioGraphView : HSplitContainer
{
private GraphEdit _graph;
private RichTextLabel _infoLabel;
private Dictionary<string, GraphNode> _nodes;
private Dictionary<string, SceneGraphNode> _lookup;
public event Action<string> ScenarioFileRequested;
public ScenarioGraphView()
{
SizeFlagsHorizontal = SizeFlags.ExpandFill;
SizeFlagsVertical = SizeFlags.ExpandFill;
_graph = new GraphEdit
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SizeFlagsVertical = SizeFlags.ExpandFill,
RightDisconnects = false,
ConnectionLinesThickness = 2f,
ConnectionLinesAntialiased = true,
MinimapEnabled = true,
ShowGrid = true,
SnappingEnabled = true,
SnappingDistance = 20
};
_graph.ConnectionRequest += OnConnectionRequest;
_graph.DisconnectionRequest += OnDisconnectionRequest;
_graph.NodeSelected += OnNodeSelected;
_graph.NodeDeselected += OnNodeDeselected;
AddChild(_graph);
var panel = new VBoxContainer { CustomMinimumSize = new Vector2(260, 0) };
panel.SizeFlagsVertical = SizeFlags.ExpandFill;
AddChild(panel);
var hint = UiKit.Hint(TranslationServer.Translate("tools/scenario_editor/graph_select_node"));
hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
panel.AddChild(hint);
_infoLabel = new RichTextLabel
{
BbcodeEnabled = true,
SizeFlagsVertical = SizeFlags.ExpandFill
};
_infoLabel.AddThemeFontSizeOverride("normal_font_size", UiTokens.FontBody);
_infoLabel.AddThemeFontSizeOverride("bold_font_size", UiTokens.FontBodyLarge);
_infoLabel.AddThemeColorOverride("default_color", UiTokens.Body);
_infoLabel.MetaClicked += OnLinkClicked;
panel.AddChild(_infoLabel);
}
/// <summary>Rebuilds the graph nodes and edges, preserving the nodes' saved canvas positions.</summary>
public void Populate(SceneGraphData data)
{
var savedPositions = SaveNodePositions();
ClearGraph();
_nodes = new Dictionary<string, GraphNode>();
_lookup = new Dictionary<string, SceneGraphNode>();
var layout = ScenarioGraphLayout.Compute(data);
foreach (var nodeData in data.Nodes)
{
var key = nodeData.SceneName.ToLowerInvariant();
if (_nodes.ContainsKey(key))
continue;
var bgCount = nodeData.Backgrounds?.Count ?? 0;
var firstBg = bgCount > 0 ? nodeData.Backgrounds[0] : "";
var position = savedPositions.TryGetValue(key, out var saved)
? saved
: layout[key];
var gn = new GraphNode
{
Title = nodeData.SceneName,
Size = new Vector2(240, 90),
PositionOffset = position
};
var nodeLabel = new Label
{
Text = string.Format(Tr("tools/scenario_editor/graph_node"), nodeData.ModId, nodeData.ScenarioCount) +
(bgCount > 0 ? "\n" + string.Format(Tr("tools/scenario_editor/graph_node_bg"), firstBg) +
(bgCount > 1 ? $" +{bgCount - 1}" : "") : "")
};
nodeLabel.AddThemeColorOverride("font_color", UiTokens.Body);
nodeLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
gn.AddChild(nodeLabel);
var color = UiTokens.White;
if (nodeData.IsStartScene)
{
color = UiTokens.Success;
gn.AddThemeStyleboxOverride("titlebar", CreateTitlebarStyle(UiTokens.Alpha(UiTokens.Success, 0.3f)));
}
else if (nodeData.HasErrors)
{
color = UiTokens.DangerText;
gn.AddThemeStyleboxOverride("titlebar", CreateTitlebarStyle(UiTokens.Alpha(UiTokens.Danger, 0.5f)));
}
gn.SetSlot(0, true, 0, UiTokens.White, true, 0, color);
_graph.AddChild(gn);
_nodes[key] = gn;
_lookup[key] = nodeData;
}
foreach (var edge in data.Edges)
{
var srcK = edge.FromScene.ToLowerInvariant();
var dstK = edge.ToScene.ToLowerInvariant();
if (!_nodes.ContainsKey(srcK) || !_nodes.ContainsKey(dstK))
continue;
_graph.ConnectNode(_nodes[srcK].Name, 0, _nodes[dstK].Name, 0);
}
}
private Dictionary<string, Vector2> SaveNodePositions()
{
var positions = new Dictionary<string, Vector2>();
if (_nodes == null)
return positions;
foreach (var kv in _nodes)
positions[kv.Key] = kv.Value.PositionOffset;
return positions;
}
private static StyleBoxFlat CreateTitlebarStyle(Color bgColor)
{
var style = UiKit.Frame(UiTokens.RadiusMd, bgColor);
style.CornerRadiusBottomLeft = 0;
style.CornerRadiusBottomRight = 0;
return style;
}
private void ClearGraph()
{
foreach (var c in _graph.GetChildren())
if (c is GraphNode gn)
gn.QueueFree();
_infoLabel.Text = "";
_nodes?.Clear();
_lookup?.Clear();
}
private void OnConnectionRequest(StringName fromNode, long fromPort, StringName toNode, long toPort) { }
private void OnDisconnectionRequest(StringName fromNode, long fromPort, StringName toNode, long toPort) { }
private void OnNodeSelected(Node n)
{
if (n is not GraphNode gn)
return;
var key = gn.Title.ToString().ToLowerInvariant();
if (!_lookup.TryGetValue(key, out var nodeData))
return;
var sb = new System.Text.StringBuilder();
sb.AppendLine($"[b]{nodeData.SceneName}[/b]");
sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_detail_mod"), nodeData.ModId));
if (nodeData.Backgrounds.Count > 0)
sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_node_bg"), string.Join(", ", nodeData.Backgrounds)));
sb.AppendLine();
sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_detail_scenarios"), nodeData.Scenarios.Count));
foreach (var sc in nodeData.Scenarios.OrderBy(s => s.Priority))
{
var url = Uri.EscapeDataString(sc.Key);
sb.AppendLine($" [{sc.Priority}] [url={url}]{sc.Name}[/url]");
}
_infoLabel.Text = sb.ToString();
}
private void OnNodeDeselected(Node n)
{
_infoLabel.Text = "";
}
private static string Tr(string key) => TranslationServer.Translate(key);
private void OnLinkClicked(Variant meta)
{
var path = Uri.UnescapeDataString(meta.AsString());
ScenarioFileRequested?.Invoke(path);
}
}
}
-101
View File
@@ -1,101 +0,0 @@
using System.Linq;
using Cthangover.Tools.Services;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Tool;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Read-only metadata panel for the scenario in the active text tab: scene, priority, condition, switch targets, locale keys and quest references.</summary>
public partial class ScenarioInfoPanel : VBoxContainer
{
private Label _infoScene;
private Label _infoPriority;
private Label _infoCondition;
private Label _infoSwitchTargets;
private Label _infoLocaleKeys;
private Label _infoQuestRefs;
/// <summary>Binds the rows once the panel enters the tree (see <see cref="Bind"/>).</summary>
public override void _Ready()
{
Bind();
}
/// <summary>
/// Looks the rows up lazily: the tscn builder runs the constructor before
/// the child nodes exist, so lookups there would fail.
/// </summary>
private void Bind()
{
if (_infoScene != null)
return;
SetHint("HintScene", "tools/scenario_editor/info_scene");
SetHint("HintPriority", "tools/scenario_editor/info_priority");
SetHint("HintCondition", "tools/scenario_editor/info_condition");
SetHint("HintSwitchTargets", "tools/scenario_editor/info_switch_targets");
SetHint("HintLocaleKeys", "tools/scenario_editor/info_locale_keys");
SetHint("HintQuestRefs", "tools/scenario_editor/info_quest_refs");
_infoScene = Value("InfoScene");
_infoPriority = Value("InfoPriority");
_infoCondition = Value("InfoCondition");
_infoSwitchTargets = Value("InfoSwitchTargets");
_infoLocaleKeys = Value("InfoLocaleKeys");
_infoQuestRefs = Value("InfoQuestRefs");
}
private void SetHint(string node, string key)
{
var label = GetNode<Label>("Scroll/VBox/" + node);
label.Text = TranslationServer.Translate(key).ToString().ToUpperInvariant();
label.AddThemeColorOverride("font_color", UiTokens.Muted);
label.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
}
private Label Value(string node)
{
var label = GetNode<Label>("Scroll/VBox/" + node);
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
label.SizeFlagsHorizontal = SizeFlags.ExpandFill;
label.CustomMinimumSize = new Vector2(120, 0);
label.AddThemeFontOverride("font", ToolWindow.GetMonospaceFont());
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
return label;
}
/// <summary>Parses the given scenario text and refreshes every metadata row.</summary>
public void Update(string text)
{
Bind();
if (string.IsNullOrEmpty(text))
{
Clear();
return;
}
var metadata = ScenarioFileService.ParseMetadata(text);
var refs = ScenarioFileService.ExtractReferences(text);
_infoScene.Text = metadata.Scene;
_infoPriority.Text = metadata.Priority;
_infoCondition.Text = metadata.Condition;
_infoSwitchTargets.Text = refs.SwitchTargets.Count > 0 ? string.Join(", ", refs.SwitchTargets.Distinct()) : "-";
_infoLocaleKeys.Text = refs.LocaleKeys.Count > 0 ? string.Join(", ", refs.LocaleKeys.Distinct()) : "-";
_infoQuestRefs.Text = refs.QuestRefs.Count > 0 ? string.Join(", ", refs.QuestRefs.Distinct()) : "-";
}
/// <summary>Resets every metadata row to the "-" placeholder.</summary>
public void Clear()
{
Bind();
_infoScene.Text = "-";
_infoPriority.Text = "-";
_infoCondition.Text = "-";
_infoSwitchTargets.Text = "-";
_infoLocaleKeys.Text = "-";
_infoQuestRefs.Text = "-";
}
}
}
@@ -1,70 +0,0 @@
using System.Linq;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Row of background thumbnails referenced by the scenario in the active text tab.</summary>
public partial class ScenarioThumbnailPanel : ScrollContainer
{
private HBoxContainer _thumbContainer;
/// <summary>
/// Container lookup is lazy: the tscn builder runs the constructor
/// before the child nodes exist.
/// </summary>
private HBoxContainer Thumbs => _thumbContainer ??= GetNode<HBoxContainer>("ThumbContainer");
/// <summary>Loads the background thumbnails referenced by the given scenario text (missing ones render as placeholders).</summary>
public void Load(string text)
{
Clear();
if (string.IsNullOrEmpty(text))
return;
var refs = ScenarioFileService.ExtractReferences(text);
if (refs.BackgroundRefs.Count == 0)
{
Thumbs.AddChild(new Label
{
Text = TranslationServer.Translate("tools/scenario_editor/no_backgrounds")
});
return;
}
foreach (var id in refs.BackgroundRefs.Distinct())
{
var tex = ModResourceService.LoadBackgroundTexture(id);
if (tex != null)
{
Thumbs.AddChild(new TextureRect
{
Texture = tex,
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered,
CustomMinimumSize = new Vector2(120, 90),
TooltipText = id
});
}
else
{
Thumbs.AddChild(new Label
{
Text = $"[? {id}]",
TooltipText = string.Format(TranslationServer.Translate("tools/scenario_editor/bg_missing"), id)
});
}
}
}
/// <summary>Removes every thumbnail from the row.</summary>
public new void Clear()
{
foreach (var c in Thumbs.GetChildren())
c.QueueFree();
}
}
}
-17
View File
@@ -1,17 +0,0 @@
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// State of one open scenario file tab: identity, the current text buffer
/// (including unsaved edits while the tab is in the background) and the
/// dirty flag.
/// </summary>
public sealed class TabData
{
public string Key;
public string ModId;
public string RelativePath;
public string FileName;
public string Buffer;
public bool IsDirty;
}
}
-24
View File
@@ -1,24 +0,0 @@
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Declarative description of a single editable node property in the scene
/// builder inspector. Instances come from <see cref="InspectorPropertyCatalog"/>
/// and are consumed by <see cref="InspectorPanel"/> to build row editors.
/// <see cref="Key"/> doubles as the .tscn property name written back by
/// <see cref="TscnTextSurgery"/>, so only keys Godot's <c>Set()</c> understands
/// may appear here — the runtime builder applies unknown keys the same way,
/// which keeps live preview and saved file consistent.
/// </summary>
public class InspectableProperty
{
public string Key { get; set; }
public InspectorPropertyKind Kind { get; set; }
/// <summary>Whole-number mode: committed values are rounded and serialized as .tscn ints.</summary>
public bool IsInteger { get; set; }
public double Min { get; set; } = -100000d;
public double Max { get; set; } = 100000d;
public double Step { get; set; } = 1d;
public long[] EnumValues { get; set; }
public string[] EnumLabels { get; set; }
}
}
-26
View File
@@ -1,26 +0,0 @@
using System;
using Godot;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>CheckBox editor for bool properties; commits the pressed state under the bound .tscn key.</summary>
public partial class InspectorBoolField : CheckBox
{
public event Action<string, bool> Committed;
private string _key;
/// <summary>Binds the checkbox to the property key and seeds it with the current value.</summary>
public void Setup(InspectableProperty property, bool value)
{
_key = property.Key;
ButtonPressed = value;
Toggled += OnToggled;
}
private void OnToggled(bool pressed)
{
Committed?.Invoke(_key, pressed);
}
}
}
-28
View File
@@ -1,28 +0,0 @@
using System;
using Godot;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>ColorPickerButton editor for color properties (modulate, self_modulate); commits RGBA including alpha edits.</summary>
public partial class InspectorColorField : ColorPickerButton
{
public event Action<string, Color> Committed;
private string _key;
/// <summary>Binds the picker to the property key and seeds it with the current color.</summary>
public void Setup(InspectableProperty property, Color value)
{
_key = property.Key;
Color = value;
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
CustomMinimumSize = new Vector2(60f, 24f);
ColorChanged += OnColorChanged;
}
private void OnColorChanged(Color color)
{
Committed?.Invoke(_key, color);
}
}
}
-49
View File
@@ -1,49 +0,0 @@
using System;
using Godot;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// OptionButton-based editor for enum-valued properties (grow direction,
/// mouse filter, ...). Maps the selected list index back to the stored
/// numeric value from <see cref="InspectableProperty.EnumValues"/>; unknown
/// current values leave the dropdown unselected instead of lying about state.
/// </summary>
public partial class InspectorEnumField : OptionButton
{
public event Action<string, long> Committed;
private InspectableProperty _property;
/// <summary>Populates the dropdown from the property's enum labels and selects the current value, if known.</summary>
public void Setup(InspectableProperty property, long currentValue)
{
_property = property;
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
for (var i = 0; i < property.EnumLabels.Length; i++)
AddItem(property.EnumLabels[i]);
Selected = IndexOfValue(currentValue);
ItemSelected += OnItemSelected;
}
private int IndexOfValue(long value)
{
if (_property.EnumValues == null)
return -1;
for (var i = 0; i < _property.EnumValues.Length; i++)
{
if (_property.EnumValues[i] == value)
return i;
}
return -1;
}
private void OnItemSelected(long index)
{
if (_property.EnumValues != null && index >= 0 && index < _property.EnumValues.Length)
Committed?.Invoke(_property.Key, _property.EnumValues[index]);
}
}
}
-38
View File
@@ -1,38 +0,0 @@
using System;
using Godot;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// SpinBox-based inspector editor for scalar properties. Carries the .tscn
/// property key and re-emits committed values through <see cref="Committed"/>,
/// so the panel needs no per-widget closures (mod assemblies cannot rely on
/// Godot's source-generated signal wiring). Integer mode rounds away the
/// fractional part so enum-ish ints survive serialization.
/// </summary>
public partial class InspectorNumericField : SpinBox
{
public event Action<string, double> Committed;
private string _key;
private bool _integer;
/// <summary>Applies the property's min/max/step and integer mode, then seeds the initial (clamped) value.</summary>
public void Setup(InspectableProperty property, double initialValue)
{
_key = property.Key;
_integer = property.IsInteger;
MinValue = property.Min;
MaxValue = property.Max;
Step = property.Step;
Value = System.Math.Clamp(initialValue, property.Min, property.Max);
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
ValueChanged += OnValueChanged;
}
private void OnValueChanged(double value)
{
Committed?.Invoke(_key, _integer ? System.Math.Round(value) : value);
}
}
}
-407
View File
@@ -1,407 +0,0 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Godot-editor-style property inspector for the scene builder tool. Shows
/// curated rows from <see cref="InspectorPropertyCatalog"/> for the selected
/// mod-scene node, applies every edit to the live node via <c>Set()</c> (so
/// the preview reacts immediately) and reports it through
/// <see cref="PropertyEdited"/> so the host window can patch the .tscn text.
/// Initial widget values come from <paramref name="fileValues"/> — the raw
/// literals stored in the file — because post-layout live values would show
/// computed sizes instead of authored anchors; keys present in the file are
/// marked with "*". Controls managed by a Container get a warning banner and
/// container-relevant rows only (anchors/offsets would be recomputed by the
/// parent every frame anyway). For Control nodes an anchors-preset dropdown
/// applies Godot's preset math, then rebuilds all rows so widgets reflect
/// the resulting anchor/offset values; the rebuild is deferred because it
/// frees widgets mid-signal. Host must call <see cref="ModWidget.EnsureConstructed"/>
/// after adding the panel to the tree (ModWidget contract).
/// </summary>
public partial class InspectorPanel : ModWidget
{
/// <summary>Raised after a live edit: (node, tscn property key, new value).</summary>
public event Action<Node, string, Variant> PropertyEdited;
private static readonly string[] PresetNames =
{
"Top Left", "Top Right", "Bottom Left", "Bottom Right",
"Center Left", "Center Top", "Center Right", "Center Bottom",
"Center", "Left Wide", "Top Wide", "Right Wide",
"Bottom Wide", "VCenter Wide", "HCenter Wide", "Full Rect"
};
private Node _node;
private bool _suppress;
private readonly Dictionary<string, InspectableProperty> _propertiesByKey = new();
private Label _header;
private Label _hint;
private Label _banner;
private HBoxContainer _presetRow;
private OptionButton _presetDropdown;
private VBoxContainer _rows;
protected override void Construct()
{
// ModWidget is a plain Control: it neither sizes its child nor
// reports the child's height. Anchor the column to full width and
// mirror its height into the minimum size so the hosting
// ScrollContainer can scroll (and wrapped hints get real width).
var root = new VBoxContainer();
root.AddThemeConstantOverride("separation", UiTokens.Space2);
AddChild(root);
root.SetAnchorsAndOffsetsPreset(LayoutPreset.TopWide);
root.MinimumSizeChanged += () => CustomMinimumSize = new Vector2(0, root.GetCombinedMinimumSize().Y);
_hint = new Label();
_hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
_hint.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_hint.AddThemeColorOverride("font_color", UiTokens.Faint);
_hint.Visible = false;
root.AddChild(_hint);
_header = new Label();
_header.AddThemeColorOverride("font_color", UiTokens.InkBright);
_header.AddThemeFontSizeOverride("font_size", UiTokens.FontBodyLarge);
_header.Visible = false;
root.AddChild(_header);
_banner = new Label();
_banner.AutowrapMode = TextServer.AutowrapMode.WordSmart;
_banner.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_banner.AddThemeColorOverride("font_color", UiTokens.Warning);
_banner.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
_banner.Visible = false;
root.AddChild(_banner);
_presetRow = new HBoxContainer();
_presetRow.Visible = false;
root.AddChild(_presetRow);
var presetLabel = new Label
{
Text = TranslationServer.Translate("tools/scene_builder/inspector_preset"),
TooltipText = "anchors_preset"
};
presetLabel.CustomMinimumSize = new Vector2(120f, 0f);
presetLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
presetLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
_presetRow.AddChild(presetLabel);
_presetDropdown = new OptionButton();
_presetDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
for (var i = 0; i < PresetNames.Length; i++)
_presetDropdown.AddItem(PresetNames[i]);
_presetDropdown.ItemSelected += OnPresetSelected;
_presetRow.AddChild(_presetDropdown);
_rows = new VBoxContainer();
_rows.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_rows.AddThemeConstantOverride("separation", 2);
root.AddChild(_rows);
}
/// <summary>
/// Shows editable rows for a mod-scene node. <paramref name="fileValues"/>
/// carries the raw tscn literals of this node's section (null when the
/// section is not in the file yet); they seed widget values and mark
/// file-stored keys.
/// </summary>
public void ShowNode(Node node, IReadOnlyDictionary<string, string> fileValues)
{
EnsureConstructed();
_suppress = true;
_node = node;
ClearBody();
_propertiesByKey.Clear();
_hint.Visible = false;
_header.Visible = true;
_header.Text = node.Name + " · " + node.GetClass();
var insideContainer = node is Control && node.GetParent() is Container;
_banner.Visible = insideContainer;
if (insideContainer)
_banner.Text = TranslationServer.Translate("tools/scene_builder/inspector_container_banner");
var isControl = node is Control;
_presetRow.Visible = isControl && !insideContainer;
_presetDropdown.Selected = -1;
foreach (var entry in InspectorPropertyCatalog.Build(node, insideContainer))
{
var categoryLabel = UiKit.SectionTitle(TranslationServer.Translate(entry.CategoryKey), UiTokens.FontSmall);
_rows.AddChild(categoryLabel);
foreach (var property in entry.Properties)
{
_propertiesByKey[property.Key] = property;
AddPropertyRow(property, fileValues);
}
}
_suppress = false;
}
/// <summary>Placeholder view (hint/read-only notice) when no editable node is selected.</summary>
public void ShowMessage(string message)
{
EnsureConstructed();
_suppress = true;
_node = null;
_propertiesByKey.Clear();
ClearBody();
_header.Visible = false;
_presetRow.Visible = false;
_banner.Visible = false;
_hint.Visible = true;
_hint.Text = message;
_suppress = false;
}
private void AddPropertyRow(InspectableProperty property, IReadOnlyDictionary<string, string> fileValues)
{
var storedInFile = fileValues != null && fileValues.ContainsKey(property.Key);
string rawValue = null;
if (storedInFile)
fileValues.TryGetValue(property.Key, out rawValue);
var row = new HBoxContainer();
var labelText = Humanize(property.Key);
if (storedInFile)
labelText += " *";
var label = new Label { Text = labelText, TooltipText = BuildTooltip(property.Key, storedInFile), MouseFilter = MouseFilterEnum.Pass, ClipText = true };
label.CustomMinimumSize = new Vector2(120f, 0f);
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
label.AddThemeColorOverride("font_color", storedInFile ? UiTokens.Ink : UiTokens.Muted);
label.SizeFlagsVertical = Control.SizeFlags.ShrinkCenter;
row.AddChild(label);
switch (property.Kind)
{
case InspectorPropertyKind.Numeric:
{
var initial = rawValue != null && TscnLiteralReader.TryReadFloat(rawValue, out var parsed)
? parsed
: ReadDouble(property.Key);
var field = new InspectorNumericField();
field.Setup(property, initial);
field.Committed += OnNumericCommitted;
row.AddChild(field);
break;
}
case InspectorPropertyKind.Vector2:
{
var initial = rawValue != null && TscnLiteralReader.TryReadVector2(rawValue, out var parsed)
? parsed
: ReadVector2(property.Key);
var field = new InspectorVector2Field();
field.Setup(property, initial);
field.Committed += OnVectorCommitted;
row.AddChild(field);
break;
}
case InspectorPropertyKind.Bool:
{
var initial = rawValue != null && TscnLiteralReader.TryReadBool(rawValue, out var parsed)
? parsed
: ReadBool(property.Key);
var field = new InspectorBoolField();
field.Setup(property, initial);
field.Committed += OnBoolCommitted;
row.AddChild(field);
break;
}
case InspectorPropertyKind.String:
{
var initial = rawValue != null ? TscnLiteralReader.ReadString(rawValue) : null;
if (initial == null)
initial = ReadString(property.Key);
var field = new InspectorTextField();
field.Setup(property, initial);
field.Committed += OnTextCommitted;
row.AddChild(field);
break;
}
case InspectorPropertyKind.Color:
{
var initial = rawValue != null && TscnLiteralReader.TryReadColor(rawValue, out var parsed)
? parsed
: ReadColor(property.Key);
var field = new InspectorColorField();
field.Setup(property, initial);
field.Committed += OnColorCommitted;
row.AddChild(field);
break;
}
case InspectorPropertyKind.Enum:
{
var initial = rawValue != null && TscnLiteralReader.TryReadLong(rawValue, out var parsed)
? parsed
: ReadLong(property.Key);
var field = new InspectorEnumField();
field.Setup(property, initial);
field.Committed += OnEnumCommitted;
row.AddChild(field);
break;
}
}
_rows.AddChild(row);
}
private static string BuildTooltip(string key, bool storedInFile)
{
return storedInFile
? key + "\n" + TranslationServer.Translate("tools/scene_builder/inspector_in_file_tooltip")
: key;
}
private void OnPresetSelected(long index)
{
if (_suppress || !(_node is Control control))
return;
control.SetAnchorsAndOffsetsPreset((Control.LayoutPreset)index, Control.LayoutPresetMode.KeepSize);
if (ReadLong("layout_mode") != 1)
EmitChange("layout_mode", Variant.From(1L));
EmitChange("anchor_left", Variant.From(control.AnchorLeft));
EmitChange("anchor_top", Variant.From(control.AnchorTop));
EmitChange("anchor_right", Variant.From(control.AnchorRight));
EmitChange("anchor_bottom", Variant.From(control.AnchorBottom));
EmitChange("offset_left", Variant.From(control.OffsetLeft));
EmitChange("offset_top", Variant.From(control.OffsetTop));
EmitChange("offset_right", Variant.From(control.OffsetRight));
EmitChange("offset_bottom", Variant.From(control.OffsetBottom));
EmitChange("grow_horizontal", Variant.From((long)(int)control.GrowHorizontal));
EmitChange("grow_vertical", Variant.From((long)(int)control.GrowVertical));
// Deferred: this handler runs inside the dropdown's own signal and the
// resync frees the widget tree being iterated.
CallDeferred(nameof(ResyncRows));
}
private void ResyncRows()
{
if (_node == null || !GodotObject.IsInstanceValid(_node))
return;
// File values may be stale here; the host refreshes rows through
// ShowNode after committing text, so a plain rebuild suffices.
ShowNode(_node, null);
}
private void EmitChange(string key, Variant value)
{
if (_suppress || _node == null)
return;
_node.Set(key, value);
PropertyEdited?.Invoke(_node, key, value);
}
private void OnNumericCommitted(string key, double value)
{
var isInteger = _propertiesByKey.TryGetValue(key, out var prop) && prop.IsInteger;
EmitChange(key, isInteger ? Variant.From((long)System.Math.Round(value)) : Variant.From(value));
}
private void OnVectorCommitted(string key, Vector2 value)
{
EmitChange(key, Variant.From(value));
}
private void OnBoolCommitted(string key, bool pressed)
{
EmitChange(key, Variant.From(pressed));
}
private void OnTextCommitted(string key, string text)
{
EmitChange(key, Variant.From(text));
}
private void OnColorCommitted(string key, Color color)
{
EmitChange(key, Variant.From(color));
}
private void OnEnumCommitted(string key, long value)
{
EmitChange(key, Variant.From(value));
}
private void ClearBody()
{
foreach (var child in _rows.GetChildren())
{
_rows.RemoveChild(child);
child.Free();
}
}
private double ReadDouble(string key)
{
var v = _node.Get(key);
return v.VariantType == Variant.Type.Nil ? 0d : v.AsDouble();
}
private long ReadLong(string key)
{
var v = _node.Get(key);
if (v.VariantType == Variant.Type.Int)
return v.AsInt64();
if (v.VariantType == Variant.Type.Float)
return (long)v.AsDouble();
return 0;
}
private bool ReadBool(string key)
{
var v = _node.Get(key);
return v.VariantType != Variant.Type.Nil && v.AsBool();
}
private string ReadString(string key)
{
var v = _node.Get(key);
return v.VariantType == Variant.Type.Nil ? "" : v.AsString();
}
private Color ReadColor(string key)
{
var v = _node.Get(key);
return v.VariantType == Variant.Type.Nil ? UiTokens.White : v.AsColor();
}
private Vector2 ReadVector2(string key)
{
var v = _node.Get(key);
return v.VariantType == Variant.Type.Nil ? Vector2.Zero : v.AsVector2();
}
private static string Humanize(string key)
{
var parts = key.Split('_');
for (var i = 0; i < parts.Length; i++)
{
if (parts[i].Length == 0)
continue;
parts[i] = char.ToUpperInvariant(parts[i][0]) + parts[i].Substring(1);
}
return string.Join(" ", parts);
}
}
}
@@ -1,155 +0,0 @@
using System.Collections.Generic;
using Godot;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Curated property lists shown by the scene builder inspector, mirroring the
/// Godot editor's most-used entries. Deliberately hardcoded instead of derived
/// from <c>ClassDB</c>: the runtime tscn builder ignores theme overrides and
/// metadata, so a fixed allow-list guarantees every visible property is both
/// live-applied via <c>Set()</c> and faithfully written back to the file.
/// Two shapes: a free Control gets the layout/rect/visibility/misc set; a
/// Control managed by a Container ignores anchors/offsets entirely (the parent
/// recomputes them every frame), so it gets container-relevant fields instead —
/// otherwise the inspector would show editable-looking values that do nothing,
/// which is exactly how anchored scenes looked "wrong" before file-truth init.
/// </summary>
public static class InspectorPropertyCatalog
{
/// <summary>Builds the ordered (categoryKey, properties) groups for the given node.</summary>
public static List<(string CategoryKey, List<InspectableProperty> Properties)> Build(Node node, bool insideContainer)
{
var result = new List<(string, List<InspectableProperty>)>();
if (node is Control)
{
if (insideContainer)
{
result.Add(("tools/scene_builder/inspector_cat_container", BuildContainer()));
}
else
{
result.Add(("tools/scene_builder/inspector_cat_layout", BuildLayout()));
result.Add(("tools/scene_builder/inspector_cat_rect", BuildRect()));
}
result.Add(("tools/scene_builder/inspector_cat_visibility", BuildControlVisibility()));
result.Add(("tools/scene_builder/inspector_cat_misc", BuildMisc()));
}
else if (node is CanvasItem)
{
result.Add(("tools/scene_builder/inspector_cat_visibility", BuildCanvasItemVisibility()));
}
return result;
}
private static List<InspectableProperty> BuildContainer()
{
return new List<InspectableProperty>
{
Int("size_flags_horizontal", 0, 15),
Int("size_flags_vertical", 0, 15),
Vector2("custom_minimum_size", 0d, 16384d, 1d)
};
}
private static List<InspectableProperty> BuildLayout()
{
return new List<InspectableProperty>
{
Enum("layout_mode", new long[] { 0, 1, 2, 3 },
new[] { "Position", "Anchors", "Container", "Uncontrolled" }),
Float("anchor_left", -8d, 8d, 0.01d),
Float("anchor_top", -8d, 8d, 0.01d),
Float("anchor_right", -8d, 8d, 0.01d),
Float("anchor_bottom", -8d, 8d, 0.01d),
Enum("grow_horizontal", new long[] { 0, 1, 2 }, new[] { "Begin", "End", "Both" }),
Enum("grow_vertical", new long[] { 0, 1, 2 }, new[] { "Begin", "End", "Both" }),
Float("offset_left", -100000d, 100000d, 1d),
Float("offset_top", -100000d, 100000d, 1d),
Float("offset_right", -100000d, 100000d, 1d),
Float("offset_bottom", -100000d, 100000d, 1d)
};
}
private static List<InspectableProperty> BuildRect()
{
return new List<InspectableProperty>
{
Vector2("custom_minimum_size", 0d, 16384d, 1d),
Vector2("position", -100000d, 100000d, 1d),
Vector2("size", 0d, 100000d, 1d),
Float("rotation", -36000d, 36000d, 1d),
Vector2("scale", -64d, 64d, 0.05d),
Vector2("pivot_offset", -100000d, 100000d, 1d)
};
}
private static List<InspectableProperty> BuildControlVisibility()
{
return new List<InspectableProperty>
{
Bool("visible"),
Color("modulate"),
Color("self_modulate"),
Bool("clip_contents")
};
}
private static List<InspectableProperty> BuildCanvasItemVisibility()
{
return new List<InspectableProperty>
{
Bool("visible"),
Color("modulate"),
Color("self_modulate")
};
}
private static List<InspectableProperty> BuildMisc()
{
return new List<InspectableProperty>
{
Enum("mouse_filter", new long[] { 0, 1, 2 }, new[] { "Stop", "Pass", "Ignore" }),
Enum("focus_mode", new long[] { 0, 1, 2 }, new[] { "None", "Click", "All" }),
Text("tooltip_text")
};
}
private static InspectableProperty Float(string key, double min, double max, double step)
{
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.Numeric, Min = min, Max = max, Step = step };
}
private static InspectableProperty Int(string key, int min, int max)
{
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.Numeric, IsInteger = true, Min = min, Max = max, Step = 1d };
}
private static InspectableProperty Vector2(string key, double min, double max, double step)
{
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.Vector2, Min = min, Max = max, Step = step };
}
private static InspectableProperty Bool(string key)
{
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.Bool };
}
private static InspectableProperty Text(string key)
{
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.String };
}
private static InspectableProperty Color(string key)
{
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.Color };
}
private static InspectableProperty Enum(string key, long[] values, string[] labels)
{
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.Enum, IsInteger = true, EnumValues = values, EnumLabels = labels };
}
}
}
@@ -1,9 +0,0 @@
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Editing widget category for one inspector property. Drives which concrete
/// field control <see cref="InspectorPanel"/> instantiates and which Variant
/// shape is emitted back through <see cref="InspectorPanel.PropertyEdited"/>.
/// </summary>
public enum InspectorPropertyKind { Numeric, Vector2, Bool, String, Color, Enum }
}
-27
View File
@@ -1,27 +0,0 @@
using System;
using Godot;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>LineEdit editor for string properties (tooltip_text); every keystroke commits, matching the live-preview editing model.</summary>
public partial class InspectorTextField : LineEdit
{
public event Action<string, string> Committed;
private string _key;
/// <summary>Binds the line edit to the property key and seeds it with the current text.</summary>
public void Setup(InspectableProperty property, string value)
{
_key = property.Key;
Text = value ?? "";
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
TextChanged += OnTextChanged;
}
private void OnTextChanged(string text)
{
Committed?.Invoke(_key, text);
}
}
}
-46
View File
@@ -1,46 +0,0 @@
using System;
using Godot;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Paired x/y SpinBox row for Vector2 properties. Both axes share one commit
/// channel: any axis change emits the full current vector, which keeps the
/// live node, the preview and the .tscn text in sync on every keystroke.
/// </summary>
public partial class InspectorVector2Field : HBoxContainer
{
public event Action<string, Vector2> Committed;
private string _key;
private SpinBox _x;
private SpinBox _y;
/// <summary>Builds the x/y spin pair bound to the property key and seeds both axes.</summary>
public void Setup(InspectableProperty property, Vector2 initialValue)
{
_key = property.Key;
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
AddChild(new Label { Text = "x", CustomMinimumSize = new Vector2(14f, 0f) });
_x = CreateAxis(property, initialValue.X);
AddChild(new Label { Text = "y", CustomMinimumSize = new Vector2(14f, 0f) });
_y = CreateAxis(property, initialValue.Y);
}
private SpinBox CreateAxis(InspectableProperty property, double initial)
{
var box = new InspectorNumericField();
box.Setup(property, initial);
box.CustomMinimumSize = new Vector2(60f, 0f);
box.Committed += OnAxisChanged;
AddChild(box);
return box;
}
private void OnAxisChanged(string key, double value)
{
Committed?.Invoke(_key, new Vector2((float)_x.Value, (float)_y.Value));
}
}
}
-438
View File
@@ -1,438 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Controller for the scene builder tool. Composes a preview from a base
/// scene (a native Godot scene from <c>res://scenes</c>, or an empty root)
/// and a mod-provided <c>.tscn</c> scene layered on top of it via the
/// runtime tscn pipeline (<see cref="TscnSceneResolver"/>). Builds an
/// interactive node hierarchy tree, tracks selection for the highlight
/// overlay, and reads/writes the raw tscn source so the tool can edit and
/// save mod scenes in place. Mod scene listing is sourced from
/// <see cref="TscnSceneResolver.Collect"/> (deduplicated by mod load order),
/// while base scenes come from <see cref="ModScenes.CollectTscnFiles"/>.
/// </summary>
public class SceneBuilderController
{
private const string ResScenesPrefix = "res://scenes/";
private Node _loadedRoot;
private Node _modRoot;
private ColorRect _highlight;
private readonly Dictionary<Node, TreeItem> _nodeToItem = new();
private readonly Dictionary<TreeItem, Node> _itemToNode = new();
/// <summary>
/// Returns the native base scenes available under <c>res://scenes</c> as
/// (Display, Path) tuples. Display is the path relative to
/// <c>res://scenes/</c> so nested scenes remain distinguishable.
/// </summary>
public List<(string Display, string Path)> GetBaseSceneList()
{
var result = new List<(string, string)>();
foreach (var (_, path) in ModScenes.CollectTscnFiles())
{
var display = path;
if (display.StartsWith(ResScenesPrefix, StringComparison.Ordinal))
display = display.Substring(ResScenesPrefix.Length);
result.Add((display, path));
}
return result;
}
/// <summary>
/// Returns every mod-provided tscn scene as (ModId, Name, RelativePath)
/// tuples, deduplicated by mod load order. RelativePath is the key used
/// by <see cref="TscnSceneResolver.LoadAndBuild"/> (e.g. "scenes/foo.tscn").
/// </summary>
public List<(string ModId, string Name, string RelativePath)> GetModSceneIndex()
{
var result = new List<(string, string, string)>();
var resolver = ModManager.Instance.GetResolver<TscnSceneResolver>();
var index = resolver?.Collect();
if (index == null)
return result;
foreach (var kvp in index)
{
var relativePath = kvp.Key;
var modId = kvp.Value.ModId;
result.Add((modId, Path.GetFileName(relativePath), relativePath));
}
result.Sort((a, b) =>
{
var modCompare = string.Compare(a.Item1, b.Item1, StringComparison.OrdinalIgnoreCase);
return modCompare != 0 ? modCompare : string.Compare(a.Item3, b.Item3, StringComparison.OrdinalIgnoreCase);
});
return result;
}
/// <summary>
/// Loads a native base scene into <paramref name="viewport"/> as the root.
/// Replaces any previously loaded scene and attaches the selection
/// highlight overlay.
/// </summary>
public Node LoadBaseScene(string path, SubViewport viewport)
{
UnloadScene();
var packed = GD.Load<PackedScene>(path);
if (packed == null)
return null;
var instance = packed.Instantiate();
viewport.AddChild(instance);
AttachHighlight(instance);
_loadedRoot = instance;
return instance;
}
/// <summary>
/// Loads an empty full-rect <see cref="Control"/> as the preview root,
/// used when no base scene is selected so mod scenes still have a parent.
/// </summary>
public Node LoadEmptyScene(SubViewport viewport)
{
UnloadScene();
var root = new Control { Name = "EmptyScene" };
root.AnchorRight = 1f;
root.AnchorBottom = 1f;
viewport.AddChild(root);
AttachHighlight(root);
_loadedRoot = root;
return root;
}
/// <summary>
/// Builds a mod scene via the runtime tscn pipeline and adds it as a child
/// of the current base root. Returns the built node, or null if there is
/// no base root or the scene could not be resolved.
/// </summary>
public Node LoadModScene(string relativePath)
{
UnloadModScene();
if (_loadedRoot == null)
return null;
var node = TscnScenes.LoadAndBuild(relativePath);
if (node == null)
return null;
_loadedRoot.AddChild(node);
_modRoot = node;
return node;
}
/// <summary>Reads the raw tscn source of a mod scene through its file provider.</summary>
public string ReadSceneText(string modId, string relativePath)
{
return ModManager.Instance.ReadFileText(modId, relativePath);
}
/// <summary>
/// Writes the edited tscn source back to the mod on disk. Returns false
/// when the mod has no filesystem backing (e.g. zip mods).
/// </summary>
public bool SaveSceneText(string modId, string relativePath, string text)
{
var absolutePath = ModManager.Instance.GetFileSystemPath(modId, relativePath);
if (string.IsNullOrEmpty(absolutePath))
return false;
File.WriteAllText(absolutePath, text);
return true;
}
/// <summary>
/// Invalidates the resolver cache for one scene so a subsequent reload
/// re-parses the edited file instead of returning the cached build.
/// </summary>
public void InvalidateScene(string relativePath)
{
ModManager.Instance.GetResolver<TscnSceneResolver>()?.Invalidate(relativePath);
}
/// <summary>Root node of the loaded mod scene; null when no mod scene is loaded.</summary>
public Node GetModRoot()
{
return _modRoot;
}
/// <summary>
/// Creates a live instance of the given Godot class under the parent with
/// a sibling-unique name and returns it, or null when ClassDB cannot
/// instantiate the type. The caller is responsible for the matching tscn
/// write-back (and for rolling the live node back if that fails).
/// </summary>
public Node CreateUniqueChild(Node parentNode, string typeName)
{
if (parentNode == null || string.IsNullOrEmpty(typeName))
return null;
var instance = ClassDB.Instantiate(typeName).As<Node>();
if (instance == null)
return null;
var name = typeName;
var suffix = 2;
while (parentNode.HasNode(name))
name = typeName + suffix++;
instance.Name = name;
parentNode.AddChild(instance);
return instance;
}
/// <summary>
/// Detaches the node from the preview tree immediately (so hierarchy
/// rebuilds right away don't show a ghost) and queues it for deletion.
/// Returns false for nodes outside the mod subtree or the mod root itself.
/// </summary>
public bool DeleteModNode(Node node)
{
if (!IsEditableModNode(node) || node == _modRoot)
return false;
var parent = node.GetParent();
parent?.RemoveChild(node);
node.QueueFree();
return true;
}
/// <summary>Frees the loaded scene root (and its mod scene child) and clears mappings.</summary>
public void UnloadScene()
{
_nodeToItem.Clear();
_itemToNode.Clear();
_highlight = null;
_modRoot = null;
if (_loadedRoot != null && GodotObject.IsInstanceValid(_loadedRoot))
_loadedRoot.QueueFree();
_loadedRoot = null;
}
private void UnloadModScene()
{
if (_modRoot == null)
return;
if (_loadedRoot != null && GodotObject.IsInstanceValid(_loadedRoot) && _modRoot.GetParent() == _loadedRoot)
_loadedRoot.RemoveChild(_modRoot);
if (GodotObject.IsInstanceValid(_modRoot))
_modRoot.QueueFree();
_modRoot = null;
}
private void AttachHighlight(Node root)
{
_highlight = new ColorRect();
_highlight.Color = UiTokens.Alpha(UiTokens.Accent, 0.35f);
_highlight.Visible = false;
_highlight.MouseFilter = Control.MouseFilterEnum.Ignore;
_highlight.ZIndex = 4096;
root.AddChild(_highlight);
_highlight.Owner = root;
}
/// <summary>
/// Recursively walks the loaded scene tree and populates the given
/// <see cref="Tree"/> control with nodes labelled <c>Name · Class</c>:
/// the mod scene root in gold, editable mod nodes in ink, read-only
/// base-scene nodes faint. Skips the internal highlight rect.
/// </summary>
public void BuildHierarchy(Tree tree)
{
tree.Clear();
_nodeToItem.Clear();
_itemToNode.Clear();
if (_loadedRoot == null)
return;
var treeRoot = tree.CreateItem();
tree.HideRoot = true;
AddNodeToTree(tree, treeRoot, _loadedRoot);
}
private void AddNodeToTree(Tree tree, TreeItem parent, Node node)
{
var item = tree.CreateItem(parent);
item.SetText(0, $"{node.Name} · {node.GetClass()}");
item.SetSelectable(0, true);
// Mod-scene nodes are the editable ones; base-scene nodes stay faint.
item.SetCustomColor(0, IsEditableModNode(node) ? UiTokens.Ink : UiTokens.Faint);
if (node == _modRoot)
item.SetCustomColor(0, UiTokens.AccentBright);
_nodeToItem[node] = item;
_itemToNode[item] = node;
for (int i = 0; i < node.GetChildCount(); i++)
{
var child = node.GetChild(i);
if (child != _highlight)
AddNodeToTree(tree, item, child);
}
}
/// <summary>
/// Highlights the selected node by positioning the highlight rect over it.
/// For <see cref="Control"/> nodes, uses <c>GetGlobalRect()</c>; for others,
/// approximates the position via parent chain traversal. Hides the highlight
/// if the root node itself is selected.
/// </summary>
public void SelectNode(TreeItem item)
{
if (_highlight == null || item == null)
{
HideHighlight();
return;
}
if (!_itemToNode.TryGetValue(item, out var node))
{
HideHighlight();
return;
}
if (node == _loadedRoot)
{
HideHighlight();
return;
}
var control = node as Control;
if (control != null)
{
var rect = control.GetGlobalRect();
_highlight.Position = rect.Position;
_highlight.Size = rect.Size;
_highlight.Visible = true;
}
else
{
var pos = new Vector2(0, 0);
var current = node;
while (current != _loadedRoot && current != null)
{
if (current is Control c)
{
pos += c.GetGlobalRect().Position;
break;
}
current = current.GetParent();
}
_highlight.Position = pos;
_highlight.Size = new Vector2(20, 20);
_highlight.Color = UiTokens.Alpha(UiTokens.Danger, 0.5f);
_highlight.Visible = true;
}
}
/// <summary>Hierarchy tree item for a live node; null when unmapped.</summary>
public TreeItem GetItemForNode(Node node)
{
_nodeToItem.TryGetValue(node, out var item);
return item;
}
/// <summary>Reverse lookup of the hierarchy item for a live node; null when unmapped.</summary>
public Node GetNodeForItem(TreeItem item)
{
return _itemToNode.TryGetValue(item, out var node) ? node : null;
}
/// <summary>
/// True when the node belongs to the loaded mod scene subtree (including
/// the mod root itself) and therefore may be edited by the inspector.
/// Base-scene nodes and tool internals are excluded so edits never leak
/// into files this tool cannot save.
/// </summary>
public bool IsEditableModNode(Node node)
{
if (node == null || _modRoot == null || node == _highlight)
return false;
for (Node current = node; current != null; current = current.GetParent())
{
if (current == _modRoot)
return true;
}
return false;
}
/// <summary>
/// Address path of a mod node in .tscn terms, relative to the mod scene
/// root ("", "Panel", "Panel/Btn") — exactly what TscnTextSurgery needs to
/// locate the matching <c>[node]</c> section. Returns "" for the root node
/// itself (its section has no parent attribute) and null for non-editable nodes.
/// </summary>
public string GetModNodePath(Node node)
{
if (!IsEditableModNode(node))
return null;
if (node == _modRoot)
return "";
var segments = new List<string>();
for (Node current = node; current != null && current != _modRoot; current = current.GetParent())
segments.Add(current.Name);
segments.Reverse();
return string.Join("/", segments);
}
/// <summary>Topmost loaded-scene node whose visual rect contains the viewport position; null when nothing matches.</summary>
public Node FindNodeAtPosition(Vector2 position)
{
if (_loadedRoot == null)
return null;
return FindNodeAtPositionRecursive(_loadedRoot, position);
}
private Node FindNodeAtPositionRecursive(Node node, Vector2 position)
{
for (int i = node.GetChildCount() - 1; i >= 0; i--)
{
var child = node.GetChild(i);
if (child == _highlight)
continue;
var hit = FindNodeAtPositionRecursive(child, position);
if (hit != null)
return hit;
}
if (node == _loadedRoot)
return null;
if (node is Control control && control.GetGlobalRect().HasPoint(position))
return node;
return null;
}
/// <summary>Hides the node highlight overlay and resets its colour.</summary>
public void HideHighlight()
{
if (_highlight == null)
return;
_highlight.Visible = false;
_highlight.Color = UiTokens.Alpha(UiTokens.Accent, 0.35f);
}
}
}
@@ -1,19 +0,0 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>Registers the scene builder as a tool window. Creates <see cref="SceneBuilderWindow"/> instances.</summary>
public class SceneBuilderToolProvider : IToolProvider
{
/// <summary>Unique tool identifier.</summary>
public string Id => "scene_builder";
/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey => "tools/scene_builder/title";
/// <summary>Creates the editor window instance.</summary>
public Window CreateWindow() => new SceneBuilderWindow();
/// <summary>Adds the scene builder's launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
}
}
-784
View File
@@ -1,784 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Developer tool window for interactively previewing a mod <c>.tscn</c> scene
/// layered on top of a base game scene. Features three chained selectors
/// (base scene → mod → scene-in-mod), a live viewport with a selectable node
/// hierarchy tree, draggable resize/anchor handles over the preview, and one
/// compact tabbed sidebar holding the property inspector and the tscn source
/// editor (edits applied live, debounced auto-save), plus an always-visible
/// output strip for status and errors. All logic lives in
/// <see cref="SceneBuilderController"/>.
/// </summary>
public partial class SceneBuilderWindow : ToolWindow
{
private SceneBuilderController _controller;
private OptionButton _baseDropdown;
private OptionButton _modDropdown;
private OptionButton _sceneDropdown;
private Tree _hierarchyTree;
private SubViewport _viewport;
private TabContainer _sideTabs;
private InspectorPanel _inspector;
private SelectionOverlay _selectionOverlay;
private Timer _autosaveTimer;
private Node _selectedNode;
private TscnFileProps _fileProps = TscnFileProps.Build(null);
private CodeEdit _tscnEditor;
private OptionButton _newNodeTypeDropdown;
private int _shownBase;
private int _shownMod;
private int _shownScene;
private bool _loadingText;
/// <summary>Logical game resolution (project.godot) the preview canvas is laid out at.</summary>
private static readonly Vector2I GameResolution = new(1920, 1024);
/// <summary>Curated palette for node creation — common Control classes the runtime builder instantiates via ClassDB.</summary>
private static readonly string[] CreatableTypes =
{
"Control", "Label", "Button", "Panel", "ColorRect", "TextureRect",
"RichTextLabel", "LineEdit", "VBoxContainer", "HBoxContainer",
"GridContainer", "MarginContainer", "CenterContainer", "ScrollContainer"
};
private List<(string Display, string Path)> _baseScenes;
private List<(string ModId, string Name, string RelativePath)> _modScenes;
private List<string> _mods;
private List<(string ModId, string Name, string RelativePath)> _currentModScenes = new();
/// <summary>Constructs the window, creates the controller, builds UI, and populates selectors.</summary>
public SceneBuilderWindow() : base("tools/scene_builder/title")
{
CthangoverTheme.Apply(this);
_controller = new SceneBuilderController();
BuildUI();
PopulateBaseList();
PopulateModList();
RebuildPreview();
SetHint(ToolUi.Tr("tools/scene_builder/hint"));
}
private void BuildUI()
{
var outerVBox = CreateFillContainer();
AddChild(outerVBox);
var toolbar = CreateToolbar();
outerVBox.AddChild(toolbar);
_baseDropdown = Dropdown(toolbar, "tools/scene_builder/base_scene", OnBaseSelected);
_modDropdown = Dropdown(toolbar, "tools/scene_builder/mod", OnModSelected);
_sceneDropdown = Dropdown(toolbar, "tools/scene_builder/scene", OnSceneSelected);
toolbar.AddChild(ToolUi.Divider());
toolbar.AddChild(ToolUi.Button("tools/common/save", OnSavePressed, ButtonKind.Primary, "tools/scene_builder/save_tip"));
toolbar.AddChild(ToolUi.Button("tools/scene_builder/reload", OnReloadPressed, ButtonKind.Secondary, "tools/scene_builder/reload_tip"));
var mainSplit = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outerVBox.AddChild(mainSplit);
mainSplit.AddChild(BuildHierarchyPanel());
var centerSplit = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
mainSplit.AddChild(centerSplit);
centerSplit.AddChild(BuildPreview());
centerSplit.AddChild(BuildSidePanel());
_autosaveTimer = new Timer { OneShot = true, WaitTime = 0.4 };
_autosaveTimer.Timeout += OnAutosaveTimeout;
AddChild(_autosaveTimer);
}
private static OptionButton Dropdown(HBoxContainer toolbar, string tooltipKey, Action<long> selected)
{
var dropdown = new OptionButton
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
TooltipText = ToolUi.Tr(tooltipKey),
FitToLongestItem = false
};
dropdown.ItemSelected += index => selected(index);
toolbar.AddChild(dropdown);
return dropdown;
}
private Control BuildHierarchyPanel()
{
var column = new VBoxContainer();
column.AddThemeConstantOverride("separation", UiTokens.Space2);
var panel = new PanelContainer { CustomMinimumSize = new Vector2(280, 0) };
panel.AddThemeStyleboxOverride("panel", SidebarBox());
panel.AddChild(column);
column.AddChild(ToolUi.Section("tools/scene_builder/hierarchy"));
var actions = new HBoxContainer();
actions.AddThemeConstantOverride("separation", UiTokens.Space1);
column.AddChild(actions);
_newNodeTypeDropdown = new OptionButton
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
TooltipText = ToolUi.Tr("tools/scene_builder/node_type_tip")
};
foreach (var t in CreatableTypes)
_newNodeTypeDropdown.AddItem(t);
_newNodeTypeDropdown.Selected = 0;
actions.AddChild(_newNodeTypeDropdown);
var addBtn = UiKit.IconButton("+", ToolUi.Tr("tools/scene_builder/add_node_tip"));
addBtn.Pressed += OnAddNodePressed;
actions.AddChild(addBtn);
var deleteBtn = UiKit.IconButton("\u2212", ToolUi.Tr("tools/scene_builder/delete_node_tip"));
deleteBtn.Pressed += OnDeleteNodePressed;
actions.AddChild(deleteBtn);
var refreshBtn = UiKit.IconButton("\u27F3", ToolUi.Tr("tools/scene_builder/refresh_tip"));
refreshBtn.Pressed += OnRefreshPressed;
actions.AddChild(refreshBtn);
_hierarchyTree = new Tree
{
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
HideRoot = true
};
_hierarchyTree.CellSelected += OnTreeCellSelected;
column.AddChild(_hierarchyTree);
column.AddChild(ToolUi.Hint(ToolUi.Tr("tools/scene_builder/hierarchy_hint")));
return panel;
}
/// <summary>
/// Preview canvas. The sub-viewport lays the scene out at the game
/// resolution (2D size override) and is scaled to fit inside an
/// aspect-locked box, so anchors and sizes look exactly as in the game
/// whatever the window size. The selection overlay shares the box, so
/// its coordinate mapping is a plain scale.
/// </summary>
private Control BuildPreview()
{
var previewContainer = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
previewContainer.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line));
var aspect = new AspectRatioContainer
{
Ratio = (float)GameResolution.X / GameResolution.Y,
StretchMode = AspectRatioContainer.StretchModeEnum.Fit
};
previewContainer.AddChild(aspect);
_viewport = new SubViewport
{
Size = GameResolution,
Size2DOverride = GameResolution,
Size2DOverrideStretch = true
};
var clickCatcher = new Control();
clickCatcher.MouseFilter = Control.MouseFilterEnum.Stop;
clickCatcher.ZIndex = 4095;
clickCatcher.AnchorRight = 1f;
clickCatcher.AnchorBottom = 1f;
clickCatcher.GuiInput += OnViewportClick;
_viewport.AddChild(clickCatcher);
var viewportContainer = new SubViewportContainer { Stretch = true };
viewportContainer.AddChild(_viewport);
aspect.AddChild(viewportContainer);
_selectionOverlay = new SelectionOverlay();
aspect.AddChild(_selectionOverlay);
_selectionOverlay.Setup(_viewport, viewportContainer, GameResolution);
_selectionOverlay.PickRequested += OnOverlayPick;
_selectionOverlay.BatchCommitted += OnOverlayBatchCommitted;
_selectionOverlay.EnsureConstructed();
return previewContainer;
}
/// <summary>Inspector and tscn source share one tabbed column.</summary>
private Control BuildSidePanel()
{
var panel = new PanelContainer { CustomMinimumSize = new Vector2(380, 0) };
panel.AddThemeStyleboxOverride("panel", SidebarBox());
_sideTabs = new TabContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
panel.AddChild(_sideTabs);
var inspectorScroll = new ScrollContainer { HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled };
_sideTabs.AddChild(inspectorScroll);
_sideTabs.SetTabTitle(0, ToolUi.Tr("tools/scene_builder/tab_inspector"));
_inspector = new InspectorPanel { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
inspectorScroll.AddChild(_inspector);
_inspector.PropertyEdited += OnInspectorPropertyEdited;
_inspector.EnsureConstructed();
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));
// Titles are set explicitly: a node name cannot hold "." (".tscn").
var tscnTab = new VBoxContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
_sideTabs.AddChild(tscnTab);
_sideTabs.SetTabTitle(1, ToolUi.Tr("tools/scene_builder/tab_tscn"));
_tscnEditor = ToolUi.Code();
_tscnEditor.TextChanged += OnTscnTextChanged;
tscnTab.AddChild(_tscnEditor);
tscnTab.AddChild(ToolUi.Hint(ToolUi.Tr("tools/scene_builder/tscn_hint")));
return panel;
}
private void PopulateBaseList()
{
_baseDropdown.Clear();
_baseDropdown.AddItem(ToolUi.Tr("tools/scene_builder/empty_scene"));
_baseScenes = _controller.GetBaseSceneList();
foreach (var s in _baseScenes)
_baseDropdown.AddItem(s.Display);
_baseDropdown.Selected = 0;
}
private void PopulateModList()
{
_modDropdown.Clear();
_modDropdown.AddItem(ToolUi.Tr("tools/scene_builder/select_mod"));
_modScenes = _controller.GetModSceneIndex();
_mods = _modScenes.Select(s => s.ModId).Distinct().ToList();
foreach (var modId in _mods)
_modDropdown.AddItem(modId);
_modDropdown.Selected = 0;
_sceneDropdown.Clear();
_sceneDropdown.AddItem(ToolUi.Tr("tools/scene_builder/select_scene"));
_sceneDropdown.Selected = 0;
}
private void PopulateSceneListForMod(string modId)
{
_currentModScenes = new List<(string, string, string)>();
if (!string.IsNullOrEmpty(modId))
{
foreach (var s in _modScenes)
if (s.ModId == modId)
_currentModScenes.Add(s);
}
_sceneDropdown.Clear();
_sceneDropdown.AddItem(ToolUi.Tr("tools/scene_builder/select_scene"));
foreach (var s in _currentModScenes)
_sceneDropdown.AddItem(s.Name);
_sceneDropdown.Selected = 0;
}
private bool TryGetSelectedModScene(out (string ModId, string Name, string RelativePath) scene)
{
scene = default;
var idx = (int)_sceneDropdown.Selected - 1;
if (idx < 0 || idx >= _currentModScenes.Count)
return false;
scene = _currentModScenes[idx];
return true;
}
private void RebuildPreview()
{
_shownBase = _baseDropdown.Selected;
_shownMod = _modDropdown.Selected;
_shownScene = _sceneDropdown.Selected;
var baseIdx = (int)_baseDropdown.Selected;
if (baseIdx <= 0 || baseIdx - 1 >= _baseScenes.Count)
_controller.LoadEmptyScene(_viewport);
else
_controller.LoadBaseScene(_baseScenes[baseIdx - 1].Path, _viewport);
if (TryGetSelectedModScene(out var scene))
_controller.LoadModScene(scene.RelativePath);
_controller.BuildHierarchy(_hierarchyTree);
ShowTscnText();
RefreshFileSnapshot();
_selectedNode = null;
_selectionOverlay.Track(null);
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));
}
/// <summary>Re-parses the current editor text into the file-truth property snapshot used by the inspector.</summary>
private void RefreshFileSnapshot()
{
_fileProps = TscnFileProps.Build(_tscnEditor.Text);
}
/// <summary>Restarts the debounced auto-save; called after every inspector/overlay write-back.</summary>
private void KickAutosave()
{
_autosaveTimer.Stop();
_autosaveTimer.Start();
}
private void OnAutosaveTimeout()
{
SaveCurrentText(true);
}
private void ShowTscnText()
{
_loadingText = true;
_tscnEditor.Text = "";
if (TryGetSelectedModScene(out var scene))
{
var text = _controller.ReadSceneText(scene.ModId, scene.RelativePath);
_tscnEditor.Text = text ?? "";
}
_tscnEditor.ClearUndoHistory();
_loadingText = false;
MarkClean();
}
/// <summary>Hand edits of the source mark the window dirty; they reach the preview on save.</summary>
private void OnTscnTextChanged()
{
if (!_loadingText)
SetDirty();
}
private void OnBaseSelected(long index)
{
RunDiscardingChanges(RebuildPreview, RestoreDropdowns);
}
private void OnModSelected(long index)
{
RunDiscardingChanges(() =>
{
var modId = index == 0 ? null : _mods[(int)index - 1];
PopulateSceneListForMod(modId);
RebuildPreview();
}, RestoreDropdowns);
}
private void OnSceneSelected(long index)
{
RunDiscardingChanges(RebuildPreview, RestoreDropdowns);
}
/// <summary>Rolls the selectors back to the scene still shown after a refused switch.</summary>
private void RestoreDropdowns()
{
_baseDropdown.Select(_shownBase);
_modDropdown.Select(_shownMod);
PopulateSceneListForMod(_shownMod <= 0 ? null : _mods[_shownMod - 1]);
_sceneDropdown.Select(_shownScene);
}
private void OnReloadPressed()
{
RunDiscardingChanges(() =>
{
if (TryGetSelectedModScene(out var scene))
_controller.InvalidateScene(scene.RelativePath);
RebuildPreview();
ShowOutput(ToolUi.Tr("tools/scene_builder/reloaded"), false);
});
}
/// <summary>Ctrl+S: save the source and rebuild the preview from it.</summary>
protected override void OnSaveShortcut() => OnSavePressed();
/// <summary>Delete removes the selected mod node after a confirmation.</summary>
protected override bool HandleKey(InputEventKey key)
{
if (key.Keycode != Key.Delete || _selectedNode == null || !_controller.IsEditableModNode(_selectedNode))
return false;
ShowConfirm("tools/scene_builder/delete_node_title", "tools/scene_builder/delete_node_confirm", OnDeleteNodePressed);
return true;
}
/// <summary>
/// Saves the source and, when it was edited by hand, rebuilds the preview
/// from the saved file — hand edits do not reach the live preview
/// otherwise (inspector and overlay edits are applied live already).
/// </summary>
private void OnSavePressed()
{
var handEdited = _dirty;
if (!SaveCurrentText(false) || !handEdited || !TryGetSelectedModScene(out var scene))
return;
_controller.InvalidateScene(scene.RelativePath);
RebuildPreview();
ShowOutput(ToolUi.Tr("tools/scene_builder/saved_rebuilt"), false);
}
/// <summary>
/// Writes the editor text to disk. auto=true marks the debounced
/// inspector-driven save: failures are logged and reported, but the
/// wording differs from the explicit Save button feedback.
/// </summary>
private bool SaveCurrentText(bool auto)
{
if (!TryGetSelectedModScene(out var scene))
{
if (!auto)
ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"), true);
return false;
}
if (_controller.SaveSceneText(scene.ModId, scene.RelativePath, _tscnEditor.Text))
{
MarkClean();
ShowOutput(ToolUi.Tr(auto
? "tools/scene_builder/autosaved"
: "tools/scene_builder/saved_ok"), false);
return true;
}
ShowOutput(ToolUi.Tr("tools/scene_builder/error_save"), true);
GameLogger.Log("SCENE_BUILDER",
$"save failed: mod='{scene.ModId}' path='{scene.RelativePath}' (zip mods are read-only)",
LogLevel.Error);
return false;
}
private void OnRefreshPressed()
{
_controller.BuildHierarchy(_hierarchyTree);
}
private void OnTreeCellSelected()
{
var item = _hierarchyTree.GetSelected();
if (item == null)
return;
_controller.SelectNode(item);
SyncInspector(item);
}
private void OnViewportClick(InputEvent evt)
{
if (evt is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left && mb.Pressed)
{
var node = _controller.FindNodeAtPosition(mb.Position);
if (node == null)
return;
var item = _controller.GetItemForNode(node);
if (item == null)
return;
var current = _hierarchyTree.GetSelected();
if (current != null)
current.Deselect(0);
item.Select(0);
_hierarchyTree.ScrollToItem(item);
_controller.SelectNode(item);
SyncInspector(item);
}
}
/// <summary>
/// Mirrors the hierarchy selection into the inspector and the overlay:
/// mod-scene nodes get editable rows and manipulation handles, everything
/// else gets a read-only notice.
/// </summary>
private void SyncInspector(TreeItem item)
{
var node = _controller.GetNodeForItem(item);
_selectedNode = node;
if (node != null && _controller.IsEditableModNode(node))
{
var nodePath = _controller.GetModNodePath(node);
_inspector.ShowNode(node, _fileProps.GetNodeProperties(nodePath));
_selectionOverlay.Track(node as Control);
_sideTabs.CurrentTab = 0;
}
else
{
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_readonly"));
_selectionOverlay.Track(null);
}
}
private void OnOverlayPick(Vector2 canvasPosition)
{
var node = _controller.FindNodeAtPosition(canvasPosition);
if (node == null)
return;
var item = _controller.GetItemForNode(node);
if (item == null)
return;
SelectTreeItem(item);
}
/// <summary>Moves tree selection to the item and mirrors it into highlight/inspector/overlay.</summary>
private void SelectTreeItem(TreeItem item)
{
var current = _hierarchyTree.GetSelected();
if (current != null)
current.Deselect(0);
item.Select(0);
_hierarchyTree.ScrollToItem(item);
_controller.SelectNode(item);
SyncInspector(item);
}
private void OnAddNodePressed()
{
if (!TryGetSelectedModScene(out var scene))
{
ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"), true);
return;
}
var typeName = CreatableTypes[System.Math.Max(_newNodeTypeDropdown.Selected, 0)];
var parent = ResolveAddParent();
if (parent == null)
{
ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"), true);
return;
}
var created = _controller.CreateUniqueChild(parent, typeName);
if (created == null)
{
ShowOutput(ToolUi.Tr("tools/scene_builder/err_add_failed"), true);
GameLogger.Log("SCENE_BUILDER", $"ClassDB.Instantiate failed for '{typeName}'", LogLevel.Error);
return;
}
var props = BuildInitialNodeProps(created, parent);
var parentPath = _controller.GetModNodePath(parent);
if (!TscnTextSurgery.TryInsertNodeSection(_tscnEditor.Text, parentPath, created.Name, typeName, props, out var updated))
{
created.GetParent().RemoveChild(created);
created.QueueFree();
ShowOutput(ToolUi.Tr("tools/scene_builder/err_add_parent_missing"), true);
GameLogger.Log("SCENE_BUILDER",
$"node insert rejected: parent section '{parentPath}' absent from file", LogLevel.Warning);
return;
}
_tscnEditor.Text = updated;
RefreshFileSnapshot();
KickAutosave();
_controller.BuildHierarchy(_hierarchyTree);
var item = _controller.GetItemForNode(created);
if (item != null)
SelectTreeItem(item);
}
/// <summary>Selected editable mod node, falling back to the mod scene root.</summary>
private Node ResolveAddParent()
{
if (_selectedNode != null && _controller.IsEditableModNode(_selectedNode))
return _selectedNode;
return _controller.GetModRoot();
}
/// <summary>
/// Minimal viable layout written with a fresh node: container children get
/// container mode; free Controls get an anchors-mode box of visible size so
/// a new node is immediately grabbable by the overlay handles.
/// </summary>
private static List<KeyValuePair<string, string>> BuildInitialNodeProps(Node created, Node parent)
{
var props = new List<KeyValuePair<string, string>>();
if (created is Control)
{
if (parent is Container)
props.Add(new KeyValuePair<string, string>("layout_mode", "2"));
else
{
props.Add(new KeyValuePair<string, string>("layout_mode", "1"));
props.Add(new KeyValuePair<string, string>("offset_right", "120.0"));
props.Add(new KeyValuePair<string, string>("offset_bottom", "40.0"));
}
}
return props;
}
private void OnDeleteNodePressed()
{
var node = _selectedNode;
if (node == null || !_controller.IsEditableModNode(node))
{
ShowOutput(ToolUi.Tr("tools/scene_builder/err_del_none"), true);
return;
}
if (node == _controller.GetModRoot())
{
ShowOutput(ToolUi.Tr("tools/scene_builder/err_del_root"), true);
return;
}
var path = _controller.GetModNodePath(node);
if (!_controller.DeleteModNode(node))
{
ShowOutput(ToolUi.Tr("tools/scene_builder/err_del_none"), true);
return;
}
if (TscnTextSurgery.TryRemoveNodeSubtree(_tscnEditor.Text, path, out var updated))
{
_tscnEditor.Text = updated;
RefreshFileSnapshot();
KickAutosave();
}
else
{
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_missing_section"), true);
GameLogger.Log("SCENE_BUILDER",
$"subtree remove failed: section '{path}' absent from file", LogLevel.Warning);
}
_selectedNode = null;
_selectionOverlay.Track(null);
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));
_controller.BuildHierarchy(_hierarchyTree);
}
/// <summary>
/// Applies one overlay drag result: patches every key of the batch into
/// the tscn text, refreshes the file snapshot, inspector rows and handles,
/// then arms the auto-save.
/// </summary>
private void OnOverlayBatchCommitted(Node node, List<KeyValuePair<string, Variant>> batch)
{
if (!TryGetSelectedModScene(out var scene))
{
ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"), true);
return;
}
var nodePath = _controller.GetModNodePath(node);
if (nodePath == null)
{
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_readonly"), true);
GameLogger.Log("SCENE_BUILDER", $"batch rejected: '{node.Name}' is not an editable mod node", LogLevel.Warning);
return;
}
var text = _tscnEditor.Text;
foreach (var pair in batch)
{
var serialized = TscnValueFormatter.Format(pair.Value);
if (serialized == null)
{
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_bad_value"), true);
GameLogger.Log("SCENE_BUILDER", $"unserializable value for '{pair.Key}'", LogLevel.Warning);
continue;
}
if (!TscnTextSurgery.TryUpsertProperty(text, nodePath, pair.Key, serialized, out text))
{
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_missing_section"), true);
GameLogger.Log("SCENE_BUILDER",
$"section not found: path='{nodePath}' key='{pair.Key}'", LogLevel.Warning);
}
}
_tscnEditor.Text = text;
RefreshFileSnapshot();
RebuildInspectorFor(node);
_selectionOverlay.Refresh();
KickAutosave();
}
/// <summary>
/// Write-back pipeline for one inspector edit: serialize the Variant into
/// tscn syntax and patch the corresponding property line of the node's
/// section in the source editor. Every failure is reported loudly —
/// silent drops previously made edits look like they "did nothing".
/// </summary>
private void OnInspectorPropertyEdited(Node node, string key, Variant value)
{
if (!TryGetSelectedModScene(out var scene))
{
ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"), true);
return;
}
var serialized = TscnValueFormatter.Format(value);
if (serialized == null)
{
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_bad_value"), true);
GameLogger.Log("SCENE_BUILDER", $"unserializable value for '{key}'", LogLevel.Warning);
return;
}
var nodePath = _controller.GetModNodePath(node);
if (nodePath == null)
{
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_readonly"), true);
GameLogger.Log("SCENE_BUILDER", $"edit rejected: '{node.Name}' is not an editable mod node", LogLevel.Warning);
return;
}
if (!TscnTextSurgery.TryUpsertProperty(_tscnEditor.Text, nodePath, key, serialized, out var updated))
{
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_missing_section"), true);
GameLogger.Log("SCENE_BUILDER",
$"section not found: path='{nodePath}' key='{key}'", LogLevel.Warning);
return;
}
_tscnEditor.Text = updated;
RefreshFileSnapshot();
_selectionOverlay.Refresh();
KickAutosave();
}
/// <summary>Full inspector resync after batch writes (drag finished, focus loss is fine).</summary>
private void RebuildInspectorFor(Node node)
{
if (_selectedNode != node || !_controller.IsEditableModNode(node))
return;
var nodePath = _controller.GetModNodePath(node);
_inspector.ShowNode(node, _fileProps.GetNodeProperties(nodePath));
}
private void ShowOutput(string text, bool isError)
{
SetStatus(text, isError ? ToolStatusKind.Error : ToolStatusKind.Ok);
}
}
}
-532
View File
@@ -1,532 +0,0 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Godot-editor-style manipulation overlay drawn over the scene builder
/// preview: 8 resize markers (corners + edges, editing offset_* respecting
/// anchors) and 4 draggable anchor points with crosshair lines (editing
/// anchor_* while preserving the visual rectangle). One input surface owns
/// the whole interaction — markers are purely visual (input-ignore) and the
/// overlay itself hit-tests them in its <c>GuiInput</c> handler — because
/// per-marker Controls cannot capture motion outside their small rects and
/// virtual overrides (_Process/_GuiInput) do not dispatch in mod assemblies.
/// Pressing inside the selected rectangle and dragging moves the node (all
/// four offsets shift together); a press that does not move is a pick.
/// Live edits go straight onto the node during the drag; the accumulated
/// key/value batch is emitted once on mouse release via
/// <see cref="BatchCommitted"/> so text write-back happens in one pass.
/// Coordinates map between the game-resolution canvas of the SubViewport
/// and this overlay (which shares the viewport container's rectangle) by a
/// plain scale.
/// </summary>
public partial class SelectionOverlay : ModWidget
{
/// <summary>Left press that did not grab any marker: (position in viewport canvas coordinates).</summary>
public event Action<Vector2> PickRequested;
/// <summary>Raised once per finished drag: (node, ordered key/value batch to persist).</summary>
public event Action<Node, List<KeyValuePair<string, Variant>>> BatchCommitted;
private const float HandleHalf = 5f;
private const float HandleInset = 8f;
private const float AnchorDotHalf = 3.5f;
private const float AnchorLineLength = 44f;
private const float AnchorHitRadius = 10f;
private const float HandleHitPadding = 5f;
private const float BorderThickness = 1.5f;
// Resize marker indices: 0=TL corner, 1=top edge, 2=TR, 3=left, 4=right, 5=BL, 6=bottom, 7=BR.
// Anchor dot indices: 0=TL, 1=TR, 2=BL, 3=BR of the anchor quad.
private const int ResizeMarkerCount = 8;
private const int AnchorDotCount = 4;
private const float MoveThreshold = 4f;
private enum DragKind { None, Resize, Anchor, PendingMove, Move }
private struct Marker
{
public Rect2 Rect;
public bool IsAnchor;
public int Index;
}
private SubViewport _viewport;
private Control _containerArea;
private Vector2 _canvasSize;
private Vector2 _pressPoint;
private readonly float[] _moveStartOffsets = new float[4];
private Control _selected;
private readonly List<Marker> _markers = new();
private readonly List<ColorRect> _borderRects = new();
private readonly List<ColorRect> _handleRects = new();
private readonly List<ColorRect> _anchorLines = new();
private readonly List<ColorRect> _anchorDots = new();
private DragKind _drag = DragKind.None;
private int _dragIndex;
private readonly Dictionary<string, double> _pendingValues = new();
/// <summary>Binds the overlay to the preview pipeline; call once before use.</summary>
public void Setup(SubViewport viewport, Control containerArea, Vector2I canvasSize)
{
_viewport = viewport;
_containerArea = containerArea;
_canvasSize = canvasSize;
_containerArea.Resized += Refresh;
Refresh();
}
/// <summary>Selects the node to manipulate; hides markers for null/non-manipulable nodes.</summary>
public void Track(Control selected)
{
_selected = selected;
_drag = DragKind.None;
_pendingValues.Clear();
Refresh();
}
protected override void Construct()
{
MouseFilter = Control.MouseFilterEnum.Stop;
Resized += Refresh;
for (var i = 0; i < 4; i++)
_borderRects.Add(CreateMarker(UiTokens.Alpha(UiTokens.AccentBright, 0.95f)));
for (var i = 0; i < ResizeMarkerCount; i++)
_handleRects.Add(CreateMarker(UiTokens.Accent));
for (var i = 0; i < AnchorDotCount * 2; i++)
_anchorLines.Add(CreateMarker(UiTokens.Alpha(UiTokens.Info, 0.55f)));
for (var i = 0; i < AnchorDotCount; i++)
_anchorDots.Add(CreateMarker(UiTokens.Info));
GuiInput += OnOverlayGui;
}
private ColorRect CreateMarker(Color color)
{
var rect = new ColorRect { Color = color, MouseFilter = Control.MouseFilterEnum.Ignore };
AddChild(rect);
return rect;
}
/// <summary>Recomputes all marker geometry from the tracked node's current state.</summary>
public void Refresh()
{
var control = _selected as Control;
var parent = control?.GetParent() as Control;
var manipulable = control != null
&& GodotObject.IsInstanceValid(control)
&& parent != null
&& !(parent is Container);
if (!manipulable || _viewport == null || _containerArea == null)
{
SetVisibleAll(false);
_markers.Clear();
return;
}
UpdateMarkers(control);
UpdateMarkerControls();
}
private void UpdateMarkers(Control control)
{
_markers.Clear();
var rect = CanvasRectToOverlay(control.GetGlobalRect());
for (var i = 0; i < ResizeMarkerCount; i++)
{
var center = GetHandleCenter(rect, i);
_markers.Add(new Marker
{
Rect = new Rect2(center - new Vector2(HandleHalf + HandleHitPadding, HandleHalf + HandleHitPadding),
new Vector2(HandleHalf + HandleHitPadding, HandleHalf + HandleHitPadding) * 2f),
IsAnchor = false,
Index = i
});
}
var parent = (Control)control.GetParent();
var parentOrigin = parent.GetGlobalRect().Position;
for (var i = 0; i < AnchorDotCount; i++)
{
var anchorPoint = GetAnchorCorner(parentOrigin, parent.Size,
control.AnchorLeft, control.AnchorTop, control.AnchorRight, control.AnchorBottom, i);
var center = CanvasToOverlay(anchorPoint);
_markers.Add(new Marker
{
Rect = new Rect2(center - new Vector2(AnchorHitRadius, AnchorHitRadius),
new Vector2(AnchorHitRadius, AnchorHitRadius) * 2f),
IsAnchor = true,
Index = i
});
}
}
private void UpdateMarkerControls()
{
var anyVisible = _markers.Count > 0;
var rect = anyVisible ? CanvasRectToOverlay(((Control)_selected).GetGlobalRect()) : new Rect2();
for (var i = 0; i < _borderRects.Count; i++)
{
var bar = _borderRects[i];
bar.Visible = anyVisible;
switch (i)
{
case 0: bar.Position = rect.Position - new Vector2(BorderThickness, BorderThickness); bar.Size = new Vector2(rect.Size.X + BorderThickness * 2f, BorderThickness); break;
case 1: bar.Position = new Vector2(rect.Position.X - BorderThickness, rect.End.Y); bar.Size = new Vector2(rect.Size.X + BorderThickness * 2f, BorderThickness); break;
case 2: bar.Position = new Vector2(rect.Position.X - BorderThickness, rect.Position.Y); bar.Size = new Vector2(BorderThickness, rect.Size.Y); break;
case 3: bar.Position = new Vector2(rect.End.X, rect.Position.Y); bar.Size = new Vector2(BorderThickness, rect.Size.Y); break;
}
}
for (var i = 0; i < _handleRects.Count; i++)
{
var handle = _handleRects[i];
handle.Visible = anyVisible;
if (anyVisible)
handle.Position = GetHandleCenter(rect, i) - new Vector2(HandleHalf, HandleHalf);
handle.Size = new Vector2(HandleHalf * 2f, HandleHalf * 2f);
}
for (var i = 0; i < AnchorDotCount; i++)
{
var markerIdx = ResizeMarkerCount + i;
var has = anyVisible && markerIdx < _markers.Count;
var dot = _anchorDots[i];
var hLine = _anchorLines[i * 2];
var vLine = _anchorLines[i * 2 + 1];
dot.Visible = has;
hLine.Visible = has;
vLine.Visible = has;
if (!has)
continue;
var center = _markers[markerIdx].Rect.GetCenter();
dot.Position = center - new Vector2(AnchorDotHalf, AnchorDotHalf);
dot.Size = new Vector2(AnchorDotHalf * 2f, AnchorDotHalf * 2f);
hLine.Position = new Vector2(center.X - AnchorLineLength / 2f, center.Y - 0.5f);
hLine.Size = new Vector2(AnchorLineLength, 1f);
vLine.Position = new Vector2(center.X - 0.5f, center.Y - AnchorLineLength / 2f);
vLine.Size = new Vector2(1f, AnchorLineLength);
}
}
private void SetVisibleAll(bool visible)
{
foreach (var rect in _borderRects) rect.Visible = visible;
foreach (var rect in _handleRects) rect.Visible = visible;
foreach (var rect in _anchorLines) rect.Visible = visible;
foreach (var rect in _anchorDots) rect.Visible = visible;
}
private static Vector2 GetHandleCenter(Rect2 rect, int index)
{
var inset = HandleHalf + 3f;
switch (index)
{
case 0: return rect.Position + new Vector2(inset, inset);
case 1: return new Vector2(rect.GetCenter().X, rect.Position.Y);
case 2: return new Vector2(rect.End.X - inset, rect.Position.Y + inset);
case 3: return new Vector2(rect.Position.X, rect.GetCenter().Y);
case 4: return new Vector2(rect.End.X, rect.GetCenter().Y);
case 5: return new Vector2(rect.Position.X + inset, rect.End.Y - inset);
case 6: return new Vector2(rect.GetCenter().X, rect.End.Y);
default: return rect.End - new Vector2(inset, inset);
}
}
private static Vector2 GetAnchorCorner(Vector2 parentOrigin, Vector2 parentSize,
float aLeft, float aTop, float aRight, float aBottom, int index)
{
var x = index % 2 == 0 ? aLeft : aRight;
var y = index < 2 ? aTop : aBottom;
return parentOrigin + new Vector2(x * parentSize.X, y * parentSize.Y);
}
private void OnOverlayGui(InputEvent evt)
{
if (evt is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
OnPress(mb.Position);
else if (_drag == DragKind.PendingMove)
{
_drag = DragKind.None;
PickRequested?.Invoke(LocalToCanvas(mb.Position));
}
else if (_drag != DragKind.None)
FinishDrag();
return;
}
if (evt is InputEventMouseMotion motion)
{
if (_drag == DragKind.PendingMove && (motion.Position - _pressPoint).Length() >= MoveThreshold)
_drag = DragKind.Move;
if (_drag is DragKind.Resize or DragKind.Anchor or DragKind.Move)
{
ApplyDrag(motion.Position);
Refresh();
}
else
{
UpdateHoverCursor(motion.Position);
}
}
}
private void OnPress(Vector2 localPoint)
{
// Anchor dots win over coinciding resize markers (full-rect nodes keep
// their corners resizable because resize centers sit inset).
for (var i = 0; i < _markers.Count; i++)
{
if (!_markers[i].IsAnchor || !_markers[i].Rect.HasPoint(localPoint))
continue;
BeginDrag(DragKind.Anchor, _markers[i].Index);
AcceptEvent();
return;
}
for (var i = 0; i < _markers.Count; i++)
{
if (_markers[i].IsAnchor || !_markers[i].Rect.HasPoint(localPoint))
continue;
BeginDrag(DragKind.Resize, _markers[i].Index);
AcceptEvent();
return;
}
if (_markers.Count > 0 && _selected is Control control
&& CanvasRectToOverlay(control.GetGlobalRect()).HasPoint(localPoint))
{
BeginDrag(DragKind.PendingMove, 0);
_pressPoint = localPoint;
_moveStartOffsets[0] = control.OffsetLeft;
_moveStartOffsets[1] = control.OffsetTop;
_moveStartOffsets[2] = control.OffsetRight;
_moveStartOffsets[3] = control.OffsetBottom;
AcceptEvent();
return;
}
PickRequested?.Invoke(LocalToCanvas(localPoint));
}
private void BeginDrag(DragKind kind, int index)
{
_drag = kind;
_dragIndex = index;
_pendingValues.Clear();
}
private void ApplyDrag(Vector2 localPoint)
{
if (!(_selected is Control control) || !(control.GetParent() is Control parent))
return;
var mouseInParent = LocalToCanvas(localPoint) - parent.GetGlobalRect().Position;
var parentSize = parent.Size;
if (_drag == DragKind.Resize)
{
ApplyResizeEdge(control, parentSize, mouseInParent, _dragIndex);
return;
}
if (_drag == DragKind.Move)
{
var delta = LocalToCanvas(localPoint) - LocalToCanvas(_pressPoint);
var dx = Mathf.Round(delta.X);
var dy = Mathf.Round(delta.Y);
StorePair(control, "offset_left", _moveStartOffsets[0] + dx);
StorePair(control, "offset_top", _moveStartOffsets[1] + dy);
StorePair(control, "offset_right", _moveStartOffsets[2] + dx);
StorePair(control, "offset_bottom", _moveStartOffsets[3] + dy);
return;
}
ApplyAnchorDrag(control, parentSize, mouseInParent, _dragIndex);
}
private void ApplyResizeEdge(Control control, Vector2 parentSize, Vector2 mouseInParent, int index)
{
// Bit layout of the index: 0=TL,1=T,2=TR,3=L,4=R,5=BL,6=B,7=BR.
var topSide = index == 0 || index == 1 || index == 2;
var bottomSide = index == 5 || index == 6 || index == 7;
var leftSide = index == 0 || index == 3 || index == 5;
var rightSide = index == 2 || index == 4 || index == 7;
if (topSide)
StorePair(control, "offset_top", mouseInParent.Y - control.AnchorTop * parentSize.Y);
if (bottomSide)
StorePair(control, "offset_bottom", mouseInParent.Y - control.AnchorBottom * parentSize.Y);
if (leftSide)
StorePair(control, "offset_left", mouseInParent.X - control.AnchorLeft * parentSize.X);
if (rightSide)
StorePair(control, "offset_right", mouseInParent.X - control.AnchorRight * parentSize.X);
}
private void ApplyAnchorDrag(Control control, Vector2 parentSize, Vector2 mouseInParent, int index)
{
var moveRight = index % 2 == 1;
var moveBottom = index >= 2;
var newAnchorX = Mathf.Clamp(mouseInParent.X / Mathf.Max(parentSize.X, 1f), 0f, 1f);
var newAnchorY = Mathf.Clamp(mouseInParent.Y / Mathf.Max(parentSize.Y, 1f), 0f, 1f);
// Preserve the visual rectangle: shift the matching offset by the
// anchor delta so only the anchor moves, exactly like the Godot editor.
var oldAnchorX = moveRight ? control.AnchorRight : control.AnchorLeft;
var oldOffsetX = moveRight ? control.OffsetRight : control.OffsetLeft;
var newOffsetX = oldOffsetX + (oldAnchorX - newAnchorX) * parentSize.X;
var oldAnchorY = moveBottom ? control.AnchorBottom : control.AnchorTop;
var oldOffsetY = moveBottom ? control.OffsetBottom : control.OffsetTop;
var newOffsetY = oldOffsetY + (oldAnchorY - newAnchorY) * parentSize.Y;
if (moveRight)
{
StorePair(control, "anchor_right", newAnchorX);
StorePair(control, "offset_right", newOffsetX);
}
else
{
StorePair(control, "anchor_left", newAnchorX);
StorePair(control, "offset_left", newOffsetX);
}
if (moveBottom)
{
StorePair(control, "anchor_bottom", newAnchorY);
StorePair(control, "offset_bottom", newOffsetY);
}
else
{
StorePair(control, "anchor_top", newAnchorY);
StorePair(control, "offset_top", newOffsetY);
}
}
private void StorePair(Control control, string key, double value)
{
_pendingValues[key] = value;
control.Set(key, value);
}
private void FinishDrag()
{
var node = _selected;
_drag = DragKind.None;
if (node == null || _pendingValues.Count == 0)
{
_pendingValues.Clear();
return;
}
// Canonical tscn ordering: layout gate, anchors, offsets.
var batch = new List<KeyValuePair<string, Variant>>();
if (_pendingValues.ContainsKey("anchor_left") && ReadLayoutMode(node) != 1)
batch.Add(new KeyValuePair<string, Variant>("layout_mode", Variant.From(1L)));
foreach (var key in new[] { "anchor_left", "anchor_top", "anchor_right", "anchor_bottom" })
if (_pendingValues.TryGetValue(key, out var v))
batch.Add(new KeyValuePair<string, Variant>(key, Variant.From(v)));
foreach (var key in new[] { "offset_left", "offset_top", "offset_right", "offset_bottom" })
if (_pendingValues.TryGetValue(key, out var v))
batch.Add(new KeyValuePair<string, Variant>(key, Variant.From(v)));
_pendingValues.Clear();
BatchCommitted?.Invoke(node, batch);
}
private static long ReadLayoutMode(Node node)
{
var v = node.Get("layout_mode");
if (v.VariantType == Variant.Type.Int)
return v.AsInt64();
if (v.VariantType == Variant.Type.Float)
return (long)v.AsDouble();
return 0;
}
private void UpdateHoverCursor(Vector2 localPoint)
{
for (var i = 0; i < _markers.Count; i++)
{
if (!_markers[i].Rect.HasPoint(localPoint))
continue;
if (_markers[i].IsAnchor)
{
MouseDefaultCursorShape = Control.CursorShape.Move;
return;
}
switch (_markers[i].Index)
{
case 0:
case 7:
MouseDefaultCursorShape = Control.CursorShape.Fdiagsize;
return;
case 2:
case 5:
MouseDefaultCursorShape = Control.CursorShape.Bdiagsize;
return;
case 1:
case 6:
MouseDefaultCursorShape = Control.CursorShape.Vsize;
return;
default:
MouseDefaultCursorShape = Control.CursorShape.Hsize;
return;
}
}
MouseDefaultCursorShape = _markers.Count > 0 && _selected is Control control
&& CanvasRectToOverlay(control.GetGlobalRect()).HasPoint(localPoint)
? Control.CursorShape.Move
: Control.CursorShape.Arrow;
}
// ── coordinate mapping ──────────────────────────────────────────
private Vector2 ComputeStretchScale()
{
return new Vector2(
_canvasSize.X > 0 ? _containerArea.Size.X / _canvasSize.X : 1f,
_canvasSize.Y > 0 ? _containerArea.Size.Y / _canvasSize.Y : 1f);
}
private Vector2 CanvasToOverlay(Vector2 canvasPoint)
{
return canvasPoint * ComputeStretchScale();
}
private Rect2 CanvasRectToOverlay(Rect2 canvasRect)
{
var scale = ComputeStretchScale();
return new Rect2(canvasRect.Position * scale, canvasRect.Size * scale);
}
private Vector2 LocalToCanvas(Vector2 localPoint)
{
var scale = ComputeStretchScale();
return new Vector2(
scale.X > 0.0001f ? localPoint.X / scale.X : localPoint.X,
scale.Y > 0.0001f ? localPoint.Y / scale.Y : localPoint.Y);
}
}
}
-58
View File
@@ -1,58 +0,0 @@
using System.Collections.Generic;
using Cthangover.Core.Scenes.TscnParsing;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Snapshot of the raw node properties currently stored in a .tscn source
/// text: node address → property key → unparsed literal. The inspector uses
/// it to show what the FILE contains rather than post-layout live values —
/// after Godot lays out a control its position/size reflect computed layout,
/// which previously made anchored nodes look like they had none. Rebuild via
/// <see cref="Build"/> whenever the source text changes. Section addressing
/// matches <see cref="TscnTextSurgery"/> through <see cref="TscnNodeAddressing"/>.
/// </summary>
public class TscnFileProps
{
private readonly Dictionary<string, Dictionary<string, string>> _nodes = new();
/// <summary>Parses tscn source into the snapshot; empty snapshot for null/empty text.</summary>
public static TscnFileProps Build(string text)
{
var result = new TscnFileProps();
if (string.IsNullOrEmpty(text))
return result;
Dictionary<string, string> current = null;
foreach (var token in new TscnLexer().Tokenize(text))
{
if (token.Type == TscnTokenType.SectionHeader)
{
current = null;
if (token.SectionName == "node")
{
var path = TscnNodeAddressing.ComputePath(token.SectionAttrs);
if (path != null && !result._nodes.ContainsKey(path))
{
current = new Dictionary<string, string>();
result._nodes[path] = current;
}
}
}
else if (token.Type == TscnTokenType.Property && current != null)
{
if (!current.ContainsKey(token.PropertyKey))
current[token.PropertyKey] = token.PropertyValue;
}
}
return result;
}
/// <summary>Raw property literals of one node; null when the section is absent from the file.</summary>
public IReadOnlyDictionary<string, string> GetNodeProperties(string nodePath)
{
return _nodes.TryGetValue(nodePath ?? "", out var props) ? props : null;
}
}
}
-109
View File
@@ -1,109 +0,0 @@
using System.Globalization;
using Godot;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Inverse of <see cref="TscnValueFormatter"/>: parses raw .tscn property
/// literals (as stored in the file, e.g. "0.5", "Vector2(160, 50)",
/// "\"Go\"") into display values for inspector widgets. Returns false for
/// anything it cannot represent — object references like ExtResource("..."),
/// sub-resources — so callers fall back to live-node values instead of
/// showing garbage. Pure string math, safe outside the engine runtime.
/// </summary>
public static class TscnLiteralReader
{
/// <summary>Parses a bare .tscn float literal; constructors and references return false.</summary>
public static bool TryReadFloat(string raw, out double value)
{
value = 0d;
if (raw == null || raw.Contains('('))
return false;
return double.TryParse(raw.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
}
/// <summary>Parses a bare .tscn integer literal; floats and constructors return false.</summary>
public static bool TryReadLong(string raw, out long value)
{
value = 0;
if (raw == null || raw.Contains('.') || raw.Contains('('))
return false;
return long.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
}
/// <summary>Parses a .tscn boolean literal (true/false).</summary>
public static bool TryReadBool(string raw, out bool value)
{
var t = raw?.Trim();
if (t == "true") { value = true; return true; }
if (t == "false") { value = false; return true; }
value = false;
return false;
}
/// <summary>Reads a quoted .tscn string literal; null when the raw text is not a quoted string.</summary>
public static string ReadString(string raw)
{
if (raw == null)
return null;
var t = raw.Trim();
if (t.Length < 2 || t[0] != '"' || t[t.Length - 1] != '"')
return null;
var body = t.Substring(1, t.Length - 2);
return body
.Replace("\\n", "\n")
.Replace("\\r", "\r")
.Replace("\\t", "\t")
.Replace("\\\"", "\"")
.Replace("\\\\", "\\");
}
/// <summary>Parses a <c>Vector2(x, y)</c> constructor literal.</summary>
public static bool TryReadVector2(string raw, out Vector2 value)
{
value = Vector2.Zero;
if (!TryReadConstructor(raw, "Vector2", out var parts) || parts.Length != 2)
return false;
if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x) ||
!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
return false;
value = new Vector2((float)x, (float)y);
return true;
}
/// <summary>Parses a <c>Color(r, g, b[, a])</c> constructor literal.</summary>
public static bool TryReadColor(string raw, out Color value)
{
value = Colors.White;
if (!TryReadConstructor(raw, "Color", out var parts) || parts.Length is not (3 or 4))
return false;
if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var r) ||
!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var g) ||
!double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var b))
return false;
var a = 1d;
if (parts.Length == 4 && !double.TryParse(parts[3], NumberStyles.Float, CultureInfo.InvariantCulture, out a))
return false;
value = new Color((float)r, (float)g, (float)b, (float)a);
return true;
}
private static bool TryReadConstructor(string raw, string typeName, out string[] args)
{
args = null;
if (raw == null)
return false;
var t = raw.Trim();
var prefix = typeName + "(";
if (!t.StartsWith(prefix, System.StringComparison.Ordinal) || !t.EndsWith(")", System.StringComparison.Ordinal))
return false;
var body = t.Substring(prefix.Length, t.Length - prefix.Length - 1);
args = body.Split(',');
for (var i = 0; i < args.Length; i++)
args[i] = args[i].Trim();
return true;
}
}
}
-25
View File
@@ -1,25 +0,0 @@
using System.Collections.Generic;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Single source of truth for mapping a <c>[node]</c> section's attributes to
/// the address path used across the scene builder tool (inspector selection,
/// tscn text surgery, file property snapshots). Both <see cref="TscnTextSurgery"/>
/// and <see cref="TscnFileProps"/> must resolve sections identically, so the
/// rule lives here and nowhere else: no parent attribute = scene root (""),
/// parent="." = direct child of root, otherwise parent path + "/" + name.
/// </summary>
internal static class TscnNodeAddressing
{
/// <summary>Computes the node address from section attributes; null when attributes lack a usable name.</summary>
public static string ComputePath(Dictionary<string, string> attrs)
{
if (attrs == null || !attrs.TryGetValue("name", out var name) || string.IsNullOrEmpty(name))
return null;
if (!attrs.TryGetValue("parent", out var parent))
return "";
return parent == "." ? name : parent + "/" + name;
}
}
}
-283
View File
@@ -1,283 +0,0 @@
using System.Collections.Generic;
using Cthangover.Core.Scenes.TscnParsing;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Targeted line edits of raw .tscn source without a full re-serialization:
/// the file is tokenized with <see cref="TscnLexer"/> (which reports 1-based
/// line numbers), the affected <c>[node]</c> sections are located by their
/// Godot address path (via <see cref="TscnNodeAddressing"/>, shared with
/// <see cref="TscnFileProps"/>), and only the involved lines are touched.
/// Everything outside them — comments, blank lines, formatting — survives.
/// Beyond property upsert/removal it can insert a whole new node section
/// right after its parent's block (parent-before-child order is what the
/// runtime builder relies on) and delete a node subtree together with any
/// dangling [connection] entries. Newly inserted properties follow Godot's
/// layout convention (<c>layout_mode</c> → anchors → grow → offsets → rest)
/// so the builder applies them in a valid sequence.
/// </summary>
public static class TscnTextSurgery
{
/// <summary>
/// Inserts or replaces one property line inside the given node's section.
/// nodePath uses .tscn addressing relative to the scene root ("", "Panel", "Panel/Btn");
/// "" addresses the root node section itself. Returns false when the section
/// does not exist in this file (e.g. override sections for instanced sub-scenes).
/// </summary>
public static bool TryUpsertProperty(string text, string nodePath, string key, string tscnValue, out string newText)
{
newText = text;
if (text == null || string.IsNullOrEmpty(key))
return false;
var lines = SplitLines(text, out var crlf);
var tokens = new TscnLexer().Tokenize(text);
var blocks = CollectBlocks(tokens);
if (!TryFindBlock(blocks, nodePath, out var block) || !block.IsNode)
return false;
var replaceLine = -1;
var insertAfterLine = block.Start;
var group = GroupOrder(key);
foreach (var token in tokens)
{
if (token.Type != TscnTokenType.Property || token.LineNumber <= block.Start || token.LineNumber > block.End)
continue;
if (token.PropertyKey == key)
{
replaceLine = token.LineNumber;
break;
}
if (GroupOrder(token.PropertyKey) <= group)
insertAfterLine = token.LineNumber;
}
var newLine = key + " = " + tscnValue;
if (replaceLine >= 0)
lines[replaceLine - 1] = newLine;
else
lines.Insert(insertAfterLine, newLine);
newText = JoinLines(lines, crlf);
return true;
}
/// <summary>
/// Removes one property line from the given node's section. Returns false
/// when the section or the property line is not present.
/// </summary>
public static bool TryRemoveProperty(string text, string nodePath, string key, out string newText)
{
newText = text;
if (text == null || string.IsNullOrEmpty(key))
return false;
var lines = SplitLines(text, out var crlf);
var tokens = new TscnLexer().Tokenize(text);
var blocks = CollectBlocks(tokens);
if (!TryFindBlock(blocks, nodePath, out var block) || !block.IsNode)
return false;
foreach (var token in tokens)
{
if (token.Type != TscnTokenType.Property || token.LineNumber <= block.Start || token.LineNumber > block.End)
continue;
if (token.PropertyKey != key)
continue;
lines.RemoveAt(token.LineNumber - 1);
newText = JoinLines(lines, crlf);
return true;
}
return false;
}
/// <summary>
/// Inserts a complete <c>[node]</c> section directly after its parent's
/// block so the runtime builder meets the parent first. parentPath "" adds
/// a child of the scene root (parent="."). Returns false when the parent
/// section is absent from this file or name/type contain illegal characters.
/// </summary>
public static bool TryInsertNodeSection(string text, string parentPath, string name, string type,
List<KeyValuePair<string, string>> properties, out string newText)
{
newText = text;
if (text == null || string.IsNullOrEmpty(name) || string.IsNullOrEmpty(type))
return false;
if (name.Contains('"') || name.Contains('[') || type.Contains('"') || type.Contains('['))
return false;
var lines = SplitLines(text, out var crlf);
var blocks = CollectBlocks(new TscnLexer().Tokenize(text));
if (!TryFindBlock(blocks, parentPath ?? "", out var parentBlock) || !parentBlock.IsNode)
return false;
var parentAttr = string.IsNullOrEmpty(parentPath) ? "." : parentPath;
var newBlock = new List<string> { "[node name=\"" + name + "\" type=\"" + type + "\" parent=\"" + parentAttr + "\"]" };
if (properties != null)
{
foreach (var pair in properties)
newBlock.Add(pair.Key + " = " + pair.Value);
}
newBlock.Add("");
// 1-based End line occupies index End-1; inserting at index End places
// the new block between the parent's last line and whatever follows.
lines.InsertRange(parentBlock.End, newBlock);
newText = JoinLines(lines, crlf);
return true;
}
/// <summary>
/// Deletes the node's own section, every descendant section (path prefix)
/// and [connection] headers pointing into the subtree. Trailing blank lines
/// of each removed range are swallowed to avoid blank-line buildup.
/// Returns false when no matching section exists.
/// </summary>
public static bool TryRemoveNodeSubtree(string text, string nodePath, out string newText)
{
newText = text;
if (text == null || string.IsNullOrEmpty(nodePath))
return false;
var lines = SplitLines(text, out var crlf);
var blocks = CollectBlocks(new TscnLexer().Tokenize(text));
var subtreePrefix = nodePath + "/";
// (startIndex, endExclusiveIndex) removal ranges in descending order later.
var ranges = new List<(int StartIdx, int EndIdx)>();
var matched = false;
foreach (var block in blocks)
{
var inSubtree = false;
if (block.IsNode && block.Path != null &&
(block.Path == nodePath || block.Path.StartsWith(subtreePrefix, System.StringComparison.Ordinal)))
inSubtree = true;
if (block.IsConnection && block.From != null &&
(block.From == nodePath || block.From.StartsWith(subtreePrefix, System.StringComparison.Ordinal)))
inSubtree = true;
if (!inSubtree)
continue;
matched = true;
var startIdx = block.Start - 1;
var endIdx = block.End; // 0-based index of the first line AFTER the block
while (endIdx < lines.Count && lines[endIdx].Trim().Length == 0)
endIdx++;
ranges.Add((startIdx, endIdx));
}
if (!matched)
return false;
ranges.Sort((a, b) => b.StartIdx.CompareTo(a.StartIdx));
foreach (var range in ranges)
lines.RemoveRange(range.StartIdx, range.EndIdx - range.StartIdx);
newText = JoinLines(lines, crlf);
return true;
}
private struct SectionInfo
{
public bool IsNode;
public bool IsConnection;
public string Path;
public string From;
public int Start;
public int End;
}
/// <summary>Walks tokens once and produces every section block with its inclusive [Start..End] 1-based line span.</summary>
private static List<SectionInfo> CollectBlocks(List<TscnToken> tokens)
{
var result = new List<SectionInfo>();
TscnToken currentHeader = null;
var lastLine = -1;
for (var i = 0; i <= tokens.Count; i++)
{
var token = i < tokens.Count ? tokens[i] : null;
if (token == null || token.Type == TscnTokenType.SectionHeader)
{
if (currentHeader != null)
{
var attrs = currentHeader.SectionAttrs ?? new Dictionary<string, string>();
var info = new SectionInfo { Start = currentHeader.LineNumber, End = lastLine };
if (currentHeader.SectionName == "node")
{
info.IsNode = true;
info.Path = TscnNodeAddressing.ComputePath(attrs);
}
else if (currentHeader.SectionName == "connection")
{
info.IsConnection = true;
attrs.TryGetValue("from", out info.From);
}
result.Add(info);
}
if (token == null)
break;
currentHeader = token;
lastLine = token.LineNumber;
}
else
{
lastLine = token.LineNumber;
}
}
return result;
}
private static bool TryFindBlock(List<SectionInfo> blocks, string nodePath, out SectionInfo block)
{
block = default;
foreach (var candidate in blocks)
{
if (candidate.Path != null && candidate.Path == nodePath)
{
block = candidate;
return true;
}
}
return false;
}
private static int GroupOrder(string key)
{
if (key == "layout_mode")
return 0;
if (key == "anchors_preset")
return 1;
if (key.StartsWith("anchor_", System.StringComparison.Ordinal))
return 2;
if (key.StartsWith("grow_", System.StringComparison.Ordinal))
return 3;
if (key.StartsWith("offset_", System.StringComparison.Ordinal))
return 4;
return 5;
}
private static List<string> SplitLines(string text, out bool crlf)
{
crlf = text.Contains("\r\n");
var normalized = text.Replace("\r\n", "\n").Replace('\r', '\n');
return new List<string>(normalized.Split('\n'));
}
private static string JoinLines(List<string> lines, bool crlf)
{
var separator = crlf ? "\r\n" : "\n";
return string.Join(separator, lines);
}
}
}
-82
View File
@@ -1,82 +0,0 @@
using System.Globalization;
using Godot;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
/// Serializes editor-side <see cref="Variant"/> values into .tscn literal syntax
/// accepted by the runtime parser (<c>TscnPropertyParser</c>): floats always carry
/// a decimal point, strings are quoted with Godot escape sequences, composite
/// types use the <c>Type(a, b, ...)</c> constructor form. Returns null for values
/// the text format cannot represent (non-finite floats, unsupported variants) so
/// callers can skip the write-back instead of corrupting the scene file.
/// </summary>
public static class TscnValueFormatter
{
/// <summary>Formats a Variant as a .tscn property value; null when not representable.</summary>
public static string Format(Variant value)
{
switch (value.VariantType)
{
case Variant.Type.Nil:
return "null";
case Variant.Type.Bool:
return value.AsBool() ? "true" : "false";
case Variant.Type.Int:
return value.AsInt64().ToString(CultureInfo.InvariantCulture);
case Variant.Type.Float:
return FormatFloat(value.AsDouble());
case Variant.Type.String:
return Quote(value.AsString());
case Variant.Type.Vector2:
{
var v = value.AsVector2();
var x = FormatFloat(v.X);
var y = FormatFloat(v.Y);
return x == null || y == null ? null : $"Vector2({x}, {y})";
}
case Variant.Type.Color:
{
var c = value.AsColor();
var r = FormatFloat(c.R);
var g = FormatFloat(c.G);
var b = FormatFloat(c.B);
var a = FormatFloat(c.A);
return r == null || g == null || b == null || a == null ? null : $"Color({r}, {g}, {b}, {a})";
}
default:
return null;
}
}
/// <summary>
/// Formats a float in invariant culture with a guaranteed decimal point
/// ("1" → "1.0"). Values pass through float precision first because every
/// numeric property edited here originates from Godot float properties —
/// that yields the short canonical form ("0.35" instead of the double
/// expansion "0.34999999403953552"). Null for NaN/Infinity.
/// </summary>
public static string FormatFloat(double v)
{
var f = (float)v;
if (!float.IsFinite(f))
return null;
var s = f.ToString("R", CultureInfo.InvariantCulture);
if (!s.Contains('.') && !s.Contains('e') && !s.Contains('E'))
s += ".0";
return s;
}
private static string Quote(string s)
{
var escaped = s
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\n", "\\n")
.Replace("\r", "\\r")
.Replace("\t", "\\t");
return "\"" + escaped + "\"";
}
}
}
-45
View File
@@ -1,45 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Cthangover.Core.Mods;
using Cthangover.Mods.Map;
using Cthangover.Core.Utils;
namespace Cthangover.Tools.Services
{
/// <summary>
/// (De)serialisation of world map zone files (<c>mods/&lt;mod&gt;/map/*.json</c>)
/// for the map editor. File access itself goes through
/// <see cref="ModFileService"/>.
/// </summary>
public static class MapFileService
{
/// <summary>Folder of map zone files inside a mod.</summary>
public const string MapDir = "map";
/// <summary>
/// Parses the standard <c>{"Items": [...]}</c> envelope into zones.
/// Throws <see cref="JsonException"/> on malformed JSON so the editor
/// can show the error instead of silently opening an empty map.
/// </summary>
public static List<MapZone> ParseZones(string json)
{
if (string.IsNullOrWhiteSpace(json))
return new List<MapZone>();
var data = JsonSerializer.Deserialize<FileData<MapZone>>(json, JsonOptionsProvider.ReadOptions);
return data?.Items ?? new List<MapZone>();
}
/// <summary>
/// Serialises zones into the standard envelope with indentation and
/// without null fields, so hand-editing the result stays pleasant and
/// optional properties such as <c>label_pos</c> only appear when used.
/// </summary>
public static string BuildJson(IEnumerable<MapZone> zones)
{
var data = new FileData<MapZone> { Items = zones?.ToList() ?? new List<MapZone>() };
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
}
}
}
-115
View File
@@ -1,115 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Providers;
namespace Cthangover.Tools.Services
{
/// <summary>
/// File access shared by the editors: listing a mod's files by folder and
/// extension, reading them through the mod provider and writing them back.
/// Reading works for every mod (folder, zip, test-suite subdir), writing only
/// for folder-backed mods — the only ones with a real directory behind them.
/// New files are created under the provider's <c>RootPath</c> because
/// <c>GetFileSystemPath</c> reports <c>null</c> for files that do not exist
/// yet, and an editor must be able to author a brand-new file.
/// </summary>
public static class ModFileService
{
/// <summary>IDs of every loaded folder-backed (writable) mod, sorted.</summary>
public static List<string> ListWritableMods()
{
var result = new List<string>();
var manager = ModManager.Instance;
if (manager?.Mods == null)
return result;
foreach (var pair in manager.Mods)
{
if (pair.Value?.FileProvider is FolderModFileProvider)
result.Add(pair.Key);
}
result.Sort(StringComparer.OrdinalIgnoreCase);
return result;
}
/// <summary>True when the mod is folder-backed, i.e. the editors can save into it.</summary>
public static bool IsWritable(string modId)
{
return !string.IsNullOrEmpty(modId)
&& ModManager.Instance.GetMod(modId)?.FileProvider is FolderModFileProvider;
}
/// <summary>
/// Mod-relative paths of the files under <paramref name="directory"/>
/// (recursively) ending with <paramref name="extension"/>, sorted.
/// </summary>
public static List<string> ListFiles(string modId, string directory, string extension)
{
var result = new List<string>();
if (string.IsNullOrEmpty(modId))
return result;
Collect(modId, directory, extension, result);
result.Sort(StringComparer.OrdinalIgnoreCase);
return result;
}
/// <summary>Reads a file through the mod provider; <c>null</c> when missing.</summary>
public static string ReadText(string modId, string relativePath)
{
return ModManager.Instance.ReadFileText(modId, relativePath);
}
/// <summary>
/// Writes a file into a folder-backed mod, creating missing directories.
/// Returns <c>false</c> for zip/virtual mods, unknown mods and empty paths.
/// </summary>
public static bool WriteText(string modId, string relativePath, string text)
{
var absolute = GetWritablePath(modId, relativePath);
if (absolute == null)
return false;
var directory = Path.GetDirectoryName(absolute);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(absolute, text);
return true;
}
/// <summary>
/// Absolute path a file of a folder-backed mod has (or would have once
/// written); <c>null</c> for read-only mods and empty paths.
/// </summary>
public static string GetWritablePath(string modId, string relativePath)
{
if (string.IsNullOrEmpty(modId) || string.IsNullOrEmpty(relativePath))
return null;
if (ModManager.Instance.GetMod(modId)?.FileProvider is not FolderModFileProvider provider
|| string.IsNullOrEmpty(provider.RootPath))
return null;
return Path.GetFullPath(Path.Combine(provider.RootPath, relativePath.Replace('\\', '/')));
}
private static void Collect(string modId, string directory, string extension, List<string> result)
{
foreach (var entry in ModManager.Instance.ListFiles(modId, directory))
{
if (entry.EndsWith("/"))
{
Collect(modId, entry.TrimEnd('/'), extension, result);
continue;
}
if (entry.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
result.Add(entry.Replace('\\', '/'));
}
}
}
}
-121
View File
@@ -1,121 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Godot;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Static service for discovering and loading mod background resources.
/// Queries <see cref="Files.Collect"/> for files under
/// <c>"backgrounds"</c>, strips extensions to produce IDs, and loads textures
/// via <see cref="BackgroundFactory"/>. Used by the light editor, interactive
/// editor, and scenario editor to populate background selection lists and
/// render previews.
/// </summary>
public static class ModResourceService
{
/// <summary>
/// Returns all unique background IDs (filenames without extension) from
/// the <c>"backgrounds"</c> file collection across all installed mods,
/// sorted, without the <c>_albedo</c>/<c>_depth</c> lighting masks.
/// </summary>
public static List<string> GetBackgroundIds()
{
var ids = new List<string>();
var files = Files.Collect("backgrounds");
var exts = GetExt();
foreach (var kvp in files)
{
var id = kvp.Key;
var ext = Path.GetExtension(id);
if (!string.IsNullOrWhiteSpace(id) && exts.Contains(ext.ToLower()))
{
var cleanId = id.Substring(0, id.LastIndexOf('.'));
var bare = cleanId.TrimEnd('_');
if (!bare.EndsWith("_albedo") && !bare.EndsWith("_depth"))
ids.Add(cleanId);
}
}
ids.Sort(StringComparer.OrdinalIgnoreCase);
return ids;
}
private static HashSet<string> GetExt()
{
var set = new HashSet<string>();
foreach (var ext in ModConfig.Instance.TextureExtensions)
{
if(string.IsNullOrWhiteSpace(ext))
continue;
set.Add(ext.ToLower());
}
return set;
}
/// <summary>
/// Returns every texture logical name (filename without extension) from
/// all configured texture groups, sorted for stable UI lists. Used by
/// the map editor's marker icon picker: these are exactly the IDs that
/// <c>MapZone.Image</c> accepts, because resolution also keys textures
/// by basename.
/// </summary>
public static List<string> GetTextureIds()
{
var ids = new List<string>();
var textures = Textures.Collect();
if (textures == null)
return ids;
foreach (var kvp in textures)
ids.Add(kvp.Key);
ids.Sort(StringComparer.OrdinalIgnoreCase);
return ids;
}
/// <summary>
/// Texture IDs that make sense as small icons (map markers): every
/// texture except backgrounds and their <c>_depth</c>/<c>_albedo</c>
/// masks. Textures are keyed by file name, so backgrounds are excluded
/// by the file-name part of their IDs.
/// </summary>
public static List<string> GetIconIds()
{
var backgrounds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var id in GetBackgroundIds())
backgrounds.Add(id.Substring(id.LastIndexOf('/') + 1));
var result = new List<string>();
foreach (var id in GetTextureIds())
{
if (backgrounds.Contains(id) || id.EndsWith("_depth", StringComparison.OrdinalIgnoreCase)
|| id.EndsWith("_albedo", StringComparison.OrdinalIgnoreCase))
continue;
result.Add(id);
}
return result;
}
/// <summary>
/// Loads a background texture by ID via <see cref="BackgroundFactory.Instance.Get"/>.
/// Returns <c>null</c> on any error (missing resource, load failure).
/// </summary>
public static Texture2D LoadBackgroundTexture(string id)
{
try
{
return BackgroundFactory.Instance.Get(id);
}
catch
{
return null;
}
}
}
}
-360
View File
@@ -1,360 +0,0 @@
using System;
using Cthangover.Mods.Dialog.Scenarios;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Utils;
using Cthangover.Core.Mods;
namespace Cthangover.Tools.Services
{
/// <summary>Parsed metadata from a scenario file header (before the <c>---</c> separator).</summary>
public sealed class ScenarioMetadata
{
/// <summary>Target scene name for the scenario.</summary>
public string Scene { get; set; } = "-";
/// <summary>Priority level of the scenario.</summary>
public string Priority { get; set; } = "-";
/// <summary>Condition string for the scenario to activate.</summary>
public string Condition { get; set; } = "-";
}
/// <summary>Collected references extracted from scenario command lines for the info panel.</summary>
public sealed class ScenarioReferences
{
/// <summary>Scene switch targets referenced in commands.</summary>
public List<string> SwitchTargets { get; set; } = new();
/// <summary>Localisation keys found in named parameters.</summary>
public List<string> LocaleKeys { get; set; } = new();
/// <summary>Quest IDs referenced in named <c>quest_id</c> parameters.</summary>
public List<string> QuestRefs { get; set; } = new();
/// <summary>Background IDs referenced in positional parameters of background-related commands.</summary>
public List<string> BackgroundRefs { get; set; } = new();
}
/// <summary>
/// Describes a single scenario file found during scanning. The file is
/// addressed by its owning mod and mod-relative path, so both folder
/// mods and zip mods are covered; <see cref="Key"/> is the stable
/// identity used by the editor UI.
/// </summary>
public sealed class ScenarioFileInfo
{
/// <summary>Identifier of the mod that provides the file.</summary>
public string ModId { get; set; }
/// <summary>Mod-relative path, e.g. <c>scenarios/start.scenario</c>.</summary>
public string RelativePath { get; set; }
/// <summary>Filename without extension.</summary>
public string Name { get; set; }
/// <summary>Directory path relative to the mod's <c>scenarios/</c> folder.</summary>
public string RelativeDir { get; set; }
/// <summary>Stable UI identity: <c>modId:relativePath</c>.</summary>
public string Key => ScenarioFileService.MakeKey(ModId, RelativePath);
}
/// <summary>A group of scenario files within the same subdirectory under <c>scenarios/</c>.</summary>
public sealed class ScenarioGroup
{
/// <summary>Relative directory path (empty for root-level files).</summary>
public string DirectoryPath { get; set; }
/// <summary>Files in this group, sorted by name.</summary>
public List<ScenarioFileInfo> Files { get; set; } = new();
}
/// <summary>Represents all scenario files for one mod, grouped by subdirectory.</summary>
public sealed class ModScenarioTree
{
/// <summary>The mod's identifier.</summary>
public string ModId { get; set; }
/// <summary>Groups of scenario files within this mod.</summary>
public List<ScenarioGroup> Groups { get; set; } = new();
}
/// <summary>
/// Static service for discovering, reading, writing, and analysing scenario files.
/// Scans every loaded mod's <c>scenarios/</c> folder through the mod file
/// provider abstraction (<see cref="IModFileProvider"/>), so folder and zip
/// mods are discovered identically. Writing is only possible for folder mods:
/// zip mods are read-only, and <see cref="WriteAllText"/> reports that instead
/// of touching the filesystem. Used by <see cref="ScenarioEditorWindow"/> for
/// the file tree and info panel.
/// </summary>
public static class ScenarioFileService
{
private const string ScenariosDir = "scenarios";
private const string ScenarioExtension = ".scenario";
private const string KeySeparator = ":";
/// <summary>
/// Recursively scans every loaded mod's <c>scenarios/</c> folder for
/// <c>.scenario</c> files via <see cref="IModFileProvider"/>. Groups
/// results by mod ID and subdirectory. Returns an empty list when no
/// mods are loaded or none provide scenarios.
/// </summary>
public static List<ModScenarioTree> ScanScenarioFiles()
{
var result = new List<ModScenarioTree>();
var manager = ModManager.Instance;
foreach (var mod in manager.Mods.Values.OrderBy(m => m.Id, StringComparer.OrdinalIgnoreCase))
{
var files = new List<ScenarioFileInfo>();
CollectScenarioFiles(manager, mod.Id, ScenariosDir, files);
if (files.Count == 0)
continue;
var groups = files
.GroupBy(f => f.RelativeDir)
.OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.Select(g => new ScenarioGroup
{
DirectoryPath = g.Key,
Files = g.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase).ToList()
})
.ToList();
result.Add(new ModScenarioTree { ModId = mod.Id, Groups = groups });
}
return result;
}
private static void CollectScenarioFiles(ModManager manager, string modId, string directory, List<ScenarioFileInfo> result)
{
foreach (var entry in manager.ListFiles(modId, directory))
{
if (entry.EndsWith("/"))
{
CollectScenarioFiles(manager, modId, entry.TrimEnd('/'), result);
continue;
}
if (!entry.EndsWith(ScenarioExtension, StringComparison.OrdinalIgnoreCase))
continue;
var path = entry.Replace('\\', '/');
var lastSlash = path.LastIndexOf('/');
var fileName = lastSlash >= 0 ? path.Substring(lastSlash + 1) : path;
var dirPath = lastSlash >= 0 ? path.Substring(0, lastSlash) : "";
var relativeDir = "";
if (dirPath.StartsWith(ScenariosDir + "/", StringComparison.OrdinalIgnoreCase))
relativeDir = dirPath.Substring(ScenariosDir.Length + 1);
else if (!dirPath.Equals(ScenariosDir, StringComparison.OrdinalIgnoreCase))
relativeDir = dirPath;
result.Add(new ScenarioFileInfo
{
ModId = modId,
RelativePath = path,
Name = Path.GetFileNameWithoutExtension(fileName),
RelativeDir = relativeDir
});
}
}
/// <summary>Builds the stable UI identity for a scenario file.</summary>
public static string MakeKey(string modId, string relativePath) => modId + KeySeparator + relativePath;
/// <summary>Splits a key produced by <see cref="MakeKey"/> back into mod ID and mod-relative path.</summary>
public static bool TryParseKey(string key, out string modId, out string relativePath)
{
modId = null;
relativePath = null;
if (string.IsNullOrEmpty(key))
return false;
var separator = key.IndexOf(KeySeparator, StringComparison.Ordinal);
if (separator <= 0 || separator >= key.Length - 1)
return false;
modId = key.Substring(0, separator);
relativePath = key.Substring(separator + 1);
return true;
}
/// <summary>
/// Parses the header block (before first <c>---</c> line) in YAML-like
/// <c>key: value</c> format to extract scene, priority, and condition.
/// </summary>
public static ScenarioMetadata ParseMetadata(string text)
{
var meta = new ScenarioMetadata();
var metaEnd = text.IndexOf("\n---");
var metaText = metaEnd >= 0 ? text.Substring(0, metaEnd) : "";
foreach (var line in metaText.Split('\n'))
{
var t = line.Trim();
if (t.Length == 0 || t.StartsWith("#"))
continue;
var ci = t.IndexOf(':');
if (ci <= 0)
continue;
var key = t.Substring(0, ci).Trim().ToLowerInvariant();
var val = t.Substring(ci + 1).Trim();
switch (key)
{
case "scene": meta.Scene = val; break;
case "priority": meta.Priority = val; break;
case "condition": meta.Condition = val; break;
}
}
return meta;
}
/// <summary>
/// Walks the body of the scenario (after <c>---</c>) and extracts references:
/// background IDs, scene switch targets, locale keys, and quest IDs.
/// Uses <see cref="ScenarioSyntaxService.ExtractCommandParams"/> to tokenise
/// each line and <see cref="ScenarioCommandStrategyFactory"/> metadata to
/// classify positional parameters.
/// </summary>
/// <param name="text">Full scenario file text including header.</param>
public static ScenarioReferences ExtractReferences(string text)
{
var refs = new ScenarioReferences();
var metaEnd = text.IndexOf("\n---");
var content = metaEnd >= 0 ? text.Substring(metaEnd + 4) : text;
foreach (var rawLine in content.Split('\n'))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(":"))
continue;
ScenarioSyntaxService.ExtractCommandParams(line, out var cmd, out var positional, out var named);
if (string.IsNullOrEmpty(cmd))
continue;
var meta = ScenarioCommandStrategyFactory.GetReferenceMetadata(cmd);
if (meta != null && positional.Count > 0)
{
switch (meta.Positional0Kind)
{
case PositionalReferenceKind.Background:
refs.BackgroundRefs.Add(positional[0]);
break;
case PositionalReferenceKind.Scene:
refs.SwitchTargets.Add(positional[0]);
break;
}
}
if (named.TryGetValue("key", out var lk))
refs.LocaleKeys.Add(lk);
if (named.TryGetValue("quest_id", out var qid))
refs.QuestRefs.Add(qid);
}
return refs;
}
/// <summary>Reads a scenario file through its mod provider. Returns <c>null</c> when not found.</summary>
public static string ReadAllText(string modId, string relativePath)
{
return ModManager.Instance.ReadFileText(modId, relativePath);
}
/// <summary>
/// Writes a scenario file back to disk. Returns <c>false</c> when the
/// owning mod is not filesystem-backed (zip mods are read-only).
/// </summary>
public static bool WriteAllText(string modId, string relativePath, string text)
{
var absolutePath = ModManager.Instance.GetFileSystemPath(modId, relativePath);
if (string.IsNullOrEmpty(absolutePath))
return false;
File.WriteAllText(absolutePath, text);
return true;
}
/// <summary>Absolute filesystem path of a scenario file, or <c>null</c> for zip-backed mods.</summary>
public static string GetAbsolutePath(string modId, string relativePath)
{
return ModManager.Instance.GetFileSystemPath(modId, relativePath);
}
/// <summary>Project-style display path (<c>mods/&lt;modId&gt;/&lt;relativePath&gt;</c>).</summary>
public static string GetDisplayPath(string modId, string relativePath)
{
return "mods/" + modId + "/" + relativePath;
}
/// <summary>Scans every mod's scenarios and builds the cross-scene graph (nodes per scene, edges per switch target).</summary>
public static SceneGraphData BuildSceneGraph()
{
var data = new SceneGraphData();
var sceneNodes = new Dictionary<string, SceneGraphNode>(StringComparer.OrdinalIgnoreCase);
var edgeSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var mod in ScanScenarioFiles())
{
foreach (var group in mod.Groups)
{
foreach (var file in group.Files)
{
var text = ReadAllText(file.ModId, file.RelativePath);
if (text == null)
continue;
var metadata = ParseMetadata(text);
if (string.IsNullOrEmpty(metadata.Scene) || metadata.Scene == "-")
continue;
var sceneKey = metadata.Scene.ToLowerInvariant();
if (!sceneNodes.TryGetValue(sceneKey, out var node))
{
node = new SceneGraphNode
{
SceneName = metadata.Scene,
ModId = file.ModId,
IsStartScene = string.Equals(metadata.Scene, "start_scene", StringComparison.OrdinalIgnoreCase)
};
sceneNodes[sceneKey] = node;
}
node.ScenarioCount++;
node.Scenarios.Add(new ScenarioGraphLink
{
Name = file.Name,
Priority = int.TryParse(metadata.Priority, out var p) ? p : 0,
Key = file.Key
});
var refs = ExtractReferences(text);
foreach (var target in refs.SwitchTargets)
{
var srcKey = metadata.Scene.ToLowerInvariant();
var dstKey = target.ToLowerInvariant();
var edgeKey = srcKey + "|" + dstKey;
if (!edgeSet.Contains(edgeKey))
{
edgeSet.Add(edgeKey);
data.Edges.Add(new SceneGraphEdge
{
FromScene = metadata.Scene,
ToScene = target
});
}
}
}
}
}
foreach (var node in sceneNodes.Values)
data.Nodes.Add(node);
return data;
}
}
}
-38
View File
@@ -1,38 +0,0 @@
using System.Collections.Generic;
namespace Cthangover.Tools.Services
{
/// <summary>One scenario attached to a scene node (shown on the graph node and in its detail links).</summary>
public sealed class ScenarioGraphLink
{
public string Name { get; set; }
public int Priority { get; set; }
public string Key { get; set; }
}
/// <summary>One scene of the cross-scenario graph: owning mod, background list, start-scene flag and its scenarios.</summary>
public sealed class SceneGraphNode
{
public string SceneName { get; set; }
public string ModId { get; set; }
public int ScenarioCount { get; set; }
public List<string> Backgrounds { get; set; } = new();
public bool IsStartScene { get; set; }
public List<ScenarioGraphLink> Scenarios { get; set; } = new();
public bool HasErrors { get; set; }
}
/// <summary>Directed switch-target link between two scenes of the graph.</summary>
public sealed class SceneGraphEdge
{
public string FromScene { get; set; }
public string ToScene { get; set; }
}
/// <summary>Complete graph model consumed by <c>ScenarioGraphView</c>.</summary>
public sealed class SceneGraphData
{
public List<SceneGraphNode> Nodes { get; set; } = new();
public List<SceneGraphEdge> Edges { get; set; } = new();
}
}
-128
View File
@@ -1,128 +0,0 @@
using System.Collections.Generic;
using Cthangover.Mods.Dialog.Scenarios;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Static service providing scenario DSL syntax highlighting, tokenisation,
/// and command-line parsing. <see cref="CreateHighlighter"/> produces a
/// <see cref="CodeHighlighter"/> for the scenario editor's
/// <see cref="TextEdit"/>. <see cref="ExtractCommandParams"/> tokenises a
/// scenario command line into a command name, positional arguments, and
/// named (<c>key=value</c>) parameters — used by
/// <see cref="ScenarioFileService.ExtractReferences"/> for reference analysis.
/// </summary>
public static class ScenarioSyntaxService
{
/// <summary>
/// Builds a <see cref="CodeHighlighter"/> for scenario DSL syntax.
/// Comments (lines starting with <c>#</c>) are green, strings are bright
/// gold, command names are blue, and argument keywords (e.g. <c>key</c>,
/// <c>wait</c>, <c>quest_id</c>) are amber — all from <see cref="UiTokens"/>.
/// </summary>
public static CodeHighlighter CreateHighlighter()
{
var hl = new CodeHighlighter();
hl.AddColorRegion("#", null, UiTokens.Success, true);
hl.AddColorRegion("\"", "\"", UiTokens.AccentBright, false);
hl.NumberColor = UiTokens.Info;
hl.SymbolColor = UiTokens.Muted;
var cmdColor = UiTokens.Info;
hl.KeywordColors = new Godot.Collections.Dictionary();
foreach (var cmd in ScenarioCommandStrategyFactory.GetAllCommandNames())
hl.KeywordColors[cmd] = cmdColor;
foreach (var cmd in new[] {
"option", "select"
}) hl.KeywordColors[cmd] = cmdColor;
var argColor = UiTokens.Accent;
foreach (var kw in new[] {
"key", "first", "second", "hide_color", "wait", "speed", "hidden",
"sprites", "loop", "target", "quest_id", "show", "hide", "duration"
}) hl.KeywordColors[kw] = argColor;
return hl;
}
/// <summary>
/// Splits a line into tokens, handling quoted strings (with backslash
/// escaping) and whitespace. Returns all non-whitespace tokens in order.
/// </summary>
public static List<string> Tokenize(string line)
{
var result = new List<string>();
var i = 0;
while (i < line.Length)
{
if (char.IsWhiteSpace(line[i])) { i++; continue; }
if (line[i] == '"')
{
i++;
var start = i;
while (i < line.Length && line[i] != '"')
{
if (line[i] == '\\' && i + 1 < line.Length) i++;
i++;
}
result.Add(line.Substring(start, i - start));
if (i < line.Length) i++;
continue;
}
var tok = i;
while (i < line.Length && !char.IsWhiteSpace(line[i])) i++;
result.Add(line.Substring(tok, i - tok));
}
return result;
}
/// <summary>Strips surrounding double quotes from a value if present.</summary>
public static string StripQuotes(string val)
{
if (val.Length >= 2 && val.StartsWith("\"") && val.EndsWith("\""))
return val.Substring(1, val.Length - 2);
return val;
}
/// <summary>
/// Parses a scenario command line into its components. Tokenises via
/// <see cref="Tokenize"/>, then extracts the first token as the command
/// name (lowercased), remaining bare tokens as positional arguments, and
/// <c>key=value</c> pairs as named arguments. The <c>-></c> arrow token
/// is consumed but discarded.
/// </summary>
/// <param name="line">A single scenario command line (trimmed).</param>
/// <param name="cmd">The command name, lowercased.</param>
/// <param name="positional">Positional arguments in order.</param>
/// <param name="named">Named arguments keyed by lowercased name.</param>
public static void ExtractCommandParams(string line, out string cmd, out List<string> positional, out Dictionary<string, string> named)
{
positional = new List<string>();
named = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
cmd = "";
var tokens = Tokenize(line);
if (tokens.Count == 0)
return;
cmd = tokens[0].ToLowerInvariant();
for (int t = 1; t < tokens.Count; t++)
{
var tk = tokens[t];
if (tk == "->") { t++; continue; }
var eq = tk.IndexOf('=');
if (eq > 0)
named[tk.Substring(0, eq).ToLowerInvariant()] = StripQuotes(tk.Substring(eq + 1));
else
positional.Add(tk);
}
}
}
}
-80
View File
@@ -1,80 +0,0 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods.Resolvers;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Finds which background an editor should show for a piece of content,
/// so the author does not have to guess it from a list of hundreds:
/// <list type="bullet">
/// <item>a scene's background is the first <c>defaultBackground</c> of its
/// <c>scenes/*.json</c> definition;</item>
/// <item>an interactive belongs to the scene of the scenario that adds it
/// (<c>interactive_add &lt;id&gt;</c>).</item>
/// </list>
/// Both are best-effort hints: <c>null</c> means "no idea", and the author
/// can always pick another background by hand.
/// </summary>
public static class SceneBackgroundService
{
private const string InteractiveAddCommand = "interactive_add";
/// <summary>Default background ID of a logical scene, or <c>null</c>.</summary>
public static string ForScene(string sceneName)
{
if (string.IsNullOrEmpty(sceneName) || sceneName == "-")
return null;
var defs = SceneDefinitions.Collect();
if (defs == null)
return null;
foreach (var pair in defs)
{
if (!string.Equals(pair.Key, sceneName, StringComparison.OrdinalIgnoreCase) || pair.Value == null)
continue;
// Several mods may define the scene; the last one wins at runtime.
for (var i = pair.Value.Count - 1; i >= 0; i--)
{
if (pair.Value[i]?.DefaultBackground is { Count: > 0 } list)
return list[0];
}
}
return null;
}
/// <summary>
/// Scans every scenario once and maps interactive IDs to the scene of
/// the first scenario that adds them.
/// </summary>
public static Dictionary<string, string> MapInteractivesToScenes()
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var mod in ScenarioFileService.ScanScenarioFiles())
{
foreach (var group in mod.Groups)
{
foreach (var file in group.Files)
{
var text = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath);
if (string.IsNullOrEmpty(text) || text.IndexOf(InteractiveAddCommand, StringComparison.Ordinal) < 0)
continue;
var scene = ScenarioFileService.ParseMetadata(text).Scene;
foreach (var raw in text.Split('\n'))
{
var tokens = ScenarioSyntaxService.Tokenize(raw.Trim());
if (tokens.Count >= 2 && tokens[0] == InteractiveAddCommand && !result.ContainsKey(tokens[1]))
result[tokens[1]] = scene;
}
}
}
}
return result;
}
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"name": "In-game Play-test Test",
"description": "Integration test of the scenario editor opened inside a running game (the novel template with the editors added): plays a scenario from the editor and checks the play-test stays in the game until Stop, then Stop returns to the game with the editor visible — never to the main menu. Activated by --itest=ingame_play.",
"sources": [
"src/*.cs"
],
"depends": [
"my_novel_game>=1"
]
}
@@ -1,163 +0,0 @@
using System.Linq;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Games;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.Tests.InGamePlay
{
/// <summary>
/// The player's path that used to throw them out to the main menu: inside
/// a running game (its dialog on screen) open the scenario editor, play a
/// scenario, and expect the play-test to keep running — the teardown of the
/// game's own dialog must not count as the end of the played scenario.
/// Stop, and the played scenario leaving its scene (an option's
/// <c>switch_scene</c>), must both come back into the game with the editor
/// visible. Ends with
/// <c>[TEST_RESULT] name=ingame_play passed=N failed=M</c> and the exit code.
/// </summary>
public partial class InGamePlayTestDriverNode : Node
{
private const string ResultName = "ingame_play";
private const string GameModId = "my_novel_game";
private const string ScenarioPath = "scenarios/my_novel_home.scenario";
private const float TickSeconds = 0.5f;
private const float TimeoutSeconds = 90f;
private const int GameSettleTicks = 6;
private const int PlaySettleTicks = 6;
private int _passed;
private int _failed;
private int _step;
private int _settle;
private double _elapsed;
private bool _finished;
private ScenarioEditorWindow _window;
/// <summary>Creates the driver ticking on its own timer.</summary>
public InGamePlayTestDriverNode()
{
var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
AddChild(timer);
timer.Timeout += Tick;
}
private void Tick()
{
if (_finished)
return;
_elapsed += TickSeconds;
if (_elapsed >= TimeoutSeconds)
{
Check(false, $"timeout ({TimeoutSeconds:0}s) at step {_step}");
Finish();
return;
}
var tree = GetTree();
var scenes = tree.Root.GetNodeOrNull<GodotSceneService>("GodotSceneService");
switch (_step)
{
case 0:
if (!Boot.IsReady || !ActiveGame.IsActive || InMainMenu(scenes) || GodotSceneService.IsTransitioning)
return;
if (++_settle < GameSettleTicks)
return;
Check(SceneManager.GetCurrentSceneName() == "my_novel_town", $"game entered its town (scene '{SceneManager.GetCurrentSceneName()}')");
_window = (ScenarioEditorWindow)ToolWindow.ShowWindow(new ScenarioEditorWindow());
var tabs = _window.FindChildren("*", "", true, false).OfType<ScenarioEditTabs>().FirstOrDefault();
tabs?.OpenFile(ScenarioFileService.MakeKey(GameModId, ScenarioPath));
Check(tabs?.HasActiveFile == true, "scenario editor opened the home scenario");
break;
case 1:
PressButton(_window, "tools/scenario_editor/play");
_settle = 0;
break;
case 2:
if (GodotSceneService.IsTransitioning)
return;
if (InMainMenu(scenes))
{
Check(false, "play-test threw the player out to the main menu");
Finish();
return;
}
if (++_settle < PlaySettleTicks)
return;
Check(SceneManager.IsTestPlayActive, "play-test is still running while its dialog waits for the player");
Check(!_window.Visible, "the editor is hidden during the play-test");
Check(SceneManager.GetCurrentSceneName() == "my_novel_home", $"play-test entered the scenario's scene (scene '{SceneManager.GetCurrentSceneName()}')");
PressButton(tree.Root, "tools/scenario_editor/stop");
_settle = 0;
break;
case 3:
if (GodotSceneService.IsTransitioning || ++_settle < PlaySettleTicks)
return;
Check(!InMainMenu(scenes), "Stop returns into the game, not to the main menu");
Check(IsInstanceValid(_window) && _window.Visible, "the editor is visible again after Stop");
Check(!SceneManager.IsTestPlayActive, "Stop ends the play-test");
Check(SceneManager.GetCurrentSceneName() == "my_novel_town", $"Stop returns to the scene the player was in (scene '{SceneManager.GetCurrentSceneName()}')");
break;
case 4:
PressButton(_window, "tools/scenario_editor/play");
_settle = 0;
break;
case 5:
if (GodotSceneService.IsTransitioning || ++_settle < PlaySettleTicks)
return;
// The played scenario's option "Back to the town" does exactly this.
tree.Root.GetNodeOrNull<SceneManager>("SceneManager")?.SwitchScene("my_novel_town");
_settle = 0;
break;
case 6:
if (GodotSceneService.IsTransitioning || ++_settle < PlaySettleTicks)
return;
Check(!InMainMenu(scenes), "a scene switch in the played scenario returns into the game, not to the main menu");
Check(!SceneManager.IsTestPlayActive, "leaving the played scene ends the play-test");
Check(IsInstanceValid(_window) && _window.Visible, "the editor is visible again after the played scenario left its scene");
Finish();
return;
}
_step++;
}
private static bool InMainMenu(GodotSceneService scenes) => scenes?.CurrentGodotSceneName == "mainmenu";
private static void PressButton(Node root, string textKey)
{
var text = ToolUi.Tr(textKey);
var button = root.FindChildren("*", nameof(Button), true, false).OfType<Button>().FirstOrDefault(b => b.Text == text);
button?.EmitSignal(BaseButton.SignalName.Pressed);
}
private void Check(bool condition, string label)
{
if (condition)
_passed++;
else
_failed++;
GameLogger.Log("TEST", $"InGamePlayTest: [{(condition ? "PASS" : "FAIL")}] {label}");
}
private void Finish()
{
_finished = true;
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={_passed} failed={_failed}");
GetTree().Quit(_failed == 0 ? 0 : 1);
}
}
}
@@ -1,31 +0,0 @@
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.Tests.InGamePlay
{
/// <summary>
/// Entry point of the in-game play-test GUI test, activated only by the
/// exact user arg <c>--itest=ingame_play</c>. The driver is added deferred:
/// resources-ready fires before the game's shell exists.
/// </summary>
public class InGamePlayTestInitializer : IModInitializer
{
private static bool driverQueued;
/// <summary>Nothing to do per mod: the driver is queued on resources-ready.</summary>
public void OnModLoaded(string modId) { }
/// <summary>Queues the driver once when the activation flag is present.</summary>
public void OnModResourcesReady()
{
if (driverQueued || !OS.GetCmdlineUserArgs().Any(a => a == "--itest=ingame_play"))
return;
driverQueued = true;
GameLogger.Log("TEST", "InGamePlayTestInitializer: activating in-game play-test test");
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new InGamePlayTestDriverNode());
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "Novel Flow Test",
"description": "End-to-end authoring test on the novel template with the editors added, using only the editors' UI: opens the scene editor from the in-game toolbar, creates a new scene with a background, writes its scenario (a way back to the town) in the scenario editor, adds a way to it to the town's scenario, goes to the town from the scene editor and walks town -> new scene -> town through the real dialog choices. Restores the game's files at the end. Activated by --itest=novel_flow; --shots=<dir> saves screenshots (windowed runs only).",
"sources": [
"src/*.cs"
],
"depends": [
"my_novel_game>=0.1.0"
]
}
@@ -0,0 +1,381 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Dialog;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Core.Utils;
using Cthangover.Mods.Dialog;
using Cthangover.Tools.Common;
using Cthangover.Tools.SceneEditor;
using Cthangover.Tools.Services;
using Cthangover.Mods.Interface.View;
using Godot;
namespace Cthangover.Tools.Tests.NovelFlow
{
/// <summary>
/// An author's session in the running novel template, through the editors' UI only:
/// <list type="number">
/// <item>the toolbar's tools menu opens the scene editor;</item>
/// <item>New scene, a name, the game's mod, a background, Save;</item>
/// <item>New scenario of that scene opens the scenario editor; the scenario (a way back to
/// the town) is typed in and saved;</item>
/// <item>the town is picked in the scene editor, its scenario opened and a way to the new
/// scene typed in and saved;</item>
/// <item>Go to scene puts the game into the town, and the player walks town → new scene →
/// town by clicking the dialog's answers.</item>
/// </list>
/// Buttons, list rows, answers and text edits get the signals a mouse or keyboard would
/// raise. The game's files are restored at the end (town scenario text, new files deleted).
/// Result line: <c>[TEST_RESULT] name=novel_flow passed=N failed=M</c>, 150 s watchdog.
/// </summary>
public partial class NovelFlowTestDriver : Node
{
private int readinessToken = int.MinValue;
private ulong readinessStarted;
private ulong readinessFrame;
private const float TickSeconds = 0.05f;
private const float TimeoutSeconds = 150f;
private const string Mod = "my_novel_game";
private const string Town = "my_novel_town";
private const string Park = "my_novel_park";
private const string TownScenario = "scenarios/my_novel_town.scenario";
private const string ParkScenario = "scenarios/my_novel_park.scenario";
private const string ToPark = "Go to the park";
private const string ToTown = "Back to the town";
// The town scenario before the test edits it; survives a run that is killed midway
// (the next run restores it first, so an interrupted run never leaves the park behind).
private const string Backup = "user://novel_flow_town.scenario.bak";
private const string ParkText =
"scene: " + Park + "\npriority: 10\n---\n" +
"text \"A quiet park behind the town. Wet leaves, an empty bench.\"\n" +
"select \"What next?\"\n" +
"option \"" + ToTown + "\" -> :town\n\n" +
":town\nswitch_scene " + Town + "\nend\n";
private readonly string shotsDir;
private readonly List<string> failures = new();
private int passed;
private int step;
private int wait;
private double elapsed;
private string townOriginal;
private SceneEditorWindow sceneEditor;
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
public NovelFlowTestDriver(string shotsDir)
{
this.shotsDir = shotsDir;
Name = "NovelFlowTestDriver";
var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
AddChild(timer);
timer.Timeout += Tick;
}
private SceneManager Scenes => GetTree().Root.GetNodeOrNull<SceneManager>("SceneManager");
private void Tick()
{
if (step < 0)
return;
elapsed += TickSeconds;
if (elapsed > TimeoutSeconds)
{
Fail($"timeout at step {step}");
Finish();
return;
}
try
{
RunStep();
}
catch (System.Exception ex)
{
Fail($"step {step}: {ex.GetType().Name}: {ex.Message}");
Finish();
}
}
// Deferred UI/scene work must finish before the original assertions run.
private bool WaitForReadyFrames(int token, bool ready, double timeoutSeconds = 30)
{
var now = global::Godot.Time.GetTicksMsec();
if (readinessToken != token)
{
readinessToken = token;
readinessStarted = now;
readinessFrame = 0;
}
if (now - readinessStarted > timeoutSeconds * 1000)
throw new global::System.TimeoutException($"readiness at step {token}: scene/UI did not become ready");
if (!ready) { readinessFrame = 0; return false; }
if (readinessFrame == 0) readinessFrame = global::Godot.Engine.GetProcessFrames();
return global::Godot.Engine.GetProcessFrames() - readinessFrame >= 2;
}
private void RunStep()
{
if (!WaitForReadyFrames(step, Boot.IsReady && Scenes != null && !GodotSceneService.IsTransitioning && (step < 2 || sceneEditor != null && sceneEditor.IsInsideTree()), 30)) return;
switch (step)
{
case 0:
if (!Boot.IsReady || Scenes?.CurrentSceneName != Town || GodotSceneService.IsTransitioning)
return;
RestoreFromBackup();
townOriginal = ModFileService.ReadText(Mod, TownScenario);
File.WriteAllText(ProjectSettings.GlobalizePath(Backup), townOriginal ?? string.Empty);
DeleteParkFiles();
SceneManager.ReloadContent();
Check(townOriginal != null && !Scenes.HasScene(Park), "the novel runs in the town, no park yet");
OpenToolFromToolbar("tools/scene_editor/title");
Next();
return;
case 1:
sceneEditor = Windows<SceneEditorWindow>().FirstOrDefault();
if (sceneEditor == null)
return;
Check(true, "the toolbar's tools menu opens the scene editor");
Press(sceneEditor, "tools/scene_editor/new_scene");
var name = sceneEditor.FindChildren("*", "", true, false).OfType<LineEdit>().First(e => e.PlaceholderText == "my_scene");
name.Text = Park;
name.EmitSignal(LineEdit.SignalName.TextChanged, Park);
var mods = sceneEditor.FindChildren("*", "", true, false).OfType<OptionButton>()
.First(o => Enumerable.Range(0, o.ItemCount).Any(i => o.GetItemText(i) == Mod));
var modIndex = Enumerable.Range(0, mods.ItemCount).First(i => mods.GetItemText(i) == Mod);
mods.Select(modIndex);
mods.EmitSignal(OptionButton.SignalName.ItemSelected, modIndex);
var picker = Lists(sceneEditor)[1];
var background = Row(picker, "home/vestibule") >= 0 ? Row(picker, "home/vestibule") : 0;
picker.EmitSignal(ItemList.SignalName.ItemActivated, background);
Next();
return;
case 2:
Shot("1_new_scene");
Press(sceneEditor, "tools/common/save");
Check(Scenes.HasScene(Park), "Save in the scene editor: the game knows the new scene at once");
Check(File.Exists(ModFileService.GetWritablePath(Mod, "scenes/" + Park + ".json")), "the scene file is in the game's mod");
Press(sceneEditor, "tools/scene_editor/new_scenario");
Next();
return;
case 3:
var parkEditor = EditorWith("scene: " + Park);
if (parkEditor == null)
return;
Check(true, "New scenario opens the scenario editor on the scene's new scenario");
Type(parkEditor, ParkText);
Press(parkEditor, "tools/common/save");
Check(ModFileService.ReadText(Mod, ParkScenario)?.Contains(ToTown) == true, "the park scenario is saved with the way back");
Next();
return;
case 4:
// A tick after typing, so the frame shows the new text.
Shot("2_park_scenario");
PickScene(Town);
Next();
return;
case 5:
var rows = sceneEditor.FindChildren("*", "", true, false).OfType<ItemList>().Last();
var townRow = Row(rows, Mod + ": " + TownScenario);
Check(townRow >= 0, "the town's scenario is listed under the town in the scene editor");
rows.Select(townRow);
rows.EmitSignal(ItemList.SignalName.ItemActivated, townRow);
Next();
return;
case 6:
var townEditor = EditorWith("scene: " + Town);
if (townEditor == null)
return;
var text = Code(townEditor).Text;
text = text.Replace("option \"Go back home\"", "option \"" + ToPark + "\" -> :park\noption \"Go back home\"")
+ "\n:park\nswitch_scene " + Park + "\nend\n";
Type(townEditor, text);
Press(townEditor, "tools/common/save");
Check(ModFileService.ReadText(Mod, TownScenario)?.Contains("switch_scene " + Park) == true, "the town scenario is saved with the way to the park");
Next();
return;
case 7:
Shot("3_town_scenario");
Press(sceneEditor, "tools/scene_editor/go");
Next();
return;
case 8:
if (GodotSceneService.IsTransitioning || Scenes.CurrentSceneName != Town)
return;
foreach (var window in GetTree().Root.GetChildren().OfType<Window>().ToList())
window.EmitSignal(Window.SignalName.CloseRequested);
Next();
return;
case 9:
if (!Answer(ToPark))
return;
Shot("4_town_choice");
Click(ToPark);
Next();
return;
case 10:
if (Scenes.CurrentSceneName != Park || GodotSceneService.IsTransitioning || !Answer(ToTown))
return;
Check(true, "choosing the new answer in the town leads into the park");
Shot("5_park");
Click(ToTown);
Next();
return;
case 11:
if (Scenes.CurrentSceneName != Town || GodotSceneService.IsTransitioning || !Answer(ToPark))
return;
Check(true, "the park's answer leads back to the town, which offers the park again");
Shot("6_back_in_town");
Finish();
return;
}
}
// ── the author's hands ─────────────────────────────────────
/// <summary>The toolbar's tools button opens its menu; the tool is picked by its title.</summary>
private void OpenToolFromToolbar(string titleKey)
{
var menuTitle = TranslationServer.Translate("tools/menu_title").ToString();
var button = GetTree().Root.FindChildren("*", "", true, false).OfType<Button>().First(b => b.TooltipText == menuTitle);
button.EmitSignal(BaseButton.SignalName.Pressed);
var menu = button.GetChildren().OfType<PopupMenu>().First();
var title = TranslationServer.Translate(titleKey).ToString();
var index = Enumerable.Range(0, menu.ItemCount).First(i => menu.GetItemText(i) == title);
menu.EmitSignal(PopupMenu.SignalName.IndexPressed, index);
}
private void PickScene(string name)
{
var list = Lists(sceneEditor)[0];
var row = Enumerable.Range(0, list.ItemCount).First(i => list.GetItemTooltip(i) == name);
list.Select(row);
list.EmitSignal(ItemList.SignalName.ItemSelected, row);
}
private static void Type(ScenarioEditorWindow editor, string text)
{
var code = Code(editor);
code.Text = text;
code.EmitSignal(TextEdit.SignalName.TextChanged);
}
private void Press(Node root, string key)
{
var text = TranslationServer.Translate(key).ToString();
var button = root.FindChildren("*", "", true, false).OfType<Button>().FirstOrDefault(b => b.Text == text && b.IsVisibleInTree());
Check(button != null && !button.Disabled, $"button '{text}' is there and enabled");
button?.EmitSignal(BaseButton.SignalName.Pressed);
}
/// <summary>True when the dialog shows an answer with this text (advances the text before it).</summary>
private bool Answer(string text)
{
if (AnswerItems().Any(a => a.Model?.Text == text))
return true;
if (++wait % 10 == 0)
GameLogger.Log("TEST", $"waiting for answer '{text}' in '{Scenes?.CurrentSceneName}', shown: [{string.Join(", ", AnswerItems().Select(a => a.Model?.Text))}]");
var box = SceneContextNode.FindNode<Node>("DialogBox") as IDialogBox;
if (box != null && box.IsRunning && !box.IsWaitingAnswer)
box.NextAction();
return false;
}
private void Click(string text)
{
var item = AnswerItems().First(a => a.Model?.Text == text);
item.GetNode<Button>("Button").EmitSignal(BaseButton.SignalName.Pressed);
}
private IEnumerable<AnswerItem> AnswerItems() =>
GetTree().Root.FindChildren("*", "", true, false).OfType<AnswerItem>().Where(a => a.IsVisibleInTree());
// ── finding things ─────────────────────────────────────────
private IEnumerable<T> Windows<T>() where T : Window =>
GetTree().Root.GetChildren().OfType<T>().Where(w => !w.IsQueuedForDeletion());
private ScenarioEditorWindow EditorWith(string header) =>
Windows<ScenarioEditorWindow>().FirstOrDefault(w => Code(w)?.Text.StartsWith(header) == true);
private static CodeEdit Code(Node editor) =>
editor.FindChildren("*", "", true, false).OfType<CodeEdit>().FirstOrDefault(c => c.IsVisibleInTree());
private static List<ItemList> Lists(Node root) =>
root.FindChildren("*", "", true, false).OfType<FilteredList>()
.Select(f => f.FindChildren("*", "", true, false).OfType<ItemList>().First()).ToList();
private static int Row(ItemList list, string text) =>
Enumerable.Range(0, list.ItemCount).FirstOrDefault(i => list.GetItemText(i) == text, -1);
// ── bookkeeping ────────────────────────────────────────────
private static void RestoreFromBackup()
{
var path = ProjectSettings.GlobalizePath(Backup);
if (!File.Exists(path))
return;
ModFileService.WriteText(Mod, TownScenario, File.ReadAllText(path));
File.Delete(path);
GameLogger.Log("TEST", "novel_flow: restored the town scenario left by an interrupted run");
}
private void DeleteParkFiles()
{
foreach (var file in new[] { "scenes/" + Park + ".json", ParkScenario })
{
var path = ModFileService.GetWritablePath(Mod, file);
if (path != null && File.Exists(path))
File.Delete(path);
}
}
private void Next()
{
step++;
wait = 0;
}
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 void Shot(string name)
{
if (string.IsNullOrEmpty(shotsDir) || DisplayServer.GetName() == "headless")
return;
DirAccess.MakeDirRecursiveAbsolute(shotsDir);
ViewportCapture.SavePng(GetViewport(), shotsDir.TrimEnd('/') + "/novel_flow_" + name + ".png");
}
private void Finish()
{
step = -1;
if (townOriginal != null)
ModFileService.WriteText(Mod, TownScenario, townOriginal);
File.Delete(ProjectSettings.GlobalizePath(Backup));
DeleteParkFiles();
SceneManager.ReloadContent();
GameLogger.Log("TEST", $"[TEST_RESULT] name=novel_flow passed={passed} failed={failures.Count}");
Cthangover.Core.Bootstrap.ApplicationExit.Quit(GetTree(), failures.Count == 0 ? 0 : 1);
}
}
}
@@ -0,0 +1,37 @@
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.Tests.NovelFlow
{
/// <summary>
/// Entry point of the novel_flow test: adds the driver to the tree root when the exact
/// flag <c>--itest=novel_flow</c> is given (the driver quits the process). Optional
/// <c>--shots=&lt;dir&gt;</c> makes the driver save a screenshot per step.
/// </summary>
public class NovelFlowTestInitializer : 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=novel_flow"))
return;
driverQueued = true;
var shots = args.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal))
.Select(a => a.Substring("--shots=".Length))
.FirstOrDefault();
GameLogger.Log("TEST", $"NovelFlowTestInitializer: activating novel_flow, shots={shots ?? "off"}");
ModRoot.AddDeferred(new NovelFlowTestDriver(shots));
}
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"src/*.cs"
],
"depends": [
"cthangover_game>=1"
"cthangover_game>=0.1.0",
"village_pack>=0.1.0",
"test_defaults_and_patches>=0.1.0",
"rpg_editor>=0.1.0"
]
}
@@ -1,105 +0,0 @@
using System;
using System.Linq;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Hub;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.Tests.Windows
{
/// <summary>
/// Play-test round trip of the scenario editor opened from the
/// <see cref="NovelEditorHub"/>: plays this suite's own scenario (so the
/// editor's save-before-play rewrites a test file, not game content), lets
/// the play end the way a finished dialog ends it
/// (<see cref="SceneManager.IsTestPlayActive"/> drops), then plays again and
/// presses Stop. Both times the game must come back to the hub with the
/// editor visible — not to the engine's main menu. Advanced one step per
/// driver tick; <see cref="Tick"/> returns true when done.
/// </summary>
public class ScenarioPlayReturnCheck
{
private const string SuiteModId = "editor_tools__windows";
private const string ScenarioPath = "scenarios/playtest_return.scenario";
private const int ShellSettleTicks = 3;
private readonly Action<bool, string> _check;
private ScenarioEditorWindow _window;
private int _step;
private int _settle;
/// <summary>Creates the check; <paramref name="check"/> records one assertion.</summary>
public ScenarioPlayReturnCheck(Action<bool, string> check)
{
_check = check;
}
/// <summary>Advances the round trip by one step; true once finished.</summary>
public bool Tick(SceneTree tree)
{
switch (_step)
{
case 0:
_window = (ScenarioEditorWindow)ToolWindow.ShowWindow(new ScenarioEditorWindow());
var tabs = _window.FindChildren("*", "", true, false).OfType<ScenarioEditTabs>().FirstOrDefault();
tabs?.OpenFile(ScenarioFileService.MakeKey(SuiteModId, ScenarioPath));
_check(tabs?.HasActiveFile == true, "scenario_editor: test scenario opened");
break;
case 1:
case 4:
PressButton(_window, "tools/scenario_editor/play");
_settle = ShellSettleTicks;
break;
case 2:
if (!InGame(tree))
return false;
SceneManager.IsTestPlayActive = false;
break;
case 5:
if (!InGame(tree))
return false;
PressButton(tree.Root, "tools/scenario_editor/stop");
break;
case 3:
case 6:
if (tree.CurrentScene is not NovelEditorHub || !_window.Visible)
return false;
_check(true, _step == 3
? "scenario_editor: the scenario's end returns to the editor hub"
: "scenario_editor: Stop returns to the editor hub");
if (_step == 6)
{
_window.QueueFree();
return true;
}
break;
}
_step++;
return false;
}
/// <summary>The play-test left the hub for the game shell and the shell settled.</summary>
private bool InGame(SceneTree tree)
{
if (tree.CurrentScene is NovelEditorHub || GodotSceneService.IsTransitioning || --_settle > 0)
return false;
_check(!_window.Visible, "scenario_editor: play hides the editor and enters the game");
return true;
}
private static void PressButton(Node root, string textKey)
{
var text = ToolUi.Tr(textKey);
var button = root.FindChildren("*", nameof(Button), true, false).OfType<Button>().FirstOrDefault(b => b.Text == text);
button?.EmitSignal(BaseButton.SignalName.Pressed);
}
}
}
+150 -8
View File
@@ -1,13 +1,20 @@
using System;
using System.Linq;
using Cthangover.Core.UI.Tool.LightEditor;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Tools.InteractiveEditor;
using Cthangover.Tools.MapEditor;
using Godot;
namespace Cthangover.Tools.Tests.Windows
{
using System;
using System.IO;
using System.Linq;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Tool.LightEditor;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Tools.InteractiveEditor;
using Cthangover.Tools.Common;
using Cthangover.Mods.Rpg.Model;
using Cthangover.Tools.MapEditor;
using Cthangover.Tools.RpgEditor;
using Cthangover.Tools.SceneEditor;
using Cthangover.Tools.Services;
using Godot;
/// <summary>
/// Per-tool interaction checks run on an open window, through the same
/// public surface the UI uses: canvas input is delivered by emitting the
@@ -20,24 +27,87 @@ namespace Cthangover.Tools.Tests.Windows
/// <summary>Runs the checks for <paramref name="toolId"/>; <paramref name="check"/> records each result.</summary>
public static void Run(string toolId, Window window, Action<bool, string> check)
{
foreach (var editor in window.FindChildren("*", "", true, false).OfType<CodeEdit>())
check(editor.SyntaxHighlighter != null, toolId + ": source field has syntax highlighting");
switch (toolId)
{
case "map_editor":
MapDrawing(window, check);
break;
case "rpg_map_editor":
RpgMapCanvas(window, check);
break;
case "interactive_editor":
InteractivePolygon(window, check);
break;
case "light_editor":
LightAdd(window, check);
break;
case "scene_editor":
SceneCreate(window, check);
break;
case "scenario_editor":
var tree = Find<ScenarioFileTreePanel>(window)?.FindChildren("*", nameof(Tree), true, false).OfType<Tree>().FirstOrDefault();
check(tree?.GetRoot()?.GetChildCount() > 0, "scenario_editor: file tree lists mods with scenarios");
ScenarioTimerLifecycle(window, check);
break;
}
}
/// <summary>Checks the debounced metadata after the detached window has re-entered the tree.</summary>
public static void Verify(string toolId, Window window, Action<bool, string> check)
{
if (toolId == "scenario_editor")
check(window.FindChildren("InfoScene", "", true, false).OfType<Label>().Any(label => label.Text == "timer_lifecycle_probe"),
"scenario_editor: metadata refresh survives an edit outside the scene tree");
}
/// <summary>Edits an open buffer while detached; no file is saved, and refresh must wait for tree entry.</summary>
private static void ScenarioTimerLifecycle(Window window, Action<bool, string> check)
{
var file = ScenarioFileService.ScanScenarioFiles().SelectMany(mod => mod.Groups).SelectMany(group => group.Files).FirstOrDefault();
var tabs = Find<ScenarioEditTabs>(window);
check(file != null && tabs != null, "scenario_editor: scenario and text editor available for lifecycle check");
if (file == null || tabs == null)
return;
((ScenarioEditorWindow)window).OpenScenario(file.Key);
var parent = window.GetParent();
parent.RemoveChild(window);
try
{
tabs.CodeEditor.Text = "scene: timer_lifecycle_probe\npriority: 10\n---\ntext \"Timer lifecycle probe\"\nend\n";
tabs.CodeEditor.EmitSignal(TextEdit.SignalName.TextChanged);
}
finally
{
parent.AddChild(window);
}
}
/// <summary>
/// The RPG map editor (plan 12.4): its canvas draws the map in both projections, the palette lists the four shapes,
/// and switching the projection keeps the map. Nothing is saved.
/// </summary>
private static void RpgMapCanvas(Window window, Action<bool, string> check)
{
var editor = window as RpgEditorWindow;
check(editor?.Canvas != null, "rpg_map_editor: the map canvas is there");
// A game without RPG maps (cthangover) opens the window on its map selector: no document, nothing to draw.
// The map checks run only when a map is open (plan E0: editor_tools windows do not need an RPG map).
var hasMap = editor?.Document != null && editor.Canvas != null;
if (hasMap)
check(editor.Canvas.Scene != null, "rpg_map_editor: a map is open and drawn");
check(editor != null && editor.Palette.Count(RpgObjectForm.Surface) > 0 && editor.Palette.Count(RpgObjectForm.Sprite) > 0,
"rpg_map_editor: the palette lists surfaces and sprites");
if (!hasMap)
return;
editor.SetProjection(RpgEditorCanvas.Isometric);
check(editor.Canvas.Projection == RpgEditorCanvas.Isometric && editor.Canvas.Scene != null, "rpg_map_editor: the isometric projection draws the map");
editor.SetProjection(RpgEditorCanvas.TopDown);
check(editor.Canvas.Projection == RpgEditorCanvas.TopDown && editor.Canvas.Scene != null, "rpg_map_editor: the top-down projection draws the map again");
}
/// <summary>Draws a triangle; the finishing double-click must not add a fourth vertex.</summary>
private static void MapDrawing(Window window, Action<bool, string> check)
{
@@ -104,6 +174,78 @@ namespace Cthangover.Tools.Tests.Windows
"light_editor: double-click adds a light at the clicked point");
}
/// <summary>
/// Creates a scene the way an author does — New scene, a name, the test fixture mod,
/// a double-clicked background, Save — and checks that the running game knows it and
/// that no scenario was made for it (a scene may hold only interactives); then removes
/// the files again. The scene goes
/// into editor_tools itself: it is always loaded with the window, while a test fixture
/// mod is not (--all-mods skips fixtures). Skipped when the mod is not a folder mod.
/// </summary>
private static void SceneCreate(Window window, Action<bool, string> check)
{
const string mod = "editor_tools";
const string name = "zz_tools_test_scene";
var lists = window.FindChildren("*", "", true, false).OfType<FilteredList>().ToList();
check(lists.Count == 2 && lists[0].Count > 0, $"scene_editor: scene list shows the game's scenes ({(lists.Count > 0 ? lists[0].Count : 0)})");
var modSelect = window.FindChildren("*", "", true, false).OfType<OptionButton>()
.FirstOrDefault(o => Enumerable.Range(0, o.ItemCount).Any(i => o.GetItemText(i) == mod));
if (modSelect == null || lists.Count < 2 || lists[1].Count == 0)
return;
var scenePath = ModFileService.GetWritablePath(mod, SceneFileEntry.PathFor(name));
var scenarioPath = ModFileService.GetWritablePath(mod, "scenarios/" + name + ".scenario");
try
{
DeleteLeftovers(scenePath, scenarioPath);
PressButton(window, "tools/scene_editor/new_scene");
var nameEdit = window.FindChildren("*", "", true, false).OfType<LineEdit>().First(e => e.PlaceholderText == "my_scene");
nameEdit.Text = name;
nameEdit.EmitSignal(LineEdit.SignalName.TextChanged, name);
var index = Enumerable.Range(0, modSelect.ItemCount).First(i => modSelect.GetItemText(i) == mod);
modSelect.Select(index);
modSelect.EmitSignal(OptionButton.SignalName.ItemSelected, index);
var picker = lists[1].FindChildren("*", "", true, false).OfType<ItemList>().First();
picker.EmitSignal(ItemList.SignalName.ItemActivated, 0);
PressButton(window, "tools/common/save");
var manager = window.GetTree().Root.GetNodeOrNull<SceneManager>("SceneManager");
check(File.Exists(scenePath) && File.ReadAllText(scenePath).Contains("\"defaultBackground\""), "scene_editor: Save writes the scene file with its background");
check(!File.Exists(scenarioPath), "scene_editor: a new scene is saved without a scenario");
check(manager?.HasScene(name) == true, "scene_editor: the running game knows the new scene without a reload");
}
finally
{
DeleteLeftovers(scenePath, scenarioPath);
SceneManager.ReloadContent();
}
}
/// <summary>Removes the test scene's files and the folders they alone needed (editor_tools has no scenes of its own).</summary>
private static void DeleteLeftovers(string scenePath, string scenarioPath)
{
foreach (var path in new[] { scenePath, scenarioPath })
{
if (path != null && File.Exists(path))
File.Delete(path);
DeleteIfEmpty(Path.GetDirectoryName(path));
}
}
/// <summary>Deletes a folder left empty by the test.</summary>
private static void DeleteIfEmpty(string directory)
{
if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
Directory.Delete(directory);
}
private static void PressButton(Node root, string key)
{
var text = TranslationServer.Translate(key).ToString();
var button = root.FindChildren("*", "", true, false).OfType<Button>().First(b => b.Text == text);
button.EmitSignal(BaseButton.SignalName.Pressed);
}
private static void Click(Control target, Vector2 local, bool doubleClick)
{
var ev = new InputEventMouseButton
@@ -1,12 +1,10 @@
using System.Collections.Generic;
using System.IO;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Games;
using Cthangover.Core.UI.Tool;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Menu;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Hub;
using Cthangover.Mods.Interface.View;
using Godot;
namespace Cthangover.Tools.Tests.Windows
@@ -14,18 +12,11 @@ namespace Cthangover.Tools.Tests.Windows
/// <summary>
/// Opens every registered tool window one by one through the same path the
/// toolbar button uses (<see cref="ToolFactory"/> → <see cref="ToolWindow.ShowWindow"/>),
/// lets it lay out for a couple of ticks, checks that it entered the tree
/// waits for visible layout and two real frames (visual settles with screenshots), checks that it entered the tree
/// visible and inside the game viewport, optionally saves a screenshot and
/// closes it. A window that throws while constructing or laying out fails
/// only its own check, the others still run. Ends with the runner contract
/// <c>[TEST_RESULT] name=tools_windows passed=N failed=M</c> and the exit code.
/// <para/>
/// Launched as the <c>novel_editor</c> game (<c>--game=novel_editor --new</c>)
/// it first waits for the collection's entry scene, the
/// <see cref="NovelEditorHub"/>, and checks it lists a launcher button for
/// every registered tool before opening the windows; after the windows it
/// plays a scenario from the editor and checks that both the scenario's
/// end and Stop return to the hub (<see cref="ScenarioPlayReturnCheck"/>).
/// </summary>
public partial class ToolWindowsTestDriverNode : Node
{
@@ -43,17 +34,20 @@ namespace Cthangover.Tools.Tests.Windows
private int _settle;
private double _elapsed;
private bool _finished;
private bool _exercised;
private ulong _waitStarted;
private ulong _readyFrame;
private readonly double _tickSeconds;
private Window _window;
private bool _inHub;
private ScenarioPlayReturnCheck _playCheck;
private readonly ToolWindowsErrorLogger _errors = new();
/// <summary>Creates the driver; <paramref name="shotsDir"/> null or empty disables screenshots.</summary>
public ToolWindowsTestDriverNode(string shotsDir)
{
_shotsDir = shotsDir;
_tickSeconds = string.IsNullOrEmpty(shotsDir) ? 0.05 : TickSeconds;
OS.AddLogger(_errors);
var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
var timer = new Timer { WaitTime = _tickSeconds, Autostart = true };
AddChild(timer);
timer.Timeout += Tick;
}
@@ -63,7 +57,7 @@ namespace Cthangover.Tools.Tests.Windows
if (_finished)
return;
_elapsed += TickSeconds;
_elapsed += _tickSeconds;
if (_elapsed >= TimeoutSeconds)
{
Fail($"timeout ({TimeoutSeconds:0}s) at tool #{_toolIndex}");
@@ -77,28 +71,26 @@ namespace Cthangover.Tools.Tests.Windows
if (!Boot.IsReady || tree?.CurrentScene == null)
return;
_inHub = ActiveGame.Id == NovelEditorHub.SceneName;
if (_inHub && tree.CurrentScene is not NovelEditorHub)
return;
_tools.AddRange(ToolFactory.Instance.GetAll());
Check(_tools.Count > 0, $"tools registered ({_tools.Count})");
if (_inHub)
CheckHub(tree.CurrentScene);
var toolbarTools = new HashSet<string>();
foreach (var button in ToolBoxButtonFactory.Instance.GetVisible())
toolbarTools.Add(button.ToolId);
foreach (var tool in _tools)
Check(toolbarTools.Contains(tool.Id), $"{tool.Id}: reachable from the in-game toolbar");
OpenNext();
return;
}
if (_playCheck != null)
{
if (_playCheck.Tick(GetTree()))
Finish();
return;
}
if (_window == null)
return;
if (string.IsNullOrEmpty(_shotsDir))
{
AdvanceWhenReady();
return;
}
if (--_settle == ExerciseTick)
{
try
@@ -118,15 +110,59 @@ namespace Cthangover.Tools.Tests.Windows
OpenNext();
}
private void AdvanceWhenReady()
{
var id = _tools[_toolIndex].Id;
var ready = GodotObject.IsInstanceValid(_window) && _window.IsInsideTree()
&& _window.Visible && _window.Size.X > 0 && _window.Size.Y > 0;
// This exercise intentionally schedules a metadata debounce while detached.
if (ready && _exercised && id == "scenario_editor")
ready = MetadataIsReady();
if (Time.GetTicksMsec() - _waitStarted > 10000)
{
Fail($"{id}: timed out waiting for {(_exercised ? "exercise completion" : "visible layout")}");
Finish();
return;
}
if (!ready)
{
_readyFrame = 0;
return;
}
if (_readyFrame == 0)
_readyFrame = Engine.GetProcessFrames();
if (Engine.GetProcessFrames() - _readyFrame < 2)
return;
if (!_exercised)
{
try { ToolWindowsExercises.Run(id, _window, Check); }
catch (System.Exception ex)
{
Fail($"{id}: interaction threw {ex.GetType().Name}: {ex.Message}");
}
_exercised = true;
_waitStarted = Time.GetTicksMsec();
_readyFrame = 0;
return;
}
InspectAndClose();
OpenNext();
}
private bool MetadataIsReady()
{
foreach (var child in _window.FindChildren("InfoScene", "", true, false))
if (child is Label label && label.Text == "timer_lifecycle_probe")
return true;
return false;
}
private void OpenNext()
{
_toolIndex++;
if (_toolIndex >= _tools.Count)
{
if (_inHub)
_playCheck = new ScenarioPlayReturnCheck(Check);
else
Finish();
Finish();
return;
}
@@ -136,6 +172,9 @@ namespace Cthangover.Tools.Tests.Windows
{
_window = ToolWindow.ShowWindow(tool.CreateWindow());
_settle = SettleTicks;
_exercised = false;
_waitStarted = Time.GetTicksMsec();
_readyFrame = 0;
}
catch (System.Exception ex)
{
@@ -145,30 +184,13 @@ namespace Cthangover.Tools.Tests.Windows
}
}
/// <summary>Every registered tool has its launcher button on the hub screen.</summary>
private void CheckHub(Node hub)
{
var keys = new HashSet<string>();
CollectButtonKeys(hub, keys);
foreach (var tool in _tools)
Check(keys.Contains(tool.LocaleKey), $"hub: launcher button for {tool.Id}");
SaveShot("novel_editor_hub");
}
private static void CollectButtonKeys(Node node, HashSet<string> keys)
{
if (node is Button button && button.HasMeta(MenuBuilder.LocaleMetaKey))
keys.Add(button.GetMeta(MenuBuilder.LocaleMetaKey).AsString());
foreach (var child in node.GetChildren())
CollectButtonKeys(child, keys);
}
private void InspectAndClose()
{
var id = _tools[_toolIndex].Id;
var window = _window;
_window = null;
ToolWindowsExercises.Verify(id, window, Check);
var errors = _errors.Drain();
Check(errors.Count == 0, $"{id}: no engine errors while opening" + (errors.Count > 0 ? ": " + string.Join(" | ", errors) : ""));
Check(GodotObject.IsInstanceValid(window) && window.IsInsideTree() && window.Visible, $"{id}: window visible in tree");
@@ -190,9 +212,8 @@ namespace Cthangover.Tools.Tests.Windows
var dir = ProjectSettings.GlobalizePath(_shotsDir);
Directory.CreateDirectory(dir);
var image = GetViewport().GetTexture().GetImage();
var path = Path.Combine(dir, id + ".png");
image.SavePng(path);
ViewportCapture.SavePng(GetViewport(), path);
Log($"screenshot {path}");
}
@@ -222,7 +243,7 @@ namespace Cthangover.Tools.Tests.Windows
_finished = true;
OS.RemoveLogger(_errors);
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);
}
private static void Log(string message) => GameLogger.Log("TEST", message);
@@ -37,7 +37,7 @@ namespace Cthangover.Tools.Tests.Windows
driverQueued = true;
GameLogger.Log("TEST", $"ToolWindowsTestInitializer: activating tool windows test, shots='{shots ?? ""}'");
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new ToolWindowsTestDriverNode(shots));
ModRoot.AddDeferred(new ToolWindowsTestDriverNode(shots));
}
}
}