Compare commits
2
Commits
dbe46a05bf
...
bbdfe95de3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbdfe95de3 | ||
|
|
c585960c61 |
No files matched your search
+38
@@ -0,0 +1,38 @@
|
||||
# Godot 4.x
|
||||
.godot/
|
||||
.idea/
|
||||
sdk/
|
||||
bin/
|
||||
obj/
|
||||
|
||||
#ai
|
||||
.omo/
|
||||
.opencode/
|
||||
|
||||
gen/
|
||||
obj/
|
||||
bin/
|
||||
art_sources/
|
||||
|
||||
*.tres~
|
||||
*.tscn~
|
||||
|
||||
# GDScript LSP
|
||||
.godot/
|
||||
|
||||
# Export
|
||||
*.exe
|
||||
*.dmg
|
||||
*.apk
|
||||
*.aab
|
||||
*.pck
|
||||
*.zip
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
*.import
|
||||
*.godot.uid
|
||||
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
@@ -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:
|
||||
|
||||

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

|
||||
|
||||
After **Save** (Ctrl+S) the merchant stands in the house:
|
||||
|
||||

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

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

|
||||
|
||||
Tras **guardar** (Ctrl+S), el mercader está en la casa:
|
||||
|
||||

|
||||
|
||||
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).
|
||||
|
||||

|
||||
|
||||
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 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
@@ -0,0 +1,57 @@
|
||||
# RPG Map Editor — タイルマップを描き、イベントを置く
|
||||
|
||||
RPG マップエディターは、実行中のゲームの上で RPG エンジンのタイルマップを編集します。3 つのタイルレイヤー、壁と通路、プレイヤーの開始地点、ランダムバトル、そしてイベント——キャラクター、看板、ドア、拾えるアイテム。保存するとマップはすぐにゲームへ反映され、**プレイ**で実際に歩き回れます。
|
||||
|
||||
ツールボックス(ツールバーのハンマー)から **RPG マップエディター** として開きます。対象は、プレイヤーが今いるマップです。
|
||||
|
||||
## 編集と、その結果のゲーム内での様子
|
||||
|
||||
RPG Plus の長老の家、編集前:
|
||||
|
||||

|
||||
|
||||
エディターでは、**Appearance**(外観)タブで商人のキャラチップを選び、**Place NPC**(NPC を配置)を押して、空いたマスをクリックします。新しいイベントは **イベント** レイヤーで選択され、プロパティが右に表示されます。
|
||||
|
||||

|
||||
|
||||
**保存**(Ctrl+S)のあと、商人が家に立っています:
|
||||
|
||||

|
||||
|
||||
彼の会話と振る舞いはイベントのプロパティで設定します。シナリオ、条件、起動方法です。
|
||||
|
||||
## レイヤーとツール
|
||||
|
||||
- **背景**、**メイン**、**前景** — タイルレイヤーです。背景とメインは solid なタイルで通行を妨げられます。前景はキャラクターの上に描かれ、決して妨げません。
|
||||
- **衝突** — タイルの上に塗ります。*壁*(常に通れない)、*通路*(常に通れる)、*自動*(タイルに従う)。
|
||||
- **イベント** — クリックで選択・ドラッグ、空いたマスのダブルクリックで追加、Del で削除。
|
||||
- **鉛筆**、**消しゴム**、**塗りつぶし**。右クリックで消し、Alt+クリックでタイルを拾い、1–5 キーでレイヤー、B/E/G でブラシを切り替えます。
|
||||
- **元に戻す**/**やり直す**(Ctrl+Z / Ctrl+Y)、グリッド、ズーム(Ctrl+ホイール)。
|
||||
|
||||

|
||||
|
||||
左のタブは **タイル**(マップのタイルセットのパレット。家や木のようなオブジェクト全体を一度に置けます)、**キャラクター**(戦闘ステータスを持つキャラクター。敵として配置)、**アイテム**(個数つきの拾えるアイテムとして配置)、そして **Appearance**(読み込まれた MOD の全スプライト。NPC として配置)です。
|
||||
|
||||
右の列にはマップのプロパティが並びます。名前のキー、タイルセット、サイズ、拡大率、固定の環境光と雰囲気、開始地点と向き、そしてランダムバトル(約 N 歩ごと、敵グループは `slime; wolf,slime` のように書きます)。
|
||||
|
||||
## トップダウンとアイソメトリック
|
||||
|
||||
エディターはゲームと同じようにマップを描きます。ツールバーのビュー切り替えはプレビューだけを変え、マップのデータは変わりません。アイソメトリックプレビューの家:
|
||||
|
||||

|
||||
|
||||
ゲーム本体のビューはコレクション設定 `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
@@ -0,0 +1,57 @@
|
||||
# RPG Map Editor — 타일 맵을 그리고 이벤트 배치하기
|
||||
|
||||
RPG 맵 에디터는 게임이 실행되는 동안 RPG 엔진의 타일 맵을 편집합니다. 타일 레이어 3개, 벽과 통로, 플레이어 시작 지점, 랜덤 전투, 그리고 이벤트 — 캐릭터, 표지판, 문, 주울 수 있는 아이템까지. 저장하면 맵이 곧바로 게임에 반영되고, **플레이**로 직접 걸어 볼 수 있습니다.
|
||||
|
||||
툴박스(툴바의 망치)에서 **RPG 맵 에디터**로 열리며, 플레이어가 서 있는 맵을 대상으로 합니다.
|
||||
|
||||
## 편집 하나와 그 결과
|
||||
|
||||
편집 전의 RPG Plus 장로의 집:
|
||||
|
||||

|
||||
|
||||
에디터에서는 **Appearance**(외형) 탭에서 상인 캐릭터 시트를 고르고, **Place NPC**(NPC로 배치)를 누른 뒤 빈 칸을 클릭합니다. 새 이벤트는 **이벤트** 레이어에서 선택되고 속성이 오른쪽에 표시됩니다:
|
||||
|
||||

|
||||
|
||||
**저장**(Ctrl+S) 후에는 상인이 집 안에 서 있습니다:
|
||||
|
||||

|
||||
|
||||
그의 대화와 행동은 이벤트 속성에서 정합니다. 시나리오, 조건, 발동 방식입니다.
|
||||
|
||||
## 레이어와 도구
|
||||
|
||||
- **배경**, **메인**, **전경** — 타일 레이어입니다. 배경과 메인은 막힌 타일로 이동을 막을 수 있고, 전경은 캐릭터 위에 그려지며 절대 막지 않습니다.
|
||||
- **충돌** — 타일 위에 칠합니다: *벽*(항상 막힘), *통로*(항상 지나갈 수 있음), *자동*(타일에 따름).
|
||||
- **이벤트** — 클릭으로 선택하거나 끌고, 빈 칸을 더블클릭해 추가하고, Del로 삭제합니다.
|
||||
- **연필**, **지우개**, **채우기**. 오른쪽 버튼은 지우기, Alt+클릭은 타일 집기, 1–5 키는 레이어 전환, B/E/G는 브러시 전환입니다.
|
||||
- **되돌리기**/**다시 실행**(Ctrl+Z / Ctrl+Y), 격자, 확대·축소(Ctrl+휠).
|
||||
|
||||

