Compare commits

...
25 Commits
Author SHA1 Message Date
cthangover 152096337b Add mod documentation in seven languages 2026-10-29 09:25:54 +01:00
iisaev 8944ba756c gitignore file 2026-10-28 15:19:56 +03:00
cthangover 5be5bed0c5 Support schema field options in entity editors 2026-10-27 01:07:29 +01:00
cthangover cbc9472738 Highlight JSON, TSCN and scenario fields across editor tools 2026-10-26 19:44:26 +03:00
cthangover 84894b1e98 Test: toggle states keep their margins 2026-10-24 18:39:39 +02:00
cthangover 80c86063c4 Entity list icons shrink to the list width 2026-10-24 12:56:09 +02:00
cthangoverandClaude Opus 5.5 93ebb12c58 Root nodes join through ModRoot.AddDeferred, so a reload that unloads the mod first never hands Godot a freed node
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:29:12 +02:00
cthangoverandClaude Opus 5.5 033192dcc9 Schema field editors (scalar, list, map, locale); entity editors show fields that other mods' schemas add to their kind
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:01:34 +02:00
cthangover 3ddb61d61e Localization in es, zh, ja, ko, vi; novel editor hub removed (its collection is gone, tools open from the menu and the toolbar) 2026-10-23 20:54:37 +02:00
cthangover e1a1d770cf Data editor base: EntityEditorWindow (searchable list, form over JSON entities, texts in every language of the mod, other fields as JSON), EntityDocument, LocaleFileService, PropertiesText; mod tests 2026-10-23 19:09:39 +02:00
cthangover 6ca1fff35f Test screenshots through ViewportCapture: sRGB instead of the dark linear HDR frame 2026-10-23 09:03:55 +02:00
cthangover 3b2c1ddb95 Scene re-read through SceneManager.ReloadContent 2026-10-22 19:14:55 +02:00
cthangover 972df5ccff Scene editor for logical scenes; scenario editor Save refreshes the running game; novel_flow itest; mod cover 2026-10-22 17:09:38 +02:00
cthangover 2b8eaa5452 Line endings: LF everywhere
.gitattributes forces LF (CRLF only for .bat/.cmd), core.autocrlf is off;
files that differed only by CRLF are normalized.
2026-10-21 13:29:40 +02:00
cthangover 5f3f4b0d82 Commit pending working-tree changes (local edits, line endings) 2026-10-21 12:31:53 +02:00
cthangover bd9147e1b6 Play-test from a running game returns into the game
Stop brings the player back to the scene they were in instead of the main
menu, and a scene switch inside the played scenario ends the play-test in
place. New GUI test suite ingame_play covers both.
2026-10-18 21:06:11 +02:00
cthangover bf4ac5e27a Rename cthangover_story to cthangover_game
The mod holding the main game's own scenes and story follows the <game>_game
naming rule; further parts stay <game>_<part>, reusable content is <theme>_pack.
2026-10-18 19:45:50 +02:00
cthangover 2bb0a07edb Collections are packages of their own
A collection is no longer a file inside a mod: it is a package (folder or zip)
named after its id with collection.json, cover and optional locale/, shipped in
collections/ next to the mods or kept by the player in user://collections/.
Covers and texts come from the package; mods do not ship collections.
2026-10-18 19:21:35 +02:00
cthangover 754b854817 Mod naming rule and engine UI in engine mods
Mods are renamed by role (shell2d_base, menu_base, ui_base, live2d_base,
editor_tools, cthangover_story, cthangover_murakami, cthangover_party) and
every reference follows. Engine UI prefabs (dialog, answers, message, game
menu, inventory, map, skills) and their panel entries moved out of the story
mod into the subsystem mods; the card_novel and vn_starter_novel collections
are removed.
2026-10-18 19:00:24 +02:00
cthangover 7b6ae80616 Scenario play-test returns to the screen the editor was opened from
The play-test loads the game shell, which frees the screen the editor was
opened from (the novel_editor hub). Both the scenario's end and Stop used to
switch to the engine's main menu; the editor now remembers the route it left
and switches back to it, falling back to the main menu only when it was not
opened from a route. The tools_hub run of the windows suite plays its own
scenario and checks both ways back.
2026-10-17 21:44:34 +02:00
cthangover 55ef9416ef Tool windows test suite; drop visual_novel_core dependency
- test_suites/windows (--itest=tools_windows [--shots=<dir>]) opens every
  tool window, fails on any engine error logged meanwhile, drives the
  map/interactive/light canvases and can save a screenshot per window.
- The tools need the engine mods only; visual_novel_core is no longer a
  dependency (the Novel Editor collection brings the content).
2026-10-17 14:29:15 +00:00
cthangover 8742aa3da5 Tool windows redesign and the Novel Editor workspace
Tool windows:
- Every editor (scenario, scene builder, map, interactive objects,
  lighting, catalog export) is rebuilt on the interface ToolWindow chrome
  and the denser ToolTheme: header with title/close, status bar, Ctrl+S,
  dirty-state confirmation.
- Shared building blocks in src/Common: ToolUi (rows, buttons, sections),
  CollapsibleSection, FilteredList, ModFileSelector, DragHandle and
  BackgroundCanvas (scene background with pan/zoom).
- The map, interactive and light editors draw on canvases
  (MapEditorCanvas, InteractiveEditorCanvas, LightEditorCanvas) instead of
  one Control "handle" node per object; the *Handle classes are removed.
- Scenario editor: tab data model (TabData), automatic graph layout
  (ScenarioGraphLayout), file tree/info/thumbnail panel updates.
- Services: ModFileService (mod file listing and writing),
  SceneBackgroundService; MapFileService/ModResourceService follow.
- Catalog export: per-language name/description editing.
- Locale (en/ru) regrouped under tools/*; unused keys removed.

Novel Editor:
- collections/novel_editor.json: a "game" whose entry scene is an
  authoring workspace over the novel engine and the story content.
- NovelEditorHub (scenes/novel_editor_hub.tscn) lists every installed
  tool through IToolProvider.AddMenuButton; ToolsInitializer registers it
  as the logical scene "novel_editor".
2026-10-17 14:29:14 +00:00
cthangover 578ba7c5d5 dev_tools collection: Russian texts, atlases in the validation block
- ru name/description for the collection and every block.
- The optional "Validation" block also installs resource_atlas and
  locale_atlas next to the validator.
2026-10-17 14:29:08 +00:00
iisaevandClaude Opus 5.5 e9b9205514 upd: sync local changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:18:22 +00:00
cthangover 03ee4dbc8f tscn patch system 2026-09-29 19:31:09 +03:00
129 changed files with 11676 additions and 3484 deletions

No files matched your search

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+73
View File
@@ -0,0 +1,73 @@
# 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.
The mod also holds the base of the item and character editors (they are separate mods: Items Editor, Characters Editor). A game gets these tools through the Developer Tools toolset or its own collection.
## Opening the tools
In a game, the hammer button on the toolbar opens the tool box:
![The toolbar of the novel template with the hammer pressed and the tool box open: Interactive Editor, Light Editor, Map Editor, Scene editor.](assets/toolbox.jpg)
- **Scene editor**, **Map Editor**, **Interactive Editor** and **Light Editor** are in the tool box. Other tool mods add their own entries there (the item, character and RPG map editors, the Data Editor).
- The **scenario editor** opens from the scene editor: select a scene, pick one of its scenarios and press **Open**, or **+ New scenario**.
- When the tools are loaded at the main menu (for example with `--all-mods`), every tool also has a button in the menu's tool block, including the scene builder and the catalog export.
The windows stay open across scene changes, can be moved and resized, and close with the cross.
## A whole small edit: a new place
The novel template has a town, a bar and a home. Here an author adds a park that can be reached from the town, using only the editors.
**1. A new scene.** In the scene editor press **+ New scene**, type the name, choose the mod to save it to and add a background:
![The scene editor: the scenes my_novel_bar, my_novel_home and my_novel_town of my_novel_game on the left; in the middle the background home/vestibule added to the scene; on the right Name my_novel_park, Save to my_novel_game, Sound none, Default scenario none.](assets/new-scene.jpg)
Save (Ctrl+S) writes `scenes/my_novel_park.json` into `my_novel_game`; the game knows the scene at once.
**2. Its scenario.** **+ New scenario** opens the scenario editor with a file for the scene. The park offers one way back:
![The scenario editor with my_novel_park.scenario: scene my_novel_park, priority 10, the narration "A quiet park behind the town. Wet leaves, an empty bench.", the choice "What next?" with "Back to the town" leading to switch_scene my_novel_town.](assets/park-scenario.jpg)
**3. A way there.** In the town's scenario one answer and one label are added:
![The scenario editor with my_novel_town.scenario: the choice "Where to go?" now has the answer "Go to the park" -> :park, and the label :park does switch_scene my_novel_park.](assets/town-scenario.jpg)
```scenario
option "Go to the park" -> :park
:park
switch_scene my_novel_park
end
```
Saving a scenario drops the game's scenario caches, so the next entry into the town plays the new text.
**4. Play it.** **Go to scene** in the scene editor, or simply playing on, enters the town, and the new answer is there:
![The town in the running game: "Where to go?" with Go into the bar, Go to the park, Go back home.](assets/town-choice.jpg)
![The new park after choosing Go to the park: "What next?" with Back to the town.](assets/park.jpg)
## The editors
- **Scene editor** — the places of a novel: name, the mod to save to, backgrounds (one is picked at random on entry), ambient sound with a listen button, the default scenario and the scene's scenarios. **Go to scene** ends the running dialog and enters the scene.
- **Scenario editor** — the files of every mod, a summary and a graph of scenes and transitions; DSL highlighting; Ctrl+S saves; **Play** (F5) saves and plays the scenario inside the running game, and Stop returns to where the game was; **Open in external editor** opens the file in the system's editor.
- **Map Editor** — the zones of the world map.
- **Interactive Editor** — clickable objects of the current scene.
- **Light Editor** — the lights of the current scene.
- **Scene builder** — Godot `.tscn` scenes for mods.
- **Catalog export** — packs a mod into a zip and writes its entry for a mod catalog with checksums.
Undo works while editing (Ctrl+Z in the text and in the forms of the entity editors). Checking a mod for broken references is the Validator's job; the editors do not stop you from saving a scenario with a mistake.
## 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.
+73
View File
@@ -0,0 +1,73 @@
# Editor Tools — crear escenas y escenarios dentro del juego en marcha
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.
El mod también contiene la base de los editores de objetos y de personajes (que son mods aparte: Items Editor y Characters Editor). Un juego obtiene estas herramientas mediante el conjunto Developer Tools o con su propia colección.
## Cómo abrir las herramientas
Dentro de un juego, el botón del martillo de la barra de herramientas abre la caja de herramientas:
![La barra de herramientas de la plantilla de novela con el martillo pulsado y la caja de herramientas abierta: Interactive Editor, Light Editor, Map Editor, Scene editor.](assets/toolbox.jpg)
- **Editor de escenas**, **Editor de mapas**, **Editor de interactivos** y **Editor de luces** están en la caja. Otros mods de herramientas añaden ahí sus propias entradas (los editores de objetos, de personajes y de mapas RPG, el editor de datos).
- El **editor de escenarios** se abre desde el editor de escenas: elige una escena, selecciona uno de sus escenarios y pulsa **Abrir**, o **+ Nuevo escenario**.
- Si las herramientas se cargan en el menú principal (por ejemplo con `--all-mods`), cada una tiene también un botón en el bloque de herramientas del menú, incluidos el constructor de escenas y la exportación al catálogo.
Las ventanas siguen abiertas al cambiar de escena, se pueden mover y redimensionar, y se cierran con la cruz.
## Una edición pequeña completa: un lugar nuevo
La plantilla de novela tiene un pueblo, un bar y una casa. Aquí añadiremos un parque al que se llega desde el pueblo usando solo los editores.
**1. Una escena nueva.** En el editor de escenas pulsa **+ Nueva escena**, escribe el nombre, elige el mod donde guardarla y añade un fondo:
![El editor de escenas: a la izquierda, las escenas my_novel_bar, my_novel_home y my_novel_town de my_novel_game; en el centro, el fondo home/vestibule añadido a la escena; a la derecha, Nombre my_novel_park, Guardar en my_novel_game, Sonido ninguno, Escenario por defecto ninguno.](assets/new-scene.jpg)
Guardar (Ctrl+S) escribe `scenes/my_novel_park.json` en `my_novel_game`; el juego conoce la escena al instante.
**2. Su escenario.** **+ Nuevo escenario** abre el editor de escenarios con un archivo para la escena. El parque ofrece una única salida:
![El editor de escenarios con my_novel_park.scenario: escena my_novel_park, prioridad 10, la narración «Un parque tranquilo detrás del pueblo. Hojas mojadas, un banco vacío.», la elección «¿Qué hago ahora?» con «Volver al pueblo» que lleva a switch_scene my_novel_town.](assets/park-scenario.jpg)
**3. Un camino hasta allí.** En el escenario del pueblo se añaden una respuesta y una etiqueta:
![El editor de escenarios con my_novel_town.scenario: la elección «¿Adónde ir?» tiene ahora la respuesta «Ir al parque» -> :park, y la etiqueta :park hace switch_scene my_novel_park.](assets/town-scenario.jpg)
```scenario
option "Ir al parque" -> :park
:park
switch_scene my_novel_park
end
```
Guardar un escenario descarta las cachés de escenarios del juego, así que la próxima entrada al pueblo ya reproduce el texto nuevo.
**4. Juégalo.** **Ir a la escena** en el editor de escenas, o simplemente seguir jugando, entra en el pueblo, y la nueva respuesta ya está ahí:
![El pueblo en el juego en marcha: «¿Adónde ir?» con Entrar en el bar, Ir al parque, Volver a casa.](assets/town-choice.jpg)
![El parque nuevo tras elegir Ir al parque: «¿Qué hago ahora?» con Volver al pueblo.](assets/park.jpg)
## Los editores
- **Editor de escenas**: los lugares de una novela: nombre, el mod donde guardar, fondos (al entrar se elige uno al azar), sonido ambiente con botón de escucha, el escenario por defecto y los escenarios de la escena. **Ir a la escena** termina el diálogo en curso y entra en la escena.
- **Editor de escenarios**: los archivos de todos los mods, un resumen y un grafo de escenas y transiciones; resaltado de DSL; Ctrl+S guarda; **Jugar** (F5) guarda y reproduce el escenario dentro del juego en marcha, y Detener vuelve a donde estaba el juego; **Abrir en un editor externo** abre el archivo en el editor del sistema.
- **Editor de mapas**: las zonas del mapa del mundo.
- **Editor de interactivos**: los objetos clicables de la escena actual.
- **Editor de luces**: las luces de la escena actual.
- **Constructor de escenas**: escenas `.tscn` de Godot para los mods.
- **Exportación al catálogo**: empaqueta un mod en un zip y escribe su entrada para un catálogo de mods con sumas de comprobación.
Deshacer funciona mientras editas (Ctrl+Z en el texto y en los formularios de los editores de objetos y personajes). Comprobar un mod en busca de referencias rotas es tarea de Validator; los editores no te impiden guardar un escenario con un error.
## 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.
+73
View File
@@ -0,0 +1,73 @@
# Editor Tools — 実行中のゲーム内でシーンとシナリオを作る
Editor Tools は、実行中のゲームの上に開く作者向けのウィンドウ群です。シーンエディター、シナリオエディター、ワールドマップ・インタラクティブ・ライトの各エディター、Godot シーンのビルダー、MOD カタログの書き出しが含まれます。保存されるのはどれも普通の MOD ファイルで、ゲームは変更をすぐに取り込みます。そのため、場所を書いて、その場に入って遊ぶまでを、再起動なしで行えます。
この MOD には、アイテムエディターとキャラクターエディターの土台も入っています(本体は別の MOD、Items Editor と Characters Editor です)。ゲームにこれらのツールを入れるには、Developer Tools ツールセットか、そのゲーム独自のコレクションを使います。
## ツールを開く
ゲーム内では、ツールバーのハンマーのボタンでツールボックスが開きます:
![ノベルのテンプレートのツールバー。ハンマーが押され、ツールボックスが開いている: Interactive Editor、Light Editor、Map Editor、Scene editor。](assets/toolbox.jpg)
- ツールボックスには**シーンエディター**、**マップエディター**、**インタラクティブエディター**、**ライトエディター**が入っています。他のツール MOD もここに項目を追加します(アイテム、キャラクター、RPG マップの各エディター、データエディター)。
- **シナリオエディター**はシーンエディターから開きます。シーンを選び、そのシナリオのひとつを選んで**開く**を押すか、**+ 新しいシナリオ**を押します。
- メインメニューでツールを読み込んでいる場合(たとえば `--all-mods`)、メニューのツール欄にも各ツールのボタンが並びます。シーンビルダーとカタログの書き出しも含まれます。
ウィンドウはシーンを切り替えても開いたまま残り、移動やサイズ変更ができ、×で閉じます。
## 小さな編集をひと通り: 新しい場所
ノベルのテンプレートには、町、バー、家があります。ここではエディターだけを使って、町から行ける公園を追加します。
**1. 新しいシーン。** シーンエディターで **+ 新しいシーン**を押し、名前を入力し、保存先の MOD を選んで背景を追加します:
![シーンエディター: 左に my_novel_game の my_novel_bar、my_novel_home、my_novel_town。中央にはシーンに追加した背景 home/vestibule。右は名前 my_novel_park、保存先 my_novel_game、音 なし、既定のシナリオ なし。](assets/new-scene.jpg)
保存(Ctrl+S)すると `scenes/my_novel_park.json` が `my_novel_game` に書き込まれ、ゲームはそのシーンをすぐに認識します。
**2. そのシナリオ。** **+ 新しいシナリオ**を押すと、そのシーンのファイルを開いたシナリオエディターが立ち上がります。公園からは戻る道が一本だけあります:
![my_novel_park.scenario を開いたシナリオエディター: シーン my_novel_park、優先度 10、ナレーション「町の裏手にある静かな公園。濡れた落ち葉、誰もいないベンチ。」、選択肢「次はどうする?」の「町へ戻る」が switch_scene my_novel_town へ続く。](assets/park-scenario.jpg)
**3. そこへ行く道。** 町のシナリオに、答えをひとつとラベルをひとつ追加します:
![my_novel_town.scenario を開いたシナリオエディター: 選択肢「どこへ行く?」に「公園へ行く」-> :park が加わり、ラベル :park が switch_scene my_novel_park を実行する。](assets/town-scenario.jpg)
```scenario
option "公園へ行く" -> :park
:park
switch_scene my_novel_park
end
```
シナリオを保存するとゲームのシナリオキャッシュが破棄されるので、次に町へ入ったときには新しいテキストが再生されます。
**4. 遊んでみる。** シーンエディターの**シーンへ移動**を押すか、そのまま遊び続けると町に入り、新しい答えがそこにあります:
![実行中のゲームの町:「どこへ行く?」に、バーに入る、公園へ行く、家に帰る。](assets/town-choice.jpg)
![「公園へ行く」を選んだあとの新しい公園:「次はどうする?」に、町へ戻る。](assets/park.jpg)
## 各エディター
- **シーンエディター** — ノベルの場所: 名前、保存先の MOD、背景(入るたびにひとつがランダムで選ばれます)、試聴ボタン付きの環境音、既定のシナリオ、そのシーンのシナリオ。**シーンへ移動**は進行中のダイアログを終わらせてシーンに入ります。
- **シナリオエディター** — すべての MOD のファイル、概要、シーンと遷移のグラフ、DSL のハイライト、Ctrl+S で保存。**プレイ**(F5)は保存して実行中のゲーム内でシナリオを再生し、停止するとゲームが元いた場所へ戻ります。**外部エディターで開く**はシステムのエディターでファイルを開きます。
- **マップエディター** — ワールドマップのゾーン。
- **インタラクティブエディター** — 現在のシーンのクリックできるオブジェクト。
- **ライトエディター** — 現在のシーンのライト。
- **シーンビルダー** — MOD 用の Godot `.tscn` シーン。
- **カタログの書き出し** — MOD を zip にまとめ、チェックサム付きで MOD カタログ用のエントリーを書き出します。
編集中は元に戻せます(テキスト内と、アイテム・キャラクターエディターのフォームで Ctrl+Z)。壊れた参照の検査は Validator の仕事で、エディターは間違いのあるシナリオの保存を止めません。
## どの MOD のファイルが書かれるか
ツールが書き込むのは、フォルダーとしてインストールされた MOD だけです。シーンは全 MOD の `scenes/<name>.json` の合計なので、シーンエディターは**保存先**で選んだ MOD のファイルを書き込みます。他の MOD やパッケージ済みの MOD に属するシーンを変更すると、それを補うファイルがあなたの MOD に作られ、他のファイル由来の背景はロックされて表示されます。シナリオは所属する MOD に保存されます。
## うまくいかないとき
1. **ハンマーのボタンがない。** そのゲームにツールが入っていません。コレクションに Developer Tools ツールセットを追加するか、`--with-mods=editor_tools` で起動してください。
2. **新しいシナリオが再生されない。** ヘッダーが別のシーンを指しているか、そのシーンの別のシナリオが先に選ばれています(`priority` を比べてください)。
3. **保存先に MOD が出ない。** 書き込めるのはフォルダーとしてインストールされた MOD だけです。
+73
View File
@@ -0,0 +1,73 @@
# Editor Tools — 실행 중인 게임 안에서 장면과 시나리오 만들기
Editor Tools는 실행 중인 게임 위에 열리는 창작용 창 모음입니다. 장면 에디터, 시나리오 에디터, 월드 맵·상호작용·조명 에디터, Godot 장면 빌더, 모드 카탈로그 내보내기가 들어 있습니다. 저장되는 것은 모두 평범한 모드 파일이고 게임은 변경을 곧바로 받아들입니다. 그래서 장소를 쓰고, 바로 들어가 플레이하는 일을 재시작 없이 할 수 있습니다.
이 모드에는 아이템·캐릭터 에디터의 기반도 들어 있습니다(본체는 별도 모드인 Items Editor와 Characters Editor입니다). 게임에 이 도구들을 넣으려면 Developer Tools 도구 모음이나 그 게임의 컬렉션을 사용합니다.
## 도구 열기
게임 안에서 도구 모음의 망치 버튼을 누르면 도구 상자가 열립니다:
![소설 템플릿의 도구 모음. 망치가 눌려 있고 도구 상자가 열려 있습니다: Interactive Editor, Light Editor, Map Editor, Scene editor.](assets/toolbox.jpg)
- 도구 상자에는 **장면 에디터**, **맵 에디터**, **상호작용 에디터**, **조명 에디터**가 있습니다. 다른 도구 모드도 여기에 항목을 추가합니다(아이템, 캐릭터, RPG 맵 에디터, 데이터 에디터).
- **시나리오 에디터**는 장면 에디터에서 엽니다. 장면을 고르고 그 시나리오 중 하나를 선택한 뒤 **열기**를 누르거나 **+ 새 시나리오**를 누릅니다.
- 메인 메뉴에서 도구를 불러오는 경우(예: `--all-mods`), 메뉴의 도구 영역에도 각 도구 버튼이 생깁니다. 장면 빌더와 카탈로그 내보내기도 포함됩니다.
창은 장면을 바꿔도 열린 채로 남고, 옮기고 크기를 바꿀 수 있으며 ×로 닫습니다.
## 작은 편집 하나를 끝까지: 새 장소
소설 템플릿에는 마을, 술집, 집이 있습니다. 여기서는 에디터만 써서 마을에서 갈 수 있는 공원을 추가해 보겠습니다.
**1. 새 장면.** 장면 에디터에서 **+ 새 장면**을 누르고 이름을 입력하고 저장할 모드를 고른 다음 배경을 추가합니다:
![장면 에디터: 왼쪽에 my_novel_game의 my_novel_bar, my_novel_home, my_novel_town 장면. 가운데에는 장면에 추가한 배경 home/vestibule. 오른쪽은 이름 my_novel_park, 저장할 곳 my_novel_game, 소리 없음, 기본 시나리오 없음.](assets/new-scene.jpg)
저장(Ctrl+S)하면 `scenes/my_novel_park.json`이 `my_novel_game`에 기록되고, 게임은 그 장면을 곧바로 인식합니다.
**2. 그 장면의 시나리오.** **+ 새 시나리오**를 누르면 그 장면의 파일이 열린 시나리오 에디터가 뜹니다. 공원에서는 돌아가는 길이 하나뿐입니다:
![my_novel_park.scenario를 연 시나리오 에디터: 장면 my_novel_park, 우선순위 10, 내레이션 "마을 뒤의 조용한 공원. 젖은 낙엽, 비어 있는 벤치.", 선택지 "이제 뭘 하지?"의 "마을로 돌아가기"가 switch_scene my_novel_town으로 이어집니다.](assets/park-scenario.jpg)
**3. 그곳으로 가는 길.** 마을 시나리오에 답 하나와 레이블 하나를 추가합니다:
![my_novel_town.scenario를 연 시나리오 에디터: 선택지 "어디로 갈까?"에 "공원으로 가기" -> :park가 생겼고, :park 레이블이 switch_scene my_novel_park를 실행합니다.](assets/town-scenario.jpg)
```scenario
option "공원으로 가기" -> :park
:park
switch_scene my_novel_park
end
```
시나리오를 저장하면 게임의 시나리오 캐시가 비워지므로, 다음에 마을에 들어가면 새 텍스트가 재생됩니다.
**4. 플레이해 보기.** 장면 에디터의 **장면으로 이동**을 누르거나 그냥 계속 플레이하면 마을로 들어가고, 새 답이 그 자리에 있습니다:
![실행 중인 게임의 마을: "어디로 갈까?"에 술집으로 들어가기, 공원으로 가기, 집으로 돌아가기.](assets/town-choice.jpg)
!["공원으로 가기"를 고른 뒤의 새 공원: "이제 뭘 하지?"에 마을로 돌아가기.](assets/park.jpg)
## 각 에디터
- **장면 에디터** — 소설의 장소: 이름, 저장할 모드, 배경(들어갈 때 하나가 무작위로 선택됩니다), 청취 버튼이 있는 환경음, 기본 시나리오, 장면의 시나리오. **장면으로 이동**은 진행 중인 대화를 끝내고 장면으로 들어갑니다.
- **시나리오 에디터** — 모든 모드의 파일, 요약, 장면과 전환의 그래프, DSL 강조 표시, Ctrl+S 저장. **플레이**(F5)는 저장하고 실행 중인 게임 안에서 시나리오를 재생하며, 정지는 게임이 있던 곳으로 돌아갑니다. **외부 에디터로 열기**는 시스템 편집기로 파일을 엽니다.
- **맵 에디터** — 월드 맵의 구역.
- **상호작용 에디터** — 현재 장면의 클릭 가능한 객체.
- **조명 에디터** — 현재 장면의 조명.
- **장면 빌더** — 모드용 Godot `.tscn` 장면.
- **카탈로그 내보내기** — 모드를 zip으로 묶고 체크섬과 함께 모드 카탈로그용 항목을 기록합니다.
편집 중에는 되돌리기가 됩니다(텍스트와 아이템·캐릭터 에디터의 폼에서 Ctrl+Z). 깨진 참조 검사는 Validator의 일이며, 에디터가 실수가 있는 시나리오의 저장을 막지는 않습니다.
## 어떤 모드의 파일이 기록되는가
도구는 폴더로 설치된 모드에만 씁니다. 장면은 모든 모드의 `scenes/<name>.json` 파일을 합친 것이므로, 장면 에디터는 **저장할 곳**에서 고른 모드의 파일을 씁니다. 다른 모드나 압축된 모드에 속한 장면을 바꾸면 그 장면을 보충하는 파일이 당신의 모드에 만들어지고, 다른 파일에서 온 배경은 잠긴 상태로 표시됩니다. 시나리오는 속한 모드에 저장됩니다.
## 문제가 생겼을 때
1. **망치 버튼이 없습니다.** 그 게임에 도구가 들어 있지 않습니다. 컬렉션에 Developer Tools 도구 모음을 추가하거나 `--with-mods=editor_tools`로 실행하세요.
2. **새 시나리오가 재생되지 않습니다.** 머리말이 다른 장면을 가리키고 있거나, 그 장면의 다른 시나리오가 먼저 선택됩니다(`priority`를 비교해 보세요).
3. **저장할 곳에 모드가 없습니다.** 폴더로 설치된 모드에만 쓸 수 있습니다.
+73
View File
@@ -0,0 +1,73 @@
# Editor Tools — сцены и сценарии прямо в запущенной игре
Editor Tools — это авторские окна поверх работающей игры: редактор сцен, редактор сценариев, редакторы карты мира, интерактивных объектов и света, конструктор сцен Godot и экспорт каталога мода. Всё, что они сохраняют, — обычные файлы модов, и игра подхватывает правки сразу: можно описать место, тут же войти в него и сыграть, ничего не перезапуская.
Здесь же лежит основа редакторов предметов и персонажей (сами они — отдельные моды: Items Editor и Characters Editor). В игру эти инструменты попадают через набор Developer Tools или через её собственную коллекцию.
## Как открыть инструменты
Молоток на панели инструментов в игре открывает набор инструментов:
![Панель шаблона новеллы: нажат молоток, открыт набор инструментов — Interactive Editor, Light Editor, Map Editor, Scene editor.](assets/toolbox.jpg)
- **Редактор сцен**, **Редактор карты**, **Редактор интерактивов** и **Редактор освещения** входят в набор. Другие инструментальные моды добавляют туда свои пункты (редакторы предметов, персонажей и RPG-карт, редактор данных).
- **Редактор сценариев** открывается из редактора сцен: выберите сцену, укажите один из её сценариев и нажмите **Открыть** — или **+ Новый сценарий**.
- Если инструменты загружены в главном меню (например, с `--all-mods`), у каждого есть кнопка и в блоке инструментов меню, включая конструктор сцен и экспорт каталога.
Окна остаются открытыми при смене сцен, их можно двигать и растягивать, а закрываются они крестиком.
## Целая небольшая правка: новое место
В шаблоне новеллы есть город, бар и дом. Добавим туда парк, до которого можно дойти из города, — только средствами редакторов.
**1. Новая сцена.** В редакторе сцен нажмите **+ Новая сцена**, введите имя, выберите мод для сохранения и добавьте фон:
![Редактор сцен: слева сцены my_novel_bar, my_novel_home и my_novel_town мода my_novel_game; в центре добавленный в сцену фон home/vestibule; справа Имя my_novel_park, Сохранять в my_novel_game, Звук нет, Сценарий по умолч. нет.](assets/new-scene.jpg)
Сохранение (Ctrl+S) записывает `scenes/my_novel_park.json` в `my_novel_game` — и игра знает сцену сразу.
**2. Её сценарий.** **+ Новый сценарий** открывает редактор сценариев с файлом для сцены. Из парка есть один путь назад:
![Редактор сценариев с my_novel_park.scenario: сцена my_novel_park, приоритет 10, повествование «Тихий парк за городом. Мокрая листва, пустая скамейка.», выбор «Что дальше?» с ответом «Обратно в город», ведущим к switch_scene my_novel_town.](assets/park-scenario.jpg)
**3. Путь туда.** В сценарии города добавляются один ответ и одна метка:
![Редактор сценариев с my_novel_town.scenario: у выбора «Куда пойти?» появился ответ «Пойти в парк» -> :park, а метка :park выполняет switch_scene my_novel_park.](assets/town-scenario.jpg)
```scenario
option "Пойти в парк" -> :park
:park
switch_scene my_novel_park
end
```
Сохранение сценария сбрасывает кэш сценариев игры, так что при следующем входе в город сыграет уже новый текст.
**4. Сыграйте.** **Перейти в сцену** в редакторе сцен — или просто продолжайте играть — и вы попадёте в город, где новый ответ уже на месте:
![Город в запущенной игре: «Куда пойти?» с ответами «Зайти в бар», «Пойти в парк», «Вернуться домой».](assets/town-choice.jpg)
![Новый парк после выбора «Пойти в парк»: «Что дальше?» с ответом «Обратно в город».](assets/park.jpg)
## Редакторы
- **Редактор сцен** — места новеллы: имя, мод для сохранения, фоны (при входе один выбирается случайно), звук окружения с кнопкой прослушивания, сценарий по умолчанию и сценарии сцены. **Перейти в сцену** завершает текущий диалог и входит в сцену.
- **Редактор сценариев** — файлы всех модов, сводка и граф сцен и переходов; подсветка DSL; Ctrl+S сохраняет; **Запустить** (F5) сохраняет и проигрывает сценарий прямо в запущенной игре, а «Стоп» возвращает туда, где игра была; **Открыть во внешнем редакторе** открывает файл в системном редакторе.
- **Редактор карты** — зоны карты мира.
- **Редактор интерактивов** — кликабельные объекты текущей сцены.
- **Редактор освещения** — источники света текущей сцены.
- **Конструктор сцен** — сцены Godot `.tscn` для модов.
- **Экспорт каталога** — упаковывает мод в zip и пишет его запись для каталога модов с контрольными суммами.
Отмена работает во время редактирования (Ctrl+Z в тексте и в формах редакторов предметов и персонажей). Проверка мода на битые ссылки — задача Validator; редакторы не мешают сохранить сценарий с ошибкой.
## Чьи файлы пишутся
Инструменты пишут только в моды, установленные папками. Сцена — это сумма файлов `scenes/<name>.json` всех модов, поэтому редактор сцен пишет файл того мода, который выбран в **Сохранять в**. Если вы меняете сцену, принадлежащую другому моду или упакованному моду, создаётся файл в вашем моде, который её дополняет; фоны из чужих файлов показываются заблокированными. Сценарии сохраняются в том моде, которому принадлежат.
## Если что-то пошло не так
1. **Нет кнопки-молотка.** В игре нет инструментов — добавьте набор Developer Tools в её коллекцию или запустите с `--with-mods=editor_tools`.
2. **Новый сценарий не играет.** В его заголовке указана другая сцена, или вперёд выбран другой сценарий этой сцены (сравните их `priority`).
3. **В «Сохранять в» нет модов.** Писать можно только в моды, установленные папками.
+73
View File
@@ -0,0 +1,73 @@
# Editor Tools — dựng cảnh và kịch bản ngay trong trò chơi đang chạy
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.
Mod này cũng chứa phần nền của trình sửa vật phẩm và nhân vật (bản thân chúng là mod riêng: Items Editor và Characters Editor). Một trò chơi nhận các công cụ này qua bộ Developer Tools hoặc qua bộ sưu tập riêng của nó.
## Mở các công cụ
Trong game, nút chiếc búa trên thanh công cụ mở hộp công cụ:
![Thanh công cụ của mẫu tiểu thuyết với chiếc búa đang được nhấn và hộp công cụ mở ra: Interactive Editor, Light Editor, Map Editor, Scene editor.](assets/toolbox.jpg)
- **Trình sửa cảnh**, **trình sửa bản đồ**, **trình sửa tương tác** và **trình sửa ánh sáng** nằm trong hộp công 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 và bản đồ RPG, trình sửa dữ liệu).
- **Trình sửa kịch bản** mở 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**.
- Nếu các công cụ được tải ở menu chính (ví dụ với `--all-mods`), mỗi công cụ cũng có nút trong khối công cụ của menu, kể cả trình dựng cảnh và phần xuất danh mục.
Các cửa sổ vẫn mở khi chuyển cảnh, có thể di chuyển và đổi kích thước, và đóng bằng dấu ×.
## Một chỉnh sửa nhỏ trọn vẹn: một địa điểm mới
Mẫu tiểu thuyết có một thị trấn, một quán bar và một ngôi nhà. Ở đây ta thêm một công viên đi được từ thị trấn, chỉ bằng các trình sửa.
**1. Một cảnh mới.** Trong trình sửa cảnh nhấn **+ Cảnh mới**, gõ tên, chọn mod để lưu và thêm phông nền:
![Trình sửa cảnh: bên trái là các cảnh my_novel_bar, my_novel_home và my_novel_town của my_novel_game; ở giữa là phông nền home/vestibule đã thêm vào cảnh; bên phải là Tên my_novel_park, Lưu vào my_novel_game, Âm thanh không, Kịch bản mặc định không.](assets/new-scene.jpg)
Lưu (Ctrl+S) ghi `scenes/my_novel_park.json` vào `my_novel_game`; trò chơi biết cảnh này ngay.
**2. Kịch bản của cảnh.** **+ Kịch bản mới** mở trình sửa kịch bản với một tệp cho cảnh. Công viên chỉ có một lối về:
![Trình sửa kịch bản với my_novel_park.scenario: cảnh my_novel_park, ưu tiên 10, lời dẫn "Một công viên yên tĩnh sau thị trấn. Lá ướt, chiếc ghế trống.", lựa chọn "Làm gì tiếp?" với "Về thị trấn" dẫn tới switch_scene my_novel_town.](assets/park-scenario.jpg)
**3. Một lối đến đó.** Trong kịch bản của thị trấn, thêm một câu trả lời và một nhãn:
![Trình sửa kịch bản với my_novel_town.scenario: lựa chọn "Đi đâu?" giờ có câu trả lời "Ra công viên" -> :park, và nhãn :park thực hiện switch_scene my_novel_park.](assets/town-scenario.jpg)
```scenario
option "Ra công viên" -> :park
:park
switch_scene my_novel_park
end
```
Lưu kịch bản sẽ xóa bộ nhớ đệm kịch bản của game, nên lần vào thị trấn tiếp theo sẽ chơi đoạn văn bản mới.
**4. Chơi thử.** **Đến cảnh** trong trình sửa cảnh, hoặc cứ chơi tiếp, sẽ vào thị trấn, và câu trả lời mới đã ở đó:
![Thị trấn trong game đang chạy: "Đi đâu?" với Vào quán bar, Ra công viên, Về nhà.](assets/town-choice.jpg)
![Công viên mới sau khi chọn Ra công viên: "Làm gì tiếp?" với Về thị trấn.](assets/park.jpg)
## Các trình sửa
- **Trình sửa cảnh** — các địa điểm của tiểu thuyết: tên, mod để lưu, phông nền (mỗi lần vào chọn ngẫu nhiên một), âm thanh nền có nút nghe thử, kịch bản mặc định và các kịch bản của cảnh. **Đến cảnh** kết thúc đoạn hội thoại đang chạy và vào cảnh.
- **Trình sửa kịch bản** — tệp của mọi mod, bản tóm tắt và đồ thị cảnh cùng chuyển cảnh; tô sáng DSL; Ctrl+S để lưu; **Chơi** (F5) lưu và phát kịch bản ngay trong game đang chạy, còn Dừng đưa game về chỗ cũ; **Mở bằng trình sửa ngoài** mở tệp bằng chương trình mặc định của hệ thống.
- **Trình sửa bản đồ** — các vùng của bản đồ thế giới.
- **Trình sửa tương tác** — những vật bấm được của cảnh hiện tại.
- **Trình sửa ánh sáng** — đèn của cảnh hiện tại.
- **Trình dựng cảnh** — cảnh `.tscn` của Godot cho các mod.
- **Xuất danh mục** — đóng gói mod thành zip và ghi mục danh mục kèm tổng kiểm tra.
Hoàn tác hoạt động trong khi sửa (Ctrl+Z trong phần văn bản và trong biểu mẫu của trình sửa vật phẩm, nhân vật). Kiểm tra mod có tham chiếu hỏng là việc của Validator; các trình sửa không cản bạn lưu một kịch bản còn lỗi.
## 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.
+73
View File
@@ -0,0 +1,73 @@
# Editor Tools — 在运行中的游戏里制作场景与剧本
Editor Tools 是叠在运行中游戏之上的作者工具窗口:场景编辑器、剧本编辑器、世界地图编辑器、交互物编辑器和光照编辑器,还有一个 Godot 场景构建器与模组目录导出。它们保存的全是普通的模组文件,游戏会立刻读到改动,所以作者可以写下一个地点,马上走进去试玩,无需重启。
本模组还包含物品与角色编辑器的底层部分(它们是两个独立的模组:Items Editor 和 Characters Editor)。游戏通过 Developer Tools 工具集或自己的集合获得这些工具。
## 打开工具
在游戏中,工具栏上的锤子按钮会打开工具箱:
![小说模板的工具栏:锤子按钮已按下,工具箱已打开:Interactive Editor、Light Editor、Map Editor、Scene editor。](assets/toolbox.jpg)
- 工具箱里有**场景编辑器**、**地图编辑器**、**交互编辑器**和**光照编辑器**。其他工具模组会往里面添加自己的条目(物品、角色和 RPG 地图编辑器,以及数据编辑器)。
- **剧本编辑器**从场景编辑器打开:选中一个场景,挑一个它的剧本,按**打开**,或者按 **+ 新剧本**。
- 如果在主菜单就加载了工具(比如用 `--all-mods`),菜单的工具区里每个工具也都有按钮,包括场景构建器和目录导出。
这些窗口在切换场景后依然开着,可以拖动和调整大小,点叉号关闭。
## 一次完整的小改动:一个新地点
小说模板里有小镇、酒吧和家。下面只用编辑器,给小镇加一个能走过去的公园。
**1. 新场景。** 在场景编辑器里按 **+ 新场景**,输入名称,选择保存到哪个模组,再添加一个背景:
![场景编辑器:左侧是 my_novel_game 的 my_novel_bar、my_novel_home 和 my_novel_town 三个场景;中间是添加到场景的背景 home/vestibule;右侧是名称 my_novel_park、保存到 my_novel_game、声音 无、默认剧本 无。](assets/new-scene.jpg)
保存(Ctrl+S)会把 `scenes/my_novel_park.json` 写进 `my_novel_game`,游戏马上就知道这个场景了。
**2. 它的剧本。** **+ 新剧本**会打开剧本编辑器,里面是该场景的一个文件。公园只有一条回去的路:
![剧本编辑器中的 my_novel_park.scenario:场景 my_novel_park,优先级 10,旁白「小镇后面的一座静园。湿漉漉的落叶,空着的长椅。」,选项「接下来做什么?」里的「回小镇」指向 switch_scene my_novel_town。](assets/park-scenario.jpg)
**3. 通往那里的路。** 在小镇的剧本里加一个回答和一个标签:
![剧本编辑器中的 my_novel_town.scenario:选项「去哪里?」现在多了回答「去公园」-> :park,而 :park 标签执行 switch_scene my_novel_park。](assets/town-scenario.jpg)
```scenario
option "去公园" -> :park
:park
switch_scene my_novel_park
end
```
保存剧本会清掉游戏的剧本缓存,所以下次进小镇时播放的就是新文本。
**4. 试玩。** 在场景编辑器里点**前往场景**,或者干脆继续玩下去,就会进入小镇,新的回答已经在那里了:
![运行中的游戏里的小镇:「去哪里?」下面是 进酒吧、去公园、回家。](assets/town-choice.jpg)
![选择「去公园」后出现的新公园:「接下来做什么?」下面是 回小镇。](assets/park.jpg)
## 各个编辑器
- **场景编辑器** —— 小说的地点:名称、保存到的模组、背景(进入时随机选一个)、带试听按钮的环境音、默认剧本和场景的剧本。**前往场景**会结束当前对话并进入该场景。
- **剧本编辑器** —— 所有模组的文件、一份概要,以及场景与转场的图;DSL 高亮;Ctrl+S 保存;**试玩**(F5)保存并在运行中的游戏里播放剧本,停止则回到游戏原来的位置;**在外部编辑器中打开**会用系统默认程序打开文件。
- **地图编辑器** —— 世界地图上的区域。
- **交互编辑器** —— 当前场景里可点击的对象。
- **光照编辑器** —— 当前场景的灯光。
- **场景构建器** —— 为模组制作 Godot `.tscn` 场景。
- **目录导出** —— 把模组打包成 zip,并写出带校验和的模组目录条目。
编辑期间可以撤销(文本里和物品、角色编辑器的表单里都支持 Ctrl+Z)。检查模组有没有失效引用是 Validator 的活儿;编辑器不会拦着你保存一个有错误的剧本。
## 写的是谁的文件
这些工具只写安装成文件夹的模组。一个场景是所有模组 `scenes/<name>.json` 文件的总和,所以场景编辑器写的是**保存到**里选中的那个模组的文件。改动属于别的模组或打包模组的场景时,会在你的模组里新建一个文件来补充它;来自其他文件的背景会显示为锁定。剧本则保存在它所属的模组里。
## 出问题了怎么办
1. **找不到锤子按钮。** 这个游戏没带工具;在它的集合里加上 Developer Tools 工具集,或者用 `--with-mods=editor_tools` 启动。
2. **新剧本不播放。** 它的头部写的是另一个场景,或者这个场景里有别的剧本先被选中了(比较它们的 `priority`)。
3. **「保存到」里没有模组。** 只有安装成文件夹的模组才能写入。
+3
View File
@@ -1,2 +1,5 @@
# Catalog export tool — extra keys
mods/catalog_select_mod = Select a mod:
mods/catalog_add_locale = Add language
mods/catalog_locale_name = Name in this language
mods/catalog_locale_desc = Description in this language
+5
View File
@@ -0,0 +1,5 @@
# Catalog export tool — extra keys
mods/catalog_select_mod = Selecciona un mod:
mods/catalog_add_locale = Añadir idioma
mods/catalog_locale_name = Nombre en este idioma
mods/catalog_locale_desc = Descripción en este idioma
+5
View File
@@ -0,0 +1,5 @@
# Catalog export tool — extra keys
mods/catalog_select_mod = MOD を選択:
mods/catalog_add_locale = 言語を追加
mods/catalog_locale_name = この言語での名前
mods/catalog_locale_desc = この言語での説明
+5
View File
@@ -0,0 +1,5 @@
# Catalog export tool — extra keys
mods/catalog_select_mod = 모드 선택:
mods/catalog_add_locale = 언어 추가
mods/catalog_locale_name = 이 언어의 이름
mods/catalog_locale_desc = 이 언어의 설명
+3
View File
@@ -1,2 +1,5 @@
# Catalog export tool — дополнительные ключи
mods/catalog_select_mod = Выберите мод:
mods/catalog_add_locale = Добавить язык
mods/catalog_locale_name = Название на этом языке
mods/catalog_locale_desc = Описание на этом языке
+5
View File
@@ -0,0 +1,5 @@
# Catalog export tool — extra keys
mods/catalog_select_mod = Chọn một mod:
mods/catalog_add_locale = Thêm ngôn ngữ
mods/catalog_locale_name = Tên bằng ngôn ngữ này
mods/catalog_locale_desc = Mô tả bằng ngôn ngữ này
+5
View File
@@ -0,0 +1,5 @@
# Catalog export tool — extra keys
mods/catalog_select_mod = 选择模组:
mods/catalog_add_locale = 添加语言
mods/catalog_locale_name = 此语言的名称
mods/catalog_locale_desc = 此语言的描述
+6
View File
@@ -0,0 +1,6 @@
tools/entity_editor/section_schema = Fields of other mods
tools/entity_editor/schema_hint = These fields are described in the schemas of the mods: {0}. The game reads them from the same JSON.
tools/schema/add_ref = + add…
tools/schema/list_add = + Add
tools/schema/list_remove = ✕
tools/schema/map_add = + Stat
+6
View File
@@ -0,0 +1,6 @@
tools/entity_editor/section_schema = Campos de otros mods
tools/entity_editor/schema_hint = Estos campos los describen los esquemas de los mods: {0}. El juego los lee del mismo JSON.
tools/schema/add_ref = + añadir…
tools/schema/list_add = + Añadir
tools/schema/list_remove = ✕
tools/schema/map_add = + Estadística
+6
View File
@@ -0,0 +1,6 @@
tools/entity_editor/section_schema = 他の MOD のフィールド
tools/entity_editor/schema_hint = これらのフィールドは次の MOD のスキーマが記述している:{0}。ゲームは同じ JSON から読む。
tools/schema/add_ref = + 追加…
tools/schema/list_add = + 追加
tools/schema/list_remove = ✕
tools/schema/map_add = + ステータス
+6
View File
@@ -0,0 +1,6 @@
tools/entity_editor/section_schema = 다른 모드의 필드
tools/entity_editor/schema_hint = 이 필드는 다음 모드들의 스키마가 설명합니다: {0}. 게임은 같은 JSON에서 읽습니다.
tools/schema/add_ref = + 추가…
tools/schema/list_add = + 추가
tools/schema/list_remove = ✕
tools/schema/map_add = + 능력치
+6
View File
@@ -0,0 +1,6 @@
tools/entity_editor/section_schema = Поля других модов
tools/entity_editor/schema_hint = Эти поля описали в своих схемах моды: {0}. Игра читает их из того же JSON.
tools/schema/add_ref = + добавить…
tools/schema/list_add = + Добавить
tools/schema/list_remove = ✕
tools/schema/map_add = + Стат
+6
View File
@@ -0,0 +1,6 @@
tools/entity_editor/section_schema = Trường của mod khác
tools/entity_editor/schema_hint = Các trường này do lược đồ của các mod mô tả: {0}. Game đọc chúng từ cùng tệp JSON.
tools/schema/add_ref = + thêm…
tools/schema/list_add = + Thêm
tools/schema/list_remove = ✕
tools/schema/map_add = + Chỉ số
+6
View File
@@ -0,0 +1,6 @@
tools/entity_editor/section_schema = 其他模组的字段
tools/entity_editor/schema_hint = 这些字段由以下模组的模式描述:{0}。游戏从同一个 JSON 读取它们。
tools/schema/add_ref = + 添加…
tools/schema/list_add = + 添加
tools/schema/list_remove = ✕
tools/schema/map_add = + 属性
+313 -146
View File
@@ -1,39 +1,145 @@
mod_tools_name = Tools
mod_tools_desc = Development tools
mod_editor_tools_name = Editor Tools
mod_editor_tools_desc = The author's windows over a running game: scene, scenario, map, interactive and light editors and a Godot scene builder. What they save are ordinary mod files the game applies at once.
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/common/save = Save
tools/common/save_tip = Save the file (Ctrl+S)
tools/common/saved = Saved: {0}
tools/common/loaded = Opened: {0}
tools/common/load_error = Load error
tools/common/readonly = Cannot save: the mod is read-only (zip) or the path is invalid.
tools/common/no_writable_mods = No writable mods (only folder mods can be edited).
tools/common/no_files = Mod "{0}" has no such files yet — create one with "+".
tools/common/no_file_to_save = Pick or create a file first ("+" next to the file list).
tools/common/new_file = New file
tools/common/new_file_hint = The file is created in {0}/{1}/ with the {2} extension on first save.
tools/common/new_file_status = New file {0} — created on save.
tools/common/mod_tip = Mod (mods that already have such files first; · — none yet)
tools/common/file_tip = File inside the mod
tools/common/search = Search…
tools/common/json = JSON
tools/common/json_tip = Show the resulting file JSON
tools/common/background_missing = Background "{0}" of scene "{1}" is not provided by any mod — pick one by hand.
tools/map_editor/title = Map Editor
tools/map_editor/hint = LMB — select / drag · RMB — delete vertex · double-click, Enter or Esc — finish region · Del — delete zone · Ctrl+S — save
tools/map_editor/reload_map = Refresh in-game map
tools/map_editor/reload_map_tip = Re-read saved map files in the running game
tools/map_editor/reloaded = In-game map refreshed.
tools/map_editor/select_mode = Select
tools/map_editor/draw_region = Draw
tools/map_editor/draw_started = Click the map to place vertices. Double-click, Enter or Esc finishes, RMB removes the last one.
tools/map_editor/background = Preview background
tools/map_editor/zones = Zones
tools/map_editor/add_region = + Region
tools/map_editor/add_region_tip = New region: draw the polygon by clicking the map
tools/map_editor/add_marker = + Marker
tools/map_editor/add_label = + Label
tools/map_editor/delete = Delete zone
tools/map_editor/delete_tip = Delete the selected zone (Del)
tools/map_editor/delete_confirm = Delete the selected zone? The change reaches the file on save.
tools/map_editor/deleted = Zone "{0}" deleted.
tools/map_editor/props = Zone properties
tools/map_editor/zone_missing = Pick a zone in the list or on the map.
tools/map_editor/id = ID
tools/map_editor/id_empty = Zone ID cannot be empty.
tools/map_editor/duplicate_id = Zone ID already used:
tools/map_editor/title_key = Title
tools/map_editor/description = Description
tools/map_editor/no_translation = key not found in localization
tools/map_editor/scene = Scene
tools/map_editor/scene_none = (none)
tools/map_editor/availability = Availability
tools/map_editor/locked = Locked for travel
tools/map_editor/hidden = Hidden from the map
tools/map_editor/effective = In game:
tools/map_editor/effective_open = open
tools/map_editor/effective_locked = locked (Locked flag)
tools/map_editor/effective_no_scene = locked (no scene)
tools/map_editor/effective_unknown_scene = locked (scene not found)
tools/map_editor/effective_hidden = hidden from the map
tools/map_editor/marker = Marker
tools/map_editor/image = Icon
tools/map_editor/image_placeholder = texture ID
tools/map_editor/icon_search = Find icon…
tools/map_editor/label_section = Label
tools/map_editor/label = Text label on the map
tools/map_editor/x = X (0–1)
tools/map_editor/y = Y (0–1)
tools/map_editor/vertices = Polygon vertices
tools/map_editor/vertices_hint = Drag vertices on the map; RMB on a vertex deletes it.
tools/map_editor/add_vertex = + Vertex
tools/map_editor/delete_vertex = Delete vertex
tools/map_editor/preview = ▶ As in game
tools/map_editor/preview_tip = View the map as in game: hover, zone card, markers (unsaved edits included)
tools/map_editor/preview_on = Preview: Esc or ✕ returns to the editor.
tools/map_editor/preview_off = Editing mode.
tools/map_editor/preview_missing = Cannot load the map scene (map_widget.tscn).
tools/scenario_editor/title = Scenario Editor
tools/scenario_editor/hint = Ctrl+S — save · F5 — play the scenario · click a scene in the graph — its scenarios
tools/scenario_editor/play = ▶ Play
tools/scenario_editor/play_tooltip = Save and play the scenario in game (F5)
tools/scenario_editor/play_stopped = Test play stopped.
tools/scenario_editor/stop = ■ Stop
tools/scenario_editor/open_file = Open in external editor
tools/scenario_editor/open_file_tip = Open the file with the system default program
tools/scenario_editor/open_file_error = Cannot open the file
tools/scenario_editor/rescan = Rescan
tools/scenario_editor/rescan_tip = Re-read the scenario list and graph (after adding files)
tools/scenario_editor/rescanned = Scenario list refreshed.
tools/scenario_editor/search_files = Find file…
tools/scenario_editor/tab_files = Files
tools/scenario_editor/tab_info = Summary
tools/scenario_editor/tab_graph = Scene graph
tools/scenario_editor/graph_select_node = Select a scene in the graph to see its scenarios and transitions.
tools/scenario_editor/graph_node = mod {0} · scenarios {1}
tools/scenario_editor/graph_node_bg = background: {0}
tools/scenario_editor/graph_detail_mod = mod: {0}
tools/scenario_editor/graph_detail_scenarios = Scenarios ({0}), by priority:
tools/scenario_editor/info_scene = Scene
tools/scenario_editor/info_priority = Priority
tools/scenario_editor/info_condition = Condition
tools/scenario_editor/info_switch_targets = Scene switches
tools/scenario_editor/info_locale_keys = Locale keys
tools/scenario_editor/info_quest_refs = Quests
tools/scenario_editor/bg_previews = Scenario backgrounds
tools/scenario_editor/bg_missing = Background not found: {0}
tools/scenario_editor/no_backgrounds = The scenario does not change the background
tools/scenario_editor/save_error = Save error: {0}
tools/scenario_editor/readonly_zip = Files of zip mods are read-only
tools/scenario_editor/no_scene_error = The scenario header has no scene (scene:) — nothing to play.
tools/scenario_editor/unsaved_title = Unsaved changes
tools/scenario_editor/unsaved_switch_text = This tab has unsaved changes. Close it without saving?
tools/scene_builder/title = Scene Builder
tools/scene_builder/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/hint = Drag a node by its body or handles · click — pick the node under the cursor · Del — delete node · Ctrl+S — save and rebuild
tools/scene_builder/base_scene = Base scene the mod scene is shown over
tools/scene_builder/mod = Mod
tools/scene_builder/scene = Mod scene (.tscn)
tools/scene_builder/empty_scene = (empty base scene)
tools/scene_builder/select_mod = — pick a mod —
tools/scene_builder/select_scene = — pick a scene —
tools/scene_builder/save_tip = Save the .tscn and rebuild the preview (Ctrl+S)
tools/scene_builder/reload = Reload from disk
tools/scene_builder/reload_tip = Drop unsaved edits and re-read the scene file
tools/scene_builder/reloaded = Scene reloaded from disk.
tools/scene_builder/saved_ok = Saved.
tools/scene_builder/reloaded = Reloaded.
tools/scene_builder/inspector = Inspector
tools/scene_builder/saved_rebuilt = Saved, preview rebuilt.
tools/scene_builder/autosaved = Autosaved.
tools/scene_builder/error_save = Save error: the mod is read-only.
tools/scene_builder/error_no_scene = Pick a mod scene first.
tools/scene_builder/hierarchy = Hierarchy
tools/scene_builder/hierarchy_hint = Gold — mod scene root, bright — editable nodes, faint — base scene (read-only).
tools/scene_builder/node_type_tip = Type of the new node
tools/scene_builder/add_node_tip = Add a node of the chosen type into the selected node (or the scene root)
tools/scene_builder/delete_node_tip = Delete the selected mod scene node
tools/scene_builder/delete_node_title = Delete node
tools/scene_builder/delete_node_confirm = Delete the selected node with its children? The file is saved automatically.
tools/scene_builder/refresh_tip = Refresh the node tree
tools/scene_builder/tab_inspector = Inspector
tools/scene_builder/tab_tscn = .tscn source
tools/scene_builder/tscn_hint = Inspector and handle edits save themselves; hand edits of the text save with Ctrl+S, which also rebuilds the preview.
tools/scene_builder/inspector_cat_container = In container
tools/scene_builder/inspector_select = Select a node of the mod scene to edit its properties
tools/scene_builder/inspector_readonly = Read-only: node belongs to the base scene
tools/scene_builder/inspector_missing_section = Node section not found in tscn (nodes of instanced sub-scenes are not editable yet)
@@ -42,129 +148,190 @@ 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/hint = Click a collider — select · drag handles · in polygon mode a click on the background adds a vertex, RMB on a vertex deletes it · Del — delete object · Ctrl+S — save
tools/interactive_editor/items = Objects in file
tools/interactive_editor/add = + New
tools/interactive_editor/duplicate = Duplicate
tools/interactive_editor/delete = Delete object
tools/interactive_editor/delete_confirm = Remove the selected interactive from the file? The change reaches the file on save.
tools/interactive_editor/deleted = Object "{0}" removed.
tools/interactive_editor/background = Preview background
tools/interactive_editor/props = Properties
tools/interactive_editor/none_selected = Pick an object in the list or on the preview.
tools/interactive_editor/label_id = ID
tools/interactive_editor/label_texture_key = Texture
tools/interactive_editor/texture_placeholder = no sprite
tools/interactive_editor/texture_tip = Sprite texture ID; empty — an invisible clickable area
tools/interactive_editor/label_layer = Layer
tools/interactive_editor/label_z = Z order
tools/interactive_editor/label_cursor = Cursor
tools/interactive_editor/cursor_none = (default)
tools/interactive_editor/enabled = Enabled
tools/interactive_editor/visible = Visible
tools/interactive_editor/collider = Collider
tools/interactive_editor/label_collider_type = Shape
tools/interactive_editor/hit_rect = rectangle
tools/interactive_editor/hit_circle = circle
tools/interactive_editor/hit_polygon = polygon
tools/interactive_editor/label_x = X
tools/interactive_editor/label_y = Y
tools/interactive_editor/label_w = Width
tools/interactive_editor/label_h = Height
tools/interactive_editor/label_cx = Centre X
tools/interactive_editor/label_cy = Centre Y
tools/interactive_editor/label_r = Radius
tools/interactive_editor/polygon_hint = A click on the background adds a vertex, RMB on a vertex deletes it. At least 3 vertices.
tools/interactive_editor/polygon_too_small = Polygon "{0}" has fewer than 3 vertices.
tools/interactive_editor/highlight = Highlight
tools/interactive_editor/label_highlight_color = Colour
tools/interactive_editor/label_highlight_scale = Scale
tools/interactive_editor/actions = Actions
tools/interactive_editor/label_onclick_scenario = Scenario
tools/interactive_editor/label_onclick_commands = Commands on click (one per line)
tools/interactive_editor/label_hover_enter = Hover in
tools/interactive_editor/label_hover_leave = Hover out
tools/interactive_editor/id_empty = An object has an empty ID.
tools/interactive_editor/duplicate_id = Duplicate object ID:
tools/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/light_editor/title = Light Editor
tools/light_editor/hint = Double-click the background — new light · drag lights · Del — delete · Ctrl+S — write light_set into the scenario
tools/light_editor/scenario_tip = Scenario whose light_set line is edited (● — already sets lights)
tools/light_editor/pick_scenario = — pick a scenario —
tools/light_editor/save_tip = Write the light_set line into the scenario (Ctrl+S)
tools/light_editor/copy_line = Copy line
tools/light_editor/copy_line_tip = Copy the light_set command to the clipboard
tools/light_editor/copied = light_set line copied.
tools/light_editor/show_line = light_set
tools/light_editor/loaded = Lights of {0} loaded.
tools/light_editor/no_lights_yet = {0} has no light_set yet — add lights, the line is inserted after the header.
tools/light_editor/static_lights = Lights
tools/light_editor/add_light = + Light
tools/light_editor/delete = Delete
tools/light_editor/limit = At most {0} static lights.
tools/light_editor/selected_props = Selected light
tools/light_editor/none_selected = Pick a light in the list or on the preview.
tools/light_editor/x = X (0–1)
tools/light_editor/y = Y (0–1)
tools/light_editor/radius = Radius
tools/light_editor/radius_tip = Radius in pixels of the 1920 px game screen
tools/light_editor/influence = Strength
tools/light_editor/influence_tip = Light influence (0–1)
tools/light_editor/color = Colour
tools/light_editor/background = Preview background
tools/interactive_editor/tab_background = Background
tools/interactive_editor/tab_interactive = Interactive
tools/interactive_editor/tab_scenario = Scenarios
tools/catalog_export/hint = The archive and hashes are computed on export; the JSON is written next to the mod archive
tools/catalog_export/mod_info = Mod
tools/catalog_export/id = ID
tools/catalog_export/name = Name
tools/catalog_export/metadata = Catalog
tools/catalog_export/locales = Localizations
tools/catalog_export/locales_hint = With no rows the English name and description of the manifest are used.
tools/catalog_export/remove_locale = Remove language
tools/catalog_export/preview = Resulting JSON
tools/catalog_export/copied = JSON copied to the clipboard.
tools/catalog_export/export_tip = Build the archive, compute hashes and write the catalog entry JSON
tools/catalog_export/exported = Exported: {0}
tools/catalog_export/export_error = Export error
tools/catalog_export/url_required = Set the download URL — a catalog entry is useless without it.
tools/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).
tools/scene_editor/title = Scene editor
tools/scene_editor/hint = A scene is a place: backgrounds, ambient sound and scenarios. Ctrl+S saves · double-click a background in the list to add it
tools/scene_editor/save_tip = Save the scene file into the chosen mod (Ctrl+S); the game sees the change at once
tools/scene_editor/new_scene = + New scene
tools/scene_editor/new_scene_tip = Create a new place: name, mod, backgrounds. Scenarios can be added later; a scene may also have none
tools/scene_editor/delete = Delete
tools/scene_editor/delete_tip = Delete the chosen mod's scene file
tools/scene_editor/delete_confirm = Delete this scene's file from the chosen mod? The scene's scenarios stay.
tools/scene_editor/go = ▶ Go to scene
tools/scene_editor/go_tip = Switch the game to this scene
tools/scene_editor/rescan_tip = Re-read scenes, backgrounds and sounds (after adding files to mods)
tools/scene_editor/scenes = Scenes
tools/scene_editor/backgrounds = Scene backgrounds (one is picked at random on entry)
tools/scene_editor/all_backgrounds = All backgrounds
tools/scene_editor/add_background = + Add background to scene
tools/scene_editor/add_background_tip = Add the shown background to the scene (or double-click it in the list)
tools/scene_editor/remove_background = Remove the background from the scene
tools/scene_editor/properties = Properties
tools/scene_editor/name = Name
tools/scene_editor/name_tip = Scene id: latin letters, digits and _; switch_scene and scenario headers refer to it. Set only when creating
tools/scene_editor/mod = Save to
tools/scene_editor/mod_tip = Mod the scene file is written to (folder mods only)
tools/scene_editor/ambient = Sound
tools/scene_editor/ambient_tip = Ambient sound playing in the scene
tools/scene_editor/listen = ▶
tools/scene_editor/listen_tip = Listen to the ambient sound
tools/scene_editor/stop = ■
tools/scene_editor/default_scenario = Default scenario
tools/scene_editor/default_scenario_tip = Plays when no other scenario of the scene is left
tools/scene_editor/scenarios = Scenarios of the scene
tools/scene_editor/open_scenario = Open
tools/scene_editor/open_scenario_tip = Open the selected scenario in the scenario editor (or double-click)
tools/scene_editor/new_scenario = + New scenario
tools/scene_editor/new_scenario_tip = Create a scenario of this scene in the chosen mod and open it
tools/scene_editor/none = — none —
tools/scene_editor/effective = In game: {0} (from mod {1})
tools/scene_editor/adds_to = The scene is defined in: {0}. Saving creates a file in the chosen mod that adds to it.
tools/scene_editor/also_in = Also added to by: {0}
tools/scene_editor/no_writable_mod = No folder mod to save the scene to.
tools/scene_editor/bad_name = Set the scene name: latin letters, digits and _ (starting with a letter).
tools/scene_editor/name_taken = Scene "{0}" already exists; pick it in the list.
tools/scene_editor/need_background = Add at least one background to the scene.
tools/scene_editor/created = Scene "{0}" created.
tools/scene_editor/deleted = Deleted: {0}
tools/scene_editor/opened = Scene: {0}
tools/scene_editor/new_started = New scene: set the name and the mod, add backgrounds.
tools/scene_editor/already_there = Background {0} is already in the scene.
tools/scene_editor/go_failed = The game does not know scene "{0}"; save it first.
tools/scene_editor/went = Switched to scene {0}.
tools/scene_editor/rescanned = Scenes, backgrounds and sounds re-read.
tools/scene_editor/scenario_created = Scenario {0} created.
tools/scene_editor/no_scenarios = The scene has no scenarios yet
tools/scene_editor/no_backgrounds = The scene has no backgrounds: pick one in the list below and add it.
tools/scene_editor/pick_scene = Pick a scene on the left or create a new one.
tools/scene_editor/preview_hint = Pick a background to see it.
tools/scene_editor/not_in_scene = (not in the scene)
tools/scene_editor/new_line_text = A new scene. Replace this line with your text.
tools/entity_editor/hint = The list with a search on the left, the fields of the selected entry on the right · Ctrl+N new, Ctrl+D copy · Ctrl+Z/Y undo/redo · Ctrl+S save, the game sees the change at once
tools/entity_editor/section_list = Entries of the mod
tools/entity_editor/search = Search by name or id
tools/entity_editor/mod_tip = The mod whose entries are edited; new entries go into its file
tools/entity_editor/new = New
tools/entity_editor/new_tip = Add an entry to the chosen mod (Ctrl+N)
tools/entity_editor/duplicate = Copy
tools/entity_editor/duplicate_tip = Copy the selected entry under a new id (Ctrl+D)
tools/entity_editor/delete = Delete
tools/entity_editor/delete_tip = Remove the selected entry from the mod's file
tools/entity_editor/delete_title = Delete the entry?
tools/entity_editor/delete_text = The entry leaves the mod's file on save. References to it in scenarios and maps stay — check them.
tools/entity_editor/undo = Undo
tools/entity_editor/undo_tip = Undo the last change (Ctrl+Z)
tools/entity_editor/redo = Redo
tools/entity_editor/redo_tip = Redo what was undone (Ctrl+Y)
tools/entity_editor/no_entity = Pick an entry on the left or make one with "New".
tools/entity_editor/file = Mod {0} · file {1}
tools/entity_editor/id = Id
tools/entity_editor/id_tip = Unique id: scenarios, maps and other mods find the entry by it. A mod that depends on the owner and ships the same id replaces the entry
tools/entity_editor/locale_key_tip = Locale key; the texts below go into the mod's locale/*.properties
tools/entity_editor/text_in = text ({0})
tools/entity_editor/picture_pick_tip = Pick a picture from the mod folders
tools/entity_editor/picture_pick_title = Pictures of the “{0}” group
tools/entity_editor/section_other = Other fields
tools/entity_editor/other_hint = Fields the form does not show (other mods' data, loot…) as JSON. Edits apply as soon as the JSON is valid.
tools/entity_editor/other_invalid = Other fields: invalid JSON — {0}
tools/entity_editor/other_applied = Other fields applied
tools/entity_editor/opened = {0} entries in mod {1}
tools/entity_editor/added = Added entry {0} to {1}
tools/entity_editor/deleted = Entry {0} deleted (save to write it)
tools/entity_editor/saved = Saved: {0} file(s), {1} text(s) in mod {2}; the game re-read its data
tools/entity_editor/broken_files = Files with broken JSON were not read: {0}
tools/entity_editor/problem_id = An entry in file {0} has an empty id
tools/entity_editor/problem_duplicate = Id {0} occurs more than once
+337
View File
@@ -0,0 +1,337 @@
mod_editor_tools_name = Herramientas de edición
mod_editor_tools_desc = Ventanas de autor sobre el juego en marcha: editores de escenas, escenarios, mapa, interactivos y luces, y un constructor de escenas de Godot. Todo lo que guardan son archivos de mod normales que el juego aplica al instante.
tools/common/save = Guardar
tools/common/save_tip = Guardar el archivo (Ctrl+S)
tools/common/saved = Guardado: {0}
tools/common/loaded = Abierto: {0}
tools/common/load_error = Error al cargar
tools/common/readonly = No se puede guardar: el mod es de solo lectura (zip) o la ruta no es válida.
tools/common/no_writable_mods = No hay mods editables (solo se pueden editar los mods en carpeta).
tools/common/no_files = El mod «{0}» todavía no tiene archivos de este tipo: crea uno con «+».
tools/common/no_file_to_save = Primero elige o crea un archivo («+» junto a la lista de archivos).
tools/common/new_file = Nuevo archivo
tools/common/new_file_hint = El archivo se crea en {0}/{1}/ con la extensión {2} al guardarlo por primera vez.
tools/common/new_file_status = Nuevo archivo {0}: se crea al guardar.
tools/common/mod_tip = Mod (primero los mods que ya tienen archivos así; · — todavía ninguno)
tools/common/file_tip = Archivo dentro del mod
tools/common/search = Buscar…
tools/common/json = JSON
tools/common/json_tip = Mostrar el JSON del archivo resultante
tools/common/background_missing = Ningún mod aporta el fondo «{0}» de la escena «{1}»: elige uno a mano.
tools/map_editor/title = Editor de mapas
tools/map_editor/hint = Clic izq.: seleccionar / arrastrar · clic der.: borrar vértice · doble clic, Intro o Esc: terminar la región · Supr: borrar zona · Ctrl+S: guardar
tools/map_editor/reload_map = Actualizar el mapa del juego
tools/map_editor/reload_map_tip = Volver a leer los archivos del mapa guardados en el juego en marcha
tools/map_editor/reloaded = Mapa del juego actualizado.
tools/map_editor/select_mode = Seleccionar
tools/map_editor/draw_region = Dibujar
tools/map_editor/draw_started = Haz clic en el mapa para poner vértices. Doble clic, Intro o Esc terminan; el clic derecho quita el último.
tools/map_editor/background = Fondo de vista previa
tools/map_editor/zones = Zonas
tools/map_editor/add_region = + Región
tools/map_editor/add_region_tip = Nueva región: dibuja el polígono haciendo clic en el mapa
tools/map_editor/add_marker = + Marcador
tools/map_editor/add_label = + Rótulo
tools/map_editor/delete = Borrar zona
tools/map_editor/delete_tip = Borrar la zona seleccionada (Supr)
tools/map_editor/delete_confirm = ¿Borrar la zona seleccionada? El cambio llega al archivo al guardar.
tools/map_editor/deleted = Zona «{0}» borrada.
tools/map_editor/props = Propiedades de la zona
tools/map_editor/zone_missing = Elige una zona en la lista o en el mapa.
tools/map_editor/id = ID
tools/map_editor/id_empty = El ID de la zona no puede estar vacío.
tools/map_editor/duplicate_id = ID de zona ya usado:
tools/map_editor/title_key = Título
tools/map_editor/description = Descripción
tools/map_editor/no_translation = clave no encontrada en la localización
tools/map_editor/scene = Escena
tools/map_editor/scene_none = (ninguna)
tools/map_editor/availability = Disponibilidad
tools/map_editor/locked = Bloqueada para viajar
tools/map_editor/hidden = Oculta en el mapa
tools/map_editor/effective = En el juego:
tools/map_editor/effective_open = abierta
tools/map_editor/effective_locked = bloqueada (marca Locked)
tools/map_editor/effective_no_scene = bloqueada (sin escena)
tools/map_editor/effective_unknown_scene = bloqueada (escena no encontrada)
tools/map_editor/effective_hidden = oculta en el mapa
tools/map_editor/marker = Marcador
tools/map_editor/image = Icono
tools/map_editor/image_placeholder = ID de textura
tools/map_editor/icon_search = Buscar icono…
tools/map_editor/label_section = Rótulo
tools/map_editor/label = Rótulo de texto en el mapa
tools/map_editor/x = X (0–1)
tools/map_editor/y = Y (0–1)
tools/map_editor/vertices = Vértices del polígono
tools/map_editor/vertices_hint = Arrastra los vértices en el mapa; clic derecho en un vértice lo borra.
tools/map_editor/add_vertex = + Vértice
tools/map_editor/delete_vertex = Borrar vértice
tools/map_editor/preview = ▶ Como en el juego
tools/map_editor/preview_tip = Ver el mapa como en el juego: paso del ratón, tarjeta de zona, marcadores (incluidos los cambios sin guardar)
tools/map_editor/preview_on = Vista previa: Esc o ✕ vuelve al editor.
tools/map_editor/preview_off = Modo de edición.
tools/map_editor/preview_missing = No se puede cargar la escena del mapa (map_widget.tscn).
tools/scenario_editor/title = Editor de escenarios
tools/scenario_editor/hint = Ctrl+S: guardar · F5: jugar el escenario · clic en una escena del grafo: sus escenarios
tools/scenario_editor/play = ▶ Jugar
tools/scenario_editor/play_tooltip = Guardar y jugar el escenario en el juego (F5)
tools/scenario_editor/play_stopped = Prueba detenida.
tools/scenario_editor/stop = ■ Detener
tools/scenario_editor/open_file = Abrir en un editor externo
tools/scenario_editor/open_file_tip = Abrir el archivo con el programa predeterminado del sistema
tools/scenario_editor/open_file_error = No se puede abrir el archivo
tools/scenario_editor/rescan = Volver a escanear
tools/scenario_editor/rescan_tip = Volver a leer la lista de escenarios y el grafo (tras añadir archivos)
tools/scenario_editor/rescanned = Lista de escenarios actualizada.
tools/scenario_editor/search_files = Buscar archivo…
tools/scenario_editor/tab_files = Archivos
tools/scenario_editor/tab_info = Resumen
tools/scenario_editor/tab_graph = Grafo de escenas
tools/scenario_editor/graph_select_node = Selecciona una escena en el grafo para ver sus escenarios y transiciones.
tools/scenario_editor/graph_node = mod {0} · escenarios {1}
tools/scenario_editor/graph_node_bg = fondo: {0}
tools/scenario_editor/graph_detail_mod = mod: {0}
tools/scenario_editor/graph_detail_scenarios = Escenarios ({0}), por prioridad:
tools/scenario_editor/info_scene = Escena
tools/scenario_editor/info_priority = Prioridad
tools/scenario_editor/info_condition = Condición
tools/scenario_editor/info_switch_targets = Cambios de escena
tools/scenario_editor/info_locale_keys = Claves de localización
tools/scenario_editor/info_quest_refs = Misiones
tools/scenario_editor/bg_previews = Fondos del escenario
tools/scenario_editor/bg_missing = Fondo no encontrado: {0}
tools/scenario_editor/no_backgrounds = El escenario no cambia el fondo
tools/scenario_editor/save_error = Error al guardar: {0}
tools/scenario_editor/readonly_zip = Los archivos de los mods zip son de solo lectura
tools/scenario_editor/no_scene_error = La cabecera del escenario no tiene escena (scene:): no hay nada que jugar.
tools/scenario_editor/unsaved_title = Cambios sin guardar
tools/scenario_editor/unsaved_switch_text = Esta pestaña tiene cambios sin guardar. ¿Cerrarla sin guardar?
tools/scene_builder/title = Constructor de escenas
tools/scene_builder/hint = Arrastra un nodo por su cuerpo o sus tiradores · clic: elegir el nodo bajo el cursor · Supr: borrar nodo · Ctrl+S: guardar y reconstruir
tools/scene_builder/base_scene = Escena base sobre la que se muestra la escena del mod
tools/scene_builder/mod = Mod
tools/scene_builder/scene = Escena del mod (.tscn)
tools/scene_builder/empty_scene = (escena base vacía)
tools/scene_builder/select_mod = — elige un mod —
tools/scene_builder/select_scene = — elige una escena —
tools/scene_builder/save_tip = Guardar el .tscn y reconstruir la vista previa (Ctrl+S)
tools/scene_builder/reload = Recargar desde el disco
tools/scene_builder/reload_tip = Descartar los cambios sin guardar y volver a leer el archivo de la escena
tools/scene_builder/reloaded = Escena recargada desde el disco.
tools/scene_builder/saved_ok = Guardado.
tools/scene_builder/saved_rebuilt = Guardado, vista previa reconstruida.
tools/scene_builder/autosaved = Guardado automático.
tools/scene_builder/error_save = Error al guardar: el mod es de solo lectura.
tools/scene_builder/error_no_scene = Primero elige una escena del mod.
tools/scene_builder/hierarchy = Jerarquía
tools/scene_builder/hierarchy_hint = Dorado: raíz de la escena del mod; brillante: nodos editables; tenue: escena base (solo lectura).
tools/scene_builder/node_type_tip = Tipo del nuevo nodo
tools/scene_builder/add_node_tip = Añadir un nodo del tipo elegido dentro del nodo seleccionado (o en la raíz de la escena)
tools/scene_builder/delete_node_tip = Borrar el nodo seleccionado de la escena del mod
tools/scene_builder/delete_node_title = Borrar nodo
tools/scene_builder/delete_node_confirm = ¿Borrar el nodo seleccionado con sus hijos? El archivo se guarda automáticamente.
tools/scene_builder/refresh_tip = Actualizar el árbol de nodos
tools/scene_builder/tab_inspector = Inspector
tools/scene_builder/tab_tscn = Código .tscn
tools/scene_builder/tscn_hint = Los cambios del inspector y de los tiradores se guardan solos; los cambios a mano en el texto se guardan con Ctrl+S, que también reconstruye la vista previa.
tools/scene_builder/inspector_cat_container = En contenedor
tools/scene_builder/inspector_select = Selecciona un nodo de la escena del mod para editar sus propiedades
tools/scene_builder/inspector_readonly = Solo lectura: el nodo pertenece a la escena base
tools/scene_builder/inspector_missing_section = No se encontró la sección del nodo en el tscn (los nodos de subescenas instanciadas todavía no se pueden editar)
tools/scene_builder/inspector_preset = Preajuste de anclas
tools/scene_builder/inspector_cat_layout = Disposición
tools/scene_builder/inspector_cat_rect = Rectángulo
tools/scene_builder/inspector_cat_visibility = Visibilidad
tools/scene_builder/inspector_cat_misc = Varios
tools/scene_builder/inspector_bad_value = El valor no se puede escribir en el tscn.
tools/scene_builder/inspector_in_file_tooltip = Guardado en el archivo tscn
tools/scene_builder/inspector_container_banner = Este nodo lo gestiona un contenedor: el padre recalcula las anclas y los desplazamientos, así que aquí no tendrán efecto.
tools/scene_builder/err_add_failed = Error: no se puede crear un nodo de este tipo.
tools/scene_builder/err_add_parent_missing = Error: no se encontró la sección del padre en el tscn (no se admiten padres de subescenas instanciadas).
tools/scene_builder/err_del_root = Error: no se puede borrar la raíz de la escena.
tools/scene_builder/err_del_none = Error: elige un nodo del mod para borrarlo.
tools/interactive_editor/title = Editor de interactivos
tools/interactive_editor/hint = Clic en un colisionador: seleccionar · arrastra los tiradores · en modo polígono, un clic en el fondo añade un vértice y el clic derecho en un vértice lo borra · Supr: borrar objeto · Ctrl+S: guardar
tools/interactive_editor/items = Objetos del archivo
tools/interactive_editor/add = + Nuevo
tools/interactive_editor/duplicate = Duplicar
tools/interactive_editor/delete = Borrar objeto
tools/interactive_editor/delete_confirm = ¿Quitar del archivo el interactivo seleccionado? El cambio llega al archivo al guardar.
tools/interactive_editor/deleted = Objeto «{0}» quitado.
tools/interactive_editor/background = Fondo de vista previa
tools/interactive_editor/props = Propiedades
tools/interactive_editor/none_selected = Elige un objeto en la lista o en la vista previa.
tools/interactive_editor/label_id = ID
tools/interactive_editor/label_texture_key = Textura
tools/interactive_editor/texture_placeholder = sin sprite
tools/interactive_editor/texture_tip = ID de la textura del sprite; vacío: un área clicable invisible
tools/interactive_editor/label_layer = Capa
tools/interactive_editor/label_z = Orden Z
tools/interactive_editor/label_cursor = Cursor
tools/interactive_editor/cursor_none = (por defecto)
tools/interactive_editor/enabled = Activado
tools/interactive_editor/visible = Visible
tools/interactive_editor/collider = Colisionador
tools/interactive_editor/label_collider_type = Forma
tools/interactive_editor/hit_rect = rectángulo
tools/interactive_editor/hit_circle = círculo
tools/interactive_editor/hit_polygon = polígono
tools/interactive_editor/label_x = X
tools/interactive_editor/label_y = Y
tools/interactive_editor/label_w = Ancho
tools/interactive_editor/label_h = Alto
tools/interactive_editor/label_cx = Centro X
tools/interactive_editor/label_cy = Centro Y
tools/interactive_editor/label_r = Radio
tools/interactive_editor/polygon_hint = Un clic en el fondo añade un vértice; el clic derecho en un vértice lo borra. Al menos 3 vértices.
tools/interactive_editor/polygon_too_small = El polígono «{0}» tiene menos de 3 vértices.
tools/interactive_editor/highlight = Resaltado
tools/interactive_editor/label_highlight_color = Color
tools/interactive_editor/label_highlight_scale = Escala
tools/interactive_editor/actions = Acciones
tools/interactive_editor/label_onclick_scenario = Escenario
tools/interactive_editor/label_onclick_commands = Comandos al hacer clic (uno por línea)
tools/interactive_editor/label_hover_enter = Al entrar el ratón
tools/interactive_editor/label_hover_leave = Al salir el ratón
tools/interactive_editor/id_empty = Un objeto tiene el ID vacío.
tools/interactive_editor/duplicate_id = ID de objeto duplicado:
tools/light_editor/title = Editor de luces
tools/light_editor/hint = Doble clic en el fondo: nueva luz · arrastra las luces · Supr: borrar · Ctrl+S: escribir light_set en el escenario
tools/light_editor/scenario_tip = Escenario cuya línea light_set se edita (●: ya define luces)
tools/light_editor/pick_scenario = — elige un escenario —
tools/light_editor/save_tip = Escribir la línea light_set en el escenario (Ctrl+S)
tools/light_editor/copy_line = Copiar línea
tools/light_editor/copy_line_tip = Copiar el comando light_set al portapapeles
tools/light_editor/copied = Línea light_set copiada.
tools/light_editor/show_line = light_set
tools/light_editor/loaded = Luces de {0} cargadas.
tools/light_editor/no_lights_yet = {0} todavía no tiene light_set: añade luces y la línea se insertará tras la cabecera.
tools/light_editor/static_lights = Luces
tools/light_editor/add_light = + Luz
tools/light_editor/delete = Borrar
tools/light_editor/limit = Como máximo {0} luces estáticas.
tools/light_editor/selected_props = Luz seleccionada
tools/light_editor/none_selected = Elige una luz en la lista o en la vista previa.
tools/light_editor/x = X (0–1)
tools/light_editor/y = Y (0–1)
tools/light_editor/radius = Radio
tools/light_editor/radius_tip = Radio en píxeles de la pantalla de juego de 1920 px
tools/light_editor/influence = Intensidad
tools/light_editor/influence_tip = Influencia de la luz (0–1)
tools/light_editor/color = Color
tools/light_editor/background = Fondo de vista previa
tools/catalog_export/hint = El archivo y los hashes se calculan al exportar; el JSON se escribe junto al archivo del mod
tools/catalog_export/mod_info = Mod
tools/catalog_export/id = ID
tools/catalog_export/name = Nombre
tools/catalog_export/metadata = Catálogo
tools/catalog_export/locales = Localizaciones
tools/catalog_export/locales_hint = Sin filas se usan el nombre y la descripción en inglés del manifiesto.
tools/catalog_export/remove_locale = Quitar idioma
tools/catalog_export/preview = JSON resultante
tools/catalog_export/copied = JSON copiado al portapapeles.
tools/catalog_export/export_tip = Crear el archivo, calcular los hashes y escribir el JSON de la entrada del catálogo
tools/catalog_export/exported = Exportado: {0}
tools/catalog_export/export_error = Error al exportar
tools/catalog_export/url_required = Indica la URL de descarga: sin ella la entrada del catálogo no sirve.
tools/scene_editor/title = Editor de escenas
tools/scene_editor/hint = Una escena es un lugar: fondos, sonido ambiente y escenarios. Ctrl+S guarda · doble clic en un fondo de la lista para añadirlo
tools/scene_editor/save_tip = Guardar el archivo de la escena en el mod elegido (Ctrl+S); el juego ve el cambio al momento
tools/scene_editor/new_scene = + Nueva escena
tools/scene_editor/new_scene_tip = Crear un lugar nuevo: nombre, mod, fondos. Los escenarios se pueden añadir después; una escena también puede no tener ninguno
tools/scene_editor/delete = Borrar
tools/scene_editor/delete_tip = Borrar el archivo de la escena del mod elegido
tools/scene_editor/delete_confirm = ¿Borrar el archivo de esta escena del mod elegido? Los escenarios de la escena se conservan.
tools/scene_editor/go = ▶ Ir a la escena
tools/scene_editor/go_tip = Cambiar el juego a esta escena
tools/scene_editor/rescan_tip = Volver a leer escenas, fondos y sonidos (tras añadir archivos a los mods)
tools/scene_editor/scenes = Escenas
tools/scene_editor/backgrounds = Fondos de la escena (al entrar se elige uno al azar)
tools/scene_editor/all_backgrounds = Todos los fondos
tools/scene_editor/add_background = + Añadir fondo a la escena
tools/scene_editor/add_background_tip = Añadir a la escena el fondo mostrado (o haz doble clic en él en la lista)
tools/scene_editor/remove_background = Quitar el fondo de la escena
tools/scene_editor/properties = Propiedades
tools/scene_editor/name = Nombre
tools/scene_editor/name_tip = Id de la escena: letras latinas, dígitos y _; lo usan switch_scene y las cabeceras de los escenarios. Solo se indica al crearla
tools/scene_editor/mod = Guardar en
tools/scene_editor/mod_tip = Mod en el que se escribe el archivo de la escena (solo mods en carpeta)
tools/scene_editor/ambient = Sonido
tools/scene_editor/ambient_tip = Sonido ambiente que suena en la escena
tools/scene_editor/listen = ▶
tools/scene_editor/listen_tip = Escuchar el sonido ambiente
tools/scene_editor/stop = ■
tools/scene_editor/default_scenario = Escenario por defecto
tools/scene_editor/default_scenario_tip = Se reproduce cuando no queda ningún otro escenario de la escena
tools/scene_editor/scenarios = Escenarios de la escena
tools/scene_editor/open_scenario = Abrir
tools/scene_editor/open_scenario_tip = Abrir el escenario seleccionado en el editor de escenarios (o doble clic)
tools/scene_editor/new_scenario = + Nuevo escenario
tools/scene_editor/new_scenario_tip = Crear un escenario de esta escena en el mod elegido y abrirlo
tools/scene_editor/none = — ninguno —
tools/scene_editor/effective = En el juego: {0} (del mod {1})
tools/scene_editor/adds_to = La escena está definida en: {0}. Al guardar se crea un archivo en el mod elegido que la amplía.
tools/scene_editor/also_in = También la amplían: {0}
tools/scene_editor/no_writable_mod = No hay ningún mod en carpeta donde guardar la escena.
tools/scene_editor/bad_name = Indica el nombre de la escena: letras latinas, dígitos y _ (empezando por una letra).
tools/scene_editor/name_taken = La escena «{0}» ya existe; elígela en la lista.
tools/scene_editor/need_background = Añade al menos un fondo a la escena.
tools/scene_editor/created = Escena «{0}» creada.
tools/scene_editor/deleted = Borrado: {0}
tools/scene_editor/opened = Escena: {0}
tools/scene_editor/new_started = Nueva escena: indica el nombre y el mod, añade fondos.
tools/scene_editor/already_there = El fondo {0} ya está en la escena.
tools/scene_editor/go_failed = El juego no conoce la escena «{0}»; guárdala primero.
tools/scene_editor/went = Cambiado a la escena {0}.
tools/scene_editor/rescanned = Escenas, fondos y sonidos releídos.
tools/scene_editor/scenario_created = Escenario {0} creado.
tools/scene_editor/no_scenarios = La escena todavía no tiene escenarios
tools/scene_editor/no_backgrounds = La escena no tiene fondos: elige uno en la lista de abajo y añádelo.
tools/scene_editor/pick_scene = Elige una escena a la izquierda o crea una nueva.
tools/scene_editor/preview_hint = Elige un fondo para verlo.
tools/scene_editor/not_in_scene = (no está en la escena)
tools/scene_editor/new_line_text = Una escena nueva. Sustituye esta línea por tu texto.
tools/entity_editor/hint = A la izquierda, la lista con búsqueda; a la derecha, los campos de la entrada elegida · Ctrl+N: nueva, Ctrl+D: copia · Ctrl+Z/Y: deshacer/rehacer · Ctrl+S: guardar, el juego ve el cambio al momento
tools/entity_editor/section_list = Entradas del mod
tools/entity_editor/search = Buscar por nombre o id
tools/entity_editor/mod_tip = El mod cuyas entradas se editan; las nuevas entradas van a su archivo
tools/entity_editor/new = Nueva
tools/entity_editor/new_tip = Añadir una entrada al mod elegido (Ctrl+N)
tools/entity_editor/duplicate = Copia
tools/entity_editor/duplicate_tip = Copiar la entrada seleccionada con un id nuevo (Ctrl+D)
tools/entity_editor/delete = Borrar
tools/entity_editor/delete_tip = Quitar la entrada seleccionada del archivo del mod
tools/entity_editor/delete_title = ¿Borrar la entrada?
tools/entity_editor/delete_text = La entrada sale del archivo del mod al guardar. Las referencias a ella en escenarios y mapas se quedan: revísalas.
tools/entity_editor/undo = Deshacer
tools/entity_editor/undo_tip = Deshacer el último cambio (Ctrl+Z)
tools/entity_editor/redo = Rehacer
tools/entity_editor/redo_tip = Rehacer lo deshecho (Ctrl+Y)
tools/entity_editor/no_entity = Elige una entrada a la izquierda o crea una con «Nueva».
tools/entity_editor/file = Mod {0} · archivo {1}
tools/entity_editor/id = Id
tools/entity_editor/id_tip = Id único: los escenarios, los mapas y otros mods encuentran la entrada por él. Un mod que depende del propietario y trae el mismo id sustituye la entrada
tools/entity_editor/locale_key_tip = Clave de localización; los textos de abajo van a locale/*.properties del mod
tools/entity_editor/text_in = texto ({0})
tools/entity_editor/picture_pick_tip = Elegir una imagen de las carpetas del mod
tools/entity_editor/picture_pick_title = Imágenes del grupo «{0}»
tools/entity_editor/section_other = Otros campos
tools/entity_editor/other_hint = Campos que el formulario no muestra (datos de otros mods, botín…) como JSON. Los cambios se aplican en cuanto el JSON es válido.
tools/entity_editor/other_invalid = Otros campos: JSON no válido — {0}
tools/entity_editor/other_applied = Otros campos aplicados
tools/entity_editor/opened = {0} entradas en el mod {1}
tools/entity_editor/added = Entrada {0} añadida a {1}
tools/entity_editor/deleted = Entrada {0} borrada (guarda para escribirlo)
tools/entity_editor/saved = Guardado: {0} archivo(s), {1} texto(s) en el mod {2}; el juego ha releído sus datos
tools/entity_editor/broken_files = No se leyeron los archivos con JSON roto: {0}
tools/entity_editor/problem_id = Una entrada del archivo {0} tiene el id vacío
tools/entity_editor/problem_duplicate = El id {0} aparece más de una vez
+337
View File
@@ -0,0 +1,337 @@
mod_editor_tools_name = エディターツール
mod_editor_tools_desc = 実行中のゲームの上に開くオーサリング用ウィンドウ:シーン、シナリオ、マップ、インタラクティブ、ライトの各エディターと Godot シーンビルダー。保存内容は普通の MOD ファイルで、ゲームはすぐに反映します。
tools/common/save = 保存
tools/common/save_tip = ファイルを保存(Ctrl+S)
tools/common/saved = 保存しました:{0}
tools/common/loaded = 開きました:{0}
tools/common/load_error = 読み込みエラー
tools/common/readonly = 保存できません:MOD が読み取り専用(zip)か、パスが不正です。
tools/common/no_writable_mods = 書き込める MOD がありません(編集できるのはフォルダー形式の MOD だけです)。
tools/common/no_files = MOD「{0}」にはまだこの種類のファイルがありません——「+」で作成してください。
tools/common/no_file_to_save = 先にファイルを選ぶか作成してください(ファイル一覧の横の「+」)。
tools/common/new_file = 新しいファイル
tools/common/new_file_hint = 最初に保存したとき、{0}/{1}/ に拡張子 {2} のファイルが作られます。
tools/common/new_file_status = 新しいファイル {0}——保存時に作成されます。
tools/common/mod_tip = MOD(この種類のファイルがある MOD が先頭;·——まだなし)
tools/common/file_tip = MOD 内のファイル
tools/common/search = 検索…
tools/common/json = JSON
tools/common/json_tip = できあがるファイルの JSON を表示
tools/common/background_missing = シーン「{1}」の背景「{0}」はどの MOD にもありません——手で選んでください。
tools/map_editor/title = マップエディター
tools/map_editor/hint = 左クリック——選択/ドラッグ · 右クリック——頂点を削除 · ダブルクリック、Enter、Esc——領域を確定 · Del——ゾーンを削除 · Ctrl+S——保存
tools/map_editor/reload_map = ゲーム内のマップを更新
tools/map_editor/reload_map_tip = 実行中のゲームで、保存済みのマップファイルを読み直す
tools/map_editor/reloaded = ゲーム内のマップを更新しました。
tools/map_editor/select_mode = 選択
tools/map_editor/draw_region = 描画
tools/map_editor/draw_started = マップをクリックして頂点を置きます。ダブルクリック、Enter、Esc で確定、右クリックで最後の頂点を取り消します。
tools/map_editor/background = プレビューの背景
tools/map_editor/zones = ゾーン
tools/map_editor/add_region = + 領域
tools/map_editor/add_region_tip = 新しい領域:マップをクリックして多角形を描く
tools/map_editor/add_marker = + マーカー
tools/map_editor/add_label = + ラベル
tools/map_editor/delete = ゾーンを削除
tools/map_editor/delete_tip = 選択したゾーンを削除(Del)
tools/map_editor/delete_confirm = 選択したゾーンを削除しますか? 変更は保存時にファイルへ反映されます。
tools/map_editor/deleted = ゾーン「{0}」を削除しました。
tools/map_editor/props = ゾーンのプロパティ
tools/map_editor/zone_missing = 一覧かマップでゾーンを選んでください。
tools/map_editor/id = ID
tools/map_editor/id_empty = ゾーンの ID は空にできません。
tools/map_editor/duplicate_id = ゾーンの ID はすでに使われています:
tools/map_editor/title_key = タイトル
tools/map_editor/description = 説明
tools/map_editor/no_translation = ローカライズにキーがありません
tools/map_editor/scene = シーン
tools/map_editor/scene_none = (なし)
tools/map_editor/availability = 利用可否
tools/map_editor/locked = 移動不可
tools/map_editor/hidden = マップに表示しない
tools/map_editor/effective = ゲーム内:
tools/map_editor/effective_open = 開いている
tools/map_editor/effective_locked = 閉鎖(Locked フラグ)
tools/map_editor/effective_no_scene = 閉鎖(シーンなし)
tools/map_editor/effective_unknown_scene = 閉鎖(シーンが見つからない)
tools/map_editor/effective_hidden = マップに表示しない
tools/map_editor/marker = マーカー
tools/map_editor/image = アイコン
tools/map_editor/image_placeholder = テクスチャ ID
tools/map_editor/icon_search = アイコンを探す…
tools/map_editor/label_section = ラベル
tools/map_editor/label = マップ上のテキストラベル
tools/map_editor/x = X(0–1)
tools/map_editor/y = Y(0–1)
tools/map_editor/vertices = 多角形の頂点
tools/map_editor/vertices_hint = マップ上で頂点をドラッグ。頂点を右クリックすると削除。
tools/map_editor/add_vertex = + 頂点
tools/map_editor/delete_vertex = 頂点を削除
tools/map_editor/preview = ▶ ゲームと同じ表示
tools/map_editor/preview_tip = ゲームと同じようにマップを見る:ホバー、ゾーンカード、マーカー(未保存の編集も含む)
tools/map_editor/preview_on = プレビュー:Esc か ✕ でエディターに戻ります。
tools/map_editor/preview_off = 編集モード。
tools/map_editor/preview_missing = マップのシーン(map_widget.tscn)を読み込めません。
tools/scenario_editor/title = シナリオエディター
tools/scenario_editor/hint = Ctrl+S——保存 · F5——シナリオをプレイ · グラフのシーンをクリック——そのシナリオ
tools/scenario_editor/play = ▶ プレイ
tools/scenario_editor/play_tooltip = 保存してゲーム内でシナリオをプレイ(F5)
tools/scenario_editor/play_stopped = テストプレイを止めました。
tools/scenario_editor/stop = ■ 停止
tools/scenario_editor/open_file = 外部エディターで開く
tools/scenario_editor/open_file_tip = システムの既定のプログラムでファイルを開く
tools/scenario_editor/open_file_error = ファイルを開けません
tools/scenario_editor/rescan = 再スキャン
tools/scenario_editor/rescan_tip = シナリオ一覧とグラフを読み直す(ファイルを追加した後)
tools/scenario_editor/rescanned = シナリオ一覧を更新しました。
tools/scenario_editor/search_files = ファイルを探す…
tools/scenario_editor/tab_files = ファイル
tools/scenario_editor/tab_info = 概要
tools/scenario_editor/tab_graph = シーングラフ
tools/scenario_editor/graph_select_node = グラフでシーンを選ぶと、そのシナリオと遷移が表示されます。
tools/scenario_editor/graph_node = MOD {0} · シナリオ {1}
tools/scenario_editor/graph_node_bg = 背景:{0}
tools/scenario_editor/graph_detail_mod = MOD:{0}
tools/scenario_editor/graph_detail_scenarios = シナリオ({0})、優先度順:
tools/scenario_editor/info_scene = シーン
tools/scenario_editor/info_priority = 優先度
tools/scenario_editor/info_condition = 条件
tools/scenario_editor/info_switch_targets = シーン切り替え
tools/scenario_editor/info_locale_keys = ローカライズキー
tools/scenario_editor/info_quest_refs = クエスト
tools/scenario_editor/bg_previews = シナリオの背景
tools/scenario_editor/bg_missing = 背景が見つかりません:{0}
tools/scenario_editor/no_backgrounds = このシナリオは背景を変えません
tools/scenario_editor/save_error = 保存エラー:{0}
tools/scenario_editor/readonly_zip = zip の MOD のファイルは読み取り専用です
tools/scenario_editor/no_scene_error = シナリオのヘッダーにシーン(scene:)がありません——プレイするものがありません。
tools/scenario_editor/unsaved_title = 保存していない変更
tools/scenario_editor/unsaved_switch_text = このタブには保存していない変更があります。保存せずに閉じますか?
tools/scene_builder/title = シーンビルダー
tools/scene_builder/hint = ノードは本体かハンドルでドラッグ · クリック——カーソル下のノードを選択 · Del——ノードを削除 · Ctrl+S——保存して再構築
tools/scene_builder/base_scene = MOD のシーンを重ねる基本シーン
tools/scene_builder/mod = MOD
tools/scene_builder/scene = MOD のシーン(.tscn)
tools/scene_builder/empty_scene = (空の基本シーン)
tools/scene_builder/select_mod = — MOD を選択 —
tools/scene_builder/select_scene = — シーンを選択 —
tools/scene_builder/save_tip = .tscn を保存してプレビューを再構築(Ctrl+S)
tools/scene_builder/reload = ディスクから読み直す
tools/scene_builder/reload_tip = 保存していない編集を捨てて、シーンファイルを読み直す
tools/scene_builder/reloaded = ディスクからシーンを読み直しました。
tools/scene_builder/saved_ok = 保存しました。
tools/scene_builder/saved_rebuilt = 保存し、プレビューを再構築しました。
tools/scene_builder/autosaved = 自動保存しました。
tools/scene_builder/error_save = 保存エラー:MOD は読み取り専用です。
tools/scene_builder/error_no_scene = 先に MOD のシーンを選んでください。
tools/scene_builder/hierarchy = 階層
tools/scene_builder/hierarchy_hint = 金——MOD シーンのルート、明るい——編集できるノード、薄い——基本シーン(読み取り専用)。
tools/scene_builder/node_type_tip = 新しいノードの型
tools/scene_builder/add_node_tip = 選択したノード(またはシーンのルート)の中に、選んだ型のノードを追加
tools/scene_builder/delete_node_tip = 選択した MOD シーンのノードを削除
tools/scene_builder/delete_node_title = ノードを削除
tools/scene_builder/delete_node_confirm = 選択したノードを子ごと削除しますか? ファイルは自動で保存されます。
tools/scene_builder/refresh_tip = ノードツリーを更新
tools/scene_builder/tab_inspector = インスペクター
tools/scene_builder/tab_tscn = .tscn ソース
tools/scene_builder/tscn_hint = インスペクターとハンドルの編集は自動で保存されます。テキストを手で編集したときは Ctrl+S で保存し、同時にプレビューも再構築されます。
tools/scene_builder/inspector_cat_container = コンテナー内
tools/scene_builder/inspector_select = MOD シーンのノードを選ぶとプロパティを編集できます
tools/scene_builder/inspector_readonly = 読み取り専用:基本シーンのノードです
tools/scene_builder/inspector_missing_section = tscn にノードのセクションがありません(インスタンス化したサブシーンのノードはまだ編集できません)
tools/scene_builder/inspector_preset = アンカーのプリセット
tools/scene_builder/inspector_cat_layout = レイアウト
tools/scene_builder/inspector_cat_rect = 矩形
tools/scene_builder/inspector_cat_visibility = 表示
tools/scene_builder/inspector_cat_misc = その他
tools/scene_builder/inspector_bad_value = この値は tscn に書き込めません。
tools/scene_builder/inspector_in_file_tooltip = tscn ファイルに保存されています
tools/scene_builder/inspector_container_banner = このノードはコンテナーが管理しています:アンカーとオフセットは親が計算し直すので、ここでは効果がありません。
tools/scene_builder/err_add_failed = エラー:この型のノードを作成できません。
tools/scene_builder/err_add_parent_missing = エラー:tscn に親のセクションがありません(インスタンス化したサブシーンの親には対応していません)。
tools/scene_builder/err_del_root = エラー:シーンのルートは削除できません。
tools/scene_builder/err_del_none = エラー:削除する MOD のノードを選んでください。
tools/interactive_editor/title = インタラクティブエディター
tools/interactive_editor/hint = コライダーをクリック——選択 · ハンドルをドラッグ · 多角形モードでは背景のクリックで頂点を追加、頂点の右クリックで削除 · Del——オブジェクトを削除 · Ctrl+S——保存
tools/interactive_editor/items = ファイル内のオブジェクト
tools/interactive_editor/add = + 新規
tools/interactive_editor/duplicate = 複製
tools/interactive_editor/delete = オブジェクトを削除
tools/interactive_editor/delete_confirm = 選択したインタラクティブをファイルから取り除きますか? 変更は保存時にファイルへ反映されます。
tools/interactive_editor/deleted = オブジェクト「{0}」を取り除きました。
tools/interactive_editor/background = プレビューの背景
tools/interactive_editor/props = プロパティ
tools/interactive_editor/none_selected = 一覧かプレビューでオブジェクトを選んでください。
tools/interactive_editor/label_id = ID
tools/interactive_editor/label_texture_key = テクスチャ
tools/interactive_editor/texture_placeholder = スプライトなし
tools/interactive_editor/texture_tip = スプライトのテクスチャ ID。空——見えないクリック領域
tools/interactive_editor/label_layer = レイヤー
tools/interactive_editor/label_z = Z 順
tools/interactive_editor/label_cursor = カーソル
tools/interactive_editor/cursor_none = (既定)
tools/interactive_editor/enabled = 有効
tools/interactive_editor/visible = 表示
tools/interactive_editor/collider = コライダー
tools/interactive_editor/label_collider_type = 形
tools/interactive_editor/hit_rect = 矩形
tools/interactive_editor/hit_circle = 円
tools/interactive_editor/hit_polygon = 多角形
tools/interactive_editor/label_x = X
tools/interactive_editor/label_y = Y
tools/interactive_editor/label_w = 幅
tools/interactive_editor/label_h = 高さ
tools/interactive_editor/label_cx = 中心 X
tools/interactive_editor/label_cy = 中心 Y
tools/interactive_editor/label_r = 半径
tools/interactive_editor/polygon_hint = 背景をクリックすると頂点を追加、頂点を右クリックすると削除。頂点は 3 つ以上。
tools/interactive_editor/polygon_too_small = 多角形「{0}」の頂点が 3 つ未満です。
tools/interactive_editor/highlight = ハイライト
tools/interactive_editor/label_highlight_color = 色
tools/interactive_editor/label_highlight_scale = 拡大率
tools/interactive_editor/actions = アクション
tools/interactive_editor/label_onclick_scenario = シナリオ
tools/interactive_editor/label_onclick_commands = クリック時のコマンド(1 行に 1 つ)
tools/interactive_editor/label_hover_enter = カーソルが入ったとき
tools/interactive_editor/label_hover_leave = カーソルが出たとき
tools/interactive_editor/id_empty = ID が空のオブジェクトがあります。
tools/interactive_editor/duplicate_id = オブジェクト ID が重複しています:
tools/light_editor/title = ライトエディター
tools/light_editor/hint = 背景をダブルクリック——新しいライト · ライトをドラッグ · Del——削除 · Ctrl+S——シナリオに light_set を書き込む
tools/light_editor/scenario_tip = light_set の行を編集するシナリオ(●——すでにライトを設定済み)
tools/light_editor/pick_scenario = — シナリオを選択 —
tools/light_editor/save_tip = シナリオに light_set の行を書き込む(Ctrl+S)
tools/light_editor/copy_line = 行をコピー
tools/light_editor/copy_line_tip = light_set コマンドをクリップボードにコピー
tools/light_editor/copied = light_set の行をコピーしました。
tools/light_editor/show_line = light_set
tools/light_editor/loaded = {0} のライトを読み込みました。
tools/light_editor/no_lights_yet = {0} にはまだ light_set がありません——ライトを追加すると、ヘッダーの後に行が入ります。
tools/light_editor/static_lights = ライト
tools/light_editor/add_light = + ライト
tools/light_editor/delete = 削除
tools/light_editor/limit = 静的ライトは最大 {0} 個です。
tools/light_editor/selected_props = 選択したライト
tools/light_editor/none_selected = 一覧かプレビューでライトを選んでください。
tools/light_editor/x = X(0–1)
tools/light_editor/y = Y(0–1)
tools/light_editor/radius = 半径
tools/light_editor/radius_tip = 幅 1920 px のゲーム画面でのピクセル半径
tools/light_editor/influence = 強さ
tools/light_editor/influence_tip = ライトの影響(0–1)
tools/light_editor/color = 色
tools/light_editor/background = プレビューの背景
tools/catalog_export/hint = アーカイブとハッシュは書き出し時に計算され、JSON は MOD のアーカイブの隣に書かれます
tools/catalog_export/mod_info = MOD
tools/catalog_export/id = ID
tools/catalog_export/name = 名前
tools/catalog_export/metadata = カタログ
tools/catalog_export/locales = ローカライズ
tools/catalog_export/locales_hint = 行がない場合は、マニフェストの英語の名前と説明が使われます。
tools/catalog_export/remove_locale = 言語を削除
tools/catalog_export/preview = できあがる JSON
tools/catalog_export/copied = JSON をクリップボードにコピーしました。
tools/catalog_export/export_tip = アーカイブを作り、ハッシュを計算し、カタログ項目の JSON を書き出す
tools/catalog_export/exported = 書き出しました:{0}
tools/catalog_export/export_error = 書き出しエラー
tools/catalog_export/url_required = ダウンロード URL を設定してください——それがないカタログ項目は役に立ちません。
tools/scene_editor/title = シーンエディター
tools/scene_editor/hint = シーンは場所です:背景、環境音、シナリオ。Ctrl+S で保存 · 一覧の背景をダブルクリックで追加
tools/scene_editor/save_tip = 選んだ MOD にシーンファイルを保存(Ctrl+S)。ゲームはすぐに変更を反映します
tools/scene_editor/new_scene = + 新しいシーン
tools/scene_editor/new_scene_tip = 新しい場所を作る:名前、MOD、背景。シナリオは後から追加でき、シナリオのないシーンでも構いません
tools/scene_editor/delete = 削除
tools/scene_editor/delete_tip = 選んだ MOD のシーンファイルを削除
tools/scene_editor/delete_confirm = 選んだ MOD からこのシーンのファイルを削除しますか? シーンのシナリオは残ります。
tools/scene_editor/go = ▶ シーンへ移動
tools/scene_editor/go_tip = ゲームをこのシーンに切り替える
tools/scene_editor/rescan_tip = シーン、背景、音を読み直す(MOD にファイルを追加した後)
tools/scene_editor/scenes = シーン
tools/scene_editor/backgrounds = シーンの背景(入るたびにランダムに 1 つ)
tools/scene_editor/all_backgrounds = すべての背景
tools/scene_editor/add_background = + シーンに背景を追加
tools/scene_editor/add_background_tip = 表示中の背景をシーンに追加(または一覧でダブルクリック)
tools/scene_editor/remove_background = シーンから背景を外す
tools/scene_editor/properties = プロパティ
tools/scene_editor/name = 名前
tools/scene_editor/name_tip = シーンの id:ラテン文字、数字、_。switch_scene とシナリオのヘッダーが参照します。作成時にだけ設定
tools/scene_editor/mod = 保存先
tools/scene_editor/mod_tip = シーンファイルを書き込む MOD(フォルダー形式の MOD のみ)
tools/scene_editor/ambient = 音
tools/scene_editor/ambient_tip = シーンで流れる環境音
tools/scene_editor/listen = ▶
tools/scene_editor/listen_tip = 環境音を聴く
tools/scene_editor/stop = ■
tools/scene_editor/default_scenario = 既定のシナリオ
tools/scene_editor/default_scenario_tip = シーンの他のシナリオがなくなったときに流れる
tools/scene_editor/scenarios = シーンのシナリオ
tools/scene_editor/open_scenario = 開く
tools/scene_editor/open_scenario_tip = 選択したシナリオをシナリオエディターで開く(またはダブルクリック)
tools/scene_editor/new_scenario = + 新しいシナリオ
tools/scene_editor/new_scenario_tip = 選んだ MOD にこのシーンのシナリオを作って開く
tools/scene_editor/none = — なし —
tools/scene_editor/effective = ゲーム内:{0}(MOD {1} から)
tools/scene_editor/adds_to = シーンの定義元:{0}。保存すると、選んだ MOD にそれを補うファイルが作られます。
tools/scene_editor/also_in = ほかに補っている MOD:{0}
tools/scene_editor/no_writable_mod = シーンを保存できるフォルダー形式の MOD がありません。
tools/scene_editor/bad_name = シーンの名前を設定してください:ラテン文字、数字、_(先頭は文字)。
tools/scene_editor/name_taken = シーン「{0}」はすでにあります。一覧から選んでください。
tools/scene_editor/need_background = シーンに背景を 1 つ以上追加してください。
tools/scene_editor/created = シーン「{0}」を作成しました。
tools/scene_editor/deleted = 削除しました:{0}
tools/scene_editor/opened = シーン:{0}
tools/scene_editor/new_started = 新しいシーン:名前と MOD を設定し、背景を追加してください。
tools/scene_editor/already_there = 背景 {0} はすでにシーンにあります。
tools/scene_editor/go_failed = ゲームはシーン「{0}」を知りません。先に保存してください。
tools/scene_editor/went = シーン {0} に切り替えました。
tools/scene_editor/rescanned = シーン、背景、音を読み直しました。
tools/scene_editor/scenario_created = シナリオ {0} を作成しました。
tools/scene_editor/no_scenarios = このシーンにはまだシナリオがありません
tools/scene_editor/no_backgrounds = このシーンには背景がありません:下の一覧から選んで追加してください。
tools/scene_editor/pick_scene = 左でシーンを選ぶか、新しく作ってください。
tools/scene_editor/preview_hint = 背景を選ぶと表示されます。
tools/scene_editor/not_in_scene = (シーンにない)
tools/scene_editor/new_line_text = 新しいシーン。この行をあなたの文章に置き換えてください。
tools/entity_editor/hint = 左は検索付きの一覧、右は選んだ項目のフィールド · Ctrl+N 新規、Ctrl+D コピー · Ctrl+Z/Y 元に戻す/やり直す · Ctrl+S 保存、ゲームはすぐに変更を反映します
tools/entity_editor/section_list = MOD の項目
tools/entity_editor/search = 名前か id で検索
tools/entity_editor/mod_tip = 項目を編集する MOD。新しい項目はこの MOD のファイルに入ります
tools/entity_editor/new = 新規
tools/entity_editor/new_tip = 選んだ MOD に項目を追加(Ctrl+N)
tools/entity_editor/duplicate = コピー
tools/entity_editor/duplicate_tip = 選択した項目を新しい id でコピー(Ctrl+D)
tools/entity_editor/delete = 削除
tools/entity_editor/delete_tip = 選択した項目を MOD のファイルから取り除く
tools/entity_editor/delete_title = 項目を削除しますか?
tools/entity_editor/delete_text = 保存すると項目は MOD のファイルから消えます。シナリオやマップからの参照は残るので確認してください。
tools/entity_editor/undo = 元に戻す
tools/entity_editor/undo_tip = 最後の変更を元に戻す(Ctrl+Z)
tools/entity_editor/redo = やり直す
tools/entity_editor/redo_tip = 元に戻した操作をやり直す(Ctrl+Y)
tools/entity_editor/no_entity = 左で項目を選ぶか、「新規」で作ってください。
tools/entity_editor/file = MOD {0} · ファイル {1}
tools/entity_editor/id = Id
tools/entity_editor/id_tip = 一意の id:シナリオ、マップ、他の MOD はこれで項目を見つけます。所有者に依存して同じ id を持つ MOD は、その項目を置き換えます
tools/entity_editor/locale_key_tip = ローカライズキー。下のテキストは MOD の locale/*.properties に書かれます
tools/entity_editor/text_in = テキスト({0})
tools/entity_editor/picture_pick_tip = MOD のフォルダーから画像を選ぶ
tools/entity_editor/picture_pick_title = 「{0}」グループの画像
tools/entity_editor/section_other = その他のフィールド
tools/entity_editor/other_hint = フォームに表示されないフィールド(他の MOD のデータ、戦利品…)を JSON で。JSON が正しくなった時点で編集が適用されます。
tools/entity_editor/other_invalid = その他のフィールド:不正な JSON——{0}
tools/entity_editor/other_applied = その他のフィールドを適用しました
tools/entity_editor/opened = MOD {1} の項目 {0} 件
tools/entity_editor/added = 項目 {0} を {1} に追加しました
tools/entity_editor/deleted = 項目 {0} を削除しました(書き込むには保存)
tools/entity_editor/saved = 保存しました:ファイル {0}、テキスト {1}(MOD {2})。ゲームはデータを読み直しました
tools/entity_editor/broken_files = JSON が壊れているファイルは読み込みませんでした:{0}
tools/entity_editor/problem_id = ファイル {0} に id が空の項目があります
tools/entity_editor/problem_duplicate = id {0} が複数回出てきます
+337
View File
@@ -0,0 +1,337 @@
mod_editor_tools_name = 에디터 도구
mod_editor_tools_desc = 실행 중인 게임 위에 열리는 창작용 창: 장면, 시나리오, 맵, 상호작용, 조명 에디터와 Godot 장면 빌더. 저장하는 것은 모두 일반 모드 파일이며 게임이 즉시 적용합니다.
tools/common/save = 저장
tools/common/save_tip = 파일 저장 (Ctrl+S)
tools/common/saved = 저장됨: {0}
tools/common/loaded = 열림: {0}
tools/common/load_error = 불러오기 오류
tools/common/readonly = 저장할 수 없습니다: 모드가 읽기 전용(zip)이거나 경로가 잘못되었습니다.
tools/common/no_writable_mods = 쓸 수 있는 모드가 없습니다(폴더형 모드만 편집할 수 있습니다).
tools/common/no_files = 모드 "{0}"에는 아직 이런 파일이 없습니다 — "+"로 만드세요.
tools/common/no_file_to_save = 먼저 파일을 고르거나 만드세요(파일 목록 옆의 "+").
tools/common/new_file = 새 파일
tools/common/new_file_hint = 처음 저장할 때 {0}/{1}/에 확장자 {2}인 파일이 만들어집니다.
tools/common/new_file_status = 새 파일 {0} — 저장할 때 만들어집니다.
tools/common/mod_tip = 모드(이런 파일이 이미 있는 모드가 먼저; · — 아직 없음)
tools/common/file_tip = 모드 안의 파일
tools/common/search = 검색…
tools/common/json = JSON
tools/common/json_tip = 결과 파일의 JSON 보기
tools/common/background_missing = 장면 "{1}"의 배경 "{0}"을(를) 제공하는 모드가 없습니다 — 직접 고르세요.
tools/map_editor/title = 맵 에디터
tools/map_editor/hint = 좌클릭 — 선택 / 드래그 · 우클릭 — 꼭짓점 삭제 · 더블클릭, Enter 또는 Esc — 영역 완성 · Del — 구역 삭제 · Ctrl+S — 저장
tools/map_editor/reload_map = 게임 내 지도 새로 고침
tools/map_editor/reload_map_tip = 실행 중인 게임에서 저장된 지도 파일을 다시 읽습니다
tools/map_editor/reloaded = 게임 내 지도를 새로 고쳤습니다.
tools/map_editor/select_mode = 선택
tools/map_editor/draw_region = 그리기
tools/map_editor/draw_started = 지도를 클릭해 꼭짓점을 놓으세요. 더블클릭, Enter 또는 Esc로 완성, 우클릭으로 마지막 꼭짓점을 지웁니다.
tools/map_editor/background = 미리 보기 배경
tools/map_editor/zones = 구역
tools/map_editor/add_region = + 영역
tools/map_editor/add_region_tip = 새 영역: 지도를 클릭해 다각형을 그립니다
tools/map_editor/add_marker = + 표식
tools/map_editor/add_label = + 라벨
tools/map_editor/delete = 구역 삭제
tools/map_editor/delete_tip = 선택한 구역 삭제 (Del)
tools/map_editor/delete_confirm = 선택한 구역을 삭제할까요? 변경은 저장할 때 파일에 반영됩니다.
tools/map_editor/deleted = 구역 "{0}"을(를) 삭제했습니다.
tools/map_editor/props = 구역 속성
tools/map_editor/zone_missing = 목록이나 지도에서 구역을 고르세요.
tools/map_editor/id = ID
tools/map_editor/id_empty = 구역 ID는 비워 둘 수 없습니다.
tools/map_editor/duplicate_id = 이미 쓰인 구역 ID:
tools/map_editor/title_key = 제목
tools/map_editor/description = 설명
tools/map_editor/no_translation = 로컬라이즈에 키가 없음
tools/map_editor/scene = 장면
tools/map_editor/scene_none = (없음)
tools/map_editor/availability = 이용 여부
tools/map_editor/locked = 이동 불가
tools/map_editor/hidden = 지도에서 숨김
tools/map_editor/effective = 게임에서:
tools/map_editor/effective_open = 열림
tools/map_editor/effective_locked = 잠김 (Locked 플래그)
tools/map_editor/effective_no_scene = 잠김 (장면 없음)
tools/map_editor/effective_unknown_scene = 잠김 (장면을 찾을 수 없음)
tools/map_editor/effective_hidden = 지도에서 숨김
tools/map_editor/marker = 표식
tools/map_editor/image = 아이콘
tools/map_editor/image_placeholder = 텍스처 ID
tools/map_editor/icon_search = 아이콘 찾기…
tools/map_editor/label_section = 라벨
tools/map_editor/label = 지도 위의 텍스트 라벨
tools/map_editor/x = X (0–1)
tools/map_editor/y = Y (0–1)
tools/map_editor/vertices = 다각형 꼭짓점
tools/map_editor/vertices_hint = 지도에서 꼭짓점을 끌어 옮기세요. 꼭짓점을 우클릭하면 삭제됩니다.
tools/map_editor/add_vertex = + 꼭짓점
tools/map_editor/delete_vertex = 꼭짓점 삭제
tools/map_editor/preview = ▶ 게임처럼 보기
tools/map_editor/preview_tip = 게임에서처럼 지도 보기: 호버, 구역 카드, 표식(저장하지 않은 편집 포함)
tools/map_editor/preview_on = 미리 보기: Esc 또는 ✕로 에디터로 돌아갑니다.
tools/map_editor/preview_off = 편집 모드.
tools/map_editor/preview_missing = 지도 장면(map_widget.tscn)을 불러올 수 없습니다.
tools/scenario_editor/title = 시나리오 에디터
tools/scenario_editor/hint = Ctrl+S — 저장 · F5 — 시나리오 플레이 · 그래프의 장면 클릭 — 그 장면의 시나리오
tools/scenario_editor/play = ▶ 플레이
tools/scenario_editor/play_tooltip = 저장하고 게임에서 시나리오 플레이 (F5)
tools/scenario_editor/play_stopped = 테스트 플레이를 멈췄습니다.
tools/scenario_editor/stop = ■ 정지
tools/scenario_editor/open_file = 외부 에디터로 열기
tools/scenario_editor/open_file_tip = 시스템 기본 프로그램으로 파일 열기
tools/scenario_editor/open_file_error = 파일을 열 수 없습니다
tools/scenario_editor/rescan = 다시 검색
tools/scenario_editor/rescan_tip = 시나리오 목록과 그래프를 다시 읽습니다(파일을 추가한 뒤)
tools/scenario_editor/rescanned = 시나리오 목록을 새로 고쳤습니다.
tools/scenario_editor/search_files = 파일 찾기…
tools/scenario_editor/tab_files = 파일
tools/scenario_editor/tab_info = 요약
tools/scenario_editor/tab_graph = 장면 그래프
tools/scenario_editor/graph_select_node = 그래프에서 장면을 고르면 그 시나리오와 전환이 보입니다.
tools/scenario_editor/graph_node = 모드 {0} · 시나리오 {1}
tools/scenario_editor/graph_node_bg = 배경: {0}
tools/scenario_editor/graph_detail_mod = 모드: {0}
tools/scenario_editor/graph_detail_scenarios = 시나리오 ({0}), 우선순위순:
tools/scenario_editor/info_scene = 장면
tools/scenario_editor/info_priority = 우선순위
tools/scenario_editor/info_condition = 조건
tools/scenario_editor/info_switch_targets = 장면 전환
tools/scenario_editor/info_locale_keys = 로컬라이즈 키
tools/scenario_editor/info_quest_refs = 퀘스트
tools/scenario_editor/bg_previews = 시나리오 배경
tools/scenario_editor/bg_missing = 배경을 찾을 수 없음: {0}
tools/scenario_editor/no_backgrounds = 이 시나리오는 배경을 바꾸지 않습니다
tools/scenario_editor/save_error = 저장 오류: {0}
tools/scenario_editor/readonly_zip = zip 모드의 파일은 읽기 전용입니다
tools/scenario_editor/no_scene_error = 시나리오 헤더에 장면(scene:)이 없습니다 — 플레이할 것이 없습니다.
tools/scenario_editor/unsaved_title = 저장하지 않은 변경
tools/scenario_editor/unsaved_switch_text = 이 탭에 저장하지 않은 변경이 있습니다. 저장하지 않고 닫을까요?
tools/scene_builder/title = 장면 빌더
tools/scene_builder/hint = 노드는 본체나 핸들로 끌어 옮기기 · 클릭 — 커서 아래 노드 선택 · Del — 노드 삭제 · Ctrl+S — 저장하고 다시 빌드
tools/scene_builder/base_scene = 모드 장면을 겹쳐 보여 줄 기본 장면
tools/scene_builder/mod = 모드
tools/scene_builder/scene = 모드 장면 (.tscn)
tools/scene_builder/empty_scene = (빈 기본 장면)
tools/scene_builder/select_mod = — 모드 선택 —
tools/scene_builder/select_scene = — 장면 선택 —
tools/scene_builder/save_tip = .tscn을 저장하고 미리 보기를 다시 빌드 (Ctrl+S)
tools/scene_builder/reload = 디스크에서 다시 읽기
tools/scene_builder/reload_tip = 저장하지 않은 편집을 버리고 장면 파일을 다시 읽습니다
tools/scene_builder/reloaded = 디스크에서 장면을 다시 읽었습니다.
tools/scene_builder/saved_ok = 저장했습니다.
tools/scene_builder/saved_rebuilt = 저장했고, 미리 보기를 다시 빌드했습니다.
tools/scene_builder/autosaved = 자동 저장했습니다.
tools/scene_builder/error_save = 저장 오류: 모드가 읽기 전용입니다.
tools/scene_builder/error_no_scene = 먼저 모드 장면을 고르세요.
tools/scene_builder/hierarchy = 계층
tools/scene_builder/hierarchy_hint = 금색 — 모드 장면의 루트, 밝은색 — 편집 가능한 노드, 흐린색 — 기본 장면(읽기 전용).
tools/scene_builder/node_type_tip = 새 노드의 유형
tools/scene_builder/add_node_tip = 선택한 노드(또는 장면 루트) 안에 고른 유형의 노드를 추가합니다
tools/scene_builder/delete_node_tip = 선택한 모드 장면 노드 삭제
tools/scene_builder/delete_node_title = 노드 삭제
tools/scene_builder/delete_node_confirm = 선택한 노드를 자식과 함께 삭제할까요? 파일은 자동으로 저장됩니다.
tools/scene_builder/refresh_tip = 노드 트리 새로 고침
tools/scene_builder/tab_inspector = 인스펙터
tools/scene_builder/tab_tscn = .tscn 소스
tools/scene_builder/tscn_hint = 인스펙터와 핸들로 한 편집은 저절로 저장됩니다. 텍스트를 직접 고친 내용은 Ctrl+S로 저장하며, 그때 미리 보기도 다시 빌드됩니다.
tools/scene_builder/inspector_cat_container = 컨테이너 안
tools/scene_builder/inspector_select = 속성을 편집하려면 모드 장면의 노드를 고르세요
tools/scene_builder/inspector_readonly = 읽기 전용: 기본 장면에 속한 노드입니다
tools/scene_builder/inspector_missing_section = tscn에서 노드 섹션을 찾을 수 없습니다(인스턴스화한 하위 장면의 노드는 아직 편집할 수 없습니다)
tools/scene_builder/inspector_preset = 앵커 프리셋
tools/scene_builder/inspector_cat_layout = 레이아웃
tools/scene_builder/inspector_cat_rect = 사각형
tools/scene_builder/inspector_cat_visibility = 보이기
tools/scene_builder/inspector_cat_misc = 기타
tools/scene_builder/inspector_bad_value = 이 값은 tscn에 쓸 수 없습니다.
tools/scene_builder/inspector_in_file_tooltip = tscn 파일에 저장됨
tools/scene_builder/inspector_container_banner = 이 노드는 컨테이너가 관리합니다: 앵커와 오프셋은 부모가 다시 계산하므로 여기서는 효과가 없습니다.
tools/scene_builder/err_add_failed = 오류: 이 유형의 노드를 만들 수 없습니다.
tools/scene_builder/err_add_parent_missing = 오류: tscn에서 부모 섹션을 찾을 수 없습니다(인스턴스화한 하위 장면의 부모는 지원하지 않습니다).
tools/scene_builder/err_del_root = 오류: 장면 루트는 삭제할 수 없습니다.
tools/scene_builder/err_del_none = 오류: 삭제할 모드 노드를 고르세요.
tools/interactive_editor/title = 상호작용 에디터
tools/interactive_editor/hint = 충돌체 클릭 — 선택 · 핸들 드래그 · 다각형 모드에서는 배경 클릭으로 꼭짓점 추가, 꼭짓점 우클릭으로 삭제 · Del — 오브젝트 삭제 · Ctrl+S — 저장
tools/interactive_editor/items = 파일 안의 오브젝트
tools/interactive_editor/add = + 새로 만들기
tools/interactive_editor/duplicate = 복제
tools/interactive_editor/delete = 오브젝트 삭제
tools/interactive_editor/delete_confirm = 선택한 상호작용을 파일에서 지울까요? 변경은 저장할 때 파일에 반영됩니다.
tools/interactive_editor/deleted = 오브젝트 "{0}"을(를) 지웠습니다.
tools/interactive_editor/background = 미리 보기 배경
tools/interactive_editor/props = 속성
tools/interactive_editor/none_selected = 목록이나 미리 보기에서 오브젝트를 고르세요.
tools/interactive_editor/label_id = ID
tools/interactive_editor/label_texture_key = 텍스처
tools/interactive_editor/texture_placeholder = 스프라이트 없음
tools/interactive_editor/texture_tip = 스프라이트 텍스처 ID. 비우면 보이지 않는 클릭 영역
tools/interactive_editor/label_layer = 레이어
tools/interactive_editor/label_z = Z 순서
tools/interactive_editor/label_cursor = 커서
tools/interactive_editor/cursor_none = (기본)
tools/interactive_editor/enabled = 켜짐
tools/interactive_editor/visible = 보임
tools/interactive_editor/collider = 충돌체
tools/interactive_editor/label_collider_type = 모양
tools/interactive_editor/hit_rect = 사각형
tools/interactive_editor/hit_circle = 원
tools/interactive_editor/hit_polygon = 다각형
tools/interactive_editor/label_x = X
tools/interactive_editor/label_y = Y
tools/interactive_editor/label_w = 너비
tools/interactive_editor/label_h = 높이
tools/interactive_editor/label_cx = 중심 X
tools/interactive_editor/label_cy = 중심 Y
tools/interactive_editor/label_r = 반지름
tools/interactive_editor/polygon_hint = 배경을 클릭하면 꼭짓점이 추가되고, 꼭짓점을 우클릭하면 삭제됩니다. 꼭짓점은 3개 이상.
tools/interactive_editor/polygon_too_small = 다각형 "{0}"의 꼭짓점이 3개보다 적습니다.
tools/interactive_editor/highlight = 강조
tools/interactive_editor/label_highlight_color = 색
tools/interactive_editor/label_highlight_scale = 크기
tools/interactive_editor/actions = 액션
tools/interactive_editor/label_onclick_scenario = 시나리오
tools/interactive_editor/label_onclick_commands = 클릭할 때의 명령(한 줄에 하나)
tools/interactive_editor/label_hover_enter = 마우스가 들어올 때
tools/interactive_editor/label_hover_leave = 마우스가 나갈 때
tools/interactive_editor/id_empty = ID가 비어 있는 오브젝트가 있습니다.
tools/interactive_editor/duplicate_id = 중복된 오브젝트 ID:
tools/light_editor/title = 조명 에디터
tools/light_editor/hint = 배경 더블클릭 — 새 조명 · 조명 드래그 · Del — 삭제 · Ctrl+S — 시나리오에 light_set 쓰기
tools/light_editor/scenario_tip = light_set 줄을 편집할 시나리오 (● — 이미 조명을 설정함)
tools/light_editor/pick_scenario = — 시나리오 선택 —
tools/light_editor/save_tip = 시나리오에 light_set 줄 쓰기 (Ctrl+S)
tools/light_editor/copy_line = 줄 복사
tools/light_editor/copy_line_tip = light_set 명령을 클립보드에 복사
tools/light_editor/copied = light_set 줄을 복사했습니다.
tools/light_editor/show_line = light_set
tools/light_editor/loaded = {0}의 조명을 불러왔습니다.
tools/light_editor/no_lights_yet = {0}에는 아직 light_set이 없습니다 — 조명을 추가하면 헤더 뒤에 줄이 들어갑니다.
tools/light_editor/static_lights = 조명
tools/light_editor/add_light = + 조명
tools/light_editor/delete = 삭제
tools/light_editor/limit = 정적 조명은 최대 {0}개입니다.
tools/light_editor/selected_props = 선택한 조명
tools/light_editor/none_selected = 목록이나 미리 보기에서 조명을 고르세요.
tools/light_editor/x = X (0–1)
tools/light_editor/y = Y (0–1)
tools/light_editor/radius = 반지름
tools/light_editor/radius_tip = 너비 1920 px 게임 화면 기준 픽셀 반지름
tools/light_editor/influence = 세기
tools/light_editor/influence_tip = 조명 영향 (0–1)
tools/light_editor/color = 색
tools/light_editor/background = 미리 보기 배경
tools/catalog_export/hint = 압축 파일과 해시는 내보낼 때 계산되며, JSON은 모드 압축 파일 옆에 쓰입니다
tools/catalog_export/mod_info = 모드
tools/catalog_export/id = ID
tools/catalog_export/name = 이름
tools/catalog_export/metadata = 카탈로그
tools/catalog_export/locales = 로컬라이즈
tools/catalog_export/locales_hint = 줄이 없으면 매니페스트의 영어 이름과 설명이 쓰입니다.
tools/catalog_export/remove_locale = 언어 제거
tools/catalog_export/preview = 결과 JSON
tools/catalog_export/copied = JSON을 클립보드에 복사했습니다.
tools/catalog_export/export_tip = 압축 파일을 만들고 해시를 계산해 카탈로그 항목 JSON을 씁니다
tools/catalog_export/exported = 내보냄: {0}
tools/catalog_export/export_error = 내보내기 오류
tools/catalog_export/url_required = 다운로드 URL을 설정하세요 — 그것 없는 카탈로그 항목은 쓸모가 없습니다.
tools/scene_editor/title = 장면 에디터
tools/scene_editor/hint = 장면은 장소입니다: 배경, 환경음, 시나리오. Ctrl+S로 저장 · 목록의 배경을 더블클릭하면 추가
tools/scene_editor/save_tip = 선택한 모드에 장면 파일 저장 (Ctrl+S). 게임에 바로 반영됩니다
tools/scene_editor/new_scene = + 새 장면
tools/scene_editor/new_scene_tip = 새 장소 만들기: 이름, 모드, 배경. 시나리오는 나중에 추가할 수 있고, 시나리오가 없는 장면도 괜찮습니다
tools/scene_editor/delete = 삭제
tools/scene_editor/delete_tip = 선택한 모드의 장면 파일 삭제
tools/scene_editor/delete_confirm = 선택한 모드에서 이 장면의 파일을 삭제할까요? 장면의 시나리오는 남습니다.
tools/scene_editor/go = ▶ 장면으로 이동
tools/scene_editor/go_tip = 게임을 이 장면으로 전환
tools/scene_editor/rescan_tip = 장면, 배경, 소리를 다시 읽습니다(모드에 파일을 추가한 뒤)
tools/scene_editor/scenes = 장면
tools/scene_editor/backgrounds = 장면 배경 (들어갈 때 하나가 무작위로 선택됨)
tools/scene_editor/all_backgrounds = 모든 배경
tools/scene_editor/add_background = + 장면에 배경 추가
tools/scene_editor/add_background_tip = 보이는 배경을 장면에 추가(또는 목록에서 더블클릭)
tools/scene_editor/remove_background = 장면에서 배경 빼기
tools/scene_editor/properties = 속성
tools/scene_editor/name = 이름
tools/scene_editor/name_tip = 장면 id: 라틴 문자, 숫자, _. switch_scene과 시나리오 헤더가 이를 참조합니다. 만들 때만 정합니다
tools/scene_editor/mod = 저장할 곳
tools/scene_editor/mod_tip = 장면 파일을 쓸 모드(폴더형 모드만)
tools/scene_editor/ambient = 소리
tools/scene_editor/ambient_tip = 장면에서 재생되는 환경음
tools/scene_editor/listen = ▶
tools/scene_editor/listen_tip = 환경음 듣기
tools/scene_editor/stop = ■
tools/scene_editor/default_scenario = 기본 시나리오
tools/scene_editor/default_scenario_tip = 장면에 다른 시나리오가 남지 않았을 때 재생됩니다
tools/scene_editor/scenarios = 장면의 시나리오
tools/scene_editor/open_scenario = 열기
tools/scene_editor/open_scenario_tip = 선택한 시나리오를 시나리오 에디터에서 열기(또는 더블클릭)
tools/scene_editor/new_scenario = + 새 시나리오
tools/scene_editor/new_scenario_tip = 선택한 모드에 이 장면의 시나리오를 만들고 엽니다
tools/scene_editor/none = — 없음 —
tools/scene_editor/effective = 게임에서: {0} (모드 {1})
tools/scene_editor/adds_to = 장면이 정의된 곳: {0}. 저장하면 선택한 모드에 이를 보충하는 파일이 만들어집니다.
tools/scene_editor/also_in = 이 장면을 보충하는 모드: {0}
tools/scene_editor/no_writable_mod = 장면을 저장할 폴더형 모드가 없습니다.
tools/scene_editor/bad_name = 장면 이름을 정하세요: 라틴 문자, 숫자, _ (문자로 시작).
tools/scene_editor/name_taken = 장면 "{0}"이(가) 이미 있습니다. 목록에서 고르세요.
tools/scene_editor/need_background = 장면에 배경을 하나 이상 추가하세요.
tools/scene_editor/created = 장면 "{0}"을(를) 만들었습니다.
tools/scene_editor/deleted = 삭제함: {0}
tools/scene_editor/opened = 장면: {0}
tools/scene_editor/new_started = 새 장면: 이름과 모드를 정하고 배경을 추가하세요.
tools/scene_editor/already_there = 배경 {0}은(는) 이미 장면에 있습니다.
tools/scene_editor/go_failed = 게임이 장면 "{0}"을(를) 모릅니다. 먼저 저장하세요.
tools/scene_editor/went = 장면 {0}(으)로 전환했습니다.
tools/scene_editor/rescanned = 장면, 배경, 소리를 다시 읽었습니다.
tools/scene_editor/scenario_created = 시나리오 {0}을(를) 만들었습니다.
tools/scene_editor/no_scenarios = 이 장면에는 아직 시나리오가 없습니다
tools/scene_editor/no_backgrounds = 이 장면에는 배경이 없습니다: 아래 목록에서 하나 골라 추가하세요.
tools/scene_editor/pick_scene = 왼쪽에서 장면을 고르거나 새로 만드세요.
tools/scene_editor/preview_hint = 배경을 고르면 보입니다.
tools/scene_editor/not_in_scene = (장면에 없음)
tools/scene_editor/new_line_text = 새 장면입니다. 이 줄을 당신의 글로 바꾸세요.
tools/entity_editor/hint = 왼쪽은 검색이 있는 목록, 오른쪽은 선택한 항목의 필드 · Ctrl+N 새로 만들기, Ctrl+D 복사 · Ctrl+Z/Y 되돌리기/다시 실행 · Ctrl+S 저장, 게임에 바로 반영
tools/entity_editor/section_list = 모드의 항목
tools/entity_editor/search = 이름 또는 id로 검색
tools/entity_editor/mod_tip = 항목을 편집할 모드. 새 항목은 이 모드의 파일에 들어갑니다
tools/entity_editor/new = 새로 만들기
tools/entity_editor/new_tip = 선택한 모드에 항목 추가 (Ctrl+N)
tools/entity_editor/duplicate = 복사
tools/entity_editor/duplicate_tip = 선택한 항목을 새 id로 복사 (Ctrl+D)
tools/entity_editor/delete = 삭제
tools/entity_editor/delete_tip = 선택한 항목을 모드 파일에서 제거
tools/entity_editor/delete_title = 항목을 삭제할까요?
tools/entity_editor/delete_text = 저장하면 항목이 모드 파일에서 사라집니다. 시나리오와 맵의 참조는 남으니 확인하세요.
tools/entity_editor/undo = 되돌리기
tools/entity_editor/undo_tip = 마지막 변경 되돌리기 (Ctrl+Z)
tools/entity_editor/redo = 다시 실행
tools/entity_editor/redo_tip = 되돌린 것을 다시 실행 (Ctrl+Y)
tools/entity_editor/no_entity = 왼쪽에서 항목을 고르거나 "새로 만들기"로 만드세요.
tools/entity_editor/file = 모드 {0} · 파일 {1}
tools/entity_editor/id = Id
tools/entity_editor/id_tip = 고유 id: 시나리오, 맵, 다른 모드가 이것으로 항목을 찾습니다. 소유자에 의존하고 같은 id를 가진 모드가 그 항목을 대체합니다
tools/entity_editor/locale_key_tip = 로컬라이즈 키. 아래 텍스트는 모드의 locale/*.properties에 쓰입니다
tools/entity_editor/text_in = 텍스트 ({0})
tools/entity_editor/picture_pick_tip = 모드 폴더에서 그림 고르기
tools/entity_editor/picture_pick_title = "{0}" 그룹의 그림
tools/entity_editor/section_other = 기타 필드
tools/entity_editor/other_hint = 폼에 보이지 않는 필드(다른 모드의 데이터, 전리품…)를 JSON으로. JSON이 올바르면 바로 적용됩니다.
tools/entity_editor/other_invalid = 기타 필드: 잘못된 JSON — {0}
tools/entity_editor/other_applied = 기타 필드를 적용했습니다
tools/entity_editor/opened = 모드 {1}의 항목 {0}개
tools/entity_editor/added = 항목 {0}을(를) {1}에 추가했습니다
tools/entity_editor/deleted = 항목 {0}을(를) 삭제했습니다(기록하려면 저장)
tools/entity_editor/saved = 저장함: 파일 {0}개, 텍스트 {1}개 (모드 {2}). 게임이 데이터를 다시 읽었습니다
tools/entity_editor/broken_files = JSON이 깨진 파일은 읽지 않았습니다: {0}
tools/entity_editor/problem_id = 파일 {0}에 id가 비어 있는 항목이 있습니다
tools/entity_editor/problem_duplicate = id {0}이(가) 두 번 이상 나옵니다
+313 -145
View File
@@ -1,40 +1,145 @@
# Русская локализация для инструментов разработки
mod_tools_name = Инструменты
mod_tools_desc = Инструменты разработчика
mod_editor_tools_name = Редакторы
mod_editor_tools_desc = Авторские окна поверх запущенной игры: редакторы сцен, сценариев, карты, интерактива и света, а также конструктор сцен Godot. Всё, что они сохраняют, — обычные файлы модов, которые игра подхватывает сразу.
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/common/save = Сохранить
tools/common/save_tip = Сохранить файл (Ctrl+S)
tools/common/saved = Сохранено: {0}
tools/common/loaded = Открыт: {0}
tools/common/load_error = Ошибка загрузки
tools/common/readonly = Не удалось сохранить: мод доступен только для чтения (zip) или путь неверен.
tools/common/no_writable_mods = Нет модов, доступных для записи (только папочные моды можно редактировать).
tools/common/no_files = В моде «{0}» таких файлов пока нет — создайте новый кнопкой «+».
tools/common/no_file_to_save = Сначала выберите или создайте файл («+» рядом со списком файлов).
tools/common/new_file = Новый файл
tools/common/new_file_hint = Файл будет создан в {0}/{1}/ с расширением {2} при первом сохранении.
tools/common/new_file_status = Новый файл {0} — будет создан при сохранении.
tools/common/mod_tip = Мод (сначала — моды, где такие файлы уже есть; · — пока нет)
tools/common/file_tip = Файл внутри мода
tools/common/search = Поиск…
tools/common/json = JSON
tools/common/json_tip = Показать итоговый JSON файла
tools/common/background_missing = Фон «{0}» сцены «{1}» не найден ни в одном моде — выберите фон вручную.
tools/map_editor/title = Редактор карты
tools/map_editor/hint = ЛКМ — выбрать / тянуть · ПКМ — удалить вершину · двойной клик, Enter или Esc — закончить регион · Del — удалить зону · Ctrl+S — сохранить
tools/map_editor/reload_map = Обновить карту в игре
tools/map_editor/reload_map_tip = Перечитать сохранённые файлы карты в запущенной игре
tools/map_editor/reloaded = Карта в игре обновлена.
tools/map_editor/select_mode = Выбор
tools/map_editor/draw_region = Рисование
tools/map_editor/draw_started = Кликайте по карте, чтобы ставить вершины. Двойной клик, Enter или Esc — закончить, ПКМ — убрать последнюю.
tools/map_editor/background = Фон превью
tools/map_editor/zones = Зоны
tools/map_editor/add_region = + Регион
tools/map_editor/add_region_tip = Новый регион: нарисуйте полигон кликами по карте
tools/map_editor/add_marker = + Метка
tools/map_editor/add_label = + Надпись
tools/map_editor/delete = Удалить зону
tools/map_editor/delete_tip = Удалить выбранную зону (Del)
tools/map_editor/delete_confirm = Удалить выбранную зону? Изменение попадёт в файл при сохранении.
tools/map_editor/deleted = Зона «{0}» удалена.
tools/map_editor/props = Свойства зоны
tools/map_editor/zone_missing = Выберите зону в списке или на карте.
tools/map_editor/id = ID
tools/map_editor/id_empty = ID зоны не может быть пустым.
tools/map_editor/duplicate_id = ID зоны уже занят:
tools/map_editor/title_key = Название
tools/map_editor/description = Описание
tools/map_editor/no_translation = ключ не найден в локализации
tools/map_editor/scene = Сцена
tools/map_editor/scene_none = (нет)
tools/map_editor/availability = Доступность
tools/map_editor/locked = Закрыта для посещения
tools/map_editor/hidden = Скрыта с карты
tools/map_editor/effective = В игре:
tools/map_editor/effective_open = доступна
tools/map_editor/effective_locked = закрыта (флаг «Закрыта»)
tools/map_editor/effective_no_scene = закрыта (сцена не задана)
tools/map_editor/effective_unknown_scene = закрыта (сцена не найдена)
tools/map_editor/effective_hidden = скрыта с карты
tools/map_editor/marker = Метка
tools/map_editor/image = Иконка
tools/map_editor/image_placeholder = ID текстуры
tools/map_editor/icon_search = Поиск иконки…
tools/map_editor/label_section = Надпись
tools/map_editor/label = Текстовая надпись на карте
tools/map_editor/x = X (0–1)
tools/map_editor/y = Y (0–1)
tools/map_editor/vertices = Вершины полигона
tools/map_editor/vertices_hint = Тяните вершины на карте; ПКМ по вершине — удалить.
tools/map_editor/add_vertex = + Вершина
tools/map_editor/delete_vertex = Удалить вершину
tools/map_editor/preview = ▶ Как в игре
tools/map_editor/preview_tip = Посмотреть карту как в игре: наведение, карточка зоны, метки (несохранённые правки тоже)
tools/map_editor/preview_on = Просмотр: Esc или ✕ — вернуться к редактору.
tools/map_editor/preview_off = Режим редактирования.
tools/map_editor/preview_missing = Не удалось загрузить сцену карты (map_widget.tscn).
tools/scenario_editor/title = Редактор сценариев
tools/scenario_editor/hint = Ctrl+S — сохранить · F5 — запустить сценарий · клик по сцене в графе — список её сценариев
tools/scenario_editor/play = ▶ Запустить
tools/scenario_editor/play_tooltip = Сохранить и запустить сценарий в игре (F5)
tools/scenario_editor/play_stopped = Тестовый запуск остановлен.
tools/scenario_editor/stop = ■ Стоп
tools/scenario_editor/open_file = Открыть во внешнем редакторе
tools/scenario_editor/open_file_tip = Открыть файл программой, назначенной в системе
tools/scenario_editor/open_file_error = Не удалось открыть файл
tools/scenario_editor/rescan = Пересканировать
tools/scenario_editor/rescan_tip = Перечитать список сценариев и граф (после добавления файлов)
tools/scenario_editor/rescanned = Список сценариев обновлён.
tools/scenario_editor/search_files = Поиск файла…
tools/scenario_editor/tab_files = Файлы
tools/scenario_editor/tab_info = Сводка
tools/scenario_editor/tab_graph = Граф сцен
tools/scenario_editor/graph_select_node = Выберите сцену в графе, чтобы увидеть её сценарии и переходы.
tools/scenario_editor/graph_node = мод {0} · сценариев {1}
tools/scenario_editor/graph_node_bg = фон: {0}
tools/scenario_editor/graph_detail_mod = мод: {0}
tools/scenario_editor/graph_detail_scenarios = Сценарии ({0}), по приоритету:
tools/scenario_editor/info_scene = Сцена
tools/scenario_editor/info_priority = Приоритет
tools/scenario_editor/info_condition = Условие
tools/scenario_editor/info_switch_targets = Переходы на сцены
tools/scenario_editor/info_locale_keys = Ключи локализации
tools/scenario_editor/info_quest_refs = Квесты
tools/scenario_editor/bg_previews = Фоны сценария
tools/scenario_editor/bg_missing = Фон не найден: {0}
tools/scenario_editor/no_backgrounds = Сценарий не меняет фон
tools/scenario_editor/save_error = Ошибка сохранения: {0}
tools/scenario_editor/readonly_zip = Файл из zip-мода доступен только для чтения
tools/scenario_editor/no_scene_error = В заголовке сценария не указана сцена (scene:) — запускать некуда.
tools/scenario_editor/unsaved_title = Несохранённые изменения
tools/scenario_editor/unsaved_switch_text = В этой вкладке есть несохранённые изменения. Закрыть её без сохранения?
tools/scene_builder/title = Конструктор сцен
tools/scene_builder/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/hint = Тяните узел за тело или маркеры · клик — выбрать узел под курсором · Del — удалить узел · Ctrl+S — сохранить и пересобрать
tools/scene_builder/base_scene = Базовая сцена, поверх которой показывается мод-сцена
tools/scene_builder/mod = Мод
tools/scene_builder/scene = Сцена мода (.tscn)
tools/scene_builder/empty_scene = (пустая базовая сцена)
tools/scene_builder/select_mod = — выберите мод —
tools/scene_builder/select_scene = — выберите сцену —
tools/scene_builder/save_tip = Сохранить .tscn и пересобрать превью (Ctrl+S)
tools/scene_builder/reload = Перечитать с диска
tools/scene_builder/reload_tip = Отбросить несохранённое и перечитать сцену из файла
tools/scene_builder/reloaded = Сцена перечитана с диска.
tools/scene_builder/saved_ok = Сохранено.
tools/scene_builder/reloaded = Перезагружено.
tools/scene_builder/inspector = Инспектор
tools/scene_builder/saved_rebuilt = Сохранено, превью пересобрано.
tools/scene_builder/autosaved = Автосохранено.
tools/scene_builder/error_save = Ошибка сохранения: мод доступен только для чтения.
tools/scene_builder/error_no_scene = Сначала выберите сцену мода.
tools/scene_builder/hierarchy = Иерархия
tools/scene_builder/hierarchy_hint = Золотой — корень мод-сцены, светлые — редактируемые узлы, тусклые — базовая сцена (только чтение).
tools/scene_builder/node_type_tip = Тип нового узла
tools/scene_builder/add_node_tip = Добавить узел выбранного типа в выделенный узел (или в корень сцены)
tools/scene_builder/delete_node_tip = Удалить выбранный узел мод-сцены
tools/scene_builder/delete_node_title = Удалить узел
tools/scene_builder/delete_node_confirm = Удалить выбранный узел вместе с дочерними? Файл сохранится автоматически.
tools/scene_builder/refresh_tip = Обновить дерево узлов
tools/scene_builder/tab_inspector = Инспектор
tools/scene_builder/tab_tscn = Исходник .tscn
tools/scene_builder/tscn_hint = Правки инспектора и маркеров сохраняются сами; ручные правки текста — по Ctrl+S, после чего превью пересобирается.
tools/scene_builder/inspector_cat_container = В контейнере
tools/scene_builder/inspector_select = Выберите узел мод-сцены для правки свойств
tools/scene_builder/inspector_readonly = Только чтение: узел принадлежит базовой сцене
tools/scene_builder/inspector_missing_section = Секция узла не найдена в tscn (узлы вложенных сцен пока не редактируются)
@@ -43,127 +148,190 @@ 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/hint = Клик по коллайдеру — выбрать · тяните маркеры · в режиме полигона клик по фону добавляет вершину, ПКМ по вершине — удаляет · Del — удалить объект · Ctrl+S — сохранить
tools/interactive_editor/items = Объекты файла
tools/interactive_editor/add = + Новый
tools/interactive_editor/duplicate = Дублировать
tools/interactive_editor/delete = Удалить объект
tools/interactive_editor/delete_confirm = Удалить выбранный интерактив из файла? Изменение попадёт в файл при сохранении.
tools/interactive_editor/deleted = Объект «{0}» удалён.
tools/interactive_editor/background = Фон превью
tools/interactive_editor/props = Свойства
tools/interactive_editor/none_selected = Выберите объект в списке или на превью.
tools/interactive_editor/label_id = ID
tools/interactive_editor/label_texture_key = Текстура
tools/interactive_editor/texture_placeholder = без спрайта
tools/interactive_editor/texture_tip = ID текстуры спрайта объекта; пусто — невидимая кликабельная зона
tools/interactive_editor/label_layer = Слой
tools/interactive_editor/label_z = Z-порядок
tools/interactive_editor/label_cursor = Курсор
tools/interactive_editor/cursor_none = (обычный)
tools/interactive_editor/enabled = Активен
tools/interactive_editor/visible = Видим
tools/interactive_editor/collider = Коллайдер
tools/interactive_editor/label_collider_type = Форма
tools/interactive_editor/hit_rect = прямоугольник
tools/interactive_editor/hit_circle = круг
tools/interactive_editor/hit_polygon = полигон
tools/interactive_editor/label_x = X
tools/interactive_editor/label_y = Y
tools/interactive_editor/label_w = Ширина
tools/interactive_editor/label_h = Высота
tools/interactive_editor/label_cx = Центр X
tools/interactive_editor/label_cy = Центр Y
tools/interactive_editor/label_r = Радиус
tools/interactive_editor/polygon_hint = Клик по фону добавляет вершину, ПКМ по вершине удаляет её. Нужно минимум 3 вершины.
tools/interactive_editor/polygon_too_small = У полигона «{0}» меньше 3 вершин.
tools/interactive_editor/highlight = Подсветка
tools/interactive_editor/label_highlight_color = Цвет
tools/interactive_editor/label_highlight_scale = Масштаб
tools/interactive_editor/actions = Действия
tools/interactive_editor/label_onclick_scenario = Сценарий
tools/interactive_editor/label_onclick_commands = Команды по клику (по одной в строке)
tools/interactive_editor/label_hover_enter = Наведение
tools/interactive_editor/label_hover_leave = Уход
tools/interactive_editor/id_empty = У объекта пустой ID.
tools/interactive_editor/duplicate_id = ID объекта повторяется:
tools/interactive_editor/btn_load_json = Загрузить JSON
tools/interactive_editor/btn_save = Сохранить
tools/interactive_editor/btn_import_mod = Импорт из мода
tools/interactive_editor/dlg_select_def = Выбор определения
tools/light_editor/title = Редактор освещения
tools/light_editor/hint = Двойной клик по фону — новый источник · тяните источники · Del — удалить · Ctrl+S — записать light_set в сценарий
tools/light_editor/scenario_tip = Сценарий, чья строка light_set редактируется (● — уже задаёт свет)
tools/light_editor/pick_scenario = — выберите сценарий —
tools/light_editor/save_tip = Записать строку light_set в сценарий (Ctrl+S)
tools/light_editor/copy_line = Копировать строку
tools/light_editor/copy_line_tip = Скопировать команду light_set в буфер обмена
tools/light_editor/copied = Строка light_set скопирована.
tools/light_editor/show_line = light_set
tools/light_editor/loaded = Свет сценария {0} загружен.
tools/light_editor/no_lights_yet = В {0} ещё нет light_set — добавьте источники, строка будет вставлена после заголовка.
tools/light_editor/static_lights = Источники света
tools/light_editor/add_light = + Источник
tools/light_editor/delete = Удалить
tools/light_editor/limit = Не больше {0} статичных источников.
tools/light_editor/selected_props = Выбранный источник
tools/light_editor/none_selected = Выберите источник в списке или на превью.
tools/light_editor/x = X (0–1)
tools/light_editor/y = Y (0–1)
tools/light_editor/radius = Радиус
tools/light_editor/radius_tip = Радиус в пикселях игрового экрана 1920 px
tools/light_editor/influence = Сила
tools/light_editor/influence_tip = Влияние источника (0–1)
tools/light_editor/color = Цвет
tools/light_editor/background = Фон превью
tools/interactive_editor/tab_background = Задник
tools/interactive_editor/tab_interactive = Интерактив
tools/interactive_editor/tab_scenario = Сценарии
tools/catalog_export/hint = Архив и хэши считаются при экспорте; JSON кладётся рядом с архивом мода
tools/catalog_export/mod_info = Мод
tools/catalog_export/id = ID
tools/catalog_export/name = Название
tools/catalog_export/metadata = Каталог
tools/catalog_export/locales = Локализации
tools/catalog_export/locales_hint = Без строк будет использовано английское название и описание из манифеста.
tools/catalog_export/remove_locale = Убрать язык
tools/catalog_export/preview = Итоговый JSON
tools/catalog_export/copied = JSON скопирован в буфер обмена.
tools/catalog_export/export_tip = Собрать архив, посчитать хэши и записать JSON записи каталога
tools/catalog_export/exported = Экспортировано: {0}
tools/catalog_export/export_error = Ошибка экспорта
tools/catalog_export/url_required = Укажите URL скачивания — без него запись каталога бесполезна.
tools/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).
tools/scene_editor/title = Редактор сцен
tools/scene_editor/hint = Сцена — это место: фоны, звук окружения и сценарии. Ctrl+S — сохранить · двойной клик по фону в списке — добавить в сцену
tools/scene_editor/save_tip = Сохранить файл сцены в выбранный мод (Ctrl+S); игра увидит изменения сразу
tools/scene_editor/new_scene = + Новая сцена
tools/scene_editor/new_scene_tip = Создать новое место: имя, мод, фоны. Сценарии можно добавить потом — сцена может обходиться и без них
tools/scene_editor/delete = Удалить
tools/scene_editor/delete_tip = Удалить файл сцены выбранного мода
tools/scene_editor/delete_confirm = Удалить файл этой сцены из выбранного мода? Сценарии сцены останутся на месте.
tools/scene_editor/go = ▶ Перейти в сцену
tools/scene_editor/go_tip = Переключить игру на эту сцену
tools/scene_editor/rescan_tip = Перечитать сцены, фоны и звуки (после добавления файлов в моды)
tools/scene_editor/scenes = Сцены
tools/scene_editor/backgrounds = Фоны сцены (при входе выбирается один случайно)
tools/scene_editor/all_backgrounds = Все фоны
tools/scene_editor/add_background = + Добавить фон в сцену
tools/scene_editor/add_background_tip = Добавить показанный фон в сцену (или двойной клик по нему в списке)
tools/scene_editor/remove_background = Убрать фон из сцены
tools/scene_editor/properties = Свойства
tools/scene_editor/name = Имя
tools/scene_editor/name_tip = Идентификатор сцены: латиница, цифры и _; на него ссылаются switch_scene и заголовки сценариев. Задаётся только при создании
tools/scene_editor/mod = Сохранять в
tools/scene_editor/mod_tip = Мод, в который пишется файл сцены (только моды-папки)
tools/scene_editor/ambient = Звук
tools/scene_editor/ambient_tip = Звук окружения, который играет в сцене
tools/scene_editor/listen = ▶
tools/scene_editor/listen_tip = Прослушать звук окружения
tools/scene_editor/stop = ■
tools/scene_editor/default_scenario = Сценарий по умолч.
tools/scene_editor/default_scenario_tip = Играет, когда в сцене не осталось других сценариев
tools/scene_editor/scenarios = Сценарии сцены
tools/scene_editor/open_scenario = Открыть
tools/scene_editor/open_scenario_tip = Открыть выбранный сценарий в редакторе сценариев (или двойной клик)
tools/scene_editor/new_scenario = + Новый сценарий
tools/scene_editor/new_scenario_tip = Создать сценарий этой сцены в выбранном моде и открыть его
tools/scene_editor/none = — нет —
tools/scene_editor/effective = В игре: {0} (из мода {1})
tools/scene_editor/adds_to = Сцена определена в: {0}. Сохранение создаст в выбранном моде файл, который дополнит её.
tools/scene_editor/also_in = Сцену дополняют также: {0}
tools/scene_editor/no_writable_mod = Нет мода-папки, куда можно сохранить сцену.
tools/scene_editor/bad_name = Задайте имя сцены: латиница, цифры и _ (начинается с буквы).
tools/scene_editor/name_taken = Сцена «{0}» уже есть — выберите её в списке.
tools/scene_editor/need_background = Добавьте в сцену хотя бы один фон.
tools/scene_editor/created = Сцена «{0}» создана.
tools/scene_editor/deleted = Удалено: {0}
tools/scene_editor/opened = Сцена: {0}
tools/scene_editor/new_started = Новая сцена: задайте имя, мод и добавьте фоны.
tools/scene_editor/already_there = Фон {0} уже есть в сцене.
tools/scene_editor/go_failed = Игра не знает сцену «{0}» — сохраните её.
tools/scene_editor/went = Переход в сцену {0}.
tools/scene_editor/rescanned = Сцены, фоны и звуки перечитаны.
tools/scene_editor/scenario_created = Создан сценарий {0}.
tools/scene_editor/no_scenarios = У сцены пока нет сценариев
tools/scene_editor/no_backgrounds = В сцене нет фонов — выберите фон в списке ниже и добавьте его.
tools/scene_editor/pick_scene = Выберите сцену слева или создайте новую.
tools/scene_editor/preview_hint = Выберите фон, чтобы посмотреть его.
tools/scene_editor/not_in_scene = (не в сцене)
tools/scene_editor/new_line_text = Новая сцена. Замените эту строку своим текстом.
tools/entity_editor/hint = Слева — список с поиском, справа — поля выбранной записи · Ctrl+N — новая, Ctrl+D — копия · Ctrl+Z/Y — отмена/повтор · Ctrl+S — сохранить, игра сразу видит изменения
tools/entity_editor/section_list = Записи мода
tools/entity_editor/search = Поиск по названию или id
tools/entity_editor/mod_tip = Мод, записи которого правятся; новые записи попадают в его файл
tools/entity_editor/new = Новый
tools/entity_editor/new_tip = Добавить запись в выбранный мод (Ctrl+N)
tools/entity_editor/duplicate = Копия
tools/entity_editor/duplicate_tip = Скопировать выбранную запись под новым id (Ctrl+D)
tools/entity_editor/delete = Удалить
tools/entity_editor/delete_tip = Удалить выбранную запись из файла мода
tools/entity_editor/delete_title = Удалить запись?
tools/entity_editor/delete_text = Запись исчезнет из файла мода после сохранения. Ссылки на неё в сценариях и картах останутся — проверьте их.
tools/entity_editor/undo = Отменить
tools/entity_editor/undo_tip = Отменить последнее изменение (Ctrl+Z)
tools/entity_editor/redo = Повторить
tools/entity_editor/redo_tip = Повторить отменённое (Ctrl+Y)
tools/entity_editor/no_entity = Выберите запись слева или создайте новую кнопкой «Новый».
tools/entity_editor/file = Мод {0} · файл {1}
tools/entity_editor/id = Id
tools/entity_editor/id_tip = Уникальный id: по нему запись находят сценарии, карты и другие моды. Мод, который зависит от владельца и даёт тот же id, заменяет запись
tools/entity_editor/locale_key_tip = Ключ локализации; тексты ниже пишутся в locale/*.properties мода
tools/entity_editor/text_in = текст ({0})
tools/entity_editor/picture_pick_tip = Выбрать картинку из папки мода
tools/entity_editor/picture_pick_title = Картинки группы «{0}»
tools/entity_editor/section_other = Прочие поля
tools/entity_editor/other_hint = Поля, которых нет в форме (данные других модов, лут…), как JSON. Правки применяются, как только JSON корректен.
tools/entity_editor/other_invalid = Прочие поля: неверный JSON — {0}
tools/entity_editor/other_applied = Прочие поля применены
tools/entity_editor/opened = {0} записей в моде {1}
tools/entity_editor/added = Добавлена запись {0} в {1}
tools/entity_editor/deleted = Запись {0} удалена (сохраните, чтобы записать)
tools/entity_editor/saved = Сохранено: файлов {0}, текстов {1} в моде {2}; игра перечитала данные
tools/entity_editor/broken_files = Не прочитаны файлы с ошибкой JSON: {0}
tools/entity_editor/problem_id = У записи в файле {0} пустой id
tools/entity_editor/problem_duplicate = Id {0} встречается несколько раз
+337
View File
@@ -0,0 +1,337 @@
mod_editor_tools_name = Công cụ chỉnh sửa
mod_editor_tools_desc = Các cửa sổ tác giả mở trên trò chơi đang chạy: trình sửa cảnh, kịch bản, bản đồ, tương tác và ánh sáng, cùng trình dựng cảnh Godot. Mọi thứ chúng lưu là tệp mod bình thường và game áp dụng ngay lập tức.
tools/common/save = Lưu
tools/common/save_tip = Lưu tệp (Ctrl+S)
tools/common/saved = Đã lưu: {0}
tools/common/loaded = Đã mở: {0}
tools/common/load_error = Lỗi tải
tools/common/readonly = Không thể lưu: mod chỉ đọc (zip) hoặc đường dẫn không hợp lệ.
tools/common/no_writable_mods = Không có mod nào ghi được (chỉ sửa được mod dạng thư mục).
tools/common/no_files = Mod "{0}" chưa có tệp loại này — hãy tạo bằng "+".
tools/common/no_file_to_save = Hãy chọn hoặc tạo một tệp trước ("+" cạnh danh sách tệp).
tools/common/new_file = Tệp mới
tools/common/new_file_hint = Tệp được tạo trong {0}/{1}/ với phần mở rộng {2} khi lưu lần đầu.
tools/common/new_file_status = Tệp mới {0} — được tạo khi lưu.
tools/common/mod_tip = Mod (mod đã có tệp loại này đứng trước; · — chưa có)
tools/common/file_tip = Tệp trong mod
tools/common/search = Tìm…
tools/common/json = JSON
tools/common/json_tip = Hiển thị JSON của tệp kết quả
tools/common/background_missing = Không mod nào cung cấp phông nền "{0}" của cảnh "{1}" — hãy chọn thủ công.
tools/map_editor/title = Trình sửa bản đồ
tools/map_editor/hint = Chuột trái — chọn / kéo · chuột phải — xóa đỉnh · nhấp đúp, Enter hoặc Esc — hoàn tất vùng · Del — xóa vùng · Ctrl+S — lưu
tools/map_editor/reload_map = Làm mới bản đồ trong game
tools/map_editor/reload_map_tip = Đọc lại các tệp bản đồ đã lưu trong game đang chạy
tools/map_editor/reloaded = Đã làm mới bản đồ trong game.
tools/map_editor/select_mode = Chọn
tools/map_editor/draw_region = Vẽ
tools/map_editor/draw_started = Nhấp vào bản đồ để đặt đỉnh. Nhấp đúp, Enter hoặc Esc để hoàn tất, chuột phải bỏ đỉnh cuối.
tools/map_editor/background = Phông nền xem trước
tools/map_editor/zones = Vùng
tools/map_editor/add_region = + Khu vực
tools/map_editor/add_region_tip = Khu vực mới: nhấp vào bản đồ để vẽ đa giác
tools/map_editor/add_marker = + Điểm đánh dấu
tools/map_editor/add_label = + Nhãn
tools/map_editor/delete = Xóa vùng
tools/map_editor/delete_tip = Xóa vùng đã chọn (Del)
tools/map_editor/delete_confirm = Xóa vùng đã chọn? Thay đổi sẽ vào tệp khi lưu.
tools/map_editor/deleted = Đã xóa vùng "{0}".
tools/map_editor/props = Thuộc tính vùng
tools/map_editor/zone_missing = Chọn một vùng trong danh sách hoặc trên bản đồ.
tools/map_editor/id = ID
tools/map_editor/id_empty = ID vùng không được để trống.
tools/map_editor/duplicate_id = ID vùng đã được dùng:
tools/map_editor/title_key = Tiêu đề
tools/map_editor/description = Mô tả
tools/map_editor/no_translation = không tìm thấy khóa trong bản địa hóa
tools/map_editor/scene = Cảnh
tools/map_editor/scene_none = (không có)
tools/map_editor/availability = Khả dụng
tools/map_editor/locked = Không cho di chuyển tới
tools/map_editor/hidden = Ẩn khỏi bản đồ
tools/map_editor/effective = Trong game:
tools/map_editor/effective_open = mở
tools/map_editor/effective_locked = bị khóa (cờ Locked)
tools/map_editor/effective_no_scene = bị khóa (không có cảnh)
tools/map_editor/effective_unknown_scene = bị khóa (không tìm thấy cảnh)
tools/map_editor/effective_hidden = ẩn khỏi bản đồ
tools/map_editor/marker = Điểm đánh dấu
tools/map_editor/image = Biểu tượng
tools/map_editor/image_placeholder = ID texture
tools/map_editor/icon_search = Tìm biểu tượng…
tools/map_editor/label_section = Nhãn
tools/map_editor/label = Nhãn chữ trên bản đồ
tools/map_editor/x = X (0–1)
tools/map_editor/y = Y (0–1)
tools/map_editor/vertices = Các đỉnh đa giác
tools/map_editor/vertices_hint = Kéo các đỉnh trên bản đồ; chuột phải vào một đỉnh để xóa.
tools/map_editor/add_vertex = + Đỉnh
tools/map_editor/delete_vertex = Xóa đỉnh
tools/map_editor/preview = ▶ Như trong game
tools/map_editor/preview_tip = Xem bản đồ như trong game: rê chuột, thẻ vùng, điểm đánh dấu (kể cả thay đổi chưa lưu)
tools/map_editor/preview_on = Xem trước: Esc hoặc ✕ để quay lại trình sửa.
tools/map_editor/preview_off = Chế độ chỉnh sửa.
tools/map_editor/preview_missing = Không tải được cảnh bản đồ (map_widget.tscn).
tools/scenario_editor/title = Trình sửa kịch bản
tools/scenario_editor/hint = Ctrl+S — lưu · F5 — chơi thử kịch bản · nhấp vào một cảnh trên đồ thị — các kịch bản của nó
tools/scenario_editor/play = ▶ Chơi
tools/scenario_editor/play_tooltip = Lưu và chơi thử kịch bản trong game (F5)
tools/scenario_editor/play_stopped = Đã dừng chơi thử.
tools/scenario_editor/stop = ■ Dừng
tools/scenario_editor/open_file = Mở bằng trình sửa ngoài
tools/scenario_editor/open_file_tip = Mở tệp bằng chương trình mặc định của hệ thống
tools/scenario_editor/open_file_error = Không mở được tệp
tools/scenario_editor/rescan = Quét lại
tools/scenario_editor/rescan_tip = Đọc lại danh sách kịch bản và đồ thị (sau khi thêm tệp)
tools/scenario_editor/rescanned = Đã làm mới danh sách kịch bản.
tools/scenario_editor/search_files = Tìm tệp…
tools/scenario_editor/tab_files = Tệp
tools/scenario_editor/tab_info = Tóm tắt
tools/scenario_editor/tab_graph = Đồ thị cảnh
tools/scenario_editor/graph_select_node = Chọn một cảnh trên đồ thị để xem các kịch bản và chuyển cảnh của nó.
tools/scenario_editor/graph_node = mod {0} · kịch bản {1}
tools/scenario_editor/graph_node_bg = phông nền: {0}
tools/scenario_editor/graph_detail_mod = mod: {0}
tools/scenario_editor/graph_detail_scenarios = Kịch bản ({0}), theo ưu tiên:
tools/scenario_editor/info_scene = Cảnh
tools/scenario_editor/info_priority = Ưu tiên
tools/scenario_editor/info_condition = Điều kiện
tools/scenario_editor/info_switch_targets = Chuyển cảnh
tools/scenario_editor/info_locale_keys = Khóa bản địa hóa
tools/scenario_editor/info_quest_refs = Nhiệm vụ
tools/scenario_editor/bg_previews = Phông nền của kịch bản
tools/scenario_editor/bg_missing = Không tìm thấy phông nền: {0}
tools/scenario_editor/no_backgrounds = Kịch bản không đổi phông nền
tools/scenario_editor/save_error = Lỗi lưu: {0}
tools/scenario_editor/readonly_zip = Tệp của mod zip là chỉ đọc
tools/scenario_editor/no_scene_error = Phần đầu kịch bản không có cảnh (scene:) — không có gì để chơi.
tools/scenario_editor/unsaved_title = Thay đổi chưa lưu
tools/scenario_editor/unsaved_switch_text = Thẻ này có thay đổi chưa lưu. Đóng mà không lưu?
tools/scene_builder/title = Trình dựng cảnh
tools/scene_builder/hint = Kéo nút bằng thân hoặc tay cầm · nhấp — chọn nút dưới con trỏ · Del — xóa nút · Ctrl+S — lưu và dựng lại
tools/scene_builder/base_scene = Cảnh nền mà cảnh của mod hiển thị lên trên
tools/scene_builder/mod = Mod
tools/scene_builder/scene = Cảnh của mod (.tscn)
tools/scene_builder/empty_scene = (cảnh nền trống)
tools/scene_builder/select_mod = — chọn mod —
tools/scene_builder/select_scene = — chọn cảnh —
tools/scene_builder/save_tip = Lưu .tscn và dựng lại bản xem trước (Ctrl+S)
tools/scene_builder/reload = Tải lại từ đĩa
tools/scene_builder/reload_tip = Bỏ các chỉnh sửa chưa lưu và đọc lại tệp cảnh
tools/scene_builder/reloaded = Đã tải lại cảnh từ đĩa.
tools/scene_builder/saved_ok = Đã lưu.
tools/scene_builder/saved_rebuilt = Đã lưu, bản xem trước đã được dựng lại.
tools/scene_builder/autosaved = Đã tự động lưu.
tools/scene_builder/error_save = Lỗi lưu: mod chỉ đọc.
tools/scene_builder/error_no_scene = Hãy chọn một cảnh của mod trước.
tools/scene_builder/hierarchy = Phân cấp
tools/scene_builder/hierarchy_hint = Vàng — gốc cảnh của mod, sáng — nút sửa được, mờ — cảnh nền (chỉ đọc).
tools/scene_builder/node_type_tip = Loại của nút mới
tools/scene_builder/add_node_tip = Thêm một nút thuộc loại đã chọn vào nút đang chọn (hoặc gốc cảnh)
tools/scene_builder/delete_node_tip = Xóa nút đang chọn của cảnh mod
tools/scene_builder/delete_node_title = Xóa nút
tools/scene_builder/delete_node_confirm = Xóa nút đang chọn cùng các nút con? Tệp được lưu tự động.
tools/scene_builder/refresh_tip = Làm mới cây nút
tools/scene_builder/tab_inspector = Thuộc tính
tools/scene_builder/tab_tscn = Mã nguồn .tscn
tools/scene_builder/tscn_hint = Chỉnh sửa ở thanh thuộc tính và tay cầm tự lưu; sửa tay văn bản thì lưu bằng Ctrl+S, đồng thời dựng lại bản xem trước.
tools/scene_builder/inspector_cat_container = Trong vùng chứa
tools/scene_builder/inspector_select = Chọn một nút của cảnh mod để sửa thuộc tính
tools/scene_builder/inspector_readonly = Chỉ đọc: nút thuộc cảnh nền
tools/scene_builder/inspector_missing_section = Không tìm thấy phần của nút trong tscn (nút của cảnh con được thể hiện chưa sửa được)
tools/scene_builder/inspector_preset = Mẫu neo
tools/scene_builder/inspector_cat_layout = Bố cục
tools/scene_builder/inspector_cat_rect = Hình chữ nhật
tools/scene_builder/inspector_cat_visibility = Hiển thị
tools/scene_builder/inspector_cat_misc = Khác
tools/scene_builder/inspector_bad_value = Không thể ghi giá trị vào tscn.
tools/scene_builder/inspector_in_file_tooltip = Được lưu trong tệp tscn
tools/scene_builder/inspector_container_banner = Nút này do vùng chứa quản lý: neo và độ lệch được nút cha tính lại nên chỉnh ở đây sẽ không có tác dụng.
tools/scene_builder/err_add_failed = Lỗi: không tạo được nút loại này.
tools/scene_builder/err_add_parent_missing = Lỗi: không tìm thấy phần của nút cha trong tscn (không hỗ trợ nút cha của cảnh con được thể hiện).
tools/scene_builder/err_del_root = Lỗi: không thể xóa gốc cảnh.
tools/scene_builder/err_del_none = Lỗi: hãy chọn một nút của mod để xóa.
tools/interactive_editor/title = Trình sửa tương tác
tools/interactive_editor/hint = Nhấp vào vùng va chạm — chọn · kéo tay cầm · ở chế độ đa giác, nhấp vào nền để thêm đỉnh, chuột phải vào đỉnh để xóa · Del — xóa đối tượng · Ctrl+S — lưu
tools/interactive_editor/items = Đối tượng trong tệp
tools/interactive_editor/add = + Mới
tools/interactive_editor/duplicate = Nhân bản
tools/interactive_editor/delete = Xóa đối tượng
tools/interactive_editor/delete_confirm = Gỡ đối tượng tương tác đã chọn khỏi tệp? Thay đổi sẽ vào tệp khi lưu.
tools/interactive_editor/deleted = Đã gỡ đối tượng "{0}".
tools/interactive_editor/background = Phông nền xem trước
tools/interactive_editor/props = Thuộc tính
tools/interactive_editor/none_selected = Chọn một đối tượng trong danh sách hoặc trên bản xem trước.
tools/interactive_editor/label_id = ID
tools/interactive_editor/label_texture_key = Texture
tools/interactive_editor/texture_placeholder = không có sprite
tools/interactive_editor/texture_tip = ID texture của sprite; để trống — vùng nhấp vô hình
tools/interactive_editor/label_layer = Lớp
tools/interactive_editor/label_z = Thứ tự Z
tools/interactive_editor/label_cursor = Con trỏ
tools/interactive_editor/cursor_none = (mặc định)
tools/interactive_editor/enabled = Bật
tools/interactive_editor/visible = Hiển thị
tools/interactive_editor/collider = Vùng va chạm
tools/interactive_editor/label_collider_type = Hình dạng
tools/interactive_editor/hit_rect = hình chữ nhật
tools/interactive_editor/hit_circle = hình tròn
tools/interactive_editor/hit_polygon = đa giác
tools/interactive_editor/label_x = X
tools/interactive_editor/label_y = Y
tools/interactive_editor/label_w = Chiều rộng
tools/interactive_editor/label_h = Chiều cao
tools/interactive_editor/label_cx = Tâm X
tools/interactive_editor/label_cy = Tâm Y
tools/interactive_editor/label_r = Bán kính
tools/interactive_editor/polygon_hint = Nhấp vào nền để thêm đỉnh, chuột phải vào đỉnh để xóa. Ít nhất 3 đỉnh.
tools/interactive_editor/polygon_too_small = Đa giác "{0}" có ít hơn 3 đỉnh.
tools/interactive_editor/highlight = Làm nổi bật
tools/interactive_editor/label_highlight_color = Màu
tools/interactive_editor/label_highlight_scale = Tỉ lệ
tools/interactive_editor/actions = Hành động
tools/interactive_editor/label_onclick_scenario = Kịch bản
tools/interactive_editor/label_onclick_commands = Lệnh khi nhấp (mỗi dòng một lệnh)
tools/interactive_editor/label_hover_enter = Khi rê chuột vào
tools/interactive_editor/label_hover_leave = Khi rê chuột ra
tools/interactive_editor/id_empty = Có đối tượng để trống ID.
tools/interactive_editor/duplicate_id = Trùng ID đối tượng:
tools/light_editor/title = Trình sửa ánh sáng
tools/light_editor/hint = Nhấp đúp vào nền — đèn mới · kéo các đèn · Del — xóa · Ctrl+S — ghi light_set vào kịch bản
tools/light_editor/scenario_tip = Kịch bản có dòng light_set được sửa (● — đã đặt đèn)
tools/light_editor/pick_scenario = — chọn kịch bản —
tools/light_editor/save_tip = Ghi dòng light_set vào kịch bản (Ctrl+S)
tools/light_editor/copy_line = Sao chép dòng
tools/light_editor/copy_line_tip = Sao chép lệnh light_set vào bộ nhớ tạm
tools/light_editor/copied = Đã sao chép dòng light_set.
tools/light_editor/show_line = light_set
tools/light_editor/loaded = Đã tải đèn của {0}.
tools/light_editor/no_lights_yet = {0} chưa có light_set — hãy thêm đèn, dòng sẽ được chèn sau phần đầu.
tools/light_editor/static_lights = Đèn
tools/light_editor/add_light = + Đèn
tools/light_editor/delete = Xóa
tools/light_editor/limit = Tối đa {0} đèn tĩnh.
tools/light_editor/selected_props = Đèn đã chọn
tools/light_editor/none_selected = Chọn một đèn trong danh sách hoặc trên bản xem trước.
tools/light_editor/x = X (0–1)
tools/light_editor/y = Y (0–1)
tools/light_editor/radius = Bán kính
tools/light_editor/radius_tip = Bán kính tính bằng điểm ảnh của màn hình game rộng 1920 px
tools/light_editor/influence = Cường độ
tools/light_editor/influence_tip = Mức ảnh hưởng của đèn (0–1)
tools/light_editor/color = Màu
tools/light_editor/background = Phông nền xem trước
tools/catalog_export/hint = Tệp nén và mã băm được tính khi xuất; JSON được ghi cạnh tệp nén của mod
tools/catalog_export/mod_info = Mod
tools/catalog_export/id = ID
tools/catalog_export/name = Tên
tools/catalog_export/metadata = Danh mục
tools/catalog_export/locales = Bản địa hóa
tools/catalog_export/locales_hint = Nếu không có dòng nào thì dùng tên và mô tả tiếng Anh trong manifest.
tools/catalog_export/remove_locale = Bỏ ngôn ngữ
tools/catalog_export/preview = JSON kết quả
tools/catalog_export/copied = Đã sao chép JSON vào bộ nhớ tạm.
tools/catalog_export/export_tip = Tạo tệp nén, tính mã băm và ghi JSON của mục danh mục
tools/catalog_export/exported = Đã xuất: {0}
tools/catalog_export/export_error = Lỗi xuất
tools/catalog_export/url_required = Hãy đặt URL tải xuống — thiếu nó thì mục danh mục vô dụng.
tools/scene_editor/title = Trình sửa cảnh
tools/scene_editor/hint = Cảnh là một địa điểm: phông nền, âm thanh nền và kịch bản. Ctrl+S để lưu · nhấp đúp vào một phông nền trong danh sách để thêm
tools/scene_editor/save_tip = Lưu tệp cảnh vào mod đã chọn (Ctrl+S); game thấy thay đổi ngay
tools/scene_editor/new_scene = + Cảnh mới
tools/scene_editor/new_scene_tip = Tạo một địa điểm mới: tên, mod, phông nền. Có thể thêm kịch bản sau; cảnh cũng có thể không có kịch bản nào
tools/scene_editor/delete = Xóa
tools/scene_editor/delete_tip = Xóa tệp cảnh của mod đã chọn
tools/scene_editor/delete_confirm = Xóa tệp của cảnh này khỏi mod đã chọn? Các kịch bản của cảnh vẫn được giữ.
tools/scene_editor/go = ▶ Đến cảnh
tools/scene_editor/go_tip = Chuyển game sang cảnh này
tools/scene_editor/rescan_tip = Đọc lại cảnh, phông nền và âm thanh (sau khi thêm tệp vào mod)
tools/scene_editor/scenes = Cảnh
tools/scene_editor/backgrounds = Phông nền của cảnh (mỗi lần vào chọn ngẫu nhiên một cái)
tools/scene_editor/all_backgrounds = Tất cả phông nền
tools/scene_editor/add_background = + Thêm phông nền vào cảnh
tools/scene_editor/add_background_tip = Thêm phông nền đang hiển thị vào cảnh (hoặc nhấp đúp vào nó trong danh sách)
tools/scene_editor/remove_background = Bỏ phông nền khỏi cảnh
tools/scene_editor/properties = Thuộc tính
tools/scene_editor/name = Tên
tools/scene_editor/name_tip = Id của cảnh: chữ Latin, chữ số và _; switch_scene và phần đầu kịch bản tham chiếu tới nó. Chỉ đặt khi tạo
tools/scene_editor/mod = Lưu vào
tools/scene_editor/mod_tip = Mod mà tệp cảnh được ghi vào (chỉ mod dạng thư mục)
tools/scene_editor/ambient = Âm thanh
tools/scene_editor/ambient_tip = Âm thanh nền phát trong cảnh
tools/scene_editor/listen = ▶
tools/scene_editor/listen_tip = Nghe âm thanh nền
tools/scene_editor/stop = ■
tools/scene_editor/default_scenario = Kịch bản mặc định
tools/scene_editor/default_scenario_tip = Phát khi cảnh không còn kịch bản nào khác
tools/scene_editor/scenarios = Kịch bản của cảnh
tools/scene_editor/open_scenario = Mở
tools/scene_editor/open_scenario_tip = Mở kịch bản đã chọn trong trình sửa kịch bản (hoặc nhấp đúp)
tools/scene_editor/new_scenario = + Kịch bản mới
tools/scene_editor/new_scenario_tip = Tạo một kịch bản của cảnh này trong mod đã chọn và mở nó
tools/scene_editor/none = — không có —
tools/scene_editor/effective = Trong game: {0} (từ mod {1})
tools/scene_editor/adds_to = Cảnh được định nghĩa trong: {0}. Khi lưu sẽ tạo trong mod đã chọn một tệp bổ sung cho nó.
tools/scene_editor/also_in = Cũng được bổ sung bởi: {0}
tools/scene_editor/no_writable_mod = Không có mod dạng thư mục nào để lưu cảnh.
tools/scene_editor/bad_name = Hãy đặt tên cảnh: chữ Latin, chữ số và _ (bắt đầu bằng chữ cái).
tools/scene_editor/name_taken = Cảnh "{0}" đã tồn tại; hãy chọn nó trong danh sách.
tools/scene_editor/need_background = Hãy thêm ít nhất một phông nền cho cảnh.
tools/scene_editor/created = Đã tạo cảnh "{0}".
tools/scene_editor/deleted = Đã xóa: {0}
tools/scene_editor/opened = Cảnh: {0}
tools/scene_editor/new_started = Cảnh mới: đặt tên và mod, thêm phông nền.
tools/scene_editor/already_there = Phông nền {0} đã có trong cảnh.
tools/scene_editor/go_failed = Game không biết cảnh "{0}"; hãy lưu nó trước.
tools/scene_editor/went = Đã chuyển sang cảnh {0}.
tools/scene_editor/rescanned = Đã đọc lại cảnh, phông nền và âm thanh.
tools/scene_editor/scenario_created = Đã tạo kịch bản {0}.
tools/scene_editor/no_scenarios = Cảnh chưa có kịch bản nào
tools/scene_editor/no_backgrounds = Cảnh chưa có phông nền: hãy chọn một cái trong danh sách bên dưới và thêm vào.
tools/scene_editor/pick_scene = Chọn một cảnh bên trái hoặc tạo cảnh mới.
tools/scene_editor/preview_hint = Chọn một phông nền để xem.
tools/scene_editor/not_in_scene = (không có trong cảnh)
tools/scene_editor/new_line_text = Một cảnh mới. Hãy thay dòng này bằng văn bản của bạn.
tools/entity_editor/hint = Bên trái là danh sách có tìm kiếm, bên phải là các trường của mục đã chọn · Ctrl+N mới, Ctrl+D sao chép · Ctrl+Z/Y hoàn tác/làm lại · Ctrl+S lưu, game thấy thay đổi ngay
tools/entity_editor/section_list = Các mục của mod
tools/entity_editor/search = Tìm theo tên hoặc id
tools/entity_editor/mod_tip = Mod có các mục đang được sửa; mục mới được ghi vào tệp của nó
tools/entity_editor/new = Mới
tools/entity_editor/new_tip = Thêm một mục vào mod đã chọn (Ctrl+N)
tools/entity_editor/duplicate = Sao chép
tools/entity_editor/duplicate_tip = Sao chép mục đã chọn với id mới (Ctrl+D)
tools/entity_editor/delete = Xóa
tools/entity_editor/delete_tip = Gỡ mục đã chọn khỏi tệp của mod
tools/entity_editor/delete_title = Xóa mục này?
tools/entity_editor/delete_text = Mục sẽ rời khỏi tệp của mod khi lưu. Các tham chiếu tới nó trong kịch bản và bản đồ vẫn còn — hãy kiểm tra chúng.
tools/entity_editor/undo = Hoàn tác
tools/entity_editor/undo_tip = Hoàn tác thay đổi cuối (Ctrl+Z)
tools/entity_editor/redo = Làm lại
tools/entity_editor/redo_tip = Làm lại thao tác đã hoàn tác (Ctrl+Y)
tools/entity_editor/no_entity = Chọn một mục bên trái hoặc tạo mới bằng "Mới".
tools/entity_editor/file = Mod {0} · tệp {1}
tools/entity_editor/id = Id
tools/entity_editor/id_tip = Id duy nhất: kịch bản, bản đồ và các mod khác tìm mục qua nó. Một mod phụ thuộc vào chủ sở hữu và có cùng id sẽ thay thế mục đó
tools/entity_editor/locale_key_tip = Khóa bản địa hóa; văn bản bên dưới được ghi vào locale/*.properties của mod
tools/entity_editor/text_in = văn bản ({0})
tools/entity_editor/picture_pick_tip = Chọn một hình từ các thư mục của mod
tools/entity_editor/picture_pick_title = Hình của nhóm "{0}"
tools/entity_editor/section_other = Các trường khác
tools/entity_editor/other_hint = Các trường mà biểu mẫu không hiển thị (dữ liệu của mod khác, chiến lợi phẩm…) dưới dạng JSON. Chỉnh sửa có hiệu lực ngay khi JSON hợp lệ.
tools/entity_editor/other_invalid = Các trường khác: JSON không hợp lệ — {0}
tools/entity_editor/other_applied = Đã áp dụng các trường khác
tools/entity_editor/opened = {0} mục trong mod {1}
tools/entity_editor/added = Đã thêm mục {0} vào {1}
tools/entity_editor/deleted = Đã xóa mục {0} (lưu để ghi lại)
tools/entity_editor/saved = Đã lưu: {0} tệp, {1} văn bản trong mod {2}; game đã đọc lại dữ liệu
tools/entity_editor/broken_files = Không đọc các tệp có JSON hỏng: {0}
tools/entity_editor/problem_id = Một mục trong tệp {0} để trống id
tools/entity_editor/problem_duplicate = Id {0} xuất hiện nhiều lần
+337
View File
@@ -0,0 +1,337 @@
mod_editor_tools_name = 编辑工具
mod_editor_tools_desc = 运行中的游戏之上的创作窗口:场景、剧本、地图、交互物和光照编辑器,以及 Godot 场景构建器。保存的都是普通模组文件,游戏会立即生效。
tools/common/save = 保存
tools/common/save_tip = 保存文件(Ctrl+S)
tools/common/saved = 已保存:{0}
tools/common/loaded = 已打开:{0}
tools/common/load_error = 加载错误
tools/common/readonly = 无法保存:模组是只读的(zip)或路径无效。
tools/common/no_writable_mods = 没有可写的模组(只能编辑文件夹形式的模组)。
tools/common/no_files = 模组"{0}"还没有这类文件——用"+"创建一个。
tools/common/no_file_to_save = 请先选择或创建一个文件(文件列表旁的"+")。
tools/common/new_file = 新文件
tools/common/new_file_hint = 首次保存时会在 {0}/{1}/ 中创建扩展名为 {2} 的文件。
tools/common/new_file_status = 新文件 {0}——保存时创建。
tools/common/mod_tip = 模组(已有此类文件的模组排在前面;· —— 还没有)
tools/common/file_tip = 模组内的文件
tools/common/search = 搜索…
tools/common/json = JSON
tools/common/json_tip = 显示生成文件的 JSON
tools/common/background_missing = 场景"{1}"的背景"{0}"没有任何模组提供——请手动选择一个。
tools/map_editor/title = 地图编辑器
tools/map_editor/hint = 左键——选择 / 拖动 · 右键——删除顶点 · 双击、Enter 或 Esc——完成区域 · Del——删除区域 · Ctrl+S——保存
tools/map_editor/reload_map = 刷新游戏内地图
tools/map_editor/reload_map_tip = 在运行中的游戏里重新读取已保存的地图文件
tools/map_editor/reloaded = 游戏内地图已刷新。
tools/map_editor/select_mode = 选择
tools/map_editor/draw_region = 绘制
tools/map_editor/draw_started = 点击地图放置顶点。双击、Enter 或 Esc 完成,右键删除最后一个。
tools/map_editor/background = 预览背景
tools/map_editor/zones = 区域
tools/map_editor/add_region = + 区域
tools/map_editor/add_region_tip = 新区域:点击地图绘制多边形
tools/map_editor/add_marker = + 标记
tools/map_editor/add_label = + 文字标签
tools/map_editor/delete = 删除区域
tools/map_editor/delete_tip = 删除选中的区域(Del)
tools/map_editor/delete_confirm = 删除选中的区域?保存时更改会写入文件。
tools/map_editor/deleted = 区域"{0}"已删除。
tools/map_editor/props = 区域属性
tools/map_editor/zone_missing = 在列表或地图上选择一个区域。
tools/map_editor/id = ID
tools/map_editor/id_empty = 区域 ID 不能为空。
tools/map_editor/duplicate_id = 区域 ID 已被使用:
tools/map_editor/title_key = 标题
tools/map_editor/description = 描述
tools/map_editor/no_translation = 在本地化中找不到该键
tools/map_editor/scene = 场景
tools/map_editor/scene_none = (无)
tools/map_editor/availability = 可用性
tools/map_editor/locked = 禁止前往
tools/map_editor/hidden = 在地图上隐藏
tools/map_editor/effective = 游戏中:
tools/map_editor/effective_open = 开放
tools/map_editor/effective_locked = 锁定(Locked 标志)
tools/map_editor/effective_no_scene = 锁定(没有场景)
tools/map_editor/effective_unknown_scene = 锁定(找不到场景)
tools/map_editor/effective_hidden = 在地图上隐藏
tools/map_editor/marker = 标记
tools/map_editor/image = 图标
tools/map_editor/image_placeholder = 纹理 ID
tools/map_editor/icon_search = 查找图标…
tools/map_editor/label_section = 文字标签
tools/map_editor/label = 地图上的文字标签
tools/map_editor/x = X(0–1)
tools/map_editor/y = Y(0–1)
tools/map_editor/vertices = 多边形顶点
tools/map_editor/vertices_hint = 在地图上拖动顶点;右键点击顶点将其删除。
tools/map_editor/add_vertex = + 顶点
tools/map_editor/delete_vertex = 删除顶点
tools/map_editor/preview = ▶ 游戏中效果
tools/map_editor/preview_tip = 以游戏中的样子查看地图:悬停、区域卡片、标记(包括未保存的修改)
tools/map_editor/preview_on = 预览:Esc 或 ✕ 返回编辑器。
tools/map_editor/preview_off = 编辑模式。
tools/map_editor/preview_missing = 无法加载地图场景(map_widget.tscn)。
tools/scenario_editor/title = 剧本编辑器
tools/scenario_editor/hint = Ctrl+S——保存 · F5——试玩剧本 · 点击图中的场景——查看其剧本
tools/scenario_editor/play = ▶ 试玩
tools/scenario_editor/play_tooltip = 保存并在游戏中试玩剧本(F5)
tools/scenario_editor/play_stopped = 试玩已停止。
tools/scenario_editor/stop = ■ 停止
tools/scenario_editor/open_file = 在外部编辑器中打开
tools/scenario_editor/open_file_tip = 用系统默认程序打开文件
tools/scenario_editor/open_file_error = 无法打开文件
tools/scenario_editor/rescan = 重新扫描
tools/scenario_editor/rescan_tip = 重新读取剧本列表和图(添加文件后)
tools/scenario_editor/rescanned = 剧本列表已刷新。
tools/scenario_editor/search_files = 查找文件…
tools/scenario_editor/tab_files = 文件
tools/scenario_editor/tab_info = 概要
tools/scenario_editor/tab_graph = 场景图
tools/scenario_editor/graph_select_node = 在图中选择一个场景,查看它的剧本和转场。
tools/scenario_editor/graph_node = 模组 {0} · 剧本 {1}
tools/scenario_editor/graph_node_bg = 背景:{0}
tools/scenario_editor/graph_detail_mod = 模组:{0}
tools/scenario_editor/graph_detail_scenarios = 剧本({0}),按优先级:
tools/scenario_editor/info_scene = 场景
tools/scenario_editor/info_priority = 优先级
tools/scenario_editor/info_condition = 条件
tools/scenario_editor/info_switch_targets = 场景切换
tools/scenario_editor/info_locale_keys = 本地化键
tools/scenario_editor/info_quest_refs = 任务
tools/scenario_editor/bg_previews = 剧本的背景
tools/scenario_editor/bg_missing = 找不到背景:{0}
tools/scenario_editor/no_backgrounds = 这个剧本不更换背景
tools/scenario_editor/save_error = 保存错误:{0}
tools/scenario_editor/readonly_zip = zip 模组中的文件是只读的
tools/scenario_editor/no_scene_error = 剧本头部没有场景(scene:)——没有可试玩的内容。
tools/scenario_editor/unsaved_title = 未保存的更改
tools/scenario_editor/unsaved_switch_text = 这个标签页有未保存的更改。不保存就关闭吗?
tools/scene_builder/title = 场景构建器
tools/scene_builder/hint = 拖动节点本体或手柄 · 点击——选择光标下的节点 · Del——删除节点 · Ctrl+S——保存并重建
tools/scene_builder/base_scene = 显示模组场景的基础场景
tools/scene_builder/mod = 模组
tools/scene_builder/scene = 模组场景(.tscn)
tools/scene_builder/empty_scene = (空的基础场景)
tools/scene_builder/select_mod = — 选择模组 —
tools/scene_builder/select_scene = — 选择场景 —
tools/scene_builder/save_tip = 保存 .tscn 并重建预览(Ctrl+S)
tools/scene_builder/reload = 从磁盘重新加载
tools/scene_builder/reload_tip = 放弃未保存的修改并重新读取场景文件
tools/scene_builder/reloaded = 已从磁盘重新加载场景。
tools/scene_builder/saved_ok = 已保存。
tools/scene_builder/saved_rebuilt = 已保存,预览已重建。
tools/scene_builder/autosaved = 已自动保存。
tools/scene_builder/error_save = 保存错误:模组是只读的。
tools/scene_builder/error_no_scene = 请先选择一个模组场景。
tools/scene_builder/hierarchy = 层级
tools/scene_builder/hierarchy_hint = 金色——模组场景根节点,亮色——可编辑的节点,暗色——基础场景(只读)。
tools/scene_builder/node_type_tip = 新节点的类型
tools/scene_builder/add_node_tip = 在选中的节点(或场景根节点)中添加所选类型的节点
tools/scene_builder/delete_node_tip = 删除选中的模组场景节点
tools/scene_builder/delete_node_title = 删除节点
tools/scene_builder/delete_node_confirm = 删除选中的节点及其子节点?文件会自动保存。
tools/scene_builder/refresh_tip = 刷新节点树
tools/scene_builder/tab_inspector = 检查器
tools/scene_builder/tab_tscn = .tscn 源码
tools/scene_builder/tscn_hint = 检查器和手柄的修改会自动保存;手动修改文本需用 Ctrl+S 保存,同时会重建预览。
tools/scene_builder/inspector_cat_container = 在容器中
tools/scene_builder/inspector_select = 选择模组场景中的一个节点来编辑其属性
tools/scene_builder/inspector_readonly = 只读:该节点属于基础场景
tools/scene_builder/inspector_missing_section = 在 tscn 中找不到节点段(实例化子场景中的节点暂不能编辑)
tools/scene_builder/inspector_preset = 锚点预设
tools/scene_builder/inspector_cat_layout = 布局
tools/scene_builder/inspector_cat_rect = 矩形
tools/scene_builder/inspector_cat_visibility = 可见性
tools/scene_builder/inspector_cat_misc = 其他
tools/scene_builder/inspector_bad_value = 该值无法写入 tscn。
tools/scene_builder/inspector_in_file_tooltip = 存储在 tscn 文件中
tools/scene_builder/inspector_container_banner = 这个节点由容器管理:锚点和偏移由父节点重新计算,在这里修改不会生效。
tools/scene_builder/err_add_failed = 错误:无法创建这种类型的节点。
tools/scene_builder/err_add_parent_missing = 错误:在 tscn 中找不到父节点段(不支持实例化子场景的父节点)。
tools/scene_builder/err_del_root = 错误:不能删除场景根节点。
tools/scene_builder/err_del_none = 错误:请选择一个要删除的模组节点。
tools/interactive_editor/title = 交互编辑器
tools/interactive_editor/hint = 点击碰撞体——选择 · 拖动手柄 · 多边形模式下点击背景添加顶点,右键点击顶点将其删除 · Del——删除对象 · Ctrl+S——保存
tools/interactive_editor/items = 文件中的对象
tools/interactive_editor/add = + 新建
tools/interactive_editor/duplicate = 复制
tools/interactive_editor/delete = 删除对象
tools/interactive_editor/delete_confirm = 从文件中移除选中的交互物?保存时更改会写入文件。
tools/interactive_editor/deleted = 已移除对象"{0}"。
tools/interactive_editor/background = 预览背景
tools/interactive_editor/props = 属性
tools/interactive_editor/none_selected = 在列表或预览中选择一个对象。
tools/interactive_editor/label_id = ID
tools/interactive_editor/label_texture_key = 纹理
tools/interactive_editor/texture_placeholder = 无精灵图
tools/interactive_editor/texture_tip = 精灵图的纹理 ID;为空——不可见的可点击区域
tools/interactive_editor/label_layer = 图层
tools/interactive_editor/label_z = Z 顺序
tools/interactive_editor/label_cursor = 光标
tools/interactive_editor/cursor_none = (默认)
tools/interactive_editor/enabled = 启用
tools/interactive_editor/visible = 可见
tools/interactive_editor/collider = 碰撞体
tools/interactive_editor/label_collider_type = 形状
tools/interactive_editor/hit_rect = 矩形
tools/interactive_editor/hit_circle = 圆形
tools/interactive_editor/hit_polygon = 多边形
tools/interactive_editor/label_x = X
tools/interactive_editor/label_y = Y
tools/interactive_editor/label_w = 宽度
tools/interactive_editor/label_h = 高度
tools/interactive_editor/label_cx = 中心 X
tools/interactive_editor/label_cy = 中心 Y
tools/interactive_editor/label_r = 半径
tools/interactive_editor/polygon_hint = 点击背景添加顶点,右键点击顶点将其删除。至少 3 个顶点。
tools/interactive_editor/polygon_too_small = 多边形"{0}"的顶点少于 3 个。
tools/interactive_editor/highlight = 高亮
tools/interactive_editor/label_highlight_color = 颜色
tools/interactive_editor/label_highlight_scale = 缩放
tools/interactive_editor/actions = 动作
tools/interactive_editor/label_onclick_scenario = 剧本
tools/interactive_editor/label_onclick_commands = 点击时的命令(每行一条)
tools/interactive_editor/label_hover_enter = 鼠标移入
tools/interactive_editor/label_hover_leave = 鼠标移出
tools/interactive_editor/id_empty = 有对象的 ID 为空。
tools/interactive_editor/duplicate_id = 对象 ID 重复:
tools/light_editor/title = 光照编辑器
tools/light_editor/hint = 双击背景——新建灯光 · 拖动灯光 · Del——删除 · Ctrl+S——把 light_set 写入剧本
tools/light_editor/scenario_tip = 要编辑其 light_set 行的剧本(●——已设置灯光)
tools/light_editor/pick_scenario = — 选择剧本 —
tools/light_editor/save_tip = 把 light_set 行写入剧本(Ctrl+S)
tools/light_editor/copy_line = 复制行
tools/light_editor/copy_line_tip = 把 light_set 命令复制到剪贴板
tools/light_editor/copied = 已复制 light_set 行。
tools/light_editor/show_line = light_set
tools/light_editor/loaded = 已加载 {0} 的灯光。
tools/light_editor/no_lights_yet = {0} 还没有 light_set——添加灯光后,这一行会插在头部之后。
tools/light_editor/static_lights = 灯光
tools/light_editor/add_light = + 灯光
tools/light_editor/delete = 删除
tools/light_editor/limit = 最多 {0} 个静态灯光。
tools/light_editor/selected_props = 选中的灯光
tools/light_editor/none_selected = 在列表或预览中选择一个灯光。
tools/light_editor/x = X(0–1)
tools/light_editor/y = Y(0–1)
tools/light_editor/radius = 半径
tools/light_editor/radius_tip = 以 1920 像素宽的游戏画面计的半径(像素)
tools/light_editor/influence = 强度
tools/light_editor/influence_tip = 灯光影响(0–1)
tools/light_editor/color = 颜色
tools/light_editor/background = 预览背景
tools/catalog_export/hint = 归档和哈希在导出时计算;JSON 写在模组归档旁边
tools/catalog_export/mod_info = 模组
tools/catalog_export/id = ID
tools/catalog_export/name = 名称
tools/catalog_export/metadata = 目录
tools/catalog_export/locales = 本地化
tools/catalog_export/locales_hint = 没有任何行时,使用清单中的英文名称和描述。
tools/catalog_export/remove_locale = 移除语言
tools/catalog_export/preview = 生成的 JSON
tools/catalog_export/copied = JSON 已复制到剪贴板。
tools/catalog_export/export_tip = 打包归档、计算哈希并写入目录条目的 JSON
tools/catalog_export/exported = 已导出:{0}
tools/catalog_export/export_error = 导出错误
tools/catalog_export/url_required = 请设置下载 URL——没有它的目录条目毫无用处。
tools/scene_editor/title = 场景编辑器
tools/scene_editor/hint = 场景就是一个地点:背景、环境音和剧本。Ctrl+S 保存 · 双击列表中的背景将其添加
tools/scene_editor/save_tip = 把场景文件保存到所选模组(Ctrl+S);游戏会立即看到更改
tools/scene_editor/new_scene = + 新场景
tools/scene_editor/new_scene_tip = 创建一个新地点:名称、模组、背景。剧本可以以后再加;场景也可以没有剧本
tools/scene_editor/delete = 删除
tools/scene_editor/delete_tip = 删除所选模组中的场景文件
tools/scene_editor/delete_confirm = 从所选模组中删除这个场景的文件?场景的剧本会保留。
tools/scene_editor/go = ▶ 前往场景
tools/scene_editor/go_tip = 把游戏切换到这个场景
tools/scene_editor/rescan_tip = 重新读取场景、背景和声音(向模组添加文件后)
tools/scene_editor/scenes = 场景
tools/scene_editor/backgrounds = 场景背景(进入时随机选一个)
tools/scene_editor/all_backgrounds = 所有背景
tools/scene_editor/add_background = + 把背景加入场景
tools/scene_editor/add_background_tip = 把显示的背景加入场景(或在列表中双击它)
tools/scene_editor/remove_background = 从场景中移除背景
tools/scene_editor/properties = 属性
tools/scene_editor/name = 名称
tools/scene_editor/name_tip = 场景 id:拉丁字母、数字和 _;switch_scene 和剧本头部会引用它。只能在创建时设置
tools/scene_editor/mod = 保存到
tools/scene_editor/mod_tip = 写入场景文件的模组(仅限文件夹模组)
tools/scene_editor/ambient = 声音
tools/scene_editor/ambient_tip = 场景中播放的环境音
tools/scene_editor/listen = ▶
tools/scene_editor/listen_tip = 试听环境音
tools/scene_editor/stop = ■
tools/scene_editor/default_scenario = 默认剧本
tools/scene_editor/default_scenario_tip = 当场景没有其他剧本剩下时播放
tools/scene_editor/scenarios = 场景的剧本
tools/scene_editor/open_scenario = 打开
tools/scene_editor/open_scenario_tip = 在剧本编辑器中打开选中的剧本(或双击)
tools/scene_editor/new_scenario = + 新剧本
tools/scene_editor/new_scenario_tip = 在所选模组中创建本场景的剧本并打开
tools/scene_editor/none = — 无 —
tools/scene_editor/effective = 游戏中:{0}(来自模组 {1})
tools/scene_editor/adds_to = 场景定义在:{0}。保存时会在所选模组中创建一个补充它的文件。
tools/scene_editor/also_in = 也补充它的有:{0}
tools/scene_editor/no_writable_mod = 没有可以保存场景的文件夹模组。
tools/scene_editor/bad_name = 请设置场景名称:拉丁字母、数字和 _(以字母开头)。
tools/scene_editor/name_taken = 场景"{0}"已存在;请在列表中选择它。
tools/scene_editor/need_background = 请至少给场景添加一个背景。
tools/scene_editor/created = 已创建场景"{0}"。
tools/scene_editor/deleted = 已删除:{0}
tools/scene_editor/opened = 场景:{0}
tools/scene_editor/new_started = 新场景:设置名称和模组,添加背景。
tools/scene_editor/already_there = 背景 {0} 已在场景中。
tools/scene_editor/go_failed = 游戏不认识场景"{0}";请先保存它。
tools/scene_editor/went = 已切换到场景 {0}。
tools/scene_editor/rescanned = 已重新读取场景、背景和声音。
tools/scene_editor/scenario_created = 已创建剧本 {0}。
tools/scene_editor/no_scenarios = 这个场景还没有剧本
tools/scene_editor/no_backgrounds = 这个场景没有背景:在下方列表中选一个加入。
tools/scene_editor/pick_scene = 在左边选择一个场景,或创建一个新场景。
tools/scene_editor/preview_hint = 选择一个背景来查看。
tools/scene_editor/not_in_scene = (不在场景中)
tools/scene_editor/new_line_text = 一个新场景。用你的文字替换这一行。
tools/entity_editor/hint = 左边是带搜索的列表,右边是所选条目的字段 · Ctrl+N 新建,Ctrl+D 复制 · Ctrl+Z/Y 撤销/重做 · Ctrl+S 保存,游戏会立即看到更改
tools/entity_editor/section_list = 模组的条目
tools/entity_editor/search = 按名称或 id 搜索
tools/entity_editor/mod_tip = 要编辑其条目的模组;新条目会写入它的文件
tools/entity_editor/new = 新建
tools/entity_editor/new_tip = 向所选模组添加一个条目(Ctrl+N)
tools/entity_editor/duplicate = 复制
tools/entity_editor/duplicate_tip = 用新 id 复制选中的条目(Ctrl+D)
tools/entity_editor/delete = 删除
tools/entity_editor/delete_tip = 从模组文件中移除选中的条目
tools/entity_editor/delete_title = 删除该条目?
tools/entity_editor/delete_text = 保存时该条目会从模组文件中移除。剧本和地图中对它的引用会保留——请检查它们。
tools/entity_editor/undo = 撤销
tools/entity_editor/undo_tip = 撤销上一次更改(Ctrl+Z)
tools/entity_editor/redo = 重做
tools/entity_editor/redo_tip = 重做已撤销的操作(Ctrl+Y)
tools/entity_editor/no_entity = 在左边选择一个条目,或用"新建"创建一个。
tools/entity_editor/file = 模组 {0} · 文件 {1}
tools/entity_editor/id = Id
tools/entity_editor/id_tip = 唯一 id:剧本、地图和其他模组通过它找到这个条目。依赖所有者并提供相同 id 的模组会替换该条目
tools/entity_editor/locale_key_tip = 本地化键;下面的文本会写入模组的 locale/*.properties
tools/entity_editor/text_in = 文本({0})
tools/entity_editor/picture_pick_tip = 从模组文件夹中选择图片
tools/entity_editor/picture_pick_title = "{0}"组的图片
tools/entity_editor/section_other = 其他字段
tools/entity_editor/other_hint = 表单中没有显示的字段(其他模组的数据、战利品…),以 JSON 形式显示。JSON 一旦有效,修改就会生效。
tools/entity_editor/other_invalid = 其他字段:JSON 无效——{0}
tools/entity_editor/other_applied = 已应用其他字段
tools/entity_editor/opened = 模组 {1} 中有 {0} 个条目
tools/entity_editor/added = 已把条目 {0} 加入 {1}
tools/entity_editor/deleted = 已删除条目 {0}(保存后写入)
tools/entity_editor/saved = 已保存:{0} 个文件、{1} 条文本,模组 {2};游戏已重新读取数据
tools/entity_editor/broken_files = 没有读取 JSON 损坏的文件:{0}
tools/entity_editor/problem_id = 文件 {0} 中有条目的 id 为空
tools/entity_editor/problem_duplicate = id {0} 出现了不止一次
+12 -4
View File
@@ -1,12 +1,20 @@
{
"id": "tools",
"name": "Tools",
"id": "editor_tools",
"name": "Editor Tools",
"version": "1.0.0",
"author": "ct",
"description": "Development tools",
"description": "The author's windows over a running game: scene, scenario, map, interactive and light editors and a Godot scene builder. What they save are ordinary mod files the game applies at once.",
"category": "tools, editor",
"sources": [
"src/**/*.cs"
],
"depends": ["visual_novel_core>=1"]
"test_sources": [
"tests/*.cs"
],
"depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1", "schema_base>=1"],
"test_suites": [
{"id": "windows", "activate": ["--itest=tools_windows"]},
{"id": "ingame_play", "activate": ["--itest=ingame_play"]},
{"id": "novel_flow", "activate": ["--itest=novel_flow"]}
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+4
View File
@@ -5,6 +5,10 @@
[node name="ScenarioFileTreePanel" type="VBoxContainer" script=ExtResource("1_script")]
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 4
[node name="Search" type="LineEdit" parent="."]
clear_button_enabled = true
[node name="FileTree" type="Tree" parent="."]
size_flags_horizontal = 3
+3 -2
View File
@@ -9,10 +9,11 @@ theme_override_constants/separation = 6
[node name="Scroll" type="ScrollContainer" parent="."]
size_flags_vertical = 3
horizontal_scroll_mode = 3
horizontal_scroll_mode = 0
[node name="VBox" type="VBoxContainer" parent="Scroll"]
theme_override_constants/separation = 8
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="HintScene" type="Label" parent="Scroll/VBox"]
@@ -1,6 +1,7 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.CatalogExport
{
/// <summary>
+137 -269
View File
@@ -4,8 +4,10 @@ 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 Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Core.UI.Tool.CatalogExport
@@ -16,11 +18,11 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
/// 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"/>.
/// Left: the form — read-only manifest data, catalog-only metadata
/// (download URL, categories, tags, changelog, status, minimum game
/// version, icon URL) and localized names. Right: the live JSON preview.
/// Actions: copy the JSON or export it next to the mod's zip via
/// <see cref="ModCatalogExporter"/> (archive and hashes are computed there).
/// </summary>
public partial class CatalogExportWindow : ToolWindow
{
@@ -29,57 +31,59 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
private OptionButton _modSelect;
private VBoxContainer _formHost;
private CodeEdit _previewEdit;
private ModInfo _modInfo;
private ModManifest _manifest;
private LineEdit _urlEdit;
private ItemList _categoryList;
private readonly List<CheckBox> _categoryChecks = new();
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);
Size = new Vector2I(1200, 820);
CthangoverTheme.Apply(this);
var vbox = CreateFillContainer();
vbox.AddThemeConstantOverride("separation", 8);
AddChild(vbox);
var outer = CreateFillContainer();
AddChild(outer);
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,
};
var toolbar = CreateToolbar();
outer.AddChild(toolbar);
_modSelect = new OptionButton { CustomMinimumSize = new Vector2(320, 0), TooltipText = ToolUi.Tr("mods/catalog_select_mod") };
_modSelect.ItemSelected += OnModSelected;
selectRow.AddChild(_modSelect);
vbox.AddChild(selectRow);
toolbar.AddChild(_modSelect);
toolbar.AddChild(UiKit.Spacer());
toolbar.AddChild(ToolUi.Button("mods/catalog_copy", OnCopyPressed));
toolbar.AddChild(ToolUi.Button("mods/catalog_export_file", OnExportPressed, ButtonKind.Primary, "tools/catalog_export/export_tip"));
var scroll = new ScrollContainer
var split = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
vbox.AddChild(scroll);
outer.AddChild(split);
_formHost = new VBoxContainer
_formHost = new VBoxContainer();
var form = CreateSidebar(_formHost, 520);
form.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
split.AddChild(form);
var previewColumn = new VBoxContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
_formHost.AddThemeConstantOverride("separation", 8);
scroll.AddChild(_formHost);
previewColumn.AddChild(ToolUi.Section("tools/catalog_export/preview"));
_previewEdit = ToolUi.Code(editable: false);
previewColumn.AddChild(_previewEdit);
split.AddChild(previewColumn);
SetHint(ToolUi.Tr("tools/catalog_export/hint"));
PopulateModList();
}
@@ -92,7 +96,7 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
if (info == null)
continue;
_modSelect.AddItem(info.DisplayTitle);
_modSelect.AddItem($"{info.DisplayTitle} ({id})");
_modSelect.SetItemMetadata(_modSelect.ItemCount - 1, id);
}
@@ -112,175 +116,101 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
{
foreach (var child in _formHost.GetChildren())
child.QueueFree();
_categoryChecks.Clear();
_modInfo = ModRegistry.Instance.GetMod(modId) as ModInfo;
_manifest = _modInfo?.Manifest;
if (_modInfo == null || _manifest == null)
return;
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 ?? "—");
_formHost.AddChild(ToolUi.Section("tools/catalog_export/mod_info"));
AddReadOnly("tools/catalog_export/id", _modInfo.Id);
AddReadOnly("tools/catalog_export/name", _modInfo.DisplayTitle);
AddReadOnly("mods/version", _modInfo.Version ?? "—");
AddReadOnly("mods/author", _modInfo.Author ?? "—");
AddSectionLabel(_formHost, "Catalog Metadata");
_urlEdit = AddLineEdit(_formHost, "mods/catalog_download_url");
_formHost.AddChild(ToolUi.Section("tools/catalog_export/metadata"));
_urlEdit = ToolUi.Field(_formHost, "mods/catalog_download_url", ToolUi.Text("https://…/" + modId + ".zip", _ => UpdatePreview()));
_statusOpt = ToolUi.Field(_formHost, "mods/catalog_status", new OptionButton());
foreach (var status in Statuses)
_statusOpt.AddItem(status);
_statusOpt.ItemSelected += _ => UpdatePreview();
_minGameVerEdit = ToolUi.Field(_formHost, "mods/catalog_min_game_version", ToolUi.Text("1.0.0", _ => UpdatePreview()));
_iconUrlEdit = ToolUi.Field(_formHost, "mods/catalog_icon_url", ToolUi.Text("https://…/icon.png", _ => UpdatePreview()));
_tagsEdit = ToolUi.Field(_formHost, "mods/catalog_tags", ToolUi.Text("story, ru", _ => UpdatePreview()));
_categoryList = new ItemList
{
CustomMinimumSize = new Vector2(0, 130),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SelectMode = ItemList.SelectModeEnum.Toggle,
};
_formHost.AddChild(ToolUi.Section("mods/catalog_category"));
var grid = new GridContainer { Columns = 3 };
grid.AddThemeConstantOverride("h_separation", UiTokens.Space5);
var preselected = _manifest.Category ?? CategoryType.Other;
for (var i = 0; i < Categories.Length; i++)
foreach (var category in Categories)
{
_categoryList.AddItem(GetCategoryDisplayName(Categories[i]));
if ((preselected & Categories[i]) != 0)
_categoryList.Select(i, false);
var check = new CheckBox { Text = GetCategoryDisplayName(category), ButtonPressed = (preselected & category) != 0 };
check.Toggled += _ => UpdatePreview();
_categoryChecks.Add(check);
grid.AddChild(check);
}
AddLabeledControl(_formHost, "mods/catalog_category", _categoryList);
_tagsEdit = AddLineEdit(_formHost, "mods/catalog_tags");
_formHost.AddChild(grid);
_formHost.AddChild(ToolUi.Section("mods/catalog_changelog"));
_changelogEdit = new TextEdit
{
CustomMinimumSize = new Vector2(0, 60),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(0, 80),
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();
_formHost.AddChild(_changelogEdit);
_formHost.AddChild(ToolUi.Section("tools/catalog_export/locales"));
_formHost.AddChild(ToolUi.Hint(ToolUi.Tr("tools/catalog_export/locales_hint")));
_localeContainer = new VBoxContainer();
_localeContainer.AddThemeConstantOverride("separation", UiTokens.Space1);
_formHost.AddChild(_localeContainer);
_formHost.AddChild(ToolUi.Button("mods/catalog_add_locale", () => AddLocaleRow(string.Empty, string.Empty, string.Empty)));
UpdatePreview();
SetStatus(string.Empty);
}
private void AddReadOnly(string labelKey, string value)
{
var label = UiKit.Label(value, UiTokens.FontBody, UiTokens.Body);
label.AddThemeFontOverride("font", ToolUi.Mono());
ToolUi.Field(_formHost, labelKey, label);
}
private static string GetCategoryDisplayName(CategoryType cat) => cat switch
{
CategoryType.Story => 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"),
CategoryType.Story => ToolUi.Tr("mods/category_story"),
CategoryType.Characters => ToolUi.Tr("mods/category_characters"),
CategoryType.Gameplay => ToolUi.Tr("mods/category_gameplay"),
CategoryType.Visual => ToolUi.Tr("mods/category_visual"),
CategoryType.Audio => ToolUi.Tr("mods/category_audio"),
CategoryType.UI => ToolUi.Tr("mods/category_ui"),
CategoryType.Locale => ToolUi.Tr("mods/category_locale"),
CategoryType.Library => ToolUi.Tr("mods/category_library"),
CategoryType.Engine => ToolUi.Tr("mods/category_engine"),
CategoryType.Overhaul => ToolUi.Tr("mods/category_overhaul"),
CategoryType.Tools => ToolUi.Tr("mods/category_tools"),
CategoryType.Editor => ToolUi.Tr("mods/category_editor"),
CategoryType.Debug => ToolUi.Tr("mods/category_debug"),
CategoryType.Other => ToolUi.Tr("mods/category_other"),
_ => cat.ToString(),
};
private void AddLocaleRow(string lang, string name, string description)
{
var row = new HBoxContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
row.AddThemeConstantOverride("separation", 4);
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space1);
var langEdit = new LineEdit
{
CustomMinimumSize = new Vector2(44, 0),
PlaceholderText = "en",
MaxLength = 5,
Text = lang,
};
var langEdit = new LineEdit { CustomMinimumSize = new Vector2(52, 0), PlaceholderText = "en", MaxLength = 5, Text = lang };
row.AddChild(langEdit);
var nameEdit = new LineEdit
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
PlaceholderText = TranslationServer.Translate("mods/catalog_locale_name"),
Text = name,
};
var nameEdit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, PlaceholderText = ToolUi.Tr("mods/catalog_locale_name"), Text = name };
row.AddChild(nameEdit);
var descEdit = new LineEdit
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
PlaceholderText = TranslationServer.Translate("mods/catalog_locale_desc"),
Text = description,
};
var descEdit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, PlaceholderText = ToolUi.Tr("mods/catalog_locale_desc"), Text = description };
row.AddChild(descEdit);
var removeBtn = new Button { Text = "×", CustomMinimumSize = new Vector2(28, 0) };
var removeBtn = UiKit.IconButton("✕", ToolUi.Tr("tools/catalog_export/remove_locale"));
removeBtn.Pressed += () => RemoveLocaleRow(row);
row.AddChild(removeBtn);
@@ -289,6 +219,7 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
descEdit.TextChanged += _ => UpdatePreview();
_localeContainer.AddChild(row);
UpdatePreview();
}
private void RemoveLocaleRow(HBoxContainer row)
@@ -300,28 +231,13 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
private void UpdatePreview()
{
if (_previewEdit == null || _modInfo == null || _manifest == null)
if (_previewEdit == null || _modInfo == null || _manifest == null || _changelogEdit == 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();
var entry = BuildExportEntry();
// Hashes are computed by the exporter from the real archive.
entry.Downloads[0].ArchiveSha256 = "computed-on-export";
entry.Downloads[0].InstallSha256 = "computed-on-export";
_previewEdit.Text = JsonSerializer.Serialize(entry, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
}
@@ -330,32 +246,23 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
var list = new List<CatalogLocaleEntry>();
foreach (var child in _localeContainer.GetChildren())
{
if (child is not HBoxContainer row || row.GetChildCount() < 3)
if (child is not HBoxContainer row || row.IsQueuedForDeletion() || 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),
Name = NullIfEmpty(((LineEdit)row.GetChild(1)).Text),
Description = NullIfEmpty(((LineEdit)row.GetChild(2)).Text),
});
}
if (list.Count == 0 && _manifest != null)
{
list.Add(new CatalogLocaleEntry
{
Lang = "en",
Name = _manifest.Name,
Description = _manifest.Description,
});
}
if (list.Count == 0)
list.Add(new CatalogLocaleEntry { Lang = "en", Name = _manifest.Name, Description = _manifest.Description });
return list;
}
@@ -370,12 +277,12 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
Version = entry.Version,
Urls = new List<string> { _urlEdit.Text.Trim() },
SizeBytes = 0,
Changelog = NullIfEmpty(_changelogEdit.Text),
},
};
entry.Category = CollectSelectedCategories(_categoryList, Categories);
entry.Category = CollectSelectedCategories();
entry.Tags = ParseTags(_tagsEdit.Text);
entry.Downloads[0].Changelog = NullIfEmpty(_changelogEdit.Text);
entry.Status = Statuses[_statusOpt.Selected];
entry.Status = Statuses[Math.Max(0, _statusOpt.Selected)];
entry.MinGameVersion = NullIfEmpty(_minGameVerEdit.Text);
entry.IconUrl = NullIfEmpty(_iconUrlEdit.Text);
entry.Locale = CollectLocales();
@@ -385,7 +292,7 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
private void OnCopyPressed()
{
DisplayServer.ClipboardSet(_previewEdit.Text);
GameLogger.Log("CATALOG_EXPORT", $"Copied catalog JSON for '{_modInfo?.Id}' to clipboard");
SetStatus(ToolUi.Tr("tools/catalog_export/copied"), ToolStatusKind.Ok);
}
private void OnExportPressed()
@@ -393,15 +300,27 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
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);
if (string.IsNullOrWhiteSpace(_urlEdit.Text))
{
SetStatus(ToolUi.Tr("tools/catalog_export/url_required"), ToolStatusKind.Error);
return;
}
QueueFree();
try
{
var entry = BuildExportEntry();
var modsRoot = ModRegistry.ModsPath;
var isZip = _modInfo.FileProvider.RootPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase);
var path = isZip
? ModCatalogExporter.ExportFromZip(_modInfo, entry, modsRoot)
: ModCatalogExporter.ExportFromFolder(_modInfo, entry, modsRoot);
SetStatus(string.Format(ToolUi.Tr("tools/catalog_export/exported"), path), ToolStatusKind.Ok);
}
catch (Exception ex)
{
GameLogger.Log("CATALOG_EXPORT", $"export of '{_modInfo.Id}' failed: {ex.Message}", LogLevel.Error);
SetStatus($"{ToolUi.Tr("tools/catalog_export/export_error")}: {ex.Message}", ToolStatusKind.Error);
}
}
private static List<string> ParseTags(string text)
@@ -434,66 +353,15 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
};
}
private static CategoryType CollectSelectedCategories(ItemList list, CategoryType[] categories)
private CategoryType CollectSelectedCategories()
{
var selected = (CategoryType)0;
for (var i = 0; i < categories.Length && i < list.ItemCount; i++)
for (var i = 0; i < Categories.Length && i < _categoryChecks.Count; i++)
{
if (list.IsSelected(i))
selected |= categories[i];
if (_categoryChecks[i].ButtonPressed)
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);
}
}
}
+137
View File
@@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Base of the editors that place things over a scene background (map
/// zones, interactive colliders, lights). Draws the background with the
/// same aspect-preserving "fit" layout the game uses and converts between
/// canvas pixels and the normalised [0..1] coordinates stored in the data.
/// Getting this rectangle wrong is what made saved positions drift: the
/// data means "fraction of the background", not "fraction of the panel".
/// <para/>
/// Subclasses paint their overlay in <see cref="DrawContent"/> and rebuild
/// their <see cref="DragHandle"/>s in <see cref="RebuildHandles"/>, which is
/// also called whenever the canvas is resized or the background changes.
/// </summary>
public abstract partial class BackgroundCanvas : Control
{
private static readonly Vector2 FallbackSize = new(1920, 1024);
/// <summary>Live handles of the current selection.</summary>
protected readonly List<DragHandle> _handles = new();
/// <summary>Reference background; <c>null</c> draws a flat placeholder.</summary>
public Texture2D Background { get; private set; }
protected BackgroundCanvas()
{
MouseFilter = MouseFilterEnum.Stop;
ClipContents = true;
Resized += OnResized;
Connect("draw", Callable.From((Action)DrawAll));
}
/// <summary>Assigns the background texture and relayouts handles.</summary>
public void SetBackground(Texture2D texture)
{
Background = texture;
RebuildHandles();
QueueRedraw();
}
/// <summary>Recreates the handles from the current selection.</summary>
public abstract void RebuildHandles();
/// <summary>Paints the overlay above the background inside <paramref name="rect"/>.</summary>
protected abstract void DrawContent(Rect2 rect);
/// <summary>
/// Aspect-preserving rectangle the background occupies inside the
/// canvas — identical to the runtime "fit" layout.
/// </summary>
public Rect2 GetBackgroundRect()
{
var controlSize = Size;
if (controlSize.X <= 0f || controlSize.Y <= 0f)
return new Rect2();
var textureSize = Background?.GetSize() ?? FallbackSize;
if (textureSize.X <= 0f || textureSize.Y <= 0f)
textureSize = FallbackSize;
var scale = Mathf.Min(controlSize.X / textureSize.X, controlSize.Y / textureSize.Y);
var size = textureSize * scale;
return new Rect2((controlSize - size) / 2f, size);
}
/// <summary>Canvas-local pixel of a normalised point.</summary>
public Vector2 NormToLocal(Vector2 normalized)
{
var rect = GetBackgroundRect();
return rect.Position + normalized * rect.Size;
}
/// <summary>Normalised point of a canvas-local pixel (not clamped).</summary>
public Vector2 LocalToNorm(Vector2 local)
{
var rect = GetBackgroundRect();
if (rect.Size.X <= 0f || rect.Size.Y <= 0f)
return Vector2.Zero;
return (local - rect.Position) / rect.Size;
}
/// <summary>Clamps both axes into [0..1].</summary>
public static Vector2 Clamp01(Vector2 value)
{
return new Vector2(Mathf.Clamp(value.X, 0f, 1f), Mathf.Clamp(value.Y, 0f, 1f));
}
/// <summary>Frees every handle.</summary>
protected void ClearHandles()
{
foreach (var handle in _handles)
handle.QueueFree();
_handles.Clear();
}
/// <summary>
/// Adds a handle centred on a normalised point. The handle enters the
/// tree before it is positioned (setting a global position on a
/// detached Control lands it in the wrong place). <paramref name="drag"/>
/// receives the clamped normalised point while dragging.
/// </summary>
protected DragHandle AddHandle(Vector2 normalized, Color color, int index, Action<Vector2> drag, float diameter = 14f)
{
var handle = new DragHandle(color, index, diameter);
handle.DragUpdate = globalCenter => drag(Clamp01(LocalToNorm(globalCenter - GlobalPosition)));
AddChild(handle);
handle.CenterAt(GlobalPosition + NormToLocal(Clamp01(normalized)));
_handles.Add(handle);
return handle;
}
private void OnResized()
{
RebuildHandles();
QueueRedraw();
}
private void DrawAll()
{
var rect = GetBackgroundRect();
if (rect.Size.X <= 0f || rect.Size.Y <= 0f)
return;
if (Background != null)
DrawTextureRect(Background, rect, false);
else
DrawRect(rect, UiTokens.Surface2);
DrawContent(rect);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Sidebar section with a clickable uppercase caption (style guide
/// <c>.ct-section-title</c> plus a ▸/▾ marker) that shows or hides its
/// <see cref="Body"/>. Editors expand the sections relevant to the selected
/// object and collapse the rest, so a long property panel stays readable
/// without losing access to rarely used fields.
/// </summary>
public partial class CollapsibleSection : VBoxContainer
{
private readonly Button _header;
private readonly string _title;
/// <summary>Container for the section's controls.</summary>
public VBoxContainer Body { get; }
/// <summary>Whether the body is shown.</summary>
public bool Expanded
{
get => Body.Visible;
set
{
Body.Visible = value;
_header.Text = (value ? "▾ " : "▸ ") + _title;
}
}
/// <summary>Creates a section titled by <paramref name="titleKey"/>.</summary>
public CollapsibleSection(string titleKey, bool expanded = true)
{
_title = ToolUi.Tr(titleKey).ToUpperInvariant();
AddThemeConstantOverride("separation", UiTokens.Space2);
_header = new Button
{
Flat = true,
Alignment = HorizontalAlignment.Left,
FocusMode = FocusModeEnum.None,
MouseDefaultCursorShape = CursorShape.PointingHand
};
_header.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
_header.AddThemeColorOverride("font_color", UiTokens.Muted);
_header.AddThemeColorOverride("font_hover_color", UiTokens.AccentBright);
_header.AddThemeColorOverride("font_pressed_color", UiTokens.Muted);
_header.AddThemeStyleboxOverride("normal", new StyleBoxEmpty());
_header.AddThemeStyleboxOverride("hover", new StyleBoxEmpty());
_header.AddThemeStyleboxOverride("pressed", new StyleBoxEmpty());
_header.Pressed += () => Expanded = !Expanded;
AddChild(_header);
Body = new VBoxContainer();
Body.AddThemeConstantOverride("separation", UiTokens.Space2);
AddChild(Body);
Expanded = expanded;
}
}
}
+102
View File
@@ -0,0 +1,102 @@
using System;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Round draggable point used by every canvas editor (polygon vertices,
/// rect corners, markers, lights). It is a small Control placed over its
/// canvas: the left button drags it and reports the new centre in global
/// coordinates, the right button reports a delete request. Input arrives
/// through the <c>GuiInput</c> event and drawing through the <c>draw</c>
/// signal — the forms that dispatch reliably for mod assemblies.
/// <para/>
/// <see cref="Index"/> is free for the owner: a vertex index or a negative
/// role constant (marker, label, rect corner...).
/// </summary>
public partial class DragHandle : Control
{
/// <summary>Owner-defined identity of the handle.</summary>
public int Index;
/// <summary>Called while dragging with the handle centre in global coordinates.</summary>
public Action<Vector2> DragUpdate;
/// <summary>Called on left press.</summary>
public Action Clicked;
/// <summary>Called on left release after a press.</summary>
public Action DragEnd;
/// <summary>Called on right press.</summary>
public Action RightClicked;
private readonly Color _fill;
private bool _dragging;
private bool _hovered;
private Vector2 _dragOffset;
/// <summary>Creates a handle of the given fill colour, role index and diameter.</summary>
public DragHandle(Color fill, int index, float diameter = 14f)
{
_fill = fill;
Index = index;
Size = new Vector2(diameter, diameter);
MouseFilter = MouseFilterEnum.Stop;
MouseDefaultCursorShape = CursorShape.Drag;
GuiInput += OnGuiInput;
MouseEntered += () => { _hovered = true; QueueRedraw(); };
MouseExited += () => { _hovered = false; QueueRedraw(); };
Connect("draw", Callable.From((Action)Paint));
}
/// <summary>Places the handle so that its centre sits at <paramref name="globalCenter"/>; call after it is in the tree.</summary>
public void CenterAt(Vector2 globalCenter)
{
GlobalPosition = globalCenter - Size / 2f;
}
private void Paint()
{
var center = Size / 2f;
var radius = Size.X / 2f - 1f;
DrawCircle(center, radius, UiTokens.Surface0);
DrawCircle(center, radius - 2f, _fill);
DrawArc(center, radius, 0f, Mathf.Tau, 24, _hovered || _dragging ? UiTokens.AccentHover : UiTokens.Ink, 1.5f, true);
}
private void OnGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb)
{
if (mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
{
_dragging = true;
_dragOffset = GetGlobalMousePosition() - GlobalPosition;
Clicked?.Invoke();
}
else if (_dragging)
{
_dragging = false;
DragEnd?.Invoke();
}
AcceptEvent();
}
else if (mb.ButtonIndex == MouseButton.Right && mb.Pressed)
{
RightClicked?.Invoke();
AcceptEvent();
}
}
else if (@event is InputEventMouseMotion && _dragging)
{
GlobalPosition = GetGlobalMousePosition() - _dragOffset;
DragUpdate?.Invoke(GlobalPosition + Size / 2f);
AcceptEvent();
}
}
}
}
+159
View File
@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Search field + list used wherever an editor picks from a long catalogue
/// (backgrounds, icons, files, definitions). Items are identified by a
/// stable id, not by row index: filtering reorders and hides rows, so the
/// list keeps the id↔row mapping itself and raises <see cref="Chosen"/> /
/// <see cref="Activated"/> with the id. <see cref="Select"/> is silent so
/// windows can mirror their model into the list without feedback loops.
/// </summary>
public partial class FilteredList : VBoxContainer
{
// Scroll bar, item padding and a little text room on the right.
private const int IconBoxMargin = 40;
private readonly LineEdit _search;
private readonly ItemList _list;
private readonly List<(string Id, string Text, Texture2D Icon)> _items = new();
private readonly List<string> _visibleIds = new();
private string _selectedId;
private bool _silent;
private int _fitSide;
/// <summary>Raised when the user selects a row (click or keyboard).</summary>
public event Action<string> Chosen;
/// <summary>Raised on double-click / Enter.</summary>
public event Action<string> Activated;
/// <summary>Currently selected id, or <c>null</c>.</summary>
public string SelectedId => _selectedId;
/// <summary>Creates the list; <paramref name="minHeight"/> is the list part's minimum height.</summary>
public FilteredList(float minHeight = 140f, string placeholderKey = "tools/common/search")
{
AddThemeConstantOverride("separation", UiTokens.Space1);
_search = new LineEdit
{
PlaceholderText = TranslationServer.Translate(placeholderKey),
ClearButtonEnabled = true
};
_search.TextChanged += _ => Rebuild();
AddChild(_search);
_list = new ItemList
{
CustomMinimumSize = new Vector2(0, minHeight),
SizeFlagsVertical = SizeFlags.ExpandFill,
AllowReselect = true
};
_list.ItemSelected += OnItemSelected;
_list.ItemActivated += index => Activated?.Invoke(IdAt((int)index));
AddChild(_list);
}
/// <summary>Turns rows into an icon grid of <paramref name="iconSize"/> px thumbnails.</summary>
public void UseIconGrid(int iconSize)
{
_list.FixedIconSize = new Vector2I(iconSize, iconSize);
_list.IconMode = ItemList.IconModeEnum.Top;
_list.MaxColumns = 0;
_list.SameColumnWidth = true;
_list.FixedColumnWidth = iconSize + 24;
}
/// <summary>
/// Scales row icons to fit a box at most <paramref name="maxSide"/> px high and never wider than the
/// list (minus room for the scroll bar): pictures of any size — a 1024×1920 portrait as well as a
/// 32 px icon — keep their aspect and the row stays inside the panel. The box follows the list
/// width, so narrowing the panel shrinks the pictures further.
/// </summary>
public void FitIcons(int maxSide)
{
_fitSide = maxSide;
_list.Resized += UpdateIconBox;
UpdateIconBox();
}
/// <summary>Replaces all items; keeps the current selection when its id is still present.</summary>
public void SetItems(IEnumerable<(string Id, string Text, Texture2D Icon)> items)
{
_items.Clear();
_items.AddRange(items);
Rebuild();
}
/// <summary>Replaces all items with text-only rows where text equals id.</summary>
public void SetItems(IEnumerable<string> ids)
{
_items.Clear();
foreach (var id in ids)
_items.Add((id, id, null));
Rebuild();
}
/// <summary>Selects the row with <paramref name="id"/> (null clears) without raising events.</summary>
public void Select(string id)
{
_selectedId = id;
_silent = true;
_list.DeselectAll();
var index = _visibleIds.IndexOf(id);
if (index >= 0)
{
_list.Select(index);
_list.EnsureCurrentIsVisible();
}
_silent = false;
}
/// <summary>Number of items before filtering.</summary>
public int Count => _items.Count;
private void Rebuild()
{
var filter = _search.Text.Trim();
_silent = true;
_list.Clear();
_visibleIds.Clear();
foreach (var item in _items)
{
if (filter.Length > 0 && item.Text.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0
&& item.Id.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
continue;
var index = item.Icon != null ? _list.AddItem(item.Text, item.Icon) : _list.AddItem(item.Text);
_list.SetItemTooltip(index, item.Id);
_visibleIds.Add(item.Id);
}
_silent = false;
Select(_selectedId);
}
private void OnItemSelected(long index)
{
if (_silent)
return;
_selectedId = IdAt((int)index);
Chosen?.Invoke(_selectedId);
}
private void UpdateIconBox()
{
var width = (int)_list.Size.X - IconBoxMargin;
_list.FixedIconSize = new Vector2I(Math.Clamp(width, 16, _fitSide), _fitSide);
}
private string IdAt(int index) => index >= 0 && index < _visibleIds.Count ? _visibleIds[index] : null;
}
}
+180
View File
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Toolbar group "mod ▾ file ▾ +" for editors that own one kind of file
/// inside a mod folder (<c>map/*.json</c>, <c>interactives/*.json</c>).
/// Only writable (folder-backed) mods are offered, and mods that already
/// contain such files come first, so the author lands on real data instead
/// of an arbitrary engine mod with nothing to edit.
/// <para/>
/// The selector never loads anything itself: it raises
/// <see cref="FileRequested"/> and the window decides (usually after an
/// "unsaved changes" confirmation). When the window declines, it calls
/// <see cref="Show"/> with the file still open to roll the dropdowns back.
/// A <c>null</c> path means "this mod has no such file yet".
/// </summary>
public partial class ModFileSelector : HBoxContainer
{
private readonly string _directory;
private readonly string _extension;
private readonly OptionButton _modSelect;
private readonly OptionButton _fileSelect;
private readonly List<string> _mods = new();
private readonly List<string> _files = new();
private bool _silent;
/// <summary>Raised with (modId, mod-relative path or null) when the author picks or creates a file.</summary>
public event Action<string, string> FileRequested;
/// <summary>Mod shown in the selector.</summary>
public string CurrentMod => _modSelect.Selected >= 0 && _modSelect.Selected < _mods.Count ? _mods[_modSelect.Selected] : null;
/// <summary>File shown in the selector, or <c>null</c>.</summary>
public string CurrentPath => _fileSelect.Selected >= 0 && _fileSelect.Selected < _files.Count ? _files[_fileSelect.Selected] : null;
/// <summary>Creates the selector for files in <paramref name="directory"/> with <paramref name="extension"/>.</summary>
public ModFileSelector(string directory, string extension)
{
_directory = directory;
_extension = extension;
AddThemeConstantOverride("separation", UiTokens.Space2);
_modSelect = new OptionButton { CustomMinimumSize = new Vector2(170, 0), TooltipText = ToolUi.Tr("tools/common/mod_tip") };
_modSelect.ItemSelected += _ => OnModPicked();
AddChild(_modSelect);
_fileSelect = new OptionButton { CustomMinimumSize = new Vector2(220, 0), TooltipText = ToolUi.Tr("tools/common/file_tip") };
_fileSelect.ItemSelected += _ => OnFilePicked();
AddChild(_fileSelect);
var newBtn = UiKit.IconButton("+", ToolUi.Tr("tools/common/new_file"));
newBtn.Pressed += OnNewPressed;
AddChild(newBtn);
}
/// <summary>Re-reads mods and files and requests the first file of the first mod that has any.</summary>
public void Refresh()
{
var writable = ModFileService.ListWritableMods();
_mods.Clear();
_mods.AddRange(writable.Where(HasFiles));
_mods.AddRange(writable.Where(m => !HasFiles(m)));
_silent = true;
_modSelect.Clear();
foreach (var mod in _mods)
_modSelect.AddItem(HasFiles(mod) ? mod : mod + " ·");
_silent = false;
if (_mods.Count == 0)
{
FileRequested?.Invoke(null, null);
return;
}
_modSelect.Select(0);
FillFiles(_mods[0]);
FileRequested?.Invoke(_mods[0], CurrentPath);
}
/// <summary>Silently shows <paramref name="modId"/>/<paramref name="path"/> (a not-yet-saved path is added).</summary>
public void Show(string modId, string path)
{
_silent = true;
var modIndex = _mods.IndexOf(modId);
if (modIndex >= 0)
{
_modSelect.Select(modIndex);
FillFiles(modId);
if (path != null && !_files.Contains(path))
{
_files.Add(path);
_fileSelect.AddItem(ShortName(path));
}
_fileSelect.Select(path != null ? _files.IndexOf(path) : -1);
}
_silent = false;
}
private bool HasFiles(string modId) => ModFileService.ListFiles(modId, _directory, _extension).Count > 0;
private void FillFiles(string modId)
{
_files.Clear();
_files.AddRange(ModFileService.ListFiles(modId, _directory, _extension));
var silent = _silent;
_silent = true;
_fileSelect.Clear();
foreach (var file in _files)
_fileSelect.AddItem(ShortName(file));
_fileSelect.Disabled = _files.Count == 0;
if (_files.Count > 0)
_fileSelect.Select(0);
_silent = silent;
}
private string ShortName(string path)
{
var prefix = _directory + "/";
return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? path.Substring(prefix.Length) : path;
}
private void OnModPicked()
{
if (_silent)
return;
var mod = CurrentMod;
FillFiles(mod);
FileRequested?.Invoke(mod, CurrentPath);
}
private void OnFilePicked()
{
if (!_silent)
FileRequested?.Invoke(CurrentMod, CurrentPath);
}
private void OnNewPressed()
{
if (CurrentMod == null)
return;
var dialog = new ConfirmationDialog { Title = ToolUi.Tr("tools/common/new_file") };
var column = new VBoxContainer();
column.AddChild(UiKit.Hint(string.Format(ToolUi.Tr("tools/common/new_file_hint"), CurrentMod, _directory, _extension)));
var nameEdit = new LineEdit { PlaceholderText = "new_file", CustomMinimumSize = new Vector2(320, 0) };
column.AddChild(nameEdit);
dialog.AddChild(column);
dialog.RegisterTextEnter(nameEdit);
dialog.Confirmed += () =>
{
var name = SanitizeName(nameEdit.Text);
dialog.QueueFree();
if (name.Length == 0)
return;
FileRequested?.Invoke(CurrentMod, _directory + "/" + name + _extension);
};
dialog.Canceled += () => dialog.QueueFree();
AddChild(dialog);
dialog.PopupCentered();
nameEdit.GrabFocus();
}
private string SanitizeName(string raw)
{
var name = (raw ?? string.Empty).Trim().Replace('\\', '/').Trim('/');
if (name.EndsWith(_extension, StringComparison.OrdinalIgnoreCase))
name = name.Substring(0, name.Length - _extension.Length);
var chars = name.Where(c => char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '/').ToArray();
return new string(chars);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Building blocks shared by every editor window of the tools mod, so all
/// of them read the same way: uppercase muted section captions, "label +
/// control" rows with a fixed label column, numeric spin boxes and token
/// buttons from <see cref="UiKit"/>. Editors are built in code (the
/// skill-code exception for utility tools), so this is the place where
/// their look is kept consistent with the style guide instead of each
/// window inventing its own spacing and label styles.
/// </summary>
public static class ToolUi
{
/// <summary>Width of the label column in <see cref="Field"/> rows.</summary>
public const int LabelWidth = 110;
/// <summary>Translates a locale key.</summary>
public static string Tr(string key) => TranslationServer.Translate(key);
/// <summary>Uppercase muted section caption (style guide <c>.ct-section-title</c>) with a little air above.</summary>
public static Control Section(string titleKey)
{
var box = new MarginContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
box.AddThemeConstantOverride("margin_top", UiTokens.Space2);
box.AddChild(UiKit.SectionTitle(Tr(titleKey), UiTokens.FontSmall));
return box;
}
/// <summary>
/// Adds a "label | control" row to <paramref name="parent"/>: the label is
/// muted, fixed width and carries the optional tooltip; the control fills
/// the rest. Returns the control for chaining.
/// </summary>
public static T Field<T>(Container parent, string labelKey, T control, string tooltipKey = null) where T : Control
{
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space3);
var label = new Label
{
Text = Tr(labelKey),
CustomMinimumSize = new Vector2(LabelWidth, 0),
VerticalAlignment = VerticalAlignment.Center,
ClipText = true,
MouseFilter = Control.MouseFilterEnum.Pass,
TooltipText = tooltipKey != null ? Tr(tooltipKey) : string.Empty
};
label.AddThemeColorOverride("font_color", UiTokens.Muted);
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
row.AddChild(label);
control.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(control);
parent.AddChild(row);
return control;
}
/// <summary>
/// Numeric field. Values are typed and shown culture-independently by
/// <see cref="SpinBox"/>, which also clamps and steps them, so the editors
/// no longer parse free text ("0,5" vs "0.5").
/// </summary>
public static SpinBox Number(double min, double max, double step, Action<double> changed)
{
var spin = new SpinBox
{
MinValue = min,
MaxValue = max,
Step = step,
SelectAllOnFocus = true,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
CustomArrowStep = step
};
spin.GetLineEdit().AddThemeFontOverride("font", Mono());
if (changed != null)
spin.ValueChanged += value => changed(value);
return spin;
}
/// <summary>Single-line text field with a placeholder and an optional change handler.</summary>
public static LineEdit Text(string placeholder = "", Action<string> changed = null)
{
var edit = new LineEdit { PlaceholderText = placeholder ?? string.Empty };
if (changed != null)
edit.TextChanged += text => changed(text);
return edit;
}
/// <summary>Token button with a localized caption, optional tooltip and kind.</summary>
public static Button Button(string textKey, Action pressed, ButtonKind kind = ButtonKind.Secondary, string tooltipKey = null)
{
var button = UiKit.Button(Tr(textKey), kind);
if (tooltipKey != null)
button.TooltipText = Tr(tooltipKey);
if (pressed != null)
button.Pressed += pressed;
return button;
}
/// <summary>Thin vertical divider between toolbar groups.</summary>
public static VSeparator Divider() => new() { CustomMinimumSize = new Vector2(UiTokens.Space3, 0) };
/// <summary>Faint helper text that wraps inside narrow sidebars.</summary>
public static Label Hint(string text)
{
var label = UiKit.Hint(text);
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
label.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
label.CustomMinimumSize = new Vector2(80, 0);
return label;
}
/// <summary>Monospace font for IDs, numbers and code (style guide <c>--ct-mono</c>).</summary>
public static Font Mono() => ToolWindow.GetMonospaceFont();
/// <summary>Code editor preset: monospace, line numbers, JSON highlighting and no wrap; scenario/scene callers select their syntax.</summary>
public static CodeEdit Code(bool editable = true)
{
var edit = new CodeEdit
{
Editable = editable,
SyntaxHighlighter = SourceSyntax.CreateJsonHighlighter(),
GuttersDrawLineNumbers = true,
HighlightCurrentLine = true,
WrapMode = TextEdit.LineWrappingMode.None,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
IndentUseSpaces = true,
IndentSize = 2,
AutoBraceCompletionEnabled = false
};
edit.AddThemeFontOverride("font", Mono());
edit.AddThemeFontSizeOverride("font_size", UiTokens.FontBody);
edit.AddThemeColorOverride("current_line_color", UiTokens.WhiteTrace);
edit.AddThemeColorOverride("line_number_color", UiTokens.Faint);
edit.AddThemeColorOverride("font_readonly_color", UiTokens.Body);
return edit;
}
}
}
+252
View File
@@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Cthangover.Tools.EntityEditor
{
/// <summary>
/// The entities of one kind (<c>"Kind": "item"</c>, <c>"character"</c>…) in one
/// mod, as an editor changes them, free of any UI.
/// <para/>
/// Files are kept as JSON trees, not as typed DTOs: a form changes only the
/// properties it knows, and everything else — fields of other mods' C#
/// (<c>heal</c>, <c>verb</c>, <c>Charset</c>), loot tables, the property
/// order — survives a save untouched. A file is written only when its text
/// differs from what was loaded or last saved, so opening and saving does not
/// reformat files nobody changed. New entities go to <see cref="NewFile"/>,
/// created on the first save when the mod has no such file.
/// <para/>
/// Localized texts are part of the document too (<see cref="SetText"/>): the
/// form shows a name in every language of the mod, and an undo step restores
/// texts and data together. Undo works on whole states, as in the map editor.
/// </summary>
public class EntityDocument
{
private const int HistoryLimit = 200;
private static readonly JsonSerializerOptions WriteOptions = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private readonly Dictionary<string, JsonObject> files = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> savedText = new(StringComparer.Ordinal);
private readonly List<EntityRecord> records = new();
private readonly List<string> undo = new();
private readonly List<string> redo = new();
private Dictionary<string, string> texts = new(StringComparer.Ordinal);
private Dictionary<string, string> savedTexts = new(StringComparer.Ordinal);
/// <summary>Entity kind of the document.</summary>
public string Kind { get; }
/// <summary>File new entities are added to.</summary>
public string NewFile { get; }
/// <summary>The entities in file order (files sorted by path).</summary>
public IReadOnlyList<EntityRecord> Records => records;
/// <summary>True when there is a step to undo.</summary>
public bool CanUndo => undo.Count > 0;
/// <summary>True when there is a step to redo.</summary>
public bool CanRedo => redo.Count > 0;
private EntityDocument(string kind, string newFile)
{
Kind = kind;
NewFile = newFile;
}
/// <summary>
/// Builds the document from the mod's JSON files (path, text). Files of
/// another kind, without a kind or with broken JSON are skipped; their
/// paths are returned in <paramref name="skipped"/> only when they declare
/// this kind but cannot be read, so the window can name them.
/// </summary>
public static EntityDocument Load(string kind, string newFile, IEnumerable<(string Path, string Text)> sources, out List<string> skipped)
{
var document = new EntityDocument(kind, newFile);
skipped = new List<string>();
foreach (var (path, text) in sources.OrderBy(s => s.Path, StringComparer.Ordinal))
{
JsonObject root;
try
{
root = JsonNode.Parse(text ?? string.Empty) as JsonObject;
}
catch (JsonException)
{
if (text != null && text.Contains($"\"{kind}\""))
skipped.Add(path);
continue;
}
if (root == null || !string.Equals(root["Kind"]?.ToString(), kind, StringComparison.OrdinalIgnoreCase))
continue;
if (root["Items"] is not JsonArray)
root["Items"] = new JsonArray();
document.files[path] = root;
document.savedText[path] = Serialize(root);
}
document.Rebuild();
return document;
}
/// <summary>Paths of the document's files.</summary>
public IEnumerable<string> Files => files.Keys;
/// <summary>Adds an entity to <see cref="NewFile"/> (the file is created when missing) and returns it.</summary>
public EntityRecord Add(JsonObject entity)
{
if (!files.TryGetValue(NewFile, out var root))
{
root = new JsonObject { ["Kind"] = Kind, ["Items"] = new JsonArray() };
files[NewFile] = root;
}
((JsonArray)root["Items"]).Add(entity);
Rebuild();
return records.First(r => r.Data == entity);
}
/// <summary>A copy of <paramref name="record"/> with id <paramref name="newId"/>, placed right after it in its file.</summary>
public EntityRecord Duplicate(EntityRecord record, string newId)
{
var copy = (JsonObject)record.Data.DeepClone();
copy["Id"] = newId;
var items = (JsonArray)files[record.File]["Items"];
items.Insert(items.IndexOf(record.Data) + 1, copy);
Rebuild();
return records.First(r => r.Data == copy);
}
/// <summary>Removes an entity from its file (the file stays, maybe with no items).</summary>
public void Remove(EntityRecord record)
{
((JsonArray)files[record.File]["Items"]).Remove(record.Data);
Rebuild();
}
/// <summary><paramref name="wanted"/> when no entity has it, else <c>wanted_2</c>, <c>wanted_3</c>…</summary>
public string FreeId(string wanted)
{
var id = wanted;
for (var number = 2; records.Any(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); number++)
id = wanted + "_" + number;
return id;
}
/// <summary>The record with this id, or null.</summary>
public EntityRecord Find(string id) => records.FirstOrDefault(r => r.Id == id);
/// <summary>A localized text the document holds (see <see cref="SetText"/>), or null when it was never set or loaded.</summary>
public string GetText(string language, string key)
=> texts.TryGetValue(TextKey(language, key), out var value) ? value : null;
/// <summary>Remembers the text the mod's files have now (loading, not an edit).</summary>
public void LoadText(string language, string key, string value)
{
var textKey = TextKey(language, key);
texts[textKey] = value;
savedTexts[textKey] = value;
}
/// <summary>Changes a localized text; it is written on save.</summary>
public void SetText(string language, string key, string value) => texts[TextKey(language, key)] = value ?? string.Empty;
/// <summary>Remembers the current state as one undo step and forgets the redo branch.</summary>
public void BeginChange()
{
undo.Add(Snapshot());
if (undo.Count > HistoryLimit)
undo.RemoveAt(0);
redo.Clear();
}
/// <summary>Steps back; false when there is nothing to undo.</summary>
public bool Undo() => Swap(undo, redo);
/// <summary>Steps forward again; false when there is nothing to redo.</summary>
public bool Redo() => Swap(redo, undo);
/// <summary>Files whose text changed since loading or the last <see cref="MarkSaved"/>.</summary>
public List<(string Path, string Text)> ChangedFiles()
=> files.Select(f => (f.Key, Serialize(f.Value)))
.Where(f => !savedText.TryGetValue(f.Key, out var saved) || saved != f.Item2)
.ToList();
/// <summary>Texts changed since loading or the last <see cref="MarkSaved"/>: language, key, value.</summary>
public List<(string Language, string Key, string Value)> ChangedTexts()
=> texts.Where(t => !savedTexts.TryGetValue(t.Key, out var saved) || saved != t.Value)
.Select(t => (t.Key.Substring(0, t.Key.IndexOf('\n')), t.Key.Substring(t.Key.IndexOf('\n') + 1), t.Value))
.ToList();
/// <summary>The current files and texts are what is on disk now.</summary>
public void MarkSaved()
{
foreach (var (path, text) in ChangedFiles())
savedText[path] = text;
savedTexts = new Dictionary<string, string>(texts, StringComparer.Ordinal);
}
/// <summary>Problems that would break the game's data, as locale keys with an argument; empty when it can be saved.</summary>
public List<(string Key, string Argument)> Validate()
{
var problems = new List<(string, string)>();
foreach (var record in records.Where(r => string.IsNullOrWhiteSpace(r.Id)))
problems.Add(("tools/entity_editor/problem_id", record.File));
foreach (var group in records.Where(r => !string.IsNullOrWhiteSpace(r.Id)).GroupBy(r => r.Id, StringComparer.OrdinalIgnoreCase))
{
if (group.Count() > 1)
problems.Add(("tools/entity_editor/problem_duplicate", group.Key));
}
return problems;
}
/// <summary>A file's text as the document writes it: two-space indented, letters unescaped, LF line breaks (the writer would use CRLF on Windows), a final one.</summary>
public static string Serialize(JsonObject root) => root.ToJsonString(WriteOptions).Replace("\r\n", "\n") + "\n";
private static string TextKey(string language, string key) => language + "\n" + key;
private void Rebuild()
{
records.Clear();
foreach (var file in files.OrderBy(f => f.Key, StringComparer.Ordinal))
{
foreach (var item in (JsonArray)file.Value["Items"])
{
if (item is JsonObject entity)
records.Add(new EntityRecord(file.Key, entity));
}
}
}
private string Snapshot()
{
var state = new JsonObject
{
["files"] = new JsonObject(files.Select(f => new KeyValuePair<string, JsonNode>(f.Key, f.Value.DeepClone()))),
["texts"] = new JsonObject(texts.Select(t => new KeyValuePair<string, JsonNode>(t.Key, t.Value)))
};
return state.ToJsonString();
}
private bool Swap(List<string> from, List<string> to)
{
if (from.Count == 0)
return false;
to.Add(Snapshot());
var state = JsonNode.Parse(from[^1]).AsObject();
from.RemoveAt(from.Count - 1);
files.Clear();
foreach (var file in state["files"].AsObject())
files[file.Key] = file.Value.DeepClone().AsObject();
texts = state["texts"].AsObject().ToDictionary(t => t.Key, t => t.Value?.ToString() ?? string.Empty, StringComparer.Ordinal);
Rebuild();
return true;
}
}
}
+740
View File
@@ -0,0 +1,740 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Mods.Schemas;
using Cthangover.Tools.Common;
using Cthangover.Tools.Schemas;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.EntityEditor
{
/// <summary>
/// Base of the data editors (items, characters…): every entity of one kind in
/// one writable mod, a search list on the left and a form on the right built
/// from the editor's <see cref="EntityField"/> list. Layout and behaviour are
/// shared by all such editors, so they look and work alike; a concrete editor
/// only names its kind, its fields and how a new entity starts out.
/// <para/>
/// For a beginner the form is all there is: a name is typed in each language
/// of the mod (the locale key is made from the id), a picture is picked from a
/// grid, lists are check boxes. Save writes the mod's data and locale files and
/// makes the running game re-read them (entities, then texts), so the change is
/// visible at once. For a professional nothing is hidden: the locale key stays
/// editable, and every property the form does not know is shown as JSON under
/// "other fields", edited there and written back as it is.
/// <para/>
/// Fields other mods describe for the kind in their schemas (<c>schemas/*.json</c>: the
/// equipment mod adds <c>Equip</c> and <c>Traits</c> to items, the stats add <c>Archetype</c>
/// and <c>Stats</c> to characters) get their own block, "fields of other mods", with the
/// schema editors, so a data editor grows with the mods loaded next to it without
/// knowing them.
/// <para/>
/// Every change is undoable (Ctrl+Z / Ctrl+Y): one undo step per editing
/// session of one entity, and one per add, duplicate or delete.
/// </summary>
public abstract partial class EntityEditorWindow : ToolWindow
{
private readonly string kind;
private readonly string folder;
private readonly List<EntityField> fields;
private readonly Dictionary<EntityField, Control> controls = new();
private readonly Dictionary<EntityField, LineEdit> keyEdits = new();
private readonly Dictionary<EntityField, Dictionary<string, LineEdit>> textEdits = new();
private readonly Dictionary<EntityField, TextureRect> previews = new();
private readonly Dictionary<string, Texture2D> icons = new();
private readonly List<string> mods = new();
private readonly List<ISchemaFieldEditor> schemaEditors = new();
private OptionButton modPicker;
private FilteredList list;
private VBoxContainer form;
private Label emptyHint;
private Label fileLabel;
private LineEdit idEdit;
private CodeEdit otherEdit;
private EntityDocument document;
private EntityRecord selected;
private List<string> languages = new();
private string modId;
private bool suppress;
private bool editing;
/// <summary>Builds the window for entities of <paramref name="entityKind"/>; new ones go to <c>&lt;folder&gt;/&lt;mod&gt;.json</c>.</summary>
protected EntityEditorWindow(string titleKey, string entityKind, string entityFolder, List<EntityField> entityFields) : base(titleKey)
{
kind = entityKind;
folder = entityFolder;
fields = entityFields;
CthangoverTheme.Apply(this);
BuildUi();
SetHint(ToolUi.Tr("tools/entity_editor/hint"));
FillMods();
}
/// <summary>A new entity's JSON: the kind's required fields with starting values.</summary>
protected abstract JsonObject CreateEntity(string id);
/// <summary>Id a new entity starts from (numbered when taken), e.g. <c>&lt;mod&gt;/new_item</c>.</summary>
protected abstract string NewIdBase(string mod);
/// <summary>The edited mod, or null.</summary>
public string ModId => modId;
/// <summary>The selected entity's id, or null.</summary>
public string SelectedId => selected?.Id;
/// <summary>The edited entities (null before a mod is opened).</summary>
public EntityDocument Document => document;
// ── layout ─────────────────────────────────────────────────
private void BuildUi()
{
var outer = CreateFillContainer();
AddChild(outer);
outer.AddChild(BuildToolbar());
var main = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill };
outer.AddChild(main);
var listColumn = new VBoxContainer { SizeFlagsVertical = Control.SizeFlags.ExpandFill };
listColumn.AddChild(ToolUi.Section("tools/entity_editor/section_list"));
list = new FilteredList(320f, "tools/entity_editor/search");
list.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
list.FitIcons(64);
list.Chosen += OnChosen;
listColumn.AddChild(list);
var listPanel = CreateSidebar(listColumn, 320);
main.AddChild(listPanel);
var scroll = new ScrollContainer { HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
main.AddChild(scroll);
var column = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
column.AddThemeConstantOverride("separation", UiTokens.Space2);
scroll.AddChild(column);
emptyHint = ToolUi.Hint(ToolUi.Tr("tools/entity_editor/no_entity"));
column.AddChild(emptyHint);
form = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
form.AddThemeConstantOverride("separation", UiTokens.Space2);
column.AddChild(form);
BuildForm();
}
private HBoxContainer BuildToolbar()
{
var toolbar = CreateToolbar();
modPicker = new OptionButton { TooltipText = ToolUi.Tr("tools/entity_editor/mod_tip") };
modPicker.ItemSelected += OnModPicked;
toolbar.AddChild(modPicker);
toolbar.AddChild(ToolUi.Button("tools/common/save", OnSavePressed, ButtonKind.Primary, "tools/common/save_tip"));
toolbar.AddChild(ToolUi.Divider());
toolbar.AddChild(ToolUi.Button("tools/entity_editor/new", AddNew, ButtonKind.Secondary, "tools/entity_editor/new_tip"));
toolbar.AddChild(ToolUi.Button("tools/entity_editor/duplicate", DuplicateSelected, ButtonKind.Secondary, "tools/entity_editor/duplicate_tip"));
toolbar.AddChild(ToolUi.Button("tools/entity_editor/delete", DeleteSelected, ButtonKind.Secondary, "tools/entity_editor/delete_tip"));
toolbar.AddChild(ToolUi.Divider());
toolbar.AddChild(ToolUi.Button("tools/entity_editor/undo", Undo, ButtonKind.Secondary, "tools/entity_editor/undo_tip"));
toolbar.AddChild(ToolUi.Button("tools/entity_editor/redo", Redo, ButtonKind.Secondary, "tools/entity_editor/redo_tip"));
return toolbar;
}
private void BuildForm()
{
fileLabel = ToolUi.Hint(string.Empty);
form.AddChild(fileLabel);
idEdit = ToolUi.Field(form, "tools/entity_editor/id", ToolUi.Text("", OnTextChanged), "tools/entity_editor/id_tip");
idEdit.AddThemeFontOverride("font", ToolUi.Mono());
foreach (var field in fields)
BuildField(field);
BuildSchemaFields();
form.AddChild(ToolUi.Section("tools/entity_editor/section_other"));
form.AddChild(ToolUi.Hint(ToolUi.Tr("tools/entity_editor/other_hint")));
otherEdit = ToolUi.Code();
otherEdit.CustomMinimumSize = new Vector2(0, 160);
otherEdit.SizeFlagsVertical = Control.SizeFlags.Fill;
otherEdit.TextChanged += OnOtherChanged;
form.AddChild(otherEdit);
}
private void BuildField(EntityField field)
{
switch (field.Kind)
{
case EntityFieldKind.Text:
controls[field] = ToolUi.Field(form, field.LabelKey, ToolUi.Text("", OnTextChanged), field.TooltipKey);
break;
case EntityFieldKind.Integer:
controls[field] = ToolUi.Field(form, field.LabelKey, ToolUi.Number(-999999, 999999, 1, OnNumberChanged), field.TooltipKey);
break;
case EntityFieldKind.Choice:
var choice = ToolUi.Field(form, field.LabelKey, new OptionButton(), field.TooltipKey);
choice.ItemSelected += OnChoicePicked;
controls[field] = choice;
break;
case EntityFieldKind.Flags:
case EntityFieldKind.List:
controls[field] = ToolUi.Field(form, field.LabelKey, new HFlowContainer(), field.TooltipKey);
break;
case EntityFieldKind.Picture:
BuildPicture(field);
break;
case EntityFieldKind.LocaleText:
// The texts get their rows when a mod is opened: its languages decide them.
keyEdits[field] = ToolUi.Field(form, field.LabelKey, ToolUi.Text("", OnTextChanged), "tools/entity_editor/locale_key_tip");
keyEdits[field].AddThemeFontOverride("font", ToolUi.Mono());
var texts = new VBoxContainer();
form.AddChild(texts);
controls[field] = texts;
textEdits[field] = new Dictionary<string, LineEdit>();
break;
}
}
private void BuildSchemaFields()
{
var described = Cthangover.Mods.Schemas.SchemaCatalog.Current.FieldsOf(kind)
.Where(f => !string.Equals(f.Key, "Id", StringComparison.OrdinalIgnoreCase) && !fields.Any(e => string.Equals(e.Key, f.Key, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (described.Count == 0)
return;
form.AddChild(ToolUi.Section("tools/entity_editor/section_schema"));
var sources = string.Join(", ", described.Select(f => f.SourceMod).Where(m => m != null).Distinct());
form.AddChild(ToolUi.Hint(string.Format(ToolUi.Tr("tools/entity_editor/schema_hint"), sources)));
foreach (var field in described)
schemaEditors.Add(SchemaFieldEditors.Create(form, field, WriteForm));
}
private void BuildPicture(EntityField field)
{
var row = new HBoxContainer();
var path = ToolUi.Text(field.Group + "/…", OnPictureTyped);
path.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(path);
var pick = UiKit.IconButton("…", ToolUi.Tr("tools/entity_editor/picture_pick_tip"));
pick.Pressed += () => OpenPicker(field);
row.AddChild(pick);
ToolUi.Field(form, field.LabelKey, row, field.TooltipKey);
controls[field] = path;
var preview = new TextureRect
{
CustomMinimumSize = new Vector2(96, 96),
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered,
TextureFilter = CanvasItem.TextureFilterEnum.Nearest,
SizeFlagsHorizontal = Control.SizeFlags.ShrinkBegin
};
var margin = new MarginContainer();
margin.AddThemeConstantOverride("margin_left", ToolUi.LabelWidth + UiTokens.Space3);
margin.AddChild(preview);
form.AddChild(margin);
previews[field] = preview;
}
// ── mods and the list ──────────────────────────────────────
private void FillMods()
{
mods.Clear();
mods.AddRange(ModFileService.ListWritableMods());
modPicker.Clear();
foreach (var mod in mods)
modPicker.AddItem(mod);
if (mods.Count == 0)
{
SetStatus(ToolUi.Tr("tools/common/no_writable_mods"), ToolStatusKind.Error);
return;
}
// Start in the first mod that already has such entities: usually the game's own content.
var start = mods.FindIndex(m => LoadDocument(m).Records.Count > 0);
OpenMod(mods[Math.Max(0, start)]);
}
private EntityDocument LoadDocument(string mod)
{
var sources = ModFileService.ListFiles(mod, string.Empty, ".json")
.Select(path => (path, ModFileService.ReadText(mod, path)));
var loaded = EntityDocument.Load(kind, $"{folder}/{mod}.json", sources, out var skipped);
if (skipped.Count > 0)
SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/broken_files"), string.Join(", ", skipped)), ToolStatusKind.Error);
return loaded;
}
/// <summary>Opens the entities of a mod (asks first about unsaved changes).</summary>
public void OpenMod(string mod)
{
RunDiscardingChanges(() => DoOpenMod(mod), () => modPicker.Select(mods.IndexOf(modId)));
}
private void DoOpenMod(string mod)
{
modId = mod;
modPicker.Select(mods.IndexOf(mod));
document = LoadDocument(mod);
languages = LocaleFileService.Languages(mod);
foreach (var field in fields.Where(f => f.Kind == EntityFieldKind.LocaleText))
BuildTextRows(field);
foreach (var locale in schemaEditors.OfType<LocaleFieldEditor>())
locale.Open(document, mod, languages);
MarkClean();
selected = null;
RefreshList();
Select(document.Records.FirstOrDefault());
SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/opened"), document.Records.Count, mod));
}
private void BuildTextRows(EntityField field)
{
var box = (VBoxContainer)controls[field];
ClearChildren(box);
textEdits[field].Clear();
foreach (var language in languages)
{
var label = string.Format(ToolUi.Tr("tools/entity_editor/text_in"), language);
var edit = ToolUi.Text("", OnTextChanged);
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space3);
row.AddChild(new Label { Text = label, CustomMinimumSize = new Vector2(ToolUi.LabelWidth, 0), HorizontalAlignment = HorizontalAlignment.Right });
edit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(edit);
box.AddChild(row);
textEdits[field][language] = edit;
}
}
private void RefreshList()
{
var first = fields.FirstOrDefault(f => f.Kind == EntityFieldKind.Picture);
var name = fields.FirstOrDefault(f => f.Kind == EntityFieldKind.LocaleText);
list.SetItems(document.Records.Select(r => (r.Id, RowText(r, name), first == null ? null : Icon(first.Group, Str(r.Data, first.Key)))));
list.Select(selected?.Id);
}
private string RowText(EntityRecord record, EntityField nameField)
{
var key = nameField == null ? null : Str(record.Data, nameField.Key);
var text = key == null ? null : document.GetText(languages.FirstOrDefault() ?? "ru", key) ?? LocaleFileService.Read(modId, languages.FirstOrDefault() ?? "ru", key);
return string.IsNullOrWhiteSpace(text) ? record.Id : $"{text} · {record.Id}";
}
private Texture2D Icon(string group, string id)
{
if (string.IsNullOrEmpty(id))
return null;
var cacheKey = group + "|" + id;
if (!icons.TryGetValue(cacheKey, out var texture))
{
texture = ModResourceService.LoadGroupImage(group, id);
icons[cacheKey] = texture;
}
return texture;
}
private void OnChosen(string id) => Select(document?.Find(id));
private void OnModPicked(long index)
{
if (index >= 0 && index < mods.Count && mods[(int)index] != modId)
OpenMod(mods[(int)index]);
}
// ── form ───────────────────────────────────────────────────
/// <summary>Shows an entity in the form (null: none).</summary>
public void Select(EntityRecord record)
{
selected = record;
editing = false;
emptyHint.Visible = record == null;
form.Visible = record != null;
list.Select(record?.Id);
if (record == null)
return;
suppress = true;
fileLabel.Text = string.Format(ToolUi.Tr("tools/entity_editor/file"), modId, record.File);
idEdit.Text = record.Id;
foreach (var field in fields)
ShowField(field, record.Data);
foreach (var editor in schemaEditors)
editor.Load(record.Data.FirstOrDefault(p => string.Equals(p.Key, editor.Field.Key, StringComparison.OrdinalIgnoreCase)).Value);
otherEdit.Text = OtherJson(record.Data);
suppress = false;
}
private void ShowField(EntityField field, JsonObject data)
{
switch (field.Kind)
{
case EntityFieldKind.Text:
((LineEdit)controls[field]).Text = Str(data, field.Key) ?? string.Empty;
break;
case EntityFieldKind.Integer:
((SpinBox)controls[field]).Value = data[field.Key] is JsonValue v && v.TryGetValue(out int number) ? number : 0;
break;
case EntityFieldKind.Choice:
FillChoice((OptionButton)controls[field], field, Str(data, field.Key));
break;
case EntityFieldKind.Flags:
FillChecks((HFlowContainer)controls[field], field, (Str(data, field.Key) ?? string.Empty).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
break;
case EntityFieldKind.List:
FillChecks((HFlowContainer)controls[field], field, (data[field.Key] as JsonArray)?.Select(n => n?.ToString()).Where(s => s != null).ToArray() ?? Array.Empty<string>());
break;
case EntityFieldKind.Picture:
var path = Str(data, field.Key) ?? string.Empty;
((LineEdit)controls[field]).Text = path;
previews[field].Texture = ModResourceService.LoadGroupImage(field.Group, path);
break;
case EntityFieldKind.LocaleText:
var key = Str(data, field.Key) ?? string.Empty;
keyEdits[field].Text = key;
foreach (var pair in textEdits[field])
pair.Value.Text = TextOf(pair.Key, key);
break;
}
}
private string TextOf(string language, string key)
{
if (string.IsNullOrEmpty(key))
return string.Empty;
var text = document.GetText(language, key);
if (text != null)
return text;
text = LocaleFileService.Read(modId, language, key) ?? string.Empty;
document.LoadText(language, key, text);
return text;
}
private static void FillChoice(OptionButton picker, EntityField field, string value)
{
var options = (field.Options?.Invoke() ?? Enumerable.Empty<string>()).ToList();
if (!string.IsNullOrEmpty(value) && !options.Contains(value))
options.Insert(0, value);
picker.Clear();
foreach (var option in options)
picker.AddItem(option);
picker.Select(options.IndexOf(value ?? string.Empty));
}
private void FillChecks(HFlowContainer box, EntityField field, string[] values)
{
ClearChildren(box);
var options = (field.Options?.Invoke() ?? Enumerable.Empty<string>()).ToList();
options.AddRange(values.Where(v => !options.Contains(v)));
foreach (var option in options)
{
var check = new CheckBox { Text = option, ButtonPressed = values.Contains(option) };
check.Toggled += OnChecked;
box.AddChild(check);
}
}
private void OnTextChanged(string text) => WriteForm();
private void OnNumberChanged(double value) => WriteForm();
private void OnChoicePicked(long index) => WriteForm();
private void OnChecked(bool on) => WriteForm();
private void OnPictureTyped(string text)
{
WriteForm();
foreach (var pair in previews)
pair.Value.Texture = ModResourceService.LoadGroupImage(pair.Key.Group, ((LineEdit)controls[pair.Key]).Text);
}
private void OpenPicker(EntityField field)
{
if (selected == null)
return;
var dialog = new PicturePickerDialog(field.Group, ((LineEdit)controls[field]).Text);
dialog.Picked += id => ((LineEdit)controls[field]).Text = id;
dialog.Picked += OnPictureTyped;
AddChild(dialog);
dialog.PopupCentered();
}
/// <summary>Copies the form into the selected entity; the first change of a session records an undo step.</summary>
private void WriteForm()
{
if (suppress || selected == null)
return;
if (!editing)
{
document.BeginChange();
editing = true;
}
var data = selected.Data;
data["Id"] = idEdit.Text.Trim();
foreach (var field in fields)
WriteField(field, data);
foreach (var editor in schemaEditors)
{
var key = data.FirstOrDefault(p => string.Equals(p.Key, editor.Field.Key, StringComparison.OrdinalIgnoreCase)).Key ?? editor.Field.Key;
var value = editor.Save();
if (value == null)
data.Remove(key);
else
data[key] = value;
}
SetDirty();
RefreshList();
}
private void WriteField(EntityField field, JsonObject data)
{
switch (field.Kind)
{
case EntityFieldKind.Text:
case EntityFieldKind.Picture:
SetOrRemove(data, field.Key, ((LineEdit)controls[field]).Text.Trim());
break;
case EntityFieldKind.Integer:
var number = (int)((SpinBox)controls[field]).Value;
if (number != 0 || data.ContainsKey(field.Key))
data[field.Key] = number;
break;
case EntityFieldKind.Choice:
var picker = (OptionButton)controls[field];
if (picker.Selected >= 0)
data[field.Key] = picker.GetItemText(picker.Selected);
break;
case EntityFieldKind.Flags:
var flags = Checked((HFlowContainer)controls[field]);
if (flags.Count > 0 || data.ContainsKey(field.Key))
data[field.Key] = flags.Count == 0 ? "None" : string.Join(",", flags);
break;
case EntityFieldKind.List:
var values = Checked((HFlowContainer)controls[field]);
if (values.Count > 0 || data.ContainsKey(field.Key))
data[field.Key] = new JsonArray(values.Select(v => (JsonNode)v).ToArray());
break;
case EntityFieldKind.LocaleText:
var key = keyEdits[field].Text.Trim();
SetOrRemove(data, field.Key, key);
if (key.Length == 0)
break;
foreach (var pair in textEdits[field])
document.SetText(pair.Key, key, pair.Value.Text);
break;
}
}
private static List<string> Checked(HFlowContainer box)
=> box.GetChildren().OfType<CheckBox>().Where(c => c.ButtonPressed).Select(c => c.Text).ToList();
/// <summary>Takes the children out of the tree at once (a search of the tree must not find them) and frees them.</summary>
private static void ClearChildren(Node box)
{
foreach (var child in box.GetChildren())
{
box.RemoveChild(child);
child.QueueFree();
}
}
private static void SetOrRemove(JsonObject data, string key, string value)
{
if (value.Length > 0)
data[key] = value;
else
data.Remove(key);
}
/// <summary>The properties no field shows, as an indented JSON object.</summary>
private string OtherJson(JsonObject data)
{
var other = new JsonObject();
foreach (var pair in data.Where(p => !IsFormKey(p.Key)))
other[pair.Key] = pair.Value?.DeepClone();
return EntityDocument.Serialize(other).TrimEnd();
}
private bool IsFormKey(string key)
=> key == "Id" || fields.Any(f => f.Key == key) || schemaEditors.Any(e => string.Equals(e.Field.Key, key, StringComparison.OrdinalIgnoreCase));
private void OnOtherChanged()
{
if (suppress || selected == null)
return;
JsonObject other;
try
{
other = JsonNode.Parse(otherEdit.Text) as JsonObject;
}
catch (JsonException ex)
{
SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/other_invalid"), ex.Message), ToolStatusKind.Error);
return;
}
if (other == null)
return;
if (!editing)
{
document.BeginChange();
editing = true;
}
var data = selected.Data;
foreach (var key in data.Select(p => p.Key).Where(k => !IsFormKey(k)).ToList())
data.Remove(key);
foreach (var pair in other.Where(p => !IsFormKey(p.Key)).ToList())
data[pair.Key] = pair.Value?.DeepClone();
SetDirty();
SetStatus(ToolUi.Tr("tools/entity_editor/other_applied"));
}
// ── commands ───────────────────────────────────────────────
private void AddNew()
{
if (document == null)
return;
document.BeginChange();
var id = document.FreeId(NewIdBase(modId));
var entity = CreateEntity(id);
foreach (var field in fields.Where(f => f.Kind == EntityFieldKind.LocaleText && f.KeyPattern != null))
{
var key = string.Format(field.KeyPattern, id);
entity[field.Key] = key;
foreach (var language in languages)
document.SetText(language, key, field.Key == "Name" ? id.Substring(id.LastIndexOf('/') + 1) : string.Empty);
}
var record = document.Add(entity);
SetDirty();
RefreshList();
Select(record);
SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/added"), id, record.File));
}
private void DuplicateSelected()
{
if (selected == null)
return;
document.BeginChange();
var record = document.Duplicate(selected, document.FreeId(selected.Id));
SetDirty();
RefreshList();
Select(record);
}
private void DeleteSelected()
{
if (selected == null)
return;
ShowConfirm("tools/entity_editor/delete_title", "tools/entity_editor/delete_text", ConfirmDelete);
}
private void ConfirmDelete()
{
if (selected == null)
return;
document.BeginChange();
var id = selected.Id;
document.Remove(selected);
SetDirty();
Select(null);
RefreshList();
SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/deleted"), id));
}
private void Undo() => StepHistory(document != null && document.Undo());
private void Redo() => StepHistory(document != null && document.Redo());
private void StepHistory(bool stepped)
{
if (!stepped)
return;
// The document holds new JSON objects now: find the selection again by id.
var id = selected?.Id;
SetDirty();
RefreshList();
Select(document.Find(id) ?? document.Records.FirstOrDefault());
}
/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut() => Save();
private void OnSavePressed() => Save();
/// <summary>
/// Writes the changed data and locale files and makes the game re-read
/// them: the entity cache is dropped (factories rebuild from the new
/// snapshot) and the current language's texts are reloaded.
/// </summary>
public bool Save()
{
if (document == null)
return false;
var problems = document.Validate();
if (problems.Count > 0)
{
SetStatus(string.Format(ToolUi.Tr(problems[0].Key), problems[0].Argument), ToolStatusKind.Error);
return false;
}
var changedFiles = document.ChangedFiles();
var changedTexts = document.ChangedTexts();
foreach (var (path, text) in changedFiles)
{
if (!ModFileService.WriteText(modId, path, text))
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return false;
}
}
foreach (var (language, key, value) in changedTexts)
LocaleFileService.Write(modId, language, key, value);
document.MarkSaved();
MarkClean();
editing = false;
ModManager.Instance.GetResolver<EntityResolver>()?.ClearCache();
ModManager.Instance.GetResolver<FileListResolver>()?.ClearCache();
icons.Clear();
if (changedTexts.Count > 0)
LocaleFileService.ReloadGame();
GameLogger.Log("TOOLS", $"entity editor: saved {changedFiles.Count} {kind} file(s), {changedTexts.Count} text(s) of '{modId}'");
SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/saved"), changedFiles.Count, changedTexts.Count, modId), ToolStatusKind.Ok);
return true;
}
/// <summary>Shortcuts: Ctrl+Z / Ctrl+Y undo and redo, Ctrl+N adds, Ctrl+D duplicates.</summary>
protected override bool HandleKey(InputEventKey key)
{
if (!key.CtrlPressed)
return false;
switch (key.Keycode)
{
case Key.Z when key.ShiftPressed:
case Key.Y:
Redo();
return true;
case Key.Z:
Undo();
return true;
case Key.N:
AddNew();
return true;
case Key.D:
DuplicateSelected();
return true;
}
return false;
}
private static string Str(JsonObject data, string key) => data[key]?.ToString();
}
}
+35
View File
@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
namespace Cthangover.Tools.EntityEditor
{
/// <summary>
/// One property of an entity as an <see cref="EntityEditorWindow"/> form shows
/// it. A concrete editor lists its fields; properties no field names are
/// shown and edited as raw JSON ("other fields"), so the form never hides or
/// drops data a mod added.
/// </summary>
public class EntityField
{
/// <summary>JSON property name.</summary>
public string Key { get; init; }
/// <summary>Locale key of the label.</summary>
public string LabelKey { get; init; }
/// <summary>Locale key of the tooltip, or null.</summary>
public string TooltipKey { get; init; }
/// <summary>How the value is edited.</summary>
public EntityFieldKind Kind { get; init; }
/// <summary>Picture: the file group of the pictures (<c>items</c>, <c>characters</c>…).</summary>
public string Group { get; init; }
/// <summary>Choice, flags, list: the values offered (read when the form is built).</summary>
public Func<IEnumerable<string>> Options { get; init; }
/// <summary>Locale text: how a new entity's key is made from its id (<c>{0}</c> = id).</summary>
public string KeyPattern { get; init; }
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.Tools.EntityEditor
{
/// <summary>How an <see cref="EntityField"/> is shown and stored.</summary>
public enum EntityFieldKind
{
/// <summary>One line of text.</summary>
Text,
/// <summary>A locale key with its text in every language of the mod.</summary>
LocaleText,
/// <summary>A whole number.</summary>
Integer,
/// <summary>A picture path in a file group, with a preview and a picker.</summary>
Picture,
/// <summary>One value of a list (values outside the list stay possible).</summary>
Choice,
/// <summary>Several names of a list written as <c>"A,B"</c> (an enum with flags).</summary>
Flags,
/// <summary>A JSON array of strings picked from a list.</summary>
List
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json.Nodes;
namespace Cthangover.Tools.EntityEditor
{
/// <summary>One entity of an <see cref="EntityDocument"/>: its JSON object inside the <c>Items</c> array of its file.</summary>
public class EntityRecord
{
/// <summary>Mod-relative path of the file holding the entity.</summary>
public string File { get; }
/// <summary>The entity itself; unknown properties are kept as they are.</summary>
public JsonObject Data { get; }
/// <summary>The entity's <c>Id</c> (empty when missing).</summary>
public string Id => Data["Id"]?.ToString() ?? string.Empty;
/// <summary>Wraps an entity of a file.</summary>
public EntityRecord(string file, JsonObject data)
{
File = file;
Data = data;
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using System;
using System.Linq;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.EntityEditor
{
/// <summary>
/// Grid of every picture of a file group with a search, for a picture field
/// of an entity form: a double click or OK picks one. The pictures are read
/// when the dialog opens, so a file just dropped into a mod folder shows up
/// (after the game re-reads its files: the editor's Save does that).
/// </summary>
public partial class PicturePickerDialog : AcceptDialog
{
private readonly FilteredList list;
/// <summary>A picture was picked: its id in the group.</summary>
public event Action<string> Picked;
/// <summary>Builds the dialog for the pictures of <paramref name="group"/>.</summary>
public PicturePickerDialog(string group, string current)
{
Theme = ToolTheme.Get();
Title = string.Format(ToolUi.Tr("tools/entity_editor/picture_pick_title"), group);
MinSize = new Vector2I(640, 520);
list = new FilteredList(420f);
list.UseIconGrid(64);
list.SetItems(ModResourceService.GetGroupImageIds(group)
.Select(id => (id, id.Substring(id.LastIndexOf('/') + 1), ModResourceService.LoadGroupImage(group, id))));
list.Select(current);
list.Activated += OnActivated;
AddChild(list);
Confirmed += OnConfirmed;
CloseRequested += QueueFree;
Canceled += QueueFree;
}
private void OnActivated(string id)
{
if (id == null)
return;
Picked?.Invoke(id);
Hide();
QueueFree();
}
private void OnConfirmed()
{
if (list.SelectedId != null)
Picked?.Invoke(list.SelectedId);
QueueFree();
}
}
}
@@ -0,0 +1,260 @@
using System;
using System.Linq;
using Cthangover.Core.Interactive;
using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Tools.InteractiveEditor
{
/// <summary>
/// Preview of the interactives of one file over a scene background. Every
/// collider is drawn (others faint, the selected one filled), so the author
/// sees how objects of the same scene sit next to each other; clicking a
/// collider selects it. The selected collider gets handles:
/// <list type="bullet">
/// <item>rect — top-left and bottom-right corners plus a centre handle
/// that moves the whole rect;</item>
/// <item>circle — centre and a radius handle;</item>
/// <item>polygon — one handle per vertex (right-click deletes it); a click
/// on empty background appends a vertex.</item>
/// </list>
/// Geometry is in the same normalised background space as
/// <c>InteractiveObject</c>: x/y/width/height are fractions of the
/// background size, a circle radius is a fraction of its shorter side.
/// </summary>
public partial class InteractiveEditorCanvas : BackgroundCanvas
{
private const int TopLeft = 0;
private const int BottomRight = 1;
private const int Move = 2;
private const int RadiusHandle = 1;
private const float MinExtent = 0.005f;
/// <summary>Data being edited; set once by the window.</summary>
public InteractiveEditorController Controller;
/// <summary>Raised whenever geometry of the selected item changes.</summary>
public event Action Changed;
/// <summary>Raised when a click on the canvas selects another item (-1: none).</summary>
public event Action<int> ItemPicked;
public InteractiveEditorCanvas()
{
GuiInput += OnGuiInput;
}
/// <summary>Recreates the handles of the selected item's collider.</summary>
public override void RebuildHandles()
{
ClearHandles();
var hit = Controller?.Selected?.HitArea;
if (hit == null || GetBackgroundRect().Size.X <= 0f)
return;
switch (hit.Type)
{
case "circle":
AddHandle(new Vector2(hit.X, hit.Y), UiTokens.Info, Move, p => { hit.X = p.X; hit.Y = p.Y; Moved(); });
AddHandle(RadiusPoint(hit), UiTokens.Info, RadiusHandle, p =>
{
var shortSide = ShortSide();
var centre = NormToLocal(new Vector2(hit.X, hit.Y));
hit.Radius = Mathf.Max(MinExtent, (NormToLocal(p) - centre).Length() / shortSide);
Moved();
});
break;
case "polygon":
var vertices = hit.Vertices ?? Array.Empty<Vector2>();
for (var i = 0; i < vertices.Length; i++)
{
var index = i;
var handle = AddHandle(vertices[i], UiTokens.Accent, i, p => { hit.Vertices[index] = p; Moved(); });
handle.RightClicked = () => RemoveVertex(index);
}
break;
default:
AddHandle(new Vector2(hit.X, hit.Y), UiTokens.Success, TopLeft, p =>
{
var right = hit.X + hit.Width;
var bottom = hit.Y + hit.Height;
hit.X = Mathf.Min(p.X, right - MinExtent);
hit.Y = Mathf.Min(p.Y, bottom - MinExtent);
hit.Width = right - hit.X;
hit.Height = bottom - hit.Y;
Moved();
});
AddHandle(new Vector2(hit.X + hit.Width, hit.Y + hit.Height), UiTokens.Success, BottomRight, p =>
{
hit.Width = Mathf.Max(MinExtent, p.X - hit.X);
hit.Height = Mathf.Max(MinExtent, p.Y - hit.Y);
Moved();
});
AddHandle(new Vector2(hit.X + hit.Width / 2f, hit.Y + hit.Height / 2f), UiTokens.AccentBright, Move, p =>
{
hit.X = Mathf.Clamp(p.X - hit.Width / 2f, 0f, 1f - hit.Width);
hit.Y = Mathf.Clamp(p.Y - hit.Height / 2f, 0f, 1f - hit.Height);
Moved();
}, 12f);
break;
}
foreach (var handle in _handles)
handle.DragEnd = () => { RebuildHandles(); Changed?.Invoke(); };
}
/// <summary>Keeps the other handles glued to the shape while one is dragged.</summary>
private void Moved()
{
var hit = Controller.Selected.HitArea;
foreach (var handle in _handles)
{
Vector2? target = hit.Type switch
{
"circle" => handle.Index == Move ? new Vector2(hit.X, hit.Y) : RadiusPoint(hit),
"polygon" => null,
_ => handle.Index switch
{
TopLeft => new Vector2(hit.X, hit.Y),
BottomRight => new Vector2(hit.X + hit.Width, hit.Y + hit.Height),
_ => new Vector2(hit.X + hit.Width / 2f, hit.Y + hit.Height / 2f)
}
};
if (target.HasValue)
handle.CenterAt(GlobalPosition + NormToLocal(target.Value));
}
QueueRedraw();
Changed?.Invoke();
}
private Vector2 RadiusPoint(HitAreaDefinition hit)
{
var rect = GetBackgroundRect();
var offset = rect.Size.X > 0f ? hit.Radius * ShortSide() / rect.Size.X : 0f;
return new Vector2(hit.X + offset, hit.Y);
}
private float ShortSide()
{
var rect = GetBackgroundRect();
return Mathf.Max(1f, Mathf.Min(rect.Size.X, rect.Size.Y));
}
private void RemoveVertex(int index)
{
var hit = Controller.Selected?.HitArea;
if (hit?.Vertices == null || index >= hit.Vertices.Length)
return;
hit.Vertices = hit.Vertices.Where((_, i) => i != index).ToArray();
RebuildHandles();
QueueRedraw();
Changed?.Invoke();
}
private void OnGuiInput(InputEvent @event)
{
if (Controller == null || @event is not InputEventMouseButton mb || !mb.Pressed || mb.ButtonIndex != MouseButton.Left)
return;
var norm = LocalToNorm(mb.Position);
var hitIndex = HitTest(norm);
var selected = Controller.Selected;
if (hitIndex >= 0 && hitIndex != Controller.SelectedIndex)
{
Controller.Select(hitIndex);
RebuildHandles();
QueueRedraw();
ItemPicked?.Invoke(hitIndex);
}
else if (hitIndex < 0 && selected?.HitArea.Type == "polygon")
{
var vertices = selected.HitArea.Vertices ?? Array.Empty<Vector2>();
selected.HitArea.Vertices = vertices.Append(Clamp01(norm)).ToArray();
RebuildHandles();
QueueRedraw();
Changed?.Invoke();
}
AcceptEvent();
}
/// <summary>Topmost item whose collider contains the normalised point, or -1.</summary>
private int HitTest(Vector2 norm)
{
var rect = GetBackgroundRect();
var local = NormToLocal(norm);
for (var i = Controller.Items.Count - 1; i >= 0; i--)
{
var hit = Controller.Items[i].HitArea;
if (hit == null)
continue;
var inside = hit.Type switch
{
"circle" => (local - NormToLocal(new Vector2(hit.X, hit.Y))).Length() <= hit.Radius * Mathf.Min(rect.Size.X, rect.Size.Y),
"polygon" => hit.Vertices is { Length: >= 3 } && Geometry2D.IsPointInPolygon(norm, hit.Vertices),
_ => new Rect2(hit.X, hit.Y, hit.Width, hit.Height).HasPoint(norm)
};
if (inside)
return i;
}
return -1;
}
protected override void DrawContent(Rect2 rect)
{
if (Controller == null)
return;
for (var i = 0; i < Controller.Items.Count; i++)
DrawCollider(Controller.Items[i], rect, i == Controller.SelectedIndex);
}
private void DrawCollider(InteractiveDefinition def, Rect2 rect, bool selected)
{
var hit = def.HitArea;
if (hit == null)
return;
var line = selected ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.Ink, 0.55f);
var fill = selected ? UiTokens.Alpha(UiTokens.Accent, 0.22f) : UiTokens.Alpha(UiTokens.Ink, 0.06f);
Vector2 labelAt;
switch (hit.Type)
{
case "circle":
var centre = NormToLocal(new Vector2(hit.X, hit.Y));
var radius = hit.Radius * Mathf.Min(rect.Size.X, rect.Size.Y);
DrawCircle(centre, radius, fill);
DrawArc(centre, radius, 0f, Mathf.Tau, 48, line, selected ? 2f : 1f, true);
labelAt = centre + new Vector2(-radius, -radius);
break;
case "polygon":
var vertices = hit.Vertices ?? Array.Empty<Vector2>();
var points = vertices.Select(NormToLocal).ToArray();
if (points.Length >= 3 && Geometry2D.TriangulatePolygon(points).Length > 0)
DrawColoredPolygon(points, fill);
if (points.Length >= 2)
DrawPolyline(points.Append(points[0]).ToArray(), line, selected ? 2f : 1f, true);
labelAt = points.Length > 0 ? points[0] : rect.Position;
break;
default:
var box = new Rect2(NormToLocal(new Vector2(hit.X, hit.Y)), new Vector2(hit.Width, hit.Height) * rect.Size);
DrawRect(box, fill);
DrawRect(box, line, false, selected ? 2f : 1f);
labelAt = box.Position;
break;
}
var font = GetThemeDefaultFont();
var size = UiTokens.FontSmall;
DrawString(font, labelAt + new Vector2(4f, -4f), def.ID ?? "?", HorizontalAlignment.Left, -1, size,
selected ? UiTokens.AccentBright : UiTokens.Body);
}
}
}
@@ -1,244 +1,206 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Cthangover.Core.Interactive;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
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>
/// Data model of the interactive editor: every definition of one
/// <c>interactives/*.json</c> file plus the selection. Definitions are
/// edited in place (the same objects the game deserialises), so the file
/// round-trips without a parallel field-by-field copy that could forget a
/// property. <see cref="Ensure"/> fills the optional sub-objects the editor
/// needs (hit area, highlight, actions) before a definition is shown.
/// </summary>
public class InteractiveEditorController
{
/// <summary>Collider kinds understood by <c>InteractiveObject</c>.</summary>
public static readonly string[] HitTypes = { "rect", "circle", "polygon" };
/// <summary>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>Render layers understood by the interactive layer host.</summary>
public static readonly string[] Layers = { "foreground", "background", "ui" };
/// <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>Definitions of the open file, in file order.</summary>
public List<InteractiveDefinition> Items { get; private set; } = new();
/// <summary>List of normalised (0–1) vertices defining the polygon collider.</summary>
public List<Vector2> PolygonVertices { get; set; } = new();
/// <summary>Index of the selected definition, or -1.</summary>
public int SelectedIndex { get; private set; } = -1;
/// <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>The selected definition, or <c>null</c>.</summary>
public InteractiveDefinition Selected => SelectedIndex >= 0 && SelectedIndex < Items.Count ? Items[SelectedIndex] : null;
/// <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>Replaces the items with the contents of a <c>{"Items": [...]}</c> file. Throws on malformed JSON.</summary>
public void FromJson(string json)
{
var data = string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<FileData<InteractiveDefinition>>(json, JsonOptionsProvider.ReadOptions);
Items = data?.Items ?? new List<InteractiveDefinition>();
foreach (var item in Items)
Ensure(item);
SelectedIndex = Items.Count > 0 ? 0 : -1;
}
/// <summary>Builds an InteractiveDefinition from current state.</summary>
public InteractiveDefinition ToDefinition()
{
var hitArea = new HitAreaDefinition { Type = HitType };
/// <summary>Serialises all items into the standard envelope (indented, nulls omitted).</summary>
public string ToJson()
{
var data = new FileData<InteractiveDefinition> { Items = Items };
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
}
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;
}
/// <summary>Drops all items.</summary>
public void Clear()
{
Items = new List<InteractiveDefinition>();
SelectedIndex = -1;
}
var clickAction = new ClickAction();
/// <summary>Selects by index; out of range clears the selection.</summary>
public void Select(int index)
{
SelectedIndex = index >= 0 && index < Items.Count ? index : -1;
}
if (!string.IsNullOrWhiteSpace(OnClickScenario))
clickAction.Scenario = OnClickScenario.Trim();
/// <summary>Appends a new clickable rect in the middle of the background and selects it.</summary>
public InteractiveDefinition Add()
{
var def = new InteractiveDefinition
{
ID = MakeUniqueId("interactive"),
Cursor = "PointingHand",
HitArea = new HitAreaDefinition { Type = "rect", X = 0.45f, Y = 0.45f, Width = 0.1f, Height = 0.1f }
};
Ensure(def);
Items.Add(def);
SelectedIndex = Items.Count - 1;
return def;
}
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();
}
/// <summary>Appends a deep copy of the selected definition with a fresh ID, slightly offset.</summary>
public InteractiveDefinition DuplicateSelected()
{
var source = Selected;
if (source == null)
return null;
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()
}
};
}
var json = JsonSerializer.Serialize(source, JsonOptionsProvider.WriteCompactOptions);
var copy = JsonSerializer.Deserialize<InteractiveDefinition>(json, JsonOptionsProvider.ReadOptions);
copy.ID = MakeUniqueId(source.ID);
Ensure(copy);
Offset(copy.HitArea, new Vector2(0.02f, 0.02f));
Items.Add(copy);
SelectedIndex = Items.Count - 1;
return copy;
}
/// <summary>Loads state from an InteractiveDefinition.</summary>
public void FromDefinition(InteractiveDefinition def)
{
if (def == null) return;
/// <summary>Removes the selected definition and keeps a sensible selection.</summary>
public void RemoveSelected()
{
if (Selected == 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 ?? "";
Items.RemoveAt(SelectedIndex);
SelectedIndex = Items.Count == 0 ? -1 : Math.Min(SelectedIndex, Items.Count - 1);
}
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";
}
/// <summary>
/// Changes the collider kind of a definition, converting the current
/// shape so it stays roughly where it was (rect ↔ circle by bounds,
/// polygon from the bounds' corners).
/// </summary>
public static void SetHitType(InteractiveDefinition def, string type)
{
var hit = def.HitArea;
if (hit.Type == type)
return;
if (def.Highlight != null)
{
HighlightColorHex = def.Highlight.ColorHex ?? "#FFFF0033";
HighlightScale = def.Highlight.Scale;
}
var bounds = Bounds(hit);
hit.Type = type;
switch (type)
{
case "circle":
hit.X = bounds.GetCenter().X;
hit.Y = bounds.GetCenter().Y;
hit.Radius = Mathf.Max(0.01f, Mathf.Min(bounds.Size.X, bounds.Size.Y) / 2f);
break;
case "polygon":
hit.Vertices = new[]
{
bounds.Position,
new Vector2(bounds.End.X, bounds.Position.Y),
bounds.End,
new Vector2(bounds.Position.X, bounds.End.Y)
};
break;
default:
hit.X = bounds.Position.X;
hit.Y = bounds.Position.Y;
hit.Width = bounds.Size.X;
hit.Height = bounds.Size.Y;
break;
}
}
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>
/// Normalised bounding box of a hit area (circle radius taken as a
/// fraction of both axes — an approximation good enough for
/// conversions and duplication offsets).
/// </summary>
public static Rect2 Bounds(HitAreaDefinition hit)
{
switch (hit?.Type)
{
case "circle":
return new Rect2(hit.X - hit.Radius, hit.Y - hit.Radius, hit.Radius * 2f, hit.Radius * 2f);
case "polygon" when hit.Vertices is { Length: > 0 }:
var min = hit.Vertices[0];
var max = hit.Vertices[0];
foreach (var v in hit.Vertices)
{
min = new Vector2(Mathf.Min(min.X, v.X), Mathf.Min(min.Y, v.Y));
max = new Vector2(Mathf.Max(max.X, v.X), Mathf.Max(max.Y, v.Y));
}
return new Rect2(min, max - min);
case null:
return new Rect2(0.45f, 0.45f, 0.1f, 0.1f);
default:
return new Rect2(hit.X, hit.Y, hit.Width, hit.Height);
}
}
/// <summary>Serializes the definition to formatted JSON (FileData envelope).</summary>
public string ToJson()
{
var def = ToDefinition();
var wrapper = new
{
Items = new[] { def }
};
/// <summary>Makes sure the optional parts the editor binds to exist.</summary>
public static void Ensure(InteractiveDefinition def)
{
def.HitArea ??= new HitAreaDefinition();
def.HitArea.Type = HitTypes.Contains(def.HitArea.Type) ? def.HitArea.Type : "rect";
def.Highlight ??= new HighlightDefinition();
def.Actions ??= new InteractiveActionDefinition();
}
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>ID not used by another item: <c>base</c>, <c>base_2</c>, <c>base_3</c>…</summary>
public string MakeUniqueId(string baseId)
{
var used = new HashSet<string>(Items.Select(i => i.ID ?? string.Empty), StringComparer.OrdinalIgnoreCase);
if (!used.Contains(baseId))
return baseId;
return JsonSerializer.Serialize(wrapper, options);
}
var suffix = 2;
while (used.Contains($"{baseId}_{suffix}"))
suffix++;
return $"{baseId}_{suffix}";
}
/// <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));
}
}
private static void Offset(HitAreaDefinition hit, Vector2 delta)
{
hit.X += delta.X;
hit.Y += delta.Y;
if (hit.Vertices != null)
hit.Vertices = hit.Vertices.Select(v => v + delta).ToArray();
}
}
}
@@ -1,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,6 +1,7 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Tools.InteractiveEditor
{
/// <summary>
File diff suppressed because it is too large. Load diff
+129
View File
@@ -0,0 +1,129 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>
/// Night-time preview of a scene with its static lights. The background is
/// drawn through the game's <c>timed_sprite</c> shader (set as this
/// control's material), so the preview lights the picture exactly like the
/// game; the lights' positions are pushed to the shader in this canvas'
/// coordinates and their radii scaled from the 1920-px game width to the
/// drawn background width. Radius rings and handles live in a separate
/// child overlay — anything drawn by this control itself would be shaded.
/// <para/>
/// A click on a handle selects that light and drags it; a double-click on
/// empty background adds a light there.
/// </summary>
public partial class LightEditorCanvas : BackgroundCanvas
{
private readonly Control _overlay;
/// <summary>Data being edited; set once by the window.</summary>
public LightEditorController Controller;
/// <summary>Preview material (may be null when the shader is missing).</summary>
public ShaderMaterial PreviewMaterial
{
get => Material as ShaderMaterial;
set => Material = value;
}
/// <summary>Raised when a light moved or was added on the canvas.</summary>
public event Action Changed;
/// <summary>Raised when the selection changed on the canvas.</summary>
public event Action SelectionChanged;
public LightEditorCanvas()
{
_overlay = new Control { MouseFilter = MouseFilterEnum.Ignore };
AddChild(_overlay);
_overlay.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_overlay.Connect("draw", Callable.From((Action)DrawRings));
GuiInput += OnGuiInput;
}
/// <summary>One handle per light, coloured like the light.</summary>
public override void RebuildHandles()
{
ClearHandles();
if (Controller == null || GetBackgroundRect().Size.X <= 0f)
return;
for (var i = 0; i < Controller.Lights.Count; i++)
{
var index = i;
var light = Controller.Lights[i];
var handle = AddHandle(new Vector2(light.X, light.Y), light.ToColor(), i, p =>
{
light.X = p.X;
light.Y = p.Y;
Refresh();
Changed?.Invoke();
}, index == Controller.SelectedIndex ? 20f : 16f);
handle.Clicked = () => SelectLight(index);
}
Refresh();
}
/// <summary>Redraws the preview and re-uploads the shader uniforms.</summary>
public void Refresh()
{
QueueRedraw();
_overlay.QueueRedraw();
}
private void SelectLight(int index)
{
if (index == Controller.SelectedIndex)
return;
Controller.Select(index);
SelectionChanged?.Invoke();
CallDeferred(nameof(RebuildHandles));
}
private void OnGuiInput(InputEvent @event)
{
if (Controller == null || @event is not InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left } mb)
return;
if (mb.DoubleClick && Controller.Add(Clamp01(LocalToNorm(mb.Position))) != null)
{
RebuildHandles();
Changed?.Invoke();
SelectionChanged?.Invoke();
}
AcceptEvent();
}
protected override void DrawContent(Rect2 rect)
{
// The shader compares against world_pos (MODEL_MATRIX * VERTEX), i.e.
// canvas coordinates of the window, not this control's local ones.
Controller?.UpdatePreview(PreviewMaterial, n => GlobalPosition + NormToLocal(n), rect.Size.X / LightEditorController.GameWidth);
}
private void DrawRings()
{
var rect = GetBackgroundRect();
if (Controller == null || rect.Size.X <= 0f)
return;
var scale = rect.Size.X / LightEditorController.GameWidth;
for (var i = 0; i < Controller.Lights.Count; i++)
{
var light = Controller.Lights[i];
var selected = i == Controller.SelectedIndex;
var centre = NormToLocal(new Vector2(light.X, light.Y));
var color = selected ? UiTokens.AccentBright : UiTokens.Alpha(light.ToColor(), 0.45f);
_overlay.DrawArc(centre, light.Radius * scale, 0f, Mathf.Tau, 64, color, selected ? 1.5f : 1f, true);
}
}
}
}
+173 -147
View File
@@ -1,176 +1,202 @@
using System;
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 Cthangover.Core.Utils;
using Cthangover.Mods.Time;
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.
/// Data of the light editor: the static lights of one scenario and their
/// place in the scenario text. Static lights live in a <c>light_set</c>
/// line (<c>light_set [{"x":…,"y":…,"radius":…,"influence":…,"color":…}]</c>)
/// that <c>UiLightController.SetStaticLights</c> reads at runtime: x/y are
/// fractions of the viewport, radius is in viewport pixels at the game
/// resolution. The controller parses that line, edits the list and writes
/// the line back (or inserts one right after the scenario header), and
/// feeds the <c>timed_sprite</c> shader of the preview the same way the
/// game does, so the preview shows the real night lighting.
/// </summary>
public class LightEditorController
{
/// <summary>The editable list of light definitions. Capped at 10 for shader preview.</summary>
public List<LightDef> Lights { get; } = new();
public class LightEditorController
{
/// <summary>Static light slots the shader has (slot 0 is the dynamic light).</summary>
public const int MaxLights = 10;
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];
/// <summary>Viewport width the stored radii refer to.</summary>
public const float GameWidth = 1920f;
private bool dirty = true;
private const string Command = "light_set";
private const int ShaderSlots = 11;
private static readonly Vector2 Offscreen = new(-1000f, -1000f);
/// <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>The edited lights, in file order.</summary>
public List<LightDef> Lights { get; private set; } = new();
/// <summary>Index of the selected light, or -1.</summary>
public int SelectedIndex { get; private set; } = -1;
/// <summary>The selected light, or <c>null</c>.</summary>
public LightDef Selected => SelectedIndex >= 0 && SelectedIndex < Lights.Count ? Lights[SelectedIndex] : null;
/// <summary>Selects by index; out of range clears the selection.</summary>
public void Select(int index) => SelectedIndex = index >= 0 && index < Lights.Count ? index : -1;
/// <summary>Adds a warm light at the given normalised point and selects it; null when all slots are used.</summary>
public LightDef Add(Vector2 position)
{
if (Lights.Count >= MaxLights)
return null;
var light = new LightDef { X = position.X, Y = position.Y, Radius = 400f, Influence = 1f, ColorHex = "#ffcc66" };
Lights.Add(light);
SelectedIndex = Lights.Count - 1;
return light;
}
/// <summary>Removes the selected light.</summary>
public void RemoveSelected()
{
if (Selected == null)
return;
Lights.RemoveAt(SelectedIndex);
SelectedIndex = Math.Min(SelectedIndex, Lights.Count - 1);
}
/// <summary>Compact JSON array exactly as written into the <c>light_set</c> line.</summary>
public string ToJson() => JsonSerializer.Serialize(Lights, JsonOptionsProvider.WriteCompactOptions);
/// <summary>The scenario line for the current lights.</summary>
public string ToScenarioLine() => Command + " " + ToJson();
/// <summary>
/// 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.
/// Loads the lights of the first <c>light_set</c> line of a scenario.
/// Returns <c>false</c> when the scenario has none (the list is then
/// empty and saving inserts a new line). Throws on malformed JSON.
/// </summary>
public ShaderMaterial CreatePreviewMaterial()
{
var shader = Shaders.Resolve("timed_sprite");
if (shader == null)
return null;
public bool LoadFromScenario(string text)
{
Lights = new List<LightDef>();
SelectedIndex = -1;
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;
}
var index = FindLine(text, out var lines);
if (index < 0)
return false;
/// <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;
}
var json = ArgumentOf(lines[index]);
Lights = JsonSerializer.Deserialize<List<LightDef>>(json, JsonOptionsProvider.ReadOptions) ?? new List<LightDef>();
SelectedIndex = Lights.Count > 0 ? 0 : -1;
return true;
}
/// <summary>
/// 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.
/// Returns the scenario text with its <c>light_set</c> line replaced by
/// the current lights, or — when it has none — with a new line inserted
/// right after the <c>---</c> header separator (at the top otherwise).
/// </summary>
public void UpdatePreview(ShaderMaterial material, Vector2 previewGlobalPos, Vector2 previewSize)
{
if (material == null || !dirty)
return;
public string WriteToScenario(string text)
{
var index = FindLine(text, out var lines);
var newline = text.Contains("\r\n") ? "\r\n" : "\n";
if (index >= 0)
{
var indent = lines[index].Substring(0, lines[index].Length - lines[index].TrimStart().Length);
lines[index] = indent + ToScenarioLine();
}
else
{
var separator = lines.FindIndex(l => l.Trim() == "---");
lines.Insert(separator + 1, ToScenarioLine());
}
for (int i = 0; i < 11; i++)
{
previewPositions[i] = new Vector2(-1000f, -1000f);
previewRadii[i] = 0f;
previewInfluence[i] = 0f;
previewColors[i] = UiTokens.White;
}
return string.Join(newline, lines);
}
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();
}
/// <summary>True when the scenario text contains a <c>light_set</c> line.</summary>
public static bool HasLights(string text) => FindLine(text, out _) >= 0;
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]);
}
private static int FindLine(string text, out List<string> lines)
{
lines = new List<string>((text ?? string.Empty).Replace("\r\n", "\n").Split('\n'));
return lines.FindIndex(l => l.TrimStart().StartsWith(Command + " ", StringComparison.Ordinal));
}
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);
/// <summary>Argument of a <c>light_set</c> line; the old quoted form (<c>"[{\"x\":…}]"</c>) is unescaped.</summary>
private static string ArgumentOf(string line)
{
var arg = line.Trim().Substring(Command.Length).Trim();
if (arg.Length >= 2 && arg[0] == '"' && arg[arg.Length - 1] == '"')
arg = arg.Substring(1, arg.Length - 2).Replace("\\\"", "\"");
return arg;
}
dirty = false;
}
// ── shader preview ─────────────────────────────────────────
/// <summary>Serialises the light list as a compact JSON string.</summary>
public string ExportLightsJson()
{
return JsonSerializer.Serialize(Lights, Cthangover.Core.Utils.JsonOptionsProvider.WriteCompactOptions);
}
/// <summary>
/// Material of the preview: the game's <c>timed_sprite</c> shader at
/// night weights. <c>null</c> when the shader is not installed.
/// </summary>
public static ShaderMaterial CreatePreviewMaterial()
{
var shader = Shaders.Resolve("timed_sprite");
if (shader == null)
return null;
/// <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("\"", "\\\"")}\"";
}
}
var material = new ShaderMaterial { Shader = shader };
material.SetShaderParameter("time_weights", new Vector4(0, 0, 0, 1));
material.SetShaderParameter("use_time", true);
return material;
}
/// <summary>Binds the background and its optional depth/albedo masks to the preview material.</summary>
public static void SetPreviewTextures(ShaderMaterial material, Texture2D depth, Texture2D albedo)
{
if (material == null)
return;
material.SetShaderParameter("depth_mask", depth);
material.SetShaderParameter("albedo_map", albedo);
}
/// <summary>
/// Uploads the lights into the shader uniforms. <paramref name="toCanvas"/>
/// maps a normalised position into the preview's canvas coordinates
/// (the space of the shader's <c>world_pos</c>); <paramref name="radiusScale"/>
/// shrinks game-resolution radii to the preview size.
/// </summary>
public void UpdatePreview(ShaderMaterial material, Func<Vector2, Vector2> toCanvas, float radiusScale)
{
if (material == null)
return;
var positions = new Godot.Collections.Array<Vector2>();
var colors = new Godot.Collections.Array<Color>();
var radii = new Godot.Collections.Array<float>();
var influence = new Godot.Collections.Array<float>();
// Slot 0 is the dynamic (cursor) light at runtime; keep it off.
positions.Add(Offscreen);
colors.Add(UiTokens.White);
radii.Add(0f);
influence.Add(0f);
var count = Math.Min(Lights.Count, MaxLights);
for (var i = 0; i < ShaderSlots - 1; i++)
{
var on = i < count;
positions.Add(on ? toCanvas(new Vector2(Lights[i].X, Lights[i].Y)) : Offscreen);
colors.Add(on ? Lights[i].ToColor() : UiTokens.White);
radii.Add(on ? Lights[i].Radius : 0f);
influence.Add(on ? Lights[i].Influence : 0f);
}
material.SetShaderParameter("light_count", count + 1);
material.SetShaderParameter("light_positions", positions);
material.SetShaderParameter("light_colors", colors);
material.SetShaderParameter("light_radii", radii);
material.SetShaderParameter("light_influence", influence);
material.SetShaderParameter("light_radius_scale", radiusScale);
}
}
}
-58
View File
@@ -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,6 +1,7 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>Registers the light editor as a tool window. Creates <see cref="LightEditorWindow"/> instances.</summary>
+321 -313
View File
@@ -1,379 +1,387 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Linq;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.UI.Lights;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>
/// Editor 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.
/// Editor of the static lights of a scene. The author picks a scenario —
/// the ones that already set lights come first — and edits its
/// <c>light_set</c> line visually: drag lights on a night-time preview of
/// the scene background, double-click to add one, tune radius, influence
/// and colour. Saving rewrites that line in the scenario (or inserts it
/// after the header). The background follows the scenario's scene.
/// </summary>
public partial class LightEditorWindow : ToolWindow
{
private LightEditorController controller;
{
private readonly LightEditorController _controller = new();
private readonly List<ScenarioFileInfo> _scenarios = new();
private readonly List<string> _backgroundIds = new();
private ItemList backgroundList;
private VBoxContainer lightListPanel;
private Button addLightBtn;
private TextureRect preview;
private ShaderMaterial previewMaterial;
private ScenarioFileInfo _scenario;
private string _scenarioText;
private bool _suppress;
private LineEdit radiusEdit;
private LineEdit influenceEdit;
private ColorPickerButton colorPicker;
private TextEdit jsonEdit;
private OptionButton _scenarioSelect;
private LightEditorCanvas _canvas;
private ItemList _lightList;
private Button _addBtn;
private Button _deleteBtn;
private VBoxContainer _propsBody;
private Label _propsEmpty;
private SpinBox _x;
private SpinBox _y;
private SpinBox _radius;
private SpinBox _influence;
private ColorPickerButton _color;
private FilteredList _bgList;
private CodeEdit _lineEdit;
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();
}
{
CthangoverTheme.Apply(this);
BuildUI();
LoadBackgrounds();
LoadScenarioList();
SetHint(ToolUi.Tr("tools/light_editor/hint"));
}
protected override void Cleanup()
{
foreach (var h in handles)
h.QueueFree();
handles.Clear();
}
// ── UI construction ────────────────────────────────────────
private Vector2 PreviewSize()
{
var sz = preview.Size;
if (sz.X <= 0 || sz.Y <= 0)
sz = new Vector2(1920, 1080);
return sz;
}
private void BuildUI()
{
var outer = CreateFillContainer();
AddChild(outer);
private void RefreshAll()
{
RepositionHandles();
controller.MarkDirty();
controller.UpdatePreview(previewMaterial, preview.GlobalPosition, PreviewSize());
UpdateLightList();
UpdateJsonEdit();
}
var toolbar = CreateToolbar();
outer.AddChild(toolbar);
_scenarioSelect = new OptionButton { CustomMinimumSize = new Vector2(420, 0), TooltipText = ToolUi.Tr("tools/light_editor/scenario_tip") };
_scenarioSelect.ItemSelected += OnScenarioPicked;
toolbar.AddChild(_scenarioSelect);
toolbar.AddChild(ToolUi.Button("tools/common/save", Save, ButtonKind.Primary, "tools/light_editor/save_tip"));
toolbar.AddChild(ToolUi.Button("tools/light_editor/copy_line", CopyLine, ButtonKind.Secondary, "tools/light_editor/copy_line_tip"));
toolbar.AddChild(UiKit.Spacer());
var lineToggle = ToolUi.Button("tools/light_editor/show_line", null, ButtonKind.Tab);
lineToggle.ToggleMode = true;
lineToggle.Toggled += on => _lineEdit.Visible = on;
toolbar.AddChild(lineToggle);
private void BuildUI()
{
var outerVBox = CreateFillContainer();
AddChild(outerVBox);
var main = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outer.AddChild(main);
var mainHBox = new HBoxContainer();
mainHBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
mainHBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
outerVBox.AddChild(mainHBox);
var side = new VBoxContainer();
main.AddChild(CreateSidebar(side, 300));
BuildSidebar(side);
var sidebarVBox = new VBoxContainer();
var sidebar = CreateSidebar(sidebarVBox);
mainHBox.AddChild(sidebar);
var canvasColumn = new VSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
main.AddChild(canvasColumn);
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/background") });
var canvasPanel = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
canvasPanel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line));
canvasColumn.AddChild(canvasPanel);
backgroundList = new ItemList();
backgroundList.CustomMinimumSize = new Vector2(0, 140);
backgroundList.ItemSelected += OnBackgroundSelected;
sidebarVBox.AddChild(backgroundList);
_canvas = new LightEditorCanvas
{
Controller = _controller,
PreviewMaterial = LightEditorController.CreatePreviewMaterial()
};
_canvas.Changed += OnCanvasChanged;
_canvas.SelectionChanged += RefreshAll;
canvasPanel.AddChild(_canvas);
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/static_lights") });
_lineEdit = ToolUi.Code(editable: false);
_lineEdit.SyntaxHighlighter = ScenarioSyntaxService.CreateHighlighter();
_lineEdit.CustomMinimumSize = new Vector2(0, 70);
_lineEdit.WrapMode = TextEdit.LineWrappingMode.Boundary;
_lineEdit.Visible = false;
canvasColumn.AddChild(_lineEdit);
}
lightListPanel = new VBoxContainer();
sidebarVBox.AddChild(lightListPanel);
private void BuildSidebar(VBoxContainer column)
{
column.AddChild(ToolUi.Section("tools/light_editor/static_lights"));
_lightList = new ItemList { CustomMinimumSize = new Vector2(0, 150) };
_lightList.ItemSelected += index => { _controller.Select((int)index); RefreshAll(); };
column.AddChild(_lightList);
addLightBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/add_light") };
addLightBtn.Pressed += OnAddLight;
sidebarVBox.AddChild(addLightBtn);
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space1);
column.AddChild(row);
_addBtn = ToolUi.Button("tools/light_editor/add_light", OnAdd);
_addBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(_addBtn);
_deleteBtn = ToolUi.Button("tools/light_editor/delete", OnDelete, ButtonKind.Danger);
_deleteBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(_deleteBtn);
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/selected_props") });
column.AddChild(ToolUi.Section("tools/light_editor/selected_props"));
_propsEmpty = ToolUi.Hint(ToolUi.Tr("tools/light_editor/none_selected"));
column.AddChild(_propsEmpty);
_propsBody = new VBoxContainer();
_propsBody.AddThemeConstantOverride("separation", UiTokens.Space2);
column.AddChild(_propsBody);
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);
_x = ToolUi.Field(_propsBody, "tools/light_editor/x", ToolUi.Number(0, 1, 0.001, _ => OnFieldsChanged()));
_y = ToolUi.Field(_propsBody, "tools/light_editor/y", ToolUi.Number(0, 1, 0.001, _ => OnFieldsChanged()));
_radius = ToolUi.Field(_propsBody, "tools/light_editor/radius", ToolUi.Number(10, 3000, 10, _ => OnFieldsChanged()), "tools/light_editor/radius_tip");
_influence = ToolUi.Field(_propsBody, "tools/light_editor/influence", ToolUi.Number(0, 1, 0.05, _ => OnFieldsChanged()), "tools/light_editor/influence_tip");
_color = ToolUi.Field(_propsBody, "tools/light_editor/color", new ColorPickerButton { CustomMinimumSize = new Vector2(0, 28), EditAlpha = false });
_color.ColorChanged += _ => OnFieldsChanged();
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);
column.AddChild(ToolUi.Section("tools/light_editor/background"));
_bgList = new FilteredList(180);
_bgList.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
_bgList.Chosen += ShowBackground;
column.AddChild(_bgList);
}
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);
// ── loading ────────────────────────────────────────────────
var importBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/import_json") };
importBtn.Pressed += OnImport;
sidebarVBox.AddChild(importBtn);
private void LoadBackgrounds()
{
_backgroundIds.AddRange(ModResourceService.GetBackgroundIds());
_bgList.SetItems(_backgroundIds);
}
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/norm_coords") });
/// <summary>Scenarios with a <c>light_set</c> line first (marked ●), then all others.</summary>
private void LoadScenarioList()
{
var withLights = new List<ScenarioFileInfo>();
var others = new List<ScenarioFileInfo>();
foreach (var file in ScenarioFileService.ScanScenarioFiles().SelectMany(m => m.Groups).SelectMany(g => g.Files))
{
var text = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath);
(text != null && LightEditorController.HasLights(text) ? withLights : others).Add(file);
}
var previewContainer = new PanelContainer();
previewContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
previewContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
mainHBox.AddChild(previewContainer);
_scenarios.Clear();
_scenarios.AddRange(withLights);
_scenarios.AddRange(others);
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);
_scenarioSelect.Clear();
_scenarioSelect.AddItem(ToolUi.Tr("tools/light_editor/pick_scenario"));
foreach (var file in _scenarios)
_scenarioSelect.AddItem((withLights.Contains(file) ? "● " : " ") + file.ModId + ": " + file.RelativePath);
_scenarioSelect.Select(0);
previewMaterial = controller.CreatePreviewMaterial();
preview.Material = previewMaterial;
if (withLights.Count > 0)
{
_scenarioSelect.Select(1);
OpenScenario(withLights[0]);
}
else
{
RefreshAll();
}
}
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 OnScenarioPicked(long index)
{
var file = index > 0 && index - 1 < _scenarios.Count ? _scenarios[(int)index - 1] : null;
RunDiscardingChanges(() => OpenScenario(file), () => _scenarioSelect.Select(_scenario == null ? 0 : _scenarios.IndexOf(_scenario) + 1));
}
private void OnJsonEditChanged()
{
}
private void OpenScenario(ScenarioFileInfo file)
{
_scenario = file;
_scenarioText = null;
MarkClean();
private void UpdateJsonEdit()
{
var json = controller.ExportLightsJson();
if (jsonEdit.Text != json)
jsonEdit.Text = json;
}
if (file != null)
{
_scenarioText = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath) ?? string.Empty;
try
{
var found = _controller.LoadFromScenario(_scenarioText);
SetStatus(string.Format(ToolUi.Tr(found ? "tools/light_editor/loaded" : "tools/light_editor/no_lights_yet"), file.RelativePath));
}
catch (Exception ex)
{
SetStatus($"{ToolUi.Tr("tools/common/load_error")}: {ex.Message}", ToolStatusKind.Error);
GameLogger.Log("LIGHT_EDITOR", $"light_set parse failed in '{file.Key}': {ex.Message}", LogLevel.Error);
}
private void LoadBackgroundList()
{
bgIds.Clear();
backgroundList.Clear();
var scene = ScenarioFileService.ParseMetadata(_scenarioText).Scene;
var background = SceneBackgroundService.ForScene(scene);
if (background != null && ModResourceService.LoadBackgroundTexture(background) == null)
SetStatus(string.Format(ToolUi.Tr("tools/common/background_missing"), background, scene), ToolStatusKind.Error);
else if (background != null)
ShowBackground(background);
}
else
{
_controller.LoadFromScenario(string.Empty);
}
var ids = ModResourceService.GetBackgroundIds();
bgIds.AddRange(ids);
foreach (var id in ids)
backgroundList.AddItem(id);
}
RefreshAll();
}
private void OnBackgroundSelected(long index)
{
var bgId = bgIds[(int)index];
var tex = BackgroundFactory.Instance.Get(bgId);
preview.Texture = tex;
controller.SetBackgroundForPreview(tex, previewMaterial);
/// <summary>Shows a background with its optional <c>_depth</c>/<c>_albedo</c> masks in the shader.</summary>
private void ShowBackground(string id)
{
if (string.IsNullOrEmpty(id))
return;
var depthTex = BackgroundFactory.Instance.Get(bgId + "_depth");
if (depthTex != null)
controller.SetDepthForPreview(depthTex, previewMaterial);
_bgList.Select(id);
_canvas.SetBackground(ModResourceService.LoadBackgroundTexture(id));
LightEditorController.SetPreviewTextures(_canvas.PreviewMaterial,
ModResourceService.LoadBackgroundTexture(id + "_depth"),
ModResourceService.LoadBackgroundTexture(id + "_albedo"));
_canvas.Refresh();
}
var albedoTex = BackgroundFactory.Instance.Get(bgId + "_albedo");
if (albedoTex != null)
controller.SetAlbedoForPreview(albedoTex, previewMaterial);
}
// ── saving ─────────────────────────────────────────────────
private void OnAddLight()
{
if (handles.Count >= 10)
return;
/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut() => Save();
var light = new LightDef
{
X = 0.4f,
Y = 0.3f,
Radius = 300f,
Influence = 1f,
ColorHex = "#ffff00"
};
private void Save()
{
if (_scenario == null)
{
SetStatus(ToolUi.Tr("tools/light_editor/pick_scenario"), ToolStatusKind.Error);
return;
}
controller.AddLight(light);
AddHandle(light, controller.Lights.Count - 1);
RefreshAll();
}
var updated = _controller.WriteToScenario(_scenarioText);
if (!ScenarioFileService.WriteAllText(_scenario.ModId, _scenario.RelativePath, updated))
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return;
}
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);
}
_scenarioText = updated;
MarkClean();
SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), _scenario.ModId + "/" + _scenario.RelativePath), ToolStatusKind.Ok);
}
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 CopyLine()
{
DisplayServer.ClipboardSet(_controller.ToScenarioLine());
SetStatus(ToolUi.Tr("tools/light_editor/copied"), ToolStatusKind.Ok);
}
private void RepositionHandles()
{
for (int i = 0; i < handles.Count && i < controller.Lights.Count; i++)
RepositionHandle(handles[i], controller.Lights[i]);
}
// ── editing ────────────────────────────────────────────────
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;
}
private void OnAdd()
{
if (_controller.Add(new Vector2(0.5f, 0.5f)) == null)
{
SetStatus(string.Format(ToolUi.Tr("tools/light_editor/limit"), LightEditorController.MaxLights), ToolStatusKind.Error);
return;
}
var light = controller.Lights[index];
radiusEdit.Text = light.Radius.ToString("F0");
influenceEdit.Text = light.Influence.ToString("F2");
colorPicker.Color = light.ToColor();
}
SetDirty();
RefreshAll();
}
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 OnDelete()
{
if (_controller.Selected == null)
return;
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();
}
}
_controller.RemoveSelected();
SetDirty();
RefreshAll();
}
private void OnColorChanged(Color color)
{
if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count)
return;
controller.Lights[selectedIndex].ColorHex = color.ToHtml();
RefreshAll();
}
/// <summary>Delete removes the selected light.</summary>
protected override bool HandleKey(InputEventKey key)
{
if (key.Keycode != Key.Delete || _controller.Selected == null)
return false;
OnDelete();
return true;
}
private void UpdateLightList()
{
foreach (var child in lightListPanel.GetChildren())
child.QueueFree();
private void OnFieldsChanged()
{
var light = _controller.Selected;
if (_suppress || light == null)
return;
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);
light.X = (float)_x.Value;
light.Y = (float)_y.Value;
light.Radius = (float)_radius.Value;
light.Influence = (float)_influence.Value;
light.ColorHex = "#" + _color.Color.ToHtml(false);
SetDirty();
RefreshList();
_lineEdit.Text = _controller.ToScenarioLine();
_canvas.RebuildHandles();
}
var selBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/btn_select") };
int idx = i;
selBtn.Pressed += () => SelectHandle(idx);
hBox.AddChild(selBtn);
private void OnCanvasChanged()
{
SetDirty();
_suppress = true;
WriteProperties();
_suppress = false;
RefreshList();
_lineEdit.Text = _controller.ToScenarioLine();
}
var delBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/btn_delete") };
delBtn.Pressed += () => RemoveLight(idx);
hBox.AddChild(delBtn);
// ── refresh ────────────────────────────────────────────────
lightListPanel.AddChild(hBox);
}
private void RefreshAll()
{
RefreshList();
_suppress = true;
WriteProperties();
_suppress = false;
_lineEdit.Text = _controller.ToScenarioLine();
_addBtn.Disabled = _controller.Lights.Count >= LightEditorController.MaxLights;
_deleteBtn.Disabled = _controller.Selected == null;
_canvas.RebuildHandles();
}
addLightBtn.Disabled = controller.Lights.Count >= 10;
}
private void RefreshList()
{
_lightList.Clear();
for (var i = 0; i < _controller.Lights.Count; i++)
{
var light = _controller.Lights[i];
var index = _lightList.AddItem($"● {i + 1}. {light.X:0.000}, {light.Y:0.000} r {light.Radius:0}");
_lightList.SetItemCustomFgColor(index, light.ToColor());
}
private void RemoveLight(int index)
{
if (index < 0 || index >= controller.Lights.Count)
return;
if (_controller.SelectedIndex >= 0)
_lightList.Select(_controller.SelectedIndex);
}
controller.RemoveLight(index);
private void WriteProperties()
{
var light = _controller.Selected;
_propsEmpty.Visible = light == null;
_propsBody.Visible = light != null;
if (light == null)
return;
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);
}
}
}
_x.Value = light.X;
_y.Value = light.Y;
_radius.Value = light.Radius;
_influence.Value = light.Influence;
_color.Color = light.ToColor();
}
}
}
+129 -160
View File
@@ -2,39 +2,35 @@ using System;
using System.Collections.Generic;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.UI.Tool;
using Cthangover.Mods.Map;
using Cthangover.Tools.Common;
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:
/// Interactive preview of the edited map: the background (laid out by
/// <see cref="BackgroundCanvas"/> exactly like <c>MapWidget</c>), every zone
/// polygon, marker and text label, and the draggable handles of the
/// selected zone. Two modes:
/// <list type="bullet">
/// <item><b>select</b> — clicking picks the topmost zone (polygon first,
/// <item><b>select</b> — a click picks the topmost zone (polygon first,
/// then marker/label dots); handles move vertices and positions;</item>
/// <item><b>draw</b> — clicking appends polygon vertices to the selected
/// region and double-click finishes the polygon and returns to select
/// mode.</item>
/// <item><b>draw</b> — clicks append polygon vertices to the selected
/// region while a dashed rubber band shows the next edge; a double-click
/// (or Enter/Esc via the window) finishes the polygon.</item>
/// </list>
/// Right-click deletes 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.
/// Right-click deletes the polygon vertex nearest to the pointer; while
/// drawing, a click that misses every vertex removes the last one ("undo
/// point"), in select mode only a targeted vertex is deleted.
/// <para/>
/// 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.
/// The canvas owns no data: it reads and mutates
/// <see cref="MapEditorController"/> and raises <see cref="Changed"/> so
/// the window can refresh the sidebar and JSON. Zone fills and outlines are
/// painted through <see cref="MapZoneStyle"/> — the definition the game's
/// <c>MapZonesLayer</c> uses — so the editor and the live map look the same.
/// </summary>
public partial class MapEditorCanvas : Control
public partial class MapEditorCanvas : BackgroundCanvas
{
/// <summary>Mode constant: clicking selects zones and drags handles.</summary>
public const string ModeSelect = "select";
@@ -42,129 +38,97 @@ namespace Cthangover.Tools.MapEditor
/// <summary>Mode constant: clicking appends polygon vertices.</summary>
public const string ModeDraw = "draw";
/// <summary>Handle index of the marker position.</summary>
public const int MarkerHandle = -2;
/// <summary>Handle index of the label position.</summary>
public const int LabelHandle = -3;
private const float PickRadius = 24f;
private static readonly Color VertexColor = MapZoneStyle.VertexHandle;
private static readonly Color MarkerColor = MapZoneStyle.MarkerHandle;
private static readonly Color LabelColor = MapZoneStyle.LabelHandle;
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>
/// <summary>Raised whenever the canvas mutates controller data (drag, draw, delete).</summary>
public event Action Changed;
/// <summary>Raised with the newly picked zone index (-1 for empty space).</summary>
public event Action<int> ZonePicked;
/// <summary>Raised when the canvas leaves draw mode on its own (double-click finishing a polygon).</summary>
public event Action ModeChanged;
private Vector2? _pointer;
public MapEditorCanvas()
{
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;
MouseDefaultCursorShape = Mode == ModeDraw ? CursorShape.Cross : CursorShape.Arrow;
RebuildHandles();
QueueRedraw();
}
/// <summary>Assigns the background texture and rebuilds the layout.</summary>
public void SetBackground(Texture2D texture)
/// <summary>Leaves draw mode (double-click, Enter, Esc) and tells the window.</summary>
public void FinishDrawing()
{
Background = texture;
RebuildHandles();
QueueRedraw();
if (Mode != ModeDraw)
return;
SetMode(ModeSelect);
_pointer = null;
ModeChanged?.Invoke();
}
/// <summary>Recreates the handles from the current selection. Call after structural changes.</summary>
public void RebuildHandles()
public override void RebuildHandles()
{
foreach (var handle in _handles)
handle.QueueFree();
_handles.Clear();
ClearHandles();
var zone = Controller?.Selected;
if (zone == null)
return;
var rect = GetBackgroundRect();
if (rect.Size.X <= 0f || rect.Size.Y <= 0f)
if (zone == null || GetBackgroundRect().Size.X <= 0f)
return;
if (zone.Points != null)
{
for (var i = 0; i < zone.Points.Count; i++)
AddHandle(rect, zone.Points[i], VertexColor, i);
{
var index = i;
var handle = AddHandle(zone.Points[i], VertexColor, i, p => OnHandleDrag(index, p));
handle.RightClicked = () => RemoveVertexAt(index);
handle.DragEnd = () => Changed?.Invoke();
}
}
if (HasMarker(zone))
AddHandle(rect, zone.ImagePos, MarkerColor, MapEditorHandle.MarkerHandle);
AddHandle(zone.ImagePos, MarkerColor, MarkerHandle, p => OnHandleDrag(MarkerHandle, p), 18f).DragEnd = () => Changed?.Invoke();
if (zone.LabelPos.HasValue)
AddHandle(rect, zone.LabelPos.Value, LabelColor, MapEditorHandle.LabelHandle);
AddHandle(zone.LabelPos.Value, LabelColor, LabelHandle, p => OnHandleDrag(LabelHandle, p), 18f).DragEnd = () => Changed?.Invoke();
}
/// <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)
private void OnHandleDrag(int index, Vector2 normalized)
{
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)
if (index == MarkerHandle)
Controller.SetImagePos(normalized);
else if (index == MapEditorHandle.LabelHandle)
else if (index == LabelHandle)
Controller.SetLabelPos(normalized);
else
Controller.SetVertex(index, normalized);
@@ -175,19 +139,39 @@ namespace Cthangover.Tools.MapEditor
private void OnGuiInput(InputEvent @event)
{
if (Controller == null || @event is not InputEventMouseButton mb || !mb.Pressed)
if (Controller == null)
return;
if (@event is InputEventMouseMotion motion)
{
if (Mode == ModeDraw)
{
_pointer = motion.Position;
QueueRedraw();
}
return;
}
if (@event is not InputEventMouseButton mb || !mb.Pressed)
return;
if (mb.ButtonIndex == MouseButton.Left)
{
if (Mode == ModeDraw)
{
Controller.AddVertex(Clamp01(PixelToNorm(mb.Position)));
// The first click of a double-click already placed the
// vertex; the double-click itself only finishes the polygon.
if (mb.DoubleClick)
Mode = ModeSelect;
RebuildHandles();
Changed?.Invoke();
QueueRedraw();
{
FinishDrawing();
}
else
{
Controller.AddVertex(Clamp01(LocalToNorm(mb.Position)));
RebuildHandles();
Changed?.Invoke();
QueueRedraw();
}
}
else
{
@@ -197,7 +181,6 @@ namespace Cthangover.Tools.MapEditor
Controller.Select(hit);
RebuildHandles();
ZonePicked?.Invoke(hit);
Changed?.Invoke();
QueueRedraw();
}
}
@@ -213,12 +196,7 @@ namespace Cthangover.Tools.MapEditor
private void OnMouseExited()
{
QueueRedraw();
}
private void OnResized()
{
RebuildHandles();
_pointer = null;
QueueRedraw();
}
@@ -238,41 +216,51 @@ namespace Cthangover.Tools.MapEditor
for (var i = zones.Count - 1; i >= 0; i--)
{
var zone = zones[i];
if (HasMarker(zone) && (local - ToPixel(zone.ImagePos, rect)).Length() <= 24f)
if (HasMarker(zone) && (local - ToPixel(zone.ImagePos, rect)).Length() <= PickRadius)
return i;
if (zone.LabelPos.HasValue && (local - ToPixel(zone.LabelPos.Value, rect)).Length() <= 24f)
if (zone.LabelPos.HasValue && (local - ToPixel(zone.LabelPos.Value, rect)).Length() <= PickRadius)
return i;
}
return -1;
}
private void DrawAll()
protected override void DrawContent(Rect2 rect)
{
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);
}
DrawZone(zones[i], rect, i == Controller.SelectedIndex);
DrawRubberBand(rect);
}
/// <summary>
/// In draw mode shows where the next vertex goes: a dashed segment from
/// the last vertex to the pointer and back to the first one, so the
/// author sees the polygon closing before clicking.
/// </summary>
private void DrawRubberBand(Rect2 rect)
{
var points = Controller.Selected?.Points;
if (Mode != ModeDraw || !_pointer.HasValue || points == null || points.Count == 0)
return;
var pointer = _pointer.Value;
DrawDashedLine(ToPixel(points[points.Count - 1], rect), pointer, UiTokens.AccentBright, 1.5f, 6f);
if (points.Count >= 2)
DrawDashedLine(pointer, ToPixel(points[0], rect), UiTokens.AccentLine, 1f, 6f);
}
private void DrawZone(MapZone zone, Rect2 rect, bool selected)
{
if (zone.Points != null && zone.Points.Count >= 2)
MapZoneStyle.Draw(this, ToPixels(zone.Points, rect), selected, false, zone.Locked);
{
var locked = MapZoneRules.IsLocked(zone, !zone.Locked);
MapZoneStyle.Draw(this, ToPixels(zone.Points, rect), selected, false, locked, zone.Hidden);
}
if (HasMarker(zone))
DrawMarker(zone, ToPixel(zone.ImagePos, rect), selected);
@@ -337,41 +325,31 @@ namespace Cthangover.Tools.MapEditor
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.
/// pure inscription, the marker is only shown when an icon exists. The
/// region currently being drawn is excluded while it has no vertices.
/// </summary>
private static bool HasMarker(MapZone zone)
private bool HasMarker(MapZone zone)
{
if (!string.IsNullOrEmpty(zone.Image))
return true;
if (Mode == ModeDraw && zone == Controller?.Selected)
return false;
return (zone.Points == null || zone.Points.Count == 0) && !zone.LabelPos.HasValue;
}
/// <summary>
/// Right-click 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.
/// Right-click on empty canvas: removes the polygon vertex nearest to
/// the pointer. While drawing, a click that misses every vertex removes
/// the last one instead, which makes right-click a natural "undo
/// point"; in select mode only a targeted vertex is deleted, so a stray
/// click cannot destroy geometry.
/// </summary>
private void RemoveVertexAtPointer(Vector2 local, bool fallbackToLast)
{
@@ -383,7 +361,13 @@ namespace Cthangover.Tools.MapEditor
var index = FindNearestVertex(zone, local, rect);
if (index < 0 && fallbackToLast)
index = zone.Points.Count - 1;
if (index < 0)
if (index >= 0)
RemoveVertexAt(index);
}
private void RemoveVertexAt(int index)
{
if (Controller == null)
return;
Controller.RemoveVertex(index);
@@ -395,7 +379,7 @@ namespace Cthangover.Tools.MapEditor
private static int FindNearestVertex(MapZone zone, Vector2 local, Rect2 rect)
{
var best = -1;
var bestDistance = 24f;
var bestDistance = PickRadius;
for (var i = 0; i < zone.Points.Count; i++)
{
@@ -409,20 +393,5 @@ namespace Cthangover.Tools.MapEditor
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();
}
}
}
+17 -1
View File
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Tool;
using Cthangover.Mods.Map;
using Cthangover.Tools.Services;
using Godot;
@@ -70,6 +70,22 @@ namespace Cthangover.Tools.MapEditor
return Append(zone);
}
/// <summary>
/// Creates a region without vertices and selects it — the start of
/// drawing a polygon point by point on the canvas.
/// </summary>
public MapZone StartRegion()
{
var zone = new MapZone
{
ID = MakeUniqueId("region"),
Title = string.Empty,
Points = new List<Vector2>(),
};
return Append(zone);
}
/// <summary>
/// Creates an icon marker at the map centre with no icon chosen yet and
/// selects it. The author assigns <c>image</c> and <c>scene</c> in the
-85
View File
@@ -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
View File
@@ -1,6 +1,7 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Tools.MapEditor
{
/// <summary>
File diff suppressed because it is too large. Load diff
+31 -30
View File
@@ -2,36 +2,34 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
public sealed class TabData
{
public string Key;
public string ModId;
public string RelativePath;
public string FileName;
public string SavedText;
public bool IsDirty;
}
/// <summary>
/// Tabbed editor area of the scenario editor: a permanent graph tab plus
/// one text tab per opened .scenario file, with dirty tracking and a
/// monospace syntax-highlighted code editor with line numbers. Only one
/// <see cref="CodeEdit"/> exists; switching tabs swaps its text with the
/// tab's <see cref="TabData.Buffer"/>, which therefore holds unsaved edits
/// of background tabs.
/// </summary>
public partial class ScenarioEditTabs : VBoxContainer
{
private const int GraphTabIndex = 0;
private TabBar _tabBar;
private TextEdit _textEdit;
private CodeEdit _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 CodeEdit CodeEditor => _textEdit;
public string CurrentText => _textEdit.Text;
public string CurrentKey =>
_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count ? _tabs[_activeTabIndex].Key : null;
@@ -48,10 +46,12 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
public event Action<bool> DirtyChanged;
public event Action<string> GraphFileRequested;
public ScenarioEditTabs(ScenarioGraphView graphView, Font monospaceFont)
/// <summary>Raised on every user edit of the active text tab.</summary>
public event Action BufferChanged;
public ScenarioEditTabs(ScenarioGraphView graphView)
{
_graphView = graphView;
_monospaceFont = monospaceFont;
SizeFlagsHorizontal = SizeFlags.ExpandFill;
SizeFlagsVertical = SizeFlags.ExpandFill;
@@ -63,15 +63,8 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_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 = ToolUi.Code();
_textEdit.SyntaxHighlighter = ScenarioSyntaxService.CreateHighlighter();
_textEdit.TextChanged += OnTextChanged;
AddChild(_textEdit);
@@ -79,6 +72,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
AddChild(_graphView);
}
/// <summary>Opens the scenario in a new tab, or just activates its existing tab.</summary>
public void OpenFile(string key)
{
for (int i = 0; i < _tabs.Count; i++)
@@ -108,12 +102,13 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
ModId = modId,
RelativePath = relativePath,
FileName = Path.GetFileNameWithoutExtension(relativePath),
SavedText = text,
Buffer = text,
IsDirty = false
};
_tabs.Add(tab);
_tabBar.AddTab(tab.FileName);
_tabBar.SetTabTooltip(_tabBar.TabCount - 1, ScenarioFileService.GetDisplayPath(modId, relativePath));
SwitchToTextTab(_tabs.Count - 1);
}
@@ -123,13 +118,14 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
}
/// <summary>Marks the active tab clean, remembering the editor text as the saved baseline.</summary>
public void SaveCurrentFile()
{
if (_activeTabIndex < 0 || _activeTabIndex >= _tabs.Count)
return;
var tab = _tabs[_activeTabIndex];
tab.SavedText = _textEdit.Text;
tab.Buffer = _textEdit.Text;
if (tab.IsDirty)
{
tab.IsDirty = false;
@@ -138,6 +134,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
}
/// <summary>Replaces the editor text without firing the text-changed handler.</summary>
public void SetTextSilent(string text)
{
_suppressTextChanged = true;
@@ -145,6 +142,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_suppressTextChanged = false;
}
/// <summary>Switches to the graph tab when no file tabs are open.</summary>
public void SetViewToGraph()
{
if (_tabs.Count > 0)
@@ -156,6 +154,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
GraphTabSelected?.Invoke();
}
/// <summary>Rebuilds the graph from the current scenario files, if the graph tab is visible.</summary>
public void RefreshGraph()
{
if (_graphView.Visible)
@@ -165,6 +164,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
}
/// <summary>Returns false when any tab still has unsaved changes.</summary>
public bool ConfirmCloseAll()
{
for (int i = _tabs.Count - 1; i >= 0; i--)
@@ -206,16 +206,16 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
return;
if (_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count)
_tabs[_activeTabIndex].SavedText = _textEdit.Text;
_tabs[_activeTabIndex].Buffer = _textEdit.Text;
_activeTabIndex = index;
_tabBar.CurrentTab = index + 1;
var tab = _tabs[index];
SetTextSilent(tab.SavedText);
SetTextSilent(tab.Buffer);
TextTabSelected?.Invoke(tab.SavedText);
TextTabSelected?.Invoke(tab.Buffer);
}
private void CloseTextTab(int index)
@@ -284,6 +284,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
UpdateTabTitle(_activeTabIndex);
UpdateGlobalDirty();
}
BufferChanged?.Invoke();
}
private void UpdateTabTitle(int index)
@@ -303,7 +304,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private void SaveActiveTextEditContent()
{
if (_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count)
_tabs[_activeTabIndex].SavedText = _textEdit.Text;
_tabs[_activeTabIndex].Buffer = _textEdit.Text;
}
private void OnGraphScenarioFileRequested(string filePath)
@@ -1,6 +1,7 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Registers the scenario editor as a tool window. Creates <see cref="ScenarioEditorWindow"/> instances.</summary>
+161 -127
View File
@@ -1,46 +1,81 @@
using System;
using System;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// Tool window hosting the scenario editor: a searchable tree of every
/// mod's .scenario files, tabbed code/graph editors, a metadata panel that
/// follows the text as it is typed, background thumbnails, plus a
/// play-test mode that runs the current scenario in-game.
/// <para/>
/// Shortcuts: Ctrl+S saves the active file, F5 play-tests it.
/// </summary>
public partial class ScenarioEditorWindow : ToolWindow
{
private const double InfoRefreshDelay = 0.4;
private Control _editLayout;
private Control _playLayout;
private ScenarioFileTreePanel _fileTreePanel;
private ScenarioEditTabs _editTabs;
private ScenarioInfoPanel _infoPanel;
private ScenarioThumbnailPanel _thumbPanel;
private Timer _infoTimer;
private Button _saveBtn;
private Button _openFileBtn;
private Button _playBtn;
private Label _filePathLabel;
private Label _statusLabel;
private bool _playActive;
private string _pendingKey;
private string _returnRoute;
private string _returnScene;
private CanvasLayer _stopOverlay;
public ScenarioEditorWindow() : base("tools/scenario_editor/title")
{
CthangoverTheme.Apply(this);
BuildUI();
SetHint(ToolUi.Tr("tools/scenario_editor/hint"));
}
/// <summary>Opens with the graph view so the editor starts on the cross-scene map, or on the file asked for by <see cref="OpenScenario"/>.</summary>
public override void _Ready()
{
_editTabs.SetViewToGraph();
if (_pendingKey != null)
OpenScenario(_pendingKey);
}
protected override void Cleanup()
/// <summary>
/// Opens a scenario file (key <c>mod:path</c>) in a text tab and selects it in the
/// tree — for other tools, e.g. the scene editor's "open" button. Before the window
/// is in the tree the key waits for <see cref="_Ready"/>, which would otherwise
/// switch the view back to the graph.
/// </summary>
public void OpenScenario(string key)
{
if (!IsInsideTree())
{
_pendingKey = key;
return;
}
_pendingKey = null;
_fileTreePanel.SelectFile(key);
_editTabs.OpenFile(key);
}
/// <summary>Returns from play-test mode as soon as the in-game test play ends.</summary>
public override void _Process(double delta)
{
if (_playActive && !SceneManager.IsTestPlayActive)
@@ -52,8 +87,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_editLayout = new Control
{
AnchorRight = 1f,
AnchorBottom = 1f,
Visible = true
AnchorBottom = 1f
};
AddChild(_editLayout);
@@ -63,111 +97,70 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
var toolbar = CreateToolbar();
outerVBox.AddChild(toolbar);
_saveBtn = new Button
{
Text = TranslationServer.Translate("tools/scenario_editor/save"),
Disabled = true
};
_saveBtn.Pressed += OnSavePressed;
_saveBtn = ToolUi.Button("tools/common/save", OnSavePressed, ButtonKind.Primary, "tools/common/save_tip");
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;
_playBtn = ToolUi.Button("tools/scenario_editor/play", OnPlayPressed, ButtonKind.Secondary, "tools/scenario_editor/play_tooltip");
toolbar.AddChild(_playBtn);
_openFileBtn = ToolUi.Button("tools/scenario_editor/open_file", OnOpenFilePressed, ButtonKind.Secondary, "tools/scenario_editor/open_file_tip");
toolbar.AddChild(_openFileBtn);
toolbar.AddChild(ToolUi.Divider());
toolbar.AddChild(ToolUi.Button("tools/scenario_editor/rescan", OnRescanPressed, ButtonKind.Secondary, "tools/scenario_editor/rescan_tip"));
_filePathLabel = new Label { Text = "" };
_filePathLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_filePathLabel = new Label
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis
};
_filePathLabel.AddThemeFontOverride("font", ToolUi.Mono());
_filePathLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
_filePathLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
toolbar.AddChild(_filePathLabel);
var mainSplit = new HSplitContainer();
mainSplit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
mainSplit.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
outerVBox.AddChild(mainSplit);
var leftPanel = new PanelContainer
var mainSplit = new HSplitContainer
{
CustomMinimumSize = new Vector2(340, 0),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outerVBox.AddChild(mainSplit);
var leftVBox = new VBoxContainer { SizeFlagsVertical = Control.SizeFlags.ExpandFill };
var leftPanel = CreateSidebar(leftVBox, 320);
mainSplit.AddChild(leftPanel);
var leftVBox = new VBoxContainer();
leftPanel.AddChild(leftVBox);
var tabsContainer = new TabContainer();
tabsContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
tabsContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
var tabsContainer = new TabContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(0, 420)
};
leftVBox.AddChild(tabsContainer);
_fileTreePanel = (ScenarioFileTreePanel)TscnScenes.LoadAndBuild("scenes/scenario_file_tree_panel.tscn");
_fileTreePanel.FileSelected += OnFileSelected;
tabsContainer.AddChild(_fileTreePanel);
tabsContainer.SetTabTitle(0, TranslationServer.Translate("tools/scenario_editor/tab_files"));
tabsContainer.SetTabTitle(0, ToolUi.Tr("tools/scenario_editor/tab_files"));
_infoPanel = (ScenarioInfoPanel)TscnScenes.LoadAndBuild("scenes/scenario_info_panel.tscn");
tabsContainer.AddChild(_infoPanel);
tabsContainer.SetTabTitle(1, TranslationServer.Translate("tools/scenario_editor/tab_info"));
leftVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/scenario_editor/bg_previews") + ":" });
tabsContainer.SetTabTitle(1, ToolUi.Tr("tools/scenario_editor/tab_info"));
leftVBox.AddChild(ToolUi.Section("tools/scenario_editor/bg_previews"));
_thumbPanel = (ScenarioThumbnailPanel)TscnScenes.LoadAndBuild("scenes/scenario_thumbnail_panel.tscn");
leftVBox.AddChild(_thumbPanel);
var rightPanel = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
mainSplit.AddChild(rightPanel);
_editTabs = new ScenarioEditTabs(new ScenarioGraphView(), GetMonospaceFont());
_editTabs = new ScenarioEditTabs(new ScenarioGraphView());
_editTabs.TextTabSelected += OnTextTabSelected;
_editTabs.GraphTabSelected += OnGraphTabSelected;
_editTabs.DirtyChanged += OnDirtyChanged;
_editTabs.GraphFileRequested += OnGraphFileRequested;
rightPanel.AddChild(_editTabs);
_editTabs.BufferChanged += () => _infoTimer.Start();
mainSplit.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);
_infoTimer = new Timer { OneShot = true, WaitTime = InfoRefreshDelay };
_infoTimer.Timeout += () => _infoPanel.Update(_editTabs.CurrentText);
AddChild(_infoTimer);
}
private void OnFileSelected(string filePath)
@@ -180,15 +173,19 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_fileTreePanel.SelectFile(filePath);
}
private void OnRescanPressed()
{
_fileTreePanel.Reload();
_editTabs.RefreshGraph();
SetStatus(ToolUi.Tr("tools/scenario_editor/rescanned"));
}
private void OnTextTabSelected(string text)
{
_filePathLabel.Text = _editTabs.CurrentKey != null
var hasFile = _editTabs.CurrentKey != null;
_filePathLabel.Text = hasFile
? 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") : "";
SetFileButtonsEnabled(hasFile);
_infoPanel.Update(text);
_thumbPanel.Load(text);
}
@@ -196,26 +193,38 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private void OnGraphTabSelected()
{
_filePathLabel.Text = "";
_saveBtn.Disabled = true;
_openFileBtn.Disabled = true;
_playBtn.Disabled = true;
_statusLabel.Text = "";
SetFileButtonsEnabled(false);
_infoPanel.Clear();
_thumbPanel.Clear();
_editTabs.RefreshGraph();
}
private void SetFileButtonsEnabled(bool enabled)
{
_saveBtn.Disabled = !enabled;
_openFileBtn.Disabled = !enabled;
_playBtn.Disabled = !enabled;
}
private void OnDirtyChanged(bool anyDirty)
{
if (anyDirty)
{
SetDirty();
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/status_modified");
}
else
{
MarkClean();
}
}
/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut() => OnSavePressed();
/// <summary>F5 play-tests the active scenario.</summary>
protected override bool HandleKey(InputEventKey key)
{
if (key.Keycode != Key.F5 || !_editTabs.HasActiveFile)
return false;
OnPlayPressed();
return true;
}
private void OnSavePressed()
@@ -230,22 +239,23 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
if (!ScenarioFileService.WriteAllText(modId, relativePath, text))
{
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip");
SetStatus(ToolUi.Tr("tools/scenario_editor/readonly_zip"), ToolStatusKind.Error);
return;
}
_editTabs.SaveCurrentFile();
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/status_saved");
// The running game caches scenario headers and texts: drop them, so a new or
// edited scenario plays on the next entry into its scene without a mod reload.
SceneManager.ReloadContent();
SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), ScenarioFileService.GetDisplayPath(modId, relativePath)), ToolStatusKind.Ok);
_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);
SetStatus(string.Format(ToolUi.Tr("tools/scenario_editor/save_error"), ex.Message), ToolStatusKind.Error);
}
}
@@ -257,14 +267,16 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
var path = ScenarioFileService.GetAbsolutePath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath);
if (string.IsNullOrEmpty(path))
{
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip");
SetStatus(ToolUi.Tr("tools/scenario_editor/readonly_zip"), ToolStatusKind.Error);
return;
}
GD.Print($"[ScenarioEditor] Opening: {path}");
var err = OS.ShellOpen(path);
if (err != Error.Ok)
GameLogger.Log("SCENARIO_EDITOR", $"ShellOpen error: {err}", LogLevel.Error);
{
GameLogger.Log("SCENARIO_EDITOR", $"ShellOpen '{path}' failed: {err}", LogLevel.Error);
SetStatus($"{ToolUi.Tr("tools/scenario_editor/open_file_error")}: {err}", ToolStatusKind.Error);
}
}
private void OnPlayPressed()
@@ -277,7 +289,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
if (metadata.Scene == "-")
{
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/no_scene_error");
SetStatus(ToolUi.Tr("tools/scenario_editor/no_scene_error"), ToolStatusKind.Error);
return;
}
@@ -291,7 +303,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
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);
@@ -299,14 +310,10 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
catch (Exception ex)
{
_statusLabel.Text = string.Format(
TranslationServer.Translate("tools/scenario_editor/save_error"), ex.Message);
SetStatus(string.Format(ToolUi.Tr("tools/scenario_editor/save_error"), ex.Message), ToolStatusKind.Error);
return;
}
_editLayout.Visible = false;
_playLayout.Visible = true;
SceneManager.IsTestPlayActive = true;
SceneManager.TestScenarioText = text;
@@ -314,18 +321,32 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
Visible = false;
// The screen the editor was opened from (a mod menu, a mod
// route) is freed by the shell load below; remember its route so
// the play-test returns there instead of to the engine's main menu.
var sceneService = GetNodeOrNull<GodotSceneService>("/root/GodotSceneService");
var current = sceneService?.CurrentGodotSceneName;
_returnRoute = !string.IsNullOrEmpty(current) && SceneRouteRegistry.TryGet(current, out _) ? current : null;
// Opened inside a running game: come back into the scene the
// player was in, not to the main menu (there is no way back to the
// editor from there).
var sceneManager = GetNodeOrNull<SceneManager>("/root/SceneManager");
var gameScene = sceneManager?.CurrentSceneName;
_returnScene = _returnRoute == null && current != "mainmenu" && !string.IsNullOrEmpty(gameScene) && sceneManager.HasScene(gameScene)
? gameScene
: null;
_stopOverlay = CreateStopOverlay();
var tree = Godot.Engine.GetMainLoop() as SceneTree;
tree?.Root.AddChild(_stopOverlay);
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;
@@ -340,8 +361,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private void ReturnFromPlay()
{
_playActive = false;
_playLayout.Visible = false;
_editLayout.Visible = true;
Visible = true;
if (_stopOverlay != null)
@@ -351,23 +370,38 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
var sceneService = GetNodeOrNull<GodotSceneService>("/root/GodotSceneService");
sceneService?.SwitchToMenu();
var sceneManager = GetNodeOrNull<SceneManager>("/root/SceneManager");
if (_returnRoute != null && SceneRouteRegistry.TryGet(_returnRoute, out var route))
{
sceneService?.SwitchRoute(route);
}
else if (_returnScene != null && sceneManager != null)
{
sceneManager.PendingSceneName = _returnScene;
sceneService?.LoadShell();
}
else
{
sceneService?.SwitchToMenu();
}
_returnRoute = null;
_returnScene = null;
SetStatus(ToolUi.Tr("tools/scenario_editor/play_stopped"));
}
/// <summary>Stop button shown over the running game while the scenario is play-tested.</summary>
private CanvasLayer CreateStopOverlay()
{
var layer = new CanvasLayer { Layer = 128 };
var btn = new Button
{
Text = TranslationServer.Translate("tools/scenario_editor/stop"),
CustomMinimumSize = new Vector2(120, 40)
};
btn.Pressed += OnStopPressed;
var btn = ToolUi.Button("tools/scenario_editor/stop", OnStopPressed, ButtonKind.Danger);
btn.Theme = ToolTheme.Get();
btn.CustomMinimumSize = new Vector2(120, 36);
var margin = new MarginContainer();
margin.AddThemeConstantOverride("margin_top", 8);
margin.AddThemeConstantOverride("margin_left", 8);
margin.AddThemeConstantOverride("margin_top", UiTokens.Space3);
margin.AddThemeConstantOverride("margin_left", UiTokens.Space3);
margin.AddChild(btn);
layer.AddChild(margin);
+100 -49
View File
@@ -6,80 +6,138 @@ using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// Left-hand tree of every mod's scenario files, grouped by mod and
/// subdirectory, with a search field that keeps only matching files (and
/// expands their groups). Raises <see cref="FileSelected"/> with the
/// scenario key on picks.
/// <para/>
/// Built from <c>scenario_file_tree_panel.tscn</c>: the tscn builder creates
/// the script node through its constructor before the child nodes exist,
/// so children are looked up lazily (<see cref="Bind"/>) — not in the
/// constructor, which is what used to crash the whole editor on open.
/// </summary>
public partial class ScenarioFileTreePanel : VBoxContainer
{
private Tree _fileTree;
private readonly Dictionary<string, string> _fileItemToKey = new();
private readonly Dictionary<string, TreeItem> _keyToTreeItem = new();
private LineEdit _search;
private List<ModScenarioTree> _mods = new();
private readonly Dictionary<TreeItem, string> _itemToKey = new();
private readonly Dictionary<string, TreeItem> _keyToItem = new();
private bool _isProgrammaticSelection;
private string _selectedKey;
public event Action<string> FileSelected;
public ScenarioFileTreePanel()
/// <summary>Binds child nodes and fills the tree once the panel enters the tree.</summary>
public override void _Ready()
{
Reload();
}
/// <summary>Re-scans the mods' scenario files and rebuilds the tree.</summary>
public void Reload()
{
Bind();
_mods = ScenarioFileService.ScanScenarioFiles();
Populate();
}
private void Bind()
{
if (_fileTree != null)
return;
_fileTree = GetNode<Tree>("FileTree");
_fileTree.CellSelected += OnCellSelected;
Populate();
_search = GetNode<LineEdit>("Search");
_search.PlaceholderText = TranslationServer.Translate("tools/scenario_editor/search_files");
_search.TextChanged += _ => Populate();
}
private void Populate()
{
_fileTree.Clear();
_fileItemToKey.Clear();
_keyToTreeItem.Clear();
_itemToKey.Clear();
_keyToItem.Clear();
var filter = _search.Text.Trim();
var root = _fileTree.CreateItem();
var mods = ScenarioFileService.ScanScenarioFiles();
foreach (var mod in mods)
foreach (var mod in _mods)
{
var modItem = _fileTree.CreateItem(root);
modItem.SetText(0, $"[{mod.ModId}]");
modItem.SetSelectable(0, false);
modItem.SetCustomColor(0, UiTokens.Info);
TreeItem modItem = null;
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);
}
TreeItem groupItem = null;
foreach (var file in group.Files)
{
var fileItem = _fileTree.CreateItem(parentItem);
if (filter.Length > 0 && file.RelativePath.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
continue;
if (modItem == null)
{
modItem = _fileTree.CreateItem(root);
modItem.SetText(0, mod.ModId);
modItem.SetSelectable(0, false);
modItem.SetCustomColor(0, UiTokens.AccentBright);
}
if (groupItem == null)
{
if (string.IsNullOrEmpty(group.DirectoryPath))
{
groupItem = modItem;
}
else
{
groupItem = _fileTree.CreateItem(modItem);
groupItem.SetText(0, group.DirectoryPath + "/");
groupItem.SetSelectable(0, false);
groupItem.SetCustomColor(0, UiTokens.Muted);
groupItem.Collapsed = filter.Length == 0;
}
}
var fileItem = _fileTree.CreateItem(groupItem);
fileItem.SetText(0, file.Name);
fileItem.SetMetadata(0, Variant.From("scenario"));
_fileItemToKey[GetItemKey(fileItem)] = file.Key;
_keyToTreeItem[file.Key] = fileItem;
fileItem.SetTooltipText(0, ScenarioFileService.GetDisplayPath(file.ModId, file.RelativePath));
_itemToKey[fileItem] = file.Key;
_keyToItem[file.Key] = fileItem;
}
}
}
if (_selectedKey != null && _keyToItem.TryGetValue(_selectedKey, out var selected))
SelectSilently(selected);
}
private static string GetItemKey(TreeItem item)
{
return item.GetInstanceId().ToString();
}
/// <summary>Selects the tree row of a scenario key and raises <see cref="FileSelected"/>.</summary>
public void SelectFile(string key)
{
if (!_keyToTreeItem.TryGetValue(key, out var item))
return;
Bind();
_selectedKey = key;
if (!_keyToItem.TryGetValue(key, out var item))
{
_search.Text = string.Empty;
Populate();
if (!_keyToItem.TryGetValue(key, out item))
return;
}
SelectSilently(item);
FileSelected?.Invoke(key);
}
private void SelectSilently(TreeItem item)
{
for (var parent = item.GetParent(); parent != null; parent = parent.GetParent())
parent.Collapsed = false;
_isProgrammaticSelection = true;
item.Select(0);
_fileTree.ScrollToItem(item);
_isProgrammaticSelection = false;
FileSelected?.Invoke(key);
}
private void OnCellSelected()
@@ -88,18 +146,11 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
return;
var item = _fileTree.GetSelected();
if (item == null)
if (item == null || !_itemToKey.TryGetValue(item, out var key))
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);
_selectedKey = key;
FileSelected?.Invoke(key);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// Layered left-to-right layout of the scene graph: a scene's column is
/// its distance (in scene switches) from the start scenes — the
/// <c>start_scene</c> node, or every scene nothing switches to when there
/// is none — so the story reads from left to right. Scenes no start
/// reaches are stacked in one extra column on the right. Inside a column
/// scenes are sorted by name, which keeps the picture stable between
/// rebuilds.
/// </summary>
public static class ScenarioGraphLayout
{
private const float ColumnWidth = 300f;
private const float RowHeight = 130f;
private const float Margin = 40f;
/// <summary>Canvas position per lower-cased scene name.</summary>
public static Dictionary<string, Vector2> Compute(SceneGraphData data)
{
var names = data.Nodes.Select(n => n.SceneName.ToLowerInvariant()).Distinct().ToList();
var outgoing = names.ToDictionary(n => n, _ => new List<string>());
var incoming = new HashSet<string>();
foreach (var edge in data.Edges)
{
var from = edge.FromScene.ToLowerInvariant();
var to = edge.ToScene.ToLowerInvariant();
if (!outgoing.ContainsKey(from) || !outgoing.ContainsKey(to) || from == to)
continue;
outgoing[from].Add(to);
incoming.Add(to);
}
var starts = data.Nodes.Where(n => n.IsStartScene).Select(n => n.SceneName.ToLowerInvariant()).ToList();
if (starts.Count == 0)
starts = names.Where(n => !incoming.Contains(n)).ToList();
var depth = new Dictionary<string, int>();
var queue = new Queue<string>();
foreach (var start in starts)
{
depth[start] = 0;
queue.Enqueue(start);
}
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var next in outgoing[current])
{
if (depth.ContainsKey(next))
continue;
depth[next] = depth[current] + 1;
queue.Enqueue(next);
}
}
var unreachableColumn = depth.Count > 0 ? depth.Values.Max() + 1 : 0;
var result = new Dictionary<string, Vector2>();
foreach (var column in names.GroupBy(n => depth.TryGetValue(n, out var d) ? d : unreachableColumn).OrderBy(g => g.Key))
{
var row = 0;
foreach (var name in column.OrderBy(n => n, StringComparer.Ordinal))
result[name] = new Vector2(Margin + column.Key * ColumnWidth, Margin + row++ * RowHeight);
}
return result;
}
}
}
+26 -29
View File
@@ -5,8 +5,10 @@ using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Services;
using Godot;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Cross-scene graph of all scenario files: a GraphEdit of scene nodes linked by switch targets, with a detail panel for the selected scenario.</summary>
public partial class ScenarioGraphView : HSplitContainer
{
private GraphEdit _graph;
@@ -39,27 +41,27 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_graph.NodeDeselected += OnNodeDeselected;
AddChild(_graph);
var panel = new VBoxContainer { CustomMinimumSize = new Vector2(250, 0) };
var panel = new VBoxContainer { CustomMinimumSize = new Vector2(260, 0) };
panel.SizeFlagsVertical = SizeFlags.ExpandFill;
AddChild(panel);
panel.AddChild(new Label
{
Text = TranslationServer.Translate("tools/scenario_editor/graph_select_node"),
AutowrapMode = TextServer.AutowrapMode.WordSmart
});
var hint = UiKit.Hint(TranslationServer.Translate("tools/scenario_editor/graph_select_node"));
hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
panel.AddChild(hint);
_infoLabel = new RichTextLabel
{
BbcodeEnabled = true,
FitContent = true,
ScrollFollowing = true,
SizeFlagsVertical = SizeFlags.ExpandFill
};
_infoLabel.AddThemeFontSizeOverride("normal_font_size", UiTokens.FontBody);
_infoLabel.AddThemeFontSizeOverride("bold_font_size", UiTokens.FontBodyLarge);
_infoLabel.AddThemeColorOverride("default_color", UiTokens.Body);
_infoLabel.MetaClicked += OnLinkClicked;
panel.AddChild(_infoLabel);
}
/// <summary>Rebuilds the graph nodes and edges, preserving the nodes' saved canvas positions.</summary>
public void Populate(SceneGraphData data)
{
var savedPositions = SaveNodePositions();
@@ -68,13 +70,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_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;
}
var layout = ScenarioGraphLayout.Compute(data);
foreach (var nodeData in data.Nodes)
{
@@ -87,7 +83,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
var position = savedPositions.TryGetValue(key, out var saved)
? saved
: new Vector2(x, y);
: layout[key];
var gn = new GraphNode
{
@@ -96,12 +92,15 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
PositionOffset = position
};
gn.AddChild(new Label
var nodeLabel = new Label
{
Text = $"Mod: {nodeData.ModId}\nScenarios: {nodeData.ScenarioCount}" +
(bgCount > 0 ? $"\nBg: {firstBg}" +
Text = string.Format(Tr("tools/scenario_editor/graph_node"), nodeData.ModId, nodeData.ScenarioCount) +
(bgCount > 0 ? "\n" + string.Format(Tr("tools/scenario_editor/graph_node_bg"), firstBg) +
(bgCount > 1 ? $" +{bgCount - 1}" : "") : "")
});
};
nodeLabel.AddThemeColorOverride("font_color", UiTokens.Body);
nodeLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
gn.AddChild(nodeLabel);
var color = UiTokens.White;
if (nodeData.IsStartScene)
@@ -120,11 +119,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_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)
@@ -135,6 +129,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
continue;
_graph.ConnectNode(_nodes[srcK].Name, 0, _nodes[dstK].Name, 0);
}
}
private Dictionary<string, Vector2> SaveNodePositions()
@@ -178,12 +173,12 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
return;
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Scene: [b]{nodeData.SceneName}[/b]");
sb.AppendLine($"Mod: {nodeData.ModId}");
sb.AppendLine($"[b]{nodeData.SceneName}[/b]");
sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_detail_mod"), nodeData.ModId));
if (nodeData.Backgrounds.Count > 0)
sb.AppendLine($"Backgrounds: {string.Join(", ", nodeData.Backgrounds)}");
sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_node_bg"), string.Join(", ", nodeData.Backgrounds)));
sb.AppendLine();
sb.AppendLine($"Scenarios ({nodeData.Scenarios.Count}):");
sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_detail_scenarios"), nodeData.Scenarios.Count));
foreach (var sc in nodeData.Scenarios.OrderBy(s => s.Priority))
{
@@ -199,6 +194,8 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_infoLabel.Text = "";
}
private static string Tr(string key) => TranslationServer.Translate(key);
private void OnLinkClicked(Variant meta)
{
var path = Uri.UnescapeDataString(meta.AsString());
+53 -14
View File
@@ -1,9 +1,12 @@
using System.Linq;
using Cthangover.Tools.Services;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Tool;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Read-only metadata panel for the scenario in the active text tab: scene, priority, condition, switch targets, locale keys and quest references.</summary>
public partial class ScenarioInfoPanel : VBoxContainer
{
private Label _infoScene;
@@ -13,25 +16,59 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private Label _infoLocaleKeys;
private Label _infoQuestRefs;
public ScenarioInfoPanel()
/// <summary>Binds the rows once the panel enters the tree (see <see cref="Bind"/>).</summary>
public override void _Ready()
{
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");
Bind();
}
/// <summary>
/// Looks the rows up lazily: the tscn builder runs the constructor before
/// the child nodes exist, so lookups there would fail.
/// </summary>
private void Bind()
{
if (_infoScene != null)
return;
SetHint("HintScene", "tools/scenario_editor/info_scene");
SetHint("HintPriority", "tools/scenario_editor/info_priority");
SetHint("HintCondition", "tools/scenario_editor/info_condition");
SetHint("HintSwitchTargets", "tools/scenario_editor/info_switch_targets");
SetHint("HintLocaleKeys", "tools/scenario_editor/info_locale_keys");
SetHint("HintQuestRefs", "tools/scenario_editor/info_quest_refs");
_infoScene = Value("InfoScene");
_infoPriority = Value("InfoPriority");
_infoCondition = Value("InfoCondition");
_infoSwitchTargets = Value("InfoSwitchTargets");
_infoLocaleKeys = Value("InfoLocaleKeys");
_infoQuestRefs = Value("InfoQuestRefs");
}
private void SetHint(string node, string key)
{
var label = GetNode<Label>("Scroll/VBox/" + node);
label.Text = TranslationServer.Translate(key).ToString().ToUpperInvariant();
label.AddThemeColorOverride("font_color", UiTokens.Muted);
label.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
}
private Label Value(string node)
{
var label = GetNode<Label>("Scroll/VBox/" + node);
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
label.SizeFlagsHorizontal = SizeFlags.ExpandFill;
label.CustomMinimumSize = new Vector2(120, 0);
label.AddThemeFontOverride("font", ToolWindow.GetMonospaceFont());
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
return label;
}
/// <summary>Parses the given scenario text and refreshes every metadata row.</summary>
public void Update(string text)
{
Bind();
if (string.IsNullOrEmpty(text))
{
Clear();
@@ -49,8 +86,10 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_infoQuestRefs.Text = refs.QuestRefs.Count > 0 ? string.Join(", ", refs.QuestRefs.Distinct()) : "-";
}
/// <summary>Resets every metadata row to the "-" placeholder.</summary>
public void Clear()
{
Bind();
_infoScene.Text = "-";
_infoPriority.Text = "-";
_infoCondition.Text = "-";
+13 -9
View File
@@ -4,15 +4,18 @@ using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Row of background thumbnails referenced by the scenario in the active text tab.</summary>
public partial class ScenarioThumbnailPanel : ScrollContainer
{
private HBoxContainer _thumbContainer;
public ScenarioThumbnailPanel()
{
_thumbContainer = GetNode<HBoxContainer>("ThumbContainer");
}
/// <summary>
/// Container lookup is lazy: the tscn builder runs the constructor
/// before the child nodes exist.
/// </summary>
private HBoxContainer Thumbs => _thumbContainer ??= GetNode<HBoxContainer>("ThumbContainer");
/// <summary>Loads the background thumbnails referenced by the given scenario text (missing ones render as placeholders).</summary>
public void Load(string text)
{
Clear();
@@ -24,7 +27,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
if (refs.BackgroundRefs.Count == 0)
{
_thumbContainer.AddChild(new Label
Thumbs.AddChild(new Label
{
Text = TranslationServer.Translate("tools/scenario_editor/no_backgrounds")
});
@@ -37,7 +40,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
if (tex != null)
{
_thumbContainer.AddChild(new TextureRect
Thumbs.AddChild(new TextureRect
{
Texture = tex,
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
@@ -48,18 +51,19 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
else
{
_thumbContainer.AddChild(new Label
Thumbs.AddChild(new Label
{
Text = $"[? {id}]",
TooltipText = $"Not found: {id}"
TooltipText = string.Format(TranslationServer.Translate("tools/scenario_editor/bg_missing"), id)
});
}
}
}
/// <summary>Removes every thumbnail from the row.</summary>
public new void Clear()
{
foreach (var c in _thumbContainer.GetChildren())
foreach (var c in Thumbs.GetChildren())
c.QueueFree();
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// State of one open scenario file tab: identity, the current text buffer
/// (including unsaved edits while the tab is in the background) and the
/// dirty flag.
/// </summary>
public sealed class TabData
{
public string Key;
public string ModId;
public string RelativePath;
public string FileName;
public string Buffer;
public bool IsDirty;
}
}
+1
View File
@@ -10,6 +10,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private string _key;
/// <summary>Binds the checkbox to the property key and seeds it with the current value.</summary>
public void Setup(InspectableProperty property, bool value)
{
_key = property.Key;
+1
View File
@@ -10,6 +10,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private string _key;
/// <summary>Binds the picker to the property key and seeds it with the current color.</summary>
public void Setup(InspectableProperty property, Color value)
{
_key = property.Key;
+1
View File
@@ -15,6 +15,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private InspectableProperty _property;
/// <summary>Populates the dropdown from the property's enum labels and selects the current value, if known.</summary>
public void Setup(InspectableProperty property, long currentValue)
{
_property = property;
@@ -17,6 +17,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private string _key;
private bool _integer;
/// <summary>Applies the property's min/max/step and integer mode, then seeds the initial (clamped) value.</summary>
public void Setup(InspectableProperty property, double initialValue)
{
_key = property.Key;
+23 -8
View File
@@ -4,6 +4,8 @@ using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
@@ -49,24 +51,34 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
protected override void Construct()
{
// ModWidget is a plain Control: it neither sizes its child nor
// reports the child's height. Anchor the column to full width and
// mirror its height into the minimum size so the hosting
// ScrollContainer can scroll (and wrapped hints get real width).
var root = new VBoxContainer();
root.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
root.AddThemeConstantOverride("separation", UiTokens.Space2);
AddChild(root);
root.SetAnchorsAndOffsetsPreset(LayoutPreset.TopWide);
root.MinimumSizeChanged += () => CustomMinimumSize = new Vector2(0, root.GetCombinedMinimumSize().Y);
_hint = new Label();
_hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
_hint.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_hint.AddThemeColorOverride("font_color", UiTokens.Faint);
_hint.Visible = false;
root.AddChild(_hint);
_header = new Label();
_header.AddThemeColorOverride("font_color", UiTokens.InkBright);
_header.AddThemeFontSizeOverride("font_size", UiTokens.FontBodyLarge);
_header.Visible = false;
root.AddChild(_header);
_banner = new Label();
_banner.AutowrapMode = TextServer.AutowrapMode.WordSmart;
_banner.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_banner.Modulate = UiTokens.Warning;
_banner.AddThemeColorOverride("font_color", UiTokens.Warning);
_banner.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
_banner.Visible = false;
root.AddChild(_banner);
@@ -79,7 +91,9 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
Text = TranslationServer.Translate("tools/scene_builder/inspector_preset"),
TooltipText = "anchors_preset"
};
presetLabel.CustomMinimumSize = new Vector2(110f, 0f);
presetLabel.CustomMinimumSize = new Vector2(120f, 0f);
presetLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
presetLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
_presetRow.AddChild(presetLabel);
_presetDropdown = new OptionButton();
@@ -112,7 +126,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
_hint.Visible = false;
_header.Visible = true;
_header.Text = node.Name + " (" + node.GetClass() + ")";
_header.Text = node.Name + " · " + node.GetClass();
var insideContainer = node is Control && node.GetParent() is Container;
_banner.Visible = insideContainer;
@@ -125,8 +139,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
foreach (var entry in InspectorPropertyCatalog.Build(node, insideContainer))
{
var categoryLabel = new Label { Text = TranslationServer.Translate(entry.CategoryKey) };
categoryLabel.Modulate = UiTokens.AccentBright;
var categoryLabel = UiKit.SectionTitle(TranslationServer.Translate(entry.CategoryKey), UiTokens.FontSmall);
_rows.AddChild(categoryLabel);
foreach (var property in entry.Properties)
@@ -168,8 +181,10 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
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);
var label = new Label { Text = labelText, TooltipText = BuildTooltip(property.Key, storedInFile), MouseFilter = MouseFilterEnum.Pass, ClipText = true };
label.CustomMinimumSize = new Vector2(120f, 0f);
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
label.AddThemeColorOverride("font_color", storedInFile ? UiTokens.Ink : UiTokens.Muted);
label.SizeFlagsVertical = Control.SizeFlags.ShrinkCenter;
row.AddChild(label);
+1
View File
@@ -10,6 +10,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private string _key;
/// <summary>Binds the line edit to the property key and seeds it with the current text.</summary>
public void Setup(InspectableProperty property, string value)
{
_key = property.Key;
@@ -16,6 +16,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private SpinBox _x;
private SpinBox _y;
/// <summary>Builds the x/y spin pair bound to the property key and seeds both axes.</summary>
public void Setup(InspectableProperty property, Vector2 initialValue)
{
_key = property.Key;
+13 -6
View File
@@ -251,8 +251,9 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
/// <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.
/// <see cref="Tree"/> control with nodes labelled <c>Name · Class</c>:
/// the mod scene root in gold, editable mod nodes in ink, read-only
/// base-scene nodes faint. Skips the internal highlight rect.
/// </summary>
public void BuildHierarchy(Tree tree)
{
@@ -271,9 +272,12 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
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.SetText(0, $"{node.Name} · {node.GetClass()}");
item.SetSelectable(0, true);
// Mod-scene nodes are the editable ones; base-scene nodes stay faint.
item.SetCustomColor(0, IsEditableModNode(node) ? UiTokens.Ink : UiTokens.Faint);
if (node == _modRoot)
item.SetCustomColor(0, UiTokens.AccentBright);
_nodeToItem[node] = item;
_itemToNode[item] = node;
@@ -341,6 +345,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
}
}
/// <summary>Hierarchy tree item for a live node; null when unmapped.</summary>
public TreeItem GetItemForNode(Node node)
{
_nodeToItem.TryGetValue(node, out var item);
@@ -392,6 +397,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
return string.Join("/", segments);
}
/// <summary>Topmost loaded-scene node whose visual rect contains the viewport position; null when nothing matches.</summary>
public Node FindNodeAtPosition(Vector2 position)
{
if (_loadedRoot == null)
@@ -423,8 +429,9 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
/// <summary>Hides the node highlight overlay and resets its colour.</summary>
public void HideHighlight()
{
if (_highlight != null)
_highlight.Visible = false;
if (_highlight == null)
return;
_highlight.Visible = false;
_highlight.Color = UiTokens.Alpha(UiTokens.Accent, 0.35f);
}
}
@@ -1,6 +1,7 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>Registers the scene builder as a tool window. Creates <see cref="SceneBuilderWindow"/> instances.</summary>
+226 -166
View File
@@ -5,6 +5,9 @@ using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
@@ -32,14 +35,15 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
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 CodeEdit _tscnEditor;
private OptionButton _newNodeTypeDropdown;
private Button _addNodeBtn;
private Button _deleteNodeBtn;
private RichTextLabel _outputLabel;
private int _shownBase;
private int _shownMod;
private int _shownScene;
private bool _loadingText;
/// <summary>Logical game resolution (project.godot) the preview canvas is laid out at.</summary>
private static readonly Vector2I GameResolution = new(1920, 1024);
/// <summary>Curated palette for node creation — common Control classes the runtime builder instantiates via ClassDB.</summary>
private static readonly string[] CreatableTypes =
@@ -63,6 +67,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
PopulateBaseList();
PopulateModList();
RebuildPreview();
SetHint(ToolUi.Tr("tools/scene_builder/hint"));
}
private void BuildUI()
@@ -73,84 +78,121 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
var toolbar = CreateToolbar();
outerVBox.AddChild(toolbar);
toolbar.AddChild(new Label { Text = TranslationServer.Translate("tools/scene_builder/base_scene") });
_baseDropdown = Dropdown(toolbar, "tools/scene_builder/base_scene", OnBaseSelected);
_modDropdown = Dropdown(toolbar, "tools/scene_builder/mod", OnModSelected);
_sceneDropdown = Dropdown(toolbar, "tools/scene_builder/scene", OnSceneSelected);
toolbar.AddChild(ToolUi.Divider());
toolbar.AddChild(ToolUi.Button("tools/common/save", OnSavePressed, ButtonKind.Primary, "tools/scene_builder/save_tip"));
toolbar.AddChild(ToolUi.Button("tools/scene_builder/reload", OnReloadPressed, ButtonKind.Secondary, "tools/scene_builder/reload_tip"));
_baseDropdown = new OptionButton();
_baseDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_baseDropdown.ItemSelected += OnBaseSelected;
toolbar.AddChild(_baseDropdown);
var mainSplit = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outerVBox.AddChild(mainSplit);
toolbar.AddChild(new Label { Text = TranslationServer.Translate("tools/scene_builder/mod") });
mainSplit.AddChild(BuildHierarchyPanel());
_modDropdown = new OptionButton();
_modDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_modDropdown.ItemSelected += OnModSelected;
toolbar.AddChild(_modDropdown);
var centerSplit = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
mainSplit.AddChild(centerSplit);
centerSplit.AddChild(BuildPreview());
centerSplit.AddChild(BuildSidePanel());
toolbar.AddChild(new Label { Text = TranslationServer.Translate("tools/scene_builder/scene") });
_autosaveTimer = new Timer { OneShot = true, WaitTime = 0.4 };
_autosaveTimer.Timeout += OnAutosaveTimeout;
AddChild(_autosaveTimer);
}
_sceneDropdown = new OptionButton();
_sceneDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_sceneDropdown.ItemSelected += OnSceneSelected;
toolbar.AddChild(_sceneDropdown);
private static OptionButton Dropdown(HBoxContainer toolbar, string tooltipKey, Action<long> selected)
{
var dropdown = new OptionButton
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
TooltipText = ToolUi.Tr(tooltipKey),
FitToLongestItem = false
};
dropdown.ItemSelected += index => selected(index);
toolbar.AddChild(dropdown);
return dropdown;
}
_reloadBtn = new Button { Text = TranslationServer.Translate("tools/scene_builder/reload") };
_reloadBtn.Pressed += OnReloadPressed;
toolbar.AddChild(_reloadBtn);
private Control BuildHierarchyPanel()
{
var column = new VBoxContainer();
column.AddThemeConstantOverride("separation", UiTokens.Space2);
var panel = new PanelContainer { CustomMinimumSize = new Vector2(280, 0) };
panel.AddThemeStyleboxOverride("panel", SidebarBox());
panel.AddChild(column);
var mainHBox = new HBoxContainer();
mainHBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
mainHBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
outerVBox.AddChild(mainHBox);
column.AddChild(ToolUi.Section("tools/scene_builder/hierarchy"));
var leftVBox = new VBoxContainer();
leftVBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
leftVBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
var leftPanel = CreateSidebar(leftVBox);
mainHBox.AddChild(leftPanel);
var actions = new HBoxContainer();
actions.AddThemeConstantOverride("separation", UiTokens.Space1);
column.AddChild(actions);
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");
_newNodeTypeDropdown = new OptionButton
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
TooltipText = ToolUi.Tr("tools/scene_builder/node_type_tip")
};
foreach (var t in CreatableTypes)
_newNodeTypeDropdown.AddItem(t);
_newNodeTypeDropdown.Selected = 0;
hierarchyHeader.AddChild(_newNodeTypeDropdown);
actions.AddChild(_newNodeTypeDropdown);
_addNodeBtn = new Button { Text = "+" };
_addNodeBtn.TooltipText = TranslationServer.Translate("tools/scene_builder/add_node_tip");
_addNodeBtn.Pressed += OnAddNodePressed;
hierarchyHeader.AddChild(_addNodeBtn);
var addBtn = UiKit.IconButton("+", ToolUi.Tr("tools/scene_builder/add_node_tip"));
addBtn.Pressed += OnAddNodePressed;
actions.AddChild(addBtn);
_deleteNodeBtn = new Button { Text = "-" };
_deleteNodeBtn.TooltipText = TranslationServer.Translate("tools/scene_builder/delete_node_tip");
_deleteNodeBtn.Pressed += OnDeleteNodePressed;
hierarchyHeader.AddChild(_deleteNodeBtn);
var deleteBtn = UiKit.IconButton("\u2212", ToolUi.Tr("tools/scene_builder/delete_node_tip"));
deleteBtn.Pressed += OnDeleteNodePressed;
actions.AddChild(deleteBtn);
_refreshBtn = new Button();
_refreshBtn.Text = TranslationServer.Translate("tools/scene_builder/refresh");
_refreshBtn.Pressed += OnRefreshPressed;
hierarchyHeader.AddChild(_refreshBtn);
var refreshBtn = UiKit.IconButton("\u27F3", ToolUi.Tr("tools/scene_builder/refresh_tip"));
refreshBtn.Pressed += OnRefreshPressed;
actions.AddChild(refreshBtn);
leftVBox.AddChild(hierarchyHeader);
_hierarchyTree = new Tree();
_hierarchyTree.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
_hierarchyTree.HideRoot = true;
_hierarchyTree = new Tree
{
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
HideRoot = true
};
_hierarchyTree.CellSelected += OnTreeCellSelected;
leftVBox.AddChild(_hierarchyTree);
column.AddChild(_hierarchyTree);
column.AddChild(ToolUi.Hint(ToolUi.Tr("tools/scene_builder/hierarchy_hint")));
return panel;
}
var previewContainer = new PanelContainer();
previewContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
previewContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
mainHBox.AddChild(previewContainer);
/// <summary>
/// Preview canvas. The sub-viewport lays the scene out at the game
/// resolution (2D size override) and is scaled to fit inside an
/// aspect-locked box, so anchors and sizes look exactly as in the game
/// whatever the window size. The selection overlay shares the box, so
/// its coordinate mapping is a plain scale.
/// </summary>
private Control BuildPreview()
{
var previewContainer = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
previewContainer.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line));
_viewport = new SubViewport();
_viewport.Size = new Vector2I(1920, 1080);
var aspect = new AspectRatioContainer
{
Ratio = (float)GameResolution.X / GameResolution.Y,
StretchMode = AspectRatioContainer.StretchModeEnum.Fit
};
previewContainer.AddChild(aspect);
_viewport = new SubViewport
{
Size = GameResolution,
Size2DOverride = GameResolution,
Size2DOverrideStretch = true
};
var clickCatcher = new Control();
clickCatcher.MouseFilter = Control.MouseFilterEnum.Stop;
@@ -160,97 +202,65 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
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;
var viewportContainer = new SubViewportContainer { Stretch = true };
viewportContainer.AddChild(_viewport);
previewContainer.AddChild(viewportContainer);
aspect.AddChild(viewportContainer);
_selectionOverlay = new SelectionOverlay();
previewContainer.AddChild(_selectionOverlay);
_selectionOverlay.Setup(_viewport, viewportContainer);
aspect.AddChild(_selectionOverlay);
_selectionOverlay.Setup(_viewport, viewportContainer, GameResolution);
_selectionOverlay.PickRequested += OnOverlayPick;
_selectionOverlay.BatchCommitted += OnOverlayBatchCommitted;
_selectionOverlay.EnsureConstructed();
_autosaveTimer = new Timer { OneShot = true, WaitTime = 0.4 };
_autosaveTimer.Timeout += OnAutosaveTimeout;
AddChild(_autosaveTimer);
return previewContainer;
}
// 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);
/// <summary>Inspector and tscn source share one tabbed column.</summary>
private Control BuildSidePanel()
{
var panel = new PanelContainer { CustomMinimumSize = new Vector2(380, 0) };
panel.AddThemeStyleboxOverride("panel", SidebarBox());
_sideTabs = new TabContainer();
_sideTabs.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_sideTabs.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
rightVBox.AddChild(_sideTabs);
var inspectorTab = new VBoxContainer
_sideTabs = new TabContainer
{
Name = TranslationServer.Translate("tools/scene_builder/tab_inspector"),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
_sideTabs.AddChild(inspectorTab);
panel.AddChild(_sideTabs);
_inspector = new InspectorPanel();
inspectorTab.AddChild(_inspector);
var inspectorScroll = new ScrollContainer { HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled };
_sideTabs.AddChild(inspectorScroll);
_sideTabs.SetTabTitle(0, ToolUi.Tr("tools/scene_builder/tab_inspector"));
_inspector = new InspectorPanel { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
inspectorScroll.AddChild(_inspector);
_inspector.PropertyEdited += OnInspectorPropertyEdited;
_inspector.EnsureConstructed();
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_select"));
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));
// Titles are set explicitly: a node name cannot hold "." (".tscn").
var tscnTab = new VBoxContainer
{
Name = TranslationServer.Translate("tools/scene_builder/tab_tscn"),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
_sideTabs.AddChild(tscnTab);
_sideTabs.SetTabTitle(1, ToolUi.Tr("tools/scene_builder/tab_tscn"));
_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();
_tscnEditor = ToolUi.Code();
_tscnEditor.SyntaxHighlighter = SourceSyntax.CreateTscnHighlighter();
_tscnEditor.TextChanged += OnTscnTextChanged;
tscnTab.AddChild(_tscnEditor);
tscnTab.AddChild(ToolUi.Hint(ToolUi.Tr("tools/scene_builder/tscn_hint")));
_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);
return panel;
}
private void PopulateBaseList()
{
_baseDropdown.Clear();
_baseDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/empty_scene"));
_baseDropdown.AddItem(ToolUi.Tr("tools/scene_builder/empty_scene"));
_baseScenes = _controller.GetBaseSceneList();
foreach (var s in _baseScenes)
_baseDropdown.AddItem(s.Display);
@@ -260,7 +270,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private void PopulateModList()
{
_modDropdown.Clear();
_modDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/select_mod"));
_modDropdown.AddItem(ToolUi.Tr("tools/scene_builder/select_mod"));
_modScenes = _controller.GetModSceneIndex();
_mods = _modScenes.Select(s => s.ModId).Distinct().ToList();
foreach (var modId in _mods)
@@ -268,7 +278,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
_modDropdown.Selected = 0;
_sceneDropdown.Clear();
_sceneDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/select_scene"));
_sceneDropdown.AddItem(ToolUi.Tr("tools/scene_builder/select_scene"));
_sceneDropdown.Selected = 0;
}
@@ -283,7 +293,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
}
_sceneDropdown.Clear();
_sceneDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/select_scene"));
_sceneDropdown.AddItem(ToolUi.Tr("tools/scene_builder/select_scene"));
foreach (var s in _currentModScenes)
_sceneDropdown.AddItem(s.Name);
_sceneDropdown.Selected = 0;
@@ -302,6 +312,10 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private void RebuildPreview()
{
_shownBase = _baseDropdown.Selected;
_shownMod = _modDropdown.Selected;
_shownScene = _sceneDropdown.Selected;
var baseIdx = (int)_baseDropdown.Selected;
if (baseIdx <= 0 || baseIdx - 1 >= _baseScenes.Count)
_controller.LoadEmptyScene(_viewport);
@@ -316,7 +330,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
RefreshFileSnapshot();
_selectedNode = null;
_selectionOverlay.Track(null);
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_select"));
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));
}
/// <summary>Re-parses the current editor text into the file-truth property snapshot used by the inspector.</summary>
@@ -339,43 +353,93 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private void ShowTscnText()
{
_loadingText = true;
_tscnEditor.Text = "";
if (TryGetSelectedModScene(out var scene))
{
var text = _controller.ReadSceneText(scene.ModId, scene.RelativePath);
_tscnEditor.Text = text ?? "";
}
_tscnEditor.ClearUndoHistory();
_loadingText = false;
MarkClean();
}
/// <summary>Hand edits of the source mark the window dirty; they reach the preview on save.</summary>
private void OnTscnTextChanged()
{
if (!_loadingText)
SetDirty();
}
private void OnBaseSelected(long index)
{
RebuildPreview();
RunDiscardingChanges(RebuildPreview, RestoreDropdowns);
}
private void OnModSelected(long index)
{
var modId = index == 0 ? null : _mods[(int)index - 1];
PopulateSceneListForMod(modId);
RebuildPreview();
RunDiscardingChanges(() =>
{
var modId = index == 0 ? null : _mods[(int)index - 1];
PopulateSceneListForMod(modId);
RebuildPreview();
}, RestoreDropdowns);
}
private void OnSceneSelected(long index)
{
RebuildPreview();
RunDiscardingChanges(RebuildPreview, RestoreDropdowns);
}
/// <summary>Rolls the selectors back to the scene still shown after a refused switch.</summary>
private void RestoreDropdowns()
{
_baseDropdown.Select(_shownBase);
_modDropdown.Select(_shownMod);
PopulateSceneListForMod(_shownMod <= 0 ? null : _mods[_shownMod - 1]);
_sceneDropdown.Select(_shownScene);
}
private void OnReloadPressed()
{
if (TryGetSelectedModScene(out var scene))
_controller.InvalidateScene(scene.RelativePath);
RunDiscardingChanges(() =>
{
if (TryGetSelectedModScene(out var scene))
_controller.InvalidateScene(scene.RelativePath);
RebuildPreview();
ShowOutput(TranslationServer.Translate("tools/scene_builder/reloaded"), false);
RebuildPreview();
ShowOutput(ToolUi.Tr("tools/scene_builder/reloaded"), false);
});
}
/// <summary>Ctrl+S: save the source and rebuild the preview from it.</summary>
protected override void OnSaveShortcut() => OnSavePressed();
/// <summary>Delete removes the selected mod node after a confirmation.</summary>
protected override bool HandleKey(InputEventKey key)
{
if (key.Keycode != Key.Delete || _selectedNode == null || !_controller.IsEditableModNode(_selectedNode))
return false;
ShowConfirm("tools/scene_builder/delete_node_title", "tools/scene_builder/delete_node_confirm", OnDeleteNodePressed);
return true;
}
/// <summary>
/// Saves the source and, when it was edited by hand, rebuilds the preview
/// from the saved file — hand edits do not reach the live preview
/// otherwise (inspector and overlay edits are applied live already).
/// </summary>
private void OnSavePressed()
{
SaveCurrentText(false);
var handEdited = _dirty;
if (!SaveCurrentText(false) || !handEdited || !TryGetSelectedModScene(out var scene))
return;
_controller.InvalidateScene(scene.RelativePath);
RebuildPreview();
ShowOutput(ToolUi.Tr("tools/scene_builder/saved_rebuilt"), false);
}
/// <summary>
@@ -388,20 +452,20 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
if (!TryGetSelectedModScene(out var scene))
{
if (!auto)
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"), true);
return false;
}
if (_controller.SaveSceneText(scene.ModId, scene.RelativePath, _tscnEditor.Text))
{
MarkClean();
ShowOutput(TranslationServer.Translate(auto
ShowOutput(ToolUi.Tr(auto
? "tools/scene_builder/autosaved"
: "tools/scene_builder/saved_ok"), false);
return true;
}
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_save"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/error_save"), true);
GameLogger.Log("SCENE_BUILDER",
$"save failed: mod='{scene.ModId}' path='{scene.RelativePath}' (zip mods are read-only)",
LogLevel.Error);
@@ -465,7 +529,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
}
else
{
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_readonly"));
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_readonly"));
_selectionOverlay.Track(null);
}
}
@@ -500,7 +564,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
{
if (!TryGetSelectedModScene(out var scene))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"), true);
return;
}
@@ -508,14 +572,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
var parent = ResolveAddParent();
if (parent == null)
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
ShowOutput(ToolUi.Tr("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);
ShowOutput(ToolUi.Tr("tools/scene_builder/err_add_failed"), true);
GameLogger.Log("SCENE_BUILDER", $"ClassDB.Instantiate failed for '{typeName}'", LogLevel.Error);
return;
}
@@ -526,7 +590,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
{
created.GetParent().RemoveChild(created);
created.QueueFree();
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_add_parent_missing"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/err_add_parent_missing"), true);
GameLogger.Log("SCENE_BUILDER",
$"node insert rejected: parent section '{parentPath}' absent from file", LogLevel.Warning);
return;
@@ -577,19 +641,19 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
var node = _selectedNode;
if (node == null || !_controller.IsEditableModNode(node))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_del_none"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/err_del_none"), true);
return;
}
if (node == _controller.GetModRoot())
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_del_root"), true);
ShowOutput(ToolUi.Tr("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);
ShowOutput(ToolUi.Tr("tools/scene_builder/err_del_none"), true);
return;
}
@@ -601,14 +665,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
}
else
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_missing_section"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_missing_section"), true);
GameLogger.Log("SCENE_BUILDER",
$"subtree remove failed: section '{path}' absent from file", LogLevel.Warning);
}
_selectedNode = null;
_selectionOverlay.Track(null);
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_select"));
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));
_controller.BuildHierarchy(_hierarchyTree);
}
@@ -621,14 +685,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
{
if (!TryGetSelectedModScene(out var scene))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
ShowOutput(ToolUi.Tr("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);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_readonly"), true);
GameLogger.Log("SCENE_BUILDER", $"batch rejected: '{node.Name}' is not an editable mod node", LogLevel.Warning);
return;
}
@@ -639,14 +703,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
var serialized = TscnValueFormatter.Format(pair.Value);
if (serialized == null)
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_bad_value"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_bad_value"), true);
GameLogger.Log("SCENE_BUILDER", $"unserializable value for '{pair.Key}'", LogLevel.Warning);
continue;
}
if (!TscnTextSurgery.TryUpsertProperty(text, nodePath, pair.Key, serialized, out text))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_missing_section"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_missing_section"), true);
GameLogger.Log("SCENE_BUILDER",
$"section not found: path='{nodePath}' key='{pair.Key}'", LogLevel.Warning);
}
@@ -669,14 +733,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
{
if (!TryGetSelectedModScene(out var scene))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
ShowOutput(ToolUi.Tr("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);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_bad_value"), true);
GameLogger.Log("SCENE_BUILDER", $"unserializable value for '{key}'", LogLevel.Warning);
return;
}
@@ -684,14 +748,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
var nodePath = _controller.GetModNodePath(node);
if (nodePath == null)
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_readonly"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_readonly"), true);
GameLogger.Log("SCENE_BUILDER", $"edit rejected: '{node.Name}' is not an editable mod node", LogLevel.Warning);
return;
}
if (!TscnTextSurgery.TryUpsertProperty(_tscnEditor.Text, nodePath, key, serialized, out var updated))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_missing_section"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_missing_section"), true);
GameLogger.Log("SCENE_BUILDER",
$"section not found: path='{nodePath}' key='{key}'", LogLevel.Warning);
return;
@@ -715,11 +779,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private void ShowOutput(string text, bool isError)
{
if (_outputLabel == null)
return;
var color = isError ? "red" : "green";
_outputLabel.Text = $"[color={color}]{text}[/color]";
SetStatus(text, isError ? ToolStatusKind.Error : ToolStatusKind.Ok);
}
}
}
+54 -9
View File
@@ -4,6 +4,7 @@ using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
@@ -15,11 +16,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
/// overlay itself hit-tests them in its <c>GuiInput</c> handler — because
/// per-marker Controls cannot capture motion outside their small rects and
/// virtual overrides (_Process/_GuiInput) do not dispatch in mod assemblies.
/// Pressing inside the selected rectangle and dragging moves the node (all
/// four offsets shift together); a press that does not move is a pick.
/// Live edits go straight onto the node during the drag; the accumulated
/// key/value batch is emitted once on mouse release via
/// <see cref="BatchCommitted"/> so text write-back happens in one pass.
/// Coordinates map between the fixed-size SubViewport canvas and this
/// overlay through the stretch scale of the hosting SubViewportContainer.
/// Coordinates map between the game-resolution canvas of the SubViewport
/// and this overlay (which shares the viewport container's rectangle) by a
/// plain scale.
/// </summary>
public partial class SelectionOverlay : ModWidget
{
@@ -42,7 +46,9 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private const int ResizeMarkerCount = 8;
private const int AnchorDotCount = 4;
private enum DragKind { None, Resize, Anchor }
private const float MoveThreshold = 4f;
private enum DragKind { None, Resize, Anchor, PendingMove, Move }
private struct Marker
{
@@ -53,6 +59,9 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private SubViewport _viewport;
private Control _containerArea;
private Vector2 _canvasSize;
private Vector2 _pressPoint;
private readonly float[] _moveStartOffsets = new float[4];
private Control _selected;
private readonly List<Marker> _markers = new();
private readonly List<ColorRect> _borderRects = new();
@@ -65,10 +74,11 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
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)
public void Setup(SubViewport viewport, Control containerArea, Vector2I canvasSize)
{
_viewport = viewport;
_containerArea = containerArea;
_canvasSize = canvasSize;
_containerArea.Resized += Refresh;
Refresh();
}
@@ -249,6 +259,11 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
{
if (mb.Pressed)
OnPress(mb.Position);
else if (_drag == DragKind.PendingMove)
{
_drag = DragKind.None;
PickRequested?.Invoke(LocalToCanvas(mb.Position));
}
else if (_drag != DragKind.None)
FinishDrag();
return;
@@ -256,7 +271,10 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
if (evt is InputEventMouseMotion motion)
{
if (_drag != DragKind.None)
if (_drag == DragKind.PendingMove && (motion.Position - _pressPoint).Length() >= MoveThreshold)
_drag = DragKind.Move;
if (_drag is DragKind.Resize or DragKind.Anchor or DragKind.Move)
{
ApplyDrag(motion.Position);
Refresh();
@@ -290,6 +308,19 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
return;
}
if (_markers.Count > 0 && _selected is Control control
&& CanvasRectToOverlay(control.GetGlobalRect()).HasPoint(localPoint))
{
BeginDrag(DragKind.PendingMove, 0);
_pressPoint = localPoint;
_moveStartOffsets[0] = control.OffsetLeft;
_moveStartOffsets[1] = control.OffsetTop;
_moveStartOffsets[2] = control.OffsetRight;
_moveStartOffsets[3] = control.OffsetBottom;
AcceptEvent();
return;
}
PickRequested?.Invoke(LocalToCanvas(localPoint));
}
@@ -314,6 +345,18 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
return;
}
if (_drag == DragKind.Move)
{
var delta = LocalToCanvas(localPoint) - LocalToCanvas(_pressPoint);
var dx = Mathf.Round(delta.X);
var dy = Mathf.Round(delta.Y);
StorePair(control, "offset_left", _moveStartOffsets[0] + dx);
StorePair(control, "offset_top", _moveStartOffsets[1] + dy);
StorePair(control, "offset_right", _moveStartOffsets[2] + dx);
StorePair(control, "offset_bottom", _moveStartOffsets[3] + dy);
return;
}
ApplyAnchorDrag(control, parentSize, mouseInParent, _dragIndex);
}
@@ -452,17 +495,19 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
}
}
MouseDefaultCursorShape = Control.CursorShape.Arrow;
MouseDefaultCursorShape = _markers.Count > 0 && _selected is Control control
&& CanvasRectToOverlay(control.GetGlobalRect()).HasPoint(localPoint)
? Control.CursorShape.Move
: 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);
_canvasSize.X > 0 ? _containerArea.Size.X / _canvasSize.X : 1f,
_canvasSize.Y > 0 ? _containerArea.Size.Y / _canvasSize.Y : 1f);
}
private Vector2 CanvasToOverlay(Vector2 canvasPoint)
+5
View File
@@ -13,6 +13,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
/// </summary>
public static class TscnLiteralReader
{
/// <summary>Parses a bare .tscn float literal; constructors and references return false.</summary>
public static bool TryReadFloat(string raw, out double value)
{
value = 0d;
@@ -21,6 +22,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
return double.TryParse(raw.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
}
/// <summary>Parses a bare .tscn integer literal; floats and constructors return false.</summary>
public static bool TryReadLong(string raw, out long value)
{
value = 0;
@@ -29,6 +31,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
return long.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
}
/// <summary>Parses a .tscn boolean literal (true/false).</summary>
public static bool TryReadBool(string raw, out bool value)
{
var t = raw?.Trim();
@@ -56,6 +59,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
.Replace("\\\\", "\\");
}
/// <summary>Parses a <c>Vector2(x, y)</c> constructor literal.</summary>
public static bool TryReadVector2(string raw, out Vector2 value)
{
value = Vector2.Zero;
@@ -68,6 +72,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
return true;
}
/// <summary>Parses a <c>Color(r, g, b[, a])</c> constructor literal.</summary>
public static bool TryReadColor(string raw, out Color value)
{
value = Colors.White;
+69
View File
@@ -0,0 +1,69 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.SceneEditor
{
/// <summary>
/// One background of the edited scene in <see cref="SceneEditorWindow"/>: a thumbnail
/// (click shows it in the preview), the background id and the mod whose scene file
/// adds it, and a remove button only for backgrounds of the file being edited —
/// another mod's background can only be hidden by editing that mod.
/// </summary>
public partial class SceneBackgroundTile : PanelContainer
{
private readonly string _background;
private readonly Action<string> _picked;
private readonly Action<string> _removed;
/// <summary>Builds the tile; <paramref name="removable"/> shows the ✕ button, <paramref name="selected"/> the accent frame.</summary>
public SceneBackgroundTile(string background, string modId, bool removable, bool selected,
Action<string> picked, Action<string> removed)
{
_background = background;
_picked = picked;
_removed = removed;
AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusSm, UiTokens.Surface1,
selected ? UiTokens.Accent : UiTokens.Line));
var column = new VBoxContainer();
column.AddThemeConstantOverride("separation", UiTokens.Space1);
AddChild(column);
var thumb = new TextureButton
{
TextureNormal = ModResourceService.LoadBackgroundTexture(background),
IgnoreTextureSize = true,
StretchMode = TextureButton.StretchModeEnum.KeepAspectCentered,
CustomMinimumSize = SceneEditorWindow.ThumbSize,
TooltipText = background
};
thumb.Pressed += OnPicked;
column.AddChild(thumb);
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space1);
column.AddChild(row);
var caption = ToolUi.Hint(background + " · " + modId);
caption.TooltipText = background + " (" + modId + ")";
caption.MouseFilter = MouseFilterEnum.Pass;
caption.SizeFlagsHorizontal = SizeFlags.ExpandFill;
caption.CustomMinimumSize = new Vector2(SceneEditorWindow.ThumbSize.X - 32, 0);
caption.TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis;
row.AddChild(caption);
if (removable)
{
var remove = UiKit.IconButton("✕", ToolUi.Tr("tools/scene_editor/remove_background"));
remove.Pressed += OnRemoved;
row.AddChild(remove);
}
}
private void OnPicked() => _picked?.Invoke(_background);
private void OnRemoved() => _removed?.Invoke(_background);
}
}
@@ -0,0 +1,17 @@
using Cthangover.Core.UI.Tool;
namespace Cthangover.Tools.SceneEditor
{
/// <summary>Toolbox button that opens the scene editor. Always visible.</summary>
public class SceneEditorToolBoxButton : IToolBoxButton
{
/// <summary>Matches <see cref="SceneEditorToolProvider.Id"/>.</summary>
public string ToolId => "scene_editor";
/// <summary>No custom icon — uses text label.</summary>
public string IconPath => "";
/// <summary>Translation key for the button label.</summary>
public string LocaleKey => "tools/scene_editor/title";
/// <summary>Always visible in the toolbox.</summary>
public bool IsVisible() => true;
}
}
@@ -0,0 +1,19 @@
using Cthangover.Core.UI.Tool;
using Cthangover.Mods.Interface.Tool;
using Godot;
namespace Cthangover.Tools.SceneEditor
{
/// <summary>Registers the scene editor as a tool window (main menu tools, in-game toolbox).</summary>
public class SceneEditorToolProvider : IToolProvider
{
/// <summary>Unique tool identifier.</summary>
public string Id => "scene_editor";
/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey => "tools/scene_editor/title";
/// <summary>Creates the editor window instance.</summary>
public Window CreateWindow() => new SceneEditorWindow();
/// <summary>Adds the launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
}
}
+743
View File
@@ -0,0 +1,743 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Audio;
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Mods.Dialog;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.SceneEditor
{
/// <summary>
/// Editor of logical scenes — the places of a novel: which backgrounds a scene shows
/// (one of them at random on every entry), its ambient sound, the scenario played
/// when nothing else starts, and the scenarios that happen there. Left: every scene
/// of the loaded mods; centre: the background preview, the scene's backgrounds and a
/// searchable list of all backgrounds to add; right: properties and the scene's
/// scenarios (open one in the scenario editor, or start a new one).
/// <para/>
/// A scene is the merge of the <c>scenes/&lt;name&gt;.json</c> files of all mods
/// (backgrounds add up, a later ambient or default scenario wins). Scenarios are optional:
/// a place may hold only interactives, so a new scene starts without any. The editor writes
/// one of them — the file of the mod chosen in "Save to": the scene's own file when
/// that mod defines it, otherwise a new file that adds to a foreign scene without
/// touching the foreign mod. Backgrounds of the other files are shown locked, with
/// their mod. After saving the game re-reads scenes (<see cref="SceneManager.ReloadContent"/>),
/// so a new scene can be entered at once with "Go to scene".
/// </summary>
public partial class SceneEditorWindow : ToolWindow
{
private const int ThumbWidth = 160;
private const int ThumbHeight = 90;
private readonly List<SceneFileEntry> _files = new();
private readonly List<string> _writableMods = new();
private readonly List<string> _sounds = new();
private readonly List<string> _scenarioKeys = new();
private readonly List<string> _defaultChoices = new();
private string _sceneName;
private bool _isNew;
private SceneFileEntry _target;
private string _preview;
private bool _suppress;
private bool _ambientPlaying;
private string _pendingMod;
private string _pendingScene;
private FilteredList _sceneList;
private TextureRect _previewRect;
private Label _previewCaption;
private HFlowContainer _backgroundRow;
private FilteredList _picker;
private Button _addBackgroundBtn;
private LineEdit _nameEdit;
private OptionButton _modSelect;
private Label _modHint;
private OptionButton _ambientSelect;
private Button _ambientPlayBtn;
private Label _ambientHint;
private OptionButton _defaultSelect;
private Label _defaultHint;
private ItemList _scenarioList;
private Button _openScenarioBtn;
private Button _newScenarioBtn;
private Button _deleteBtn;
private Button _goBtn;
public SceneEditorWindow() : base("tools/scene_editor/title")
{
CthangoverTheme.Apply(this);
BuildUI();
LoadSources();
SetHint(ToolUi.Tr("tools/scene_editor/hint"));
RefreshAll();
}
// ── UI construction ────────────────────────────────────────
private void BuildUI()
{
var outer = CreateFillContainer();
AddChild(outer);
var toolbar = CreateToolbar();
outer.AddChild(toolbar);
toolbar.AddChild(ToolUi.Button("tools/common/save", Save, ButtonKind.Primary, "tools/scene_editor/save_tip"));
toolbar.AddChild(ToolUi.Button("tools/scene_editor/new_scene", NewScene, ButtonKind.Secondary, "tools/scene_editor/new_scene_tip"));
_deleteBtn = ToolUi.Button("tools/scene_editor/delete", AskDelete, ButtonKind.Danger, "tools/scene_editor/delete_tip");
toolbar.AddChild(_deleteBtn);
toolbar.AddChild(ToolUi.Divider());
_goBtn = ToolUi.Button("tools/scene_editor/go", GoToScene, ButtonKind.Secondary, "tools/scene_editor/go_tip");
toolbar.AddChild(_goBtn);
toolbar.AddChild(UiKit.Spacer());
toolbar.AddChild(ToolUi.Button("tools/scenario_editor/rescan", Rescan, ButtonKind.Secondary, "tools/scene_editor/rescan_tip"));
var main = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outer.AddChild(main);
var left = new VBoxContainer();
main.AddChild(CreateSidebar(left, 260));
left.AddChild(ToolUi.Section("tools/scene_editor/scenes"));
_sceneList = new FilteredList(200);
_sceneList.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
_sceneList.Chosen += OnScenePicked;
left.AddChild(_sceneList);
var rest = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
main.AddChild(rest);
rest.AddChild(BuildCentre());
var right = new VBoxContainer();
right.AddThemeConstantOverride("separation", UiTokens.Space2);
rest.AddChild(CreateSidebar(right, 340));
BuildProperties(right);
}
private Control BuildCentre()
{
var column = new VBoxContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
column.AddThemeConstantOverride("separation", UiTokens.Space2);
var frame = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(0, 260)
};
frame.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line));
column.AddChild(frame);
_previewRect = new TextureRect
{
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered,
MouseFilter = Control.MouseFilterEnum.Ignore
};
frame.AddChild(_previewRect);
_previewCaption = ToolUi.Hint(string.Empty);
column.AddChild(_previewCaption);
column.AddChild(ToolUi.Section("tools/scene_editor/backgrounds"));
_backgroundRow = new HFlowContainer();
_backgroundRow.AddThemeConstantOverride("h_separation", UiTokens.Space2);
_backgroundRow.AddThemeConstantOverride("v_separation", UiTokens.Space2);
column.AddChild(_backgroundRow);
column.AddChild(ToolUi.Section("tools/scene_editor/all_backgrounds"));
_picker = new FilteredList(120);
_picker.Chosen += ShowPreview;
_picker.Activated += AddBackground;
column.AddChild(_picker);
_addBackgroundBtn = ToolUi.Button("tools/scene_editor/add_background", AddPreviewed, ButtonKind.Secondary, "tools/scene_editor/add_background_tip");
column.AddChild(_addBackgroundBtn);
return column;
}
private void BuildProperties(VBoxContainer column)
{
column.AddChild(ToolUi.Section("tools/scene_editor/properties"));
_nameEdit = ToolUi.Field(column, "tools/scene_editor/name", ToolUi.Text("my_scene", OnNameChanged), "tools/scene_editor/name_tip");
_modSelect = ToolUi.Field(column, "tools/scene_editor/mod", new OptionButton(), "tools/scene_editor/mod_tip");
_modSelect.ItemSelected += OnModPicked;
_modHint = ToolUi.Hint(string.Empty);
column.AddChild(_modHint);
var ambientRow = new HBoxContainer();
ambientRow.AddThemeConstantOverride("separation", UiTokens.Space1);
_ambientSelect = new OptionButton { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, FitToLongestItem = false };
_ambientSelect.ItemSelected += OnAmbientPicked;
ambientRow.AddChild(_ambientSelect);
_ambientPlayBtn = ToolUi.Button("tools/scene_editor/listen", ToggleAmbientPreview, ButtonKind.Secondary, "tools/scene_editor/listen_tip");
ambientRow.AddChild(_ambientPlayBtn);
ToolUi.Field(column, "tools/scene_editor/ambient", ambientRow, "tools/scene_editor/ambient_tip");
_ambientHint = ToolUi.Hint(string.Empty);
column.AddChild(_ambientHint);
_defaultSelect = ToolUi.Field(column, "tools/scene_editor/default_scenario",
new OptionButton { FitToLongestItem = false }, "tools/scene_editor/default_scenario_tip");
_defaultSelect.ItemSelected += OnDefaultPicked;
_defaultHint = ToolUi.Hint(string.Empty);
column.AddChild(_defaultHint);
column.AddChild(ToolUi.Section("tools/scene_editor/scenarios"));
_scenarioList = new ItemList { CustomMinimumSize = new Vector2(0, 140), SizeFlagsVertical = Control.SizeFlags.ExpandFill };
_scenarioList.ItemActivated += OnScenarioActivated;
column.AddChild(_scenarioList);
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space1);
column.AddChild(row);
_openScenarioBtn = ToolUi.Button("tools/scene_editor/open_scenario", OpenSelectedScenario, ButtonKind.Secondary, "tools/scene_editor/open_scenario_tip");
_openScenarioBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(_openScenarioBtn);
_newScenarioBtn = ToolUi.Button("tools/scene_editor/new_scenario", NewScenario, ButtonKind.Secondary, "tools/scene_editor/new_scenario_tip");
_newScenarioBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(_newScenarioBtn);
}
// ── loading ────────────────────────────────────────────────
private void LoadSources()
{
_files.Clear();
_files.AddRange(SceneFileService.Scan());
_writableMods.Clear();
_writableMods.AddRange(ModFileService.ListWritableMods());
_sounds.Clear();
_sounds.AddRange(ModResourceService.GetSoundIds());
_picker.SetItems(ModResourceService.GetBackgroundIds());
_modSelect.Clear();
foreach (var mod in _writableMods)
_modSelect.AddItem(mod);
_ambientSelect.Clear();
_ambientSelect.AddItem(ToolUi.Tr("tools/scene_editor/none"));
foreach (var sound in _sounds)
_ambientSelect.AddItem(sound);
RebuildSceneList();
}
private void RebuildSceneList()
{
var items = new List<(string Id, string Text, Texture2D Icon)>();
foreach (var group in _files.GroupBy(f => f.Name).OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
items.Add((group.Key, group.Key + " · " + string.Join(", ", group.Select(f => f.ModId).Distinct()), null));
_sceneList.SetItems(items);
_sceneList.Select(_isNew ? null : _sceneName);
}
private void OnScenePicked(string name)
{
if (name == _sceneName && !_isNew)
return;
_pendingScene = name;
RunDiscardingChanges(OpenPendingScene, KeepSceneSelection);
}
private void OpenPendingScene() => OpenScene(_pendingScene);
private void KeepSceneSelection() => _sceneList.Select(_isNew ? null : _sceneName);
private void OpenScene(string name)
{
_sceneName = name;
_isNew = false;
var target = FilesOf(name).LastOrDefault(f => _writableMods.Contains(f.ModId))?.ModId
?? _writableMods.FirstOrDefault();
LoadTarget(target);
_preview = Merged().FirstOrDefault().Background;
MarkClean();
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/opened"), name));
RefreshAll();
}
/// <summary>Takes the chosen mod's file of the current scene as the edited one (a copy), or a new empty file.</summary>
private void LoadTarget(string modId)
{
if (modId == null)
{
_target = null;
return;
}
var own = FilesOf(_sceneName).FirstOrDefault(f => f.ModId == modId);
_target = new SceneFileEntry
{
ModId = modId,
RelativePath = own?.RelativePath ?? SceneFileEntry.PathFor(_sceneName ?? string.Empty),
Name = _sceneName,
Backgrounds = new List<string>(own?.Backgrounds ?? new List<string>()),
Ambient = own?.Ambient ?? string.Empty,
DefaultScenario = own?.DefaultScenario ?? string.Empty,
};
}
private IEnumerable<SceneFileEntry> FilesOf(string name) => _files.Where(f => f.Name == name);
/// <summary>
/// The scene as the game will merge it after saving: the other mods' files in load
/// order with the edited file at its mod's place. Yields each background with the
/// file it comes from.
/// </summary>
private IEnumerable<(string Background, SceneFileEntry File)> Merged()
{
foreach (var file in MergedFiles())
{
foreach (var background in file.Backgrounds)
yield return (background, file);
}
}
private List<SceneFileEntry> MergedFiles()
{
var order = ModRegistry.Instance.GetOrderedModIds();
var result = FilesOf(_sceneName).Where(f => _target == null || f.ModId != _target.ModId).ToList();
if (_target != null)
result.Add(_target);
return result.OrderBy(f => Rank(order, f.ModId)).ToList();
}
private static int Rank(IReadOnlyList<string> order, string modId)
{
for (var i = 0; i < order.Count; i++)
{
if (order[i] == modId)
return i;
}
return order.Count;
}
// ── editing ────────────────────────────────────────────────
private void NewScene()
{
RunDiscardingChanges(StartNewScene);
}
private void StartNewScene()
{
_sceneName = string.Empty;
_isNew = true;
LoadTarget(_writableMods.FirstOrDefault());
_preview = null;
_sceneList.Select(null);
SetDirty();
SetStatus(ToolUi.Tr("tools/scene_editor/new_started"));
RefreshAll();
_nameEdit.GrabFocus();
}
private void OnNameChanged(string text)
{
if (_suppress || !_isNew || _target == null)
return;
_sceneName = SceneFileService.ValidName(text) ?? string.Empty;
_target.Name = _sceneName;
_target.RelativePath = SceneFileEntry.PathFor(_sceneName);
SetDirty();
}
private void OnModPicked(long index)
{
if (_suppress)
return;
var mod = index >= 0 && index < _writableMods.Count ? _writableMods[(int)index] : null;
if (_isNew)
{
if (_target != null)
_target.ModId = mod;
RefreshAll();
return;
}
_pendingMod = mod;
RunDiscardingChanges(SwitchTargetMod, RefreshAll);
}
private void SwitchTargetMod()
{
LoadTarget(_pendingMod);
MarkClean();
RefreshAll();
}
private void OnAmbientPicked(long index)
{
if (_suppress || _target == null)
return;
_target.Ambient = index > 0 ? _sounds[(int)index - 1] : string.Empty;
StopAmbientPreview();
SetDirty();
RefreshAll();
}
private void OnDefaultPicked(long index)
{
if (_suppress || _target == null)
return;
_target.DefaultScenario = index > 0 ? _defaultChoices[(int)index - 1] : string.Empty;
SetDirty();
RefreshAll();
}
private void ShowPreview(string background)
{
_preview = background;
RefreshPreview();
}
private void AddPreviewed() => AddBackground(_preview);
private void AddBackground(string background)
{
if (_target == null || string.IsNullOrEmpty(background))
return;
if (Merged().Any(m => m.Background == background))
{
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/already_there"), background));
return;
}
_target.Backgrounds.Add(background);
_preview = background;
SetDirty();
RefreshAll();
}
private void RemoveBackground(string background)
{
if (_target == null || !_target.Backgrounds.Remove(background))
return;
if (_preview == background)
_preview = Merged().FirstOrDefault().Background;
SetDirty();
RefreshAll();
}
// ── saving, deleting, going ────────────────────────────────
/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut() => Save();
private void Save()
{
if (_target == null || !ModFileService.IsWritable(_target.ModId))
{
SetStatus(ToolUi.Tr("tools/scene_editor/no_writable_mod"), ToolStatusKind.Error);
return;
}
if (string.IsNullOrEmpty(_sceneName))
{
SetStatus(ToolUi.Tr("tools/scene_editor/bad_name"), ToolStatusKind.Error);
return;
}
if (_isNew && _files.Any(f => f.Name == _sceneName))
{
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/name_taken"), _sceneName), ToolStatusKind.Error);
return;
}
if (!Merged().Any())
{
SetStatus(ToolUi.Tr("tools/scene_editor/need_background"), ToolStatusKind.Error);
return;
}
if (!SceneFileService.Write(_target))
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return;
}
var created = _isNew;
SceneManager.ReloadContent();
var name = _sceneName;
var mod = _target.ModId;
LoadSources();
_sceneName = name;
_isNew = false;
LoadTarget(mod);
MarkClean();
_sceneList.Select(name);
RefreshAll();
SetStatus(string.Format(ToolUi.Tr(created ? "tools/scene_editor/created" : "tools/common/saved"),
created ? name : mod + "/" + _target.RelativePath), ToolStatusKind.Ok);
}
private void AskDelete()
{
if (_target == null || _isNew || !FilesOf(_sceneName).Any(f => f.ModId == _target.ModId))
return;
ShowConfirm("tools/scene_editor/delete", "tools/scene_editor/delete_confirm", Delete);
}
private void Delete()
{
if (!SceneFileService.Delete(_target))
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return;
}
var path = _target.ModId + "/" + _target.RelativePath;
SceneManager.ReloadContent();
LoadSources();
if (FilesOf(_sceneName).Any())
{
OpenScene(_sceneName);
}
else
{
_sceneName = null;
_target = null;
MarkClean();
RefreshAll();
}
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/deleted"), path), ToolStatusKind.Ok);
}
private void GoToScene()
{
if (_isNew || string.IsNullOrEmpty(_sceneName))
return;
var manager = GetTree().Root.GetNodeOrNull<SceneManager>("SceneManager");
if (manager == null || !manager.HasScene(_sceneName))
{
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/go_failed"), _sceneName), ToolStatusKind.Error);
return;
}
StopAmbientPreview();
// Like the studio's teleport: a dialog left running would keep the box, and the
// entered scene's own scenario would wait behind it (or the old one would go on).
SceneContextNode.FindNode<DialogBox>("DialogBox")?.Abort();
manager.SwitchScene(_sceneName);
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/went"), _sceneName), ToolStatusKind.Ok);
}
private void Rescan()
{
RunDiscardingChanges(RescanNow);
}
private void RescanNow()
{
var name = _isNew ? null : _sceneName;
LoadSources();
if (name != null && FilesOf(name).Any())
OpenScene(name);
else
RefreshAll();
SetStatus(ToolUi.Tr("tools/scene_editor/rescanned"));
}
// ── scenarios ──────────────────────────────────────────────
private void NewScenario()
{
if (_target == null || _isNew || string.IsNullOrEmpty(_sceneName))
return;
var key = ScenarioFileService.CreateForScene(_target.ModId, _sceneName, NewLine());
if (key == null)
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return;
}
SceneManager.ReloadContent();
RefreshAll();
OpenScenarioEditor(key);
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/scenario_created"), key), ToolStatusKind.Ok);
}
private void OnScenarioActivated(long index) => OpenSelectedScenario();
private void OpenSelectedScenario()
{
var selected = _scenarioList.GetSelectedItems();
if (selected.Length == 0 || selected[0] >= _scenarioKeys.Count)
return;
OpenScenarioEditor(_scenarioKeys[selected[0]]);
}
private static void OpenScenarioEditor(string key)
{
var window = new ScenarioEditorWindow();
window.OpenScenario(key);
ShowWindow(window);
}
private static string NewLine() => "text \"" + ToolUi.Tr("tools/scene_editor/new_line_text").Replace("\"", "\\\"") + "\"";
// ── ambient preview ────────────────────────────────────────
private void ToggleAmbientPreview()
{
if (_ambientPlaying)
{
StopAmbientPreview();
RefreshAll();
return;
}
var ambient = EffectiveAmbient().Ambient;
var audio = GetTree().Root.GetNodeOrNull<Node>("AudioService") as IAudioService;
if (audio == null || string.IsNullOrEmpty(ambient))
return;
audio.PlayAmbient(ambient);
_ambientPlaying = true;
RefreshAll();
}
private void StopAmbientPreview()
{
if (!_ambientPlaying)
return;
_ambientPlaying = false;
(GetTree()?.Root.GetNodeOrNull<Node>("AudioService") as IAudioService)?.StopAmbient();
}
/// <summary>Stops a playing ambient preview when the window closes.</summary>
protected override void Cleanup() => StopAmbientPreview();
// ── refresh ────────────────────────────────────────────────
/// <summary>The ambient the game will play: the last non-empty one in load order, and the mod it comes from.</summary>
private (string Ambient, string ModId) EffectiveAmbient()
{
var file = MergedFiles().LastOrDefault(f => !string.IsNullOrEmpty(f.Ambient));
return (file?.Ambient ?? string.Empty, file?.ModId);
}
private (string Scenario, string ModId) EffectiveDefault()
{
var file = MergedFiles().LastOrDefault(f => !string.IsNullOrEmpty(f.DefaultScenario));
return (file?.DefaultScenario ?? string.Empty, file?.ModId);
}
private void RefreshAll()
{
_suppress = true;
var hasScene = _target != null && (_isNew || !string.IsNullOrEmpty(_sceneName));
_nameEdit.Text = _sceneName ?? string.Empty;
_nameEdit.Editable = _isNew;
_modSelect.Disabled = !hasScene;
_modSelect.Select(_target == null ? -1 : _writableMods.IndexOf(_target.ModId));
_modHint.Text = ModHint();
_ambientSelect.Disabled = !hasScene;
_ambientSelect.Select(_target == null ? 0 : _sounds.IndexOf(_target.Ambient) + 1);
var ambient = EffectiveAmbient();
_ambientHint.Text = ambient.ModId != null && ambient.ModId != _target?.ModId
? string.Format(ToolUi.Tr("tools/scene_editor/effective"), ambient.Ambient, ambient.ModId)
: string.Empty;
_ambientPlayBtn.Text = ToolUi.Tr(_ambientPlaying ? "tools/scene_editor/stop" : "tools/scene_editor/listen");
_ambientPlayBtn.Disabled = string.IsNullOrEmpty(ambient.Ambient) && !_ambientPlaying;
RefreshScenarios(hasScene);
RefreshBackgrounds();
RefreshPreview();
_deleteBtn.Disabled = _target == null || _isNew || !FilesOf(_sceneName).Any(f => f.ModId == _target.ModId);
_goBtn.Disabled = _isNew || string.IsNullOrEmpty(_sceneName);
_suppress = false;
}
private string ModHint()
{
if (_writableMods.Count == 0)
return ToolUi.Tr("tools/scene_editor/no_writable_mod");
if (_target == null || _isNew)
return string.Empty;
var others = FilesOf(_sceneName).Select(f => f.ModId).Where(m => m != _target.ModId).Distinct().ToList();
var own = FilesOf(_sceneName).Any(f => f.ModId == _target.ModId);
if (!own)
return string.Format(ToolUi.Tr("tools/scene_editor/adds_to"), string.Join(", ", others));
return others.Count > 0 ? string.Format(ToolUi.Tr("tools/scene_editor/also_in"), string.Join(", ", others)) : string.Empty;
}
private void RefreshScenarios(bool hasScene)
{
_scenarioKeys.Clear();
_defaultChoices.Clear();
_scenarioList.Clear();
if (hasScene && !_isNew)
{
foreach (var file in ScenarioFileService.ScanScenarioFiles().SelectMany(m => m.Groups).SelectMany(g => g.Files))
{
var text = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath);
if (text == null || ScenarioFileService.ParseMetadata(text).Scene != _sceneName)
continue;
_scenarioKeys.Add(file.Key);
_scenarioList.AddItem(file.ModId + ": " + file.RelativePath);
if (!_defaultChoices.Contains(file.RelativePath))
_defaultChoices.Add(file.RelativePath);
}
}
if (_target != null && !string.IsNullOrEmpty(_target.DefaultScenario) && !_defaultChoices.Contains(_target.DefaultScenario))
_defaultChoices.Add(_target.DefaultScenario);
_defaultSelect.Clear();
_defaultSelect.AddItem(ToolUi.Tr("tools/scene_editor/none"));
foreach (var path in _defaultChoices)
_defaultSelect.AddItem(path);
_defaultSelect.Select(_target == null ? 0 : _defaultChoices.IndexOf(_target.DefaultScenario) + 1);
_defaultSelect.Disabled = !hasScene || _isNew;
var effective = EffectiveDefault();
_defaultHint.Text = effective.ModId != null && effective.ModId != _target?.ModId
? string.Format(ToolUi.Tr("tools/scene_editor/effective"), effective.Scenario, effective.ModId)
: string.Empty;
if (hasScene && !_isNew && _scenarioKeys.Count == 0)
_scenarioList.AddItem(ToolUi.Tr("tools/scene_editor/no_scenarios"), null, false);
_openScenarioBtn.Disabled = _scenarioKeys.Count == 0;
_newScenarioBtn.Disabled = !hasScene || _isNew || !ModFileService.IsWritable(_target?.ModId);
}
private void RefreshBackgrounds()
{
foreach (var child in _backgroundRow.GetChildren())
{
_backgroundRow.RemoveChild(child);
child.QueueFree();
}
var any = false;
foreach (var (background, file) in Merged())
{
any = true;
_backgroundRow.AddChild(new SceneBackgroundTile(background, file.ModId, file == _target, background == _preview,
ShowPreview, RemoveBackground));
}
if (!any)
_backgroundRow.AddChild(ToolUi.Hint(ToolUi.Tr(_target == null ? "tools/scene_editor/pick_scene" : "tools/scene_editor/no_backgrounds")));
}
private void RefreshPreview()
{
_previewRect.Texture = string.IsNullOrEmpty(_preview) ? null : ModResourceService.LoadBackgroundTexture(_preview);
_previewCaption.Text = string.IsNullOrEmpty(_preview)
? ToolUi.Tr("tools/scene_editor/preview_hint")
: _preview + (Merged().Any(m => m.Background == _preview) ? string.Empty : " " + ToolUi.Tr("tools/scene_editor/not_in_scene"));
if (_addBackgroundBtn != null)
_addBackgroundBtn.Disabled = _target == null || string.IsNullOrEmpty(_preview) || Merged().Any(m => m.Background == _preview);
}
internal static Vector2 ThumbSize => new(ThumbWidth, ThumbHeight);
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace Cthangover.Tools.Schemas
{
using Cthangover.Mods.Schemas;
using SchemaFieldDefinition = Cthangover.Mods.Schemas.FieldSchema;
using SchemaJsonValues = Cthangover.Mods.Schemas.SchemaJson;
using System.Text.Json.Nodes;
/// <summary>One field's editor in the data editor form: shows the field's JSON and gives it back edited.</summary>
public interface ISchemaFieldEditor
{
/// <summary>The described field.</summary>
SchemaFieldDefinition Field { get; }
/// <summary>Shows <paramref name="node"/> (null: the field is absent).</summary>
void Load(JsonNode node);
/// <summary>The edited value, or null to remove the key.</summary>
JsonNode Save();
}
}
+175
View File
@@ -0,0 +1,175 @@
namespace Cthangover.Tools.Schemas
{
using Cthangover.Mods.Schemas;
using SchemaFieldDefinition = Cthangover.Mods.Schemas.FieldSchema;
using SchemaJsonValues = Cthangover.Mods.Schemas.SchemaJson;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json.Nodes;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Tools.Common;
using Godot;
/// <summary>
/// Edits a <c>list</c> field — an array of objects described by <see cref="SchemaFieldDefinition.Of"/>
/// (an item's traits, an archetype's learnings, a shop's goods) — as a stack of small
/// cards with an add and a remove button, each card a form of the sub-fields.
/// <para/>
/// The <c>values</c> type is the same editor over the engine's older list of
/// <c>"Key=Value"</c> strings (a battle action's <c>Values</c>): known keys become fields,
/// entries the schema does not know are kept as they were.
/// </summary>
public class ListFieldEditor : ISchemaFieldEditor
{
private readonly SchemaFieldDefinition field;
private readonly Action changed;
private readonly VBoxContainer cards;
private readonly List<List<SchemaFieldControl>> rows = new();
private readonly List<string> unknownValues = new();
private bool suppress;
/// <inheritdoc />
public SchemaFieldDefinition Field => field;
/// <summary>Adds the editor for <paramref name="field"/> to <paramref name="parent"/>.</summary>
public ListFieldEditor(Container parent, SchemaFieldDefinition field, Action changed)
{
this.field = field;
this.changed = changed;
var box = new VBoxContainer { Name = "List_" + field.Key };
box.AddThemeConstantOverride("separation", UiTokens.Space2);
cards = new VBoxContainer();
cards.AddThemeConstantOverride("separation", UiTokens.Space2);
box.AddChild(cards);
var add = ToolUi.Button("tools/schema/list_add", AddEmpty, ButtonKind.Secondary);
add.Name = "Add";
add.SizeFlagsHorizontal = Control.SizeFlags.ShrinkBegin;
box.AddChild(add);
ToolUi.Field(parent, string.IsNullOrEmpty(field.Name) ? field.Key : field.Name, box, field.Description);
}
private bool IsValues => string.Equals(field.Type, "values", StringComparison.OrdinalIgnoreCase);
/// <summary>Shows the array <paramref name="node"/> (null or not an array: empty).</summary>
public void Load(JsonNode node)
{
suppress = true;
Clear();
unknownValues.Clear();
if (node is JsonArray array)
{
if (IsValues)
LoadValues(array);
else
foreach (var item in array.OfType<JsonObject>())
AddCard(item);
}
suppress = false;
}
/// <summary>The edited array, or null when it has no entries (the key is then removed).</summary>
public JsonNode Save()
{
var array = new JsonArray();
if (IsValues)
{
foreach (var card in rows)
{
foreach (var control in card.Where(c => c.Value.Length > 0))
array.Add(control.Field.Key + "=" + control.Value);
}
foreach (var raw in unknownValues)
array.Add(raw);
return array.Count == 0 ? null : array;
}
foreach (var card in rows)
{
var item = new JsonObject();
foreach (var control in card.Where(c => c.Value.Length > 0))
item[control.Field.Key] = SchemaJsonValues.ToNode(control.Field, control.Value);
if (item.Count > 0)
array.Add(item);
}
return array.Count == 0 ? null : array;
}
private void LoadValues(JsonArray array)
{
var known = new JsonObject();
foreach (var raw in array.Select(n => n?.ToString()).Where(s => s != null))
{
var eq = raw.IndexOf('=');
var key = eq > 0 ? raw.Substring(0, eq) : raw;
var def = field.Of?.FirstOrDefault(f => string.Equals(f.Key, key, StringComparison.OrdinalIgnoreCase));
if (def == null || eq <= 0)
unknownValues.Add(raw);
else
known[def.Key] = raw.Substring(eq + 1);
}
AddCard(known);
}
private void AddEmpty()
{
AddCard(new JsonObject());
if (!suppress)
changed?.Invoke();
}
private void AddCard(JsonObject item)
{
if (IsValues && rows.Count > 0)
return;
var card = new PanelContainer { Name = "Card" + rows.Count };
card.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusSm, UiTokens.Well, UiTokens.LineSoft));
var column = new VBoxContainer();
card.AddChild(column);
var controls = new List<SchemaFieldControl>();
foreach (var sub in field.Of ?? new List<SchemaFieldDefinition>())
{
var control = new SchemaFieldControl(column, sub, OnChanged);
control.Value = SchemaJsonValues.ToText(Find(item, sub.Key));
controls.Add(control);
}
if (!IsValues)
{
var remove = ToolUi.Button("tools/schema/list_remove", () => Remove(card, controls), ButtonKind.Secondary);
remove.Name = "Remove";
remove.SizeFlagsHorizontal = Control.SizeFlags.ShrinkEnd;
column.AddChild(remove);
}
rows.Add(controls);
cards.AddChild(card);
}
private static JsonNode Find(JsonObject item, string key)
=> item.FirstOrDefault(p => string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase)).Value;
private void Remove(Control card, List<SchemaFieldControl> controls)
{
rows.Remove(controls);
cards.RemoveChild(card);
card.QueueFree();
changed?.Invoke();
}
private void OnChanged()
{
if (!suppress)
changed?.Invoke();
}
private void Clear()
{
rows.Clear();
foreach (var child in cards.GetChildren())
{
cards.RemoveChild(child);
child.QueueFree();
}
}
}
}
+112
View File
@@ -0,0 +1,112 @@
namespace Cthangover.Tools.Schemas
{
using Cthangover.Mods.Schemas;
using SchemaFieldDefinition = Cthangover.Mods.Schemas.FieldSchema;
using SchemaJsonValues = Cthangover.Mods.Schemas.SchemaJson;
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Common;
using Cthangover.Tools.EntityEditor;
using Cthangover.Tools.Services;
using Godot;
/// <summary>
/// Edits a <c>locale</c> field: the locale key the JSON holds and, below it, the text of
/// that key in every language of the mod — the way the item and character editors do,
/// so a beginner types names and a professional can still rename the key. Texts go
/// through the <see cref="EntityDocument"/> and are written by the window's Save.
/// </summary>
public class LocaleFieldEditor : ISchemaFieldEditor
{
private readonly SchemaFieldDefinition field;
private readonly Action changed;
private readonly LineEdit keyEdit;
private readonly VBoxContainer texts;
private readonly Dictionary<string, LineEdit> edits = new();
private EntityDocument document;
private string modId;
private bool suppress;
/// <inheritdoc />
public SchemaFieldDefinition Field => field;
/// <summary>Adds the editor for <paramref name="field"/> to <paramref name="parent"/>.</summary>
public LocaleFieldEditor(Container parent, SchemaFieldDefinition field, Action changed)
{
this.field = field;
this.changed = changed;
keyEdit = ToolUi.Field(parent, string.IsNullOrEmpty(field.Name) ? field.Key : field.Name, ToolUi.Text(string.Empty, OnTyped), "tools/entity_editor/locale_key_tip");
keyEdit.Name = "Key_" + field.Key;
keyEdit.AddThemeFontOverride("font", ToolUi.Mono());
texts = new VBoxContainer();
parent.AddChild(texts);
}
/// <summary>Builds one text row per language of <paramref name="mod"/>.</summary>
public void Open(EntityDocument doc, string mod, IEnumerable<string> languages)
{
document = doc;
modId = mod;
foreach (var child in texts.GetChildren())
{
texts.RemoveChild(child);
child.QueueFree();
}
edits.Clear();
foreach (var language in languages)
{
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space3);
row.AddChild(new Label { Text = string.Format(ToolUi.Tr("tools/entity_editor/text_in"), language), CustomMinimumSize = new Vector2(ToolUi.LabelWidth, 0), HorizontalAlignment = HorizontalAlignment.Right });
var edit = ToolUi.Text(string.Empty, OnTyped);
edit.Name = "Text_" + field.Key + "_" + language;
edit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(edit);
texts.AddChild(row);
edits[language] = edit;
}
}
/// <summary>Shows the key in <paramref name="node"/> and its texts.</summary>
public void Load(JsonNode node)
{
suppress = true;
var key = node?.ToString() ?? string.Empty;
keyEdit.Text = key;
foreach (var pair in edits)
pair.Value.Text = TextOf(pair.Key, key);
suppress = false;
}
/// <summary>The key for the JSON (null when empty); texts are put into the document.</summary>
public JsonNode Save()
{
var key = keyEdit.Text.Trim();
if (key.Length == 0)
return null;
foreach (var pair in edits)
document?.SetText(pair.Key, key, pair.Value.Text);
return JsonValue.Create(key);
}
private string TextOf(string language, string key)
{
if (string.IsNullOrEmpty(key) || document == null)
return string.Empty;
var text = document.GetText(language, key);
if (text != null)
return text;
text = LocaleFileService.Read(modId, language, key) ?? string.Empty;
document.LoadText(language, key, text);
return text;
}
private void OnTyped(string text)
{
if (!suppress)
changed?.Invoke();
}
}
}
+130
View File
@@ -0,0 +1,130 @@
namespace Cthangover.Tools.Schemas
{
using Cthangover.Mods.Schemas;
using SchemaFieldDefinition = Cthangover.Mods.Schemas.FieldSchema;
using SchemaJsonValues = Cthangover.Mods.Schemas.SchemaJson;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Tools.Common;
using Godot;
/// <summary>
/// Edits a <c>map</c> field — an object whose keys are ids of <see cref="SchemaFieldDefinition.Kind"/>
/// and whose values are numbers or formulas (a character's own <c>Stats</c>, an archetype's
/// growth): one row per key with a key picker, a value box and a remove button. A number
/// is written as a number, anything else as a formula text.
/// </summary>
public class MapFieldEditor : ISchemaFieldEditor
{
private readonly SchemaFieldDefinition field;
private readonly Action changed;
private readonly VBoxContainer lines;
private readonly List<(OptionButton Key, List<string> Keys, LineEdit Value, Control Row)> rows = new();
private bool suppress;
/// <inheritdoc />
public SchemaFieldDefinition Field => field;
/// <summary>Adds the editor for <paramref name="field"/> to <paramref name="parent"/>.</summary>
public MapFieldEditor(Container parent, SchemaFieldDefinition field, Action changed)
{
this.field = field;
this.changed = changed;
var box = new VBoxContainer { Name = "Map_" + field.Key };
lines = new VBoxContainer();
lines.AddThemeConstantOverride("separation", UiTokens.Space1);
box.AddChild(lines);
var add = ToolUi.Button("tools/schema/map_add", AddEmpty, ButtonKind.Secondary);
add.Name = "Add";
add.SizeFlagsHorizontal = Control.SizeFlags.ShrinkBegin;
box.AddChild(add);
ToolUi.Field(parent, string.IsNullOrEmpty(field.Name) ? field.Key : field.Name, box, field.Description);
}
/// <summary>Shows the object <paramref name="node"/>.</summary>
public void Load(JsonNode node)
{
suppress = true;
foreach (var row in rows)
{
lines.RemoveChild(row.Row);
row.Row.QueueFree();
}
rows.Clear();
if (node is JsonObject obj)
{
foreach (var pair in obj)
AddRow(pair.Key, SchemaJsonValues.ToText(pair.Value));
}
suppress = false;
}
/// <summary>The edited object, or null when empty.</summary>
public JsonNode Save()
{
var obj = new JsonObject();
foreach (var row in rows)
{
var key = row.Key.Selected >= 0 && row.Key.Selected < row.Keys.Count ? row.Keys[row.Key.Selected] : null;
if (!string.IsNullOrEmpty(key) && row.Value.Text.Trim().Length > 0)
obj[key] = SchemaJsonValues.NumberOrText(row.Value.Text.Trim());
}
return obj.Count == 0 ? null : obj;
}
private void AddEmpty()
{
AddRow(null, string.Empty);
changed?.Invoke();
}
private void AddRow(string key, string value)
{
var row = new HBoxContainer { Name = "Row" + rows.Count };
row.AddThemeConstantOverride("separation", UiTokens.Space2);
var keys = new List<string> { string.Empty };
if (!string.IsNullOrEmpty(key) && !GameEntityIndex.Of(field.Kind).ContainsKey(key))
keys.Add(key);
keys.AddRange(GameEntityIndex.Of(field.Kind).Keys);
var picker = new OptionButton { CustomMinimumSize = new Vector2(200, 0) };
foreach (var k in keys)
picker.AddItem(k.Length == 0 ? "—" : GameEntityIndex.Label(field.Kind, k));
picker.Select(Math.Max(0, keys.FindIndex(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase))));
picker.ItemSelected += OnPicked;
row.AddChild(picker);
var edit = ToolUi.Text(field.Default ?? string.Empty, OnTyped);
edit.Text = value ?? string.Empty;
edit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
edit.AddThemeFontOverride("font", ToolUi.Mono());
row.AddChild(edit);
var entry = (picker, keys, edit, (Control)row);
row.AddChild(ToolUi.Button("tools/schema/list_remove", () => Remove(entry), ButtonKind.Secondary));
rows.Add(entry);
lines.AddChild(row);
}
private void Remove((OptionButton Key, List<string> Keys, LineEdit Value, Control Row) entry)
{
rows.Remove(entry);
lines.RemoveChild(entry.Row);
entry.Row.QueueFree();
changed?.Invoke();
}
private void OnPicked(long index)
{
if (!suppress)
changed?.Invoke();
}
private void OnTyped(string text)
{
if (!suppress)
changed?.Invoke();
}
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace Cthangover.Tools.Schemas
{
using Cthangover.Mods.Schemas;
using SchemaFieldDefinition = Cthangover.Mods.Schemas.FieldSchema;
using SchemaJsonValues = Cthangover.Mods.Schemas.SchemaJson;
using System;
using System.Text.Json.Nodes;
using Godot;
/// <summary>A data editor form field with a single value: a <see cref="SchemaFieldControl"/> plus the JSON conversion.</summary>
public class ScalarFieldEditor : ISchemaFieldEditor
{
private readonly SchemaFieldControl control;
/// <summary>Adds the row for <paramref name="field"/> to <paramref name="parent"/>.</summary>
public ScalarFieldEditor(Container parent, SchemaFieldDefinition field, Action changed)
{
control = new SchemaFieldControl(parent, field, changed);
}
/// <inheritdoc />
public SchemaFieldDefinition Field => control.Field;
/// <inheritdoc />
public void Load(JsonNode node) => control.Value = SchemaJsonValues.ToText(node);
/// <inheritdoc />
public JsonNode Save() => SchemaJsonValues.ToNode(control.Field, control.Value);
}
}
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more