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

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

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

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

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

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

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

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