|
||||
|
||||
왼쪽 탭: **타일**(맵 타일셋 팔레트, 집이나 나무 같은 온전한 물체를 한 번에 찍을 수 있음), **캐릭터**(전투 스탯이 있는 캐릭터, 적으로 배치), **아이템**(개수를 정해 주울 수 있게 배치), 그리고 **Appearance**(불러온 모드의 모든 스프라이트, NPC로 배치).
|
||||
|
||||
오른쪽 열에는 맵 속성이 있습니다: 이름 키, 타일셋, 크기, 배율, 고정 환경광과 분위기, 시작 지점과 방향, 그리고 랜덤 전투(약 N걸음마다, `slime; wolf,slime` 같은 적 그룹).
|
||||
|
||||
## 탑다운과 아이소메트릭
|
||||
|
||||
에디터는 게임과 같은 방식으로 맵을 그립니다. 툴바의 보기 전환기는 미리 보기만 바꾸고 맵 데이터는 그대로입니다. 아이소메트릭 미리 보기의 집:
|
||||
|
||||

|
||||
|
||||
게임 자체의 보기는 컬렉션 설정 `tile.renderer`(`topdown` 또는 `isometric`)가 정합니다. `--editor-renderer=isometric`로 시작하면 에디터가 처음부터 아이소메트릭 미리 보기로 열립니다.
|
||||
|
||||
## 어떤 파일에 기록되는가
|
||||
|
||||
- 맵 자체는 자기 `maps/<id>.json`에 저장됩니다.
|
||||
- 이벤트는 같은 모드 안의 그 맵 고유의 이벤트 파일(`map_events/<맵>/…`)에 기록됩니다. 다른 파일이나 다른 모드가 맵에 추가한 이벤트는 점선 테두리와 함께 흐리게 표시되며 절대 다시 쓰이지 않고, 그 id는 재사용할 수 없습니다.
|
||||
- 파일 목록 옆의 **+**는 선택한 모드에 새 맵을 만듭니다. 저장하면 게임이 곧바로 씬으로 인식합니다.
|
||||
- 기록할 수 있는 것은 폴더로 설치된 모드뿐입니다.
|
||||
|
||||
## 문제가 생겼을 때
|
||||
|
||||
1. **플레이가 게임이 씬을 모른다고 말한다.** 그 맵의 모드에 `scenes/<id>.json`이 필요합니다.
|
||||
2. **이벤트가 선택되지 않는다.** 다른 파일이나 모드의 것입니다(점선 테두리).
|
||||
3. **NPC가 말하지 않는다.** 이벤트에 시나리오가 없거나, 발동이 액션 키가 아닙니다.
|
||||
4. **저장하면 건드리지 않은 줄까지 바뀐다.** 에디터는 자기 순서대로 파일을 쓰고 이벤트의 기본값도 채워 넣으므로, diff가 편집보다 많은 것을 보여 줄 수 있습니다.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# RPG Map Editor — рисование тайловых карт и расстановка событий
|
||||
|
||||
RPG Map Editor правит тайловые карты движка RPG прямо во время игры: три слоя тайлов, стены и проходы, точку появления героя, случайные бои и события — персонажей, таблички, двери, подбираемые предметы. Сохранение сразу обновляет карту в запущенной игре, а кнопка **Играть** позволяет по ней пройтись.
|
||||
|
||||
Открывается редактор из набора инструментов (молоток на панели инструментов) как **Редактор RPG-карт** — для той карты, на которой сейчас стоит игрок.
|
||||
|
||||
## Правка и её результат в игре
|
||||
|
||||
Дом старейшины из RPG Plus до правки:
|
||||
|
||||

|
||||
|
||||
В редакторе: вкладка **Внешность**, спрайт торговца, кнопка **Разместить NPC**, клик по свободной клетке. Новое событие выбрано на слое **События**, его свойства — справа:
|
||||
|
||||

|
||||
|
||||
После **сохранения** (Ctrl+S) торговец стоит в доме:
|
||||
|
||||

