Document upright world planes; mod documentation

This commit is contained in:
cthangover committed 2026-10-29 09:26:19 +01:00
1 parent 8bb951f7f7
commit 5267c91cea
54 files changed
+1620

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
* text=auto eol=lf
# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf
# Binaries: never converted, never diffed as text.
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.svg text
*.ogg binary
*.wav binary
*.mp3 binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.dll binary
*.so binary
*.dylib binary
*.pdb binary
*.exe binary
*.zip binary
*.pck binary
*.moc3 binary
*.res binary
*.scn binary
+14
View File
@@ -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
View File
@@ -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.
![The same village map drawn by both renderers: square top-down on the left, isometric on the right.](assets/same-map-two-projections.jpg)
## 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
View File
@@ -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.
![El mismo mapa de la aldea dibujado por los dos renderizadores: vista superior cuadrada a la izquierda, isométrica a la derecha.](assets/same-map-two-projections.jpg)
## 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
View File
@@ -0,0 +1,59 @@
# Tile Rendering — タイルマップの共通エンジン
Tile Rendering はタイルマップを描画します。用意されたマップの記述——地面、物体、キャラクター、光——を受け取って絵に変えますが、どの角度から見るかは決めません。それを決めるのは小さな**レンダラー** Mod です。**Top-down Tiles** はマップを真上から、**Isometric Tiles** はひし形の格子で示します。同じマップ、同じイベント、同じセーブが、どちらでもそのまま働きます。
この Mod を直接使うことはありません。タイルマップのあるゲーム——たとえば RPG Plus——には、レンダラーを最低ひとつと一緒に必ず入っています。
![同じ村のマップを 2 つのレンダラーで描いたもの: 左が正方形の見下ろし、右がアイソメトリック。](assets/same-map-two-projections.jpg)
## 役割分担
```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
View File
@@ -0,0 +1,59 @@
# Tile Rendering — 타일 맵 뒤의 공유 엔진
Tile Rendering은 타일 맵을 그립니다. 준비된 맵 묘사 — 지면, 물건, 캐릭터, 빛 — 를 받아 그림으로 바꾸지만, 어느 각도에서 볼지는 정하지 않습니다. 그 선택은 아주 작은 **렌더러** 모드의 몫입니다. **Top-down Tiles**는 맵을 곧바로 위에서 보여 주고, **Isometric Tiles**는 마름모 격자로 보여 줍니다. 같은 맵, 같은 이벤트, 같은 저장이 어느 쪽에서든 그대로 작동합니다.
이 모드를 직접 쓸 일은 없습니다. 타일 맵이 있는 모든 게임 — 예컨대 RPG Plus — 에는 최소 하나의 렌더러와 함께 이 모드가 들어 있습니다.
![같은 마을 맵을 두 렌더러로 그린 모습: 왼쪽은 정사각형 탑다운, 오른쪽은 아이소메트릭.](assets/same-map-two-projections.jpg)
## 누가 무엇을 맡는가
```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
View File
@@ -0,0 +1,59 @@
# Tile Rendering — общий движок тайловых карт
Tile Rendering рисует тайловые карты. На вход он получает подготовленное описание карты — землю, объекты, персонажей, источники света — и превращает его в картинку, но сам не решает, под каким углом на неё смотреть. Этот выбор делает небольшой мод-**рендерер**: **Top-down Tiles** показывает карту строго сверху, **Isometric Tiles** — ромбовидной сеткой. Одна и та же карта, те же события и сохранения работают с любым из них.
Сам по себе этот мод не используется: он входит в каждую игру с тайловыми картами — например, в RPG Plus — вместе хотя бы с одним рендерером.
![Одна и та же карта деревни, нарисованная обоими рендерерами: слева квадратный вид сверху, справа изометрия.](assets/same-map-two-projections.jpg)
## Кто за что отвечает
```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
View File
@@ -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.
![Cùng một bản đồ làng được hai trình kết xuất vẽ: ô vuông nhìn từ trên ở bên trái, đẳng cự ở bên phải.](assets/same-map-two-projections.jpg)
## 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
View File
@@ -0,0 +1,59 @@
# Tile Rendering — 图块地图背后的共享引擎
Tile Rendering 负责绘制图块地图。它接收一份准备好的地图描述——地面、物件、角色、光源——并把它变成画面,但并不决定你从哪个角度看。这个选择属于一个很小的**渲染器**模组:**Top-down Tiles** 从正上方显示地图,**Isometric Tiles** 则把它显示为菱形网格。同一张地图、同一批事件和存档,在两者之下都能正常工作。
你不会直接使用这个模组。它连同至少一个渲染器一起,存在于每个带图块地图的游戏中,例如 RPG Plus。
![同一张村庄地图由两个渲染器分别绘制:左边是方形俯视,右边是等距。](assets/same-map-two-projections.jpg)
## 分工
```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. **地图是空的,日志说渲染器不可用。** 游戏的**地图投影**指定了一个不在游戏中的渲染器模组;把它加进来,或者改一下设置。
+3
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
collection_settings/projection = マップの投影
collection_settings/projection_hint = タイルの描画方式を選びます。対応する描画MODを構成に含めてください。
collection_settings/world_group = ワールド
+3
View File
@@ -0,0 +1,3 @@
collection_settings/projection = 지도 투영
collection_settings/projection_hint = 타일 표시 방식을 선택합니다. 해당 렌더러 모드가 컬렉션에 있어야 합니다.
collection_settings/world_group = 월드
+3
View File
@@ -0,0 +1,3 @@
collection_settings/projection = Проекция карты
collection_settings/projection_hint = Выберите способ отображения тайлов. Соответствующий мод рендера должен быть включён в коллекцию.
collection_settings/world_group = Мир
+3
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
collection_settings/projection = 地图投影
collection_settings/projection_hint = 选择瓦片的渲染方式。对应渲染模组必须包含在合集中。
collection_settings/world_group = 世界
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
mod_tile_render_base_name = タイル描画
mod_tile_render_base_desc=タイルマップの背後にある共通エンジン。用意されたマップの記述を絵に変えます。見る角度は、その上の小さなレンダラー Mod が決めます。
+2
View File
@@ -0,0 +1,2 @@
mod_tile_render_base_name = 타일 렌더링
mod_tile_render_base_desc=타일 맵 뒤의 공유 엔진: 준비된 맵 묘사를 그림으로 바꿉니다. 보는 각도는 그 위의 작은 렌더러 모드가 정합니다.
+2
View File
@@ -0,0 +1,2 @@
mod_tile_render_base_name = Отрисовка тайлов
mod_tile_render_base_desc=Общий движок тайловых карт: превращает подготовленное описание карты в картинку, а угол обзора выбирает небольшой мод-рендерер поверх.
+2
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
mod_tile_render_base_name = 图块渲染
mod_tile_render_base_desc=图块地图背后的共享引擎:把准备好的地图描述变成画面;观看角度由其上的一个小型渲染器模组决定。
+17
View File
@@ -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"
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

