Compare commits

..
7 Commits
91 changed files with 4065 additions and 436 deletions

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
# Godot 4.x
.godot/
.idea/
sdk/
bin/
obj/
#ai
.omo/
.opencode/
gen/
obj/
bin/
art_sources/
*.tres~
*.tscn~
# GDScript LSP
.godot/
# Export
*.exe
*.dmg
*.apk
*.aab
*.pck
*.zip
*.log
# OS
.DS_Store
Thumbs.db
# Editor
*.import
*.godot.uid
+14
View File
@@ -0,0 +1,14 @@
{
"settings": [
{
"key": "rpg.player_charset",
"name": "collection_settings/appearance",
"description": "collection_settings/appearance_hint",
"group": "collection_settings/world_group",
"type": "resource",
"source": "charsets",
"allow_custom": false,
"automatic": "collection_settings/appearance_auto"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+95
View File
@@ -0,0 +1,95 @@
# RPG World — walk a tile map, talk, open, fight
RPG World turns a game into a tile-map RPG in the spirit of classic 2D role-playing games: you walk a grid map with the keyboard, talk to people, open chests, step through doors and fight monsters, then come back to the same spot. A map is an ordinary scene of the game, so the dialog box, scenarios, saving, music and time of day all work on maps as they do in a visual novel.
It powers the village, forest and desert of **RPG Plus**. The maps and people themselves come from content mods such as RPG Demo; this mod supplies the rules.
![Exploring the village in RPG Plus: the map name at the top left, the toolbar at the top right, the controls hint at the bottom left.](assets/village.jpg)
## How to play on a map
- **Walk** with WASD or the arrow keys; hold **Shift** to run.
- **Act** with **E** or **F**: talk to the person you face, open the chest, read the sign.
- **Step** onto a doorway or a map edge to go to the next map.
- **Random battles** can start as you walk in some places, such as the forest.
- After a battle you are back on the same cell of the same map.
- The game saves where you stand; loading puts you back there.
The cell you can act on is framed, and the hint at the bottom left changes to **E / F · Interact**.
## Example: a chest that stays open
The chest in the elder's house is two events on the same cell. The closed one shows while the story flag `rpg_house_chest` is not set; its scenario gives three coins and sets the flag. From then on the open chest shows instead.
![Before: the hero next to the closed chest in the elder's house at noon.](assets/chest-before.jpg)
![The chest's text: old socks, a note saying "Don't touch", and three coins underneath.](assets/chest-text.jpg)
![After: the same cell now shows the open chest, framed by the interaction marker; the hint at the bottom left reads "E / F · Interact".](assets/chest-after.jpg)
```json
{ "Id": "chest", "X": 9, "Y": 7, "Tile": 14,
"Scenario": "rpg_demo/events/house_chest",
"Condition": "rpg_house_chest != 1" },
{ "Id": "chest_open", "X": 9, "Y": 7, "Tile": 15,
"Scenario": "rpg_demo/events/chest_empty",
"Condition": "rpg_house_chest == 1" }
```
```scenario
ptext "In the chest: old socks and a note saying 'Don't touch'. Too late. Under the socks: three coins." key=rpg_demo/chest/house
action item.add id=money/coin count=3
flag rpg_house_chest 1
end
```
## For authors: maps
A map is a JSON file in a mod's `maps/` folder, plus a scene with the same name in `scenes/` for its background in battles and its ambience.
- **Three drawn layers**, each a list of rows of tile numbers (`.` for empty): `Back` — ground, floors, water; `Main` — objects at the characters' level: walls, trunks, fences; `Front` — drawn over the characters: tree crowns, eaves, arches.
- **Collision** — a row layer that overrides the tiles' own passability where needed: an invisible wall at a cliff edge or a gap in a hedge.
- **Tileset** — the tile atlas and the properties of its tiles (solid or not, surfaces, objects).
- **Spawn** — where a new arrival appears; **Zoom**; optional **Ambient** colour for interiors (otherwise the time of day lights the map) and **Atmosphere** (`woodland` adds leaves, fireflies and water ripples).
- **Encounters** — random battles: on average one per `Steps` steps, against one of the `Groups`.
```json
"Encounters": { "Steps": 48, "Groups": ["rpg_slime", "rpg_slime,rpg_slime"] }
```
## For authors: events
Events live in `map_events/<map id>/*.json` of any mod. An event has a **look** and an **effect**, chosen independently:
- look: a character `Sprite`, a single atlas `Tile`, an item `Icon` lying on the ground, or nothing (an invisible trigger);
- effect: a `Teleport` to a cell of this or another map, or a `Scenario` followed by optional inline `Commands` — the full scenario language, including `action rpg.battle`, flags and quests;
- `Trigger`: `Action` (E/F while facing it) or `Touch` (stepping onto it);
- `Solid`: whether it blocks the way (by default, drawn events do);
- `Condition`: hide the event unless the condition holds;
- `Pages`: alternative looks and effects, each with its own condition — the last page whose condition holds is used;
- `Route`: autonomous movement such as `{"Steps": ["left", "wait", "right"], "Every": 0.8}` with `up`/`down`/`left`/`right`, `random`, `toward`/`away` (from the player), `wait` and `face_…`;
- `Light`: a glow around the event, `{"Radius": 3, "Color": "#ffd8a0"}`, seen at dusk and night.
**Add to or change another mod's map.** Event files are merged in load order: a new `Id` adds an event to that map, and the same `Id` from a mod that depends on the owner replaces it. This is how RPG Plus gives the demo's elder new dialogs.
## Scenario actions
```scenario
action rpg.battle enemies=rpg_wolf,rpg_slime win_flag=rpg_wolf_dead
action rpg.teleport map=rpg_forest x=1 y=8 dir=right
```
- `rpg.battle` starts a battle with the game's battle core and returns to the same cell; on victory `win_flag` is set to 1, so a map monster whose condition tests that flag disappears. End the scenario after it.
- `rpg.teleport` moves the player to a cell, on this map or another one.
## Game settings
- **Appearance** (`rpg.player_charset`) — the hero's sprite on maps, chosen from the charsets in the game.
- **Map projection** (`tile.renderer`) — top-down or isometric; see Tile Rendering.
## If something goes wrong
1. **A person or chest does not react.** Face it and press E or F; a `Touch` event reacts only when you step on it.
2. **A monster or chest comes back.** Its `Condition` must test the flag that its scenario or `win_flag` sets.
3. **You cannot walk somewhere that looks open.** A solid tile or the map's collision layer blocks that cell.
4. **No battle background.** The map's scene in `scenes/<map>.json` needs a `defaultBackground`.
+95
View File
@@ -0,0 +1,95 @@
# RPG World — camina por un mapa de tiles, habla, abre, lucha
RPG World convierte un juego en un RPG de mapas de tiles al estilo de los rol clásicos en 2D: avanzas por una cuadrícula con el teclado, hablas con la gente, abres cofres, cruzas puertas y luchas contra monstruos, y tras el combate vuelves al mismo punto. El mapa es una escena cualquiera del juego, así que el cuadro de diálogo, los escenarios, el guardado, la música y la hora del día funcionan en los mapas igual que en una novela visual.
Este mod da vida a la aldea, el bosque y el desierto de **RPG Plus**. Los mapas y sus habitantes vienen de mods de contenido como RPG Demo; este aporta las reglas.
![Explorando la aldea de RPG Plus: el nombre del mapa arriba a la izquierda, la barra de herramientas arriba a la derecha y la ayuda de controles abajo a la izquierda.](assets/village.jpg)
## Cómo jugar en un mapa
- **Camina** con WASD o las flechas; mantén pulsado **Shift** para correr.
- **Actúa** con **E** o **F**: habla con quien tengas delante, abre el cofre, lee el cartel.
- **Pisa** una puerta o el borde del mapa para pasar al siguiente.
- En algunos sitios, como el bosque, los **combates aleatorios** pueden empezar mientras caminas.
- Tras un combate vuelves a la misma casilla del mismo mapa.
- El juego guarda tu posición; al cargar reapareces exactamente ahí.
La casilla con la que puedes interactuar se enmarca, y la ayuda de abajo a la izquierda cambia a **E / F · Interact**.
## Ejemplo: un cofre que sigue abierto
El cofre de la casa del anciano son dos eventos en la misma casilla. El cerrado se muestra mientras la bandera de historia `rpg_house_chest` no esté activa; su escenario entrega tres monedas y activa la bandera. A partir de entonces se muestra en su lugar el cofre abierto.
![Antes: el héroe junto al cofre cerrado en la casa del anciano, al mediodía.](assets/chest-before.jpg)
![El texto del cofre: calcetines viejos, una nota de «No tocar» y tres monedas debajo.](assets/chest-text.jpg)
![Después: la misma casilla muestra ahora el cofre abierto, enmarcado por el marcador de interacción; la ayuda de abajo a la izquierda dice «E / F · Interact».](assets/chest-after.jpg)
```json
{ "Id": "chest", "X": 9, "Y": 7, "Tile": 14,
"Scenario": "rpg_demo/events/house_chest",
"Condition": "rpg_house_chest != 1" },
{ "Id": "chest_open", "X": 9, "Y": 7, "Tile": 15,
"Scenario": "rpg_demo/events/chest_empty",
"Condition": "rpg_house_chest == 1" }
```
```scenario
ptext "En el cofre: calcetines viejos y una nota que dice "No tocar". Demasiado tarde. Debajo de los calcetines: tres monedas." key=rpg_demo/chest/house
action item.add id=money/coin count=3
flag rpg_house_chest 1
end
```
## Para autores: mapas
Un mapa es un archivo JSON en la carpeta `maps/` de un mod, más una escena con el mismo nombre en `scenes/` para su fondo en los combates y su ambiente sonoro.
- **Tres capas dibujadas**, cada una una lista de filas con números de tile (`.` para vacío): `Back` — suelo, interiores, agua; `Main` — objetos a la altura de los personajes: muros, troncos, vallas; `Front` — se dibuja sobre los personajes: copas de los árboles, aleros, arcos.
- **Colisiones** — una capa de filas que sustituye la transitabilidad de los tiles donde hace falta: un muro invisible al borde de un acantilado o un hueco en un seto.
- **Tileset** — el atlas de tiles y las propiedades de cada uno (sólido o no, superficies, objetos).
- **Spawn** — dónde aparece quien llega nuevo; **Zoom**; el color **Ambient** opcional para interiores (si no, la hora del día ilumina el mapa) y **Atmosphere** (`woodland` añade hojas, luciérnagas y ondas en el agua).
- **Encounters** — combates aleatorios: de media uno cada `Steps` pasos contra uno de los `Groups`.
```json
"Encounters": { "Steps": 48, "Groups": ["rpg_slime", "rpg_slime,rpg_slime"] }
```
## Para autores: eventos
Los eventos viven en `map_events/<id del mapa>/*.json` de cualquier mod. Un evento tiene un **aspecto** y un **efecto**, elegidos por separado:
- aspecto: un `Sprite` de personaje, un `Tile` suelto del atlas, el `Icon` de un objeto tirado en el suelo o nada (un desencadenante invisible);
- efecto: un `Teleport` a una casilla de este u otro mapa, o un `Scenario` seguido de `Commands` en línea opcionales — el lenguaje de escenarios completo, con `action rpg.battle`, banderas y misiones incluidas;
- `Trigger`: `Action` (E/F estando de cara) o `Touch` (al pisarlo);
- `Solid`: si bloquea el paso (por defecto, los eventos dibujados sí lo hacen);
- `Condition`: oculta el evento mientras no se cumpla la condición;
- `Pages`: aspectos y efectos alternativos, cada uno con su condición; se usa la última página cuya condición se cumpla;
- `Route`: movimiento autónomo como `{"Steps": ["left", "wait", "right"], "Every": 0.8}`, con `up`/`down`/`left`/`right`, `random`, `toward`/`away` (hacia el jugador o alejándose), `wait` y `face_…`;
- `Light`: un brillo alrededor del evento, `{"Radius": 3, "Color": "#ffd8a0"}`, visible al anochecer y de noche.
**Añade o cambia el mapa de otro mod.** Los archivos de eventos se combinan en orden de carga: un `Id` nuevo añade un evento al mapa, y el mismo `Id` de un mod que dependa del dueño lo sustituye. Así da RPG Plus nuevos diálogos al anciano de la demo.
## Acciones de escenario
```scenario
action rpg.battle enemies=rpg_wolf,rpg_slime win_flag=rpg_wolf_dead
action rpg.teleport map=rpg_forest x=1 y=8 dir=right
```
- `rpg.battle` inicia un combate con el núcleo de combate del juego y vuelve a la misma casilla; al ganar, `win_flag` pasa a 1, así que el monstruo del mapa cuya condición comprueba esa bandera desaparece. Termina el escenario después de esta acción.
- `rpg.teleport` mueve al jugador a una casilla de este mapa o de otro.
## Ajustes del juego
- **Apariencia** (`rpg.player_charset`) — el sprite del héroe en los mapas, elegido entre los charsets del juego.
- **Proyección del mapa** (`tile.renderer`) — vista superior o isométrica; ver Tile Rendering.
## Si algo falla
1. **Un personaje o un cofre no reacciona.** Colócate de frente y pulsa E o F; un evento `Touch` solo responde al pisarlo.
2. **Un monstruo o un cofre vuelve a aparecer.** Su `Condition` debe comprobar la bandera que activa su propio escenario o `win_flag`.
3. **No puedes pasar por un sitio que parece libre.** Esa casilla la bloquea un tile sólido o la capa de colisiones del mapa.
4. **No hay fondo de combate.** La escena del mapa en `scenes/<map>.json` necesita un `defaultBackground`.
+95
View File
@@ -0,0 +1,95 @@
# RPG World — タイルマップを歩き、話し、開け、戦う
RPG World は、ゲームをクラシックな 2D RPG の趣のあるタイルマップ式 RPG に変えます。キーボードでマス目状のマップを歩き、人と話し、宝箱を開け、ドアをくぐり、モンスターと戦う——そして戦いのあとは元の場所へ戻ります。マップはゲームのごく普通のシーンなので、会話ウィンドウもシナリオもセーブも音楽も時間の流れも、ビジュアルノベルと同じようにマップ上で働きます。
**RPG Plus** の村、森、砂漠はこの仕組みで動いています。マップや登場人物そのものは RPG Demo のようなコンテンツ Mod が担い、この Mod がルールを提供します。
![RPG Plus で村を探索したところ。左上にマップ名、右上にツールバー、左下に操作ヒント。](assets/village.jpg)
## マップでの遊び方
- **移動**は WASD か矢印キー。**Shift** を押している間は走ります。
- **操作**は **E** または **F**。向いている人に話しかける、宝箱を開ける、看板を読む。
- **ドアやマップの端に歩いて乗ると**、次のマップへ移動します。
- 場所によっては、歩いているだけで**ランダムエンカウント**が起こります(森など)。
- 戦闘が終わると、同じマップの同じマスへ戻ります。
- ゲームは立っている位置をセーブします。ロードすればそこへ戻れます。
操作できるマスは枠で囲まれ、左下のヒントが **E / F · Interact** に変わります。
## 例:開いたままの宝箱
長老の家の宝箱は、同じマスに置かれた 2 つのイベントです。ストーリーフラグ `rpg_house_chest` が立っていない間は閉じた宝箱が表示され、そのシナリオは硬貨 3 枚を渡してフラグを立てます。以降は、代わりに開いた宝箱が表示されます。
![変更前:正午の長老の家。閉じた宝箱のそばに主人公が立っている。](assets/chest-before.jpg)
![宝箱の中身のテキスト:古い靴下、「さわるな」というメモ、そしてその下の硬貨 3 枚。](assets/chest-text.jpg)
![変更後:同じマスには開いた宝箱が表示され、インタラクションマーカーの枠が付く。左下のヒントは「E / F · Interact」。](assets/chest-after.jpg)
```json
{ "Id": "chest", "X": 9, "Y": 7, "Tile": 14,
"Scenario": "rpg_demo/events/house_chest",
"Condition": "rpg_house_chest != 1" },
{ "Id": "chest_open", "X": 9, "Y": 7, "Tile": 15,
"Scenario": "rpg_demo/events/chest_empty",
"Condition": "rpg_house_chest == 1" }
```
```scenario
ptext "宝箱の中には古い靴下と「さわるな」と書かれたメモ。もう遅い。靴下の下には硬貨が 3 枚。" key=rpg_demo/chest/house
action item.add id=money/coin count=3
flag rpg_house_chest 1
end
```
## 作者向け:マップ
マップは Mod の `maps/` フォルダーにある JSON ファイルです。戦闘の背景と環境音のために、`scenes/` にも同名のシーンを置きます。
- **描画レイヤー 3 枚**。それぞれタイル番号の行のリスト(`.` は空)です。`Back` は地面・床・水、`Main` はキャラクターと同じ高さの物体(壁、幹、柵)、`Front` はキャラクターの上に描かれるもの(樹冠、軒、アーチ)。
- **衝突(コリジョン)** — 必要な場所でタイル自身の通行可否を上書きする行レイヤー。崖際の見えない壁や生け垣の抜け道に使います。
- **タイルセット** — タイルアトラスと、各タイルの属性(solid かどうか、地面の種類、物体)。
- **Spawn** — 新しく来た者が出現する位置。**Zoom**。屋内用の任意の **Ambient** カラー(なければ時間の流れがマップを照らします)。そして **Atmosphere**(`woodland` は落ち葉・蛍・水のさざ波を追加)。
- **Encounters** — ランダムエンカウント。平均して `Steps` 歩ごとに 1 回、`Groups` のいずれかと戦います。
```json
"Encounters": { "Steps": 48, "Groups": ["rpg_slime", "rpg_slime,rpg_slime"] }
```
## 作者向け:イベント
イベントは、どの Mod の `map_events/<マップ id>/*.json` にも置けます。イベントは**見た目**と**効果**を独立して選べます。
- 見た目:キャラクターの `Sprite`、アトラスの `Tile` 1 枚、地面に置いたアイテムの `Icon`、あるいは何もなし(見えないトリガー)。
- 効果:このマップか別のマップのマスへの `Teleport`、または `Scenario` とそこに書ける任意の `Commands`——`action rpg.battle`、フラグ、クエストを含むシナリオ言語のすべて。
- `Trigger`: `Action`(正面から E/F)か `Touch`(乗る)。
- `Solid`: 通行を妨げるか(描画されるイベントは既定で妨げます)。
- `Condition`: 条件を満たさない間、イベントを隠します。
- `Pages`: 条件つきの別の見た目と効果。条件を満たす最後のページが使われます。
- `Route`: 自律的な動き。`{"Steps": ["left", "wait", "right"], "Every": 0.8}` のように、`up`/`down`/`left`/`right`、`random`、`toward`/`away`(プレイヤーに向かって/離れて)、`wait`、`face_…` が使えます。
- `Light`: イベントのまわりの光。`{"Radius": 3, "Color": "#ffd8a0"}`。夕暮れと夜に見えます。
**他の Mod のマップに追加・変更を加える。** イベントファイルは読み込み順にマージされます。新しい `Id` はそのマップへイベントを追加し、所有者に依存する Mod が同じ `Id` を置けば置き換わります。RPG Plus がデモの長老に新しい会話を与えているのはこの仕組みです。
## シナリオアクション
```scenario
action rpg.battle enemies=rpg_wolf,rpg_slime win_flag=rpg_wolf_dead
action rpg.teleport map=rpg_forest x=1 y=8 dir=right
```
- `rpg.battle` はゲームのバトルコアで戦闘を始め、同じマスへ戻します。勝つと `win_flag` が 1 になるので、そのフラグを条件で見ているマップ上のモンスターは消えます。このアクションのあとでシナリオを終えてください。
- `rpg.teleport` はプレイヤーを、このマップまたは別のマップのマスへ移動させます。
## ゲーム設定
- **外見**(`rpg.player_charset`)— マップ上で主人公に使うスプライト。ゲームのチャーセットから選びます。
- **マップ投影**(`tile.renderer`)— トップダウンかアイソメトリック。Tile Rendering を参照。
## うまくいかないとき
1. **人物や宝箱が反応しない。** 正面から E か F を押してください。`Touch` イベントは乗ったときにしか反応しません。
2. **モンスターや宝箱が元に戻る。** その `Condition` は、シナリオや `win_flag` が立てるフラグを見ている必要があります。
3. **開けて見える場所を歩けない。** そのマスは solid なタイルかマップの衝突レイヤーに塞がれています。
4. **戦闘の背景がない。** `scenes/<map>.json` にあるマップのシーンに `defaultBackground` が必要です。
+95
View File
@@ -0,0 +1,95 @@
# RPG World — 타일 맵을 걷고, 말하고, 열고, 싸우기
RPG World는 게임을 클래식 2D 롤플레이 게임 스타일의 타일 맵 RPG로 바꿉니다. 키보드로 격자 맵을 걷고, 사람들과 대화하고, 상자를 열고, 문을 지나가고, 몬스터와 싸운 뒤 제자리로 돌아옵니다. 맵은 게임의 평범한 씬이므로 대화창, 시나리오, 저장, 음악, 시간대가 비주얼 노벨에서처럼 맵 위에서 똑같이 작동합니다.
**RPG Plus**의 마을, 숲, 사막이 이 엔진으로 돌아갑니다. 맵과 사람 자체는 RPG Demo 같은 콘텐츠 모드에서 나오며, 이 모드는 규칙을 담당합니다.
![RPG Plus에서 마을 탐험: 왼쪽 위 맵 이름, 오른쪽 위 툴바, 왼쪽 아래 조작 힌트.](assets/village.jpg)
## 맵에서 플레이하기
- **이동**은 WASD 또는 방향키입니다. **Shift**를 누르고 있으면 달립니다.
- **상호 작용**은 **E** 또는 **F**: 마주 본 사람과 대화하기, 상자 열기, 표지판 읽기.
- 문이나 맵 가장자리로 **걸어 들어가면** 다음 맵으로 넘어갑니다.
- 일부 지역(예: 숲)에서는 걷다가 **랜덤 전투**가 시작될 수 있습니다.
- 전투가 끝나면 같은 맵의 같은 칸으로 돌아옵니다.
- 게임은 서 있는 위치를 저장하며, 불러오면 그 자리로 돌아갑니다.
상호 작용할 수 있는 칸에는 테두리가 표시되고, 왼쪽 아래 힌트가 **E / F · Interact**로 바뀝니다.
## 예시: 열린 채로 있는 상자
장로의 집 상자는 같은 칸에 놓인 두 이벤트입니다. 스토리 플래그 `rpg_house_chest`가 설정되지 않은 동안은 닫힌 상자가 보이고, 그 시나리오는 동전 세 닢을 주며 플래그를 설정합니다. 이후에는 열린 상자가 대신 표시됩니다.
![이전: 정오의 장로의 집, 닫힌 상자 옆에 선 주인공.](assets/chest-before.jpg)
![상자의 문구: 낡은 양말, '만지지 마시오'라고 쓴 쪽지, 그 밑의 동전 세 닢.](assets/chest-text.jpg)
![이후: 같은 칸에 이제 열린 상자가 표시되고 상호 작용 마커 테두리가 둘러짐. 왼쪽 아래 힌트는 "E / F · Interact".](assets/chest-after.jpg)
```json
{ "Id": "chest", "X": 9, "Y": 7, "Tile": 14,
"Scenario": "rpg_demo/events/house_chest",
"Condition": "rpg_house_chest != 1" },
{ "Id": "chest_open", "X": 9, "Y": 7, "Tile": 15,
"Scenario": "rpg_demo/events/chest_empty",
"Condition": "rpg_house_chest == 1" }
```
```scenario
ptext "상자 안에는 낡은 양말과 '만지지 마시오'라고 쓴 쪽지. 이미 늦었다. 양말 밑에 동전 세 닢." key=rpg_demo/chest/house
action item.add id=money/coin count=3
flag rpg_house_chest 1
end
```
## 작성자용: 맵
맵은 모드의 `maps/` 폴더에 있는 JSON 파일이고, 전투 배경과 환경음을 위해 `scenes/`에 같은 이름의 씬도 하나 필요합니다.
- **그려지는 레이어 3개**, 각 레이어는 타일 번호 행의 목록(`.`은 빈 칸)입니다. `Back` — 지면, 바닥, 물 / `Main` — 캐릭터 높이의 물건: 벽, 나무줄기, 울타리 / `Front` — 캐릭터 위에 그려지는 것: 나무 꼭대기, 처마, 아치.
- **충돌** — 필요한 곳에서 타일 자체의 통과 가능 여부를 덮어쓰는 행 레이어: 절벽 가장자리의 보이지 않는 벽, 울타리의 빈틈.
- **타일셋** — 타일 아틀라스와 타일 속성(막혀 있는지, 지형, 물건).
- **Spawn** — 새로 도착한 사람이 나타나는 위치. **Zoom**. 실내용 선택 **Ambient** 색(없으면 시간대가 맵을 비춥니다), 그리고 **Atmosphere**(`woodland`은 낙엽, 반딧불, 물결을 추가).
- **Encounters** — 랜덤 전투: 평균 `Steps` 걸음마다 한 번, `Groups` 중 하나와.
```json
"Encounters": { "Steps": 48, "Groups": ["rpg_slime", "rpg_slime,rpg_slime"] }
```
## 작성자용: 이벤트
이벤트는 어느 모드의 `map_events/<맵 id>/*.json`에나 있습니다. 이벤트는 **모습**과 **효과**를 따로 고릅니다.
- 모습: 캐릭터 `Sprite`, 아틀라스의 `Tile` 한 장, 땅에 놓인 아이템 `Icon`, 또는 없음(보이지 않는 트리거).
- 효과: 이 맵이나 다른 맵의 칸으로 가는 `Teleport`, 또는 `Scenario`와 선택적인 인라인 `Commands` — `action rpg.battle`, 플래그, 퀘스트를 포함한 시나리오 언어 전부.
- `Trigger`: `Action`(마주 본 채 E/F) 또는 `Touch`(밟기).
- `Solid`: 길을 막는지(그려지는 이벤트는 기본적으로 막습니다).
- `Condition`: 조건이 맞지 않으면 이벤트를 숨깁니다.
- `Pages`: 각자 조건을 가진 대체 모습과 효과. 조건이 맞는 마지막 페이지가 사용됩니다.
- `Route`: 자율 이동. `{"Steps": ["left", "wait", "right"], "Every": 0.8}` 같은 형태로 `up`/`down`/`left`/`right`, `random`, `toward`/`away`(플레이어 쪽으로/반대로), `wait`, `face_…` 사용.
- `Light`: 이벤트 주위의 빛. `{"Radius": 3, "Color": "#ffd8a0"}`. 저녁과 밤에 보입니다.
**다른 모드의 맵에 추가하거나 바꾸기.** 이벤트 파일은 로드 순서대로 합쳐집니다. 새 `Id`는 그 맵에 이벤트를 추가하고, 소유자에게 의존하는 모드가 같은 `Id`를 쓰면 대체합니다. RPG Plus가 데모의 장로에게 새 대화를 주는 방식이 바로 이것입니다.
## 시나리오 액션
```scenario
action rpg.battle enemies=rpg_wolf,rpg_slime win_flag=rpg_wolf_dead
action rpg.teleport map=rpg_forest x=1 y=8 dir=right
```
- `rpg.battle`은 게임의 전투 코어로 전투를 시작하고 같은 칸으로 돌아옵니다. 승리하면 `win_flag`가 1로 설정되어, 그 플래그를 조건으로 검사하는 맵의 몬스터는 사라집니다. 이 액션 뒤에는 시나리오를 끝내세요.
- `rpg.teleport`는 플레이어를 이 맵이나 다른 맵의 칸으로 옮깁니다.
## 게임 설정
- **외형**(`rpg.player_charset`) — 맵 위 주인공 스프라이트. 게임의 캐릭터셋에서 고릅니다.
- **맵 투영**(`tile.renderer`) — 탑다운 또는 아이소메트릭. Tile Rendering 참조.
## 문제가 생겼을 때
1. **인물이나 상자가 반응하지 않는다.** 마주 보고 서서 E 또는 F를 누르세요. `Touch` 이벤트는 밟았을 때만 반응합니다.
2. **몬스터나 상자가 돌아온다.** 그 `Condition`은 시나리오나 `win_flag`가 설정하는 플래그를 검사해야 합니다.
3. **비어 보이는 곳을 걸을 수 없다.** 그 칸은 막힌 타일이나 맵의 충돌 레이어가 막고 있습니다.
4. **전투 배경이 없다.** `scenes/<map>.json`의 맵 씬에 `defaultBackground`가 필요합니다.
+95
View File
@@ -0,0 +1,95 @@
# RPG World — ходите по тайловой карте, говорите, открывайте, сражайтесь
RPG World превращает игру в RPG на тайловых картах в духе классических двухмерных ролевых игр: вы ходите по карте из клеток, разговариваете с людьми, открываете сундуки, проходите через двери, сражаетесь с монстрами — а после боя возвращаетесь на то же место. Карта при этом остаётся обычной сценой игры, поэтому диалоговое окно, сценарии, сохранения, музыка и время суток работают на ней так же, как в визуальной новелле.
На этом движке построены деревня, лес и пустыня **RPG Plus**. Сами карты и их обитатели лежат в контентных модах вроде RPG Demo; этот мод отвечает за правила.
![Исследование деревни в RPG Plus: название карты слева сверху, панель инструментов справа сверху, подсказка управления слева снизу.](assets/village.jpg)
## Как играть на карте
- **Ходьба** — WASD или стрелки; с зажатым **Shift** герой бежит.
- **Действие** — **E** или **F**: заговорить с тем, кто перед вами, открыть сундук, прочитать табличку.
- **Шагните** на дверной проём или за край карты, чтобы попасть на следующую карту.
- **Случайные бои** могут начинаться прямо на ходу в некоторых местах — например, в лесу.
- После боя вы возвращаетесь на ту же клетку той же карты.
- Игра сохраняет, где вы стоите; загрузка возвращает вас ровно туда же.
Клетку, с которой можно взаимодействовать, обводит рамка, а подсказка слева снизу меняется на **E / F · Взаимодействовать**.
## Пример: сундук, который остаётся открытым
Сундук в доме старейшины — это два события на одной клетке. Закрытый показывается, пока не установлен сюжетный флаг `rpg_house_chest`; его сценарий выдаёт три монеты и ставит флаг. С этого момента вместо закрытого сундука показывается открытый.
![До: герой рядом с закрытым сундуком в доме старейшины в полдень.](assets/chest-before.jpg)
![Текст сундука: старые носки, записка «Не трогай» и три монеты под ними.](assets/chest-text.jpg)
![После: на той же клетке теперь открытый сундук в рамке маркера взаимодействия; подсказка слева снизу гласит «E / F · Взаимодействовать».](assets/chest-after.jpg)
```json
{ "Id": "chest", "X": 9, "Y": 7, "Tile": 14,
"Scenario": "rpg_demo/events/house_chest",
"Condition": "rpg_house_chest != 1" },
{ "Id": "chest_open", "X": 9, "Y": 7, "Tile": 15,
"Scenario": "rpg_demo/events/chest_empty",
"Condition": "rpg_house_chest == 1" }
```
```scenario
ptext "В сундуке — старые носки и записка: «Не трогай». Поздно. Под носками — три монеты." key=rpg_demo/chest/house
action item.add id=money/coin count=3
flag rpg_house_chest 1
end
```
## Авторам: карты
Карта — это JSON-файл в папке `maps/` мода, плюс сцена с тем же именем в `scenes/`: она задаёт фон для боёв и фоновый звук карты.
- **Три рисуемых слоя**, каждый — список строк с номерами тайлов (`.` — пусто): `Back` — земля, полы, вода; `Main` — объекты уровня персонажей: стены, стволы, заборы; `Front` — рисуется поверх персонажей: кроны деревьев, свесы крыш, арки.
- **Коллизии** — слой строк, который там, где нужно, переопределяет проходимость самих тайлов: невидимая стена у края обрыва или проход в живой изгороди.
- **Тайлсет** — атлас тайлов и свойства его тайлов (проходимость, поверхности, объекты).
- **Spawn** — где появляется только что прибывший герой; **Zoom**; необязательный цвет **Ambient** для интерьеров (иначе карту освещает время суток) и **Atmosphere** (`woodland` добавляет листья, светлячков и рябь на воде).
- **Encounters** — случайные бои: в среднем один за каждые `Steps` шагов против одной из групп `Groups`.
```json
"Encounters": { "Steps": 48, "Groups": ["rpg_slime", "rpg_slime,rpg_slime"] }
```
## Авторам: события
События лежат в `map_events/<id карты>/*.json` любого мода. У события независимо друг от друга выбираются **внешний вид** и **эффект**:
- вид: спрайт персонажа `Sprite`, один тайл из атласа `Tile`, иконка предмета `Icon`, лежащего на земле, или ничего (невидимый триггер);
- эффект: `Teleport` в клетку этой или другой карты либо `Scenario` с необязательными командами `Commands` прямо в событии — весь язык сценариев, включая `action rpg.battle`, флаги и задания;
- `Trigger`: `Action` (E/F, когда вы стоите к нему лицом) или `Touch` (наступить на событие);
- `Solid`: блокирует ли путь (нарисованные события по умолчанию блокируют);
- `Condition`: скрыть событие, пока условие не выполняется;
- `Pages`: альтернативные вид и эффект, у каждого своё условие; используется последняя страница с выполненным условием;
- `Route`: самостоятельное движение вроде `{"Steps": ["left", "wait", "right"], "Every": 0.8}` с направлениями `up`/`down`/`left`/`right`, режимами `random`, `toward`/`away` (к игроку и от него), а также `wait` и `face_…`;
- `Light`: свечение вокруг события, `{"Radius": 3, "Color": "#ffd8a0"}`; видно в сумерках и ночью.
**Дополняйте и меняйте карты чужих модов.** Файлы событий сливаются в порядке загрузки: новый `Id` добавляет событие на карту, а тот же `Id` от мода, который зависит от владельца карты, заменяет его. Именно так RPG Plus даёт старейшине из демо новые диалоги.
## Действия сценариев
```scenario
action rpg.battle enemies=rpg_wolf,rpg_slime win_flag=rpg_wolf_dead
action rpg.teleport map=rpg_forest x=1 y=8 dir=right
```
- `rpg.battle` запускает бой боевым ядром игры и возвращает героя на ту же клетку; при победе `win_flag` получает значение 1 — и монстр на карте, чьё условие проверяет этот флаг, исчезает. После этого действия сценарий нужно завершать.
- `rpg.teleport` переносит игрока в клетку этой или другой карты.
## Настройки игры
- **Внешний вид** (`rpg.player_charset`) — спрайт героя на картах, выбирается из чарсетов игры.
- **Проекция карты** (`tile.renderer`) — вид сверху или изометрия; см. Tile Rendering.
## Если что-то пошло не так
1. **Персонаж или сундук не реагирует.** Встаньте к нему лицом и нажмите E или F; событие с `Touch` срабатывает, только если на него наступить.
2. **Монстр или сундук возвращается.** Его `Condition` должен проверять тот флаг, который выставляет его сценарий или `win_flag`.
3. **Не пройти туда, где вроде бы открыто.** Клетку блокирует непроходимый тайл или слой коллизий карты.
4. **Нет фона для боя.** Сцене карты в `scenes/<map>.json` нужен `defaultBackground`.
+95
View File
@@ -0,0 +1,95 @@
# RPG World — đi lại trên bản đồ ô, trò chuyện, mở rương, chiến đấu
RPG World biến trò chơi thành RPG bản đồ ô theo tinh thần các game nhập vai 2D cổ điển: bạn đi trên lưới ô bằng bàn phím, nói chuyện với mọi người, mở rương, bước qua cửa và chiến đấu với quái vật, rồi trở về đúng chỗ cũ. Bản đồ vẫn là một cảnh bình thường của trò chơi, nên hộp hội thoại, kịch bản, lưu game, nhạc và thời gian trong ngày đều hoạt động trên bản đồ như trong tiểu thuyết thị giác.
Chính nó vận hành làng, rừng và sa mạc của **RPG Plus**. Bản đồ và nhân vật đến từ các mod nội dung như RPG Demo; còn mod này cung cấp luật chơi.
![Đang khám phá làng trong RPG Plus: tên bản đồ ở trên trái, thanh công cụ ở trên phải, gợi ý điều khiển ở dưới trái.](assets/village.jpg)
## Chơi trên bản đồ như thế nào
- **Đi lại** bằng WASD hoặc phím mũi tên; giữ **Shift** để chạy.
- **Tương tác** bằng **E** hoặc **F**: nói chuyện với người đang đối mặt, mở rương, đọc biển hiệu.
- **Bước lên** cửa hoặc rìa bản đồ để sang bản đồ kế tiếp.
- Ở một số nơi, ví dụ khu rừng, **trận chiến ngẫu nhiên** có thể nổ ra khi bạn đi lại.
- Sau trận chiến bạn trở về đúng ô cũ của bản đồ cũ.
- Trò chơi lưu vị trí bạn đang đứng; tải lại là về đúng đó.
Ô có thể tương tác được viền khung, và gợi ý ở dưới trái đổi thành **E / F · Interact**.
## Ví dụ: chiếc rương vẫn mở
Chiếc rương trong nhà trưởng lão là hai sự kiện trên cùng một ô. Rương đóng hiện ra khi cờ cốt truyện `rpg_house_chest` chưa được đặt; kịch bản của nó trao ba đồng xu và đặt cờ. Từ đó thay vào đó là chiếc rương mở.
![Trước: nhân vật chính đứng cạnh rương đóng trong nhà trưởng lão lúc buổi trưa.](assets/chest-before.jpg)
![Dòng chữ của rương: tất cũ, mẩu giấy "Đừng động vào", và ba đồng xu bên dưới.](assets/chest-text.jpg)
![Sau: ô cũ giờ hiển thị rương mở, được viền bởi dấu tương tác; gợi ý dưới trái hiện "E / F · Interact".](assets/chest-after.jpg)
```json
{ "Id": "chest", "X": 9, "Y": 7, "Tile": 14,
"Scenario": "rpg_demo/events/house_chest",
"Condition": "rpg_house_chest != 1" },
{ "Id": "chest_open", "X": 9, "Y": 7, "Tile": 15,
"Scenario": "rpg_demo/events/chest_empty",
"Condition": "rpg_house_chest == 1" }
```
```scenario
ptext "Trong rương: tất cũ và một mẩu giấy ghi "Đừng động vào". Muộn rồi. Dưới đống tất: ba đồng xu." key=rpg_demo/chest/house
action item.add id=money/coin count=3
flag rpg_house_chest 1
end
```
## Dành cho tác giả: bản đồ
Bản đồ là một tệp JSON trong thư mục `maps/` của mod, kèm một cảnh cùng tên trong `scenes/` làm nền chiến đấu và âm nền của bản đồ.
- **Ba lớp vẽ**, mỗi lớp là danh sách các hàng số ô (`.` là trống): `Back` — mặt đất, sàn, nước; `Main` — vật thể ngang tầm nhân vật: tường, thân cây, hàng rào; `Front` — vẽ đè lên nhân vật: tán cây, mái hiên, vòm cửa.
- **Va chạm** — lớp theo hàng, ghi đè khả năng đi qua của ô khi cần: bức tường vô hình ở mép vách hay chỗ hở trong hàng rào cây.
- **Tileset** — atlas ô và thuộc tính từng ô (có đặc hay không, nền, vật thể).
- **Spawn** — người mới xuất hiện ở đâu; **Zoom**; màu **Ambient** tùy chọn cho nội thất (nếu không, thời gian trong ngày sẽ chiếu sáng bản đồ) và **Atmosphere** (`woodland` thêm lá, đom đóm và gợn nước).
- **Encounters** — chiến đấu ngẫu nhiên: trung bình một lần mỗi `Steps` bước, gặp một trong các `Groups`.
```json
"Encounters": { "Steps": 48, "Groups": ["rpg_slime", "rpg_slime,rpg_slime"] }
```
## Dành cho tác giả: sự kiện
Sự kiện nằm trong `map_events/<id bản đồ>/*.json` của bất kỳ mod nào. Một sự kiện có **diện mạo** và **tác dụng** được chọn riêng biệt:
- diện mạo: `Sprite` nhân vật, một ô `Tile` từ atlas, `Icon` vật phẩm nằm trên đất, hoặc không gì cả (bộ kích hoạt vô hình);
- tác dụng: `Teleport` tới một ô của bản đồ này hoặc bản đồ khác, hoặc `Scenario` kèm `Commands` viết ngay tại chỗ (tùy chọn) — ngôn ngữ kịch bản đầy đủ, kể cả `action rpg.battle`, cờ và nhiệm vụ;
- `Trigger`: `Action` (E/F khi đang đối mặt) hoặc `Touch` (bước lên);
- `Solid`: có chặn đường hay không (mặc định, sự kiện được vẽ ra sẽ chặn);
- `Condition`: ẩn sự kiện khi điều kiện không thỏa;
- `Pages`: các diện mạo và tác dụng thay thế, mỗi cái một điều kiện; dùng trang cuối cùng có điều kiện thỏa;
- `Route`: di chuyển tự động như `{"Steps": ["left", "wait", "right"], "Every": 0.8}`, với `up`/`down`/`left`/`right`, `random`, `toward`/`away` (về phía hoặc xa khỏi người chơi), `wait` và `face_…`;
- `Light`: quầng sáng quanh sự kiện, `{"Radius": 3, "Color": "#ffd8a0"}`, nhìn thấy lúc chạng vạng và ban đêm.
**Thêm vào hoặc thay đổi bản đồ của mod khác.** Các tệp sự kiện được gộp theo thứ tự nạp: `Id` mới thêm sự kiện vào bản đồ, còn cùng `Id` từ một mod phụ thuộc vào chủ nhân sẽ thay thế nó. RPG Plus đã cho trưởng lão bản demo những hội thoại mới nhờ cách này.
## Hành động kịch bản
```scenario
action rpg.battle enemies=rpg_wolf,rpg_slime win_flag=rpg_wolf_dead
action rpg.teleport map=rpg_forest x=1 y=8 dir=right
```
- `rpg.battle` mở một trận chiến bằng lõi chiến đấu của trò chơi rồi trở về đúng ô cũ; khi thắng, `win_flag` được đặt thành 1, nhờ vậy quái vật trên bản đồ mà điều kiện của nó kiểm tra cờ này sẽ biến mất. Hãy kết thúc kịch bản sau hành động này.
- `rpg.teleport` đưa người chơi tới một ô trên bản đồ này hoặc bản đồ khác.
## Cài đặt trò chơi
- **Diện mạo** (`rpg.player_charset`) — sprite của nhân vật chính trên bản đồ, chọn từ các charset trong trò chơi.
- **Phép chiếu bản đồ** (`tile.renderer`) — nhìn từ trên hoặc đẳng cự; xem Tile Rendering.
## Khi có trục trặc
1. **Nhân vật hay rương không phản ứng.** Đối mặt và nhấn E hoặc F; sự kiện `Touch` chỉ phản ứng khi bạn bước lên.
2. **Quái vật hay rương quay lại.** `Condition` của nó phải kiểm tra đúng cờ mà kịch bản hoặc `win_flag` của nó đặt.
3. **Chỗ trông có vẻ trống lại không đi được.** Ô đó bị ô đặc hoặc lớp va chạm của bản đồ chặn.
4. **Không có nền trận chiến.** Cảnh của bản đồ trong `scenes/<map>.json` cần `defaultBackground`.
+95
View File
@@ -0,0 +1,95 @@
# RPG World — 在图块地图上行走、交谈、开启与战斗
RPG World 把游戏变成经典 2D 角色扮演风格的图块地图 RPG:用键盘在网格地图上行走,与人交谈,打开宝箱,穿过房门,与怪物战斗——打完再回到原地。地图本身就是游戏的普通场景,所以对话框、剧本、存档、音乐和昼夜变化在地图上的表现与视觉小说里完全一样。
**RPG Plus** 的村庄、森林和沙漠都由它驱动。地图和人物本身来自 RPG Demo 之类的内容模组;这个模组提供的是规则。
![在 RPG Plus 中探索村庄:左上角是地图名,右上角是工具栏,左下角是操作提示。](assets/village.jpg)
## 在地图上怎么玩
- **行走**用 WASD 或方向键;按住 **Shift** 奔跑。
- **互动**用 **E** 或 **F**:与面前的人交谈、打开宝箱、阅读告示牌。
- **走上**门口或地图边缘即可前往下一张地图。
- 在某些地方(例如森林)行走时可能触发**随机战斗**。
- 战斗结束后,你会回到原地图的原格子。
- 游戏会保存你站立的位置;读档后回到原地。
可以互动的格子会被框起来,左下角的提示随之变为 **E / F · Interact**。
## 示例:保持开启的宝箱
长者之家的宝箱由同一格子上的两个事件组成。剧情旗标 `rpg_house_chest` 未设置时显示关闭的宝箱;它的剧本会给出三枚硬币并设置旗标。此后改而显示打开的宝箱。
![之前:正午,长者之家中主角站在关闭的宝箱旁。](assets/chest-before.jpg)
![宝箱的文字:旧袜子、一张写着"别碰"的纸条,以及压在下面的三枚硬币。](assets/chest-text.jpg)
![之后:同一格子现在显示打开的宝箱,并被互动标记框住;左下角提示为"E / F · Interact"。](assets/chest-after.jpg)
```json
{ "Id": "chest", "X": 9, "Y": 7, "Tile": 14,
"Scenario": "rpg_demo/events/house_chest",
"Condition": "rpg_house_chest != 1" },
{ "Id": "chest_open", "X": 9, "Y": 7, "Tile": 15,
"Scenario": "rpg_demo/events/chest_empty",
"Condition": "rpg_house_chest == 1" }
```
```scenario
ptext "箱子里:旧袜子和一张写着"别碰"的纸条。太迟了。袜子下面有三枚硬币。" key=rpg_demo/chest/house
action item.add id=money/coin count=3
flag rpg_house_chest 1
end
```
## 给作者:地图
地图是模组 `maps/` 文件夹里的一个 JSON 文件,另外还要在 `scenes/` 里放一个同名场景,用于战斗背景和地图的环境音。
- **三个绘制图层**,各是一份按行排列的图块编号列表(`.` 表示空):`Back`——地面、地板、水;`Main`——与角色同层的物件:墙、树干、栅栏;`Front`——绘制在角色之上:树冠、屋檐、拱门。
- **碰撞**——一层按行记录的数据,在需要的地方覆盖图块自身的通行性:悬崖边的隐形墙,或树篱上的缺口。
- **图块集(tileset)**——图块图集以及各图块的属性(是否实心、地表、物件)。
- **Spawn**——新到来者出现的位置;**Zoom**;可选的 **Ambient** 颜色,用于室内(否则地图由昼夜光照);以及 **Atmosphere**(`woodland` 会添加落叶、萤火虫和水面涟漪)。
- **Encounters**——随机战斗:平均每走 `Steps` 步遇到一次,对手是 `Groups` 中的一组。
```json
"Encounters": { "Steps": 48, "Groups": ["rpg_slime", "rpg_slime,rpg_slime"] }
```
## 给作者:事件
事件存放在任意模组的 `map_events/<地图 id>/*.json` 中。每个事件都有相互独立的**外观**和**效果**:
- 外观:角色 `Sprite`、图块集里的单个 `Tile`、放在地上的物品 `Icon`,或者什么都不画(隐形触发器);
- 效果:`Teleport` 到本地图或另一张地图的某格,或者 `Scenario` 加可选的内联 `Commands`——完整的剧本语言,包括 `action rpg.battle`、旗标和任务;
- `Trigger`:`Action`(面向它按 E/F)或 `Touch`(踩上去);
- `Solid`:是否挡路(画出来的事件默认挡路);
- `Condition`:条件不成立时隐藏事件;
- `Pages`:备选的外观和效果,各自带条件;使用最后一条条件成立的页面;
- `Route`:自主移动,例如 `{"Steps": ["left", "wait", "right"], "Every": 0.8}`,可用 `up`/`down`/`left`/`right`、`random`、`toward`/`away`(朝向或远离玩家)、`wait` 和 `face_…`;
- `Light`:事件周围的光晕,`{"Radius": 3, "Color": "#ffd8a0"}`,黄昏和夜晚可见。
**增补或修改其他模组的地图。** 事件文件按加载顺序合并:新的 `Id` 向地图添加事件;依赖地图归属模组的模组若使用相同的 `Id`,则替换该事件。RPG Plus 就是这样给演示版的年长者加上新对话的。
## 剧本动作
```scenario
action rpg.battle enemies=rpg_wolf,rpg_slime win_flag=rpg_wolf_dead
action rpg.teleport map=rpg_forest x=1 y=8 dir=right
```
- `rpg.battle` 用游戏的战斗核心开启一场战斗,并回到同一格;胜利时 `win_flag` 被置为 1,于是地图上条件检查该旗标的怪物就会消失。这个动作之后应结束剧本。
- `rpg.teleport` 把玩家移动到本地图或另一张地图的某格。
## 游戏设置
- **外观**(`rpg.player_charset`)——主角在地图上的精灵,从游戏的字符图(charset)中选择。
- **地图投影**(`tile.renderer`)——俯视或等距;参见 Tile Rendering。
## 如果遇到问题
1. **人物或宝箱没有反应。** 面对它按 E 或 F;`Touch` 事件只有踩上去才会触发。
2. **怪物或宝箱又出现了。** 它的 `Condition` 必须检查它的剧本或 `win_flag` 所设置的旗标。
3. **看起来能走的地方走不过去。** 那个格子被实心图块或地图的碰撞层挡住了。
4. **战斗没有背景。** 地图在 `scenes/<map>.json` 中的场景需要 `defaultBackground`。
+4
View File
@@ -0,0 +1,4 @@
collection_settings/appearance = Player appearance
collection_settings/appearance_hint = Player sprite on the map, from the collection mods’ charsets folders.
collection_settings/appearance_auto = Uses the hero sprite when unspecified.
collection_settings/world_group = World
+4
View File
@@ -0,0 +1,4 @@
collection_settings/appearance = Aspecto del jugador
collection_settings/appearance_hint = Sprite del jugador en el mapa, de las carpetas charsets de los mods.
collection_settings/appearance_auto = Sin valor se usa el sprite hero.
collection_settings/world_group = Mundo
+4
View File
@@ -0,0 +1,4 @@
collection_settings/appearance = プレイヤーの外見
collection_settings/appearance_hint = 構成内MODのcharsetsフォルダーにあるマップ用プレイヤースプライト。
collection_settings/appearance_auto = 未指定の場合はheroスプライトを使用。
collection_settings/world_group = ワールド
+4
View File
@@ -0,0 +1,4 @@
collection_settings/appearance = 플레이어 외형
collection_settings/appearance_hint = 컬렉션 모드의 charsets 폴더에 있는 지도용 플레이어 스프라이트입니다.
collection_settings/appearance_auto = 지정하지 않으면 hero 스프라이트를 사용합니다.
collection_settings/world_group = 월드
+4
View File
@@ -0,0 +1,4 @@
collection_settings/appearance = Внешность игрока
collection_settings/appearance_hint = Спрайт игрока на карте. Доступны изображения из папки charsets модов коллекции.
collection_settings/appearance_auto = Без настройки используется спрайт hero.
collection_settings/world_group = Мир
+4
View File
@@ -0,0 +1,4 @@
collection_settings/appearance = Ngoại hình người chơi
collection_settings/appearance_hint = Ảnh nhân vật trên bản đồ, từ thư mục charsets của các mod trong bộ sưu tập.
collection_settings/appearance_auto = Nếu không đặt, dùng ảnh hero.
collection_settings/world_group = Thế giới
+4
View File
@@ -0,0 +1,4 @@
collection_settings/appearance = 玩家外观
collection_settings/appearance_hint = 地图上的玩家精灵,来自合集模组的 charsets 文件夹。
collection_settings/appearance_auto = 未指定时使用 hero 精灵。
collection_settings/world_group = 世界
+8 -1
View File
@@ -1,2 +1,9 @@
mod_rpg_name = RPG World mod_rpg_name = RPG World
mod_rpg_desc = Top-down tile world in the spirit of RPG Maker: three tile layers and a collision layer, grid walking with WASD, events on the action key E/F, random encounters and battles that return to the map. mod_rpg_desc = A walkable tile world in the spirit of RPG Maker: grid movement with WASD, events on E/F, teleports, random encounters; a map is an ordinary scene of the game.
rpg/ui/location = EXPLORING
rpg/ui/interact = E / F · Interact
rpg/ui/walk = WASD / arrows · Walk
rpg/ui/hint = Shift · Run | The journal keeps your next steps.
rpg/ui/no_renderer=No map renderer is available. Check the game’s mod selection.
+4
View File
@@ -0,0 +1,4 @@
mod_rpg_name = Mundo RPG
mod_rpg_desc = Un mundo de tiles que se recorre a pie, al estilo de RPG Maker: movimiento por cuadrícula con WASD, eventos con E/F, teletransportes, encuentros aleatorios; un mapa es una escena cualquiera del juego.
rpg/ui/no_renderer=No hay un renderizador de mapas. Revisa los mods del juego.
+4
View File
@@ -0,0 +1,4 @@
mod_rpg_name = RPG ワールド
mod_rpg_desc = RPG Maker 風の、歩き回れるタイルワールド:WASD によるマス目移動、E/F のイベント、テレポート、ランダムエンカウント。マップはゲームの普通のシーンです。
rpg/ui/no_renderer=マップ描画モジュールがありません。ゲームのMOD設定を確認してください。
+4
View File
@@ -0,0 +1,4 @@
mod_rpg_name = RPG 월드
mod_rpg_desc = RPG Maker 스타일의 걸어 다닐 수 있는 타일 월드: WASD 격자 이동, E/F 이벤트, 텔레포트, 랜덤 인카운터. 맵은 게임의 평범한 씬입니다.
rpg/ui/no_renderer=지도 렌더러가 없습니다. 게임 모드 구성을 확인하세요.
+8 -1
View File
@@ -1,2 +1,9 @@
mod_rpg_name = RPG-мир mod_rpg_name = RPG-мир
mod_rpg_desc = Мир из тайлов с видом сверху в духе RPG Maker: три слоя тайлов и слой коллизий, ходьба по клеткам на WASD, события по клавише действия E/F, случайные встречи и бои с возвратом на карту. mod_rpg_desc = Мир из тайлов, по которому можно ходить, в духе RPG Maker: перемещение по клеткам на WASD, события по E/F, телепорты, случайные встречи; карта — обычная сцена игры.
rpg/ui/location = В ПУТИ
rpg/ui/interact = E / F · Взаимодействовать
rpg/ui/walk = WASD / стрелки · Идти
rpg/ui/hint = Shift · Бежать | Следующие шаги — в журнале.
rpg/ui/no_renderer=Нет доступного рендера карты. Проверьте состав модов игры.
+13
View File
@@ -0,0 +1,13 @@
rpg/schema/builtin/rpg_battle = Battle from the map
rpg/schema/builtin/rpg_battle_desc = Enemies, comma separated; the win flag is set on victory
rpg/schema/field/enemies = Enemies
rpg/schema/field/win_flag = Win flag
rpg/schema/builtin/rpg_teleport = Teleport on the map
rpg/schema/builtin/rpg_teleport_desc = A cell and a facing; the map is optional
rpg/schema/field/map = Map
rpg/schema/field/x = X
rpg/schema/field/y = Y
rpg/schema/field/dir = Facing
rpg/schema/field/visual_groups=Image groups
rpg/schema/field/visual_groups_desc=Exact tile patterns, anchors and planes for whole objects.
+13
View File
@@ -0,0 +1,13 @@
rpg/schema/builtin/rpg_battle = Combate desde el mapa
rpg/schema/builtin/rpg_battle_desc = Enemigos separados por comas; la marca de victoria se pone al ganar
rpg/schema/field/enemies = Enemigos
rpg/schema/field/win_flag = Marca de victoria
rpg/schema/builtin/rpg_teleport = Teletransportar en el mapa
rpg/schema/builtin/rpg_teleport_desc = Una casilla y una dirección; el mapa es opcional
rpg/schema/field/map = Mapa
rpg/schema/field/x = X
rpg/schema/field/y = Y
rpg/schema/field/dir = Dirección
rpg/schema/field/visual_groups=Grupos de imágenes
rpg/schema/field/visual_groups_desc=Patrones de casillas, anclas y planos para objetos completos.
+13
View File
@@ -0,0 +1,13 @@
rpg/schema/builtin/rpg_battle = マップからの戦闘
rpg/schema/builtin/rpg_battle_desc = 敵はカンマ区切り。勝つと勝利フラグが立つ
rpg/schema/field/enemies = 敵
rpg/schema/field/win_flag = 勝利フラグ
rpg/schema/builtin/rpg_teleport = マップ上で移動
rpg/schema/builtin/rpg_teleport_desc = マスと向き。マップは任意
rpg/schema/field/map = マップ
rpg/schema/field/x = X
rpg/schema/field/y = Y
rpg/schema/field/dir = 向き
rpg/schema/field/visual_groups=画像グループ
rpg/schema/field/visual_groups_desc=オブジェクト全体のタイル配置、基準点と面。
+13
View File
@@ -0,0 +1,13 @@
rpg/schema/builtin/rpg_battle = 맵에서 전투
rpg/schema/builtin/rpg_battle_desc = 적은 쉼표로 구분. 이기면 승리 플래그 설정
rpg/schema/field/enemies = 적
rpg/schema/field/win_flag = 승리 플래그
rpg/schema/builtin/rpg_teleport = 맵에서 이동
rpg/schema/builtin/rpg_teleport_desc = 칸과 방향. 맵은 선택
rpg/schema/field/map = 맵
rpg/schema/field/x = X
rpg/schema/field/y = Y
rpg/schema/field/dir = 방향
rpg/schema/field/visual_groups=이미지 그룹
rpg/schema/field/visual_groups_desc=전체 오브젝트의 타일 패턴, 기준점과 평면.
+13
View File
@@ -0,0 +1,13 @@
rpg/schema/builtin/rpg_battle = Бой с карты
rpg/schema/builtin/rpg_battle_desc = Враги через запятую; флаг победы ставится при выигрыше
rpg/schema/field/enemies = Враги
rpg/schema/field/win_flag = Флаг победы
rpg/schema/builtin/rpg_teleport = Переместить на карте
rpg/schema/builtin/rpg_teleport_desc = Клетка и направление; карта — по желанию
rpg/schema/field/map = Карта
rpg/schema/field/x = X
rpg/schema/field/y = Y
rpg/schema/field/dir = Направление
rpg/schema/field/visual_groups=Группы изображений
rpg/schema/field/visual_groups_desc=Точные шаблоны тайлов, опорные точки и плоскости целых объектов.
+13
View File
@@ -0,0 +1,13 @@
rpg/schema/builtin/rpg_battle = Trận đấu từ bản đồ
rpg/schema/builtin/rpg_battle_desc = Kẻ địch cách nhau bằng dấu phẩy; thắng thì đặt cờ chiến thắng
rpg/schema/field/enemies = Kẻ địch
rpg/schema/field/win_flag = Cờ chiến thắng
rpg/schema/builtin/rpg_teleport = Dịch chuyển trên bản đồ
rpg/schema/builtin/rpg_teleport_desc = Ô và hướng; bản đồ tùy chọn
rpg/schema/field/map = Bản đồ
rpg/schema/field/x = X
rpg/schema/field/y = Y
rpg/schema/field/dir = Hướng
rpg/schema/field/visual_groups=Nhóm hình ảnh
rpg/schema/field/visual_groups_desc=Mẫu ô, điểm neo và mặt phẳng của đối tượng hoàn chỉnh.
+13
View File
@@ -0,0 +1,13 @@
rpg/schema/builtin/rpg_battle = 地图上的战斗
rpg/schema/builtin/rpg_battle_desc = 敌人以逗号分隔;获胜时设置胜利标记
rpg/schema/field/enemies = 敌人
rpg/schema/field/win_flag = 胜利标记
rpg/schema/builtin/rpg_teleport = 地图传送
rpg/schema/builtin/rpg_teleport_desc = 格子和朝向;地图可选
rpg/schema/field/map = 地图
rpg/schema/field/x = X
rpg/schema/field/y = Y
rpg/schema/field/dir = 朝向
rpg/schema/field/visual_groups=图像组
rpg/schema/field/visual_groups_desc=完整物体的图块模板、锚点和平面。
+4
View File
@@ -0,0 +1,4 @@
mod_rpg_name = Thế giới RPG
mod_rpg_desc = Thế giới ô đi lại được theo phong cách RPG Maker: di chuyển theo lưới bằng WASD, sự kiện bằng E/F, dịch chuyển, chạm trán ngẫu nhiên; bản đồ là một cảnh bình thường của trò chơi.
rpg/ui/no_renderer=Không có bộ hiển thị bản đồ. Hãy kiểm tra các mod của trò chơi.
+4
View File
@@ -0,0 +1,4 @@
mod_rpg_name = RPG 世界
mod_rpg_desc = RPG Maker 风格、可以行走的图块世界:WASD 网格移动,E/F 触发事件,传送,随机遇敌;地图就是游戏的普通场景。
rpg/ui/no_renderer=没有可用的地图渲染器,请检查游戏模组。
+21 -3
View File
@@ -3,7 +3,7 @@
"name": "RPG World", "name": "RPG World",
"version": "1.0.0", "version": "1.0.0",
"author": "ct", "author": "ct",
"description": "Top-down tile world in the spirit of RPG Maker on top of the 2D engine mods: maps of square tiles in three layers (back, main, front) with a collision layer, walking on the grid with WASD or the arrows, events that talk, teleport or start battles with the action key E/F or on touch, random encounters. A map is a logical scene, so entry scenarios, ambience, saving and the dialog box work on maps unchanged; battles run on the turn-based platform with the game's battle core and return to the same cell. Scenario actions: rpg.battle, rpg.teleport.", "description": "A walkable tile world in the spirit of RPG Maker: grid movement with WASD, events on E/F, teleports, random encounters; a map is an ordinary scene of the game.",
"category": "gameplay", "category": "gameplay",
"sources": [ "sources": [
"src/**/*.cs" "src/**/*.cs"
@@ -11,8 +11,26 @@
"test_sources": [ "test_sources": [
"tests/*.cs" "tests/*.cs"
], ],
"depends": ["shell2d_base>=1", "ui_base>=1", "dialog_base>=1", "turn_battle_base>=2"], "depends": [
"shell2d_base>=1",
"ui_base>=1",
"time_base>=1",
"dialog_base>=1",
"turn_battle_base>=2",
"tile_render_base>=1"
],
"test_suites": [ "test_suites": [
{"id": "walk", "activate": ["--itest=rpg_walk"]} {
"id": "walk",
"activate": [
"--itest=rpg_walk"
]
},
{
"id": "render",
"activate": [
"--itest=tile_render"
]
}
] ]
} }
+61
View File
@@ -21,3 +21,64 @@ grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
mouse_filter = 2 mouse_filter = 2
color = Color(0.06, 0.07, 0.09, 1) color = Color(0.06, 0.07, 0.09, 1)
[node name="Hud" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
mouse_filter = 2
[node name="Location" type="VBoxContainer" parent="Hud"]
layout_mode = 0
offset_left = 32.0
offset_top = 84.0
offset_right = 530.0
offset_bottom = 145.0
mouse_filter = 2
[node name="Eyebrow" type="Label" parent="Hud/Location"]
layout_mode = 2
theme_override_colors/font_color = Color(0.82, 0.72, 0.47, 1)
theme_override_colors/font_shadow_color = Color(0.03, 0.06, 0.07, 0.95)
theme_override_constants/shadow_offset_x = 1
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 12
text = "rpg/ui/location"
mouse_filter = 2
[node name="Name" type="Label" parent="Hud/Location"]
layout_mode = 2
theme_override_colors/font_color = Color(0.94, 0.94, 0.84, 1)
theme_override_colors/font_shadow_color = Color(0.03, 0.06, 0.07, 0.95)
theme_override_constants/shadow_offset_x = 1
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 24
mouse_filter = 2
[node name="Controls" type="VBoxContainer" parent="Hud"]
layout_mode = 1
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = -76.0
offset_right = 570.0
offset_bottom = -22.0
mouse_filter = 2
[node name="Prompt" type="Label" parent="Hud/Controls"]
layout_mode = 2
theme_override_colors/font_color = Color(0.96, 0.88, 0.65, 1)
theme_override_colors/font_shadow_color = Color(0.02, 0.04, 0.05, 1)
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 18
mouse_filter = 2
[node name="Hint" type="Label" parent="Hud/Controls"]
layout_mode = 2
theme_override_colors/font_color = Color(0.75, 0.83, 0.78, 1)
theme_override_colors/font_shadow_color = Color(0.02, 0.04, 0.05, 1)
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 13
text = "rpg/ui/hint"
mouse_filter = 2
+74
View File
@@ -0,0 +1,74 @@
{
"Actions": [
{
"Id": "rpg.battle",
"Name": "rpg/schema/builtin/rpg_battle",
"Description": "rpg/schema/builtin/rpg_battle_desc",
"Category": "battle",
"Params": [
{
"Key": "enemies",
"Type": "ref_list",
"Kind": "character",
"Required": true,
"Name": "rpg/schema/field/enemies"
},
{
"Key": "win_flag",
"Type": "flag",
"Name": "rpg/schema/field/win_flag"
}
]
},
{
"Id": "rpg.teleport",
"Name": "rpg/schema/builtin/rpg_teleport",
"Description": "rpg/schema/builtin/rpg_teleport_desc",
"Category": "world",
"Params": [
{
"Key": "map",
"Type": "ref",
"Kind": "rpg_map",
"Name": "rpg/schema/field/map"
},
{
"Key": "x",
"Type": "int",
"Required": true,
"Name": "rpg/schema/field/x"
},
{
"Key": "y",
"Type": "int",
"Required": true,
"Name": "rpg/schema/field/y"
},
{
"Key": "dir",
"Type": "choice",
"Options": [
"Up",
"Down",
"Left",
"Right"
],
"Name": "rpg/schema/field/dir"
}
]
}
],
"Kinds": [
{
"Kind": "rpg_tileset",
"Fields": [
{
"Key": "VisualGroups",
"Type": "json",
"Name": "rpg/schema/field/visual_groups",
"Description": "rpg/schema/field/visual_groups_desc"
}
]
}
]
}
+78
View File
@@ -0,0 +1,78 @@
using System;
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Autotiles in the RPG Maker A2 layout: one picture of 2×3 tiles describes a terrain
/// (water, a path, a cliff top), and each map cell is assembled from four quarter tiles
/// chosen by its eight neighbours — so an author paints "water" and the shores draw
/// themselves. A tileset entry becomes an autotile by naming that picture
/// (<see cref="RpgTileInfo.Autotile"/>); its index is any number outside the atlas.
/// <para/>
/// The block in quarter units (4 columns × 6 rows): the top-left tile is the palette
/// preview; the top-right tile holds the four inner corners; the 2×2 tiles below are a
/// framed patch whose outer ring gives outer corners and edges and whose middle gives the
/// fill. For a quarter, the two neighbours along its sides and the one across its corner
/// decide: both sides alike and the corner too — fill; both sides alike but not the
/// corner — inner corner; only one side alike — the edge along the other; none — the
/// outer corner. Cells past the map edge count as alike, so a terrain runs off the map
/// without a border. The world adapter and map editor draw
/// through <see cref="Quarters"/> and <see cref="Draw"/>, so both show the same cells.
/// </summary>
public static class RpgAutotiles
{
/// <summary>
/// Source quarters (in quarter units of the block) for the top-left, top-right,
/// bottom-left and bottom-right quarter of a cell, from which neighbours are alike.
/// </summary>
public static Vector2I[] Quarters(bool up, bool down, bool left, bool right,
bool upLeft, bool upRight, bool downLeft, bool downRight)
=> Cthangover.Mods.TileRender.TileAutotile.Quarters(up, down, left, right, upLeft, upRight, downLeft, downRight);
/// <summary>
/// The quarters of the cell (<paramref name="x"/>, <paramref name="y"/>) of
/// <paramref name="layer"/>, whose tile is the autotile <paramref name="index"/>.
/// </summary>
public static Vector2I[] QuartersAt(RpgMapGrid grid, RpgLayer layer, int x, int y, int index)
{
return Quarters(
Alike(grid, layer, x, y - 1, index), Alike(grid, layer, x, y + 1, index),
Alike(grid, layer, x - 1, y, index), Alike(grid, layer, x + 1, y, index),
Alike(grid, layer, x - 1, y - 1, index), Alike(grid, layer, x + 1, y - 1, index),
Alike(grid, layer, x - 1, y + 1, index), Alike(grid, layer, x + 1, y + 1, index));
}
/// <summary>Pixel rectangle of a quarter in the block picture of a tileset with <paramref name="tileSize"/>.</summary>
public static Rect2 SourceRect(Vector2I quarter, int tileSize)
{
var half = tileSize / 2f;
return new Rect2(quarter.X * half, quarter.Y * half, half, half);
}
/// <summary>Draws a cell of an autotile into <paramref name="cell"/> on <paramref name="target"/>.</summary>
public static void Draw(CanvasItem target, Texture2D block, Rect2 cell, Vector2I[] quarters, int tileSize, Color modulate)
{
var half = cell.Size / 2f;
for (var i = 0; i < 4; i++)
{
var offset = new Vector2(i % 2 * half.X, i / 2 * half.Y);
target.DrawTextureRectRegion(block, new Rect2(cell.Position + offset, half), SourceRect(quarters[i], tileSize), modulate);
}
}
/// <summary>Source rectangle of the block's preview tile (its top-left tile), for palettes.</summary>
public static Rect2 PreviewRect(int tileSize) => new(0, 0, tileSize, tileSize);
/// <summary>
/// Selects one A2 block from a sheet of adjacent 2×3 blocks by map position.
/// A single-block image keeps its original behaviour. Larger sheets let a
/// material span several cells without introducing seams or extra terrain ids.
/// </summary>
public static Rect2 VariantRect(Vector2I imageSize, int tileSize, Vector2I cell)
=> Cthangover.Mods.TileRender.TileAutotile.VariantRect(imageSize, tileSize, cell);
private static bool Alike(RpgMapGrid grid, RpgLayer layer, int x, int y, int index)
=> !grid.InBounds(x, y) || grid.GetTile(layer, x, y) == index;
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ namespace Cthangover.Mods.Rpg
/// <summary> /// <summary>
/// Facing of an actor on the grid. The order is the row order of an RPG Maker /// Facing of an actor on the grid. The order is the row order of an RPG Maker
/// style charset (down, left, right, up), so the value is also the frame row /// style charset (down, left, right, up), so the value is also the frame row
/// of <see cref="RpgActor"/>. JSON spells it as a word ("down", "Left"). /// of <see cref="RpgActorState"/>. JSON spells it as a word ("down", "Left").
/// </summary> /// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))] [JsonConverter(typeof(JsonStringEnumConverter))]
public enum RpgDirection public enum RpgDirection
+50
View File
@@ -0,0 +1,50 @@
using System.Collections.Generic;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// One page of a map event, as in RPG Maker: the event's look and effect while the
/// page's <see cref="Condition"/> holds. Every field left out (null) is the event's
/// own; an empty <see cref="Sprite"/>/<see cref="Icon"/> clears it. The last page whose
/// condition holds is the active one; the event's own fields are page 0 and count only
/// while the event's own condition holds (<see cref="RpgEventPages"/>).
/// </summary>
public class RpgEventPage
{
/// <summary>Scenario condition of the page; empty = always.</summary>
public string Condition { get; set; }
/// <summary>Charset of the page ("" = none).</summary>
public string Sprite { get; set; }
/// <summary>Atlas tile of the page (-1 = none).</summary>
public int? Tile { get; set; }
/// <summary>Item icon of the page ("" = none).</summary>
public string Icon { get; set; }
/// <summary>Facing the event turns to when the page becomes active.</summary>
public RpgDirection? Dir { get; set; }
/// <summary>How the page starts.</summary>
public RpgEventTrigger? Trigger { get; set; }
/// <summary>Whether the page blocks walking.</summary>
public bool? Solid { get; set; }
/// <summary>Scenario file of the page.</summary>
public string Scenario { get; set; }
/// <summary>Inline scenario lines of the page.</summary>
public List<string> Commands { get; set; }
/// <summary>Teleport of the page.</summary>
public RpgTeleport Teleport { get; set; }
/// <summary>Autonomous movement of the page.</summary>
public RpgMoveRoute Route { get; set; }
/// <summary>Light of the page (a lamp lit by a page).</summary>
public RpgLight Light { get; set; }
}
}
+73
View File
@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Picks the active page of an event and builds the event as that page shows it. Page
/// index 0 is the event's own fields (active while the event's own condition holds),
/// 1… are <see cref="RpgMapEvent.Pages"/>; the last page whose condition holds wins, -1
/// means none holds and the event is hidden. Pure: the caller passes the condition check.
/// </summary>
public static class RpgEventPages
{
/// <summary>The index of the active page, or -1.</summary>
public static int ActiveIndex(RpgMapEvent mapEvent, Func<string, bool> holds)
{
if (mapEvent == null)
return -1;
var pages = mapEvent.Pages;
if (pages != null)
{
for (var i = pages.Count - 1; i >= 0; i--)
{
if (pages[i] != null && Holds(pages[i].Condition, holds))
return i + 1;
}
}
return Holds(mapEvent.Condition, holds) ? 0 : -1;
}
/// <summary>
/// The event as page <paramref name="index"/> shows it: the event itself for 0, else a
/// copy with the page's fields over the event's. Null for an index out of range.
/// </summary>
public static RpgMapEvent Effective(RpgMapEvent mapEvent, int index)
{
if (mapEvent == null || index < 0)
return null;
if (index == 0)
return mapEvent;
if (mapEvent.Pages == null || index > mapEvent.Pages.Count || mapEvent.Pages[index - 1] == null)
return null;
var page = mapEvent.Pages[index - 1];
var hasLook = page.Sprite != null || page.Tile.HasValue || page.Icon != null;
return new RpgMapEvent
{
Id = mapEvent.Id,
X = mapEvent.X,
Y = mapEvent.Y,
// A page that names any look replaces the whole look, so a chest page with an
// Icon does not keep the closed chest's Tile underneath it.
Sprite = hasLook ? page.Sprite : mapEvent.Sprite,
Tile = hasLook ? page.Tile ?? -1 : mapEvent.Tile,
Icon = hasLook ? page.Icon : mapEvent.Icon,
Dir = page.Dir ?? mapEvent.Dir,
Trigger = page.Trigger ?? mapEvent.Trigger,
Solid = page.Solid ?? (hasLook ? null : mapEvent.Solid),
Scenario = page.Scenario ?? mapEvent.Scenario,
Commands = page.Commands ?? mapEvent.Commands,
Teleport = page.Teleport ?? mapEvent.Teleport,
Route = page.Route ?? mapEvent.Route,
Light = page.Light ?? mapEvent.Light,
Condition = page.Condition,
ModId = mapEvent.ModId,
File = mapEvent.File,
};
}
private static bool Holds(string condition, Func<string, bool> holds)
=> string.IsNullOrWhiteSpace(condition) || (holds != null && holds(condition));
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// A light an event gives off (a lamp, a fire, a lit window), drawn by
/// <see cref="RpgLighting"/>. It brightens the cells around the event towards their
/// own colours, so it shows at dusk and at night and is lost in daylight.
/// </summary>
public class RpgLight
{
/// <summary>Reach in cells from the event's centre; the light fades to nothing there.</summary>
public float Radius { get; set; } = 3f;
/// <summary>Colour (<c>#rrggbb</c>, optionally <c>#rrggbbaa</c>: alpha is the strength).</summary>
public string Color { get; set; } = "#ffd8a0";
}
}
+13 -3
View File
@@ -16,7 +16,10 @@ namespace Cthangover.Mods.Rpg
/// <para/> /// <para/>
/// Files declare <c>"Kind": "rpg_map"</c>. Layers are parsed by /// Files declare <c>"Kind": "rpg_map"</c>. Layers are parsed by
/// <see cref="RpgMapGrid"/>; a dependent mod replaces a whole map by id, the /// <see cref="RpgMapGrid"/>; a dependent mod replaces a whole map by id, the
/// usual entity override. /// usual entity override. The map file holds only the ground: what stands on it
/// (NPCs, doors, chests, pickups) are events in <c>map_events/&lt;id&gt;/</c> of any
/// mod, merged by <see cref="RpgMapEventResolver"/>, so a mod adds to another
/// mod's map without replacing it.
/// </summary> /// </summary>
[EntityKind("rpg_map")] [EntityKind("rpg_map")]
public class RpgMap : IIdentifiable public class RpgMap : IIdentifiable
@@ -49,10 +52,17 @@ namespace Cthangover.Mods.Rpg
/// <summary>Where the player appears when entering the map without a teleport target.</summary> /// <summary>Where the player appears when entering the map without a teleport target.</summary>
public RpgTeleport Spawn { get; set; } = new(); public RpgTeleport Spawn { get; set; } = new();
/// <summary>Events placed on the map.</summary>
public List<RpgMapEvent> Events { get; set; } = new();
/// <summary>Random battles; null for a safe map.</summary> /// <summary>Random battles; null for a safe map.</summary>
public RpgEncounterTable Encounters { get; set; } public RpgEncounterTable Encounters { get; set; }
/// <summary>
/// Fixed light colour of the map (<c>#rrggbb</c>), e.g. a lit room; null = the time of day
/// lights the map (<see cref="RpgLighting"/>).
/// </summary>
public string Ambient { get; set; }
/// <summary>Optional woodland leaves, fireflies and water ripples; empty keeps interiors still.</summary>
public string Atmosphere { get; set; }
} }
} }
+35 -3
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Cthangover.Mods.Rpg namespace Cthangover.Mods.Rpg
{ {
@@ -7,7 +8,7 @@ namespace Cthangover.Mods.Rpg
/// RPG Maker events: an NPC, a sign, a chest, a door. What it looks like and /// RPG Maker events: an NPC, a sign, a chest, a door. What it looks like and
/// what it does are independent: /// what it does are independent:
/// <list type="bullet"> /// <list type="bullet">
/// <item>look — a charset <see cref="Sprite"/>, or one atlas <see cref="Tile"/>, or nothing (an invisible trigger);</item> /// <item>look — a charset <see cref="Sprite"/>, or one atlas <see cref="Tile"/>, or an item <see cref="Icon"/>, or nothing (an invisible trigger);</item>
/// <item>effect — a <see cref="Teleport"/>, handled natively, or a dialog built /// <item>effect — a <see cref="Teleport"/>, handled natively, or a dialog built
/// from a <see cref="Scenario"/> file followed by inline <see cref="Commands"/> /// from a <see cref="Scenario"/> file followed by inline <see cref="Commands"/>
/// (the full scenario DSL, so <c>action rpg.battle …</c>, <c>flag …</c>, quests).</item> /// (the full scenario DSL, so <c>action rpg.battle …</c>, <c>flag …</c>, quests).</item>
@@ -15,6 +16,9 @@ namespace Cthangover.Mods.Rpg
/// A <see cref="Condition"/> in the scenario condition language hides the /// A <see cref="Condition"/> in the scenario condition language hides the
/// event while false; it is re-checked whenever game state changes, so a /// event while false; it is re-checked whenever game state changes, so a
/// defeated boss (<c>flag boss_dead 1</c>) disappears at once. /// defeated boss (<c>flag boss_dead 1</c>) disappears at once.
/// <para/>
/// Events are stored apart from their map, in <c>map_events/&lt;map&gt;/*.json</c>
/// of any mod (<see cref="RpgMapEventResolver"/>).
/// </summary> /// </summary>
public class RpgMapEvent public class RpgMapEvent
{ {
@@ -33,13 +37,19 @@ namespace Cthangover.Mods.Rpg
/// <summary>Atlas index of the map's tileset drawn as the event when it has no sprite; -1 for none.</summary> /// <summary>Atlas index of the map's tileset drawn as the event when it has no sprite; -1 for none.</summary>
public int Tile { get; set; } = -1; public int Tile { get; set; } = -1;
/// <summary>
/// Picture in the <c>items</c> group (what an item's <c>Sprite</c> names), drawn fitted into the
/// cell when the event has neither a sprite nor a tile: an item lying on the ground.
/// </summary>
public string Icon { get; set; }
/// <summary>Initial facing of a sprite.</summary> /// <summary>Initial facing of a sprite.</summary>
public RpgDirection Dir { get; set; } = RpgDirection.Down; public RpgDirection Dir { get; set; } = RpgDirection.Down;
/// <summary>When the event fires.</summary> /// <summary>When the event fires.</summary>
public RpgEventTrigger Trigger { get; set; } = RpgEventTrigger.Action; public RpgEventTrigger Trigger { get; set; } = RpgEventTrigger.Action;
/// <summary>The event occupies its cell. Null means "solid when it is visible" (a sprite or a tile).</summary> /// <summary>The event occupies its cell. Null means "solid when it is visible" (a sprite, a tile or an icon).</summary>
public bool? Solid { get; set; } public bool? Solid { get; set; }
/// <summary>Scenario path inside the <c>scenarios</c> group (extension optional), played as a dialog.</summary> /// <summary>Scenario path inside the <c>scenarios</c> group (extension optional), played as a dialog.</summary>
@@ -54,7 +64,29 @@ namespace Cthangover.Mods.Rpg
/// <summary>Scenario condition; the event exists only while it is true.</summary> /// <summary>Scenario condition; the event exists only while it is true.</summary>
public string Condition { get; set; } public string Condition { get; set; }
/// <summary>
/// Further pages (RPG Maker's event pages): the last one whose condition holds replaces
/// the event's look and effect; with pages, a false own <see cref="Condition"/> only
/// turns off page 0 (<see cref="RpgEventPages"/>).
/// </summary>
public List<RpgEventPage> Pages { get; set; }
/// <summary>Autonomous movement (<see cref="RpgMoveRoute"/>); null = the event stands still.</summary>
public RpgMoveRoute Route { get; set; }
/// <summary>Light the event gives off (<see cref="RpgLight"/>); null = none.</summary>
public RpgLight Light { get; set; }
/// <summary>Mod the event was read from (set by the event resolver, not stored).</summary>
[JsonIgnore]
public string ModId { get; set; }
/// <summary>Mod-relative path of the file the event was read from (set by the event resolver, not stored).</summary>
[JsonIgnore]
public string File { get; set; }
/// <summary>Effective solidity: explicit value, else whether the event is drawn.</summary> /// <summary>Effective solidity: explicit value, else whether the event is drawn.</summary>
public bool IsSolid => Solid ?? (!string.IsNullOrEmpty(Sprite) || Tile >= 0); [JsonIgnore]
public bool IsSolid => Solid ?? (!string.IsNullOrEmpty(Sprite) || Tile >= 0 || !string.IsNullOrEmpty(Icon));
} }
} }
+15
View File
@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// One file of map events, <c>map_events/&lt;map id&gt;/&lt;any name&gt;.json</c>:
/// <c>{"Events": [ … ]}</c>. The folder names the map, so a file holds the events
/// of one map, and a mod may split them over as many files as it likes.
/// </summary>
public class RpgMapEventFile
{
/// <summary>The events of the file, in drawing order.</summary>
public List<RpgMapEvent> Events { get; set; } = new();
}
}
+53
View File
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Builds the event list of every map from event files read in mod load order.
/// A new id is appended; an id already present (ignoring case) is replaced in
/// place, so the later file wins and keeps the original's drawing order. Mods
/// load after their dependencies, so the engine's replacement rule holds:
/// ship the same event id and declare <c>depends</c> on the owner to replace
/// it, ship a new id to add to any mod's map.
/// <para/>
/// Pure data, so the merge rules are tested without a mod set.
/// </summary>
public class RpgMapEventMerge
{
/// <summary>Events per map id (case-insensitive).</summary>
public Dictionary<string, List<RpgMapEvent>> Maps { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Adds the events of one file of <paramref name="modId"/> for map <paramref name="map"/>;
/// each event gets <see cref="RpgMapEvent.ModId"/> and <see cref="RpgMapEvent.File"/>.
/// Returns the ids that replaced an earlier event.
/// </summary>
public List<string> Add(string map, string modId, string file, IEnumerable<RpgMapEvent> events)
{
var replaced = new List<string>();
if (!Maps.TryGetValue(map, out var list))
{
list = new List<RpgMapEvent>();
Maps[map] = list;
}
foreach (var mapEvent in events ?? Array.Empty<RpgMapEvent>())
{
if (mapEvent == null || string.IsNullOrWhiteSpace(mapEvent.Id))
continue;
mapEvent.ModId = modId;
mapEvent.File = file;
var index = list.FindIndex(e => string.Equals(e.Id, mapEvent.Id, StringComparison.OrdinalIgnoreCase));
if (index < 0)
{
list.Add(mapEvent);
continue;
}
list[index] = mapEvent;
replaced.Add(mapEvent.Id);
}
return replaced;
}
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Utils;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Collects map events from <c>map_events/&lt;map id&gt;/**/*.json</c> of every
/// loaded mod in load order (files of one mod sorted by path), then from the
/// studio overlays, and merges them per map with <see cref="RpgMapEventMerge"/>.
/// <para/>
/// Events live apart from the map file (<c>maps/&lt;id&gt;.json</c> holds only
/// tiles, collision, spawn and encounters) so that any mod can put its own NPC,
/// chest or quest trigger on another mod's map without replacing the map, and
/// so that drawing and placing do not fight over one big file. Events of a map
/// no mod defines are kept and simply never shown.
/// </summary>
public class RpgMapEventResolver : ModAssetResolver
{
/// <summary>Group folder of event files.</summary>
public const string Directory = "map_events";
private Dictionary<string, List<RpgMapEvent>> cache;
/// <inheritdoc />
public override string Id => "rpg_map_events";
/// <summary>Events per map id, merged; cached until the next mod reload or <see cref="ClearCache"/>.</summary>
public Dictionary<string, List<RpgMapEvent>> Collect()
{
Context.Initialize();
if (cache != null)
return cache;
var merge = new RpgMapEventMerge();
foreach (var modId in Context.GetOrderedModIds())
CollectMod(Context.Mods[modId].FileProvider, modId, merge);
foreach (var overlay in ModOverlays.All)
CollectMod(overlay.Value, overlay.Key, merge);
cache = merge.Maps;
GameLogger.Log("RPG", $"map events: {cache.Values.Sum(l => l.Count)} event(s) on {cache.Count} map(s)");
return cache;
}
/// <inheritdoc />
public override void ClearCache() => cache = null;
private void CollectMod(IModFileProvider provider, string modId, RpgMapEventMerge merge)
{
foreach (var mapDir in provider.ListFiles(Directory).Where(e => e.EndsWith("/")).OrderBy(e => e, System.StringComparer.Ordinal))
{
var map = mapDir.TrimEnd('/');
map = map.Substring(map.LastIndexOf('/') + 1);
var files = new List<string>();
ListJson(provider, mapDir.TrimEnd('/'), files);
foreach (var file in files.OrderBy(f => f, System.StringComparer.Ordinal))
ReadFile(provider, modId, map, file, merge);
}
}
private static void ListJson(IModFileProvider provider, string dir, List<string> files)
{
foreach (var entry in provider.ListFiles(dir))
{
if (entry.EndsWith("/"))
ListJson(provider, entry.TrimEnd('/'), files);
else if (entry.EndsWith(".json", System.StringComparison.OrdinalIgnoreCase))
files.Add(entry);
}
}
private void ReadFile(IModFileProvider provider, string modId, string map, string file, RpgMapEventMerge merge)
{
var text = Context.ReadResolvedText(modId, file, provider);
if (string.IsNullOrWhiteSpace(text))
return;
try
{
var data = JsonSerializer.Deserialize<RpgMapEventFile>(text, JsonOptionsProvider.ReadOptions);
foreach (var id in merge.Add(map, modId, file, data?.Events))
GameLogger.Log("RPG", $"map '{map}': event '{id}' replaced by mod '{modId}' ({file})");
}
catch (JsonException ex)
{
GameLogger.Log("RPG", $"map events '{modId}/{file}': broken JSON: {ex.Message}", LogLevel.Error);
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using Cthangover.Core.Mods;
namespace Cthangover.Mods.Rpg
{
/// <summary>Access to the merged map events of <see cref="RpgMapEventResolver"/>.</summary>
public static class RpgMapEvents
{
/// <summary>The events of a map (an empty list when it has none); the list is shared, do not change it.</summary>
public static IReadOnlyList<RpgMapEvent> Of(string map)
{
var maps = ModManager.Instance.GetResolver<RpgMapEventResolver>()?.Collect();
return map != null && maps != null && maps.TryGetValue(map, out var list) ? list : new List<RpgMapEvent>();
}
/// <summary>Forgets the collected events, so the next <see cref="Of"/> reads the files again (after an editor save).</summary>
public static void Invalidate() => ModManager.Instance.GetResolver<RpgMapEventResolver>()?.ClearCache();
}
}
+25
View File
@@ -0,0 +1,25 @@
using System.Collections.Generic;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Autonomous movement of an event (RPG Maker's "move route" / "autonomous movement"):
/// <c>"Route": { "Steps": ["left", "left", "wait", "right", "right"], "Every": 0.8 }</c>.
/// Steps: <c>up</c>, <c>down</c>, <c>left</c>, <c>right</c>, <c>random</c> (a random
/// direction), <c>toward</c> / <c>away</c> (relative to the player), <c>wait</c>, and
/// <c>face_up</c> … <c>face_right</c> (turn without moving). A step into a blocked cell is
/// skipped (the event turns that way). The route pauses while a dialog runs or the player
/// cannot walk; positions are not saved, so an event starts from its cell on every visit.
/// </summary>
public class RpgMoveRoute
{
/// <summary>The steps, repeated from the start when <see cref="Repeat"/> is set.</summary>
public List<string> Steps { get; set; } = new();
/// <summary>Seconds between steps.</summary>
public double Every { get; set; } = 1.0;
/// <summary>Whether the route starts over after the last step.</summary>
public bool Repeat { get; set; } = true;
}
}
+58
View File
@@ -0,0 +1,58 @@
using System;
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// One step of an <see cref="RpgMoveRoute"/> resolved for the current positions: a
/// direction (null for <c>wait</c> or an unknown word) and whether the event walks or only
/// turns. Pure, so routes are tested without a map.
/// </summary>
public readonly struct RpgRouteStep
{
/// <summary>Creates a step.</summary>
public RpgRouteStep(RpgDirection? direction, bool moves)
{
Direction = direction;
Moves = moves;
}
/// <summary>Where to walk or turn; null = stand.</summary>
public RpgDirection? Direction { get; }
/// <summary>True for a walk, false for a turn or a wait.</summary>
public bool Moves { get; }
/// <summary>Resolves <paramref name="word"/> (any case) for an event at <paramref name="self"/>.</summary>
public static RpgRouteStep Resolve(string word, Vector2I self, Vector2I player, Random random)
{
var step = (word ?? string.Empty).Trim().ToLowerInvariant();
switch (step)
{
case "up": return new RpgRouteStep(RpgDirection.Up, true);
case "down": return new RpgRouteStep(RpgDirection.Down, true);
case "left": return new RpgRouteStep(RpgDirection.Left, true);
case "right": return new RpgRouteStep(RpgDirection.Right, true);
case "face_up": return new RpgRouteStep(RpgDirection.Up, false);
case "face_down": return new RpgRouteStep(RpgDirection.Down, false);
case "face_left": return new RpgRouteStep(RpgDirection.Left, false);
case "face_right": return new RpgRouteStep(RpgDirection.Right, false);
case "random": return new RpgRouteStep((RpgDirection)(random ?? Random.Shared).Next(4), true);
case "toward": return new RpgRouteStep(Toward(self, player), true);
case "away": return new RpgRouteStep(Toward(self, player) is { } d ? RpgDirections.Opposite(d) : null, true);
default: return new RpgRouteStep(null, false);
}
}
/// <summary>The direction that shortens the longer axis of the distance to <paramref name="target"/>; null when already there.</summary>
public static RpgDirection? Toward(Vector2I self, Vector2I target)
{
var delta = target - self;
if (delta == Vector2I.Zero)
return null;
if (Math.Abs(delta.X) >= Math.Abs(delta.Y))
return delta.X > 0 ? RpgDirection.Right : RpgDirection.Left;
return delta.Y > 0 ? RpgDirection.Down : RpgDirection.Up;
}
}
}
+26 -1
View File
@@ -6,9 +6,18 @@ namespace Cthangover.Mods.Rpg
/// </summary> /// </summary>
public class RpgTileInfo public class RpgTileInfo
{ {
/// <summary>Row-major atlas index: <c>row * Columns + column</c>.</summary> /// <summary>Logical map id; AtlasIndex or Surface can give it independent physical storage.</summary>
public int Index { get; set; } public int Index { get; set; }
/// <summary>Physical cell in the object atlas; null preserves legacy row-major addressing.</summary>
public int? AtlasIndex { get; set; }
/// <summary>Square material patch in the separate surface atlas; null denotes an object.</summary>
public int? Surface { get; set; }
/// <summary>Blend this material with neighbouring surfaces; interiors can keep hard edges.</summary>
public bool Diffuse { get; set; } = true;
/// <summary> /// <summary>
/// The tile blocks walking when it lies on the back or the main layer /// The tile blocks walking when it lies on the back or the main layer
/// (front-layer tiles never block: the player walks under them). The map's /// (front-layer tiles never block: the player walks under them). The map's
@@ -21,5 +30,21 @@ namespace Cthangover.Mods.Rpg
/// "tree_crown"). Not a locale key: players never see it. /// "tree_crown"). Not a locale key: players never see it.
/// </summary> /// </summary>
public string Tag { get; set; } public string Tag { get; set; }
/// <summary>Light the tile gives off wherever it lies on a map (a lamp, a lit window); null = none.</summary>
public RpgLight Light { get; set; }
/// <summary>
/// Picture of an autotile block (RPG Maker A2 layout, see <see cref="RpgAutotiles"/>) in the
/// tilesets group; set, the entry is an autotile and <see cref="Index"/> should lie outside the atlas.
/// </summary>
public string Autotile { get; set; }
/// <summary>
/// Thin upright object (fence, wall, railing) shown in the isometric view as a vertical plane along a map
/// axis instead of a camera-facing sprite: "x", "y", or "auto" (along neighbouring plane tiles of the same
/// layer, see <see cref="RpgTilePlanes"/>). Null keeps the sprite; the square view always shows the sprite.
/// </summary>
public string Plane { get; set; }
} }
} }
+40 -3
View File
@@ -5,9 +5,10 @@ using Cthangover.Core.Factories;
namespace Cthangover.Mods.Rpg namespace Cthangover.Mods.Rpg
{ {
/// <summary> /// <summary>
/// A square-tile atlas: one image cut into <see cref="TileSize"/> cells, /// A square-tile object atlas, optionally accompanied by a strip of surface materials.
/// <see cref="Columns"/> per row, addressed by a row-major index. Maps store /// Maps store logical tile ids; AtlasIndex decouples those ids from compact object
/// only these indices, so a pack can redraw its tiles without touching maps. /// storage, while Surface selects a patch for runtime material diffusion. Legacy
/// packs without these fields retain row-major image addressing.
/// <para/> /// <para/>
/// Files declare <c>"Kind": "rpg_tileset"</c> and live anywhere in a mod; the /// Files declare <c>"Kind": "rpg_tileset"</c> and live anywhere in a mod; the
/// image is resolved in the <c>tilesets</c> file group (last mod wins), so a /// image is resolved in the <c>tilesets</c> file group (last mod wins), so a
@@ -25,6 +26,15 @@ namespace Cthangover.Mods.Rpg
/// <summary>Image path inside the <c>tilesets</c> group, extension optional.</summary> /// <summary>Image path inside the <c>tilesets</c> group, extension optional.</summary>
public string Image { get; set; } public string Image { get; set; }
/// <summary>Separate atlas of reusable surface patches, arranged in one horizontal strip.</summary>
public string SurfaceImage { get; set; }
/// <summary>Width and height of one surface patch in pixels.</summary>
public int SurfaceSize { get; set; } = 128;
/// <summary>Properties of a logical map tile, independent of physical atlas packing.</summary>
public RpgTileInfo TileOf(int index) => Tiles?.Find(tile => tile.Index == index);
/// <summary>Edge of one square tile in image pixels.</summary> /// <summary>Edge of one square tile in image pixels.</summary>
public int TileSize { get; set; } = 32; public int TileSize { get; set; } = 32;
@@ -34,6 +44,33 @@ namespace Cthangover.Mods.Rpg
/// <summary>Per-cell properties; cells without an entry are walkable.</summary> /// <summary>Per-cell properties; cells without an entry are walkable.</summary>
public List<RpgTileInfo> Tiles { get; set; } = new(); public List<RpgTileInfo> Tiles { get; set; } = new();
/// <summary>Exact image groups for projection-independent whole objects; legacy sets may omit them.</summary>
public List<RpgVisualGroup> VisualGroups { get; set; } = new();
/// <summary>The autotile entry with this index (<see cref="RpgTileInfo.Autotile"/> set), or null.</summary>
public RpgTileInfo AutotileOf(int index)
{
if (index < 0)
return null;
foreach (var tile in Tiles ?? new List<RpgTileInfo>())
{
if (tile.Index == index && !string.IsNullOrEmpty(tile.Autotile))
return tile;
}
return null;
}
/// <summary>Light of the atlas cell (<see cref="RpgTileInfo.Light"/>), or null.</summary>
public RpgLight LightOf(int index)
{
foreach (var tile in Tiles ?? new List<RpgTileInfo>())
{
if (tile.Index == index && tile.Light != null)
return tile.Light;
}
return null;
}
/// <summary>True when the atlas cell blocks walking. Built once from <see cref="Tiles"/>.</summary> /// <summary>True when the atlas cell blocks walking. Built once from <see cref="Tiles"/>.</summary>
public bool IsSolid(int index) public bool IsSolid(int index)
{ {
+13 -1
View File
@@ -1,14 +1,26 @@
using System; using System;
using Cthangover.Core.Factories; using Cthangover.Core.Factories;
using Cthangover.Core.Mods.Resolvers;
namespace Cthangover.Mods.Rpg namespace Cthangover.Mods.Rpg
{ {
/// <summary>Tilesets collected by the shared entity resolver (<c>"Kind": "rpg_tileset"</c>).</summary> /// <summary>
/// Tilesets collected by the shared entity resolver (<c>"Kind": "rpg_tileset"</c>).
/// <see cref="Publish"/> lets the tileset editor put a just-saved tileset into the resolver's cache,
/// like <see cref="RpgMapFactory.Publish"/> does for maps.
/// </summary>
public class RpgTilesetFactory : FileFactory<RpgTileset> public class RpgTilesetFactory : FileFactory<RpgTileset>
{ {
private static readonly Lazy<RpgTilesetFactory> instance = new(() => new RpgTilesetFactory()); private static readonly Lazy<RpgTilesetFactory> instance = new(() => new RpgTilesetFactory());
/// <summary>Shared instance.</summary> /// <summary>Shared instance.</summary>
public static RpgTilesetFactory Instance => instance.Value; public static RpgTilesetFactory Instance => instance.Value;
/// <summary>Replaces the cached definition of <paramref name="tileset"/>'s id.</summary>
public void Publish(RpgTileset tileset)
{
if (tileset != null && !string.IsNullOrEmpty(tileset.ID))
Entities.Collect<RpgTileset>()[tileset.ID] = tileset;
}
} }
} }
+19
View File
@@ -0,0 +1,19 @@
namespace Cthangover.Mods.Rpg
{
using System.Collections.Generic;
/// <summary>
/// An exact legacy tile pattern describing one whole image. Matching never changes
/// collision or event coordinates; partial patterns remain ordinary cells.
/// </summary>
public class RpgVisualGroup
{
public string Id { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public int[] Pattern { get; set; }
public float AnchorX { get; set; }
public float AnchorY { get; set; }
/// <summary>Optional same-atlas architectural planes, instead of an upright whole image.</summary>
public List<RpgVisualPlane> IsoPlanes { get; set; }
}
}
+33
View File
@@ -0,0 +1,33 @@
namespace Cthangover.Mods.Rpg
{
using System;
/// <summary>Rejects ambiguous source geometry before it reaches a renderer; validation never changes map cells.</summary>
public static class RpgVisualGroupValidator
{
public static string Error(RpgVisualGroup group)
{
if (group == null || string.IsNullOrWhiteSpace(group.Id)) return "visual group requires an ID";
if (group.Width <= 0 || group.Height <= 0 || group.Pattern?.Length != (long)group.Width * group.Height)
return "visual group pattern dimensions differ";
if (!float.IsFinite(group.AnchorX) || !float.IsFinite(group.AnchorY)) return "non-finite group anchor";
foreach (var id in group.Pattern) if (id < 0) return "visual group contains an empty logical ID";
if (group.IsoPlanes == null) return null;
foreach (var plane in group.IsoPlanes)
{
if (plane?.Crop?.Length != 4 || plane.Points?.Length != 4) return "plane requires a crop and four corners";
var crop = plane.Crop;
if (crop[0] < 0 || crop[1] < 0 || crop[2] <= 0 || crop[3] <= 0 ||
(long)crop[0] + crop[2] > group.Width || (long)crop[1] + crop[3] > group.Height) return "plane crop exceeds its source group";
foreach (var point in plane.Points)
{
if (point?.Length != 3) return "plane corners require XYZ coordinates";
foreach (var value in point) if (!float.IsFinite(value)) return "non-finite plane corner";
}
for (var axis = 0; axis < 3; axis++)
if (Math.Abs(plane.Points[0][axis] + plane.Points[2][axis] - plane.Points[1][axis] - plane.Points[3][axis]) > 0.001f)
return "plane corners must form an affine quad";
}
return null;
}
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace Cthangover.Mods.Rpg
{
/// <summary>Crop in group cells, and four local XYZ cell coordinates (TL/TR/BR/BL).</summary>
public class RpgVisualPlane
{
public int[] Crop { get; set; }
public float[][] Points { get; set; }
}
}
-133
View File
@@ -1,133 +0,0 @@
using System;
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Something standing on a map cell: the player, an NPC, a chest. Drawn
/// either from a charset — 3 frames by 4 facings, rows in
/// <see cref="RpgDirection"/> order, the middle frame is the standing pose — or
/// from one atlas tile. The frame is bottom-aligned and centred on the cell,
/// so taller charsets overlap the cell above like in RPG Maker.
/// <para/>
/// Movement is grid based: <see cref="StepTo"/> starts a step to the next
/// cell, the node slides there at <see cref="TilesPerSecond"/> and then raises
/// <see cref="StepFinished"/>. The actor's <see cref="Cell"/> changes at the
/// start of a step, so two actors never walk into the same cell. The actor
/// layer sorts children by Y, which keeps whoever is lower in front.
/// </summary>
public partial class RpgActor : Node2D
{
/// <summary>Walking speed.</summary>
public const float TilesPerSecond = 4.5f;
private static readonly int[] WalkCycle = { 0, 1, 2, 1 };
private readonly int tileSize;
private Sprite2D sprite;
private Vector2 from;
private Vector2 to;
private float progress = 1f;
private float animationTime;
/// <summary>Raised when a step reaches its cell.</summary>
public event Action<RpgActor> StepFinished;
/// <summary>Cell the actor stands on (or walks into).</summary>
public Vector2I Cell { get; private set; }
/// <summary>Facing.</summary>
public RpgDirection Facing { get; private set; }
/// <summary>True while a step is in progress.</summary>
public bool IsMoving => progress < 1f;
/// <summary>The map event this actor shows; null for the player.</summary>
public RpgMapEvent Event { get; }
/// <summary>Creates an actor for a cell grid of <paramref name="tileSize"/> pixels.</summary>
public RpgActor(int tileSize, RpgMapEvent mapEvent = null)
{
this.tileSize = tileSize;
Event = mapEvent;
}
/// <summary>Shows a charset; a null texture leaves the actor invisible (an invisible trigger).</summary>
public void UseCharset(Texture2D texture)
{
if (texture == null)
return;
sprite = new Sprite2D { Texture = texture, Hframes = 3, Vframes = 4, Centered = false };
var frame = new Vector2(texture.GetWidth() / 3f, texture.GetHeight() / 4f);
sprite.Position = new Vector2((tileSize - frame.X) / 2f, tileSize - frame.Y);
AddChild(sprite);
UpdateFrame();
}
/// <summary>Shows one atlas cell of the map's tileset.</summary>
public void UseTile(Texture2D atlas, Vector2I atlasCoords)
{
if (atlas == null)
return;
sprite = new Sprite2D
{
Texture = atlas,
Centered = false,
RegionEnabled = true,
RegionRect = new Rect2(atlasCoords * tileSize, new Vector2(tileSize, tileSize)),
};
AddChild(sprite);
}
/// <summary>Puts the actor on a cell at once, stopping any step.</summary>
public void Place(Vector2I cell, RpgDirection facing)
{
Cell = cell;
Facing = facing;
progress = 1f;
Position = CellPosition(cell);
UpdateFrame();
}
/// <summary>Turns without moving.</summary>
public void Face(RpgDirection facing)
{
Facing = facing;
UpdateFrame();
}
/// <summary>Starts a step to the neighbour cell; the caller has checked it is free.</summary>
public void StepTo(RpgDirection direction)
{
Facing = direction;
from = CellPosition(Cell);
Cell += RpgDirections.Delta(direction);
to = CellPosition(Cell);
progress = 0f;
}
/// <summary>Advances the step and the walk animation.</summary>
public override void _Process(double delta)
{
if (!IsMoving)
return;
progress = Mathf.Min(1f, progress + (float)delta * TilesPerSecond);
animationTime += (float)delta * TilesPerSecond * 2f;
Position = from.Lerp(to, progress);
UpdateFrame();
if (!IsMoving)
StepFinished?.Invoke(this);
}
private Vector2 CellPosition(Vector2I cell) => new(cell.X * tileSize, cell.Y * tileSize);
private void UpdateFrame()
{
if (sprite == null || sprite.Hframes != 3)
return;
var column = IsMoving ? WalkCycle[(int)animationTime % WalkCycle.Length] : 1;
sprite.Frame = (int)Facing * 3 + column;
}
}
}
+100
View File
@@ -0,0 +1,100 @@
namespace Cthangover.Mods.Rpg
{
using System;
using Godot;
/// <summary>
/// An actor in cell coordinates, independent of nodes and textures. The destination
/// is reserved at step start; renderers interpolate Position without owning the clock.
/// This transient state is rebuilt on map entry, while RpgState owns saved position.
/// </summary>
public class RpgActorState
{
/// <summary>Normal walking speed in cells per second.</summary>
public const float TilesPerSecond = 4.5f;
private Vector2 from;
private float progress = 1f;
/// <summary>Definition and all its pages, or null for the player.</summary>
public RpgMapEvent Event { get; }
/// <summary>Effective event page, resolved by the world session.</summary>
public RpgMapEvent Current { get; internal set; }
/// <summary>Active page index; -2 means no page has yet been resolved.</summary>
public int PageIndex { get; internal set; } = -2;
/// <summary>Whether conditions currently enable this event in the world.</summary>
public bool Visible { get; internal set; } = true;
/// <summary>Route bookkeeping belongs to the simulation, never to a sprite.</summary>
public int RouteIndex { get; internal set; }
/// <summary>Seconds until another route instruction may start.</summary>
public double RouteTimer { get; internal set; }
/// <summary>Reserved cell, updated immediately when a step starts.</summary>
public Vector2I Cell { get; private set; }
/// <summary>Direction in map axes, independent of camera orientation.</summary>
public RpgDirection Facing { get; private set; }
/// <summary>Duration uses cells per second, not projected pixel distance.</summary>
public float Speed { get; set; } = TilesPerSecond;
/// <summary>True until the current step reaches its reserved destination.</summary>
public bool IsMoving => progress < 1f;
/// <summary>Continuous top-left cell position for any projection.</summary>
public Vector2 Position => from.Lerp(new Vector2(Cell.X, Cell.Y), progress);
/// <summary>Continuous walk phase shared by every presentation.</summary>
public float AnimationTime { get; private set; }
/// <summary>Creates transient state; no Godot object is allocated.</summary>
public RpgActorState(RpgMapEvent mapEvent = null)
{
Event = mapEvent;
}
/// <summary>Teleportation stops an unfinished step without reporting its completion.</summary>
public void Place(Vector2I cell, RpgDirection facing)
{
Cell = cell;
from = new Vector2(cell.X, cell.Y);
Facing = facing;
progress = 1f;
}
/// <summary>A blocked movement command can still turn the actor.</summary>
public void Face(RpgDirection facing) => Facing = facing;
/// <summary>Reserves a neighbour; the session checks passability before calling.</summary>
public void StepTo(RpgDirection direction)
{
if (IsMoving)
return;
Facing = direction;
from = new Vector2(Cell.X, Cell.Y);
Cell += RpgDirections.Delta(direction);
progress = 0f;
}
/// <summary>
/// Advances an existing step and returns true exactly once at arrival.
/// Renderers must never call this: the session is the sole time source.
/// </summary>
public bool Advance(double delta)
{
if (delta < 0 || !double.IsFinite(delta))
throw new ArgumentOutOfRangeException(nameof(delta));
if (!IsMoving || delta <= 0)
return false;
var speed = Math.Clamp(Speed, 1f, 12f);
progress = Math.Min(1f, progress + (float)delta * speed);
AnimationTime += (float)delta * speed * 2f;
return !IsMoving;
}
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ namespace Cthangover.Mods.Rpg
if (dialogBox == null || dialogBox.IsRunning) if (dialogBox == null || dialogBox.IsRunning)
return false; return false;
var dialog = ScenarioParser.Parse(text.ToString()); var dialog = new ScenarioConverter().Convert(text.ToString());
if (dialog?.Queue == null || dialog.Queue.Count == 0) if (dialog?.Queue == null || dialog.Queue.Count == 0)
return false; return false;
+26
View File
@@ -0,0 +1,26 @@
namespace Cthangover.Mods.Rpg
{
using System.Collections.Generic;
using Godot;
/// <summary>
/// Where each tile of a visual group sits inside its object. A cell left over after another object overwrote
/// part of a group (so the pattern no longer matches) is placed relative to the anchor of the object it came
/// from: the isometric view draws the remaining fragment exactly as that part of the whole sprite, instead of
/// standing every cell up at its own map cell. A tile used by several groups or twice in one keeps its first place.
/// </summary>
public static class RpgGroupFragments
{
public static Dictionary<int, (RpgVisualGroup Group, Vector2I Cell)> Of(RpgTileset tileset)
{
var result = new Dictionary<int, (RpgVisualGroup, Vector2I)>();
foreach (var group in tileset.VisualGroups ?? new List<RpgVisualGroup>())
{
if (group.IsoPlanes?.Count > 0 || RpgVisualGroupValidator.Error(group) != null) continue;
for (var i = 0; i < group.Pattern.Length; i++)
result.TryAdd(group.Pattern[i], (group, new Vector2I(i % group.Width, i / group.Width)));
}
return result;
}
}
}
+82
View File
@@ -0,0 +1,82 @@
namespace Cthangover.Mods.Rpg
{
using System.Collections.Generic;
using Cthangover.Core.Settings;
using Cthangover.Mods.Time;
using Cthangover.Mods.TileRender;
using Godot;
/// <summary>Collects logical lighting; projection and shader application belong to the renderer.</summary>
public sealed class RpgLighting
{
public const int MaxLights = 16;
public const float PlayerRadius = 3.5f;
private static readonly Vector4 Day = new(0, 1, 0, 0);
private static readonly Vector4 Night = new(0, 0, 0, 1);
private readonly RpgMap map;
private readonly List<(Vector2I Cell, RpgLight Light)> tileLights;
private readonly List<TileLight> candidates = new();
private Vector2 focus;
public RpgLighting(RpgMap map, RpgMapGrid grid, RpgTileset tileset)
{
this.map = map;
tileLights = tileset == null ? new() : TileLights(grid, tileset);
}
/// <summary>
/// The ambient weights for an hour 0–24: night in dark mode, day while lighting is
/// switched off, otherwise the phase blend of the time shaders.
/// </summary>
public static Vector4 Weights(float hour, bool darkMode, bool useLight)
{
if (darkMode)
return Night;
if (!useLight)
return Day;
return UiLightController.CalculateTimeWeights(hour);
}
/// <summary>Cells of <paramref name="grid"/> whose tile (on any layer) gives off light, with that light.</summary>
public static List<(Vector2I Cell, RpgLight Light)> TileLights(RpgMapGrid grid, RpgTileset tileset)
{
var result = new List<(Vector2I, RpgLight)>();
for (var y = 0; y < grid.Height; y++)
{
for (var x = 0; x < grid.Width; x++)
{
foreach (var layer in new[] { RpgLayer.Back, RpgLayer.Main, RpgLayer.Front })
{
var light = tileset.LightOf(grid.GetTile(layer, x, y));
if (light == null)
continue;
result.Add((new Vector2I(x, y), light));
break;
}
}
}
return result;
}
public void Fill(TileFrame frame, RpgWorldSession session)
{
var controller = UiLightController.Instance;
var hour = GameData.Instance?.Runtime?.Time?.Normalized ?? 12f;
frame.TimeWeights = Weights(hour, controller?.IsDarkMode == true, controller?.IsUseLight != false);
frame.Ambient = !string.IsNullOrEmpty(map.Ambient) && Color.HtmlIsValid(map.Ambient) ? Color.FromHtml(map.Ambient) : null;
frame.Lights.Clear();
focus = session.Player.Position + Vector2.One / 2;
frame.Lights.Add(new TileLight { Position = focus, Radius = PlayerRadius, Color = new Color(1f, 0.9f, 0.75f, 0.75f) });
candidates.Clear();
foreach (var (cell, light) in tileLights) Add(new Vector2(cell.X, cell.Y) + Vector2.One / 2, light);
foreach (var actor in session.Events)
if (actor.Visible && actor.Current?.Light != null) Add(actor.Position + Vector2.One / 2, actor.Current.Light);
candidates.Sort(CompareDistance);
for (var i = 0; i < candidates.Count && frame.Lights.Count < MaxLights; i++) frame.Lights.Add(candidates[i]);
}
private void Add(Vector2 position, RpgLight light)
=> candidates.Add(new TileLight { Position = position, Radius = light.Radius,
Color = Color.HtmlIsValid(light.Color ?? "") ? Color.FromHtml(light.Color) : Colors.White });
private int CompareDistance(TileLight a, TileLight b)
=> a.Position.DistanceSquaredTo(focus).CompareTo(b.Position.DistanceSquaredTo(focus));
}
}
+230
View File
@@ -0,0 +1,230 @@
namespace Cthangover.Mods.Rpg
{
using System;
using System.Collections.Generic;
using Cthangover.Mods.TileRender;
using Cthangover.Core.Utils;
using Godot;
/// <summary>
/// Converts the existing map format into renderer-neutral image primitives. No
/// projection, collision decision or scenario execution belongs in this adapter.
/// </summary>
public class RpgRenderSceneBuilder : IDisposable
{
private readonly RpgTextures textures;
private readonly Dictionary<RpgLayer, TileSurfaceCache> surfaceCaches = new();
private readonly HashSet<string> reported = new();
private long revision;
public int LastBuiltSurfaceChunks { get; private set; }
public long SurfaceTextureBytes { get; private set; }
public RpgRenderSceneBuilder(RpgTextures textures) { this.textures = textures; }
public TileRenderScene Build(RpgMap map, RpgMapGrid grid, RpgTileset tileset)
{
var scene = new TileRenderScene { Revision = ++revision, Size = new Vector2I(grid.Width, grid.Height), TileSize = tileset?.TileSize ?? 32,
Zoom = Math.Max(1, map.Zoom), Atmosphere = map.Atmosphere };
LastBuiltSurfaceChunks = 0;
SurfaceTextureBytes = 0;
if (tileset == null) return scene;
var atlas = textures.Tileset(tileset.Image);
var grouped = new HashSet<(int Layer, int X, int Y)>();
AddGroups(scene, grid, tileset, atlas, grouped);
var fragments = RpgGroupFragments.Of(tileset);
foreach (var layer in new[] { RpgLayer.Back, RpgLayer.Main, RpgLayer.Front })
{
if (!string.IsNullOrEmpty(tileset.SurfaceImage) && textures.Tileset(tileset.SurfaceImage) is { } source)
{
if (!surfaceCaches.TryGetValue(layer, out var cache)) surfaceCaches[layer] = cache = new TileSurfaceCache();
cache.Update(RpgSurfaceRenderer.Field(grid, layer, tileset), source);
LastBuiltSurfaceChunks += cache.LastBuiltChunks;
SurfaceTextureBytes += cache.TextureBytes;
foreach (var chunk in cache.Chunks)
scene.Visuals.Add(new TileVisual { Id = "surface/" + layer, Texture = chunk.Texture, Layer = (int)layer,
Bounds = new Rect2(chunk.Cells.Position, chunk.Cells.Size),
Source = new Rect2(Vector2.Zero, chunk.Texture.GetSize()) });
}
for (var y = 0; y < grid.Height; y++)
for (var x = 0; x < grid.Width; x++)
{
var id = grid.GetTile(layer, x, y);
if (id < 0) continue;
var info = tileset.TileOf(id);
if (info?.Tag == "water" && layer == RpgLayer.Back) scene.Water.Add(new Vector2(x, y));
if (info?.Surface != null) continue;
var only = grouped.Contains(((int)layer, x, y)) ? "topdown" : null;
if (tileset.AutotileOf(id) is { } autotile)
{
var block = textures.Autotile(autotile.Autotile, tileset.TileSize, x, y);
var quarters = RpgAutotiles.QuartersAt(grid, layer, x, y, id);
for (var q = 0; q < 4; q++)
scene.Visuals.Add(new TileVisual { Texture = block, Layer = (int)layer,
Bounds = new Rect2(x + q % 2 * 0.5f, y + q / 2 * 0.5f, 0.5f, 0.5f),
Source = RpgAutotiles.SourceRect(quarters[q], tileset.TileSize) });
}
else if (atlas != null)
{
var planes = only == null && layer != RpgLayer.Back && info?.Plane != null;
var fragment = only == null && fragments.TryGetValue(id, out var part) ? part : default;
scene.Visuals.Add(new TileVisual { Texture = atlas, Source = TileRegion(tileset, id),
Bounds = new Rect2(x, y, 1, 1), Anchor = new Vector2(x + 0.5f, y + 1),
Layer = (int)layer, Only = planes || fragment.Group != null ? "topdown" : only, Upright = layer != RpgLayer.Back });
if (fragment.Group != null)
scene.Visuals.Add(new TileVisual { Id = fragment.Group.Id, Texture = atlas, Source = TileRegion(tileset, id),
Bounds = new Rect2(x, y, 1, 1), Layer = (int)layer, Upright = true, Only = "isometric",
Anchor = new Vector2(x - fragment.Cell.X + fragment.Group.AnchorX, y - fragment.Cell.Y + fragment.Group.AnchorY) });
if (planes)
foreach (var quad in RpgTilePlanes.Quads(grid, tileset, layer, x, y, info.Plane))
scene.Visuals.Add(new TileVisual { Texture = atlas, Source = TileRegion(tileset, id),
Bounds = new Rect2(x, y, 1, 1), Anchor = new Vector2(x + 0.5f, y + 0.5f), Layer = (int)layer,
Only = "isometric", WorldQuad = quad, Upright = true });
}
}
}
ReportPartial(scene, map.ID, grid, tileset, grouped);
return scene;
}
private void ReportPartial(TileRenderScene scene, string mapId, RpgMapGrid grid, RpgTileset tileset,
HashSet<(int Layer, int X, int Y)> used)
{
foreach (var group in tileset.VisualGroups ?? new List<RpgVisualGroup>())
{
var ids = new HashSet<int>(group.Pattern);
var partial = false;
for (var layer = 0; layer < 3 && !partial; layer++)
for (var y = 0; y < grid.Height && !partial; y++)
for (var x = 0; x < grid.Width && !partial; x++)
partial = !used.Contains((layer, x, y)) && ids.Contains(grid.GetTile((RpgLayer)layer, x, y));
if (!partial) continue;
var message = $"map '{mapId}': incomplete visual group '{group.Id}', drawing the remaining cells in place of the whole object";
scene.Warnings.Add(message);
if (reported.Add(mapId + "/" + tileset.ID + "/" + group.Id)) GameLogger.Log("RPG", message, LogLevel.Warning);
}
}
private void AddGroups(TileRenderScene scene, RpgMapGrid grid, RpgTileset tileset, Texture2D atlas,
HashSet<(int Layer, int X, int Y)> grouped)
{
if (atlas == null) return;
foreach (var group in tileset.VisualGroups ?? new List<RpgVisualGroup>())
{
var error = RpgVisualGroupValidator.Error(group);
if (error != null) throw new ArgumentException(error + ": " + group?.Id);
if (group.IsoPlanes?.Count > 0 && !PackedRectangle(group, tileset))
throw new ArgumentException("Architectural source must remain a packed rectangle: " + group.Id);
for (var y = 0; y <= grid.Height - group.Height; y++)
for (var x = 0; x <= grid.Width - group.Width; x++)
{
var matched = Match(grid, group, x, y, grouped);
if (matched == null) continue;
foreach (var cell in matched) grouped.Add(cell);
if (group.IsoPlanes?.Count > 0)
{
AddPlanes(scene, group, x, y, tileset, atlas);
continue;
}
if (PackedRectangle(group, tileset))
{
scene.Visuals.Add(new TileVisual { Id = group.Id, Texture = atlas,
Source = new Rect2(TileRegion(tileset, group.Pattern[0]).Position, new Vector2(group.Width, group.Height) * tileset.TileSize),
Bounds = new Rect2(x, y, group.Width, group.Height), Anchor = new Vector2(x + group.AnchorX, y + group.AnchorY),
Layer = 1, Upright = true, Only = "isometric" });
continue;
}
for (var dy = 0; dy < group.Height; dy++)
for (var dx = 0; dx < group.Width; dx++)
{
var id = group.Pattern[dy * group.Width + dx];
scene.Visuals.Add(new TileVisual { Id = group.Id, Texture = atlas, Source = TileRegion(tileset, id),
Bounds = new Rect2(x + dx, y + dy, 1, 1), Anchor = new Vector2(x + group.AnchorX, y + group.AnchorY),
Layer = 1, Upright = true, Only = "isometric" });
}
}
}
}
private static bool PackedRectangle(RpgVisualGroup group, RpgTileset tileset)
{
var first = RpgTileSetBuilder.AtlasCoords(tileset, group.Pattern[0]);
for (var y = 0; y < group.Height; y++) for (var x = 0; x < group.Width; x++)
if (RpgTileSetBuilder.AtlasCoords(tileset, group.Pattern[y * group.Width + x]) != first + new Vector2I(x, y)) return false;
return true;
}
/// <summary>Exact source-cell consumption; no inference from tags, opacity or collision.</summary>
private static List<(int Layer, int X, int Y)> Match(RpgMapGrid grid, RpgVisualGroup group, int x, int y,
HashSet<(int Layer, int X, int Y)> used)
{
var first = group.Pattern[0];
if (grid.GetTile(RpgLayer.Back, x, y) != first && grid.GetTile(RpgLayer.Main, x, y) != first && grid.GetTile(RpgLayer.Front, x, y) != first) return null;
var result = new List<(int, int, int)>();
for (var dy = 0; dy < group.Height; dy++)
for (var dx = 0; dx < group.Width; dx++)
{
var found = -1;
for (var layer = 0; layer < 3; layer++)
if (!used.Contains((layer, x + dx, y + dy)) && grid.GetTile((RpgLayer)layer, x + dx, y + dy) == group.Pattern[dy * group.Width + dx])
{ found = layer; break; }
if (found < 0) return null;
result.Add((found, x + dx, y + dy));
}
return result;
}
private static void AddPlanes(TileRenderScene scene, RpgVisualGroup group, int x, int y, RpgTileset tileset, Texture2D atlas)
{
var start = TileRegion(tileset, group.Pattern[0]).Position;
foreach (var plane in group.IsoPlanes)
{
if (plane.Crop?.Length != 4 || plane.Points?.Length != 4) throw new ArgumentException("Invalid architectural plane: " + group.Id);
var points = new Vector3[4];
for (var i = 0; i < 4; i++)
points[i] = new Vector3(x + plane.Points[i][0], y + plane.Points[i][1], plane.Points[i][2]);
scene.Visuals.Add(new TileVisual { Id = group.Id, Texture = atlas, Only = "isometric", Layer = 0,
Bounds = new Rect2(x, y, group.Width, group.Height), WorldQuad = points,
Source = new Rect2(start + new Vector2(plane.Crop[0], plane.Crop[1]) * tileset.TileSize,
new Vector2(plane.Crop[2], plane.Crop[3]) * tileset.TileSize) });
}
}
public static Rect2 TileRegion(RpgTileset tileset, int id)
=> new(RpgTileSetBuilder.AtlasCoords(tileset, id) * tileset.TileSize, Vector2.One * tileset.TileSize);
public void Dispose()
{
foreach (var cache in surfaceCaches.Values) cache.Dispose();
surfaceCaches.Clear();
}
public TileVisual ActorLook(RpgActorState actor, RpgTileset tileset, bool player)
{
var page = actor.Current;
var charset = player ? RpgPlayerLook.Charset() : page?.Sprite;
if (!string.IsNullOrEmpty(charset))
{
var texture = textures.Charset(charset);
if (texture == null) return null;
var size = texture.GetSize() / new Vector2(3, 4) / (tileset?.TileSize ?? 32);
return new TileVisual { Texture = texture, Bounds = new Rect2(new Vector2(0.5f - size.X / 2, 1 - size.Y), size),
Anchor = new Vector2(0.5f, 1), Source = new Rect2(Vector2.Zero, texture.GetSize()) };
}
if (page?.Tile >= 0 && tileset != null)
return new TileVisual { Texture = textures.Tileset(tileset.Image), Source = TileRegion(tileset, page.Tile),
Bounds = new Rect2(0, 0, 1, 1), Anchor = new Vector2(0.5f, 1) };
if (!string.IsNullOrEmpty(page?.Icon))
{
var texture = textures.Icon(page.Icon);
if (texture == null) return null;
var size = texture.GetSize() * (0.75f / Math.Max(texture.GetWidth(), texture.GetHeight()));
return new TileVisual { Texture = texture, Source = new Rect2(Vector2.Zero, texture.GetSize()),
Bounds = new Rect2((Vector2.One - size) / 2, size), Anchor = new Vector2(0.5f, 1) };
}
return null;
}
/// <summary>Editor appearance without running conditions, routes or gameplay events.</summary>
public TileVisual EventLook(RpgMapEvent mapEvent, RpgTileset tileset)
=> ActorLook(new RpgActorState(mapEvent) { Current = mapEvent }, tileset, false);
}
}
+61
View File
@@ -0,0 +1,61 @@
using System;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Mods.TileRender;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Diffuses neighbouring materials in map space at load time (and after editor changes).
/// The content atlas contains only source patches, never edge masks or phase variants.
/// A smooth separable kernel includes diagonal neighbours; world-anchored irregularity
/// and material sampling continue across cell boundaries. Object cells remain transparent.
/// The resulting layer uses the world's ordinary lighting material.
/// </summary>
public static class RpgSurfaceRenderer
{
/// <summary>Builds a transient layer texture; maps without surfaces allocate no image.</summary>
public static Texture2D Build(RpgMapGrid grid, RpgLayer layer, RpgTileset tileset, RpgTextures textures)
{
if (!GameEnvironment.IsRuntime || string.IsNullOrEmpty(tileset.SurfaceImage))
return null;
var found = false;
for (var y = 0; y < grid.Height && !found; y++)
for (var x = 0; x < grid.Width && !found; x++)
found = tileset.TileOf(grid.GetTile(layer, x, y))?.Surface != null;
if (!found || textures.Tileset(tileset.SurfaceImage) is not { } atlas)
return null;
using var source = atlas.GetImage();
source.Convert(Image.Format.Rgba8);
var pixels = Render(grid, layer, tileset, source.GetData(), source.GetWidth());
using var image = Image.CreateFromData(grid.Width * tileset.TileSize, grid.Height * tileset.TileSize,
false, Image.Format.Rgba8, pixels);
return ImageTexture.CreateFromImage(image);
}
/// <summary>
/// Renders RGBA8 pixels from a horizontal strip of square patches. This pure CPU
/// entry point is shared by the editor, the world and tests; it calls no Godot APIs.
/// </summary>
public static byte[] Render(RpgMapGrid grid, RpgLayer layer, RpgTileset tileset, byte[] atlas, int atlasWidth)
=> TileSurfaceRasterizer.Render(Field(grid, layer, tileset), atlas, atlasWidth,
new Rect2I(0, 0, grid.Width, grid.Height));
/// <summary>Material preparation independent of pixel rasterization and map projection.</summary>
public static TileSurfaceField Field(RpgMapGrid grid, RpgLayer layer, RpgTileset tileset)
{
var field = new TileSurfaceField { Width = grid.Width, Height = grid.Height,
TileSize = tileset.TileSize, PatchSize = tileset.SurfaceSize,
Materials = new int[grid.Width * grid.Height], Diffuse = new bool[grid.Width * grid.Height] };
for (var y = 0; y < grid.Height; y++)
for (var x = 0; x < grid.Width; x++)
{
var info = tileset.TileOf(grid.GetTile(layer, x, y));
var i = y * grid.Width + x;
field.Materials[i] = info?.Surface ?? -1;
field.Diffuse[i] = info?.Diffuse ?? false;
}
return field;
}
}
}
+29 -15
View File
@@ -8,10 +8,11 @@ namespace Cthangover.Mods.Rpg
/// Image lookup of the RPG world: tileset atlases in the <c>tilesets</c> file /// Image lookup of the RPG world: tileset atlases in the <c>tilesets</c> file
/// group, charsets in <c>charsets</c>. Both are file groups (last mod wins), /// group, charsets in <c>charsets</c>. Both are file groups (last mod wins),
/// not texture groups, so their names never collide with backgrounds or /// not texture groups, so their names never collide with backgrounds or
/// icons. Decoded textures are kept for the life of one world, which builds /// icons; item pictures for <see cref="RpgMapEvent.Icon"/> come from the
/// <c>items</c> group the inventory uses. Decoded textures are kept for the life of one world, which builds
/// and owns an instance; the editor keeps its own. /// and owns an instance; the editor keeps its own.
/// </summary> /// </summary>
public class RpgTextures public class RpgTextures : System.IDisposable
{ {
/// <summary>File group of tileset images.</summary> /// <summary>File group of tileset images.</summary>
public const string TilesetGroup = "tilesets"; public const string TilesetGroup = "tilesets";
@@ -19,25 +20,38 @@ namespace Cthangover.Mods.Rpg
/// <summary>File group of charset images.</summary> /// <summary>File group of charset images.</summary>
public const string CharsetGroup = "charsets"; public const string CharsetGroup = "charsets";
private readonly Dictionary<string, Texture2D> cache = new(); /// <summary>File group of item pictures (an item's <c>Sprite</c>).</summary>
public const string IconGroup = "items";
private readonly Cthangover.Mods.TileRender.TileAssetCache cache = new();
/// <summary>Tileset atlas image, or null (logged by the loader).</summary> /// <summary>Tileset atlas image, or null (logged by the loader).</summary>
public Texture2D Tileset(string path) => Load(TilesetGroup, path); public Texture2D Tileset(string path) => Load(TilesetGroup, path);
/// <summary>
/// An A2 block for this map cell. Multi-block sheets repeat by world position;
/// regions are cached so every water cell sharing a phase reuses one texture.
/// The editor and the world use this same lookup.
/// </summary>
public Texture2D Autotile(string path, int tileSize, int x, int y)
{
var sheet = Tileset(path);
if (sheet == null)
return null;
var region = RpgAutotiles.VariantRect(new Vector2I(sheet.GetWidth(), sheet.GetHeight()), tileSize, new Vector2I(x, y));
if (region.Position == Vector2.Zero && region.Size == sheet.GetSize())
return sheet;
var key = $"autotile/{path}/{tileSize}/{region.Position.X}/{region.Position.Y}";
return cache.Region(key, sheet, region);
}
/// <summary>Charset image, or null.</summary> /// <summary>Charset image, or null.</summary>
public Texture2D Charset(string path) => Load(CharsetGroup, path); public Texture2D Charset(string path) => Load(CharsetGroup, path);
private Texture2D Load(string group, string path) /// <summary>Item picture, or null.</summary>
{ public Texture2D Icon(string path) => Load(IconGroup, path);
if (string.IsNullOrWhiteSpace(path))
return null; private Texture2D Load(string group, string path) => cache.Resolve(group, path);
var key = group + "/" + path; public void Dispose() => cache.Dispose();
if (!cache.TryGetValue(key, out var texture))
{
texture = TextureUtils.LoadFromModGroup(group, path);
cache[key] = texture;
}
return texture;
}
} }
} }
+38
View File
@@ -0,0 +1,38 @@
namespace Cthangover.Mods.Rpg
{
using System.Collections.Generic;
using Godot;
/// <summary>
/// World quads of a <see cref="RpgTileInfo.Plane"/> tile: a one-cell-high vertical plane through the cell centre,
/// so the isometric view shears the same atlas sprite onto a map axis like the ground, keeping its pixels.
/// "auto" follows neighbouring plane tiles of the same layer like an autotile: left/right neighbours give the
/// x plane, up/down neighbours the y plane, both give a corner of two planes, none a lone x plane.
/// Corners are TL/TR/BR/BL with the image's left edge on the screen's left in both orientations.
/// </summary>
public static class RpgTilePlanes
{
public static List<Vector3[]> Quads(RpgMapGrid grid, RpgTileset tileset, RpgLayer layer, int x, int y, string plane)
{
var alongX = plane == "x";
var alongY = plane == "y";
if (plane == "auto")
{
alongX = IsPlane(grid, tileset, layer, x - 1, y) || IsPlane(grid, tileset, layer, x + 1, y);
alongY = IsPlane(grid, tileset, layer, x, y - 1) || IsPlane(grid, tileset, layer, x, y + 1);
alongX |= !alongY;
}
var result = new List<Vector3[]>();
if (alongX)
result.Add(new[] { new Vector3(x, y + 0.5f, 1), new Vector3(x + 1, y + 0.5f, 1),
new Vector3(x + 1, y + 0.5f, 0), new Vector3(x, y + 0.5f, 0) });
if (alongY)
result.Add(new[] { new Vector3(x + 0.5f, y + 1, 1), new Vector3(x + 0.5f, y, 1),
new Vector3(x + 0.5f, y, 0), new Vector3(x + 0.5f, y + 1, 0) });
return result;
}
private static bool IsPlane(RpgMapGrid grid, RpgTileset tileset, RpgLayer layer, int x, int y)
=> grid.GetTile(layer, x, y) is var id and >= 0 && tileset.TileOf(id)?.Plane != null;
}
}
+5 -27
View File
@@ -1,41 +1,19 @@
using Godot;
namespace Cthangover.Mods.Rpg namespace Cthangover.Mods.Rpg
{ {
using Godot;
/// <summary> /// <summary>
/// Turns an <see cref="RpgTileset"/> and its atlas image into a Godot /// Resolves logical tile IDs to source atlas coordinates for the palette and
/// <see cref="TileSet"/> with a single atlas source (id 0), so maps are drawn /// scene adapter. Rendering and projection belong to the tile renderer mods.
/// by <see cref="TileMapLayer"/> — batched, culled, pixel-exact. Atlas index
/// <c>i</c> is atlas coordinate <c>(i % Columns, i / Columns)</c>; the world
/// and the editor both convert through <see cref="AtlasCoords"/>.
/// </summary> /// </summary>
public static class RpgTileSetBuilder public static class RpgTileSetBuilder
{ {
/// <summary>The only atlas source id of a built tile set.</summary> /// <summary>Legacy single-atlas source id for tools consuming atlas metadata.</summary>
public const int SourceId = 0; public const int SourceId = 0;
/// <summary>Builds the tile set; every whole cell of the image becomes a tile.</summary>
public static TileSet Build(RpgTileset tileset, Texture2D texture)
{
var size = new Vector2I(tileset.TileSize, tileset.TileSize);
var tileSet = new TileSet { TileSize = size };
var source = new TileSetAtlasSource { Texture = texture, TextureRegionSize = size };
var columns = texture.GetWidth() / tileset.TileSize;
var rows = texture.GetHeight() / tileset.TileSize;
for (var y = 0; y < rows; y++)
{
for (var x = 0; x < columns; x++)
source.CreateTile(new Vector2I(x, y));
}
tileSet.AddSource(source, SourceId);
return tileSet;
}
/// <summary>Atlas coordinate of a row-major index.</summary> /// <summary>Atlas coordinate of a row-major index.</summary>
public static Vector2I AtlasCoords(RpgTileset tileset, int index) public static Vector2I AtlasCoords(RpgTileset tileset, int index)
{ {
index = tileset.TileOf(index)?.AtlasIndex ?? index;
var columns = System.Math.Max(1, tileset.Columns); var columns = System.Math.Max(1, tileset.Columns);
return new Vector2I(index % columns, index / columns); return new Vector2I(index % columns, index / columns);
} }
+90 -4
View File
@@ -1,10 +1,10 @@
namespace Cthangover.Mods.Rpg
{
using Cthangover.Core.Events; using Cthangover.Core.Events;
using Cthangover.Core.Scenes; using Cthangover.Core.Scenes;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Godot; using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary> /// <summary>
/// The place of the RPG world in the 2D shell. The rpg mod patches /// The place of the RPG world in the 2D shell. The rpg mod patches
/// <c>base_scene.tscn</c> with this full-screen node right above the view box, /// <c>base_scene.tscn</c> with this full-screen node right above the view box,
@@ -23,6 +23,9 @@ namespace Cthangover.Mods.Rpg
public partial class RpgWorldHost : Control public partial class RpgWorldHost : Control
{ {
private Control backdrop; private Control backdrop;
private Control hud;
private Label mapName;
private Label prompt;
/// <summary>The host in the running shell, or null.</summary> /// <summary>The host in the running shell, or null.</summary>
public static RpgWorldHost Current { get; private set; } public static RpgWorldHost Current { get; private set; }
@@ -30,6 +33,9 @@ namespace Cthangover.Mods.Rpg
/// <summary>The mounted map, or null.</summary> /// <summary>The mounted map, or null.</summary>
public RpgWorldView View { get; private set; } public RpgWorldView View { get; private set; }
/// <summary>Logical world owned by the shell, independently of its current presentation.</summary>
public RpgWorldSession Session { get; private set; }
/// <summary>Subscribes to the scene lifecycle and condition changes.</summary> /// <summary>Subscribes to the scene lifecycle and condition changes.</summary>
public override void _EnterTree() public override void _EnterTree()
{ {
@@ -43,15 +49,50 @@ namespace Cthangover.Mods.Rpg
public override void _Ready() public override void _Ready()
{ {
backdrop = GetNodeOrNull<Control>("Backdrop"); backdrop = GetNodeOrNull<Control>("Backdrop");
hud = GetNodeOrNull<Control>("Hud");
mapName = GetNodeOrNull<Label>("Hud/Location/Name");
prompt = GetNodeOrNull<Label>("Hud/Controls/Prompt");
Visible = false; Visible = false;
} }
/// <summary>Updates the action hint from the same target selection as the world.</summary>
public override void _Process(double delta)
{
if (View == null)
return;
var blocked = View.Presentation == null || RpgInput.IsWalkBlocked(this);
Session.Tick(delta, blocked, RpgInput.HeldDirection(), Input.IsPhysicalKeyPressed(Key.Shift));
View.Present(delta);
if (hud != null)
hud.Visible = !RpgInput.IsWalkBlocked(this);
if (prompt != null)
prompt.Text = Tr(View.Presentation == null ? "rpg/ui/no_renderer" : View.InteractionTarget() != null ? "rpg/ui/interact" : "rpg/ui/walk");
}
/// <summary>Shell input owns gameplay; a future SubViewport receives no duplicate walking/actions.</summary>
public override void _UnhandledInput(InputEvent @event)
{
if (View == null || !RpgInput.IsActionPress(@event))
return;
var dialogBox = RpgInput.FindDialogBox();
if (dialogBox?.IsRunning == true)
{
if (!dialogBox.IsWaitingAnswer)
dialogBox.NextAction();
GetViewport().SetInputAsHandled();
return;
}
if (!RpgInput.IsWalkBlocked(this) && View.Interact())
GetViewport().SetInputAsHandled();
}
/// <summary>Unsubscribes; the view goes with the node.</summary> /// <summary>Unsubscribes; the view goes with the node.</summary>
public override void _ExitTree() public override void _ExitTree()
{ {
ModEventBus.Unsubscribe<SceneEnteredEvent>(OnSceneEntered); ModEventBus.Unsubscribe<SceneEnteredEvent>(OnSceneEntered);
ModEventBus.Unsubscribe<SceneExitedEvent>(OnSceneExited); ModEventBus.Unsubscribe<SceneExitedEvent>(OnSceneExited);
ModEventBus.Unsubscribe<ConditionStateChangedEvent>(OnConditionsChanged); ModEventBus.Unsubscribe<ConditionStateChangedEvent>(OnConditionsChanged);
DetachSession();
if (Current == this) if (Current == this)
Current = null; Current = null;
} }
@@ -97,7 +138,15 @@ namespace Cthangover.Mods.Rpg
View = new RpgWorldView(); View = new RpgWorldView();
AddChild(View); AddChild(View);
View.Build(map); MoveChild(View, 1);
Session = new RpgWorldSession(map, RpgTilesetFactory.Instance.Get(map.Tileset),
RpgMapEvents.Of(map.ID), ScenarioCondition.Evaluate);
Session.PlayerStepFinished += OnPlayerStepFinished;
Session.TouchTriggered += OnTouchTriggered;
Session.EncounterTriggered += OnEncounterTriggered;
View.Build(Session);
if (mapName != null)
mapName.Text = Tr(map.Name ?? map.ID);
var state = RpgState.Instance.Data; var state = RpgState.Instance.Data;
if (state.Map == map.ID && View.Contains(state.X, state.Y)) if (state.Map == map.ID && View.Contains(state.X, state.Y))
@@ -116,6 +165,7 @@ namespace Cthangover.Mods.Rpg
{ {
if (View != null) if (View != null)
{ {
DetachSession();
RemoveChild(View); RemoveChild(View);
View.QueueFree(); View.QueueFree();
View = null; View = null;
@@ -123,10 +173,46 @@ namespace Cthangover.Mods.Rpg
Visible = false; Visible = false;
} }
/// <summary>Arrival placement updates the save independently of any presentation.</summary>
public void PlacePlayer(int x, int y, RpgDirection facing)
{
Session.Player.Place(new Vector2I(x, y), facing);
RpgState.Instance.Place(Session.Map.ID, x, y, facing);
View.Present(0);
}
/// <summary>Executes exactly the action selected by the logical session.</summary>
public bool Interact()
{
var target = Session.PrepareInteraction();
View.Present(0);
return target != null && RpgEventRunner.Run(target, Session.Map.ID);
}
private void OnSceneEntered(SceneEnteredEvent evt) => Mount(evt.SceneName); private void OnSceneEntered(SceneEnteredEvent evt) => Mount(evt.SceneName);
private void OnSceneExited(SceneExitedEvent evt) => Unmount(); private void OnSceneExited(SceneExitedEvent evt) => Unmount();
private void OnConditionsChanged(ConditionStateChangedEvent evt) => View?.RefreshEvents(); private void OnConditionsChanged(ConditionStateChangedEvent evt)
{
View?.RefreshEvents();
}
private void OnPlayerStepFinished(RpgActorState player)
=> RpgState.Instance.Place(Session.Map.ID, player.Cell.X, player.Cell.Y, player.Facing);
private void OnTouchTriggered(RpgMapEvent mapEvent) => RpgEventRunner.Run(mapEvent, Session.Map.ID);
private void OnEncounterTriggered(string enemies) => RpgBattleService.Start(enemies, null);
private void DetachSession()
{
if (Session == null)
return;
Session.PlayerStepFinished -= OnPlayerStepFinished;
Session.TouchTriggered -= OnTouchTriggered;
Session.EncounterTriggered -= OnEncounterTriggered;
Session = null;
}
} }
} }
+197
View File
@@ -0,0 +1,197 @@
namespace Cthangover.Mods.Rpg
{
using System;
using System.Collections.Generic;
using Cthangover.Core.Utils;
using Godot;
/// <summary>
/// One map's simulation, with no scene-tree or presentation dependency. The host
/// supplies input and time and handles emitted gameplay effects. All renderers
/// observe these same reserved cells, event pages and interpolated actor positions.
/// </summary>
public class RpgWorldSession
{
private readonly Dictionary<string, RpgActorState> events = new();
private readonly Func<string, bool> evaluate;
private readonly Random random;
/// <summary>Original map definition, including the encounter table.</summary>
public RpgMap Map { get; }
/// <summary>Parsed terrain shared by passability and presentation preparation.</summary>
public RpgMapGrid Grid { get; }
/// <summary>Tile rules; null retains the existing walkable-terrain fallback.</summary>
public RpgTileset Tileset { get; }
/// <summary>The player remains alive when a presentation is replaced.</summary>
public RpgActorState Player { get; } = new();
/// <summary>Event states in resolved file order, including disabled events.</summary>
public IEnumerable<RpgActorState> Events => events.Values;
/// <summary>Raised before a touch event or encounter, so the host can save arrival.</summary>
public event Action<RpgActorState> PlayerStepFinished;
/// <summary>The host executes a touch event in the existing scenario/dialog system.</summary>
public event Action<RpgMapEvent> TouchTriggered;
/// <summary>The host starts a battle on the existing turn-based platform.</summary>
public event Action<string> EncounterTriggered;
/// <summary>
/// Builds a logical world from already resolved data. Supplying a Random makes
/// route and encounter decisions reproducible without creating a viewport.
/// </summary>
public RpgWorldSession(RpgMap map, RpgTileset tileset, IEnumerable<RpgMapEvent> definitions,
Func<string, bool> evaluate, Random random = null)
{
Map = map ?? throw new ArgumentNullException(nameof(map));
Tileset = tileset;
Grid = RpgMapGrid.Parse(map);
this.evaluate = evaluate ?? throw new ArgumentNullException(nameof(evaluate));
this.random = random ?? new Random();
foreach (var definition in definitions ?? Array.Empty<RpgMapEvent>())
{
if (string.IsNullOrEmpty(definition.Id) || events.ContainsKey(definition.Id))
{
GameLogger.Log("RPG", $"map '{map.ID}': event without a unique id at {definition.X},{definition.Y} skipped", LogLevel.Error);
continue;
}
events[definition.Id] = new RpgActorState(definition);
}
RefreshEvents();
}
/// <summary>Re-resolves pages while retaining moved cells and restarting changed routes.</summary>
public void RefreshEvents()
{
foreach (var actor in events.Values)
{
var index = RpgEventPages.ActiveIndex(actor.Event, evaluate);
actor.Visible = index >= 0;
if (index < 0 || index == actor.PageIndex)
continue;
var first = actor.PageIndex == -2;
actor.PageIndex = index;
actor.Current = RpgEventPages.Effective(actor.Event, index);
actor.RouteIndex = 0;
actor.RouteTimer = actor.Current.Route?.Every ?? 0;
if (first)
actor.Place(new Vector2I(actor.Event.X, actor.Event.Y), actor.Current.Dir);
else if (index > 0 && actor.Event.Pages[index - 1].Dir.HasValue)
actor.Face(actor.Current.Dir);
}
}
/// <summary>Uses terrain overrides and the destinations reserved by visible solid events.</summary>
public bool CanEnter(Vector2I cell)
{
if (!RpgPassability.IsWalkable(Grid, Tileset, cell.X, cell.Y))
return false;
foreach (var actor in events.Values)
if (actor.Visible && actor.Current?.IsSolid == true && actor.Cell == cell)
return false;
return true;
}
/// <summary>Looks up an event independently of the presence of a sprite.</summary>
public RpgActorState FindEvent(string id) => id != null && events.TryGetValue(id, out var actor) ? actor : null;
/// <summary>Returns the first enabled event of the requested trigger at a logical cell.</summary>
public RpgActorState EventAt(Vector2I cell, RpgEventTrigger trigger)
{
foreach (var actor in events.Values)
if (actor.Visible && actor.Cell == cell && actor.Current?.Trigger == trigger)
return actor;
return null;
}
/// <summary>The same logical target is used for action input and the visual cue.</summary>
public RpgActorState InteractionTarget()
{
if (Player.IsMoving)
return null;
return EventAt(Player.Cell + RpgDirections.Delta(Player.Facing), RpgEventTrigger.Action)
?? EventAt(Player.Cell, RpgEventTrigger.Action);
}
/// <summary>Turns the target toward the player and returns its effective action for the host.</summary>
public RpgMapEvent PrepareInteraction()
{
var target = InteractionTarget();
if (target == null)
return null;
if (target.Cell != Player.Cell)
target.Face(RpgDirections.Opposite(Player.Facing));
return target.Current;
}
/// <summary>
/// Starts at most one route/player step per frame, then advances all existing steps.
/// UI blockers prevent new commands, while already started steps still arrive as before.
/// Touch events take precedence over random encounters at arrival.
/// </summary>
public void Tick(double delta, bool blocked, RpgDirection? heldDirection = null, bool dash = false)
{
if (delta < 0 || !double.IsFinite(delta))
throw new ArgumentOutOfRangeException(nameof(delta));
if (!blocked)
WalkRoutes(delta);
if (!blocked && !Player.IsMoving && heldDirection.HasValue)
{
var next = Player.Cell + RpgDirections.Delta(heldDirection.Value);
if (CanEnter(next))
{
Player.Speed = dash ? 7f : RpgActorState.TilesPerSecond;
Player.StepTo(heldDirection.Value);
}
else
Player.Face(heldDirection.Value);
}
foreach (var actor in events.Values)
actor.Advance(delta);
if (!Player.Advance(delta))
return;
PlayerStepFinished?.Invoke(Player);
var touch = EventAt(Player.Cell, RpgEventTrigger.Touch);
if (touch != null)
{
TouchTriggered?.Invoke(touch.Current);
return;
}
var enemies = RpgEncounterRoller.Roll(Map.Encounters, random);
if (enemies != null)
EncounterTriggered?.Invoke(enemies);
}
private void WalkRoutes(double delta)
{
foreach (var actor in events.Values)
{
var route = actor.Current?.Route;
if (!actor.Visible || route?.Steps == null || route.Steps.Count == 0 || actor.IsMoving)
continue;
actor.RouteTimer -= delta;
if (actor.RouteTimer > 0)
continue;
actor.RouteTimer = Math.Max(0.1, route.Every);
if (actor.RouteIndex >= route.Steps.Count)
{
if (!route.Repeat)
continue;
actor.RouteIndex = 0;
}
var step = RpgRouteStep.Resolve(route.Steps[actor.RouteIndex++], actor.Cell, Player.Cell, random);
if (!step.Direction.HasValue)
continue;
var next = actor.Cell + RpgDirections.Delta(step.Direction.Value);
if (step.Moves && CanEnter(next) && next != Player.Cell)
actor.StepTo(step.Direction.Value);
else
actor.Face(step.Direction.Value);
}
}
}
}
+129 -224
View File
@@ -1,254 +1,159 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Mods.Rpg namespace Cthangover.Mods.Rpg
{ {
using System;
using System.Collections.Generic;
using Cthangover.Core.Games;
using Cthangover.Core.Events;
using Cthangover.Core.Utils;
using Cthangover.Mods.TileRender;
using Godot;
/// <summary> /// <summary>
/// One mounted map: the drawn layers, the actors and the player, plus the /// Compatibility facade and RPG-to-renderer adapter. The host owns simulation;
/// rules of walking around. Built by <see cref="RpgWorldHost"/> when a map /// implementation mods own all visible geometry. A renderer can be replaced while
/// scene is entered and freed when the scene is left. /// a step is in progress without rebuilding this session or rerolling encounters.
/// <para/>
/// Draw order (children, bottom to top): back tiles, main tiles, the actor
/// layer (Y-sorted: events and the player), front tiles. The whole node is
/// scaled by the map's zoom with nearest filtering and follows the player
/// like a camera, clamped to the map edges; a map smaller than the screen is
/// centred.
/// <para/>
/// Each frame without a blocker (<see cref="RpgInput.IsWalkBlocked"/>) a held
/// direction turns the player and, if the cell is free, starts a step. When a
/// step ends the position is stored in <see cref="RpgState"/>, then a touch
/// event on the cell fires, else the encounter table rolls. The action key
/// fires the action event in front of the player (or under them); during a
/// dialog it advances the dialog instead.
/// </summary> /// </summary>
public partial class RpgWorldView : Node2D public partial class RpgWorldView : Node2D
{ {
private readonly Dictionary<string, RpgActor> eventActors = new(); private readonly Dictionary<string, RpgActorState> eventActors = new();
private readonly Dictionary<string, RpgMapEvent> shownPages = new();
private readonly Dictionary<string, TileActorPose> poses = new();
private readonly RpgTextures textures = new(); private readonly RpgTextures textures = new();
private readonly Random random = new(); private readonly TileFrame frame = new();
private RpgMapGrid grid; private TileRenderScene scene;
private Node2D actorLayer; private RpgRenderSceneBuilder builder;
private Texture2D atlas; private RpgLighting lighting;
public RpgWorldSession Session { get; private set; }
/// <summary>The mounted map.</summary> public RpgMap Map => Session.Map;
public RpgMap Map { get; private set; } public RpgTileset Tileset => Session.Tileset;
public RpgActorState Player => Session.Player;
/// <summary>The map's tileset (null when it could not be resolved: the map is drawn empty).</summary>
public RpgTileset Tileset { get; private set; }
/// <summary>The player's actor.</summary>
public RpgActor Player { get; private set; }
/// <summary>Pixel edge of a tile before zoom.</summary>
public int TileSize => Tileset?.TileSize ?? 32; public int TileSize => Tileset?.TileSize ?? 32;
public ITileWorldPresentation Presentation { get; private set; }
public string RendererId { get; private set; }
/// <summary>Builds the layers and actors of <paramref name="map"/>; the player is placed by <see cref="PlacePlayer"/>.</summary> public override void _EnterTree()
public void Build(RpgMap map)
{ {
Map = map; TileRendererRegistry.Instance.Removing += OnRendererRemoving;
ModEventBus.Subscribe<ModSetReloadedEvent>(OnModsReloaded);
}
public override void _ExitTree()
{
TileRendererRegistry.Instance.Removing -= OnRendererRemoving;
ModEventBus.Unsubscribe<ModSetReloadedEvent>(OnModsReloaded);
Presentation?.Dispose();
Presentation = null;
scene?.Release();
scene = null;
builder?.Dispose();
textures.Dispose();
}
public void Build(RpgWorldSession session)
{
Session = session;
Name = "World"; Name = "World";
TextureFilter = TextureFilterEnum.Nearest; builder = new RpgRenderSceneBuilder(textures);
Scale = Vector2.One * Math.Max(1, map.Zoom); scene = builder.Build(Map, session.Grid, Tileset);
grid = RpgMapGrid.Parse(map); lighting = new RpgLighting(Map, session.Grid, Tileset);
foreach (var state in session.Events) eventActors[state.Event.Id] = state;
Tileset = RpgTilesetFactory.Instance.Get(map.Tileset); UpdateFrame();
if (Tileset == null) var id = ActiveGame.GetSetting("tile.renderer");
GameLogger.Log("RPG", $"map '{map.ID}': tileset '{map.Tileset}' not found", LogLevel.Error); foreach (var arg in OS.GetCmdlineUserArgs())
atlas = Tileset != null ? textures.Tileset(Tileset.Image) : null; if (arg.StartsWith("--tile-renderer=")) id = arg.Substring(16);
var tileSet = Tileset != null && atlas != null ? RpgTileSetBuilder.Build(Tileset, atlas) : null; SetRenderer(id);
AddChild(BuildLayer(RpgLayer.Back, tileSet));
AddChild(BuildLayer(RpgLayer.Main, tileSet));
actorLayer = new Node2D { Name = "Actors", YSortEnabled = true };
AddChild(actorLayer);
AddChild(BuildLayer(RpgLayer.Front, tileSet));
foreach (var mapEvent in map.Events ?? new List<RpgMapEvent>())
AddEventActor(mapEvent);
RefreshEvents();
Player = new RpgActor(TileSize) { Name = "Player" };
Player.UseCharset(textures.Charset(RpgPlayerLook.Charset()));
Player.StepFinished += OnPlayerStepFinished;
actorLayer.AddChild(Player);
} }
/// <summary>Puts the player on a cell and stores the position.</summary> /// <summary>Transactional replacement: a failed candidate leaves the current presentation alive.</summary>
public void PlacePlayer(int x, int y, RpgDirection facing) public bool SetRenderer(string id)
{ {
Player.Place(new Vector2I(x, y), facing); ITileWorldPresentation candidate = null;
RpgState.Instance.Place(Map.ID, x, y, facing); try
UpdateCamera();
}
/// <summary>
/// Re-evaluates every event's condition: hidden events stop drawing,
/// blocking and reacting. Called on build and whenever game state that a
/// condition may read changes.
/// </summary>
public void RefreshEvents()
{ {
foreach (var actor in eventActors.Values) var factory = TileRendererRegistry.Instance.Resolve(id);
actor.Visible = string.IsNullOrWhiteSpace(actor.Event.Condition) || ScenarioCondition.Evaluate(actor.Event.Condition); candidate = factory.Create();
} AddChild(candidate.Root);
candidate.Root.Visible = false;
/// <summary>True when the cell lies on the map (a saved position may be stale after a map edit).</summary> candidate.Mount(scene);
public bool Contains(int x, int y) => grid.InBounds(x, y); candidate.Resize(GetParent() is Control host ? host.Size : GetViewportRect().Size);
candidate.Present(frame, 0);
/// <summary>True when the player could step into the cell now.</summary> Presentation?.Dispose();
public bool CanEnter(Vector2I cell) Presentation = candidate;
{ RendererId = factory.Id;
if (!RpgPassability.IsWalkable(grid, Tileset, cell.X, cell.Y)) Presentation.Root.Visible = true;
return false;
foreach (var actor in eventActors.Values)
{
if (actor.Visible && actor.Event.IsSolid && actor.Cell == cell)
return false;
}
return true; return true;
} }
catch (Exception ex)
/// <summary>The actor of the event with this id (visible or not), or null.</summary>
public RpgActor FindEvent(string id) => id != null && eventActors.TryGetValue(id, out var actor) ? actor : null;
/// <summary>The visible event on a cell with the given trigger, or null.</summary>
public RpgActor EventAt(Vector2I cell, RpgEventTrigger trigger)
{ {
candidate?.Dispose();
GameLogger.Log("RPG", ex.Message, LogLevel.Error);
return false;
}
}
private void OnRendererRemoving(string id)
{
if (RendererId != id) return;
Presentation?.Dispose();
Presentation = null;
}
private void OnModsReloaded(ModSetReloadedEvent evt)
{
if (evt.Applied && Presentation == null && scene != null) SetRenderer(RendererId);
}
public void PlacePlayer(int x, int y, RpgDirection facing)
{
if (GetParent() is RpgWorldHost host) host.PlacePlayer(x, y, facing);
else { Session.Player.Place(new Vector2I(x, y), facing); Present(0); }
}
public void RefreshEvents() { Session.RefreshEvents(); Present(0); }
public bool Contains(int x, int y) => Session.Grid.InBounds(x, y);
public bool CanEnter(Vector2I cell) => Session.CanEnter(cell);
public RpgActorState FindEvent(string id) => id != null && eventActors.TryGetValue(id, out var actor) ? actor : null;
public RpgActorState EventAt(Vector2I cell, RpgEventTrigger trigger) => FindEvent(Session.EventAt(cell, trigger)?.Event.Id);
public RpgActorState InteractionTarget() => FindEvent(Session.InteractionTarget()?.Event.Id);
public bool Interact() => GetParent() is RpgWorldHost host && host.Interact();
public void Present(double delta)
{
UpdateFrame();
if (Presentation == null) return;
Presentation.Resize(GetParent() is Control host ? host.Size : GetViewportRect().Size);
Presentation.Present(frame, delta);
}
private void UpdateFrame()
{
frame.Focus = Session.Player.Position + Vector2.One / 2;
frame.InteractionCell = RpgInput.IsWalkBlocked(GetParent()) ? null : Session.InteractionTarget()?.Cell;
FillActor("player", Session.Player, true);
foreach (var actor in eventActors.Values) foreach (var actor in eventActors.Values)
{ {
if (actor.Visible && actor.Cell == cell && actor.Event.Trigger == trigger) FillActor("event/" + actor.Event.Id, actor, false);
return actor;
} }
return null; lighting.Fill(frame, Session);
} }
/// <summary> private void FillActor(string id, RpgActorState state, bool player)
/// Fires the action event the player faces, or the one under them
/// (a sign on the floor). An NPC turns to the player first.
/// </summary>
public bool Interact()
{ {
if (Player == null || Player.IsMoving) if (!poses.TryGetValue(id, out var pose))
return false;
var target = EventAt(Player.Cell + RpgDirections.Delta(Player.Facing), RpgEventTrigger.Action)
?? EventAt(Player.Cell, RpgEventTrigger.Action);
if (target == null)
return false;
if (target.Cell != Player.Cell)
target.Face(RpgDirections.Opposite(Player.Facing));
return RpgEventRunner.Run(target.Event, Map.ID);
}
/// <summary>Walking: one decision per frame while the player stands still.</summary>
public override void _Process(double delta)
{ {
if (Player != null && !Player.IsMoving && !RpgInput.IsWalkBlocked(this)) pose = new TileActorPose { Id = id };
poses[id] = pose;
frame.Actors.Add(pose);
}
if (pose.Look == null || !shownPages.TryGetValue(id, out var page) || page != state.Current)
{ {
var held = RpgInput.HeldDirection(); pose.Look = builder.ActorLook(state, Tileset, player);
if (held.HasValue) shownPages[id] = state.Current;
{
var next = Player.Cell + RpgDirections.Delta(held.Value);
if (CanEnter(next))
Player.StepTo(held.Value);
else
Player.Face(held.Value);
} }
} pose.Position = state.Position;
UpdateCamera(); pose.Visible = state.Visible;
} pose.Charset = player || !string.IsNullOrEmpty(state.Current?.Sprite);
pose.Facing = (int)state.Facing;
/// <summary>The action key: advances a running dialog, otherwise interacts.</summary> var cycle = (int)state.AnimationTime % 4;
public override void _UnhandledInput(InputEvent @event) pose.Column = !state.IsMoving ? 1 : cycle == 3 ? 1 : cycle;
{
if (!RpgInput.IsActionPress(@event))
return;
var dialogBox = RpgInput.FindDialogBox();
if (dialogBox?.IsRunning == true)
{
if (!dialogBox.IsWaitingAnswer)
dialogBox.NextAction();
GetViewport().SetInputAsHandled();
return;
}
if (!RpgInput.IsWalkBlocked(this) && Interact())
GetViewport().SetInputAsHandled();
}
private void OnPlayerStepFinished(RpgActor player)
{
RpgState.Instance.Place(Map.ID, player.Cell.X, player.Cell.Y, player.Facing);
var touch = EventAt(player.Cell, RpgEventTrigger.Touch);
if (touch != null)
{
RpgEventRunner.Run(touch.Event, Map.ID);
return;
}
var enemies = RpgEncounterRoller.Roll(Map.Encounters, random);
if (enemies != null)
RpgBattleService.Start(enemies, null);
}
private TileMapLayer BuildLayer(RpgLayer layer, TileSet tileSet)
{
var node = new TileMapLayer { Name = layer.ToString(), TileSet = tileSet };
if (tileSet == null)
return node;
for (var y = 0; y < grid.Height; y++)
{
for (var x = 0; x < grid.Width; x++)
{
var index = grid.GetTile(layer, x, y);
if (index >= 0)
node.SetCell(new Vector2I(x, y), RpgTileSetBuilder.SourceId, RpgTileSetBuilder.AtlasCoords(Tileset, index));
}
}
return node;
}
private void AddEventActor(RpgMapEvent mapEvent)
{
if (string.IsNullOrEmpty(mapEvent.Id) || eventActors.ContainsKey(mapEvent.Id))
{
GameLogger.Log("RPG", $"map '{Map.ID}': event without a unique id at {mapEvent.X},{mapEvent.Y} skipped", LogLevel.Error);
return;
}
var actor = new RpgActor(TileSize, mapEvent) { Name = "Event_" + mapEvent.Id };
if (!string.IsNullOrEmpty(mapEvent.Sprite))
actor.UseCharset(textures.Charset(mapEvent.Sprite));
else if (mapEvent.Tile >= 0 && Tileset != null)
actor.UseTile(atlas, RpgTileSetBuilder.AtlasCoords(Tileset, mapEvent.Tile));
actor.Place(new Vector2I(mapEvent.X, mapEvent.Y), mapEvent.Dir);
actorLayer.AddChild(actor);
eventActors[mapEvent.Id] = actor;
}
private void UpdateCamera()
{
if (Player == null || GetParent() is not Control host)
return;
var zoom = Scale.X;
var mapSize = new Vector2(grid.Width, grid.Height) * TileSize * zoom;
var screen = host.Size;
var focus = (Player.Position + new Vector2(TileSize, TileSize) / 2f) * zoom;
Position = new Vector2(Axis(screen.X, mapSize.X, focus.X), Axis(screen.Y, mapSize.Y, focus.Y));
}
private static float Axis(float screen, float map, float focus)
{
if (map <= screen)
return Mathf.Round((screen - map) / 2f);
return Mathf.Round(Mathf.Clamp(screen / 2f - focus, screen - map, 0f));
} }
} }
} }
+10
View File
@@ -0,0 +1,10 @@
{
"name": "Tile renderer contracts and visual preview",
"sources": [
"src/*.cs"
],
"depends": [
"rpg_demo_game>=1",
"rpg_editor>=1"
]
}
@@ -0,0 +1,103 @@
namespace Cthangover.Mods.Rpg.Tests
{
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Utils;
using Cthangover.Mods.TileRender;
using Godot;
/// <summary>Opt-in measurements with the same map, materials, camera rectangle and edits for both projections.</summary>
public static class TileRenderBenchmark
{
public static async Task Run(Node owner, string output)
{
var results = new List<Dictionary<string, object>>();
using var textures = new RpgTextures();
using var builder = new RpgRenderSceneBuilder(textures);
var tileset = RpgTilesetFactory.Instance.Get("village");
var viewport = owner.GetViewport().GetViewportRid();
using var process = Process.GetCurrentProcess();
RenderingServer.ViewportSetMeasureRenderTime(viewport, true);
foreach (var size in new[] { 128, 256 })
{
var grid = new RpgMapGrid(size, size);
for (var y = 0; y < size; y++) for (var x = 0; x < size; x++) grid.SetTile(RpgLayer.Back, x, y, 0);
var trees = 0;
for (var y = 8; y < size - 3; y += 8)
for (var x = 8; x < size - 3; x += 8)
{
for (var ty = 0; ty < 3; ty++) for (var tx = 0; tx < 3; tx++)
grid.SetTile(RpgLayer.Main, x + tx, y + ty, 64 + ty * 3 + tx);
trees++;
}
var map = new RpgMap { ID = "benchmark", Width = size, Height = size, Tileset = "village", Zoom = 1 };
var clock = Stopwatch.StartNew();
var scene = builder.Build(map, grid, tileset);
var row = new Dictionary<string, object> { ["cells_per_axis"] = size, ["cold_ms"] = clock.Elapsed.TotalMilliseconds,
["surface_texture_bytes"] = builder.SurfaceTextureBytes, ["cold_chunks"] = builder.LastBuiltSurfaceChunks,
["trees"] = trees, ["lights"] = 16 };
foreach (var id in new[] { "topdown", "isometric" })
{
var view = TileRendererRegistry.Instance.Resolve(id).Create();
owner.AddChild(view.Root);
try
{
clock.Restart();
view.Mount(scene);
view.Resize(new Vector2(1920, 1024));
var frame = new TileFrame { Focus = new Vector2(size, size) / 2, Ambient = Colors.White };
for (var i = 0; i < 16; i++) frame.Lights.Add(new TileLight {
Position = frame.Focus + new Vector2(i % 4 * 3 - 6, i / 4 * 3 - 6), Color = Colors.Orange, Radius = 4 });
view.Present(frame, 0);
row[id + "_mount_ms"] = clock.Elapsed.TotalMilliseconds;
for (var i = 0; i < 8; i++) await owner.ToSignal(owner.GetTree(), SceneTree.SignalName.ProcessFrame);
double presentMs = 0, cpuMs = 0, gpuMs = 0;
var wall = Stopwatch.StartNew();
for (var i = 0; i < 16; i++)
{
clock.Restart();
view.Present(frame, 1.0 / 60);
presentMs += clock.Elapsed.TotalMilliseconds;
await owner.ToSignal(owner.GetTree(), SceneTree.SignalName.ProcessFrame);
cpuMs += RenderingServer.ViewportGetMeasuredRenderTimeCpu(viewport);
gpuMs += RenderingServer.ViewportGetMeasuredRenderTimeGpu(viewport);
}
row[id + "_present_cpu_ms"] = presentMs / 16;
row[id + "_frame_wall_ms"] = wall.Elapsed.TotalMilliseconds / 16;
row[id + "_draw_calls"] = Performance.GetMonitor(Performance.Monitor.RenderTotalDrawCallsInFrame);
row[id + "_total_video_bytes"] = Performance.GetMonitor(Performance.Monitor.RenderVideoMemUsed);
row[id + "_render_cpu_ms"] = cpuMs / 16;
row[id + "_render_gpu_ms"] = gpuMs / 16;
process.Refresh();
row[id + "_process_working_set_bytes"] = process.WorkingSet64;
}
finally { view.Dispose(); }
}
scene.Release();
clock.Restart();
scene = builder.Build(map, grid, tileset);
row["warm_ms"] = clock.Elapsed.TotalMilliseconds;
ModAssert.Equal(0, builder.LastBuiltSurfaceChunks, "unchanged map reuses every surface texture");
scene.Release();
grid.SetTile(RpgLayer.Back, 31, size / 2, 4);
clock.Restart();
scene = builder.Build(map, grid, tileset);
row["edit_ms"] = clock.Elapsed.TotalMilliseconds;
row["edit_chunks"] = builder.LastBuiltSurfaceChunks;
ModAssert.Equal(4, builder.LastBuiltSurfaceChunks, "a corner edit rebuilds only four halo chunks");
scene.Release();
results.Add(row);
GameLogger.Log("TEST", "TILE_BENCHMARK " + JsonSerializer.Serialize(row));
}
RenderingServer.ViewportSetMeasureRenderTime(viewport, false);
if (!string.IsNullOrEmpty(output))
{
Directory.CreateDirectory(output);
File.WriteAllText(Path.Combine(output, "benchmark.json"), JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }));
}
}
}
}
@@ -0,0 +1,55 @@
namespace Cthangover.Mods.Rpg.Tests
{
using System;
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.TileRender;
using Godot;
/// <summary>Exercises version checks, sparse redraws and removal through the actual presentation API.</summary>
public static class TileRenderDeltaContract
{
public static int Run(Node owner)
{
var passed = 0;
foreach (var id in new[] { "topdown", "isometric" })
{
var view = TileRendererRegistry.Instance.Resolve(id).Create();
owner.AddChild(view.Root);
try
{
var first = Snapshot(1, Colors.White);
view.Mount(first);
var unchanged = Snapshot(2, Colors.White);
view.Apply(new TileSceneChanges { BaseRevision = 1, Scene = unchanged });
ModAssert.Equal(0, ((TileCanvasPresentation)view).LastChangedDrawChunks, "unchanged draw chunks retained " + id);
var changed = Snapshot(3, Colors.Red);
view.Apply(new TileSceneChanges { BaseRevision = 2, Scene = changed });
ModAssert.Equal(1, ((TileCanvasPresentation)view).LastChangedDrawChunks, "one edited draw chunk " + id);
var rejected = false;
try { view.Apply(new TileSceneChanges { BaseRevision = 1, Scene = Snapshot(4, Colors.White) }); }
catch (InvalidOperationException) { rejected = true; }
ModAssert.True(rejected, "stale revision rejected " + id);
var frame = new TileFrame();
frame.Actors.Add(new TileActorPose { Id = "removed", Visible = true });
view.Present(frame, 0);
ModAssert.Equal(1, view.Root.GetNode<Node2D>("Actors").GetChildCount(), "actor mounted " + id);
frame.Actors.Clear();
view.Present(frame, 0);
ModAssert.Equal(0, view.Root.GetNode<Node2D>("Actors").GetChildCount(), "removed actor released " + id);
passed += 5;
}
finally { view.Dispose(); }
}
return passed;
}
private static TileRenderScene Snapshot(long revision, Color firstTint)
{
var scene = new TileRenderScene { Size = new Vector2I(64, 64), Revision = revision, Zoom = 1 };
scene.Visuals.Add(new TileVisual { Bounds = new Rect2(0, 0, 1, 1), Tint = firstTint });
scene.Visuals.Add(new TileVisual { Bounds = new Rect2(33, 0, 1, 1) });
scene.Visuals.Add(new TileVisual { Bounds = new Rect2(0, 33, 1, 1) });
return scene;
}
}
}
@@ -0,0 +1,196 @@
namespace Cthangover.Mods.Rpg.Tests
{
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Cthangover.Core.Mods;
using Cthangover.Core.Games;
using Cthangover.Core.Mods.HotReload;
using Cthangover.Core.Scenes;
using Cthangover.Tools.RpgEditor;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.View;
using Cthangover.Mods.TileRender;
using Godot;
/// <summary>Compares both presentations of identical prepared resources and tests projection/picking invariants.</summary>
public partial class TileRenderTestDriver : Node
{
private readonly string[] maps = { "rpg_village", "rpg_elder_house", "rpg_forest" };
private readonly List<ITileWorldPresentation> views = new();
private readonly RpgTextures textures = new();
private readonly TileFrame frame = new();
private CanvasLayer overlay;
private RpgRenderSceneBuilder builder;
private TileRenderScene scene;
private int ticks;
private int step;
private int passed;
private double elapsed;
private string shots;
private Task contracts;
public override void _Ready()
{
foreach (var arg in OS.GetCmdlineUserArgs()) if (arg.StartsWith("--shots=")) shots = arg.Substring(8);
overlay = new CanvasLayer { Layer = 90 };
AddChild(overlay);
overlay.AddChild(new ColorRect { Size = new Vector2(1920, 1024), Color = new Color(0.04f, 0.05f, 0.06f) });
}
public override void _Process(double delta)
{
elapsed += delta;
if (elapsed > 300) { Finish("timeout"); return; }
if (++ticks < 30) return;
try
{
if (contracts != null)
{
if (contracts.IsCompleted) { contracts.GetAwaiter().GetResult(); Finish(null); }
return;
}
if (views.Count == 0)
Build(maps[step]);
foreach (var view in views) view.Present(frame, 0);
if (ticks < 45) return;
if (shots != null && DisplayServer.GetName() != "headless")
{
DirAccess.MakeDirRecursiveAbsolute(shots);
ViewportCapture.SavePng(GetViewport(), shots.TrimEnd('/') + "/compare_" + maps[step] + ".png");
}
Release();
ticks = 0;
if (++step == maps.Length) contracts = RunContracts();
}
catch (Exception ex) { Finish(ex.ToString()); }
}
private async Task RunContracts()
{
passed += TileRenderDeltaContract.Run(this);
TestEditor();
GetTree().Root.GetNode<SceneManager>("SceneManager").SwitchScene("rpg_village");
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
var host = RpgWorldHost.Current;
ModAssert.NotNull(host, "shell world host");
host.Mount("rpg_village");
host.PlacePlayer(15, 14, RpgDirection.Up);
var session = host.Session;
session.Tick(0.05, false, RpgDirection.Up);
host.View.Present(0);
var pose = session.Player.Position;
var cell = session.Player.Cell;
ModAssert.True(session.Player.IsMoving, "switch test starts during a step");
ModAssert.True(host.View.SetRenderer("isometric"), "iso view can replace square view");
ModAssert.True(session.Player.Position.IsEqualApprox(pose) && session.Player.Cell == cell, "switch preserves step progress and reservation");
ModAssert.True(host.View.SetRenderer("topdown"), "switch back");
ModAssert.True(ReferenceEquals(session, host.Session) && session.Player.Position.IsEqualApprox(pose), "one session survives both projections");
ModAssert.True(host.View.SetRenderer("isometric"), "reload test has a live iso presentation");
var oldFactory = new WeakReference(TileRendererRegistry.Instance.Resolve("isometric"));
var oldProjection = ((TileCanvasPresentation)host.View.Presentation).ProjectionLifetime();
passed += 6;
session.Tick(1, true);
var savedCell = session.Player.Cell;
var savedFacing = session.Player.Facing;
var verified = await ModHotReloadService.ReloadNowAsync(new[] { "tile_isometric_base" });
ModAssert.True(verified?.Applied == true, "forced implementation reload applies through the normal session-parking flow");
GameLogger.Log("TEST", $"renderer lifetime: old factory={oldFactory.IsAlive}, old projection={oldProjection.IsAlive}");
ModAssert.Equal(0, verified.Leaked.Count, "renderer load context is collectible after disposal");
// LoadAndEnter schedules the shell; the public reload task ends
// before the deferred scene entry mounts its world.
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
ModAssert.NotNull(RpgWorldHost.Current?.View?.Presentation, "kept host restores its presentation after reload");
ModAssert.True(savedCell == RpgWorldHost.Current.Session.Player.Cell, "reload restores saved logical cell");
ModAssert.True(savedFacing == RpgWorldHost.Current.Session.Player.Facing, "reload restores saved world facing");
passed += 5;
foreach (var arg in OS.GetCmdlineUserArgs())
if (arg == "--tile-benchmark") await TileRenderBenchmark.Run(this, shots);
}
private void TestEditor()
{
var document = RpgMapDocument.Create("render_contract", "village", 8, 8, 0);
var tileset = RpgTilesetFactory.Instance.Get("village");
var canvas = new RpgMapEditorCanvas { Document = document, Tileset = tileset, Atlas = textures.Tileset(tileset.Image),
ActiveLayer = RpgEditLayer.Main, SelectedTile = 3 };
overlay.AddChild(canvas);
canvas.EditStarting += document.BeginChange;
try
{
foreach (var id in new[] { "topdown", "isometric" })
{
canvas.SetRenderer(id);
var point = canvas.ProjectCell(new Vector2(2.5f, 3.5f));
ModAssert.True(canvas.Preview.TryPickGround(point, out var cell) && cell == new Vector2I(2, 3), "editor picking " + id);
canvas.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true, Position = point });
canvas.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = false, Position = point });
ModAssert.Equal(3, document.Grid.GetTile(RpgLayer.Main, 2, 3), "projected GUI click paints exact cell " + id);
ModAssert.True(document.Undo(), "projected stroke has an undo step " + id);
ModAssert.Equal(-1, document.Grid.GetTile(RpgLayer.Main, 2, 3), "undo restores prior data " + id);
canvas.Refresh();
passed += 4;
}
}
finally { canvas.Free(); }
}
private void Build(string mapId)
{
var map = RpgMapFactory.Instance.Get(mapId);
var tileset = RpgTilesetFactory.Instance.Get(map.Tileset);
builder ??= new RpgRenderSceneBuilder(textures);
scene = builder.Build(map, RpgMapGrid.Parse(map), tileset);
scene.Zoom = mapId == "rpg_elder_house" ? 1.1f : 0.55f;
frame.Focus = new Vector2(map.Width, map.Height) / 2;
frame.Ambient = Colors.White;
var index = 0;
foreach (var id in new[] { "topdown", "isometric" })
{
var factory = TileRendererRegistry.Instance.Resolve(id);
var projection = factory.Projection(scene.TileSize);
var point = new Vector2(3.25f, 2.75f);
ModAssert.True(projection.Unproject(TileDrawing.Project(projection, point)).IsEqualApprox(point), "round trip " + id);
var view = factory.Create();
var host = new Control { Position = new Vector2(index++ * 960, 0), Size = new Vector2(960, 1024), ClipContents = true };
overlay.AddChild(host);
host.AddChild(view.Root);
view.Mount(scene);
view.Resize(new Vector2(960, 1024));
view.Present(frame, 0);
var pixel = view.ProjectToView(new Vector3(3.5f, 2.5f, 0));
ModAssert.True(view.TryPickGround(pixel, out var cell) && cell == new Vector2I(3, 2), "picking " + id);
ModAssert.False(view.TryPickGround(view.ProjectToView(new Vector3(-1, -1, 0)), out _), "outside " + id);
views.Add(view);
passed += 3;
}
GameLogger.Log("TEST", $"renderer preview {mapId}: {scene.Visuals.Count} visual parts, {scene.OwnedTextures.Count} generated textures");
}
private void Release()
{
foreach (var view in views)
{
var parent = view.Root.GetParent();
view.Dispose();
parent.Free();
}
views.Clear();
scene?.Release();
scene = null;
}
private void Finish(string error)
{
Release();
if (error != null) GameLogger.Log("TEST", error, LogLevel.Error);
GameLogger.Log("TEST", $"[TEST_RESULT] name=tile_render passed={passed} failed={(error == null ? 0 : 1)}");
builder?.Dispose();
textures.Dispose();
GetTree().Quit(error == null ? 0 : 1);
SetProcess(false);
}
}
}
@@ -0,0 +1,18 @@
namespace Cthangover.Mods.Rpg.Tests
{
using Cthangover.Core.Mods;
using Godot;
/// <summary>Opt-in renderer contract/preview driver, never active during normal gameplay.</summary>
public class TileRenderTestInitializer : IModInitializer
{
private static bool queued;
public void OnModLoaded(string id) { }
public void OnModResourcesReady()
{
if (queued) return;
foreach (var arg in OS.GetCmdlineUserArgs())
if (arg == "--itest=tile_render")
{ queued = true; ModRoot.AddDeferred(new TileRenderTestDriver()); return; }
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "RPG Walk Test", "name": "RPG Walk Test",
"description": "Integration test of the rpg mod on the rpg_demo game: real WASD/E key events walk the hero, collide, read a sign, turn an NPC, go through a door teleport, flip events by a story flag, fight a battle that returns to the same cell with the win flag set. Activated by --itest=rpg_walk with --game=rpg_demo --new; --shots=<dir> saves screenshots (windowed runs only).", "description": "Integration test of the rpg mod on the rpg_plus game (its rpg_demo_game world): real WASD/E key events walk the hero, collide, read a sign, turn an NPC, go through a door teleport, flip events by a story flag, fight a battle that returns to the same cell with the win flag set. A pickup the suite adds to the village through its own map_events file is picked up and must stay gone after leaving the map and coming back, and after a save is loaded. Activated by --itest=rpg_walk with --game=rpg_plus --new (the prologue is clicked through first); --shots=<dir> saves screenshots (windowed runs only).",
"sources": [ "sources": [
"src/*.cs" "src/*.cs"
], ],
@@ -0,0 +1,19 @@
{
"Events": [
{
"Id": "rpg_walk_mushroom",
"X": 18,
"Y": 14,
"Icon": "rpg_demo/forest_mushroom",
"Trigger": "Touch",
"Solid": false,
"Commands": [
"ptext \"Found: a mushroom\"",
"action item.add id=rpg_demo/mushroom count=1",
"flag rpg_village_rpg_walk_mushroom_taken 1",
"end"
],
"Condition": "rpg_village_rpg_walk_mushroom_taken != 1"
}
]
}
+116 -10
View File
@@ -6,6 +6,7 @@ using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Games; using Cthangover.Core.Games;
using Cthangover.Core.Scenes; using Cthangover.Core.Scenes;
using Cthangover.Core.Settings; using Cthangover.Core.Settings;
using Cthangover.Core.Items;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.View; using Cthangover.Mods.Interface.View;
using Godot; using Godot;
@@ -17,6 +18,13 @@ namespace Cthangover.Mods.Rpg.Tests
/// <see cref="Input.ParseInputEvent"/>, so the world reads them exactly as /// <see cref="Input.ParseInputEvent"/>, so the world reads them exactly as
/// it reads a keyboard (physical keys, held state, unhandled action key). /// it reads a keyboard (physical keys, held state, unhandled action key).
/// <para/> /// <para/>
/// A pickup is checked across map rebuilds: the suite's own
/// <c>map_events/rpg_village/rpg_walk_test.json</c> lays a mushroom on the demo's
/// village (an event added by another mod, shaped like the map editor's
/// pickups); it is picked up, the player leaves the map and comes back, then
/// saves, loads and comes back again — it must never reappear nor give a
/// second item.
/// <para/>
/// One step per timer tick; a step either waits (returns without /// One step per timer tick; a step either waits (returns without
/// advancing) or checks and moves on. A failed check is recorded and the /// advancing) or checks and moves on. A failed check is recorded and the
/// run goes on; an exception ends it. Result line: /// run goes on; an exception ends it. Result line:
@@ -26,17 +34,26 @@ namespace Cthangover.Mods.Rpg.Tests
public partial class RpgWalkTestDriver : Node public partial class RpgWalkTestDriver : Node
{ {
private const float TickSeconds = 0.1f; private const float TickSeconds = 0.1f;
// The battle opens with an intro animation (the backdrop zooms in): shots wait it out.
private const int BattleIntroTicks = 20;
private const float TimeoutSeconds = 90f; private const float TimeoutSeconds = 90f;
private const string Village = "rpg_village"; private const string Village = "rpg_village";
private const string House = "rpg_elder_house"; private const string House = "rpg_elder_house";
private const string WinFlag = "rpg_test_win"; private const string WinFlag = "rpg_test_win";
private const string SaveSlot = "slot_9901"; private const string SaveSlot = "slot_9901";
private const string PickupId = "rpg_walk_mushroom";
private const string PickupFlag = "rpg_village_rpg_walk_mushroom_taken";
private const string Mushroom = "rpg_demo/mushroom";
private static readonly Vector2I PickupCell = new(18, 14);
private readonly string shotsDir; private readonly string shotsDir;
private readonly List<string> failures = new(); private readonly List<string> failures = new();
private int passed; private int passed;
private int step; private int step;
private int waitTicks; private int waitTicks;
private int mushroomsBefore;
private double elapsed; private double elapsed;
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary> /// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
@@ -51,6 +68,10 @@ namespace Cthangover.Mods.Rpg.Tests
private static RpgWorldView View => RpgWorldHost.Current?.View; private static RpgWorldView View => RpgWorldHost.Current?.View;
private static IInventory Inventory => GameData.Instance.Runtime.Inventory;
private static string Flag(string name) => GameData.Instance.Runtime.Flags.TryGetValue(name, out var value) ? value : null;
private void Tick() private void Tick()
{ {
if (step < 0) if (step < 0)
@@ -79,6 +100,8 @@ namespace Cthangover.Mods.Rpg.Tests
switch (step) switch (step)
{ {
case 0: case 0:
if (Boot.IsReady && SkipPrologue())
return;
if (Boot.IsReady && View?.Map?.ID == Village) if (Boot.IsReady && View?.Map?.ID == Village)
Next(); Next();
return; return;
@@ -142,17 +165,63 @@ namespace Cthangover.Mods.Rpg.Tests
case 9: case 9:
if (AdvanceDialog()) if (AdvanceDialog())
return; return;
// A pickup this suite adds to the demo's village (map_events of another mod).
mushroomsBefore = Inventory.CheckCount(Mushroom);
Check(View.FindEvent(PickupId)?.Visible == true, "a test mod's map_events file adds a pickup to the demo's village");
View.PlacePlayer(PickupCell.X - 1, PickupCell.Y, RpgDirection.Right);
Press(Key.D, true);
Next();
return;
case 10:
if (View.Player.Cell.X < PickupCell.X)
return;
Press(Key.D, false);
Next();
return;
case 11:
// Stepping on the pickup starts its dialog; the flag is set when it ends.
if (AdvanceDialog() || Flag(PickupFlag) != "1")
return;
Check(Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, $"stepping on the pickup adds the item, {Inventory.CheckCount(Mushroom)} now");
Check(View.FindEvent(PickupId)?.Visible == false, "the picked item leaves the map");
RpgWorldHost.Teleport(new RpgTeleport { Map = House, X = 6, Y = 8, Dir = RpgDirection.Up });
Next();
return;
case 12:
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning || AdvanceDialog())
return;
RpgWorldHost.Teleport(new RpgTeleport { Map = Village, X = PickupCell.X - 1, Y = PickupCell.Y, Dir = RpgDirection.Right });
Next();
return;
case 13:
if (View?.Map?.ID != Village || GodotSceneService.IsTransitioning || AdvanceDialog())
return;
CheckPickupGone("after leaving the map and coming back");
Press(Key.D, true);
Next();
return;
case 14:
if (View.Player.Cell.X < PickupCell.X)
return;
Press(Key.D, false);
Next();
return;
case 15:
if (View.Player.IsMoving || ++waitTicks < 5)
return;
Check(RpgInput.FindDialogBox()?.IsRunning != true && Inventory.CheckCount(Mushroom) == mushroomsBefore + 1,
"stepping on the empty cell again gives nothing");
View.PlacePlayer(6, 7, RpgDirection.Up); View.PlacePlayer(6, 7, RpgDirection.Up);
Press(Key.W, true); Press(Key.W, true);
Next(); Next();
return; return;
case 10: case 16:
if (!View.Player.IsMoving && View.Map.ID == Village) if (!View.Player.IsMoving && View.Map.ID == Village)
return; return;
Press(Key.W, false); Press(Key.W, false);
Next(); Next();
return; return;
case 11: case 17:
if (View?.Map?.ID != House) if (View?.Map?.ID != House)
return; return;
if (AdvanceDialog()) if (AdvanceDialog())
@@ -163,35 +232,35 @@ namespace Cthangover.Mods.Rpg.Tests
ScenarioCondition.SetFlag("rpg_wolf_dead", "1"); ScenarioCondition.SetFlag("rpg_wolf_dead", "1");
Next(); Next();
return; return;
case 12: case 18:
Check(View.FindEvent("elder_quest")?.Visible == false && View.FindEvent("elder_thanks")?.Visible == true, "a story flag swaps the elder's events at once"); Check(View.FindEvent("elder_quest")?.Visible == false && View.FindEvent("elder_thanks")?.Visible == true, "a story flag swaps the elder's events at once");
ScenarioCondition.SetFlag("rpg_wolf_dead", "0"); ScenarioCondition.SetFlag("rpg_wolf_dead", "0");
Check(RpgBattleService.Start("rpg_slime", WinFlag), "a battle starts from the map"); Check(RpgBattleService.Start("rpg_slime", WinFlag), "a battle starts from the map");
Godot.Engine.TimeScale = 4; Godot.Engine.TimeScale = 4;
Next(); Next();
return; return;
case 13: case 19:
if (GetTree().CurrentScene is not BattleSceneContext battle) if (GetTree().CurrentScene is not BattleSceneContext battle)
return; return;
// The core's auto turn only passes, so the hero would lose: once the // The core's auto turn only passes, so the hero would lose: once the
// core waits for the player, take the platform's victory path. // core waits for the player, take the platform's victory path.
if (battle.ActiveBattleCore is not IBattleAutoPlayable auto || !auto.IsWaitingForPlayerInput) if (battle.ActiveBattleCore is not IBattleAutoPlayable auto || !auto.IsWaitingForPlayerInput)
return; return;
// The shot waits for the fade-in too, otherwise it catches the blank transition. // The shot waits for the fade-in and the intro animation, otherwise it catches the zoomed backdrop.
if (GodotSceneService.IsTransitioning) if (GodotSceneService.IsTransitioning || ++waitTicks < BattleIntroTicks)
return; return;
Shot("battle"); Shot("battle");
battle.ShowWinground(); battle.ShowWinground();
Next(); Next();
return; return;
case 14: case 20:
if (GetTree().Root.FindChild("ContinueButton", true, false) is not Button button || !button.IsVisibleInTree()) if (GetTree().Root.FindChild("ContinueButton", true, false) is not Button button || !button.IsVisibleInTree())
return; return;
Shot("victory"); Shot("victory");
button.EmitSignal(BaseButton.SignalName.Pressed); button.EmitSignal(BaseButton.SignalName.Pressed);
Next(); Next();
return; return;
case 15: case 21:
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning) if (View?.Map?.ID != House || GodotSceneService.IsTransitioning)
return; return;
Godot.Engine.TimeScale = 1; Godot.Engine.TimeScale = 1;
@@ -202,19 +271,27 @@ namespace Cthangover.Mods.Rpg.Tests
GameSession.OpenMainMenu(GetTree()); GameSession.OpenMainMenu(GetTree());
Next(); Next();
return; return;
case 16: case 22:
if (RpgWorldHost.Current != null || ++waitTicks < 5) if (RpgWorldHost.Current != null || ++waitTicks < 5)
return; return;
Check(GameSession.LoadAndEnter(this, SaveSlot), "the save loads"); Check(GameSession.LoadAndEnter(this, SaveSlot), "the save loads");
Next(); Next();
return; return;
case 17: case 23:
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning || ++waitTicks < 5) if (View?.Map?.ID != House || GodotSceneService.IsTransitioning || ++waitTicks < 5)
return; return;
Shot("loaded"); Shot("loaded");
var dialog = RpgInput.FindDialogBox(); var dialog = RpgInput.FindDialogBox();
Check(dialog != null && !dialog.IsRunning && !dialog.IsVisibleInTree(), $"no dialog box after loading (running={dialog?.IsRunning}, visible={dialog?.IsVisibleInTree()})"); Check(dialog != null && !dialog.IsRunning && !dialog.IsVisibleInTree(), $"no dialog box after loading (running={dialog?.IsRunning}, visible={dialog?.IsVisibleInTree()})");
Check(View.Player.Cell == new Vector2I(6, 8), $"the loaded game puts the player on the saved cell, got {View.Player.Cell}"); Check(View.Player.Cell == new Vector2I(6, 8), $"the loaded game puts the player on the saved cell, got {View.Player.Cell}");
Check(Flag(PickupFlag) == "1" && Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, "the save keeps the pickup's flag and the picked item");
RpgWorldHost.Teleport(new RpgTeleport { Map = Village, X = PickupCell.X - 1, Y = PickupCell.Y, Dir = RpgDirection.Right });
Next();
return;
case 24:
if (View?.Map?.ID != Village || GodotSceneService.IsTransitioning || AdvanceDialog())
return;
CheckPickupGone("after loading the save");
SaveService.DeleteSave(SaveSlot); SaveService.DeleteSave(SaveSlot);
Finish(); Finish();
return; return;
@@ -237,7 +314,36 @@ namespace Cthangover.Mods.Rpg.Tests
} }
} }
/// <summary>The map was built again (a teleport or a load): the picked item must stay gone and give nothing.</summary>
private void CheckPickupGone(string when)
{
var pickup = View.FindEvent(PickupId);
Check(pickup != null && !pickup.Visible, $"the picked item does not come back {when}");
Check(View.EventAt(PickupCell, RpgEventTrigger.Touch) == null, $"its cell has no pickup to step on {when}");
Check(Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, $"the inventory still holds exactly the one picked item {when}");
}
/// <summary>Advances a running dialog with the action key; true while it is still running.</summary> /// <summary>Advances a running dialog with the action key; true while it is still running.</summary>
/// <summary>
/// The game opens with a novel-style prologue (no map yet): its lines are clicked
/// through and every choice takes the first answer. True while it runs.
/// </summary>
private static bool SkipPrologue()
{
// Asking for the dialog box before the first scene is up caches the wrong node.
var scene = ((SceneTree)Engine.GetMainLoop()).Root.GetNodeOrNull<SceneManager>("SceneManager")?.CurrentSceneName;
if (string.IsNullOrEmpty(scene) || RpgWorldHost.Current?.View?.Map != null)
return false;
var box = RpgInput.FindDialogBox();
if (box == null || !box.IsRunning)
return false;
if (box.IsWaitingAnswer)
box.TrySelectVariant(0);
else
box.NextAction();
return true;
}
private bool AdvanceDialog() private bool AdvanceDialog()
{ {
var box = RpgInput.FindDialogBox(); var box = RpgInput.FindDialogBox();
@@ -32,7 +32,7 @@ namespace Cthangover.Mods.Rpg.Tests
.Select(a => a.Substring("--shots=".Length)) .Select(a => a.Substring("--shots=".Length))
.FirstOrDefault(); .FirstOrDefault();
GameLogger.Log("TEST", $"RpgWalkTestInitializer: activating rpg_walk, shots={shots ?? "off"}"); GameLogger.Log("TEST", $"RpgWalkTestInitializer: activating rpg_walk, shots={shots ?? "off"}");
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new RpgWalkTestDriver(shots)); ModRoot.AddDeferred(new RpgWalkTestDriver(shots));
} }
} }
} }
+52
View File
@@ -0,0 +1,52 @@
namespace Cthangover.Mods.Rpg.Tests
{
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.TileRender;
using Godot;
/// <summary>Replacing real renderer instances cannot change the logical actor clock.</summary>
public class RpgActorPresentationTests
{
private static Node2D Actor(ITileWorldPresentation view) => (Node2D)view.Root.GetNode("Actors").GetChild(0);
[ModTest("actor_presentation/recreation_keeps_inflight_state")]
static void RecreationKeepsInflightState(ModTestContext ctx)
{
var state = new RpgActorState { Speed = 2 };
state.Place(new Vector2I(1, 1), RpgDirection.Right);
state.StepTo(RpgDirection.Right);
state.Advance(0.25);
using var image = Image.CreateEmpty(1, 1, false, Image.Format.Rgba8);
image.Fill(Colors.White);
using var texture = ImageTexture.CreateFromImage(image);
var frame = new TileFrame();
var pose = new TileActorPose { Id = "player", Visible = true, Position = state.Position,
Look = new TileVisual { Texture = texture, Bounds = new Rect2(0, 0, 1, 1), Source = new Rect2(0, 0, 1, 1) } };
frame.Actors.Add(pose);
var first = TileRendererRegistry.Instance.Resolve("topdown").Create();
try
{
first.Mount(new TileRenderScene { Size = new Vector2I(8, 8), TileSize = 32, Zoom = 1 });
first.Present(frame, 0);
first.Present(frame, 0);
ModAssert.CloseTo(48, Actor(first).Position.X, what: "first projection");
ModAssert.CloseTo(1.5, state.Position.X, what: "presenting twice does not tick");
}
finally { first.Dispose(); }
var second = TileRendererRegistry.Instance.Resolve("topdown").Create();
try
{
second.Mount(new TileRenderScene { Size = new Vector2I(8, 8), TileSize = 64, Zoom = 1 });
second.Present(frame, 0);
ModAssert.CloseTo(96, Actor(second).Position.X, what: "another projection uses the same pose");
ModAssert.True(state.IsMoving, "freeing the old presentation does not cancel the step");
ModAssert.True(state.Advance(0.25), "remaining duration is preserved");
pose.Position = state.Position;
second.Present(frame, 0);
ModAssert.CloseTo(128, Actor(second).Position.X, what: "arrival updates the replacement");
}
finally { second.Dispose(); }
}
}
}
+84
View File
@@ -0,0 +1,84 @@
namespace Cthangover.Mods.Rpg.Tests
{
using Cthangover.Core.Mods.Testing;
using Godot;
/// <summary>Cell reservations, timing and poses work without a scene tree or a sprite.</summary>
public class RpgActorStateTests
{
[ModTest("actor_state/reserves_destination_before_arrival")]
static void ReservesDestinationBeforeArrival(ModTestContext ctx)
{
var actor = new RpgActorState { Speed = 2 };
actor.Place(new Vector2I(3, 4), RpgDirection.Down);
actor.StepTo(RpgDirection.Right);
ModAssert.True(actor.Cell == new Vector2I(4, 4), "destination is reserved immediately");
ModAssert.CloseTo(3, actor.Position.X, what: "drawing starts at the source");
ModAssert.True(actor.IsMoving, "an unfinished step");
ModAssert.False(actor.Advance(0.25), "half a second per cell at speed 2");
ModAssert.CloseTo(3.5, actor.Position.X, what: "position is measured in cells");
ModAssert.CloseTo(4, actor.Position.Y, what: "unchanged axis");
ModAssert.True(actor.Advance(0.25), "arrival is reported once");
ModAssert.False(actor.Advance(1), "idle time never repeats arrival");
ModAssert.CloseTo(4, actor.Position.X, what: "destination reached exactly");
}
[ModTest("actor_state/teleport_cancels_pending_arrival")]
static void TeleportCancelsPendingArrival(ModTestContext ctx)
{
var actor = new RpgActorState();
actor.StepTo(RpgDirection.Down);
actor.Advance(0.05);
actor.Place(new Vector2I(8, 2), RpgDirection.Left);
ModAssert.False(actor.IsMoving, "teleport stops interpolation");
ModAssert.False(actor.Advance(1), "old step cannot complete after teleport");
ModAssert.CloseTo(8, actor.Position.X, what: "new x");
ModAssert.CloseTo(2, actor.Position.Y, what: "new y");
ModAssert.True(actor.Facing == RpgDirection.Left, "new facing");
}
[ModTest("actor_state/moving_command_does_not_reserve_second_cell")]
static void MovingCommandDoesNotReserveSecondCell(ModTestContext ctx)
{
var actor = new RpgActorState { Speed = 2 };
actor.StepTo(RpgDirection.Right);
actor.Advance(0.25);
actor.StepTo(RpgDirection.Up);
ModAssert.True(actor.Cell == new Vector2I(1, 0), "commands do not stack during a step");
ModAssert.CloseTo(0.5, actor.Position.X, what: "interpolation is not restarted");
actor.Face(RpgDirection.Left);
ModAssert.True(actor.Facing == RpgDirection.Left, "turning is independent of displacement");
ModAssert.True(actor.Advance(0.25), "original step finishes on time");
}
[ModTest("actor_state/speed_clamp_and_large_frame")]
static void SpeedClampAndLargeFrame(ModTestContext ctx)
{
var actor = new RpgActorState { Speed = 0 };
actor.StepTo(RpgDirection.Up);
ModAssert.False(actor.Advance(0.5), "speed clamps to one cell per second");
ModAssert.CloseTo(-0.5, actor.Position.Y, what: "halfway up");
ModAssert.True(actor.Advance(20), "a large frame completes one step");
ModAssert.CloseTo(-1, actor.Position.Y, what: "no overshoot");
actor.Speed = 100;
actor.StepTo(RpgDirection.Right);
ModAssert.False(actor.Advance(1.0 / 24), "speed clamps to twelve");
ModAssert.CloseTo(0.5, actor.Position.X, what: "upper speed limit");
}
[ModTest("actor_state/frame_partition_keeps_pose_and_animation")]
static void FramePartitionKeepsPoseAndAnimation(ModTestContext ctx)
{
var a = new RpgActorState { Speed = 2 };
var b = new RpgActorState { Speed = 2 };
a.StepTo(RpgDirection.Down);
b.StepTo(RpgDirection.Down);
a.Advance(0.2);
b.Advance(0.1);
b.Advance(0.1);
ModAssert.CloseTo(a.Position.Y, b.Position.Y, what: "same movement without renderer timing");
ModAssert.CloseTo(a.AnimationTime, b.AnimationTime, what: "same animation phase");
ModAssert.False(a.Advance(0), "zero time cannot complete an unfinished step");
}
}
}
+79
View File
@@ -0,0 +1,79 @@
namespace Cthangover.Mods.Rpg.Tests
{
using Cthangover.Core.Mods.Testing;
using Godot;
/// <summary>Autotile cells are assembled from quarters of the A2 block picked by their neighbours.</summary>
public class RpgAutotilesTests
{
private const int Water = 40;
private static bool Is(Vector2I[] quarters, params int[] xy)
{
for (var i = 0; i < 4; i++)
{
if (quarters[i] != new Vector2I(xy[i * 2], xy[i * 2 + 1]))
return false;
}
return true;
}
[ModTest("autotiles/quarters_by_neighbours")]
static void QuartersByNeighbours(ModTestContext ctx)
{
ModAssert.True(Is(RpgAutotiles.Quarters(false, false, false, false, false, false, false, false), 0, 2, 3, 2, 0, 5, 3, 5),
"alone: the four outer corners");
ModAssert.True(Is(RpgAutotiles.Quarters(true, true, true, true, true, true, true, true), 2, 4, 1, 4, 2, 3, 1, 3),
"surrounded: the fill of the patch");
ModAssert.True(Is(RpgAutotiles.Quarters(true, true, true, true, false, false, false, false), 2, 0, 3, 0, 2, 1, 3, 1),
"sides alike, corners not: the inner corners");
ModAssert.True(Is(RpgAutotiles.Quarters(false, true, true, true, false, false, true, true), 2, 2, 1, 2, 2, 3, 1, 3),
"the top row of a pond: top edge above, fill below");
ModAssert.True(Is(RpgAutotiles.Quarters(true, true, false, true, false, true, false, true), 0, 4, 1, 4, 0, 3, 1, 3),
"the left column: left edge");
var corner = RpgAutotiles.Quarters(true, true, true, true, false, true, true, true);
ModAssert.True(corner[0] == new Vector2I(2, 0) && corner[3] == new Vector2I(1, 3), "only the missing corner turns inner");
}
[ModTest("autotiles/cells_read_their_layer_and_the_map_edge_counts_alike")]
static void QuartersAt(ModTestContext ctx)
{
var grid = new RpgMapGrid(3, 3);
grid.SetTile(RpgLayer.Back, 0, 0, Water);
grid.SetTile(RpgLayer.Back, 1, 0, Water);
grid.SetTile(RpgLayer.Main, 0, 1, Water);
var corner = RpgAutotiles.QuartersAt(grid, RpgLayer.Back, 0, 0, Water);
ModAssert.True(corner[0] == new Vector2I(2, 4), "off the map counts as water: the top-left quarter is fill");
ModAssert.True(corner[2] == new Vector2I(2, 5), "below lies grass on this layer: bottom edge (the main layer is not read)");
ModAssert.True(corner[1] == new Vector2I(1, 4), "to the right is water");
var end = RpgAutotiles.QuartersAt(grid, RpgLayer.Back, 1, 0, Water);
ModAssert.True(end[1] == new Vector2I(3, 4), "the right end: right edge");
ModAssert.True(end[3] == new Vector2I(3, 5), "and its outer bottom-right corner");
}
[ModTest("autotiles/source_and_preview_rects")]
static void Rects(ModTestContext ctx)
{
ModAssert.True(RpgAutotiles.SourceRect(new Vector2I(3, 5), 32) == new Rect2(48, 80, 16, 16), "quarters are half tiles");
ModAssert.True(RpgAutotiles.SourceRect(Vector2I.Zero, 48) == new Rect2(0, 0, 24, 24), "of any tile size");
ModAssert.True(RpgAutotiles.PreviewRect(32) == new Rect2(0, 0, 32, 32), "the preview is the top-left tile");
}
[ModTest("autotiles/material_variants_repeat_by_world_cell")]
static void MaterialVariants(ModTestContext ctx)
{
ModAssert.True(RpgAutotiles.VariantRect(new Vector2I(64, 96), 32, new Vector2I(9, 7)) == new Rect2(0, 0, 64, 96),
"legacy A2 textures still use the whole single block");
ModAssert.True(RpgAutotiles.VariantRect(new Vector2I(256, 384), 32, new Vector2I(2, 3)) == new Rect2(128, 288, 64, 96),
"a four-by-four material sheet selects its world phase");
ModAssert.True(RpgAutotiles.VariantRect(new Vector2I(256, 384), 32, new Vector2I(6, 7)) == new Rect2(128, 288, 64, 96),
"the material repeats across its period");
ModAssert.True(RpgAutotiles.VariantRect(new Vector2I(256, 384), 32, new Vector2I(-1, -2)) == new Rect2(192, 192, 64, 96),
"negative editor coordinates wrap to a valid block");
ModAssert.True(RpgAutotiles.VariantRect(new Vector2I(192, 288), 48, new Vector2I(1, 1)) == new Rect2(96, 144, 96, 144),
"other logical tile sizes keep the A2 proportions");
}
}
}
+113
View File
@@ -0,0 +1,113 @@
namespace Cthangover.Mods.Rpg.Tests
{
using System;
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Utils;
using Godot;
/// <summary>Event pages pick the last page whose condition holds; routes resolve their steps; a page light replaces the event's.</summary>
public class RpgEventPagesTests
{
private static RpgMapEvent Chest() => JsonSerializer.Deserialize<RpgMapEvent>(@"{
""Id"": ""chest"", ""X"": 3, ""Y"": 2, ""Tile"": 40, ""Commands"": [""flag chest_open 1""],
""Pages"": [
{ ""Condition"": ""chest_open == 1"", ""Icon"": ""rpg_demo/coin"", ""Commands"": [""text \""Empty.\""""] },
{ ""Condition"": ""guard_back == 1"", ""Sprite"": ""village/guard"", ""Dir"": ""left"", ""Solid"": true }
]
}", JsonOptionsProvider.ReadOptions);
private static Func<string, bool> Flags(params string[] holding) => c => Array.IndexOf(holding, c) >= 0;
[ModTest("pages/last_holding_page_wins")]
static void LastHoldingPageWins(ModTestContext ctx)
{
var chest = Chest();
ModAssert.Equal(0, RpgEventPages.ActiveIndex(chest, Flags()), "no page holds: the event itself");
ModAssert.Equal(1, RpgEventPages.ActiveIndex(chest, Flags("chest_open == 1")), "the opened page");
ModAssert.Equal(2, RpgEventPages.ActiveIndex(chest, Flags("chest_open == 1", "guard_back == 1")), "the later page wins");
}
[ModTest("pages/own_condition_only_turns_off_page_zero")]
static void OwnConditionOnlyTurnsOffPageZero(ModTestContext ctx)
{
var chest = Chest();
chest.Condition = "chest_here == 1";
ModAssert.Equal(-1, RpgEventPages.ActiveIndex(chest, Flags()), "nothing holds: hidden");
ModAssert.Equal(1, RpgEventPages.ActiveIndex(chest, Flags("chest_open == 1")), "a page shows it without the own condition");
ModAssert.Equal(0, RpgEventPages.ActiveIndex(new RpgMapEvent { Id = "plain" }, Flags()), "an event without pages and condition");
}
[ModTest("pages/effective_merges_page_over_event")]
static void EffectiveMergesPageOverEvent(ModTestContext ctx)
{
var chest = Chest();
ModAssert.True(ReferenceEquals(chest, RpgEventPages.Effective(chest, 0)), "page 0 is the event itself");
var open = RpgEventPages.Effective(chest, 1);
ModAssert.Equal("chest", open.Id, "id kept");
ModAssert.Equal(3, open.X, "cell kept");
ModAssert.Equal("rpg_demo/coin", open.Icon, "the page's icon");
ModAssert.Equal(-1, open.Tile, "a page look replaces the whole look");
ModAssert.Equal("text \"Empty.\"", open.Commands[0], "the page's commands");
ModAssert.True(open.Trigger == RpgEventTrigger.Action, "trigger inherited");
var guard = RpgEventPages.Effective(chest, 2);
ModAssert.Equal("village/guard", guard.Sprite, "charset of page 2");
ModAssert.True(guard.Dir == RpgDirection.Left, "facing of page 2");
ModAssert.True(guard.IsSolid, "solid");
ModAssert.Equal("flag chest_open 1", guard.Commands[0], "commands inherited from the event");
ModAssert.Null(RpgEventPages.Effective(chest, 3), "out of range");
}
[ModTest("routes/steps_resolve")]
static void StepsResolve(ModTestContext ctx)
{
var self = new Vector2I(5, 5);
var player = new Vector2I(2, 6);
var left = RpgRouteStep.Resolve("Left", self, player, null);
ModAssert.True(left.Moves && left.Direction == RpgDirection.Left, "a walk, any case");
var face = RpgRouteStep.Resolve("face_up", self, player, null);
ModAssert.True(!face.Moves && face.Direction == RpgDirection.Up, "a turn");
ModAssert.Null(RpgRouteStep.Resolve("wait", self, player, null).Direction, "wait stands");
ModAssert.Null(RpgRouteStep.Resolve("dance", self, player, null).Direction, "an unknown word stands");
ModAssert.True(RpgRouteStep.Resolve("toward", self, player, null).Direction == RpgDirection.Left, "toward along the longer axis");
ModAssert.True(RpgRouteStep.Resolve("away", self, player, null).Direction == RpgDirection.Right, "away is the opposite");
ModAssert.Null(RpgRouteStep.Toward(self, self), "already there");
var random = RpgRouteStep.Resolve("random", self, player, new Random(7));
ModAssert.True(random.Moves && random.Direction.HasValue, "random walks somewhere");
}
[ModTest("routes/json_defaults")]
static void JsonDefaults(ModTestContext ctx)
{
var mapEvent = JsonSerializer.Deserialize<RpgMapEvent>(@"{ ""Id"": ""cat"", ""Route"": { ""Steps"": [""random""] } }", JsonOptionsProvider.ReadOptions);
ModAssert.CloseTo(1.0, mapEvent.Route.Every, what: "a step a second by default");
ModAssert.True(mapEvent.Route.Repeat, "repeats by default");
ModAssert.Null(mapEvent.Pages, "no pages");
}
[ModTest("lights/page_light_over_event_light_round_trip")]
static void PageLightOverEventLight(ModTestContext ctx)
{
var lamp = JsonSerializer.Deserialize<RpgMapEvent>(@"{
""Id"": ""lamp"", ""Tile"": 12, ""Light"": { ""Radius"": 2.5, ""Color"": ""#ff8040"" },
""Pages"": [ { ""Condition"": ""lamp_out == 1"", ""Tile"": 13, ""Light"": { ""Radius"": 1 } }, { ""Condition"": ""door == 1"" } ]
}", JsonOptionsProvider.ReadOptions);
ModAssert.CloseTo(2.5, lamp.Light.Radius, what: "the event's light");
ModAssert.Equal("#ff8040", lamp.Light.Color, "the event's colour");
var dim = RpgEventPages.Effective(lamp, 1);
ModAssert.CloseTo(1, dim.Light.Radius, what: "the page's light replaces it");
ModAssert.Equal("#ffd8a0", dim.Light.Color, "a page light without a colour is warm by default");
ModAssert.CloseTo(2.5, RpgEventPages.Effective(lamp, 2).Light.Radius, what: "a page without a light keeps the event's");
var again = JsonSerializer.Deserialize<RpgMapEvent>(JsonSerializer.Serialize(lamp), JsonOptionsProvider.ReadOptions);
ModAssert.CloseTo(2.5, again.Light.Radius, what: "radius survives the round trip");
ModAssert.Equal("#ff8040", again.Light.Color, "colour survives the round trip");
ModAssert.CloseTo(1, again.Pages[0].Light.Radius, what: "the page light survives the round trip");
ModAssert.Null(JsonSerializer.Deserialize<RpgMapEvent>(@"{ ""Id"": ""dark"" }", JsonOptionsProvider.ReadOptions).Light, "no light by default");
}
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace Cthangover.Mods.Rpg.Tests
{
using Cthangover.Core.Mods.Testing;
using Godot;
/// <summary>A cell of a broken object knows its place inside that object.</summary>
public class RpgGroupFragmentsTests
{
[ModTest("group_fragments/place_in_object")]
static void PlaceInObject(ModTestContext ctx)
{
var tileset = new RpgTileset { ID = "fragments" };
tileset.VisualGroups.Add(new RpgVisualGroup { Id = "house", Width = 3, Height = 2, Pattern = new[] { 1, 2, 3, 4, 5, 6 }, AnchorX = 1.5f, AnchorY = 2 });
tileset.VisualGroups.Add(new RpgVisualGroup { Id = "shed", Width = 1, Height = 2, Pattern = new[] { 6, 7 }, AnchorX = 0.5f, AnchorY = 2 });
tileset.VisualGroups.Add(new RpgVisualGroup { Id = "bad", Width = 2, Height = 1, Pattern = new[] { 8 } });
var fragments = RpgGroupFragments.Of(tileset);
ModAssert.Equal("house", fragments[5].Group.Id, "owner of a cell");
ModAssert.True(fragments[5].Cell == new Vector2I(1, 1), "column and row inside the object");
ModAssert.Equal("house", fragments[6].Group.Id, "a shared tile keeps its first object");
ModAssert.True(fragments[7].Cell == new Vector2I(0, 1), "second object's cells");
ModAssert.False(fragments.ContainsKey(8), "invalid groups are ignored");
}
}
}
+59
View File
@@ -0,0 +1,59 @@
namespace Cthangover.Mods.Rpg.Tests
{
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Utils;
using Godot;
/// <summary>The map's light: ambient weights from the clock, lit tiles, the fixed ambient of a map.</summary>
public class RpgLightingTests
{
[ModTest("lights/weights_follow_clock_dark_mode_and_switch")]
static void WeightsFollowClock(ModTestContext ctx)
{
ModAssert.CloseTo(1, RpgLighting.Weights(13f, false, true).Y, what: "noon is day");
ModAssert.CloseTo(1, RpgLighting.Weights(0.5f, false, true).W, what: "midnight is night");
ModAssert.CloseTo(1, RpgLighting.Weights(13f, true, true).W, what: "dark mode is night at noon");
ModAssert.CloseTo(1, RpgLighting.Weights(0.5f, false, false).Y, what: "lighting switched off keeps day at midnight");
var dawn = RpgLighting.Weights(5f, false, true);
ModAssert.CloseTo(1, dawn.X + dawn.W, what: "dawn blends night into morning");
ModAssert.True(dawn.X > 0 && dawn.W > 0, "both phases at dawn");
}
[ModTest("lights/tile_lights_on_any_layer_once_per_cell")]
static void TileLights(ModTestContext ctx)
{
var tileset = new RpgTileset
{
ID = "lit",
Tiles = new List<RpgTileInfo>
{
new() { Index = 3, Light = new RpgLight { Radius = 4 } },
new() { Index = 7, Light = new RpgLight { Radius = 1.5f, Color = "#ffd27a" } },
new() { Index = 9, Solid = true },
},
};
var grid = new RpgMapGrid(4, 3);
grid.SetTile(RpgLayer.Front, 1, 0, 3);
grid.SetTile(RpgLayer.Main, 2, 2, 7);
grid.SetTile(RpgLayer.Back, 2, 2, 3);
grid.SetTile(RpgLayer.Main, 0, 1, 9);
var lights = RpgLighting.TileLights(grid, tileset);
ModAssert.Equal(2, lights.Count, "one light per lit cell, unlit tiles give none");
ModAssert.True(lights.Exists(l => l.Cell == new Vector2I(1, 0) && Mathf.IsEqualApprox(l.Light.Radius, 4f)), "a front-layer lamp");
ModAssert.True(lights.Exists(l => l.Cell == new Vector2I(2, 2)), "a cell lit on two layers counts once");
}
[ModTest("lights/map_ambient_json")]
static void MapAmbientJson(ModTestContext ctx)
{
var room = JsonSerializer.Deserialize<RpgMap>(@"{ ""ID"": ""room"", ""Ambient"": ""#e8dcc8"" }", JsonOptionsProvider.ReadOptions);
ModAssert.Equal("#e8dcc8", room.Ambient, "a lit room");
var again = JsonSerializer.Deserialize<RpgMap>(JsonSerializer.Serialize(room), JsonOptionsProvider.ReadOptions);
ModAssert.Equal("#e8dcc8", again.Ambient, "ambient survives the round trip");
ModAssert.Null(JsonSerializer.Deserialize<RpgMap>(@"{ ""ID"": ""field"" }", JsonOptionsProvider.ReadOptions).Ambient, "outdoors by default");
}
}
}
+20
View File
@@ -53,6 +53,26 @@ namespace Cthangover.Mods.Rpg.Tests
ModAssert.False(tileset.IsSolid(-1), "empty cell"); ModAssert.False(tileset.IsSolid(-1), "empty cell");
} }
[ModTest("rules/tileset_light_and_autotile_lookup_round_trip")]
static void TilesetLightAndAutotileLookup(ModTestContext ctx)
{
var tileset = System.Text.Json.JsonSerializer.Deserialize<RpgTileset>(@"{
""Id"": ""lit"", ""Tiles"": [
{ ""Index"": 29, ""Tag"": ""lamp_top"", ""Light"": { ""Radius"": 4, ""Color"": ""#ffcf80"" } },
{ ""Index"": 40, ""Solid"": true, ""Tag"": ""water"", ""Autotile"": ""village/water_auto"" }
] }", Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
tileset = System.Text.Json.JsonSerializer.Deserialize<RpgTileset>(System.Text.Json.JsonSerializer.Serialize(tileset), Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
ModAssert.CloseTo(4, tileset.LightOf(29).Radius, what: "the lamp's light survives the round trip");
ModAssert.Equal("#ffcf80", tileset.LightOf(29).Color, "with its colour");
ModAssert.Null(tileset.LightOf(40), "the water gives no light");
ModAssert.Null(tileset.LightOf(-1), "an empty cell gives no light");
ModAssert.Equal("village/water_auto", tileset.AutotileOf(40)?.Autotile, "the autotile entry and its block");
ModAssert.Null(tileset.AutotileOf(29), "a plain tile is no autotile");
ModAssert.Null(tileset.AutotileOf(-1), "nor an empty cell");
ModAssert.True(tileset.IsSolid(40), "an autotile is solid like any tile");
}
[ModTest("rules/encounter_off_without_table")] [ModTest("rules/encounter_off_without_table")]
static void EncounterOffWithoutTable(ModTestContext ctx) static void EncounterOffWithoutTable(ModTestContext ctx)
{ {
+56 -3
View File
@@ -57,20 +57,73 @@ namespace Cthangover.Mods.Rpg.Tests
{ {
var json = @"{""Id"":""m"",""Tileset"":""village"",""Width"":2,""Height"":1, var json = @"{""Id"":""m"",""Tileset"":""village"",""Width"":2,""Height"":1,
""Spawn"":{""X"":1,""Y"":0,""Dir"":""left""}, ""Spawn"":{""X"":1,""Y"":0,""Dir"":""left""},
""Events"":[{""Id"":""door"",""X"":0,""Y"":0,""Trigger"":""touch"",""Teleport"":{""Map"":""x"",""X"":3,""Y"":4,""Dir"":""Up""}}], ""Atmosphere"":""woodland"",""Ambient"":""#c5d6b0"",
""Encounters"":{""Steps"":20,""Groups"":[""slime""]}}"; ""Encounters"":{""Steps"":20,""Groups"":[""slime""]}}";
var map = JsonSerializer.Deserialize<RpgMap>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions); var map = JsonSerializer.Deserialize<RpgMap>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
var again = JsonSerializer.Deserialize<RpgMap>(JsonSerializer.Serialize(map), Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions); var again = JsonSerializer.Deserialize<RpgMap>(JsonSerializer.Serialize(map), Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
ModAssert.Equal("m", again.ID, "id"); ModAssert.Equal("m", again.ID, "id");
ModAssert.Equal("woodland", again.Atmosphere, "atmosphere survives saves and editor snapshots");
ModAssert.Equal("#c5d6b0", again.Ambient, "fixed ambient retained");
ModAssert.Equal("Left", again.Spawn.Dir.ToString(), "spawn facing, read in any case"); ModAssert.Equal("Left", again.Spawn.Dir.ToString(), "spawn facing, read in any case");
ModAssert.Equal(20, again.Encounters.Steps, "encounter steps");
ModAssert.Equal("slime", again.Encounters.Groups[0], "encounter group");
ModAssert.Equal(2, again.Zoom, "zoom default");
}
[ModTest("state/event_file_round_trip")]
static void EventFileRoundTrip(ModTestContext ctx)
{
var json = @"{""Events"":[
{""Id"":""door"",""X"":0,""Y"":0,""Trigger"":""touch"",""Teleport"":{""Map"":""x"",""X"":3,""Y"":4,""Dir"":""Up""}},
{""Id"":""herb"",""X"":2,""Y"":1,""Icon"":""rpg_demo/forest_mushroom"",""Commands"":[""end""],""Condition"":""herb != 1""}]}";
var file = JsonSerializer.Deserialize<RpgMapEventFile>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
file.Events[0].ModId = "some_mod";
var text = JsonSerializer.Serialize(file);
var again = JsonSerializer.Deserialize<RpgMapEventFile>(text, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
ModAssert.Equal(2, again.Events.Count, "both events");
ModAssert.Equal("Touch", again.Events[0].Trigger.ToString(), "trigger"); ModAssert.Equal("Touch", again.Events[0].Trigger.ToString(), "trigger");
ModAssert.Equal("x", again.Events[0].Teleport.Map, "teleport map"); ModAssert.Equal("x", again.Events[0].Teleport.Map, "teleport map");
ModAssert.Equal(4, again.Events[0].Teleport.Y, "teleport y"); ModAssert.Equal(4, again.Events[0].Teleport.Y, "teleport y");
ModAssert.False(again.Events[0].IsSolid, "an invisible event does not block"); ModAssert.False(again.Events[0].IsSolid, "an invisible event does not block");
ModAssert.Equal(20, again.Encounters.Steps, "encounter steps"); ModAssert.Equal("rpg_demo/forest_mushroom", again.Events[1].Icon, "icon");
ModAssert.Equal(2, again.Zoom, "zoom default"); ModAssert.True(again.Events[1].IsSolid, "an icon is drawn, so it blocks unless told otherwise");
ModAssert.Equal("herb != 1", again.Events[1].Condition, "condition");
ModAssert.Equal("end", again.Events[1].Commands[0], "commands");
ModAssert.False(text.Contains("some_mod"), "where an event came from is not written");
ModAssert.False(text.Contains("IsSolid"), "the computed solidity is not written");
}
[ModTest("state/event_merge_adds_and_replaces")]
static void EventMergeAddsAndReplaces(ModTestContext ctx)
{
var merge = new RpgMapEventMerge();
merge.Add("village", "base_game", "map_events/village/events.json", new[]
{
new RpgMapEvent { Id = "elder", X = 1, Y = 1 },
new RpgMapEvent { Id = "sign", X = 2, Y = 1 },
new RpgMapEvent { Id = " " }
});
var replaced = merge.Add("VILLAGE", "quest_mod", "map_events/village/quest.json", new[]
{
new RpgMapEvent { Id = "Elder", X = 5, Y = 5 },
new RpgMapEvent { Id = "quest_giver", X = 3, Y = 3 }
});
merge.Add("forest", "quest_mod", "map_events/forest/a.json", null);
var village = merge.Maps["village"];
ModAssert.Equal(3, village.Count, "an event without an id is skipped, a new id is added");
ModAssert.Equal(1, replaced.Count, "one replacement reported");
ModAssert.Equal("Elder", replaced[0], "the replacing id");
ModAssert.Equal(5, village[0].X, "the later mod's event replaces in place (any case)");
ModAssert.Equal("quest_mod", village[0].ModId, "the replacement remembers its mod");
ModAssert.Equal("sign", village[1].Id, "the others keep their order");
ModAssert.Equal("base_game", village[1].ModId, "and their mod");
ModAssert.Equal("map_events/village/quest.json", village[2].File, "an added event remembers its file");
ModAssert.Equal(0, merge.Maps["forest"].Count, "a file without events adds nothing");
} }
} }
} }
+88
View File
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Mods.Testing;
namespace Cthangover.Mods.Rpg.Tests
{
/// <summary>Material transitions are calculated from neighbours, with stable world sampling and unchanged collision.</summary>
public class RpgSurfaceRendererTests
{
private static RpgTileset Tileset() => new()
{
Image = "test/objects", SurfaceImage = "test/surfaces", SurfaceSize = 16,
Columns = 1,
Tiles = new List<RpgTileInfo>
{
new() { Index = 0, Surface = 0 },
new() { Index = 3, Surface = 1, Solid = true },
new() { Index = 11, AtlasIndex = 0, Solid = true }
}
};
private static byte[] Atlas()
{
var pixels = new byte[32 * 16 * 4];
for (var y = 0; y < 16; y++)
for (var x = 0; x < 32; x++)
{
pixels[(y * 32 + x) * 4 + (x < 16 ? 0 : 2)] = 255;
pixels[(y * 32 + x) * 4 + 3] = 255;
}
return pixels;
}
[ModTest("surfaces/packing_and_materials_json_round_trip")]
static void PackingRoundTrip(ModTestContext ctx)
{
var restored = JsonSerializer.Deserialize<RpgTileset>(JsonSerializer.Serialize(Tileset()));
ModAssert.Equal("test/surfaces", restored.SurfaceImage, "surface atlas survives serialization");
ModAssert.Equal(16, restored.SurfaceSize, "material size survives serialization");
ModAssert.Equal(1, restored.TileOf(3).Surface.Value, "logical surface maps to its physical patch");
ModAssert.True(restored.TileOf(3).Diffuse, "natural surfaces diffuse by default");
ModAssert.True(restored.IsSolid(3), "blending never changes collision");
ModAssert.Equal(0, restored.TileOf(11).AtlasIndex.Value, "objects retain logical ids after packing");
ModAssert.Equal(0, RpgTileSetBuilder.AtlasCoords(restored, 11).X, "world and editor find the packed object");
restored.TileOf(3).Diffuse = false;
restored = JsonSerializer.Deserialize<RpgTileset>(JsonSerializer.Serialize(restored));
ModAssert.False(restored.TileOf(3).Diffuse, "hard surface edges survive serialization");
}
[ModTest("surfaces/blend_is_continuous_deterministic_and_leaves_objects_empty")]
static void ContinuousBlend(ModTestContext ctx)
{
var grid = new RpgMapGrid(3, 1);
grid.SetTile(RpgLayer.Back, 0, 0, 0);
grid.SetTile(RpgLayer.Back, 1, 0, 3);
grid.SetTile(RpgLayer.Back, 2, 0, 11);
var first = RpgSurfaceRenderer.Render(grid, RpgLayer.Back, Tileset(), Atlas(), 32);
var second = RpgSurfaceRenderer.Render(grid, RpgLayer.Back, Tileset(), Atlas(), 32);
ModAssert.True(first.AsSpan().SequenceEqual(second), "same map produces the same diffusion");
ModAssert.Equal((byte)255, first[(16 * 96 + 16) * 4], "material interior stays intact");
var left = (16 * 96 + 31) * 4;
var right = (16 * 96 + 32) * 4;
ModAssert.True(first[left] > 0 && first[left + 2] > 0, "boundary mixes both source materials");
ModAssert.True(Math.Abs(first[left] - first[right]) < 40, "no colour jump at cell boundary");
ModAssert.Equal((byte)0, first[(16 * 96 + 64) * 4 + 3], "object cell is transparent in surface layer");
var tileset = Tileset();
tileset.TileOf(3).Diffuse = false;
var hard = RpgSurfaceRenderer.Render(grid, RpgLayer.Back, tileset, Atlas(), 32);
ModAssert.Equal((byte)255, hard[left], "hard floor boundary keeps its own material");
ModAssert.Equal((byte)0, hard[left + 2], "hard floor does not diffuse into its neighbour");
}
[ModTest("surfaces/diagonal_material_reaches_shared_corner")]
static void DiagonalBlend(ModTestContext ctx)
{
var grid = new RpgMapGrid(2, 2);
grid.SetTile(RpgLayer.Back, 0, 0, 0);
grid.SetTile(RpgLayer.Back, 1, 0, 0);
grid.SetTile(RpgLayer.Back, 0, 1, 0);
grid.SetTile(RpgLayer.Back, 1, 1, 3);
var pixels = RpgSurfaceRenderer.Render(grid, RpgLayer.Back, Tileset(), Atlas(), 32);
ModAssert.True(pixels[(31 * 64 + 31) * 4 + 2] > 0, "diagonal corner participates in the diffusion kernel");
ModAssert.True(RpgSurfaceRenderer.Render(grid, RpgLayer.Front, Tileset(), Atlas(), 32).AsSpan().IndexOfAnyExcept((byte)0) < 0,
"empty layers stay transparent");
}
}
}
+56
View File
@@ -0,0 +1,56 @@
namespace Cthangover.Mods.Rpg.Tests
{
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Utils;
using Godot;
/// <summary>Plane tiles stand on a map axis through the cell centre; "auto" follows plane neighbours.</summary>
public class RpgTilePlanesTests
{
private const int Fence = 12;
private static RpgTileset Tileset()
{
var tileset = new RpgTileset { ID = "planes" };
tileset.Tiles.Add(new RpgTileInfo { Index = Fence, Solid = true, Plane = "auto" });
return tileset;
}
[ModTest("tile_planes/json_roundtrip")]
static void JsonRoundtrip(ModTestContext ctx)
{
var restored = JsonSerializer.Deserialize<RpgTileset>(JsonSerializer.Serialize(Tileset()), JsonOptionsProvider.ReadOptions);
ModAssert.Equal("auto", restored.TileOf(Fence).Plane, "plane mode");
ModAssert.Equal(null, JsonSerializer.Deserialize<RpgTileInfo>("{}").Plane, "legacy tiles stay sprites");
}
[ModTest("tile_planes/auto_by_neighbours")]
static void AutoByNeighbours(ModTestContext ctx)
{
var grid = new RpgMapGrid(5, 5);
grid.SetTile(RpgLayer.Main, 1, 1, Fence);
grid.SetTile(RpgLayer.Main, 2, 1, Fence);
grid.SetTile(RpgLayer.Main, 2, 2, Fence);
grid.SetTile(RpgLayer.Main, 4, 4, Fence);
grid.SetTile(RpgLayer.Front, 2, 3, Fence);
var tileset = Tileset();
var row = RpgTilePlanes.Quads(grid, tileset, RpgLayer.Main, 1, 1, "auto");
ModAssert.Equal(1, row.Count, "a row end is one plane");
ModAssert.True(row[0][0] == new Vector3(1, 1.5f, 1) && row[0][2] == new Vector3(2, 1.5f, 0), "along x through the centre, one cell high");
ModAssert.Equal(2, RpgTilePlanes.Quads(grid, tileset, RpgLayer.Main, 2, 1, "auto").Count, "a corner has both planes");
var column = RpgTilePlanes.Quads(grid, tileset, RpgLayer.Main, 2, 2, "auto");
ModAssert.Equal(1, column.Count, "a column end ignores the other layer");
ModAssert.True(column[0][0] == new Vector3(2.5f, 3, 1) && column[0][1] == new Vector3(2.5f, 2, 1),
"along y, the image's left edge at the larger y (screen left)");
List<Vector3[]> lone = RpgTilePlanes.Quads(grid, tileset, RpgLayer.Main, 4, 4, "auto");
ModAssert.True(lone.Count == 1 && lone[0][0].Y == 4.5f, "a lone tile defaults to x");
ModAssert.True(RpgTilePlanes.Quads(grid, tileset, RpgLayer.Main, 4, 4, "y")[0][0].X == 4.5f, "an explicit axis wins");
}
}
}
+28
View File
@@ -0,0 +1,28 @@
namespace Cthangover.Mods.Rpg.Tests
{
using System.Text.Json;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Utils;
/// <summary>Visual metadata survives the same serializer used by content and editor snapshots.</summary>
public class RpgVisualGroupTests
{
[ModTest("visual_groups/json_roundtrip")]
static void JsonRoundtrip(ModTestContext ctx)
{
var tileset = new RpgTileset { ID = "room" };
tileset.VisualGroups.Add(new RpgVisualGroup { Id = "wall", Width = 2, Height = 1, Pattern = new[] { 1000, 1001 },
AnchorX = 1, AnchorY = 0.75f, IsoPlanes = new() { new() { Crop = new[] { 0, 0, 2, 1 },
Points = new[] { new[] { 0f, 0, 1 }, new[] { 2f, 0, 1 }, new[] { 2f, 0, 0 }, new[] { 0f, 0, 0 } } } } });
var restored = JsonSerializer.Deserialize<RpgTileset>(JsonSerializer.Serialize(tileset), JsonOptionsProvider.ReadOptions);
var group = restored.VisualGroups[0];
ModAssert.Equal("wall", group.Id, "group ID");
ModAssert.Equal(2, group.Width, "width");
ModAssert.Equal(1, group.Height, "height");
ModAssert.Equal(1001, group.Pattern[1], "logical source IDs");
ModAssert.CloseTo(0.75, group.AnchorY, what: "pivot");
ModAssert.Equal(2, group.IsoPlanes[0].Crop[2], "plane crop");
ModAssert.CloseTo(1, group.IsoPlanes[0].Points[0][2], what: "elevation");
ModAssert.Equal(0, JsonSerializer.Deserialize<RpgTileset>("{}").VisualGroups.Count, "legacy default");
}
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace Cthangover.Mods.Rpg.Tests
{
using Cthangover.Core.Mods.Testing;
/// <summary>Malformed profiles fail before renderer creation rather than stretching unrelated atlas pixels.</summary>
public class RpgVisualGroupValidatorTests
{
[ModTest("visual_groups/invalid_geometry_is_reported")]
static void InvalidGeometryIsReported(ModTestContext ctx)
{
var group = new RpgVisualGroup { Id = "wall", Width = 2, Height = 1, Pattern = new[] { 4, 5 } };
ModAssert.Null(RpgVisualGroupValidator.Error(group), "valid sprite group");
group.Pattern = new[] { 4 };
ModAssert.NotNull(RpgVisualGroupValidator.Error(group), "incomplete pattern");
group.Pattern = new[] { 4, 5 };
group.IsoPlanes = new() { new() { Crop = new[] { 0, 0, 3, 1 }, Points = new[] {
new[] { 0f, 0, 1 }, new[] { 2f, 0, 1 }, new[] { 2f, 0, 0 }, new[] { 0f, 0, 0 } } } };
ModAssert.NotNull(RpgVisualGroupValidator.Error(group), "crop outside packed group");
group.IsoPlanes[0].Crop[2] = 2;
ModAssert.Null(RpgVisualGroupValidator.Error(group), "valid architectural quad");
group.IsoPlanes[0].Points[2][2] = float.NaN;
ModAssert.NotNull(RpgVisualGroupValidator.Error(group), "non-finite geometry");
}
}
}
+178
View File
@@ -0,0 +1,178 @@
namespace Cthangover.Mods.Rpg.Tests
{
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods.Testing;
using Godot;
/// <summary>World decisions run without presentation nodes, including pages, routes and arrival effects.</summary>
public class RpgWorldSessionTests
{
private readonly List<string> effects = new();
private bool flag;
private bool Evaluate(string condition) => string.IsNullOrEmpty(condition) || flag;
private void OnArrival(RpgActorState actor) => effects.Add($"arrived:{actor.Cell.X},{actor.Cell.Y}");
private void OnTouch(RpgMapEvent mapEvent) => effects.Add("touch:" + mapEvent.Id);
private void OnEncounter(string enemies) => effects.Add("encounter:" + enemies);
private RpgWorldSession Create(IEnumerable<RpgMapEvent> events = null, RpgEncounterTable encounters = null)
{
var map = new RpgMap { ID = "session_test", Width = 6, Height = 6, Encounters = encounters };
var session = new RpgWorldSession(map, null, events, Evaluate, new Random(7));
session.PlayerStepFinished += OnArrival;
session.TouchTriggered += OnTouch;
session.EncounterTriggered += OnEncounter;
return session;
}
[ModTest("session/blockers_stop_commands_but_finish_existing_step")]
static void BlockersStopCommandsButFinishExistingStep(ModTestContext ctx)
{
var test = new RpgWorldSessionTests();
var session = test.Create();
session.Player.Place(new Vector2I(2, 2), RpgDirection.Down);
session.Tick(0.1, true, RpgDirection.Right);
ModAssert.True(session.Player.Cell == new Vector2I(2, 2), "blocked input cannot reserve a step");
session.Tick(0.1, false, RpgDirection.Right);
ModAssert.True(session.Player.Cell == new Vector2I(3, 2), "accepted command reserves destination");
ModAssert.Equal(0, test.effects.Count, "no arrival while interpolating");
session.Tick(1, true, RpgDirection.Right);
ModAssert.Equal(1, test.effects.Count, "an already started step completes even after UI opens");
ModAssert.Equal("arrived:3,2", test.effects[0], "saved arrival uses logical coordinates");
session.Tick(1, true, RpgDirection.Right);
ModAssert.Equal(1, test.effects.Count, "no repeated completion");
}
[ModTest("session/touch_wins_over_encounter_and_follows_arrival")]
static void TouchWinsOverEncounterAndFollowsArrival(ModTestContext ctx)
{
var test = new RpgWorldSessionTests();
var session = test.Create(new[]
{
new RpgMapEvent { Id = "pickup", X = 1, Trigger = RpgEventTrigger.Touch, Solid = false }
}, new RpgEncounterTable { Steps = 1, Groups = new() { "wolf" } });
session.Tick(1, false, RpgDirection.Right);
ModAssert.Equal(2, test.effects.Count, "arrival and touch only");
ModAssert.Equal("arrived:1,0", test.effects[0], "position is available before running the event");
ModAssert.Equal("touch:pickup", test.effects[1], "touch suppresses the encounter roll");
session.Tick(1, false, RpgDirection.Right);
ModAssert.Equal("encounter:wolf", test.effects[3], "a free step can start a battle");
}
[ModTest("session/terrain_and_hidden_events_use_logical_passability")]
static void TerrainAndHiddenEventsUseLogicalPassability(ModTestContext ctx)
{
var test = new RpgWorldSessionTests();
var session = test.Create(new[]
{
new RpgMapEvent { Id = "gate", X = 1, Y = 0, Tile = 5, Condition = "enabled" }
});
ModAssert.True(session.CanEnter(new Vector2I(1, 0)), "hidden event does not block");
test.flag = true;
session.RefreshEvents();
ModAssert.False(session.CanEnter(new Vector2I(1, 0)), "active drawn event blocks even without a sprite node");
session.Tick(0.1, false, RpgDirection.Right);
ModAssert.False(session.Player.IsMoving, "blocked step does not start");
ModAssert.True(session.Player.Facing == RpgDirection.Right, "blocked command still turns");
session.Grid.SetCollision(0, 1, RpgCollision.Block);
ModAssert.False(session.CanEnter(new Vector2I(0, 1)), "painted collision is respected");
ModAssert.False(session.CanEnter(new Vector2I(-1, 0)), "outside map is blocked");
}
[ModTest("session/action_target_and_facing_are_shared_without_view")]
static void ActionTargetAndFacingAreSharedWithoutView(ModTestContext ctx)
{
var test = new RpgWorldSessionTests();
var session = test.Create(new[]
{
new RpgMapEvent { Id = "sign", X = 1, Y = 0, Tile = 1 },
new RpgMapEvent { Id = "floor", X = 0, Y = 0, Solid = false }
});
session.Player.Face(RpgDirection.Right);
ModAssert.Equal("sign", session.InteractionTarget().Event.Id, "front event takes precedence over floor");
ModAssert.Equal("sign", session.PrepareInteraction().Id, "the executed target is the highlighted one");
ModAssert.True(session.FindEvent("sign").Facing == RpgDirection.Left, "NPC turns toward player");
session.Player.Face(RpgDirection.Down);
ModAssert.Equal("floor", session.PrepareInteraction().Id, "fallback is event under the player");
session.Player.StepTo(RpgDirection.Down);
ModAssert.Null(session.InteractionTarget(), "no cue during a step");
ModAssert.Null(session.PrepareInteraction(), "no action during a step");
ModAssert.Null(session.FindEvent(null), "null selection is safe");
}
[ModTest("session/routes_reserve_destinations_and_respect_player")]
static void RoutesReserveDestinationsAndRespectPlayer(ModTestContext ctx)
{
var test = new RpgWorldSessionTests();
var session = test.Create(new[]
{
new RpgMapEvent { Id = "a", X = 1, Y = 1, Tile = 1, Route = new() { Every = 0.1, Steps = new() { "right" } } },
new RpgMapEvent { Id = "b", X = 3, Y = 1, Tile = 1, Route = new() { Every = 0.1, Steps = new() { "left" } } }
});
session.Tick(0.1, false);
ModAssert.True(session.FindEvent("a").Cell == new Vector2I(2, 1), "first route reserves target");
ModAssert.True(session.FindEvent("b").Cell == new Vector2I(3, 1), "second route cannot enter the reservation");
ModAssert.True(session.FindEvent("b").Facing == RpgDirection.Left, "blocked route still faces target");
session.Player.Place(new Vector2I(3, 1), RpgDirection.Down);
session.Tick(1, true);
session.Tick(0.2, false);
ModAssert.True(session.FindEvent("a").Cell == new Vector2I(2, 1), "route does not enter player cell");
}
[ModTest("session/page_change_retains_position_and_restarts_route")]
static void PageChangeRetainsPositionAndRestartsRoute(ModTestContext ctx)
{
var test = new RpgWorldSessionTests();
var session = test.Create(new[]
{
new RpgMapEvent
{
Id = "chest", X = 1, Y = 1, Tile = 14,
Route = new() { Every = 0.1, Steps = new() { "right" }, Repeat = false },
Pages = new() { new() { Condition = "opened", Tile = 15, Dir = RpgDirection.Up } }
}
});
session.Tick(1, false);
var actor = session.FindEvent("chest");
ModAssert.True(actor.Cell == new Vector2I(2, 1), "route moved event");
test.flag = true;
session.RefreshEvents();
ModAssert.Equal(1, actor.PageIndex, "conditional page becomes active");
ModAssert.Equal(15, actor.Current.Tile, "look describes the effective page");
ModAssert.True(actor.Cell == new Vector2I(2, 1), "page does not reset position");
ModAssert.True(actor.Facing == RpgDirection.Up, "explicit page direction applies");
ModAssert.Equal(0, actor.RouteIndex, "new page restarts the route");
}
[ModTest("session/route_clock_pauses_while_ui_blocks_commands")]
static void RouteClockPausesWhileUiBlocksCommands(ModTestContext ctx)
{
var test = new RpgWorldSessionTests();
var session = test.Create(new[]
{
new RpgMapEvent { Id = "cat", X = 1, Y = 1, Route = new() { Every = 1, Steps = new() { "right" } } }
});
session.Tick(10, true);
session.Tick(0.5, false);
ModAssert.True(session.FindEvent("cat").Cell == new Vector2I(1, 1), "blocked time does not accumulate route delay");
session.Tick(0.5, false);
ModAssert.True(session.FindEvent("cat").Cell == new Vector2I(2, 1), "route starts after one unblocked second");
}
[ModTest("session/dash_changes_duration_not_destination")]
static void DashChangesDurationNotDestination(ModTestContext ctx)
{
var normal = new RpgWorldSessionTests().Create();
var dash = new RpgWorldSessionTests().Create();
normal.Tick(0.15, false, RpgDirection.Right);
dash.Tick(0.15, false, RpgDirection.Right, true);
ModAssert.True(normal.Player.Cell == dash.Player.Cell, "same logical step");
ModAssert.True(normal.Player.IsMoving, "normal step takes more time");
ModAssert.False(dash.Player.IsMoving, "dash arrives sooner");
}
}
}