|
||||
|
||||
Его диалог и поведение задаются в свойствах события: сценарий, условие, способ запуска.
|
||||
|
||||
## Слои и инструменты
|
||||
|
||||
- **Фон**, **Основной**, **Передний** — слои тайлов. Фон и Основной могут перекрывать проход непроходимыми тайлами; Передний рисуется поверх персонажей и никогда не блокирует.
|
||||
- **Коллизии** — рисуется поверх тайлов: *Стена* (всегда непроходимо), *Проход* (всегда проходимо) или *Авто* (по тайлам).
|
||||
- **События** — клик выбирает или тащит событие, двойной клик по пустой клетке добавляет, Del удаляет.
|
||||
- **Карандаш**, **Ластик**, **Заливка**; правая кнопка мыши стирает, Alt+клик берёт тайл, клавиши 1–5 переключают слои, B/E/G — кисти.
|
||||
- **Отменить**/**Вернуть** (Ctrl+Z / Ctrl+Y), сетка, масштаб (Ctrl+колесо).
|
||||
|
||||

|
||||
|
||||
Вкладки слева: **Тайлы** (палитра тайлсета карты; целые объекты вроде дома или дерева можно ставить одним штампом), **Персонажи** (персонажи с боевыми характеристиками, размещаются как враги), **Предметы** (раскладываются как подбираемые предметы с количеством) и **Внешность** (все спрайты загруженных модов, размещаются как NPC).
|
||||
|
||||
Правый столбец — свойства карты: ключ имени, тайлсет, размер, масштаб, своё освещение и атмосфера, точка появления и направление взгляда, а также случайные бои (примерно раз в N шагов, группы врагов вида `slime; wolf,slime`).
|
||||
|
||||
## Вид сверху и изометрия
|
||||
|
||||
Редактор рисует карту так же, как её рисует игра. Переключатель вида на панели инструментов меняет только предпросмотр; данные карты остаются прежними. Дом в изометрическом предпросмотре:
|
||||
|
||||

|
||||
|
||||
Сам вид игры задаёт настройка коллекции `tile.renderer` (`topdown` или `isometric`); ключ `--editor-renderer=isometric` открывает редактор сразу в изометрическом предпросмотре.
|
||||
|
||||
## Какие файлы записываются
|
||||
|
||||
- Сама карта сохраняется в свой `maps/<id>.json`.
|
||||
- События попадают в файл событий этой карты в том же моде (`map_events/<карта>/…`). События, которые добавляют на карту другие файлы или другие моды, показываются приглушённо, с пунктирной рамкой, и никогда не перезаписываются; их id нельзя использовать повторно.
|
||||
- **+** рядом со списком файлов создаёт новую карту в выбранном моде; после сохранения игра сразу знает её как сцену.
|
||||
- Записывать можно только в моды, установленные папками.
|
||||
|
||||
## Если что-то пошло не так
|
||||
|
||||
1. **«Играть» сообщает, что игра не знает сцену.** Карте нужен файл `scenes/<id>.json` в её моде.
|
||||
2. **Событие не выбирается.** Оно принадлежит другому файлу или другому моду (пунктирная рамка).
|
||||
3. **NPC не разговаривает.** У его события нет сценария, либо запуск у него не по клавише действия.
|
||||
4. **Сохранение меняет строки, которые вы не трогали.** Редактор записывает файлы в своём порядке и добавляет значения по умолчанию для полей событий, поэтому diff может показать больше, чем сама правка.
|
||||
+57
@@ -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:
|
||||
|
||||

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

|
||||
|
||||
Sau khi **lưu** (Ctrl+S), thương nhân đã đứng trong nhà:
|
||||
|
||||

|
||||
|
||||
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).
|
||||
|
||||

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

|
||||
|
||||
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
@@ -0,0 +1,57 @@
|
||||
# RPG Map Editor — 绘制图块地图、摆放事件
|
||||
|
||||
RPG 地图编辑器在游戏运行时编辑 RPG 引擎的图块地图:三个图块图层、墙壁与通道、玩家出生点、随机战斗,以及事件——人物、告示牌、门、可拾取的物品。保存后地图立即在游戏中更新,按 **试玩** 就能走一走。
|
||||
|
||||
它从工具箱(工具栏上的锤子)打开,名为 **RPG 地图编辑器**,作用于玩家当前所在的地图。
|
||||
|
||||
## 一次编辑及其在游戏中的结果
|
||||
|
||||
RPG Plus 中长者之家编辑前的样子:
|
||||
|
||||

|
||||
|
||||
在编辑器里:**Appearance**(外观)标签页、商人的行走图、**Place NPC**(放置 NPC),再点一个空格子。新事件会在 **事件** 图层中被选中,属性显示在右侧:
|
||||
|
||||

|
||||
|
||||
**保存**(Ctrl+S)之后,商人就站进了屋里:
|
||||
|
||||

|
||||
|
||||
他的对话与行为在事件属性里设置:剧本、条件、触发方式。
|
||||
|
||||
## 图层与工具
|
||||
|
||||
- **背景层**、**主层**、**前景层** — 图块图层。背景层和主层可以用实心图块挡住去路;前景层画在角色之上,从不挡路。
|
||||
- **碰撞** — 画在图块之上:*墙*(始终阻挡)、*通道*(始终可走)或 *自动*(取决于图块)。
|
||||
- **事件** — 点击选中或拖动,双击空格子添加,Del 删除。
|
||||
- **铅笔**、**橡皮**、**填充**;鼠标右键擦除,Alt+点击吸取图块,数字键 1–5 切换图层,B/E/G 切换画笔。
|
||||
- **撤销**/**重做**(Ctrl+Z / Ctrl+Y)、网格、缩放(Ctrl+滚轮)。
|
||||
|
||||

|
||||
|
||||
左侧标签页:**瓦片**(地图图块集的调色板,整栋房子或整棵树这样的完整对象可以一次盖印)、**角色**(带战斗属性的角色,放置为敌人)、**物品**(放置为带数量的可拾取物)和 **Appearance**(已加载模组的全部行走图,放置为 NPC)。
|
||||
|
||||
右栏是地图属性:名称键、图块集、尺寸、缩放、固定环境光与氛围、出生点和朝向,以及随机战斗(约每 N 步一次,敌人组形如 `slime; wolf,slime`)。
|
||||
|
||||
## 俯视与等距
|
||||
|
||||
编辑器绘制地图的方式与游戏一致。工具栏上的视图切换器只改变预览;地图数据保持不变。等距预览中的房子:
|
||||
|
||||