+51
View File
@@ -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);
}
+19
View File
@@ -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);
}
}
+13
View File
@@ -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();
}
}
+34
View File
@@ -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();
}
}
+17
View File
@@ -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;
}
}
+31
View File
@@ -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();
}
}
}
+39
View File
@@ -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);
}
}
}
+45
View File
@@ -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)));
}
}
}
}
+35
View File
@@ -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);
}
}
}
+243
View File
@@ -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));
}
}
+77
View File
@@ -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);
}
}
}
+25
View File
@@ -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);
}
}
}
+15
View File
@@ -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; }
}
}
+11
View File
@@ -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; }
}
}
+28
View File
@@ -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();
}
}
}
+11
View File
@@ -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);
}
}
+46
View File
@@ -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)}");
}
}
}
+9
View File
@@ -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; }
}
}
+78
View File
@@ -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;
}
}
}
+10
View File
@@ -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; }
}
}
+13
View File
@@ -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; }
}
}
+96
View File
@@ -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); }
}
}
+21
View File
@@ -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; }
}
}
+19
View File
@@ -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 };
}
}
}
+12
View File
@@ -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();
}
}
+31
View File
@@ -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");
}
}
}
+41
View File
@@ -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");
}
}
}
+41
View File
@@ -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");
}
}
}
}