Compare commits
34
Commits
d82733e3f7
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d55b357eb | ||
|
|
877ae0386f | ||
|
|
869a15875e | ||
|
|
af5f17a4ee | ||
|
|
6d89a19696 | ||
|
|
cf993e0d1e | ||
|
|
f8a0cb588f | ||
|
|
b9507b80c3 | ||
|
|
f5cf990d72 | ||
|
|
152096337b | ||
|
|
8944ba756c | ||
|
|
5be5bed0c5 | ||
|
|
cbc9472738 | ||
|
|
84894b1e98 | ||
|
|
80c86063c4 | ||
|
|
93ebb12c58 | ||
|
|
033192dcc9 | ||
|
|
3ddb61d61e | ||
|
|
e1a1d770cf | ||
|
|
6ca1fff35f | ||
|
|
3b2c1ddb95 | ||
|
|
972df5ccff | ||
|
|
2b8eaa5452 | ||
|
|
5f3f4b0d82 | ||
|
|
bd9147e1b6 | ||
|
|
bf4ac5e27a | ||
|
|
2bb0a07edb | ||
|
|
754b854817 | ||
|
|
7b6ae80616 | ||
|
|
55ef9416ef | ||
|
|
8742aa3da5 | ||
|
|
578ba7c5d5 | ||
|
|
e9b9205514 | ||
|
|
03ee4dbc8f |
No files matched your search
@@ -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
@@ -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
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "dev_tools",
|
|
||||||
"name": "Developer Tools",
|
|
||||||
"author": "ct",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Mod authoring toolset: the scenario graph editor, content validators and the mod manager.",
|
|
||||||
"mechanics": [
|
|
||||||
{
|
|
||||||
"id": "editor",
|
|
||||||
"name": "Scenario Editor",
|
|
||||||
"description": "Graph-based scenario and scene authoring tools.",
|
|
||||||
"mods": ["tools>=1"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "validation",
|
|
||||||
"name": "Validation",
|
|
||||||
"description": "Optional content validation suite.",
|
|
||||||
"mods": ["validator>=0.1"],
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "manager",
|
|
||||||
"name": "Mod Manager",
|
|
||||||
"description": "Catalog browser and installer.",
|
|
||||||
"mods": ["mod_manager>=1"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
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
@@ -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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**3. A way there.** In the town's scenario one answer and one label are added:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```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 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
@@ -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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**3. Un camino hasta allí.** En el escenario del pueblo se añaden una respuesta y una etiqueta:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```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í:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
@@ -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` にあります。すべてゲーム内ツールバーから開き、メインメニューにはボタンを追加しません。
|
||||||
|
|
||||||
|
## ツールを開く
|
||||||
|
|
||||||
|
ゲーム内では、ツールバーのハンマーのボタンでツールボックスが開きます:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 7 つのウィンドウはすべてツールボックスにあります: **シーンエディター**、**シナリオエディター**、**マップエディター**、**インタラクティブエディター**、**ライトエディター**、**シーンビルダー**、**カタログ項目を書き出す**。ほかのツール MOD もそこに独自の項目を追加します(アイテム、キャラクター、RPG マップ、RPG オブジェクトのエディター、データエディター)。
|
||||||
|
- **シナリオエディター**はシーンエディターからも開けます。シーンを選び、そのシナリオのひとつを選んで**開く**を押すか、**+ 新しいシナリオ**を押します。
|
||||||
|
|
||||||
|
ウィンドウはシーンを切り替えても開いたまま残り、移動やサイズ変更ができ、×で閉じます。
|
||||||
|
|
||||||
|
## 小さな編集をひと通り: 新しい場所
|
||||||
|
|
||||||
|
ノベルのテンプレートには、町、バー、家があります。ここではエディターだけを使って、町から行ける公園を追加します。
|
||||||
|
|
||||||
|
**1. 新しいシーン。** シーンエディターで **+ 新しいシーン**を押し、名前を入力し、保存先の MOD を選んで背景を追加します:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
保存(Ctrl+S)すると `scenes/my_novel_park.json` が `my_novel_game` に書き込まれ、ゲームはそのシーンをすぐに認識します。
|
||||||
|
|
||||||
|
**2. そのシナリオ。** **+ 新しいシナリオ**を押すと、そのシーンのファイルを開いたシナリオエディターが立ち上がります。公園からは戻る道が一本だけあります:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**3. そこへ行く道。** 町のシナリオに、答えをひとつとラベルをひとつ追加します:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```scenario
|
||||||
|
option "公園へ行く" -> :park
|
||||||
|
|
||||||
|
:park
|
||||||
|
switch_scene my_novel_park
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
シナリオを保存するとゲームのシナリオキャッシュが破棄されるので、次に町へ入ったときには新しいテキストが再生されます。
|
||||||
|
|
||||||
|
**4. 遊んでみる。** シーンエディターの**シーンへ移動**を押すか、そのまま遊び続けると町に入り、新しい答えがそこにあります:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 各エディター
|
||||||
|
|
||||||
|
- **シーンエディター** — ノベルの場所: 名前、保存先の 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
@@ -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`에 있습니다. 모든 창은 게임 도구 모음에서 열며 주 메뉴에는 버튼을 추가하지 않습니다.
|
||||||
|
|
||||||
|
## 도구 열기
|
||||||
|
|
||||||
|
게임 안에서 도구 모음의 망치 버튼을 누르면 도구 상자가 열립니다:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 일곱 창 모두 도구 상자에 있습니다: **장면 에디터**, **시나리오 에디터**, **맵 에디터**, **상호작용 에디터**, **조명 에디터**, **장면 빌더**, **카탈로그 항목 내보내기**. 다른 도구 모드도 그곳에 자기 항목을 추가합니다(아이템, 캐릭터, RPG 맵, RPG 물체 에디터, 데이터 에디터).
|
||||||
|
- **시나리오 에디터**는 장면 에디터에서도 엽니다. 장면을 고르고 그 시나리오 중 하나를 선택한 뒤 **열기**를 누르거나 **+ 새 시나리오**를 누릅니다.
|
||||||
|
|
||||||
|
창은 장면을 바꿔도 열린 채로 남고, 옮기고 크기를 바꿀 수 있으며 ×로 닫습니다.
|
||||||
|
|
||||||
|
## 작은 편집 하나를 끝까지: 새 장소
|
||||||
|
|
||||||
|
소설 템플릿에는 마을, 술집, 집이 있습니다. 여기서는 에디터만 써서 마을에서 갈 수 있는 공원을 추가해 보겠습니다.
|
||||||
|
|
||||||
|
**1. 새 장면.** 장면 에디터에서 **+ 새 장면**을 누르고 이름을 입력하고 저장할 모드를 고른 다음 배경을 추가합니다:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
저장(Ctrl+S)하면 `scenes/my_novel_park.json`이 `my_novel_game`에 기록되고, 게임은 그 장면을 곧바로 인식합니다.
|
||||||
|
|
||||||
|
**2. 그 장면의 시나리오.** **+ 새 시나리오**를 누르면 그 장면의 파일이 열린 시나리오 에디터가 뜹니다. 공원에서는 돌아가는 길이 하나뿐입니다:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**3. 그곳으로 가는 길.** 마을 시나리오에 답 하나와 레이블 하나를 추가합니다:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```scenario
|
||||||
|
option "공원으로 가기" -> :park
|
||||||
|
|
||||||
|
:park
|
||||||
|
switch_scene my_novel_park
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
시나리오를 저장하면 게임의 시나리오 캐시가 비워지므로, 다음에 마을에 들어가면 새 텍스트가 재생됩니다.
|
||||||
|
|
||||||
|
**4. 플레이해 보기.** 장면 에디터의 **장면으로 이동**을 누르거나 그냥 계속 플레이하면 마을로 들어가고, 새 답이 그 자리에 있습니다:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 각 에디터
|
||||||
|
|
||||||
|
- **장면 에디터** — 소설의 장소: 이름, 저장할 모드, 배경(들어갈 때 하나가 무작위로 선택됩니다), 청취 버튼이 있는 환경음, 기본 시나리오, 장면의 시나리오. **장면으로 이동**은 진행 중인 대화를 끝내고 장면으로 들어갑니다.
|
||||||
|
- **시나리오 에디터** — 모든 모드의 파일, 요약, 장면과 전환의 그래프, 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
@@ -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`. Все окна открываются с игрового тулбара; кнопок редакторов в главном меню нет.
|
||||||
|
|
||||||
|
## Как открыть инструменты
|
||||||
|
|
||||||
|
Молоток на панели инструментов в игре открывает набор инструментов:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- Все семь окон есть в наборе: **Редактор сцен**, **Редактор сценариев**, **Редактор карты**, **Редактор интерактивов**, **Редактор освещения**, **Конструктор сцен** и **Экспорт записи каталога**. Другие инструментальные моды добавляют туда свои пункты (редакторы предметов, персонажей, RPG-карт и RPG-объектов, редактор данных).
|
||||||
|
- **Редактор сценариев** открывается и из редактора сцен: выберите сцену, укажите один из её сценариев и нажмите **Открыть** — или **+ Новый сценарий**.
|
||||||
|
|
||||||
|
Окна остаются открытыми при смене сцен, их можно двигать и растягивать, а закрываются они крестиком.
|
||||||
|
|
||||||
|
## Целая небольшая правка: новое место
|
||||||
|
|
||||||
|
В шаблоне новеллы есть город, бар и дом. Добавим туда парк, до которого можно дойти из города, — только средствами редакторов.
|
||||||
|
|
||||||
|
**1. Новая сцена.** В редакторе сцен нажмите **+ Новая сцена**, введите имя, выберите мод для сохранения и добавьте фон:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Сохранение (Ctrl+S) записывает `scenes/my_novel_park.json` в `my_novel_game` — и игра знает сцену сразу.
|
||||||
|
|
||||||
|
**2. Её сценарий.** **+ Новый сценарий** открывает редактор сценариев с файлом для сцены. Из парка есть один путь назад:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**3. Путь туда.** В сценарии города добавляются один ответ и одна метка:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```scenario
|
||||||
|
option "Пойти в парк" -> :park
|
||||||
|
|
||||||
|
:park
|
||||||
|
switch_scene my_novel_park
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Сохранение сценария сбрасывает кэш сценариев игры, так что при следующем входе в город сыграет уже новый текст.
|
||||||
|
|
||||||
|
**4. Сыграйте.** **Перейти в сцену** в редакторе сцен — или просто продолжайте играть — и вы попадёте в город, где новый ответ уже на месте:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Редакторы
|
||||||
|
|
||||||
|
- **Редактор сцен** — места новеллы: имя, мод для сохранения, фоны (при входе один выбирается случайно), звук окружения с кнопкой прослушивания, сценарий по умолчанию и сценарии сцены. **Перейти в сцену** завершает текущий диалог и входит в сцену.
|
||||||
|
- **Редактор сценариев** — файлы всех модов, сводка и граф сцен и переходов; подсветка 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
@@ -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ụ:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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ề:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```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 đã ở đó:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
@@ -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`。所有窗口从游戏工具栏打开,不添加主菜单按钮。
|
||||||
|
|
||||||
|
## 打开工具
|
||||||
|
|
||||||
|
在游戏中,工具栏上的锤子按钮会打开工具箱:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 七个窗口都在工具箱里:**场景编辑器**、**剧本编辑器**、**地图编辑器**、**交互编辑器**、**光照编辑器**、**场景构建器**和**导出目录条目**。其他工具模组会往里面添加自己的条目(物品、角色、RPG 地图和RPG 对象编辑器,以及数据编辑器)。
|
||||||
|
- **剧本编辑器**也可以从场景编辑器打开:选中一个场景,挑一个它的剧本,按**打开**,或者按 **+ 新剧本**。
|
||||||
|
|
||||||
|
这些窗口在切换场景后依然开着,可以拖动和调整大小,点叉号关闭。
|
||||||
|
|
||||||
|
## 一次完整的小改动:一个新地点
|
||||||
|
|
||||||
|
小说模板里有小镇、酒吧和家。下面只用编辑器,给小镇加一个能走过去的公园。
|
||||||
|
|
||||||
|
**1. 新场景。** 在场景编辑器里按 **+ 新场景**,输入名称,选择保存到哪个模组,再添加一个背景:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
保存(Ctrl+S)会把 `scenes/my_novel_park.json` 写进 `my_novel_game`,游戏马上就知道这个场景了。
|
||||||
|
|
||||||
|
**2. 它的剧本。** **+ 新剧本**会打开剧本编辑器,里面是该场景的一个文件。公园只有一条回去的路:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**3. 通往那里的路。** 在小镇的剧本里加一个回答和一个标签:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```scenario
|
||||||
|
option "去公园" -> :park
|
||||||
|
|
||||||
|
:park
|
||||||
|
switch_scene my_novel_park
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
保存剧本会清掉游戏的剧本缓存,所以下次进小镇时播放的就是新文本。
|
||||||
|
|
||||||
|
**4. 试玩。** 在场景编辑器里点**前往场景**,或者干脆继续玩下去,就会进入小镇,新的回答已经在那里了:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 各个编辑器
|
||||||
|
|
||||||
|
- **场景编辑器** —— 小说的地点:名称、保存到的模组、背景(进入时随机选一个)、带试听按钮的环境音、默认剧本和场景的剧本。**前往场景**会结束当前对话并进入该场景。
|
||||||
|
- **剧本编辑器** —— 所有模组的文件、一份概要,以及场景与转场的图;DSL 高亮;Ctrl+S 保存;**试玩**(F5)保存并在运行中的游戏里播放剧本,停止则回到游戏原来的位置;**在外部编辑器中打开**会用系统默认程序打开文件。
|
||||||
|
- **地图编辑器** —— 世界地图上的区域。
|
||||||
|
- **交互编辑器** —— 当前场景里可点击的对象。
|
||||||
|
- **光照编辑器** —— 当前场景的灯光。
|
||||||
|
- **场景构建器** —— 为模组制作 Godot `.tscn` 场景。
|
||||||
|
- **目录导出** —— 把模组打包成 zip,并写出带校验和的模组目录条目。
|
||||||
|
|
||||||
|
Validator 检查模组间引用;保存普通对话脚本不执行完整检查。加载 `npc_barks_base` 时,无效后台程序及其补丁会在写入前被拒绝,状态栏显示编译错误。
|
||||||
|
|
||||||
|
## 写的是谁的文件
|
||||||
|
|
||||||
|
这些工具只写安装成文件夹的模组。一个场景是所有模组 `scenes/<name>.json` 文件的总和,所以场景编辑器写的是**保存到**里选中的那个模组的文件。改动属于别的模组或打包模组的场景时,会在你的模组里新建一个文件来补充它;来自其他文件的背景会显示为锁定。剧本则保存在它所属的模组里。
|
||||||
|
|
||||||
|
## 出问题了怎么办
|
||||||
|
|
||||||
|
1. **找不到锤子按钮。** 这个游戏没带工具;在它的集合里加上 Developer Tools 工具集,或者用 `--with-mods=editor_tools` 启动。
|
||||||
|
2. **新剧本不播放。** 它的头部写的是另一个场景,或者这个场景里有别的剧本先被选中了(比较它们的 `priority`)。
|
||||||
|
3. **「保存到」里没有模组。** 只有安装成文件夹的模组才能写入。
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Catalog export tool — extra keys
|
|
||||||
mods/catalog_select_mod = Select a mod:
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Catalog export tool — дополнительные ключи
|
|
||||||
mods/catalog_select_mod = Выберите мод:
|
|
||||||
+2
-170
@@ -1,170 +1,2 @@
|
|||||||
mod_tools_name = Tools
|
mod_editor_tools_name = Editor Tools
|
||||||
mod_tools_desc = Development tools
|
mod_editor_tools_desc = Compatibility toolset: installs the individual scene, scenario, map, interactive, light, Godot scene and catalog tools.
|
||||||
|
|
||||||
tools/light_editor/title = Light Editor
|
|
||||||
tools/light_editor/background = Background
|
|
||||||
tools/light_editor/static_lights = Static lights (max. 10)
|
|
||||||
tools/light_editor/add_light = + Add
|
|
||||||
tools/light_editor/selected_props = Selected properties
|
|
||||||
tools/light_editor/radius = Radius
|
|
||||||
tools/light_editor/influence = Influence
|
|
||||||
tools/light_editor/color = Color
|
|
||||||
tools/light_editor/import_json = Import from JSON
|
|
||||||
tools/light_editor/norm_coords = Norm. coords. (0-1). Drag markers.
|
|
||||||
tools/light_editor/light_n = Light {0} ({1:F3},{2:F3})
|
|
||||||
tools/light_editor/btn_select = S
|
|
||||||
tools/light_editor/btn_delete = D
|
|
||||||
|
|
||||||
tools/scene_builder/title = Scene Builder
|
|
||||||
tools/scene_builder/base_scene = Base scene:
|
|
||||||
tools/scene_builder/mod = Mod:
|
|
||||||
tools/scene_builder/scene = Scene:
|
|
||||||
tools/scene_builder/hierarchy = Hierarchy
|
|
||||||
tools/scene_builder/tscn = TSCN
|
|
||||||
tools/scene_builder/output = Output
|
|
||||||
tools/scene_builder/clear = Clear
|
|
||||||
tools/scene_builder/refresh = Refresh
|
|
||||||
tools/scene_builder/reload = Reload
|
|
||||||
tools/scene_builder/save = Save
|
|
||||||
tools/scene_builder/select_scene = -- Select a scene --
|
|
||||||
tools/scene_builder/select_mod = -- Select a mod --
|
|
||||||
tools/scene_builder/empty_scene = (empty scene)
|
|
||||||
tools/scene_builder/error_no_scene = Error: scene not selected.
|
|
||||||
tools/scene_builder/error_save = Save error.
|
|
||||||
tools/scene_builder/saved_ok = Saved.
|
|
||||||
tools/scene_builder/reloaded = Reloaded.
|
|
||||||
tools/scene_builder/inspector = Inspector
|
|
||||||
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/autosaved = Auto-saved.
|
|
||||||
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/add_node_tip = Add child node of the selected type (to selection, or scene root)
|
|
||||||
tools/scene_builder/delete_node_tip = Delete selected mod node
|
|
||||||
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/scene_builder/tab_inspector = Inspector
|
|
||||||
tools/scene_builder/tab_tscn = TSCN
|
|
||||||
tools/scene_builder/light_editor = Light Editor
|
|
||||||
tools/scene_builder/scene_builder = Scene Builder
|
|
||||||
|
|
||||||
tools/scenario_editor/title = Scenario Editor
|
|
||||||
tools/scenario_editor/save = Save
|
|
||||||
tools/scenario_editor/open_file = Open file
|
|
||||||
tools/scenario_editor/tab_files = Files
|
|
||||||
tools/scenario_editor/tab_info = Info
|
|
||||||
tools/scenario_editor/tab_graph = Graph
|
|
||||||
tools/scenario_editor/graph_select_node = Select a node to see details
|
|
||||||
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 transitions
|
|
||||||
tools/scenario_editor/info_locale_keys = Locale keys
|
|
||||||
tools/scenario_editor/info_quest_refs = Quests
|
|
||||||
tools/scenario_editor/bg_previews = Background previews
|
|
||||||
tools/scenario_editor/no_backgrounds = No backgrounds
|
|
||||||
tools/scenario_editor/status_modified = Modified (unsaved)
|
|
||||||
tools/scenario_editor/status_saved = Saved
|
|
||||||
tools/scenario_editor/save_error = Save error: {0}
|
|
||||||
tools/scenario_editor/readonly_zip = File is provided by a zip mod and is read-only
|
|
||||||
tools/scenario_editor/unsaved_switch_text = You have unsaved changes. Switch file without saving?
|
|
||||||
|
|
||||||
tools/unsaved_title = Unsaved changes
|
|
||||||
tools/unsaved_text = You have unsaved changes. Close without saving?
|
|
||||||
|
|
||||||
mods/catalog_select_mod = Select a mod:
|
|
||||||
|
|
||||||
tools/scenario_editor/play = ▶ Play
|
|
||||||
tools/scenario_editor/play_tooltip = Run scenario in test mode
|
|
||||||
tools/scenario_editor/stop = ⏹ Stop
|
|
||||||
tools/scenario_editor/no_scene_error = Error: scene (scene:) not specified in scenario metadata
|
|
||||||
|
|
||||||
tools/interactive_editor/title = Interactive Editor
|
|
||||||
|
|
||||||
tools/interactive_editor/btn_load_json = Load JSON
|
|
||||||
tools/interactive_editor/btn_save = Save
|
|
||||||
tools/interactive_editor/btn_import_mod = Import from mod
|
|
||||||
tools/interactive_editor/dlg_select_def = Select Definition
|
|
||||||
|
|
||||||
tools/interactive_editor/tab_background = Background
|
|
||||||
tools/interactive_editor/tab_interactive = Interactive
|
|
||||||
tools/interactive_editor/tab_scenario = Scenarios
|
|
||||||
|
|
||||||
tools/interactive_editor/label_id = ID:
|
|
||||||
tools/interactive_editor/label_texture_key = Texture key:
|
|
||||||
tools/interactive_editor/label_layer = Layer:
|
|
||||||
tools/interactive_editor/label_bg_preview = Background preview:
|
|
||||||
tools/interactive_editor/label_collider_type = Collider type:
|
|
||||||
tools/interactive_editor/label_rect_params = Rect (norm. 0-1):
|
|
||||||
tools/interactive_editor/label_x = x:
|
|
||||||
tools/interactive_editor/label_y = y:
|
|
||||||
tools/interactive_editor/label_w = w:
|
|
||||||
tools/interactive_editor/label_h = h:
|
|
||||||
tools/interactive_editor/label_circle_params = Circle (norm. 0-1):
|
|
||||||
tools/interactive_editor/label_cx = cx:
|
|
||||||
tools/interactive_editor/label_cy = cy:
|
|
||||||
tools/interactive_editor/label_r = r:
|
|
||||||
tools/interactive_editor/label_polygon_vertices = Vertices:
|
|
||||||
tools/interactive_editor/btn_add_vertex = + Add vertex
|
|
||||||
tools/interactive_editor/label_highlight_color = Highlight color:
|
|
||||||
tools/interactive_editor/label_highlight_scale = Highlight scale:
|
|
||||||
tools/interactive_editor/label_cursor = Cursor:
|
|
||||||
tools/interactive_editor/cursor_none = (none)
|
|
||||||
tools/interactive_editor/label_onclick_scenario = OnClick scenario:
|
|
||||||
tools/interactive_editor/label_onclick_commands = OnClick commands:
|
|
||||||
tools/interactive_editor/label_hover_enter = OnHoverEnter DSL:
|
|
||||||
tools/interactive_editor/label_hover_leave = OnHoverLeave DSL:
|
|
||||||
|
|
||||||
tools/map_editor/title = Map Editor
|
|
||||||
tools/map_editor/mod = Mod:
|
|
||||||
tools/map_editor/file = File:
|
|
||||||
tools/map_editor/load = Load
|
|
||||||
tools/map_editor/save = Save
|
|
||||||
tools/map_editor/save_ok = Saved.
|
|
||||||
tools/map_editor/reloaded = Map reloaded.
|
|
||||||
tools/map_editor/load_error = Load error
|
|
||||||
tools/map_editor/readonly = Cannot save: mod is not writable (zip) or file path is invalid.
|
|
||||||
tools/map_editor/no_mods = No writable mods
|
|
||||||
tools/map_editor/no_file = (new file)
|
|
||||||
tools/map_editor/reload_map = Reload map
|
|
||||||
tools/map_editor/background = Background:
|
|
||||||
tools/map_editor/zones = Zones
|
|
||||||
tools/map_editor/props = Selected zone
|
|
||||||
tools/map_editor/add_region = + Region
|
|
||||||
tools/map_editor/add_marker = + Marker
|
|
||||||
tools/map_editor/add_label = + Label
|
|
||||||
tools/map_editor/delete = Delete
|
|
||||||
tools/map_editor/select_mode = Select
|
|
||||||
tools/map_editor/draw_region = Draw region
|
|
||||||
tools/map_editor/id = ID:
|
|
||||||
tools/map_editor/id_empty = ID must not be empty.
|
|
||||||
tools/map_editor/duplicate_id = Duplicate zone ID.
|
|
||||||
tools/map_editor/title_key = Title locale key:
|
|
||||||
tools/map_editor/no_translation = no translation
|
|
||||||
tools/map_editor/scene = Scene:
|
|
||||||
tools/map_editor/scene_none = (none)
|
|
||||||
tools/map_editor/description = Description locale key:
|
|
||||||
tools/map_editor/image = Marker icon:
|
|
||||||
tools/map_editor/image_pos = Marker position (0-1):
|
|
||||||
tools/map_editor/label = Text label on map
|
|
||||||
tools/map_editor/locked = Closed for travel (locked)
|
|
||||||
tools/map_editor/hidden = Hidden from map
|
|
||||||
tools/map_editor/vertices = Polygon vertices
|
|
||||||
tools/map_editor/add_vertex = + Vertex
|
|
||||||
tools/map_editor/x = x:
|
|
||||||
tools/map_editor/y = y:
|
|
||||||
tools/map_editor/zone_missing = No zone selected.
|
|
||||||
tools/map_editor/preview = ▶ Preview
|
|
||||||
tools/map_editor/preview_tip = View the map as in game: hover, location card, markers
|
|
||||||
tools/map_editor/preview_on = Preview mode: Esc or ✕ returns to the editor.
|
|
||||||
tools/map_editor/preview_off = Edit mode.
|
|
||||||
tools/map_editor/preview_missing = Failed to load the map widget scene (map_widget.tscn).
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_editor_tools_name = エディターツール
|
||||||
|
mod_editor_tools_desc = シーン、シナリオ、マップ、操作対象、照明、Godotシーン、カタログ出力の互換ツールセット。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_editor_tools_name = 에디터 도구
|
||||||
|
mod_editor_tools_desc = 장면, 시나리오, 지도, 상호작용, 조명, Godot 장면 및 카탈로그 내보내기 도구의 호환 모음.
|
||||||
+2
-169
@@ -1,169 +1,2 @@
|
|||||||
# Русская локализация для инструментов разработки
|
mod_editor_tools_name = Редакторы
|
||||||
mod_tools_name = Инструменты
|
mod_editor_tools_desc = Совместимый набор отдельных редакторов сцен, сценариев, карт, интерактивов, света, Godot-сцен и экспорта каталога.
|
||||||
mod_tools_desc = Инструменты разработчика
|
|
||||||
|
|
||||||
tools/light_editor/title = Редактор освещения
|
|
||||||
tools/light_editor/background = Фон
|
|
||||||
tools/light_editor/static_lights = Статичные источники (макс. 10)
|
|
||||||
tools/light_editor/add_light = + Добавить
|
|
||||||
tools/light_editor/selected_props = Свойства выбранного
|
|
||||||
tools/light_editor/radius = Радиус
|
|
||||||
tools/light_editor/influence = Влияние
|
|
||||||
tools/light_editor/color = Цвет
|
|
||||||
tools/light_editor/import_json = Импорт из JSON
|
|
||||||
tools/light_editor/norm_coords = Норм. коорд. (0-1). Перетащите маркеры.
|
|
||||||
tools/light_editor/light_n = Источник {0} ({1:F3},{2:F3})
|
|
||||||
tools/light_editor/btn_select = В
|
|
||||||
tools/light_editor/btn_delete = У
|
|
||||||
|
|
||||||
tools/scene_builder/title = Конструктор сцен
|
|
||||||
tools/scene_builder/base_scene = Базовая сцена:
|
|
||||||
tools/scene_builder/mod = Мод:
|
|
||||||
tools/scene_builder/scene = Сцена:
|
|
||||||
tools/scene_builder/hierarchy = Иерархия
|
|
||||||
tools/scene_builder/tscn = TSCN
|
|
||||||
tools/scene_builder/output = Вывод
|
|
||||||
tools/scene_builder/clear = Очистить
|
|
||||||
tools/scene_builder/refresh = Обновить
|
|
||||||
tools/scene_builder/reload = Перезагрузить
|
|
||||||
tools/scene_builder/save = Сохранить
|
|
||||||
tools/scene_builder/select_scene = -- Выберите сцену --
|
|
||||||
tools/scene_builder/select_mod = -- Выберите мод --
|
|
||||||
tools/scene_builder/empty_scene = (пустая сцена)
|
|
||||||
tools/scene_builder/error_no_scene = Ошибка: сцена не выбрана.
|
|
||||||
tools/scene_builder/error_save = Ошибка сохранения.
|
|
||||||
tools/scene_builder/saved_ok = Сохранено.
|
|
||||||
tools/scene_builder/reloaded = Перезагружено.
|
|
||||||
tools/scene_builder/inspector = Инспектор
|
|
||||||
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/autosaved = Автосохранено.
|
|
||||||
tools/scene_builder/inspector_bad_value = Значение не может быть записано в tscn.
|
|
||||||
tools/scene_builder/inspector_in_file_tooltip = Записано в tscn-файле
|
|
||||||
tools/scene_builder/inspector_container_banner = Узлом управляет контейнер: якоря и отступы пересчитываются родителем и здесь не действуют.
|
|
||||||
tools/scene_builder/add_node_tip = Добавить дочерний узел выбранного типа (в выделение или корень сцены)
|
|
||||||
tools/scene_builder/delete_node_tip = Удалить выбранный узел мод-сцены
|
|
||||||
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/scene_builder/tab_inspector = Инспектор
|
|
||||||
tools/scene_builder/tab_tscn = TSCN
|
|
||||||
tools/scene_builder/light_editor = Редактор освещения
|
|
||||||
tools/scene_builder/scene_builder = Конструктор сцен
|
|
||||||
|
|
||||||
tools/scenario_editor/title = Редактор сценариев
|
|
||||||
tools/scenario_editor/save = Сохранить
|
|
||||||
tools/scenario_editor/open_file = Открыть файл
|
|
||||||
tools/scenario_editor/tab_files = Файлы
|
|
||||||
tools/scenario_editor/tab_info = Инфо
|
|
||||||
tools/scenario_editor/tab_graph = Граф
|
|
||||||
tools/scenario_editor/graph_select_node = Выберите узел для просмотра деталей
|
|
||||||
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/no_backgrounds = Нет фонов
|
|
||||||
tools/scenario_editor/status_modified = Изменено (не сохранено)
|
|
||||||
tools/scenario_editor/status_saved = Сохранено
|
|
||||||
tools/scenario_editor/save_error = Ошибка сохранения: {0}
|
|
||||||
tools/scenario_editor/readonly_zip = Файл из zip-мода доступен только для чтения
|
|
||||||
tools/scenario_editor/unsaved_switch_text = У вас есть несохранённые изменения. Сменить файл без сохранения?
|
|
||||||
|
|
||||||
tools/unsaved_title = Есть изменения
|
|
||||||
tools/unsaved_text = У вас есть несохранённые изменения. Закрыть без сохранения?
|
|
||||||
|
|
||||||
tools/scenario_editor/play = ▶ Запустить
|
|
||||||
tools/scenario_editor/play_tooltip = Запустить сценарий в тестовом режиме
|
|
||||||
tools/scenario_editor/stop = ⏹ Стоп
|
|
||||||
tools/scenario_editor/no_scene_error = Ошибка: не указана сцена (scene:) в метаданных сценария
|
|
||||||
|
|
||||||
tools/interactive_editor/title = Редактор интерактивов
|
|
||||||
|
|
||||||
tools/interactive_editor/btn_load_json = Загрузить JSON
|
|
||||||
tools/interactive_editor/btn_save = Сохранить
|
|
||||||
tools/interactive_editor/btn_import_mod = Импорт из мода
|
|
||||||
tools/interactive_editor/dlg_select_def = Выбор определения
|
|
||||||
|
|
||||||
tools/interactive_editor/tab_background = Задник
|
|
||||||
tools/interactive_editor/tab_interactive = Интерактив
|
|
||||||
tools/interactive_editor/tab_scenario = Сценарии
|
|
||||||
|
|
||||||
tools/interactive_editor/label_id = ID:
|
|
||||||
tools/interactive_editor/label_texture_key = Ключ текстуры:
|
|
||||||
tools/interactive_editor/label_layer = Слой:
|
|
||||||
tools/interactive_editor/label_bg_preview = Превью задника:
|
|
||||||
tools/interactive_editor/label_collider_type = Тип коллайдера:
|
|
||||||
tools/interactive_editor/label_rect_params = Прямоугольник (норм. 0-1):
|
|
||||||
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_circle_params = Окружность (норм. 0-1):
|
|
||||||
tools/interactive_editor/label_cx = cx:
|
|
||||||
tools/interactive_editor/label_cy = cy:
|
|
||||||
tools/interactive_editor/label_r = r:
|
|
||||||
tools/interactive_editor/label_polygon_vertices = Вершины:
|
|
||||||
tools/interactive_editor/btn_add_vertex = + Добавить вершину
|
|
||||||
tools/interactive_editor/label_highlight_color = Цвет подсветки:
|
|
||||||
tools/interactive_editor/label_highlight_scale = Масштаб подсветки:
|
|
||||||
tools/interactive_editor/label_cursor = Курсор:
|
|
||||||
tools/interactive_editor/cursor_none = (нет)
|
|
||||||
tools/interactive_editor/label_onclick_scenario = Сценарий по клику:
|
|
||||||
tools/interactive_editor/label_onclick_commands = Команды по клику:
|
|
||||||
tools/interactive_editor/label_hover_enter = OnHoverEnter DSL:
|
|
||||||
tools/interactive_editor/label_hover_leave = OnHoverLeave DSL:
|
|
||||||
|
|
||||||
tools/map_editor/title = Редактор карты
|
|
||||||
tools/map_editor/mod = Мод:
|
|
||||||
tools/map_editor/file = Файл:
|
|
||||||
tools/map_editor/load = Загрузить
|
|
||||||
tools/map_editor/save = Сохранить
|
|
||||||
tools/map_editor/save_ok = Сохранено.
|
|
||||||
tools/map_editor/reloaded = Карта перезагружена.
|
|
||||||
tools/map_editor/load_error = Ошибка загрузки
|
|
||||||
tools/map_editor/readonly = Не удалось сохранить: мод не для записи (zip) или неверный путь файла.
|
|
||||||
tools/map_editor/no_mods = Нет модов для записи
|
|
||||||
tools/map_editor/no_file = (новый файл)
|
|
||||||
tools/map_editor/reload_map = Перезагрузить карту
|
|
||||||
tools/map_editor/background = Фон:
|
|
||||||
tools/map_editor/zones = Зоны
|
|
||||||
tools/map_editor/props = Выбранная зона
|
|
||||||
tools/map_editor/add_region = + Регион
|
|
||||||
tools/map_editor/add_marker = + Метка
|
|
||||||
tools/map_editor/add_label = + Надпись
|
|
||||||
tools/map_editor/delete = Удалить
|
|
||||||
tools/map_editor/select_mode = Выбор
|
|
||||||
tools/map_editor/draw_region = Рисовать регион
|
|
||||||
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/no_translation = нет перевода
|
|
||||||
tools/map_editor/scene = Сцена:
|
|
||||||
tools/map_editor/scene_none = (нет)
|
|
||||||
tools/map_editor/description = Ключ описания:
|
|
||||||
tools/map_editor/image = Иконка метки:
|
|
||||||
tools/map_editor/image_pos = Позиция метки (0-1):
|
|
||||||
tools/map_editor/label = Текстовая надпись на карте
|
|
||||||
tools/map_editor/locked = Закрыта для посещения
|
|
||||||
tools/map_editor/hidden = Скрыта с карты
|
|
||||||
tools/map_editor/vertices = Вершины полигона
|
|
||||||
tools/map_editor/add_vertex = + Вершина
|
|
||||||
tools/map_editor/x = x:
|
|
||||||
tools/map_editor/y = y:
|
|
||||||
tools/map_editor/zone_missing = Зона не выбрана.
|
|
||||||
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).
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod_editor_tools_name = 编辑工具
|
||||||
|
mod_editor_tools_desc = 兼容工具包,安装场景、剧本、地图、交互、灯光、Godot 场景和目录导出工具。
|
||||||
+28
-8
@@ -1,12 +1,32 @@
|
|||||||
{
|
{
|
||||||
"id": "tools",
|
"id": "editor_tools",
|
||||||
"name": "Tools",
|
"name": "Editor Tools",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"author": "ct",
|
"author": "ct",
|
||||||
"description": "Development tools",
|
"description": "Compatibility toolset: installs the individual scene, scenario, map, interactive, light, Godot scene and catalog tools.",
|
||||||
"category": "tools, editor",
|
"categories": ["tools", "editor"],
|
||||||
"sources": [
|
"sources": [],
|
||||||
"src/**/*.cs"
|
"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": ["visual_novel_core>=1"]
|
"test_suites": [
|
||||||
|
{
|
||||||
|
"id": "windows",
|
||||||
|
"activate": [
|
||||||
|
"--itest=tools_windows"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "novel_flow",
|
||||||
|
"activate": [
|
||||||
|
"--itest=novel_flow"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -1,12 +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
|
|
||||||
|
|
||||||
[node name="FileTree" type="Tree" parent="."]
|
|
||||||
size_flags_horizontal = 3
|
|
||||||
size_flags_vertical = 3
|
|
||||||
hide_root = true
|
|
||||||
@@ -1,47 +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 = 3
|
|
||||||
|
|
||||||
[node name="VBox" type="VBoxContainer" parent="Scroll"]
|
|
||||||
theme_override_constants/separation = 8
|
|
||||||
|
|
||||||
[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 = "-"
|
|
||||||
@@ -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,26 +0,0 @@
|
|||||||
using Cthangover.Core.UI.Tool;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,499 +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.UI.Tool;
|
|
||||||
using Cthangover.Core.Utils;
|
|
||||||
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/>
|
|
||||||
/// The window lets the author pick any discovered mod, fill in the
|
|
||||||
/// catalog-only metadata (download URL, category, tags, changelog, status,
|
|
||||||
/// minimum game version, icon URL, localized names/descriptions), preview
|
|
||||||
/// the resulting JSON, copy it to the clipboard, or export it next to the
|
|
||||||
/// mod's zip via <see cref="ModCatalogExporter"/>.
|
|
||||||
/// </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 ModInfo _modInfo;
|
|
||||||
private ModManifest _manifest;
|
|
||||||
private LineEdit _urlEdit;
|
|
||||||
private ItemList _categoryList;
|
|
||||||
private LineEdit _tagsEdit;
|
|
||||||
private TextEdit _changelogEdit;
|
|
||||||
private OptionButton _statusOpt;
|
|
||||||
private LineEdit _minGameVerEdit;
|
|
||||||
private LineEdit _iconUrlEdit;
|
|
||||||
private VBoxContainer _localeContainer;
|
|
||||||
private TextEdit _previewEdit;
|
|
||||||
|
|
||||||
public CatalogExportWindow() : base("mods/catalog_title")
|
|
||||||
{
|
|
||||||
Size = new Vector2I(620, 720);
|
|
||||||
CthangoverTheme.Apply(this);
|
|
||||||
|
|
||||||
var vbox = CreateFillContainer();
|
|
||||||
vbox.AddThemeConstantOverride("separation", 8);
|
|
||||||
AddChild(vbox);
|
|
||||||
|
|
||||||
var selectRow = new HBoxContainer();
|
|
||||||
selectRow.AddThemeConstantOverride("separation", 8);
|
|
||||||
var selectLabel = new Label { Text = TranslationServer.Translate("mods/catalog_select_mod") };
|
|
||||||
selectLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
|
|
||||||
selectRow.AddChild(selectLabel);
|
|
||||||
|
|
||||||
_modSelect = new OptionButton
|
|
||||||
{
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
};
|
|
||||||
_modSelect.ItemSelected += OnModSelected;
|
|
||||||
selectRow.AddChild(_modSelect);
|
|
||||||
vbox.AddChild(selectRow);
|
|
||||||
|
|
||||||
var scroll = new ScrollContainer
|
|
||||||
{
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
|
|
||||||
HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled,
|
|
||||||
};
|
|
||||||
vbox.AddChild(scroll);
|
|
||||||
|
|
||||||
_formHost = new VBoxContainer
|
|
||||||
{
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
};
|
|
||||||
_formHost.AddThemeConstantOverride("separation", 8);
|
|
||||||
scroll.AddChild(_formHost);
|
|
||||||
|
|
||||||
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);
|
|
||||||
_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();
|
|
||||||
|
|
||||||
_modInfo = ModRegistry.Instance.GetMod(modId) as ModInfo;
|
|
||||||
_manifest = _modInfo?.Manifest;
|
|
||||||
if (_modInfo == null || _manifest == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
AddSectionLabel(_formHost, "Mod Info");
|
|
||||||
AddReadOnlyField(_formHost, "mods/id", _modInfo.Id);
|
|
||||||
AddReadOnlyField(_formHost, "mods/name", _modInfo.DisplayTitle);
|
|
||||||
AddReadOnlyField(_formHost, "mods/version", _modInfo.Version ?? "—");
|
|
||||||
AddReadOnlyField(_formHost, "mods/author", _modInfo.Author ?? "—");
|
|
||||||
|
|
||||||
AddSectionLabel(_formHost, "Catalog Metadata");
|
|
||||||
_urlEdit = AddLineEdit(_formHost, "mods/catalog_download_url");
|
|
||||||
|
|
||||||
_categoryList = new ItemList
|
|
||||||
{
|
|
||||||
CustomMinimumSize = new Vector2(0, 130),
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
SelectMode = ItemList.SelectModeEnum.Toggle,
|
|
||||||
};
|
|
||||||
var preselected = _manifest.Category ?? CategoryType.Other;
|
|
||||||
for (var i = 0; i < Categories.Length; i++)
|
|
||||||
{
|
|
||||||
_categoryList.AddItem(GetCategoryDisplayName(Categories[i]));
|
|
||||||
if ((preselected & Categories[i]) != 0)
|
|
||||||
_categoryList.Select(i, false);
|
|
||||||
}
|
|
||||||
AddLabeledControl(_formHost, "mods/catalog_category", _categoryList);
|
|
||||||
|
|
||||||
_tagsEdit = AddLineEdit(_formHost, "mods/catalog_tags");
|
|
||||||
|
|
||||||
_changelogEdit = new TextEdit
|
|
||||||
{
|
|
||||||
CustomMinimumSize = new Vector2(0, 60),
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
WrapMode = TextEdit.LineWrappingMode.Boundary,
|
|
||||||
};
|
|
||||||
AddLabeledControl(_formHost, "mods/catalog_changelog", _changelogEdit);
|
|
||||||
|
|
||||||
_statusOpt = AddOptionButton(_formHost, "mods/catalog_status", Statuses);
|
|
||||||
|
|
||||||
_minGameVerEdit = AddLineEdit(_formHost, "mods/catalog_min_game_version");
|
|
||||||
_iconUrlEdit = AddLineEdit(_formHost, "mods/catalog_icon_url");
|
|
||||||
|
|
||||||
AddSectionLabel(_formHost, "JSON Preview");
|
|
||||||
_previewEdit = new TextEdit
|
|
||||||
{
|
|
||||||
CustomMinimumSize = new Vector2(0, 160),
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
Editable = false,
|
|
||||||
WrapMode = TextEdit.LineWrappingMode.None,
|
|
||||||
};
|
|
||||||
_previewEdit.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
|
|
||||||
_formHost.AddChild(_previewEdit);
|
|
||||||
|
|
||||||
var btnRow = new HBoxContainer
|
|
||||||
{
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
};
|
|
||||||
btnRow.AddThemeConstantOverride("separation", 8);
|
|
||||||
|
|
||||||
var copyBtn = new Button
|
|
||||||
{
|
|
||||||
Text = TranslationServer.Translate("mods/catalog_copy"),
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
};
|
|
||||||
copyBtn.Pressed += OnCopyPressed;
|
|
||||||
btnRow.AddChild(copyBtn);
|
|
||||||
|
|
||||||
var exportBtn = new Button
|
|
||||||
{
|
|
||||||
Text = TranslationServer.Translate("mods/catalog_export_file"),
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
};
|
|
||||||
exportBtn.Pressed += OnExportPressed;
|
|
||||||
btnRow.AddChild(exportBtn);
|
|
||||||
|
|
||||||
var closeBtn = new Button
|
|
||||||
{
|
|
||||||
Text = TranslationServer.Translate("mods/catalog_close"),
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
};
|
|
||||||
closeBtn.Pressed += () => QueueFree();
|
|
||||||
btnRow.AddChild(closeBtn);
|
|
||||||
|
|
||||||
_formHost.AddChild(btnRow);
|
|
||||||
|
|
||||||
AddSectionLabel(_formHost, "Locales");
|
|
||||||
_localeContainer = new VBoxContainer
|
|
||||||
{
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
};
|
|
||||||
_localeContainer.AddThemeConstantOverride("separation", 4);
|
|
||||||
_formHost.AddChild(_localeContainer);
|
|
||||||
|
|
||||||
var addLocaleBtn = new Button
|
|
||||||
{
|
|
||||||
Text = TranslationServer.Translate("mods/catalog_add_locale"),
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
};
|
|
||||||
addLocaleBtn.Pressed += () => AddLocaleRow(string.Empty, string.Empty, string.Empty);
|
|
||||||
_formHost.AddChild(addLocaleBtn);
|
|
||||||
|
|
||||||
_urlEdit.TextChanged += _ => UpdatePreview();
|
|
||||||
_categoryList.ItemSelected += _ => UpdatePreview();
|
|
||||||
_categoryList.MultiSelected += (_, _) => UpdatePreview();
|
|
||||||
_tagsEdit.TextChanged += _ => UpdatePreview();
|
|
||||||
_changelogEdit.TextChanged += UpdatePreview;
|
|
||||||
_statusOpt.ItemSelected += _ => UpdatePreview();
|
|
||||||
_minGameVerEdit.TextChanged += _ => UpdatePreview();
|
|
||||||
_iconUrlEdit.TextChanged += _ => UpdatePreview();
|
|
||||||
|
|
||||||
UpdatePreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetCategoryDisplayName(CategoryType cat) => cat switch
|
|
||||||
{
|
|
||||||
CategoryType.Story => TranslationServer.Translate("mods/category_story"),
|
|
||||||
CategoryType.Characters => TranslationServer.Translate("mods/category_characters"),
|
|
||||||
CategoryType.Gameplay => TranslationServer.Translate("mods/category_gameplay"),
|
|
||||||
CategoryType.Visual => TranslationServer.Translate("mods/category_visual"),
|
|
||||||
CategoryType.Audio => TranslationServer.Translate("mods/category_audio"),
|
|
||||||
CategoryType.UI => TranslationServer.Translate("mods/category_ui"),
|
|
||||||
CategoryType.Locale => TranslationServer.Translate("mods/category_locale"),
|
|
||||||
CategoryType.Library => TranslationServer.Translate("mods/category_library"),
|
|
||||||
CategoryType.Engine => TranslationServer.Translate("mods/category_engine"),
|
|
||||||
CategoryType.Overhaul => TranslationServer.Translate("mods/category_overhaul"),
|
|
||||||
CategoryType.Tools => TranslationServer.Translate("mods/category_tools"),
|
|
||||||
CategoryType.Editor => TranslationServer.Translate("mods/category_editor"),
|
|
||||||
CategoryType.Debug => TranslationServer.Translate("mods/category_debug"),
|
|
||||||
CategoryType.Other => TranslationServer.Translate("mods/category_other"),
|
|
||||||
_ => cat.ToString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
private void AddLocaleRow(string lang, string name, string description)
|
|
||||||
{
|
|
||||||
var row = new HBoxContainer
|
|
||||||
{
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
};
|
|
||||||
row.AddThemeConstantOverride("separation", 4);
|
|
||||||
|
|
||||||
var langEdit = new LineEdit
|
|
||||||
{
|
|
||||||
CustomMinimumSize = new Vector2(44, 0),
|
|
||||||
PlaceholderText = "en",
|
|
||||||
MaxLength = 5,
|
|
||||||
Text = lang,
|
|
||||||
};
|
|
||||||
row.AddChild(langEdit);
|
|
||||||
|
|
||||||
var nameEdit = new LineEdit
|
|
||||||
{
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
PlaceholderText = TranslationServer.Translate("mods/catalog_locale_name"),
|
|
||||||
Text = name,
|
|
||||||
};
|
|
||||||
row.AddChild(nameEdit);
|
|
||||||
|
|
||||||
var descEdit = new LineEdit
|
|
||||||
{
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
PlaceholderText = TranslationServer.Translate("mods/catalog_locale_desc"),
|
|
||||||
Text = description,
|
|
||||||
};
|
|
||||||
row.AddChild(descEdit);
|
|
||||||
|
|
||||||
var removeBtn = new Button { Text = "×", CustomMinimumSize = new Vector2(28, 0) };
|
|
||||||
removeBtn.Pressed += () => RemoveLocaleRow(row);
|
|
||||||
row.AddChild(removeBtn);
|
|
||||||
|
|
||||||
langEdit.TextChanged += _ => UpdatePreview();
|
|
||||||
nameEdit.TextChanged += _ => UpdatePreview();
|
|
||||||
descEdit.TextChanged += _ => UpdatePreview();
|
|
||||||
|
|
||||||
_localeContainer.AddChild(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RemoveLocaleRow(HBoxContainer row)
|
|
||||||
{
|
|
||||||
_localeContainer.RemoveChild(row);
|
|
||||||
row.QueueFree();
|
|
||||||
UpdatePreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdatePreview()
|
|
||||||
{
|
|
||||||
if (_previewEdit == null || _modInfo == null || _manifest == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var entry = BuildEntryFromManifest(_manifest);
|
|
||||||
entry.Downloads = new List<CatalogEntryDownload>
|
|
||||||
{
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
Version = entry.Version,
|
|
||||||
Urls = new List<string> { _urlEdit.Text.Trim() },
|
|
||||||
SizeBytes = 0,
|
|
||||||
ArchiveSha256 = "—",
|
|
||||||
InstallSha256 = "—",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
entry.Category = CollectSelectedCategories(_categoryList, Categories);
|
|
||||||
entry.Tags = ParseTags(_tagsEdit.Text);
|
|
||||||
entry.Downloads[0].Changelog = NullIfEmpty(_changelogEdit.Text);
|
|
||||||
entry.Status = Statuses[_statusOpt.Selected];
|
|
||||||
entry.MinGameVersion = NullIfEmpty(_minGameVerEdit.Text);
|
|
||||||
entry.IconUrl = NullIfEmpty(_iconUrlEdit.Text);
|
|
||||||
entry.Locale = CollectLocales();
|
|
||||||
_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.GetChildCount() < 3)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var lang = ((LineEdit)row.GetChild(0)).Text.Trim();
|
|
||||||
var name = ((LineEdit)row.GetChild(1)).Text.Trim();
|
|
||||||
var description = ((LineEdit)row.GetChild(2)).Text.Trim();
|
|
||||||
if (lang.Length == 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
list.Add(new CatalogLocaleEntry
|
|
||||||
{
|
|
||||||
Lang = lang,
|
|
||||||
Name = NullIfEmpty(name),
|
|
||||||
Description = NullIfEmpty(description),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (list.Count == 0 && _manifest != null)
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
entry.Category = CollectSelectedCategories(_categoryList, Categories);
|
|
||||||
entry.Tags = ParseTags(_tagsEdit.Text);
|
|
||||||
entry.Downloads[0].Changelog = NullIfEmpty(_changelogEdit.Text);
|
|
||||||
entry.Status = Statuses[_statusOpt.Selected];
|
|
||||||
entry.MinGameVersion = NullIfEmpty(_minGameVerEdit.Text);
|
|
||||||
entry.IconUrl = NullIfEmpty(_iconUrlEdit.Text);
|
|
||||||
entry.Locale = CollectLocales();
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnCopyPressed()
|
|
||||||
{
|
|
||||||
DisplayServer.ClipboardSet(_previewEdit.Text);
|
|
||||||
GameLogger.Log("CATALOG_EXPORT", $"Copied catalog JSON for '{_modInfo?.Id}' to clipboard");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnExportPressed()
|
|
||||||
{
|
|
||||||
if (_modInfo == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var entry = BuildExportEntry();
|
|
||||||
var modsRoot = ModRegistry.ModsPath;
|
|
||||||
var isZip = _modInfo.FileProvider.RootPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase);
|
|
||||||
if (isZip)
|
|
||||||
ModCatalogExporter.ExportFromZip(_modInfo, entry, modsRoot);
|
|
||||||
else
|
|
||||||
ModCatalogExporter.ExportFromFolder(_modInfo, entry, modsRoot);
|
|
||||||
|
|
||||||
QueueFree();
|
|
||||||
}
|
|
||||||
|
|
||||||
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 static CategoryType CollectSelectedCategories(ItemList list, CategoryType[] categories)
|
|
||||||
{
|
|
||||||
var selected = (CategoryType)0;
|
|
||||||
for (var i = 0; i < categories.Length && i < list.ItemCount; i++)
|
|
||||||
{
|
|
||||||
if (list.IsSelected(i))
|
|
||||||
selected |= categories[i];
|
|
||||||
}
|
|
||||||
return selected != 0 ? selected : CategoryType.Other;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddLabeledControl(Container parent, string labelKey, Control control)
|
|
||||||
{
|
|
||||||
var box = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
|
|
||||||
var label = new Label
|
|
||||||
{
|
|
||||||
Text = TranslationServer.Translate(labelKey),
|
|
||||||
};
|
|
||||||
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
|
|
||||||
label.AddThemeColorOverride("font_color", UiTokens.Muted);
|
|
||||||
box.AddChild(label);
|
|
||||||
box.AddChild(control);
|
|
||||||
parent.AddChild(box);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddReadOnlyField(Container parent, string labelKey, string value)
|
|
||||||
{
|
|
||||||
var label = new Label
|
|
||||||
{
|
|
||||||
Text = $"{TranslationServer.Translate(labelKey)}: {value}",
|
|
||||||
};
|
|
||||||
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBody);
|
|
||||||
label.AddThemeColorOverride("font_color", UiTokens.Body);
|
|
||||||
parent.AddChild(label);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LineEdit AddLineEdit(Container parent, string labelKey)
|
|
||||||
{
|
|
||||||
var edit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
|
|
||||||
AddLabeledControl(parent, labelKey, edit);
|
|
||||||
return edit;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static OptionButton AddOptionButton(Container parent, string labelKey, string[] items)
|
|
||||||
{
|
|
||||||
var opt = new OptionButton { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
|
|
||||||
foreach (var item in items)
|
|
||||||
opt.AddItem(item);
|
|
||||||
AddLabeledControl(parent, labelKey, opt);
|
|
||||||
return opt;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddSectionLabel(Container parent, string text)
|
|
||||||
{
|
|
||||||
var sep = new HSeparator();
|
|
||||||
parent.AddChild(sep);
|
|
||||||
var label = new Label { Text = text };
|
|
||||||
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodyLarge);
|
|
||||||
label.AddThemeColorOverride("font_color", UiTokens.Ink);
|
|
||||||
parent.AddChild(label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using Cthangover.Core.Interactive;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Tools.InteractiveEditor
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Data model for the interactive editor. Holds interactive definition
|
|
||||||
/// fields, manages collider vertex list for polygon editing, and produces
|
|
||||||
/// the output JSON.
|
|
||||||
/// </summary>
|
|
||||||
public class InteractiveEditorController
|
|
||||||
{
|
|
||||||
/// <summary>Unique identifier for the interactive object resource.</summary>
|
|
||||||
public string Id { get; set; } = "new_interactive";
|
|
||||||
/// <summary>Texture key mapped to a sprite resource by the resource system.</summary>
|
|
||||||
public string Texture { get; set; } = "";
|
|
||||||
/// <summary>Render layer: <c>"foreground"</c>, <c>"background"</c>, or <c>"ui"</c>.</summary>
|
|
||||||
public string Layer { get; set; } = "foreground";
|
|
||||||
/// <summary>Z-index for draw order within the layer.</summary>
|
|
||||||
public int ZIndex { get; set; }
|
|
||||||
/// <summary>Whether the interactive is initially enabled.</summary>
|
|
||||||
public bool Enabled { get; set; } = true;
|
|
||||||
/// <summary>Whether the interactive is visible.</summary>
|
|
||||||
public bool Visible { get; set; } = true;
|
|
||||||
/// <summary>Mouse cursor override name, e.g. <c>"PointingHand"</c>. Empty for default.</summary>
|
|
||||||
public string Cursor { get; set; } = "";
|
|
||||||
/// <summary>Collider shape type: <c>"rect"</c>, <c>"circle"</c>, or <c>"polygon"</c>.</summary>
|
|
||||||
public string HitType { get; set; } = "rect";
|
|
||||||
|
|
||||||
/// <summary>Normalised X position of the rect collider (0–1).</summary>
|
|
||||||
public float RectX { get; set; }
|
|
||||||
/// <summary>Normalised Y position of the rect collider (0–1).</summary>
|
|
||||||
public float RectY { get; set; }
|
|
||||||
/// <summary>Normalised width of the rect collider (0–1).</summary>
|
|
||||||
public float RectW { get; set; } = 0.1f;
|
|
||||||
/// <summary>Normalised height of the rect collider (0–1).</summary>
|
|
||||||
public float RectH { get; set; } = 0.1f;
|
|
||||||
|
|
||||||
/// <summary>Normalised X centre of the circle collider (0–1).</summary>
|
|
||||||
public float CircleX { get; set; } = 0.5f;
|
|
||||||
/// <summary>Normalised Y centre of the circle collider (0–1).</summary>
|
|
||||||
public float CircleY { get; set; } = 0.5f;
|
|
||||||
/// <summary>Normalised radius of the circle collider (0–1).</summary>
|
|
||||||
public float CircleRadius { get; set; } = 0.05f;
|
|
||||||
|
|
||||||
/// <summary>List of normalised (0–1) vertices defining the polygon collider.</summary>
|
|
||||||
public List<Vector2> PolygonVertices { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>Hex colour string (e.g. <c>"#FFFF0033"</c>) for the highlight overlay.</summary>
|
|
||||||
public string HighlightColorHex { get; set; } = "#FFFF0033";
|
|
||||||
/// <summary>Scale multiplier applied to the sprite when highlighted.</summary>
|
|
||||||
public float HighlightScale { get; set; } = 1.02f;
|
|
||||||
|
|
||||||
/// <summary>Scenario file path executed on click.</summary>
|
|
||||||
public string OnClickScenario { get; set; } = "";
|
|
||||||
/// <summary>Inline DSL commands executed on click, one per line.</summary>
|
|
||||||
public string OnClickCommands { get; set; } = "";
|
|
||||||
/// <summary>DSL commands executed when the mouse enters the interactive area.</summary>
|
|
||||||
public string OnHoverEnter { get; set; } = "";
|
|
||||||
/// <summary>DSL commands executed when the mouse leaves the interactive area.</summary>
|
|
||||||
public string OnHoverLeave { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>Builds an InteractiveDefinition from current state.</summary>
|
|
||||||
public InteractiveDefinition ToDefinition()
|
|
||||||
{
|
|
||||||
var hitArea = new HitAreaDefinition { Type = HitType };
|
|
||||||
|
|
||||||
switch (HitType)
|
|
||||||
{
|
|
||||||
case "rect":
|
|
||||||
hitArea.X = RectX;
|
|
||||||
hitArea.Y = RectY;
|
|
||||||
hitArea.Width = RectW;
|
|
||||||
hitArea.Height = RectH;
|
|
||||||
break;
|
|
||||||
case "circle":
|
|
||||||
hitArea.X = CircleX;
|
|
||||||
hitArea.Y = CircleY;
|
|
||||||
hitArea.Radius = CircleRadius;
|
|
||||||
break;
|
|
||||||
case "polygon":
|
|
||||||
hitArea.Vertices = PolygonVertices.ToArray();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var clickAction = new ClickAction();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(OnClickScenario))
|
|
||||||
clickAction.Scenario = OnClickScenario.Trim();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(OnClickCommands))
|
|
||||||
{
|
|
||||||
var lines = OnClickCommands.Split('\n', System.StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
var cleanLines = new List<string>();
|
|
||||||
foreach (var line in lines)
|
|
||||||
{
|
|
||||||
var trimmed = line.Trim();
|
|
||||||
if (!string.IsNullOrEmpty(trimmed))
|
|
||||||
cleanLines.Add(trimmed);
|
|
||||||
}
|
|
||||||
if (cleanLines.Count > 0)
|
|
||||||
clickAction.Commands = cleanLines.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new InteractiveDefinition
|
|
||||||
{
|
|
||||||
ID = Id,
|
|
||||||
Texture = string.IsNullOrWhiteSpace(Texture) ? null : Texture.Trim(),
|
|
||||||
Layer = Layer,
|
|
||||||
ZIndex = ZIndex,
|
|
||||||
Enabled = Enabled,
|
|
||||||
Visible = Visible,
|
|
||||||
Cursor = string.IsNullOrWhiteSpace(Cursor) ? null : Cursor.Trim(),
|
|
||||||
HitArea = hitArea,
|
|
||||||
Highlight = new HighlightDefinition
|
|
||||||
{
|
|
||||||
ColorHex = HighlightColorHex,
|
|
||||||
Scale = HighlightScale
|
|
||||||
},
|
|
||||||
Actions = new InteractiveActionDefinition
|
|
||||||
{
|
|
||||||
OnClick = (clickAction.Scenario != null || clickAction.Commands != null) ? clickAction : null,
|
|
||||||
OnHoverEnter = string.IsNullOrWhiteSpace(OnHoverEnter) ? null : OnHoverEnter.Trim(),
|
|
||||||
OnHoverLeave = string.IsNullOrWhiteSpace(OnHoverLeave) ? null : OnHoverLeave.Trim()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Loads state from an InteractiveDefinition.</summary>
|
|
||||||
public void FromDefinition(InteractiveDefinition def)
|
|
||||||
{
|
|
||||||
if (def == null) return;
|
|
||||||
|
|
||||||
Id = def.ID ?? "new_interactive";
|
|
||||||
Texture = def.Texture ?? "";
|
|
||||||
Layer = def.Layer ?? "foreground";
|
|
||||||
ZIndex = def.ZIndex;
|
|
||||||
Enabled = def.Enabled;
|
|
||||||
Visible = def.Visible;
|
|
||||||
Cursor = def.Cursor ?? "";
|
|
||||||
|
|
||||||
if (def.HitArea != null)
|
|
||||||
{
|
|
||||||
HitType = def.HitArea.Type ?? "rect";
|
|
||||||
RectX = def.HitArea.X;
|
|
||||||
RectY = def.HitArea.Y;
|
|
||||||
RectW = def.HitArea.Width;
|
|
||||||
RectH = def.HitArea.Height;
|
|
||||||
CircleX = def.HitArea.X;
|
|
||||||
CircleY = def.HitArea.Y;
|
|
||||||
CircleRadius = def.HitArea.Radius;
|
|
||||||
PolygonVertices = def.HitArea.Vertices != null
|
|
||||||
? new List<Vector2>(def.HitArea.Vertices)
|
|
||||||
: new List<Vector2>();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
HitType = "rect";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (def.Highlight != null)
|
|
||||||
{
|
|
||||||
HighlightColorHex = def.Highlight.ColorHex ?? "#FFFF0033";
|
|
||||||
HighlightScale = def.Highlight.Scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
OnClickScenario = def.Actions?.OnClick?.Scenario ?? "";
|
|
||||||
OnClickCommands = def.Actions?.OnClick?.Commands != null
|
|
||||||
? string.Join("\n", def.Actions.OnClick.Commands)
|
|
||||||
: "";
|
|
||||||
OnHoverEnter = def.Actions?.OnHoverEnter ?? "";
|
|
||||||
OnHoverLeave = def.Actions?.OnHoverLeave ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Serializes the definition to formatted JSON (FileData envelope).</summary>
|
|
||||||
public string ToJson()
|
|
||||||
{
|
|
||||||
var def = ToDefinition();
|
|
||||||
var wrapper = new
|
|
||||||
{
|
|
||||||
Items = new[] { def }
|
|
||||||
};
|
|
||||||
|
|
||||||
var options = new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
||||||
};
|
|
||||||
|
|
||||||
return JsonSerializer.Serialize(wrapper, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Parses JSON back into the controller. Returns false on failure.</summary>
|
|
||||||
public bool FromJson(string json)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip };
|
|
||||||
var wrapper = JsonSerializer.Deserialize<FileDataWrapper>(json, options);
|
|
||||||
if (wrapper?.Items != null && wrapper.Items.Count > 0)
|
|
||||||
{
|
|
||||||
FromDefinition(wrapper.Items[0]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FileDataWrapper
|
|
||||||
{
|
|
||||||
public List<InteractiveDefinition> Items { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Updates a polygon vertex at the given index.</summary>
|
|
||||||
public void SetPolygonVertex(int index, Vector2 value)
|
|
||||||
{
|
|
||||||
while (PolygonVertices.Count <= index)
|
|
||||||
PolygonVertices.Add(Vector2.Zero);
|
|
||||||
PolygonVertices[index] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Removes a polygon vertex at the given index.</summary>
|
|
||||||
public void RemovePolygonVertex(int index)
|
|
||||||
{
|
|
||||||
if (index >= 0 && index < PolygonVertices.Count)
|
|
||||||
PolygonVertices.RemoveAt(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Adds a new polygon vertex at the default position (center of viewport).</summary>
|
|
||||||
public void AddPolygonVertex()
|
|
||||||
{
|
|
||||||
var offset = PolygonVertices.Count * 0.04f;
|
|
||||||
PolygonVertices.Add(new Vector2(0.5f + offset, 0.5f + offset));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using Cthangover.Core.Utils;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Tools.InteractiveEditor
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Draggable 16x16 handle representing a collider vertex on the preview area.
|
|
||||||
/// Used for rect corners, circle radius marker, and polygon vertices.
|
|
||||||
/// Subscribes to the GuiInput C# event rather than overriding _GuiInput
|
|
||||||
/// for reliable event delivery in mod assemblies.
|
|
||||||
/// </summary>
|
|
||||||
public partial class InteractiveEditorHandle : ColorRect
|
|
||||||
{
|
|
||||||
/// <summary>Index of the vertex this handle represents (0-based).</summary>
|
|
||||||
public int VertexIndex;
|
|
||||||
/// <summary>Invoked during drag with the centre position of the handle in global coordinates.</summary>
|
|
||||||
public System.Action<Vector2> DragUpdate;
|
|
||||||
/// <summary>Invoked when the handle is clicked (mouse down).</summary>
|
|
||||||
public System.Action Clicked;
|
|
||||||
/// <summary>Invoked when the drag ends (mouse up). Used to sync sidebar values.</summary>
|
|
||||||
public System.Action DragEnd;
|
|
||||||
|
|
||||||
private bool _dragging;
|
|
||||||
private Vector2 _dragOffset;
|
|
||||||
|
|
||||||
/// <summary>Creates a handle with the given colour, size 16×16, and index for vertex identification.</summary>
|
|
||||||
public InteractiveEditorHandle(Color color, int index)
|
|
||||||
{
|
|
||||||
VertexIndex = index;
|
|
||||||
Size = new Vector2(16, 16);
|
|
||||||
Color = color;
|
|
||||||
MouseFilter = Control.MouseFilterEnum.Stop;
|
|
||||||
MouseDefaultCursorShape = Control.CursorShape.PointingHand;
|
|
||||||
GuiInput += OnGuiInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnGuiInput(InputEvent @event)
|
|
||||||
{
|
|
||||||
if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
|
|
||||||
{
|
|
||||||
if (mb.Pressed)
|
|
||||||
{
|
|
||||||
_dragging = true;
|
|
||||||
_dragOffset = GetGlobalMousePosition() - GlobalPosition;
|
|
||||||
GameLogger.Log("INTERACTIVE_HANDLE", $"drag start idx={VertexIndex} pos={GlobalPosition}");
|
|
||||||
Clicked?.Invoke();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_dragging = false;
|
|
||||||
GameLogger.Log("INTERACTIVE_HANDLE", $"drag end idx={VertexIndex}");
|
|
||||||
DragEnd?.Invoke();
|
|
||||||
}
|
|
||||||
AcceptEvent();
|
|
||||||
}
|
|
||||||
else if (@event is InputEventMouseMotion && _dragging)
|
|
||||||
{
|
|
||||||
var center = GlobalPosition + Size / 2;
|
|
||||||
GlobalPosition = GetGlobalMousePosition() - _dragOffset;
|
|
||||||
DragUpdate?.Invoke(center);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using Cthangover.Core.UI.Tool;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
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,855 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Cthangover.Core.Interactive;
|
|
||||||
using Cthangover.Core.Mods;
|
|
||||||
using Cthangover.Core.Mods.Resolvers;
|
|
||||||
using Cthangover.Core.Factories.Impls;
|
|
||||||
using Cthangover.Core.UI.Theming;
|
|
||||||
using Cthangover.Core.UI.Tool;
|
|
||||||
using Cthangover.Core.Utils;
|
|
||||||
using Cthangover.Tools.Services;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Tools.InteractiveEditor
|
|
||||||
{
|
|
||||||
public partial class InteractiveEditorWindow : ToolWindow
|
|
||||||
{
|
|
||||||
private static readonly Color _rectColor = UiTokens.Alpha(UiTokens.Success, 0.6f);
|
|
||||||
private static readonly Color _circleColor = UiTokens.Alpha(UiTokens.Info, 0.6f);
|
|
||||||
private static readonly Color _vertexColor = UiTokens.Alpha(UiTokens.AccentBright, 0.8f);
|
|
||||||
private static readonly Color _colliderOverlayColor = UiTokens.Alpha(UiTokens.Success, 0.15f);
|
|
||||||
private static readonly Color _colliderCircleOverlayColor = UiTokens.Alpha(UiTokens.Info, 0.15f);
|
|
||||||
private static readonly Color _colliderPolyOverlayColor = UiTokens.Alpha(UiTokens.Accent, 0.15f);
|
|
||||||
private static readonly Color _colliderPolyOutlineColor = UiTokens.Alpha(UiTokens.Accent, 0.6f);
|
|
||||||
|
|
||||||
private InteractiveEditorController _controller = new();
|
|
||||||
|
|
||||||
private Control _previewContainer;
|
|
||||||
private TextureRect _preview;
|
|
||||||
private Control _colliderOverlay;
|
|
||||||
private string _currentBgId;
|
|
||||||
|
|
||||||
private readonly List<InteractiveEditorHandle> _handles = new();
|
|
||||||
|
|
||||||
private TextEdit _jsonEdit;
|
|
||||||
|
|
||||||
private LineEdit _idEdit, _textureEdit;
|
|
||||||
private OptionButton _layerSelect;
|
|
||||||
private OptionButton _hitTypeSelect;
|
|
||||||
private ItemList _bgList;
|
|
||||||
|
|
||||||
private LineEdit _rectX, _rectY, _rectW, _rectH;
|
|
||||||
private LineEdit _circleX, _circleY, _circleR;
|
|
||||||
private VBoxContainer _rectParams, _circleParams, _polyParams;
|
|
||||||
|
|
||||||
private ColorPickerButton _highlightColorBtn;
|
|
||||||
private LineEdit _highlightScaleEdit;
|
|
||||||
|
|
||||||
private LineEdit _clickScenario, _hoverEnter, _hoverLeave;
|
|
||||||
private TextEdit _clickCommands;
|
|
||||||
|
|
||||||
private VBoxContainer _vertexList;
|
|
||||||
private Button _addVertexBtn;
|
|
||||||
|
|
||||||
private bool _suppressingSidebarEvents;
|
|
||||||
private int _draggingHandleIndex = -1;
|
|
||||||
|
|
||||||
public InteractiveEditorWindow() : base("tools/interactive_editor/title")
|
|
||||||
{
|
|
||||||
Size = new Vector2I(1280, 820);
|
|
||||||
CthangoverTheme.Apply(this);
|
|
||||||
|
|
||||||
var outer = CreateFillContainer();
|
|
||||||
AddChild(outer);
|
|
||||||
|
|
||||||
var toolbar = CreateToolbar();
|
|
||||||
outer.AddChild(toolbar);
|
|
||||||
|
|
||||||
var loadBtn = new Button { Text = Tr("tools/interactive_editor/btn_load_json") };
|
|
||||||
loadBtn.Pressed += OnLoadJson;
|
|
||||||
toolbar.AddChild(loadBtn);
|
|
||||||
|
|
||||||
var saveBtn = new Button { Text = Tr("tools/interactive_editor/btn_save") };
|
|
||||||
saveBtn.Pressed += OnSave;
|
|
||||||
toolbar.AddChild(saveBtn);
|
|
||||||
|
|
||||||
var importDefBtn = new Button { Text = Tr("tools/interactive_editor/btn_import_mod") };
|
|
||||||
importDefBtn.Pressed += OnImportFromMod;
|
|
||||||
toolbar.AddChild(importDefBtn);
|
|
||||||
|
|
||||||
var mainHBox = new HBoxContainer();
|
|
||||||
mainHBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
mainHBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
outer.AddChild(mainHBox);
|
|
||||||
|
|
||||||
var sidebarVBox = new VBoxContainer();
|
|
||||||
var sidebar = CreateSidebar(sidebarVBox, 290);
|
|
||||||
mainHBox.AddChild(sidebar);
|
|
||||||
|
|
||||||
BuildSidebar(sidebarVBox);
|
|
||||||
|
|
||||||
_previewContainer = new Control
|
|
||||||
{
|
|
||||||
MouseFilter = Control.MouseFilterEnum.Stop,
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill
|
|
||||||
};
|
|
||||||
mainHBox.AddChild(_previewContainer);
|
|
||||||
|
|
||||||
_preview = new TextureRect
|
|
||||||
{
|
|
||||||
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
|
|
||||||
StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered,
|
|
||||||
MouseFilter = Control.MouseFilterEnum.Ignore,
|
|
||||||
AnchorRight = 1f,
|
|
||||||
AnchorBottom = 1f
|
|
||||||
};
|
|
||||||
_previewContainer.AddChild(_preview);
|
|
||||||
|
|
||||||
_colliderOverlay = new Control
|
|
||||||
{
|
|
||||||
MouseFilter = Control.MouseFilterEnum.Ignore,
|
|
||||||
Visible = false
|
|
||||||
};
|
|
||||||
_colliderOverlay.Connect("draw", Callable.From((Action)DrawColliderShape));
|
|
||||||
_previewContainer.AddChild(_colliderOverlay);
|
|
||||||
|
|
||||||
_previewContainer.Resized += () =>
|
|
||||||
{
|
|
||||||
RebuildHandles();
|
|
||||||
UpdateColliderOverlay();
|
|
||||||
};
|
|
||||||
|
|
||||||
_jsonEdit = new TextEdit { CustomMinimumSize = new Vector2(0, 150) };
|
|
||||||
_jsonEdit.AddThemeFontOverride("font", GetMonospaceFont());
|
|
||||||
_jsonEdit.TextChanged += () => SetDirty();
|
|
||||||
outer.AddChild(_jsonEdit);
|
|
||||||
|
|
||||||
RefreshJson();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildSidebar(VBoxContainer sidebar)
|
|
||||||
{
|
|
||||||
sidebar.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
|
|
||||||
var tabs = new TabContainer();
|
|
||||||
tabs.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
sidebar.AddChild(tabs);
|
|
||||||
|
|
||||||
var bgTab = new VBoxContainer();
|
|
||||||
tabs.AddChild(bgTab);
|
|
||||||
tabs.SetTabTitle(0, Tr("tools/interactive_editor/tab_background"));
|
|
||||||
BuildBackgroundTab(bgTab);
|
|
||||||
|
|
||||||
var interactiveTab = new VBoxContainer();
|
|
||||||
tabs.AddChild(interactiveTab);
|
|
||||||
tabs.SetTabTitle(1, Tr("tools/interactive_editor/tab_interactive"));
|
|
||||||
BuildInteractiveTab(interactiveTab);
|
|
||||||
|
|
||||||
var scenarioTab = new VBoxContainer();
|
|
||||||
tabs.AddChild(scenarioTab);
|
|
||||||
tabs.SetTabTitle(2, Tr("tools/interactive_editor/tab_scenario"));
|
|
||||||
BuildScenarioTab(scenarioTab);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildBackgroundTab(VBoxContainer parent)
|
|
||||||
{
|
|
||||||
AddLabel(parent, "tools/interactive_editor/label_id");
|
|
||||||
_idEdit = AddLineEdit(parent, "my_interactive");
|
|
||||||
_idEdit.TextChanged += OnParamChanged;
|
|
||||||
|
|
||||||
AddLabel(parent, "tools/interactive_editor/label_texture_key");
|
|
||||||
_textureEdit = AddLineEdit(parent, "lamp");
|
|
||||||
_textureEdit.TextChanged += OnParamChanged;
|
|
||||||
|
|
||||||
AddLabel(parent, "tools/interactive_editor/label_layer");
|
|
||||||
_layerSelect = new OptionButton();
|
|
||||||
_layerSelect.AddItem("foreground");
|
|
||||||
_layerSelect.AddItem("background");
|
|
||||||
_layerSelect.AddItem("ui");
|
|
||||||
_layerSelect.ItemSelected += _ => OnParamChanged();
|
|
||||||
parent.AddChild(_layerSelect);
|
|
||||||
|
|
||||||
AddLabel(parent, "tools/interactive_editor/label_bg_preview");
|
|
||||||
_bgList = new ItemList();
|
|
||||||
_bgList.CustomMinimumSize = new Vector2(0, 100);
|
|
||||||
_bgList.ItemSelected += OnBgSelected;
|
|
||||||
parent.AddChild(_bgList);
|
|
||||||
LoadBackgroundList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildInteractiveTab(VBoxContainer parent)
|
|
||||||
{
|
|
||||||
AddLabel(parent, "tools/interactive_editor/label_collider_type");
|
|
||||||
_hitTypeSelect = new OptionButton();
|
|
||||||
_hitTypeSelect.AddItem("rect");
|
|
||||||
_hitTypeSelect.AddItem("circle");
|
|
||||||
_hitTypeSelect.AddItem("polygon");
|
|
||||||
_hitTypeSelect.ItemSelected += OnHitTypeChanged;
|
|
||||||
parent.AddChild(_hitTypeSelect);
|
|
||||||
|
|
||||||
_rectParams = new VBoxContainer();
|
|
||||||
AddLabel(_rectParams, "tools/interactive_editor/label_rect_params");
|
|
||||||
_rectX = AddLabeledLine(_rectParams, "tools/interactive_editor/label_x"); _rectX.TextChanged += OnParamChanged;
|
|
||||||
_rectY = AddLabeledLine(_rectParams, "tools/interactive_editor/label_y"); _rectY.TextChanged += OnParamChanged;
|
|
||||||
_rectW = AddLabeledLine(_rectParams, "tools/interactive_editor/label_w"); _rectW.TextChanged += OnParamChanged;
|
|
||||||
_rectH = AddLabeledLine(_rectParams, "tools/interactive_editor/label_h"); _rectH.TextChanged += OnParamChanged;
|
|
||||||
parent.AddChild(_rectParams);
|
|
||||||
|
|
||||||
_circleParams = new VBoxContainer();
|
|
||||||
AddLabel(_circleParams, "tools/interactive_editor/label_circle_params");
|
|
||||||
_circleX = AddLabeledLine(_circleParams, "tools/interactive_editor/label_cx"); _circleX.TextChanged += OnParamChanged;
|
|
||||||
_circleY = AddLabeledLine(_circleParams, "tools/interactive_editor/label_cy"); _circleY.TextChanged += OnParamChanged;
|
|
||||||
_circleR = AddLabeledLine(_circleParams, "tools/interactive_editor/label_r"); _circleR.TextChanged += OnParamChanged;
|
|
||||||
_circleParams.Visible = false;
|
|
||||||
parent.AddChild(_circleParams);
|
|
||||||
|
|
||||||
_polyParams = new VBoxContainer();
|
|
||||||
AddLabel(_polyParams, "tools/interactive_editor/label_polygon_vertices");
|
|
||||||
_addVertexBtn = new Button { Text = Tr("tools/interactive_editor/btn_add_vertex") };
|
|
||||||
_addVertexBtn.Pressed += OnAddVertex;
|
|
||||||
_polyParams.AddChild(_addVertexBtn);
|
|
||||||
_vertexList = new VBoxContainer();
|
|
||||||
_polyParams.AddChild(_vertexList);
|
|
||||||
_polyParams.Visible = false;
|
|
||||||
parent.AddChild(_polyParams);
|
|
||||||
|
|
||||||
AddLabel(parent, "tools/interactive_editor/label_highlight_color");
|
|
||||||
_highlightColorBtn = new ColorPickerButton
|
|
||||||
{
|
|
||||||
Color = UiTokens.Alpha(UiTokens.Accent, 0.2f),
|
|
||||||
CustomMinimumSize = new Vector2(40, 30)
|
|
||||||
};
|
|
||||||
_highlightColorBtn.ColorChanged += _ => OnParamChanged();
|
|
||||||
parent.AddChild(_highlightColorBtn);
|
|
||||||
|
|
||||||
AddLabel(parent, "tools/interactive_editor/label_highlight_scale");
|
|
||||||
_highlightScaleEdit = AddLineEdit(parent, "1.02");
|
|
||||||
_highlightScaleEdit.TextChanged += OnParamChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildScenarioTab(VBoxContainer parent)
|
|
||||||
{
|
|
||||||
AddLabel(parent, "tools/interactive_editor/label_cursor");
|
|
||||||
var cursorSelect = new OptionButton();
|
|
||||||
cursorSelect.AddItem(Tr("tools/interactive_editor/cursor_none"));
|
|
||||||
cursorSelect.AddItem("PointingHand");
|
|
||||||
cursorSelect.ItemSelected += idx =>
|
|
||||||
{
|
|
||||||
_controller.Cursor = idx == 0 ? "" : "PointingHand";
|
|
||||||
OnParamChanged();
|
|
||||||
};
|
|
||||||
parent.AddChild(cursorSelect);
|
|
||||||
|
|
||||||
AddLabel(parent, "tools/interactive_editor/label_onclick_scenario");
|
|
||||||
_clickScenario = AddLineEdit(parent, "scenarios/my_click.scenario");
|
|
||||||
_clickScenario.TextChanged += OnParamChanged;
|
|
||||||
|
|
||||||
AddLabel(parent, "tools/interactive_editor/label_onclick_commands");
|
|
||||||
_clickCommands = new TextEdit { CustomMinimumSize = new Vector2(0, 50) };
|
|
||||||
_clickCommands.TextChanged += OnParamChanged;
|
|
||||||
parent.AddChild(_clickCommands);
|
|
||||||
|
|
||||||
AddLabel(parent, "tools/interactive_editor/label_hover_enter");
|
|
||||||
_hoverEnter = AddLineEdit(parent, "set cursor_hint=click_me");
|
|
||||||
_hoverEnter.TextChanged += OnParamChanged;
|
|
||||||
|
|
||||||
AddLabel(parent, "tools/interactive_editor/label_hover_leave");
|
|
||||||
_hoverLeave = AddLineEdit(parent, "set cursor_hint=");
|
|
||||||
_hoverLeave.TextChanged += OnParamChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Tr(string key) => TranslationServer.Translate(key);
|
|
||||||
|
|
||||||
private void AddLabel(Control parent, string locKey)
|
|
||||||
{
|
|
||||||
var label = new Label { Text = Tr(locKey) };
|
|
||||||
label.AddThemeColorOverride("font_color", UiTokens.Muted);
|
|
||||||
parent.AddChild(label);
|
|
||||||
}
|
|
||||||
|
|
||||||
private LineEdit AddLineEdit(Control parent, string placeholder)
|
|
||||||
{
|
|
||||||
var edit = new LineEdit { PlaceholderText = placeholder };
|
|
||||||
parent.AddChild(edit);
|
|
||||||
return edit;
|
|
||||||
}
|
|
||||||
|
|
||||||
private LineEdit AddLabeledLine(Control parent, string locKey)
|
|
||||||
{
|
|
||||||
var hbox = new HBoxContainer();
|
|
||||||
var label = new Label { Text = Tr(locKey) };
|
|
||||||
label.CustomMinimumSize = new Vector2(25, 0);
|
|
||||||
hbox.AddChild(label);
|
|
||||||
var edit = new LineEdit();
|
|
||||||
edit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
hbox.AddChild(edit);
|
|
||||||
parent.AddChild(hbox);
|
|
||||||
return edit;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LoadBackgroundList()
|
|
||||||
{
|
|
||||||
_bgList.Clear();
|
|
||||||
var ids = ModResourceService.GetBackgroundIds();
|
|
||||||
foreach (var id in ids)
|
|
||||||
_bgList.AddItem(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnBgSelected(long index)
|
|
||||||
{
|
|
||||||
var id = _bgList.GetItemText((int)index);
|
|
||||||
_currentBgId = id;
|
|
||||||
var tex = ModResourceService.LoadBackgroundTexture(id);
|
|
||||||
if (tex != null)
|
|
||||||
{
|
|
||||||
_preview.Texture = tex;
|
|
||||||
}
|
|
||||||
OnParamChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnHitTypeChanged(long index)
|
|
||||||
{
|
|
||||||
_controller.HitType = _hitTypeSelect.GetItemText((int)index);
|
|
||||||
_rectParams.Visible = _controller.HitType == "rect";
|
|
||||||
_circleParams.Visible = _controller.HitType == "circle";
|
|
||||||
_polyParams.Visible = _controller.HitType == "polygon";
|
|
||||||
OnParamChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnAddVertex()
|
|
||||||
{
|
|
||||||
_controller.AddPolygonVertex();
|
|
||||||
OnParamChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnParamChanged()
|
|
||||||
{
|
|
||||||
if (_suppressingSidebarEvents)
|
|
||||||
{
|
|
||||||
GameLogger.Log("INTERACTIVE_EDITOR", "OnParamChanged suppressed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
GameLogger.Log("INTERACTIVE_EDITOR", $"OnParamChanged hitType={_controller.HitType} bgSize=({_previewContainer.Size.X:F0}x{_previewContainer.Size.Y:F0})");
|
|
||||||
ReadSidebarToController();
|
|
||||||
RebuildHandles();
|
|
||||||
UpdateColliderOverlay();
|
|
||||||
RefreshJson();
|
|
||||||
SetDirty();
|
|
||||||
UpdateVertexList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDragUpdate()
|
|
||||||
{
|
|
||||||
UpdateNonDraggedHandles();
|
|
||||||
UpdateColliderOverlay();
|
|
||||||
RefreshJson();
|
|
||||||
SetDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReadSidebarToController()
|
|
||||||
{
|
|
||||||
_controller.Id = string.IsNullOrWhiteSpace(_idEdit.Text) ? "new_interactive" : _idEdit.Text.Trim();
|
|
||||||
_controller.Texture = _textureEdit.Text.Trim();
|
|
||||||
_controller.Layer = _layerSelect.GetItemText(_layerSelect.Selected);
|
|
||||||
|
|
||||||
if (_controller.HitType == "rect")
|
|
||||||
{
|
|
||||||
float.TryParse(_rectX.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v);
|
|
||||||
_controller.RectX = v;
|
|
||||||
float.TryParse(_rectY.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.RectY = v;
|
|
||||||
float.TryParse(_rectW.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.RectW = v;
|
|
||||||
float.TryParse(_rectH.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.RectH = v;
|
|
||||||
}
|
|
||||||
else if (_controller.HitType == "circle")
|
|
||||||
{
|
|
||||||
float.TryParse(_circleX.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v);
|
|
||||||
_controller.CircleX = v;
|
|
||||||
float.TryParse(_circleY.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.CircleY = v;
|
|
||||||
float.TryParse(_circleR.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.CircleRadius = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
_controller.HighlightColorHex = "#" + _highlightColorBtn.Color.ToHtml(false);
|
|
||||||
float.TryParse(_highlightScaleEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var scale);
|
|
||||||
_controller.HighlightScale = scale;
|
|
||||||
|
|
||||||
_controller.OnClickScenario = _clickScenario.Text.Trim();
|
|
||||||
_controller.OnClickCommands = _clickCommands.Text.Trim();
|
|
||||||
_controller.OnHoverEnter = _hoverEnter.Text.Trim();
|
|
||||||
_controller.OnHoverLeave = _hoverLeave.Text.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Rect2 GetBackgroundRect()
|
|
||||||
{
|
|
||||||
var cw = _previewContainer.Size.X;
|
|
||||||
var ch = _previewContainer.Size.Y;
|
|
||||||
if (cw <= 0 || ch <= 0) return new Rect2();
|
|
||||||
|
|
||||||
var texSize = new Vector2(1920, 1024);
|
|
||||||
var tex = _preview.Texture;
|
|
||||||
if (tex != null)
|
|
||||||
{
|
|
||||||
var ts = tex.GetSize();
|
|
||||||
if (ts.X > 0 && ts.Y > 0) texSize = ts;
|
|
||||||
}
|
|
||||||
|
|
||||||
var scale = Mathf.Min(cw / texSize.X, ch / texSize.Y);
|
|
||||||
var rw = texSize.X * scale;
|
|
||||||
var rh = texSize.Y * scale;
|
|
||||||
var ox = (cw - rw) / 2f;
|
|
||||||
var oy = (ch - rh) / 2f;
|
|
||||||
return new Rect2(ox, oy, rw, rh);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Vector2 NormToPixel(Vector2 norm)
|
|
||||||
{
|
|
||||||
var bg = GetBackgroundRect();
|
|
||||||
return bg.Position + norm * bg.Size;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Vector2 PixelToNorm(Vector2 pixel)
|
|
||||||
{
|
|
||||||
var bg = GetBackgroundRect();
|
|
||||||
if (bg.Size.X <= 0 || bg.Size.Y <= 0) return Vector2.Zero;
|
|
||||||
return new Vector2(
|
|
||||||
(pixel.X - bg.Position.X) / bg.Size.X,
|
|
||||||
(pixel.Y - bg.Position.Y) / bg.Size.Y);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void WriteControllerToSidebar()
|
|
||||||
{
|
|
||||||
_suppressingSidebarEvents = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_idEdit.Text = _controller.Id;
|
|
||||||
_textureEdit.Text = _controller.Texture;
|
|
||||||
|
|
||||||
for (int i = 0; i < _layerSelect.ItemCount; i++)
|
|
||||||
if (_layerSelect.GetItemText(i) == _controller.Layer)
|
|
||||||
_layerSelect.Select(i);
|
|
||||||
|
|
||||||
for (int i = 0; i < _hitTypeSelect.ItemCount; i++)
|
|
||||||
if (_hitTypeSelect.GetItemText(i) == _controller.HitType)
|
|
||||||
_hitTypeSelect.Select(i);
|
|
||||||
|
|
||||||
_rectX.Text = _controller.RectX.ToString("F3", System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
_rectY.Text = _controller.RectY.ToString("F3", System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
_rectW.Text = _controller.RectW.ToString("F3", System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
_rectH.Text = _controller.RectH.ToString("F3", System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
|
|
||||||
_circleX.Text = _controller.CircleX.ToString("F3", System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
_circleY.Text = _controller.CircleY.ToString("F3", System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
_circleR.Text = _controller.CircleRadius.ToString("F3", System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
|
|
||||||
_rectParams.Visible = _controller.HitType == "rect";
|
|
||||||
_circleParams.Visible = _controller.HitType == "circle";
|
|
||||||
_polyParams.Visible = _controller.HitType == "polygon";
|
|
||||||
|
|
||||||
_highlightColorBtn.Color = new Color(_controller.HighlightColorHex);
|
|
||||||
_highlightScaleEdit.Text = _controller.HighlightScale.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
|
|
||||||
_clickScenario.Text = _controller.OnClickScenario;
|
|
||||||
_clickCommands.Text = _controller.OnClickCommands;
|
|
||||||
_hoverEnter.Text = _controller.OnHoverEnter;
|
|
||||||
_hoverLeave.Text = _controller.OnHoverLeave;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_suppressingSidebarEvents = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RebuildHandles()
|
|
||||||
{
|
|
||||||
foreach (var h in _handles)
|
|
||||||
h.QueueFree();
|
|
||||||
_handles.Clear();
|
|
||||||
|
|
||||||
var bg = GetBackgroundRect();
|
|
||||||
if (bg.Size.X <= 0 || bg.Size.Y <= 0)
|
|
||||||
{
|
|
||||||
GameLogger.Log("INTERACTIVE_EDITOR", "RebuildHandles skipped: bg size <= 0");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
GameLogger.Log("INTERACTIVE_EDITOR", $"RebuildHandles hitType={_controller.HitType} bg=({bg.Position.X:F0},{bg.Position.Y:F0} {bg.Size.X:F0}x{bg.Size.Y:F0})");
|
|
||||||
|
|
||||||
var globalTopLeft = _previewContainer.GlobalPosition;
|
|
||||||
|
|
||||||
switch (_controller.HitType)
|
|
||||||
{
|
|
||||||
case "rect":
|
|
||||||
{
|
|
||||||
var topLeft = NormToPixel(new Vector2(_controller.RectX, _controller.RectY));
|
|
||||||
var bottomRight = NormToPixel(new Vector2(
|
|
||||||
_controller.RectX + _controller.RectW,
|
|
||||||
_controller.RectY + _controller.RectH));
|
|
||||||
|
|
||||||
AddHandleAt(globalTopLeft + topLeft, _rectColor, 0, pos =>
|
|
||||||
{
|
|
||||||
var norm = PixelToNorm(pos - globalTopLeft);
|
|
||||||
_controller.RectX = Mathf.Clamp(norm.X, 0f, 1f);
|
|
||||||
_controller.RectY = Mathf.Clamp(norm.Y, 0f, 1f);
|
|
||||||
_draggingHandleIndex = 0;
|
|
||||||
OnDragUpdate();
|
|
||||||
});
|
|
||||||
AddHandleAt(globalTopLeft + bottomRight, _rectColor, 1, pos =>
|
|
||||||
{
|
|
||||||
var norm = PixelToNorm(pos - globalTopLeft);
|
|
||||||
_controller.RectW = Mathf.Max(0.01f, norm.X - _controller.RectX);
|
|
||||||
_controller.RectH = Mathf.Max(0.01f, norm.Y - _controller.RectY);
|
|
||||||
_draggingHandleIndex = 1;
|
|
||||||
OnDragUpdate();
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "circle":
|
|
||||||
{
|
|
||||||
var refDim = Mathf.Min(bg.Size.X, bg.Size.Y);
|
|
||||||
var center = NormToPixel(new Vector2(_controller.CircleX, _controller.CircleY));
|
|
||||||
var radiusPx = _controller.CircleRadius * refDim;
|
|
||||||
|
|
||||||
AddHandleAt(globalTopLeft + center, _circleColor, 0, pos =>
|
|
||||||
{
|
|
||||||
var norm = PixelToNorm(pos - globalTopLeft);
|
|
||||||
_controller.CircleX = Mathf.Clamp(norm.X, 0f, 1f);
|
|
||||||
_controller.CircleY = Mathf.Clamp(norm.Y, 0f, 1f);
|
|
||||||
_draggingHandleIndex = 0;
|
|
||||||
OnDragUpdate();
|
|
||||||
});
|
|
||||||
AddHandleAt(globalTopLeft + new Vector2(center.X + radiusPx, center.Y), _circleColor, 1, pos =>
|
|
||||||
{
|
|
||||||
var local = pos - globalTopLeft;
|
|
||||||
var centerPx = NormToPixel(new Vector2(_controller.CircleX, _controller.CircleY));
|
|
||||||
var dist = (local - centerPx).Length();
|
|
||||||
_controller.CircleRadius = Mathf.Max(0.005f, dist / refDim);
|
|
||||||
_draggingHandleIndex = 1;
|
|
||||||
OnDragUpdate();
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "polygon":
|
|
||||||
{
|
|
||||||
for (int i = 0; i < _controller.PolygonVertices.Count; i++)
|
|
||||||
{
|
|
||||||
var idx = i;
|
|
||||||
var v = _controller.PolygonVertices[i];
|
|
||||||
AddHandleAt(globalTopLeft + NormToPixel(v), _vertexColor, i, pos =>
|
|
||||||
{
|
|
||||||
var norm = PixelToNorm(pos - globalTopLeft);
|
|
||||||
_controller.SetPolygonVertex(idx, new Vector2(
|
|
||||||
Mathf.Clamp(norm.X, 0f, 1f),
|
|
||||||
Mathf.Clamp(norm.Y, 0f, 1f)));
|
|
||||||
_draggingHandleIndex = idx;
|
|
||||||
OnDragUpdate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateNonDraggedHandles()
|
|
||||||
{
|
|
||||||
var bg = GetBackgroundRect();
|
|
||||||
if (bg.Size.X <= 0 || bg.Size.Y <= 0) return;
|
|
||||||
|
|
||||||
var globalTopLeft = _previewContainer.GlobalPosition;
|
|
||||||
|
|
||||||
for (int i = 0; i < _handles.Count; i++)
|
|
||||||
{
|
|
||||||
if (i == _draggingHandleIndex) continue;
|
|
||||||
var pos = GetHandleNormPosition(i);
|
|
||||||
var pixelPos = globalTopLeft + NormToPixel(pos);
|
|
||||||
var h = _handles[i];
|
|
||||||
if (h != null && !h.IsQueuedForDeletion())
|
|
||||||
h.GlobalPosition = pixelPos - h.Size / 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Vector2 GetHandleNormPosition(int index)
|
|
||||||
{
|
|
||||||
switch (_controller.HitType)
|
|
||||||
{
|
|
||||||
case "rect":
|
|
||||||
if (index == 0)
|
|
||||||
return new Vector2(_controller.RectX, _controller.RectY);
|
|
||||||
return new Vector2(_controller.RectX + _controller.RectW, _controller.RectY + _controller.RectH);
|
|
||||||
case "circle":
|
|
||||||
if (index == 0)
|
|
||||||
return new Vector2(_controller.CircleX, _controller.CircleY);
|
|
||||||
return new Vector2(_controller.CircleX + _controller.CircleRadius, _controller.CircleY);
|
|
||||||
case "polygon":
|
|
||||||
if (index >= 0 && index < _controller.PolygonVertices.Count)
|
|
||||||
return _controller.PolygonVertices[index];
|
|
||||||
return Vector2.Zero;
|
|
||||||
default:
|
|
||||||
return Vector2.Zero;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateColliderOverlay()
|
|
||||||
{
|
|
||||||
var bg = GetBackgroundRect();
|
|
||||||
if (bg.Size.X <= 0 || bg.Size.Y <= 0)
|
|
||||||
{
|
|
||||||
_colliderOverlay.Visible = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_colliderOverlay.Visible = true;
|
|
||||||
_colliderOverlay.Position = bg.Position;
|
|
||||||
_colliderOverlay.Size = bg.Size;
|
|
||||||
_colliderOverlay.QueueRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawColliderShape()
|
|
||||||
{
|
|
||||||
var size = _colliderOverlay.Size;
|
|
||||||
if (size.X <= 0 || size.Y <= 0) return;
|
|
||||||
|
|
||||||
switch (_controller.HitType)
|
|
||||||
{
|
|
||||||
case "rect":
|
|
||||||
{
|
|
||||||
var pos = NormToPixelLocal(new Vector2(_controller.RectX, _controller.RectY));
|
|
||||||
var rectSize = new Vector2(_controller.RectW * size.X, _controller.RectH * size.Y);
|
|
||||||
_colliderOverlay.DrawRect(new Rect2(pos, rectSize), _colliderOverlayColor);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "circle":
|
|
||||||
{
|
|
||||||
var rPx = _controller.CircleRadius * Mathf.Min(size.X, size.Y);
|
|
||||||
var center = NormToPixelLocal(new Vector2(_controller.CircleX, _controller.CircleY));
|
|
||||||
_colliderOverlay.DrawCircle(center, rPx, _colliderCircleOverlayColor);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "polygon":
|
|
||||||
{
|
|
||||||
if (_controller.PolygonVertices.Count >= 3)
|
|
||||||
{
|
|
||||||
var points = new Vector2[_controller.PolygonVertices.Count];
|
|
||||||
for (int i = 0; i < _controller.PolygonVertices.Count; i++)
|
|
||||||
points[i] = NormToPixelLocal(_controller.PolygonVertices[i]);
|
|
||||||
|
|
||||||
if (PolygonArea(points) > 0.0001f)
|
|
||||||
_colliderOverlay.DrawColoredPolygon(points, _colliderPolyOverlayColor);
|
|
||||||
_colliderOverlay.DrawPolyline(points, _colliderPolyOutlineColor);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Vector2 NormToPixelLocal(Vector2 norm)
|
|
||||||
{
|
|
||||||
return norm * _colliderOverlay.Size;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static float PolygonArea(Vector2[] points)
|
|
||||||
{
|
|
||||||
float area = 0;
|
|
||||||
for (int i = 0; i < points.Length; i++)
|
|
||||||
{
|
|
||||||
var j = (i + 1) % points.Length;
|
|
||||||
area += points[i].X * points[j].Y;
|
|
||||||
area -= points[j].X * points[i].Y;
|
|
||||||
}
|
|
||||||
return Mathf.Abs(area) * 0.5f;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddHandleAt(Vector2 globalPos, Color color, int index, Action<Vector2> onDrag)
|
|
||||||
{
|
|
||||||
var handle = new InteractiveEditorHandle(color, index);
|
|
||||||
handle.DragUpdate = center =>
|
|
||||||
{
|
|
||||||
onDrag(center);
|
|
||||||
};
|
|
||||||
handle.Clicked = () =>
|
|
||||||
{
|
|
||||||
SelectHandle(index);
|
|
||||||
};
|
|
||||||
handle.DragEnd = () =>
|
|
||||||
{
|
|
||||||
GameLogger.Log("INTERACTIVE_EDITOR", $"DragEnd idx={index} syncing sidebar");
|
|
||||||
_draggingHandleIndex = -1;
|
|
||||||
_suppressingSidebarEvents = true;
|
|
||||||
WriteControllerToSidebar();
|
|
||||||
_suppressingSidebarEvents = false;
|
|
||||||
UpdateVertexList();
|
|
||||||
};
|
|
||||||
handle.MouseDefaultCursorShape = Control.CursorShape.PointingHand;
|
|
||||||
_previewContainer.AddChild(handle);
|
|
||||||
handle.GlobalPosition = globalPos - handle.Size / 2;
|
|
||||||
_handles.Add(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SelectHandle(int index)
|
|
||||||
{
|
|
||||||
if (_controller.HitType == "polygon" && index >= 0 && index < _controller.PolygonVertices.Count)
|
|
||||||
{
|
|
||||||
var v = _controller.PolygonVertices[index];
|
|
||||||
GD.Print($"Selected vertex {index}: ({v.X:F3}, {v.Y:F3})");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateVertexList()
|
|
||||||
{
|
|
||||||
for (int i = _vertexList.GetChildCount() - 1; i >= 0; i--)
|
|
||||||
_vertexList.GetChild(i).QueueFree();
|
|
||||||
|
|
||||||
if (_controller.HitType != "polygon") return;
|
|
||||||
|
|
||||||
for (int i = 0; i < _controller.PolygonVertices.Count; i++)
|
|
||||||
{
|
|
||||||
var idx = i;
|
|
||||||
var v = _controller.PolygonVertices[i];
|
|
||||||
var row = new HBoxContainer();
|
|
||||||
|
|
||||||
var xEdit = new LineEdit();
|
|
||||||
xEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
xEdit.Text = v.X.ToString("F3", System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
xEdit.TextChanged += txt =>
|
|
||||||
{
|
|
||||||
if (float.TryParse(xEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var val))
|
|
||||||
_controller.SetPolygonVertex(idx, new Vector2(val, _controller.PolygonVertices[idx].Y));
|
|
||||||
OnParamChanged();
|
|
||||||
};
|
|
||||||
row.AddChild(xEdit);
|
|
||||||
|
|
||||||
var yEdit = new LineEdit();
|
|
||||||
yEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
yEdit.Text = v.Y.ToString("F3", System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
yEdit.TextChanged += txt =>
|
|
||||||
{
|
|
||||||
if (float.TryParse(yEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var val))
|
|
||||||
_controller.SetPolygonVertex(idx, new Vector2(_controller.PolygonVertices[idx].X, val));
|
|
||||||
OnParamChanged();
|
|
||||||
};
|
|
||||||
row.AddChild(yEdit);
|
|
||||||
|
|
||||||
var delBtn = new Button { Text = "X" };
|
|
||||||
delBtn.CustomMinimumSize = new Vector2(30, 0);
|
|
||||||
delBtn.Pressed += () =>
|
|
||||||
{
|
|
||||||
_controller.RemovePolygonVertex(idx);
|
|
||||||
OnParamChanged();
|
|
||||||
};
|
|
||||||
row.AddChild(delBtn);
|
|
||||||
|
|
||||||
_vertexList.AddChild(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshJson()
|
|
||||||
{
|
|
||||||
_jsonEdit.Text = _controller.ToJson();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnLoadJson()
|
|
||||||
{
|
|
||||||
var dialog = new FileDialog();
|
|
||||||
dialog.FileMode = FileDialog.FileModeEnum.OpenFile;
|
|
||||||
dialog.AddFilter("*.json", "JSON Files");
|
|
||||||
dialog.AddFilter("*.interactive", "Interactive Files");
|
|
||||||
dialog.FileSelected += path =>
|
|
||||||
{
|
|
||||||
var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
|
|
||||||
if (file != null)
|
|
||||||
{
|
|
||||||
var text = file.GetAsText();
|
|
||||||
file.Close();
|
|
||||||
if (_controller.FromJson(text))
|
|
||||||
{
|
|
||||||
WriteControllerToSidebar();
|
|
||||||
UpdateVertexList();
|
|
||||||
RebuildHandles();
|
|
||||||
UpdateColliderOverlay();
|
|
||||||
RefreshJson();
|
|
||||||
MarkClean();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
GD.PrintErr("Failed to parse JSON");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dialog.QueueFree();
|
|
||||||
};
|
|
||||||
dialog.Canceled += () => dialog.QueueFree();
|
|
||||||
AddChild(dialog);
|
|
||||||
dialog.PopupCentered(new Vector2I(800, 600));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnImportFromMod()
|
|
||||||
{
|
|
||||||
var ids = Interactives.Collect();
|
|
||||||
if (ids.Count == 0)
|
|
||||||
{
|
|
||||||
GD.Print("No interactive definitions found in mods");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var dialog = new AcceptDialog { Title = Tr("tools/interactive_editor/dlg_select_def") };
|
|
||||||
dialog.Size = new Vector2I(400, 300);
|
|
||||||
|
|
||||||
var list = new ItemList();
|
|
||||||
list.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
list.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
foreach (var kv in ids)
|
|
||||||
list.AddItem(kv.Key);
|
|
||||||
list.ItemActivated += idx =>
|
|
||||||
{
|
|
||||||
var id = list.GetItemText((int)idx);
|
|
||||||
var def = InteractiveFactory.Instance.Get(id);
|
|
||||||
if (def != null)
|
|
||||||
{
|
|
||||||
_controller.FromDefinition(def);
|
|
||||||
WriteControllerToSidebar();
|
|
||||||
UpdateVertexList();
|
|
||||||
RebuildHandles();
|
|
||||||
UpdateColliderOverlay();
|
|
||||||
RefreshJson();
|
|
||||||
MarkClean();
|
|
||||||
}
|
|
||||||
dialog.QueueFree();
|
|
||||||
};
|
|
||||||
dialog.AddChild(list);
|
|
||||||
AddChild(dialog);
|
|
||||||
dialog.PopupCentered();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSave()
|
|
||||||
{
|
|
||||||
var dialog = new FileDialog();
|
|
||||||
dialog.FileMode = FileDialog.FileModeEnum.SaveFile;
|
|
||||||
dialog.AddFilter("*.json", "JSON Files");
|
|
||||||
dialog.FileSelected += path =>
|
|
||||||
{
|
|
||||||
var json = _controller.ToJson();
|
|
||||||
var file = FileAccess.Open(path, FileAccess.ModeFlags.Write);
|
|
||||||
if (file != null)
|
|
||||||
{
|
|
||||||
file.StoreString(json);
|
|
||||||
file.Close();
|
|
||||||
MarkClean();
|
|
||||||
GD.Print($"Saved to {path}");
|
|
||||||
}
|
|
||||||
dialog.QueueFree();
|
|
||||||
};
|
|
||||||
dialog.Canceled += () => dialog.QueueFree();
|
|
||||||
AddChild(dialog);
|
|
||||||
dialog.PopupCentered(new Vector2I(800, 600));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnParamChanged(string _ = null) => OnParamChanged();
|
|
||||||
|
|
||||||
protected override void Cleanup()
|
|
||||||
{
|
|
||||||
foreach (var h in _handles)
|
|
||||||
h.QueueFree();
|
|
||||||
_handles.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Cthangover.Core.Mods;
|
|
||||||
using Cthangover.Core.Mods.Resolvers;
|
|
||||||
using Cthangover.Core.UI.Lights;
|
|
||||||
using Cthangover.Core.UI.Theming;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Core.UI.Tool.LightEditor
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Data controller for the light editor tool. Maintains a list of
|
|
||||||
/// <see cref="LightDef"/> objects and feeds up to 10 lights plus one
|
|
||||||
/// sentinel entry into the <c>"timed_sprite"</c> shader material for
|
|
||||||
/// real-time preview. Exports lights as JSON (<see cref="ExportLightsJson"/>)
|
|
||||||
/// or as a scenario DSL snippet (<see cref="ExportScenarioSnippet"/>).
|
|
||||||
/// Uses a dirty-flag pattern so shader uniform updates only occur when
|
|
||||||
/// the light list changes.
|
|
||||||
/// </summary>
|
|
||||||
public class LightEditorController
|
|
||||||
{
|
|
||||||
/// <summary>The editable list of light definitions. Capped at 10 for shader preview.</summary>
|
|
||||||
public List<LightDef> Lights { get; } = new();
|
|
||||||
|
|
||||||
private readonly Vector2[] previewPositions = new Vector2[11];
|
|
||||||
private readonly Color[] previewColors = new Color[11];
|
|
||||||
private readonly float[] previewRadii = new float[11];
|
|
||||||
private readonly float[] previewInfluence = new float[11];
|
|
||||||
|
|
||||||
private bool dirty = true;
|
|
||||||
|
|
||||||
/// <summary>Initialises preview arrays with out-of-bounds sentinel values.</summary>
|
|
||||||
public LightEditorController()
|
|
||||||
{
|
|
||||||
for (int i = 0; i < 11; i++)
|
|
||||||
{
|
|
||||||
previewPositions[i] = new Vector2(-1000f, -1000f);
|
|
||||||
previewRadii[i] = 0f;
|
|
||||||
previewInfluence[i] = 0f;
|
|
||||||
previewColors[i] = UiTokens.White;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a <see cref="ShaderMaterial"/> using the <c>"timed_sprite"</c> shader
|
|
||||||
/// with night-time weights applied. Returns <c>null</c> if the shader is not found.
|
|
||||||
/// </summary>
|
|
||||||
public ShaderMaterial CreatePreviewMaterial()
|
|
||||||
{
|
|
||||||
var shader = Shaders.Resolve("timed_sprite");
|
|
||||||
if (shader == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var mat = new ShaderMaterial { Shader = shader };
|
|
||||||
var night = new Vector4(0, 0, 0, 1);
|
|
||||||
mat.SetShaderParameter("time_weights", night);
|
|
||||||
mat.SetShaderParameter("use_time", true);
|
|
||||||
return mat;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Sets the background texture uniform on the preview shader material.</summary>
|
|
||||||
public void SetBackgroundForPreview(Texture2D texture, ShaderMaterial material)
|
|
||||||
{
|
|
||||||
if (material != null)
|
|
||||||
material.SetShaderParameter("background_map", texture);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Sets the depth mask texture uniform on the preview shader material.</summary>
|
|
||||||
public void SetDepthForPreview(Texture2D texture, ShaderMaterial material)
|
|
||||||
{
|
|
||||||
if (material != null)
|
|
||||||
material.SetShaderParameter("depth_mask", texture);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Sets the albedo texture uniform on the preview shader material.</summary>
|
|
||||||
public void SetAlbedoForPreview(Texture2D texture, ShaderMaterial material)
|
|
||||||
{
|
|
||||||
if (material != null)
|
|
||||||
material.SetShaderParameter("albedo_map", texture);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Appends a <see cref="LightDef"/> to the list and marks the preview dirty.</summary>
|
|
||||||
public void AddLight(LightDef light)
|
|
||||||
{
|
|
||||||
Lights.Add(light);
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Removes the light at <paramref name="index"/>. No-op if out of range.</summary>
|
|
||||||
public void RemoveLight(int index)
|
|
||||||
{
|
|
||||||
if (index < 0 || index >= Lights.Count)
|
|
||||||
return;
|
|
||||||
Lights.RemoveAt(index);
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Removes all lights from the list and marks dirty.</summary>
|
|
||||||
public void Clear()
|
|
||||||
{
|
|
||||||
Lights.Clear();
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Forces the preview to be recalculated on the next <see cref="UpdatePreview"/> call.</summary>
|
|
||||||
public void MarkDirty()
|
|
||||||
{
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Packs up to 10 lights plus a sentinel into shader uniform arrays.
|
|
||||||
/// Only executes if <c>dirty</c> is <c>true</c>. Converts each light's
|
|
||||||
/// normalised position to pixel coordinates relative to the preview area.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdatePreview(ShaderMaterial material, Vector2 previewGlobalPos, Vector2 previewSize)
|
|
||||||
{
|
|
||||||
if (material == null || !dirty)
|
|
||||||
return;
|
|
||||||
|
|
||||||
for (int i = 0; i < 11; i++)
|
|
||||||
{
|
|
||||||
previewPositions[i] = new Vector2(-1000f, -1000f);
|
|
||||||
previewRadii[i] = 0f;
|
|
||||||
previewInfluence[i] = 0f;
|
|
||||||
previewColors[i] = UiTokens.White;
|
|
||||||
}
|
|
||||||
|
|
||||||
int count = Mathf.Min(Lights.Count, 10);
|
|
||||||
for (int i = 0; i < count; i++)
|
|
||||||
{
|
|
||||||
var pixelPos = Lights[i].ToPixelPos(previewSize);
|
|
||||||
previewPositions[i] = new Vector2(
|
|
||||||
pixelPos.X + previewGlobalPos.X,
|
|
||||||
pixelPos.Y + previewGlobalPos.Y);
|
|
||||||
previewRadii[i] = Lights[i].Radius;
|
|
||||||
previewInfluence[i] = Lights[i].Influence;
|
|
||||||
previewColors[i] = Lights[i].ToColor();
|
|
||||||
}
|
|
||||||
|
|
||||||
var posArray = new Godot.Collections.Array<Vector2>();
|
|
||||||
var colArray = new Godot.Collections.Array<Color>();
|
|
||||||
var radArray = new Godot.Collections.Array<float>();
|
|
||||||
var infArray = new Godot.Collections.Array<float>();
|
|
||||||
for (int i = 0; i < 11; i++)
|
|
||||||
{
|
|
||||||
posArray.Add(previewPositions[i]);
|
|
||||||
colArray.Add(previewColors[i]);
|
|
||||||
radArray.Add(previewRadii[i]);
|
|
||||||
infArray.Add(previewInfluence[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
material.SetShaderParameter("light_count", count);
|
|
||||||
material.SetShaderParameter("light_positions", posArray);
|
|
||||||
material.SetShaderParameter("light_colors", colArray);
|
|
||||||
material.SetShaderParameter("light_radii", radArray);
|
|
||||||
material.SetShaderParameter("light_influence", infArray);
|
|
||||||
material.SetShaderParameter("light_radius_scale", 1.0f);
|
|
||||||
|
|
||||||
dirty = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Serialises the light list as a compact JSON string.</summary>
|
|
||||||
public string ExportLightsJson()
|
|
||||||
{
|
|
||||||
return JsonSerializer.Serialize(Lights, Cthangover.Core.Utils.JsonOptionsProvider.WriteCompactOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Produces a <c>light_set</c> scenario DSL command with the lights JSON as its argument.</summary>
|
|
||||||
public string ExportScenarioSnippet()
|
|
||||||
{
|
|
||||||
var json = ExportLightsJson();
|
|
||||||
return $"light_set \"{json.Replace("\"", "\\\"")}\"";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
using Cthangover.Core.UI.Lights;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Core.UI.Tool.LightEditor
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Draggable 24×24 coloured handle representing a light source on the preview.
|
|
||||||
/// Overrides <c>_GuiInput</c> (Godot virtual) for mouse drag support; the colour
|
|
||||||
/// is taken from <see cref="LightDef.ToColor"/>. Raises <see cref="DragUpdate"/>
|
|
||||||
/// with the handle's centre position in global coordinates for the window to
|
|
||||||
/// convert back to normalised coordinates.
|
|
||||||
/// </summary>
|
|
||||||
public partial class LightEditorHandle : ColorRect
|
|
||||||
{
|
|
||||||
/// <summary>Index of the light in <see cref="LightEditorController.Lights"/>.</summary>
|
|
||||||
public int LightIndex;
|
|
||||||
/// <summary>Invoked during drag with the centre position in global coordinates.</summary>
|
|
||||||
public System.Action<Vector2> DragUpdate;
|
|
||||||
/// <summary>Invoked when the handle is clicked (mouse down).</summary>
|
|
||||||
public System.Action Clicked;
|
|
||||||
|
|
||||||
private bool dragging;
|
|
||||||
private Vector2 dragOffset;
|
|
||||||
|
|
||||||
/// <summary>Creates a 24×24 handle coloured to match the light's <see cref="LightDef.ToColor"/> value.</summary>
|
|
||||||
public LightEditorHandle(LightDef light, int index)
|
|
||||||
{
|
|
||||||
LightIndex = index;
|
|
||||||
Size = new Vector2(24, 24);
|
|
||||||
Color = light.ToColor();
|
|
||||||
MouseFilter = MouseFilterEnum.Stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Handles mouse drag and click via Godot's virtual input system (<c>_GuiInput</c>).</summary>
|
|
||||||
public override void _GuiInput(InputEvent @event)
|
|
||||||
{
|
|
||||||
if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
|
|
||||||
{
|
|
||||||
if (mb.Pressed)
|
|
||||||
{
|
|
||||||
dragging = true;
|
|
||||||
dragOffset = GetGlobalMousePosition() - GlobalPosition;
|
|
||||||
Clicked?.Invoke();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
dragging = false;
|
|
||||||
}
|
|
||||||
AcceptEvent();
|
|
||||||
}
|
|
||||||
else if (@event is InputEventMouseMotion && dragging)
|
|
||||||
{
|
|
||||||
GlobalPosition = GetGlobalMousePosition() - dragOffset;
|
|
||||||
DragUpdate?.Invoke(GlobalPosition + Size / 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using Cthangover.Core.UI.Tool;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Cthangover.Core.Factories.Impls;
|
|
||||||
using Cthangover.Core.UI.Lights;
|
|
||||||
using Cthangover.Core.UI.Theming;
|
|
||||||
using Cthangover.Core.Utils;
|
|
||||||
using Cthangover.Tools.Services;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Core.UI.Tool.LightEditor
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Editor window for placing and configuring static lights on a background.
|
|
||||||
/// Provides a sidebar with background selection, a light list with add/delete/select,
|
|
||||||
/// per-light property editors (radius, influence, colour), draggable handles on a
|
|
||||||
/// shader-powered preview, and JSON export. Uses <see cref="LightEditorController"/>
|
|
||||||
/// for data management and <see cref="ModResourceService"/> for background textures.
|
|
||||||
/// </summary>
|
|
||||||
public partial class LightEditorWindow : ToolWindow
|
|
||||||
{
|
|
||||||
private LightEditorController controller;
|
|
||||||
|
|
||||||
private ItemList backgroundList;
|
|
||||||
private VBoxContainer lightListPanel;
|
|
||||||
private Button addLightBtn;
|
|
||||||
private TextureRect preview;
|
|
||||||
private ShaderMaterial previewMaterial;
|
|
||||||
|
|
||||||
private LineEdit radiusEdit;
|
|
||||||
private LineEdit influenceEdit;
|
|
||||||
private ColorPickerButton colorPicker;
|
|
||||||
private TextEdit jsonEdit;
|
|
||||||
|
|
||||||
private readonly List<LightEditorHandle> handles = new();
|
|
||||||
private int selectedIndex = -1;
|
|
||||||
|
|
||||||
private readonly List<string> bgIds = new();
|
|
||||||
|
|
||||||
/// <summary>Constructs the window, creates the controller, builds UI, and loads the background list.</summary>
|
|
||||||
public LightEditorWindow() : base("tools/light_editor/title")
|
|
||||||
{
|
|
||||||
controller = new LightEditorController();
|
|
||||||
CthangoverTheme.Apply(this);
|
|
||||||
BuildUI();
|
|
||||||
LoadBackgroundList();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Cleanup()
|
|
||||||
{
|
|
||||||
foreach (var h in handles)
|
|
||||||
h.QueueFree();
|
|
||||||
handles.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Vector2 PreviewSize()
|
|
||||||
{
|
|
||||||
var sz = preview.Size;
|
|
||||||
if (sz.X <= 0 || sz.Y <= 0)
|
|
||||||
sz = new Vector2(1920, 1080);
|
|
||||||
return sz;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshAll()
|
|
||||||
{
|
|
||||||
RepositionHandles();
|
|
||||||
controller.MarkDirty();
|
|
||||||
controller.UpdatePreview(previewMaterial, preview.GlobalPosition, PreviewSize());
|
|
||||||
UpdateLightList();
|
|
||||||
UpdateJsonEdit();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildUI()
|
|
||||||
{
|
|
||||||
var outerVBox = CreateFillContainer();
|
|
||||||
AddChild(outerVBox);
|
|
||||||
|
|
||||||
var mainHBox = new HBoxContainer();
|
|
||||||
mainHBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
mainHBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
outerVBox.AddChild(mainHBox);
|
|
||||||
|
|
||||||
var sidebarVBox = new VBoxContainer();
|
|
||||||
var sidebar = CreateSidebar(sidebarVBox);
|
|
||||||
mainHBox.AddChild(sidebar);
|
|
||||||
|
|
||||||
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/background") });
|
|
||||||
|
|
||||||
backgroundList = new ItemList();
|
|
||||||
backgroundList.CustomMinimumSize = new Vector2(0, 140);
|
|
||||||
backgroundList.ItemSelected += OnBackgroundSelected;
|
|
||||||
sidebarVBox.AddChild(backgroundList);
|
|
||||||
|
|
||||||
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/static_lights") });
|
|
||||||
|
|
||||||
lightListPanel = new VBoxContainer();
|
|
||||||
sidebarVBox.AddChild(lightListPanel);
|
|
||||||
|
|
||||||
addLightBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/add_light") };
|
|
||||||
addLightBtn.Pressed += OnAddLight;
|
|
||||||
sidebarVBox.AddChild(addLightBtn);
|
|
||||||
|
|
||||||
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/selected_props") });
|
|
||||||
|
|
||||||
var radiusHBox = new HBoxContainer();
|
|
||||||
radiusHBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/radius") });
|
|
||||||
radiusEdit = new LineEdit { Text = "300" };
|
|
||||||
radiusEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
radiusEdit.TextChanged += OnRadiusChanged;
|
|
||||||
radiusHBox.AddChild(radiusEdit);
|
|
||||||
sidebarVBox.AddChild(radiusHBox);
|
|
||||||
|
|
||||||
var infHBox = new HBoxContainer();
|
|
||||||
infHBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/influence") });
|
|
||||||
influenceEdit = new LineEdit { Text = "1" };
|
|
||||||
influenceEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
influenceEdit.TextChanged += OnInfluenceChanged;
|
|
||||||
infHBox.AddChild(influenceEdit);
|
|
||||||
sidebarVBox.AddChild(infHBox);
|
|
||||||
|
|
||||||
var colorHBox = new HBoxContainer();
|
|
||||||
colorHBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/color") });
|
|
||||||
colorPicker = new ColorPickerButton { Color = Colors.Yellow };
|
|
||||||
colorPicker.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
colorPicker.ColorChanged += OnColorChanged;
|
|
||||||
colorHBox.AddChild(colorPicker);
|
|
||||||
sidebarVBox.AddChild(colorHBox);
|
|
||||||
|
|
||||||
var importBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/import_json") };
|
|
||||||
importBtn.Pressed += OnImport;
|
|
||||||
sidebarVBox.AddChild(importBtn);
|
|
||||||
|
|
||||||
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/norm_coords") });
|
|
||||||
|
|
||||||
var previewContainer = new PanelContainer();
|
|
||||||
previewContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
previewContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
mainHBox.AddChild(previewContainer);
|
|
||||||
|
|
||||||
preview = new TextureRect();
|
|
||||||
preview.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
|
|
||||||
preview.StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered;
|
|
||||||
preview.MouseFilter = Control.MouseFilterEnum.Stop;
|
|
||||||
preview.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
preview.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
previewContainer.AddChild(preview);
|
|
||||||
|
|
||||||
previewMaterial = controller.CreatePreviewMaterial();
|
|
||||||
preview.Material = previewMaterial;
|
|
||||||
|
|
||||||
jsonEdit = new TextEdit();
|
|
||||||
jsonEdit.CustomMinimumSize = new Vector2(0, 70);
|
|
||||||
jsonEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
jsonEdit.WrapMode = TextEdit.LineWrappingMode.Boundary;
|
|
||||||
jsonEdit.TextChanged += OnJsonEditChanged;
|
|
||||||
outerVBox.AddChild(jsonEdit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnJsonEditChanged()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateJsonEdit()
|
|
||||||
{
|
|
||||||
var json = controller.ExportLightsJson();
|
|
||||||
if (jsonEdit.Text != json)
|
|
||||||
jsonEdit.Text = json;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LoadBackgroundList()
|
|
||||||
{
|
|
||||||
bgIds.Clear();
|
|
||||||
backgroundList.Clear();
|
|
||||||
|
|
||||||
var ids = ModResourceService.GetBackgroundIds();
|
|
||||||
bgIds.AddRange(ids);
|
|
||||||
foreach (var id in ids)
|
|
||||||
backgroundList.AddItem(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnBackgroundSelected(long index)
|
|
||||||
{
|
|
||||||
var bgId = bgIds[(int)index];
|
|
||||||
var tex = BackgroundFactory.Instance.Get(bgId);
|
|
||||||
preview.Texture = tex;
|
|
||||||
controller.SetBackgroundForPreview(tex, previewMaterial);
|
|
||||||
|
|
||||||
var depthTex = BackgroundFactory.Instance.Get(bgId + "_depth");
|
|
||||||
if (depthTex != null)
|
|
||||||
controller.SetDepthForPreview(depthTex, previewMaterial);
|
|
||||||
|
|
||||||
var albedoTex = BackgroundFactory.Instance.Get(bgId + "_albedo");
|
|
||||||
if (albedoTex != null)
|
|
||||||
controller.SetAlbedoForPreview(albedoTex, previewMaterial);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnAddLight()
|
|
||||||
{
|
|
||||||
if (handles.Count >= 10)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var light = new LightDef
|
|
||||||
{
|
|
||||||
X = 0.4f,
|
|
||||||
Y = 0.3f,
|
|
||||||
Radius = 300f,
|
|
||||||
Influence = 1f,
|
|
||||||
ColorHex = "#ffff00"
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.AddLight(light);
|
|
||||||
AddHandle(light, controller.Lights.Count - 1);
|
|
||||||
RefreshAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddHandle(LightDef light, int index)
|
|
||||||
{
|
|
||||||
var handle = new LightEditorHandle(light, index);
|
|
||||||
handle.DragUpdate += globalCenter =>
|
|
||||||
{
|
|
||||||
var localCenter = globalCenter - preview.GlobalPosition;
|
|
||||||
var currentSize = PreviewSize();
|
|
||||||
light.FromPixelPos(localCenter, currentSize);
|
|
||||||
RefreshAll();
|
|
||||||
};
|
|
||||||
handle.Clicked += () => SelectHandle(index);
|
|
||||||
preview.AddChild(handle);
|
|
||||||
handles.Add(handle);
|
|
||||||
RepositionHandle(handle, light);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RepositionHandle(LightEditorHandle handle, LightDef light)
|
|
||||||
{
|
|
||||||
var size = PreviewSize();
|
|
||||||
handle.GlobalPosition = preview.GlobalPosition
|
|
||||||
+ new Vector2(light.X * size.X - 12, light.Y * size.Y - 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RepositionHandles()
|
|
||||||
{
|
|
||||||
for (int i = 0; i < handles.Count && i < controller.Lights.Count; i++)
|
|
||||||
RepositionHandle(handles[i], controller.Lights[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SelectHandle(int index)
|
|
||||||
{
|
|
||||||
selectedIndex = index;
|
|
||||||
if (index < 0 || index >= controller.Lights.Count)
|
|
||||||
{
|
|
||||||
radiusEdit.Text = "300";
|
|
||||||
influenceEdit.Text = "1";
|
|
||||||
colorPicker.Color = Colors.Yellow;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var light = controller.Lights[index];
|
|
||||||
radiusEdit.Text = light.Radius.ToString("F0");
|
|
||||||
influenceEdit.Text = light.Influence.ToString("F2");
|
|
||||||
colorPicker.Color = light.ToColor();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnRadiusChanged(string text)
|
|
||||||
{
|
|
||||||
if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count)
|
|
||||||
return;
|
|
||||||
if (float.TryParse(text, out var val))
|
|
||||||
{
|
|
||||||
controller.Lights[selectedIndex].Radius = val;
|
|
||||||
RefreshAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnInfluenceChanged(string text)
|
|
||||||
{
|
|
||||||
if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count)
|
|
||||||
return;
|
|
||||||
if (float.TryParse(text, out var val))
|
|
||||||
{
|
|
||||||
controller.Lights[selectedIndex].Influence = val;
|
|
||||||
RefreshAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnColorChanged(Color color)
|
|
||||||
{
|
|
||||||
if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count)
|
|
||||||
return;
|
|
||||||
controller.Lights[selectedIndex].ColorHex = color.ToHtml();
|
|
||||||
RefreshAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateLightList()
|
|
||||||
{
|
|
||||||
foreach (var child in lightListPanel.GetChildren())
|
|
||||||
child.QueueFree();
|
|
||||||
|
|
||||||
for (int i = 0; i < controller.Lights.Count; i++)
|
|
||||||
{
|
|
||||||
var light = controller.Lights[i];
|
|
||||||
var hBox = new HBoxContainer();
|
|
||||||
var label = new Label
|
|
||||||
{
|
|
||||||
Text = string.Format(TranslationServer.Translate("tools/light_editor/light_n"), i + 1, light.X, light.Y),
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill
|
|
||||||
};
|
|
||||||
hBox.AddChild(label);
|
|
||||||
|
|
||||||
var selBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/btn_select") };
|
|
||||||
int idx = i;
|
|
||||||
selBtn.Pressed += () => SelectHandle(idx);
|
|
||||||
hBox.AddChild(selBtn);
|
|
||||||
|
|
||||||
var delBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/btn_delete") };
|
|
||||||
delBtn.Pressed += () => RemoveLight(idx);
|
|
||||||
hBox.AddChild(delBtn);
|
|
||||||
|
|
||||||
lightListPanel.AddChild(hBox);
|
|
||||||
}
|
|
||||||
|
|
||||||
addLightBtn.Disabled = controller.Lights.Count >= 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RemoveLight(int index)
|
|
||||||
{
|
|
||||||
if (index < 0 || index >= controller.Lights.Count)
|
|
||||||
return;
|
|
||||||
|
|
||||||
controller.RemoveLight(index);
|
|
||||||
|
|
||||||
if (index < handles.Count)
|
|
||||||
{
|
|
||||||
handles[index].QueueFree();
|
|
||||||
handles.RemoveAt(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < handles.Count; i++)
|
|
||||||
handles[i].LightIndex = i;
|
|
||||||
|
|
||||||
if (selectedIndex == index)
|
|
||||||
selectedIndex = -1;
|
|
||||||
else if (selectedIndex > index)
|
|
||||||
selectedIndex--;
|
|
||||||
|
|
||||||
RefreshAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnImport()
|
|
||||||
{
|
|
||||||
var json = jsonEdit.Text;
|
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lights = JsonSerializer.Deserialize<List<LightDef>>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
|
|
||||||
if (lights == null || lights.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
foreach (var h in handles)
|
|
||||||
h.QueueFree();
|
|
||||||
handles.Clear();
|
|
||||||
controller.Clear();
|
|
||||||
|
|
||||||
foreach (var light in lights)
|
|
||||||
{
|
|
||||||
controller.AddLight(light);
|
|
||||||
AddHandle(light, controller.Lights.Count - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedIndex = -1;
|
|
||||||
RefreshAll();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
GameLogger.Log("LIGHT_EDITOR", $"Import JSON failed: {ex.Message}", LogLevel.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Cthangover.Core.Mods.Resolvers;
|
|
||||||
using Cthangover.Core.UI.Theming;
|
|
||||||
using Cthangover.Core.UI.Tool;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Tools.MapEditor
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Interactive preview of the edited map: draws the background with the
|
|
||||||
/// exact aspect-preserving layout used by <c>MapWidget</c>, overlays every
|
|
||||||
/// zone polygon, marker and text label, and hosts the draggable handles.
|
|
||||||
/// Implements the two editor modes:
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item><b>select</b> — clicking picks the topmost zone (polygon first,
|
|
||||||
/// then marker/label dots); handles move vertices and positions;</item>
|
|
||||||
/// <item><b>draw</b> — clicking appends polygon vertices to the selected
|
|
||||||
/// region and double-click finishes the polygon and returns to select
|
|
||||||
/// mode.</item>
|
|
||||||
/// </list>
|
|
||||||
/// Right-click deletes a polygon vertex in either mode: the 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.
|
|
||||||
/// 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 preview. Drawing is connected
|
|
||||||
/// through the "draw" signal and input through the GuiInput event, because
|
|
||||||
/// virtual method overrides are unreliable in mod assemblies.
|
|
||||||
/// <para/>
|
|
||||||
/// Zone fills and outlines are painted through <see cref="MapZoneStyle"/> —
|
|
||||||
/// the same definition the game's <c>MapZonesLayer</c> and the window's
|
|
||||||
/// game-like preview use — so a zone the author sees here and the same zone
|
|
||||||
/// on the live map are guaranteed to look identical.
|
|
||||||
/// </summary>
|
|
||||||
public partial class MapEditorCanvas : Control
|
|
||||||
{
|
|
||||||
/// <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";
|
|
||||||
|
|
||||||
private static readonly Color VertexColor = MapZoneStyle.VertexHandle;
|
|
||||||
private static readonly Color MarkerColor = MapZoneStyle.MarkerHandle;
|
|
||||||
private static readonly Color LabelColor = MapZoneStyle.LabelHandle;
|
|
||||||
|
|
||||||
private readonly List<MapEditorHandle> _handles = new();
|
|
||||||
|
|
||||||
/// <summary>Data being edited; set once by the window.</summary>
|
|
||||||
public MapEditorController Controller;
|
|
||||||
|
|
||||||
/// <summary>Reference background; <c>null</c> falls back to a flat rectangle.</summary>
|
|
||||||
public Texture2D Background;
|
|
||||||
|
|
||||||
/// <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, pick).</summary>
|
|
||||||
public event Action Changed;
|
|
||||||
|
|
||||||
/// <summary>Raised with the newly picked zone index (-1 for empty space).</summary>
|
|
||||||
public event Action<int> ZonePicked;
|
|
||||||
|
|
||||||
public MapEditorCanvas()
|
|
||||||
{
|
|
||||||
MouseFilter = MouseFilterEnum.Stop;
|
|
||||||
ClipContents = true;
|
|
||||||
MapZoneStyle.Prepare(this);
|
|
||||||
GuiInput += OnGuiInput;
|
|
||||||
MouseExited += OnMouseExited;
|
|
||||||
Resized += OnResized;
|
|
||||||
Connect("draw", Callable.From((Action)DrawAll));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Switches the interaction mode and refreshes the handles.</summary>
|
|
||||||
public void SetMode(string mode)
|
|
||||||
{
|
|
||||||
Mode = mode == ModeDraw ? ModeDraw : ModeSelect;
|
|
||||||
RebuildHandles();
|
|
||||||
QueueRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Assigns the background texture and rebuilds the layout.</summary>
|
|
||||||
public void SetBackground(Texture2D texture)
|
|
||||||
{
|
|
||||||
Background = texture;
|
|
||||||
RebuildHandles();
|
|
||||||
QueueRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Recreates the handles from the current selection. Call after structural changes.</summary>
|
|
||||||
public void RebuildHandles()
|
|
||||||
{
|
|
||||||
foreach (var handle in _handles)
|
|
||||||
handle.QueueFree();
|
|
||||||
_handles.Clear();
|
|
||||||
|
|
||||||
var zone = Controller?.Selected;
|
|
||||||
if (zone == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var rect = GetBackgroundRect();
|
|
||||||
if (rect.Size.X <= 0f || rect.Size.Y <= 0f)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (zone.Points != null)
|
|
||||||
{
|
|
||||||
for (var i = 0; i < zone.Points.Count; i++)
|
|
||||||
AddHandle(rect, zone.Points[i], VertexColor, i);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (HasMarker(zone))
|
|
||||||
AddHandle(rect, zone.ImagePos, MarkerColor, MapEditorHandle.MarkerHandle);
|
|
||||||
|
|
||||||
if (zone.LabelPos.HasValue)
|
|
||||||
AddHandle(rect, zone.LabelPos.Value, LabelColor, MapEditorHandle.LabelHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Aspect-preserving rectangle the background occupies inside the
|
|
||||||
/// canvas. Must stay identical to the runtime layout: the saved
|
|
||||||
/// normalised coordinates mean nothing if the editor and the game
|
|
||||||
/// disagree about this rectangle.
|
|
||||||
/// </summary>
|
|
||||||
public Rect2 GetBackgroundRect()
|
|
||||||
{
|
|
||||||
var controlSize = Size;
|
|
||||||
if (controlSize.X <= 0f || controlSize.Y <= 0f)
|
|
||||||
return new Rect2();
|
|
||||||
|
|
||||||
var textureSize = Background?.GetSize() ?? new Vector2(1920, 1080);
|
|
||||||
if (textureSize.X <= 0f || textureSize.Y <= 0f)
|
|
||||||
textureSize = new Vector2(1920, 1080);
|
|
||||||
|
|
||||||
var scale = Mathf.Min(controlSize.X / textureSize.X, controlSize.Y / textureSize.Y);
|
|
||||||
var size = textureSize * scale;
|
|
||||||
return new Rect2((controlSize - size) / 2f, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddHandle(Rect2 rect, Vector2 normalized, Color color, int index)
|
|
||||||
{
|
|
||||||
var handle = new MapEditorHandle(color, index);
|
|
||||||
handle.DragUpdate = center => OnHandleDrag(index, center);
|
|
||||||
handle.DragEnd = () => Changed?.Invoke();
|
|
||||||
handle.RightClicked = () => RemoveVertexHandle(index);
|
|
||||||
AddChild(handle);
|
|
||||||
handle.GlobalPosition = GlobalPosition + rect.Position + Clamp01(normalized) * rect.Size - handle.Size / 2f;
|
|
||||||
_handles.Add(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnHandleDrag(int index, Vector2 globalCenter)
|
|
||||||
{
|
|
||||||
if (Controller == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var rect = GetBackgroundRect();
|
|
||||||
if (rect.Size.X <= 0f || rect.Size.Y <= 0f)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var local = globalCenter - GlobalPosition - rect.Position;
|
|
||||||
var normalized = Clamp01(new Vector2(local.X / rect.Size.X, local.Y / rect.Size.Y));
|
|
||||||
|
|
||||||
if (index == MapEditorHandle.MarkerHandle)
|
|
||||||
Controller.SetImagePos(normalized);
|
|
||||||
else if (index == MapEditorHandle.LabelHandle)
|
|
||||||
Controller.SetLabelPos(normalized);
|
|
||||||
else
|
|
||||||
Controller.SetVertex(index, normalized);
|
|
||||||
|
|
||||||
QueueRedraw();
|
|
||||||
Changed?.Invoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnGuiInput(InputEvent @event)
|
|
||||||
{
|
|
||||||
if (Controller == null || @event is not InputEventMouseButton mb || !mb.Pressed)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (mb.ButtonIndex == MouseButton.Left)
|
|
||||||
{
|
|
||||||
if (Mode == ModeDraw)
|
|
||||||
{
|
|
||||||
Controller.AddVertex(Clamp01(PixelToNorm(mb.Position)));
|
|
||||||
if (mb.DoubleClick)
|
|
||||||
Mode = ModeSelect;
|
|
||||||
RebuildHandles();
|
|
||||||
Changed?.Invoke();
|
|
||||||
QueueRedraw();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var hit = HitTest(mb.Position);
|
|
||||||
if (hit != Controller.SelectedIndex)
|
|
||||||
{
|
|
||||||
Controller.Select(hit);
|
|
||||||
RebuildHandles();
|
|
||||||
ZonePicked?.Invoke(hit);
|
|
||||||
Changed?.Invoke();
|
|
||||||
QueueRedraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AcceptEvent();
|
|
||||||
}
|
|
||||||
else if (mb.ButtonIndex == MouseButton.Right)
|
|
||||||
{
|
|
||||||
RemoveVertexAtPointer(mb.Position, Mode == ModeDraw);
|
|
||||||
AcceptEvent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnMouseExited()
|
|
||||||
{
|
|
||||||
QueueRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnResized()
|
|
||||||
{
|
|
||||||
RebuildHandles();
|
|
||||||
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() <= 24f)
|
|
||||||
return i;
|
|
||||||
if (zone.LabelPos.HasValue && (local - ToPixel(zone.LabelPos.Value, rect)).Length() <= 24f)
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
var zones = Controller?.Zones;
|
|
||||||
if (zones == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
for (var i = 0; i < zones.Count; i++)
|
|
||||||
{
|
|
||||||
var selected = i == Controller.SelectedIndex;
|
|
||||||
DrawZone(zones[i], rect, selected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawZone(MapZone zone, Rect2 rect, bool selected)
|
|
||||||
{
|
|
||||||
if (zone.Points != null && zone.Points.Count >= 2)
|
|
||||||
MapZoneStyle.Draw(this, ToPixels(zone.Points, rect), selected, false, zone.Locked);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Vector2 PixelToNorm(Vector2 local)
|
|
||||||
{
|
|
||||||
var rect = GetBackgroundRect();
|
|
||||||
if (rect.Size.X <= 0f || rect.Size.Y <= 0f)
|
|
||||||
return Vector2.Zero;
|
|
||||||
|
|
||||||
return new Vector2((local.X - rect.Position.X) / rect.Size.X, (local.Y - rect.Position.Y) / rect.Size.Y);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Vector2 Clamp01(Vector2 value)
|
|
||||||
{
|
|
||||||
return new Vector2(Mathf.Clamp(value.X, 0f, 1f), Mathf.Clamp(value.Y, 0f, 1f));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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.
|
|
||||||
/// </summary>
|
|
||||||
private static bool HasMarker(MapZone zone)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(zone.Image))
|
|
||||||
return true;
|
|
||||||
|
|
||||||
return (zone.Points == null || zone.Points.Count == 0) && !zone.LabelPos.HasValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Right-click handler: 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)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Controller.RemoveVertex(index);
|
|
||||||
RebuildHandles();
|
|
||||||
Changed?.Invoke();
|
|
||||||
QueueRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int FindNearestVertex(MapZone zone, Vector2 local, Rect2 rect)
|
|
||||||
{
|
|
||||||
var best = -1;
|
|
||||||
var bestDistance = 24f;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a vertex from its handle's right-click. Marker and label
|
|
||||||
/// handles (negative indexes) are not vertices and are ignored.
|
|
||||||
/// </summary>
|
|
||||||
private void RemoveVertexHandle(int index)
|
|
||||||
{
|
|
||||||
if (index < 0 || Controller == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Controller.RemoveVertex(index);
|
|
||||||
RebuildHandles();
|
|
||||||
Changed?.Invoke();
|
|
||||||
QueueRedraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Cthangover.Core.UI.Tool;
|
|
||||||
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/<mod>/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 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
using Cthangover.Core.Utils;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Tools.MapEditor
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Draggable 16x16 handle on the map editor canvas. One handle represents
|
|
||||||
/// either a polygon vertex, the marker position or the label position,
|
|
||||||
/// distinguished by <see cref="Index"/> with negative values reserved for
|
|
||||||
/// the position handles. Subscribes to the GuiInput C# event rather than
|
|
||||||
/// overriding <c>_GuiInput</c> for reliable event delivery in mod
|
|
||||||
/// assemblies, following <c>InteractiveEditorHandle</c>.
|
|
||||||
/// </summary>
|
|
||||||
public partial class MapEditorHandle : ColorRect
|
|
||||||
{
|
|
||||||
/// <summary>Handle role: 0..n — vertex index, <see cref="MarkerHandle"/> or <see cref="LabelHandle"/>.</summary>
|
|
||||||
public const int MarkerHandle = -2;
|
|
||||||
|
|
||||||
/// <summary>Handle role used for the label position handle.</summary>
|
|
||||||
public const int LabelHandle = -3;
|
|
||||||
|
|
||||||
/// <summary>Vertex index or one of the negative role constants.</summary>
|
|
||||||
public int Index;
|
|
||||||
|
|
||||||
/// <summary>Invoked during drag with the handle centre in global coordinates.</summary>
|
|
||||||
public System.Action<Vector2> DragUpdate;
|
|
||||||
|
|
||||||
/// <summary>Invoked when the handle is clicked (mouse down).</summary>
|
|
||||||
public System.Action Clicked;
|
|
||||||
|
|
||||||
/// <summary>Invoked when the drag ends (mouse up).</summary>
|
|
||||||
public System.Action DragEnd;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Invoked on a right-click over the handle. Handles stop mouse input,
|
|
||||||
/// so without this hook a right-click aimed exactly at a vertex would
|
|
||||||
/// never reach the canvas and the vertex could not be deleted there.
|
|
||||||
/// </summary>
|
|
||||||
public System.Action RightClicked;
|
|
||||||
|
|
||||||
private bool _dragging;
|
|
||||||
private Vector2 _dragOffset;
|
|
||||||
|
|
||||||
/// <summary>Creates a handle with the given colour and role index.</summary>
|
|
||||||
public MapEditorHandle(Color color, int index)
|
|
||||||
{
|
|
||||||
Index = index;
|
|
||||||
Size = new Vector2(16, 16);
|
|
||||||
Color = color;
|
|
||||||
MouseFilter = MouseFilterEnum.Stop;
|
|
||||||
MouseDefaultCursorShape = CursorShape.PointingHand;
|
|
||||||
GuiInput += OnGuiInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnGuiInput(InputEvent @event)
|
|
||||||
{
|
|
||||||
if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
|
|
||||||
{
|
|
||||||
if (mb.Pressed)
|
|
||||||
{
|
|
||||||
_dragging = true;
|
|
||||||
_dragOffset = GetGlobalMousePosition() - GlobalPosition;
|
|
||||||
Clicked?.Invoke();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_dragging = false;
|
|
||||||
DragEnd?.Invoke();
|
|
||||||
}
|
|
||||||
AcceptEvent();
|
|
||||||
}
|
|
||||||
else if (@event is InputEventMouseButton right && right.ButtonIndex == MouseButton.Right && right.Pressed)
|
|
||||||
{
|
|
||||||
RightClicked?.Invoke();
|
|
||||||
AcceptEvent();
|
|
||||||
}
|
|
||||||
else if (@event is InputEventMouseMotion && _dragging)
|
|
||||||
{
|
|
||||||
var center = GlobalPosition + Size / 2;
|
|
||||||
GlobalPosition = GetGlobalMousePosition() - _dragOffset;
|
|
||||||
DragUpdate?.Invoke(center);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
using Cthangover.Core.UI.Tool;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
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
@@ -1,314 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using Cthangover.Tools.Services;
|
|
||||||
using Cthangover.Core.Utils;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Core.UI.Tool.ScenarioEditor
|
|
||||||
{
|
|
||||||
public sealed class TabData
|
|
||||||
{
|
|
||||||
public string Key;
|
|
||||||
public string ModId;
|
|
||||||
public string RelativePath;
|
|
||||||
public string FileName;
|
|
||||||
public string SavedText;
|
|
||||||
public bool IsDirty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial class ScenarioEditTabs : VBoxContainer
|
|
||||||
{
|
|
||||||
private const int GraphTabIndex = 0;
|
|
||||||
|
|
||||||
private TabBar _tabBar;
|
|
||||||
private TextEdit _textEdit;
|
|
||||||
private ScenarioGraphView _graphView;
|
|
||||||
private Font _monospaceFont;
|
|
||||||
|
|
||||||
private readonly List<TabData> _tabs = new();
|
|
||||||
private int _activeTabIndex = -1;
|
|
||||||
private bool _suppressTextChanged;
|
|
||||||
|
|
||||||
public TextEdit 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;
|
|
||||||
|
|
||||||
public ScenarioEditTabs(ScenarioGraphView graphView, Font monospaceFont)
|
|
||||||
{
|
|
||||||
_graphView = graphView;
|
|
||||||
_monospaceFont = monospaceFont;
|
|
||||||
|
|
||||||
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 = new TextEdit
|
|
||||||
{
|
|
||||||
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
|
||||||
SizeFlagsVertical = SizeFlags.ExpandFill,
|
|
||||||
Editable = true,
|
|
||||||
SyntaxHighlighter = ScenarioSyntaxService.CreateHighlighter(),
|
|
||||||
WrapMode = TextEdit.LineWrappingMode.None
|
|
||||||
};
|
|
||||||
_textEdit.AddThemeFontOverride("font", _monospaceFont);
|
|
||||||
_textEdit.TextChanged += OnTextChanged;
|
|
||||||
AddChild(_textEdit);
|
|
||||||
|
|
||||||
_graphView.ScenarioFileRequested += OnGraphScenarioFileRequested;
|
|
||||||
AddChild(_graphView);
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
SavedText = text,
|
|
||||||
IsDirty = false
|
|
||||||
};
|
|
||||||
|
|
||||||
_tabs.Add(tab);
|
|
||||||
_tabBar.AddTab(tab.FileName);
|
|
||||||
|
|
||||||
SwitchToTextTab(_tabs.Count - 1);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
GameLogger.Log("SCENARIO_EDITOR", $"Failed to load file: {ex.Message}", LogLevel.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SaveCurrentFile()
|
|
||||||
{
|
|
||||||
if (_activeTabIndex < 0 || _activeTabIndex >= _tabs.Count)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var tab = _tabs[_activeTabIndex];
|
|
||||||
tab.SavedText = _textEdit.Text;
|
|
||||||
if (tab.IsDirty)
|
|
||||||
{
|
|
||||||
tab.IsDirty = false;
|
|
||||||
UpdateTabTitle(_activeTabIndex);
|
|
||||||
UpdateGlobalDirty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetTextSilent(string text)
|
|
||||||
{
|
|
||||||
_suppressTextChanged = true;
|
|
||||||
_textEdit.Text = text;
|
|
||||||
_suppressTextChanged = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetViewToGraph()
|
|
||||||
{
|
|
||||||
if (_tabs.Count > 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_tabBar.CurrentTab = GraphTabIndex;
|
|
||||||
_textEdit.Visible = false;
|
|
||||||
_graphView.Visible = true;
|
|
||||||
GraphTabSelected?.Invoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RefreshGraph()
|
|
||||||
{
|
|
||||||
if (_graphView.Visible)
|
|
||||||
{
|
|
||||||
var data = ScenarioFileService.BuildSceneGraph();
|
|
||||||
_graphView.Populate(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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].SavedText = _textEdit.Text;
|
|
||||||
|
|
||||||
_activeTabIndex = index;
|
|
||||||
_tabBar.CurrentTab = index + 1;
|
|
||||||
|
|
||||||
var tab = _tabs[index];
|
|
||||||
|
|
||||||
SetTextSilent(tab.SavedText);
|
|
||||||
|
|
||||||
TextTabSelected?.Invoke(tab.SavedText);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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].SavedText = _textEdit.Text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnGraphScenarioFileRequested(string filePath)
|
|
||||||
{
|
|
||||||
GraphFileRequested?.Invoke(filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using Cthangover.Core.UI.Tool;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Cthangover.Core.Scenes;
|
|
||||||
using Cthangover.Core.UI.Theming;
|
|
||||||
using Cthangover.Core.Utils;
|
|
||||||
using Cthangover.Tools.Services;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Core.UI.Tool.ScenarioEditor
|
|
||||||
{
|
|
||||||
public partial class ScenarioEditorWindow : ToolWindow
|
|
||||||
{
|
|
||||||
private Control _editLayout;
|
|
||||||
private Control _playLayout;
|
|
||||||
|
|
||||||
private ScenarioFileTreePanel _fileTreePanel;
|
|
||||||
private ScenarioEditTabs _editTabs;
|
|
||||||
private ScenarioInfoPanel _infoPanel;
|
|
||||||
private ScenarioThumbnailPanel _thumbPanel;
|
|
||||||
|
|
||||||
private Button _saveBtn;
|
|
||||||
private Button _openFileBtn;
|
|
||||||
private Button _playBtn;
|
|
||||||
private Label _filePathLabel;
|
|
||||||
private Label _statusLabel;
|
|
||||||
|
|
||||||
private bool _playActive;
|
|
||||||
private CanvasLayer _stopOverlay;
|
|
||||||
|
|
||||||
public ScenarioEditorWindow() : base("tools/scenario_editor/title")
|
|
||||||
{
|
|
||||||
CthangoverTheme.Apply(this);
|
|
||||||
BuildUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void _Ready()
|
|
||||||
{
|
|
||||||
_editTabs.SetViewToGraph();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Cleanup()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void _Process(double delta)
|
|
||||||
{
|
|
||||||
if (_playActive && !SceneManager.IsTestPlayActive)
|
|
||||||
ReturnFromPlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildUI()
|
|
||||||
{
|
|
||||||
_editLayout = new Control
|
|
||||||
{
|
|
||||||
AnchorRight = 1f,
|
|
||||||
AnchorBottom = 1f,
|
|
||||||
Visible = true
|
|
||||||
};
|
|
||||||
AddChild(_editLayout);
|
|
||||||
|
|
||||||
var outerVBox = CreateFillContainer();
|
|
||||||
_editLayout.AddChild(outerVBox);
|
|
||||||
|
|
||||||
var toolbar = CreateToolbar();
|
|
||||||
outerVBox.AddChild(toolbar);
|
|
||||||
|
|
||||||
_saveBtn = new Button
|
|
||||||
{
|
|
||||||
Text = TranslationServer.Translate("tools/scenario_editor/save"),
|
|
||||||
Disabled = true
|
|
||||||
};
|
|
||||||
_saveBtn.Pressed += OnSavePressed;
|
|
||||||
toolbar.AddChild(_saveBtn);
|
|
||||||
|
|
||||||
_openFileBtn = new Button
|
|
||||||
{
|
|
||||||
Text = TranslationServer.Translate("tools/scenario_editor/open_file"),
|
|
||||||
Disabled = true
|
|
||||||
};
|
|
||||||
_openFileBtn.Pressed += OnOpenFilePressed;
|
|
||||||
toolbar.AddChild(_openFileBtn);
|
|
||||||
|
|
||||||
_playBtn = new Button
|
|
||||||
{
|
|
||||||
Text = TranslationServer.Translate("tools/scenario_editor/play"),
|
|
||||||
TooltipText = TranslationServer.Translate("tools/scenario_editor/play_tooltip"),
|
|
||||||
Disabled = true
|
|
||||||
};
|
|
||||||
_playBtn.Pressed += OnPlayPressed;
|
|
||||||
toolbar.AddChild(_playBtn);
|
|
||||||
|
|
||||||
_filePathLabel = new Label { Text = "" };
|
|
||||||
_filePathLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
toolbar.AddChild(_filePathLabel);
|
|
||||||
|
|
||||||
var mainSplit = new HSplitContainer();
|
|
||||||
mainSplit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
mainSplit.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
outerVBox.AddChild(mainSplit);
|
|
||||||
|
|
||||||
var leftPanel = new PanelContainer
|
|
||||||
{
|
|
||||||
CustomMinimumSize = new Vector2(340, 0),
|
|
||||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill
|
|
||||||
};
|
|
||||||
mainSplit.AddChild(leftPanel);
|
|
||||||
|
|
||||||
var leftVBox = new VBoxContainer();
|
|
||||||
leftPanel.AddChild(leftVBox);
|
|
||||||
|
|
||||||
var tabsContainer = new TabContainer();
|
|
||||||
tabsContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
tabsContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
leftVBox.AddChild(tabsContainer);
|
|
||||||
|
|
||||||
_fileTreePanel = (ScenarioFileTreePanel)TscnScenes.LoadAndBuild("scenes/scenario_file_tree_panel.tscn");
|
|
||||||
_fileTreePanel.FileSelected += OnFileSelected;
|
|
||||||
tabsContainer.AddChild(_fileTreePanel);
|
|
||||||
tabsContainer.SetTabTitle(0, TranslationServer.Translate("tools/scenario_editor/tab_files"));
|
|
||||||
|
|
||||||
_infoPanel = (ScenarioInfoPanel)TscnScenes.LoadAndBuild("scenes/scenario_info_panel.tscn");
|
|
||||||
tabsContainer.AddChild(_infoPanel);
|
|
||||||
tabsContainer.SetTabTitle(1, TranslationServer.Translate("tools/scenario_editor/tab_info"));
|
|
||||||
|
|
||||||
leftVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/scenario_editor/bg_previews") + ":" });
|
|
||||||
|
|
||||||
_thumbPanel = (ScenarioThumbnailPanel)TscnScenes.LoadAndBuild("scenes/scenario_thumbnail_panel.tscn");
|
|
||||||
leftVBox.AddChild(_thumbPanel);
|
|
||||||
|
|
||||||
var rightPanel = new PanelContainer
|
|
||||||
{
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill
|
|
||||||
};
|
|
||||||
mainSplit.AddChild(rightPanel);
|
|
||||||
|
|
||||||
_editTabs = new ScenarioEditTabs(new ScenarioGraphView(), GetMonospaceFont());
|
|
||||||
_editTabs.TextTabSelected += OnTextTabSelected;
|
|
||||||
_editTabs.GraphTabSelected += OnGraphTabSelected;
|
|
||||||
_editTabs.DirtyChanged += OnDirtyChanged;
|
|
||||||
_editTabs.GraphFileRequested += OnGraphFileRequested;
|
|
||||||
rightPanel.AddChild(_editTabs);
|
|
||||||
|
|
||||||
var statusBar = new HBoxContainer();
|
|
||||||
outerVBox.AddChild(statusBar);
|
|
||||||
|
|
||||||
_statusLabel = new Label { Text = "" };
|
|
||||||
statusBar.AddChild(_statusLabel);
|
|
||||||
|
|
||||||
_playLayout = new Control
|
|
||||||
{
|
|
||||||
AnchorRight = 1f,
|
|
||||||
AnchorBottom = 1f,
|
|
||||||
Visible = false
|
|
||||||
};
|
|
||||||
AddChild(_playLayout);
|
|
||||||
|
|
||||||
var stopContainer = new HBoxContainer();
|
|
||||||
stopContainer.Alignment = BoxContainer.AlignmentMode.Center;
|
|
||||||
stopContainer.AnchorRight = 0.5f;
|
|
||||||
stopContainer.AnchorLeft = 0.5f;
|
|
||||||
stopContainer.OffsetTop = 8;
|
|
||||||
_playLayout.AddChild(stopContainer);
|
|
||||||
|
|
||||||
var stopBtn = new Button
|
|
||||||
{
|
|
||||||
Text = TranslationServer.Translate("tools/scenario_editor/stop"),
|
|
||||||
CustomMinimumSize = new Vector2(120, 40)
|
|
||||||
};
|
|
||||||
stopBtn.Pressed += OnStopPressed;
|
|
||||||
stopContainer.AddChild(stopBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnFileSelected(string filePath)
|
|
||||||
{
|
|
||||||
_editTabs.OpenFile(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnGraphFileRequested(string filePath)
|
|
||||||
{
|
|
||||||
_fileTreePanel.SelectFile(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnTextTabSelected(string text)
|
|
||||||
{
|
|
||||||
_filePathLabel.Text = _editTabs.CurrentKey != null
|
|
||||||
? ScenarioFileService.GetDisplayPath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath) : "";
|
|
||||||
_saveBtn.Disabled = false;
|
|
||||||
_openFileBtn.Disabled = false;
|
|
||||||
_playBtn.Disabled = false;
|
|
||||||
_statusLabel.Text = _editTabs.IsCurrentDirty
|
|
||||||
? TranslationServer.Translate("tools/scenario_editor/status_modified") : "";
|
|
||||||
_infoPanel.Update(text);
|
|
||||||
_thumbPanel.Load(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnGraphTabSelected()
|
|
||||||
{
|
|
||||||
_filePathLabel.Text = "";
|
|
||||||
_saveBtn.Disabled = true;
|
|
||||||
_openFileBtn.Disabled = true;
|
|
||||||
_playBtn.Disabled = true;
|
|
||||||
_statusLabel.Text = "";
|
|
||||||
_infoPanel.Clear();
|
|
||||||
_thumbPanel.Clear();
|
|
||||||
_editTabs.RefreshGraph();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDirtyChanged(bool anyDirty)
|
|
||||||
{
|
|
||||||
if (anyDirty)
|
|
||||||
{
|
|
||||||
SetDirty();
|
|
||||||
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/status_modified");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
MarkClean();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
{
|
|
||||||
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_editTabs.SaveCurrentFile();
|
|
||||||
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/status_saved");
|
|
||||||
_infoPanel.Update(text);
|
|
||||||
_thumbPanel.Load(text);
|
|
||||||
_editTabs.RefreshGraph();
|
|
||||||
GameLogger.Log("SCENARIO_EDITOR", $"Saved: {modId}/{relativePath}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
GameLogger.Log("SCENARIO_EDITOR", $"Save failed: {ex.Message}", LogLevel.Error);
|
|
||||||
_statusLabel.Text = string.Format(
|
|
||||||
TranslationServer.Translate("tools/scenario_editor/save_error"), ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnOpenFilePressed()
|
|
||||||
{
|
|
||||||
if (_editTabs.CurrentKey == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var path = ScenarioFileService.GetAbsolutePath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath);
|
|
||||||
if (string.IsNullOrEmpty(path))
|
|
||||||
{
|
|
||||||
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
GD.Print($"[ScenarioEditor] Opening: {path}");
|
|
||||||
var err = OS.ShellOpen(path);
|
|
||||||
if (err != Error.Ok)
|
|
||||||
GameLogger.Log("SCENARIO_EDITOR", $"ShellOpen error: {err}", LogLevel.Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPlayPressed()
|
|
||||||
{
|
|
||||||
if (!_editTabs.HasActiveFile)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var text = _editTabs.CurrentText;
|
|
||||||
var metadata = ScenarioFileService.ParseMetadata(text);
|
|
||||||
|
|
||||||
if (metadata.Scene == "-")
|
|
||||||
{
|
|
||||||
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/no_scene_error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var modId = _editTabs.CurrentModId;
|
|
||||||
var relativePath = _editTabs.CurrentRelativePath;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (ScenarioFileService.WriteAllText(modId, relativePath, text))
|
|
||||||
{
|
|
||||||
_editTabs.SaveCurrentFile();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip");
|
|
||||||
GameLogger.Log("SCENARIO_EDITOR",
|
|
||||||
$"Test play: '{modId}/{relativePath}' is read-only, playing from the editor buffer",
|
|
||||||
LogLevel.Warning);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_statusLabel.Text = string.Format(
|
|
||||||
TranslationServer.Translate("tools/scenario_editor/save_error"), ex.Message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_editLayout.Visible = false;
|
|
||||||
_playLayout.Visible = true;
|
|
||||||
|
|
||||||
SceneManager.IsTestPlayActive = true;
|
|
||||||
SceneManager.TestScenarioText = text;
|
|
||||||
|
|
||||||
GameLogger.Log("SCENARIO_EDITOR", $"Test play START: scene='{metadata.Scene}', queue={text.Split('\n').Length} lines");
|
|
||||||
|
|
||||||
Visible = false;
|
|
||||||
|
|
||||||
_stopOverlay = CreateStopOverlay();
|
|
||||||
var tree = Godot.Engine.GetMainLoop() as SceneTree;
|
|
||||||
tree?.Root.AddChild(_stopOverlay);
|
|
||||||
|
|
||||||
var sceneManager = GetNodeOrNull<SceneManager>("/root/SceneManager");
|
|
||||||
if (sceneManager != null)
|
|
||||||
{
|
|
||||||
sceneManager.Initialize();
|
|
||||||
sceneManager.PendingSceneName = metadata.Scene;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sceneService = GetNodeOrNull<GodotSceneService>("/root/GodotSceneService");
|
|
||||||
sceneService?.LoadShell();
|
|
||||||
|
|
||||||
_playActive = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnStopPressed()
|
|
||||||
{
|
|
||||||
SceneManager.IsTestPlayActive = false;
|
|
||||||
ReturnFromPlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReturnFromPlay()
|
|
||||||
{
|
|
||||||
_playActive = false;
|
|
||||||
_playLayout.Visible = false;
|
|
||||||
_editLayout.Visible = true;
|
|
||||||
Visible = true;
|
|
||||||
|
|
||||||
if (_stopOverlay != null)
|
|
||||||
{
|
|
||||||
_stopOverlay.QueueFree();
|
|
||||||
_stopOverlay = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sceneService = GetNodeOrNull<GodotSceneService>("/root/GodotSceneService");
|
|
||||||
sceneService?.SwitchToMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
private CanvasLayer CreateStopOverlay()
|
|
||||||
{
|
|
||||||
var layer = new CanvasLayer { Layer = 128 };
|
|
||||||
|
|
||||||
var btn = new Button
|
|
||||||
{
|
|
||||||
Text = TranslationServer.Translate("tools/scenario_editor/stop"),
|
|
||||||
CustomMinimumSize = new Vector2(120, 40)
|
|
||||||
};
|
|
||||||
btn.Pressed += OnStopPressed;
|
|
||||||
|
|
||||||
var margin = new MarginContainer();
|
|
||||||
margin.AddThemeConstantOverride("margin_top", 8);
|
|
||||||
margin.AddThemeConstantOverride("margin_left", 8);
|
|
||||||
margin.AddChild(btn);
|
|
||||||
|
|
||||||
layer.AddChild(margin);
|
|
||||||
return layer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +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
|
|
||||||
{
|
|
||||||
public partial class ScenarioFileTreePanel : VBoxContainer
|
|
||||||
{
|
|
||||||
private Tree _fileTree;
|
|
||||||
private readonly Dictionary<string, string> _fileItemToKey = new();
|
|
||||||
private readonly Dictionary<string, TreeItem> _keyToTreeItem = new();
|
|
||||||
private bool _isProgrammaticSelection;
|
|
||||||
|
|
||||||
public event Action<string> FileSelected;
|
|
||||||
|
|
||||||
public ScenarioFileTreePanel()
|
|
||||||
{
|
|
||||||
_fileTree = GetNode<Tree>("FileTree");
|
|
||||||
_fileTree.CellSelected += OnCellSelected;
|
|
||||||
|
|
||||||
Populate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Populate()
|
|
||||||
{
|
|
||||||
_fileTree.Clear();
|
|
||||||
_fileItemToKey.Clear();
|
|
||||||
_keyToTreeItem.Clear();
|
|
||||||
|
|
||||||
var root = _fileTree.CreateItem();
|
|
||||||
var mods = ScenarioFileService.ScanScenarioFiles();
|
|
||||||
|
|
||||||
foreach (var mod in mods)
|
|
||||||
{
|
|
||||||
var modItem = _fileTree.CreateItem(root);
|
|
||||||
modItem.SetText(0, $"[{mod.ModId}]");
|
|
||||||
modItem.SetSelectable(0, false);
|
|
||||||
modItem.SetCustomColor(0, UiTokens.Info);
|
|
||||||
|
|
||||||
foreach (var group in mod.Groups)
|
|
||||||
{
|
|
||||||
TreeItem parentItem;
|
|
||||||
if (string.IsNullOrEmpty(group.DirectoryPath))
|
|
||||||
{
|
|
||||||
parentItem = modItem;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
parentItem = _fileTree.CreateItem(modItem);
|
|
||||||
parentItem.SetText(0, group.DirectoryPath + "/");
|
|
||||||
parentItem.SetSelectable(0, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var file in group.Files)
|
|
||||||
{
|
|
||||||
var fileItem = _fileTree.CreateItem(parentItem);
|
|
||||||
fileItem.SetText(0, file.Name);
|
|
||||||
fileItem.SetMetadata(0, Variant.From("scenario"));
|
|
||||||
_fileItemToKey[GetItemKey(fileItem)] = file.Key;
|
|
||||||
_keyToTreeItem[file.Key] = fileItem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetItemKey(TreeItem item)
|
|
||||||
{
|
|
||||||
return item.GetInstanceId().ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SelectFile(string key)
|
|
||||||
{
|
|
||||||
if (!_keyToTreeItem.TryGetValue(key, out var item))
|
|
||||||
return;
|
|
||||||
|
|
||||||
_isProgrammaticSelection = true;
|
|
||||||
item.Select(0);
|
|
||||||
_isProgrammaticSelection = false;
|
|
||||||
|
|
||||||
FileSelected?.Invoke(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnCellSelected()
|
|
||||||
{
|
|
||||||
if (_isProgrammaticSelection)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var item = _fileTree.GetSelected();
|
|
||||||
if (item == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var meta = item.GetMetadata(0);
|
|
||||||
if (meta.VariantType != Variant.Type.String || meta.AsString() != "scenario")
|
|
||||||
return;
|
|
||||||
|
|
||||||
var itemKey = GetItemKey(item);
|
|
||||||
if (!_fileItemToKey.TryGetValue(itemKey, out var fileKey))
|
|
||||||
return;
|
|
||||||
|
|
||||||
FileSelected?.Invoke(fileKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Cthangover.Core.UI.Theming;
|
|
||||||
using Cthangover.Tools.Services;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Core.UI.Tool.ScenarioEditor
|
|
||||||
{
|
|
||||||
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(250, 0) };
|
|
||||||
panel.SizeFlagsVertical = SizeFlags.ExpandFill;
|
|
||||||
AddChild(panel);
|
|
||||||
|
|
||||||
panel.AddChild(new Label
|
|
||||||
{
|
|
||||||
Text = TranslationServer.Translate("tools/scenario_editor/graph_select_node"),
|
|
||||||
AutowrapMode = TextServer.AutowrapMode.WordSmart
|
|
||||||
});
|
|
||||||
|
|
||||||
_infoLabel = new RichTextLabel
|
|
||||||
{
|
|
||||||
BbcodeEnabled = true,
|
|
||||||
FitContent = true,
|
|
||||||
ScrollFollowing = true,
|
|
||||||
SizeFlagsVertical = SizeFlags.ExpandFill
|
|
||||||
};
|
|
||||||
_infoLabel.MetaClicked += OnLinkClicked;
|
|
||||||
panel.AddChild(_infoLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Populate(SceneGraphData data)
|
|
||||||
{
|
|
||||||
var savedPositions = SaveNodePositions();
|
|
||||||
ClearGraph();
|
|
||||||
|
|
||||||
_nodes = new Dictionary<string, GraphNode>();
|
|
||||||
_lookup = new Dictionary<string, SceneGraphNode>();
|
|
||||||
|
|
||||||
int x = 40, y = 40;
|
|
||||||
foreach (var pos in savedPositions.Values)
|
|
||||||
{
|
|
||||||
int bottom = (int)pos.Y + 160 + 20;
|
|
||||||
if (bottom > y)
|
|
||||||
y = bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
: new Vector2(x, y);
|
|
||||||
|
|
||||||
var gn = new GraphNode
|
|
||||||
{
|
|
||||||
Title = nodeData.SceneName,
|
|
||||||
Size = new Vector2(240, 90),
|
|
||||||
PositionOffset = position
|
|
||||||
};
|
|
||||||
|
|
||||||
gn.AddChild(new Label
|
|
||||||
{
|
|
||||||
Text = $"Mod: {nodeData.ModId}\nScenarios: {nodeData.ScenarioCount}" +
|
|
||||||
(bgCount > 0 ? $"\nBg: {firstBg}" +
|
|
||||||
(bgCount > 1 ? $" +{bgCount - 1}" : "") : "")
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (!savedPositions.ContainsKey(key))
|
|
||||||
{
|
|
||||||
x += 280;
|
|
||||||
if (x > 1000) { x = 40; y += 160; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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($"Scene: [b]{nodeData.SceneName}[/b]");
|
|
||||||
sb.AppendLine($"Mod: {nodeData.ModId}");
|
|
||||||
if (nodeData.Backgrounds.Count > 0)
|
|
||||||
sb.AppendLine($"Backgrounds: {string.Join(", ", nodeData.Backgrounds)}");
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"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 void OnLinkClicked(Variant meta)
|
|
||||||
{
|
|
||||||
var path = Uri.UnescapeDataString(meta.AsString());
|
|
||||||
ScenarioFileRequested?.Invoke(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
using System.Linq;
|
|
||||||
using Cthangover.Tools.Services;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Core.UI.Tool.ScenarioEditor
|
|
||||||
{
|
|
||||||
public partial class ScenarioInfoPanel : VBoxContainer
|
|
||||||
{
|
|
||||||
private Label _infoScene;
|
|
||||||
private Label _infoPriority;
|
|
||||||
private Label _infoCondition;
|
|
||||||
private Label _infoSwitchTargets;
|
|
||||||
private Label _infoLocaleKeys;
|
|
||||||
private Label _infoQuestRefs;
|
|
||||||
|
|
||||||
public ScenarioInfoPanel()
|
|
||||||
{
|
|
||||||
GetNode<Label>("Scroll/VBox/HintScene").Text = TranslationServer.Translate("tools/scenario_editor/info_scene") + ":";
|
|
||||||
GetNode<Label>("Scroll/VBox/HintPriority").Text = TranslationServer.Translate("tools/scenario_editor/info_priority") + ":";
|
|
||||||
GetNode<Label>("Scroll/VBox/HintCondition").Text = TranslationServer.Translate("tools/scenario_editor/info_condition") + ":";
|
|
||||||
GetNode<Label>("Scroll/VBox/HintSwitchTargets").Text = TranslationServer.Translate("tools/scenario_editor/info_switch_targets") + ":";
|
|
||||||
GetNode<Label>("Scroll/VBox/HintLocaleKeys").Text = TranslationServer.Translate("tools/scenario_editor/info_locale_keys") + ":";
|
|
||||||
GetNode<Label>("Scroll/VBox/HintQuestRefs").Text = TranslationServer.Translate("tools/scenario_editor/info_quest_refs") + ":";
|
|
||||||
|
|
||||||
_infoScene = GetNode<Label>("Scroll/VBox/InfoScene");
|
|
||||||
_infoPriority = GetNode<Label>("Scroll/VBox/InfoPriority");
|
|
||||||
_infoCondition = GetNode<Label>("Scroll/VBox/InfoCondition");
|
|
||||||
_infoSwitchTargets = GetNode<Label>("Scroll/VBox/InfoSwitchTargets");
|
|
||||||
_infoLocaleKeys = GetNode<Label>("Scroll/VBox/InfoLocaleKeys");
|
|
||||||
_infoQuestRefs = GetNode<Label>("Scroll/VBox/InfoQuestRefs");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Update(string text)
|
|
||||||
{
|
|
||||||
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()) : "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Clear()
|
|
||||||
{
|
|
||||||
_infoScene.Text = "-";
|
|
||||||
_infoPriority.Text = "-";
|
|
||||||
_infoCondition.Text = "-";
|
|
||||||
_infoSwitchTargets.Text = "-";
|
|
||||||
_infoLocaleKeys.Text = "-";
|
|
||||||
_infoQuestRefs.Text = "-";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
using System.Linq;
|
|
||||||
using Cthangover.Tools.Services;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
namespace Cthangover.Core.UI.Tool.ScenarioEditor
|
|
||||||
{
|
|
||||||
public partial class ScenarioThumbnailPanel : ScrollContainer
|
|
||||||
{
|
|
||||||
private HBoxContainer _thumbContainer;
|
|
||||||
|
|
||||||
public ScenarioThumbnailPanel()
|
|
||||||
{
|
|
||||||
_thumbContainer = GetNode<HBoxContainer>("ThumbContainer");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Load(string text)
|
|
||||||
{
|
|
||||||
Clear();
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(text))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var refs = ScenarioFileService.ExtractReferences(text);
|
|
||||||
|
|
||||||
if (refs.BackgroundRefs.Count == 0)
|
|
||||||
{
|
|
||||||
_thumbContainer.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)
|
|
||||||
{
|
|
||||||
_thumbContainer.AddChild(new TextureRect
|
|
||||||
{
|
|
||||||
Texture = tex,
|
|
||||||
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
|
|
||||||
StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered,
|
|
||||||
CustomMinimumSize = new Vector2(120, 90),
|
|
||||||
TooltipText = id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_thumbContainer.AddChild(new Label
|
|
||||||
{
|
|
||||||
Text = $"[? {id}]",
|
|
||||||
TooltipText = $"Not found: {id}"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public new void Clear()
|
|
||||||
{
|
|
||||||
foreach (var c in _thumbContainer.GetChildren())
|
|
||||||
c.QueueFree();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +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;
|
|
||||||
|
|
||||||
public void Setup(InspectableProperty property, bool value)
|
|
||||||
{
|
|
||||||
_key = property.Key;
|
|
||||||
ButtonPressed = value;
|
|
||||||
Toggled += OnToggled;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnToggled(bool pressed)
|
|
||||||
{
|
|
||||||
Committed?.Invoke(_key, pressed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +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;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +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;
|
|
||||||
|
|
||||||
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +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;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Cthangover.Core.UI;
|
|
||||||
using Cthangover.Core.UI.Theming;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
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()
|
|
||||||
{
|
|
||||||
var root = new VBoxContainer();
|
|
||||||
root.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
AddChild(root);
|
|
||||||
|
|
||||||
_hint = new Label();
|
|
||||||
_hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
|
||||||
_hint.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
_hint.Visible = false;
|
|
||||||
root.AddChild(_hint);
|
|
||||||
|
|
||||||
_header = new Label();
|
|
||||||
_header.Visible = false;
|
|
||||||
root.AddChild(_header);
|
|
||||||
|
|
||||||
_banner = new Label();
|
|
||||||
_banner.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
|
||||||
_banner.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
_banner.Modulate = UiTokens.Warning;
|
|
||||||
_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(110f, 0f);
|
|
||||||
_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 = new Label { Text = TranslationServer.Translate(entry.CategoryKey) };
|
|
||||||
categoryLabel.Modulate = UiTokens.AccentBright;
|
|
||||||
_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) };
|
|
||||||
label.CustomMinimumSize = new Vector2(110f, 0f);
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
@@ -1,26 +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;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +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;
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,431 +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 (Type)</c>.
|
|
||||||
/// 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);
|
|
||||||
var typeName = node.GetType().Name;
|
|
||||||
item.SetText(0, $"{node.Name} ({typeName})");
|
|
||||||
item.SetSelectable(0, true);
|
|
||||||
|
|
||||||
_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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
_highlight.Visible = false;
|
|
||||||
_highlight.Color = UiTokens.Alpha(UiTokens.Accent, 0.35f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using Cthangover.Core.UI.Tool;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,725 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Cthangover.Core.UI.Theming;
|
|
||||||
using Cthangover.Core.Utils;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
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 TextEdit _tscnEditor;
|
|
||||||
private Button _reloadBtn;
|
|
||||||
private Button _saveBtn;
|
|
||||||
private Button _refreshBtn;
|
|
||||||
private OptionButton _newNodeTypeDropdown;
|
|
||||||
private Button _addNodeBtn;
|
|
||||||
private Button _deleteNodeBtn;
|
|
||||||
private RichTextLabel _outputLabel;
|
|
||||||
|
|
||||||
/// <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();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BuildUI()
|
|
||||||
{
|
|
||||||
var outerVBox = CreateFillContainer();
|
|
||||||
AddChild(outerVBox);
|
|
||||||
|
|
||||||
var toolbar = CreateToolbar();
|
|
||||||
outerVBox.AddChild(toolbar);
|
|
||||||
|
|
||||||
toolbar.AddChild(new Label { Text = TranslationServer.Translate("tools/scene_builder/base_scene") });
|
|
||||||
|
|
||||||
_baseDropdown = new OptionButton();
|
|
||||||
_baseDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
_baseDropdown.ItemSelected += OnBaseSelected;
|
|
||||||
toolbar.AddChild(_baseDropdown);
|
|
||||||
|
|
||||||
toolbar.AddChild(new Label { Text = TranslationServer.Translate("tools/scene_builder/mod") });
|
|
||||||
|
|
||||||
_modDropdown = new OptionButton();
|
|
||||||
_modDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
_modDropdown.ItemSelected += OnModSelected;
|
|
||||||
toolbar.AddChild(_modDropdown);
|
|
||||||
|
|
||||||
toolbar.AddChild(new Label { Text = TranslationServer.Translate("tools/scene_builder/scene") });
|
|
||||||
|
|
||||||
_sceneDropdown = new OptionButton();
|
|
||||||
_sceneDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
_sceneDropdown.ItemSelected += OnSceneSelected;
|
|
||||||
toolbar.AddChild(_sceneDropdown);
|
|
||||||
|
|
||||||
_reloadBtn = new Button { Text = TranslationServer.Translate("tools/scene_builder/reload") };
|
|
||||||
_reloadBtn.Pressed += OnReloadPressed;
|
|
||||||
toolbar.AddChild(_reloadBtn);
|
|
||||||
|
|
||||||
var mainHBox = new HBoxContainer();
|
|
||||||
mainHBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
mainHBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
outerVBox.AddChild(mainHBox);
|
|
||||||
|
|
||||||
var leftVBox = new VBoxContainer();
|
|
||||||
leftVBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
leftVBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
var leftPanel = CreateSidebar(leftVBox);
|
|
||||||
mainHBox.AddChild(leftPanel);
|
|
||||||
|
|
||||||
var hierarchyHeader = new HBoxContainer();
|
|
||||||
var hierarchyLabel = new Label { Text = TranslationServer.Translate("tools/scene_builder/hierarchy") };
|
|
||||||
hierarchyLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
hierarchyHeader.AddChild(hierarchyLabel);
|
|
||||||
|
|
||||||
_newNodeTypeDropdown = new OptionButton();
|
|
||||||
_newNodeTypeDropdown.TooltipText = TranslationServer.Translate("tools/scene_builder/add_node_tip");
|
|
||||||
foreach (var t in CreatableTypes)
|
|
||||||
_newNodeTypeDropdown.AddItem(t);
|
|
||||||
_newNodeTypeDropdown.Selected = 0;
|
|
||||||
hierarchyHeader.AddChild(_newNodeTypeDropdown);
|
|
||||||
|
|
||||||
_addNodeBtn = new Button { Text = "+" };
|
|
||||||
_addNodeBtn.TooltipText = TranslationServer.Translate("tools/scene_builder/add_node_tip");
|
|
||||||
_addNodeBtn.Pressed += OnAddNodePressed;
|
|
||||||
hierarchyHeader.AddChild(_addNodeBtn);
|
|
||||||
|
|
||||||
_deleteNodeBtn = new Button { Text = "-" };
|
|
||||||
_deleteNodeBtn.TooltipText = TranslationServer.Translate("tools/scene_builder/delete_node_tip");
|
|
||||||
_deleteNodeBtn.Pressed += OnDeleteNodePressed;
|
|
||||||
hierarchyHeader.AddChild(_deleteNodeBtn);
|
|
||||||
|
|
||||||
_refreshBtn = new Button();
|
|
||||||
_refreshBtn.Text = TranslationServer.Translate("tools/scene_builder/refresh");
|
|
||||||
_refreshBtn.Pressed += OnRefreshPressed;
|
|
||||||
hierarchyHeader.AddChild(_refreshBtn);
|
|
||||||
|
|
||||||
leftVBox.AddChild(hierarchyHeader);
|
|
||||||
|
|
||||||
_hierarchyTree = new Tree();
|
|
||||||
_hierarchyTree.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
_hierarchyTree.HideRoot = true;
|
|
||||||
_hierarchyTree.CellSelected += OnTreeCellSelected;
|
|
||||||
leftVBox.AddChild(_hierarchyTree);
|
|
||||||
|
|
||||||
var previewContainer = new PanelContainer();
|
|
||||||
previewContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
previewContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
mainHBox.AddChild(previewContainer);
|
|
||||||
|
|
||||||
_viewport = new SubViewport();
|
|
||||||
_viewport.Size = new Vector2I(1920, 1080);
|
|
||||||
|
|
||||||
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();
|
|
||||||
viewportContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
viewportContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
viewportContainer.Stretch = true;
|
|
||||||
viewportContainer.StretchShrink = 1;
|
|
||||||
viewportContainer.AddChild(_viewport);
|
|
||||||
previewContainer.AddChild(viewportContainer);
|
|
||||||
|
|
||||||
_selectionOverlay = new SelectionOverlay();
|
|
||||||
previewContainer.AddChild(_selectionOverlay);
|
|
||||||
_selectionOverlay.Setup(_viewport, viewportContainer);
|
|
||||||
_selectionOverlay.PickRequested += OnOverlayPick;
|
|
||||||
_selectionOverlay.BatchCommitted += OnOverlayBatchCommitted;
|
|
||||||
_selectionOverlay.EnsureConstructed();
|
|
||||||
|
|
||||||
_autosaveTimer = new Timer { OneShot = true, WaitTime = 0.4 };
|
|
||||||
_autosaveTimer.Timeout += OnAutosaveTimeout;
|
|
||||||
AddChild(_autosaveTimer);
|
|
||||||
|
|
||||||
// Compact tabbed sidebar: inspector and tscn source share one column,
|
|
||||||
// the output strip stays below the tabs so errors are always visible.
|
|
||||||
var rightVBox = new VBoxContainer();
|
|
||||||
rightVBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
rightVBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
var rightPanel = CreateSidebar(rightVBox, 360);
|
|
||||||
mainHBox.AddChild(rightPanel);
|
|
||||||
|
|
||||||
_sideTabs = new TabContainer();
|
|
||||||
_sideTabs.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
_sideTabs.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
rightVBox.AddChild(_sideTabs);
|
|
||||||
|
|
||||||
var inspectorTab = new VBoxContainer
|
|
||||||
{
|
|
||||||
Name = TranslationServer.Translate("tools/scene_builder/tab_inspector"),
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill
|
|
||||||
};
|
|
||||||
_sideTabs.AddChild(inspectorTab);
|
|
||||||
|
|
||||||
_inspector = new InspectorPanel();
|
|
||||||
inspectorTab.AddChild(_inspector);
|
|
||||||
_inspector.PropertyEdited += OnInspectorPropertyEdited;
|
|
||||||
_inspector.EnsureConstructed();
|
|
||||||
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_select"));
|
|
||||||
|
|
||||||
var tscnTab = new VBoxContainer
|
|
||||||
{
|
|
||||||
Name = TranslationServer.Translate("tools/scene_builder/tab_tscn"),
|
|
||||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
|
||||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill
|
|
||||||
};
|
|
||||||
_sideTabs.AddChild(tscnTab);
|
|
||||||
|
|
||||||
_tscnEditor = new TextEdit();
|
|
||||||
_tscnEditor.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
_tscnEditor.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
|
||||||
_tscnEditor.WrapMode = TextEdit.LineWrappingMode.None;
|
|
||||||
_tscnEditor.AddThemeFontOverride("font", GetMonospaceFont());
|
|
||||||
_tscnEditor.TextChanged += () => SetDirty();
|
|
||||||
tscnTab.AddChild(_tscnEditor);
|
|
||||||
|
|
||||||
_saveBtn = new Button();
|
|
||||||
_saveBtn.Text = TranslationServer.Translate("tools/scene_builder/save");
|
|
||||||
_saveBtn.Pressed += OnSavePressed;
|
|
||||||
tscnTab.AddChild(_saveBtn);
|
|
||||||
|
|
||||||
var outputHeader = new HBoxContainer();
|
|
||||||
var outputLabel = new Label { Text = TranslationServer.Translate("tools/scene_builder/output") };
|
|
||||||
outputLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
|
||||||
outputHeader.AddChild(outputLabel);
|
|
||||||
|
|
||||||
var clearOutputBtn = new Button { Text = TranslationServer.Translate("tools/scene_builder/clear") };
|
|
||||||
clearOutputBtn.Pressed += () => _outputLabel.Text = "";
|
|
||||||
outputHeader.AddChild(clearOutputBtn);
|
|
||||||
|
|
||||||
rightVBox.AddChild(outputHeader);
|
|
||||||
|
|
||||||
_outputLabel = new RichTextLabel();
|
|
||||||
_outputLabel.BbcodeEnabled = true;
|
|
||||||
_outputLabel.FitContent = false;
|
|
||||||
_outputLabel.ScrollActive = true;
|
|
||||||
_outputLabel.ScrollFollowing = true;
|
|
||||||
_outputLabel.CustomMinimumSize = new Vector2(0, 70);
|
|
||||||
rightVBox.AddChild(_outputLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PopulateBaseList()
|
|
||||||
{
|
|
||||||
_baseDropdown.Clear();
|
|
||||||
_baseDropdown.AddItem(TranslationServer.Translate("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(TranslationServer.Translate("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(TranslationServer.Translate("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(TranslationServer.Translate("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()
|
|
||||||
{
|
|
||||||
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(TranslationServer.Translate("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()
|
|
||||||
{
|
|
||||||
_tscnEditor.Text = "";
|
|
||||||
if (TryGetSelectedModScene(out var scene))
|
|
||||||
{
|
|
||||||
var text = _controller.ReadSceneText(scene.ModId, scene.RelativePath);
|
|
||||||
_tscnEditor.Text = text ?? "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnBaseSelected(long index)
|
|
||||||
{
|
|
||||||
RebuildPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnModSelected(long index)
|
|
||||||
{
|
|
||||||
var modId = index == 0 ? null : _mods[(int)index - 1];
|
|
||||||
PopulateSceneListForMod(modId);
|
|
||||||
RebuildPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSceneSelected(long index)
|
|
||||||
{
|
|
||||||
RebuildPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnReloadPressed()
|
|
||||||
{
|
|
||||||
if (TryGetSelectedModScene(out var scene))
|
|
||||||
_controller.InvalidateScene(scene.RelativePath);
|
|
||||||
|
|
||||||
RebuildPreview();
|
|
||||||
ShowOutput(TranslationServer.Translate("tools/scene_builder/reloaded"), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSavePressed()
|
|
||||||
{
|
|
||||||
SaveCurrentText(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(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_controller.SaveSceneText(scene.ModId, scene.RelativePath, _tscnEditor.Text))
|
|
||||||
{
|
|
||||||
MarkClean();
|
|
||||||
ShowOutput(TranslationServer.Translate(auto
|
|
||||||
? "tools/scene_builder/autosaved"
|
|
||||||
: "tools/scene_builder/saved_ok"), false);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ShowOutput(TranslationServer.Translate("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(TranslationServer.Translate("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(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var typeName = CreatableTypes[System.Math.Max(_newNodeTypeDropdown.Selected, 0)];
|
|
||||||
var parent = ResolveAddParent();
|
|
||||||
if (parent == null)
|
|
||||||
{
|
|
||||||
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var created = _controller.CreateUniqueChild(parent, typeName);
|
|
||||||
if (created == null)
|
|
||||||
{
|
|
||||||
ShowOutput(TranslationServer.Translate("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(TranslationServer.Translate("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(TranslationServer.Translate("tools/scene_builder/err_del_none"), true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (node == _controller.GetModRoot())
|
|
||||||
{
|
|
||||||
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_del_root"), true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var path = _controller.GetModNodePath(node);
|
|
||||||
if (!_controller.DeleteModNode(node))
|
|
||||||
{
|
|
||||||
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_del_none"), true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TscnTextSurgery.TryRemoveNodeSubtree(_tscnEditor.Text, path, out var updated))
|
|
||||||
{
|
|
||||||
_tscnEditor.Text = updated;
|
|
||||||
RefreshFileSnapshot();
|
|
||||||
KickAutosave();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ShowOutput(TranslationServer.Translate("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(TranslationServer.Translate("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(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var nodePath = _controller.GetModNodePath(node);
|
|
||||||
if (nodePath == null)
|
|
||||||
{
|
|
||||||
ShowOutput(TranslationServer.Translate("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(TranslationServer.Translate("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(TranslationServer.Translate("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(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var serialized = TscnValueFormatter.Format(value);
|
|
||||||
if (serialized == null)
|
|
||||||
{
|
|
||||||
ShowOutput(TranslationServer.Translate("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(TranslationServer.Translate("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(TranslationServer.Translate("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)
|
|
||||||
{
|
|
||||||
if (_outputLabel == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var color = isError ? "red" : "green";
|
|
||||||
_outputLabel.Text = $"[color={color}]{text}[/color]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Cthangover.Core.UI;
|
|
||||||
using Cthangover.Core.UI.Theming;
|
|
||||||
using Godot;
|
|
||||||
|
|
||||||
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.
|
|
||||||
/// 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 fixed-size SubViewport canvas and this
|
|
||||||
/// overlay through the stretch scale of the hosting SubViewportContainer.
|
|
||||||
/// </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 enum DragKind { None, Resize, Anchor }
|
|
||||||
|
|
||||||
private struct Marker
|
|
||||||
{
|
|
||||||
public Rect2 Rect;
|
|
||||||
public bool IsAnchor;
|
|
||||||
public int Index;
|
|
||||||
}
|
|
||||||
|
|
||||||
private SubViewport _viewport;
|
|
||||||
private Control _containerArea;
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
_viewport = viewport;
|
|
||||||
_containerArea = containerArea;
|
|
||||||
_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.None)
|
|
||||||
FinishDrag();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (evt is InputEventMouseMotion motion)
|
|
||||||
{
|
|
||||||
if (_drag != DragKind.None)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = Control.CursorShape.Arrow;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── coordinate mapping ──────────────────────────────────────────
|
|
||||||
|
|
||||||
private Vector2 ComputeStretchScale()
|
|
||||||
{
|
|
||||||
var viewSize = _viewport.Size;
|
|
||||||
return new Vector2(
|
|
||||||
viewSize.X > 0 ? _containerArea.Size.X / viewSize.X : 1f,
|
|
||||||
viewSize.Y > 0 ? _containerArea.Size.Y / viewSize.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +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
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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("\\\\", "\\");
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 + "\"";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Cthangover.Core.Mods;
|
|
||||||
using Cthangover.Core.Mods.Providers;
|
|
||||||
using Cthangover.Core.UI.Tool;
|
|
||||||
using Cthangover.Core.Utils;
|
|
||||||
|
|
||||||
namespace Cthangover.Tools.Services
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Static service for discovering, reading and writing world map zone files
|
|
||||||
/// (<c>mods/<mod>/map/*.json</c>) from the map editor. Follows
|
|
||||||
/// <see cref="ScenarioFileService"/>: reading goes through the mod file
|
|
||||||
/// provider abstraction so zip mods are visible, writing is deliberately
|
|
||||||
/// limited to folder-backed mods because zip archives are read-only.
|
|
||||||
/// <para/>
|
|
||||||
/// New files are created through the provider's <c>RootPath</c> rather than
|
|
||||||
/// <c>GetFileSystemPath</c>, which reports <c>null</c> for files that do not
|
|
||||||
/// exist yet — the editor must be able to author a brand-new map file, not
|
|
||||||
/// only overwrite an existing one.
|
|
||||||
/// </summary>
|
|
||||||
public static class MapFileService
|
|
||||||
{
|
|
||||||
private const string MapDir = "map";
|
|
||||||
private const string JsonExtension = ".json";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// IDs of every loaded mod that can be written to (folder-backed).
|
|
||||||
/// Zip mods are skipped silently: they appear in the game but the editor
|
|
||||||
/// cannot save into them.
|
|
||||||
/// </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>
|
|
||||||
/// Mod-relative paths of all <c>.json</c> files under the mod's
|
|
||||||
/// <c>map/</c> directory, including nested subdirectories. Returns an
|
|
||||||
/// empty list for unknown mods.
|
|
||||||
/// </summary>
|
|
||||||
public static List<string> ListMapFiles(string modId)
|
|
||||||
{
|
|
||||||
var result = new List<string>();
|
|
||||||
if (string.IsNullOrEmpty(modId))
|
|
||||||
return result;
|
|
||||||
|
|
||||||
CollectJsonFiles(modId, MapDir, result);
|
|
||||||
result.Sort(StringComparer.OrdinalIgnoreCase);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Reads a map file through the mod provider; <c>null</c> when missing.</summary>
|
|
||||||
public static string ReadFile(string modId, string relativePath)
|
|
||||||
{
|
|
||||||
return ModManager.Instance.ReadFileText(modId, relativePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes a map file into a folder-backed mod, creating the
|
|
||||||
/// <c>map/</c> directory and any missing parents. Returns <c>false</c>
|
|
||||||
/// for zip mods, unknown mods and empty paths.
|
|
||||||
/// </summary>
|
|
||||||
public static bool WriteFile(string modId, string relativePath, string text)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(modId) || string.IsNullOrEmpty(relativePath))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var mod = ModManager.Instance.GetMod(modId);
|
|
||||||
if (mod?.FileProvider is not FolderModFileProvider provider)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var root = provider.RootPath;
|
|
||||||
if (string.IsNullOrEmpty(root))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var absolute = Path.GetFullPath(Path.Combine(root, relativePath.Replace('\\', '/')));
|
|
||||||
var directory = Path.GetDirectoryName(absolute);
|
|
||||||
if (!string.IsNullOrEmpty(directory))
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
|
|
||||||
File.WriteAllText(absolute, text);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CollectJsonFiles(string modId, string directory, List<string> result)
|
|
||||||
{
|
|
||||||
foreach (var entry in ModManager.Instance.ListFiles(modId, directory))
|
|
||||||
{
|
|
||||||
if (entry.EndsWith("/"))
|
|
||||||
{
|
|
||||||
CollectJsonFiles(modId, entry.TrimEnd('/'), result);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.EndsWith(JsonExtension, StringComparison.OrdinalIgnoreCase))
|
|
||||||
result.Add(entry.Replace('\\', '/'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +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.
|
|
||||||
/// </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('.'));
|
|
||||||
if (!cleanId.EndsWith("_albedo") && !cleanId.EndsWith("_depth"))
|
|
||||||
ids.Add(cleanId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
using System;
|
|
||||||
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/<modId>/<relativePath></c>).</summary>
|
|
||||||
public static string GetDisplayPath(string modId, string relativePath)
|
|
||||||
{
|
|
||||||
return "mods/" + modId + "/" + relativePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace Cthangover.Tools.Services
|
|
||||||
{
|
|
||||||
public sealed class ScenarioGraphLink
|
|
||||||
{
|
|
||||||
public string Name { get; set; }
|
|
||||||
public int Priority { get; set; }
|
|
||||||
public string Key { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class SceneGraphEdge
|
|
||||||
{
|
|
||||||
public string FromScene { get; set; }
|
|
||||||
public string ToScene { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class SceneGraphData
|
|
||||||
{
|
|
||||||
public List<SceneGraphNode> Nodes { get; set; } = new();
|
|
||||||
public List<SceneGraphEdge> Edges { get; set; } = new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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=<dir></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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "Tool Windows Test",
|
||||||
|
"description": "Integration test of the tools mod: opens every registered tool window through ToolFactory exactly like the toolbar button does, checks the chrome and the window's own smoke checks, optionally saves a screenshot of each window (--shots=<dir>) and reports [TEST_RESULT]. Activated by --itest=tools_windows.",
|
||||||
|
"sources": [
|
||||||
|
"src/*.cs"
|
||||||
|
],
|
||||||
|
"depends": [
|
||||||
|
"cthangover_game>=0.1.0",
|
||||||
|
"village_pack>=0.1.0",
|
||||||
|
"test_defaults_and_patches>=0.1.0",
|
||||||
|
"rpg_editor>=0.1.0"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
scene: home_basement
|
||||||
|
priority: 0
|
||||||
|
---
|
||||||
|
delay 60 hidden=true
|
||||||
|
end
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.Tests.Windows
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Engine logger that records every error Godot reports while the test
|
||||||
|
/// runs. An exception thrown inside a signal handler (a button toggled
|
||||||
|
/// during construction, a draw callback) never reaches the code that opened
|
||||||
|
/// the window — Godot only prints it — so without this the test would pass
|
||||||
|
/// windows that are visibly broken. Errors may arrive from any thread.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ToolWindowsErrorLogger : Logger
|
||||||
|
{
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private readonly List<string> _errors = new();
|
||||||
|
|
||||||
|
/// <summary>Records the error text (rationale or code, whichever is set).</summary>
|
||||||
|
public override void _LogError(string function, string file, int line, string code, string rationale,
|
||||||
|
bool editorNotify, int errorType, Godot.Collections.Array<ScriptBacktrace> scriptBacktraces)
|
||||||
|
{
|
||||||
|
if (errorType == (int)ErrorType.Warning)
|
||||||
|
return;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
_errors.Add($"{(string.IsNullOrEmpty(rationale) ? code : rationale)} ({file}:{line})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns and clears the errors collected so far.</summary>
|
||||||
|
public List<string> Drain()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var copy = new List<string>(_errors);
|
||||||
|
_errors.Clear();
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
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
|
||||||
|
/// canvas' <c>gui_input</c> signal (the editors subscribe to it), data is
|
||||||
|
/// read back from the canvas' public controller. Nothing is saved — the
|
||||||
|
/// window is closed without writing afterwards.
|
||||||
|
/// </summary>
|
||||||
|
public static class ToolWindowsExercises
|
||||||
|
{
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
var canvas = Find<MapEditorCanvas>(window);
|
||||||
|
if (canvas?.Controller == null)
|
||||||
|
{
|
||||||
|
check(false, "map_editor: canvas found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var zone = canvas.Controller.StartRegion();
|
||||||
|
canvas.SetMode(MapEditorCanvas.ModeDraw);
|
||||||
|
var rect = canvas.GetBackgroundRect();
|
||||||
|
Click(canvas, rect.Position + rect.Size * new Vector2(0.2f, 0.2f), false);
|
||||||
|
Click(canvas, rect.Position + rect.Size * new Vector2(0.4f, 0.2f), false);
|
||||||
|
Click(canvas, rect.Position + rect.Size * new Vector2(0.3f, 0.4f), false);
|
||||||
|
Click(canvas, rect.Position + rect.Size * new Vector2(0.3f, 0.4f), true);
|
||||||
|
|
||||||
|
check(zone.Points.Count == 3, $"map_editor: drawn region has 3 vertices (got {zone.Points.Count})");
|
||||||
|
check(canvas.Mode == MapEditorCanvas.ModeSelect, "map_editor: double-click finishes drawing");
|
||||||
|
check(Mathf.Abs(zone.Points[0].X - 0.2f) < 0.01f && Mathf.Abs(zone.Points[0].Y - 0.2f) < 0.01f,
|
||||||
|
$"map_editor: vertex lands at the clicked background point (got {zone.Points[0]})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A new object switched to polygon gets 4 vertices; a click on empty background appends one.</summary>
|
||||||
|
private static void InteractivePolygon(Window window, Action<bool, string> check)
|
||||||
|
{
|
||||||
|
var canvas = Find<InteractiveEditorCanvas>(window);
|
||||||
|
if (canvas?.Controller == null)
|
||||||
|
{
|
||||||
|
check(false, "interactive_editor: canvas found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var def = canvas.Controller.Add();
|
||||||
|
InteractiveEditorController.SetHitType(def, "polygon");
|
||||||
|
canvas.RebuildHandles();
|
||||||
|
check(def.HitArea.Vertices?.Length == 4, "interactive_editor: rect converts to a 4-vertex polygon");
|
||||||
|
|
||||||
|
var rect = canvas.GetBackgroundRect();
|
||||||
|
Click(canvas, rect.Position + rect.Size * new Vector2(0.97f, 0.97f), false);
|
||||||
|
check(def.HitArea.Vertices?.Length == 5, $"interactive_editor: click on empty background adds a vertex (got {def.HitArea.Vertices?.Length})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A double-click on the background adds a light at that point.</summary>
|
||||||
|
private static void LightAdd(Window window, Action<bool, string> check)
|
||||||
|
{
|
||||||
|
var canvas = Find<LightEditorCanvas>(window);
|
||||||
|
if (canvas?.Controller == null)
|
||||||
|
{
|
||||||
|
check(false, "light_editor: canvas found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var before = canvas.Controller.Lights.Count;
|
||||||
|
if (before >= LightEditorController.MaxLights)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var rect = canvas.GetBackgroundRect();
|
||||||
|
Click(canvas, rect.Position + rect.Size * new Vector2(0.75f, 0.25f), true);
|
||||||
|
var light = canvas.Controller.Selected;
|
||||||
|
check(canvas.Controller.Lights.Count == before + 1 && light != null
|
||||||
|
&& Mathf.Abs(light.X - 0.75f) < 0.01f && Mathf.Abs(light.Y - 0.25f) < 0.01f,
|
||||||
|
"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
|
||||||
|
{
|
||||||
|
ButtonIndex = MouseButton.Left,
|
||||||
|
Pressed = true,
|
||||||
|
DoubleClick = doubleClick,
|
||||||
|
Position = local
|
||||||
|
};
|
||||||
|
target.EmitSignal(Control.SignalName.GuiInput, ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T Find<T>(Node root) where T : Node
|
||||||
|
{
|
||||||
|
foreach (var child in root.GetChildren())
|
||||||
|
{
|
||||||
|
if (child is T match)
|
||||||
|
return match;
|
||||||
|
var nested = Find<T>(child);
|
||||||
|
if (nested != null)
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using Cthangover.Core.Bootstrap;
|
||||||
|
using Cthangover.Core.UI.Tool;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Cthangover.Mods.Interface.Tool;
|
||||||
|
using Cthangover.Mods.Interface.View;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
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"/>),
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ToolWindowsTestDriverNode : Node
|
||||||
|
{
|
||||||
|
private const string ResultName = "tools_windows";
|
||||||
|
private const float TickSeconds = 0.5f;
|
||||||
|
private const float TimeoutSeconds = 90f;
|
||||||
|
private const int SettleTicks = 4;
|
||||||
|
private const int ExerciseTick = 2;
|
||||||
|
|
||||||
|
private readonly string _shotsDir;
|
||||||
|
private readonly List<IToolProvider> _tools = new();
|
||||||
|
private int _passed;
|
||||||
|
private int _failed;
|
||||||
|
private int _toolIndex = -1;
|
||||||
|
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 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 };
|
||||||
|
AddChild(timer);
|
||||||
|
timer.Timeout += Tick;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Tick()
|
||||||
|
{
|
||||||
|
if (_finished)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_elapsed += _tickSeconds;
|
||||||
|
if (_elapsed >= TimeoutSeconds)
|
||||||
|
{
|
||||||
|
Fail($"timeout ({TimeoutSeconds:0}s) at tool #{_toolIndex}");
|
||||||
|
Finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_toolIndex < 0)
|
||||||
|
{
|
||||||
|
var tree = Godot.Engine.GetMainLoop() as SceneTree;
|
||||||
|
if (!Boot.IsReady || tree?.CurrentScene == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_tools.AddRange(ToolFactory.Instance.GetAll());
|
||||||
|
Check(_tools.Count > 0, $"tools registered ({_tools.Count})");
|
||||||
|
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 (_window == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_shotsDir))
|
||||||
|
{
|
||||||
|
AdvanceWhenReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (--_settle == ExerciseTick)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ToolWindowsExercises.Run(_tools[_toolIndex].Id, _window, Check);
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
Fail($"{_tools[_toolIndex].Id}: interaction threw {ex.GetType().Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_settle > 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
InspectAndClose();
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
Finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tool = _tools[_toolIndex];
|
||||||
|
_errors.Drain();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_window = ToolWindow.ShowWindow(tool.CreateWindow());
|
||||||
|
_settle = SettleTicks;
|
||||||
|
_exercised = false;
|
||||||
|
_waitStarted = Time.GetTicksMsec();
|
||||||
|
_readyFrame = 0;
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
Fail($"{tool.Id}: open threw {ex.GetType().Name}: {ex.Message}");
|
||||||
|
_window = null;
|
||||||
|
OpenNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
if (!GodotObject.IsInstanceValid(window))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var viewport = GetTree().Root.GetVisibleRect().Size;
|
||||||
|
Check(window.Size.X <= viewport.X + 1 && window.Size.Y <= viewport.Y + 1,
|
||||||
|
$"{id}: window {window.Size} fits viewport {viewport}");
|
||||||
|
|
||||||
|
SaveShot(id);
|
||||||
|
window.QueueFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveShot(string id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_shotsDir) || DisplayServer.GetName() == "headless")
|
||||||
|
return;
|
||||||
|
|
||||||
|
var dir = ProjectSettings.GlobalizePath(_shotsDir);
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var path = Path.Combine(dir, id + ".png");
|
||||||
|
ViewportCapture.SavePng(GetViewport(), path);
|
||||||
|
Log($"screenshot {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Check(bool ok, string name)
|
||||||
|
{
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
_passed++;
|
||||||
|
Log($"[PASS] {name}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Fail(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Fail(string message)
|
||||||
|
{
|
||||||
|
_failed++;
|
||||||
|
Log($"[FAIL] {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Finish()
|
||||||
|
{
|
||||||
|
if (_finished)
|
||||||
|
return;
|
||||||
|
_finished = true;
|
||||||
|
OS.RemoveLogger(_errors);
|
||||||
|
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={_passed} failed={_failed}");
|
||||||
|
Cthangover.Core.Bootstrap.ApplicationExit.Quit(GetTree(), _failed == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Log(string message) => GameLogger.Log("TEST", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.Tests.Windows
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Entry point of the tool windows GUI test. Activated only by the exact
|
||||||
|
/// user arg <c>--itest=tools_windows</c>; <c>--shots=<dir></c> additionally
|
||||||
|
/// saves a screenshot of every opened window (needs a real display, e.g.
|
||||||
|
/// xvfb — the headless renderer produces no image). The driver is added to
|
||||||
|
/// the tree deferred because resources-ready fires before the shell scene
|
||||||
|
/// exists; the driver waits for the boot itself.
|
||||||
|
/// </summary>
|
||||||
|
public class ToolWindowsTestInitializer : 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)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var args = OS.GetCmdlineUserArgs();
|
||||||
|
if (!args.Any(a => a == "--itest=tools_windows"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var shots = args
|
||||||
|
.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal))
|
||||||
|
.Select(a => a.Substring("--shots=".Length))
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
driverQueued = true;
|
||||||
|
GameLogger.Log("TEST", $"ToolWindowsTestInitializer: activating tool windows test, shots='{shots ?? ""}'");
|
||||||
|
ModRoot.AddDeferred(new ToolWindowsTestDriverNode(shots));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user