|
||||
|
||||
游戏本身采用哪种视图由集合设置 `tile.renderer`(`topdown` 或 `isometric`)决定;加上 `--editor-renderer=isometric` 会直接以等距预览打开编辑器。
|
||||
|
||||
## 会写入哪些文件
|
||||
|
||||
- 地图本身保存到它自己的 `maps/<id>.json`。
|
||||
- 事件写入同一模组中该地图自己的事件文件(`map_events/<地图>/…`)。其他文件或其他模组加进这张地图的事件会以虚线框半透明显示,永远不会被重写;它们的 id 不能重复使用。
|
||||
- 文件列表旁的 **+** 在所选模组里新建地图;保存之后,游戏立刻把它当作场景。
|
||||
- 只有以文件夹形式安装的模组才能写入。
|
||||
|
||||
## 如果遇到问题
|
||||
|
||||
1. **试玩提示游戏不认识该场景。** 地图需要在它的模组里有 `scenes/<id>.json`。
|
||||
2. **事件选不中。** 它属于另一个文件或模组(虚线框)。
|
||||
3. **NPC 不说话。** 它的事件没有剧本,或者触发方式不是动作键。
|
||||
4. **保存改动了你没碰过的行。** 编辑器按自己的顺序写文件,还会补上事件的默认值,所以 diff 显示的内容可能多于这次编辑。
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_rpg_editor_name = RPG マップエディター
|
||||
mod_rpg_editor_desc = 実行中のゲームで RPG エンジンのタイルマップを描きます。タイルレイヤー 3 枚、衝突、イベント、開始地点、ランダムエンカウント。保存すると世界がすぐに更新されます。
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_rpg_editor_name = RPG 맵 에디터
|
||||
mod_rpg_editor_desc = 게임이 실행되는 동안 RPG 엔진의 타일 맵을 그립니다. 타일 레이어 3개, 충돌, 이벤트, 시작 지점, 랜덤 인카운터. 저장하면 세계가 곧바로 갱신됩니다.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_rpg_editor_name = Редактор RPG-карт
|
||||
mod_rpg_editor_desc = Рисует тайловые карты движка RPG прямо во время игры: три слоя тайлов, коллизии, события, точка появления и случайные встречи. Сохранение мгновенно обновляет мир.
|
||||
+17
-2
@@ -1,5 +1,3 @@
|
||||
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/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
|
||||
@@ -79,6 +77,11 @@ 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/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
|
||||
@@ -110,3 +113,15 @@ tools/rpg_editor/light_color_tip = #rrggbb or #rrggbbaa (alpha is the strength);
|
||||
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
|
||||
@@ -1,5 +1,3 @@
|
||||
mod_rpg_editor_name = Editor de mapas RPG
|
||||
mod_rpg_editor_desc = Editor de mapas de casillas del mundo RPG: tres capas de casillas, la capa de colisiones y los eventos del mapa, con deshacer y vista previa en vivo en el juego en marcha.
|
||||
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
|
||||
@@ -110,3 +108,6 @@ tools/rpg_editor/light_color_tip = #rrggbb o #rrggbbaa (alfa = intensidad); vac
|
||||
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
|
||||
@@ -1,5 +1,3 @@
|
||||
mod_rpg_editor_name = RPG マップエディター
|
||||
mod_rpg_editor_desc = RPG ワールドのタイルマップエディター:3 つのタイルレイヤー、衝突レイヤー、マップのイベント。元に戻す機能と、実行中のゲームでのライブプレビュー付き。
|
||||
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 = 背景
|
||||
@@ -110,3 +108,6 @@ 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=アイソメトリック
|
||||
@@ -1,5 +1,3 @@
|
||||
mod_rpg_editor_name = RPG 맵 에디터
|
||||
mod_rpg_editor_desc = RPG 월드의 타일 맵 에디터: 타일 레이어 3개, 충돌 레이어, 맵 이벤트. 되돌리기와 실행 중인 게임에서의 실시간 미리 보기 지원.
|
||||
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 = 배경
|
||||
@@ -110,3 +108,6 @@ 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=아이소메트릭
|
||||
+17
-2
@@ -1,5 +1,3 @@
|
||||
mod_rpg_editor_name = Редактор RPG-карт
|
||||
mod_rpg_editor_desc = Редактор тайловых карт RPG-мира: три слоя тайлов, слой коллизий и события на карте, с отменой и живым предпросмотром в запущенной игре.
|
||||
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 = Фон
|
||||
@@ -79,6 +77,11 @@ tools/rpg_editor/problem_event_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; реплики пишутся в «Строках сценария»
|
||||
@@ -110,3 +113,15 @@ 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=Изометрия
|
||||
@@ -1,5 +1,3 @@
|
||||
mod_rpg_editor_name = Trình sửa bản đồ RPG
|
||||
mod_rpg_editor_desc = Trình sửa bản đồ ô cho thế giới RPG: ba lớp ô, lớp va chạm và sự kiện trên bản đồ, có hoàn tác và xem trước trực tiếp trong game đang chạy.
|
||||
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
|
||||
@@ -110,3 +108,6 @@ tools/rpg_editor/light_color_tip = #rrggbb hoặc #rrggbbaa (alpha là cường
|
||||
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ự
|
||||
@@ -1,5 +1,3 @@
|
||||
mod_rpg_editor_name = RPG 地图编辑器
|
||||
mod_rpg_editor_desc = RPG 世界的瓦片地图编辑器:三层瓦片、碰撞层和地图事件,支持撤销,并在运行中的游戏里实时预览。
|
||||
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 = 背景层
|
||||
@@ -110,3 +108,6 @@ 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=等距
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_rpg_editor_name = RPG 地图编辑器
|
||||
mod_rpg_editor_desc = 在游戏运行时绘制 RPG 引擎的图块地图:三个图块图层、碰撞、事件、出生点和随机遇敌。一次保存,世界立即更新。
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"name": "RPG Map Editor",
|
||||
"version": "1.0.0",
|
||||
"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 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",
|
||||
"sources": [
|
||||
"src/**/*.cs"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,8 @@ namespace Cthangover.Tools.RpgEditor
|
||||
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))
|
||||
Icon = FrontFrame(charset == null ? null : textures.Charset(charset)),
|
||||
OwnsIcon = true
|
||||
};
|
||||
|
||||
private static string CharsetOf(CharacterInfo info, HashSet<string> charsets)
|
||||
@@ -54,7 +55,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
return candidates.FirstOrDefault(path => !string.IsNullOrEmpty(path) && charsets.Contains(path));
|
||||
}
|
||||
|
||||
private static Texture2D FrontFrame(Texture2D image)
|
||||
internal static Texture2D FrontFrame(Texture2D image)
|
||||
{
|
||||
if (image == null)
|
||||
return null;
|
||||
|
||||
+22
-4
@@ -84,13 +84,14 @@ namespace Cthangover.Tools.RpgEditor
|
||||
/// <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();
|
||||
Filter(selectedId);
|
||||
}
|
||||
|
||||
private void Filter()
|
||||
private void Filter(string selectedId)
|
||||
{
|
||||
var selectedId = Selected?.Id;
|
||||
var query = search.Text.Trim();
|
||||
shown.Clear();
|
||||
shown.AddRange(all.Where(entry => query.Length == 0
|
||||
@@ -108,6 +109,23 @@ namespace Cthangover.Tools.RpgEditor
|
||||
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)
|
||||
@@ -120,7 +138,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
button.Disabled = Selected == null;
|
||||
}
|
||||
|
||||
private void OnSearchChanged(string text) => Filter();
|
||||
private void OnSearchChanged(string text) => Filter(Selected?.Id);
|
||||
|
||||
private void OnItemSelected(long index) => UpdateButtons();
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ namespace Cthangover.Tools.RpgEditor
|
||||
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 bool suppress;
|
||||
private bool editing;
|
||||
@@ -87,6 +90,12 @@ namespace Cthangover.Tools.RpgEditor
|
||||
commandsEdit.TextChanged += OnCommandsChanged;
|
||||
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");
|
||||
@@ -128,6 +137,9 @@ namespace Cthangover.Tools.RpgEditor
|
||||
teleportX.Value = value.Teleport?.X ?? -1;
|
||||
teleportY.Value = value.Teleport?.Y ?? -1;
|
||||
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;
|
||||
@@ -154,6 +166,8 @@ namespace Cthangover.Tools.RpgEditor
|
||||
|
||||
private void OnCommandsChanged() => Write();
|
||||
|
||||
private void OnRouteRepeat(bool value) => Write();
|
||||
|
||||
private void OnDeletePressed()
|
||||
{
|
||||
if (mapEvent != null)
|
||||
@@ -195,6 +209,9 @@ namespace Cthangover.Tools.RpgEditor
|
||||
}
|
||||
: 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 }
|
||||
|
||||
@@ -146,6 +146,22 @@ namespace Cthangover.Tools.RpgEditor
|
||||
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>
|
||||
/// 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.
|
||||
|
||||
+173
-142
@@ -1,10 +1,12 @@
|
||||
using System;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Mods.Rpg;
|
||||
using Godot;
|
||||
|
||||
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>
|
||||
/// 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
|
||||
@@ -37,6 +39,13 @@ namespace Cthangover.Tools.RpgEditor
|
||||
private static readonly Color LockedModulate = new(1f, 1f, 1f, 0.55f);
|
||||
|
||||
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 bool stroking;
|
||||
private RpgMapEvent dragged;
|
||||
@@ -63,6 +72,9 @@ namespace Cthangover.Tools.RpgEditor
|
||||
/// <summary>Atlas index the pencil and the fill put down.</summary>
|
||||
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>
|
||||
public char SelectedCollision { get; set; } = RpgCollision.Block;
|
||||
|
||||
@@ -99,11 +111,16 @@ namespace Cthangover.Tools.RpgEditor
|
||||
/// <summary>Creates the canvas; input comes through the GuiInput event.</summary>
|
||||
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;
|
||||
FocusMode = FocusModeEnum.Click;
|
||||
TextureFilter = TextureFilterEnum.Nearest;
|
||||
GuiInput += OnGuiInput;
|
||||
MouseExited += OnMouseExited;
|
||||
overlay = new RpgMapEditorOverlay(this);
|
||||
AddChild(overlay);
|
||||
}
|
||||
|
||||
private float CellSize => (Tileset?.TileSize ?? 32) * Zoom;
|
||||
@@ -115,160 +132,168 @@ namespace Cthangover.Tools.RpgEditor
|
||||
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()
|
||||
{
|
||||
var grid = Document?.Grid;
|
||||
CustomMinimumSize = grid == null ? Vector2.Zero : new Vector2(grid.Width, grid.Height) * CellSize;
|
||||
if (Document == null || Tileset == null) { ReleasePreview(); CustomMinimumSize = Vector2.Zero; QueueRedraw(); return; }
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Draws the map.</summary>
|
||||
public override void _Draw()
|
||||
private void AddPreviewEvent(TileFrame frame, RpgRenderSceneBuilder builder, RpgMapEvent mapEvent, bool locked)
|
||||
{
|
||||
DrawRect(new Rect2(Vector2.Zero, Size), UiTokens.Surface0);
|
||||
var grid = Document?.Grid;
|
||||
if (grid == null)
|
||||
return;
|
||||
frame.Actors.Add(new TileActorPose { Id = (locked ? "locked/" : "own/") + mapEvent.Id,
|
||||
Position = new Vector2(mapEvent.X, mapEvent.Y), Visible = true, Look = builder.EventLook(mapEvent, Tileset),
|
||||
Facing = (int)mapEvent.Dir, Charset = !string.IsNullOrEmpty(mapEvent.Sprite), Tint = locked ? LockedModulate : Colors.White });
|
||||
}
|
||||
|
||||
DrawRect(new Rect2(Vector2.Zero, new Vector2(grid.Width, grid.Height) * CellSize), Colors.Black);
|
||||
DrawLayer(RpgLayer.Back);
|
||||
DrawLayer(RpgLayer.Main);
|
||||
DrawEvents();
|
||||
DrawLayer(RpgLayer.Front);
|
||||
DrawSpawn();
|
||||
if (ActiveLayer == RpgEditLayer.Collision)
|
||||
DrawCollision();
|
||||
public new void QueueRedraw() { base.QueueRedraw(); overlay?.QueueRedraw(); }
|
||||
|
||||
public override void _Draw() => DrawRect(new Rect2(Vector2.Zero, Size), UiTokens.Surface0);
|
||||
|
||||
/// <summary>Mouse and automation coordinates use the real presentation transform, including negative iso X.</summary>
|
||||
public Vector2 ProjectCell(Vector2 cell) => preview?.ProjectToView(new Vector3(cell.X, cell.Y, 0)) ?? cell * CellSize;
|
||||
|
||||
public void DrawOverlays(CanvasItem target)
|
||||
{
|
||||
if (Document == null || projection == null) return;
|
||||
var grid = Document.Grid;
|
||||
var unit = projection.Unit;
|
||||
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 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);
|
||||
}
|
||||
if (ActiveLayer == RpgEditLayer.Collision)
|
||||
for (var y = 0; y < grid.Height; y++)
|
||||
{
|
||||
for (var x = 0; x < grid.Width; x++)
|
||||
{
|
||||
var index = grid.GetTile(layer, x, y);
|
||||
if (index < 0)
|
||||
continue;
|
||||
// Autotiles are assembled from their neighbours exactly like in the game.
|
||||
var autotile = Tileset.AutotileOf(index);
|
||||
if (autotile == null)
|
||||
DrawTextureRectRegion(Atlas, CellRect(x, y), TileRegion(index), modulate);
|
||||
else if (textures.Tileset(autotile.Autotile) is { } block)
|
||||
RpgAutotiles.Draw(this, block, CellRect(x, y), RpgAutotiles.QuartersAt(grid, layer, x, y, index), Tileset.TileSize, modulate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawEvents()
|
||||
{
|
||||
foreach (var mapEvent in Document.LockedEvents)
|
||||
DrawEvent(mapEvent, true);
|
||||
foreach (var mapEvent in Document.Events)
|
||||
DrawEvent(mapEvent, false);
|
||||
}
|
||||
|
||||
/// <summary>One event: its look, then on the events layer a frame (dashed grey for a locked one).</summary>
|
||||
private void DrawEvent(RpgMapEvent mapEvent, bool locked)
|
||||
{
|
||||
var modulate = locked ? LockedModulate : Colors.White;
|
||||
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, modulate);
|
||||
}
|
||||
else if (mapEvent.Tile >= 0 && Atlas != null && Tileset != null)
|
||||
{
|
||||
DrawTextureRectRegion(Atlas, rect, TileRegion(mapEvent.Tile), modulate);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(mapEvent.Icon) && textures.Icon(mapEvent.Icon) is { } icon)
|
||||
{
|
||||
// Fitted like RpgActor.UseIcon: three quarters of the cell, centred.
|
||||
var fit = CellSize * 0.75f / Mathf.Max(1, Mathf.Max(icon.GetWidth(), icon.GetHeight()));
|
||||
var size = icon.GetSize() * fit;
|
||||
DrawTextureRect(icon, new Rect2(rect.GetCenter() - size / 2f, size), false, modulate);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawRect(rect.Grow(-CellSize * 0.15f), MarkerColor * modulate);
|
||||
}
|
||||
|
||||
if (ActiveLayer == RpgEditLayer.Events && locked)
|
||||
DrawDashedRect(rect.Grow(-1f));
|
||||
else if (ActiveLayer == RpgEditLayer.Events)
|
||||
DrawRect(rect, mapEvent == SelectedEvent ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.White, 0.5f), false, mapEvent == SelectedEvent ? 3f : 1f);
|
||||
}
|
||||
|
||||
private void DrawDashedRect(Rect2 rect)
|
||||
{
|
||||
var color = UiTokens.Alpha(UiTokens.White, 0.6f);
|
||||
DrawDashedLine(rect.Position, new Vector2(rect.End.X, rect.Position.Y), color, 1f, 4f);
|
||||
DrawDashedLine(new Vector2(rect.End.X, rect.Position.Y), rect.End, color, 1f, 4f);
|
||||
DrawDashedLine(rect.End, new Vector2(rect.Position.X, rect.End.Y), color, 1f, 4f);
|
||||
DrawDashedLine(new Vector2(rect.Position.X, rect.End.Y), rect.Position, color, 1f, 4f);
|
||||
}
|
||||
|
||||
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++)
|
||||
{
|
||||
var code = grid.GetCollision(x, y);
|
||||
if (code == RpgCollision.Block)
|
||||
DrawRect(CellRect(x, y), 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);
|
||||
if (code == RpgCollision.Block || code == RpgCollision.Open || !RpgPassability.IsWalkable(grid, Tileset, x, y))
|
||||
target.DrawColoredPolygon(Polygon(x, y, 1, 1), code == RpgCollision.Open ? OpenColor : BlockColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawGrid()
|
||||
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 grid = Document.Grid;
|
||||
var size = new Vector2(grid.Width, grid.Height) * CellSize;
|
||||
for (var x = 0; x <= grid.Width; x++)
|
||||
DrawLine(new Vector2(x * CellSize, 0f), new Vector2(x * CellSize, size.Y), GridColor);
|
||||
for (var y = 0; y <= grid.Height; y++)
|
||||
DrawLine(new Vector2(0f, y * CellSize), new Vector2(size.X, y * CellSize), GridColor);
|
||||
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);
|
||||
}
|
||||
|
||||
private Rect2 CellRect(int x, int y) => new(new Vector2(x, y) * CellSize, new Vector2(CellSize, CellSize));
|
||||
|
||||
private Rect2 TileRegion(int index)
|
||||
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 coords = RpgTileSetBuilder.AtlasCoords(Tileset, index);
|
||||
return new Rect2(coords * Tileset.TileSize, new Vector2(Tileset.TileSize, Tileset.TileSize));
|
||||
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 Vector2I CellAt(Vector2 position) => new((int)Mathf.Floor(position.X / CellSize), (int)Mathf.Floor(position.Y / CellSize));
|
||||
private void DrawEventMarker(CanvasItem target, RpgMapEvent mapEvent, bool locked)
|
||||
{
|
||||
var polygon = Polygon(mapEvent.X, mapEvent.Y, 1, 1);
|
||||
if (string.IsNullOrEmpty(mapEvent.Sprite) && mapEvent.Tile < 0 && string.IsNullOrEmpty(mapEvent.Icon))
|
||||
target.DrawColoredPolygon(polygon, MarkerColor);
|
||||
if (ActiveLayer != RpgEditLayer.Events) return;
|
||||
Outline(target, polygon, mapEvent == SelectedEvent ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.White, 0.6f), locked);
|
||||
}
|
||||
private static void Outline(CanvasItem target, Vector2[] polygon, Color color, bool dashed)
|
||||
{
|
||||
for (var i = 0; i < polygon.Length; i++)
|
||||
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 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)
|
||||
{
|
||||
@@ -391,8 +416,13 @@ namespace Cthangover.Tools.RpgEditor
|
||||
{
|
||||
var tile = erase ? -1 : SelectedTile;
|
||||
var collision = erase ? RpgCollision.Auto : SelectedCollision;
|
||||
if (Document.Paint(ActiveLayer, cell.X, cell.Y, tile, collision))
|
||||
QueueRedraw();
|
||||
var changed = !erase && ActiveLayer <= RpgEditLayer.Front && SelectedStamp != null
|
||||
? 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)
|
||||
@@ -406,6 +436,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
}
|
||||
dragged.X = cell.X;
|
||||
dragged.Y = cell.Y;
|
||||
Refresh();
|
||||
EventSelected?.Invoke(dragged);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ using Cthangover.Core.Utils;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Cthangover.Mods.Interface.Tool;
|
||||
using Cthangover.Mods.Rpg;
|
||||
using Cthangover.Mods.TileRender;
|
||||
using Cthangover.Tools.Common;
|
||||
using Cthangover.Tools.Services;
|
||||
using Godot;
|
||||
@@ -19,7 +20,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
/// <summary>
|
||||
/// 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),
|
||||
/// the tile palette, the game's characters and items as tabs on the left, the
|
||||
/// the tile palette, the game's characters, items and all map appearances as tabs on the left, the
|
||||
/// 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"/>,
|
||||
@@ -53,6 +54,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
private ModFileSelector fileSelector;
|
||||
private TabContainer sideTabs;
|
||||
private RpgTilePalette palette;
|
||||
private OptionButton rendererPicker;
|
||||
private SpinBox itemCount;
|
||||
private Func<Vector2I, RpgMapEvent> placeAt;
|
||||
private string placedKey;
|
||||
@@ -79,6 +81,14 @@ namespace Cthangover.Tools.RpgEditor
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
private void BuildUi()
|
||||
@@ -98,6 +108,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
paletteColumn.AddChild(ToolUi.Section("tools/rpg_editor/section_palette"));
|
||||
palette = new RpgTilePalette();
|
||||
palette.TileSelected += OnPaletteTile;
|
||||
palette.StampSelected += OnPaletteStamp;
|
||||
palette.CollisionSelected += OnPaletteCollision;
|
||||
paletteColumn.AddChild(palette);
|
||||
sideTabs.AddChild(paletteColumn);
|
||||
@@ -120,6 +131,12 @@ namespace Cthangover.Tools.RpgEditor
|
||||
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));
|
||||
|
||||
@@ -170,6 +187,24 @@ namespace Cthangover.Tools.RpgEditor
|
||||
AddToolButton(toolbar, RpgEditTool.Fill, "tools/rpg_editor/tool_fill");
|
||||
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.Toggled += OnGridToggled;
|
||||
toolbar.AddChild(gridCheck);
|
||||
@@ -432,10 +467,17 @@ namespace Cthangover.Tools.RpgEditor
|
||||
private void OnPaletteTile(int index)
|
||||
{
|
||||
canvas.SelectedTile = index;
|
||||
canvas.SelectedStamp = RpgTileStamp.Single(index);
|
||||
if (canvas.ActiveLayer > RpgEditLayer.Front)
|
||||
SelectLayer(RpgEditLayer.Main);
|
||||
}
|
||||
|
||||
private void OnPaletteStamp(RpgTileStamp stamp)
|
||||
{
|
||||
canvas.SelectedStamp = stamp;
|
||||
canvas.QueueRedraw();
|
||||
}
|
||||
|
||||
private void OnPaletteCollision(char code) => canvas.SelectedCollision = code;
|
||||
|
||||
private void OnSideTabChanged(long tab)
|
||||
@@ -448,6 +490,14 @@ namespace Cthangover.Tools.RpgEditor
|
||||
|
||||
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));
|
||||
|
||||
@@ -492,6 +542,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
private void OnTilePicked(int index)
|
||||
{
|
||||
canvas.SelectedTile = index;
|
||||
canvas.SelectedStamp = RpgTileStamp.Single(index);
|
||||
palette.Select(index);
|
||||
}
|
||||
|
||||
@@ -505,7 +556,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
palette.SetCollisionMode(layer == RpgEditLayer.Collision);
|
||||
foreach (var button in toolButtons.Values)
|
||||
button.Disabled = layer == RpgEditLayer.Events;
|
||||
canvas.QueueRedraw();
|
||||
canvas.Refresh();
|
||||
}
|
||||
|
||||
private void SelectTool(RpgEditTool tool)
|
||||
@@ -515,6 +566,12 @@ namespace Cthangover.Tools.RpgEditor
|
||||
pair.Value.SetPressedNoSignal(pair.Key == tool);
|
||||
}
|
||||
|
||||
private void OnRendererSelected(long index)
|
||||
{
|
||||
canvas.SetRenderer(rendererPicker.GetItemMetadata((int)index).AsString());
|
||||
UpdateZoomLabel();
|
||||
}
|
||||
|
||||
private void OnGridToggled(bool on)
|
||||
{
|
||||
canvas.ShowGrid = on;
|
||||
|
||||
@@ -29,6 +29,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
private readonly SpinBox heightSpin;
|
||||
private readonly SpinBox zoomSpin;
|
||||
private readonly LineEdit ambientEdit;
|
||||
private readonly OptionButton atmospherePicker;
|
||||
private readonly SpinBox spawnX;
|
||||
private readonly SpinBox spawnY;
|
||||
private readonly OptionButton spawnDir;
|
||||
@@ -65,6 +66,8 @@ namespace Cthangover.Tools.RpgEditor
|
||||
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"));
|
||||
spawnX = ToolUi.Field(this, "tools/rpg_editor/x", ToolUi.Number(0, 255, 1, OnSpawnChanged));
|
||||
spawnY = ToolUi.Field(this, "tools/rpg_editor/y", ToolUi.Number(0, 255, 1, OnSpawnChanged));
|
||||
@@ -98,6 +101,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
heightSpin.Value = value.Grid.Height;
|
||||
zoomSpin.Value = map.Zoom;
|
||||
ambientEdit.Text = map.Ambient ?? string.Empty;
|
||||
atmospherePicker.Select(map.Atmosphere == "woodland" ? 1 : 0);
|
||||
spawnX.Value = map.Spawn.X;
|
||||
spawnY.Value = map.Spawn.Y;
|
||||
spawnDir.Select((int)map.Spawn.Dir);
|
||||
@@ -127,6 +131,14 @@ namespace Cthangover.Tools.RpgEditor
|
||||
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())
|
||||
|
||||
@@ -2,10 +2,10 @@ using Godot;
|
||||
|
||||
namespace Cthangover.Tools.RpgEditor
|
||||
{
|
||||
/// <summary>One row of an <see cref="RpgEntityPicker"/>: a character or an item of the game.</summary>
|
||||
/// <summary>One row of an <see cref="RpgEntityPicker"/>: a character, item or map appearance.</summary>
|
||||
public class RpgPickerEntry
|
||||
{
|
||||
/// <summary>Entity id (what <c>rpg.battle enemies=</c> or <c>item.add id=</c> takes).</summary>
|
||||
/// <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>
|
||||
@@ -16,5 +16,8 @@ namespace Cthangover.Tools.RpgEditor
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
}
|
||||
+90
-7
@@ -17,7 +17,8 @@ namespace Cthangover.Tools.RpgEditor
|
||||
/// </summary>
|
||||
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 AutotileGap = 8f;
|
||||
|
||||
@@ -28,6 +29,15 @@ namespace Cthangover.Tools.RpgEditor
|
||||
private RpgTileset tileset;
|
||||
private Texture2D atlas;
|
||||
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>
|
||||
public int SelectedTile { get; private set; }
|
||||
@@ -54,15 +64,18 @@ namespace Cthangover.Tools.RpgEditor
|
||||
{
|
||||
tileset = value;
|
||||
atlas = image;
|
||||
selection = null;
|
||||
selectionStart = null;
|
||||
autotiles.Clear();
|
||||
if (value != null)
|
||||
autotiles.AddRange(value.Tiles.Where(t => !string.IsNullOrEmpty(t.Autotile)));
|
||||
autotiles.AddRange(value.Tiles.Where(t => !string.IsNullOrEmpty(t.Autotile) || t.Surface != null));
|
||||
Relayout();
|
||||
}
|
||||
|
||||
/// <summary>Switches between the tile atlas and the collision codes.</summary>
|
||||
public void SetCollisionMode(bool value)
|
||||
{
|
||||
selectionStart = null;
|
||||
collisionMode = value;
|
||||
Relayout();
|
||||
}
|
||||
@@ -71,6 +84,9 @@ namespace Cthangover.Tools.RpgEditor
|
||||
public void Select(int index)
|
||||
{
|
||||
SelectedTile = index;
|
||||
SelectedStamp = RpgTileStamp.Single(index);
|
||||
selection = null;
|
||||
selectionStart = null;
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
@@ -88,18 +104,24 @@ namespace Cthangover.Tools.RpgEditor
|
||||
|
||||
var cell = tileset.TileSize * Scale;
|
||||
DrawTextureRect(atlas, new Rect2(Vector2.Zero, atlas.GetSize() * Scale), false);
|
||||
foreach (var tile in tileset.Tiles.Where(t => t.Solid && string.IsNullOrEmpty(t.Autotile)))
|
||||
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++)
|
||||
{
|
||||
if (textures.Tileset(autotiles[i].Autotile) is { } block)
|
||||
if (autotiles[i].Surface is { } patch && textures.Tileset(tileset.SurfaceImage) is { } surface)
|
||||
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 slot = autotiles.FindIndex(t => t.Index == SelectedTile);
|
||||
var selected = slot >= 0 ? Slot(slot) : (Vector2)RpgTileSetBuilder.AtlasCoords(tileset, SelectedTile) * cell;
|
||||
DrawRect(new Rect2(selected, new Vector2(cell, cell)), UiTokens.AccentBright, false, 3f);
|
||||
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)
|
||||
@@ -135,9 +157,19 @@ namespace Cthangover.Tools.RpgEditor
|
||||
{
|
||||
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));
|
||||
return;
|
||||
}
|
||||
if (@event is InputEventMouseButton { Pressed: false, ButtonIndex: MouseButton.Left })
|
||||
{
|
||||
selectionStart = null;
|
||||
return;
|
||||
}
|
||||
if (@event is not InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left } button)
|
||||
return;
|
||||
|
||||
@@ -153,16 +185,63 @@ namespace Cthangover.Tools.RpgEditor
|
||||
else if (tileset != null && atlas != null)
|
||||
{
|
||||
var index = IndexAt(button.Position);
|
||||
if (index >= 0 && (index < RpgTileSetBuilder.TileCount(tileset, atlas) || tileset.AutotileOf(index) != null))
|
||||
if (index >= 0 && (index < RpgTileSetBuilder.TileCount(tileset, atlas) || tileset.TileOf(index) != null))
|
||||
{
|
||||
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);
|
||||
StampSelected?.Invoke(SelectedStamp);
|
||||
}
|
||||
}
|
||||
QueueRedraw();
|
||||
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);
|
||||
@@ -184,7 +263,11 @@ namespace Cthangover.Tools.RpgEditor
|
||||
}
|
||||
var column = (int)(position.X / 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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,12 @@ namespace Cthangover.Tools.RpgEditor.Tests
|
||||
/// buttons are pressed, the palette and the canvas get mouse events through
|
||||
/// 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. That window is
|
||||
/// 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
|
||||
/// creates a new map through the file selector's "+" and saves it: the game must
|
||||
/// know the map's scene and its event (saved apart, into map_events) at once,
|
||||
/// with no mod reload; its files are deleted again.
|
||||
/// 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>.
|
||||
/// </summary>
|
||||
public partial class RpgEditorTestDriver : Node
|
||||
@@ -46,6 +47,8 @@ namespace Cthangover.Tools.RpgEditor.Tests
|
||||
private RpgMapEditorCanvas canvas;
|
||||
private int eventsBefore;
|
||||
private string newMapMod;
|
||||
private Texture2D closingPreview;
|
||||
private Texture2D closingSource;
|
||||
|
||||
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
|
||||
public RpgEditorTestDriver(string shotsDir)
|
||||
@@ -122,21 +125,31 @@ namespace Cthangover.Tools.RpgEditor.Tests
|
||||
Check(window.Title.StartsWith("* "), "the window is marked dirty");
|
||||
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");
|
||||
PrepareStampPreview();
|
||||
step = 20;
|
||||
return;
|
||||
case 20:
|
||||
Shot("stamp_preview");
|
||||
PlaceStampAndUndo();
|
||||
Press("tools/rpg_editor/layer_events");
|
||||
eventsBefore = canvas.Document.Events.Count;
|
||||
ClickCanvas(EventCell, true);
|
||||
Check(canvas.Document.Events.Count == eventsBefore + 1 && canvas.SelectedEvent != null, "a double click adds and selects an event");
|
||||
step++;
|
||||
step = 3;
|
||||
return;
|
||||
case 3:
|
||||
// Screenshots one tick after the action, when the frame shows it.
|
||||
Shot("events");
|
||||
PlaceSlime();
|
||||
PlaceAppearance();
|
||||
Press("tools/rpg_editor/layer_collision");
|
||||
step++;
|
||||
return;
|
||||
case 4:
|
||||
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 = ToolWindow.Open<RpgMapEditorWindow>();
|
||||
step++;
|
||||
@@ -145,6 +158,8 @@ namespace Cthangover.Tools.RpgEditor.Tests
|
||||
canvas = window.FindChildren("*", "", true, false).OfType<RpgMapEditorCanvas>().FirstOrDefault();
|
||||
if (canvas?.Document == null)
|
||||
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;
|
||||
DeleteNewMapFiles();
|
||||
Selector().FindChildren("*", "", true, false).OfType<Button>().First(b => b.Text == "+").EmitSignal(BaseButton.SignalName.Pressed);
|
||||
@@ -164,11 +179,19 @@ namespace Cthangover.Tools.RpgEditor.Tests
|
||||
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");
|
||||
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");
|
||||
}
|
||||
finally
|
||||
@@ -216,7 +239,7 @@ namespace Cthangover.Tools.RpgEditor.Tests
|
||||
/// <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.GetTabCount() == 3).CurrentTab = tab;
|
||||
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;
|
||||
@@ -236,6 +259,39 @@ namespace Cthangover.Tools.RpgEditor.Tests
|
||||
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 void DeleteNewMapFiles()
|
||||
@@ -269,14 +325,43 @@ namespace Cthangover.Tools.RpgEditor.Tests
|
||||
var palette = window.FindChildren("*", "", true, false).OfType<RpgTilePalette>().First();
|
||||
var tileset = canvas.Tileset;
|
||||
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 = 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)
|
||||
{
|
||||
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 = false, Position = position });
|
||||
}
|
||||
@@ -308,9 +393,16 @@ namespace Cthangover.Tools.RpgEditor.Tests
|
||||
ViewportCapture.SavePng(GetViewport(), shotsDir.TrimEnd('/') + "/rpg_editor_" + name + ".png");
|
||||
}
|
||||
|
||||
private void Finish()
|
||||
private async void Finish()
|
||||
{
|
||||
if (step < 0)
|
||||
return;
|
||||
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}");
|
||||
GetTree().Quit(failures.Count == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,37 @@ namespace Cthangover.Tools.RpgEditor.Tests
|
||||
{
|
||||
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")]
|
||||
static void CreateFillsGroundAndCentresSpawn(ModTestContext ctx)
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user