Document upright world planes; mod documentation
This commit is contained in:
1 parent
8bb951f7f7
commit
5267c91cea
54 files changed
+1620
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
|
||||
* text=auto eol=lf
|
||||
|
||||
# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# Binaries: never converted, never diffed as text.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.svg text
|
||||
*.ogg binary
|
||||
*.wav binary
|
||||
*.mp3 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.dll binary
|
||||
*.so binary
|
||||
*.dylib binary
|
||||
*.pdb binary
|
||||
*.exe binary
|
||||
*.zip binary
|
||||
*.pck binary
|
||||
*.moc3 binary
|
||||
*.res binary
|
||||
*.scn binary
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"settings": [
|
||||
{
|
||||
"key": "tile.renderer",
|
||||
"name": "collection_settings/projection",
|
||||
"description": "collection_settings/projection_hint",
|
||||
"group": "collection_settings/world_group",
|
||||
"type": "choice",
|
||||
"source": null,
|
||||
"allow_custom": false,
|
||||
"automatic": null
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
+59
@@ -0,0 +1,59 @@
|
||||
# Tile Rendering — the shared engine behind tile maps
|
||||
|
||||
Tile Rendering draws tile maps. It takes a prepared description of a map — ground, objects, characters, lights — and turns it into a picture, but it does not decide the angle you look from. That choice belongs to a small **renderer** mod: **Top-down Tiles** shows the map straight from above, **Isometric Tiles** shows it as a diamond grid. The same map, events and saves work with either one.
|
||||
|
||||
You do not use this mod directly. It is part of every game with tile maps, such as RPG Plus, together with at least one renderer.
|
||||
|
||||

|
||||
|
||||
## Who does what
|
||||
|
||||
```text
|
||||
content mods maps, tilesets, events, characters (rpg_demo_game, village_pack…)
|
||||
│
|
||||
RPG mod (rpg) walking, collisions, events, the camera target, lighting rules
|
||||
│ prepares a "render scene": visuals in cell space, lights, the current frame
|
||||
▼
|
||||
Tile Rendering shared drawing: ground chunks, autotiles, sorting, shading,
|
||||
(this mod) asset loading, the interaction marker, picking
|
||||
│ asks the selected renderer for a projection
|
||||
▼
|
||||
renderer mod Square (topdown) · Isometric (isometric)
|
||||
how a cell maps to screen pixels
|
||||
```
|
||||
|
||||
- **Gameplay stays square.** Movement, collision, events and saves use the logical grid in every projection. A renderer only changes where things are drawn and which cell a click lands on.
|
||||
- **One drawing path for game and editor.** The RPG map editor draws its canvas with the same renderer as the game.
|
||||
- **Lights and time of day** are given in cell units, so a lamp lights the same cells in both projections; the tiles and characters share one material that blends the time of day and point lights.
|
||||
|
||||
## Choosing the projection
|
||||
|
||||
The projection is a setting of the game, **Map projection** (`tile.renderer`), in the **World** group of the collection's settings. The choices are **Top-down** (`topdown`) and **Isometric** (`isometric`); RPG Plus uses `topdown`. To change it, open **Mods & Collections → Collections → RPG Plus → Edit scheme**, set the projection, and make sure the matching renderer mod is in the game.
|
||||
|
||||
- If the setting names a renderer that is not in the game, the map does not fall back to another one; the log lists the available renderers.
|
||||
- If the setting is empty and exactly one renderer is installed, that one is used.
|
||||
- For a single run, `--tile-renderer=isometric` (or `topdown`) on the command line overrides the setting.
|
||||
|
||||
## For mod authors: a new projection
|
||||
|
||||
A renderer mod depends on `tile_render_base` and contains one class implementing `ITileRendererFactory`:
|
||||
|
||||
- `Id` — the value players choose in **Map projection**, for example `hex`;
|
||||
- `Projection(tileSize)` — the cell-to-pixel mapping used by the game, the editor and picking;
|
||||
- `Create()` — a new map view; the shared canvas presentation can be reused with your projection.
|
||||
|
||||
The factory is found automatically when the mod loads and removed when it unloads. To offer the projection in **Map projection**, add an option in your mod's `collection_settings.json`, as Top-down Tiles does:
|
||||
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{ "key": "tile.renderer", "value": "topdown", "name": "collection_settings/topdown" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `name` key is translated in `locale/collection_settings_<lang>.properties`.
|
||||
|
||||
## If something goes wrong
|
||||
|
||||
1. **The map is empty and the log says the renderer is unavailable.** The game's **Map projection** names a renderer mod that is not part of the game; add it or change the setting.
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Tile Rendering — el motor común detrás de los mapas de tiles
|
||||
|
||||
Tile Rendering dibuja los mapas de tiles. Recibe una descripción del mapa ya preparada —suelo, objetos, personajes, luces— y la convierte en una imagen, pero no decide desde qué ángulo se mira. Esa elección corresponde a un pequeño mod de **renderizado**: **Top-down Tiles** muestra el mapa directamente desde arriba y **Isometric Tiles**, como una cuadrícula de rombos. El mismo mapa, los mismos eventos y las mismas partidas guardadas funcionan con cualquiera de los dos.
|
||||
|
||||
Este mod no se usa directamente: forma parte de todo juego con mapas de tiles, como RPG Plus, junto con al menos un renderizador.
|
||||
|
||||

|
||||
|
||||
## Quién hace qué
|
||||
|
||||
```text
|
||||
content mods maps, tilesets, events, characters (rpg_demo_game, village_pack…)
|
||||
│
|
||||
RPG mod (rpg) walking, collisions, events, the camera target, lighting rules
|
||||
│ prepares a "render scene": visuals in cell space, lights, the current frame
|
||||
▼
|
||||
Tile Rendering shared drawing: ground chunks, autotiles, sorting, shading,
|
||||
(this mod) asset loading, the interaction marker, picking
|
||||
│ asks the selected renderer for a projection
|
||||
▼
|
||||
renderer mod Square (topdown) · Isometric (isometric)
|
||||
how a cell maps to screen pixels
|
||||
```
|
||||
|
||||
- **La jugabilidad sigue siendo cuadrada.** El movimiento, las colisiones, los eventos y el guardado usan la cuadrícula lógica en todas las proyecciones. Un renderizador solo cambia dónde se dibuja cada cosa y sobre qué casilla cae un clic.
|
||||
- **Un solo camino de dibujo para el juego y el editor.** El editor de mapas RPG dibuja su lienzo con el mismo renderizador que el juego.
|
||||
- **Las luces y la hora del día** se dan en unidades de casilla, así que una lámpara ilumina las mismas casillas en ambas proyecciones; los tiles y los personajes comparten un material que mezcla la hora del día con las luces puntuales.
|
||||
|
||||
## Elegir la proyección
|
||||
|
||||
La proyección es un ajuste del juego, **Proyección del mapa** (`tile.renderer`), en el grupo **Mundo** de los ajustes de la colección. Las opciones son **Vista superior** (`topdown`) e **Isométrica** (`isometric`); RPG Plus usa `topdown`. Para cambiarla, abre **Mods → Colecciones → RPG Plus → Editar esquema**, elige la proyección y asegúrate de que el mod de renderizado correspondiente esté en el juego.
|
||||
|
||||
- Si el ajuste nombra un renderizador que no está en el juego, el mapa no recurre a otro; el registro enumera los renderizadores disponibles.
|
||||
- Si el ajuste está vacío y hay exactamente un renderizador instalado, se usa ese.
|
||||
- Para una sola partida, `--tile-renderer=isometric` (o `topdown`) en la línea de comandos anula el ajuste.
|
||||
|
||||
## Para autores de mods: una proyección nueva
|
||||
|
||||
Un mod de renderizado depende de `tile_render_base` y contiene una clase que implementa `ITileRendererFactory`:
|
||||
|
||||
- `Id` — el valor que los jugadores eligen en **Proyección del mapa**, por ejemplo `hex`;
|
||||
- `Projection(tileSize)` — el mapeo de casilla a píxeles que usan el juego, el editor y la selección con el ratón;
|
||||
- `Create()` — una vista de mapa nueva; la presentación común del lienzo puede reutilizarse con tu proyección.
|
||||
|
||||
La fábrica se detecta automáticamente al cargar el mod y se retira al descargarlo. Para ofrecer la proyección en **Proyección del mapa**, añade una opción al `collection_settings.json` de tu mod, como hace Top-down Tiles:
|
||||
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{ "key": "tile.renderer", "value": "topdown", "name": "collection_settings/topdown" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
La clave `name` se traduce en `locale/collection_settings_<lang>.properties`.
|
||||
|
||||
## Si algo falla
|
||||
|
||||
1. **El mapa está vacío y el registro dice que el renderizador no está disponible.** La **Proyección del mapa** del juego nombra un mod de renderizado que no forma parte del juego; instálalo o cambia el ajuste.
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Tile Rendering — タイルマップの共通エンジン
|
||||
|
||||
Tile Rendering はタイルマップを描画します。用意されたマップの記述——地面、物体、キャラクター、光——を受け取って絵に変えますが、どの角度から見るかは決めません。それを決めるのは小さな**レンダラー** Mod です。**Top-down Tiles** はマップを真上から、**Isometric Tiles** はひし形の格子で示します。同じマップ、同じイベント、同じセーブが、どちらでもそのまま働きます。
|
||||
|
||||
この Mod を直接使うことはありません。タイルマップのあるゲーム——たとえば RPG Plus——には、レンダラーを最低ひとつと一緒に必ず入っています。
|
||||
|
||||

|
||||
|
||||
## 役割分担
|
||||
|
||||
```text
|
||||
content mods maps, tilesets, events, characters (rpg_demo_game, village_pack…)
|
||||
│
|
||||
RPG mod (rpg) walking, collisions, events, the camera target, lighting rules
|
||||
│ prepares a "render scene": visuals in cell space, lights, the current frame
|
||||
▼
|
||||
Tile Rendering shared drawing: ground chunks, autotiles, sorting, shading,
|
||||
(this mod) asset loading, the interaction marker, picking
|
||||
│ asks the selected renderer for a projection
|
||||
▼
|
||||
renderer mod Square (topdown) · Isometric (isometric)
|
||||
how a cell maps to screen pixels
|
||||
```
|
||||
|
||||
- **ゲームプレイは常に正方形です。** 移動、衝突、イベント、セーブは、どの投影でも論理グリッドを使います。レンダラーが変えるのは、何をどこに描くかと、クリックがどのマスに当たるかだけです。
|
||||
- **ゲームとエディターで描画経路はひとつ。** RPG マップエディターは、ゲームと同じレンダラーでキャンバスを描きます。
|
||||
- **光と時間の流れ**はマス単位で与えられるため、ランプはどちらの投影でも同じマスを照らします。タイルとキャラクターは、時間の流れと点光源を混ぜる 1 つのマテリアルを共有します。
|
||||
|
||||
## 投影を選ぶ
|
||||
|
||||
投影はゲームの設定、**マップの投影**(`tile.renderer`)で、コレクション設定の **ワールド** グループにあります。選択肢は **見下ろし**(`topdown`)と **アイソメトリック**(`isometric`)です。RPG Plus は `topdown` を使います。変えるには **MOD とコレクション → コレクション → RPG Plus → 構成を編集** を開き、投影を設定して、対応する描画 Mod がゲームに含まれていることを確認してください。
|
||||
|
||||
- 設定がゲームに存在しないレンダラーを指している場合、マップは別のレンダラーに切り替わりません。利用できるレンダラーがログに並びます。
|
||||
- 設定が空で、レンダラーがちょうど 1 つだけインストールされていれば、それが使われます。
|
||||
- 1 回だけの実行なら、コマンドラインの `--tile-renderer=isometric`(または `topdown`)が設定に優先します。
|
||||
|
||||
## Mod 作者向け: 新しい投影
|
||||
|
||||
レンダラー Mod は `tile_render_base` に依存し、`ITileRendererFactory` を実装するクラスを 1 つ持ちます。
|
||||
|
||||
- `Id` — プレイヤーが **マップの投影** で選ぶ値。たとえば `hex`。
|
||||
- `Projection(tileSize)` — マスから画面ピクセルへの対応付け。ゲーム・エディター・クリック判定がこれを使います。
|
||||
- `Create()` — 新しいマップビュー。共通のキャンバス表示は自分の投影でも再利用できます。
|
||||
|
||||
ファクトリは Mod の読み込み時に自動で見つかり、Mod を外すと削除されます。投影を **マップの投影** に並べるには、Top-down Tiles のように自分の Mod の `collection_settings.json` に選択肢を追加します。
|
||||
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{ "key": "tile.renderer", "value": "topdown", "name": "collection_settings/topdown" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`name` キーは `locale/collection_settings_<lang>.properties` で翻訳されます。
|
||||
|
||||
## うまくいかないとき
|
||||
|
||||
1. **マップが空で、ログにレンダラーが利用できないと出る。** ゲームの **マップの投影** が、ゲームに含まれない描画 Mod を指しています。追加するか、設定を変えてください。
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Tile Rendering — 타일 맵 뒤의 공유 엔진
|
||||
|
||||
Tile Rendering은 타일 맵을 그립니다. 준비된 맵 묘사 — 지면, 물건, 캐릭터, 빛 — 를 받아 그림으로 바꾸지만, 어느 각도에서 볼지는 정하지 않습니다. 그 선택은 아주 작은 **렌더러** 모드의 몫입니다. **Top-down Tiles**는 맵을 곧바로 위에서 보여 주고, **Isometric Tiles**는 마름모 격자로 보여 줍니다. 같은 맵, 같은 이벤트, 같은 저장이 어느 쪽에서든 그대로 작동합니다.
|
||||
|
||||
이 모드를 직접 쓸 일은 없습니다. 타일 맵이 있는 모든 게임 — 예컨대 RPG Plus — 에는 최소 하나의 렌더러와 함께 이 모드가 들어 있습니다.
|
||||
|
||||

|
||||
|
||||
## 누가 무엇을 맡는가
|
||||
|
||||
```text
|
||||
content mods maps, tilesets, events, characters (rpg_demo_game, village_pack…)
|
||||
│
|
||||
RPG mod (rpg) walking, collisions, events, the camera target, lighting rules
|
||||
│ prepares a "render scene": visuals in cell space, lights, the current frame
|
||||
▼
|
||||
Tile Rendering shared drawing: ground chunks, autotiles, sorting, shading,
|
||||
(this mod) asset loading, the interaction marker, picking
|
||||
│ asks the selected renderer for a projection
|
||||
▼
|
||||
renderer mod Square (topdown) · Isometric (isometric)
|
||||
how a cell maps to screen pixels
|
||||
```
|
||||
|
||||
- **게임플레이는 언제나 정사각형입니다.** 이동, 충돌, 이벤트, 저장은 모든 투영에서 논리 격자를 사용합니다. 렌더러가 바꾸는 것은 무엇을 어디에 그리는지, 클릭이 어느 칸에 떨어지는지뿐입니다.
|
||||
- **게임과 에디터가 하나의 그리기 경로를 씁니다.** RPG 맵 에디터는 게임과 같은 렌더러로 캔버스를 그립니다.
|
||||
- **빛과 시간대**는 칸 단위로 주어지므로, 램프는 두 투영에서 같은 칸을 밝힙니다. 타일과 캐릭터는 시간대와 점광을 섞는 하나의 머티리얼을 공유합니다.
|
||||
|
||||
## 투영 고르기
|
||||
|
||||
투영은 게임 설정의 하나로, 컬렉션 설정의 **월드** 그룹에 있는 **지도 투영**(`tile.renderer`)입니다. 선택지는 **탑다운**(`topdown`)과 **아이소메트릭**(`isometric`)이며, RPG Plus는 `topdown`을 씁니다. 바꾸려면 **모드 및 컬렉션 → 컬렉션 → RPG Plus → 구성 편집**을 열어 투영을 정하고, 해당 렌더러 모드가 게임에 포함되어 있는지 확인하세요.
|
||||
|
||||
- 설정이 게임에 없는 렌더러를 가리키면 맵이 다른 렌더러로 넘어가지 않고, 로그에 사용 가능한 렌더러가 나열됩니다.
|
||||
- 설정이 비어 있고 렌더러가 정확히 하나만 설치되어 있다면 그것을 씁니다.
|
||||
- 한 번만 실행하려면 명령줄에서 `--tile-renderer=isometric`(또는 `topdown`)이 설정을 덮어씁니다.
|
||||
|
||||
## 모드 작성자용: 새 투영
|
||||
|
||||
렌더러 모드는 `tile_render_base`에 의존하며 `ITileRendererFactory`를 구현하는 클래스 하나를 담습니다.
|
||||
|
||||
- `Id` — 플레이어가 **지도 투영**에서 고르는 값. 예: `hex`.
|
||||
- `Projection(tileSize)` — 칸을 화면 픽셀로 옮기는 대응. 게임·에디터·클릭 판정이 이것을 씁니다.
|
||||
- `Create()` — 새 맵 뷰. 공용 캔버스 표현은 자기 투영에서도 재사용할 수 있습니다.
|
||||
|
||||
팩토리는 모드가 로드될 때 자동으로 발견되고, 모드를 내리면 제거됩니다. 투영을 **지도 투영**에 넣으려면 Top-down Tiles처럼 자기 모드의 `collection_settings.json`에 옵션을 추가하세요.
|
||||
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{ "key": "tile.renderer", "value": "topdown", "name": "collection_settings/topdown" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`name` 키는 `locale/collection_settings_<lang>.properties`에서 번역됩니다.
|
||||
|
||||
## 문제가 생겼을 때
|
||||
|
||||
1. **맵이 비어 있고 로그에 렌더러를 쓸 수 없다고 나온다.** 게임의 **지도 투영**이 게임에 없는 렌더러 모드를 가리킵니다. 그 모드를 넣거나 설정을 바꾸세요.
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Tile Rendering — общий движок тайловых карт
|
||||
|
||||
Tile Rendering рисует тайловые карты. На вход он получает подготовленное описание карты — землю, объекты, персонажей, источники света — и превращает его в картинку, но сам не решает, под каким углом на неё смотреть. Этот выбор делает небольшой мод-**рендерер**: **Top-down Tiles** показывает карту строго сверху, **Isometric Tiles** — ромбовидной сеткой. Одна и та же карта, те же события и сохранения работают с любым из них.
|
||||
|
||||
Сам по себе этот мод не используется: он входит в каждую игру с тайловыми картами — например, в RPG Plus — вместе хотя бы с одним рендерером.
|
||||
|
||||

|
||||
|
||||
## Кто за что отвечает
|
||||
|
||||
```text
|
||||
content mods maps, tilesets, events, characters (rpg_demo_game, village_pack…)
|
||||
│
|
||||
RPG mod (rpg) walking, collisions, events, the camera target, lighting rules
|
||||
│ prepares a "render scene": visuals in cell space, lights, the current frame
|
||||
▼
|
||||
Tile Rendering shared drawing: ground chunks, autotiles, sorting, shading,
|
||||
(this mod) asset loading, the interaction marker, picking
|
||||
│ asks the selected renderer for a projection
|
||||
▼
|
||||
renderer mod Square (topdown) · Isometric (isometric)
|
||||
how a cell maps to screen pixels
|
||||
```
|
||||
|
||||
- **Игровая логика остаётся квадратной.** Перемещение, коллизии, события и сохранения во всех проекциях работают с логической сеткой. Рендерер меняет лишь то, где что рисуется, и на какую клетку попадает клик.
|
||||
- **Один путь отрисовки для игры и редактора.** Редактор RPG-карт рисует холст тем же рендерером, что и игра.
|
||||
- **Свет и время суток** задаются в клетках, поэтому лампа освещает одни и те же клетки в обеих проекциях; тайлы и персонажи делят один материал, смешивающий время суток и точечные источники света.
|
||||
|
||||
## Выбор проекции
|
||||
|
||||
Проекция — настройка игры, **Проекция карты** (`tile.renderer`), в группе **Мир** настроек коллекции. Варианты — **Вид сверху** (`topdown`) и **Изометрия** (`isometric`); в RPG Plus выбран `topdown`. Чтобы сменить её, откройте **Моды и коллекции → Коллекции → RPG Plus → Изменить схему**, задайте проекцию и проследите, чтобы в игру входил соответствующий мод-рендерер.
|
||||
|
||||
- Если настройка называет рендерер, которого в игре нет, карта не переключится на другой; в журнале будут перечислены доступные рендереры.
|
||||
- Если настройка пуста, а установлен ровно один рендерер, используется он.
|
||||
- Для одного запуска ключ командной строки `--tile-renderer=isometric` (или `topdown`) переопределяет настройку.
|
||||
|
||||
## Авторам модов: новая проекция
|
||||
|
||||
Мод-рендерер зависит от `tile_render_base` и содержит один класс, реализующий `ITileRendererFactory`:
|
||||
|
||||
- `Id` — значение, которое игроки выбирают в **Проекции карты**, например `hex`;
|
||||
- `Projection(tileSize)` — отображение «клетка → пиксели», которым пользуются игра, редактор и выбор щелчком мыши;
|
||||
- `Create()` — новое представление карты; общее представление холста можно переиспользовать со своей проекцией.
|
||||
|
||||
Фабрика находится автоматически при загрузке мода и убирается при выгрузке. Чтобы проекция появилась в **Проекции карты**, добавьте вариант в `collection_settings.json` своего мода — как это делает Top-down Tiles:
|
||||
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{ "key": "tile.renderer", "value": "topdown", "name": "collection_settings/topdown" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Ключ `name` переводится в `locale/collection_settings_<lang>.properties`.
|
||||
|
||||
## Если что-то пошло не так
|
||||
|
||||
1. **Карта пуста, а в журнале написано, что рендерер недоступен.** **Проекция карты** игры называет мод-рендерер, которого нет в составе игры; добавьте его или поменяйте настройку.
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Tile Rendering — nền tảng chung đằng sau bản đồ ô
|
||||
|
||||
Tile Rendering vẽ các bản đồ ô. Nó nhận một bản mô tả bản đồ đã chuẩn bị — nền, vật thể, nhân vật, nguồn sáng — và biến thành hình ảnh, nhưng không quyết định bạn nhìn từ góc nào. Việc đó thuộc về một mod **kết xuất** nhỏ: **Top-down Tiles** hiển thị bản đồ thẳng từ trên xuống, **Isometric Tiles** hiển thị thành lưới hình thoi. Cùng một bản đồ, cùng sự kiện và save đều chạy với cả hai.
|
||||
|
||||
Bạn không dùng trực tiếp mod này. Nó nằm trong mọi game có bản đồ ô — ví dụ RPG Plus — cùng với ít nhất một trình kết xuất.
|
||||
|
||||

|
||||
|
||||
## Ai làm gì
|
||||
|
||||
```text
|
||||
content mods maps, tilesets, events, characters (rpg_demo_game, village_pack…)
|
||||
│
|
||||
RPG mod (rpg) walking, collisions, events, the camera target, lighting rules
|
||||
│ prepares a "render scene": visuals in cell space, lights, the current frame
|
||||
▼
|
||||
Tile Rendering shared drawing: ground chunks, autotiles, sorting, shading,
|
||||
(this mod) asset loading, the interaction marker, picking
|
||||
│ asks the selected renderer for a projection
|
||||
▼
|
||||
renderer mod Square (topdown) · Isometric (isometric)
|
||||
how a cell maps to screen pixels
|
||||
```
|
||||
|
||||
- **Lối chơi vẫn vuông vắn.** Di chuyển, va chạm, sự kiện và save đều dùng lưới logic ở mọi phép chiếu. Trình kết xuất chỉ thay đổi nơi vẽ từng thứ và ô nào nhận cú nhấp chuột.
|
||||
- **Một đường vẽ chung cho game và trình soạn thảo.** Trình sửa bản đồ RPG vẽ khung vẽ bằng đúng trình kết xuất mà game dùng.
|
||||
- **Ánh sáng và thời gian trong ngày** tính theo đơn vị ô, nên một ngọn đèn soi sáng cùng những ô ấy ở cả hai phép chiếu; ô và nhân vật dùng chung một vật liệu hòa trộn thời gian trong ngày với nguồn sáng điểm.
|
||||
|
||||
## Chọn phép chiếu
|
||||
|
||||
Phép chiếu là một cài đặt của game, **Phép chiếu bản đồ** (`tile.renderer`), nằm trong nhóm **Thế giới** của cài đặt bộ sưu tập. Các lựa chọn là **Nhìn từ trên** (`topdown`) và **Đẳng cự** (`isometric`); RPG Plus dùng `topdown`. Để đổi, mở **Mod & Bộ sưu tập → Bộ sưu tập → RPG Plus → Sửa sơ đồ**, đặt phép chiếu và bảo đảm mod kết xuất tương ứng có trong game.
|
||||
|
||||
- Nếu cài đặt nêu tên một trình kết xuất không có trong game, bản đồ sẽ không chuyển sang trình khác; nhật ký sẽ liệt kê các trình kết xuất khả dụng.
|
||||
- Nếu cài đặt bỏ trống và chỉ cài đúng một trình kết xuất, trình đó được dùng.
|
||||
- Cho một lần chạy, `--tile-renderer=isometric` (hoặc `topdown`) trên dòng lệnh đè lên cài đặt.
|
||||
|
||||
## Dành cho tác giả mod: phép chiếu mới
|
||||
|
||||
Mod kết xuất phụ thuộc vào `tile_render_base` và chứa một lớp cài đặt `ITileRendererFactory`:
|
||||
|
||||
- `Id` — giá trị người chơi chọn trong **Phép chiếu bản đồ**, ví dụ `hex`;
|
||||
- `Projection(tileSize)` — phép ánh xạ ô sang điểm ảnh mà game, trình soạn thảo và chức năng chọn bằng chuột đều dùng;
|
||||
- `Create()` — một khung xem bản đồ mới; cách trình bày khung vẽ chung có thể tái sử dụng với phép chiếu của bạn.
|
||||
|
||||
Nhà máy được tự động nhận khi mod nạp và bị gỡ khi mod dỡ. Để phép chiếu xuất hiện trong **Phép chiếu bản đồ**, hãy thêm một lựa chọn vào `collection_settings.json` của mod, như Top-down Tiles đã làm:
|
||||
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{ "key": "tile.renderer", "value": "topdown", "name": "collection_settings/topdown" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Khóa `name` được dịch trong `locale/collection_settings_<lang>.properties`.
|
||||
|
||||
## Khi có trục trặc
|
||||
|
||||
1. **Bản đồ trống trơn và nhật ký báo trình kết xuất không khả dụng.** **Phép chiếu bản đồ** của game đang nêu một mod kết xuất không có trong game; hãy thêm nó vào hoặc đổi cài đặt.
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Tile Rendering — 图块地图背后的共享引擎
|
||||
|
||||
Tile Rendering 负责绘制图块地图。它接收一份准备好的地图描述——地面、物件、角色、光源——并把它变成画面,但并不决定你从哪个角度看。这个选择属于一个很小的**渲染器**模组:**Top-down Tiles** 从正上方显示地图,**Isometric Tiles** 则把它显示为菱形网格。同一张地图、同一批事件和存档,在两者之下都能正常工作。
|
||||
|
||||
你不会直接使用这个模组。它连同至少一个渲染器一起,存在于每个带图块地图的游戏中,例如 RPG Plus。
|
||||
|
||||

|
||||
|
||||
## 分工
|
||||
|
||||
```text
|
||||
content mods maps, tilesets, events, characters (rpg_demo_game, village_pack…)
|
||||
│
|
||||
RPG mod (rpg) walking, collisions, events, the camera target, lighting rules
|
||||
│ prepares a "render scene": visuals in cell space, lights, the current frame
|
||||
▼
|
||||
Tile Rendering shared drawing: ground chunks, autotiles, sorting, shading,
|
||||
(this mod) asset loading, the interaction marker, picking
|
||||
│ asks the selected renderer for a projection
|
||||
▼
|
||||
renderer mod Square (topdown) · Isometric (isometric)
|
||||
how a cell maps to screen pixels
|
||||
```
|
||||
|
||||
- **玩法始终是方格的。** 无论哪种投影,移动、碰撞、事件和存档都使用逻辑网格。渲染器只改变东西画在哪里、点击落在哪个格子上。
|
||||
- **游戏和编辑器共用一条绘制路径。** RPG 地图编辑器用与游戏相同的渲染器绘制画布。
|
||||
- **光照与昼夜**以格子为单位给出,所以一盏灯在两种投影里照亮的格子相同;图块与角色共用一个材质,其中混合了昼夜变化和点光源。
|
||||
|
||||
## 选择投影
|
||||
|
||||
投影是游戏的一项设置,即合集设置中**世界**组里的**地图投影**(`tile.renderer`)。可选值为**俯视**(`topdown`)和**等距**(`isometric`);RPG Plus 使用 `topdown`。要修改它,请打开**模组与合集 → 合集 → RPG Plus → 编辑结构**,设置投影,并确认对应的渲染器模组已包含在游戏中。
|
||||
|
||||
- 如果设置指向的渲染器不在游戏里,地图不会退回到别的渲染器;日志会列出可用的渲染器。
|
||||
- 如果设置为空且恰好只安装了一个渲染器,就用它。
|
||||
- 只想用一次时,可以在命令行加 `--tile-renderer=isometric`(或 `topdown`)覆盖设置。
|
||||
|
||||
## 给模组作者:新的投影
|
||||
|
||||
渲染器模组依赖 `tile_render_base`,并包含一个实现 `ITileRendererFactory` 的类:
|
||||
|
||||
- `Id`——玩家在**地图投影**里选择的值,例如 `hex`;
|
||||
- `Projection(tileSize)`——“格子到像素”的映射,游戏、编辑器和点击拾取都使用它;
|
||||
- `Create()`——一个新的地图视图;共享的画布表现可以在你的投影下复用。
|
||||
|
||||
模组加载时,工厂会被自动发现;卸载时被移除。要让投影出现在**地图投影**里,请像 Top-down Tiles 那样,在自己的 `collection_settings.json` 中添加一个选项:
|
||||
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{ "key": "tile.renderer", "value": "topdown", "name": "collection_settings/topdown" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`name` 键在 `locale/collection_settings_<lang>.properties` 中翻译。
|
||||
|
||||
## 如果遇到问题
|
||||
|
||||
1. **地图是空的,日志说渲染器不可用。** 游戏的**地图投影**指定了一个不在游戏中的渲染器模组;把它加进来,或者改一下设置。
|
||||
@@ -0,0 +1,3 @@
|
||||
collection_settings/projection = Map projection
|
||||
collection_settings/projection_hint = Choose how tiles are rendered. The corresponding renderer mod must be included in this collection.
|
||||
collection_settings/world_group = World
|
||||
@@ -0,0 +1,3 @@
|
||||
collection_settings/projection = Proyección del mapa
|
||||
collection_settings/projection_hint = Elige cómo se muestran las casillas. El mod del renderizador debe estar incluido en la colección.
|
||||
collection_settings/world_group = Mundo
|
||||
@@ -0,0 +1,3 @@
|
||||
collection_settings/projection = マップの投影
|
||||
collection_settings/projection_hint = タイルの描画方式を選びます。対応する描画MODを構成に含めてください。
|
||||
collection_settings/world_group = ワールド
|
||||
@@ -0,0 +1,3 @@
|
||||
collection_settings/projection = 지도 투영
|
||||
collection_settings/projection_hint = 타일 표시 방식을 선택합니다. 해당 렌더러 모드가 컬렉션에 있어야 합니다.
|
||||
collection_settings/world_group = 월드
|
||||
@@ -0,0 +1,3 @@
|
||||
collection_settings/projection = Проекция карты
|
||||
collection_settings/projection_hint = Выберите способ отображения тайлов. Соответствующий мод рендера должен быть включён в коллекцию.
|
||||
collection_settings/world_group = Мир
|
||||
@@ -0,0 +1,3 @@
|
||||
collection_settings/projection = Phép chiếu bản đồ
|
||||
collection_settings/projection_hint = Chọn cách hiển thị ô bản đồ. Mod kết xuất tương ứng phải có trong bộ sưu tập.
|
||||
collection_settings/world_group = Thế giới
|
||||
@@ -0,0 +1,3 @@
|
||||
collection_settings/projection = 地图投影
|
||||
collection_settings/projection_hint = 选择瓦片的渲染方式。对应渲染模组必须包含在合集中。
|
||||
collection_settings/world_group = 世界
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_tile_render_base_name = Tile Rendering
|
||||
mod_tile_render_base_desc = The shared engine behind tile maps: turns a prepared map description into a picture; the viewing angle is a small renderer mod on top.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_tile_render_base_name = Renderizado de casillas
|
||||
mod_tile_render_base_desc=El motor común detrás de los mapas de tiles: convierte la descripción preparada de un mapa en una imagen; el ángulo de visión lo elige un pequeño mod de renderizado por encima.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_tile_render_base_name = タイル描画
|
||||
mod_tile_render_base_desc=タイルマップの背後にある共通エンジン。用意されたマップの記述を絵に変えます。見る角度は、その上の小さなレンダラー Mod が決めます。
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_tile_render_base_name = 타일 렌더링
|
||||
mod_tile_render_base_desc=타일 맵 뒤의 공유 엔진: 준비된 맵 묘사를 그림으로 바꿉니다. 보는 각도는 그 위의 작은 렌더러 모드가 정합니다.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_tile_render_base_name = Отрисовка тайлов
|
||||
mod_tile_render_base_desc=Общий движок тайловых карт: превращает подготовленное описание карты в картинку, а угол обзора выбирает небольшой мод-рендерер поверх.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_tile_render_base_name = Hiển thị ô
|
||||
mod_tile_render_base_desc=Nền tảng chung đằng sau bản đồ ô: biến bản mô tả bản đồ đã chuẩn bị thành hình ảnh; góc nhìn do một mod kết xuất nhỏ nằm phía trên quyết định.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_tile_render_base_name = 图块渲染
|
||||
mod_tile_render_base_desc=图块地图背后的共享引擎:把准备好的地图描述变成画面;观看角度由其上的一个小型渲染器模组决定。
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "tile_render_base",
|
||||
"name": "Tile Rendering",
|
||||
"version": "1.0.0",
|
||||
"author": "ct",
|
||||
"description": "The shared engine behind tile maps: turns a prepared map description into a picture; the viewing angle is a small renderer mod on top.",
|
||||
"category": "visual, engine",
|
||||
"sources": [
|
||||
"src/**/*.cs"
|
||||
],
|
||||
"test_sources": [
|
||||
"tests/*.cs"
|
||||
],
|
||||
"depends": [
|
||||
"ui_base>=1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
shader_type canvas_item;
|
||||
|
||||
// Tiles and actors are multiplied by the ambient
|
||||
// colour of the time of day (time_weights blends the four phases like timed_sprite), and
|
||||
// point lights (the player, lit events) add to it. Lights only add up to white, so by day
|
||||
// they are invisible and at night they open circles in the dark. Positions and radii are
|
||||
// in logical cell units, independent of projection and camera.
|
||||
|
||||
uniform vec4 day_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
|
||||
uniform vec4 morning_color : source_color = vec4(0.86, 0.72, 0.84, 1.0);
|
||||
uniform vec4 evening_color : source_color = vec4(0.84, 0.72, 0.92, 1.0);
|
||||
uniform vec4 night_color : source_color = vec4(0.52, 0.62, 0.76, 1.0);
|
||||
uniform vec4 time_weights = vec4(0.0, 1.0, 0.0, 0.0);
|
||||
|
||||
// A map that sets its own ambient (indoors) uses it instead of the time of day.
|
||||
uniform bool use_ambient = false;
|
||||
uniform vec4 ambient_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
|
||||
|
||||
uniform vec2 light_positions[16];
|
||||
uniform vec4 light_colors[16];
|
||||
uniform float light_radii[16];
|
||||
uniform int light_count = 0;
|
||||
|
||||
uniform vec2 cell_x = vec2(1.0, 0.0);
|
||||
uniform vec2 cell_y = vec2(0.0, 1.0);
|
||||
uniform vec2 cell_offset = vec2(0.0);
|
||||
varying vec2 world_pos;
|
||||
|
||||
void vertex() {
|
||||
world_pos = (MODEL_MATRIX * vec4(VERTEX, 0.0, 1.0)).xy;
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
vec4 color = texture(TEXTURE, UV) * COLOR;
|
||||
|
||||
vec3 ambient = use_ambient
|
||||
? ambient_color.rgb
|
||||
: (morning_color * time_weights.x + day_color * time_weights.y
|
||||
+ evening_color * time_weights.z + night_color * time_weights.w).rgb;
|
||||
|
||||
vec2 cell = vec2(dot(world_pos, cell_x), dot(world_pos, cell_y)) + cell_offset;
|
||||
vec3 light = vec3(0.0);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
if (i >= light_count)
|
||||
break;
|
||||
float k = clamp(1.0 - distance(cell, light_positions[i]) / max(light_radii[i], 0.001), 0.0, 1.0);
|
||||
light += light_colors[i].rgb * light_colors[i].a * k * k;
|
||||
}
|
||||
|
||||
COLOR = vec4(color.rgb * min(ambient + light, vec3(1.0)), color.a);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using Godot;
|
||||
|
||||
/// <summary>Maps cell-space geometry to pixels; it has no knowledge of RPG rules or artwork.</summary>
|
||||
public interface ITileProjection
|
||||
{
|
||||
/// <summary>Projection-owned image placement policy; the backend never switches coordinate systems.</summary>
|
||||
TileVisualPlacement Place(TileVisual visual);
|
||||
/// <summary>Source-pixel scale, independent of camera zoom.</summary>
|
||||
float Unit { get; }
|
||||
/// <summary>Projects ground coordinates and optional elevation in cells.</summary>
|
||||
Vector2 Project(Vector3 point);
|
||||
/// <summary>Inverts ground projection before flooring to a cell.</summary>
|
||||
Vector2 Unproject(Vector2 point);
|
||||
/// <summary>Maps world facing (down/left/right/up) to a four-row charset.</summary>
|
||||
int CharsetRow(int facing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
/// <summary>Discovered through ModTypeConsumer; constructors must not allocate Godot objects.</summary>
|
||||
public interface ITileRendererFactory
|
||||
{
|
||||
/// <summary>Stable collection setting, such as topdown or isometric.</summary>
|
||||
string Id { get; }
|
||||
/// <summary>Creates a pure projection for tools, picking and previews.</summary>
|
||||
ITileProjection Projection(int tileSize);
|
||||
/// <summary>Creates one independently owned presentation, on the main thread.</summary>
|
||||
ITileWorldPresentation Create();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using System;
|
||||
using Godot;
|
||||
|
||||
/// <summary>A replaceable view. No operation may advance gameplay or write a save.</summary>
|
||||
public interface ITileWorldPresentation : IDisposable
|
||||
{
|
||||
/// <summary>Host attaches this root; the presentation owns all its descendants.</summary>
|
||||
Node2D Root { get; }
|
||||
/// <summary>Material shared by static and dynamic artwork, exposed for diagnostics.</summary>
|
||||
ShaderMaterial SharedMaterial { get; }
|
||||
/// <summary>Number of computed surface textures actually submitted to static draw chunks.</summary>
|
||||
int SurfaceChunkCount { get; }
|
||||
/// <summary>Projected static extent before camera scaling, for scrollable editor canvases.</summary>
|
||||
Rect2 ContentBounds { get; }
|
||||
/// <summary>Verifies the visible actors inherit the same material as the terrain.</summary>
|
||||
bool ActorsShareMaterial { get; }
|
||||
/// <summary>Builds static geometry from resolved visual data.</summary>
|
||||
void Mount(TileRenderScene scene);
|
||||
/// <summary>Applies a newer snapshot while reusing unaffected GPU textures and cached commands.</summary>
|
||||
void Apply(TileSceneChanges changes);
|
||||
/// <summary>Copies poses, camera focus and lighting without ticking simulation.</summary>
|
||||
void Present(TileFrame frame, double delta);
|
||||
/// <summary>Sets the visible host rectangle.</summary>
|
||||
void Resize(Vector2 size);
|
||||
/// <summary>Coordinates are relative to the host, including camera displacement.</summary>
|
||||
bool TryPickGround(Vector2 localPoint, out Vector2I cell);
|
||||
/// <summary>Projects a cell point into host coordinates.</summary>
|
||||
Vector2 ProjectToView(Vector3 point);
|
||||
/// <summary>Releases map resources; safe to call more than once.</summary>
|
||||
void Unmount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using Godot;
|
||||
|
||||
/// <summary>One already evaluated actor pose; no event conditions or route definitions cross this boundary.</summary>
|
||||
public class TileActorPose
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public Vector2 Position { get; set; }
|
||||
public bool Visible { get; set; }
|
||||
public TileVisual Look { get; set; }
|
||||
public bool Charset { get; set; }
|
||||
public int Facing { get; set; }
|
||||
public int Column { get; set; } = 1;
|
||||
public Color Tint { get; set; } = Colors.White;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
/// <summary>File-group lookup preserves mod overrides and ZIP providers. Instances own decoded textures and regions.</summary>
|
||||
public class TileAssetCache : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, Texture2D> textures = new();
|
||||
public Texture2D Resolve(string group, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
var key = group + "/" + path;
|
||||
if (!textures.TryGetValue(key, out var texture))
|
||||
textures[key] = texture = TextureUtils.LoadFromModGroup(group, path);
|
||||
return texture;
|
||||
}
|
||||
public Texture2D Region(string key, Texture2D atlas, Rect2 region)
|
||||
{
|
||||
key = "region/" + key;
|
||||
if (!textures.TryGetValue(key, out var texture)) textures[key] = texture = new AtlasTexture { Atlas = atlas, Region = region };
|
||||
return texture;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var texture in new HashSet<Texture2D>(textures.Values)) texture?.Dispose();
|
||||
textures.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using System;
|
||||
using Godot;
|
||||
/// <summary>Projection-independent A2 quarter selection and phase addressing.</summary>
|
||||
public static class TileAutotile
|
||||
{
|
||||
public static Vector2I[] Quarters(bool up, bool down, bool left, bool right,
|
||||
bool upLeft, bool upRight, bool downLeft, bool downRight)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
Quarter(up, left, upLeft, new Vector2I(2, 0), left ? 2 : 0, up ? 4 : 2),
|
||||
Quarter(up, right, upRight, new Vector2I(3, 0), right ? 1 : 3, up ? 4 : 2),
|
||||
Quarter(down, left, downLeft, new Vector2I(2, 1), left ? 2 : 0, down ? 3 : 5),
|
||||
Quarter(down, right, downRight, new Vector2I(3, 1), right ? 1 : 3, down ? 3 : 5),
|
||||
};
|
||||
}
|
||||
|
||||
public static Rect2 VariantRect(Vector2I imageSize, int tileSize, Vector2I cell)
|
||||
{
|
||||
var width = Math.Max(1, tileSize) * 2;
|
||||
var height = Math.Max(1, tileSize) * 3;
|
||||
var columns = Math.Max(1, imageSize.X / width);
|
||||
var rows = Math.Max(1, imageSize.Y / height);
|
||||
var x = (cell.X % columns + columns) % columns;
|
||||
var y = (cell.Y % rows + rows) % rows;
|
||||
return new Rect2(x * width, y * height, width, height);
|
||||
}
|
||||
|
||||
private static Vector2I Quarter(bool sideA, bool sideB, bool corner, Vector2I inner, int column, int row)
|
||||
{
|
||||
if (sideA && sideB && !corner)
|
||||
return inner;
|
||||
return new Vector2I(column, row);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using Godot;
|
||||
/// <summary>Cosmetic world-space accents and the exact interaction cell supplied by the simulation.</summary>
|
||||
public partial class TileCanvasEffects : Node2D
|
||||
{
|
||||
private TileRenderScene scene;
|
||||
private ITileProjection projection;
|
||||
private Vector2? target;
|
||||
private double elapsed;
|
||||
public TileCanvasEffects(TileRenderScene scene, ITileProjection projection)
|
||||
{ this.scene = scene; this.projection = projection; }
|
||||
public void Present(Vector2? cell, double delta)
|
||||
{ target = cell; elapsed += delta; QueueRedraw(); }
|
||||
public void Release() { scene = null; projection = null; }
|
||||
public void SetScene(TileRenderScene snapshot) { scene = snapshot; QueueRedraw(); }
|
||||
public override void _Draw()
|
||||
{
|
||||
var time = (float)elapsed;
|
||||
if (target.HasValue)
|
||||
{
|
||||
var points = TileDrawing.CellPolygon(projection, new Rect2(target.Value + Vector2.One * 0.06f, Vector2.One * 0.88f));
|
||||
var color = new Color(1f, 0.83f, 0.48f, 0.65f + 0.2f * Mathf.Sin(time * 3));
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
DrawLine(points[i], points[i].Lerp(points[(i + 1) % 4], 0.2f), color);
|
||||
DrawLine(points[i], points[i].Lerp(points[(i + 3) % 4], 0.2f), color);
|
||||
}
|
||||
}
|
||||
if (scene.Atmosphere != "woodland") return;
|
||||
for (var i = 0; i < 36; i++)
|
||||
{
|
||||
var point = new Vector2(Mathf.PosMod(i * 4.29f + time * (2 + i % 3) / 32, scene.Size.X),
|
||||
Mathf.PosMod(i * 2.61f + time * 0.044f + Mathf.Sin(time * 0.4f + i) * 0.22f, scene.Size.Y));
|
||||
var pixel = TileDrawing.Project(projection, point);
|
||||
DrawLine(pixel, pixel + new Vector2(2, 1), new Color(0.77f, 0.78f, 0.4f, 0.32f));
|
||||
}
|
||||
for (var i = 0; i < scene.Water.Count; i += 2)
|
||||
{
|
||||
var point = TileDrawing.Project(projection, scene.Water[i] + new Vector2(0.3f, 0.3f));
|
||||
DrawLine(point, point + new Vector2(6, 0), new Color(0.63f, 0.87f, 0.84f, 0.1f + 0.1f * Mathf.Sin(time + i)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using Godot;
|
||||
/// <summary>Cached draw commands for one static map chunk. Rebuilt only when the scene changes.</summary>
|
||||
public partial class TileCanvasLayer : Node2D
|
||||
{
|
||||
private ITileProjection projection;
|
||||
private readonly List<TileVisual> visuals = new();
|
||||
public TileCanvasLayer(ITileProjection projection) { this.projection = projection; }
|
||||
public void Add(TileVisual visual) => visuals.Add(visual);
|
||||
public bool Matches(IReadOnlyList<TileVisual> other)
|
||||
{
|
||||
if (visuals.Count != other.Count) return false;
|
||||
for (var i = 0; i < visuals.Count; i++)
|
||||
{
|
||||
var a = visuals[i]; var b = other[i];
|
||||
if (!ReferenceEquals(a.Texture, b.Texture) || a.Source != b.Source || a.Bounds != b.Bounds || a.Tint != b.Tint) return false;
|
||||
if ((a.WorldQuad == null) != (b.WorldQuad == null)) return false;
|
||||
if (a.WorldQuad != null)
|
||||
{
|
||||
if (a.WorldQuad.Length != b.WorldQuad.Length) return false;
|
||||
for (var p = 0; p < a.WorldQuad.Length; p++) if (a.WorldQuad[p] != b.WorldQuad[p]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
public void Replace(IEnumerable<TileVisual> other) { visuals.Clear(); visuals.AddRange(other); QueueRedraw(); }
|
||||
public void Release() { projection = null; visuals.Clear(); }
|
||||
public override void _Draw()
|
||||
{
|
||||
foreach (var visual in visuals) TileDrawing.Draw(this, visual, projection, visual.Tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Shared canvas backend: projections live in separate implementation mods.
|
||||
/// Static ground is chunked, whole props and actors share ground-anchor sorting.
|
||||
/// Camera and light transforms are derived from the same projection used for picking.
|
||||
/// </summary>
|
||||
public partial class TileCanvasPresentation : Node2D, ITileWorldPresentation
|
||||
{
|
||||
private ITileRendererFactory factory;
|
||||
private ITileProjection projection;
|
||||
private TileRenderScene scene;
|
||||
private Node2D actors;
|
||||
private Node2D[] layers;
|
||||
private readonly Dictionary<(int Layer, int X, int Y), TileCanvasLayer> chunks = new();
|
||||
private readonly List<TileCanvasSprite> props = new();
|
||||
private readonly HashSet<string> seenActors = new();
|
||||
private readonly List<string> removedActors = new();
|
||||
public int LastChangedDrawChunks { get; private set; }
|
||||
private readonly Dictionary<string, TileCanvasSprite> sprites = new();
|
||||
private readonly Vector2[] lightPositions = new Vector2[16];
|
||||
private readonly Color[] lightColors = new Color[16];
|
||||
private readonly float[] lightRadii = new float[16];
|
||||
private Vector2 viewport;
|
||||
private Rect2 bounds;
|
||||
private TileCanvasEffects effects;
|
||||
public Node2D Root => this;
|
||||
public ShaderMaterial SharedMaterial { get; private set; }
|
||||
public int SurfaceChunkCount { get; private set; }
|
||||
public Rect2 ContentBounds => bounds;
|
||||
/// <summary>Weak diagnostic only; does not retain implementation code during unload verification.</summary>
|
||||
public WeakReference ProjectionLifetime() => new(projection);
|
||||
public bool ActorsShareMaterial
|
||||
{
|
||||
get
|
||||
{
|
||||
if (actors == null || actors.Material != SharedMaterial || sprites.Count == 0) return false;
|
||||
foreach (var sprite in sprites.Values) if (!sprite.UseParentMaterial) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
public TileCanvasPresentation(ITileRendererFactory factory) { this.factory = factory; }
|
||||
|
||||
public void Mount(TileRenderScene snapshot)
|
||||
{
|
||||
Unmount();
|
||||
scene = snapshot;
|
||||
projection = factory.Projection(scene.TileSize);
|
||||
Name = "TilePresentation";
|
||||
TextureFilter = TextureFilterEnum.Nearest;
|
||||
Scale = Vector2.One * scene.Zoom;
|
||||
var corners = TileDrawing.CellPolygon(projection, new Rect2(Vector2.Zero, scene.Size));
|
||||
bounds = new Rect2(corners[0], Vector2.Zero);
|
||||
foreach (var point in corners) bounds = bounds.Expand(point);
|
||||
SharedMaterial = new ShaderMaterial { Shader = Shaders.Resolve("tile_lit") };
|
||||
layers = new Node2D[3];
|
||||
layers[0] = new Node2D { Name = "Back", Material = SharedMaterial };
|
||||
layers[1] = new Node2D { Name = "Main", Material = SharedMaterial };
|
||||
layers[2] = new Node2D { Name = "Front", Material = SharedMaterial };
|
||||
AddChild(layers[0]);
|
||||
AddChild(layers[1]);
|
||||
actors = new Node2D { Name = "Actors", YSortEnabled = true, Material = SharedMaterial };
|
||||
AddChild(actors);
|
||||
AddChild(layers[2]);
|
||||
RebuildStatic();
|
||||
effects = new TileCanvasEffects(scene, projection);
|
||||
AddChild(effects);
|
||||
}
|
||||
|
||||
public void Apply(TileSceneChanges changes)
|
||||
{
|
||||
if (scene == null || changes.BaseRevision != scene.Revision || changes.Scene.Revision <= scene.Revision)
|
||||
throw new InvalidOperationException("Stale tile presentation delta.");
|
||||
var next = changes.Scene;
|
||||
if (next.Size != scene.Size || next.TileSize != scene.TileSize || next.Zoom != scene.Zoom)
|
||||
{ Mount(next); return; }
|
||||
scene = next;
|
||||
RebuildStatic();
|
||||
effects.SetScene(scene);
|
||||
}
|
||||
|
||||
private void RebuildStatic()
|
||||
{
|
||||
var corners = TileDrawing.CellPolygon(projection, new Rect2(Vector2.Zero, scene.Size));
|
||||
bounds = new Rect2(corners[0], Vector2.Zero);
|
||||
foreach (var point in corners) bounds = bounds.Expand(point);
|
||||
foreach (var prop in props) { prop.Release(); prop.GetParent()?.RemoveChild(prop); prop.Free(); }
|
||||
props.Clear();
|
||||
SurfaceChunkCount = 0;
|
||||
LastChangedDrawChunks = 0;
|
||||
var nextChunks = new Dictionary<(int Layer, int X, int Y), List<TileVisual>>();
|
||||
foreach (var visual in scene.Visuals)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(visual.Only) && visual.Only != factory.Id) continue;
|
||||
var placement = projection.Place(visual);
|
||||
bounds = bounds.Merge(placement.Bounds);
|
||||
if (placement.DepthSorted)
|
||||
{
|
||||
var sprite = new TileCanvasSprite(projection) { UseParentMaterial = true };
|
||||
(visual.Layer == 2 ? layers[2] : actors).AddChild(sprite);
|
||||
sprite.SetStatic(visual);
|
||||
props.Add(sprite);
|
||||
}
|
||||
else
|
||||
{
|
||||
var key = (visual.Layer, (int)visual.Bounds.Position.X / 32, (int)visual.Bounds.Position.Y / 32);
|
||||
if (!nextChunks.TryGetValue(key, out var list)) nextChunks[key] = list = new List<TileVisual>();
|
||||
list.Add(visual);
|
||||
if (visual.Id?.StartsWith("surface/") == true && visual.Texture != null) SurfaceChunkCount++;
|
||||
}
|
||||
}
|
||||
var obsolete = new List<(int Layer, int X, int Y)>();
|
||||
foreach (var pair in chunks) if (!nextChunks.ContainsKey(pair.Key)) obsolete.Add(pair.Key);
|
||||
foreach (var key in obsolete)
|
||||
{
|
||||
var chunk = chunks[key];
|
||||
chunk.Release(); chunk.GetParent()?.RemoveChild(chunk); chunk.Free(); chunks.Remove(key);
|
||||
LastChangedDrawChunks++;
|
||||
}
|
||||
foreach (var pair in nextChunks)
|
||||
{
|
||||
if (!chunks.TryGetValue(pair.Key, out var chunk))
|
||||
{
|
||||
chunk = new TileCanvasLayer(projection) { UseParentMaterial = true };
|
||||
layers[pair.Key.Layer].AddChild(chunk);
|
||||
chunks[pair.Key] = chunk;
|
||||
}
|
||||
else if (chunk.Matches(pair.Value)) continue;
|
||||
chunk.Replace(pair.Value);
|
||||
LastChangedDrawChunks++;
|
||||
}
|
||||
}
|
||||
|
||||
public void Resize(Vector2 size) => viewport = size;
|
||||
|
||||
public void Present(TileFrame frame, double delta)
|
||||
{
|
||||
if (scene == null) return;
|
||||
seenActors.Clear();
|
||||
foreach (var pose in frame.Actors)
|
||||
{
|
||||
if (!sprites.TryGetValue(pose.Id, out var sprite))
|
||||
{
|
||||
sprite = new TileCanvasSprite(projection) { UseParentMaterial = true };
|
||||
actors.AddChild(sprite);
|
||||
sprites[pose.Id] = sprite;
|
||||
}
|
||||
seenActors.Add(pose.Id);
|
||||
sprite.SetPose(pose);
|
||||
}
|
||||
removedActors.Clear();
|
||||
foreach (var pair in sprites) if (!seenActors.Contains(pair.Key)) removedActors.Add(pair.Key);
|
||||
foreach (var id in removedActors)
|
||||
{
|
||||
var sprite = sprites[id];
|
||||
sprite.Release(); sprite.GetParent()?.RemoveChild(sprite); sprite.Free(); sprites.Remove(id);
|
||||
}
|
||||
var focus = TileDrawing.Project(projection, frame.Focus) * Scale.X;
|
||||
var target = new Vector2(Axis(viewport.X, bounds.Position.X * Scale.X, bounds.End.X * Scale.X, focus.X),
|
||||
Axis(viewport.Y, bounds.Position.Y * Scale.Y, bounds.End.Y * Scale.Y, focus.Y));
|
||||
Position = delta <= 0 ? target : Position.Lerp(target, 1f - Mathf.Exp(-(float)delta * 14f)).Round();
|
||||
UpdateLight(frame);
|
||||
effects.Present(frame.InteractionCell, delta);
|
||||
}
|
||||
|
||||
private void UpdateLight(TileFrame frame)
|
||||
{
|
||||
SharedMaterial.SetShaderParameter("time_weights", frame.TimeWeights);
|
||||
SharedMaterial.SetShaderParameter("use_ambient", frame.Ambient.HasValue);
|
||||
SharedMaterial.SetShaderParameter("ambient_color", frame.Ambient ?? Colors.White);
|
||||
var world = GlobalTransform * new Transform2D(TileDrawing.Project(projection, Vector2.Right),
|
||||
TileDrawing.Project(projection, Vector2.Down), Vector2.Zero);
|
||||
var inverse = world.AffineInverse();
|
||||
SharedMaterial.SetShaderParameter("cell_x", new Vector2(inverse.X.X, inverse.Y.X));
|
||||
SharedMaterial.SetShaderParameter("cell_y", new Vector2(inverse.X.Y, inverse.Y.Y));
|
||||
SharedMaterial.SetShaderParameter("cell_offset", inverse.Origin);
|
||||
var count = Math.Min(16, frame.Lights.Count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
lightPositions[i] = frame.Lights[i].Position;
|
||||
lightColors[i] = frame.Lights[i].Color;
|
||||
lightRadii[i] = frame.Lights[i].Radius;
|
||||
}
|
||||
SharedMaterial.SetShaderParameter("light_count", count);
|
||||
SharedMaterial.SetShaderParameter("light_positions", lightPositions);
|
||||
SharedMaterial.SetShaderParameter("light_colors", lightColors);
|
||||
SharedMaterial.SetShaderParameter("light_radii", lightRadii);
|
||||
}
|
||||
|
||||
public bool TryPickGround(Vector2 localPoint, out Vector2I cell)
|
||||
{
|
||||
cell = default;
|
||||
if (scene == null) return false;
|
||||
var point = projection.Unproject(Transform.AffineInverse() * localPoint);
|
||||
cell = new Vector2I(Mathf.FloorToInt(point.X), Mathf.FloorToInt(point.Y));
|
||||
return cell.X >= 0 && cell.Y >= 0 && cell.X < scene.Size.X && cell.Y < scene.Size.Y;
|
||||
}
|
||||
|
||||
public Vector2 ProjectToView(Vector3 point) => Transform * projection.Project(point);
|
||||
|
||||
public void Unmount()
|
||||
{
|
||||
foreach (var child in GetChildren()) { ReleaseReferences(child); RemoveChild(child); child.Free(); }
|
||||
sprites.Clear();
|
||||
props.Clear();
|
||||
chunks.Clear();
|
||||
layers = null;
|
||||
SurfaceChunkCount = 0;
|
||||
actors = null;
|
||||
effects = null;
|
||||
scene = null;
|
||||
projection = null;
|
||||
SharedMaterial?.Dispose();
|
||||
SharedMaterial = null;
|
||||
}
|
||||
|
||||
private static void ReleaseReferences(Node node)
|
||||
{
|
||||
foreach (var child in node.GetChildren()) ReleaseReferences(child);
|
||||
if (node is TileCanvasLayer layer) layer.Release();
|
||||
if (node is TileCanvasSprite sprite) sprite.Release();
|
||||
if (node is TileCanvasEffects accents) accents.Release();
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
if (!GodotObject.IsInstanceValid(this)) return;
|
||||
Unmount();
|
||||
factory = null;
|
||||
GetParent()?.RemoveChild(this);
|
||||
Free();
|
||||
}
|
||||
|
||||
private static float Axis(float screen, float start, float end, float focus)
|
||||
=> Mathf.Round(end - start <= screen ? (screen - start - end) / 2f
|
||||
: Mathf.Clamp(screen / 2f - focus, screen - end, -start));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using Godot;
|
||||
/// <summary>Upright image sorted at its ground anchor. A pose update never advances movement or animation.</summary>
|
||||
public partial class TileCanvasSprite : Node2D
|
||||
{
|
||||
private ITileProjection projection;
|
||||
private TileVisual visual;
|
||||
private Rect2 source;
|
||||
private bool shadow;
|
||||
private TileVisualPlacement staticPlacement;
|
||||
public TileCanvasSprite(ITileProjection projection) { this.projection = projection; }
|
||||
public void Release() { projection = null; visual = null; shown = null; staticPlacement = null; }
|
||||
|
||||
public void SetStatic(TileVisual image)
|
||||
{
|
||||
visual = image;
|
||||
Modulate = image.Tint;
|
||||
source = image.Source;
|
||||
staticPlacement = projection.Place(image);
|
||||
Position = staticPlacement.Anchor;
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
public void SetPose(TileActorPose pose)
|
||||
{
|
||||
Visible = pose.Visible;
|
||||
Modulate = pose.Tint;
|
||||
visual = pose.Look;
|
||||
if (visual == null)
|
||||
{
|
||||
if (shown != null) { shown = null; QueueRedraw(); }
|
||||
return;
|
||||
}
|
||||
Position = TileDrawing.Project(projection, pose.Position + visual.Anchor);
|
||||
var next = visual.Source;
|
||||
shadow = pose.Charset;
|
||||
if (pose.Charset && visual.Texture != null)
|
||||
{
|
||||
var frame = visual.Texture.GetSize() / new Vector2(3, 4);
|
||||
next = new Rect2(new Vector2(pose.Column, projection.CharsetRow(pose.Facing)) * frame, frame);
|
||||
}
|
||||
if (source != next || visual != shown || shadow != shownShadow)
|
||||
{
|
||||
source = next;
|
||||
shown = visual;
|
||||
shownShadow = shadow;
|
||||
QueueRedraw();
|
||||
}
|
||||
}
|
||||
private TileVisual shown;
|
||||
private bool shownShadow;
|
||||
|
||||
public override void _Draw()
|
||||
{
|
||||
if (visual?.Texture == null) return;
|
||||
if (staticPlacement != null)
|
||||
{
|
||||
var transform = staticPlacement.Transform;
|
||||
transform.Origin -= Position;
|
||||
DrawSetTransformMatrix(transform);
|
||||
DrawTextureRectRegion(visual.Texture, new Rect2(0, 0, 1, 1), source, Colors.White);
|
||||
DrawSetTransformMatrix(Transform2D.Identity);
|
||||
return;
|
||||
}
|
||||
var unit = projection.Unit;
|
||||
if (shadow)
|
||||
{
|
||||
DrawSetTransform(new Vector2(0, -3), 0, new Vector2(1, 0.32f));
|
||||
DrawCircle(Vector2.Zero, unit * 0.28f, new Color(0.04f, 0.09f, 0.1f, 0.28f));
|
||||
DrawSetTransform(Vector2.Zero);
|
||||
}
|
||||
DrawTextureRectRegion(visual.Texture, new Rect2((visual.Bounds.Position - visual.Anchor) * unit,
|
||||
visual.Bounds.Size * unit), source, Colors.White);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using Godot;
|
||||
/// <summary>Shared UV drawing for runtime and tools. Ground transforms independently of image storage.</summary>
|
||||
public static class TileDrawing
|
||||
{
|
||||
public static Vector2 Project(ITileProjection p, Vector2 point) => p.Project(new Vector3(point.X, point.Y, 0));
|
||||
|
||||
public static Vector2[] CellPolygon(ITileProjection p, Rect2 rect)
|
||||
{
|
||||
return new[] { Project(p, rect.Position), Project(p, rect.Position + new Vector2(rect.Size.X, 0)),
|
||||
Project(p, rect.End), Project(p, rect.Position + new Vector2(0, rect.Size.Y)) };
|
||||
}
|
||||
|
||||
public static void Draw(CanvasItem target, TileVisual visual, ITileProjection projection, Color tint)
|
||||
{
|
||||
if (visual.Texture == null)
|
||||
return;
|
||||
var placement = projection.Place(visual);
|
||||
target.DrawSetTransformMatrix(placement.Transform);
|
||||
target.DrawTextureRectRegion(visual.Texture, new Rect2(0, 0, 1, 1), visual.Source, tint);
|
||||
target.DrawSetTransformMatrix(Transform2D.Identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using Godot;
|
||||
/// <summary>Reusable frame storage; populated by the game, read synchronously by the presentation.</summary>
|
||||
public class TileFrame
|
||||
{
|
||||
public List<TileActorPose> Actors { get; } = new();
|
||||
public List<TileLight> Lights { get; } = new();
|
||||
public Vector2 Focus { get; set; }
|
||||
public Vector2? InteractionCell { get; set; }
|
||||
public Vector4 TimeWeights { get; set; } = new(0, 1, 0, 0);
|
||||
public Color? Ambient { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using Godot;
|
||||
/// <summary>Position and radius are logical cell units in both projections.</summary>
|
||||
public class TileLight
|
||||
{
|
||||
public Vector2 Position { get; set; }
|
||||
public float Radius { get; set; }
|
||||
public Color Color { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using Godot;
|
||||
|
||||
/// <summary>Static presentation snapshot, prepared by a game adapter. Owns only explicitly listed transient textures.</summary>
|
||||
public class TileRenderScene
|
||||
{
|
||||
public Vector2I Size { get; set; }
|
||||
public long Revision { get; set; }
|
||||
public int TileSize { get; set; } = 32;
|
||||
public float Zoom { get; set; } = 2;
|
||||
public string Atmosphere { get; set; }
|
||||
public List<Vector2> Water { get; } = new();
|
||||
public List<TileVisual> Visuals { get; } = new();
|
||||
public List<Texture2D> OwnedTextures { get; } = new();
|
||||
public List<string> Warnings { get; } = new();
|
||||
|
||||
/// <summary>Shared file-group textures are borrowed, never freed by the snapshot.</summary>
|
||||
public void Release()
|
||||
{
|
||||
foreach (var texture in OwnedTextures)
|
||||
texture.Dispose();
|
||||
OwnedTextures.Clear();
|
||||
Visuals.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using Cthangover.Core.Mods.Consumers;
|
||||
/// <summary>Uses the kernel's normal load, catch-up and reverse unload fan-out.</summary>
|
||||
public class TileRendererConsumer : ModTypeConsumer<ITileRendererFactory>
|
||||
{
|
||||
public override string Id => "tile_render_base.renderers";
|
||||
protected override void Add(ITileRendererFactory instance, string modId) => TileRendererRegistry.Instance.Register(instance);
|
||||
protected override void Remove(ITileRendererFactory instance, string modId) => TileRendererRegistry.Instance.Unregister(instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
/// <summary>Mod-owned factories. Selection is never cached across games and an explicit missing ID never falls back.</summary>
|
||||
public class TileRendererRegistry
|
||||
{
|
||||
public static TileRendererRegistry Instance { get; } = new();
|
||||
private readonly Dictionary<string, ITileRendererFactory> factories = new(StringComparer.Ordinal);
|
||||
/// <summary>Sent before the owner's assembly unloads, so live hosts release its nodes and delegates.</summary>
|
||||
public event Action<string> Removing;
|
||||
public IReadOnlyList<string> Ids => factories.Keys.OrderBy(IdOrder).ToArray();
|
||||
private static string IdOrder(string id) => id;
|
||||
|
||||
public void Register(ITileRendererFactory factory)
|
||||
{
|
||||
if (factory == null || string.IsNullOrWhiteSpace(factory.Id))
|
||||
throw new ArgumentException("Renderer requires an ID.");
|
||||
if (!factories.TryAdd(factory.Id, factory))
|
||||
throw new InvalidOperationException($"Duplicate tile renderer '{factory.Id}'.");
|
||||
}
|
||||
|
||||
public void Unregister(ITileRendererFactory factory)
|
||||
{
|
||||
if (factory == null || !factories.TryGetValue(factory.Id, out var current) || !ReferenceEquals(current, factory))
|
||||
return;
|
||||
if (Removing != null)
|
||||
foreach (Action<string> handler in Removing.GetInvocationList())
|
||||
try { handler(factory.Id); }
|
||||
catch (Exception ex) { GameLogger.Log("RPG", "renderer cleanup: " + ex.Message, LogLevel.Error); }
|
||||
factories.Remove(factory.Id);
|
||||
}
|
||||
|
||||
public ITileRendererFactory Resolve(string id)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(id) && factories.TryGetValue(id, out var factory))
|
||||
return factory;
|
||||
if (string.IsNullOrEmpty(id) && factories.Count == 1)
|
||||
return factories.Values.First();
|
||||
throw new InvalidOperationException($"Tile renderer '{id ?? "<choose>"}' unavailable or ambiguous. Available: {string.Join(", ", Ids)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
/// <summary>Versioned replacement of prepared visual data; implementations retain unchanged draw chunks.</summary>
|
||||
public class TileSceneChanges
|
||||
{
|
||||
public long BaseRevision { get; set; }
|
||||
public TileRenderScene Scene { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
/// <summary>
|
||||
/// Owns bounded 32-cell texture chunks. A one-cell halo determines invalidation;
|
||||
/// moving a camera or replacing a renderer never re-rasterizes them. After Update,
|
||||
/// apply the new snapshot before the next draw: changed textures have been replaced.
|
||||
/// </summary>
|
||||
public class TileSurfaceCache : IDisposable
|
||||
{
|
||||
public const int ChunkSize = 32;
|
||||
private readonly Dictionary<Vector2I, TileSurfaceChunk> chunks = new();
|
||||
private TileSurfaceField previous;
|
||||
private Texture2D atlas;
|
||||
private byte[] pixels;
|
||||
private int atlasWidth;
|
||||
public int LastBuiltChunks { get; private set; }
|
||||
public IEnumerable<TileSurfaceChunk> Chunks => chunks.Values;
|
||||
public long TextureBytes { get; private set; }
|
||||
|
||||
public void Update(TileSurfaceField field, Texture2D source)
|
||||
{
|
||||
if (!GameEnvironment.IsRuntime) throw new InvalidOperationException("Texture cache requires the Godot runtime.");
|
||||
var reset = !ReferenceEquals(atlas, source) || previous == null || previous.Width != field.Width ||
|
||||
previous.Height != field.Height || previous.TileSize != field.TileSize || previous.PatchSize != field.PatchSize;
|
||||
if (reset)
|
||||
{
|
||||
Dispose();
|
||||
atlas = source;
|
||||
using var image = source.GetImage();
|
||||
image.Convert(Image.Format.Rgba8);
|
||||
pixels = image.GetData();
|
||||
atlasWidth = image.GetWidth();
|
||||
}
|
||||
LastBuiltChunks = 0;
|
||||
for (var y = 0; y < field.Height; y += ChunkSize)
|
||||
for (var x = 0; x < field.Width; x += ChunkSize)
|
||||
{
|
||||
var rect = new Rect2I(x, y, Math.Min(ChunkSize, field.Width - x), Math.Min(ChunkSize, field.Height - y));
|
||||
if (!reset && !Changed(field, rect)) continue;
|
||||
var key = new Vector2I(x, y);
|
||||
if (chunks.Remove(key, out var old)) old.Texture.Dispose();
|
||||
var found = false;
|
||||
for (var cy = y; cy < rect.End.Y && !found; cy++)
|
||||
for (var cx = x; cx < rect.End.X && !found; cx++) found = field.Materials[cy * field.Width + cx] >= 0;
|
||||
if (!found) continue;
|
||||
var bytes = TileSurfaceRasterizer.Render(field, pixels, atlasWidth, rect);
|
||||
using var image = Image.CreateFromData(rect.Size.X * field.TileSize, rect.Size.Y * field.TileSize, false, Image.Format.Rgba8, bytes);
|
||||
chunks[key] = new TileSurfaceChunk { Cells = rect, Texture = ImageTexture.CreateFromImage(image) };
|
||||
LastBuiltChunks++;
|
||||
}
|
||||
previous = new TileSurfaceField { Width = field.Width, Height = field.Height, TileSize = field.TileSize,
|
||||
PatchSize = field.PatchSize, Materials = (int[])field.Materials.Clone(), Diffuse = (bool[])field.Diffuse.Clone() };
|
||||
TextureBytes = 0;
|
||||
foreach (var chunk in chunks.Values) TextureBytes += (long)chunk.Cells.Size.X * chunk.Cells.Size.Y * field.TileSize * field.TileSize * 4;
|
||||
}
|
||||
|
||||
private bool Changed(TileSurfaceField field, Rect2I rect)
|
||||
{
|
||||
for (var y = Math.Max(0, rect.Position.Y - 1); y < Math.Min(field.Height, rect.End.Y + 1); y++)
|
||||
for (var x = Math.Max(0, rect.Position.X - 1); x < Math.Min(field.Width, rect.End.X + 1); x++)
|
||||
{
|
||||
var i = y * field.Width + x;
|
||||
if (field.Materials[i] != previous.Materials[i] || field.Diffuse[i] != previous.Diffuse[i]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var chunk in chunks.Values) chunk.Texture.Dispose();
|
||||
chunks.Clear(); previous = null; atlas = null; pixels = null; TextureBytes = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using Godot;
|
||||
/// <summary>Generated texture and its logical extent; lifetime belongs to TileSurfaceCache.</summary>
|
||||
public class TileSurfaceChunk
|
||||
{
|
||||
public Rect2I Cells { get; set; }
|
||||
public Texture2D Texture { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
/// <summary>Projection-independent material IDs (-1 means no surface) and blending flags in row-major order.</summary>
|
||||
public class TileSurfaceField
|
||||
{
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public int TileSize { get; set; }
|
||||
public int PatchSize { get; set; }
|
||||
public int[] Materials { get; set; }
|
||||
public bool[] Diffuse { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using System;
|
||||
using Godot;
|
||||
/// <summary>
|
||||
/// Pure CPU reference compositor. A region samples neighbours from the full field,
|
||||
/// with global patch phase and noise, so chunk borders equal a monolithic render.
|
||||
/// </summary>
|
||||
public static class TileSurfaceRasterizer
|
||||
{
|
||||
public static byte[] Render(TileSurfaceField field, byte[] atlas, int atlasWidth, Rect2I cells)
|
||||
{
|
||||
var size = field.TileSize;
|
||||
var patch = field.PatchSize;
|
||||
if (size <= 0 || patch <= 0 || atlasWidth < patch || atlas.Length < atlasWidth * patch * 4 ||
|
||||
field.Materials.Length != field.Width * field.Height || field.Diffuse.Length != field.Materials.Length ||
|
||||
cells.Position.X < 0 || cells.Position.Y < 0 || cells.End.X > field.Width || cells.End.Y > field.Height)
|
||||
throw new ArgumentException("Invalid surface field, atlas or region.");
|
||||
var width = cells.Size.X * size;
|
||||
var height = cells.Size.Y * size;
|
||||
var output = new byte[width * height * 4];
|
||||
var radius = Math.Min(8f, size / 4f);
|
||||
var homogeneous = new bool[cells.Size.X * cells.Size.Y];
|
||||
if (size >= 8)
|
||||
for (var cy = cells.Position.Y; cy < cells.End.Y; cy++)
|
||||
for (var cx = cells.Position.X; cx < cells.End.X; cx++)
|
||||
homogeneous[(cy - cells.Position.Y) * cells.Size.X + cx - cells.Position.X] = Homogeneous(field, cx, cy, patch, atlasWidth);
|
||||
for (var oy = 0; oy < height; oy++)
|
||||
for (var ox = 0; ox < width; ox++)
|
||||
{
|
||||
var x = ox + cells.Position.X * size;
|
||||
var y = oy + cells.Position.Y * size;
|
||||
var cx = x / size;
|
||||
var cy = y / size;
|
||||
var own = cy * field.Width + cx;
|
||||
if (field.Materials[own] < 0) continue;
|
||||
// Equal source samples stay equal under any positive blend weights.
|
||||
// Most terrain interiors can copy the patch without a 9-tap kernel.
|
||||
if (homogeneous[(cy - cells.Position.Y) * cells.Size.X + cx - cells.Position.X])
|
||||
{
|
||||
var src = ((y % patch) * atlasWidth + field.Materials[own] * patch + x % patch) * 4;
|
||||
var dst = (oy * width + ox) * 4;
|
||||
output[dst] = atlas[src]; output[dst + 1] = atlas[src + 1];
|
||||
output[dst + 2] = atlas[src + 2]; output[dst + 3] = atlas[src + 3];
|
||||
continue;
|
||||
}
|
||||
var noise = (float)(Math.Sin((x + 0.5) * 0.23) * Math.Cos((y + 0.5) * 0.19) * 1.5);
|
||||
float red = 0, green = 0, blue = 0, alpha = 0, total = 0;
|
||||
for (var dy = -1; dy <= 1; dy++)
|
||||
for (var dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
var nx = cx + dx;
|
||||
var ny = cy + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= field.Width || ny >= field.Height) continue;
|
||||
var index = ny * field.Width + nx;
|
||||
var material = field.Materials[index];
|
||||
if (material < 0 || ((dx != 0 || dy != 0) && (!field.Diffuse[own] || !field.Diffuse[index]))) continue;
|
||||
if ((material + 1) * patch > atlasWidth) throw new ArgumentException("Surface index is outside the atlas.");
|
||||
var px = x + 0.5f + noise;
|
||||
var py = y + 0.5f + noise;
|
||||
var distanceX = Math.Min(px - nx * size, (nx + 1) * size - px);
|
||||
var distanceY = Math.Min(py - ny * size, (ny + 1) * size - py);
|
||||
var weight = Smooth(0.5f + distanceX / (2 * radius)) * Smooth(0.5f + distanceY / (2 * radius));
|
||||
if (weight <= 0) continue;
|
||||
var offset = ((y % patch) * atlasWidth + material * patch + x % patch) * 4;
|
||||
red += atlas[offset] * weight; green += atlas[offset + 1] * weight;
|
||||
blue += atlas[offset + 2] * weight; alpha += atlas[offset + 3] * weight; total += weight;
|
||||
}
|
||||
var dest = (oy * width + ox) * 4;
|
||||
output[dest] = (byte)Math.Clamp((int)Math.Round(red / total), 0, 255);
|
||||
output[dest + 1] = (byte)Math.Clamp((int)Math.Round(green / total), 0, 255);
|
||||
output[dest + 2] = (byte)Math.Clamp((int)Math.Round(blue / total), 0, 255);
|
||||
output[dest + 3] = (byte)Math.Clamp((int)Math.Round(alpha / total), 0, 255);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private static bool Homogeneous(TileSurfaceField field, int x, int y, int patch, int atlasWidth)
|
||||
{
|
||||
var own = y * field.Width + x;
|
||||
var material = field.Materials[own];
|
||||
if (material < 0 || (material + 1) * patch > atlasWidth) return false;
|
||||
if (!field.Diffuse[own]) return true;
|
||||
for (var ny = Math.Max(0, y - 1); ny <= Math.Min(field.Height - 1, y + 1); ny++)
|
||||
for (var nx = Math.Max(0, x - 1); nx <= Math.Min(field.Width - 1, x + 1); nx++)
|
||||
{
|
||||
var i = ny * field.Width + nx;
|
||||
if (field.Materials[i] >= 0 && field.Diffuse[i] && field.Materials[i] != material) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static float Smooth(float value)
|
||||
{ value = Math.Clamp(value, 0, 1); return value * value * (3 - 2 * value); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using Godot;
|
||||
|
||||
/// <summary>A resolved image region in cell space. Texture pixels are never gameplay geometry.</summary>
|
||||
public class TileVisual
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public Texture2D Texture { get; set; }
|
||||
public Rect2 Source { get; set; }
|
||||
public Rect2 Bounds { get; set; }
|
||||
public Vector2 Anchor { get; set; }
|
||||
public int Layer { get; set; }
|
||||
public bool Upright { get; set; }
|
||||
public Color Tint { get; set; } = Colors.White;
|
||||
/// <summary>Optional explicit world plane, TL/TR/BR/BL in world cell coordinates; with Upright it is depth-sorted at Anchor.</summary>
|
||||
public Vector3[] WorldQuad { get; set; }
|
||||
/// <summary>Only this projection uses the visual (empty means both).</summary>
|
||||
public string Only { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Cthangover.Mods.TileRender
|
||||
{
|
||||
using Godot;
|
||||
/// <summary>Projection output, consumed without knowing a coordinate system: an affine quad and optional sort anchor.</summary>
|
||||
public class TileVisualPlacement
|
||||
{
|
||||
public Transform2D Transform { get; private set; }
|
||||
public Rect2 Bounds { get; private set; }
|
||||
public Vector2 Anchor { get; private set; }
|
||||
public bool DepthSorted { get; private set; }
|
||||
public static TileVisualPlacement FromQuad(Vector2[] points, Vector2 anchor, bool depthSorted)
|
||||
{
|
||||
var bounds = new Rect2(points[0], Vector2.Zero);
|
||||
foreach (var point in points) bounds = bounds.Expand(point);
|
||||
return new TileVisualPlacement { Transform = new Transform2D(points[1] - points[0], points[3] - points[0], points[0]),
|
||||
Bounds = bounds, Anchor = anchor, DepthSorted = depthSorted };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cthangover.Mods.TileRender.Tests
|
||||
{
|
||||
using System;
|
||||
/// <summary>Registry fixture with no Godot allocation or dependency on an implementation mod.</summary>
|
||||
internal class StubTileRendererFactory : ITileRendererFactory
|
||||
{
|
||||
public string Id { get; }
|
||||
public StubTileRendererFactory(string id) { Id = id; }
|
||||
public ITileProjection Projection(int size) => throw new NotSupportedException();
|
||||
public ITileWorldPresentation Create() => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Cthangover.Mods.TileRender.Tests
|
||||
{
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
/// <summary>Explicit selection, ambiguity and owner-sensitive unregister never depend on discovery order.</summary>
|
||||
public class TileRendererRegistryTests
|
||||
{
|
||||
private readonly TileRendererRegistry registry = new();
|
||||
private readonly StubTileRendererFactory square = new("square");
|
||||
private void Missing() => registry.Resolve("missing");
|
||||
private void Ambiguous() => registry.Resolve(null);
|
||||
private void Duplicate() => registry.Register(square);
|
||||
|
||||
[ModTest("registry/selection_collision_and_unload")]
|
||||
static void SelectionCollisionAndUnload(ModTestContext ctx)
|
||||
{
|
||||
var test = new TileRendererRegistryTests();
|
||||
test.registry.Register(test.square);
|
||||
ModAssert.True(ReferenceEquals(test.square, test.registry.Resolve(null)), "single implementation may be inferred");
|
||||
ModAssert.Throws(test.Missing, "explicit missing ID must not fall back", true);
|
||||
ModAssert.Throws(test.Duplicate, "duplicate ID is rejected", true);
|
||||
var iso = new StubTileRendererFactory("iso");
|
||||
test.registry.Register(iso);
|
||||
ModAssert.Throws(test.Ambiguous, "multiple implementations require selection", true);
|
||||
ModAssert.True(ReferenceEquals(iso, test.registry.Resolve("iso")), "explicit selection");
|
||||
test.registry.Unregister(new StubTileRendererFactory("iso"));
|
||||
ModAssert.True(ReferenceEquals(iso, test.registry.Resolve("iso")), "a different owner cannot delete a factory");
|
||||
test.registry.Unregister(iso);
|
||||
ModAssert.True(ReferenceEquals(test.square, test.registry.Resolve(null)), "remaining implementation stays available");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Cthangover.Mods.TileRender.Tests
|
||||
{
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Godot;
|
||||
/// <summary>One edit rebuilds its affected halo, and cached GPU resources have explicit ownership.</summary>
|
||||
public class TileSurfaceCacheTests
|
||||
{
|
||||
private static Texture2D ThirdTexture(TileSurfaceCache cache)
|
||||
{
|
||||
foreach (var chunk in cache.Chunks) if (chunk.Cells.Position.X == 64) return chunk.Texture;
|
||||
return null;
|
||||
}
|
||||
|
||||
[ModTest("surfaces/cache_reuses_unchanged_chunks_and_invalidates_halo")]
|
||||
static void CacheReusesUnchangedChunksAndInvalidatesHalo(ModTestContext ctx)
|
||||
{
|
||||
var pixels = new byte[16 * 8 * 4];
|
||||
for (var i = 0; i < pixels.Length; i++) pixels[i] = 255;
|
||||
using var image = Image.CreateFromData(16, 8, false, Image.Format.Rgba8, pixels);
|
||||
using var texture = ImageTexture.CreateFromImage(image);
|
||||
using var cache = new TileSurfaceCache();
|
||||
var field = new TileSurfaceField { Width = 65, Height = 3, TileSize = 4, PatchSize = 8,
|
||||
Materials = new int[65 * 3], Diffuse = Enumerable.Repeat(true, 65 * 3).ToArray() };
|
||||
cache.Update(field, texture);
|
||||
ModAssert.Equal(3, cache.LastBuiltChunks, "cold build has three chunks");
|
||||
var last = ThirdTexture(cache);
|
||||
cache.Update(field, texture);
|
||||
ModAssert.Equal(0, cache.LastBuiltChunks, "camera/unchanged data generate no pixels");
|
||||
field.Materials[31] = 1;
|
||||
cache.Update(field, texture);
|
||||
ModAssert.Equal(2, cache.LastBuiltChunks, "an edit beside a chunk boundary invalidates both neighbours");
|
||||
ModAssert.True(ReferenceEquals(last, ThirdTexture(cache)), "unaffected third texture is preserved");
|
||||
field.Materials[0] = 1;
|
||||
cache.Update(field, texture);
|
||||
ModAssert.Equal(1, cache.LastBuiltChunks, "interior edit regenerates only one chunk");
|
||||
cache.Dispose();
|
||||
ModAssert.Equal(0, cache.TextureBytes, "explicit disposal releases the cache's budget");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Cthangover.Mods.TileRender.Tests
|
||||
{
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Godot;
|
||||
/// <summary>Chunk edges, world phase and diagonal blending must match the full reference texture byte-for-byte.</summary>
|
||||
public class TileSurfaceRasterizerTests
|
||||
{
|
||||
[ModTest("surfaces/homogeneous_field_preserves_every_patch_byte")]
|
||||
static void HomogeneousPatch(ModTestContext ctx)
|
||||
{
|
||||
var atlas = new byte[16 * 16 * 4];
|
||||
for (var i = 0; i < atlas.Length; i++) atlas[i] = (byte)(i * 73 % 256);
|
||||
foreach (var size in new[] { 8, 32 })
|
||||
foreach (var diffuse in new[] { false, true })
|
||||
{
|
||||
var field = new TileSurfaceField { Width = 3, Height = 2, TileSize = size, PatchSize = 16,
|
||||
Materials = new int[6], Diffuse = new[] { diffuse, diffuse, diffuse, diffuse, diffuse, diffuse } };
|
||||
var pixels = TileSurfaceRasterizer.Render(field, atlas, 16, new Rect2I(0, 0, 3, 2));
|
||||
for (var y = 0; y < 2 * size; y++) for (var x = 0; x < 3 * size; x++) for (var c = 0; c < 4; c++)
|
||||
ModAssert.Equal(atlas[((y % 16) * 16 + x % 16) * 4 + c], pixels[(y * 3 * size + x) * 4 + c], "exact source sample including alpha");
|
||||
}
|
||||
}
|
||||
[ModTest("surfaces/chunks_equal_monolithic_pixels")]
|
||||
static void ChunksEqualMonolithicPixels(ModTestContext ctx)
|
||||
{
|
||||
var field = new TileSurfaceField { Width = 4, Height = 3, TileSize = 8, PatchSize = 16,
|
||||
Materials = new[] { 0, 0, 1, 1, 0, -1, 1, 1, 1, 0, 0, 1 },
|
||||
Diffuse = new[] { true, true, true, true, true, true, true, false, true, true, true, true } };
|
||||
var atlas = new byte[32 * 16 * 4];
|
||||
for (var y = 0; y < 16; y++) for (var x = 0; x < 32; x++)
|
||||
{ var i = (y * 32 + x) * 4; atlas[i] = (byte)(x * 7); atlas[i + 1] = (byte)(y * 15); atlas[i + 3] = 255; }
|
||||
var full = TileSurfaceRasterizer.Render(field, atlas, 32, new Rect2I(0, 0, 4, 3));
|
||||
for (var cy = 0; cy < 3; cy++) for (var cx = 0; cx < 4; cx++)
|
||||
{
|
||||
var part = TileSurfaceRasterizer.Render(field, atlas, 32, new Rect2I(cx, cy, 1, 1));
|
||||
for (var y = 0; y < 8; y++) for (var x = 0; x < 8; x++) for (var c = 0; c < 4; c++)
|
||||
ModAssert.Equal(full[((cy * 8 + y) * 32 + cx * 8 + x) * 4 + c], part[(y * 8 + x) * 4 + c], "chunk including halo and global patch phase");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user