Compare commits

...
7 Commits
48 changed files with 2616 additions and 183 deletions

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
# Godot 4.x
.godot/
.idea/
sdk/
bin/
obj/
#ai
.omo/
.opencode/
gen/
obj/
bin/
art_sources/
*.tres~
*.tscn~
# GDScript LSP
.godot/
# Export
*.exe
*.dmg
*.apk
*.aab
*.pck
*.zip
*.log
# OS
.DS_Store
Thumbs.db
# Editor
*.import
*.godot.uid
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+57
View File
@@ -0,0 +1,57 @@
# RPG Map Editor — painting tile maps and placing events
The RPG Map Editor edits the tile maps of the RPG engine while the game runs: the three tile layers, walls and passages, the player's start, random battles and the events — characters, signs, doors, items to pick up. Saving updates the map in the game at once, and **Play** walks it.
It opens from the tool box (the hammer on the toolbar) as **RPG Map Editor**, on the map the player is on.
## An edit and its result in the game
The elder's house of RPG Plus before the edit:
![The elder's house in the game: a wooden room with the elder standing at the back and a chest on the right.](assets/house-before.jpg)
In the editor: the **Appearance** tab, the merchant's sprite, **Place NPC**, a click on a free cell. The new event is selected on the **Events** layer and its properties are on the right:
![The RPG Map Editor on rpg_demo_game / rpg_elder_house.json: the Appearance list with merchant · village/merchant selected, the map with the elder and the new merchant framed as events and dashed frames around events of other files, and on the right the map properties (tileset village, 12 × 10, spawn 6, 8 facing Up) and the new event npc_village_merchant at 9, 3 with charset village/merchant, Facing Down, Trigger Action key (E/F). The status line says "merchant" placed as an NPC.](assets/editor-npc.jpg)
After **Save** (Ctrl+S) the merchant stands in the house:
![The same room in the game, now with the merchant at the right of the back wall.](assets/house-after.jpg)
His dialogue and behaviour are set in the event's properties: a scenario, a condition, a trigger.
## The layers and tools
- **Back**, **Main**, **Front** — the tile layers. Back and Main can block walking with solid tiles; Front is drawn over the characters and never blocks.
- **Collision** — painted over the tiles: *Wall* (always blocked), *Passage* (always walkable) or *Auto* (from the tiles).
- **Events** — click to select or drag, double-click an empty cell to add, Del to delete.
- **Pencil**, **Eraser**, **Fill**; the right mouse button erases, Alt+click picks a tile, keys 1–5 switch layers, B/E/G the brushes.
- **Undo**/**Redo** (Ctrl+Z / Ctrl+Y), the grid, zoom (Ctrl+wheel).
![The Collision layer of the elder's house: the walls are painted red as Wall, the floor is open.](assets/collision.jpg)
The tabs on the left: **Tiles** (the palette of the map's tileset, whole objects such as a house or a tree can be stamped in one go), **Characters** (characters with combat stats, placed as enemies), **Items** (placed as pick-ups with a count) and **Appearance** (every sprite of the loaded mods, placed as NPCs).
The right column holds the map's properties: its name key, tileset, size, zoom, ambient light and atmosphere, the spawn point and facing, and random battles (every ~N steps, the enemy groups such as `slime; wolf,slime`).
## Top-down and isometric
The editor draws the map the way the game does. The view picker in the toolbar switches the preview only; the map data stays the same. The house in the isometric preview:
![The elder's house in the isometric preview of the editor: the same room drawn in isometric view, the tileset palette on the left.](assets/editor-isometric.jpg)
The game's own view is set by the collection setting `tile.renderer` (`topdown` or `isometric`); `--editor-renderer=isometric` opens the editor with the isometric preview.
## Whose files are written
- The map itself is saved into its `maps/<id>.json`.
- Events go into the map's own events file in the same mod (`map_events/<map>/…`). Events that other files or other mods add to the map are shown faded with dashed frames and are never rewritten; their ids cannot be reused.
- **+** next to the file list makes a new map in the selected mod; after saving, the game knows it as a scene.
- Only mods installed as folders can be written to.
## If something goes wrong
1. **Play says the game does not know the scene.** The map needs `scenes/<id>.json` in its mod.
2. **An event cannot be selected.** It belongs to another file or mod (dashed frame).
3. **An NPC does not talk.** Its event has no scenario, or its trigger is not the action key.
4. **Saving changes lines you did not touch.** The editor writes the files in its own order and adds default values of events, so a diff can show more than the edit.
+57
View File
@@ -0,0 +1,57 @@
# RPG Map Editor — pinta mapas de tiles y coloca eventos
El RPG Map Editor edita los mapas de tiles del motor RPG con el juego en marcha: las tres capas de tiles, muros y pasos, el punto de inicio del jugador, los combates aleatorios y los eventos — personajes, carteles, puertas, objetos que recoger. Al guardar, el mapa se actualiza en el juego al instante, y **Jugar** te deja recorrerlo a pie.
Se abre desde la caja de herramientas (el martillo de la barra) como **Editor de mapas RPG**, sobre el mapa en el que esté el jugador.
## Una edición y su resultado en el juego
La casa del anciano de RPG Plus antes de editar:
![La casa del anciano en el juego: una habitación de madera con el anciano al fondo y un cofre a la derecha.](assets/house-before.jpg)
En el editor: la pestaña **Appearance** (Apariencias), el sprite del mercader, **Place NPC** (Colocar PNJ), un clic en una casilla libre. El evento nuevo queda seleccionado en la capa **Eventos**, con sus propiedades a la derecha:
![El Editor de mapas RPG sobre rpg_demo_game / rpg_elder_house.json: la lista Appearance con merchant · village/merchant seleccionado, el mapa con el anciano y el nuevo mercader enmarcados como eventos y marcos discontinuos alrededor de los eventos de otros archivos, y a la derecha las propiedades del mapa (tileset village, 12 × 10, punto de inicio 6, 8 mirando arriba) y el nuevo evento npc_village_merchant en 9, 3 con charset village/merchant, orientación abajo y activación por tecla de acción (E/F). La línea de estado dice que «merchant» se colocó como PNJ.](assets/editor-npc.jpg)
Tras **guardar** (Ctrl+S), el mercader está en la casa:
![La misma sala en el juego, ahora con el mercader a la derecha de la pared del fondo.](assets/house-after.jpg)
Su diálogo y comportamiento se ajustan en las propiedades del evento: un escenario, una condición, un disparador.
## Las capas y las herramientas
- **Fondo**, **Principal**, **Frente** — las capas de tiles. Fondo y Principal pueden bloquear el paso con tiles sólidos; Frente se dibuja sobre los personajes y nunca bloquea.
- **Colisiones** — se pinta sobre los tiles: *Muro* (siempre bloqueado), *Paso* (siempre transitable) o *Auto* (según los tiles).
- **Eventos** — clic para seleccionar o arrastrar, doble clic en una casilla vacía para añadir, Supr para borrar.
- **Lápiz**, **Goma**, **Relleno**; el botón derecho borra, Alt+clic toma un tile, las teclas 1–5 cambian de capa y B/E/G los pinceles.
- **Deshacer**/**Rehacer** (Ctrl+Z / Ctrl+Y), la cuadrícula, el zoom (Ctrl+rueda).
![La capa Colisiones de la casa del anciano: los muros pintados en rojo como Muro, el suelo abierto.](assets/collision.jpg)
Las pestañas de la izquierda: **Casillas** (la paleta del tileset del mapa; los objetos enteros, como una casa o un árbol, se estampan de una vez), **Personajes** (personajes con atributos de combate, colocados como enemigos), **Objetos** (colocados como objetos que recoger, con cantidad) y **Appearance** (todos los sprites de los mods cargados, colocados como PNJ).
La columna derecha reúne las propiedades del mapa: su clave de nombre, tileset, tamaño, zoom, luz fija y atmósfera, el punto de inicio con su orientación, y los combates aleatorios (cada ~N pasos, los grupos de enemigos tipo `slime; wolf,slime`).
## Vista superior e isométrica
El editor dibuja el mapa igual que lo dibuja el juego. El selector de vista de la barra cambia solo la vista previa; los datos del mapa quedan igual. La casa en la vista previa isométrica:
![La casa del anciano en la vista previa isométrica del editor: la misma sala dibujada en isométrico, la paleta del tileset a la izquierda.](assets/editor-isometric.jpg)
La vista del juego la fija el ajuste de la colección `tile.renderer` (`topdown` o `isometric`); `--editor-renderer=isometric` abre el editor directamente con la vista previa isométrica.
## Qué archivos se escriben
- El mapa en sí se guarda en su `maps/<id>.json`.
- Los eventos van al archivo de eventos del propio mapa, en el mismo mod (`map_events/<mapa>/…`). Los eventos que otros archivos u otros mods añaden al mapa se muestran atenuados con marcos discontinuos y nunca se reescriben; sus ids no se pueden reutilizar.
- **+** junto a la lista de archivos crea un mapa nuevo en el mod elegido; tras guardar, el juego lo conoce como escena.
- Solo se pueden escribir los mods instalados como carpetas.
## Si algo falla
1. **Jugar dice que el juego no conoce la escena.** El mapa necesita un `scenes/<id>.json` en su mod.
2. **Un evento no se deja seleccionar.** Pertenece a otro archivo o mod (marco discontinuo).
3. **Un PNJ no habla.** Su evento no tiene escenario, o su activación no es la tecla de acción.
4. **Al guardar cambian líneas que no tocaste.** El editor escribe los archivos en su propio orden y añade valores por defecto de los eventos, así que un diff puede mostrar más que la edición.
+57
View File
@@ -0,0 +1,57 @@
# RPG Map Editor — タイルマップを描き、イベントを置く
RPG マップエディターは、実行中のゲームの上で RPG エンジンのタイルマップを編集します。3 つのタイルレイヤー、壁と通路、プレイヤーの開始地点、ランダムバトル、そしてイベント——キャラクター、看板、ドア、拾えるアイテム。保存するとマップはすぐにゲームへ反映され、**プレイ**で実際に歩き回れます。
ツールボックス(ツールバーのハンマー)から **RPG マップエディター** として開きます。対象は、プレイヤーが今いるマップです。
## 編集と、その結果のゲーム内での様子
RPG Plus の長老の家、編集前:
![ゲーム内の長老の家: 木造の部屋で、奥に長老、右に宝箱。](assets/house-before.jpg)
エディターでは、**Appearance**(外観)タブで商人のキャラチップを選び、**Place NPC**(NPC を配置)を押して、空いたマスをクリックします。新しいイベントは **イベント** レイヤーで選択され、プロパティが右に表示されます。
![rpg_demo_game / rpg_elder_house.json を開いた RPG マップエディター: 外観リストで merchant · village/merchant を選択、マップ上の長老と新しい商人はイベントとして枠囲みになり、他のファイルのイベントには破線の枠。右側にはマップのプロパティ(タイルセット village、12 × 10、開始地点 6, 8・向きは上)と新しいイベント npc_village_merchant(9, 3 にあり、キャラチップ village/merchant、向きは下、起動はアクションキー E/F)。ステータス行は「merchant」を NPC として配置したと表示しています。](assets/editor-npc.jpg)
**保存**(Ctrl+S)のあと、商人が家に立っています:
![ゲーム内の同じ部屋。今は奥の壁の右側に商人がいる。](assets/house-after.jpg)
彼の会話と振る舞いはイベントのプロパティで設定します。シナリオ、条件、起動方法です。
## レイヤーとツール
- **背景**、**メイン**、**前景** — タイルレイヤーです。背景とメインは solid なタイルで通行を妨げられます。前景はキャラクターの上に描かれ、決して妨げません。
- **衝突** — タイルの上に塗ります。*壁*(常に通れない)、*通路*(常に通れる)、*自動*(タイルに従う)。
- **イベント** — クリックで選択・ドラッグ、空いたマスのダブルクリックで追加、Del で削除。
- **鉛筆**、**消しゴム**、**塗りつぶし**。右クリックで消し、Alt+クリックでタイルを拾い、1–5 キーでレイヤー、B/E/G でブラシを切り替えます。
- **元に戻す**/**やり直す**(Ctrl+Z / Ctrl+Y)、グリッド、ズーム(Ctrl+ホイール)。
![長老の家の衝突レイヤー: 壁が「壁」として赤く塗られ、床は開いている。](assets/collision.jpg)
左のタブは **タイル**(マップのタイルセットのパレット。家や木のようなオブジェクト全体を一度に置けます)、**キャラクター**(戦闘ステータスを持つキャラクター。敵として配置)、**アイテム**(個数つきの拾えるアイテムとして配置)、そして **Appearance**(読み込まれた MOD の全スプライト。NPC として配置)です。
右の列にはマップのプロパティが並びます。名前のキー、タイルセット、サイズ、拡大率、固定の環境光と雰囲気、開始地点と向き、そしてランダムバトル(約 N 歩ごと、敵グループは `slime; wolf,slime` のように書きます)。
## トップダウンとアイソメトリック
エディターはゲームと同じようにマップを描きます。ツールバーのビュー切り替えはプレビューだけを変え、マップのデータは変わりません。アイソメトリックプレビューの家:
![エディターのアイソメトリックプレビューに映る長老の家: 同じ部屋がアイソメトリックで描かれ、左にタイルセットのパレット。](assets/editor-isometric.jpg)
ゲーム本体のビューはコレクション設定 `tile.renderer`(`topdown` か `isometric`)で決まります。`--editor-renderer=isometric` を付けると、エディターが最初からアイソメトリックプレビューで開きます。
## どのファイルが書かれるか
- マップ本体は、そのマップの `maps/<id>.json` に保存されます。
- イベントは同じ MOD 内の、そのマップ自身のイベントファイル(`map_events/<マップ>/…`)へ書き込まれます。他のファイルや他の MOD がマップに加えたイベントは、破線の枠つきで薄く表示され、決して上書きされません。その id は使えません。
- ファイル一覧の隣の **+** で、選択中の MOD に新しいマップを作れます。保存すれば、ゲームはそれをすぐにシーンとして認識します。
- 書き込めるのは、フォルダーとしてインストールされた MOD だけです。
## うまくいかないとき
1. **プレイで「ゲームはシーンを知らない」と出る。** そのマップの MOD に `scenes/<id>.json` が必要です。
2. **イベントが選べない。** それは別のファイルか MOD のイベントです(破線の枠)。
3. **NPC が話さない。** イベントにシナリオがないか、起動がアクションキーになっていません。
4. **保存すると、触っていない行まで変わる。** エディターは独自の順序でファイルを書き、イベントの既定値も書き込むため、diff が編集より多くの変更を示すことがあります。
+57
View File
@@ -0,0 +1,57 @@
# RPG Map Editor — 타일 맵을 그리고 이벤트 배치하기
RPG 맵 에디터는 게임이 실행되는 동안 RPG 엔진의 타일 맵을 편집합니다. 타일 레이어 3개, 벽과 통로, 플레이어 시작 지점, 랜덤 전투, 그리고 이벤트 — 캐릭터, 표지판, 문, 주울 수 있는 아이템까지. 저장하면 맵이 곧바로 게임에 반영되고, **플레이**로 직접 걸어 볼 수 있습니다.
툴박스(툴바의 망치)에서 **RPG 맵 에디터**로 열리며, 플레이어가 서 있는 맵을 대상으로 합니다.
## 편집 하나와 그 결과
편집 전의 RPG Plus 장로의 집:
![게임 속 장로의 집: 나무 방, 안쪽에 선 장로, 오른쪽에 상자.](assets/house-before.jpg)
에디터에서는 **Appearance**(외형) 탭에서 상인 캐릭터 시트를 고르고, **Place NPC**(NPC로 배치)를 누른 뒤 빈 칸을 클릭합니다. 새 이벤트는 **이벤트** 레이어에서 선택되고 속성이 오른쪽에 표시됩니다:
![rpg_demo_game / rpg_elder_house.json을 연 RPG 맵 에디터: Appearance 목록에서 merchant · village/merchant 선택, 장로와 새 상인은 이벤트로 테두리가 쳐지고 다른 파일의 이벤트에는 점선 테두리가 그려짐. 오른쪽에는 맵 속성(타일셋 village, 12 × 10, 시작 지점 6, 8·위쪽 방향)과 새 이벤트 npc_village_merchant(9, 3에 위치, 캐릭터 시트 village/merchant, 아래 방향, 액션 키 E/F 발동). 상태 줄에는 "merchant"가 NPC로 배치되었다고 표시됨.](assets/editor-npc.jpg)
**저장**(Ctrl+S) 후에는 상인이 집 안에 서 있습니다:
![게임 속 같은 방, 이제 뒷벽 오른쪽에 상인이 있다.](assets/house-after.jpg)
그의 대화와 행동은 이벤트 속성에서 정합니다. 시나리오, 조건, 발동 방식입니다.
## 레이어와 도구
- **배경**, **메인**, **전경** — 타일 레이어입니다. 배경과 메인은 막힌 타일로 이동을 막을 수 있고, 전경은 캐릭터 위에 그려지며 절대 막지 않습니다.
- **충돌** — 타일 위에 칠합니다: *벽*(항상 막힘), *통로*(항상 지나갈 수 있음), *자동*(타일에 따름).
- **이벤트** — 클릭으로 선택하거나 끌고, 빈 칸을 더블클릭해 추가하고, Del로 삭제합니다.
- **연필**, **지우개**, **채우기**. 오른쪽 버튼은 지우기, Alt+클릭은 타일 집기, 1–5 키는 레이어 전환, B/E/G는 브러시 전환입니다.
- **되돌리기**/**다시 실행**(Ctrl+Z / Ctrl+Y), 격자, 확대·축소(Ctrl+휠).
![장로의 집의 충돌 레이어: 벽이 "벽"으로 빨간색 칠해지고 바닥은 열려 있다.](assets/collision.jpg)
왼쪽 탭: **타일**(맵 타일셋 팔레트, 집이나 나무 같은 온전한 물체를 한 번에 찍을 수 있음), **캐릭터**(전투 스탯이 있는 캐릭터, 적으로 배치), **아이템**(개수를 정해 주울 수 있게 배치), 그리고 **Appearance**(불러온 모드의 모든 스프라이트, NPC로 배치).
오른쪽 열에는 맵 속성이 있습니다: 이름 키, 타일셋, 크기, 배율, 고정 환경광과 분위기, 시작 지점과 방향, 그리고 랜덤 전투(약 N걸음마다, `slime; wolf,slime` 같은 적 그룹).
## 탑다운과 아이소메트릭
에디터는 게임과 같은 방식으로 맵을 그립니다. 툴바의 보기 전환기는 미리 보기만 바꾸고 맵 데이터는 그대로입니다. 아이소메트릭 미리 보기의 집:
![에디터의 아이소메트릭 미리 보기에 보이는 장로의 집: 같은 방이 아이소메트릭으로 그려지고 왼쪽에 타일셋 팔레트가 있다.](assets/editor-isometric.jpg)
게임 자체의 보기는 컬렉션 설정 `tile.renderer`(`topdown` 또는 `isometric`)가 정합니다. `--editor-renderer=isometric`로 시작하면 에디터가 처음부터 아이소메트릭 미리 보기로 열립니다.
## 어떤 파일에 기록되는가
- 맵 자체는 자기 `maps/<id>.json`에 저장됩니다.
- 이벤트는 같은 모드 안의 그 맵 고유의 이벤트 파일(`map_events/<맵>/…`)에 기록됩니다. 다른 파일이나 다른 모드가 맵에 추가한 이벤트는 점선 테두리와 함께 흐리게 표시되며 절대 다시 쓰이지 않고, 그 id는 재사용할 수 없습니다.
- 파일 목록 옆의 **+**는 선택한 모드에 새 맵을 만듭니다. 저장하면 게임이 곧바로 씬으로 인식합니다.
- 기록할 수 있는 것은 폴더로 설치된 모드뿐입니다.
## 문제가 생겼을 때
1. **플레이가 게임이 씬을 모른다고 말한다.** 그 맵의 모드에 `scenes/<id>.json`이 필요합니다.
2. **이벤트가 선택되지 않는다.** 다른 파일이나 모드의 것입니다(점선 테두리).
3. **NPC가 말하지 않는다.** 이벤트에 시나리오가 없거나, 발동이 액션 키가 아닙니다.
4. **저장하면 건드리지 않은 줄까지 바뀐다.** 에디터는 자기 순서대로 파일을 쓰고 이벤트의 기본값도 채워 넣으므로, diff가 편집보다 많은 것을 보여 줄 수 있습니다.
+57
View File
@@ -0,0 +1,57 @@
# RPG Map Editor — рисование тайловых карт и расстановка событий
RPG Map Editor правит тайловые карты движка RPG прямо во время игры: три слоя тайлов, стены и проходы, точку появления героя, случайные бои и события — персонажей, таблички, двери, подбираемые предметы. Сохранение сразу обновляет карту в запущенной игре, а кнопка **Играть** позволяет по ней пройтись.
Открывается редактор из набора инструментов (молоток на панели инструментов) как **Редактор RPG-карт** — для той карты, на которой сейчас стоит игрок.
## Правка и её результат в игре
Дом старейшины из RPG Plus до правки:
![Дом старейшины в игре: деревянная комната, в глубине стоит старейшина, справа сундук.](assets/house-before.jpg)
В редакторе: вкладка **Внешность**, спрайт торговца, кнопка **Разместить NPC**, клик по свободной клетке. Новое событие выбрано на слое **События**, его свойства — справа:
![Редактор RPG-карт на файле rpg_demo_game / rpg_elder_house.json: список «Внешность» с выбранным merchant · village/merchant, карта, где старейшина и новый торговец обведены рамками как события, а события из других файлов — пунктирными рамками; справа свойства карты (тайлсет village, 12 × 10, точка появления 6, 8 со взглядом вверх) и новое событие npc_village_merchant в клетке 9, 3 с чарсетом village/merchant, взглядом вниз и запуском по клавише действия (E/F). Строка состояния сообщает, что «merchant» размещён как NPC.](assets/editor-npc.jpg)
После **сохранения** (Ctrl+S) торговец стоит в доме:
![Та же комната в игре, теперь с торговцем справа у задней стены.](assets/house-after.jpg)
Его диалог и поведение задаются в свойствах события: сценарий, условие, способ запуска.
## Слои и инструменты
- **Фон**, **Основной**, **Передний** — слои тайлов. Фон и Основной могут перекрывать проход непроходимыми тайлами; Передний рисуется поверх персонажей и никогда не блокирует.
- **Коллизии** — рисуется поверх тайлов: *Стена* (всегда непроходимо), *Проход* (всегда проходимо) или *Авто* (по тайлам).
- **События** — клик выбирает или тащит событие, двойной клик по пустой клетке добавляет, Del удаляет.
- **Карандаш**, **Ластик**, **Заливка**; правая кнопка мыши стирает, Alt+клик берёт тайл, клавиши 1–5 переключают слои, B/E/G — кисти.
- **Отменить**/**Вернуть** (Ctrl+Z / Ctrl+Y), сетка, масштаб (Ctrl+колесо).
![Слой коллизий дома старейшины: стены закрашены красным как «Стена», пол открыт.](assets/collision.jpg)
Вкладки слева: **Тайлы** (палитра тайлсета карты; целые объекты вроде дома или дерева можно ставить одним штампом), **Персонажи** (персонажи с боевыми характеристиками, размещаются как враги), **Предметы** (раскладываются как подбираемые предметы с количеством) и **Внешность** (все спрайты загруженных модов, размещаются как NPC).
Правый столбец — свойства карты: ключ имени, тайлсет, размер, масштаб, своё освещение и атмосфера, точка появления и направление взгляда, а также случайные бои (примерно раз в N шагов, группы врагов вида `slime; wolf,slime`).
## Вид сверху и изометрия
Редактор рисует карту так же, как её рисует игра. Переключатель вида на панели инструментов меняет только предпросмотр; данные карты остаются прежними. Дом в изометрическом предпросмотре:
![Дом старейшины в изометрическом предпросмотре редактора: та же комната в изометрии, слева палитра тайлсета.](assets/editor-isometric.jpg)
Сам вид игры задаёт настройка коллекции `tile.renderer` (`topdown` или `isometric`); ключ `--editor-renderer=isometric` открывает редактор сразу в изометрическом предпросмотре.
## Какие файлы записываются
- Сама карта сохраняется в свой `maps/<id>.json`.
- События попадают в файл событий этой карты в том же моде (`map_events/<карта>/…`). События, которые добавляют на карту другие файлы или другие моды, показываются приглушённо, с пунктирной рамкой, и никогда не перезаписываются; их id нельзя использовать повторно.
- **+** рядом со списком файлов создаёт новую карту в выбранном моде; после сохранения игра сразу знает её как сцену.
- Записывать можно только в моды, установленные папками.
## Если что-то пошло не так
1. **«Играть» сообщает, что игра не знает сцену.** Карте нужен файл `scenes/<id>.json` в её моде.
2. **Событие не выбирается.** Оно принадлежит другому файлу или другому моду (пунктирная рамка).
3. **NPC не разговаривает.** У его события нет сценария, либо запуск у него не по клавише действия.
4. **Сохранение меняет строки, которые вы не трогали.** Редактор записывает файлы в своём порядке и добавляет значения по умолчанию для полей событий, поэтому diff может показать больше, чем сама правка.
+57
View File
@@ -0,0 +1,57 @@
# RPG Map Editor — vẽ bản đồ ô và đặt sự kiện
Trình sửa bản đồ RPG hiệu chỉnh các bản đồ ô của nền RPG ngay khi trò chơi đang chạy: ba lớp ô, tường và lối đi, điểm xuất phát của người chơi, trận đấu ngẫu nhiên và các sự kiện — nhân vật, biển hiệu, cửa, vật phẩm nhặt được. Lưu xong bản đồ được cập nhật trong game ngay lập tức, còn **Chơi** cho bạn đi lại trên đó.
Công cụ mở từ hộp công cụ (chiếc búa trên thanh công cụ) với tên **Trình sửa bản đồ RPG**, tác động lên bản đồ mà người chơi đang đứng.
## Một lần sửa và kết quả trong game
Nhà trưởng lão trong RPG Plus trước khi sửa:
![Nhà trưởng lão trong game: căn phòng gỗ, trưởng lão đứng phía trong, bên phải là một chiếc rương.](assets/house-before.jpg)
Trong trình soạn thảo: tab **Appearance** (Ngoại hình), sprite của thương nhân, **Place NPC** (Đặt làm NPC), rồi nhấp vào một ô trống. Sự kiện mới được chọn trên lớp **Sự kiện**, thuộc tính nằm bên phải:
![Trình sửa bản đồ RPG đang mở rpg_demo_game / rpg_elder_house.json: danh sách Appearance chọn merchant · village/merchant, trưởng lão và thương nhân mới được viền như các sự kiện, sự kiện của các tệp khác có viền nét đứt; bên phải là thuộc tính bản đồ (tileset village, 12 × 10, điểm xuất phát 6, 8 hướng lên) và sự kiện mới npc_village_merchant tại 9, 3 với charset village/merchant, hướng xuống, kích hoạt bằng phím hành động (E/F). Dòng trạng thái báo "merchant" đã được đặt làm NPC.](assets/editor-npc.jpg)
Sau khi **lưu** (Ctrl+S), thương nhân đã đứng trong nhà:
![Cùng căn phòng trong game, giờ có thương nhân ở bên phải bức tường sau.](assets/house-after.jpg)
Lời thoại và hành vi của anh ta được đặt trong thuộc tính sự kiện: kịch bản, điều kiện, cách kích hoạt.
## Các lớp và công cụ
- **Nền**, **Chính**, **Phía trước** — các lớp ô. Nền và Chính có thể chặn đường bằng ô đặc; Phía trước vẽ đè lên nhân vật và không bao giờ chặn.
- **Va chạm** — vẽ đè lên các ô: *Tường* (luôn bị chặn), *Lối đi* (luôn đi được) hoặc *Tự động* (theo các ô).
- **Sự kiện** — nhấp để chọn hoặc kéo, nhấp đúp vào ô trống để thêm, Del để xóa.
- **Bút chì**, **Tẩy**, **Tô**; nút chuột phải xóa, Alt+nhấp lấy ô, phím 1–5 đổi lớp, B/E/G đổi bút.
- **Hoàn tác**/**Làm lại** (Ctrl+Z / Ctrl+Y), lưới, thu phóng (Ctrl+con lăn).
![Lớp Va chạm của nhà trưởng lão: các bức tường được tô đỏ thành Tường, sàn mở.](assets/collision.jpg)
Các tab bên trái: **Ô** (bảng màu của tileset bản đồ, vật thể nguyên vẹn như ngôi nhà hay cái cây có thể đóng dấu một lần), **Nhân vật** (nhân vật có chỉ số chiến đấu, đặt làm kẻ địch), **Vật phẩm** (đặt làm đồ nhặt kèm số lượng) và **Appearance** (mọi sprite của các mod đã nạp, đặt làm NPC).
Cột bên phải chứa thuộc tính bản đồ: khóa tên, tileset, kích thước, thu phóng, ánh sáng nền cố định và bầu không khí, điểm xuất phát cùng hướng nhìn, và trận đấu ngẫu nhiên (khoảng mỗi N bước, các nhóm địch dạng `slime; wolf,slime`).
## Nhìn từ trên và đẳng cự
Trình soạn thảo vẽ bản đồ đúng theo cách game vẽ. Bộ chuyển đổi chế độ xem trên thanh công cụ chỉ đổi phần xem trước; dữ liệu bản đồ giữ nguyên. Ngôi nhà trong bản xem trước đẳng cự:
![Nhà trưởng lão trong bản xem trước đẳng cự của trình soạn thảo: cùng căn phòng vẽ theo góc đẳng cự, bảng màu tileset bên trái.](assets/editor-isometric.jpg)
Chế độ xem của game do cài đặt bộ sưu tập `tile.renderer` (`topdown` hoặc `isometric`) quyết định; `--editor-renderer=isometric` mở trình soạn thảo ngay ở bản xem trước đẳng cự.
## Tệp nào được ghi
- Bản đồ được lưu vào `maps/<id>.json` của chính nó.
- Sự kiện được ghi vào tệp sự kiện riêng của bản đồ trong cùng mod (`map_events/<bản đồ>/…`). Các sự kiện do tệp khác hoặc mod khác thêm vào bản đồ được hiển thị mờ với viền nét đứt và không bao giờ bị ghi đè; không thể tái sử dụng id của chúng.
- **+** cạnh danh sách tệp tạo bản đồ mới trong mod đang chọn; sau khi lưu, game nhận nó là một cảnh ngay lập tức.
- Chỉ có thể ghi vào các mod được cài dưới dạng thư mục.
## Khi có trục trặc
1. **Chơi báo game không biết cảnh này.** Bản đồ cần `scenes/<id>.json` trong mod của nó.
2. **Không chọn được sự kiện.** Nó thuộc về tệp hoặc mod khác (viền nét đứt).
3. **NPC không nói chuyện.** Sự kiện của nó không có kịch bản, hoặc cách kích hoạt không phải phím hành động.
4. **Lưu làm thay cả những dòng bạn không đụng tới.** Trình soạn thảo ghi tệp theo thứ tự riêng và thêm các giá trị mặc định của sự kiện, nên diff có thể hiển thị nhiều hơn lần sửa của bạn.
+57
View File
@@ -0,0 +1,57 @@
# RPG Map Editor — 绘制图块地图、摆放事件
RPG 地图编辑器在游戏运行时编辑 RPG 引擎的图块地图:三个图块图层、墙壁与通道、玩家出生点、随机战斗,以及事件——人物、告示牌、门、可拾取的物品。保存后地图立即在游戏中更新,按 **试玩** 就能走一走。
它从工具箱(工具栏上的锤子)打开,名为 **RPG 地图编辑器**,作用于玩家当前所在的地图。
## 一次编辑及其在游戏中的结果
RPG Plus 中长者之家编辑前的样子:
![游戏中的长者之家:木质房间,长者站在里侧,右边有一个宝箱。](assets/house-before.jpg)
在编辑器里:**Appearance**(外观)标签页、商人的行走图、**Place NPC**(放置 NPC),再点一个空格子。新事件会在 **事件** 图层中被选中,属性显示在右侧:
![RPG 地图编辑器打开 rpg_demo_game / rpg_elder_house.json:Appearance 列表中选中的是 merchant · village/merchant,地图上的长者和新商人都作为事件被框出,其他文件的事件则用虚线框;右侧是地图属性(图块集 village、12 × 10、出生点 6, 8 朝上)和新事件 npc_village_merchant(在 9, 3,行走图 village/merchant,朝下,触发方式为动作键 E/F)。状态栏显示“merchant”已作为 NPC 放置。](assets/editor-npc.jpg)
**保存**(Ctrl+S)之后,商人就站进了屋里:
![游戏中的同一个房间,现在后墙右侧多了一位商人。](assets/house-after.jpg)
他的对话与行为在事件属性里设置:剧本、条件、触发方式。
## 图层与工具
- **背景层**、**主层**、**前景层** — 图块图层。背景层和主层可以用实心图块挡住去路;前景层画在角色之上,从不挡路。
- **碰撞** — 画在图块之上:*墙*(始终阻挡)、*通道*(始终可走)或 *自动*(取决于图块)。
- **事件** — 点击选中或拖动,双击空格子添加,Del 删除。
- **铅笔**、**橡皮**、**填充**;鼠标右键擦除,Alt+点击吸取图块,数字键 1–5 切换图层,B/E/G 切换画笔。
- **撤销**/**重做**(Ctrl+Z / Ctrl+Y)、网格、缩放(Ctrl+滚轮)。
![长者之家的碰撞层:墙壁被涂成红色的“墙”,地面开放。](assets/collision.jpg)
左侧标签页:**瓦片**(地图图块集的调色板,整栋房子或整棵树这样的完整对象可以一次盖印)、**角色**(带战斗属性的角色,放置为敌人)、**物品**(放置为带数量的可拾取物)和 **Appearance**(已加载模组的全部行走图,放置为 NPC)。
右栏是地图属性:名称键、图块集、尺寸、缩放、固定环境光与氛围、出生点和朝向,以及随机战斗(约每 N 步一次,敌人组形如 `slime; wolf,slime`)。
## 俯视与等距
编辑器绘制地图的方式与游戏一致。工具栏上的视图切换器只改变预览;地图数据保持不变。等距预览中的房子:
![编辑器等距预览中的长者之家:同一个房间以等距视角绘制,左侧是图块集调色板。](assets/editor-isometric.jpg)
游戏本身采用哪种视图由集合设置 `tile.renderer`(`topdown` 或 `isometric`)决定;加上 `--editor-renderer=isometric` 会直接以等距预览打开编辑器。
## 会写入哪些文件
- 地图本身保存到它自己的 `maps/<id>.json`。
- 事件写入同一模组中该地图自己的事件文件(`map_events/<地图>/…`)。其他文件或其他模组加进这张地图的事件会以虚线框半透明显示,永远不会被重写;它们的 id 不能重复使用。
- 文件列表旁的 **+** 在所选模组里新建地图;保存之后,游戏立刻把它当作场景。
- 只有以文件夹形式安装的模组才能写入。
## 如果遇到问题
1. **试玩提示游戏不认识该场景。** 地图需要在它的模组里有 `scenes/<id>.json`。
2. **事件选不中。** 它属于另一个文件或模组(虚线框)。
3. **NPC 不说话。** 它的事件没有剧本,或者触发方式不是动作键。
4. **保存改动了你没碰过的行。** 编辑器按自己的顺序写文件,还会补上事件的默认值,所以 diff 显示的内容可能多于这次编辑。
+2
View File
@@ -0,0 +1,2 @@
mod_rpg_editor_name = RPG Map Editor
mod_rpg_editor_desc = Paints the RPG engine's tile maps while the game runs: three tile layers, collision, events, spawn and random encounters. A save updates the world at once.
+2
View File
@@ -0,0 +1,2 @@
mod_rpg_editor_name = Editor de mapas RPG
mod_rpg_editor_desc = Pinta los mapas de tiles del motor RPG con el juego en marcha: tres capas de tiles, colisiones, eventos, punto de inicio y encuentros aleatorios. Un guardado actualiza el mundo al instante.
+2
View File
@@ -0,0 +1,2 @@
mod_rpg_editor_name = RPG マップエディター
mod_rpg_editor_desc = 実行中のゲームで RPG エンジンのタイルマップを描きます。タイルレイヤー 3 枚、衝突、イベント、開始地点、ランダムエンカウント。保存すると世界がすぐに更新されます。
+2
View File
@@ -0,0 +1,2 @@
mod_rpg_editor_name = RPG 맵 에디터
mod_rpg_editor_desc = 게임이 실행되는 동안 RPG 엔진의 타일 맵을 그립니다. 타일 레이어 3개, 충돌, 이벤트, 시작 지점, 랜덤 인카운터. 저장하면 세계가 곧바로 갱신됩니다.
+2
View File
@@ -0,0 +1,2 @@
mod_rpg_editor_name = Редактор RPG-карт
mod_rpg_editor_desc = Рисует тайловые карты движка RPG прямо во время игры: три слоя тайлов, коллизии, события, точка появления и случайные встречи. Сохранение мгновенно обновляет мир.
+51 -3
View File
@@ -1,7 +1,5 @@
mod_rpg_editor_name = RPG Map Editor
mod_rpg_editor_desc = Editor of tile maps for the RPG world: three tile layers, the collision layer and map events, with undo and a live preview in the running game.
tools/rpg_editor/title = RPG Map Editor tools/rpg_editor/title = RPG Map Editor
tools/rpg_editor/hint = LMB — paint · RMB — erase · Alt+LMB — pick tile · events: click — select/drag, double-click — add, Del — delete · 1–5 layers · B/E/G brush · Ctrl+wheel zoom · Ctrl+Z/Y · Ctrl+S tools/rpg_editor/hint = LMB — paint · RMB — erase · Alt+LMB — pick tile · events: click — select/drag, double-click — add, Del — delete · 1–5 layers · B/E/G brush · wheel/Shift+wheel, MMB drag — scroll · Ctrl+wheel zoom · Ctrl+Z/Y · Ctrl+S
tools/rpg_editor/layer_back = Back tools/rpg_editor/layer_back = Back
tools/rpg_editor/layer_main = Main tools/rpg_editor/layer_main = Main
tools/rpg_editor/layer_front = Front tools/rpg_editor/layer_front = Front
@@ -77,3 +75,53 @@ tools/rpg_editor/collision_auto = Auto — from the tiles
tools/rpg_editor/problem_tileset = Map "{0}" has no tileset. tools/rpg_editor/problem_tileset = Map "{0}" has no tileset.
tools/rpg_editor/problem_event_id = The event at {0} has no id. tools/rpg_editor/problem_event_id = The event at {0} has no id.
tools/rpg_editor/problem_event_duplicate = Two events share the id "{0}". tools/rpg_editor/problem_event_duplicate = Two events share the id "{0}".
tools/rpg_editor/tab_tiles = Tiles
tools/rpg_editor/tab_characters = Characters
tools/rpg_editor/tab_appearances = Appearance
tools/rpg_editor/appearance_search = Search appearances by name or path
tools/rpg_editor/appearances_hint = All appearances from enabled mods, including villagers without combat stats. Pick a sprite, press the button and choose a map cell.
tools/rpg_editor/place_appearance = Place NPC
tools/rpg_editor/place_appearance_tip = Create an NPC with this appearance; set its dialogue and behaviour in the event properties
tools/rpg_editor/character_search = Search by name or id
tools/rpg_editor/place_npc = Place as NPC
tools/rpg_editor/place_npc_tip = Put the character on the map as an event talking on E/F; write its lines in the scenario lines
tools/rpg_editor/place_enemy = Place as enemy
tools/rpg_editor/place_enemy_tip = Put the character on the map as an enemy: E/F starts a battle with it, it vanishes once beaten
tools/rpg_editor/characters_hint = Pick a character, press a button and click a cell on the map (RMB cancels). The charset is found by the character's Charset field or by its portrait path.
tools/rpg_editor/place_status = Click the cell to put “{0}” on (RMB cancels)
tools/rpg_editor/place_cancelled = Placing the character cancelled
tools/rpg_editor/placed_npc = “{0}” placed as an NPC: event {1}
tools/rpg_editor/placed_enemy = “{0}” placed as an enemy: event {1}
tools/rpg_editor/icon = Icon
tools/rpg_editor/icon_tip = Picture in the items group (like an item's Sprite) when there is neither a charset nor a tile
tools/rpg_editor/tab_items = Items
tools/rpg_editor/item_search = Search by name or id
tools/rpg_editor/items_hint = Pick an item, set the count, press "Place" and click a cell (RMB cancels). The player picks it up by stepping on the cell; then it is gone.
tools/rpg_editor/item_count = Count
tools/rpg_editor/item_count_tip = How many the player gets
tools/rpg_editor/place_item = Place
tools/rpg_editor/place_item_tip = Lay the item on the map: stepping on the cell picks it up
tools/rpg_editor/item_found_line = Found: {0} ×{1}
tools/rpg_editor/placed_item = “{0}” lies on the map: event {1}
tools/rpg_editor/picker_no_look = No map picture found, a marker is shown
tools/rpg_editor/problem_event_locked = Event id {0} is taken by an event of another file or mod — pick another one
tools/rpg_editor/section_light = Light
tools/rpg_editor/light_radius = Light radius
tools/rpg_editor/light_radius_tip = In cells; 0 means the event gives no light. Light shows at dusk and at night
tools/rpg_editor/light_color = Light colour
tools/rpg_editor/light_color_tip = #rrggbb or #rrggbbaa (alpha is the strength); empty keeps the warm default
tools/rpg_editor/ambient = Fixed ambient
tools/rpg_editor/ambient_tip = #rrggbb: the map is always lit like this (a room with a lamp); empty: by the time of day
tools/rpg_editor/autotile = autotile
tools/rpg_editor/atmosphere = Atmosphere
tools/rpg_editor/atmosphere_none = Still / indoors
tools/rpg_editor/atmosphere_woodland = Woodland: leaves, fireflies, ripples
tools/rpg_editor/section_route = Movement route
tools/rpg_editor/route_steps = Steps
tools/rpg_editor/route_steps_tip = up, down, left, right, wait, random, toward, away, face_up/down/left/right. Separate with commas. Empty = stand still.
tools/rpg_editor/route_every = Pause between steps (s)
tools/rpg_editor/route_repeat = Repeat route
tools/rpg_editor/view_topdown=Top-down
tools/rpg_editor/view_isometric=Isometric
+113
View File
@@ -0,0 +1,113 @@
tools/rpg_editor/title = Editor de mapas RPG
tools/rpg_editor/hint = Clic izq.: pintar · clic der.: borrar · Alt+clic izq.: tomar casilla · eventos: clic: seleccionar/arrastrar, doble clic: añadir, Supr: borrar · 1–5 capas · B/E/G pincel · rueda/Mayús+rueda, arrastrar con botón central: desplazar · Ctrl+rueda: zoom · Ctrl+Z/Y · Ctrl+S
tools/rpg_editor/layer_back = Fondo
tools/rpg_editor/layer_main = Principal
tools/rpg_editor/layer_front = Frente
tools/rpg_editor/layer_collision = Colisiones
tools/rpg_editor/layer_events = Eventos
tools/rpg_editor/tool_pencil = Lápiz
tools/rpg_editor/tool_eraser = Goma
tools/rpg_editor/tool_fill = Relleno
tools/rpg_editor/grid = Cuadrícula
tools/rpg_editor/zoom_in = Acercar
tools/rpg_editor/zoom_out = Alejar
tools/rpg_editor/undo = Deshacer
tools/rpg_editor/undo_tip = Deshacer el último cambio (Ctrl+Z)
tools/rpg_editor/redo = Rehacer
tools/rpg_editor/redo_tip = Rehacer (Ctrl+Y)
tools/rpg_editor/play = Jugar
tools/rpg_editor/play_tip = Guardar y recorrer el mapa desde su punto de inicio en el juego en marcha
tools/rpg_editor/play_no_scene = El juego no conoce la escena "{0}": revisa scenes/{0}.json en el mod del mapa.
tools/rpg_editor/scene_created = escena {0} creada
tools/rpg_editor/section_palette = Paleta
tools/rpg_editor/section_map = Mapa
tools/rpg_editor/section_spawn = Punto de inicio
tools/rpg_editor/section_encounters = Combates aleatorios
tools/rpg_editor/section_event = Evento
tools/rpg_editor/section_action = Qué hace
tools/rpg_editor/section_teleport = Teletransporte
tools/rpg_editor/map_id = Id
tools/rpg_editor/map_id_tip = El id del mapa es el nombre del archivo y el de la escena que muestra el mapa
tools/rpg_editor/map_name = Clave del nombre
tools/rpg_editor/map_name_tip = Clave de localización del nombre visible del mapa
tools/rpg_editor/tileset = Tileset
tools/rpg_editor/width = Ancho
tools/rpg_editor/height = Alto
tools/rpg_editor/resize = Aplicar tamaño
tools/rpg_editor/zoom = Zoom en el juego
tools/rpg_editor/zoom_tip = Píxeles de pantalla por píxel de casilla en el juego
tools/rpg_editor/x = X
tools/rpg_editor/y = Y
tools/rpg_editor/dir = Orientación
tools/rpg_editor/dir_down = Abajo
tools/rpg_editor/dir_left = Izquierda
tools/rpg_editor/dir_right = Derecha
tools/rpg_editor/dir_up = Arriba
tools/rpg_editor/encounter_steps = Cada ~N pasos
tools/rpg_editor/encounter_steps_tip = Número medio de pasos entre combates aleatorios; 0 los desactiva
tools/rpg_editor/encounter_groups = Grupos de enemigos
tools/rpg_editor/encounter_groups_tip = Ids de personajes, grupos separados por ";": por ejemplo slime; wolf,slime
tools/rpg_editor/no_event = Cambia a la capa Eventos y haz clic en un evento, o haz doble clic en una casilla vacía para añadir uno.
tools/rpg_editor/event_id = Id
tools/rpg_editor/sprite = Charset
tools/rpg_editor/sprite_tip = Ruta de la imagen en el grupo charsets: 3 fotogramas × 4 orientaciones
tools/rpg_editor/tile = Casilla
tools/rpg_editor/tile_tip = Índice del tileset que se dibuja cuando no hay charset; -1 para ninguno
tools/rpg_editor/trigger = Activación
tools/rpg_editor/trigger_action = Tecla de acción (E/F)
tools/rpg_editor/trigger_touch = Al pisarlo
tools/rpg_editor/solid = Sólido
tools/rpg_editor/solid_auto = Cuando se dibuja
tools/rpg_editor/solid_yes = Sí
tools/rpg_editor/solid_no = No
tools/rpg_editor/solid_tile = sólida
tools/rpg_editor/condition = Condición
tools/rpg_editor/condition_tip = Condición de escenario; el evento solo existe mientras se cumple
tools/rpg_editor/scenario = Escenario
tools/rpg_editor/scenario_tip = Ruta en el grupo scenarios, la extensión es opcional
tools/rpg_editor/commands = Líneas de escenario propias, se ejecutan después del escenario:
tools/rpg_editor/teleport_map = Mapa
tools/rpg_editor/teleport_map_tip = Id del mapa de destino; vacío con una casilla indicada significa este mapa
tools/rpg_editor/delete_event = Borrar evento
tools/rpg_editor/collision_block = Muro: siempre bloqueado
tools/rpg_editor/collision_open = Paso: siempre transitable
tools/rpg_editor/collision_auto = Auto: según las casillas
tools/rpg_editor/problem_tileset = El mapa "{0}" no tiene tileset.
tools/rpg_editor/problem_event_id = El evento en {0} no tiene id.
tools/rpg_editor/problem_event_duplicate = Dos eventos comparten el id "{0}".
tools/rpg_editor/tab_tiles = Casillas
tools/rpg_editor/tab_characters = Personajes
tools/rpg_editor/character_search = Buscar por nombre o id
tools/rpg_editor/place_npc = Colocar como PNJ
tools/rpg_editor/place_npc_tip = Pone al personaje en el mapa como un evento que habla con E/F; escribe sus frases en las líneas de escenario
tools/rpg_editor/place_enemy = Colocar como enemigo
tools/rpg_editor/place_enemy_tip = Pone al personaje en el mapa como enemigo: E/F inicia un combate con él y desaparece al ser vencido
tools/rpg_editor/characters_hint = Elige un personaje, pulsa un botón y haz clic en una casilla del mapa (clic derecho cancela). El charset se busca por el campo Charset del personaje o por la ruta de su retrato.
tools/rpg_editor/place_status = Haz clic en la casilla donde poner «{0}» (clic derecho cancela)
tools/rpg_editor/place_cancelled = Colocación del personaje cancelada
tools/rpg_editor/placed_npc = «{0}» colocado como PNJ: evento {1}
tools/rpg_editor/placed_enemy = «{0}» colocado como enemigo: evento {1}
tools/rpg_editor/icon = Icono
tools/rpg_editor/icon_tip = Imagen del grupo items (como el Sprite de un objeto) cuando no hay ni charset ni casilla
tools/rpg_editor/tab_items = Objetos
tools/rpg_editor/item_search = Buscar por nombre o id
tools/rpg_editor/items_hint = Elige un objeto, indica la cantidad, pulsa «Colocar» y haz clic en una casilla (clic derecho cancela). El jugador lo recoge al pisar la casilla; después desaparece.
tools/rpg_editor/item_count = Cantidad
tools/rpg_editor/item_count_tip = Cuántos recibe el jugador
tools/rpg_editor/place_item = Colocar
tools/rpg_editor/place_item_tip = Deja el objeto en el mapa: pisar la casilla lo recoge
tools/rpg_editor/item_found_line = Encontrado: {0} ×{1}
tools/rpg_editor/placed_item = «{0}» está en el mapa: evento {1}
tools/rpg_editor/picker_no_look = No se encontró imagen para el mapa, se muestra un marcador
tools/rpg_editor/problem_event_locked = El id de evento {0} ya lo usa un evento de otro archivo o mod: elige otro
tools/rpg_editor/section_light = Luz
tools/rpg_editor/light_radius = Radio de luz
tools/rpg_editor/light_radius_tip = En casillas; 0: el evento no da luz. La luz se ve al anochecer y de noche
tools/rpg_editor/light_color = Color de la luz
tools/rpg_editor/light_color_tip = #rrggbb o #rrggbbaa (alfa = intensidad); vacío: el cálido por defecto
tools/rpg_editor/ambient = Luz ambiental fija
tools/rpg_editor/ambient_tip = #rrggbb: el mapa siempre tiene esta luz (una sala con lámpara); vacío: según la hora del día
tools/rpg_editor/autotile = autotile
tools/rpg_editor/view_topdown=Vista superior
tools/rpg_editor/view_isometric=Isometría
+113
View File
@@ -0,0 +1,113 @@
tools/rpg_editor/title = RPG マップエディター
tools/rpg_editor/hint = 左クリック——描く · 右クリック——消す · Alt+左クリック——タイルを拾う · イベント:クリック——選択/ドラッグ、ダブルクリック——追加、Del——削除 · 1–5 レイヤー · B/E/G ブラシ · ホイール/Shift+ホイール、中ボタンドラッグ——スクロール · Ctrl+ホイールでズーム · Ctrl+Z/Y · Ctrl+S
tools/rpg_editor/layer_back = 背景
tools/rpg_editor/layer_main = メイン
tools/rpg_editor/layer_front = 前景
tools/rpg_editor/layer_collision = 衝突
tools/rpg_editor/layer_events = イベント
tools/rpg_editor/tool_pencil = 鉛筆
tools/rpg_editor/tool_eraser = 消しゴム
tools/rpg_editor/tool_fill = 塗りつぶし
tools/rpg_editor/grid = グリッド
tools/rpg_editor/zoom_in = 拡大
tools/rpg_editor/zoom_out = 縮小
tools/rpg_editor/undo = 元に戻す
tools/rpg_editor/undo_tip = 最後の編集を元に戻す(Ctrl+Z)
tools/rpg_editor/redo = やり直す
tools/rpg_editor/redo_tip = やり直す(Ctrl+Y)
tools/rpg_editor/play = プレイ
tools/rpg_editor/play_tip = 保存して、実行中のゲームで開始地点からマップを歩く
tools/rpg_editor/play_no_scene = ゲームはシーン「{0}」を知りません:マップの MOD の scenes/{0}.json を確認してください。
tools/rpg_editor/scene_created = シーン {0} を作成しました
tools/rpg_editor/section_palette = パレット
tools/rpg_editor/section_map = マップ
tools/rpg_editor/section_spawn = 開始地点
tools/rpg_editor/section_encounters = ランダムバトル
tools/rpg_editor/section_event = イベント
tools/rpg_editor/section_action = 動作
tools/rpg_editor/section_teleport = テレポート
tools/rpg_editor/map_id = Id
tools/rpg_editor/map_id_tip = マップの id はファイル名であり、マップを表示するシーンの名前でもある
tools/rpg_editor/map_name = 名前のキー
tools/rpg_editor/map_name_tip = マップ表示名のローカライズキー
tools/rpg_editor/tileset = タイルセット
tools/rpg_editor/width = 幅
tools/rpg_editor/height = 高さ
tools/rpg_editor/resize = サイズを適用
tools/rpg_editor/zoom = ゲーム内の拡大率
tools/rpg_editor/zoom_tip = ゲームでタイル 1 ピクセルあたりの画面ピクセル数
tools/rpg_editor/x = X
tools/rpg_editor/y = Y
tools/rpg_editor/dir = 向き
tools/rpg_editor/dir_down = 下
tools/rpg_editor/dir_left = 左
tools/rpg_editor/dir_right = 右
tools/rpg_editor/dir_up = 上
tools/rpg_editor/encounter_steps = 約 N 歩ごと
tools/rpg_editor/encounter_steps_tip = ランダムバトルの間の平均歩数。0 で無効
tools/rpg_editor/encounter_groups = 敵のグループ
tools/rpg_editor/encounter_groups_tip = キャラクターの id。グループは「;」で区切る——例:slime; wolf,slime
tools/rpg_editor/no_event = イベントレイヤーに切り替えてイベントをクリックするか、空いたマスをダブルクリックして追加してください。
tools/rpg_editor/event_id = Id
tools/rpg_editor/sprite = キャラチップ
tools/rpg_editor/sprite_tip = charsets グループの画像パス:3 コマ × 4 方向
tools/rpg_editor/tile = タイル
tools/rpg_editor/tile_tip = キャラチップがないときに描くタイルセットの番号。-1 でなし
tools/rpg_editor/trigger = 起動
tools/rpg_editor/trigger_action = アクションキー(E/F)
tools/rpg_editor/trigger_touch = 踏んだとき
tools/rpg_editor/solid = 通れない
tools/rpg_editor/solid_auto = 描かれているとき
tools/rpg_editor/solid_yes = はい
tools/rpg_editor/solid_no = いいえ
tools/rpg_editor/solid_tile = 通れない
tools/rpg_editor/condition = 条件
tools/rpg_editor/condition_tip = シナリオの条件。真の間だけイベントが存在する
tools/rpg_editor/scenario = シナリオ
tools/rpg_editor/scenario_tip = scenarios グループ内のパス。拡張子は省略可
tools/rpg_editor/commands = シナリオの後に実行するインラインの行:
tools/rpg_editor/teleport_map = マップ
tools/rpg_editor/teleport_map_tip = 移動先マップの id。空でマスが指定されていればこのマップ
tools/rpg_editor/delete_event = イベントを削除
tools/rpg_editor/collision_block = 壁——常に通れない
tools/rpg_editor/collision_open = 通路——常に通れる
tools/rpg_editor/collision_auto = 自動——タイルに従う
tools/rpg_editor/problem_tileset = マップ「{0}」にタイルセットがありません。
tools/rpg_editor/problem_event_id = {0} にあるイベントに id がありません。
tools/rpg_editor/problem_event_duplicate = 2 つのイベントが同じ id「{0}」を使っています。
tools/rpg_editor/tab_tiles = タイル
tools/rpg_editor/tab_characters = キャラクター
tools/rpg_editor/character_search = 名前か id で検索
tools/rpg_editor/place_npc = NPC として配置
tools/rpg_editor/place_npc_tip = E/F で話すイベントとしてキャラクターをマップに置く。セリフはシナリオの行に書く
tools/rpg_editor/place_enemy = 敵として配置
tools/rpg_editor/place_enemy_tip = キャラクターを敵としてマップに置く:E/F でバトルが始まり、倒すと消える
tools/rpg_editor/characters_hint = キャラクターを選び、ボタンを押して、マップのマスをクリックします(右クリックでキャンセル)。キャラチップはキャラクターの Charset フィールドか肖像のパスで探します。
tools/rpg_editor/place_status = 「{0}」を置くマスをクリック(右クリックでキャンセル)
tools/rpg_editor/place_cancelled = キャラクターの配置をキャンセルしました
tools/rpg_editor/placed_npc = 「{0}」を NPC として配置:イベント {1}
tools/rpg_editor/placed_enemy = 「{0}」を敵として配置:イベント {1}
tools/rpg_editor/icon = アイコン
tools/rpg_editor/icon_tip = キャラチップもタイルもないときの items グループの画像(アイテムの Sprite と同じ)
tools/rpg_editor/tab_items = アイテム
tools/rpg_editor/item_search = 名前か id で検索
tools/rpg_editor/items_hint = アイテムを選び、数を決め、「配置」を押してマスをクリックします(右クリックでキャンセル)。プレイヤーはマスを踏むと拾い、その後は消えます。
tools/rpg_editor/item_count = 数
tools/rpg_editor/item_count_tip = プレイヤーが得る数
tools/rpg_editor/place_item = 配置
tools/rpg_editor/place_item_tip = アイテムをマップに置く:マスを踏むと拾える
tools/rpg_editor/item_found_line = 見つけた:{0} ×{1}
tools/rpg_editor/placed_item = 「{0}」をマップに置いた:イベント {1}
tools/rpg_editor/picker_no_look = マップ用の画像が見つからないため、マーカーを表示します
tools/rpg_editor/problem_event_locked = イベント id {0} は別のファイルか MOD のイベントが使っています——別の id にしてください
tools/rpg_editor/section_light = 光
tools/rpg_editor/light_radius = 光の半径
tools/rpg_editor/light_radius_tip = マス単位。0 ならイベントは光らない。光は夕方と夜に見える
tools/rpg_editor/light_color = 光の色
tools/rpg_editor/light_color_tip = #rrggbb か #rrggbbaa(アルファが強さ)。空なら既定の暖色
tools/rpg_editor/ambient = 固定の環境光
tools/rpg_editor/ambient_tip = #rrggbb:マップは常にこの明るさ(ランプのある部屋)。空なら時刻に従う
tools/rpg_editor/autotile = オートタイル
tools/rpg_editor/view_topdown=見下ろし
tools/rpg_editor/view_isometric=アイソメトリック
+113
View File
@@ -0,0 +1,113 @@
tools/rpg_editor/title = RPG 맵 에디터
tools/rpg_editor/hint = 좌클릭 — 그리기 · 우클릭 — 지우기 · Alt+좌클릭 — 타일 집기 · 이벤트: 클릭 — 선택/드래그, 더블클릭 — 추가, Del — 삭제 · 1–5 레이어 · B/E/G 브러시 · 휠/Shift+휠, 가운데 버튼 드래그 — 스크롤 · Ctrl+휠 확대/축소 · Ctrl+Z/Y · Ctrl+S
tools/rpg_editor/layer_back = 배경
tools/rpg_editor/layer_main = 메인
tools/rpg_editor/layer_front = 전경
tools/rpg_editor/layer_collision = 충돌
tools/rpg_editor/layer_events = 이벤트
tools/rpg_editor/tool_pencil = 연필
tools/rpg_editor/tool_eraser = 지우개
tools/rpg_editor/tool_fill = 채우기
tools/rpg_editor/grid = 격자
tools/rpg_editor/zoom_in = 확대
tools/rpg_editor/zoom_out = 축소
tools/rpg_editor/undo = 되돌리기
tools/rpg_editor/undo_tip = 마지막 편집 되돌리기 (Ctrl+Z)
tools/rpg_editor/redo = 다시 실행
tools/rpg_editor/redo_tip = 다시 실행 (Ctrl+Y)
tools/rpg_editor/play = 플레이
tools/rpg_editor/play_tip = 저장하고 실행 중인 게임에서 시작 지점부터 맵을 걸어 봅니다
tools/rpg_editor/play_no_scene = 게임이 장면 "{0}"을(를) 모릅니다: 맵이 있는 모드의 scenes/{0}.json을 확인하세요.
tools/rpg_editor/scene_created = 장면 {0} 생성됨
tools/rpg_editor/section_palette = 팔레트
tools/rpg_editor/section_map = 맵
tools/rpg_editor/section_spawn = 시작 지점
tools/rpg_editor/section_encounters = 랜덤 전투
tools/rpg_editor/section_event = 이벤트
tools/rpg_editor/section_action = 동작
tools/rpg_editor/section_teleport = 순간 이동
tools/rpg_editor/map_id = Id
tools/rpg_editor/map_id_tip = 맵 id는 파일 이름이자 맵을 보여 주는 장면의 이름
tools/rpg_editor/map_name = 이름 키
tools/rpg_editor/map_name_tip = 맵 표시 이름의 로컬라이즈 키
tools/rpg_editor/tileset = 타일셋
tools/rpg_editor/width = 너비
tools/rpg_editor/height = 높이
tools/rpg_editor/resize = 크기 적용
tools/rpg_editor/zoom = 게임 내 확대 배율
tools/rpg_editor/zoom_tip = 게임에서 타일 픽셀 하나당 화면 픽셀 수
tools/rpg_editor/x = X
tools/rpg_editor/y = Y
tools/rpg_editor/dir = 방향
tools/rpg_editor/dir_down = 아래
tools/rpg_editor/dir_left = 왼쪽
tools/rpg_editor/dir_right = 오른쪽
tools/rpg_editor/dir_up = 위
tools/rpg_editor/encounter_steps = 약 N걸음마다
tools/rpg_editor/encounter_steps_tip = 랜덤 전투 사이의 평균 걸음 수. 0이면 끕니다
tools/rpg_editor/encounter_groups = 적 그룹
tools/rpg_editor/encounter_groups_tip = 캐릭터 id, 그룹은 ";"로 구분 — 예: slime; wolf,slime
tools/rpg_editor/no_event = 이벤트 레이어로 바꾸고 이벤트를 클릭하거나, 빈 칸을 더블클릭해 추가하세요.
tools/rpg_editor/event_id = Id
tools/rpg_editor/sprite = 캐릭터 시트
tools/rpg_editor/sprite_tip = charsets 그룹의 그림 경로: 3프레임 × 4방향
tools/rpg_editor/tile = 타일
tools/rpg_editor/tile_tip = 캐릭터 시트가 없을 때 그리는 타일셋 번호. -1이면 없음
tools/rpg_editor/trigger = 발동
tools/rpg_editor/trigger_action = 액션 키 (E/F)
tools/rpg_editor/trigger_touch = 밟았을 때
tools/rpg_editor/solid = 통과 불가
tools/rpg_editor/solid_auto = 그려져 있을 때
tools/rpg_editor/solid_yes = 예
tools/rpg_editor/solid_no = 아니요
tools/rpg_editor/solid_tile = 통과 불가
tools/rpg_editor/condition = 조건
tools/rpg_editor/condition_tip = 시나리오 조건. 참인 동안에만 이벤트가 존재한다
tools/rpg_editor/scenario = 시나리오
tools/rpg_editor/scenario_tip = scenarios 그룹의 경로, 확장자는 생략 가능
tools/rpg_editor/commands = 시나리오 뒤에 실행할 인라인 줄:
tools/rpg_editor/teleport_map = 맵
tools/rpg_editor/teleport_map_tip = 대상 맵 id. 비어 있고 칸이 지정되면 이 맵
tools/rpg_editor/delete_event = 이벤트 삭제
tools/rpg_editor/collision_block = 벽 — 항상 막힘
tools/rpg_editor/collision_open = 통로 — 항상 지나갈 수 있음
tools/rpg_editor/collision_auto = 자동 — 타일에 따름
tools/rpg_editor/problem_tileset = 맵 "{0}"에 타일셋이 없습니다.
tools/rpg_editor/problem_event_id = {0}에 있는 이벤트에 id가 없습니다.
tools/rpg_editor/problem_event_duplicate = 두 이벤트가 같은 id "{0}"을(를) 씁니다.
tools/rpg_editor/tab_tiles = 타일
tools/rpg_editor/tab_characters = 캐릭터
tools/rpg_editor/character_search = 이름 또는 id로 검색
tools/rpg_editor/place_npc = NPC로 배치
tools/rpg_editor/place_npc_tip = 캐릭터를 E/F로 대화하는 이벤트로 맵에 놓습니다. 대사는 시나리오 줄에 쓰세요
tools/rpg_editor/place_enemy = 적으로 배치
tools/rpg_editor/place_enemy_tip = 캐릭터를 적으로 맵에 놓습니다: E/F로 전투가 시작되고, 쓰러뜨리면 사라집니다
tools/rpg_editor/characters_hint = 캐릭터를 고르고 버튼을 누른 뒤 맵의 칸을 클릭하세요(우클릭으로 취소). 캐릭터 시트는 캐릭터의 Charset 필드나 초상화 경로로 찾습니다.
tools/rpg_editor/place_status = "{0}"을(를) 놓을 칸을 클릭하세요 (우클릭으로 취소)
tools/rpg_editor/place_cancelled = 캐릭터 배치를 취소했습니다
tools/rpg_editor/placed_npc = "{0}"을(를) NPC로 배치: 이벤트 {1}
tools/rpg_editor/placed_enemy = "{0}"을(를) 적으로 배치: 이벤트 {1}
tools/rpg_editor/icon = 아이콘
tools/rpg_editor/icon_tip = 캐릭터 시트도 타일도 없을 때 쓰는 items 그룹의 그림(아이템의 Sprite와 같음)
tools/rpg_editor/tab_items = 아이템
tools/rpg_editor/item_search = 이름 또는 id로 검색
tools/rpg_editor/items_hint = 아이템을 고르고 개수를 정한 뒤 "배치"를 누르고 칸을 클릭하세요(우클릭으로 취소). 플레이어가 칸을 밟으면 줍고, 그다음엔 사라집니다.
tools/rpg_editor/item_count = 개수
tools/rpg_editor/item_count_tip = 플레이어가 받는 개수
tools/rpg_editor/place_item = 배치
tools/rpg_editor/place_item_tip = 아이템을 맵에 놓습니다: 칸을 밟으면 줍습니다
tools/rpg_editor/item_found_line = 발견: {0} ×{1}
tools/rpg_editor/placed_item = "{0}"이(가) 맵에 놓였습니다: 이벤트 {1}
tools/rpg_editor/picker_no_look = 맵용 그림을 찾지 못해 표식을 표시합니다
tools/rpg_editor/problem_event_locked = 이벤트 id {0}은(는) 다른 파일이나 모드의 이벤트가 쓰고 있습니다 — 다른 것을 고르세요
tools/rpg_editor/section_light = 빛
tools/rpg_editor/light_radius = 빛 반경
tools/rpg_editor/light_radius_tip = 칸 단위, 0이면 이벤트가 빛나지 않음. 빛은 해질녘과 밤에 보임
tools/rpg_editor/light_color = 빛 색
tools/rpg_editor/light_color_tip = #rrggbb 또는 #rrggbbaa(알파가 세기), 비우면 기본 따뜻한 색
tools/rpg_editor/ambient = 고정 환경광
tools/rpg_editor/ambient_tip = #rrggbb: 맵이 항상 이 빛으로 비침(램프가 있는 방), 비우면 시간대에 따름
tools/rpg_editor/autotile = 오토타일
tools/rpg_editor/view_topdown=탑다운
tools/rpg_editor/view_isometric=아이소메트릭
+52 -4
View File
@@ -1,7 +1,5 @@
mod_rpg_editor_name = Редактор RPG-карт
mod_rpg_editor_desc = Редактор тайловых карт RPG-мира: три слоя тайлов, слой коллизий и события на карте, с отменой и живым предпросмотром в запущенной игре.
tools/rpg_editor/title = Редактор RPG-карт tools/rpg_editor/title = Редактор RPG-карт
tools/rpg_editor/hint = ЛКМ — рисовать · ПКМ — стирать · Alt+ЛКМ — взять тайл · события: клик — выбрать/тащить, двойной клик — добавить, Del — удалить · 1–5 слои · B/E/G кисть · Ctrl+колесо масштаб · Ctrl+Z/Y · Ctrl+S tools/rpg_editor/hint = ЛКМ — рисовать · ПКМ — стирать · Alt+ЛКМ — взять тайл · события: клик — выбрать/тащить, двойной клик — добавить, Del — удалить · 1–5 слои · B/E/G кисть · колесо/Shift+колесо, СКМ — прокрутка · Ctrl+колесо масштаб · Ctrl+Z/Y · Ctrl+S
tools/rpg_editor/layer_back = Фон tools/rpg_editor/layer_back = Фон
tools/rpg_editor/layer_main = Основной tools/rpg_editor/layer_main = Основной
tools/rpg_editor/layer_front = Передний tools/rpg_editor/layer_front = Передний
@@ -54,7 +52,7 @@ tools/rpg_editor/event_id = Id
tools/rpg_editor/sprite = Чарсет tools/rpg_editor/sprite = Чарсет
tools/rpg_editor/sprite_tip = Путь к картинке в группе charsets: 3 кадра × 4 направления tools/rpg_editor/sprite_tip = Путь к картинке в группе charsets: 3 кадра × 4 направления
tools/rpg_editor/tile = Тайл tools/rpg_editor/tile = Тайл
tools/rpg_editor/tile_tip = Номер тайла, если чарсета нет; -1 — ничего tools/rpg_editor/tile_tip = Номер тайла, если чарсета нет; -1 — нет тайла
tools/rpg_editor/trigger = Запуск tools/rpg_editor/trigger = Запуск
tools/rpg_editor/trigger_action = Клавиша действия (E/F) tools/rpg_editor/trigger_action = Клавиша действия (E/F)
tools/rpg_editor/trigger_touch = Шаг на клетку tools/rpg_editor/trigger_touch = Шаг на клетку
@@ -77,3 +75,53 @@ tools/rpg_editor/collision_auto = Авто — по тайлам
tools/rpg_editor/problem_tileset = У карты «{0}» не выбран тайлсет. tools/rpg_editor/problem_tileset = У карты «{0}» не выбран тайлсет.
tools/rpg_editor/problem_event_id = У события в клетке {0} нет id. tools/rpg_editor/problem_event_id = У события в клетке {0} нет id.
tools/rpg_editor/problem_event_duplicate = Два события с одинаковым id «{0}». tools/rpg_editor/problem_event_duplicate = Два события с одинаковым id «{0}».
tools/rpg_editor/tab_tiles = Тайлы
tools/rpg_editor/tab_characters = Персонажи
tools/rpg_editor/tab_appearances = Внешность
tools/rpg_editor/appearance_search = Поиск внешности по имени или пути
tools/rpg_editor/appearances_hint = Все варианты внешности из подключённых модов, включая жителей без боевых характеристик. Выберите спрайт, нажмите кнопку и укажите клетку на карте.
tools/rpg_editor/place_appearance = Разместить NPC
tools/rpg_editor/place_appearance_tip = Создать NPC с выбранной внешностью; диалог и поведение задаются в свойствах события
tools/rpg_editor/character_search = Поиск по имени или id
tools/rpg_editor/place_npc = На карту: NPC
tools/rpg_editor/place_npc_tip = Поставить персонажа как событие с разговором на E/F; реплики пишутся в «Строках сценария»
tools/rpg_editor/place_enemy = На карту: враг
tools/rpg_editor/place_enemy_tip = Поставить персонажа как врага: E/F начинает с ним бой, после победы он исчезает
tools/rpg_editor/characters_hint = Выберите персонажа, нажмите кнопку и кликните клетку на карте (ПКМ — отмена). Чарсет ищется по полю Charset персонажа или по пути его портрета.
tools/rpg_editor/place_status = Кликните клетку, куда поставить «{0}» (ПКМ — отмена)
tools/rpg_editor/place_cancelled = Постановка персонажа отменена
tools/rpg_editor/placed_npc = «{0}» поставлен как NPC: событие {1}
tools/rpg_editor/placed_enemy = «{0}» поставлен как враг: событие {1}
tools/rpg_editor/icon = Иконка
tools/rpg_editor/icon_tip = Картинка из группы items (как Sprite предмета), если нет ни чарсета, ни тайла
tools/rpg_editor/tab_items = Предметы
tools/rpg_editor/item_search = Поиск по названию или id
tools/rpg_editor/items_hint = Выберите предмет, задайте количество, нажмите «На карту» и кликните клетку (ПКМ — отмена). Игрок подбирает предмет, наступив на клетку; потом предмет исчезает.
tools/rpg_editor/item_count = Количество
tools/rpg_editor/item_count_tip = Сколько штук получит игрок
tools/rpg_editor/place_item = На карту
tools/rpg_editor/place_item_tip = Положить предмет на карту: шаг на клетку подбирает его
tools/rpg_editor/item_found_line = Найдено: {0} ×{1}
tools/rpg_editor/placed_item = «{0}» лежит на карте: событие {1}
tools/rpg_editor/picker_no_look = Картинка для карты не найдена, будет маркер
tools/rpg_editor/problem_event_locked = Id события {0} уже занят событием из другого файла или мода — выберите другой
tools/rpg_editor/section_light = Свет
tools/rpg_editor/light_radius = Радиус света
tools/rpg_editor/light_radius_tip = В клетках; 0 — событие не светит. Свет виден в сумерках и ночью
tools/rpg_editor/light_color = Цвет света
tools/rpg_editor/light_color_tip = #rrggbb или #rrggbbaa (альфа — сила); пусто — тёплый по умолчанию
tools/rpg_editor/ambient = Своё освещение
tools/rpg_editor/ambient_tip = #rrggbb: карта всегда освещена так (комната с лампой); пусто — по времени суток
tools/rpg_editor/autotile = автотайл
tools/rpg_editor/atmosphere = Атмосфера
tools/rpg_editor/atmosphere_none = Без движения / помещение
tools/rpg_editor/atmosphere_woodland = Лес: листья, светляки, рябь
tools/rpg_editor/section_route = Маршрут движения
tools/rpg_editor/route_steps = Шаги
tools/rpg_editor/route_steps_tip = up, down, left, right, wait, random, toward, away, face_up/down/left/right. Через запятую. Пусто — стоять на месте.
tools/rpg_editor/route_every = Пауза между шагами (с)
tools/rpg_editor/route_repeat = Повторять маршрут
tools/rpg_editor/view_topdown=Вид сверху
tools/rpg_editor/view_isometric=Изометрия
+113
View File
@@ -0,0 +1,113 @@
tools/rpg_editor/title = Trình sửa bản đồ RPG
tools/rpg_editor/hint = Chuột trái — vẽ · chuột phải — xóa · Alt+chuột trái — lấy ô · sự kiện: nhấp — chọn/kéo, nhấp đúp — thêm, Del — xóa · 1–5 lớp · B/E/G bút · cuộn/Shift+cuộn, kéo chuột giữa — di chuyển · Ctrl+cuộn thu phóng · Ctrl+Z/Y · Ctrl+S
tools/rpg_editor/layer_back = Nền
tools/rpg_editor/layer_main = Chính
tools/rpg_editor/layer_front = Phía trước
tools/rpg_editor/layer_collision = Va chạm
tools/rpg_editor/layer_events = Sự kiện
tools/rpg_editor/tool_pencil = Bút chì
tools/rpg_editor/tool_eraser = Tẩy
tools/rpg_editor/tool_fill = Tô
tools/rpg_editor/grid = Lưới
tools/rpg_editor/zoom_in = Phóng to
tools/rpg_editor/zoom_out = Thu nhỏ
tools/rpg_editor/undo = Hoàn tác
tools/rpg_editor/undo_tip = Hoàn tác thay đổi cuối (Ctrl+Z)
tools/rpg_editor/redo = Làm lại
tools/rpg_editor/redo_tip = Làm lại (Ctrl+Y)
tools/rpg_editor/play = Chơi
tools/rpg_editor/play_tip = Lưu và đi trên bản đồ từ điểm xuất phát trong game đang chạy
tools/rpg_editor/play_no_scene = Game không biết cảnh "{0}": hãy kiểm tra scenes/{0}.json trong mod của bản đồ.
tools/rpg_editor/scene_created = đã tạo cảnh {0}
tools/rpg_editor/section_palette = Bảng màu
tools/rpg_editor/section_map = Bản đồ
tools/rpg_editor/section_spawn = Điểm xuất phát
tools/rpg_editor/section_encounters = Trận đấu ngẫu nhiên
tools/rpg_editor/section_event = Sự kiện
tools/rpg_editor/section_action = Tác dụng
tools/rpg_editor/section_teleport = Dịch chuyển
tools/rpg_editor/map_id = Id
tools/rpg_editor/map_id_tip = Id của bản đồ là tên tệp và cũng là tên cảnh hiển thị bản đồ
tools/rpg_editor/map_name = Khóa tên
tools/rpg_editor/map_name_tip = Khóa bản địa hóa của tên hiển thị bản đồ
tools/rpg_editor/tileset = Tileset
tools/rpg_editor/width = Chiều rộng
tools/rpg_editor/height = Chiều cao
tools/rpg_editor/resize = Áp dụng kích thước
tools/rpg_editor/zoom = Thu phóng trong game
tools/rpg_editor/zoom_tip = Số điểm ảnh màn hình cho mỗi điểm ảnh ô trong game
tools/rpg_editor/x = X
tools/rpg_editor/y = Y
tools/rpg_editor/dir = Hướng nhìn
tools/rpg_editor/dir_down = Xuống
tools/rpg_editor/dir_left = Trái
tools/rpg_editor/dir_right = Phải
tools/rpg_editor/dir_up = Lên
tools/rpg_editor/encounter_steps = Khoảng mỗi N bước
tools/rpg_editor/encounter_steps_tip = Số bước trung bình giữa các trận đấu ngẫu nhiên; 0 để tắt
tools/rpg_editor/encounter_groups = Nhóm kẻ địch
tools/rpg_editor/encounter_groups_tip = Id nhân vật, các nhóm tách bằng ";" — ví dụ slime; wolf,slime
tools/rpg_editor/no_event = Chuyển sang lớp Sự kiện và nhấp vào một sự kiện, hoặc nhấp đúp vào ô trống để thêm.
tools/rpg_editor/event_id = Id
tools/rpg_editor/sprite = Charset
tools/rpg_editor/sprite_tip = Đường dẫn hình trong nhóm charsets: 3 khung × 4 hướng
tools/rpg_editor/tile = Ô
tools/rpg_editor/tile_tip = Số ô của tileset được vẽ khi không có charset; -1 là không có
tools/rpg_editor/trigger = Kích hoạt
tools/rpg_editor/trigger_action = Phím hành động (E/F)
tools/rpg_editor/trigger_touch = Khi bước lên
tools/rpg_editor/solid = Chặn đường
tools/rpg_editor/solid_auto = Khi được vẽ
tools/rpg_editor/solid_yes = Có
tools/rpg_editor/solid_no = Không
tools/rpg_editor/solid_tile = chặn đường
tools/rpg_editor/condition = Điều kiện
tools/rpg_editor/condition_tip = Điều kiện kịch bản; sự kiện chỉ tồn tại khi điều kiện đúng
tools/rpg_editor/scenario = Kịch bản
tools/rpg_editor/scenario_tip = Đường dẫn trong nhóm scenarios, có thể bỏ phần mở rộng
tools/rpg_editor/commands = Các dòng kịch bản riêng, chạy sau kịch bản:
tools/rpg_editor/teleport_map = Bản đồ
tools/rpg_editor/teleport_map_tip = Id bản đồ đích; để trống mà có ô thì là bản đồ này
tools/rpg_editor/delete_event = Xóa sự kiện
tools/rpg_editor/collision_block = Tường — luôn bị chặn
tools/rpg_editor/collision_open = Lối đi — luôn đi được
tools/rpg_editor/collision_auto = Tự động — theo các ô
tools/rpg_editor/problem_tileset = Bản đồ "{0}" không có tileset.
tools/rpg_editor/problem_event_id = Sự kiện ở {0} không có id.
tools/rpg_editor/problem_event_duplicate = Hai sự kiện dùng chung id "{0}".
tools/rpg_editor/tab_tiles = Ô
tools/rpg_editor/tab_characters = Nhân vật
tools/rpg_editor/character_search = Tìm theo tên hoặc id
tools/rpg_editor/place_npc = Đặt làm NPC
tools/rpg_editor/place_npc_tip = Đặt nhân vật lên bản đồ như một sự kiện nói chuyện bằng E/F; viết lời thoại trong các dòng kịch bản
tools/rpg_editor/place_enemy = Đặt làm kẻ địch
tools/rpg_editor/place_enemy_tip = Đặt nhân vật lên bản đồ làm kẻ địch: E/F bắt đầu trận đấu với nó, nó biến mất khi bị đánh bại
tools/rpg_editor/characters_hint = Chọn một nhân vật, nhấn nút rồi nhấp vào một ô trên bản đồ (chuột phải để hủy). Charset được tìm theo trường Charset của nhân vật hoặc theo đường dẫn chân dung.
tools/rpg_editor/place_status = Nhấp vào ô để đặt "{0}" (chuột phải để hủy)
tools/rpg_editor/place_cancelled = Đã hủy đặt nhân vật
tools/rpg_editor/placed_npc = "{0}" đã đặt làm NPC: sự kiện {1}
tools/rpg_editor/placed_enemy = "{0}" đã đặt làm kẻ địch: sự kiện {1}
tools/rpg_editor/icon = Biểu tượng
tools/rpg_editor/icon_tip = Hình trong nhóm items (như Sprite của vật phẩm) khi không có charset lẫn ô
tools/rpg_editor/tab_items = Vật phẩm
tools/rpg_editor/item_search = Tìm theo tên hoặc id
tools/rpg_editor/items_hint = Chọn một vật phẩm, đặt số lượng, nhấn "Đặt" và nhấp vào một ô (chuột phải để hủy). Người chơi nhặt nó khi bước lên ô; sau đó nó biến mất.
tools/rpg_editor/item_count = Số lượng
tools/rpg_editor/item_count_tip = Người chơi nhận được bao nhiêu
tools/rpg_editor/place_item = Đặt
tools/rpg_editor/place_item_tip = Đặt vật phẩm lên bản đồ: bước lên ô sẽ nhặt được
tools/rpg_editor/item_found_line = Tìm thấy: {0} ×{1}
tools/rpg_editor/placed_item = "{0}" nằm trên bản đồ: sự kiện {1}
tools/rpg_editor/picker_no_look = Không tìm thấy hình cho bản đồ, hiển thị một điểm đánh dấu
tools/rpg_editor/problem_event_locked = Id sự kiện {0} đã được một sự kiện ở tệp hoặc mod khác dùng — hãy chọn id khác
tools/rpg_editor/section_light = Ánh sáng
tools/rpg_editor/light_radius = Bán kính sáng
tools/rpg_editor/light_radius_tip = Tính theo ô; 0 là sự kiện không phát sáng. Ánh sáng hiện lúc chạng vạng và ban đêm
tools/rpg_editor/light_color = Màu ánh sáng
tools/rpg_editor/light_color_tip = #rrggbb hoặc #rrggbbaa (alpha là cường độ); để trống dùng màu ấm mặc định
tools/rpg_editor/ambient = Ánh sáng nền cố định
tools/rpg_editor/ambient_tip = #rrggbb: bản đồ luôn sáng như vậy (phòng có đèn); để trống thì theo giờ trong ngày
tools/rpg_editor/autotile = ô tự động
tools/rpg_editor/view_topdown=Nhìn từ trên
tools/rpg_editor/view_isometric=Đẳng cự
+113
View File
@@ -0,0 +1,113 @@
tools/rpg_editor/title = RPG 地图编辑器
tools/rpg_editor/hint = 左键——绘制 · 右键——擦除 · Alt+左键——吸取瓦片 · 事件:点击——选择/拖动,双击——添加,Del——删除 · 1–5 切换图层 · B/E/G 画笔 · 滚轮/Shift+滚轮、中键拖动——滚动 · Ctrl+滚轮缩放 · Ctrl+Z/Y · Ctrl+S
tools/rpg_editor/layer_back = 背景层
tools/rpg_editor/layer_main = 主层
tools/rpg_editor/layer_front = 前景层
tools/rpg_editor/layer_collision = 碰撞
tools/rpg_editor/layer_events = 事件
tools/rpg_editor/tool_pencil = 铅笔
tools/rpg_editor/tool_eraser = 橡皮
tools/rpg_editor/tool_fill = 填充
tools/rpg_editor/grid = 网格
tools/rpg_editor/zoom_in = 放大
tools/rpg_editor/zoom_out = 缩小
tools/rpg_editor/undo = 撤销
tools/rpg_editor/undo_tip = 撤销上一次编辑(Ctrl+Z)
tools/rpg_editor/redo = 重做
tools/rpg_editor/redo_tip = 重做(Ctrl+Y)
tools/rpg_editor/play = 试玩
tools/rpg_editor/play_tip = 保存,并在运行中的游戏里从出生点开始走这张地图
tools/rpg_editor/play_no_scene = 游戏不认识场景"{0}":请检查地图所在模组中的 scenes/{0}.json。
tools/rpg_editor/scene_created = 已创建场景 {0}
tools/rpg_editor/section_palette = 调色板
tools/rpg_editor/section_map = 地图
tools/rpg_editor/section_spawn = 出生点
tools/rpg_editor/section_encounters = 随机战斗
tools/rpg_editor/section_event = 事件
tools/rpg_editor/section_action = 效果
tools/rpg_editor/section_teleport = 传送
tools/rpg_editor/map_id = Id
tools/rpg_editor/map_id_tip = 地图 id 就是文件名,也是显示该地图的场景名
tools/rpg_editor/map_name = 名称键
tools/rpg_editor/map_name_tip = 地图显示名称的本地化键
tools/rpg_editor/tileset = 瓦片集
tools/rpg_editor/width = 宽度
tools/rpg_editor/height = 高度
tools/rpg_editor/resize = 应用尺寸
tools/rpg_editor/zoom = 游戏内缩放
tools/rpg_editor/zoom_tip = 游戏中每个瓦片像素对应的屏幕像素
tools/rpg_editor/x = X
tools/rpg_editor/y = Y
tools/rpg_editor/dir = 朝向
tools/rpg_editor/dir_down = 下
tools/rpg_editor/dir_left = 左
tools/rpg_editor/dir_right = 右
tools/rpg_editor/dir_up = 上
tools/rpg_editor/encounter_steps = 约每 N 步
tools/rpg_editor/encounter_steps_tip = 两次随机战斗之间的平均步数;0 表示关闭
tools/rpg_editor/encounter_groups = 敌人组
tools/rpg_editor/encounter_groups_tip = 角色 id,用";"分隔各组——例如 slime; wolf,slime
tools/rpg_editor/no_event = 切换到事件层并点击一个事件,或双击空格子来添加一个。
tools/rpg_editor/event_id = Id
tools/rpg_editor/sprite = 行走图
tools/rpg_editor/sprite_tip = charsets 组中的图片路径:3 帧 × 4 个朝向
tools/rpg_editor/tile = 瓦片
tools/rpg_editor/tile_tip = 没有行走图时绘制的瓦片集编号;-1 表示无
tools/rpg_editor/trigger = 触发
tools/rpg_editor/trigger_action = 动作键(E/F)
tools/rpg_editor/trigger_touch = 踩上去
tools/rpg_editor/solid = 实体
tools/rpg_editor/solid_auto = 绘制时
tools/rpg_editor/solid_yes = 是
tools/rpg_editor/solid_no = 否
tools/rpg_editor/solid_tile = 实体
tools/rpg_editor/condition = 条件
tools/rpg_editor/condition_tip = 剧本条件;只有条件成立时事件才存在
tools/rpg_editor/scenario = 剧本
tools/rpg_editor/scenario_tip = scenarios 组中的路径,扩展名可省略
tools/rpg_editor/commands = 内联剧本行,在剧本之后执行:
tools/rpg_editor/teleport_map = 地图
tools/rpg_editor/teleport_map_tip = 目标地图 id;为空且设置了格子表示本地图
tools/rpg_editor/delete_event = 删除事件
tools/rpg_editor/collision_block = 墙——始终阻挡
tools/rpg_editor/collision_open = 通道——始终可走
tools/rpg_editor/collision_auto = 自动——取决于瓦片
tools/rpg_editor/problem_tileset = 地图"{0}"没有瓦片集。
tools/rpg_editor/problem_event_id = 位于 {0} 的事件没有 id。
tools/rpg_editor/problem_event_duplicate = 两个事件使用了相同的 id"{0}"。
tools/rpg_editor/tab_tiles = 瓦片
tools/rpg_editor/tab_characters = 角色
tools/rpg_editor/character_search = 按名称或 id 搜索
tools/rpg_editor/place_npc = 放置为 NPC
tools/rpg_editor/place_npc_tip = 把角色作为按 E/F 对话的事件放到地图上;它的台词写在剧本行中
tools/rpg_editor/place_enemy = 放置为敌人
tools/rpg_editor/place_enemy_tip = 把角色作为敌人放到地图上:按 E/F 与它战斗,被击败后消失
tools/rpg_editor/characters_hint = 选择一个角色,按下按钮,再点击地图上的格子(右键取消)。行走图按角色的 Charset 字段或其立绘路径查找。
tools/rpg_editor/place_status = 点击要放置"{0}"的格子(右键取消)
tools/rpg_editor/place_cancelled = 已取消放置角色
tools/rpg_editor/placed_npc = "{0}"已作为 NPC 放置:事件 {1}
tools/rpg_editor/placed_enemy = "{0}"已作为敌人放置:事件 {1}
tools/rpg_editor/icon = 图标
tools/rpg_editor/icon_tip = items 组中的图片(与物品的 Sprite 相同),在既没有行走图也没有瓦片时使用
tools/rpg_editor/tab_items = 物品
tools/rpg_editor/item_search = 按名称或 id 搜索
tools/rpg_editor/items_hint = 选择物品,设置数量,按"放置"并点击格子(右键取消)。玩家踩上格子即可拾取,之后它就消失了。
tools/rpg_editor/item_count = 数量
tools/rpg_editor/item_count_tip = 玩家获得的数量
tools/rpg_editor/place_item = 放置
tools/rpg_editor/place_item_tip = 把物品放到地图上:踩上格子即可拾取
tools/rpg_editor/item_found_line = 找到:{0} ×{1}
tools/rpg_editor/placed_item = "{0}"放在地图上:事件 {1}
tools/rpg_editor/picker_no_look = 没有找到地图用的图片,显示为标记
tools/rpg_editor/problem_event_locked = 事件 id {0} 已被其他文件或模组的事件占用——请换一个
tools/rpg_editor/section_light = 光照
tools/rpg_editor/light_radius = 光照半径
tools/rpg_editor/light_radius_tip = 以格为单位;0 表示事件不发光。光在黄昏和夜晚可见
tools/rpg_editor/light_color = 光的颜色
tools/rpg_editor/light_color_tip = #rrggbb 或 #rrggbbaa(alpha 为强度);留空为默认暖色
tools/rpg_editor/ambient = 固定环境光
tools/rpg_editor/ambient_tip = #rrggbb:地图始终以此光照(点灯的房间);留空则随时间变化
tools/rpg_editor/autotile = 自动图块
tools/rpg_editor/view_topdown=俯视
tools/rpg_editor/view_isometric=等距
+2
View File
@@ -0,0 +1,2 @@
mod_rpg_editor_name = Trình sửa bản đồ RPG
mod_rpg_editor_desc = Vẽ các bản đồ ô của nền RPG ngay khi trò chơi đang chạy: ba lớp ô, va chạm, sự kiện, điểm xuất hiện và chạm trán ngẫu nhiên. Lưu xong thế giới được cập nhật ngay lập tức.
+2
View File
@@ -0,0 +1,2 @@
mod_rpg_editor_name = RPG 地图编辑器
mod_rpg_editor_desc = 在游戏运行时绘制 RPG 引擎的图块地图:三个图块图层、碰撞、事件、出生点和随机遇敌。一次保存,世界立即更新。
+3 -1
View File
@@ -3,7 +3,7 @@
"name": "RPG Map Editor", "name": "RPG Map Editor",
"version": "1.0.0", "version": "1.0.0",
"author": "ct", "author": "ct",
"description": "Editor of the rpg mod's tile maps (maps/*.json): paints the back, main and front tile layers and the collision layer with a pencil, an eraser and a flood fill from the tileset palette, places and edits map events (look, trigger, scenario, inline commands, teleport, condition), sets the map size, spawn and random encounters. Undo/redo, live update of the running world after saving, play from the spawn. Opens from the main menu tools, the novel editor hub and the in-game toolbar.", "description": "Paints the RPG engine's tile maps while the game runs: three tile layers, collision, events, spawn and random encounters. A save updates the world at once.",
"category": "tools, editor", "category": "tools, editor",
"sources": [ "sources": [
"src/**/*.cs" "src/**/*.cs"
@@ -13,6 +13,8 @@
], ],
"depends": [ "depends": [
"rpg>=1", "rpg>=1",
"characters_base>=1",
"items_base>=1",
"editor_tools>=1", "editor_tools>=1",
"ui_base>=1" "ui_base>=1"
], ],
+41
View File
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Mods.Rpg;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>
/// All map charsets supplied by the loaded mods, including NPC artwork without
/// a character entity. Paths stay distinct even when two mods use the same file name.
/// These entries describe appearance only and must never be used as battle enemy ids.
/// </summary>
public static class RpgAppearanceCatalog
{
/// <summary>Reads charset images anew and builds standing-frame previews for the picker.</summary>
public static List<RpgPickerEntry> Load(RpgTextures textures)
{
var files = Files.Collect(RpgTextures.CharsetGroup);
return (files?.Keys.AsEnumerable() ?? Enumerable.Empty<string>())
.Where(IsImage)
.Select(file => Path.ChangeExtension(file, null))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Select(path => new RpgPickerEntry
{
Id = path,
Name = Path.GetFileName(path),
Look = path,
Icon = RpgCharacterCatalog.FrontFrame(textures.Charset(path)),
OwnsIcon = true
})
.ToList();
}
private static bool IsImage(string path)
=> ModConfig.Instance.GetTextureExtensionSet().Contains(Path.GetExtension(path));
}
}
+66
View File
@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Characters;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Mods.Rpg;
using Godot;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>
/// The characters the running game knows (<c>Kind: character</c> of every
/// loaded mod), sorted by display name, for placing them on a map.
/// <para/>
/// A character has no map look of its own, so its charset is found by name:
/// an explicit <c>"Charset"</c> field of the character (kept by the
/// character's extra properties; the character editor sets it) wins, otherwise the
/// portrait path in the <c>characters</c> group (<c>characters/village/slime</c> or
/// <c>village/slime</c> → <c>village/slime</c>), as village_pack names both. Only charsets that exist are used: the texture
/// loader logs an error for a missing one. The row picture is the middle frame
/// of the "down" row, how the character stands on the map.
/// </summary>
public static class RpgCharacterCatalog
{
private const string ImageGroup = "characters";
/// <summary>Reads the character list anew.</summary>
public static List<RpgPickerEntry> Load(RpgTextures textures)
{
var charsets = RpgItemCatalog.GroupFiles(RpgTextures.CharsetGroup);
return (Entities.Collect<CharacterInfo>()?.Values ?? Enumerable.Empty<CharacterInfo>())
.Where(info => !string.IsNullOrEmpty(info.ID))
.Select(info => Entry(info, CharsetOf(info, charsets), textures))
.OrderBy(entry => entry.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
private static RpgPickerEntry Entry(CharacterInfo info, string charset, RpgTextures textures) => new()
{
Id = info.ID,
Name = string.IsNullOrEmpty(info.Name) ? info.ID : TranslationServer.Translate(info.Name).ToString(),
Look = charset,
Icon = FrontFrame(charset == null ? null : textures.Charset(charset)),
OwnsIcon = true
};
private static string CharsetOf(CharacterInfo info, HashSet<string> charsets)
{
object explicitCharset = null;
info.ExtraProperties?.TryGetValue("Charset", out explicitCharset);
var image = info.Image ?? string.Empty;
if (image.StartsWith(ImageGroup + "/", StringComparison.OrdinalIgnoreCase))
image = image.Substring(ImageGroup.Length + 1);
var candidates = new[] { explicitCharset?.ToString(), image };
return candidates.FirstOrDefault(path => !string.IsNullOrEmpty(path) && charsets.Contains(path));
}
internal static Texture2D FrontFrame(Texture2D image)
{
if (image == null)
return null;
var frame = new Vector2(image.GetWidth() / 3f, image.GetHeight() / 4f);
return new AtlasTexture { Atlas = image, Region = new Rect2(new Vector2(frame.X, 0f), frame) };
}
}
}
+145
View File
@@ -0,0 +1,145 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>
/// A tab of the editor's left column listing entities of the game (the
/// characters, the items) with a picture, a search over name and id and
/// buttons that act on the selected row. The window decides what the
/// entries are (<see cref="Source"/>) and what the buttons do
/// (<see cref="AddAction"/>); placing on the map is then a click on a cell.
/// <para/>
/// The list is read again on every <see cref="Reload"/> (the window calls it
/// whenever the tab is opened), so entities added to the game's data while
/// the editor is open show up.
/// </summary>
public partial class RpgEntityPicker : VBoxContainer
{
private readonly List<RpgPickerEntry> shown = new();
private readonly List<Button> actions = new();
private readonly LineEdit search;
private readonly ItemList list;
private readonly HBoxContainer options;
private readonly HBoxContainer buttons;
private List<RpgPickerEntry> all = new();
/// <summary>
/// Builds the option and button rows with their hint on top — the list grows with the
/// entries (<c>AutoHeight</c>), so buttons below it would scroll out of sight — then the
/// search field and the list.
/// </summary>
public RpgEntityPicker(string searchKey, string hintKey)
{
options = new HBoxContainer();
AddChild(options);
buttons = new HBoxContainer();
AddChild(buttons);
AddChild(ToolUi.Hint(ToolUi.Tr(hintKey)));
search = ToolUi.Text(ToolUi.Tr(searchKey), OnSearchChanged);
AddChild(search);
list = new ItemList
{
AutoHeight = true,
FixedIconSize = new Vector2I(32, 48),
TextureFilter = TextureFilterEnum.Nearest,
SizeFlagsHorizontal = SizeFlags.ExpandFill
};
list.ItemSelected += OnItemSelected;
AddChild(list);
}
/// <summary>Reads the entries; called by <see cref="Reload"/>.</summary>
public Func<List<RpgPickerEntry>> Source { get; set; }
/// <summary>The selected entry, or null.</summary>
public RpgPickerEntry Selected
{
get
{
var picked = list.GetSelectedItems();
return picked.Length == 0 ? null : shown[picked[0]];
}
}
/// <summary>Adds a button acting on the selected entry; it is disabled while nothing is selected.</summary>
public void AddAction(string textKey, string tooltipKey, Action<RpgPickerEntry> action)
{
var button = ToolUi.Button(textKey, null, ButtonKind.Secondary, tooltipKey);
button.Pressed += () => Run(action);
button.Disabled = true;
actions.Add(button);
buttons.AddChild(button);
}
/// <summary>Adds a control (a count, a mode) to the row above the buttons.</summary>
public void AddOption(Control control) => options.AddChild(control);
/// <summary>Reads the entries again and reapplies the search.</summary>
public void Reload()
{
var selectedId = Selected?.Id;
ReleaseEntries();
all = Source?.Invoke() ?? new List<RpgPickerEntry>();
Filter(selectedId);
}
private void Filter(string selectedId)
{
var query = search.Text.Trim();
shown.Clear();
shown.AddRange(all.Where(entry => query.Length == 0
|| entry.Name.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|| entry.Id.Contains(query, StringComparison.OrdinalIgnoreCase)));
list.Clear();
foreach (var entry in shown)
{
var index = list.AddItem($"{entry.Name} · {entry.Id}", entry.Icon);
list.SetItemTooltip(index, entry.Look ?? ToolUi.Tr("tools/rpg_editor/picker_no_look"));
if (entry.Id == selectedId)
list.Select(index);
}
UpdateButtons();
}
/// <summary>Drop the list's native references before disposing entry-owned previews; source images remain cache-owned.</summary>
public override void _ExitTree()
{
ReleaseEntries();
Source = null;
}
private void ReleaseEntries()
{
list.Clear();
shown.Clear();
var owned = new HashSet<Texture2D>(all.Where(e => e.OwnsIcon && e.Icon != null).Select(e => e.Icon));
all.Clear();
foreach (var icon in owned)
icon.Dispose();
}
private void Run(Action<RpgPickerEntry> action)
{
if (Selected != null)
action(Selected);
}
private void UpdateButtons()
{
foreach (var button in actions)
button.Disabled = Selected == null;
}
private void OnSearchChanged(string text) => Filter(Selected?.Id);
private void OnItemSelected(long index) => UpdateButtons();
}
}
+40 -3
View File
@@ -3,14 +3,16 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using Cthangover.Mods.Rpg; using Cthangover.Mods.Rpg;
using Cthangover.Tools.Common; using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot; using Godot;
namespace Cthangover.Tools.RpgEditor namespace Cthangover.Tools.RpgEditor
{ {
/// <summary> /// <summary>
/// Properties of the selected map event: id, cell, look (charset path or /// Properties of the selected map event: id, cell, look (charset path, tileset index
/// tileset index), facing, trigger, solidity, what it runs (scenario path, /// or item picture), facing, trigger, solidity, what it runs (scenario path,
/// inline DSL lines, or a teleport) and the condition that shows it. /// inline DSL lines with highlighting, or a teleport), the light it gives off (radius 0 = none)
/// and the condition that shows it.
/// <para/> /// <para/>
/// A teleport is on when its map or cell is filled in; clearing the teleport /// A teleport is on when its map or cell is filled in; clearing the teleport
/// map and both coordinates turns it off again. Solidity has a third state, /// map and both coordinates turns it off again. Solidity has a third state,
@@ -29,6 +31,7 @@ namespace Cthangover.Tools.RpgEditor
private readonly SpinBox ySpin; private readonly SpinBox ySpin;
private readonly LineEdit spriteEdit; private readonly LineEdit spriteEdit;
private readonly SpinBox tileSpin; private readonly SpinBox tileSpin;
private readonly LineEdit iconEdit;
private readonly OptionButton dirPicker; private readonly OptionButton dirPicker;
private readonly OptionButton triggerPicker; private readonly OptionButton triggerPicker;
private readonly OptionButton solidPicker; private readonly OptionButton solidPicker;
@@ -39,6 +42,11 @@ namespace Cthangover.Tools.RpgEditor
private readonly SpinBox teleportX; private readonly SpinBox teleportX;
private readonly SpinBox teleportY; private readonly SpinBox teleportY;
private readonly OptionButton teleportDir; private readonly OptionButton teleportDir;
private readonly SpinBox lightRadius;
private readonly LineEdit lightColor;
private readonly LineEdit routeSteps;
private readonly SpinBox routeEvery;
private readonly CheckBox routeRepeat;
private RpgMapEvent mapEvent; private RpgMapEvent mapEvent;
private bool suppress; private bool suppress;
private bool editing; private bool editing;
@@ -66,6 +74,7 @@ namespace Cthangover.Tools.RpgEditor
ySpin = ToolUi.Field(body, "tools/rpg_editor/y", ToolUi.Number(0, 255, 1, OnNumberChanged)); ySpin = ToolUi.Field(body, "tools/rpg_editor/y", ToolUi.Number(0, 255, 1, OnNumberChanged));
spriteEdit = ToolUi.Field(body, "tools/rpg_editor/sprite", ToolUi.Text("village/elder", OnTextChanged), "tools/rpg_editor/sprite_tip"); spriteEdit = ToolUi.Field(body, "tools/rpg_editor/sprite", ToolUi.Text("village/elder", OnTextChanged), "tools/rpg_editor/sprite_tip");
tileSpin = ToolUi.Field(body, "tools/rpg_editor/tile", ToolUi.Number(-1, 4095, 1, OnNumberChanged), "tools/rpg_editor/tile_tip"); tileSpin = ToolUi.Field(body, "tools/rpg_editor/tile", ToolUi.Number(-1, 4095, 1, OnNumberChanged), "tools/rpg_editor/tile_tip");
iconEdit = ToolUi.Field(body, "tools/rpg_editor/icon", ToolUi.Text("rpg_demo/branch_bundle", OnTextChanged), "tools/rpg_editor/icon_tip");
dirPicker = ToolUi.Field(body, "tools/rpg_editor/dir", RpgEditorFields.Direction(OnDirectionPicked)); dirPicker = ToolUi.Field(body, "tools/rpg_editor/dir", RpgEditorFields.Direction(OnDirectionPicked));
triggerPicker = ToolUi.Field(body, "tools/rpg_editor/trigger", RpgEditorFields.Choice(TriggerKeys, OnChoicePicked)); triggerPicker = ToolUi.Field(body, "tools/rpg_editor/trigger", RpgEditorFields.Choice(TriggerKeys, OnChoicePicked));
solidPicker = ToolUi.Field(body, "tools/rpg_editor/solid", RpgEditorFields.Choice(SolidKeys, OnChoicePicked)); solidPicker = ToolUi.Field(body, "tools/rpg_editor/solid", RpgEditorFields.Choice(SolidKeys, OnChoicePicked));
@@ -76,10 +85,21 @@ namespace Cthangover.Tools.RpgEditor
body.AddChild(ToolUi.Hint(ToolUi.Tr("tools/rpg_editor/commands"))); body.AddChild(ToolUi.Hint(ToolUi.Tr("tools/rpg_editor/commands")));
commandsEdit = ToolUi.Code(); commandsEdit = ToolUi.Code();
commandsEdit.CustomMinimumSize = new Vector2(0, 90); commandsEdit.CustomMinimumSize = new Vector2(0, 90);
commandsEdit.SyntaxHighlighter = ScenarioSyntaxService.CreateHighlighter();
commandsEdit.SizeFlagsVertical = SizeFlags.Fill; commandsEdit.SizeFlagsVertical = SizeFlags.Fill;
commandsEdit.TextChanged += OnCommandsChanged; commandsEdit.TextChanged += OnCommandsChanged;
body.AddChild(commandsEdit); body.AddChild(commandsEdit);
body.AddChild(ToolUi.Section("tools/rpg_editor/section_route"));
routeSteps = ToolUi.Field(body, "tools/rpg_editor/route_steps", ToolUi.Text("left, wait, right", OnTextChanged), "tools/rpg_editor/route_steps_tip");
routeEvery = ToolUi.Field(body, "tools/rpg_editor/route_every", ToolUi.Number(0.1, 30, 0.1, OnNumberChanged));
routeRepeat = ToolUi.Field(body, "tools/rpg_editor/route_repeat", new CheckBox());
routeRepeat.Toggled += OnRouteRepeat;
body.AddChild(ToolUi.Section("tools/rpg_editor/section_light"));
lightRadius = ToolUi.Field(body, "tools/rpg_editor/light_radius", ToolUi.Number(0, 16, 0.5, OnNumberChanged), "tools/rpg_editor/light_radius_tip");
lightColor = ToolUi.Field(body, "tools/rpg_editor/light_color", ToolUi.Text("#ffd8a0", OnTextChanged), "tools/rpg_editor/light_color_tip");
body.AddChild(ToolUi.Section("tools/rpg_editor/section_teleport")); body.AddChild(ToolUi.Section("tools/rpg_editor/section_teleport"));
teleportMap = ToolUi.Field(body, "tools/rpg_editor/teleport_map", ToolUi.Text("", OnTextChanged), "tools/rpg_editor/teleport_map_tip"); teleportMap = ToolUi.Field(body, "tools/rpg_editor/teleport_map", ToolUi.Text("", OnTextChanged), "tools/rpg_editor/teleport_map_tip");
teleportX = ToolUi.Field(body, "tools/rpg_editor/x", ToolUi.Number(-1, 255, 1, OnNumberChanged)); teleportX = ToolUi.Field(body, "tools/rpg_editor/x", ToolUi.Number(-1, 255, 1, OnNumberChanged));
@@ -106,6 +126,7 @@ namespace Cthangover.Tools.RpgEditor
ySpin.Value = value.Y; ySpin.Value = value.Y;
spriteEdit.Text = value.Sprite ?? string.Empty; spriteEdit.Text = value.Sprite ?? string.Empty;
tileSpin.Value = value.Tile; tileSpin.Value = value.Tile;
iconEdit.Text = value.Icon ?? string.Empty;
dirPicker.Select((int)value.Dir); dirPicker.Select((int)value.Dir);
triggerPicker.Select((int)value.Trigger); triggerPicker.Select((int)value.Trigger);
solidPicker.Select(value.Solid == null ? 0 : value.Solid.Value ? 1 : 2); solidPicker.Select(value.Solid == null ? 0 : value.Solid.Value ? 1 : 2);
@@ -116,6 +137,11 @@ namespace Cthangover.Tools.RpgEditor
teleportX.Value = value.Teleport?.X ?? -1; teleportX.Value = value.Teleport?.X ?? -1;
teleportY.Value = value.Teleport?.Y ?? -1; teleportY.Value = value.Teleport?.Y ?? -1;
teleportDir.Select((int)(value.Teleport?.Dir ?? RpgDirection.Down)); teleportDir.Select((int)(value.Teleport?.Dir ?? RpgDirection.Down));
routeSteps.Text = string.Join(", ", value.Route?.Steps ?? new List<string>());
routeEvery.Value = value.Route?.Every ?? 0.9;
routeRepeat.ButtonPressed = value.Route?.Repeat ?? true;
lightRadius.Value = value.Light?.Radius ?? 0;
lightColor.Text = value.Light?.Color ?? string.Empty;
suppress = false; suppress = false;
} }
@@ -140,6 +166,8 @@ namespace Cthangover.Tools.RpgEditor
private void OnCommandsChanged() => Write(); private void OnCommandsChanged() => Write();
private void OnRouteRepeat(bool value) => Write();
private void OnDeletePressed() private void OnDeletePressed()
{ {
if (mapEvent != null) if (mapEvent != null)
@@ -161,6 +189,7 @@ namespace Cthangover.Tools.RpgEditor
mapEvent.Y = (int)ySpin.Value; mapEvent.Y = (int)ySpin.Value;
mapEvent.Sprite = NullIfEmpty(spriteEdit.Text); mapEvent.Sprite = NullIfEmpty(spriteEdit.Text);
mapEvent.Tile = (int)tileSpin.Value; mapEvent.Tile = (int)tileSpin.Value;
mapEvent.Icon = NullIfEmpty(iconEdit.Text);
mapEvent.Dir = (RpgDirection)dirPicker.Selected; mapEvent.Dir = (RpgDirection)dirPicker.Selected;
mapEvent.Trigger = (RpgEventTrigger)triggerPicker.Selected; mapEvent.Trigger = (RpgEventTrigger)triggerPicker.Selected;
mapEvent.Solid = solidPicker.Selected switch { 1 => true, 2 => false, _ => null }; mapEvent.Solid = solidPicker.Selected switch { 1 => true, 2 => false, _ => null };
@@ -179,6 +208,14 @@ namespace Cthangover.Tools.RpgEditor
Dir = (RpgDirection)teleportDir.Selected, Dir = (RpgDirection)teleportDir.Selected,
} }
: null; : null;
var steps = routeSteps.Text.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
mapEvent.Route = steps.Count == 0 ? null : new RpgMoveRoute { Steps = steps, Every = routeEvery.Value, Repeat = routeRepeat.ButtonPressed };
// A radius of 0 means no light; an empty colour keeps the warm default.
mapEvent.Light = lightRadius.Value > 0
? new RpgLight { Radius = (float)lightRadius.Value, Color = NullIfEmpty(lightColor.Text) ?? new RpgLight().Color }
: null;
Changed?.Invoke(); Changed?.Invoke();
} }
+53
View File
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Core.Items;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Mods.Rpg;
using Godot;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>
/// The items the running game knows (<c>Kind: item</c> of every loaded mod),
/// sorted by display name, for laying them on a map. The look is the item's
/// own <c>Sprite</c> in the <c>items</c> group, the picture the inventory
/// shows; an item whose picture is missing gets none (the texture loader
/// would log an error for it).
/// </summary>
public static class RpgItemCatalog
{
/// <summary>Reads the item list anew.</summary>
public static List<RpgPickerEntry> Load(RpgTextures textures)
{
var pictures = GroupFiles(RpgTextures.IconGroup);
return (Entities.Collect<ItemInfo>()?.Values ?? Enumerable.Empty<ItemInfo>())
.Where(info => !string.IsNullOrEmpty(info.ID))
.Select(info => Entry(info, pictures.Contains(info.Sprite ?? string.Empty) ? info.Sprite : null, textures))
.OrderBy(entry => entry.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
/// <summary>Paths of a file group without extensions, case-insensitive: what exists to be loaded.</summary>
public static HashSet<string> GroupFiles(string group)
{
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var files = Files.Collect(group);
if (files != null)
{
foreach (var key in files.Keys)
paths.Add(Path.ChangeExtension(key, null));
}
return paths;
}
private static RpgPickerEntry Entry(ItemInfo info, string picture, RpgTextures textures) => new()
{
Id = info.ID,
Name = string.IsNullOrEmpty(info.Name) ? info.ID : TranslationServer.Translate(info.Name).ToString(),
Look = picture,
Icon = picture == null ? null : textures.Icon(picture)
};
}
}
+138 -20
View File
@@ -10,12 +10,19 @@ namespace Cthangover.Tools.RpgEditor
{ {
/// <summary> /// <summary>
/// The map being edited, free of any UI: the <see cref="RpgMap"/> with its /// The map being edited, free of any UI: the <see cref="RpgMap"/> with its
/// cells parsed into an <see cref="RpgMapGrid"/>, the edit operations of the /// cells parsed into an <see cref="RpgMapGrid"/>, its events, the edit
/// window and an undo history. /// operations of the window and an undo history.
/// <para/>
/// Events come in two parts. <see cref="Events"/> are the editor's own file of
/// the map's mod, <see cref="EventsPath"/>; the editor changes and saves only
/// them, so a beginner never deals with event files. <see cref="LockedEvents"/>
/// are all the others the game shows on this map: events other mods add, and
/// hand-written files of the same mod (an author may split events over files).
/// They are drawn, never edited, and no new event may take their ids.
/// <para/> /// <para/>
/// Undo works on whole states: before an edit the window calls /// Undo works on whole states: before an edit the window calls
/// <see cref="BeginChange"/>, which stores the grid and the map's other data /// <see cref="BeginChange"/>, which stores the grid and the map's other data
/// (events, spawn, size…) as JSON. A paint stroke is one change, however many /// (own events, spawn, size…) as JSON. A paint stroke is one change, however many
/// cells it touches. The grid is the only source of the cells while editing; /// cells it touches. The grid is the only source of the cells while editing;
/// <see cref="ToJson"/> writes it back into the map rows. /// <see cref="ToJson"/> writes it back into the map rows.
/// </summary> /// </summary>
@@ -26,9 +33,15 @@ namespace Cthangover.Tools.RpgEditor
private readonly List<RpgMapSnapshot> undo = new(); private readonly List<RpgMapSnapshot> undo = new();
private readonly List<RpgMapSnapshot> redo = new(); private readonly List<RpgMapSnapshot> redo = new();
/// <summary>Map data (events, spawn, encounters…); its layer rows are stale until <see cref="ToJson"/>.</summary> /// <summary>Map data (spawn, encounters…); its layer rows are stale until <see cref="ToJson"/>.</summary>
public RpgMap Map { get; private set; } public RpgMap Map { get; private set; }
/// <summary>Events of the editor's own file (<see cref="EventsPath"/>), in drawing order.</summary>
public List<RpgMapEvent> Events { get; private set; } = new();
/// <summary>Events of the map from other files and mods: shown, not edited (<see cref="SetLockedEvents"/>).</summary>
public IReadOnlyList<RpgMapEvent> LockedEvents { get; private set; } = new List<RpgMapEvent>();
/// <summary>The cells.</summary> /// <summary>The cells.</summary>
public RpgMapGrid Grid { get; private set; } public RpgMapGrid Grid { get; private set; }
@@ -41,7 +54,6 @@ namespace Cthangover.Tools.RpgEditor
private RpgMapDocument(RpgMap map) private RpgMapDocument(RpgMap map)
{ {
Map = map; Map = map;
Map.Events ??= new List<RpgMapEvent>();
Map.Spawn ??= new RpgTeleport(); Map.Spawn ??= new RpgTeleport();
Grid = RpgMapGrid.Parse(map); Grid = RpgMapGrid.Parse(map);
} }
@@ -64,24 +76,41 @@ namespace Cthangover.Tools.RpgEditor
} }
/// <summary> /// <summary>
/// Reads a map file (<c>{"Kind":"rpg_map","Items":[…]}</c>, the first item). /// Reads a map file (<c>{"Kind":"rpg_map","Items":[…]}</c>, the first item),
/// and the editor's events file (<c>{"Events":[…]}</c>; null or empty for none).
/// Throws <see cref="JsonException"/> on broken JSON and /// Throws <see cref="JsonException"/> on broken JSON and
/// <see cref="InvalidOperationException"/> on a file without maps, so the /// <see cref="InvalidOperationException"/> on a file without maps, so the
/// window can show the reason instead of opening an empty map. /// window can show the reason instead of opening an empty map.
/// </summary> /// </summary>
public static RpgMapDocument Load(string json) public static RpgMapDocument Load(string json, string eventsJson = null)
{ {
var data = JsonSerializer.Deserialize<FileData<RpgMap>>(json, JsonOptionsProvider.ReadOptions); var data = JsonSerializer.Deserialize<FileData<RpgMap>>(json, JsonOptionsProvider.ReadOptions);
var map = data?.Items?.FirstOrDefault() ?? throw new InvalidOperationException("the file has no rpg_map item"); var map = data?.Items?.FirstOrDefault() ?? throw new InvalidOperationException("the file has no rpg_map item");
return new RpgMapDocument(map); var document = new RpgMapDocument(map);
if (!string.IsNullOrWhiteSpace(eventsJson))
document.Events = JsonSerializer.Deserialize<RpgMapEventFile>(eventsJson, JsonOptionsProvider.ReadOptions)?.Events ?? new List<RpgMapEvent>();
return document;
} }
/// <summary>The map file text: cells written into rows, nulls left out, indented.</summary> /// <summary>Sets the map's events from other files and mods (shown, not edited, not undone).</summary>
public void SetLockedEvents(IReadOnlyList<RpgMapEvent> events) => LockedEvents = events ?? new List<RpgMapEvent>();
/// <summary>Mod-relative path of the editor's events file of a map: <c>map_events/&lt;id&gt;/events.json</c>.</summary>
public static string EventsPath(string mapId) => $"{RpgMapEventResolver.Directory}/{mapId}/events.json";
/// <summary>The map file text: cells written into rows, nulls left out, indented, LF line breaks on every OS, a final one.</summary>
public string ToJson() public string ToJson()
{ {
Grid.WriteTo(Map); Grid.WriteTo(Map);
var data = new FileData<RpgMap> { Kind = "rpg_map", Items = new List<RpgMap> { Map } }; var data = new FileData<RpgMap> { Kind = "rpg_map", Items = new List<RpgMap> { Map } };
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions); return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions).Replace("\r\n", "\n") + "\n";
}
/// <summary>The events file text (<see cref="EventsPath"/>): the own events, nulls left out, indented.</summary>
public string EventsToJson()
{
var data = new RpgMapEventFile { Events = Events };
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions).Replace("\r\n", "\n") + "\n";
} }
/// <summary>Remembers the current state as one undo step and forgets the redo branch.</summary> /// <summary>Remembers the current state as one undo step and forgets the redo branch.</summary>
@@ -117,6 +146,22 @@ namespace Cthangover.Tools.RpgEditor
return true; return true;
} }
/// <summary>Places a rectangular selection, skipping padding and clipping at map edges; one history step is recorded by the caller.</summary>
public int Stamp(RpgEditLayer layer, int x, int y, RpgTileStamp stamp)
{
if (layer > RpgEditLayer.Front || stamp == null)
return 0;
var changed = 0;
for (var sy = 0; sy < stamp.Height; sy++)
for (var sx = 0; sx < stamp.Width; sx++)
{
var tile = stamp.TileAt(sx, sy);
if (tile >= 0 && Paint(layer, x + sx, y + sy, tile, RpgCollision.Auto))
changed++;
}
return changed;
}
/// <summary> /// <summary>
/// Flood-fills (4-neighbourhood) the area of cells equal to the start /// Flood-fills (4-neighbourhood) the area of cells equal to the start
/// cell with a tile or a collision code. Returns the number of cells changed. /// cell with a tile or a collision code. Returns the number of cells changed.
@@ -152,7 +197,7 @@ namespace Cthangover.Tools.RpgEditor
/// <summary>The events on a cell, top-most (last) first.</summary> /// <summary>The events on a cell, top-most (last) first.</summary>
public List<RpgMapEvent> EventsAt(int x, int y) public List<RpgMapEvent> EventsAt(int x, int y)
{ {
var found = Map.Events.Where(e => e.X == x && e.Y == y).ToList(); var found = Events.Where(e => e.X == x && e.Y == y).ToList();
found.Reverse(); found.Reverse();
return found; return found;
} }
@@ -161,21 +206,90 @@ namespace Cthangover.Tools.RpgEditor
public RpgMapEvent AddEvent(int x, int y) public RpgMapEvent AddEvent(int x, int y)
{ {
var number = 1; var number = 1;
while (Map.Events.Any(e => string.Equals(e.Id, "event_" + number, StringComparison.OrdinalIgnoreCase))) while (IdTaken("event_" + number))
number++; number++;
var mapEvent = new RpgMapEvent { Id = "event_" + number, X = x, Y = y }; var mapEvent = new RpgMapEvent { Id = "event_" + number, X = x, Y = y };
Map.Events.Add(mapEvent); Events.Add(mapEvent);
return mapEvent; return mapEvent;
} }
/// <summary>Removes an event; false when it is not on this map.</summary> /// <summary>
public bool RemoveEvent(RpgMapEvent mapEvent) => Map.Events.Remove(mapEvent); /// Puts a character of the game (<c>Kind: character</c>) on a cell as an
/// action event looking like <paramref name="charset"/> (null: the editor's
/// marker). The event id is the character id, numbered (<c>_2</c>, <c>_3</c>…)
/// when taken. An enemy starts <c>rpg.battle</c> with that character on E/F
/// and vanishes once beaten: its win flag <c>&lt;map&gt;_&lt;event&gt;_won</c> is
/// also its hiding condition, so every placed enemy is beaten separately.
/// A non-enemy gets no lines; the author writes its dialog.
/// </summary>
public RpgMapEvent AddCharacter(int x, int y, string characterId, string charset, bool enemy)
{
var id = FreeId(characterId);
var mapEvent = new RpgMapEvent { Id = id, X = x, Y = y, Sprite = charset };
if (enemy)
{
var flag = $"{Map.ID}_{id}_won";
mapEvent.Commands = new List<string> { $"action rpg.battle enemies={characterId} win_flag={flag}", "end" };
mapEvent.Condition = flag + " != 1";
}
Events.Add(mapEvent);
return mapEvent;
}
/// <summary>Changes the map size; events left outside are moved to the nearest edge cell.</summary> /// <summary>
/// Lays an item of the game (<c>Kind: item</c>) on a cell to be picked up by
/// stepping on it, the way the demo's forest pickups work: the event shows
/// <paramref name="icon"/> (the item's picture), is not solid, says
/// <paramref name="line"/>, adds <paramref name="count"/> of the item and sets
/// its own flag <c>&lt;map&gt;_&lt;event&gt;_taken</c>, which hides it for good.
/// The event id is the item id with every character a flag name cannot hold
/// turned into <c>_</c> (<c>rpg_demo/branches</c> → <c>rpg_demo_branches</c>),
/// numbered when taken.
/// </summary>
public RpgMapEvent AddItem(int x, int y, string itemId, string icon, int count, string line)
{
var id = FreeId(new string(itemId.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_').ToArray()));
var flag = $"{Map.ID}_{id}_taken";
var mapEvent = new RpgMapEvent
{
Id = id,
X = x,
Y = y,
Icon = icon,
Trigger = RpgEventTrigger.Touch,
Solid = false,
Commands = new List<string>
{
$"ptext \"{line.Replace("\"", "'")}\"",
$"action item.add id={itemId} count={Math.Max(1, count)}",
$"flag {flag} 1",
"end"
},
Condition = flag + " != 1"
};
Events.Add(mapEvent);
return mapEvent;
}
/// <summary><paramref name="wanted"/> when no event has it (ignoring case), else the first free <c>wanted_N</c> from 2.</summary>
private string FreeId(string wanted)
{
var id = wanted;
for (var number = 2; IdTaken(id); number++)
id = wanted + "_" + number;
return id;
}
private bool IdTaken(string id) => Events.Concat(LockedEvents).Any(e => string.Equals(e.Id, id, StringComparison.OrdinalIgnoreCase));
/// <summary>Removes an event; false when it is not on this map.</summary>
public bool RemoveEvent(RpgMapEvent mapEvent) => Events.Remove(mapEvent);
/// <summary>Changes the map size; own events left outside are moved to the nearest edge cell.</summary>
public void Resize(int width, int height) public void Resize(int width, int height)
{ {
Grid.Resize(width, height); Grid.Resize(width, height);
foreach (var mapEvent in Map.Events) foreach (var mapEvent in Events)
{ {
mapEvent.X = Math.Clamp(mapEvent.X, 0, Grid.Width - 1); mapEvent.X = Math.Clamp(mapEvent.X, 0, Grid.Width - 1);
mapEvent.Y = Math.Clamp(mapEvent.Y, 0, Grid.Height - 1); mapEvent.Y = Math.Clamp(mapEvent.Y, 0, Grid.Height - 1);
@@ -193,12 +307,15 @@ namespace Cthangover.Tools.RpgEditor
var problems = new List<(string, string)>(); var problems = new List<(string, string)>();
if (string.IsNullOrWhiteSpace(Map.Tileset)) if (string.IsNullOrWhiteSpace(Map.Tileset))
problems.Add(("tools/rpg_editor/problem_tileset", Map.ID)); problems.Add(("tools/rpg_editor/problem_tileset", Map.ID));
foreach (var mapEvent in Map.Events) foreach (var mapEvent in Events)
{ {
if (string.IsNullOrWhiteSpace(mapEvent.Id)) if (string.IsNullOrWhiteSpace(mapEvent.Id))
problems.Add(("tools/rpg_editor/problem_event_id", $"{mapEvent.X},{mapEvent.Y}")); problems.Add(("tools/rpg_editor/problem_event_id", $"{mapEvent.X},{mapEvent.Y}"));
var locked = LockedEvents.FirstOrDefault(e => string.Equals(e.Id, mapEvent.Id, StringComparison.OrdinalIgnoreCase));
if (locked != null)
problems.Add(("tools/rpg_editor/problem_event_locked", $"{mapEvent.Id} ({locked.ModId}/{locked.File})"));
} }
foreach (var group in Map.Events.Where(e => !string.IsNullOrWhiteSpace(e.Id)).GroupBy(e => e.Id, StringComparer.OrdinalIgnoreCase)) foreach (var group in Events.Where(e => !string.IsNullOrWhiteSpace(e.Id)).GroupBy(e => e.Id, StringComparer.OrdinalIgnoreCase))
{ {
if (group.Count() > 1) if (group.Count() > 1)
problems.Add(("tools/rpg_editor/problem_event_duplicate", group.Key)); problems.Add(("tools/rpg_editor/problem_event_duplicate", group.Key));
@@ -213,7 +330,7 @@ namespace Cthangover.Tools.RpgEditor
private RpgMapSnapshot Snapshot() private RpgMapSnapshot Snapshot()
{ {
return new RpgMapSnapshot(Grid.Clone(), JsonSerializer.Serialize(Map)); return new RpgMapSnapshot(Grid.Clone(), JsonSerializer.Serialize(Map), JsonSerializer.Serialize(Events));
} }
private bool Swap(List<RpgMapSnapshot> from, List<RpgMapSnapshot> to) private bool Swap(List<RpgMapSnapshot> from, List<RpgMapSnapshot> to)
@@ -225,6 +342,7 @@ namespace Cthangover.Tools.RpgEditor
from.RemoveAt(from.Count - 1); from.RemoveAt(from.Count - 1);
Grid = state.Grid.Clone(); Grid = state.Grid.Clone();
Map = JsonSerializer.Deserialize<RpgMap>(state.MapJson); Map = JsonSerializer.Deserialize<RpgMap>(state.MapJson);
Events = JsonSerializer.Deserialize<List<RpgMapEvent>>(state.EventsJson);
return true; return true;
} }
} }
+244 -110
View File
@@ -1,10 +1,12 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Rpg;
using Godot;
namespace Cthangover.Tools.RpgEditor namespace Cthangover.Tools.RpgEditor
{ {
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Rpg;
using Cthangover.Mods.TileRender;
using Cthangover.Core.Events;
using Godot;
/// <summary> /// <summary>
/// The map as the editor shows it: back, main and front tiles, the events /// The map as the editor shows it: back, main and front tiles, the events
/// (their charset frame or tile, else a marker), the spawn point, the /// (their charset frame or tile, else a marker), the spawn point, the
@@ -16,7 +18,12 @@ namespace Cthangover.Tools.RpgEditor
/// a drag paints a stroke, RMB erases, Alt+LMB picks the tile under the /// a drag paints a stroke, RMB erases, Alt+LMB picks the tile under the
/// pointer. On the events layer: LMB selects the top event of a cell and drags /// pointer. On the events layer: LMB selects the top event of a cell and drags
/// it, a double click on an empty cell adds an event, RMB clears the selection. /// it, a double click on an empty cell adds an event, RMB clears the selection.
/// Ctrl+wheel zooms. /// Locked events (other mods' and other files') are drawn faded with a dashed
/// frame and ignore the mouse.
/// While <see cref="Placing"/>, a click only chooses a cell for the window.
/// Ctrl+wheel zooms. The plain wheel (Shift+wheel horizontally) is left to
/// the surrounding ScrollContainer, and a middle-button drag pans it, so a
/// map larger than the view is reachable on any layer and with any tool.
/// <para/> /// <para/>
/// The canvas owns no data. It edits the window's /// The canvas owns no data. It edits the window's
/// <see cref="RpgMapDocument"/> and reports through events; every stroke or /// <see cref="RpgMapDocument"/> and reports through events; every stroke or
@@ -29,12 +36,23 @@ namespace Cthangover.Tools.RpgEditor
private static readonly Color OpenColor = new(0.2f, 0.85f, 0.35f, 0.4f); private static readonly Color OpenColor = new(0.2f, 0.85f, 0.35f, 0.4f);
private static readonly Color GridColor = new(1f, 1f, 1f, 0.12f); private static readonly Color GridColor = new(1f, 1f, 1f, 0.12f);
private static readonly Color MarkerColor = new(0.35f, 0.55f, 1f, 0.55f); private static readonly Color MarkerColor = new(0.35f, 0.55f, 1f, 0.55f);
private static readonly Color LockedModulate = new(1f, 1f, 1f, 0.55f);
private readonly RpgTextures textures = new(); private readonly RpgTextures textures = new();
private ITileWorldPresentation preview;
private RpgRenderSceneBuilder builder;
private TileRenderScene previewScene;
private ITileProjection projection;
private RpgMapEditorOverlay overlay;
public string RendererId { get; private set; } = "topdown";
public ITileWorldPresentation Preview => preview;
private Vector2I? hover; private Vector2I? hover;
private bool stroking; private bool stroking;
private RpgMapEvent dragged; private RpgMapEvent dragged;
private bool dragMoved; private bool dragMoved;
private bool panning;
private Vector2 panStartMouse;
private Vector2 panStartScroll;
/// <summary>The edited map; null shows an empty canvas.</summary> /// <summary>The edited map; null shows an empty canvas.</summary>
public RpgMapDocument Document { get; set; } public RpgMapDocument Document { get; set; }
@@ -54,6 +72,9 @@ namespace Cthangover.Tools.RpgEditor
/// <summary>Atlas index the pencil and the fill put down.</summary> /// <summary>Atlas index the pencil and the fill put down.</summary>
public int SelectedTile { get; set; } public int SelectedTile { get; set; }
/// <summary>Multi-cell brush selected in the object atlas; null uses SelectedTile.</summary>
public RpgTileStamp SelectedStamp { get; set; }
/// <summary>Collision code the pencil and the fill put down.</summary> /// <summary>Collision code the pencil and the fill put down.</summary>
public char SelectedCollision { get; set; } = RpgCollision.Block; public char SelectedCollision { get; set; } = RpgCollision.Block;
@@ -78,14 +99,28 @@ namespace Cthangover.Tools.RpgEditor
/// <summary>Alt+click picked a tile.</summary> /// <summary>Alt+click picked a tile.</summary>
public event Action<int> TilePicked; public event Action<int> TilePicked;
/// <summary>
/// The next LMB on a cell is not an edit but a choice reported through
/// <see cref="CellChosen"/> (placing a character); RMB cancels. Either ends it.
/// </summary>
public bool Placing { get; set; }
/// <summary>The cell chosen while <see cref="Placing"/>, or null when cancelled.</summary>
public event Action<Vector2I?> CellChosen;
/// <summary>Creates the canvas; input comes through the GuiInput event.</summary> /// <summary>Creates the canvas; input comes through the GuiInput event.</summary>
public RpgMapEditorCanvas() public RpgMapEditorCanvas()
{ {
RendererId = Cthangover.Core.Games.ActiveGame.GetSetting("tile.renderer") ?? (TileRendererRegistry.Instance.Ids.Count == 1 ? TileRendererRegistry.Instance.Ids[0] : "topdown");
foreach (var arg in OS.GetCmdlineUserArgs())
if (arg.StartsWith("--editor-renderer=")) RendererId = arg.Substring(18);
ClipContents = true; ClipContents = true;
FocusMode = FocusModeEnum.Click; FocusMode = FocusModeEnum.Click;
TextureFilter = TextureFilterEnum.Nearest; TextureFilter = TextureFilterEnum.Nearest;
GuiInput += OnGuiInput; GuiInput += OnGuiInput;
MouseExited += OnMouseExited; MouseExited += OnMouseExited;
overlay = new RpgMapEditorOverlay(this);
AddChild(overlay);
} }
private float CellSize => (Tileset?.TileSize ?? 32) * Zoom; private float CellSize => (Tileset?.TileSize ?? 32) * Zoom;
@@ -97,129 +132,168 @@ namespace Cthangover.Tools.RpgEditor
Refresh(); Refresh();
} }
/// <summary>Resizes to the map and redraws; call after any outside change.</summary> public void SetRenderer(string id)
{
TileRendererRegistry.Instance.Resolve(id);
if (RendererId != id) { ReleasePreview(); projection = null; }
RendererId = id;
Refresh();
}
public override void _EnterTree()
{
TileRendererRegistry.Instance.Removing += OnRendererRemoving;
ModEventBus.Subscribe<ModSetReloadedEvent>(OnModsReloaded);
}
public override void _ExitTree()
{
TileRendererRegistry.Instance.Removing -= OnRendererRemoving;
ModEventBus.Unsubscribe<ModSetReloadedEvent>(OnModsReloaded);
ReleasePreview(); projection = null; builder?.Dispose(); textures.Dispose();
}
private void OnRendererRemoving(string id)
{
if (RendererId != id) return;
ReleasePreview(); projection = null;
}
private void OnModsReloaded(ModSetReloadedEvent evt)
{
if (!evt.Applied || Document == null) return;
ReleasePreview(); builder?.Dispose(); textures.Dispose();
Tileset = RpgTilesetFactory.Instance.Get(Document.Map.Tileset);
Refresh();
}
private void ReleasePreview()
{
preview?.Dispose();
preview = null;
previewScene?.Release();
previewScene = null;
}
/// <summary>The same renderer as runtime, with a static frame and authoring overlays.</summary>
public void Refresh() public void Refresh()
{ {
var grid = Document?.Grid; if (Document == null || Tileset == null) { ReleasePreview(); CustomMinimumSize = Vector2.Zero; QueueRedraw(); return; }
CustomMinimumSize = grid == null ? Vector2.Zero : new Vector2(grid.Width, grid.Height) * CellSize; var factory = TileRendererRegistry.Instance.Resolve(RendererId);
projection = factory.Projection(Tileset.TileSize);
builder ??= new RpgRenderSceneBuilder(textures);
var previous = previewScene;
previewScene = builder.Build(Document.Map, Document.Grid, Tileset);
previewScene.Zoom = Zoom;
foreach (var visual in previewScene.Visuals)
visual.Tint = new Color(1, 1, 1, ActiveLayer <= RpgEditLayer.Front && visual.Layer != (int)ActiveLayer ? 0.45f : 1f);
if (preview == null)
{
preview = factory.Create();
AddChild(preview.Root);
MoveChild(preview.Root, 0);
preview.Mount(previewScene);
}
else preview.Apply(new TileSceneChanges { BaseRevision = previous.Revision, Scene = previewScene });
previous?.Release();
CustomMinimumSize = preview.ContentBounds.Size * Zoom;
preview.Resize(CustomMinimumSize);
var frame = new TileFrame { Ambient = Colors.White, Focus = new Vector2(Document.Grid.Width, Document.Grid.Height) / 2 };
foreach (var mapEvent in Document.LockedEvents) AddPreviewEvent(frame, builder, mapEvent, true);
foreach (var mapEvent in Document.Events) AddPreviewEvent(frame, builder, mapEvent, false);
preview.Present(frame, 0);
overlay.Position = preview.Root.Position;
overlay.Scale = preview.Root.Scale;
QueueRedraw(); QueueRedraw();
} }
/// <summary>Draws the map.</summary> private void AddPreviewEvent(TileFrame frame, RpgRenderSceneBuilder builder, RpgMapEvent mapEvent, bool locked)
public override void _Draw()
{ {
DrawRect(new Rect2(Vector2.Zero, Size), UiTokens.Surface0); frame.Actors.Add(new TileActorPose { Id = (locked ? "locked/" : "own/") + mapEvent.Id,
var grid = Document?.Grid; Position = new Vector2(mapEvent.X, mapEvent.Y), Visible = true, Look = builder.EventLook(mapEvent, Tileset),
if (grid == null) Facing = (int)mapEvent.Dir, Charset = !string.IsNullOrEmpty(mapEvent.Sprite), Tint = locked ? LockedModulate : Colors.White });
return; }
DrawRect(new Rect2(Vector2.Zero, new Vector2(grid.Width, grid.Height) * CellSize), Colors.Black); public new void QueueRedraw() { base.QueueRedraw(); overlay?.QueueRedraw(); }
DrawLayer(RpgLayer.Back);
DrawLayer(RpgLayer.Main); public override void _Draw() => DrawRect(new Rect2(Vector2.Zero, Size), UiTokens.Surface0);
DrawEvents();
DrawLayer(RpgLayer.Front); /// <summary>Mouse and automation coordinates use the real presentation transform, including negative iso X.</summary>
DrawSpawn(); public Vector2 ProjectCell(Vector2 cell) => preview?.ProjectToView(new Vector3(cell.X, cell.Y, 0)) ?? cell * CellSize;
if (ActiveLayer == RpgEditLayer.Collision)
DrawCollision(); public void DrawOverlays(CanvasItem target)
{
if (Document == null || projection == null) return;
var grid = Document.Grid;
var unit = projection.Unit;
if (ShowGrid) if (ShowGrid)
DrawGrid();
if (hover.HasValue && grid.InBounds(hover.Value.X, hover.Value.Y))
DrawRect(CellRect(hover.Value.X, hover.Value.Y), UiTokens.AccentBright, false, 2f);
}
private void DrawLayer(RpgLayer layer)
{
if (Atlas == null || Tileset == null)
return;
var dim = ActiveLayer <= RpgEditLayer.Front && (int)ActiveLayer != (int)layer;
var modulate = new Color(1f, 1f, 1f, dim ? 0.45f : 1f);
var grid = Document.Grid;
for (var y = 0; y < grid.Height; y++)
{ {
for (var x = 0; x < grid.Width; x++) for (var x = 0; x <= grid.Width; x++) target.DrawLine(P(x, 0), P(x, grid.Height), GridColor);
{ for (var y = 0; y <= grid.Height; y++) target.DrawLine(P(0, y), P(grid.Width, y), GridColor);
var index = grid.GetTile(layer, x, y);
if (index >= 0)
DrawTextureRectRegion(Atlas, CellRect(x, y), TileRegion(index), modulate);
}
} }
} if (ActiveLayer == RpgEditLayer.Collision)
for (var y = 0; y < grid.Height; y++)
private void DrawEvents()
{
foreach (var mapEvent in Document.Map.Events)
{
var rect = CellRect(mapEvent.X, mapEvent.Y);
var charset = string.IsNullOrEmpty(mapEvent.Sprite) ? null : textures.Charset(mapEvent.Sprite);
if (charset != null)
{
var frame = new Vector2(charset.GetWidth() / 3f, charset.GetHeight() / 4f);
var source = new Rect2(new Vector2(frame.X, frame.Y * (int)mapEvent.Dir), frame);
var size = frame * Zoom;
var target = new Rect2(rect.Position + new Vector2((rect.Size.X - size.X) / 2f, rect.Size.Y - size.Y), size);
DrawTextureRectRegion(charset, target, source);
}
else if (mapEvent.Tile >= 0 && Atlas != null && Tileset != null)
{
DrawTextureRectRegion(Atlas, rect, TileRegion(mapEvent.Tile));
}
else
{
DrawRect(rect.Grow(-CellSize * 0.15f), MarkerColor);
}
if (ActiveLayer == RpgEditLayer.Events)
DrawRect(rect, mapEvent == SelectedEvent ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.White, 0.5f), false, mapEvent == SelectedEvent ? 3f : 1f);
}
}
private void DrawSpawn()
{
var spawn = Document.Map.Spawn;
if (spawn == null)
return;
var rect = CellRect(spawn.X, spawn.Y);
DrawCircle(rect.GetCenter(), CellSize * 0.18f, UiTokens.Ok);
DrawArc(rect.GetCenter(), CellSize * 0.18f, 0f, Mathf.Tau, 24, Colors.Black, 1.5f);
}
private void DrawCollision()
{
var grid = Document.Grid;
for (var y = 0; y < grid.Height; y++)
{
for (var x = 0; x < grid.Width; x++) for (var x = 0; x < grid.Width; x++)
{ {
var code = grid.GetCollision(x, y); var code = grid.GetCollision(x, y);
if (code == RpgCollision.Block) if (code == RpgCollision.Block || code == RpgCollision.Open || !RpgPassability.IsWalkable(grid, Tileset, x, y))
DrawRect(CellRect(x, y), BlockColor); target.DrawColoredPolygon(Polygon(x, y, 1, 1), code == RpgCollision.Open ? OpenColor : BlockColor);
else if (code == RpgCollision.Open)
DrawRect(CellRect(x, y), OpenColor);
else if (!RpgPassability.IsWalkable(grid, Tileset, x, y))
DrawRect(CellRect(x, y).Grow(-CellSize * 0.35f), BlockColor);
} }
foreach (var mapEvent in Document.LockedEvents) DrawEventMarker(target, mapEvent, true);
foreach (var mapEvent in Document.Events) DrawEventMarker(target, mapEvent, false);
if (Document.Map.Spawn is { } spawn)
{
var center = P(spawn.X + 0.5f, spawn.Y + 0.5f);
target.DrawCircle(center, unit * 0.18f, UiTokens.Ok);
target.DrawArc(center, unit * 0.18f, 0, Mathf.Tau, 24, Colors.Black);
} }
if (!hover.HasValue || !grid.InBounds(hover.Value.X, hover.Value.Y)) return;
var cell = hover.Value;
var stamp = SelectedStamp ?? RpgTileStamp.Single(SelectedTile);
if (!Placing && Tool == RpgEditTool.Pencil && ActiveLayer <= RpgEditLayer.Front && Atlas != null)
for (var y = 0; y < stamp.Height; y++)
for (var x = 0; x < stamp.Width; x++)
{
var id = stamp.TileAt(x, y);
if (id < 0) continue;
var info = Tileset.TileOf(id);
var texture = Atlas;
var source = RpgRenderSceneBuilder.TileRegion(Tileset, id);
if (info?.Surface is { } patch)
{
texture = textures.Tileset(Tileset.SurfaceImage);
source = new Rect2(patch * Tileset.SurfaceSize + (cell.X + x) * Tileset.TileSize % Tileset.SurfaceSize,
(cell.Y + y) * Tileset.TileSize % Tileset.SurfaceSize, Tileset.TileSize, Tileset.TileSize);
}
else if (!string.IsNullOrEmpty(info?.Autotile))
{
texture = textures.Autotile(info.Autotile, Tileset.TileSize, cell.X + x, cell.Y + y);
source = RpgAutotiles.PreviewRect(Tileset.TileSize);
}
TileDrawing.Draw(target, new TileVisual { Texture = texture, Source = source,
Bounds = new Rect2(cell.X + x, cell.Y + y, 1, 1) }, projection, new Color(1, 1, 1, 0.5f));
}
Outline(target, Polygon(cell.X, cell.Y, ActiveLayer <= RpgEditLayer.Front ? stamp.Width : 1,
ActiveLayer <= RpgEditLayer.Front ? stamp.Height : 1), UiTokens.AccentBright, false);
} }
private void DrawGrid() private void DrawEventMarker(CanvasItem target, RpgMapEvent mapEvent, bool locked)
{ {
var grid = Document.Grid; var polygon = Polygon(mapEvent.X, mapEvent.Y, 1, 1);
var size = new Vector2(grid.Width, grid.Height) * CellSize; if (string.IsNullOrEmpty(mapEvent.Sprite) && mapEvent.Tile < 0 && string.IsNullOrEmpty(mapEvent.Icon))
for (var x = 0; x <= grid.Width; x++) target.DrawColoredPolygon(polygon, MarkerColor);
DrawLine(new Vector2(x * CellSize, 0f), new Vector2(x * CellSize, size.Y), GridColor); if (ActiveLayer != RpgEditLayer.Events) return;
for (var y = 0; y <= grid.Height; y++) Outline(target, polygon, mapEvent == SelectedEvent ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.White, 0.6f), locked);
DrawLine(new Vector2(0f, y * CellSize), new Vector2(size.X, y * CellSize), GridColor);
} }
private static void Outline(CanvasItem target, Vector2[] polygon, Color color, bool dashed)
private Rect2 CellRect(int x, int y) => new(new Vector2(x, y) * CellSize, new Vector2(CellSize, CellSize));
private Rect2 TileRegion(int index)
{ {
var coords = RpgTileSetBuilder.AtlasCoords(Tileset, index); for (var i = 0; i < polygon.Length; i++)
return new Rect2(coords * Tileset.TileSize, new Vector2(Tileset.TileSize, Tileset.TileSize)); if (dashed) target.DrawDashedLine(polygon[i], polygon[(i + 1) % polygon.Length], color, 1, 4);
else target.DrawLine(polygon[i], polygon[(i + 1) % polygon.Length], color, 1.5f);
} }
private Vector2 P(float x, float y) => TileDrawing.Project(projection, new Vector2(x, y));
private Vector2I CellAt(Vector2 position) => new((int)Mathf.Floor(position.X / CellSize), (int)Mathf.Floor(position.Y / CellSize)); private Vector2[] Polygon(int x, int y, int width, int height)
=> TileDrawing.CellPolygon(projection, new Rect2(x, y, width, height));
private Vector2I CellAt(Vector2 position)
=> preview != null && preview.TryPickGround(position, out var cell) ? cell : new Vector2I(-1, -1);
private void OnGuiInput(InputEvent @event) private void OnGuiInput(InputEvent @event)
{ {
@@ -228,6 +302,12 @@ namespace Cthangover.Tools.RpgEditor
if (@event is InputEventMouseMotion motion) if (@event is InputEventMouseMotion motion)
{ {
if (panning)
{
Pan(motion);
AcceptEvent();
return;
}
hover = CellAt(motion.Position); hover = CellAt(motion.Position);
if (stroking && (motion.ButtonMask & (MouseButtonMask.Left | MouseButtonMask.Right)) != 0) if (stroking && (motion.ButtonMask & (MouseButtonMask.Left | MouseButtonMask.Right)) != 0)
Apply(hover.Value, (motion.ButtonMask & MouseButtonMask.Right) != 0); Apply(hover.Value, (motion.ButtonMask & MouseButtonMask.Right) != 0);
@@ -246,6 +326,15 @@ namespace Cthangover.Tools.RpgEditor
AcceptEvent(); AcceptEvent();
return; return;
} }
// Not accepted: the event bubbles up and the ScrollContainer scrolls.
if (button.ButtonIndex is MouseButton.WheelUp or MouseButton.WheelDown or MouseButton.WheelLeft or MouseButton.WheelRight)
return;
if (button.ButtonIndex == MouseButton.Middle)
{
StartOrEndPan(button);
AcceptEvent();
return;
}
var cell = CellAt(button.Position); var cell = CellAt(button.Position);
if (!button.Pressed) if (!button.Pressed)
@@ -258,6 +347,15 @@ namespace Cthangover.Tools.RpgEditor
return; return;
} }
if (Placing && (button.ButtonIndex == MouseButton.Left || button.ButtonIndex == MouseButton.Right))
{
Placing = false;
var chosen = button.ButtonIndex == MouseButton.Left && Document.Grid.InBounds(cell.X, cell.Y);
CellChosen?.Invoke(chosen ? cell : null);
AcceptEvent();
return;
}
if (ActiveLayer == RpgEditLayer.Events) if (ActiveLayer == RpgEditLayer.Events)
PressOnEvents(button, cell); PressOnEvents(button, cell);
else if (button.ButtonIndex == MouseButton.Left || button.ButtonIndex == MouseButton.Right) else if (button.ButtonIndex == MouseButton.Left || button.ButtonIndex == MouseButton.Right)
@@ -318,8 +416,13 @@ namespace Cthangover.Tools.RpgEditor
{ {
var tile = erase ? -1 : SelectedTile; var tile = erase ? -1 : SelectedTile;
var collision = erase ? RpgCollision.Auto : SelectedCollision; var collision = erase ? RpgCollision.Auto : SelectedCollision;
if (Document.Paint(ActiveLayer, cell.X, cell.Y, tile, collision)) var changed = !erase && ActiveLayer <= RpgEditLayer.Front && SelectedStamp != null
QueueRedraw(); ? Document.Stamp(ActiveLayer, cell.X, cell.Y, SelectedStamp) > 0
: Document.Paint(ActiveLayer, cell.X, cell.Y, tile, collision);
if (changed)
{
Refresh();
}
} }
private void Drag(Vector2I cell) private void Drag(Vector2I cell)
@@ -333,6 +436,7 @@ namespace Cthangover.Tools.RpgEditor
} }
dragged.X = cell.X; dragged.X = cell.X;
dragged.Y = cell.Y; dragged.Y = cell.Y;
Refresh();
EventSelected?.Invoke(dragged); EventSelected?.Invoke(dragged);
} }
@@ -343,6 +447,36 @@ namespace Cthangover.Tools.RpgEditor
QueueRedraw(); QueueRedraw();
} }
/// <summary>
/// Middle button down starts panning the parent ScrollContainer, up ends
/// it. The scroll offset is anchored to the press point rather than
/// accumulated from deltas, so hitting a scroll edge builds no drift.
/// </summary>
private void StartOrEndPan(InputEventMouseButton button)
{
var scroll = GetParentOrNull<ScrollContainer>();
panning = button.Pressed && scroll != null;
MouseDefaultCursorShape = panning ? CursorShape.Drag : CursorShape.Arrow;
if (!panning)
return;
panStartMouse = button.GlobalPosition;
panStartScroll = new Vector2(scroll.ScrollHorizontal, scroll.ScrollVertical);
}
private void Pan(InputEventMouseMotion motion)
{
var scroll = GetParentOrNull<ScrollContainer>();
if (scroll == null || (motion.ButtonMask & MouseButtonMask.Middle) == 0)
{
panning = false;
MouseDefaultCursorShape = CursorShape.Arrow;
return;
}
var delta = motion.GlobalPosition - panStartMouse;
scroll.ScrollHorizontal = Mathf.RoundToInt(panStartScroll.X - delta.X);
scroll.ScrollVertical = Mathf.RoundToInt(panStartScroll.Y - delta.Y);
}
private void OnMouseExited() private void OnMouseExited()
{ {
hover = null; hover = null;
+11
View File
@@ -0,0 +1,11 @@
namespace Cthangover.Tools.RpgEditor
{
using Godot;
/// <summary>Authoring marks render above the shared world, without intercepting canvas input.</summary>
public partial class RpgMapEditorOverlay : Node2D
{
private readonly RpgMapEditorCanvas canvas;
public RpgMapEditorOverlay(RpgMapEditorCanvas canvas) { this.canvas = canvas; ZIndex = 10; }
public override void _Draw() => canvas.DrawOverlays(this);
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ namespace Cthangover.Tools.RpgEditor
/// <summary> /// <summary>
/// Registers the RPG map editor as a tool: the kernel's tool factory finds /// Registers the RPG map editor as a tool: the kernel's tool factory finds
/// every <see cref="IToolProvider"/>, so the editor appears in the main /// every <see cref="IToolProvider"/>, so the editor appears in the main
/// menu's tool block, the novel editor hub and (with /// menu's tool block and (with
/// <see cref="RpgMapEditorToolBoxButton"/>) the in-game toolbar. /// <see cref="RpgMapEditorToolBoxButton"/>) the in-game toolbar.
/// </summary> /// </summary>
public class RpgMapEditorToolProvider : IToolProvider public class RpgMapEditorToolProvider : IToolProvider
+176 -7
View File
@@ -10,6 +10,7 @@ using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming; using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool; using Cthangover.Mods.Interface.Tool;
using Cthangover.Mods.Rpg; using Cthangover.Mods.Rpg;
using Cthangover.Mods.TileRender;
using Cthangover.Tools.Common; using Cthangover.Tools.Common;
using Cthangover.Tools.Services; using Cthangover.Tools.Services;
using Godot; using Godot;
@@ -19,8 +20,11 @@ namespace Cthangover.Tools.RpgEditor
/// <summary> /// <summary>
/// Editor of RPG tile maps (<c>maps/*.json</c>, kind <c>rpg_map</c>). /// Editor of RPG tile maps (<c>maps/*.json</c>, kind <c>rpg_map</c>).
/// Layout: toolbar (file, save, layer, brush, grid, zoom, undo/redo, play), /// Layout: toolbar (file, save, layer, brush, grid, zoom, undo/redo, play),
/// the tile palette on the left, the map in the middle, the map's and the /// the tile palette, the game's characters, items and all map appearances as tabs on the left, the
/// selected event's properties on the right. /// map in the middle, the map's and the selected event's properties on the right.
/// A character is put on the map as an NPC or an enemy, an item as a pickup:
/// pick it, press the button, click a cell (<see cref="RpgMapDocument.AddCharacter"/>,
/// <see cref="RpgMapDocument.AddItem"/>).
/// <para/> /// <para/>
/// Layers are the game's: back, main, front, the collision layer (codes /// Layers are the game's: back, main, front, the collision layer (codes
/// over the tiles' own solidity) and events. Every edit is undoable /// over the tiles' own solidity) and events. Every edit is undoable
@@ -31,6 +35,10 @@ namespace Cthangover.Tools.RpgEditor
/// (<see cref="SceneManager.ReloadContent"/>), so "Play" works on a new map /// (<see cref="SceneManager.ReloadContent"/>), so "Play" works on a new map
/// without a mod reload. /// without a mod reload.
/// <para/> /// <para/>
/// Saving writes the map file and the mod's events file of the map,
/// <c>map_events/&lt;id&gt;/events.json</c> (<see cref="RpgMapDocument"/> explains
/// own and locked events).
/// <para/>
/// After a save the map is put into the running game's map cache, and a /// After a save the map is put into the running game's map cache, and a
/// world showing it is remounted, so the edit is visible at once; "Play" /// world showing it is remounted, so the edit is visible at once; "Play"
/// saves and moves the player onto the map at its spawn. /// saves and moves the player onto the map at its spawn.
@@ -44,7 +52,13 @@ namespace Cthangover.Tools.RpgEditor
private readonly Dictionary<RpgEditTool, Button> toolButtons = new(); private readonly Dictionary<RpgEditTool, Button> toolButtons = new();
private ModFileSelector fileSelector; private ModFileSelector fileSelector;
private TabContainer sideTabs;
private RpgTilePalette palette; private RpgTilePalette palette;
private OptionButton rendererPicker;
private SpinBox itemCount;
private Func<Vector2I, RpgMapEvent> placeAt;
private string placedKey;
private string placedName;
private RpgMapEditorCanvas canvas; private RpgMapEditorCanvas canvas;
private RpgMapPropertiesPanel mapPanel; private RpgMapPropertiesPanel mapPanel;
private RpgEventPropertiesPanel eventPanel; private RpgEventPropertiesPanel eventPanel;
@@ -67,6 +81,14 @@ namespace Cthangover.Tools.RpgEditor
fileSelector.Refresh(); fileSelector.Refresh();
} }
/// <summary>Child pickers release their preview regions on exit before the window releases the shared source images.</summary>
protected override void Cleanup()
{
placeAt = null;
textures.Dispose();
base.Cleanup();
}
// ── layout ───────────────────────────────────────────────── // ── layout ─────────────────────────────────────────────────
private void BuildUi() private void BuildUi()
@@ -78,13 +100,45 @@ namespace Cthangover.Tools.RpgEditor
var main = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; var main = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill };
outer.AddChild(main); outer.AddChild(main);
var leftColumn = new VBoxContainer();
sideTabs = new TabContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
leftColumn.AddChild(sideTabs);
var paletteColumn = new VBoxContainer(); var paletteColumn = new VBoxContainer();
paletteColumn.AddChild(ToolUi.Section("tools/rpg_editor/section_palette")); paletteColumn.AddChild(ToolUi.Section("tools/rpg_editor/section_palette"));
palette = new RpgTilePalette(); palette = new RpgTilePalette();
palette.TileSelected += OnPaletteTile; palette.TileSelected += OnPaletteTile;
palette.StampSelected += OnPaletteStamp;
palette.CollisionSelected += OnPaletteCollision; palette.CollisionSelected += OnPaletteCollision;
paletteColumn.AddChild(palette); paletteColumn.AddChild(palette);
main.AddChild(CreateSidebar(paletteColumn, 400)); sideTabs.AddChild(paletteColumn);
sideTabs.SetTabTitle(0, ToolUi.Tr("tools/rpg_editor/tab_tiles"));
var characters = new RpgEntityPicker("tools/rpg_editor/character_search", "tools/rpg_editor/characters_hint") { Name = "Characters" };
characters.Source = LoadCharacters;
characters.AddAction("tools/rpg_editor/place_npc", "tools/rpg_editor/place_npc_tip", PlaceNpc);
characters.AddAction("tools/rpg_editor/place_enemy", "tools/rpg_editor/place_enemy_tip", PlaceEnemy);
sideTabs.AddChild(characters);
sideTabs.SetTabTitle(1, ToolUi.Tr("tools/rpg_editor/tab_characters"));
var items = new RpgEntityPicker("tools/rpg_editor/item_search", "tools/rpg_editor/items_hint") { Name = "Items" };
items.Source = LoadItems;
itemCount = ToolUi.Number(1, 999, 1, null);
itemCount.Value = 1;
itemCount.TooltipText = ToolUi.Tr("tools/rpg_editor/item_count_tip");
items.AddOption(new Label { Text = ToolUi.Tr("tools/rpg_editor/item_count") });
items.AddOption(itemCount);
items.AddAction("tools/rpg_editor/place_item", "tools/rpg_editor/place_item_tip", PlaceItem);
sideTabs.AddChild(items);
sideTabs.SetTabTitle(2, ToolUi.Tr("tools/rpg_editor/tab_items"));
var appearances = new RpgEntityPicker("tools/rpg_editor/appearance_search", "tools/rpg_editor/appearances_hint") { Name = "Appearances" };
appearances.Source = LoadAppearances;
appearances.AddAction("tools/rpg_editor/place_appearance", "tools/rpg_editor/place_appearance_tip", PlaceAppearance);
sideTabs.AddChild(appearances);
sideTabs.SetTabTitle(3, ToolUi.Tr("tools/rpg_editor/tab_appearances"));
sideTabs.TabChanged += OnSideTabChanged;
main.AddChild(CreateSidebar(leftColumn, 400));
var center = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; var center = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
main.AddChild(center); main.AddChild(center);
@@ -94,6 +148,7 @@ namespace Cthangover.Tools.RpgEditor
canvas.Changed += OnDocumentChanged; canvas.Changed += OnDocumentChanged;
canvas.EventSelected += OnEventSelected; canvas.EventSelected += OnEventSelected;
canvas.TilePicked += OnTilePicked; canvas.TilePicked += OnTilePicked;
canvas.CellChosen += OnCellChosen;
center.AddChild(Scroll(canvas)); center.AddChild(Scroll(canvas));
var properties = new VBoxContainer(); var properties = new VBoxContainer();
@@ -132,6 +187,24 @@ namespace Cthangover.Tools.RpgEditor
AddToolButton(toolbar, RpgEditTool.Fill, "tools/rpg_editor/tool_fill"); AddToolButton(toolbar, RpgEditTool.Fill, "tools/rpg_editor/tool_fill");
toolbar.AddChild(ToolUi.Divider()); toolbar.AddChild(ToolUi.Divider());
rendererPicker = new OptionButton { Name = "RendererPicker" };
var wantedRenderer = Cthangover.Core.Games.ActiveGame.GetSetting("tile.renderer") ?? "topdown";
foreach (var arg in OS.GetCmdlineUserArgs()) if (arg.StartsWith("--editor-renderer=")) wantedRenderer = arg.Substring(18);
foreach (var id in TileRendererRegistry.Instance.Ids)
{
var index = rendererPicker.ItemCount;
var label = id switch
{
"topdown" => ToolUi.Tr("tools/rpg_editor/view_topdown"),
"isometric" => ToolUi.Tr("tools/rpg_editor/view_isometric"),
_ => id
};
rendererPicker.AddItem(label);
rendererPicker.SetItemMetadata(index, id);
if (id == wantedRenderer) rendererPicker.Select(index);
}
rendererPicker.ItemSelected += OnRendererSelected;
toolbar.AddChild(rendererPicker);
gridCheck = new CheckBox { Text = ToolUi.Tr("tools/rpg_editor/grid"), ButtonPressed = true }; gridCheck = new CheckBox { Text = ToolUi.Tr("tools/rpg_editor/grid"), ButtonPressed = true };
gridCheck.Toggled += OnGridToggled; gridCheck.Toggled += OnGridToggled;
toolbar.AddChild(gridCheck); toolbar.AddChild(gridCheck);
@@ -206,7 +279,7 @@ namespace Cthangover.Tools.RpgEditor
{ {
var text = ModFileService.ReadText(mod, file); var text = ModFileService.ReadText(mod, file);
isNew = string.IsNullOrEmpty(text); isNew = string.IsNullOrEmpty(text);
document = isNew ? CreateDocument(file) : RpgMapDocument.Load(text); document = isNew ? CreateDocument(file) : LoadDocument(mod, text);
SetStatus(string.Format(ToolUi.Tr(isNew ? "tools/common/new_file_status" : "tools/common/loaded"), mod + "/" + file)); SetStatus(string.Format(ToolUi.Tr(isNew ? "tools/common/new_file_status" : "tools/common/loaded"), mod + "/" + file));
} }
catch (Exception ex) catch (Exception ex)
@@ -221,6 +294,23 @@ namespace Cthangover.Tools.RpgEditor
BindDocument(); BindDocument();
} }
/// <summary>
/// The map with the editor's events file of the same mod; every other event
/// the game shows on the map is locked. An own event hides a locked one of the
/// same id (another mod replacing it), so the author sees and edits their own.
/// </summary>
private static RpgMapDocument LoadDocument(string mod, string text)
{
var id = RpgMapDocument.Load(text).Map.ID;
var eventsPath = RpgMapDocument.EventsPath(id);
var document = RpgMapDocument.Load(text, ModFileService.ReadText(mod, eventsPath));
var own = new HashSet<string>(document.Events.Select(e => e.Id), StringComparer.OrdinalIgnoreCase);
document.SetLockedEvents(RpgMapEvents.Of(id)
.Where(e => !(e.ModId == mod && e.File == eventsPath) && !own.Contains(e.Id))
.ToList());
return document;
}
private static RpgMapDocument CreateDocument(string file) private static RpgMapDocument CreateDocument(string file)
{ {
var tileset = Entities.Collect<RpgTileset>().Keys.OrderBy(k => k).FirstOrDefault(); var tileset = Entities.Collect<RpgTileset>().Keys.OrderBy(k => k).FirstOrDefault();
@@ -248,11 +338,15 @@ namespace Cthangover.Tools.RpgEditor
} }
var json = document.ToJson(); var json = document.ToJson();
if (!ModFileService.WriteText(modId, path, json)) var eventsPath = RpgMapDocument.EventsPath(document.Map.ID);
var writeEvents = document.Events.Count > 0 || !string.IsNullOrEmpty(ModFileService.ReadText(modId, eventsPath));
if (!ModFileService.WriteText(modId, path, json)
|| (writeEvents && !ModFileService.WriteText(modId, eventsPath, document.EventsToJson())))
{ {
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error); SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return false; return false;
} }
RpgMapEvents.Invalidate();
var sceneNote = isNew ? EnsureScene() : null; var sceneNote = isNew ? EnsureScene() : null;
if (sceneNote != null) if (sceneNote != null)
@@ -342,7 +436,7 @@ namespace Cthangover.Tools.RpgEditor
return; return;
// The document holds a new map object now: find the selection again by id. // The document holds a new map object now: find the selection again by id.
var selectedId = canvas.SelectedEvent?.Id; var selectedId = canvas.SelectedEvent?.Id;
canvas.SelectedEvent = document.Map.Events.FirstOrDefault(e => e.Id == selectedId); canvas.SelectedEvent = document.Events.FirstOrDefault(e => e.Id == selectedId);
SetDirty(); SetDirty();
BindDocument(); BindDocument();
} }
@@ -373,27 +467,96 @@ namespace Cthangover.Tools.RpgEditor
private void OnPaletteTile(int index) private void OnPaletteTile(int index)
{ {
canvas.SelectedTile = index; canvas.SelectedTile = index;
canvas.SelectedStamp = RpgTileStamp.Single(index);
if (canvas.ActiveLayer > RpgEditLayer.Front) if (canvas.ActiveLayer > RpgEditLayer.Front)
SelectLayer(RpgEditLayer.Main); SelectLayer(RpgEditLayer.Main);
} }
private void OnPaletteStamp(RpgTileStamp stamp)
{
canvas.SelectedStamp = stamp;
canvas.QueueRedraw();
}
private void OnPaletteCollision(char code) => canvas.SelectedCollision = code; private void OnPaletteCollision(char code) => canvas.SelectedCollision = code;
private void OnSideTabChanged(long tab)
{
if (sideTabs.GetCurrentTabControl() is RpgEntityPicker picker)
picker.Reload();
}
private List<RpgPickerEntry> LoadCharacters() => RpgCharacterCatalog.Load(textures);
private List<RpgPickerEntry> LoadItems() => RpgItemCatalog.Load(textures);
private List<RpgPickerEntry> LoadAppearances() => RpgAppearanceCatalog.Load(textures);
private void PlaceAppearance(RpgPickerEntry entry)
{
var id = "npc_" + new string(entry.Look.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_').ToArray());
BeginPlacing(entry, "tools/rpg_editor/placed_npc", cell => document.AddCharacter(cell.X, cell.Y, id, entry.Look, false));
}
private void PlaceNpc(RpgPickerEntry entry)
=> BeginPlacing(entry, "tools/rpg_editor/placed_npc", cell => document.AddCharacter(cell.X, cell.Y, entry.Id, entry.Look, false));
private void PlaceEnemy(RpgPickerEntry entry)
=> BeginPlacing(entry, "tools/rpg_editor/placed_enemy", cell => document.AddCharacter(cell.X, cell.Y, entry.Id, entry.Look, true));
private void PlaceItem(RpgPickerEntry entry)
{
var count = (int)itemCount.Value;
var line = string.Format(ToolUi.Tr("tools/rpg_editor/item_found_line"), entry.Name, count);
BeginPlacing(entry, "tools/rpg_editor/placed_item", cell => document.AddItem(cell.X, cell.Y, entry.Id, entry.Look, count, line));
}
/// <summary>Something is to be placed: switch to the events layer and wait for a cell; <paramref name="place"/> makes the event.</summary>
private void BeginPlacing(RpgPickerEntry entry, string doneKey, Func<Vector2I, RpgMapEvent> place)
{
if (document == null)
return;
placeAt = place;
placedKey = doneKey;
placedName = entry.Name;
SelectLayer(RpgEditLayer.Events);
canvas.Placing = true;
SetStatus(string.Format(ToolUi.Tr("tools/rpg_editor/place_status"), entry.Name));
}
private void OnCellChosen(Vector2I? cell)
{
if (cell == null || placeAt == null || document == null)
{
SetStatus(ToolUi.Tr("tools/rpg_editor/place_cancelled"));
return;
}
RecordUndo();
var mapEvent = placeAt(cell.Value);
canvas.SelectedEvent = mapEvent;
eventPanel.Bind(mapEvent);
OnDocumentChanged();
SetStatus(string.Format(ToolUi.Tr(placedKey), placedName, mapEvent.Id), ToolStatusKind.Ok);
}
private void OnTilePicked(int index) private void OnTilePicked(int index)
{ {
canvas.SelectedTile = index; canvas.SelectedTile = index;
canvas.SelectedStamp = RpgTileStamp.Single(index);
palette.Select(index); palette.Select(index);
} }
private void SelectLayer(RpgEditLayer layer) private void SelectLayer(RpgEditLayer layer)
{ {
canvas.ActiveLayer = layer; canvas.ActiveLayer = layer;
if (layer != RpgEditLayer.Events)
canvas.Placing = false;
foreach (var pair in layerButtons) foreach (var pair in layerButtons)
pair.Value.SetPressedNoSignal(pair.Key == layer); pair.Value.SetPressedNoSignal(pair.Key == layer);
palette.SetCollisionMode(layer == RpgEditLayer.Collision); palette.SetCollisionMode(layer == RpgEditLayer.Collision);
foreach (var button in toolButtons.Values) foreach (var button in toolButtons.Values)
button.Disabled = layer == RpgEditLayer.Events; button.Disabled = layer == RpgEditLayer.Events;
canvas.QueueRedraw(); canvas.Refresh();
} }
private void SelectTool(RpgEditTool tool) private void SelectTool(RpgEditTool tool)
@@ -403,6 +566,12 @@ namespace Cthangover.Tools.RpgEditor
pair.Value.SetPressedNoSignal(pair.Key == tool); pair.Value.SetPressedNoSignal(pair.Key == tool);
} }
private void OnRendererSelected(long index)
{
canvas.SetRenderer(rendererPicker.GetItemMetadata((int)index).AsString());
UpdateZoomLabel();
}
private void OnGridToggled(bool on) private void OnGridToggled(bool on)
{ {
canvas.ShowGrid = on; canvas.ShowGrid = on;
+25 -1
View File
@@ -10,7 +10,8 @@ namespace Cthangover.Tools.RpgEditor
{ {
/// <summary> /// <summary>
/// The map's own properties in the right column: id (the scene name, read /// The map's own properties in the right column: id (the scene name, read
/// only — it is the file name), display name key, tileset, size, zoom, spawn /// only — it is the file name), display name key, tileset, size, zoom, fixed
/// ambient light (empty: the time of day lights the map), spawn
/// and random encounters. The size is applied with a button, because a /// and random encounters. The size is applied with a button, because a
/// smaller size cuts cells off. Encounter groups are written as /// smaller size cuts cells off. Encounter groups are written as
/// <c>slime; wolf,slime</c> — groups split by semicolons. /// <c>slime; wolf,slime</c> — groups split by semicolons.
@@ -27,6 +28,8 @@ namespace Cthangover.Tools.RpgEditor
private readonly SpinBox widthSpin; private readonly SpinBox widthSpin;
private readonly SpinBox heightSpin; private readonly SpinBox heightSpin;
private readonly SpinBox zoomSpin; private readonly SpinBox zoomSpin;
private readonly LineEdit ambientEdit;
private readonly OptionButton atmospherePicker;
private readonly SpinBox spawnX; private readonly SpinBox spawnX;
private readonly SpinBox spawnY; private readonly SpinBox spawnY;
private readonly OptionButton spawnDir; private readonly OptionButton spawnDir;
@@ -61,6 +64,9 @@ namespace Cthangover.Tools.RpgEditor
heightSpin = ToolUi.Field(this, "tools/rpg_editor/height", ToolUi.Number(1, 256, 1, null)); heightSpin = ToolUi.Field(this, "tools/rpg_editor/height", ToolUi.Number(1, 256, 1, null));
AddChild(ToolUi.Button("tools/rpg_editor/resize", OnResizePressed)); AddChild(ToolUi.Button("tools/rpg_editor/resize", OnResizePressed));
zoomSpin = ToolUi.Field(this, "tools/rpg_editor/zoom", ToolUi.Number(1, 4, 1, OnZoomChanged), "tools/rpg_editor/zoom_tip"); zoomSpin = ToolUi.Field(this, "tools/rpg_editor/zoom", ToolUi.Number(1, 4, 1, OnZoomChanged), "tools/rpg_editor/zoom_tip");
ambientEdit = ToolUi.Field(this, "tools/rpg_editor/ambient", ToolUi.Text("#e0d2b8", OnAmbientChanged), "tools/rpg_editor/ambient_tip");
atmospherePicker = ToolUi.Field(this, "tools/rpg_editor/atmosphere", RpgEditorFields.Choice(new[] { "tools/rpg_editor/atmosphere_none", "tools/rpg_editor/atmosphere_woodland" }, OnAtmospherePicked));
AddChild(ToolUi.Section("tools/rpg_editor/section_spawn")); AddChild(ToolUi.Section("tools/rpg_editor/section_spawn"));
spawnX = ToolUi.Field(this, "tools/rpg_editor/x", ToolUi.Number(0, 255, 1, OnSpawnChanged)); spawnX = ToolUi.Field(this, "tools/rpg_editor/x", ToolUi.Number(0, 255, 1, OnSpawnChanged));
@@ -94,6 +100,8 @@ namespace Cthangover.Tools.RpgEditor
widthSpin.Value = value.Grid.Width; widthSpin.Value = value.Grid.Width;
heightSpin.Value = value.Grid.Height; heightSpin.Value = value.Grid.Height;
zoomSpin.Value = map.Zoom; zoomSpin.Value = map.Zoom;
ambientEdit.Text = map.Ambient ?? string.Empty;
atmospherePicker.Select(map.Atmosphere == "woodland" ? 1 : 0);
spawnX.Value = map.Spawn.X; spawnX.Value = map.Spawn.X;
spawnY.Value = map.Spawn.Y; spawnY.Value = map.Spawn.Y;
spawnDir.Select((int)map.Spawn.Dir); spawnDir.Select((int)map.Spawn.Dir);
@@ -123,6 +131,22 @@ namespace Cthangover.Tools.RpgEditor
Changed?.Invoke(); Changed?.Invoke();
} }
private void OnAtmospherePicked(int index)
{
if (!Begin())
return;
document.Map.Atmosphere = index == 1 ? "woodland" : null;
Changed?.Invoke();
}
private void OnAmbientChanged(string text)
{
if (!Begin())
return;
document.Map.Ambient = string.IsNullOrWhiteSpace(text) ? null : text.Trim();
Changed?.Invoke();
}
private void OnTilesetPicked(long index) private void OnTilesetPicked(long index)
{ {
if (!Begin() || index < 0 || index >= tilesets.Count) if (!Begin() || index < 0 || index >= tilesets.Count)
+6 -2
View File
@@ -2,7 +2,7 @@ using Cthangover.Mods.Rpg;
namespace Cthangover.Tools.RpgEditor namespace Cthangover.Tools.RpgEditor
{ {
/// <summary>One undo step of <see cref="RpgMapDocument"/>: the cells and the rest of the map as JSON.</summary> /// <summary>One undo step of <see cref="RpgMapDocument"/>: the cells, the rest of the map and the own events as JSON.</summary>
public class RpgMapSnapshot public class RpgMapSnapshot
{ {
/// <summary>Copy of the cells.</summary> /// <summary>Copy of the cells.</summary>
@@ -11,11 +11,15 @@ namespace Cthangover.Tools.RpgEditor
/// <summary>The map without its cells (rows may be stale), serialized.</summary> /// <summary>The map without its cells (rows may be stale), serialized.</summary>
public string MapJson { get; } public string MapJson { get; }
/// <summary>The document's own events, serialized.</summary>
public string EventsJson { get; }
/// <summary>Creates a step.</summary> /// <summary>Creates a step.</summary>
public RpgMapSnapshot(RpgMapGrid grid, string mapJson) public RpgMapSnapshot(RpgMapGrid grid, string mapJson, string eventsJson)
{ {
Grid = grid; Grid = grid;
MapJson = mapJson; MapJson = mapJson;
EventsJson = eventsJson;
} }
} }
} }
+23
View File
@@ -0,0 +1,23 @@
using Godot;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>One row of an <see cref="RpgEntityPicker"/>: a character, item or map appearance.</summary>
public class RpgPickerEntry
{
/// <summary>Entity id for character/item entries; a charset path for appearance-only entries.</summary>
public string Id { get; init; }
/// <summary>Display name in the current language (the id when the entity has none).</summary>
public string Name { get; init; }
/// <summary>How it looks on the map: a charset path for a character, an item picture path for an item; null for none.</summary>
public string Look { get; init; }
/// <summary>Picture of the row, or null.</summary>
public Texture2D Icon { get; init; }
/// <summary>True for an entry-owned atlas preview; false for a texture borrowed from the window cache.</summary>
public bool OwnsIcon { get; init; }
}
}
+128 -12
View File
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using Cthangover.Core.UI.Theming; using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Rpg; using Cthangover.Mods.Rpg;
@@ -10,19 +11,33 @@ namespace Cthangover.Tools.RpgEditor
/// Brush picker of the editor's left column. For a tile layer it shows the /// Brush picker of the editor's left column. For a tile layer it shows the
/// tileset atlas at one and a half times the tile size with the selected tile framed and the /// tileset atlas at one and a half times the tile size with the selected tile framed and the
/// solid tiles marked by a red corner; a click picks a tile, the tooltip /// solid tiles marked by a red corner; a click picks a tile, the tooltip
/// names it (index, tag, solid). For the collision layer it shows the three /// names it (index, tag, solid). The tileset's autotiles follow in a row under the
/// collision codes instead. /// atlas, each shown by its block's preview tile (<see cref="RpgAutotiles"/>). For the
/// collision layer it shows the three collision codes instead.
/// </summary> /// </summary>
public partial class RpgTilePalette : Control public partial class RpgTilePalette : Control
{ {
private const float Scale = 1.5f; // Keep wide architectural blocks fully visible in the standard sidebar.
private float Scale => tileset == null ? 1.5f : Math.Min(1.5f, 384f / Math.Max(1, tileset.Columns * tileset.TileSize));
private const float CodeHeight = 44f; private const float CodeHeight = 44f;
private const float AutotileGap = 8f;
private static readonly char[] Codes = { RpgCollision.Block, RpgCollision.Open, RpgCollision.Auto }; private static readonly char[] Codes = { RpgCollision.Block, RpgCollision.Open, RpgCollision.Auto };
private readonly RpgTextures textures = new();
private readonly List<RpgTileInfo> autotiles = new();
private RpgTileset tileset; private RpgTileset tileset;
private Texture2D atlas; private Texture2D atlas;
private bool collisionMode; private bool collisionMode;
private Vector2I? selectionStart;
private Rect2I? selection;
private bool dragged;
/// <summary>The current rectangular brush in logical map ids.</summary>
public RpgTileStamp SelectedStamp { get; private set; } = RpgTileStamp.Single(0);
/// <summary>A dragged rectangle changed the whole brush.</summary>
public event Action<RpgTileStamp> StampSelected;
/// <summary>Selected atlas index.</summary> /// <summary>Selected atlas index.</summary>
public int SelectedTile { get; private set; } public int SelectedTile { get; private set; }
@@ -49,12 +64,18 @@ namespace Cthangover.Tools.RpgEditor
{ {
tileset = value; tileset = value;
atlas = image; atlas = image;
selection = null;
selectionStart = null;
autotiles.Clear();
if (value != null)
autotiles.AddRange(value.Tiles.Where(t => !string.IsNullOrEmpty(t.Autotile) || t.Surface != null));
Relayout(); Relayout();
} }
/// <summary>Switches between the tile atlas and the collision codes.</summary> /// <summary>Switches between the tile atlas and the collision codes.</summary>
public void SetCollisionMode(bool value) public void SetCollisionMode(bool value)
{ {
selectionStart = null;
collisionMode = value; collisionMode = value;
Relayout(); Relayout();
} }
@@ -63,6 +84,9 @@ namespace Cthangover.Tools.RpgEditor
public void Select(int index) public void Select(int index)
{ {
SelectedTile = index; SelectedTile = index;
SelectedStamp = RpgTileStamp.Single(index);
selection = null;
selectionStart = null;
QueueRedraw(); QueueRedraw();
} }
@@ -80,15 +104,29 @@ namespace Cthangover.Tools.RpgEditor
var cell = tileset.TileSize * Scale; var cell = tileset.TileSize * Scale;
DrawTextureRect(atlas, new Rect2(Vector2.Zero, atlas.GetSize() * Scale), false); DrawTextureRect(atlas, new Rect2(Vector2.Zero, atlas.GetSize() * Scale), false);
foreach (var tile in tileset.Tiles.Where(t => t.Solid)) foreach (var tile in tileset.Tiles.Where(t => t.Solid && string.IsNullOrEmpty(t.Autotile) && t.Surface == null))
DrawSolidMark((Vector2)RpgTileSetBuilder.AtlasCoords(tileset, tile.Index) * cell, cell);
for (var i = 0; i < autotiles.Count; i++)
{ {
var position = (Vector2)RpgTileSetBuilder.AtlasCoords(tileset, tile.Index) * cell; if (autotiles[i].Surface is { } patch && textures.Tileset(tileset.SurfaceImage) is { } surface)
DrawColoredPolygon(new[] { position, position + new Vector2(cell * 0.25f, 0f), position + new Vector2(0f, cell * 0.25f) }, new Color(0.9f, 0.2f, 0.2f, 0.9f)); DrawTextureRectRegion(surface, new Rect2(Slot(i), new Vector2(cell, cell)),
new Rect2(patch * tileset.SurfaceSize, 0, tileset.TileSize, tileset.TileSize));
else if (textures.Tileset(autotiles[i].Autotile) is { } block)
DrawTextureRectRegion(block, new Rect2(Slot(i), new Vector2(cell, cell)), RpgAutotiles.PreviewRect(tileset.TileSize));
if (autotiles[i].Solid)
DrawSolidMark(Slot(i), cell);
} }
var selected = (Vector2)RpgTileSetBuilder.AtlasCoords(tileset, SelectedTile) * cell; var slot = autotiles.FindIndex(t => t.Index == SelectedTile);
DrawRect(new Rect2(selected, new Vector2(cell, cell)), UiTokens.AccentBright, false, 3f); var selected = slot >= 0 ? Slot(slot) : (Vector2)RpgTileSetBuilder.AtlasCoords(tileset, SelectedTile) * cell;
var highlight = selection is { } rectangle
? new Rect2((Vector2)rectangle.Position * cell, (Vector2)rectangle.Size * cell)
: new Rect2(selected, new Vector2(cell, cell));
DrawRect(highlight, UiTokens.AccentBright, false, 3f);
} }
private void DrawSolidMark(Vector2 position, float cell)
=> DrawColoredPolygon(new[] { position, position + new Vector2(cell * 0.25f, 0f), position + new Vector2(0f, cell * 0.25f) }, new Color(0.9f, 0.2f, 0.2f, 0.9f));
private void DrawCode(int i) private void DrawCode(int i)
{ {
var rect = new Rect2(0f, i * (CodeHeight + 6f), Size.X, CodeHeight); var rect = new Rect2(0f, i * (CodeHeight + 6f), Size.X, CodeHeight);
@@ -110,7 +148,7 @@ namespace Cthangover.Tools.RpgEditor
if (collisionMode) if (collisionMode)
CustomMinimumSize = new Vector2(200f, Codes.Length * (CodeHeight + 6f)); CustomMinimumSize = new Vector2(200f, Codes.Length * (CodeHeight + 6f));
else else
CustomMinimumSize = atlas != null ? atlas.GetSize() * Scale : Vector2.Zero; CustomMinimumSize = atlas != null && tileset != null ? new Vector2(atlas.GetWidth() * Scale, AutotileTop + AutotileRows * tileset.TileSize * Scale) : Vector2.Zero;
TooltipText = string.Empty; TooltipText = string.Empty;
QueueRedraw(); QueueRedraw();
} }
@@ -119,9 +157,19 @@ namespace Cthangover.Tools.RpgEditor
{ {
if (@event is InputEventMouseMotion motion && !collisionMode && tileset != null) if (@event is InputEventMouseMotion motion && !collisionMode && tileset != null)
{ {
if (selectionStart.HasValue && (dragged || AtlasCellAt(motion.Position) != selectionStart.Value))
{
dragged = true;
SelectRectangle(motion.Position);
}
TooltipText = Describe(IndexAt(motion.Position)); TooltipText = Describe(IndexAt(motion.Position));
return; return;
} }
if (@event is InputEventMouseButton { Pressed: false, ButtonIndex: MouseButton.Left })
{
selectionStart = null;
return;
}
if (@event is not InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left } button) if (@event is not InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left } button)
return; return;
@@ -137,29 +185,97 @@ namespace Cthangover.Tools.RpgEditor
else if (tileset != null && atlas != null) else if (tileset != null && atlas != null)
{ {
var index = IndexAt(button.Position); var index = IndexAt(button.Position);
if (index >= 0 && index < RpgTileSetBuilder.TileCount(tileset, atlas)) if (index >= 0 && (index < RpgTileSetBuilder.TileCount(tileset, atlas) || tileset.TileOf(index) != null))
{ {
SelectedTile = index; SelectedTile = index;
SelectedStamp = RpgTileStamp.Single(index);
selection = null;
selectionStart = button.Position.Y < atlas.GetHeight() * Scale
? AtlasCellAt(button.Position) : null;
dragged = false;
SelectGroupOf(index);
TileSelected?.Invoke(index); TileSelected?.Invoke(index);
StampSelected?.Invoke(SelectedStamp);
} }
} }
QueueRedraw(); QueueRedraw();
AcceptEvent(); AcceptEvent();
} }
/// <summary>
/// A click on any cell of a visual group picks the whole object, so it is stamped complete: a partial
/// pattern does not match the group and the isometric view falls back to separate per-cell sprites.
/// </summary>
private void SelectGroupOf(int index)
{
var group = tileset.VisualGroups?.Find(g => g.Pattern != null && Array.IndexOf(g.Pattern, index) >= 0);
if (group == null || RpgVisualGroupValidator.Error(group) != null)
return;
SelectedStamp = new RpgTileStamp(group.Width, group.Height, group.Pattern);
SelectedTile = group.Pattern[0];
var origin = RpgTileSetBuilder.AtlasCoords(tileset, group.Pattern[0]);
var packed = true;
for (var y = 0; y < group.Height; y++)
for (var x = 0; x < group.Width; x++)
packed &= RpgTileSetBuilder.AtlasCoords(tileset, group.Pattern[y * group.Width + x]) == origin + new Vector2I(x, y);
selection = packed ? new Rect2I(origin, group.Width, group.Height) : null;
}
private Vector2I AtlasCellAt(Vector2 position)
{
var cell = tileset.TileSize * Scale;
return new Vector2I(Math.Clamp((int)(position.X / cell), 0, tileset.Columns - 1),
Math.Clamp((int)(position.Y / cell), 0, atlas.GetHeight() / tileset.TileSize - 1));
}
private void SelectRectangle(Vector2 position)
{
var end = AtlasCellAt(position);
var start = selectionStart.Value;
var left = Math.Min(start.X, end.X);
var top = Math.Min(start.Y, end.Y);
selection = new Rect2I(left, top, Math.Abs(start.X - end.X) + 1, Math.Abs(start.Y - end.Y) + 1);
SelectedStamp = RpgTileStamp.FromAtlas(tileset, selection.Value);
SelectedTile = SelectedStamp.TileAt(0, 0);
TileSelected?.Invoke(SelectedTile);
StampSelected?.Invoke(SelectedStamp);
QueueRedraw();
}
private float AutotileTop => atlas == null ? 0f : atlas.GetHeight() * Scale + (autotiles.Count > 0 ? AutotileGap : 0f);
private int AutotileRows => (autotiles.Count + Math.Max(1, tileset.Columns) - 1) / Math.Max(1, tileset.Columns);
private Vector2 Slot(int i)
{
var cell = tileset.TileSize * Scale;
var columns = Math.Max(1, tileset.Columns);
return new Vector2(i % columns * cell, AutotileTop + i / columns * cell);
}
private int IndexAt(Vector2 position) private int IndexAt(Vector2 position)
{ {
var cell = tileset.TileSize * Scale; var cell = tileset.TileSize * Scale;
if (autotiles.Count > 0 && position.Y >= AutotileTop)
{
var slot = (int)((position.Y - AutotileTop) / cell) * Math.Max(1, tileset.Columns) + (int)(position.X / cell);
return slot >= 0 && slot < autotiles.Count && position.X < tileset.Columns * cell ? autotiles[slot].Index : -1;
}
var column = (int)(position.X / cell); var column = (int)(position.X / cell);
var row = (int)(position.Y / cell); var row = (int)(position.Y / cell);
return column < 0 || column >= tileset.Columns || row < 0 ? -1 : row * tileset.Columns + column; if (column < 0 || column >= tileset.Columns || row < 0 || position.Y >= atlas.GetHeight() * Scale)
return -1;
var physical = row * tileset.Columns + column;
return tileset.Tiles.FirstOrDefault(t => t.AtlasIndex == physical)?.Index
?? (tileset.Tiles.Any(t => t.AtlasIndex != null) ? -1 : physical);
} }
private string Describe(int index) private string Describe(int index)
{ {
var info = tileset.Tiles.FirstOrDefault(t => t.Index == index); var info = tileset.Tiles.FirstOrDefault(t => t.Index == index);
var solid = info?.Solid == true ? " · " + TranslationServer.Translate("tools/rpg_editor/solid_tile") : string.Empty; var solid = info?.Solid == true ? " · " + TranslationServer.Translate("tools/rpg_editor/solid_tile") : string.Empty;
return $"#{index} {info?.Tag}{solid}"; var auto = string.IsNullOrEmpty(info?.Autotile) ? string.Empty : " · " + TranslationServer.Translate("tools/rpg_editor/autotile");
return $"#{index} {info?.Tag}{solid}{auto}";
} }
} }
} }
+49
View File
@@ -0,0 +1,49 @@
using System;
using Cthangover.Mods.Rpg;
using Godot;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>A rectangular palette selection in logical map ids; empty atlas slots are skipped when stamping.</summary>
public class RpgTileStamp
{
private readonly int[] tiles;
/// <summary>Selection width in cells.</summary>
public int Width { get; }
/// <summary>Selection height in cells.</summary>
public int Height { get; }
/// <summary>Copies a rectangular set of logical tile ids; negative ids denote gaps.</summary>
public RpgTileStamp(int width, int height, int[] values)
{
if (width < 1 || height < 1 || values == null || values.Length != width * height)
throw new ArgumentException("A stamp must contain exactly width times height cells.");
Width = width;
Height = height;
tiles = (int[])values.Clone();
}
/// <summary>Logical id at the given offset, or -1 outside the selection.</summary>
public int TileAt(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height ? tiles[y * Width + x] : -1;
/// <summary>Single-tile brush, also used for surface materials and the pipette.</summary>
public static RpgTileStamp Single(int index) => new(1, 1, new[] { index });
/// <summary>Converts physical object-atlas coordinates into logical ids without exposing padding cells.</summary>
public static RpgTileStamp FromAtlas(RpgTileset tileset, Rect2I rectangle)
{
var values = new int[rectangle.Size.X * rectangle.Size.Y];
var packed = tileset.Tiles.Exists(t => t.AtlasIndex != null);
for (var y = 0; y < rectangle.Size.Y; y++)
for (var x = 0; x < rectangle.Size.X; x++)
{
var physical = (rectangle.Position.Y + y) * tileset.Columns + rectangle.Position.X + x;
values[y * rectangle.Size.X + x] = packed
? tileset.Tiles.Find(t => t.AtlasIndex == physical)?.Index ?? -1 : physical;
}
return new RpgTileStamp(rectangle.Size.X, rectangle.Size.Y, values);
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "RPG Map Editor Test", "name": "RPG Map Editor Test",
"description": "GUI test of the RPG map editor on the rpg_demo maps: opens the window, picks a tile in the palette, paints it on the main layer with the mouse, undoes with Ctrl+Z, adds an event by double-clicking on the events layer, and closes without saving (the mod's files stay untouched). Activated by --itest=rpg_editor_edit with --game=rpg_demo --new; --shots=<dir> saves screenshots (windowed runs only).", "description": "GUI test of the RPG map editor on the rpg_demo_game maps of rpg_plus: opens the window, picks a tile in the palette, paints it on the main layer with the mouse, undoes with Ctrl+Z, adds an event by double-clicking on the events layer, and closes without saving (the mod's files stay untouched). Activated by --itest=rpg_editor_edit with --game=rpg_plus --new (the prologue is clicked through first); --shots=<dir> saves screenshots (windowed runs only).",
"sources": [ "sources": [
"src/*.cs" "src/*.cs"
], ],
+179 -11
View File
@@ -16,10 +16,14 @@ namespace Cthangover.Tools.RpgEditor.Tests
/// <summary> /// <summary>
/// Drives the RPG map editor through its controls like an author: layer /// Drives the RPG map editor through its controls like an author: layer
/// buttons are pressed, the palette and the canvas get mouse events through /// buttons are pressed, the palette and the canvas get mouse events through
/// their <c>gui_input</c> signal, Ctrl+Z goes into the window. That window is /// their <c>gui_input</c> signal, Ctrl+Z goes into the window, a slime found in
/// the characters tab is placed as an enemy and branches from the items tab as
/// a pickup. The appearance tab exposes all NPC artwork, places an elder without
/// a combat entity and supports undo/redo. That window is
/// freed without saving, so the demo maps never change. A second window then /// freed without saving, so the demo maps never change. A second window then
/// creates a new map through the file selector's "+" and saves it: the game must /// creates a new map through the file selector's "+" and saves it: the game must
/// know the map's scene at once (no mod reload); its files are deleted again. /// know the map's scene and its event (saved apart, into map_events) at once,
/// with no mod reload, including a seer placed from the appearance tab; its files are deleted again.
/// Result line: <c>[TEST_RESULT] name=rpg_editor_edit passed=N failed=M</c>. /// Result line: <c>[TEST_RESULT] name=rpg_editor_edit passed=N failed=M</c>.
/// </summary> /// </summary>
public partial class RpgEditorTestDriver : Node public partial class RpgEditorTestDriver : Node
@@ -31,6 +35,8 @@ namespace Cthangover.Tools.RpgEditor.Tests
private static readonly Vector2I PaintCell = new(3, 5); private static readonly Vector2I PaintCell = new(3, 5);
private static readonly Vector2I EventCell = new(2, 6); private static readonly Vector2I EventCell = new(2, 6);
private static readonly Vector2I CharacterCell = new(4, 6);
private static readonly Vector2I ItemCell = new(5, 6);
private readonly string shotsDir; private readonly string shotsDir;
private readonly List<string> failures = new(); private readonly List<string> failures = new();
@@ -41,6 +47,8 @@ namespace Cthangover.Tools.RpgEditor.Tests
private RpgMapEditorCanvas canvas; private RpgMapEditorCanvas canvas;
private int eventsBefore; private int eventsBefore;
private string newMapMod; private string newMapMod;
private Texture2D closingPreview;
private Texture2D closingSource;
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary> /// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
public RpgEditorTestDriver(string shotsDir) public RpgEditorTestDriver(string shotsDir)
@@ -79,14 +87,20 @@ namespace Cthangover.Tools.RpgEditor.Tests
switch (step) switch (step)
{ {
case 0: case 0:
if (!Boot.IsReady || RpgWorldHost.Current?.View == null) if (!Boot.IsReady || string.IsNullOrEmpty(GetTree().Root.GetNodeOrNull<SceneManager>("SceneManager")?.CurrentSceneName))
return; return;
// Dialogs first: the rpg_plus prologue runs before any map is shown.
var box = RpgInput.FindDialogBox(); var box = RpgInput.FindDialogBox();
if (box != null && box.IsRunning) if (box != null && box.IsRunning)
{ {
box.NextAction(); if (box.IsWaitingAnswer)
box.TrySelectVariant(0);
else
box.NextAction();
return; return;
} }
if (RpgWorldHost.Current?.View == null)
return;
window = ToolWindow.Open<RpgMapEditorWindow>(); window = ToolWindow.Open<RpgMapEditorWindow>();
step++; step++;
return; return;
@@ -95,6 +109,8 @@ namespace Cthangover.Tools.RpgEditor.Tests
if (canvas?.Document == null || canvas.Tileset == null) if (canvas?.Document == null || canvas.Tileset == null)
return; return;
Check(canvas.Document.Map.ID.StartsWith("rpg_"), $"the editor opened a demo map ({canvas.Document.Map.ID})"); Check(canvas.Document.Map.ID.StartsWith("rpg_"), $"the editor opened a demo map ({canvas.Document.Map.ID})");
Check(canvas.Document.Events.Count > 0 && canvas.Document.LockedEvents.Count == 0,
$"its events come from the mod's map_events file ({canvas.Document.Events.Count} own, {canvas.Document.LockedEvents.Count} locked)");
Shot("editor"); Shot("editor");
Press("tools/rpg_editor/layer_main"); Press("tools/rpg_editor/layer_main");
Check(canvas.ActiveLayer == RpgEditLayer.Main, "the layer button switches the brush to the main layer"); Check(canvas.ActiveLayer == RpgEditLayer.Main, "the layer button switches the brush to the main layer");
@@ -109,20 +125,31 @@ namespace Cthangover.Tools.RpgEditor.Tests
Check(window.Title.StartsWith("* "), "the window is marked dirty"); Check(window.Title.StartsWith("* "), "the window is marked dirty");
window.PushInput(new InputEventKey { Keycode = Key.Z, PhysicalKeycode = Key.Z, CtrlPressed = true, Pressed = true }); window.PushInput(new InputEventKey { Keycode = Key.Z, PhysicalKeycode = Key.Z, CtrlPressed = true, Pressed = true });
Check(canvas.Document.Grid.GetTile(RpgLayer.Main, PaintCell.X, PaintCell.Y) == before, "Ctrl+Z undoes the stroke"); Check(canvas.Document.Grid.GetTile(RpgLayer.Main, PaintCell.X, PaintCell.Y) == before, "Ctrl+Z undoes the stroke");
PrepareStampPreview();
step = 20;
return;
case 20:
Shot("stamp_preview");
PlaceStampAndUndo();
Press("tools/rpg_editor/layer_events"); Press("tools/rpg_editor/layer_events");
eventsBefore = canvas.Document.Map.Events.Count; eventsBefore = canvas.Document.Events.Count;
ClickCanvas(EventCell, true); ClickCanvas(EventCell, true);
Check(canvas.Document.Map.Events.Count == eventsBefore + 1 && canvas.SelectedEvent != null, "a double click adds and selects an event"); Check(canvas.Document.Events.Count == eventsBefore + 1 && canvas.SelectedEvent != null, "a double click adds and selects an event");
step++; step = 3;
return; return;
case 3: case 3:
// Screenshots one tick after the action, when the frame shows it. // Screenshots one tick after the action, when the frame shows it.
Shot("events"); Shot("events");
PlaceSlime();
PlaceAppearance();
Press("tools/rpg_editor/layer_collision"); Press("tools/rpg_editor/layer_collision");
step++; step++;
return; return;
case 4: case 4:
Shot("collision"); Shot("collision");
Shot("appearances");
closingPreview = window.FindChildren("*", "", true, false).OfType<RpgEntityPicker>().First(p => p.Name == "Appearances").Selected?.Icon;
closingSource = (closingPreview as AtlasTexture)?.Atlas;
window.QueueFree(); window.QueueFree();
window = ToolWindow.Open<RpgMapEditorWindow>(); window = ToolWindow.Open<RpgMapEditorWindow>();
step++; step++;
@@ -131,6 +158,8 @@ namespace Cthangover.Tools.RpgEditor.Tests
canvas = window.FindChildren("*", "", true, false).OfType<RpgMapEditorCanvas>().FirstOrDefault(); canvas = window.FindChildren("*", "", true, false).OfType<RpgMapEditorCanvas>().FirstOrDefault();
if (canvas?.Document == null) if (canvas?.Document == null)
return; return;
Check(closingPreview != null && !GodotObject.IsInstanceValid(closingPreview), "closing the editor releases the picker preview");
Check(closingSource != null && !GodotObject.IsInstanceValid(closingSource), "closing the editor releases the source texture cache");
newMapMod = Selector().CurrentMod; newMapMod = Selector().CurrentMod;
DeleteNewMapFiles(); DeleteNewMapFiles();
Selector().FindChildren("*", "", true, false).OfType<Button>().First(b => b.Text == "+").EmitSignal(BaseButton.SignalName.Pressed); Selector().FindChildren("*", "", true, false).OfType<Button>().First(b => b.Text == "+").EmitSignal(BaseButton.SignalName.Pressed);
@@ -149,8 +178,20 @@ namespace Cthangover.Tools.RpgEditor.Tests
return; return;
try try
{ {
canvas.Document.AddEvent(1, 1).Sprite = "village/elder";
var appearance = Pick("Appearances", 3, "seer", "rpg_plus/seer");
if (appearance != null)
{
Press("tools/rpg_editor/place_appearance");
ClickCanvas(new Vector2I(2, 2), false);
}
Press("tools/common/save"); Press("tools/common/save");
Check(File.Exists(ModFileService.GetWritablePath(newMapMod, "scenes/" + NewMap + ".json")), "the first save of a new map writes its scene"); Check(File.Exists(ModFileService.GetWritablePath(newMapMod, "scenes/" + NewMap + ".json")), "the first save of a new map writes its scene");
Check(!ModFileService.ReadText(newMapMod, "maps/" + NewMap + ".json").Contains("\"Events\""), "the map file holds no events");
Check(RpgMapEvents.Of(NewMap).Any(e => e.Id == "event_1" && e.ModId == newMapMod && e.File == RpgMapDocument.EventsPath(NewMap)),
"the event is saved into map_events and the game reads it at once");
Check(RpgMapEvents.Of(NewMap).Any(e => e.Sprite == "rpg_plus/seer" && e.X == 2 && e.Y == 2 && (e.Commands == null || e.Commands.Count == 0)),
"an appearance without a character entity saves and reloads as an NPC");
Check(GetTree().Root.GetNodeOrNull<SceneManager>("SceneManager")?.HasScene(NewMap) == true, "the game knows the new map's scene without a mod reload"); Check(GetTree().Root.GetNodeOrNull<SceneManager>("SceneManager")?.HasScene(NewMap) == true, "the game knows the new map's scene without a mod reload");
} }
finally finally
@@ -164,16 +205,107 @@ namespace Cthangover.Tools.RpgEditor.Tests
} }
} }
/// <summary>Characters tab: search "slime", pick it, "place as enemy", click a cell; items tab: two bundles of branches.</summary>
private void PlaceSlime()
{
var picker = Pick("Characters", 1, "slime", "rpg_slime");
if (picker == null)
return;
Check(picker.Selected.Look == "village/slime" && picker.Selected.Icon != null, $"the slime is picked with its charset ({picker.Selected.Look})");
var count = canvas.Document.Events.Count;
Press("tools/rpg_editor/place_enemy");
Check(canvas.Placing && canvas.ActiveLayer == RpgEditLayer.Events, "placing waits for a cell on the events layer");
ClickCanvas(CharacterCell, false);
var placed = canvas.SelectedEvent;
Check(canvas.Document.Events.Count == count + 1 && placed?.Sprite == "village/slime" && placed.X == CharacterCell.X && placed.Y == CharacterCell.Y,
"a click on the map puts the slime there");
Check(placed?.Commands?.FirstOrDefault()?.StartsWith("action rpg.battle enemies=rpg_slime ") == true && !canvas.Placing, "the placed enemy starts a battle with it");
picker = Pick("Items", 2, "branches", "rpg_demo/branches");
if (picker == null)
return;
Check(picker.Selected.Look == "rpg_demo/branch_bundle" && picker.Selected.Icon != null, $"the branches are picked with their picture ({picker.Selected.Look})");
var countSpin = picker.FindChildren("*", "", true, false).OfType<SpinBox>().First();
countSpin.Value = 2;
Press("tools/rpg_editor/place_item");
ClickCanvas(ItemCell, false);
placed = canvas.SelectedEvent;
Check(placed?.Icon == "rpg_demo/branch_bundle" && placed.X == ItemCell.X && placed.Trigger == RpgEventTrigger.Touch && placed.Solid == false,
"a click on the map lays the branches there, picked up by stepping on them");
Check(placed?.Commands?.Contains("action item.add id=rpg_demo/branches count=2") == true, "the pickup gives the chosen count");
}
/// <summary>Opens the side tab, types the query, checks every row matches it and selects <paramref name="id"/>; null when it is not listed.</summary>
private RpgEntityPicker Pick(string name, int tab, string query, string id)
{
window.FindChildren("*", "", true, false).OfType<TabContainer>().First(t => t.GetNodeOrNull<RpgEntityPicker>("Characters") != null).CurrentTab = tab;
var picker = window.FindChildren("*", "", true, false).OfType<RpgEntityPicker>().First(p => p.Name == name);
var search = picker.FindChildren("*", "", true, false).OfType<LineEdit>().First(e => e.GetParent() == picker);
search.Text = query;
search.EmitSignal(LineEdit.SignalName.TextChanged, query);
var list = picker.FindChildren("*", "", true, false).OfType<ItemList>().First();
Check(list.ItemCount >= 1 && Enumerable.Range(0, list.ItemCount).All(i => list.GetItemText(i).Contains(query, System.StringComparison.OrdinalIgnoreCase)),
$"the {name} search keeps only '{query}' ({list.ItemCount} rows)");
for (var i = 0; i < list.ItemCount; i++)
{
if (!list.GetItemText(i).EndsWith("· " + id))
continue;
list.Select(i);
list.EmitSignal(ItemList.SignalName.ItemSelected, i);
return picker;
}
Fail($"'{id}' is not in the {name} list");
return null;
}
private void PlaceAppearance()
{
var expected = new[] { "village/hero", "village/slime", "village/wolf", "village/elder", "village/merchant", "village/villager", "rpg_plus/trainer", "rpg_plus/seer" };
using var appearanceTextures = new RpgTextures();
var entries = RpgAppearanceCatalog.Load(appearanceTextures);
Check(entries.Count == expected.Length && expected.All(path => entries.Any(e => e.Look == path && e.Icon != null)),
"all eight appearances have previews, including NPCs without character entities");
foreach (var entry in entries.Where(e => e.OwnsIcon))
entry.Icon?.Dispose();
var picker = Pick("Appearances", 3, "elder", "village/elder");
if (picker == null) return;
var count = canvas.Document.Events.Count;
Press("tools/rpg_editor/place_appearance");
ClickCanvas(new Vector2I(6, 6), false);
var placed = canvas.SelectedEvent;
Check(canvas.Document.Events.Count == count+1 && placed?.Sprite == "village/elder" && placed.X == 6 && placed.Y == 6,
"the elder appearance is placed through the picker and canvas");
Check(placed?.Id.StartsWith("npc_") == true && placed.Scenario == null && placed.Condition == null && (placed.Commands == null || placed.Commands.Count == 0),
"a visual NPC has a safe event id and no fabricated battle enemy");
window.PushInput(new InputEventKey { Keycode = Key.Z, PhysicalKeycode = Key.Z, CtrlPressed = true, Pressed = true });
Check(canvas.Document.Events.Count == count, "undo removes the appearance placement");
window.PushInput(new InputEventKey { Keycode = Key.Y, PhysicalKeycode = Key.Y, CtrlPressed = true, Pressed = true });
Check(canvas.Document.Events.Count == count+1 && canvas.Document.Events.Any(e => e.Sprite == "village/elder" && e.X == 6 && e.Y == 6),
"redo restores the appearance placement");
Pick("Appearances", 3, "", "village/elder");
var previousPreview = picker.Selected?.Icon;
picker.Reload();
Check(previousPreview != null && !GodotObject.IsInstanceValid(previousPreview) && picker.Selected?.Id == "village/elder" && GodotObject.IsInstanceValid(picker.Selected?.Icon),
"reloading releases the old preview and preserves a valid selected appearance");
Check(picker.FindChildren("*", "", true, false).OfType<ItemList>().First().ItemCount == expected.Length,
"reloading the appearance tab keeps all eight rows without duplicates");
}
private ModFileSelector Selector() => window.FindChildren("*", "", true, false).OfType<ModFileSelector>().First(); private ModFileSelector Selector() => window.FindChildren("*", "", true, false).OfType<ModFileSelector>().First();
private void DeleteNewMapFiles() private void DeleteNewMapFiles()
{ {
foreach (var file in new[] { "maps/" + NewMap + ".json", "scenes/" + NewMap + ".json" }) foreach (var file in new[] { "maps/" + NewMap + ".json", "scenes/" + NewMap + ".json", RpgMapDocument.EventsPath(NewMap) })
{ {
var path = ModFileService.GetWritablePath(newMapMod, file); var path = ModFileService.GetWritablePath(newMapMod, file);
if (path != null && File.Exists(path)) if (path != null && File.Exists(path))
File.Delete(path); File.Delete(path);
} }
var eventsDir = Path.GetDirectoryName(ModFileService.GetWritablePath(newMapMod, RpgMapDocument.EventsPath(NewMap)) ?? "");
if (!string.IsNullOrEmpty(eventsDir) && Directory.Exists(eventsDir) && !Directory.EnumerateFileSystemEntries(eventsDir).Any())
Directory.Delete(eventsDir);
RpgMapEvents.Invalidate();
} }
private void Press(string key) private void Press(string key)
@@ -193,14 +325,43 @@ namespace Cthangover.Tools.RpgEditor.Tests
var palette = window.FindChildren("*", "", true, false).OfType<RpgTilePalette>().First(); var palette = window.FindChildren("*", "", true, false).OfType<RpgTilePalette>().First();
var tileset = canvas.Tileset; var tileset = canvas.Tileset;
var cell = palette.Size.X / tileset.Columns; var cell = palette.Size.X / tileset.Columns;
var position = new Vector2(index % tileset.Columns + 0.5f, index / tileset.Columns + 0.5f) * cell; var position = ((Vector2)RpgTileSetBuilder.AtlasCoords(tileset, index) + new Vector2(0.5f, 0.5f)) * cell;
palette.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true, Position = position }); palette.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true, Position = position });
palette.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = false, Position = position });
}
private void PrepareStampPreview()
{
var palette = window.FindChildren("*", "", true, false).OfType<RpgTilePalette>().First();
var cell = palette.Size.X / canvas.Tileset.Columns;
var start = ((Vector2)RpgTileSetBuilder.AtlasCoords(canvas.Tileset, 64) + new Vector2(0.5f, 0.5f)) * cell;
var end = start + new Vector2(cell, cell);
palette.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true, Position = start });
palette.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseMotion { Position = end, ButtonMask = MouseButtonMask.Left });
palette.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = false, Position = end });
Check(canvas.SelectedStamp?.Width == 2 && canvas.SelectedStamp.Height == 2, "dragging the palette selects a two-by-two brush");
Check(canvas.SelectedStamp?.TileAt(1, 1) == 68, "the selected rectangle preserves object tile arrangement");
var size = canvas.Tileset.TileSize * canvas.Zoom;
canvas.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseMotion { Position = canvas.ProjectCell((Vector2)PaintCell + new Vector2(0.5f, 0.5f)) });
}
private void PlaceStampAndUndo()
{
var before = canvas.Document.ToJson();
ClickCanvas(PaintCell, false);
Check(canvas.Document.Grid.GetTile(RpgLayer.Main, PaintCell.X, PaintCell.Y) == 64
&& canvas.Document.Grid.GetTile(RpgLayer.Main, PaintCell.X + 1, PaintCell.Y) == 65
&& canvas.Document.Grid.GetTile(RpgLayer.Main, PaintCell.X, PaintCell.Y + 1) == 67
&& canvas.Document.Grid.GetTile(RpgLayer.Main, PaintCell.X + 1, PaintCell.Y + 1) == 68,
"one canvas click places every cell of the rectangular brush");
window.PushInput(new InputEventKey { Keycode = Key.Z, PhysicalKeycode = Key.Z, CtrlPressed = true, Pressed = true });
Check(canvas.Document.ToJson() == before, "one undo restores the whole map before the group stamp");
} }
private void ClickCanvas(Vector2I cell, bool doubleClick) private void ClickCanvas(Vector2I cell, bool doubleClick)
{ {
var size = canvas.Tileset.TileSize * canvas.Zoom; var size = canvas.Tileset.TileSize * canvas.Zoom;
var position = new Vector2(cell.X + 0.5f, cell.Y + 0.5f) * size; var position = canvas.ProjectCell(new Vector2(cell.X + 0.5f, cell.Y + 0.5f));
canvas.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true, DoubleClick = doubleClick, Position = position }); canvas.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true, DoubleClick = doubleClick, Position = position });
canvas.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = false, Position = position }); canvas.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = false, Position = position });
} }
@@ -232,9 +393,16 @@ namespace Cthangover.Tools.RpgEditor.Tests
ViewportCapture.SavePng(GetViewport(), shotsDir.TrimEnd('/') + "/rpg_editor_" + name + ".png"); ViewportCapture.SavePng(GetViewport(), shotsDir.TrimEnd('/') + "/rpg_editor_" + name + ".png");
} }
private void Finish() private async void Finish()
{ {
if (step < 0)
return;
step = -1; step = -1;
if (GodotObject.IsInstanceValid(window) && !window.IsQueuedForDeletion())
window.QueueFree();
// Allow queued windows and their render resources to leave the tree before quitting.
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
GameLogger.Log("TEST", $"[TEST_RESULT] name=rpg_editor_edit passed={passed} failed={failures.Count}"); GameLogger.Log("TEST", $"[TEST_RESULT] name=rpg_editor_edit passed={passed} failed={failures.Count}");
GetTree().Quit(failures.Count == 0 ? 0 : 1); GetTree().Quit(failures.Count == 0 ? 0 : 1);
} }
@@ -27,7 +27,7 @@ namespace Cthangover.Tools.RpgEditor.Tests
.Select(a => a.Substring("--shots=".Length)) .Select(a => a.Substring("--shots=".Length))
.FirstOrDefault(); .FirstOrDefault();
GameLogger.Log("TEST", $"RpgEditorTestInitializer: activating rpg_editor_edit, shots={shots ?? "off"}"); GameLogger.Log("TEST", $"RpgEditorTestInitializer: activating rpg_editor_edit, shots={shots ?? "off"}");
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new RpgEditorTestDriver(shots)); ModRoot.AddDeferred(new RpgEditorTestDriver(shots));
} }
} }
} }
+133 -6
View File
@@ -8,6 +8,37 @@ namespace Cthangover.Tools.RpgEditor.Tests
{ {
private static RpgMapDocument NewMap() => RpgMapDocument.Create("test_map", "village", 5, 4, 0); private static RpgMapDocument NewMap() => RpgMapDocument.Create("test_map", "village", 5, 4, 0);
[ModTest("document/rectangle_stamp_preserves_arrangement_and_undo")]
static void RectangleStampAndUndo(ModTestContext ctx)
{
var document = NewMap();
var stamp = new RpgTileStamp(2, 2, new[] { 64, 65, 67, 68 });
document.BeginChange();
ModAssert.Equal(4, document.Stamp(RpgEditLayer.Front, 1, 1, stamp), "the whole rectangle is placed");
ModAssert.Equal(64, document.Grid.GetTile(RpgLayer.Front, 1, 1), "top left");
ModAssert.Equal(65, document.Grid.GetTile(RpgLayer.Front, 2, 1), "top right");
ModAssert.Equal(67, document.Grid.GetTile(RpgLayer.Front, 1, 2), "bottom left");
ModAssert.Equal(68, document.Grid.GetTile(RpgLayer.Front, 2, 2), "bottom right");
ModAssert.Equal(0, document.Stamp(RpgEditLayer.Front, 1, 1, stamp), "unchanged cells are not edits");
ModAssert.True(document.Undo(), "one undo removes the entire group");
ModAssert.Equal(-1, document.Grid.GetTile(RpgLayer.Front, 2, 2), "the final group cell is undone too");
ModAssert.True(document.Redo(), "one redo restores the group");
var restored = RpgMapDocument.Load(document.ToJson());
ModAssert.Equal(68, restored.Grid.GetTile(RpgLayer.Front, 2, 2), "the group survives saving and loading");
}
[ModTest("document/stamp_skips_padding_and_clips_at_map_edges")]
static void StampGapsAndBounds(ModTestContext ctx)
{
var document = NewMap();
var stamp = new RpgTileStamp(2, 2, new[] { -1, 11, 12, 13 });
ModAssert.Equal(0, document.Stamp(RpgEditLayer.Main, 4, 3, stamp), "padding does not erase the destination and other cells clip");
ModAssert.Equal(2, document.Stamp(RpgEditLayer.Main, -1, 0, stamp), "partially off-map stamps clip without shifting");
ModAssert.Equal(11, document.Grid.GetTile(RpgLayer.Main, 0, 0), "top-right stamp cell reaches the map edge");
ModAssert.Equal(13, document.Grid.GetTile(RpgLayer.Main, 0, 1), "bottom-right cell retains its offset");
ModAssert.Equal(0, document.Stamp(RpgEditLayer.Collision, 0, 0, stamp), "a tile stamp never changes collision codes");
}
[ModTest("document/create_fills_ground_and_centres_spawn")] [ModTest("document/create_fills_ground_and_centres_spawn")]
static void CreateFillsGroundAndCentresSpawn(ModTestContext ctx) static void CreateFillsGroundAndCentresSpawn(ModTestContext ctx)
{ {
@@ -58,7 +89,7 @@ namespace Cthangover.Tools.RpgEditor.Tests
document.AddEvent(3, 1).Sprite = "village/elder"; document.AddEvent(3, 1).Sprite = "village/elder";
ModAssert.True(document.Undo(), "undo the event"); ModAssert.True(document.Undo(), "undo the event");
ModAssert.Equal(0, document.Map.Events.Count, "event gone"); ModAssert.Equal(0, document.Events.Count, "event gone");
ModAssert.Equal(9, document.Grid.GetTile(RpgLayer.Front, 0, 0), "the earlier paint stays"); ModAssert.Equal(9, document.Grid.GetTile(RpgLayer.Front, 0, 0), "the earlier paint stays");
ModAssert.True(document.Undo(), "undo the paint"); ModAssert.True(document.Undo(), "undo the paint");
ModAssert.Equal(-1, document.Grid.GetTile(RpgLayer.Front, 0, 0), "paint gone"); ModAssert.Equal(-1, document.Grid.GetTile(RpgLayer.Front, 0, 0), "paint gone");
@@ -66,7 +97,7 @@ namespace Cthangover.Tools.RpgEditor.Tests
ModAssert.True(document.Redo(), "redo the paint"); ModAssert.True(document.Redo(), "redo the paint");
ModAssert.True(document.Redo(), "redo the event"); ModAssert.True(document.Redo(), "redo the event");
ModAssert.Equal("village/elder", document.Map.Events[0].Sprite, "event back with its data"); ModAssert.Equal("village/elder", document.Events[0].Sprite, "event back with its data");
document.Undo(); document.Undo();
document.BeginChange(); document.BeginChange();
@@ -87,6 +118,95 @@ namespace Cthangover.Tools.RpgEditor.Tests
ModAssert.Equal("event_1", document.AddEvent(0, 0).Id, "a freed id is used again"); ModAssert.Equal("event_1", document.AddEvent(0, 0).Id, "a freed id is used again");
} }
[ModTest("document/characters_become_npc_and_enemy_events")]
static void CharactersBecomeNpcAndEnemyEvents(ModTestContext ctx)
{
var document = NewMap();
var npc = document.AddCharacter(1, 2, "rpg_elder", "village/elder", false);
var enemy = document.AddCharacter(3, 1, "rpg_slime", "village/slime", true);
var second = document.AddCharacter(4, 1, "rpg_slime", null, true);
ModAssert.Equal("rpg_elder", npc.Id, "the character id is the event id");
ModAssert.Equal("village/elder", npc.Sprite, "the charset is the look");
ModAssert.Equal(1, npc.X, "x");
ModAssert.Equal(2, npc.Y, "y");
ModAssert.True(npc.Commands == null && npc.Condition == null, "an NPC gets no lines and no condition");
ModAssert.Equal("Action", npc.Trigger.ToString(), "talked to on E/F");
ModAssert.Equal("action rpg.battle enemies=rpg_slime win_flag=test_map_rpg_slime_won", enemy.Commands[0], "an enemy starts a battle with itself");
ModAssert.Equal("end", enemy.Commands[1], "and ends the dialog");
ModAssert.Equal("test_map_rpg_slime_won != 1", enemy.Condition, "hidden once beaten");
ModAssert.Equal("rpg_slime_2", second.Id, "a taken id gets a number");
ModAssert.Null(second.Sprite, "no charset: no look");
ModAssert.Equal("test_map_rpg_slime_2_won != 1", second.Condition, "each placed enemy is beaten separately");
ModAssert.Equal(0, document.Validate().Count, "the ids are valid");
var loaded = RpgMapDocument.Load(document.ToJson(), document.EventsToJson());
var restored = loaded.Events[1];
ModAssert.Equal("rpg_slime", restored.Id, "id survives the file");
ModAssert.Equal("village/slime", restored.Sprite, "sprite survives the file");
ModAssert.Equal(2, restored.Commands.Count, "commands survive the file");
ModAssert.Equal(enemy.Commands[0], restored.Commands[0], "battle line survives the file");
ModAssert.Equal(enemy.Condition, restored.Condition, "condition survives the file");
}
[ModTest("document/items_become_pickups")]
static void ItemsBecomePickups(ModTestContext ctx)
{
var document = NewMap();
var first = document.AddItem(2, 1, "rpg_demo/branches", "rpg_demo/branch_bundle", 3, "Found \"branches\"");
var second = document.AddItem(3, 1, "rpg_demo/branches", null, 0, "x");
ModAssert.Equal("rpg_demo_branches", first.Id, "the id holds no slash");
ModAssert.Equal("rpg_demo/branch_bundle", first.Icon, "the item picture is the look");
ModAssert.Equal("Touch", first.Trigger.ToString(), "picked up by stepping on it");
ModAssert.False(first.IsSolid, "the player can step on it");
ModAssert.Equal("ptext \"Found 'branches'\"", first.Commands[0], "the line, quotes made safe");
ModAssert.Equal("action item.add id=rpg_demo/branches count=3", first.Commands[1], "adds the stack");
ModAssert.Equal("flag test_map_rpg_demo_branches_taken 1", first.Commands[2], "remembers it was taken");
ModAssert.Equal("end", first.Commands[3], "ends the dialog");
ModAssert.Equal("test_map_rpg_demo_branches_taken != 1", first.Condition, "gone once taken");
ModAssert.Equal("rpg_demo_branches_2", second.Id, "a taken id gets a number");
ModAssert.Equal("action item.add id=rpg_demo/branches count=1", second.Commands[1], "at least one item");
ModAssert.Equal("test_map_rpg_demo_branches_2_taken != 1", second.Condition, "each pickup has its own flag");
var restored = RpgMapDocument.Load(document.ToJson(), document.EventsToJson()).Events[0];
ModAssert.Equal("rpg_demo/branch_bundle", restored.Icon, "icon survives the file");
ModAssert.Equal("Touch", restored.Trigger.ToString(), "trigger survives the file");
ModAssert.False(restored.IsSolid, "solidity survives the file");
ModAssert.Equal(4, restored.Commands.Count, "commands survive the file");
ModAssert.Equal(first.Condition, restored.Condition, "condition survives the file");
}
[ModTest("document/locked_events_are_shown_not_edited")]
static void LockedEventsAreShownNotEdited(ModTestContext ctx)
{
var document = NewMap();
document.SetLockedEvents(new[]
{
new RpgMapEvent { Id = "event_1", X = 1, Y = 1, ModId = "quest_mod", File = "map_events/test_map/quest.json" },
new RpgMapEvent { Id = "rpg_slime", X = 2, Y = 2, ModId = "quest_mod", File = "map_events/test_map/quest.json" }
});
ModAssert.Equal(0, document.EventsAt(1, 1).Count, "a locked event is not picked on its cell");
ModAssert.Equal("event_2", document.AddEvent(1, 1).Id, "a new event does not take a locked id");
ModAssert.Equal("rpg_slime_2", document.AddCharacter(0, 0, "rpg_slime", null, true).Id, "nor does a placed character");
document.BeginChange();
document.Events[0].Id = "EVENT_1";
var problems = document.Validate();
ModAssert.Equal(1, problems.Count, "renaming onto a locked id is a problem");
ModAssert.Equal("tools/rpg_editor/problem_event_locked", problems[0].Key, "the problem says why");
ModAssert.True(problems[0].Argument.Contains("quest_mod"), "and names the mod");
document.Undo();
ModAssert.Equal(2, document.LockedEvents.Count, "undo keeps the locked events");
ModAssert.Equal("event_2", document.Events[0].Id, "and restores the own ones");
ModAssert.False(document.EventsToJson().Contains("quest"), "locked events are not written");
}
[ModTest("document/resize_pulls_events_inside")] [ModTest("document/resize_pulls_events_inside")]
static void ResizePullsEventsInside(ModTestContext ctx) static void ResizePullsEventsInside(ModTestContext ctx)
{ {
@@ -128,15 +248,22 @@ namespace Cthangover.Tools.RpgEditor.Tests
mapEvent.Teleport = new RpgTeleport { Map = "other", X = 4, Y = 5, Dir = RpgDirection.Left }; mapEvent.Teleport = new RpgTeleport { Map = "other", X = 4, Y = 5, Dir = RpgDirection.Left };
var json = document.ToJson(); var json = document.ToJson();
var loaded = RpgMapDocument.Load(json); var eventsJson = document.EventsToJson();
var loaded = RpgMapDocument.Load(json, eventsJson);
ModAssert.True(json.Contains("\"Kind\": \"rpg_map\""), "the file declares its kind"); ModAssert.True(json.Contains("\"Kind\": \"rpg_map\""), "the file declares its kind");
ModAssert.False(json.Contains("\"Scenario\""), "empty fields are left out"); ModAssert.False(json.Contains("\"Events\""), "the map file holds no events");
ModAssert.True(eventsJson.Contains("\"Events\""), "the events file holds them");
ModAssert.False(eventsJson.Contains("IsSolid"), "the computed solidity is not written");
ModAssert.True(json.EndsWith("}\n") && eventsJson.EndsWith("}\n"), "both files end with a line break");
ModAssert.False(eventsJson.Contains("\"Scenario\""), "empty fields are left out");
ModAssert.Equal("map_events/test_map/events.json", RpgMapDocument.EventsPath("test_map"), "the editor's events file of the map");
ModAssert.Equal(0, RpgMapDocument.Load(json).Events.Count, "a map without an events file has no events");
ModAssert.Equal("test_map", loaded.Map.ID, "id"); ModAssert.Equal("test_map", loaded.Map.ID, "id");
ModAssert.Equal(16, loaded.Grid.GetTile(RpgLayer.Main, 3, 2), "main tile"); ModAssert.Equal(16, loaded.Grid.GetTile(RpgLayer.Main, 3, 2), "main tile");
ModAssert.Equal("o", loaded.Grid.GetCollision(0, 3).ToString(), "collision"); ModAssert.Equal("o", loaded.Grid.GetCollision(0, 3).ToString(), "collision");
ModAssert.Equal("Touch", loaded.Map.Events[0].Trigger.ToString(), "trigger"); ModAssert.Equal("Touch", loaded.Events[0].Trigger.ToString(), "trigger");
ModAssert.Equal("other", loaded.Map.Events[0].Teleport.Map, "teleport"); ModAssert.Equal("other", loaded.Events[0].Teleport.Map, "teleport");
ModAssert.Throws(() => RpgMapDocument.Load("{\"Kind\":\"rpg_map\",\"Items\":[]}"), "a file without maps", anyException: true); ModAssert.Throws(() => RpgMapDocument.Load("{\"Kind\":\"rpg_map\",\"Items\":[]}"), "a file without maps", anyException: true);
} }
} }
+34
View File
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.Rpg;
using Godot;
namespace Cthangover.Tools.RpgEditor.Tests
{
/// <summary>Palette rectangles resolve physical cells back to stable logical map ids.</summary>
public class RpgTileStampTests
{
[ModTest("stamp/atlas_rectangle_resolves_packed_ids_and_padding")]
static void PackedRectangle(ModTestContext ctx)
{
var tileset = new RpgTileset { Columns = 3, Tiles = new List<RpgTileInfo>
{
new() { Index = 64, AtlasIndex = 1 }, new() { Index = 65, AtlasIndex = 2 },
new() { Index = 67, AtlasIndex = 4 }
}};
var stamp = RpgTileStamp.FromAtlas(tileset, new Rect2I(1, 0, 2, 2));
ModAssert.Equal(2, stamp.Width, "rectangle width");
ModAssert.Equal(2, stamp.Height, "rectangle height");
ModAssert.Equal(64, stamp.TileAt(0, 0), "top left logical id");
ModAssert.Equal(65, stamp.TileAt(1, 0), "top right logical id");
ModAssert.Equal(67, stamp.TileAt(0, 1), "bottom left logical id");
ModAssert.Equal(-1, stamp.TileAt(1, 1), "an empty physical slot stays a gap");
ModAssert.Equal(-1, stamp.TileAt(-1, 0), "outside selection is empty");
ModAssert.Equal(40, RpgTileStamp.Single(40).TileAt(0, 0), "surface brushes also retain logical ids");
var values = new[] { 11 };
var copied = new RpgTileStamp(1, 1, values);
values[0] = 99;
ModAssert.Equal(11, copied.TileAt(0, 0), "the brush owns its tile snapshot");
}
}
}