diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3030c1a --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/collection_settings.json b/collection_settings.json new file mode 100644 index 0000000..8fa22a4 --- /dev/null +++ b/collection_settings.json @@ -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 + } + ] +} diff --git a/docs/assets/same-map-two-projections.jpg b/docs/assets/same-map-two-projections.jpg new file mode 100644 index 0000000..15aa14c Binary files /dev/null and b/docs/assets/same-map-two-projections.jpg differ diff --git a/docs/en.md b/docs/en.md new file mode 100644 index 0000000..71e41cd --- /dev/null +++ b/docs/en.md @@ -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_.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. diff --git a/docs/es.md b/docs/es.md new file mode 100644 index 0000000..10ea74c --- /dev/null +++ b/docs/es.md @@ -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_.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. diff --git a/docs/ja.md b/docs/ja.md new file mode 100644 index 0000000..c4f9a8a --- /dev/null +++ b/docs/ja.md @@ -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_.properties` で翻訳されます。 + +## うまくいかないとき + +1. **マップが空で、ログにレンダラーが利用できないと出る。** ゲームの **マップの投影** が、ゲームに含まれない描画 Mod を指しています。追加するか、設定を変えてください。 diff --git a/docs/ko.md b/docs/ko.md new file mode 100644 index 0000000..8215b39 --- /dev/null +++ b/docs/ko.md @@ -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_.properties`에서 번역됩니다. + +## 문제가 생겼을 때 + +1. **맵이 비어 있고 로그에 렌더러를 쓸 수 없다고 나온다.** 게임의 **지도 투영**이 게임에 없는 렌더러 모드를 가리킵니다. 그 모드를 넣거나 설정을 바꾸세요. diff --git a/docs/ru.md b/docs/ru.md new file mode 100644 index 0000000..1b7b9c9 --- /dev/null +++ b/docs/ru.md @@ -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_.properties`. + +## Если что-то пошло не так + +1. **Карта пуста, а в журнале написано, что рендерер недоступен.** **Проекция карты** игры называет мод-рендерер, которого нет в составе игры; добавьте его или поменяйте настройку. diff --git a/docs/vi.md b/docs/vi.md new file mode 100644 index 0000000..fa8c228 --- /dev/null +++ b/docs/vi.md @@ -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_.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. diff --git a/docs/zh.md b/docs/zh.md new file mode 100644 index 0000000..d7b1b2a --- /dev/null +++ b/docs/zh.md @@ -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_.properties` 中翻译。 + +## 如果遇到问题 + +1. **地图是空的,日志说渲染器不可用。** 游戏的**地图投影**指定了一个不在游戏中的渲染器模组;把它加进来,或者改一下设置。 diff --git a/locale/collection_settings_en.properties b/locale/collection_settings_en.properties new file mode 100644 index 0000000..5b86077 --- /dev/null +++ b/locale/collection_settings_en.properties @@ -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 diff --git a/locale/collection_settings_es.properties b/locale/collection_settings_es.properties new file mode 100644 index 0000000..607c6e1 --- /dev/null +++ b/locale/collection_settings_es.properties @@ -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 diff --git a/locale/collection_settings_ja.properties b/locale/collection_settings_ja.properties new file mode 100644 index 0000000..859acd8 --- /dev/null +++ b/locale/collection_settings_ja.properties @@ -0,0 +1,3 @@ +collection_settings/projection = マップの投影 +collection_settings/projection_hint = タイルの描画方式を選びます。対応する描画MODを構成に含めてください。 +collection_settings/world_group = ワールド diff --git a/locale/collection_settings_ko.properties b/locale/collection_settings_ko.properties new file mode 100644 index 0000000..ccce0f6 --- /dev/null +++ b/locale/collection_settings_ko.properties @@ -0,0 +1,3 @@ +collection_settings/projection = 지도 투영 +collection_settings/projection_hint = 타일 표시 방식을 선택합니다. 해당 렌더러 모드가 컬렉션에 있어야 합니다. +collection_settings/world_group = 월드 diff --git a/locale/collection_settings_ru.properties b/locale/collection_settings_ru.properties new file mode 100644 index 0000000..f555472 --- /dev/null +++ b/locale/collection_settings_ru.properties @@ -0,0 +1,3 @@ +collection_settings/projection = Проекция карты +collection_settings/projection_hint = Выберите способ отображения тайлов. Соответствующий мод рендера должен быть включён в коллекцию. +collection_settings/world_group = Мир diff --git a/locale/collection_settings_vi.properties b/locale/collection_settings_vi.properties new file mode 100644 index 0000000..123ffa1 --- /dev/null +++ b/locale/collection_settings_vi.properties @@ -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 diff --git a/locale/collection_settings_zh.properties b/locale/collection_settings_zh.properties new file mode 100644 index 0000000..0cf3806 --- /dev/null +++ b/locale/collection_settings_zh.properties @@ -0,0 +1,3 @@ +collection_settings/projection = 地图投影 +collection_settings/projection_hint = 选择瓦片的渲染方式。对应渲染模组必须包含在合集中。 +collection_settings/world_group = 世界 diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..90c63e7 --- /dev/null +++ b/locale/en.properties @@ -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. diff --git a/locale/es.properties b/locale/es.properties new file mode 100644 index 0000000..83bd2bb --- /dev/null +++ b/locale/es.properties @@ -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. diff --git a/locale/ja.properties b/locale/ja.properties new file mode 100644 index 0000000..b1d6441 --- /dev/null +++ b/locale/ja.properties @@ -0,0 +1,2 @@ +mod_tile_render_base_name = タイル描画 +mod_tile_render_base_desc=タイルマップの背後にある共通エンジン。用意されたマップの記述を絵に変えます。見る角度は、その上の小さなレンダラー Mod が決めます。 diff --git a/locale/ko.properties b/locale/ko.properties new file mode 100644 index 0000000..f11abe3 --- /dev/null +++ b/locale/ko.properties @@ -0,0 +1,2 @@ +mod_tile_render_base_name = 타일 렌더링 +mod_tile_render_base_desc=타일 맵 뒤의 공유 엔진: 준비된 맵 묘사를 그림으로 바꿉니다. 보는 각도는 그 위의 작은 렌더러 모드가 정합니다. diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..6426938 --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,2 @@ +mod_tile_render_base_name = Отрисовка тайлов +mod_tile_render_base_desc=Общий движок тайловых карт: превращает подготовленное описание карты в картинку, а угол обзора выбирает небольшой мод-рендерер поверх. diff --git a/locale/vi.properties b/locale/vi.properties new file mode 100644 index 0000000..5656a16 --- /dev/null +++ b/locale/vi.properties @@ -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. diff --git a/locale/zh.properties b/locale/zh.properties new file mode 100644 index 0000000..b166cda --- /dev/null +++ b/locale/zh.properties @@ -0,0 +1,2 @@ +mod_tile_render_base_name = 图块渲染 +mod_tile_render_base_desc=图块地图背后的共享引擎:把准备好的地图描述变成画面;观看角度由其上的一个小型渲染器模组决定。 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..49b5720 --- /dev/null +++ b/manifest.json @@ -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" + ] +} diff --git a/mod.jpg b/mod.jpg new file mode 100644 index 0000000..c3f86fc Binary files /dev/null and b/mod.jpg differ diff --git a/shaders/tile_lit.gdshader b/shaders/tile_lit.gdshader new file mode 100644 index 0000000..f4f6fb5 --- /dev/null +++ b/shaders/tile_lit.gdshader @@ -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); +} diff --git a/src/ITileProjection.cs b/src/ITileProjection.cs new file mode 100644 index 0000000..9256e4f --- /dev/null +++ b/src/ITileProjection.cs @@ -0,0 +1,19 @@ +namespace Cthangover.Mods.TileRender +{ + using Godot; + + /// Maps cell-space geometry to pixels; it has no knowledge of RPG rules or artwork. + public interface ITileProjection + { + /// Projection-owned image placement policy; the backend never switches coordinate systems. + TileVisualPlacement Place(TileVisual visual); + /// Source-pixel scale, independent of camera zoom. + float Unit { get; } + /// Projects ground coordinates and optional elevation in cells. + Vector2 Project(Vector3 point); + /// Inverts ground projection before flooring to a cell. + Vector2 Unproject(Vector2 point); + /// Maps world facing (down/left/right/up) to a four-row charset. + int CharsetRow(int facing); + } +} diff --git a/src/ITileRendererFactory.cs b/src/ITileRendererFactory.cs new file mode 100644 index 0000000..92f4134 --- /dev/null +++ b/src/ITileRendererFactory.cs @@ -0,0 +1,13 @@ +namespace Cthangover.Mods.TileRender +{ + /// Discovered through ModTypeConsumer; constructors must not allocate Godot objects. + public interface ITileRendererFactory + { + /// Stable collection setting, such as topdown or isometric. + string Id { get; } + /// Creates a pure projection for tools, picking and previews. + ITileProjection Projection(int tileSize); + /// Creates one independently owned presentation, on the main thread. + ITileWorldPresentation Create(); + } +} diff --git a/src/ITileWorldPresentation.cs b/src/ITileWorldPresentation.cs new file mode 100644 index 0000000..de273a2 --- /dev/null +++ b/src/ITileWorldPresentation.cs @@ -0,0 +1,34 @@ +namespace Cthangover.Mods.TileRender +{ + using System; + using Godot; + + /// A replaceable view. No operation may advance gameplay or write a save. + public interface ITileWorldPresentation : IDisposable + { + /// Host attaches this root; the presentation owns all its descendants. + Node2D Root { get; } + /// Material shared by static and dynamic artwork, exposed for diagnostics. + ShaderMaterial SharedMaterial { get; } + /// Number of computed surface textures actually submitted to static draw chunks. + int SurfaceChunkCount { get; } + /// Projected static extent before camera scaling, for scrollable editor canvases. + Rect2 ContentBounds { get; } + /// Verifies the visible actors inherit the same material as the terrain. + bool ActorsShareMaterial { get; } + /// Builds static geometry from resolved visual data. + void Mount(TileRenderScene scene); + /// Applies a newer snapshot while reusing unaffected GPU textures and cached commands. + void Apply(TileSceneChanges changes); + /// Copies poses, camera focus and lighting without ticking simulation. + void Present(TileFrame frame, double delta); + /// Sets the visible host rectangle. + void Resize(Vector2 size); + /// Coordinates are relative to the host, including camera displacement. + bool TryPickGround(Vector2 localPoint, out Vector2I cell); + /// Projects a cell point into host coordinates. + Vector2 ProjectToView(Vector3 point); + /// Releases map resources; safe to call more than once. + void Unmount(); + } +} diff --git a/src/TileActorPose.cs b/src/TileActorPose.cs new file mode 100644 index 0000000..df07ecd --- /dev/null +++ b/src/TileActorPose.cs @@ -0,0 +1,17 @@ +namespace Cthangover.Mods.TileRender +{ + using Godot; + + /// One already evaluated actor pose; no event conditions or route definitions cross this boundary. + 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; + } +} diff --git a/src/TileAssetCache.cs b/src/TileAssetCache.cs new file mode 100644 index 0000000..54a6fbf --- /dev/null +++ b/src/TileAssetCache.cs @@ -0,0 +1,31 @@ +namespace Cthangover.Mods.TileRender +{ + using System; + using System.Collections.Generic; + using Cthangover.Core.Utils; + using Godot; + /// File-group lookup preserves mod overrides and ZIP providers. Instances own decoded textures and regions. + public class TileAssetCache : IDisposable + { + private readonly Dictionary 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(textures.Values)) texture?.Dispose(); + textures.Clear(); + } + } +} diff --git a/src/TileAutotile.cs b/src/TileAutotile.cs new file mode 100644 index 0000000..6afab3c --- /dev/null +++ b/src/TileAutotile.cs @@ -0,0 +1,39 @@ +namespace Cthangover.Mods.TileRender +{ + using System; + using Godot; + /// Projection-independent A2 quarter selection and phase addressing. + 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); + } + + } +} diff --git a/src/TileCanvasEffects.cs b/src/TileCanvasEffects.cs new file mode 100644 index 0000000..88d7f04 --- /dev/null +++ b/src/TileCanvasEffects.cs @@ -0,0 +1,45 @@ +namespace Cthangover.Mods.TileRender +{ + using Godot; + /// Cosmetic world-space accents and the exact interaction cell supplied by the simulation. + 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))); + } + } + } +} diff --git a/src/TileCanvasLayer.cs b/src/TileCanvasLayer.cs new file mode 100644 index 0000000..858c9e1 --- /dev/null +++ b/src/TileCanvasLayer.cs @@ -0,0 +1,35 @@ +namespace Cthangover.Mods.TileRender +{ + using System.Collections.Generic; + using Godot; + /// Cached draw commands for one static map chunk. Rebuilt only when the scene changes. + public partial class TileCanvasLayer : Node2D + { + private ITileProjection projection; + private readonly List visuals = new(); + public TileCanvasLayer(ITileProjection projection) { this.projection = projection; } + public void Add(TileVisual visual) => visuals.Add(visual); + public bool Matches(IReadOnlyList 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 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); + } + } +} diff --git a/src/TileCanvasPresentation.cs b/src/TileCanvasPresentation.cs new file mode 100644 index 0000000..47770da --- /dev/null +++ b/src/TileCanvasPresentation.cs @@ -0,0 +1,243 @@ +namespace Cthangover.Mods.TileRender +{ + using System; + using System.Collections.Generic; + using Cthangover.Core.Mods.Resolvers; + using Godot; + + /// + /// 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. + /// + 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 props = new(); + private readonly HashSet seenActors = new(); + private readonly List removedActors = new(); + public int LastChangedDrawChunks { get; private set; } + private readonly Dictionary 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; + /// Weak diagnostic only; does not retain implementation code during unload verification. + 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>(); + 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(); + 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)); + } +} diff --git a/src/TileCanvasSprite.cs b/src/TileCanvasSprite.cs new file mode 100644 index 0000000..3aa0db9 --- /dev/null +++ b/src/TileCanvasSprite.cs @@ -0,0 +1,77 @@ +namespace Cthangover.Mods.TileRender +{ + using Godot; + /// Upright image sorted at its ground anchor. A pose update never advances movement or animation. + 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); + } + } +} diff --git a/src/TileDrawing.cs b/src/TileDrawing.cs new file mode 100644 index 0000000..6289b50 --- /dev/null +++ b/src/TileDrawing.cs @@ -0,0 +1,25 @@ +namespace Cthangover.Mods.TileRender +{ + using Godot; + /// Shared UV drawing for runtime and tools. Ground transforms independently of image storage. + 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); + } + } +} diff --git a/src/TileFrame.cs b/src/TileFrame.cs new file mode 100644 index 0000000..1ec13ac --- /dev/null +++ b/src/TileFrame.cs @@ -0,0 +1,15 @@ +namespace Cthangover.Mods.TileRender +{ + using System.Collections.Generic; + using Godot; + /// Reusable frame storage; populated by the game, read synchronously by the presentation. + public class TileFrame + { + public List Actors { get; } = new(); + public List 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; } + } +} diff --git a/src/TileLight.cs b/src/TileLight.cs new file mode 100644 index 0000000..62ee43d --- /dev/null +++ b/src/TileLight.cs @@ -0,0 +1,11 @@ +namespace Cthangover.Mods.TileRender +{ + using Godot; + /// Position and radius are logical cell units in both projections. + public class TileLight + { + public Vector2 Position { get; set; } + public float Radius { get; set; } + public Color Color { get; set; } + } +} diff --git a/src/TileRenderScene.cs b/src/TileRenderScene.cs new file mode 100644 index 0000000..1249e5f --- /dev/null +++ b/src/TileRenderScene.cs @@ -0,0 +1,28 @@ +namespace Cthangover.Mods.TileRender +{ + using System.Collections.Generic; + using Godot; + + /// Static presentation snapshot, prepared by a game adapter. Owns only explicitly listed transient textures. + 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 Water { get; } = new(); + public List Visuals { get; } = new(); + public List OwnedTextures { get; } = new(); + public List Warnings { get; } = new(); + + /// Shared file-group textures are borrowed, never freed by the snapshot. + public void Release() + { + foreach (var texture in OwnedTextures) + texture.Dispose(); + OwnedTextures.Clear(); + Visuals.Clear(); + } + } +} diff --git a/src/TileRendererConsumer.cs b/src/TileRendererConsumer.cs new file mode 100644 index 0000000..2634c99 --- /dev/null +++ b/src/TileRendererConsumer.cs @@ -0,0 +1,11 @@ +namespace Cthangover.Mods.TileRender +{ + using Cthangover.Core.Mods.Consumers; + /// Uses the kernel's normal load, catch-up and reverse unload fan-out. + public class TileRendererConsumer : ModTypeConsumer + { + 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); + } +} diff --git a/src/TileRendererRegistry.cs b/src/TileRendererRegistry.cs new file mode 100644 index 0000000..089f801 --- /dev/null +++ b/src/TileRendererRegistry.cs @@ -0,0 +1,46 @@ +namespace Cthangover.Mods.TileRender +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Cthangover.Core.Utils; + + /// Mod-owned factories. Selection is never cached across games and an explicit missing ID never falls back. + public class TileRendererRegistry + { + public static TileRendererRegistry Instance { get; } = new(); + private readonly Dictionary factories = new(StringComparer.Ordinal); + /// Sent before the owner's assembly unloads, so live hosts release its nodes and delegates. + public event Action Removing; + public IReadOnlyList 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 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 ?? ""}' unavailable or ambiguous. Available: {string.Join(", ", Ids)}"); + } + } +} diff --git a/src/TileSceneChanges.cs b/src/TileSceneChanges.cs new file mode 100644 index 0000000..b1206a0 --- /dev/null +++ b/src/TileSceneChanges.cs @@ -0,0 +1,9 @@ +namespace Cthangover.Mods.TileRender +{ + /// Versioned replacement of prepared visual data; implementations retain unchanged draw chunks. + public class TileSceneChanges + { + public long BaseRevision { get; set; } + public TileRenderScene Scene { get; set; } + } +} diff --git a/src/TileSurfaceCache.cs b/src/TileSurfaceCache.cs new file mode 100644 index 0000000..1fabf5e --- /dev/null +++ b/src/TileSurfaceCache.cs @@ -0,0 +1,78 @@ +namespace Cthangover.Mods.TileRender +{ + using System; + using System.Collections.Generic; + using Cthangover.Core.Utils; + using Godot; + /// + /// 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. + /// + public class TileSurfaceCache : IDisposable + { + public const int ChunkSize = 32; + private readonly Dictionary chunks = new(); + private TileSurfaceField previous; + private Texture2D atlas; + private byte[] pixels; + private int atlasWidth; + public int LastBuiltChunks { get; private set; } + public IEnumerable 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; + } + } +} diff --git a/src/TileSurfaceChunk.cs b/src/TileSurfaceChunk.cs new file mode 100644 index 0000000..d82c545 --- /dev/null +++ b/src/TileSurfaceChunk.cs @@ -0,0 +1,10 @@ +namespace Cthangover.Mods.TileRender +{ + using Godot; + /// Generated texture and its logical extent; lifetime belongs to TileSurfaceCache. + public class TileSurfaceChunk + { + public Rect2I Cells { get; set; } + public Texture2D Texture { get; set; } + } +} diff --git a/src/TileSurfaceField.cs b/src/TileSurfaceField.cs new file mode 100644 index 0000000..590a658 --- /dev/null +++ b/src/TileSurfaceField.cs @@ -0,0 +1,13 @@ +namespace Cthangover.Mods.TileRender +{ + /// Projection-independent material IDs (-1 means no surface) and blending flags in row-major order. + 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; } + } +} diff --git a/src/TileSurfaceRasterizer.cs b/src/TileSurfaceRasterizer.cs new file mode 100644 index 0000000..f740468 --- /dev/null +++ b/src/TileSurfaceRasterizer.cs @@ -0,0 +1,96 @@ +namespace Cthangover.Mods.TileRender +{ + using System; + using Godot; + /// + /// 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. + /// + 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); } + } +} diff --git a/src/TileVisual.cs b/src/TileVisual.cs new file mode 100644 index 0000000..2a616ac --- /dev/null +++ b/src/TileVisual.cs @@ -0,0 +1,21 @@ +namespace Cthangover.Mods.TileRender +{ + using Godot; + + /// A resolved image region in cell space. Texture pixels are never gameplay geometry. + 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; + /// Optional explicit world plane, TL/TR/BR/BL in world cell coordinates; with Upright it is depth-sorted at Anchor. + public Vector3[] WorldQuad { get; set; } + /// Only this projection uses the visual (empty means both). + public string Only { get; set; } + } +} diff --git a/src/TileVisualPlacement.cs b/src/TileVisualPlacement.cs new file mode 100644 index 0000000..23f2497 --- /dev/null +++ b/src/TileVisualPlacement.cs @@ -0,0 +1,19 @@ +namespace Cthangover.Mods.TileRender +{ + using Godot; + /// Projection output, consumed without knowing a coordinate system: an affine quad and optional sort anchor. + 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 }; + } + } +} diff --git a/tests/StubTileRendererFactory.cs b/tests/StubTileRendererFactory.cs new file mode 100644 index 0000000..7c4569b --- /dev/null +++ b/tests/StubTileRendererFactory.cs @@ -0,0 +1,12 @@ +namespace Cthangover.Mods.TileRender.Tests +{ + using System; + /// Registry fixture with no Godot allocation or dependency on an implementation mod. + 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(); + } +} diff --git a/tests/TileRendererRegistryTests.cs b/tests/TileRendererRegistryTests.cs new file mode 100644 index 0000000..779f771 --- /dev/null +++ b/tests/TileRendererRegistryTests.cs @@ -0,0 +1,31 @@ +namespace Cthangover.Mods.TileRender.Tests +{ + using Cthangover.Core.Mods.Testing; + /// Explicit selection, ambiguity and owner-sensitive unregister never depend on discovery order. + 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"); + } + } +} diff --git a/tests/TileSurfaceCacheTests.cs b/tests/TileSurfaceCacheTests.cs new file mode 100644 index 0000000..a8e2a61 --- /dev/null +++ b/tests/TileSurfaceCacheTests.cs @@ -0,0 +1,41 @@ +namespace Cthangover.Mods.TileRender.Tests +{ + using System.Linq; + using Cthangover.Core.Mods.Testing; + using Godot; + /// One edit rebuilds its affected halo, and cached GPU resources have explicit ownership. + 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"); + } + } +} diff --git a/tests/TileSurfaceRasterizerTests.cs b/tests/TileSurfaceRasterizerTests.cs new file mode 100644 index 0000000..4e74218 --- /dev/null +++ b/tests/TileSurfaceRasterizerTests.cs @@ -0,0 +1,41 @@ +namespace Cthangover.Mods.TileRender.Tests +{ + using Cthangover.Core.Mods.Testing; + using Godot; + /// Chunk edges, world phase and diagonal blending must match the full reference texture byte-for-byte. + 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"); + } + } + } +}