Add mod documentation in seven languages

This commit is contained in:
cthangover committed 2026-10-29 09:26:04 +01:00
1 parent 9f22af960c
commit b06f8ce990
43 files changed
+1828 -20

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+81
View File
@@ -0,0 +1,81 @@
# Mod Manager — mods, games and their documentation
Mod Manager is the **Mods & Collections** screen of the main menu. Use it to see which mods are installed, read what each one does, install or update mods from the online catalog, switch mods off, and look inside the ready-made games to see which mods they are built from.
It is part of the shell that is always loaded with the menu, so it works even before you start a game.
## Open it
In the main menu choose **Mods & Collections**. The screen has two tabs:
- **Mods** — every mod the game knows: installed ones and those offered by the catalog.
- **Collections** — the ready-made games, and the scheme of mods each game is built from.
**← Menu** returns to the main menu. The **?** button in the top-right corner, or **F1**, turns on contextual hints: hover over a control to see what it does, click to pin the hint, **Esc** to leave.
## Browse the mods
![The Mods tab: filters on the left, a grid of mod cards with cover pictures, the search box with a hint open, sorting and the Refresh catalog button.](assets/mods-list.jpg)
Each card shows the mod's cover, title, author, the catalog it comes from, how many games use it (**in 4 collections**), its state and version, and an on/off switch.
- **Search mods…** filters by title, author or identifier.
- **By name / By author / By status** sorts the list.
- **Filters** on the left narrow the list by **Status** (Not installed, Installed, Update available, Older version available, Modified locally, Disabled) and by **Categories** such as Story, Gameplay, Visual, Audio, Library, Engine and Tools. **Reset** clears filters and search.
- **Refresh catalog** downloads the current list of published mods.
*Modified locally* means the installed files differ from the catalog's copy of the same version — for example, a mod you are editing yourself.
## Read about a mod
Click a card to open its page: the cover, version selector, actions, the short description from the mod's manifest and, when the mod provides one, its **Documentation** — a full guide like this one.
![A mod page: the Audio Engine cover and version, the Reinstall and Disable buttons, "in 4 collections", the description, and the Documentation section with its language selector.](assets/mod-page.jpg)
The documentation is the guide of the **installed** version. If you choose another version in the version selector, a note reminds you that the guide below still belongs to the installed one.
**Documentation language.** The selector at the right of the Documentation heading starts on **Auto · interface language**: the guide follows the language of the game's interface. If the mod has no guide in that language, the manager shows the mod's default guide, then English, then the first available one; the line above the guide names the language actually shown (for example `EN`). You can also pick a language from the selector yourself. A mod without any guide keeps showing its manifest description.
For mod authors: put the guide in `docs/en.md` (and `docs/<language>.md` for translations, or `docs/default.md`) and images next to it, for example `docs/assets/screen.png`, referenced as `assets/screen.png`. The viewer shows headings, paragraphs, lists, quotes, code blocks, links and PNG/JPEG/WebP images; it does not run HTML, tables or animations.
## Install, update, switch off, delete
The buttons on a mod page and the switch on its card:
- **Install / Update / Downgrade / Reinstall** download the selected version from the catalog into your user folder (`user://mods/`).
- **Disable / Enable** (or the card's switch) decide whether the mod is loaded. A disabled mod keeps its files and the data in your saves.
- **Delete** removes a downloaded mod after confirmation.
After any of these a banner says **Changes will take effect after restarting the game**. Mod Manager itself cannot be deleted or disabled from its own screen.
**Installed is not the same as used.** A game loads the mods named in its collection, plus the menu shell. Installing a mod makes it available; it appears in a game only when that game's collection includes it.
## Games and collections
The **Collections** tab first shows the ready-made games. A game is a *collection*: a named set of mods with the place to start.
![The game gallery: Cthangover, My Novel and RPG Plus cards, each "Ready to play" with Mods 32/32, 11/11 and 36/36, and Edit scheme buttons.](assets/games-gallery.jpg)
A card tells you whether everything the game needs is installed (**Ready to play**, or **Missing N** / **Install (N)** / **Enable (N)**). One button installs or enables what is missing. **Edit scheme** opens the game's structure; **+ Build your own game** starts a new collection.
### The scheme of a game
![The scheme of My Novel: Story, Assets and 2D engine blocks with their mods, arrows from each dependency to the mod that needs it, and the Inspector showing the version requirement of My Novel.](assets/collection-scheme.jpg)
The scheme shows the game's *mechanics* (blocks such as Story, Assets, 2D engine) and the mods inside them. Arrows go from a dependency to the mod that needs it. Card positions and blocks are only for reading: they do not decide load order.
Select a block or a mod to see it in the **Inspector**. There you can change the version the game requires — **Any**, **At least**, **Exactly**, **Branch** (`^1` accepts any 1.x) and others. This is the game's requirement, not the version you have installed. **Check** lists problems; **Plan** shows what applying the collection would install or enable.
![The Inspector of a mod whose requirement "Exactly 999.0.0" does not match the installed 1.0.0: the requirement and the installed version are shown in red.](assets/version-mismatch.jpg)
Other buttons of the scheme: **+ Mechanic** and **Add mod** change the game, **Duplicate** makes your own copy (and offers to copy the saves), **JSON** shows the raw definition, **Save** and **Reset** keep or discard changes, **Apply collection** installs and enables what the plan lists. A built-in game you changed can be restored with **Revert to built-in**; your saves stay.
Changes to a game apply the next time it starts. Saves of a game remain usable when its mods change: data of mods that left the game is kept aside and comes back with the mod.
## If something goes wrong
1. **A mod does not appear in a game.** Check that it is enabled and that the game's collection includes it (open **Edit scheme**).
2. **"Install failed".** Check the internet connection and try **Refresh catalog** again.
3. **A game card says Missing or Problems.** Open its scheme and the **Check** tab: it lists missing mods, version gaps and conflicts.
4. **Catalog conflict.** The same mod and version is published with different content by two catalogs; prefer the catalog you trust.
5. **No documentation section.** The mod has no `docs/` guide yet; its manifest description is shown instead.
+81
View File
@@ -0,0 +1,81 @@
# Mod Manager — mods, juegos y su documentación
Mod Manager es la pantalla **Mods y colecciones** del menú principal. Sirve para ver qué mods hay instalados, leer qué hace cada uno, instalar o actualizar mods desde el catálogo en línea, desactivar mods y mirar dentro de los juegos ya hechos para ver de qué mods están hechos.
Forma parte de la carcasa que siempre se carga junto con el menú, así que funciona incluso antes de que empieces una partida.
## Cómo abrirlo
En el menú principal elige **Mods y colecciones**. La pantalla tiene dos pestañas:
- **Mods** — todos los mods que el juego conoce: los instalados y los que ofrece el catálogo.
- **Colecciones** — los juegos ya hechos y el esquema de mods con el que está montado cada uno.
**← Menú** vuelve al menú principal. El botón **?** de la esquina superior derecha, o **F1**, activa las sugerencias contextuales: pasa el ratón sobre un control para ver qué hace, haz clic para fijar la sugerencia y pulsa **Esc** para salir.
## Explorar los mods
![La pestaña Mods: filtros a la izquierda, una cuadrícula de tarjetas de mods con imágenes de portada, el cuadro de búsqueda con una sugerencia abierta, el orden y el botón Refresh catalog.](assets/mods-list.jpg)
Cada tarjeta muestra la portada del mod, su título, el autor, el catálogo del que viene, cuántos juegos lo usan (**en 4 colecciones**), su estado y versión, y un interruptor de encendido/apagado.
- **Buscar mods...** filtra por título, autor o identificador.
- **Por nombre / Por autor / Por estado** ordenan la lista.
- **Filtros** a la izquierda acotan la lista por **Estado** (No instalado, Instalado, Hay actualización, Hay una versión anterior, Modificado localmente, Desactivado) y por **Categorías** como Historia, Jugabilidad, Visual, Audio, Biblioteca, Motor y Herramientas. **Restablecer** limpia filtros y búsqueda.
- **Actualizar catálogo** descarga la lista actual de mods publicados.
*Modificado localmente* significa que los archivos instalados difieren de la copia de esa misma versión en el catálogo; por ejemplo, un mod que estás editando tú.
## Leer sobre un mod
Haz clic en una tarjeta para abrir su página: la portada, el selector de versión, las acciones, la descripción corta del manifiesto del mod y, cuando el mod la ofrece, su **Documentación**: una guía completa como esta.
![Una página de mod: la portada y la versión de Audio Engine, los botones Reinstall y Disable, "in 4 collections", la descripción y la sección Documentation con su selector de idioma.](assets/mod-page.jpg)
La documentación es la guía de la versión **instalada**. Si eliges otra versión en el selector, un aviso te recuerda que la guía de abajo sigue siendo la del mod instalado.
**Idioma de la documentación.** El selector a la derecha del encabezado Documentación arranca en **Auto · idioma de la interfaz**: la guía sigue el idioma de la interfaz del juego. Si el mod no tiene guía en ese idioma, el gestor muestra primero la guía predeterminada del mod, luego la inglesa y luego la primera disponible; la línea sobre la guía indica el idioma realmente mostrado (por ejemplo `EN`). También puedes elegir un idioma en el selector. Un mod sin ninguna guía sigue mostrando la descripción de su manifiesto.
Para autores de mods: pon la guía en `docs/en.md` (las traducciones en `docs/<idioma>.md`, o `docs/default.md`) y las imágenes al lado, por ejemplo `docs/assets/screen.png`, referenciada como `assets/screen.png`. El visor entiende encabezados, párrafos, listas, citas, bloques de código, enlaces e imágenes PNG/JPEG/WebP; no ejecuta HTML, tablas ni animaciones.
## Instalar, actualizar, desactivar, eliminar
Los botones de la página del mod y el interruptor de su tarjeta:
- **Instalar / Actualizar / Volver a versión anterior / Reinstalar** descargan la versión elegida del catálogo a tu carpeta de usuario (`user://mods/`).
- **Desactivar / Activar** (o el interruptor de la tarjeta) deciden si el mod se carga. Un mod desactivado conserva sus archivos y los datos de tus partidas.
- **Eliminar** borra un mod descargado tras confirmar.
Después de cualquiera de estas acciones aparece un aviso: **Los cambios surtirán efecto al reiniciar el juego**. El propio Mod Manager no puede eliminarse ni desactivarse desde su propia pantalla.
**Instalado no es lo mismo que usado.** Un juego carga los mods que nombra su colección, más la carcasa del menú. Instalar un mod lo deja disponible; solo aparece en un juego cuando la colección de ese juego lo incluye.
## Juegos y colecciones
La pestaña **Colecciones** muestra primero los juegos ya hechos. Un juego es una *colección*: un conjunto de mods con nombre y el lugar donde empezar.
![La galería de juegos: tarjetas de Cthangover, My Novel y RPG Plus, cada una "Ready to play" con Mods 32/32, 11/11 y 36/36, y botones Edit scheme.](assets/games-gallery.jpg)
Una tarjeta te dice si está instalado todo lo que el juego necesita (**Listo para jugar**, o **Faltan N** / **Instalar (N)** / **Activar (N)**). Un solo botón instala o activa lo que falta. **Editar esquema** abre la estructura del juego; **+ Crea tu propio juego** empieza una colección nueva.
### El esquema de un juego
![El esquema de My Novel: los bloques Story, Assets y 2D engine con sus mods, flechas de cada dependencia al mod que la necesita, y el Inspector mostrando el requisito de versión de My Novel.](assets/collection-scheme.jpg)
El esquema muestra las *mecánicas* del juego (bloques como Story, Assets, 2D engine) y los mods dentro de ellas. Las flechas van de una dependencia al mod que la necesita. Las posiciones de las tarjetas y los bloques son solo para leer: no deciden el orden de carga.
Selecciona un bloque o un mod para verlo en el **Inspector**. Ahí puedes cambiar la versión que exige el juego: **Cualquiera**, **Como mínimo**, **Exacta**, **Rama** (`^1` acepta cualquier 1.x) y otras. Es el requisito del juego, no la versión que tienes instalada. **Comprobar** enumera problemas; **Plan** muestra qué instalaría o activaría aplicar la colección.
![El Inspector de un mod cuyo requisito "Exactly 999.0.0" no coincide con la versión instalada 1.0.0: el requisito y la versión instalada aparecen en rojo.](assets/version-mismatch.jpg)
Otros botones del esquema: **+ Mecánica** y **Añadir mod** cambian el juego, **Duplicar** hace tu propia copia (y ofrece copiar las partidas), **JSON** muestra la definición en crudo, **Guardar** y **Restablecer** conservan o descartan los cambios, **Aplicar colección** instala y activa lo que enumera el plan. Un juego integrado que hayas cambiado se restaura con **Restaurar la integrada**; tus partidas se quedan.
Los cambios de un juego se aplican la próxima vez que arranque. Las partidas siguen sirviendo cuando cambian sus mods: los datos de los mods que salieron del juego se guardan aparte y vuelven con el mod.
## Si algo falla
1. **Un mod no aparece en un juego.** Comprueba que está activado y que la colección del juego lo incluye (abre **Editar esquema**).
2. **«No se pudo instalar».** Revisa la conexión a internet y prueba otra vez **Actualizar catálogo**.
3. **Una tarjeta dice Faltan o Problemas.** Abre su esquema y la pestaña **Comprobar**: enumera los mods que faltan, las diferencias de versión y los conflictos.
4. **Conflicto de catálogos.** El mismo mod y versión está publicado con contenido distinto por dos catálogos; fíate del catálogo en el que confíes.
5. **No hay sección de documentación.** El mod todavía no tiene guía en `docs/`; en su lugar se muestra la descripción del manifiesto.
+81
View File
@@ -0,0 +1,81 @@
# Mod Manager — MOD、ゲーム、そのドキュメント
Mod Manager はメインメニューの **MOD とコレクション** 画面です。どの MOD がインストールされているか、それぞれが何をするのかを確認し、オンラインカタログから MOD をインストール・更新し、無効にし、でき上がりのゲームの中身を見て、どの MOD で構成されているかを確かめられます。
常にメニューとともに読み込まれるシェルの一部なので、ゲームを始める前から使えます。
## 開き方
メインメニューで **MOD とコレクション** を選びます。画面には二つのタブがあります:
- **MOD** — ゲームが知っているすべての MOD:インストール済みのものとカタログが提案するもの。
- **コレクション** — でき上がりのゲームと、それぞれを構成する MOD の構成。
**← メニュー** でメインメニューに戻ります。右上の **?** ボタン、または **F1** でコンテキストヒントが有効になります。コントロールにマウスを乗せると働きが分かり、クリックでヒントを固定し、**Esc** で終わります。
## MOD を見て回る
![MOD タブ:左にフィルター、カバー付き MOD カードのグリッド、ヒントが開いた検索ボックス、並べ替えとカタログを更新ボタン。](assets/mods-list.jpg)
各カードには、MOD のカバー、タイトル、作者、出どころのカタログ、使っているゲームの数(**4 個のコレクションに含まれる**)、状態とバージョン、そしてオン/オフのスイッチが並びます。
- **MOD を検索...** はタイトル・作者・識別子で絞り込みます。
- **名前順 / 作者順 / 状態順** がリストを並べ替えます。
- **フィルター** は左側で、**状態**(未インストール、インストール済み、更新あり、古いバージョンあり、ローカルで変更、無効)と**カテゴリー**(ストーリー、ゲームプレイ、ビジュアル、オーディオ、ライブラリ、エンジン、ツールなど)でリストを狭めます。**リセット**でフィルターと検索を解除します。
- **カタログを更新** は公開中 MOD の最新一覧をダウンロードします。
*ローカルで変更* は、インストール済みのファイルがカタログの同じバージョンのコピーと違うことを意味します。たとえば自分で編集中の MOD などです。
## MOD の詳細を見る
カードをクリックするとページが開きます:カバー、バージョン選択、アクション、manifest から来る短い説明、そして MOD が用意していれば**ドキュメント** — この文書のような完全なガイドです。
![ある MOD のページ:Audio Engine のカバーとバージョン、Reinstall と Disable のボタン、"in 4 collections"、説明、そして言語セレクターつきの Documentation セクション。](assets/mod-page.jpg)
ドキュメントは**インストール済み**バージョンのガイドです。バージョン選択で別のバージョンを選ぶと、下のガイドが依然としてインストール済み版のものであることを注意書きが教えてくれます。
**ドキュメントの言語。** 見出し「ドキュメント」の右にあるセレクターは **自動 · UIの言語** で始まります。ガイドはゲームのインターフェース言語に従います。その言語のガイドが MOD にない場合、マネージャーは MOD の既定のガイド、次に英語、そして最初に見つかったものを表示します。ガイドの上の行が実際に表示している言語(たとえば `EN`)を示します。セレクターで言語を自分で選ぶこともできます。ガイドを一つも持たない MOD は、manifest の説明を表示し続けます。
MOD 作者向け:ガイドは `docs/en.md` に(翻訳は `docs/<言語>.md` か `docs/default.md`)、画像はその隣に置きます。たとえば `docs/assets/screen.png` を置いて `assets/screen.png` と参照します。ビューワーは見出し・段落・リスト・引用・コードブロック・リンク・PNG/JPEG/WebP 画像を表示します。HTML、表、アニメーションは実行しません。
## インストール、更新、無効化、削除
MOD ページのボタンとカードのスイッチ:
- **インストール / 更新 / ダウングレード / 再インストール** は選んだバージョンをカタログからユーザーフォルダー(`user://mods/`)へダウンロードします。
- **無効にする / 有効にする**(またはカードのスイッチ)が MOD を読み込むかどうかを決めます。無効の MOD もファイルとセーブ内のデータは残ります。
- **削除** は確認のうえダウンロード済みの MOD を取り除きます。
いずれの操作の後も **変更はゲームの再起動後に反映されます** というバナーが出ます。Mod Manager 自身はこの画面から削除も無効化もできません。
**インストール済みは使用中ではない。** ゲームはコレクションに名前のある MOD とメニューシェルを読み込みます。インストールは MOD を利用可能にするだけです。ゲームに現れるのは、そのゲームのコレクションが MOD を含むときです。
## ゲームとコレクション
**コレクション** タブはまずでき上がりのゲームを示します。ゲームとは *コレクション* のこと:名前のついた MOD のセットと、開始場所です。
![ゲームギャラリー:Cthangover、My Novel、RPG Plus のカード。それぞれ "Ready to play" で Mods 32/32、11/11、36/36、構成を編集ボタンつき。](assets/games-gallery.jpg)
カードは、ゲームに必要なものがすべて揃っているかを教えます(**プレイできます**、または **N 個不足** / **インストール(N)** / **有効化(N)**)。一つのボタンで不足分をインストールまたは有効化します。**構成を編集** はゲームの中身を開き、**+ 自分のゲームを作る** は新しいコレクションを始めます。
### ゲームの構成
![My Novel の構成:Story、Assets、2D engine のブロックとそれぞれの MOD、各依存関係からそれを必要とする MOD への矢印、そして My Novel のバージョン要件を表示するインスペクター。](assets/collection-scheme.jpg)
構成図はゲームの *メカニクス*(Story、Assets、2D engine などのブロック)とその中の MOD を示します。矢印は依存関係から、それを必要とする MOD へ向かいます。カードの位置とブロックは読むためのもの:ロード順序は決めません。
ブロックか MOD を選ぶと **インスペクター** に表示されます。そこでゲームが要求するバージョンを変えられます:**任意**、**以上**、**固定**、**ブランチ**(`^1` なら 1.x すべて)など。これはゲーム側の要求であり、インストール済みのバージョンではありません。**チェック** は問題を一覧し、**計画** はコレクション適用で何がインストール・有効化されるかを示します。
![要件 "Exactly 999.0.0" がインストール済み 1.0.0 と合わない MOD のインスペクター:要件とインストール済みバージョンが赤で表示されます。](assets/version-mismatch.jpg)
構成図のその他のボタン:**+ メカニクス** と **MOD を追加** がゲームを変え、**複製** が自分のコピーを作り(セーブのコピーも提案します)、**JSON** が生の定義を示し、**保存** と **リセット** が変更を残すか捨て、**コレクションを適用** が計画どおりにインストールと有効化を行います。変更した組み込みのゲームは **組み込み版に戻す** で元に戻せます。セーブは残ります。
ゲームへの変更は次回の起動で反映されます。ゲームのセーブは MOD が変わっても使えます:ゲームから離れた MOD のデータは脇に保管され、MOD とともに戻ります。
## うまく動かないとき
1. **MOD がゲームに現れない。** 有効になっているか、ゲームのコレクションに含まれているか確認してください(**構成を編集**を開く)。
2. **「インストールできませんでした」。** インターネット接続を確認し、**カタログを更新** をもう一度試してください。
3. **カードに「不足」や「問題」と出る。** その構成と **チェック** タブを開いてください:足りない MOD、バージョンのずれ、競合が一覧が出ます。
4. **カタログの競合。** 同じ MOD とバージョンが異なる内容で二つのカタログに公開されています。信頼できるカタログの方を使ってください。
5. **ドキュメントのセクションがない。** その MOD にはまだ `docs/` のガイドがありません。manifest の説明が代わりに表示されます。
+81
View File
@@ -0,0 +1,81 @@
# Mod Manager — 모드, 게임, 그리고 모드 문서
Mod Manager는 메인 메뉴의 **모드와 컬렉션** 화면입니다. 어떤 모드가 설치되어 있는지, 각 모드가 무엇을 하는지 살펴보고, 온라인 카탈로그에서 모드를 설치·업데이트하고, 모드를 끄고, 만들어진 게임의 속을 들여다보며 어떤 모드로 이루어져 있는지 확인할 수 있습니다.
메뉴와 함께 항상 로드되는 셸의 일부이므로 게임을 시작하기 전에도 동작합니다.
## 열기
메인 메뉴에서 **모드와 컬렉션**을 고르세요. 화면에는 탭이 둘 있습니다:
- **모드** — 게임이 아는 모든 모드: 설치된 것과 카탈로그가 내놓은 것.
- **컬렉션** — 만들어진 게임들과 각 게임을 이룬 모드 구성.
**← 메뉴**는 메인 메뉴로 돌아갑니다. 오른쪽 위의 **?** 버튼이나 **F1**은 상황별 힌트를 켭니다. 컨트롤에 포인터를 올리면 하는 일이 보이고, 클릭하면 힌트가 고정되며, **Esc**로 나옵니다.
## 모드 둘러보기
![모드 탭: 왼쪽 필터, 표지가 붙은 모드 카드 격자, 힌트가 열린 검색 상자, 정렬과 Refresh catalog 버튼.](assets/mods-list.jpg)
각 카드에는 모드의 표지, 제목, 제작자, 나온 카탈로그, 몇 개의 게임이 쓰는지(**컬렉션 4개에 포함**), 상태와 버전, 그리고 켜기/끄기 스위치가 나옵니다.
- **모드 검색...** — 제목, 제작자 또는 식별자로 걸러 봅니다.
- **이름순 / 작성자순 / 상태순** — 목록을 정렬합니다.
- **필터**는 왼쪽에서 목록을 **상태**(설치 안 됨, 설치됨, 업데이트 있음, 이전 버전 있음, 로컬에서 수정됨, 비활성화됨)와 **분류**(스토리, 게임플레이, 비주얼, 오디오, 라이브러리, 엔진, 도구 등)로 좁힙니다. **초기화**는 필터와 검색을 지웁니다.
- **카탈로그 새로 고침**은 공개된 모드의 현재 목록을 내려받습니다.
*로컬에서 수정됨*은 설치된 파일이 카탈로그의 같은 버전 사본과 다르다는 뜻입니다. 예컨대 직접 고치고 있는 모드이지요.
## 모드 읽어 보기
카드를 클릭하면 페이지가 열립니다: 표지, 버전 선택, 작업들, 모드 manifest의 짧은 설명, 그리고 모드가 갖추고 있다면 **문서** — 이 문서 같은 완전한 안내서입니다.
![모드 페이지: Audio Engine의 표지와 버전, Reinstall과 Disable 버튼, "in 4 collections", 설명, 그리고 언어 선택기가 있는 Documentation 섹션.](assets/mod-page.jpg)
문서는 **설치된** 버전의 안내서입니다. 버전 선택기에서 다른 버전을 고르면, 아래 안내서가 여전히 설치된 버전의 것이라는 알림이 떠옵니다.
**문서 언어.** 문서 제목 오른쪽 선택기는 **자동 · 인터페이스 언어**로 시작합니다. 안내서는 게임 인터페이스 언어를 따릅니다. 그 언어의 안내서가 모드에 없으면 관리자는 모드의 기본 안내서, 그다음 영어, 그다음 처음 발견되는 문서를 보여 줍니다. 안내서 위의 한 줄이 실제 표시 언어(예: `EN`)를 알려 줍니다. 선택기에서 직접 언어를 고를 수도 있습니다. 안내서가 하나도 없는 모드는 manifest 설명을 계속 보여 줍니다.
모드 제작자를 위해: 안내서는 `docs/en.md`에(번역은 `docs/<언어>.md` 또는 `docs/default.md`), 이미지는 곁에 두세요. 예컨대 `docs/assets/screen.png`로 두고 `assets/screen.png`로 참조합니다. 뷰어는 제목, 문단, 목록, 인용, 코드 블록, 링크, PNG/JPEG/WebP 이미지를 이해합니다. HTML, 표, 애니메이션은 실행하지 않습니다.
## 설치, 업데이트, 끄기, 삭제
모드 페이지의 버튼과 카드의 스위치:
- **설치 / 업데이트 / 다운그레이드 / 다시 설치**는 고른 버전을 카탈로그에서 사용자 폴더(`user://mods/`)로 내려받습니다.
- **끄기 / 켜기**(또는 카드의 스위치)는 모드를 불러올지 정합니다. 꺼진 모드도 파일과 저장 속 데이터는 유지됩니다.
- **삭제**는 확인 후 내려받은 모드를 제거합니다.
이들 작업 뒤에는 **변경 사항은 게임을 다시 시작한 뒤에 적용됩니다** 배너가 뜹니다. Mod Manager 자신은 이 화면에서 삭제하거나 끌 수 없습니다.
**설치됨은 사용 중이 아닙니다.** 게임은 자기 컬렉션이 이름을 적은 모드와 메뉴 셸을 불러옵니다. 설치는 모드를 이용 가능하게 할 뿐입니다. 그 게임의 컬렉션이 모드를 담을 때 비로소 게임에 나타납니다.
## 게임과 컬렉션
**컬렉션** 탭은 먼저 만들어진 게임을 보여 줍니다. 게임이란 *컬렉션*입니다: 이름 붙은 모드 세트와 시작 장소.
![게임 갤러리: Cthangover, My Novel, RPG Plus 카드, 각각 "Ready to play"와 Mods 32/32, 11/11, 36/36, 그리고 Edit scheme 버튼.](assets/games-gallery.jpg)
카드는 게임에 필요한 모든 것이 설치됐는지 알려 줍니다(**플레이 준비 완료**, 또는 **N개 부족** / **설치 (N)** / **켜기 (N)**). 한 버튼으로 빠진 것을 설치하거나 켭니다. **구성 편집**은 게임의 구조를 열고, **+ 나만의 게임 만들기**는 새 컬렉션을 시작합니다.
### 게임 구성
![My Novel의 구성: Story, Assets, 2D engine 블록과 그 모드들, 각 의존성에서 이를 필요로 하는 모드로 가는 화살표, 그리고 My Novel의 버전 요구 사항을 보여 주는 인스펙터.](assets/collection-scheme.jpg)
구성도는 게임의 *메카닉*(Story, Assets, 2D engine 같은 블록)과 그 안의 모드를 보여 줍니다. 화살표는 의존성에서 그것을 필요로 하는 모드로 향합니다. 카드 위치와 블록은 읽기용일 뿐: 로드 순서를 정하지 않습니다.
블록이나 모드를 고르면 **인스펙터**에 나타납니다. 거기서 게임이 요구하는 버전을 바꿀 수 있습니다: **모든 버전**, **최소 버전**, **정확한 버전**, **버전 분기**(`^1`은 1.x 모두 허용) 등. 이것은 게임의 요구 사항이지 설치된 버전이 아닙니다. **검사**는 문제를 나열하고, **계획**은 컬렉션 적용이 무엇을 설치하고 켤지 보여 줍니다.
![요구 사항 "Exactly 999.0.0"이 설치된 1.0.0과 맞지 않는 모드의 인스펙터: 요구 사항과 설치 버전이 빨간색으로 표시됩니다.](assets/version-mismatch.jpg)
구성도의 다른 버튼들: **+ 메카닉**과 **모드 추가**는 게임을 바꾸고, **복제**는 자기 사본을 만들며(저장 복사도 제안합니다), **JSON**은 원본 정의를 보여 주고, **저장**과 **초기화**는 변경을 남기거나 버리며, **컬렉션 적용**은 계획에 나온 것을 설치하고 켭니다. 바꾼 기본 제공 게임은 **기본 제공 버전으로 되돌리기**로 복원할 수 있습니다. 저장 파일은 남습니다.
게임 변경은 다음 시작 때 적용됩니다. 게임의 저장은 모드가 바뀌어도 쓸 수 있습니다: 게임을 떠난 모드의 데이터는 따로 보관되고 모드와 함께 돌아옵니다.
## 문제가 생겼을 때
1. **모드가 게임에 나타나지 않는다.** 켜져 있는지, 그 게임의 컬렉션이 담고 있는지 확인하세요(**구성 편집**을 여세요).
2. **"설치 실패".** 인터넷 연결을 확인하고 **카탈로그 새로 고침**을 다시 시도하세요.
3. **게임 카드에 부족 또는 문제가 표시된다.** 그 구성과 **검사** 탭을 여세요: 빠진 모드, 버전 차이, 충돌이 나열됩니다.
4. **카탈로그 충돌.** 같은 모드와 버전이 두 카탈로그에 다른 내용으로 공개되었습니다. 신뢰하는 카탈로그를 따르세요.
5. **문서 섹션이 없다.** 그 모드에는 아직 `docs/` 안내서가 없습니다. 대신 manifest 설명이 보입니다.
+81
View File
@@ -0,0 +1,81 @@
# Mod Manager — моды, игры и их документация
Mod Manager — это экран **Моды и коллекции** главного меню. Здесь видно, какие моды установлены, что делает каждый из них, отсюда устанавливают и обновляют моды из онлайн-каталога, выключают моды и заглядывают внутрь готовых игр, чтобы понять, из каких модов они собраны.
Он входит в оболочку, которая всегда загружается вместе с меню, поэтому работает ещё до запуска любой игры.
## Как открыть
В главном меню выберите **Моды и коллекции**. У экрана две вкладки:
- **Моды** — все моды, которые известны игре: установленные и предложенные каталогом.
- **Коллекции** — готовые игры и схема модов, из которых собрана каждая.
Кнопка **← Меню** возвращает в главное меню. Кнопка **?** в правом верхнем углу, или **F1**, включает контекстные подсказки: наведите курсор на элемент управления, чтобы узнать, что он делает, щёлкните, чтобы закрепить подсказку, **Esc** — чтобы уйти.
## Обзор модов
![Вкладка «Моды»: фильтры слева, сетка карточек модов с обложками, поле поиска с открытой подсказкой, сортировка и кнопка Refresh catalog.](assets/mods-list.jpg)
На каждой карточке — обложка, название, автор, каталог, откуда мод, сколькими играми он используется (**в 4 коллекциях**), состояние и версия, а также переключатель вкл/выкл.
- **Поиск модов...** фильтрует по названию, автору или идентификатору.
- **По имени / По автору / По статусу** сортируют список.
- **Фильтры** слева сужают список по **статусу** (Не установлен, Установлен, Есть обновление, Доступна старая версия, Изменён локально, Выключен) и по **категориям** — например Сюжет, Геймплей, Визуал, Аудио, Библиотека, Движок, Инструменты. **Сбросить** очищает фильтры и поиск.
- **Обновить каталог** скачивает актуальный список опубликованных модов.
*Изменён локально* значит, что установленные файлы отличаются от копии той же версии в каталоге, — например, мод, который вы правите сами.
## Описание мода
Щёлкните по карточке, чтобы открыть её страницу: обложка, выбор версии, действия, краткое описание из манифеста мода и, если мод его предоставляет, **Документация** — подробное руководство вроде этого.
![Страница мода: обложка и версия Audio Engine, кнопки Reinstall и Disable, «in 4 collections», описание и раздел Documentation с выбором языка.](assets/mod-page.jpg)
Документация — это руководство **установленной** версии. Если выбрать в селекторе другую версию, пометка напомнит, что руководство ниже по-прежнему относится к установленной.
**Язык документации.** Селектор справа от заголовка «Документация» стартует на значении **Авто · язык интерфейса**: руководство следует за языком интерфейса игры. Если руководства на этом языке у мода нет, менеджер покажет руководство мода по умолчанию, затем английское, затем первое доступное; строка над руководством называет реально показанный язык (например, `EN`). Язык можно выбрать и вручную. У мода без руководств вместо них остаётся описание из манифеста.
Авторам модов: положите руководство в `docs/en.md` (переводы — в `docs/<язык>.md`, либо `docs/default.md`), а картинки рядом, например `docs/assets/screen.png`, со ссылкой `assets/screen.png`. Просмотрщик понимает заголовки, абзацы, списки, цитаты, блоки кода, ссылки и картинки PNG/JPEG/WebP; HTML, таблицы и анимации он не исполняет.
## Установка, обновление, выключение, удаление
Кнопки на странице мода и переключатель на карточке:
- **Установить / Обновить / Понизить / Переустановить** скачивают выбранную версию из каталога в вашу пользовательскую папку (`user://mods/`).
- **Выключить / Включить** (или переключатель на карточке) решают, загружается ли мод. Выключенный мод хранит файлы и данные в ваших сохранениях.
- **Удалить** стирает скачанный мод после подтверждения.
После любого из этих действий появляется плашка **Изменения вступят в силу после перезапуска игры**. Сам Mod Manager нельзя удалить или выключить с его собственного экрана.
**Установлен — ещё не значит используется.** Игра загружает моды, названные в её коллекции, плюс оболочку меню. Установка делает мод доступным; в игре он появится, только когда коллекция этой игры его включит.
## Игры и коллекции
Вкладка **Коллекции** сначала показывает готовые игры. Игра — это *коллекция*: поименованный набор модов и место, с которого начинается игра.
![Галерея игр: карточки Cthangover, My Novel и RPG Plus, у каждой «Ready to play», счетчики модов 32/32, 11/11 и 36/36 и кнопки Edit scheme.](assets/games-gallery.jpg)
Карточка сообщает, установлено ли всё, что нужно игре (**Готова к игре**, либо **Не хватает N** / **Установить (N)** / **Включить (N)**). Одна кнопка устанавливает или включает недостающее. **Изменить схему** открывает устройство игры; **+ Собрать свою игру** начинает новую коллекцию.
### Схема игры
![Схема My Novel: блоки Story, Assets и 2D engine со своими модами, стрелки от каждой зависимости к моду, которому она нужна, и инспектор с требованием к версии My Novel.](assets/collection-scheme.jpg)
Схема показывает *механики* игры (блоки вроде Story, Assets, 2D engine) и моды внутри них. Стрелки идут от зависимости к моду, которому она нужна. Положение карточек и сами блоки нужны только для чтения: порядок загрузки они не задают.
Выберите блок или мод — он откроется в **Инспекторе**. Там меняется версия, которую требует игра: **Любая**, **Не ниже**, **Точно**, **Ветка** (`^1` принимает любую 1.x) и другие. Это требование игры, а не версия, установленная у вас. **Проверка** перечисляет проблемы; **План** показывает, что применит установка коллекции.
![Инспектор мода, у которого требование «Exactly 999.0.0» не совпадает с установленной версией 1.0.0: требование и установленная версия показаны красным.](assets/version-mismatch.jpg)
Прочие кнопки схемы: **+ Механика** и **Добавить мод** меняют игру, **Дублировать** делает вашу копию (и предлагает скопировать сохранения), **JSON** показывает исходное описание, **Сохранить** и **Сбросить** оставляют или отбрасывают правки, **Применить коллекцию** устанавливает и включает то, что перечислено в плане. Изменённую встроенную игру можно вернуть кнопкой **Вернуть встроенную**; сохранения останутся на месте.
Изменения игры применятся при её следующем запуске. Сохранения игры остаются рабочими при смене её модов: данные модов, покинувших игру, откладываются в сторону и возвращаются вместе с модом.
## Если что-то пошло не так
1. **Мода нет в игре.** Проверьте, что он включён, и что коллекция игры его содержит (откройте **Изменить схему**).
2. **«Не удалось установить».** Проверьте подключение к интернету и снова нажмите **Обновить каталог**.
3. **На карточке игры «Не хватает» или «Проблемы».** Откройте её схему и вкладку **Проверка**: там перечислены недостающие моды, несовпадения версий и конфликты.
4. **Конфликт каталогов.** Один и тот же мод и версия опубликованы с разным содержимым в двух каталогах; предпочитайте каталог, которому доверяете.
5. **Нет раздела документации.** У мода пока нет руководства в `docs/`; вместо него показано описание из манифеста.
+81
View File
@@ -0,0 +1,81 @@
# Mod Manager — mod, game và tài liệu của chúng
Mod Manager là màn hình **Mod và bộ sưu tập** của menu chính. Dùng nó để xem mod nào đã cài, đọc xem mỗi mod làm gì, cài hoặc cập nhật mod từ danh mục trực tuyến, tắt mod, và soi vào các game làm sẵn để thấy chúng được ghép từ những mod nào.
Nó thuộc lớp vỏ luôn được tải cùng menu, nên hoạt động ngay cả trước khi bạn bắt đầu một game.
## Mở nó
Trong menu chính chọn **Mod và bộ sưu tập**. Màn hình có hai thẻ:
- **Mod** — mọi mod mà game biết: những mod đã cài và những mod danh mục đề nghị.
- **Bộ sưu tập** — các game làm sẵn, và sơ đồ mod cấu thành từng game.
**← Menu** quay về menu chính. Nút **?** ở góc trên phải, hoặc **F1**, bật gợi ý theo ngữ cảnh: đưa chuột lên một điều khiển để xem nó làm gì, nhấp để ghim gợi ý, **Esc** để thoát.
## Duyệt mod
![Thẻ Mod: bộ lọc bên trái, lưới thẻ mod có ảnh bìa, ô tìm kiếm đang mở gợi ý, sắp xếp và nút Refresh catalog.](assets/mods-list.jpg)
Mỗi thẻ cho thấy ảnh bìa của mod, tiêu đề, tác giả, danh mục mà nó đến từ, bao nhiêu game dùng nó (**trong 4 bộ sưu tập**), trạng thái và phiên bản, cùng một công tắc bật/tắt.
- **Tìm mod...** lọc theo tiêu đề, tác giả hoặc định danh.
- **Theo tên / Theo tác giả / Theo trạng thái** sắp xếp danh sách.
- **Bộ lọc** bên trái thu hẹp danh sách theo **Trạng thái** (Chưa cài, Đã cài, Có bản cập nhật, Có phiên bản cũ hơn, Đã sửa cục bộ, Đã tắt) và theo **Thể loại** như Cốt truyện, Lối chơi, Hình ảnh, Âm thanh, Thư viện, Engine và Công cụ. **Đặt lại** xóa bộ lọc và tìm kiếm.
- **Làm mới danh mục** tải về danh sách mod đang xuất bản hiện thời.
*Đã sửa cục bộ* nghĩa là các tệp đã cài khác với bản sao cùng phiên bản trên danh mục — ví dụ, một mod bạn đang tự sửa.
## Đọc về một mod
Nhấp vào một thẻ để mở trang của nó: ảnh bìa, bộ chọn phiên bản, các thao tác, mô tả ngắn từ manifest của mod và, khi mod có cung cấp, **Tài liệu** của nó — một cẩm nang đầy đủ như bản bạn đang đọc.
![Một trang mod: ảnh bìa và phiên bản của Audio Engine, các nút Reinstall và Disable, "in 4 collections", phần mô tả, và khối Documentation với bộ chọn ngôn ngữ.](assets/mod-page.jpg)
Tài liệu là cẩm nang của phiên bản **đã cài**. Nếu bạn chọn phiên bản khác trong bộ chọn, một dòng nhắc sẽ cho biết hướng dẫn bên dưới vẫn thuộc về bản đã cài.
**Ngôn ngữ tài liệu.** Bộ chọn bên phải tiêu đề Tài liệu khởi đầu ở **Tự động · ngôn ngữ giao diện**: cẩm nang theo ngôn ngữ giao diện của game. Nếu mod không có hướng dẫn bằng ngôn ngữ đó, trình quản lý hiển thị hướng dẫn mặc định của mod, rồi tiếng Anh, rồi tài liệu đầu tiên tìm được; dòng phía trên cẩm nang ghi ngôn ngữ thực tế đang hiện (ví dụ `EN`). Bạn cũng có thể tự chọn ngôn ngữ trong bộ chọn. Mod không có hướng dẫn nào sẽ tiếp tục hiển thị mô tả trong manifest.
Cho tác giả mod: đặt cẩm nang vào `docs/en.md` (bản dịch ở `docs/<ngôn ngữ>.md`, hoặc `docs/default.md`) và ảnh cạnh đó, ví dụ `docs/assets/screen.png`, được dẫn như `assets/screen.png`. Trình xem hiểu tiêu đề, đoạn văn, danh sách, trích dẫn, khối mã, liên kết và ảnh PNG/JPEG/WebP; nó không chạy HTML, bảng hay hoạt ảnh.
## Cài, cập nhật, tắt, xóa
Các nút trên trang mod và công tắc trên thẻ:
- **Cài đặt / Cập nhật / Hạ phiên bản / Cài lại** tải phiên bản đã chọn từ danh mục về thư mục người dùng của bạn (`user://mods/`).
- **Tắt / Bật** (hoặc công tắc trên thẻ) quyết định mod có được nạp hay không. Mod bị tắt vẫn giữ tệp và dữ liệu trong các bản lưu của bạn.
- **Xóa** gỡ một mod đã tải về sau khi xác nhận.
Sau bất kỳ thao tác nào ở trên, một dải thông báo xuất hiện: **Thay đổi có hiệu lực sau khi khởi động lại game**. Chính Mod Manager không thể bị xóa hay tắt từ màn hình của nó.
**Đã cài không có nghĩa là đang được dùng.** Một game nạp các mod được nêu trong bộ sưu tập của nó, cộng lớp vỏ menu. Cài mod chỉ làm mod khả dụng; nó xuất hiện trong game khi bộ sưu tập của game đó bao gồm nó.
## Game và bộ sưu tập
Thẻ **Bộ sưu tập** đầu tiên hiển thị các game làm sẵn. Một game là một *bộ sưu tập*: một bộ mod có tên cùng địa điểm bắt đầu.
![Phòng trưng bày game: các thẻ Cthangover, My Novel và RPG Plus, đều "Ready to play" với Mods 32/32, 11/11 và 36/36, và nút Edit scheme.](assets/games-gallery.jpg)
Thẻ cho bạn biết mọi thứ game cần đã được cài chưa (**Sẵn sàng để chơi**, hoặc **Thiếu N** / **Cài (N)** / **Bật (N)**). Một nút cài hoặc bật phần còn thiếu. **Sửa sơ đồ** mở cấu trúc của game; **+ Tự làm game của bạn** bắt đầu một bộ sưu tập mới.
### Sơ đồ của một game
![Sơ đồ của My Novel: các khối Story, Assets và 2D engine cùng mod của chúng, mũi tên từ mỗi phụ thuộc tới mod cần nó, và thanh thuộc tính cho thấy yêu cầu phiên bản của My Novel.](assets/collection-scheme.jpg)
Sơ đồ cho thấy các *cơ chế* của game (những khối như Story, Assets, 2D engine) và các mod bên trong. Mũi tên đi từ một phụ thuộc tới mod cần nó. Vị trí thẻ và các khối chỉ để đọc: chúng không quyết định thứ tự tải.
Chọn một khối hoặc một mod để xem nó trong **Thanh thuộc tính**. Ở đó bạn có thể đổi phiên bản mà game yêu cầu — **Bất kỳ**, **Tối thiểu**, **Chính xác**, **Nhánh** (`^1` chấp nhận mọi 1.x) và các loại khác. Đây là yêu cầu của game, không phải phiên bản bạn đã cài. **Kiểm tra** liệt kê vấn đề; **Kế hoạch** cho thấy việc áp dụng bộ sưu tập sẽ cài hay bật gì.
![Thanh thuộc tính của một mod có yêu cầu "Exactly 999.0.0" không khớp bản đã cài 1.0.0: yêu cầu và phiên bản đã cài hiện màu đỏ.](assets/version-mismatch.jpg)
Các nút khác của sơ đồ: **+ Cơ chế** và **Thêm mod** thay đổi game, **Nhân bản** tạo bản sao của riêng bạn (và đề nghị sao chép bản lưu), **JSON** cho thấy nội dung gốc, **Lưu** và **Đặt lại** giữ hoặc bỏ thay đổi, **Áp dụng bộ sưu tập** cài và bật những gì kế hoạch liệt kê. Game tích hợp từng bị bạn thay đổi có thể khôi phục bằng **Khôi phục bản tích hợp**; bản lưu của bạn vẫn còn.
Thay đổi với một game được áp dụng vào lần chạy tiếp theo. Bản lưu của game vẫn dùng được khi mod thay đổi: dữ liệu của mod rời khỏi game được giữ riêng và quay lại cùng với mod.
## Khi có trục trặc
1. **Một mod không xuất hiện trong game.** Kiểm tra nó đã bật chưa và bộ sưu tập của game có chứa nó không (mở **Sửa sơ đồ**).
2. **"Cài đặt thất bại".** Kiểm tra kết nối internet và thử lại **Làm mới danh mục**.
3. **Thẻ game báo Thiếu hoặc Vấn đề.** Mở sơ đồ của nó và thẻ **Kiểm tra**: nơi liệt kê mod còn thiếu, khác phiên bản và xung đột.
4. **Xung đột danh mục.** Cùng một mod và phiên bản được hai danh mục phát hành với nội dung khác nhau; hãy tin danh mục mà bạn tin tưởng.
5. **Không có mục tài liệu.** Mod chưa có cẩm nang trong `docs/`; thay vào đó là mô tả trong manifest.
+81
View File
@@ -0,0 +1,81 @@
# Mod Manager — 模组、游戏及其文档
Mod Manager 是主菜单中的**模组与合集**屏幕。在这里可以查看装了哪些模组、了解每个模组的用途、从在线目录安装或更新模组、停用模组,也可以翻看现成游戏的内部构成,弄清它们由哪些模组搭建。
它属于随菜单一起始终加载的外壳,因此在启动任何游戏之前就能使用。
## 打开方式
在主菜单选择**模组与合集**。该屏幕有两个标签页:
- **模组** — 游戏认识的所有模组:已安装的和目录提供的。
- **合集** — 现成的游戏,以及每款游戏由哪些模组搭成的结构。
**← 菜单**返回主菜单。右上角的 **?** 按钮,或按 **F1**,开启上下文提示:把鼠标悬停在控件上看它的作用,点击可固定提示,按 **Esc** 退出。
## 浏览模组
![模组标签页:左侧是筛选器,中间是带封面图的模组卡片网格,搜索框旁开着提示,还有排序和 Refresh catalog 按钮。](assets/mods-list.jpg)
每张卡片显示模组的封面、标题、作者、来源目录、有多少游戏在用它(**在 4 个合集中**)、状态与版本,以及一个开关。
- **搜索模组...** 按标题、作者或标识符过滤。
- **按名称 / 按作者 / 按状态** 调整列表排序。
- 左侧的**筛选**按**状态**(未安装、已安装、有可用更新、有旧版本可用、本地已修改、已停用)和**分类**(例如剧情、玩法、视觉、音频、库、引擎、工具)收窄列表。**重置**清除筛选和搜索。
- **刷新目录**下载当前已发布模组的列表。
*本地已修改*表示已安装的文件与目录上同一版本的副本不一致——例如你正在自己改动的模组。
## 查看模组介绍
点击卡片打开它的页面:封面、版本选择器、各种操作、来自 manifest 的简短描述,以及模组提供时的**文档**——一份像这样的完整指南。
![一个模组页面:Audio Engine 的封面和版本、Reinstall 和 Disable 按钮、“in 4 collections”、描述,以及带语言选择器的 Documentation 区块。](assets/mod-page.jpg)
文档是**已安装**版本的指南。如果在版本选择器里选了别的版本,提示会提醒你:下面的指南仍属于已安装的那一版。
**文档语言。** 文档标题右侧的选择器默认为**自动 · 界面语言**:指南跟随游戏界面语言。若模组没有该语言的指南,管理器会先显示模组的默认指南,再退回英文,最后退回第一份可用的指南;指南上方一行会标明实际显示的语言(例如 `EN`)。你也可以自己在选择器里挑语言。没有任何指南的模组会继续显示 manifest 描述。
给模组作者:把指南放进 `docs/en.md`(译文放 `docs/<语言>.md`,或 `docs/default.md`),图片放在旁边,例如 `docs/assets/screen.png`,以 `assets/screen.png` 引用。查看器支持标题、段落、列表、引用、代码块、链接和 PNG/JPEG/WebP 图片;不会运行 HTML、表格或动画。
## 安装、更新、停用、删除
模组页面上的按钮和卡片上的开关:
- **安装 / 更新 / 降级 / 重新安装**把所选版本从目录下载到你的用户目录(`user://mods/`)。
- **停用 / 启用**(或卡片上的开关)决定模组是否被加载。停用的模组保留文件和存档中的数据。
- **删除**在确认后移除一个下载来的模组。
任何一项操作之后都会出现横幅:**重启游戏后更改才会生效**。Mod Manager 自己无法在自己的屏幕上被删除或停用。
**已安装不等于在用。** 游戏只加载它合集里列出的模组,外加菜单外壳。安装只是让模组可用;只有当游戏的合集包含它时,它才会进入游戏。
## 游戏与合集
**合集**标签页首先展示现成的游戏。游戏就是一个*合集*:一组有名字的模组,加上起始地点。
![游戏画廊:Cthangover、My Novel 和 RPG Plus 的卡片,均显示“Ready to play”,模组计数 32/32、11/11、36/36,以及 Edit scheme 按钮。](assets/games-gallery.jpg)
卡片会告诉你游戏需要的东西是否都装齐了(**可以开始游戏**,或**缺少 N** / **安装(N)** / **启用(N)**)。一个按钮即可安装或启用缺失内容。**编辑结构**打开游戏的内部构造;**+ 打造你自己的游戏**从新建合集开始。
### 游戏的结构
![My Novel 的结构:Story、Assets 和 2D engine 区块及其模组,从每条依赖指向需要它的模组的箭头,以及显示 My Novel 版本要求的检查器。](assets/collection-scheme.jpg)
结构图展示游戏的*机制*(诸如 Story、Assets、2D engine 的区块)和其中的模组。箭头从依赖指向需要它的模组。卡片位置和区块只是用于阅读:它们不决定加载顺序。
选中一个区块或模组,它就会出现在**检查器**里。在那里可以修改游戏要求的版本:**任意版本**、**不低于**、**精确版本**、**版本分支**(`^1` 接受任何 1.x)等。这是游戏的要求,不是你已安装的版本。**检查**列出问题;**计划**显示应用该合集会安装或启用什么。
![一个模组的检查器,其要求“Exactly 999.0.0”与已安装的 1.0.0 不符:要求和已安装版本都标红。](assets/version-mismatch.jpg)
结构图的其他按钮:**+ 机制**和**添加模组**修改游戏,**复制**生成你自己的副本(并提议一并复制存档),**JSON**显示原始定义,**保存**和**重置**保留或放弃更改,**应用合集**安装并启用计划中列出的内容。改过的内置游戏可用**恢复内置版本**还原;存档保留。
对游戏的更改在下次启动时生效。游戏的存档在模组变动后依然可用:离开游戏的模组数据被单独保存,随模组回归。
## 出现问题时
1. **某个模组没有进入游戏。** 检查它是否已启用、游戏的合集是否包含它(打开**编辑结构**)。
2. **“安装失败”。** 检查网络连接,再点一次**刷新目录**。
3. **游戏卡片显示缺少或有 问题。** 打开它的结构和**检查**标签页:里面列出缺失的模组、版本差距和冲突。
4. **目录冲突。** 同一模组同一版本被两个目录以不同内容发布;相信你信任的那个目录。
5. **没有文档区。** 该模组还没有 `docs/` 指南;此时显示的是 manifest 描述。
+25
View File
@@ -0,0 +1,25 @@
# Collection parameter editor
mod_manager/game_settings_title = Game parameters
mod_manager/game_settings_hint = Configure this collection. After Apply, click Save; changes take effect on the next game launch.
mod_manager/game_settings_automatic = Automatic
mod_manager/game_settings_custom = Custom value
mod_manager/game_settings_unavailable = unavailable
mod_manager/game_settings_missing = This choice is unavailable in this collection. The saved value is preserved.
mod_manager/game_settings_owner_missing = The mod using this parameter is not included in the collection. Its value will be preserved.
mod_manager/game_settings_invalid = Check the format of the entered value.
mod_manager/game_settings_single_choice = Without an explicit value, the engine uses the only available choice. Select one if several are available.
mod_manager/game_settings_browse = Browse…
mod_manager/game_settings_choose = Choose
mod_manager/game_settings_search_hint = Search by name or ID
mod_manager/game_settings_advanced = Additional parameters
mod_manager/game_settings_advanced_hint = Parameters from mods without form declarations. Edit keys and values; deleting a row removes the parameter.
mod_manager/game_settings_add = Add parameter
mod_manager/game_settings_key = Parameter key
mod_manager/game_settings_value = Value
mod_manager/game_settings_remove = Remove
mod_manager/game_settings_remove_hint = Remove this parameter from the collection on Apply
mod_manager/game_settings_duplicate = Keys must be nonempty and unique.
mod_manager/game_settings_metadata_error = Could not read parameter declarations from mods:
mod_manager/hint_game_settings_title = Game parameters
mod_manager/hint_game_settings_body = Configure this collection. After Apply, click Save; changes take effect on the next game launch.
+25
View File
@@ -0,0 +1,25 @@
# Collection parameter editor
mod_manager/game_settings_title = Parámetros del juego
mod_manager/game_settings_hint = Configura esta colección. Después de Aplicar, pulsa Guardar; los cambios se aplicarán al iniciar la partida de nuevo.
mod_manager/game_settings_automatic = Automático
mod_manager/game_settings_custom = Valor personalizado
mod_manager/game_settings_unavailable = no disponible
mod_manager/game_settings_missing = Esta opción no está disponible en esta colección. Se conserva el valor guardado.
mod_manager/game_settings_owner_missing = El mod que utiliza este parámetro no está incluido en la colección. Se conservará el valor.
mod_manager/game_settings_invalid = Comprueba el formato del valor introducido.
mod_manager/game_settings_single_choice = Sin un valor explícito, el motor utiliza la única opción disponible. Si hay varias, elige una.
mod_manager/game_settings_browse = Examinar…
mod_manager/game_settings_choose = Elegir
mod_manager/game_settings_search_hint = Buscar por nombre o identificador
mod_manager/game_settings_advanced = Parámetros adicionales
mod_manager/game_settings_advanced_hint = Parámetros de mods sin descripción de formulario. Edita claves y valores; eliminar una fila quita el parámetro.
mod_manager/game_settings_add = Añadir parámetro
mod_manager/game_settings_key = Clave del parámetro
mod_manager/game_settings_value = Valor
mod_manager/game_settings_remove = Eliminar
mod_manager/game_settings_remove_hint = Quitar este parámetro de la colección al aplicar
mod_manager/game_settings_duplicate = Las claves no pueden estar vacías ni repetirse.
mod_manager/game_settings_metadata_error = No se pudieron leer las descripciones de parámetros de los mods:
mod_manager/hint_game_settings_title = Parámetros del juego
mod_manager/hint_game_settings_body = Configura esta colección. Después de Aplicar, pulsa Guardar; los cambios se aplicarán al iniciar la partida de nuevo.
+25
View File
@@ -0,0 +1,25 @@
# Collection parameter editor
mod_manager/game_settings_title = ゲームのパラメーター
mod_manager/game_settings_hint = このコレクションを設定します。適用後に保存してください。変更は次回のゲーム起動時に反映されます。
mod_manager/game_settings_automatic = 自動
mod_manager/game_settings_custom = カスタム値
mod_manager/game_settings_unavailable = 利用不可
mod_manager/game_settings_missing = この構成では利用できません。保存済みの値は保持されます。
mod_manager/game_settings_owner_missing = このパラメーターを使うMODは構成に含まれていません。値は保持されます。
mod_manager/game_settings_invalid = 入力値の形式を確認してください。
mod_manager/game_settings_single_choice = 未指定の場合は唯一の候補を使用します。候補が複数ある場合は選択してください。
mod_manager/game_settings_browse = 参照…
mod_manager/game_settings_choose = 選択
mod_manager/game_settings_search_hint = 名前またはIDで検索
mod_manager/game_settings_advanced = 追加パラメーター
mod_manager/game_settings_advanced_hint = フォーム定義のないMODのパラメーターです。キーと値を編集できます。行の削除でパラメーターを除去します。
mod_manager/game_settings_add = パラメーターを追加
mod_manager/game_settings_key = パラメーターキー
mod_manager/game_settings_value = 値
mod_manager/game_settings_remove = 削除
mod_manager/game_settings_remove_hint = 適用時にこのパラメーターをコレクションから除去
mod_manager/game_settings_duplicate = キーは空にできず、重複もできません。
mod_manager/game_settings_metadata_error = 次のMODのパラメーター定義を読めませんでした:
mod_manager/hint_game_settings_title = ゲームのパラメーター
mod_manager/hint_game_settings_body = このコレクションを設定します。適用後に保存してください。変更は次回のゲーム起動時に反映されます。
+25
View File
@@ -0,0 +1,25 @@
# Collection parameter editor
mod_manager/game_settings_title = 게임 매개변수
mod_manager/game_settings_hint = 이 컬렉션을 설정합니다. 적용 후 저장하세요. 변경 사항은 다음 게임 시작 시 반영됩니다.
mod_manager/game_settings_automatic = 자동
mod_manager/game_settings_custom = 사용자 지정 값
mod_manager/game_settings_unavailable = 사용 불가
mod_manager/game_settings_missing = 현재 컬렉션 구성에서 사용할 수 없습니다. 저장된 값은 유지됩니다.
mod_manager/game_settings_owner_missing = 이 매개변수를 사용하는 모드가 컬렉션에 없습니다. 값은 유지됩니다.
mod_manager/game_settings_invalid = 입력한 값의 형식을 확인하세요.
mod_manager/game_settings_single_choice = 값을 지정하지 않으면 유일한 선택지를 사용합니다. 여러 개면 하나를 선택하세요.
mod_manager/game_settings_browse = 찾아보기…
mod_manager/game_settings_choose = 선택
mod_manager/game_settings_search_hint = 이름 또는 ID로 검색
mod_manager/game_settings_advanced = 추가 매개변수
mod_manager/game_settings_advanced_hint = 양식 정의가 없는 모드의 매개변수입니다. 키와 값을 편집하며 행을 삭제하면 매개변수가 제거됩니다.
mod_manager/game_settings_add = 매개변수 추가
mod_manager/game_settings_key = 매개변수 키
mod_manager/game_settings_value = 값
mod_manager/game_settings_remove = 삭제
mod_manager/game_settings_remove_hint = 적용 시 컬렉션에서 이 매개변수 제거
mod_manager/game_settings_duplicate = 키는 비어 있거나 중복될 수 없습니다.
mod_manager/game_settings_metadata_error = 다음 모드의 매개변수 정의를 읽지 못했습니다:
mod_manager/hint_game_settings_title = 게임 매개변수
mod_manager/hint_game_settings_body = 이 컬렉션을 설정합니다. 적용 후 저장하세요. 변경 사항은 다음 게임 시작 시 반영됩니다.
+25
View File
@@ -0,0 +1,25 @@
# Collection parameter editor
mod_manager/game_settings_title = Параметры игры
mod_manager/game_settings_hint = Настройте выбранную коллекцию. После применения нажмите «Сохранить»; изменения вступят в силу при следующем запуске игры.
mod_manager/game_settings_automatic = Автоматически
mod_manager/game_settings_custom = Своё значение
mod_manager/game_settings_unavailable = недоступно
mod_manager/game_settings_missing = Этот вариант недоступен в текущем составе коллекции. Сохранённое значение оставлено без изменений.
mod_manager/game_settings_owner_missing = Мод, использующий этот параметр, не включён в коллекцию. Значение будет сохранено.
mod_manager/game_settings_invalid = Проверьте формат введённого значения.
mod_manager/game_settings_single_choice = Без явного значения движок выберет единственный доступный вариант. При нескольких вариантах укажите нужный.
mod_manager/game_settings_browse = Выбрать…
mod_manager/game_settings_choose = Выбрать
mod_manager/game_settings_search_hint = Поиск по названию или идентификатору
mod_manager/game_settings_advanced = Дополнительные параметры
mod_manager/game_settings_advanced_hint = Параметры сторонних модов без описания формы. Редактируйте ключи и значения; удаление строки убирает параметр.
mod_manager/game_settings_add = Добавить параметр
mod_manager/game_settings_key = Ключ параметра
mod_manager/game_settings_value = Значение
mod_manager/game_settings_remove = Удалить
mod_manager/game_settings_remove_hint = Убрать этот параметр из коллекции после применения
mod_manager/game_settings_duplicate = Ключ не должен быть пустым или повторять другой параметр.
mod_manager/game_settings_metadata_error = Не удалось прочитать описания параметров модов:
mod_manager/hint_game_settings_title = Параметры игры
mod_manager/hint_game_settings_body = Настройте выбранную коллекцию. После применения нажмите «Сохранить»; изменения вступят в силу при следующем запуске игры.
+25
View File
@@ -0,0 +1,25 @@
# Collection parameter editor
mod_manager/game_settings_title = Tham số trò chơi
mod_manager/game_settings_hint = Cấu hình bộ sưu tập này. Sau khi áp dụng, hãy lưu; thay đổi có hiệu lực khi khởi chạy trò chơi lần sau.
mod_manager/game_settings_automatic = Tự động
mod_manager/game_settings_custom = Giá trị tùy chỉnh
mod_manager/game_settings_unavailable = không khả dụng
mod_manager/game_settings_missing = Lựa chọn này không có trong cấu hình hiện tại. Giá trị đã lưu được giữ nguyên.
mod_manager/game_settings_owner_missing = Mod sử dụng tham số này không có trong bộ sưu tập. Giá trị sẽ được giữ nguyên.
mod_manager/game_settings_invalid = Kiểm tra định dạng giá trị đã nhập.
mod_manager/game_settings_single_choice = Nếu không đặt giá trị, hệ thống dùng lựa chọn duy nhất. Hãy chọn nếu có nhiều lựa chọn.
mod_manager/game_settings_browse = Duyệt…
mod_manager/game_settings_choose = Chọn
mod_manager/game_settings_search_hint = Tìm theo tên hoặc ID
mod_manager/game_settings_advanced = Tham số bổ sung
mod_manager/game_settings_advanced_hint = Tham số của mod chưa có mô tả biểu mẫu. Sửa khóa và giá trị; xóa dòng để bỏ tham số.
mod_manager/game_settings_add = Thêm tham số
mod_manager/game_settings_key = Khóa tham số
mod_manager/game_settings_value = Giá trị
mod_manager/game_settings_remove = Xóa
mod_manager/game_settings_remove_hint = Bỏ tham số khỏi bộ sưu tập khi áp dụng
mod_manager/game_settings_duplicate = Khóa không được trống hoặc trùng lặp.
mod_manager/game_settings_metadata_error = Không đọc được mô tả tham số của các mod:
mod_manager/hint_game_settings_title = Tham số trò chơi
mod_manager/hint_game_settings_body = Cấu hình bộ sưu tập này. Sau khi áp dụng, hãy lưu; thay đổi có hiệu lực khi khởi chạy trò chơi lần sau.
+25
View File
@@ -0,0 +1,25 @@
# Collection parameter editor
mod_manager/game_settings_title = 游戏参数
mod_manager/game_settings_hint = 配置此合集。应用后请点击保存;更改将在下次启动游戏时生效。
mod_manager/game_settings_automatic = 自动
mod_manager/game_settings_custom = 自定义值
mod_manager/game_settings_unavailable = 不可用
mod_manager/game_settings_missing = 此选项在当前合集配置中不可用。已保留保存的值。
mod_manager/game_settings_owner_missing = 使用此参数的模组未包含在合集中。该值将被保留。
mod_manager/game_settings_invalid = 请检查输入值的格式。
mod_manager/game_settings_single_choice = 未指定值时引擎将使用唯一可用选项。有多个选项时必须选择。
mod_manager/game_settings_browse = 浏览…
mod_manager/game_settings_choose = 选择
mod_manager/game_settings_search_hint = 按名称或标识搜索
mod_manager/game_settings_advanced = 其他参数
mod_manager/game_settings_advanced_hint = 没有表单描述的模组参数。可编辑键和值;删除行会移除该参数。
mod_manager/game_settings_add = 添加参数
mod_manager/game_settings_key = 参数键
mod_manager/game_settings_value = 值
mod_manager/game_settings_remove = 删除
mod_manager/game_settings_remove_hint = 应用后从合集中移除此参数
mod_manager/game_settings_duplicate = 键不能为空或重复。
mod_manager/game_settings_metadata_error = 无法读取以下模组的参数描述:
mod_manager/hint_game_settings_title = 游戏参数
mod_manager/hint_game_settings_body = 配置此合集。应用后请点击保存;更改将在下次启动游戏时生效。
+1 -1
View File
@@ -1,7 +1,7 @@
# mod_manager — mod manager localization # mod_manager — mod manager localization
mod_mod_manager_name = Mod Manager mod_mod_manager_name = Mod Manager
mod_mod_manager_desc = Nexus-style mod manager: catalog browsing, category filters, install, update and remove. mod_mod_manager_desc = The Mods & Collections screen: what is installed, what each mod does, installing and updating from the online catalog, and which mods make up each ready-made game.
mod_manager/menu_title = Mods & Collections mod_manager/menu_title = Mods & Collections
mod_manager/title = Mod Manager mod_manager/title = Mod Manager
+1 -1
View File
@@ -1,7 +1,7 @@
# mod_manager — mod manager localization # mod_manager — mod manager localization
mod_mod_manager_name = Gestor de mods mod_mod_manager_name = Gestor de mods
mod_mod_manager_desc = Gestor de mods al estilo Nexus: explorar el catálogo, filtros por categoría, instalar, actualizar y eliminar. mod_mod_manager_desc = La pantalla Mods y colecciones: qué hay instalado, qué hace cada mod, instalación y actualización desde el catálogo en línea, y de qué mods está hecho cada juego ya preparado.
mod_manager/menu_title = Mods y colecciones mod_manager/menu_title = Mods y colecciones
mod_manager/title = Gestor de mods mod_manager/title = Gestor de mods
+1 -1
View File
@@ -1,7 +1,7 @@
# mod_manager — mod manager localization # mod_manager — mod manager localization
mod_mod_manager_name = MOD マネージャー mod_mod_manager_name = MOD マネージャー
mod_mod_manager_desc = Nexus 風の MOD マネージャー:カタログの閲覧、カテゴリーでの絞り込み、インストール、更新、削除。 mod_mod_manager_desc = MOD とコレクションの画面:何がインストールされているか、各 MOD の働き、オンラインカタログからのインストールと更新、そして各ゲームがどの MOD で構成されているか。
mod_manager/menu_title = MOD とコレクション mod_manager/menu_title = MOD とコレクション
mod_manager/title = MOD マネージャー mod_manager/title = MOD マネージャー
+1 -1
View File
@@ -1,7 +1,7 @@
# mod_manager — mod manager localization # mod_manager — mod manager localization
mod_mod_manager_name = 모드 관리자 mod_mod_manager_name = 모드 관리자
mod_mod_manager_desc = Nexus 스타일 모드 관리자: 카탈로그 탐색, 분류 필터, 설치, 업데이트, 삭제. mod_mod_manager_desc = 모드와 컬렉션 화면: 무엇이 설치되어 있고 각 모드가 하는 일, 온라인 카탈로그에서의 설치와 업데이트, 그리고 각 게임이 어떤 모드로 이루어져 있는지.
mod_manager/menu_title = 모드와 컬렉션 mod_manager/menu_title = 모드와 컬렉션
mod_manager/title = 모드 관리자 mod_manager/title = 모드 관리자
+1 -1
View File
@@ -1,7 +1,7 @@
# mod_manager — локализация менеджера модов # mod_manager — локализация менеджера модов
mod_mod_manager_name = Менеджер модов mod_mod_manager_name = Менеджер модов
mod_mod_manager_desc = Менеджер модов в стиле Nexus: каталог, фильтры по категориям, установка, обновление и удаление. mod_mod_manager_desc = Экран «Моды и коллекции»: что установлено, что делает каждый мод, установка и обновление из онлайн-каталога и то, из каких модов собрана каждая готовая игра.
mod_manager/menu_title = Моды и коллекции mod_manager/menu_title = Моды и коллекции
mod_manager/title = Менеджер модов mod_manager/title = Менеджер модов
+1 -1
View File
@@ -1,7 +1,7 @@
# mod_manager — mod manager localization # mod_manager — mod manager localization
mod_mod_manager_name = Trình quản lý mod mod_mod_manager_name = Trình quản lý mod
mod_mod_manager_desc = Trình quản lý mod kiểu Nexus: duyệt danh mục, lọc theo thể loại, cài đặt, cập nhật và gỡ bỏ. mod_mod_manager_desc = Màn hình Mod và bộ sưu tập: cái nào đã cài, mỗi mod làm gì, cài đặt và cập nhật từ danh mục trực tuyến, và mỗi game làm sẵn được ghép từ những mod nào.
mod_manager/menu_title = Mod và bộ sưu tập mod_manager/menu_title = Mod và bộ sưu tập
mod_manager/title = Trình quản lý mod mod_manager/title = Trình quản lý mod
+1 -1
View File
@@ -1,7 +1,7 @@
# mod_manager — mod manager localization # mod_manager — mod manager localization
mod_mod_manager_name = 模组管理器 mod_mod_manager_name = 模组管理器
mod_mod_manager_desc = Nexus 风格的模组管理器:浏览目录、按分类筛选、安装、更新和删除。 mod_mod_manager_desc = 模组与合集屏幕:装了什么、每个模组做什么、从在线目录安装和更新,以及每款现成游戏由哪些模组构成。
mod_manager/menu_title = 模组与合集 mod_manager/menu_title = 模组与合集
mod_manager/title = 模组管理器 mod_manager/title = 模组管理器
+3 -2
View File
@@ -3,7 +3,7 @@
"name": "Mod Manager", "name": "Mod Manager",
"version": "1.0.0", "version": "1.0.0",
"author": "ct", "author": "ct",
"description": "Nexus-style mod manager: browse the catalog, filter by category and status, install, update and remove mods.", "description": "The Mods & Collections screen: what is installed, what each mod does, installing and updating from the online catalog, and which mods make up each ready-made game.",
"category": "tools, ui", "category": "tools, ui",
"sources": [ "sources": [
"src/**/*.cs" "src/**/*.cs"
@@ -19,6 +19,7 @@
], ],
"test_suites": [ "test_suites": [
{ "id": "help", "activate": ["--itest=mod_help"] }, { "id": "help", "activate": ["--itest=mod_help"] },
{ "id": "documentation", "activate": ["--itest=mod_documentation"] } { "id": "documentation", "activate": ["--itest=mod_documentation"] },
{ "id": "collection_settings", "activate": ["--itest=collection_settings"] }
] ]
} }
+3
View File
@@ -120,6 +120,9 @@ theme_override_constants/separation = 8
layout_mode = 2 layout_mode = 2
text = "mod_manager/collection_add_mod" text = "mod_manager/collection_add_mod"
[node name="GameSettings" type="Button" parent="Margin/Root/Tools"]
layout_mode = 2
[node name="Search" type="LineEdit" parent="Margin/Root/Tools"] [node name="Search" type="LineEdit" parent="Margin/Root/Tools"]
custom_minimum_size = Vector2(170, 0) custom_minimum_size = Vector2(170, 0)
layout_mode = 2 layout_mode = 2
+30
View File
@@ -46,6 +46,7 @@ namespace Mods.ModManager
private Label _modsCount; private Label _modsCount;
private Label _dirty; private Label _dirty;
private Button _json; private Button _json;
private Button _gameSettings;
private Button _reset; private Button _reset;
private Button _save; private Button _save;
private Label _bannerLabel; private Label _bannerLabel;
@@ -437,6 +438,10 @@ namespace Mods.ModManager
_modsCount = GetNode<Label>("Margin/Root/Header/ModsCount"); _modsCount = GetNode<Label>("Margin/Root/Header/ModsCount");
_dirty = GetNode<Label>("Margin/Root/Header/Dirty"); _dirty = GetNode<Label>("Margin/Root/Header/Dirty");
_json = GetNode<Button>("Margin/Root/Header/Json"); _json = GetNode<Button>("Margin/Root/Header/Json");
_gameSettings = GetNode<Button>("Margin/Root/Tools/GameSettings");
_gameSettings.Text = TranslationServer.Translate("mod_manager/game_settings_title");
_gameSettings.TooltipText = TranslationServer.Translate("mod_manager/game_settings_hint");
ModHelpPanel.Attach(_gameSettings, "game_settings");
_reset = GetNode<Button>("Margin/Root/Header/Reset"); _reset = GetNode<Button>("Margin/Root/Header/Reset");
_save = GetNode<Button>("Margin/Root/Header/Save"); _save = GetNode<Button>("Margin/Root/Header/Save");
_bannerPanel = GetNode<PanelContainer>("Margin/Root/Banner"); _bannerPanel = GetNode<PanelContainer>("Margin/Root/Banner");
@@ -638,6 +643,7 @@ namespace Mods.ModManager
_pillProblems.Pressed += () => SelectTab("problems"); _pillProblems.Pressed += () => SelectTab("problems");
_pillPlan.Pressed += () => SelectTab("plan"); _pillPlan.Pressed += () => SelectTab("plan");
_json.Pressed += OpenJsonDialog; _json.Pressed += OpenJsonDialog;
_gameSettings.Pressed += OpenGameSettings;
_reset.Pressed += ResetDrafts; _reset.Pressed += ResetDrafts;
_save.Pressed += OpenSaveDialog; _save.Pressed += OpenSaveDialog;
_gameSelect.ItemSelected += OnGameSelected; _gameSelect.ItemSelected += OnGameSelected;
@@ -1823,6 +1829,30 @@ namespace Mods.ModManager
Rebuild(); Rebuild();
} }
private void OpenGameSettings()
{
var collection = EffectiveCollection(_currentId);
if (collection == null)
return;
var discovered = ModRegistry.Instance.AllDiscoveredMods;
var ordered = ModRegistry.Instance.GetOrderedModIds(false).Where(discovered.ContainsKey).Select(id => discovered[id]);
var available = Resolve().Nodes.Where(n => n.State == ModCollectionNodeState.Installed).Select(n => n.ModId);
var catalog = new CollectionSettingsCatalog(ordered, available, TranslationServer.GetLocale());
var dialog = new CollectionSettingsDialog();
AddChild(dialog);
dialog.Initialize(catalog, collection.Settings, ApplyGameSettings);
dialog.PopupCenteredClamped(new Vector2I(760, 660), 0.9f);
}
private void ApplyGameSettings(Dictionary<string, string> settings)
{
var current = EffectiveCollection(_currentId)?.Settings;
if ((current?.Count ?? 0) == settings.Count && settings.All(p => current != null && current.TryGetValue(p.Key, out var value) && value == p.Value))
return;
Draft(_currentId).Settings = settings.Count == 0 ? null : settings;
Rebuild();
}
private void OpenSaveDialog() private void OpenSaveDialog()
{ {
var draft = EffectiveCollection(_currentId); var draft = EffectiveCollection(_currentId);
@@ -0,0 +1,25 @@
namespace Mods.ModManager
{
using System.Collections.Generic;
using System.Text.Json.Serialization;
/// <summary>A mod-owned collection parameter, readable before that mod's assembly is loaded.</summary>
public class CollectionSettingDefinition
{
public string Key { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string Group { get; set; }
/// <summary>choice, resource or character. Resource/character sources name a mod-relative folder.</summary>
public string Type { get; set; } = "choice";
public string Source { get; set; }
public bool AllowCustom { get; set; }
/// <summary>Explanation of the runtime fallback, not a value to persist.</summary>
public string Automatic { get; set; }
/// <summary>Optional anchored validation expression for custom input.</summary>
public string Pattern { get; set; }
public List<CollectionSettingOption> Options { get; set; } = new();
[JsonIgnore] public string ModId { get; set; }
[JsonIgnore] public bool Available { get; set; }
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Mods.ModManager
{
using System.Text.Json.Serialization;
using Cthangover.Core.Mods;
/// <summary>An explicit mod contribution or an indexed resource; availability belongs to the edited collection.</summary>
public class CollectionSettingOption
{
public string Key { get; set; }
public string Value { get; set; }
public string Name { get; set; }
[JsonIgnore] public string ModId { get; set; }
[JsonIgnore] public bool Available { get; set; }
[JsonIgnore] public IModFileProvider Provider { get; set; }
[JsonIgnore] public string PreviewPath { get; set; }
}
}
+223
View File
@@ -0,0 +1,223 @@
namespace Mods.ModManager
{
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>One draft field. Invalid saved values are retained until the player explicitly replaces them.</summary>
public partial class CollectionSettingRow : VBoxContainer
{
private CollectionSettingsCatalog catalog;
private CollectionSettingDefinition definition;
private IReadOnlyList<CollectionSettingOption> choices;
private OptionButton select;
private LineEdit custom;
private Label feedback;
private TextureRect preview;
private ImageTexture texture;
private string original;
private string value;
private bool changing;
private bool customMode;
private int customIndex = -1;
public string Key => definition.Key;
public string Value => customMode ? custom.Text.Trim() : value;
public bool Valid => Value == original || Value == null || (definition.AllowCustom && catalog.IsValidCustom(definition, Value))
|| choices.Any(o => o.Available && o.Value == Value);
/// <summary>Builds controls from the owner's descriptor; opening a form never creates a collection override.</summary>
public void Initialize(CollectionSettingsCatalog source, CollectionSettingDefinition field, string saved)
{
catalog = source;
definition = field;
original = value = saved;
Name = field.Key;
choices = catalog.Choices(field);
customMode = saved != null && field.AllowCustom && !choices.Any(o => o.Value == saved) && catalog.IsValidCustom(field, saved);
AddThemeConstantOverride("separation", UiTokens.Space2);
var title = Wrapped(catalog.Translate(field.Name), UiTokens.InkBright);
title.TooltipText = field.Key + " · " + field.ModId;
title.MouseFilter = MouseFilterEnum.Pass;
AddChild(title);
AddChild(Wrapped(catalog.Translate(field.Description), UiTokens.Muted));
var controls = UiKit.Row(UiTokens.Space2);
select = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill, FitToLongestItem = false, TooltipText = catalog.Translate(field.Description) };
select.AddItem(Tr("mod_manager/game_settings_automatic"));
// Renderer fallback is valid only when exactly one implementation is present.
select.SetItemDisabled(0, field.Type == "choice" && !field.AllowCustom && string.IsNullOrEmpty(field.Automatic) && choices.Count(o => o.Available) != 1);
foreach (var option in choices)
{
select.AddItem(OptionTitle(option));
select.SetItemDisabled(select.ItemCount - 1, !option.Available);
}
if (saved != null && !customMode && !choices.Any(o => o.Value == saved))
{
select.AddItem(saved + " · " + Tr("mod_manager/game_settings_unavailable"));
select.SetItemDisabled(select.ItemCount - 1, true);
}
if (field.AllowCustom)
{
customIndex = select.ItemCount;
select.AddItem(Tr("mod_manager/game_settings_custom"));
}
controls.AddChild(select);
if (field.Type is "resource" or "character")
{
var browse = UiKit.Button(Tr("mod_manager/game_settings_browse"));
browse.TooltipText = Tr("mod_manager/game_settings_search_hint");
browse.Pressed += OpenPicker;
controls.AddChild(browse);
}
AddChild(controls);
custom = new LineEdit { Text = saved ?? "", PlaceholderText = Tr("mod_manager/game_settings_custom"), TooltipText = field.Key, Visible = false };
custom.TextChanged += OnCustomChanged;
AddChild(custom);
if (field.Type == "resource")
{
preview = new TextureRect { CustomMinimumSize = new Vector2(0, 112), ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize, StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered, MouseFilter = MouseFilterEnum.Ignore };
AddChild(preview);
}
feedback = Wrapped("", UiTokens.Warning);
AddChild(feedback);
select.ItemSelected += OnSelected;
Refresh();
}
public override void _ExitTree() => ReleaseTexture();
private static string Tr(string suffix) => TranslationServer.Translate(suffix);
private static Label Wrapped(string text, Color color)
=> new Label { Text = text, AutowrapMode = TextServer.AutowrapMode.WordSmart, Modulate = color, SizeFlagsHorizontal = SizeFlags.ExpandFill };
private string OptionTitle(CollectionSettingOption option)
=> catalog.Translate(option.Name ?? option.Value) + (option.Name == option.Value ? "" : " (" + option.Value + ")")
+ (option.Available ? "" : " · " + Tr("mod_manager/game_settings_unavailable"));
private void OnSelected(long index)
{
if (changing)
return;
customMode = index == customIndex;
if (customMode)
value = custom.Text.Trim();
else if (index == 0)
value = null;
else if (index <= choices.Count)
value = choices[(int)index - 1].Value;
Refresh();
}
private void OnCustomChanged(string text)
{
if (changing)
return;
value = text.Trim();
UpdateFeedback();
}
private void Refresh()
{
changing = true;
var index = choices.ToList().FindIndex(o => o.Value == value);
var typed = customMode;
select.Select(typed ? customIndex : value == null ? 0 : index >= 0 ? index + 1 : choices.Count + 1);
custom.Visible = select.Selected == customIndex;
if (custom.Visible && custom.Text != value)
custom.Text = value ?? "";
changing = false;
UpdateFeedback();
UpdatePreview(index >= 0 ? choices[index] : null);
}
private void UpdateFeedback()
{
if (!definition.Available)
feedback.Text = Tr("mod_manager/game_settings_owner_missing");
else if (!Valid)
feedback.Text = Tr("mod_manager/game_settings_invalid");
else if (value == null)
feedback.Text = string.IsNullOrEmpty(definition.Automatic) ? Tr("mod_manager/game_settings_single_choice") : catalog.Translate(definition.Automatic);
else if (!choices.Any(o => o.Available && o.Value == value) && !(definition.AllowCustom && catalog.IsValidCustom(definition, value)))
feedback.Text = Tr("mod_manager/game_settings_missing");
else
feedback.Text = "";
}
private void ReleaseTexture()
{
if (preview != null)
preview.Texture = null;
texture?.Dispose();
texture = null;
}
private void UpdatePreview(CollectionSettingOption option)
{
if (preview == null)
return;
ReleaseTexture();
preview.Visible = option?.PreviewPath != null;
if (!preview.Visible)
return;
var data = option.Provider.ReadFileBinary(option.PreviewPath);
if (data == null)
return;
using var image = new Image();
var extension = System.IO.Path.GetExtension(option.PreviewPath).ToLowerInvariant();
var error = extension == ".webp" ? image.LoadWebpFromBuffer(data) : extension is ".jpg" or ".jpeg" ? image.LoadJpgFromBuffer(data) : image.LoadPngFromBuffer(data);
if (error == Error.Ok)
preview.Texture = texture = ImageTexture.CreateFromImage(image);
}
private void OpenPicker()
{
var dialog = new ConfirmationDialog { Title = catalog.Translate(definition.Name), OkButtonText = Tr("mod_manager/game_settings_choose"), CancelButtonText = TranslationServer.Translate("mod_manager/cancel") };
var column = UiKit.Column(UiTokens.Space2);
var search = new LineEdit { PlaceholderText = Tr("mod_manager/game_settings_search_hint"), TooltipText = Tr("mod_manager/game_settings_search_hint") };
var list = new ItemList { CustomMinimumSize = new Vector2(0, 80), SizeFlagsVertical = SizeFlags.ExpandFill, TooltipText = catalog.Translate(definition.Description) };
column.AddChild(search);
column.AddChild(list);
dialog.AddChild(column);
var filtered = new List<CollectionSettingOption>();
search.TextChanged += text => FillPicker(list, filtered, text);
dialog.Confirmed += () => ApplyPicker(dialog, list, filtered);
dialog.Canceled += dialog.QueueFree;
list.ItemActivated += _ => ApplyPicker(dialog, list, filtered);
AddChild(dialog);
FillPicker(list, filtered, "");
dialog.PopupCenteredClamped(new Vector2I(540, 440), 0.9f);
search.GrabFocus();
}
private void FillPicker(ItemList list, List<CollectionSettingOption> filtered, string search)
{
list.Clear();
filtered.Clear();
foreach (var option in choices.Where(o => OptionTitle(o).Contains(search, StringComparison.OrdinalIgnoreCase)))
{
filtered.Add(option);
var index = list.AddItem(OptionTitle(option));
list.SetItemDisabled(index, !option.Available);
list.SetItemTooltip(index, option.Value + " · " + option.ModId);
if (option.Value == value)
list.Select(index);
}
}
private void ApplyPicker(ConfirmationDialog dialog, ItemList list, List<CollectionSettingOption> filtered)
{
var selected = list.GetSelectedItems();
if (selected.Length > 0 && filtered[selected[0]].Available)
{
customMode = false;
value = filtered[selected[0]].Value;
Refresh();
}
dialog.QueueFree();
}
}
}
+182
View File
@@ -0,0 +1,182 @@
namespace Mods.ModManager
{
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using Cthangover.Core.Localization;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
/// <summary>
/// Reads declarations, translations and resource choices from discovered folder/ZIP mods without loading
/// assemblies. Callers supply dependency order and the edited collection's available mod ids, never the
/// currently running game's registries. No static cache retains providers across reloads.
/// </summary>
public class CollectionSettingsCatalog
{
private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true };
private readonly Dictionary<string, string> translations = new(StringComparer.Ordinal);
private readonly Dictionary<string, CollectionSettingDefinition> definitions = new(StringComparer.Ordinal);
private readonly List<CollectionSettingOption> contributions = new();
private readonly List<IModInfo> mods;
private readonly HashSet<string> available;
public IReadOnlyCollection<CollectionSettingDefinition> Definitions => definitions.Values;
public List<string> Errors { get; } = new();
/// <summary>Later mods override resource ids and translations, matching the supplied dependency order.</summary>
public CollectionSettingsCatalog(IEnumerable<IModInfo> orderedMods, IEnumerable<string> availableModIds, string language)
{
mods = orderedMods.ToList();
available = new HashSet<string>(availableModIds, StringComparer.OrdinalIgnoreCase);
// Read inactive owners first, so their fields still explain saved values after removal.
foreach (var mod in mods.OrderBy(m => available.Contains(m.Id)))
ReadDeclarations(mod);
foreach (var mod in mods)
ReadTranslations(mod.FileProvider, "en");
foreach (var mod in mods)
ReadTranslations(mod.FileProvider, LanguageCode.Primary(language));
}
/// <summary>Mod-local locale files are read even in frontend-only mode, with an English fallback.</summary>
public string Translate(string key) => key != null && translations.TryGetValue(key.TrimStart('@'), out var text) ? text : key ?? string.Empty;
/// <summary>Choices from declarations plus the selected collection's resources; unavailable contributions stay visible.</summary>
public IReadOnlyList<CollectionSettingOption> Choices(CollectionSettingDefinition definition)
{
var result = new Dictionary<string, CollectionSettingOption>(StringComparer.Ordinal);
foreach (var option in definition.Options ?? new List<CollectionSettingOption>())
{
option.ModId = definition.ModId;
option.Available = definition.Available;
if (!string.IsNullOrEmpty(option.Value))
result[option.Value] = option;
}
foreach (var option in contributions.Where(o => o.Key == definition.Key).OrderBy(o => o.Available))
result[option.Value] = option;
if (definition.Type is "resource" or "character" && !string.IsNullOrEmpty(definition.Source))
foreach (var mod in mods.Where(m => available.Contains(m.Id)))
ReadChoices(mod, definition, result);
return result.Values.OrderBy(o => o.Value, StringComparer.Ordinal).ToList();
}
/// <summary>Unknown/unavailable saved values may be retained; newly typed values must obey the owner's syntax.</summary>
public bool IsValidCustom(CollectionSettingDefinition definition, string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
if (string.IsNullOrEmpty(definition.Pattern))
return true;
try { return Regex.IsMatch(value, definition.Pattern, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); }
catch (ArgumentException) { return false; }
catch (RegexMatchTimeoutException) { return false; }
}
private void ReadDeclarations(IModInfo mod)
{
try
{
var text = mod.FileProvider?.ReadFileText("collection_settings.json");
if (string.IsNullOrWhiteSpace(text))
return;
var file = JsonSerializer.Deserialize<CollectionSettingsFile>(text, Json);
foreach (var definition in file?.Settings ?? new List<CollectionSettingDefinition>())
{
if (definition == null || string.IsNullOrWhiteSpace(definition.Key))
continue;
definition.ModId = mod.Id;
definition.Available = available.Contains(mod.Id);
definitions[definition.Key] = definition;
}
foreach (var option in file?.Options ?? new List<CollectionSettingOption>())
{
if (option == null || string.IsNullOrEmpty(option.Key) || string.IsNullOrEmpty(option.Value))
continue;
option.ModId = mod.Id;
option.Available = available.Contains(mod.Id);
contributions.Add(option);
}
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
{
Errors.Add(mod.Id);
GameLogger.Log("MOD_COLLECTIONS", $"Invalid collection settings in '{mod.Id}': {ex.Message}", LogLevel.Warning);
}
}
private void ReadTranslations(IModFileProvider provider, string language)
{
if (provider == null || string.IsNullOrEmpty(language))
return;
foreach (var path in provider.ListFiles("locale").Order(StringComparer.Ordinal))
{
var name = Path.GetFileName(path);
if (name != language + ".properties" && !name.EndsWith("_" + language + ".properties", StringComparison.Ordinal))
continue;
foreach (var line in (provider.ReadFileText(path) ?? "").Split('\n'))
{
var trimmed = line.Trim();
var split = trimmed.IndexOf('=');
if (!trimmed.StartsWith('#') && split > 0)
translations[trimmed[..split].Trim()] = trimmed[(split + 1)..].Trim();
}
}
}
private void ReadChoices(IModInfo mod, CollectionSettingDefinition definition, Dictionary<string, CollectionSettingOption> result)
{
foreach (var path in Walk(mod.FileProvider, definition.Source))
{
var extension = Path.GetExtension(path).ToLowerInvariant();
if (definition.Type == "resource" && extension is ".png" or ".jpg" or ".jpeg" or ".webp")
{
var value = path[(definition.Source.Length + 1)..^extension.Length];
result[value] = new CollectionSettingOption { Value = value, Name = value, ModId = mod.Id, Available = true, Provider = mod.FileProvider, PreviewPath = path };
}
else if (definition.Type == "character" && extension == ".json")
ReadCharacters(mod, path, result);
}
}
private void ReadCharacters(IModInfo mod, string path, Dictionary<string, CollectionSettingOption> result)
{
try
{
using var document = JsonDocument.Parse(mod.FileProvider.ReadFileText(path));
var root = document.RootElement;
if (!root.TryGetProperty("Kind", out var kind) || kind.GetString() != "character"
|| !root.TryGetProperty("Items", out var items) || items.ValueKind != JsonValueKind.Array)
return;
foreach (var item in items.EnumerateArray())
{
if (!item.TryGetProperty("Id", out var id) || string.IsNullOrEmpty(id.GetString()))
continue;
var value = id.GetString();
var name = item.TryGetProperty("Name", out var title) ? title.GetString() : value;
result[value] = new CollectionSettingOption { Value = value, Name = name, ModId = mod.Id, Available = true };
}
}
catch (JsonException ex)
{
GameLogger.Log("MOD_COLLECTIONS", $"Cannot index '{mod.Id}/{path}': {ex.Message}", LogLevel.Warning);
}
}
private static IEnumerable<string> Walk(IModFileProvider provider, string folder)
{
foreach (var path in provider.ListFiles(folder).Order(StringComparer.Ordinal))
{
if (path.EndsWith('/'))
{
foreach (var child in Walk(provider, path.TrimEnd('/')))
yield return child;
}
else
yield return path;
}
}
}
}
+130
View File
@@ -0,0 +1,130 @@
namespace Mods.ModManager
{
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>Edits a detached copy; Apply replaces the collection draft, Cancel leaves it untouched.</summary>
public partial class CollectionSettingsDialog : ConfirmationDialog
{
private readonly List<CollectionSettingRow> rows = new();
private readonly List<HBoxContainer> extras = new();
private CollectionSettingsCatalog catalog;
private Action<Dictionary<string, string>> apply;
private VBoxContainer extraFields;
private Label error;
/// <summary>Builds a scrollable form from selected mods and existing keys, including undeclared third-party settings.</summary>
public void Initialize(CollectionSettingsCatalog source, Dictionary<string, string> settings, Action<Dictionary<string, string>> onApply)
{
catalog = source;
apply = onApply;
Name = "CollectionSettingsDialog";
Title = Tr("mod_manager/game_settings_title");
OkButtonText = TranslationServer.Translate("mod_manager/collection_json_apply");
CancelButtonText = TranslationServer.Translate("mod_manager/cancel");
DialogHideOnOk = false;
Exclusive = true;
// Only the native action bar stays outside the scroll. A large content minimum
// defeats PopupCenteredClamped: Godot grows the window back past the viewport.
Unresizable = false;
var scroll = new ScrollContainer { Name = "FieldsScroll", CustomMinimumSize = new Vector2(0, 80), SizeFlagsVertical = Control.SizeFlags.ExpandFill, SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, VerticalScrollMode = ScrollContainer.ScrollMode.Auto, FollowFocus = true };
var fields = UiKit.Column(UiTokens.Space4);
fields.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
scroll.AddChild(fields);
fields.AddChild(Wrapped(Tr("mod_manager/game_settings_hint")));
string group = null;
foreach (var definition in catalog.Definitions.Where(d => d.Available || settings?.ContainsKey(d.Key) == true).OrderBy(d => d.Group).ThenBy(d => d.Key))
{
if (group != definition.Group)
{
group = definition.Group;
fields.AddChild(Wrapped(catalog.Translate(group)));
}
var row = new CollectionSettingRow();
fields.AddChild(row);
row.Initialize(catalog, definition, settings != null && settings.TryGetValue(definition.Key, out var saved) ? saved : null);
rows.Add(row);
}
var advanced = UiKit.Button(Tr("mod_manager/game_settings_advanced"));
advanced.ToggleMode = true;
advanced.TooltipText = Tr("mod_manager/game_settings_advanced_hint");
fields.AddChild(advanced);
extraFields = UiKit.Column(UiTokens.Space2);
extraFields.Visible = false;
fields.AddChild(extraFields);
advanced.Toggled += visible => extraFields.Visible = visible;
var known = new HashSet<string>(rows.Select(r => r.Key), StringComparer.Ordinal);
foreach (var pair in settings ?? new Dictionary<string, string>())
if (!known.Contains(pair.Key))
AddExtra(pair.Key, pair.Value);
var add = UiKit.Button(Tr("mod_manager/game_settings_add"));
add.TooltipText = Tr("mod_manager/game_settings_advanced_hint");
add.Pressed += () => AddExtra("", "");
extraFields.AddChild(add);
error = Wrapped(catalog.Errors.Count == 0 ? "" : Tr("mod_manager/game_settings_metadata_error") + " " + string.Join(", ", catalog.Errors));
error.Modulate = UiTokens.Warning;
fields.AddChild(error);
AddChild(scroll);
Confirmed += Apply;
Canceled += QueueFree;
}
private static string Tr(string suffix) => TranslationServer.Translate(suffix);
private static Label Wrapped(string text) => new() { Text = text, AutowrapMode = TextServer.AutowrapMode.WordSmart, SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
private void AddExtra(string key, string value)
{
var row = UiKit.Row(UiTokens.Space2);
var keyEdit = new LineEdit { Name = "Key", Text = key, PlaceholderText = Tr("mod_manager/game_settings_key"), TooltipText = Tr("mod_manager/game_settings_key"), SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
var valueEdit = new LineEdit { Name = "Value", Text = value ?? "", PlaceholderText = Tr("mod_manager/game_settings_value"), TooltipText = Tr("mod_manager/game_settings_value"), SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
var remove = UiKit.Button(Tr("mod_manager/game_settings_remove"));
remove.TooltipText = Tr("mod_manager/game_settings_remove_hint");
row.AddChild(keyEdit);
row.AddChild(valueEdit);
row.AddChild(remove);
remove.Pressed += () => RemoveExtra(row);
extraFields.AddChild(row);
extras.Add(row);
}
private void RemoveExtra(HBoxContainer row)
{
extras.Remove(row);
row.QueueFree();
}
private void Apply()
{
var invalid = rows.FirstOrDefault(r => !r.Valid);
if (invalid != null)
{
error.Text = invalid.Key + ": " + Tr("mod_manager/game_settings_invalid");
return;
}
var result = new Dictionary<string, string>(StringComparer.Ordinal);
var reserved = new HashSet<string>(rows.Select(r => r.Key), StringComparer.Ordinal);
foreach (var row in rows)
if (row.Value != null)
result[row.Key] = row.Value;
foreach (var row in extras)
{
var key = row.GetNode<LineEdit>("Key").Text.Trim();
var value = row.GetNode<LineEdit>("Value").Text;
if (key.Length == 0 && value.Length == 0)
continue;
if (key.Length == 0 || reserved.Contains(key) || !result.TryAdd(key, value))
{
error.Text = Tr("mod_manager/game_settings_duplicate");
return;
}
}
apply(result);
QueueFree();
}
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Mods.ModManager
{
using System.Collections.Generic;
/// <summary>collection_settings.json: owners declare fields, implementation mods contribute choices.</summary>
public class CollectionSettingsFile
{
public List<CollectionSettingDefinition> Settings { get; set; } = new();
public List<CollectionSettingOption> Options { get; set; } = new();
}
}
+14 -1
View File
@@ -21,6 +21,7 @@ namespace Mods.ModManager
public partial class ModCard : PanelContainer public partial class ModCard : PanelContainer
{ {
private ModCardData _data; private ModCardData _data;
private Texture2D _ownedPreview;
private TextureRect _image; private TextureRect _image;
private Label _title; private Label _title;
private Label _author; private Label _author;
@@ -242,12 +243,13 @@ namespace Mods.ModManager
private void LoadPreview() private void LoadPreview()
{ {
ReleasePreview();
if (_data.LocalInfo != null) if (_data.LocalInfo != null)
{ {
var local = ModPreviewLoader.LoadLocal(_data.LocalInfo); var local = ModPreviewLoader.LoadLocal(_data.LocalInfo);
if (local != null) if (local != null)
{ {
_image.Texture = local; _image.Texture = _ownedPreview = local;
return; return;
} }
} }
@@ -256,6 +258,17 @@ namespace Mods.ModManager
ModPreviewLoader.LoadRemote(_data.Id, _data.Catalog.IconUrl, OnRemotePreviewLoaded); ModPreviewLoader.LoadRemote(_data.Id, _data.Catalog.IconUrl, OnRemotePreviewLoaded);
} }
/// <summary>Local previews are decoded for this view; remote previews belong to the shared icon cache.</summary>
public override void _ExitTree() => ReleasePreview();
private void ReleasePreview()
{
if (GodotObject.IsInstanceValid(_image))
_image.Texture = null;
_ownedPreview?.Dispose();
_ownedPreview = null;
}
private void OnRemotePreviewLoaded(Texture2D texture) private void OnRemotePreviewLoaded(Texture2D texture)
{ {
if (texture != null && GodotObject.IsInstanceValid(_image)) if (texture != null && GodotObject.IsInstanceValid(_image))
+14 -1
View File
@@ -19,6 +19,7 @@ namespace Mods.ModManager
public partial class ModDetailView : VBoxContainer public partial class ModDetailView : VBoxContainer
{ {
private ModCardData _data; private ModCardData _data;
private Texture2D _ownedPreview;
private bool _nodesReady; private bool _nodesReady;
private TextureRect _coverImage; private TextureRect _coverImage;
@@ -338,6 +339,7 @@ namespace Mods.ModManager
private void LoadCover() private void LoadCover()
{ {
ReleasePreview();
_coverImage.Texture = null; _coverImage.Texture = null;
if (_data.LocalInfo != null) if (_data.LocalInfo != null)
@@ -345,7 +347,7 @@ namespace Mods.ModManager
var local = ModPreviewLoader.LoadLocal(_data.LocalInfo); var local = ModPreviewLoader.LoadLocal(_data.LocalInfo);
if (local != null) if (local != null)
{ {
_coverImage.Texture = local; _coverImage.Texture = _ownedPreview = local;
return; return;
} }
} }
@@ -354,6 +356,17 @@ namespace Mods.ModManager
ModPreviewLoader.LoadRemote(_data.Id, _data.Catalog.IconUrl, OnRemotePreviewLoaded); ModPreviewLoader.LoadRemote(_data.Id, _data.Catalog.IconUrl, OnRemotePreviewLoaded);
} }
/// <summary>Local previews are decoded for this view; remote previews belong to the shared icon cache.</summary>
public override void _ExitTree() => ReleasePreview();
private void ReleasePreview()
{
if (GodotObject.IsInstanceValid(_coverImage))
_coverImage.Texture = null;
_ownedPreview?.Dispose();
_ownedPreview = null;
}
private void OnRemotePreviewLoaded(Texture2D texture) private void OnRemotePreviewLoaded(Texture2D texture)
{ {
if (texture != null && GodotObject.IsInstanceValid(_coverImage)) if (texture != null && GodotObject.IsInstanceValid(_coverImage))
@@ -0,0 +1,4 @@
{
"name": "Collection settings UI test",
"sources": ["src/*.cs"]
}
@@ -0,0 +1,202 @@
namespace Mods.ModManager.SettingsTest
{
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Localization;
using Cthangover.Core.Mods;
using Cthangover.Core.UI.Menu;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Menu;
using Godot;
/// <summary>Small-viewport scrolling, cancel, validation, automatic removal and persisted settings through real UI controls.</summary>
public partial class CollectionSettingsTestDriver : Node
{
private const string FixtureId = "collection_settings_ui_test";
private ModHubScreen hub;
private ModCollectionsScreen editor;
private CollectionSettingsDialog dialog;
private string fixturePath;
private string originalLocale;
private int step;
private int passed;
private double elapsed;
private double next;
private bool done;
public override void _Process(double delta)
{
if (done)
return;
elapsed += delta;
if (elapsed > 90)
{
Finish("timeout at step " + step);
return;
}
if (elapsed < next)
return;
next = elapsed + 0.35;
try { RunStep(); }
catch (Exception ex) { Finish("step " + step + ": " + ex); }
}
private void RunStep()
{
switch (step)
{
case 0:
if (!Boot.IsReady)
return;
if (GetTree().CurrentScene?.FindChild("LauncherPlay", true, false) is Button launcher)
{
launcher.EmitSignal(BaseButton.SignalName.Pressed);
return;
}
if (GetTree().CurrentScene?.FindChild("play", true, false) is not Button)
return;
originalLocale = TranslationServer.GetLocale();
LocaleLoader.LoadLocale("ru");
MakeFixture();
var entry = MenuRegistry.GetButtonById("mods_hub");
hub = MenuBuilder.OpenScene((Control)GetTree().CurrentScene, entry.ScenePath, "mods_hub") as ModHubScreen;
Check(hub != null, "hub opens");
hub.OpenCollectionEditor(FixtureId);
editor = hub.FindChildren("*", "", true, false).OfType<ModCollectionsScreen>().Single();
break;
case 1:
OpenForm();
break;
case 2:
Check(dialog.FindChildren("*", "", true, false).OfType<CollectionSettingRow>().Count() == 5, "five fields read without loading game mods");
Check(dialog.Title == "Параметры игры", "localized dialog title");
Check(Row("battle.core").Value == "missing_core", "unavailable saved choice retained");
dialog.Size = new Vector2I(640, 420);
break;
case 3:
var scroll = dialog.GetNode<ScrollContainer>("FieldsScroll");
Check(dialog.Size.Y <= 420, "dialog minimum does not exceed small viewport");
Check(scroll.GetChild<Control>(0).Size.Y > scroll.Size.Y, "long form is contained in scroll viewport");
Check(dialog.GetOkButton().GetGlobalRect().End.Y <= dialog.Size.Y, "Apply remains inside window");
Check(dialog.GetCancelButton().GetGlobalRect().End.Y <= dialog.Size.Y, "Cancel remains inside window");
scroll.ScrollVertical = 100000;
break;
case 4:
Check(dialog.GetNode<ScrollContainer>("FieldsScroll").ScrollVertical > 0, "form scrolls to lower fields");
Custom(Row("checks.dice"), "bad dice");
dialog.EmitSignal(ConfirmationDialog.SignalName.Confirmed);
Check(!dialog.IsQueuedForDeletion(), "invalid dice prevents Apply");
Custom(Row("checks.dice"), "2d6");
Custom(Row("stats.player"), "created_during_play");
Select(Row("tile.renderer"), 0);
dialog.EmitSignal(ConfirmationDialog.SignalName.Canceled);
break;
case 5:
OpenForm();
Check(Row("checks.dice").Value == "d20", "Cancel leaves original settings");
Custom(Row("checks.dice"), "2d6");
Custom(Row("stats.player"), "created_during_play");
Select(Row("tile.renderer"), 0);
dialog.EmitSignal(ConfirmationDialog.SignalName.Confirmed);
break;
case 6:
editor.GetNode<Button>("Margin/Root/Header/Save").EmitSignal(BaseButton.SignalName.Pressed);
break;
case 7:
var save = editor.FindChildren("*", "", true, false).OfType<AcceptDialog>().Single(d => d.Visible);
save.EmitSignal(AcceptDialog.SignalName.Confirmed);
break;
case 8:
var saved = ModCollectionStore.LoadUser().Single(c => c.Id == FixtureId);
Check(saved.Settings["checks.dice"] == "2d6", "dice saved to collection package");
Check(saved.Settings["stats.player"] == "created_during_play", "custom character saved");
Check(saved.Settings["battle.core"] == "missing_core", "unavailable choice survives save");
Check(saved.Settings["custom.fixture"] == "keep", "undeclared setting survives save");
Check(!saved.Settings.ContainsKey("tile.renderer"), "Automatic deletes explicit key");
editor.OpenCollection(FixtureId);
OpenForm();
break;
case 9:
Check(Row("checks.dice").Value == "2d6", "saved values restored on reopening");
Check(Row("tile.renderer").Value == null, "automatic restored on reopening");
dialog.EmitSignal(ConfirmationDialog.SignalName.Canceled);
Finish(null);
return;
}
step++;
}
private void MakeFixture()
{
var path = Path.Combine(ModCollectionStore.UserPath, FixtureId);
Check(!Directory.Exists(path), "fixture cannot overwrite player data");
var source = ModCollectionStore.LoadBuiltIn().Single(c => c.Id == "rpg_plus");
var clone = JsonSerializer.Deserialize<ModCollection>(JsonSerializer.Serialize(source));
clone.Id = FixtureId;
clone.Name = "Collection settings test";
// A fixture must not inherit the game's localized display names.
clone.Locale = null;
clone.Settings["custom.fixture"] = "keep";
clone.Settings["battle.core"] = "missing_core";
Directory.CreateDirectory(path);
fixturePath = path;
File.WriteAllText(Path.Combine(path, "collection.json"), JsonSerializer.Serialize(clone));
}
private void OpenForm()
{
editor.GetNode<Button>("Margin/Root/Tools/GameSettings").EmitSignal(BaseButton.SignalName.Pressed);
dialog = editor.FindChildren("*", "", true, false).OfType<CollectionSettingsDialog>().Single(d => !d.IsQueuedForDeletion());
}
private CollectionSettingRow Row(string key) => dialog.FindChildren("*", "", true, false).OfType<CollectionSettingRow>().Single(r => r.Key == key);
private static void Custom(CollectionSettingRow row, string value)
{
var select = row.FindChildren("*", "", true, false).OfType<OptionButton>().First();
Select(row, select.ItemCount - 1);
row.FindChildren("*", "", true, false).OfType<LineEdit>().First().Text = value;
}
private static void Select(CollectionSettingRow row, int index)
{
var select = row.FindChildren("*", "", true, false).OfType<OptionButton>().First();
select.Select(index);
select.EmitSignal(OptionButton.SignalName.ItemSelected, (long)index);
}
private void Check(bool condition, string label)
{
if (!condition)
throw new InvalidOperationException(label);
passed++;
GameLogger.Log("TEST", "Collection settings: [OK] " + label);
}
private void Finish(string failure)
{
done = true;
hub?.QueueFree();
CleanupFixture();
if (originalLocale != null)
LocaleLoader.LoadLocale(originalLocale);
if (failure != null)
GameLogger.Log("TEST", "Collection settings: " + failure, LogLevel.Error);
GameLogger.Log("TEST", $"[TEST_RESULT] name=collection_settings passed={passed} failed={(failure == null ? 0 : 1)}");
GetTree().Quit(failure == null ? 0 : 1);
}
/// <summary>Also remove the owned fixture when the suite exits before Finish.</summary>
public override void _ExitTree() => CleanupFixture();
private void CleanupFixture()
{
if (fixturePath != null && Directory.Exists(fixturePath))
Directory.Delete(fixturePath, true);
fixturePath = null;
}
}
}
@@ -0,0 +1,18 @@
namespace Mods.ModManager.SettingsTest
{
using Cthangover.Core.Mods;
/// <summary>Explicit frontend-only UI test, leaving game assemblies unloaded.</summary>
public class CollectionSettingsTestInitializer : IModInitializer
{
private static bool queued;
public void OnModLoaded(string modId) { }
public void OnModResourcesReady()
{
if (queued)
return;
queued = true;
ModRoot.AddDeferred(new CollectionSettingsTestDriver());
}
}
}
+1
View File
@@ -1,4 +1,5 @@
{ {
"name": "Mod documentation test", "name": "Mod documentation test",
"depends": ["menu_base>=1"],
"sources": ["src/*.cs"] "sources": ["src/*.cs"]
} }
@@ -6,13 +6,19 @@ using Cthangover.Core.Localization;
using Cthangover.Core.Mods; using Cthangover.Core.Mods;
using Cthangover.Core.UI.Menu; using Cthangover.Core.UI.Menu;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Cthangover.Core.Settings;
using Cthangover.BaseMenu;
using Cthangover.Mods.Interface.Menu; using Cthangover.Mods.Interface.Menu;
using Cthangover.Mods.Interface.View; using Cthangover.Mods.Interface.View;
using Godot; using Godot;
namespace Mods.ModManager.DocumentationTest namespace Mods.ModManager.DocumentationTest
{ {
/// <summary>Opens music_pack through the hub and exercises language selection on the actual page.</summary> /// <summary>
/// Exercises the reference page, or reviews a real English page with
/// --doc-mod=id. --doc-settings additionally captures the real Sound tab;
/// the original settings file and language are restored even after failure.
/// </summary>
public partial class DocumentationTestDriver : Node public partial class DocumentationTestDriver : Node
{ {
private double elapsed; private double elapsed;
@@ -24,6 +30,12 @@ namespace Mods.ModManager.DocumentationTest
private ModHubScreen hub; private ModHubScreen hub;
private ModManagerScreen manager; private ModManagerScreen manager;
private ModDetailView detail; private ModDetailView detail;
private readonly string reviewMod = ReviewMod();
private string reviewTitle;
private SettingsMenu settings;
private string settingsPath;
private byte[] settingsBackup;
private string settingsLanguage;
public override void _Process(double delta) public override void _Process(double delta)
{ {
@@ -40,7 +52,10 @@ namespace Mods.ModManager.DocumentationTest
stepAt = elapsed; stepAt = elapsed;
try try
{ {
RunStep(); if (string.IsNullOrEmpty(reviewMod))
RunStep();
else
RunReviewStep();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -82,7 +97,7 @@ namespace Mods.ModManager.DocumentationTest
Check(Body().GetContentHeight() > 600, "long document lays out"); Check(Body().GetContentHeight() > 600, "long document lays out");
var codeFont = Body().GetThemeFont("mono_font"); var codeFont = Body().GetThemeFont("mono_font");
Check(codeFont.HasChar('└') && codeFont.HasChar('─') && codeFont.HasChar('├'), "bundled monospace font covers tree glyphs"); Check(codeFont.HasChar('└') && codeFont.HasChar('─') && codeFont.HasChar('├'), "bundled monospace font covers tree glyphs");
Check(ModDocumentation.Discover(ModRegistry.Instance.AllDiscoveredMods["music_pack"].FileProvider).Count == 2, "two bundled languages"); Check(ModDocumentation.Discover(ModRegistry.Instance.AllDiscoveredMods["music_pack"].FileProvider).Count == LanguageCode.Supported.Length, "every project language is bundled");
Shot("music_pack_ru"); Shot("music_pack_ru");
SelectLanguage("EN"); SelectLanguage("EN");
break; break;
@@ -100,16 +115,21 @@ namespace Mods.ModManager.DocumentationTest
LocaleLoader.LoadLocale("ja"); LocaleLoader.LoadLocale("ja");
break; break;
case 6: case 6:
Check(Body().GetParsedText().Contains("a soundtrack for your game"), "missing Japanese falls back to English after locale change"); Check(Body().GetParsedText().Contains("あなたのゲームのためのサウンドトラック"), "auto language follows the interface language (Japanese)");
Check(detail.GetNode<Label>("Scroll/Content/DocumentationBox/Source").Text.Contains("EN"), "actual fallback language is visible"); Check(detail.GetNode<Label>("Scroll/Content/DocumentationBox/Source").Text.Contains("JA"), "actual document language is visible");
manager.CloseDetail(); LocaleLoader.LoadLocale("de");
manager.SetSearch("audio_base");
break; break;
case 7: case 7:
var legacyCard = hub.FindChildren("*", "", true, false).OfType<ModCard>().First(IsVisibleCard); Check(Body().GetParsedText().Contains("a soundtrack for your game"), "unbundled German falls back to English after locale change");
legacyCard.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true }); LocaleLoader.LoadLocale("en");
break; break;
case 8: case 8:
Check(detail.GetNode<Label>("Scroll/Content/DocumentationBox/Source").Text.Contains("EN"), "document language is visible for a bundled UI language");
// A controlled legacy record stays valid as documentation
// is gradually added to every real mod in the project.
detail.Open(new ModCardData { Id = "documentation_legacy_fixture", Title = "Legacy fixture", Description = "Manifest-only description" });
break;
case 9:
Check(!detail.GetNode<Control>("Scroll/Content/DocumentationBox").Visible, "legacy mod keeps manifest description"); Check(!detail.GetNode<Control>("Scroll/Content/DocumentationBox").Visible, "legacy mod keeps manifest description");
Check(!string.IsNullOrWhiteSpace(detail.GetNode<Label>("Scroll/Content/DescriptionBox/Text").Text), "legacy description remains readable"); Check(!string.IsNullOrWhiteSpace(detail.GetNode<Label>("Scroll/Content/DescriptionBox/Text").Text), "legacy description remains readable");
Finish(null); Finish(null);
@@ -118,9 +138,95 @@ namespace Mods.ModManager.DocumentationTest
step++; step++;
} }
private static string ReviewMod()
{
foreach (var arg in OS.GetCmdlineUserArgs())
if (arg.StartsWith("--doc-mod=", StringComparison.Ordinal))
return arg.Substring("--doc-mod=".Length);
return null;
}
private void RunReviewStep()
{
switch (step)
{
case 0:
if (!Boot.IsReady)
return;
if (GetTree().CurrentScene?.FindChild("LauncherPlay", true, false) is Button launcherPlay)
{
launcherPlay.EmitSignal(BaseButton.SignalName.Pressed);
return;
}
if (GetTree().CurrentScene?.FindChild("play", true, false) is not Button)
return;
originalLocale = TranslationServer.GetLocale();
settingsLanguage = GameData.Instance.Settings.Language;
settingsPath = ProjectSettings.GlobalizePath("user://settings.json");
settingsBackup = File.Exists(settingsPath) ? File.ReadAllBytes(settingsPath) : null;
settings = GetTree().CurrentScene.GetNode<SettingsMenu>("SettingsPanel");
settings.Open();
var language = settings.GetNode<OptionButton>("Window/Column/Body/Pages/General/LangRow/LangSelect");
var english = Array.IndexOf(LanguageCode.Supported, "en");
language.Select(english);
language.EmitSignal(OptionButton.SignalName.ItemSelected, (long)english);
settings.ShowTab(1);
break;
case 1:
Check(TranslationServer.GetLocale().StartsWith("en", StringComparison.Ordinal), "English UI selected through Settings");
if (OS.GetCmdlineUserArgs().Contains("--doc-settings") || OS.GetCmdlineUserArgs().Contains("--doc-settings-only"))
{
var audio = settings.GetNode<Control>("Window/Column/Body/Pages/Audio");
Check(audio.IsVisibleInTree(), "actual Sound tab visible");
Shot(reviewMod + "_sound_settings");
}
if (OS.GetCmdlineUserArgs().Contains("--doc-settings-only"))
{
Finish(null);
return;
}
settings.Visible = false;
var entry = MenuRegistry.GetButtonById("mods_hub");
hub = MenuBuilder.OpenScene((Control)GetTree().CurrentScene, entry.ScenePath, "mods_hub") as ModHubScreen;
Check(hub != null, "hub opens for documentation review");
break;
case 2:
manager = hub.FindChildren("*", "", true, false).OfType<ModManagerScreen>().First();
manager.SetSearch(reviewMod);
break;
case 3:
// The search also matches test suites such as mod_manager__documentation; open the reviewed mod itself.
reviewTitle = ModRegistry.Instance.AllDiscoveredMods[reviewMod].DisplayTitle ?? reviewMod;
var card = hub.FindChildren("*", "", true, false).OfType<ModCard>().First(IsReviewedCard);
card.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true });
break;
case 4:
detail = hub.FindChildren("*", "", true, false).OfType<ModDetailView>().First(IsVisibleDetail);
var provider = ModRegistry.Instance.AllDiscoveredMods[reviewMod].FileProvider;
var document = ModDocumentation.Select(ModDocumentation.Discover(provider), "en");
Check(document?.Path == "docs/en.md", "English entry point exists for " + reviewMod);
var heading = document.Text.Split('\n')[0].TrimStart('#', ' ', '\uFEFF').TrimEnd('\r');
var shown = Body().GetParsedText();
Check(shown.Contains(heading), "actual page displays the selected English document; heading '" + heading + "', shown '" + shown.Substring(0, Math.Min(160, shown.Length)) + "'");
Check(!Body().GetParsedText().Contains(TranslationServer.Translate("mod_manager/docs_image_unavailable").ToString()), "all page images resolve");
Check(Body().GetContentHeight() > 300, "page lays out for scrolling");
Shot(reviewMod + "_documentation_en");
LocaleLoader.LoadLocale("ru");
break;
case 5:
Check(detail.GetNode<Label>("Scroll/Content/DocumentationBox/Source").Text.Contains("EN"), "actual English fallback is identified");
Check(!string.IsNullOrWhiteSpace(Body().GetParsedText()), "fallback document remains readable");
Shot(reviewMod + "_documentation_fallback");
Finish(null);
break;
}
step++;
}
private ModMarkdownView Body() => detail.GetNode<ModMarkdownView>("Scroll/Content/DocumentationBox/Body"); private ModMarkdownView Body() => detail.GetNode<ModMarkdownView>("Scroll/Content/DocumentationBox/Body");
private OptionButton Languages() => detail.GetNode<OptionButton>("Scroll/Content/DocumentationBox/Header/Languages"); private OptionButton Languages() => detail.GetNode<OptionButton>("Scroll/Content/DocumentationBox/Header/Languages");
private static bool IsVisibleCard(ModCard card) => card.IsVisibleInTree(); private static bool IsVisibleCard(ModCard card) => card.IsVisibleInTree();
private bool IsReviewedCard(ModCard card) => IsVisibleCard(card) && card.GetNode<Label>("VBox/Body/Box/Title").Text == reviewTitle;
private static bool IsVisibleDetail(ModDetailView page) => page.IsVisibleInTree(); private static bool IsVisibleDetail(ModDetailView page) => page.IsVisibleInTree();
private static bool IsScreenshotArg(string arg) => arg.StartsWith("--shots="); private static bool IsScreenshotArg(string arg) => arg.StartsWith("--shots=");
@@ -175,6 +281,14 @@ namespace Mods.ModManager.DocumentationTest
return; return;
done = true; done = true;
hub?.QueueFree(); hub?.QueueFree();
if (settingsPath != null)
{
GameData.Instance.Settings.Language = settingsLanguage;
if (settingsBackup == null)
File.Delete(settingsPath);
else
File.WriteAllBytes(settingsPath, settingsBackup);
}
if (originalLocale != null) if (originalLocale != null)
LocaleLoader.LoadLocale(originalLocale); LocaleLoader.LoadLocale(originalLocale);
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
+79
View File
@@ -0,0 +1,79 @@
namespace Mods.ModManager.Tests
{
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.Json;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Providers;
using Cthangover.Core.Mods.Testing;
/// <summary>Declarations work before mod activation, including ZIP storage, translated resources and unavailable choices.</summary>
public class CollectionSettingsCatalogTests
{
[ModTest("collection_settings/roundtrip")]
static void Roundtrip(ModTestContext ctx)
{
var original = new CollectionSettingsFile
{
Settings = new() { new() { Key = "test.choice", Name = "field", Description = "hint", Group = "group", Type = "resource", Source = "charsets", AllowCustom = true, Automatic = "fallback", Pattern = "^d[0-9]+$", Options = new() { new() { Key = "test.choice", Value = "d20", Name = "twenty" } } } },
Options = new() { new() { Key = "test.choice", Value = "d6", Name = "six" } },
};
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
var restored = JsonSerializer.Deserialize<CollectionSettingsFile>(JsonSerializer.Serialize(original, options), options);
ModAssert.Equal(JsonSerializer.Serialize(original, options), JsonSerializer.Serialize(restored, options), "all declaration fields survive serialization");
ModAssert.Equal("d20", restored.Settings[0].Options[0].Value);
ModAssert.Equal("test.choice", restored.Options[0].Key);
}
[ModTest("collection_settings/offline_folder_zip")]
static void OfflineFolderZip(ModTestContext ctx)
{
var root = Path.Combine(ctx.SandboxDir, "collection_settings");
var zip = root + ".zip";
Directory.CreateDirectory(Path.Combine(root, "locale"));
Directory.CreateDirectory(Path.Combine(root, "charsets", "village"));
Directory.CreateDirectory(Path.Combine(root, "characters"));
File.WriteAllText(Path.Combine(root, "collection_settings.json"), "{\"settings\":[{\"key\":\"look\",\"name\":\"appearance\",\"type\":\"resource\",\"source\":\"charsets\"},{\"key\":\"who\",\"type\":\"character\",\"source\":\"characters\",\"allow_custom\":true},{\"key\":\"dice\",\"allow_custom\":true,\"pattern\":\"^(d20|2d6|none)$\"}],\"options\":[{\"key\":\"dice\",\"value\":\"d20\",\"name\":\"twenty\"}]}");
File.WriteAllText(Path.Combine(root, "locale", "settings_en.properties"), "appearance=Appearance\nhero=Hero\ntwenty=Twenty\n");
File.WriteAllText(Path.Combine(root, "locale", "settings_ru.properties"), "appearance=Внешность\nhero=Герой\n");
File.WriteAllBytes(Path.Combine(root, "charsets", "village", "hero.png"), new byte[] { 1 });
File.WriteAllText(Path.Combine(root, "characters", "heroes.json"), "{\"Kind\":\"character\",\"Items\":[{\"Id\":\"hero\",\"Name\":\"hero\"}]}");
try
{
ZipFile.CreateFromDirectory(root, zip);
using var folder = new FolderModFileProvider(root, "fixture");
using var archive = new ZipModFileProvider(zip, "fixture");
foreach (var provider in new IModFileProvider[] { folder, archive })
{
var mods = new IModInfo[] { new ModInfo { Id = "fixture", FileProvider = provider } };
var catalog = new CollectionSettingsCatalog(mods, new[] { "fixture" }, "ru_RU");
ModAssert.Equal(3, catalog.Definitions.Count, "declarations without loaded assemblies");
ModAssert.Equal("Внешность", catalog.Translate("appearance"), "regional locale");
ModAssert.Equal("Twenty", catalog.Translate("twenty"), "English fallback");
var look = catalog.Definitions.Single(d => d.Key == "look");
var sprite = catalog.Choices(look).Single();
ModAssert.Equal("village/hero", sprite.Value, "resource id keeps subfolder");
ModAssert.Equal("charsets/village/hero.png", sprite.PreviewPath);
var who = catalog.Definitions.Single(d => d.Key == "who");
ModAssert.Equal("Герой", catalog.Translate(catalog.Choices(who).Single().Name));
ModAssert.True(catalog.IsValidCustom(who, "created_at_runtime"), "runtime character ids allowed");
var dice = catalog.Definitions.Single(d => d.Key == "dice");
ModAssert.True(catalog.IsValidCustom(dice, "2d6"), "valid custom syntax");
ModAssert.False(catalog.IsValidCustom(dice, "typo"), "invalid custom syntax");
var inactive = new CollectionSettingsCatalog(mods, Array.Empty<string>(), "en");
var inactiveDice = inactive.Definitions.Single(d => d.Key == "dice");
ModAssert.False(inactiveDice.Available, "removed owner's saved keys remain describable");
ModAssert.False(inactive.Choices(inactiveDice).Single().Available, "choice from removed mod is unavailable");
ModAssert.Equal(0, inactive.Choices(inactive.Definitions.Single(d => d.Key == "look")).Count, "unselected resources excluded");
}
}
finally
{
File.Delete(zip);
Directory.Delete(root, true);
}
}
}
}