Compare commits

..
19 Commits
Author SHA1 Message Date
cthangover 307da15a12 Add mod documentation in seven languages 2026-10-29 09:26:23 +01:00
iisaev 90ce5fad8c gitignore file 2026-10-28 15:20:06 +03:00
cthangover fceeee6f32 Add shared syntax highlighting for source text fields 2026-10-26 19:44:25 +03:00
cthangover 445f7d5e61 Localization in es, zh, ja, ko, vi 2026-10-23 20:54:54 +02:00
cthangover 891c327600 Colour pixel-art toolbar icons, 64 px toolbar slots, ViewportCapture: screenshots in sRGB under hdr_2d 2026-10-23 09:03:53 +02:00
cthangover 7dd97768d9 Mod cover 2026-10-22 17:09:44 +02:00
cthangover 22fb8cc13c Menu buttons keep entry icons text-sized (icon_max_width) 2026-10-21 19:18:35 +02:00
cthangover c4c90116ed Line endings: LF everywhere
.gitattributes forces LF (CRLF only for .bat/.cmd), core.autocrlf is off;
files that differed only by CRLF are normalized.
2026-10-21 13:29:59 +02:00
cthangover 3fb418fa4a Commit pending working-tree changes (local edits, line endings) 2026-10-21 12:32:04 +02:00
cthangover 9ecbbb4056 Mod naming rule and engine UI in engine mods
Mods are renamed by role (shell2d_base, menu_base, ui_base, live2d_base,
editor_tools, cthangover_story, cthangover_murakami, cthangover_party) and
every reference follows. Engine UI prefabs (dialog, answers, message, game
menu, inventory, map, skills) and their panel entries moved out of the story
mod into the subsystem mods; the card_novel and vn_starter_novel collections
are removed.
2026-10-18 19:00:46 +02:00
cthangover 70ff37d127 Own the shared UI sounds close_click and skip
The resource atlas reported them as used by the interface widgets but
shipped by visual_novel_core, a content mod the interface does not depend
on. They move here, next to their users.
2026-10-17 14:27:30 +00:00
cthangover 563b42ea9f Menu: conditional buttons, OpenScene returns the opened node
- MenuBuilder skips a button whose MenuButtonEntry.Visible predicate is
  false (e.g. "Continue" only when a last session exists); a throwing
  predicate is logged and the button stays visible.
- MenuBuilder.OpenScene returns the overlay it opened (null when the
  scene failed to build), so callers can configure it.
- Text for mods left out of a game's mod set: mods/not_in_game_reason.
2026-10-17 14:27:29 +00:00
cthangover b2a0f1c10a Tools: one toolbar button for all tools, styled tool windows
- ToolBox: instead of a text button per tool (which did not fit the 60x60
  icon row and overlapped the icons) the HUD gets one "tools" icon button
  with a popup listing every visible tool, sorted by localized name.
- ToolWindow: chrome that follows the UI style guide (title on the left,
  close on the right, drag-to-move, double-click/maximize, edge resize),
  a status bar with buffered status/hint text, dirty-state confirmation
  and keyboard shortcuts (Ctrl+S in _Input, other keys only when no
  focused control consumed them).
- ToolTheme: denser copy of the game theme for editor toolbars;
  ToolStatusKind: status bar message kinds.
- New tools icon and the tool window texts (unsaved changes, maximize,
  close, menu title) in en/ru.
2026-10-17 14:27:19 +00:00
iisaevandClaude Opus 5.5 6884cd5374 upd: sync local changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:18:16 +00:00
cthangover 7a30f4b8c5 map ui, skill ui, update skills data 2026-09-29 13:05:41 +03:00
cthangover dc965d7426 new battle gui 2026-09-28 10:48:05 +03:00
cthangover 0703313f49 ui stule update 2026-09-26 08:05:12 +03:00
cthangover 98d439fa1a upd 2026-09-24 11:17:03 +03:00
cthangover 80e8820ade move ui to mods 2026-09-24 10:42:43 +03:00
81 changed files with 6072 additions and 16 deletions

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
* text=auto eol=lf
# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf
# Binaries: never converted, never diffed as text.
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.svg text
*.ogg binary
*.wav binary
*.mp3 binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.dll binary
*.so binary
*.dylib binary
*.pdb binary
*.exe binary
*.zip binary
*.pck binary
*.moc3 binary
*.res binary
*.scn binary
+1
View File
@@ -12,6 +12,7 @@ obj/
gen/ gen/
obj/ obj/
bin/ bin/
art_sources/
*.tres~ *.tres~
*.tscn~ *.tscn~
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+71
View File
@@ -0,0 +1,71 @@
# UI — the shared interface of every game
UI is the interface layer that the games share: the toolbar in the top-right corner, the in-game menu, the save indicator, notifications in the top-left corner, the background view and the common look of panels, buttons and lists. It has no story or rules of its own. Almost every other mod builds on it, which is why it is in every game and why so many mods list it as a requirement.
![A scene in Cthangover at noon: the clock at the top left, the toolbar at the top right, the scene's choices on the right.](assets/scene-hud.jpg)
## The toolbar
Each icon opens a panel. The buttons are not fixed: every mod in the game adds its own, so a novel without items has no bag, and a game with the Scenario Studio has one more icon.
![The Cthangover toolbar: tools, map, bag, characters, recruits, skills, menu (gear) and journal.](assets/toolbar.png)
In Cthangover, from left to right:
1. **Tools** — the developer tools menu (only when editors are in the game).
2. **Map** — world map (Map Engine).
3. **Bag** — inventory (Items).
4. **Characters** — party and characters (Characters).
5. **Recruits** — recruited characters (Recruiting).
6. **Skills** — skills (Skills).
7. **Menu** — the in-game menu described below (this mod).
8. **Journal** — quests (Quests).
Other games show other sets; RPG Plus, for example, adds the **character sheet**.
## The in-game menu
The gear icon opens the menu: **Resume**, **Save**, **Load**, **Settings**, **Main menu** and **Exit**. Save is greyed out in scenes that do not allow saving, as in this picture. Load, Save and Settings open the same windows as in the main menu.
![The in-game menu over a scene: Menu, Resume, Save, Load, Settings, Main menu, Exit.](assets/game-menu.jpg)
## Notifications
Short messages appear in the top-left corner: a new journal entry, a hint from the story. Up to four are shown at once, each for about ten seconds; more wait their turn.
![A notification under the clock: "Look around. Something is lying in the alley." — a hint from Cthangover's prologue.](assets/notification.png)
## For mod authors
**Add a toolbar button and its panel** with a `ui` entry in your manifest. This is how Items adds the bag:
```json
"ui": [
{
"id": "inventory",
"kind": "panel",
"slot": "ModBeforeUIPanel",
"scene_path": "scenes/ui/inventory_panel.tscn",
"button": { "icon": "bag", "locale": "ui/inventory/title", "index": 20 },
"priority": 10
}
]
```
- `button.index` orders the icons (map 10, bag 20, characters 30, skills 40, menu 50, journal 60, studio 90);
- `button.icon` is the icon's name, `button.locale` the tooltip's translation key;
- an entry without `button` is a panel or overlay that the game opens itself — Crafting adds its workbench as `"kind": "overlay"` in the `ModLastPanel` slot and opens it from scenarios.
**Open a panel from a scenario** with the `ui.toggle_panel` action, and **show a notification** with the `notify` command:
```scenario
notify "The journal has a new entry."
```
**Use the common look.** Panels built in code take their frames, labels, chips and buttons from the toolkit (`UiKit`) and its colour and size tokens, so a new panel matches the rest of the game and follows its theme.
## If something goes wrong
1. **A toolbar button is missing.** The mod that adds it is not part of this game; check the game's scheme in **Mods & Collections**.
2. **Save is greyed out.** The current scene does not allow saving; move to another place and try again. Authors allow it with `save_allowed: true` in the header of one of the scene's scenarios.
3. **A panel opens but is empty.** The panel's mod has nothing to show yet in this game — for example, no quests have started.
+71
View File
@@ -0,0 +1,71 @@
# UI — la interfaz común de todos los juegos
UI es la capa de interfaz que comparten todos los juegos: la barra de herramientas en la esquina superior derecha, el menú del juego, el indicador de guardado, las notificaciones en la esquina superior izquierda, la vista del fondo y el aspecto común de paneles, botones y listas. No tiene historia ni reglas propias. Casi todos los demás mods se construyen sobre ella, y por eso está presente en todos los juegos y tantos mods la declaran como dependencia.
![Una escena de Cthangover al mediodía: el reloj arriba a la izquierda, la barra de herramientas arriba a la derecha y las opciones de la escena a la derecha.](assets/scene-hud.jpg)
## La barra de herramientas
Cada icono abre su panel. Los botones no son fijos: cada mod del juego añade los suyos, así que una novela sin objetos no tiene bolsa, y un juego con Scenario Studio muestra un icono más.
![La barra de herramientas de Cthangover: herramientas, mapa, bolsa, personajes, reclutas, habilidades, menú (engranaje) y diario.](assets/toolbar.png)
En Cthangover, de izquierda a derecha:
1. **Herramientas** — el menú de herramientas de desarrollo (solo cuando hay editores en el juego).
2. **Mapa** — el mapa del mundo (Map Engine).
3. **Bolsa** — el inventario (Items).
4. **Personajes** — el grupo y los personajes (Characters).
5. **Reclutas** — los personajes reclutados (Recruiting).
6. **Habilidades** — las habilidades (Skills).
7. **Menú** — el menú del juego, descrito más abajo (este mod).
8. **Diario** — las misiones (Quests).
Otros juegos muestran otros conjuntos; RPG Plus, por ejemplo, añade la **hoja de personaje**.
## El menú del juego
El icono del engranaje abre el menú: **Continuar**, **Guardar**, **Cargar**, **Ajustes**, **Menú principal** y **Salir**. En las escenas que no permiten guardar, la opción Guardar aparece deshabilitada, como en la imagen. Cargar, Guardar y Ajustes abren las mismas ventanas que en el menú principal.
![El menú del juego sobre una escena: Menu, Resume, Save, Load, Settings, Main menu, Exit.](assets/game-menu.jpg)
## Notificaciones
En la esquina superior izquierda aparecen mensajes breves: una entrada nueva del diario, una pista de la historia. Se ven hasta cuatro a la vez, cada uno unos diez segundos; los demás esperan su turno.
![Una notificación bajo el reloj: «Mira a tu alrededor. Hay algo tirado en el callejón.» — una pista del prólogo de Cthangover.](assets/notification.png)
## Para autores de mods
**Añadir un botón a la barra de herramientas con su panel** se hace con una entrada `ui` en el manifiesto. Así añade Items la bolsa:
```json
"ui": [
{
"id": "inventory",
"kind": "panel",
"slot": "ModBeforeUIPanel",
"scene_path": "scenes/ui/inventory_panel.tscn",
"button": { "icon": "bag", "locale": "ui/inventory/title", "index": 20 },
"priority": 10
}
]
```
- `button.index` ordena los iconos (mapa 10, bolsa 20, personajes 30, habilidades 40, menú 50, diario 60, estudio 90);
- `button.icon` es el nombre del icono y `button.locale`, la clave de traducción del tooltip;
- una entrada sin `button` es un panel o una capa superpuesta que el juego abre por su cuenta: Crafting añade su mesa de trabajo como `"kind": "overlay"` en el slot `ModLastPanel` y la abre desde los escenarios.
**Abrir un panel desde un escenario** se hace con la acción `ui.toggle_panel`, y **mostrar una notificación**, con el comando `notify`:
```scenario
notify "Nueva entrada en el diario."
```
**Usa el aspecto común.** Los paneles creados en código toman sus marcos, etiquetas, chips y botones del kit de interfaz (`UiKit`) y de sus tokens de color y tamaño, así que un panel nuevo encaja con el resto del juego y sigue su tema.
## Si algo falla
1. **Falta un botón en la barra de herramientas.** El mod que lo añade no forma parte de este juego; revisa el esquema del juego en **Mods y colecciones**.
2. **Guardar aparece deshabilitado.** La escena actual no permite guardar; ve a otro sitio e inténtalo de nuevo. Los autores lo permiten con `save_allowed: true` en la cabecera de uno de los escenarios de la escena.
3. **Un panel se abre vacío.** Su mod todavía no tiene nada que mostrar en este juego; por ejemplo, aún no ha empezado ninguna misión.
+71
View File
@@ -0,0 +1,71 @@
# UI — すべてのゲームで共有されるインターフェース
UI は全ゲームが共有するインターフェース層です。右上のツールバー、ゲーム内メニュー、セーブインジケーター、左上の通知、背景の表示、そしてパネル・ボタン・リストの共通デザインがここに集まっています。UI 自体は物語もルールも持ちません。ほぼすべての他の MOD が UI の上に成り立っているため、どのゲームにも含まれており、依存関係に挙げる MOD も非常に多くなっています。
![Cthangover の正午のシーン:左上に時計、右上にツールバー、右側にシーンの選択肢。](assets/scene-hud.jpg)
## ツールバー
アイコンごとにパネルが開きます。ボタンは固定ではなく、ゲームに含まれる各 MOD が自分のボタンを追加します。そのため、アイテムのないノベルにはインベントリのボタンがなく、Scenario Studio を含むゲームではアイコンが一つ増えます。
![Cthangover のツールバー:ツール、マップ、インベントリ、キャラクター、勧誘した者たち、スキル、メニュー(歯車)、日誌。](assets/toolbar.png)
Cthangover では左から順に:
1. **ツール** — 開発者ツールのメニュー(エディターがゲームに含まれているときだけ)。
2. **マップ** — ワールドマップ(Map Engine)。
3. **インベントリ** — 所持品(Items)。
4. **キャラクター** — パーティとキャラクター(Characters)。
5. **勧誘した者たち** — 勧誘で仲間にしたキャラクター(Recruiting)。
6. **スキル** — スキル(Skills)。
7. **メニュー** — 後述のゲーム内メニュー(この MOD)。
8. **日誌** — クエスト(Quests)。
他のゲームでは並びが異なります。たとえば RPG Plus は**キャラクターシート**を追加します。
## ゲーム内メニュー
歯車のアイコンでメニューが開きます:**再開**、**セーブ**、**ロード**、**設定**、**メインメニュー**、**終了**。セーブが許可されていないシーンでは、画像のように「セーブ」がグレーアウトします。「ロード」「セーブ」「設定」はメインメニューと同じウィンドウを開きます。
![シーンの上に開いたゲーム内メニュー:Menu, Resume, Save, Load, Settings, Main menu, Exit。](assets/game-menu.jpg)
## 通知
左上には短いメッセージが表示されます。日誌の新しい記録や、物語からのヒントなどです。同時に最大 4 件まで表示され、それぞれ約 10 秒で消えます。残りは順番を待ちます。
![時計の下の通知:「あたりを見渡せ。路地に何か落ちている。」 — Cthangover プロローグのヒント。](assets/notification.png)
## MOD 作者向け
**ツールバーボタンとそのパネルを追加する**には、manifest の `ui` エントリーを使います。Items がインベントリを追加しているのがその例です:
```json
"ui": [
{
"id": "inventory",
"kind": "panel",
"slot": "ModBeforeUIPanel",
"scene_path": "scenes/ui/inventory_panel.tscn",
"button": { "icon": "bag", "locale": "ui/inventory/title", "index": 20 },
"priority": 10
}
]
```
- `button.index` がアイコンの順序を決めます(マップ 10、インベントリ 20、キャラクター 30、スキル 40、メニュー 50、日誌 60、スタジオ 90);
- `button.icon` はアイコン名、`button.locale` はツールチップの翻訳キー;
- `button` のないエントリーは、ゲームが自ら開くパネルやオーバーレイです。Crafting は作業台を `"kind": "overlay"` として `ModLastPanel` スロットに登録し、シナリオから開きます。
**シナリオからパネルを開く**には `ui.toggle_panel` アクションを、**通知を表示する**には `notify` コマンドを使います:
```scenario
notify "日誌に新しい記録"
```
**共通デザインを使う。** コードで組み立てたパネルは、フレームやラベル、チップ、ボタンを UI キット(`UiKit`)とその色・サイズのトークンから受け取ります。新しいパネルもゲームの見た目に自然になじみ、テーマに従います。
## うまく動かないとき
1. **ツールバーにボタンがない。** そのボタンを追加する MOD がこのゲームに含まれていません。**MOD とコレクション**でゲームの構成を確認してください。
2. **「セーブ」がグレーアウトしている。** 現在のシーンはセーブを許可していません。別の場所へ移動してからもう一度。作者はシーンのシナリオのいずれかのヘッダーに `save_allowed: true` と書けばセーブを許可できます。
3. **パネルが開くが中身がない。** その MOD には、このゲームでまだ見せるものがないのです。たとえばクエストがまだ始まっていない、といった具合です。
+71
View File
@@ -0,0 +1,71 @@
# UI — 모든 게임이 공유하는 인터페이스
UI는 모든 게임이 함께 쓰는 인터페이스 계층입니다. 오른쪽 위의 툴바, 인게임 메뉴, 저장 표시, 왼쪽 위의 알림, 배경 화면, 그리고 패널·버튼·목록의 공통 디자인이 여기에 속합니다. UI 자체에는 이야기도 규칙도 없습니다. 거의 모든 다른 모드가 UI 위에서 만들어지기 때문에 모든 게임에 들어 있고, 수많은 모드가 이를 의존성으로 지정합니다.
![정오의 Cthangover 장면: 왼쪽 위 시계, 오른쪽 위 툴바, 오른쪽 장면 선택지.](assets/scene-hud.jpg)
## 툴바
아이콘 하나당 패널 하나가 열립니다. 버튼 목록은 고정되어 있지 않습니다. 게임의 각 모드가 자기 버튼을 추가하므로, 아이템이 없는 소설 게임에는 인벤토리 버튼이 없고 Scenario Studio가 포함된 게임에는 아이콘이 하나 더 보입니다.
![Cthangover 툴바: 도구, 지도, 인벤토리, 캐릭터, 영입자, 스킬, 메뉴(톱니바퀴), 일지.](assets/toolbar.png)
Cthangover 기준 왼쪽부터:
1. **도구** — 개발자 도구 메뉴(게임에 편집기가 있을 때만).
2. **지도** — 월드 맵(Map Engine).
3. **인벤토리** — 소지품(Items).
4. **캐릭터** — 파티와 캐릭터(Characters).
5. **영입자** — 영입한 캐릭터(Recruiting).
6. **스킬** — 스킬(Skills).
7. **메뉴** — 아래에서 설명할 인게임 메뉴(이 모드).
8. **일지** — 퀘스트(Quests).
다른 게임에서는 구성이 다릅니다. 예를 들어 RPG Plus는 **캐릭터 시트**를 추가합니다.
## 인게임 메뉴
톱니바퀴 아이콘을 누르면 메뉴가 열립니다: **계속**, **저장**, **불러오기**, **설정**, **메인 메뉴**, **나가기**. 저장이 허용되지 않는 장면에서는 그림처럼 저장 항목이 회색으로 비활성화됩니다. 불러오기, 저장, 설정은 메인 메뉴와 같은 창을 엽니다.
![장면 위에 열린 인게임 메뉴: Menu, Resume, Save, Load, Settings, Main menu, Exit.](assets/game-menu.jpg)
## 알림
왼쪽 위에는 짧은 메시지가 나타납니다. 일지에 새 항목이 생겼다거나, 이야기가 힌트를 건네는 식입니다. 한 번에 최대 네 개까지 표시되고 각각 열 초 정도 머문 뒤 사라지며, 나머지는 차례를 기다립니다.
![시계 아래 알림: "주변을 둘러보자. 골목에 뭔가 놓여 있다." — Cthangover 프롤로그의 힌트.](assets/notification.png)
## 모드 제작자를 위해
**툴바 버튼과 그 패널 추가하기** — manifest에 `ui` 항목을 넣으면 됩니다. Items가 인벤토리를 추가하는 방식이 그 예입니다:
```json
"ui": [
{
"id": "inventory",
"kind": "panel",
"slot": "ModBeforeUIPanel",
"scene_path": "scenes/ui/inventory_panel.tscn",
"button": { "icon": "bag", "locale": "ui/inventory/title", "index": 20 },
"priority": 10
}
]
```
- `button.index`는 아이콘 순서를 정합니다(지도 10, 인벤토리 20, 캐릭터 30, 스킬 40, 메뉴 50, 일지 60, 스튜디오 90);
- `button.icon`은 아이콘 이름, `button.locale`은 툴팁의 번역 키입니다;
- `button` 없는 항목은 게임이 스스로 여는 패널이나 오버레이입니다. Crafting은 작업대를 `"kind": "overlay"`로 `ModLastPanel` 슬롯에 넣고 시나리오에서 엽니다.
**시나리오에서 패널 열기**는 `ui.toggle_panel` 액션으로, **알림 표시**는 `notify` 명령으로 합니다:
```scenario
notify "새 일지 항목"
```
**공통 디자인 사용하기.** 코드로 만든 패널도 프레임, 라벨, 칩, 버튼을 UI 키트(`UiKit`)와 색·크기 토큰에서 가져옵니다. 새 패널도 게임 전체와 자연스럽게 어울리고 테마를 따릅니다.
## 문제가 생겼을 때
1. **툴바에 버튼이 없다.** 그 버튼을 추가하는 모드가 이 게임에 포함되어 있지 않습니다. **모드와 컬렉션**에서 게임 구성을 확인하세요.
2. **저장이 비활성화되어 있다.** 현재 장면은 저장을 허용하지 않습니다. 다른 곳으로 이동한 뒤 다시 시도하세요. 제작자는 장면 시나리오 중 하나의 헤더에 `save_allowed: true`를 넣어 저장을 허용할 수 있습니다.
3. **패널은 열리지만 비어 있다.** 해당 모드가 이 게임에서 아직 보여 줄 것이 없는 것입니다. 예컨대 퀘스트가 아직 시작되지 않았을 때 그렇습니다.
+71
View File
@@ -0,0 +1,71 @@
# UI — общий интерфейс всех игр
UI — общий для всех игр слой интерфейса: панель инструментов в правом верхнем углу, игровое меню, индикатор сохранения, уведомления в левом верхнем углу, просмотр фона и единый вид панелей, кнопок и списков. Собственной истории и правил у него нет. Почти все остальные моды строятся поверх него — поэтому UI есть в каждой игре и значится в зависимостях у огромного числа модов.
![Сцена в Cthangover в полдень: часы слева вверху, панель инструментов справа вверху, варианты выбора сцены справа.](assets/scene-hud.jpg)
## Панель инструментов
Каждая иконка открывает свою панель. Набор кнопок не фиксирован: свой добавляет каждый мод игры, поэтому в новелле без предметов не будет сумки, а в игре со Scenario Studio появится ещё одна иконка.
![Панель инструментов Cthangover: инструменты, карта, сумка, персонажи, рекруты, навыки, меню (шестерёнка) и журнал.](assets/toolbar.png)
В Cthangover слева направо:
1. **Инструменты** — меню инструментов разработчика (только когда в игре есть редакторы).
2. **Карта** — карта мира (Map Engine).
3. **Сумка** — инвентарь (Items).
4. **Персонажи** — отряд и персонажи (Characters).
5. **Рекруты** — завербованные персонажи (Recruiting).
6. **Навыки** — навыки (Skills).
7. **Меню** — игровое меню, о нём ниже (этот мод).
8. **Журнал** — задания (Quests).
В других играх набор другой: например, RPG Plus добавляет **лист персонажа**.
## Игровое меню
Иконка-шестерёнка открывает меню: **Вернуться**, **Сохранить**, **Загрузить**, **Настройки**, **Главное меню** и **Выход**. В сценах, где сохранение запрещено, пункт «Сохранить» неактивен — как на скриншоте. «Загрузить», «Сохранить» и «Настройки» открывают те же окна, что и в главном меню.
![Игровое меню поверх сцены: Menu, Resume, Save, Load, Settings, Main menu, Exit.](assets/game-menu.jpg)
## Уведомления
В левом верхнем углу появляются короткие сообщения: новая запись в журнале, подсказка от сюжета. Одновременно показывается до четырёх, каждое держится на экране около десяти секунд, остальные ждут своей очереди.
![Уведомление под часами: «Осмотрись. В переулке что-то лежит.» — подсказка из пролога Cthangover.](assets/notification.png)
## Авторам модов
**Добавить кнопку на панели инструментов и её панель** можно записью `ui` в манифесте. Так Items добавляет сумку:
```json
"ui": [
{
"id": "inventory",
"kind": "panel",
"slot": "ModBeforeUIPanel",
"scene_path": "scenes/ui/inventory_panel.tscn",
"button": { "icon": "bag", "locale": "ui/inventory/title", "index": 20 },
"priority": 10
}
]
```
- `button.index` задаёт порядок иконок (карта 10, сумка 20, персонажи 30, навыки 40, меню 50, журнал 60, студия 90);
- `button.icon` — имя иконки, `button.locale` — ключ перевода подсказки;
- запись без `button` — панель или оверлей, который игра открывает сама: Crafting добавляет верстак как `"kind": "overlay"` в слот `ModLastPanel` и открывает его из сценариев.
**Открыть панель из сценария** можно действием `ui.toggle_panel`, а **показать уведомление** — командой `notify`:
```scenario
notify "Новая запись в журнале."
```
**Придерживайтесь общего стиля.** Панели, собранные в коде, берут рамки, надписи, чипы и кнопки из набора виджетов (`UiKit`) и его токенов цвета и размера, поэтому новая панель сразу вписывается в игру и следует её теме.
## Если что-то пошло не так
1. **На панели инструментов нет кнопки.** Мод, который её добавляет, не входит в эту игру; проверьте состав игры в разделе **Моды и коллекции**.
2. **«Сохранить» неактивно.** Текущая сцена не разрешает сохранение; перейдите в другое место и попробуйте снова. Авторы разрешают его строкой `save_allowed: true` в заголовке одного из сценариев сцены.
3. **Панель открывается пустой.** Моду панели пока нечего показывать в этой игре — например, задания ещё не начались.
+71
View File
@@ -0,0 +1,71 @@
# UI — giao diện chung của mọi game
UI là lớp giao diện mà mọi game dùng chung: thanh công cụ ở góc trên bên phải, menu trong game, biểu tượng lưu, thông báo ở góc trên bên trái, khung xem nền và diện mạo thống nhất của bảng điều khiển, nút bấm, danh sách. UI không có cốt truyện hay luật chơi riêng. Gần như mọi mod khác đều được xây trên nó, vì thế nó hiện diện trong mọi game và rất nhiều mod liệt kê nó làm phụ thuộc.
![Một cảnh trong Cthangover lúc buổi trưa: đồng hồ trên bên trái, thanh công cụ trên bên phải, các lựa chọn của cảnh ở bên phải.](assets/scene-hud.jpg)
## Thanh công cụ
Mỗi biểu tượng mở một bảng điều khiển. Các nút không cố định: mod nào trong game cũng thêm nút của riêng mình, nên một tiểu thuyết tương tác không có túi đồ, còn game có Scenario Studio thì nhiều thêm một biểu tượng.
![Thanh công cụ của Cthangover: công cụ, bản đồ, túi đồ, nhân vật, người được chiêu mộ, kỹ năng, menu (bánh răng) và nhật ký.](assets/toolbar.png)
Trong Cthangover, từ trái sang phải:
1. **Công cụ** — menu công cụ dành cho nhà phát triển (chỉ khi game có trình soạn thảo).
2. **Bản đồ** — bản đồ thế giới (Map Engine).
3. **Túi đồ** — vật phẩm mang theo (Items).
4. **Nhân vật** — đội và các nhân vật (Characters).
5. **Người được chiêu mộ** — các nhân vật đã chiêu mộ (Recruiting).
6. **Kỹ năng** — kỹ năng (Skills).
7. **Menu** — menu trong game, nói ở dưới (mod này).
8. **Nhật ký** — nhiệm vụ (Quests).
Các game khác hiển thị bộ nút khác; chẳng hạn RPG Plus thêm **bảng nhân vật**.
## Menu trong game
Biểu tượng bánh răng mở menu: **Tiếp tục**, **Lưu**, **Tải**, **Cài đặt**, **Menu chính** và **Thoát**. Ở những cảnh không cho phép lưu, nút Lưu bị mờ đi như trong hình. Tải, Lưu và Cài đặt mở đúng những cửa sổ như ở menu chính.
![Menu trong game đè lên một cảnh: Menu, Resume, Save, Load, Settings, Main menu, Exit.](assets/game-menu.jpg)
## Thông báo
Góc trên bên trái xuất hiện những tin nhắn ngắn: một mục mới trong nhật ký, một gợi ý từ cốt truyện. Cùng lúc hiện tối đa bốn cái, mỗi cái giữ khoảng mười giây; cái khác xếp hàng chờ tới lượt.
![Thông báo dưới đồng hồ: "Hãy nhìn quanh. Có thứ gì đó nằm trong con hẻm." — một gợi ý từ phần mở đầu của Cthangover.](assets/notification.png)
## Cho tác giả mod
**Thêm nút thanh công cụ cùng bảng điều khiển của nó** bằng một mục `ui` trong manifest. Items thêm túi đồ theo cách này:
```json
"ui": [
{
"id": "inventory",
"kind": "panel",
"slot": "ModBeforeUIPanel",
"scene_path": "scenes/ui/inventory_panel.tscn",
"button": { "icon": "bag", "locale": "ui/inventory/title", "index": 20 },
"priority": 10
}
]
```
- `button.index` xếp thứ tự các biểu tượng (bản đồ 10, túi đồ 20, nhân vật 30, kỹ năng 40, menu 50, nhật ký 60, studio 90);
- `button.icon` là tên biểu tượng, `button.locale` là khóa dịch của chú thích;
- mục không có `button` là bảng điều khiển hoặc lớp phủ mà game tự mở — Crafting đặt bàn chế tạo của mình dưới dạng `"kind": "overlay"` ở khe `ModLastPanel` và mở nó từ kịch bản.
**Mở bảng điều khiển từ kịch bản** bằng hành động `ui.toggle_panel`, còn **hiện thông báo** bằng lệnh `notify`:
```scenario
notify "Mục nhật ký mới"
```
**Dùng diện mạo chung.** Bảng điều khiển viết bằng code lấy khung, nhãn, thẻ và nút từ bộ công cụ giao diện (`UiKit`) cùng các token màu và kích thước, nên bảng mới tự hòa vào tổng thể game và đi theo chủ đề của nó.
## Khi có trục trặc
1. **Thiếu nút trên thanh công cụ.** Mod thêm nút đó không nằm trong game này; hãy kiểm tra thành phần của game trong **Mod và bộ sưu tập**.
2. **Nút Lưu bị mờ.** Cảnh hiện tại không cho phép lưu; hãy đến nơi khác rồi thử lại. Tác giả cho phép lưu bằng dòng `save_allowed: true` trong phần đầu một trong các kịch bản của cảnh.
3. **Bảng điều khiển mở ra nhưng trống trơn.** Mod ấy chưa có gì để hiển thị trong game này — ví dụ, chưa có nhiệm vụ nào bắt đầu.
+71
View File
@@ -0,0 +1,71 @@
# UI — 所有游戏共用的界面
UI 是所有游戏共用的界面层:右上角的工具栏、游戏内菜单、存档指示器、左上角的通知,以及背景画面和面板、按钮、列表的统一外观。它自己没有剧情,也不定规则。几乎所有其他模组都构建在它之上,所以每个游戏里都有它,大量模组也把它列为依赖。
![Cthangover 中午时分的一幕:左上角是时钟,右上角是工具栏,右侧是场景选项。](assets/scene-hud.jpg)
## 工具栏
每个图标都会打开一个面板。按钮并非固定不变:游戏中的每个模组都会加上自己的按钮,因此没有物品系统的小说游戏不会有背包,而带 Scenario Studio 的游戏则多出一个图标。
![Cthangover 的工具栏:工具、地图、背包、角色、招募者、技能、菜单(齿轮)和日志。](assets/toolbar.png)
在 Cthangover 中,从左到右依次是:
1. **工具** — 开发者工具菜单(仅当游戏包含编辑器时出现)。
2. **地图** — 世界地图(Map Engine)。
3. **背包** — 物品栏(Items)。
4. **角色** — 队伍和角色(Characters)。
5. **招募者** — 已招募的角色(Recruiting)。
6. **技能** — 技能(Skills)。
7. **菜单** — 下文介绍的游戏内菜单(本模组)。
8. **日志** — 任务(Quests)。
其他游戏显示的按钮不同:例如 RPG Plus 会多出**角色面板**。
## 游戏内菜单
齿轮图标打开菜单:**继续**、**保存**、**读取**、**设置**、**主菜单**和**退出**。在不允许存档的场景中,“保存”会变灰,如图所示。“读取”“保存”和“设置”打开的窗口与主菜单中相同。
![场景上方的游戏内菜单:Menu, Resume, Save, Load, Settings, Main menu, Exit。](assets/game-menu.jpg)
## 通知
左上角会出现简短消息:日志新增条目、剧情给出的提示等。同屏最多显示四条,每条停留约十秒,其余依次排队。
![时钟下方的通知:“看看四周。巷子里躺着什么东西。”——Cthangover 序章中的一条提示。](assets/notification.png)
## 给模组作者
**添加工具栏按钮及其面板**:在 manifest.json 里写一个 `ui` 条目。Items 就是这样添加背包的:
```json
"ui": [
{
"id": "inventory",
"kind": "panel",
"slot": "ModBeforeUIPanel",
"scene_path": "scenes/ui/inventory_panel.tscn",
"button": { "icon": "bag", "locale": "ui/inventory/title", "index": 20 },
"priority": 10
}
]
```
- `button.index` 决定图标顺序(地图 10、背包 20、角色 30、技能 40、菜单 50、日志 60、工作室 90);
- `button.icon` 是图标名,`button.locale` 是提示文字的翻译键;
- 不带 `button` 的条目是游戏自行打开的面板或覆盖层:Crafting 以 `"kind": "overlay"` 把工作台放进 `ModLastPanel` 槽位,并从剧本中打开它。
**从剧本打开面板**用 `ui.toggle_panel` 动作,**显示通知**用 `notify` 命令:
```scenario
notify "新的日志条目。"
```
**使用统一外观。** 用代码构建的面板会从组件库(`UiKit`)及其颜色、尺寸令牌中获取边框、文字、标签和按钮,因此新面板自然融入游戏整体风格并跟随主题变化。
## 出现问题时
1. **工具栏上少了按钮。** 添加它的模组不属于当前游戏;请在**模组与合集**中查看游戏的构成。
2. **“保存”是灰色的。** 当前场景不允许存档;换到别的地方再试。作者在场景任一剧本的头部写上 `save_allowed: true` 即可允许存档。
3. **面板打开了却是空的。** 对应模组在当前游戏中暂时没有内容可显示——例如还没有任务开始。
+2
View File
@@ -0,0 +1,2 @@
mod_ui_base_name = UI
mod_ui_base_desc = The interface layer every game shares: the toolbar, the in-game menu, the save indicator, notifications and the common look of panels, buttons and lists.
+2
View File
@@ -0,0 +1,2 @@
mod_ui_base_name = UI
mod_ui_base_desc = La capa de interfaz que comparten todos los juegos: la barra de herramientas, el menú del juego, el indicador de guardado, las notificaciones y el aspecto común de paneles, botones y listas.
+2
View File
@@ -0,0 +1,2 @@
mod_ui_base_name = UI
mod_ui_base_desc = すべてのゲームで共有される UI レイヤー:ツールバー、ゲーム内メニュー、セーブインジケーター、通知、そしてパネル・ボタン・リストの共通デザイン。
+2
View File
@@ -0,0 +1,2 @@
mod_ui_base_name = UI
mod_ui_base_desc = 모든 게임이 공유하는 인터페이스 레이어: 툴바, 인게임 메뉴, 저장 표시, 알림, 그리고 패널·버튼·목록의 공통 디자인입니다.
+2
View File
@@ -0,0 +1,2 @@
mod_ui_base_name = UI
mod_ui_base_desc = Общий для всех игр слой интерфейса: панель инструментов, игровое меню, индикатор сохранения, уведомления и единый вид панелей, кнопок и списков.
+138 -6
View File
@@ -1,6 +1,4 @@
# --- scenes/battle/winground.tscn --- # --- scenes/battle/winground.tscn ---
mod_interface_name = Interface
mod_interface_desc = Game interface
ui/battle/victory = Victory ui/battle/victory = Victory
ui/battle/victory/return = Continue ui/battle/victory/return = Continue
ui/battle/victory/loot_title = Loot: ui/battle/victory/loot_title = Loot:
@@ -17,10 +15,8 @@ menu/newgame = New Game
ui/menu/load = Load ui/menu/load = Load
menu/settings = Settings menu/settings = Settings
menu/exit = Exit menu/exit = Exit
menu/tools = Tools
# --- Mods Menu --- # --- Mods ---
menu/mods = Mods
mods/title = Mods mods/title = Mods
mods/enable = Enable mods/enable = Enable
mods/disable = Disable mods/disable = Disable
@@ -28,6 +24,7 @@ mods/enabled = Enabled
mods/disabled = Disabled mods/disabled = Disabled
mods/disabled_reason = Disabled mods/disabled_reason = Disabled
mods/dep_disabled_reason = Dependency "{0}" is disabled mods/dep_disabled_reason = Dependency "{0}" is disabled
mods/not_in_game_reason = Not part of the game "{0}"
mods/dependency_disabled = Dependency disabled mods/dependency_disabled = Dependency disabled
mods/invalid_dep_format = Invalid dependency format: "{0}" mods/invalid_dep_format = Invalid dependency format: "{0}"
mods/required_mod_not_loaded = Required mod "{0}" is not loaded mods/required_mod_not_loaded = Required mod "{0}" is not loaded
@@ -68,7 +65,11 @@ mods/category_audio = Audio
mods/category_ui = UI mods/category_ui = UI
mods/category_locale = Locale mods/category_locale = Locale
mods/category_library = Library mods/category_library = Library
mods/category_engine = Engine
mods/category_overhaul = Overhaul mods/category_overhaul = Overhaul
mods/category_tools = Tools
mods/category_editor = Editor
mods/category_debug = Debug
mods/category_other = Other mods/category_other = Other
mods/catalog_tags = Tags (comma-separated) mods/catalog_tags = Tags (comma-separated)
mods/catalog_changelog = Changelog mods/catalog_changelog = Changelog
@@ -99,6 +100,7 @@ ui/save/game_day = Day {day}, {time}
ui/save/party_size = Party: {count} ui/save/party_size = Party: {count}
# --- GameMenu --- # --- GameMenu ---
menu/title = Menu
menu/resume = Resume menu/resume = Resume
menu/save = Save menu/save = Save
menu/load = Load menu/load = Load
@@ -112,6 +114,31 @@ ui/character_panel/reset_title = Reset Action Assignments
ui/character_panel/reset_text = Are you sure you want to reset all action assignments? Character actions will return to defaults, collected actions will stay in the pool. ui/character_panel/reset_text = Are you sure you want to reset all action assignments? Character actions will return to defaults, collected actions will stay in the pool.
ui/character_panel/reset_ok = Reset ui/character_panel/reset_ok = Reset
ui/character_panel/reset_cancel = Cancel ui/character_panel/reset_cancel = Cancel
ui/character_panel/party = Party
ui/character_panel/party_sub = 3 slots per character · unused cards wait in the pool
ui/character_panel/template = template: {0}
ui/character_panel/pool_title = Action pool
ui/character_panel/search = Search by name or ID...
ui/character_panel/filter_all = All
ui/character_panel/type_enemy = Target enemy
ui/character_panel/type_alias = Target ally
ui/character_panel/type_self = Target self
ui/character_panel/type_passive = Passive
ui/character_panel/type_active = Active
ui/character_panel/cost = {0} AP
ui/character_panel/cooldown = CD {0}
ui/character_panel/slot = Slot {0}
ui/character_panel/slot_empty = + empty slot — drag a card here
ui/character_panel/assigned = Assigned {0}/{1} · in pool {2}
ui/character_panel/hint_select = select a card, then a slot
ui/character_panel/drag_hint = Drag a card into a slot · slot↔slot swaps
ui/character_panel/pool_hint = A card stays with the player while it is in the pool. Duplicates are allowed.
ui/character_panel/pool_empty = The pool is empty — collected actions will appear here
ui/character_panel/nothing_found = Nothing found
ui/character_panel/stat_hp = HP
ui/character_panel/stat_ap = AP
ui/character_panel/stat_atk = ATK
ui/character_panel/stat_def = DEF
# --- Inventory --- # --- Inventory ---
ui/inventory/title = Inventory ui/inventory/title = Inventory
@@ -124,9 +151,101 @@ ui/inventory/close = Close
ui/inventory/no_ap = Not enough AP ui/inventory/no_ap = Not enough AP
ui/inventory/select_target = Select target ui/inventory/select_target = Select target
ui/inventory/cancel = Cancel ui/inventory/cancel = Cancel
ui/inventory/tab_all = All
ui/inventory/tab_food = Food
ui/inventory/tab_resource = Resources
ui/inventory/tab_recipe = Recipes
ui/inventory/tab_quest = Quests
ui/inventory/search = Search inventory...
ui/inventory/sort_type = Type first
ui/inventory/sort_name = By name
ui/inventory/sort_cost = By price
ui/inventory/sort_count = By count
ui/inventory/shown = Showing {0} of {1}
ui/inventory/hint_select = LMB — select · action in details
ui/inventory/stack = Stack
ui/inventory/id = ID
ui/inventory/action = Action
ui/inventory/verb = Verb
ui/inventory/props_header = Properties · PropertyData
ui/inventory/props_none = no properties
ui/inventory/no_action = No ItemAction — item cannot be used yet
ui/inventory/cost = Price
ui/inventory/empty = Inventory is empty
ui/inventory/select = Select an item in the list
ui/inventory/ap = AP
ui/inventory/ap_cost = −1 AP
ui/inventory/used = Used: {0}
ui/inventory/used_recipe = Recipe learned: {0}
ui/inventory/use_failed = UseAction → false: item not consumed
ui/inventory/dropped = Dropped: {0} ×{1}
ui/inventory/applied = Applied: {0} → {1}
ui/inventory/applied_failed = Target did not accept the effect — item not consumed
ui/inventory/drop_cant = Cannot drop (CantDrop)
ui/inventory/type_quest = Quest
ui/inventory/type_used = Usable
ui/inventory/type_target = Targeted
ui/inventory/type_food = Food
ui/inventory/type_resource = Resource
ui/inventory/type_recipe = Recipe
ui/inventory/type_cant_drop = Cannot drop
ui/inventory/verb_use = Use
ui/inventory/verb_eat = Eat
ui/inventory/verb_target = Apply to target
ui/inventory/verb_recipe = Learn recipe
ui/inventory/tag_recipe = recipe
ui/inventory/tag_target = target
ui/inventory/tag_food = drink
ui/inventory/tag_use = use
# --- Map --- # --- Map ---
ui/map/title = Map
ui/map/close = Close ui/map/close = Close
ui/map/subtitle = Northern district
ui/map/zone_count = {0} locations
ui/map/legend_current = you are here
ui/map/legend_available = available
ui/map/legend_locked = closed
ui/map/hint = LMB on a marker — select a location · Esc — close the map
ui/map/current = You are here
ui/map/available = Available
ui/map/locked = Closed
ui/map/you_are_here = you are here
ui/map/closed_note = closed
ui/map/go = Travel
ui/map/here = You are here
ui/map/transition = Transition
ui/map/card_hint = The button below switches the scene. Esc — close the map.
ui/map/locked_message = The path is not open yet.
ui/map/empty = Map unavailable
ui/map/empty_hint = Background map.png not found — add the background and map/*.json zone files to a mod.
# --- Skills ---
ui/skills/title = Skills
ui/skills/grants_action = Grants an action card
ui/skills/side_title = Card study
ui/skills/collection = Collection
ui/skills/search = Search the collection…
ui/skills/sort_rarity = Rarity first
ui/skills/sort_type = By type
ui/skills/sort_name = By name
ui/skills/filter_all = All
ui/skills/filter_rarities = All rarities
ui/skills/hint = LMB — study the card · frame and glow follow rarity
ui/skills/reset = Reset filters
ui/skills/empty = No skills yet
ui/skills/nothing_found = Nothing found
ui/skills/no_effects = No permanent effects yet
ui/skills/collection_effect = Collection effect
ui/skills/collection_effect_hint = Total across the collection · updates instantly
ui/skills/rarity/common = Common
ui/skills/rarity/uncommon = Uncommon
ui/skills/rarity/rare = Rare
ui/skills/rarity/epic = Epic
ui/skills/rarity/legendary = Legendary
ui/skills/type/active = Active
ui/skills/type/passive = Passive
ui/skills/type/for_character = For character
# --- Quest Journal --- # --- Quest Journal ---
ui/journal/title = Journal ui/journal/title = Journal
@@ -134,3 +253,16 @@ ui/journal/active = Active
ui/journal/completed = Completed ui/journal/completed = Completed
ui/journal/close = Close ui/journal/close = Close
ui/journal/empty = No quests ui/journal/empty = No quests
ui/journal/toc = Contents
ui/journal/entry_header = Entry
ui/journal/entry_of = Entry {0} of {1}
ui/journal/finished = Completed
ui/journal/seals = Seals
ui/journal/no_entries = The pages of this entry are still empty. Ink will appear as the story moves on.
tools/unsaved_title = Unsaved changes
tools/unsaved_text = You have unsaved changes. Close without saving?
tools/unsaved_discard_text = There are unsaved changes. Discard them?
tools/window_maximize = Maximize / restore (double-click the title)
tools/window_close = Close
tools/menu_title = Tools
+268
View File
@@ -0,0 +1,268 @@
# --- scenes/battle/winground.tscn ---
ui/battle/victory = Victoria
ui/battle/victory/return = Continuar
ui/battle/victory/loot_title = Botín:
# --- scenes/battle/deadground.tscn ---
ui/battle/defeat = Derrota
ui/battle/endgame/to_main_menu = Menú principal
# --- scenes/battle/battle.tscn ---
ui/battle/next = Terminar turno
# --- scenes/menu/main_menu.tscn ---
menu/newgame = Nueva partida
ui/menu/load = Cargar
menu/settings = Ajustes
menu/exit = Salir
# --- Mods ---
mods/title = Mods
mods/enable = Activar
mods/disable = Desactivar
mods/enabled = Activado
mods/disabled = Desactivado
mods/disabled_reason = Desactivado
mods/dep_disabled_reason = La dependencia «{0}» está desactivada
mods/not_in_game_reason = No forma parte del juego «{0}»
mods/dependency_disabled = Dependencia desactivada
mods/invalid_dep_format = Formato de dependencia no válido: «{0}»
mods/required_mod_not_loaded = El mod requerido «{0}» no está cargado
mods/version_mismatch = Versión no coincide: requiere {0}, encontrada {1}
mods/restart_required = Hay que reiniciar para que los cambios surtan efecto
mods/back = Atrás
mods/author = Autor
mods/version = Versión
mods/depends = Dependencias
mods/used_by = Lo usan
mods/overrides_title = Sustituciones
mods/reset_order = Restablecer orden
mods/reset_order_title = Restablecer orden
mods/reset_order_text = ¿Seguro que quieres restablecer el orden de los mods? Se recalculará automáticamente
mods/reset_order_confirm = Sí
mods/reset_order_cancel = Cancelar
mods/tab_device = Dispositivo
mods/tab_network = Red
mods/network_refresh = Actualizar
mods/network_loading = Cargando catálogo...
mods/network_ready = Catálogo cargado
mods/network_empty = No hay mods en el catálogo
mods/network_error = No se pudo cargar el catálogo
mods/network_no_urls = No hay URL de catálogo configuradas
mods/status_installed = Instalado
mods/status_update_available = Hay actualización
mods/status_not_installed = No instalado
mods/status_modified = Modificado localmente
mods/catalog_export = Exportar al catálogo
mods/catalog_title = Exportar entrada del catálogo
mods/catalog_download_url = URL de descarga
mods/catalog_category = Categoría
mods/category_story = Historia
mods/category_characters = Personajes
mods/category_gameplay = Jugabilidad
mods/category_visual = Visual
mods/category_audio = Audio
mods/category_ui = Interfaz
mods/category_locale = Idioma
mods/category_library = Biblioteca
mods/category_engine = Motor
mods/category_overhaul = Conversión total
mods/category_tools = Herramientas
mods/category_editor = Editor
mods/category_debug = Depuración
mods/category_other = Otros
mods/catalog_tags = Etiquetas (separadas por comas)
mods/catalog_changelog = Cambios
mods/catalog_status = Estado
mods/catalog_min_game_version = Versión mínima del juego
mods/catalog_icon_url = URL del icono
mods/catalog_copy = Copiar JSON
mods/catalog_export_file = Exportar a archivo
mods/catalog_close = Cerrar
mods/install_button = Instalar
mods/update_button = Actualizar
mods/overwrite_button = Sobrescribir
mods/downgrade_button = Volver a versión anterior
mods/restart_button = Hay que reiniciar
mods/installing = Instalando...
mods/install_complete = Instalación completada. Hay que reiniciar.
mods/install_failed = La instalación falló. Mira el registro para más detalles.
# --- Save/Load ---
ui/load/title = Cargar
ui/save/title = Guardar
ui/save/empty = Vacío
ui/save/overwrite_title = Sobrescribir partida
ui/save/overwrite_text = Ya hay una partida en esta ranura. ¿Sobrescribirla?
ui/save/save_button = Sobrescribir
ui/save/cancel_button = Cancelar
ui/save/game_day = Día {day}, {time}
ui/save/party_size = Grupo: {count}
# --- GameMenu ---
menu/title = Menú
menu/resume = Continuar
menu/save = Guardar
menu/load = Cargar
menu/main_menu = Menú principal
# --- Character Panel ---
ui/character_panel/title = Acciones de los personajes
ui/character_panel/reset = Restablecer
ui/character_panel/close = Cerrar
ui/character_panel/reset_title = Restablecer la asignación de acciones
ui/character_panel/reset_text = ¿Seguro que quieres restablecer todas las asignaciones de acciones? Las acciones de los personajes volverán a los valores por defecto; las acciones recogidas se quedan en la reserva.
ui/character_panel/reset_ok = Restablecer
ui/character_panel/reset_cancel = Cancelar
ui/character_panel/party = Grupo
ui/character_panel/party_sub = 3 ranuras por personaje · las cartas sin usar esperan en la reserva
ui/character_panel/template = plantilla: {0}
ui/character_panel/pool_title = Reserva de acciones
ui/character_panel/search = Buscar por nombre o ID...
ui/character_panel/filter_all = Todas
ui/character_panel/type_enemy = Objetivo enemigo
ui/character_panel/type_alias = Objetivo aliado
ui/character_panel/type_self = A uno mismo
ui/character_panel/type_passive = Pasiva
ui/character_panel/type_active = Activa
ui/character_panel/cost = {0} PA
ui/character_panel/cooldown = Recarga {0}
ui/character_panel/slot = Ranura {0}
ui/character_panel/slot_empty = + ranura vacía: arrastra una carta aquí
ui/character_panel/assigned = Asignadas {0}/{1} · en reserva {2}
ui/character_panel/hint_select = elige una carta y después una ranura
ui/character_panel/drag_hint = Arrastra una carta a una ranura · ranura↔ranura las intercambia
ui/character_panel/pool_hint = Una carta sigue siendo del jugador mientras está en la reserva. Se permiten duplicados.
ui/character_panel/pool_empty = La reserva está vacía: aquí aparecerán las acciones recogidas
ui/character_panel/nothing_found = No se encontró nada
ui/character_panel/stat_hp = PV
ui/character_panel/stat_ap = PA
ui/character_panel/stat_atk = ATQ
ui/character_panel/stat_def = DEF
# --- Inventory ---
ui/inventory/title = Inventario
ui/inventory/use = Usar
ui/inventory/drop = Tirar
ui/inventory/drop_all = Todo
ui/inventory/drop_confirm = Tirar
ui/inventory/drop_quantity = Tirar
ui/inventory/close = Cerrar
ui/inventory/no_ap = No hay PA suficientes
ui/inventory/select_target = Elige un objetivo
ui/inventory/cancel = Cancelar
ui/inventory/tab_all = Todo
ui/inventory/tab_food = Comida
ui/inventory/tab_resource = Recursos
ui/inventory/tab_recipe = Recetas
ui/inventory/tab_quest = Misiones
ui/inventory/search = Buscar en el inventario...
ui/inventory/sort_type = Por tipo
ui/inventory/sort_name = Por nombre
ui/inventory/sort_cost = Por precio
ui/inventory/sort_count = Por cantidad
ui/inventory/shown = Mostrando {0} de {1}
ui/inventory/hint_select = Clic izquierdo: seleccionar · la acción está en los detalles
ui/inventory/stack = Montón
ui/inventory/id = ID
ui/inventory/action = Acción
ui/inventory/verb = Verbo
ui/inventory/props_header = Propiedades · PropertyData
ui/inventory/props_none = sin propiedades
ui/inventory/no_action = Sin ItemAction: el objeto todavía no se puede usar
ui/inventory/cost = Precio
ui/inventory/empty = El inventario está vacío
ui/inventory/select = Selecciona un objeto de la lista
ui/inventory/ap = PA
ui/inventory/ap_cost = −1 PA
ui/inventory/used = Usado: {0}
ui/inventory/used_recipe = Receta aprendida: {0}
ui/inventory/use_failed = UseAction → false: el objeto no se gastó
ui/inventory/dropped = Tirado: {0} ×{1}
ui/inventory/applied = Aplicado: {0} → {1}
ui/inventory/applied_failed = El objetivo no aceptó el efecto: el objeto no se gastó
ui/inventory/drop_cant = No se puede tirar (CantDrop)
ui/inventory/type_quest = Misión
ui/inventory/type_used = Usable
ui/inventory/type_target = Con objetivo
ui/inventory/type_food = Comida
ui/inventory/type_resource = Recurso
ui/inventory/type_recipe = Receta
ui/inventory/type_cant_drop = No se puede tirar
ui/inventory/verb_use = Usar
ui/inventory/verb_eat = Comer
ui/inventory/verb_target = Aplicar al objetivo
ui/inventory/verb_recipe = Aprender receta
ui/inventory/tag_recipe = receta
ui/inventory/tag_target = objetivo
ui/inventory/tag_food = bebida
ui/inventory/tag_use = usar
# --- Map ---
ui/map/title = Mapa
ui/map/close = Cerrar
ui/map/subtitle = Distrito norte
ui/map/zone_count = {0} lugares
ui/map/legend_current = estás aquí
ui/map/legend_available = disponible
ui/map/legend_locked = cerrado
ui/map/hint = Clic izquierdo en un marcador: elegir lugar · Esc: cerrar el mapa
ui/map/current = Estás aquí
ui/map/available = Disponible
ui/map/locked = Cerrado
ui/map/you_are_here = estás aquí
ui/map/closed_note = cerrado
ui/map/go = Viajar
ui/map/here = Estás aquí
ui/map/transition = Transición
ui/map/card_hint = El botón de abajo cambia de escena. Esc: cerrar el mapa.
ui/map/locked_message = El camino todavía no está abierto.
ui/map/empty = Mapa no disponible
ui/map/empty_hint = No se encontró el fondo map.png: añade a un mod el fondo y los archivos de zonas map/*.json.
# --- Skills ---
ui/skills/title = Habilidades
ui/skills/grants_action = Concede una carta de acción
ui/skills/side_title = Estudio de la carta
ui/skills/collection = Colección
ui/skills/search = Buscar en la colección…
ui/skills/sort_rarity = Por rareza
ui/skills/sort_type = Por tipo
ui/skills/sort_name = Por nombre
ui/skills/filter_all = Todas
ui/skills/filter_rarities = Todas las rarezas
ui/skills/hint = Clic izquierdo: estudiar la carta · el marco y el brillo dependen de la rareza
ui/skills/reset = Restablecer filtros
ui/skills/empty = Todavía no hay habilidades
ui/skills/nothing_found = No se encontró nada
ui/skills/no_effects = Todavía no hay efectos permanentes
ui/skills/collection_effect = Efecto de la colección
ui/skills/collection_effect_hint = Total de toda la colección · se actualiza al instante
ui/skills/rarity/common = Común
ui/skills/rarity/uncommon = Poco común
ui/skills/rarity/rare = Rara
ui/skills/rarity/epic = Épica
ui/skills/rarity/legendary = Legendaria
ui/skills/type/active = Activa
ui/skills/type/passive = Pasiva
ui/skills/type/for_character = Para el personaje
# --- Quest Journal ---
ui/journal/title = Diario
ui/journal/active = Activas
ui/journal/completed = Completadas
ui/journal/close = Cerrar
ui/journal/empty = No hay misiones
ui/journal/toc = Índice
ui/journal/entry_header = Entrada
ui/journal/entry_of = Entrada {0} de {1}
ui/journal/finished = Completada
ui/journal/seals = Sellos
ui/journal/no_entries = Las páginas de esta entrada siguen en blanco. La tinta aparecerá a medida que avance la historia.
tools/unsaved_title = Cambios sin guardar
tools/unsaved_text = Tienes cambios sin guardar. ¿Cerrar sin guardar?
tools/unsaved_discard_text = Hay cambios sin guardar. ¿Descartarlos?
tools/window_maximize = Maximizar / restaurar (doble clic en el título)
tools/window_close = Cerrar
tools/menu_title = Herramientas
+268
View File
@@ -0,0 +1,268 @@
# --- scenes/battle/winground.tscn ---
ui/battle/victory = 勝利
ui/battle/victory/return = 続ける
ui/battle/victory/loot_title = 戦利品:
# --- scenes/battle/deadground.tscn ---
ui/battle/defeat = 敗北
ui/battle/endgame/to_main_menu = メインメニュー
# --- scenes/battle/battle.tscn ---
ui/battle/next = ターン終了
# --- scenes/menu/main_menu.tscn ---
menu/newgame = ニューゲーム
ui/menu/load = ロード
menu/settings = 設定
menu/exit = 終了
# --- Mods ---
mods/title = MOD
mods/enable = 有効にする
mods/disable = 無効にする
mods/enabled = 有効
mods/disabled = 無効
mods/disabled_reason = 無効
mods/dep_disabled_reason = 依存先「{0}」が無効です
mods/not_in_game_reason = ゲーム「{0}」に含まれていません
mods/dependency_disabled = 依存先が無効
mods/invalid_dep_format = 依存の書式が不正:「{0}」
mods/required_mod_not_loaded = 必要な MOD「{0}」が読み込まれていません
mods/version_mismatch = バージョン不一致:{0} が必要、{1} を検出
mods/restart_required = 変更を反映するには再起動が必要です
mods/back = 戻る
mods/author = 作者
mods/version = バージョン
mods/depends = 依存関係
mods/used_by = 利用元
mods/overrides_title = 上書き
mods/reset_order = 順序をリセット
mods/reset_order_title = 順序をリセット
mods/reset_order_text = MOD の順序をリセットしますか? 自動で計算し直されます
mods/reset_order_confirm = はい
mods/reset_order_cancel = キャンセル
mods/tab_device = 端末
mods/tab_network = ネットワーク
mods/network_refresh = 更新
mods/network_loading = カタログを読み込み中...
mods/network_ready = カタログを読み込みました
mods/network_empty = カタログに MOD がありません
mods/network_error = カタログを読み込めませんでした
mods/network_no_urls = カタログの URL が設定されていません
mods/status_installed = インストール済み
mods/status_update_available = 更新あり
mods/status_not_installed = 未インストール
mods/status_modified = ローカルで変更
mods/catalog_export = カタログに書き出す
mods/catalog_title = カタログ項目を書き出す
mods/catalog_download_url = ダウンロード URL
mods/catalog_category = カテゴリー
mods/category_story = ストーリー
mods/category_characters = キャラクター
mods/category_gameplay = ゲームプレイ
mods/category_visual = ビジュアル
mods/category_audio = オーディオ
mods/category_ui = UI
mods/category_locale = 言語
mods/category_library = ライブラリ
mods/category_engine = エンジン
mods/category_overhaul = 大型改造
mods/category_tools = ツール
mods/category_editor = エディター
mods/category_debug = デバッグ
mods/category_other = その他
mods/catalog_tags = タグ(カンマ区切り)
mods/catalog_changelog = 変更履歴
mods/catalog_status = 状態
mods/catalog_min_game_version = 最低ゲームバージョン
mods/catalog_icon_url = アイコンの URL
mods/catalog_copy = JSON をコピー
mods/catalog_export_file = ファイルに書き出す
mods/catalog_close = 閉じる
mods/install_button = インストール
mods/update_button = 更新
mods/overwrite_button = 上書き
mods/downgrade_button = ダウングレード
mods/restart_button = 再起動が必要
mods/installing = インストール中...
mods/install_complete = インストール完了。再起動が必要です。
mods/install_failed = インストールに失敗しました。詳しくはログを見てください。
# --- Save/Load ---
ui/load/title = ロード
ui/save/title = セーブ
ui/save/empty = 空き
ui/save/overwrite_title = セーブを上書き
ui/save/overwrite_text = このスロットにはすでにセーブがあります。上書きしますか?
ui/save/save_button = 上書き
ui/save/cancel_button = キャンセル
ui/save/game_day = {day} 日目、{time}
ui/save/party_size = パーティ:{count}
# --- GameMenu ---
menu/title = メニュー
menu/resume = 再開
menu/save = セーブ
menu/load = ロード
menu/main_menu = メインメニュー
# --- Character Panel ---
ui/character_panel/title = キャラクターのアクション
ui/character_panel/reset = リセット
ui/character_panel/close = 閉じる
ui/character_panel/reset_title = アクションの割り当てをリセット
ui/character_panel/reset_text = すべてのアクションの割り当てをリセットしますか? キャラクターのアクションは既定に戻り、集めたアクションはプールに残ります。
ui/character_panel/reset_ok = リセット
ui/character_panel/reset_cancel = キャンセル
ui/character_panel/party = パーティ
ui/character_panel/party_sub = キャラクターごとに 3 スロット · 使わないカードはプールで待機
ui/character_panel/template = テンプレート:{0}
ui/character_panel/pool_title = アクションプール
ui/character_panel/search = 名前か ID で検索...
ui/character_panel/filter_all = すべて
ui/character_panel/type_enemy = 敵が対象
ui/character_panel/type_alias = 味方が対象
ui/character_panel/type_self = 自分が対象
ui/character_panel/type_passive = パッシブ
ui/character_panel/type_active = アクティブ
ui/character_panel/cost = {0} AP
ui/character_panel/cooldown = クールダウン {0}
ui/character_panel/slot = スロット {0}
ui/character_panel/slot_empty = + 空きスロット——ここにカードをドラッグ
ui/character_panel/assigned = 割り当て {0}/{1} · プール内 {2}
ui/character_panel/hint_select = カードを選んでからスロットを選ぶ
ui/character_panel/drag_hint = カードをスロットへドラッグ · スロット同士で入れ替え
ui/character_panel/pool_hint = プールにある間もカードはプレイヤーのものです。重複できます。
ui/character_panel/pool_empty = プールは空です——集めたアクションがここに表示されます
ui/character_panel/nothing_found = 何も見つかりません
ui/character_panel/stat_hp = HP
ui/character_panel/stat_ap = AP
ui/character_panel/stat_atk = 攻撃
ui/character_panel/stat_def = 防御
# --- Inventory ---
ui/inventory/title = インベントリ
ui/inventory/use = 使う
ui/inventory/drop = 捨てる
ui/inventory/drop_all = すべて
ui/inventory/drop_confirm = 捨てる
ui/inventory/drop_quantity = 捨てる
ui/inventory/close = 閉じる
ui/inventory/no_ap = AP が足りない
ui/inventory/select_target = 対象を選択
ui/inventory/cancel = キャンセル
ui/inventory/tab_all = すべて
ui/inventory/tab_food = 食べ物
ui/inventory/tab_resource = 素材
ui/inventory/tab_recipe = レシピ
ui/inventory/tab_quest = クエスト
ui/inventory/search = インベントリを検索...
ui/inventory/sort_type = 種類順
ui/inventory/sort_name = 名前順
ui/inventory/sort_cost = 価格順
ui/inventory/sort_count = 個数順
ui/inventory/shown = {1} 個中 {0} 個を表示
ui/inventory/hint_select = 左クリック——選択 · アクションは詳細に
ui/inventory/stack = スタック
ui/inventory/id = ID
ui/inventory/action = アクション
ui/inventory/verb = 動詞
ui/inventory/props_header = プロパティ · PropertyData
ui/inventory/props_none = プロパティなし
ui/inventory/no_action = ItemAction なし——このアイテムはまだ使えません
ui/inventory/cost = 価格
ui/inventory/empty = インベントリは空です
ui/inventory/select = 一覧からアイテムを選んでください
ui/inventory/ap = AP
ui/inventory/ap_cost = −1 AP
ui/inventory/used = 使った:{0}
ui/inventory/used_recipe = レシピを覚えた:{0}
ui/inventory/use_failed = UseAction → false:アイテムは消費されなかった
ui/inventory/dropped = 捨てた:{0} ×{1}
ui/inventory/applied = 使用:{0} → {1}
ui/inventory/applied_failed = 対象が効果を受け付けなかった——アイテムは消費されなかった
ui/inventory/drop_cant = 捨てられない(CantDrop)
ui/inventory/type_quest = クエスト
ui/inventory/type_used = 使用可
ui/inventory/type_target = 対象あり
ui/inventory/type_food = 食べ物
ui/inventory/type_resource = 素材
ui/inventory/type_recipe = レシピ
ui/inventory/type_cant_drop = 捨てられない
ui/inventory/verb_use = 使う
ui/inventory/verb_eat = 食べる
ui/inventory/verb_target = 対象に使う
ui/inventory/verb_recipe = レシピを覚える
ui/inventory/tag_recipe = レシピ
ui/inventory/tag_target = 対象
ui/inventory/tag_food = 飲み物
ui/inventory/tag_use = 使用
# --- Map ---
ui/map/title = マップ
ui/map/close = 閉じる
ui/map/subtitle = 北地区
ui/map/zone_count = {0} か所
ui/map/legend_current = 現在地
ui/map/legend_available = 行ける
ui/map/legend_locked = 閉鎖
ui/map/hint = マーカーを左クリック——場所を選ぶ · Esc——マップを閉じる
ui/map/current = 現在地
ui/map/available = 行ける
ui/map/locked = 閉鎖
ui/map/you_are_here = 現在地
ui/map/closed_note = 閉鎖
ui/map/go = 移動
ui/map/here = 現在地
ui/map/transition = 移動
ui/map/card_hint = 下のボタンでシーンを切り替えます。Esc——マップを閉じる。
ui/map/locked_message = 道はまだ開いていません。
ui/map/empty = マップを表示できません
ui/map/empty_hint = 背景 map.png が見つかりません——MOD に背景と map/*.json のゾーンファイルを追加してください。
# --- Skills ---
ui/skills/title = スキル
ui/skills/grants_action = アクションカードを付与
ui/skills/side_title = カードの研究
ui/skills/collection = コレクション
ui/skills/search = コレクションを検索…
ui/skills/sort_rarity = レア度順
ui/skills/sort_type = 種類順
ui/skills/sort_name = 名前順
ui/skills/filter_all = すべて
ui/skills/filter_rarities = すべてのレア度
ui/skills/hint = 左クリック——カードを調べる · 枠と輝きはレア度で変わる
ui/skills/reset = フィルターをリセット
ui/skills/empty = まだスキルがありません
ui/skills/nothing_found = 何も見つかりません
ui/skills/no_effects = まだ恒久的な効果はありません
ui/skills/collection_effect = コレクション効果
ui/skills/collection_effect_hint = コレクション全体の合計 · すぐに更新
ui/skills/rarity/common = コモン
ui/skills/rarity/uncommon = アンコモン
ui/skills/rarity/rare = レア
ui/skills/rarity/epic = エピック
ui/skills/rarity/legendary = レジェンダリー
ui/skills/type/active = アクティブ
ui/skills/type/passive = パッシブ
ui/skills/type/for_character = キャラクター用
# --- Quest Journal ---
ui/journal/title = 日誌
ui/journal/active = 進行中
ui/journal/completed = 完了
ui/journal/close = 閉じる
ui/journal/empty = クエストはありません
ui/journal/toc = 目次
ui/journal/entry_header = 記録
ui/journal/entry_of = 記録 {0} / {1}
ui/journal/finished = 完了
ui/journal/seals = 印章
ui/journal/no_entries = この記録のページはまだ白紙です。物語が進むにつれて文字が現れます。
tools/unsaved_title = 保存していない変更
tools/unsaved_text = 保存していない変更があります。保存せずに閉じますか?
tools/unsaved_discard_text = 保存していない変更があります。破棄しますか?
tools/window_maximize = 最大化 / 元に戻す(タイトルをダブルクリック)
tools/window_close = 閉じる
tools/menu_title = ツール
+268
View File
@@ -0,0 +1,268 @@
# --- scenes/battle/winground.tscn ---
ui/battle/victory = 승리
ui/battle/victory/return = 계속
ui/battle/victory/loot_title = 전리품:
# --- scenes/battle/deadground.tscn ---
ui/battle/defeat = 패배
ui/battle/endgame/to_main_menu = 메인 메뉴
# --- scenes/battle/battle.tscn ---
ui/battle/next = 턴 종료
# --- scenes/menu/main_menu.tscn ---
menu/newgame = 새 게임
ui/menu/load = 불러오기
menu/settings = 설정
menu/exit = 나가기
# --- Mods ---
mods/title = 모드
mods/enable = 켜기
mods/disable = 끄기
mods/enabled = 켜짐
mods/disabled = 꺼짐
mods/disabled_reason = 꺼짐
mods/dep_disabled_reason = 의존 대상 "{0}"이(가) 꺼져 있습니다
mods/not_in_game_reason = 게임 "{0}"에 포함되지 않음
mods/dependency_disabled = 의존 대상이 꺼짐
mods/invalid_dep_format = 잘못된 의존성 형식: "{0}"
mods/required_mod_not_loaded = 필요한 모드 "{0}"이(가) 로드되지 않았습니다
mods/version_mismatch = 버전 불일치: {0} 필요, {1} 발견
mods/restart_required = 변경 사항을 적용하려면 다시 시작해야 합니다
mods/back = 뒤로
mods/author = 작성자
mods/version = 버전
mods/depends = 의존성
mods/used_by = 사용처
mods/overrides_title = 덮어쓰기
mods/reset_order = 순서 초기화
mods/reset_order_title = 순서 초기화
mods/reset_order_text = 모드 순서를 초기화할까요? 자동으로 다시 계산됩니다
mods/reset_order_confirm = 예
mods/reset_order_cancel = 취소
mods/tab_device = 기기
mods/tab_network = 네트워크
mods/network_refresh = 새로 고침
mods/network_loading = 카탈로그 불러오는 중...
mods/network_ready = 카탈로그를 불러왔습니다
mods/network_empty = 카탈로그에 모드가 없습니다
mods/network_error = 카탈로그를 불러오지 못했습니다
mods/network_no_urls = 카탈로그 URL이 설정되지 않았습니다
mods/status_installed = 설치됨
mods/status_update_available = 업데이트 있음
mods/status_not_installed = 설치 안 됨
mods/status_modified = 로컬에서 수정됨
mods/catalog_export = 카탈로그로 내보내기
mods/catalog_title = 카탈로그 항목 내보내기
mods/catalog_download_url = 다운로드 URL
mods/catalog_category = 분류
mods/category_story = 스토리
mods/category_characters = 캐릭터
mods/category_gameplay = 게임플레이
mods/category_visual = 비주얼
mods/category_audio = 오디오
mods/category_ui = UI
mods/category_locale = 언어
mods/category_library = 라이브러리
mods/category_engine = 엔진
mods/category_overhaul = 대규모 개편
mods/category_tools = 도구
mods/category_editor = 에디터
mods/category_debug = 디버그
mods/category_other = 기타
mods/catalog_tags = 태그 (쉼표로 구분)
mods/catalog_changelog = 변경 기록
mods/catalog_status = 상태
mods/catalog_min_game_version = 최소 게임 버전
mods/catalog_icon_url = 아이콘 URL
mods/catalog_copy = JSON 복사
mods/catalog_export_file = 파일로 내보내기
mods/catalog_close = 닫기
mods/install_button = 설치
mods/update_button = 업데이트
mods/overwrite_button = 덮어쓰기
mods/downgrade_button = 다운그레이드
mods/restart_button = 다시 시작 필요
mods/installing = 설치 중...
mods/install_complete = 설치 완료. 다시 시작해야 합니다.
mods/install_failed = 설치에 실패했습니다. 자세한 내용은 로그를 확인하세요.
# --- Save/Load ---
ui/load/title = 불러오기
ui/save/title = 저장
ui/save/empty = 비어 있음
ui/save/overwrite_title = 저장 덮어쓰기
ui/save/overwrite_text = 이 슬롯에 이미 저장 파일이 있습니다. 덮어쓸까요?
ui/save/save_button = 덮어쓰기
ui/save/cancel_button = 취소
ui/save/game_day = {day}일째, {time}
ui/save/party_size = 파티: {count}
# --- GameMenu ---
menu/title = 메뉴
menu/resume = 계속
menu/save = 저장
menu/load = 불러오기
menu/main_menu = 메인 메뉴
# --- Character Panel ---
ui/character_panel/title = 캐릭터 액션
ui/character_panel/reset = 초기화
ui/character_panel/close = 닫기
ui/character_panel/reset_title = 액션 배정 초기화
ui/character_panel/reset_text = 모든 액션 배정을 초기화할까요? 캐릭터 액션은 기본값으로 돌아가고, 모은 액션은 풀에 남습니다.
ui/character_panel/reset_ok = 초기화
ui/character_panel/reset_cancel = 취소
ui/character_panel/party = 파티
ui/character_panel/party_sub = 캐릭터마다 슬롯 3개 · 쓰지 않는 카드는 풀에서 대기
ui/character_panel/template = 템플릿: {0}
ui/character_panel/pool_title = 액션 풀
ui/character_panel/search = 이름 또는 ID로 검색...
ui/character_panel/filter_all = 전체
ui/character_panel/type_enemy = 적 대상
ui/character_panel/type_alias = 아군 대상
ui/character_panel/type_self = 자신 대상
ui/character_panel/type_passive = 패시브
ui/character_panel/type_active = 액티브
ui/character_panel/cost = {0} AP
ui/character_panel/cooldown = 재사용 대기 {0}
ui/character_panel/slot = 슬롯 {0}
ui/character_panel/slot_empty = + 빈 슬롯 — 여기로 카드를 끌어 오세요
ui/character_panel/assigned = 배정 {0}/{1} · 풀에 {2}
ui/character_panel/hint_select = 카드를 고른 다음 슬롯을 고르세요
ui/character_panel/drag_hint = 카드를 슬롯으로 끌어 놓기 · 슬롯끼리 서로 바꾸기
ui/character_panel/pool_hint = 카드는 풀에 있는 동안에도 플레이어의 것입니다. 중복이 허용됩니다.
ui/character_panel/pool_empty = 풀이 비어 있습니다 — 모은 액션이 여기에 나타납니다
ui/character_panel/nothing_found = 아무것도 찾지 못했습니다
ui/character_panel/stat_hp = HP
ui/character_panel/stat_ap = AP
ui/character_panel/stat_atk = 공격
ui/character_panel/stat_def = 방어
# --- Inventory ---
ui/inventory/title = 인벤토리
ui/inventory/use = 사용
ui/inventory/drop = 버리기
ui/inventory/drop_all = 전부
ui/inventory/drop_confirm = 버리기
ui/inventory/drop_quantity = 버리기
ui/inventory/close = 닫기
ui/inventory/no_ap = AP가 부족합니다
ui/inventory/select_target = 대상 선택
ui/inventory/cancel = 취소
ui/inventory/tab_all = 전체
ui/inventory/tab_food = 음식
ui/inventory/tab_resource = 자원
ui/inventory/tab_recipe = 레시피
ui/inventory/tab_quest = 퀘스트
ui/inventory/search = 인벤토리 검색...
ui/inventory/sort_type = 유형순
ui/inventory/sort_name = 이름순
ui/inventory/sort_cost = 가격순
ui/inventory/sort_count = 개수순
ui/inventory/shown = {1}개 중 {0}개 표시
ui/inventory/hint_select = 좌클릭 — 선택 · 액션은 상세 정보에
ui/inventory/stack = 묶음
ui/inventory/id = ID
ui/inventory/action = 액션
ui/inventory/verb = 동사
ui/inventory/props_header = 속성 · PropertyData
ui/inventory/props_none = 속성 없음
ui/inventory/no_action = ItemAction 없음 — 아직 사용할 수 없는 아이템
ui/inventory/cost = 가격
ui/inventory/empty = 인벤토리가 비어 있습니다
ui/inventory/select = 목록에서 아이템을 고르세요
ui/inventory/ap = AP
ui/inventory/ap_cost = −1 AP
ui/inventory/used = 사용함: {0}
ui/inventory/used_recipe = 레시피를 배웠다: {0}
ui/inventory/use_failed = UseAction → false: 아이템이 소모되지 않았습니다
ui/inventory/dropped = 버림: {0} ×{1}
ui/inventory/applied = 적용: {0} → {1}
ui/inventory/applied_failed = 대상이 효과를 받지 않았습니다 — 아이템이 소모되지 않았습니다
ui/inventory/drop_cant = 버릴 수 없음 (CantDrop)
ui/inventory/type_quest = 퀘스트
ui/inventory/type_used = 사용 가능
ui/inventory/type_target = 대상 지정
ui/inventory/type_food = 음식
ui/inventory/type_resource = 자원
ui/inventory/type_recipe = 레시피
ui/inventory/type_cant_drop = 버릴 수 없음
ui/inventory/verb_use = 사용
ui/inventory/verb_eat = 먹기
ui/inventory/verb_target = 대상에게 사용
ui/inventory/verb_recipe = 레시피 배우기
ui/inventory/tag_recipe = 레시피
ui/inventory/tag_target = 대상
ui/inventory/tag_food = 음료
ui/inventory/tag_use = 사용
# --- Map ---
ui/map/title = 지도
ui/map/close = 닫기
ui/map/subtitle = 북부 지구
ui/map/zone_count = 장소 {0}곳
ui/map/legend_current = 현재 위치
ui/map/legend_available = 갈 수 있음
ui/map/legend_locked = 닫힘
ui/map/hint = 표식을 좌클릭 — 장소 선택 · Esc — 지도 닫기
ui/map/current = 현재 위치
ui/map/available = 갈 수 있음
ui/map/locked = 닫힘
ui/map/you_are_here = 현재 위치
ui/map/closed_note = 닫힘
ui/map/go = 이동
ui/map/here = 현재 위치
ui/map/transition = 전환
ui/map/card_hint = 아래 버튼으로 장면을 바꿉니다. Esc — 지도 닫기.
ui/map/locked_message = 아직 길이 열리지 않았습니다.
ui/map/empty = 지도를 쓸 수 없음
ui/map/empty_hint = 배경 map.png를 찾지 못했습니다 — 모드에 배경과 map/*.json 구역 파일을 추가하세요.
# --- Skills ---
ui/skills/title = 스킬
ui/skills/grants_action = 액션 카드를 부여
ui/skills/side_title = 카드 연구
ui/skills/collection = 컬렉션
ui/skills/search = 컬렉션 검색…
ui/skills/sort_rarity = 희귀도순
ui/skills/sort_type = 유형순
ui/skills/sort_name = 이름순
ui/skills/filter_all = 전체
ui/skills/filter_rarities = 모든 희귀도
ui/skills/hint = 좌클릭 — 카드 살펴보기 · 테두리와 빛은 희귀도를 따릅니다
ui/skills/reset = 필터 초기화
ui/skills/empty = 아직 스킬이 없습니다
ui/skills/nothing_found = 아무것도 찾지 못했습니다
ui/skills/no_effects = 아직 영구 효과가 없습니다
ui/skills/collection_effect = 컬렉션 효과
ui/skills/collection_effect_hint = 컬렉션 전체 합계 · 즉시 갱신
ui/skills/rarity/common = 일반
ui/skills/rarity/uncommon = 고급
ui/skills/rarity/rare = 희귀
ui/skills/rarity/epic = 영웅
ui/skills/rarity/legendary = 전설
ui/skills/type/active = 액티브
ui/skills/type/passive = 패시브
ui/skills/type/for_character = 캐릭터용
# --- Quest Journal ---
ui/journal/title = 일지
ui/journal/active = 진행 중
ui/journal/completed = 완료
ui/journal/close = 닫기
ui/journal/empty = 퀘스트 없음
ui/journal/toc = 목차
ui/journal/entry_header = 항목
ui/journal/entry_of = 항목 {0} / {1}
ui/journal/finished = 완료
ui/journal/seals = 인장
ui/journal/no_entries = 이 항목의 페이지는 아직 비어 있습니다. 이야기가 진행되면 글자가 나타납니다.
tools/unsaved_title = 저장하지 않은 변경
tools/unsaved_text = 저장하지 않은 변경이 있습니다. 저장하지 않고 닫을까요?
tools/unsaved_discard_text = 저장하지 않은 변경이 있습니다. 버릴까요?
tools/window_maximize = 최대화 / 복원 (제목 더블클릭)
tools/window_close = 닫기
tools/menu_title = 도구
+138 -6
View File
@@ -1,4 +1,4 @@
# --- scenes/battle/winground.tscn --- # --- scenes/battle/winground.tscn ---
ui/battle/victory = Победа ui/battle/victory = Победа
ui/battle/victory/return = Продолжить ui/battle/victory/return = Продолжить
ui/battle/victory/loot_title = Добыча: ui/battle/victory/loot_title = Добыча:
@@ -15,10 +15,8 @@ menu/newgame = Новая игра
ui/menu/load = Загрузить ui/menu/load = Загрузить
menu/settings = Настройки menu/settings = Настройки
menu/exit = Выход menu/exit = Выход
menu/tools = Инструменты
# --- Mods Menu --- # --- Mods ---
menu/mods = Моды
mods/title = Моды mods/title = Моды
mods/enable = Включить mods/enable = Включить
mods/disable = Выключить mods/disable = Выключить
@@ -26,6 +24,7 @@ mods/enabled = Включён
mods/disabled = Выключен mods/disabled = Выключен
mods/disabled_reason = Выключен mods/disabled_reason = Выключен
mods/dep_disabled_reason = Зависимость "{0}" выключена mods/dep_disabled_reason = Зависимость "{0}" выключена
mods/not_in_game_reason = Не входит в игру "{0}"
mods/dependency_disabled = Зависимость отключена mods/dependency_disabled = Зависимость отключена
mods/invalid_dep_format = Неверный формат зависимости: "{0}" mods/invalid_dep_format = Неверный формат зависимости: "{0}"
mods/required_mod_not_loaded = Требуемый мод "{0}" не загружен mods/required_mod_not_loaded = Требуемый мод "{0}" не загружен
@@ -66,7 +65,11 @@ mods/category_audio = Аудио
mods/category_ui = Интерфейс mods/category_ui = Интерфейс
mods/category_locale = Перевод mods/category_locale = Перевод
mods/category_library = Библиотека mods/category_library = Библиотека
mods/category_engine = Движок
mods/category_overhaul = Тотальная конверсия mods/category_overhaul = Тотальная конверсия
mods/category_tools = Инструменты
mods/category_editor = Редактор
mods/category_debug = Отладка
mods/category_other = Прочее mods/category_other = Прочее
mods/catalog_tags = Теги (через запятую) mods/catalog_tags = Теги (через запятую)
mods/catalog_changelog = Список изменений mods/catalog_changelog = Список изменений
@@ -97,6 +100,7 @@ ui/save/game_day = День {day}, {time}
ui/save/party_size = Отряд: {count} ui/save/party_size = Отряд: {count}
# --- GameMenu --- # --- GameMenu ---
menu/title = Меню
menu/resume = Вернуться menu/resume = Вернуться
menu/save = Сохранить menu/save = Сохранить
menu/load = Загрузить menu/load = Загрузить
@@ -110,6 +114,31 @@ ui/character_panel/reset_title = Сбросить распределение д
ui/character_panel/reset_text = Вы уверены, что хотите сбросить распределение всех действий? Действия персонажей вернутся к исходным, собранные действия останутся в пуле. ui/character_panel/reset_text = Вы уверены, что хотите сбросить распределение всех действий? Действия персонажей вернутся к исходным, собранные действия останутся в пуле.
ui/character_panel/reset_ok = Сбросить ui/character_panel/reset_ok = Сбросить
ui/character_panel/reset_cancel = Отмена ui/character_panel/reset_cancel = Отмена
ui/character_panel/party = Отряд
ui/character_panel/party_sub = 3 слота на персонажа · неиспользованные карты ждут в пуле
ui/character_panel/template = шаблон: {0}
ui/character_panel/pool_title = Пул действий
ui/character_panel/search = Поиск по названию и ID...
ui/character_panel/filter_all = Все
ui/character_panel/type_enemy = По врагу
ui/character_panel/type_alias = По союзнику
ui/character_panel/type_self = На себя
ui/character_panel/type_passive = Пассивная
ui/character_panel/type_active = Активная
ui/character_panel/cost = {0} ОД
ui/character_panel/cooldown = КД {0}
ui/character_panel/slot = Слот {0}
ui/character_panel/slot_empty = + слот пуст — перетащите карту
ui/character_panel/assigned = Назначено {0}/{1} · в пуле {2}
ui/character_panel/hint_select = выберите карту кликом, затем слот
ui/character_panel/drag_hint = Перетащите карту в слот · слот↔слот меняются местами
ui/character_panel/pool_hint = Карта из пула остаётся у игрока, пока лежит здесь. Дубликаты допустимы.
ui/character_panel/pool_empty = Пул пуст — собранные действия появятся здесь
ui/character_panel/nothing_found = Ничего не найдено
ui/character_panel/stat_hp = HP
ui/character_panel/stat_ap = ОД
ui/character_panel/stat_atk = АТК
ui/character_panel/stat_def = ЗАЩ
# --- Inventory --- # --- Inventory ---
ui/inventory/title = Инвентарь ui/inventory/title = Инвентарь
@@ -122,9 +151,101 @@ ui/inventory/close = Закрыть
ui/inventory/no_ap = Недостаточно ОД ui/inventory/no_ap = Недостаточно ОД
ui/inventory/select_target = Выберите цель ui/inventory/select_target = Выберите цель
ui/inventory/cancel = Отмена ui/inventory/cancel = Отмена
ui/inventory/tab_all = Все
ui/inventory/tab_food = Еда
ui/inventory/tab_resource = Ресурсы
ui/inventory/tab_recipe = Рецепты
ui/inventory/tab_quest = Квесты
ui/inventory/search = Поиск по инвентарю...
ui/inventory/sort_type = Сначала типы
ui/inventory/sort_name = По названию
ui/inventory/sort_cost = По цене
ui/inventory/sort_count = По количеству
ui/inventory/shown = Показано {0} из {1}
ui/inventory/hint_select = ЛКМ — выбрать · действие в паспорте
ui/inventory/stack = В стаке
ui/inventory/id = ID
ui/inventory/action = Действие
ui/inventory/verb = Глагол
ui/inventory/props_header = Свойства · PropertyData
ui/inventory/props_none = нет свойств
ui/inventory/no_action = Нет ItemAction — предмет пока нельзя использовать
ui/inventory/cost = Цена
ui/inventory/empty = Инвентарь пуст
ui/inventory/select = Выберите предмет в списке
ui/inventory/ap = ОД
ui/inventory/ap_cost = −1 ОД
ui/inventory/used = Использовано: {0}
ui/inventory/used_recipe = Изучен рецепт: {0}
ui/inventory/use_failed = UseAction → false: предмет не потрачен
ui/inventory/dropped = Выброшено: {0} ×{1}
ui/inventory/applied = Применено: {0} → {1}
ui/inventory/applied_failed = Цель не приняла эффект — предмет не потрачен
ui/inventory/drop_cant = Нельзя выбросить (CantDrop)
ui/inventory/type_quest = Квест
ui/inventory/type_used = Используемый
ui/inventory/type_target = По цели
ui/inventory/type_food = Еда
ui/inventory/type_resource = Ресурс
ui/inventory/type_recipe = Рецепт
ui/inventory/type_cant_drop = Не выбросить
ui/inventory/verb_use = Использовать
ui/inventory/verb_eat = Съесть
ui/inventory/verb_target = Применить к цели
ui/inventory/verb_recipe = Изучить рецепт
ui/inventory/tag_recipe = рецепт
ui/inventory/tag_target = цель
ui/inventory/tag_food = пить
ui/inventory/tag_use = use
# --- Map --- # --- Map ---
ui/map/title = Карта
ui/map/close = Закрыть ui/map/close = Закрыть
ui/map/subtitle = Северная округа
ui/map/zone_count = {0} локаций
ui/map/legend_current = вы здесь
ui/map/legend_available = доступна
ui/map/legend_locked = закрыта
ui/map/hint = ЛКМ по метке — выбрать локацию · Esc — закрыть карту
ui/map/current = Вы здесь
ui/map/available = Доступна
ui/map/locked = Закрыта
ui/map/you_are_here = вы здесь
ui/map/closed_note = закрыто
ui/map/go = Перейти
ui/map/here = Вы здесь
ui/map/transition = Переход
ui/map/card_hint = Кнопка ниже переключает сцену. Esc — закрыть карту.
ui/map/locked_message = Путь ещё не открыт.
ui/map/empty = Карта недоступна
ui/map/empty_hint = Не найден фон map.png — добавьте фон и файлы зон map/*.json в мод.
# --- Skills ---
ui/skills/title = Навыки
ui/skills/grants_action = Дарит карточку действия
ui/skills/side_title = Изучение карточки
ui/skills/collection = Коллекция
ui/skills/search = Поиск по коллекции…
ui/skills/sort_rarity = Сначала редкость
ui/skills/sort_type = По типу
ui/skills/sort_name = По названию
ui/skills/filter_all = Все
ui/skills/filter_rarities = Все редкости
ui/skills/hint = ЛКМ — изучить карточку · рамка и свечение окрашены по редкости
ui/skills/reset = Сброс фильтров
ui/skills/empty = Навыков пока нет
ui/skills/nothing_found = Ничего не найдено
ui/skills/no_effects = Постоянных эффектов пока нет
ui/skills/collection_effect = Эффект коллекции
ui/skills/collection_effect_hint = Суммарно по коллекции · обновляется сразу
ui/skills/rarity/common = Обычный
ui/skills/rarity/uncommon = Необычный
ui/skills/rarity/rare = Редкий
ui/skills/rarity/epic = Эпический
ui/skills/rarity/legendary = Легендарный
ui/skills/type/active = Активный
ui/skills/type/passive = Пассивный
ui/skills/type/for_character = Для персонажа
# --- Quest Journal --- # --- Quest Journal ---
ui/journal/title = Журнал ui/journal/title = Журнал
@@ -132,5 +253,16 @@ ui/journal/active = В процессе
ui/journal/completed = Завершённые ui/journal/completed = Завершённые
ui/journal/close = Закрыть ui/journal/close = Закрыть
ui/journal/empty = Нет квестов ui/journal/empty = Нет квестов
mod_interface_name = Интерфейс ui/journal/toc = Оглавление
mod_interface_desc = Игровой интерфейс ui/journal/entry_header = Запись
ui/journal/entry_of = Запись {0} из {1}
ui/journal/finished = Завершено
ui/journal/seals = Печати
ui/journal/no_entries = Страницы этой записи ещё пусты. Чернила появятся, когда история сдвинется с места.
tools/unsaved_title = Есть изменения
tools/unsaved_text = У вас есть несохранённые изменения. Закрыть без сохранения?
tools/unsaved_discard_text = Есть несохранённые изменения. Отбросить их?
tools/window_maximize = Развернуть / восстановить (двойной клик по заголовку)
tools/window_close = Закрыть
tools/menu_title = Инструменты
+268
View File
@@ -0,0 +1,268 @@
# --- scenes/battle/winground.tscn ---
ui/battle/victory = Chiến thắng
ui/battle/victory/return = Tiếp tục
ui/battle/victory/loot_title = Chiến lợi phẩm:
# --- scenes/battle/deadground.tscn ---
ui/battle/defeat = Thất bại
ui/battle/endgame/to_main_menu = Menu chính
# --- scenes/battle/battle.tscn ---
ui/battle/next = Kết thúc lượt
# --- scenes/menu/main_menu.tscn ---
menu/newgame = Trò chơi mới
ui/menu/load = Tải
menu/settings = Cài đặt
menu/exit = Thoát
# --- Mods ---
mods/title = Mod
mods/enable = Bật
mods/disable = Tắt
mods/enabled = Đã bật
mods/disabled = Đã tắt
mods/disabled_reason = Đã tắt
mods/dep_disabled_reason = Phụ thuộc "{0}" đang tắt
mods/not_in_game_reason = Không thuộc game "{0}"
mods/dependency_disabled = Phụ thuộc đang tắt
mods/invalid_dep_format = Định dạng phụ thuộc không hợp lệ: "{0}"
mods/required_mod_not_loaded = Mod cần thiết "{0}" chưa được tải
mods/version_mismatch = Không khớp phiên bản: cần {0}, tìm thấy {1}
mods/restart_required = Cần khởi động lại để thay đổi có hiệu lực
mods/back = Quay lại
mods/author = Tác giả
mods/version = Phiên bản
mods/depends = Phụ thuộc
mods/used_by = Được dùng bởi
mods/overrides_title = Ghi đè
mods/reset_order = Đặt lại thứ tự
mods/reset_order_title = Đặt lại thứ tự
mods/reset_order_text = Bạn có chắc muốn đặt lại thứ tự mod? Nó sẽ được tính lại tự động
mods/reset_order_confirm = Có
mods/reset_order_cancel = Hủy
mods/tab_device = Thiết bị
mods/tab_network = Mạng
mods/network_refresh = Làm mới
mods/network_loading = Đang tải danh mục...
mods/network_ready = Đã tải danh mục
mods/network_empty = Không có mod trong danh mục
mods/network_error = Không tải được danh mục
mods/network_no_urls = Chưa cấu hình URL danh mục
mods/status_installed = Đã cài
mods/status_update_available = Có bản cập nhật
mods/status_not_installed = Chưa cài
mods/status_modified = Đã sửa cục bộ
mods/catalog_export = Xuất sang danh mục
mods/catalog_title = Xuất mục danh mục
mods/catalog_download_url = URL tải xuống
mods/catalog_category = Thể loại
mods/category_story = Cốt truyện
mods/category_characters = Nhân vật
mods/category_gameplay = Lối chơi
mods/category_visual = Hình ảnh
mods/category_audio = Âm thanh
mods/category_ui = Giao diện
mods/category_locale = Ngôn ngữ
mods/category_library = Thư viện
mods/category_engine = Engine
mods/category_overhaul = Đại tu
mods/category_tools = Công cụ
mods/category_editor = Trình sửa
mods/category_debug = Gỡ lỗi
mods/category_other = Khác
mods/catalog_tags = Thẻ (cách nhau bằng dấu phẩy)
mods/catalog_changelog = Nhật ký thay đổi
mods/catalog_status = Trạng thái
mods/catalog_min_game_version = Phiên bản game tối thiểu
mods/catalog_icon_url = URL biểu tượng
mods/catalog_copy = Sao chép JSON
mods/catalog_export_file = Xuất ra tệp
mods/catalog_close = Đóng
mods/install_button = Cài đặt
mods/update_button = Cập nhật
mods/overwrite_button = Ghi đè
mods/downgrade_button = Hạ phiên bản
mods/restart_button = Cần khởi động lại
mods/installing = Đang cài...
mods/install_complete = Cài xong. Cần khởi động lại.
mods/install_failed = Cài đặt thất bại. Xem nhật ký để biết chi tiết.
# --- Save/Load ---
ui/load/title = Tải
ui/save/title = Lưu
ui/save/empty = Trống
ui/save/overwrite_title = Ghi đè bản lưu
ui/save/overwrite_text = Ô này đã có bản lưu. Ghi đè?
ui/save/save_button = Ghi đè
ui/save/cancel_button = Hủy
ui/save/game_day = Ngày {day}, {time}
ui/save/party_size = Tổ đội: {count}
# --- GameMenu ---
menu/title = Menu
menu/resume = Tiếp tục
menu/save = Lưu
menu/load = Tải
menu/main_menu = Menu chính
# --- Character Panel ---
ui/character_panel/title = Hành động của nhân vật
ui/character_panel/reset = Đặt lại
ui/character_panel/close = Đóng
ui/character_panel/reset_title = Đặt lại phân bổ hành động
ui/character_panel/reset_text = Bạn có chắc muốn đặt lại mọi phân bổ hành động? Hành động của nhân vật sẽ trở về mặc định, các hành động đã thu thập vẫn ở trong kho.
ui/character_panel/reset_ok = Đặt lại
ui/character_panel/reset_cancel = Hủy
ui/character_panel/party = Tổ đội
ui/character_panel/party_sub = 3 ô cho mỗi nhân vật · thẻ chưa dùng chờ trong kho
ui/character_panel/template = mẫu: {0}
ui/character_panel/pool_title = Kho hành động
ui/character_panel/search = Tìm theo tên hoặc ID...
ui/character_panel/filter_all = Tất cả
ui/character_panel/type_enemy = Nhắm kẻ địch
ui/character_panel/type_alias = Nhắm đồng đội
ui/character_panel/type_self = Nhắm bản thân
ui/character_panel/type_passive = Bị động
ui/character_panel/type_active = Chủ động
ui/character_panel/cost = {0} AP
ui/character_panel/cooldown = Hồi chiêu {0}
ui/character_panel/slot = Ô {0}
ui/character_panel/slot_empty = + ô trống — kéo một thẻ vào đây
ui/character_panel/assigned = Đã gán {0}/{1} · trong kho {2}
ui/character_panel/hint_select = chọn một thẻ, rồi chọn một ô
ui/character_panel/drag_hint = Kéo thẻ vào ô · ô↔ô để đổi chỗ
ui/character_panel/pool_hint = Thẻ vẫn thuộc về người chơi khi ở trong kho. Được phép trùng lặp.
ui/character_panel/pool_empty = Kho trống — các hành động thu thập được sẽ xuất hiện ở đây
ui/character_panel/nothing_found = Không tìm thấy gì
ui/character_panel/stat_hp = HP
ui/character_panel/stat_ap = AP
ui/character_panel/stat_atk = ATK
ui/character_panel/stat_def = DEF
# --- Inventory ---
ui/inventory/title = Túi đồ
ui/inventory/use = Dùng
ui/inventory/drop = Vứt
ui/inventory/drop_all = Tất cả
ui/inventory/drop_confirm = Vứt
ui/inventory/drop_quantity = Vứt
ui/inventory/close = Đóng
ui/inventory/no_ap = Không đủ AP
ui/inventory/select_target = Chọn mục tiêu
ui/inventory/cancel = Hủy
ui/inventory/tab_all = Tất cả
ui/inventory/tab_food = Đồ ăn
ui/inventory/tab_resource = Tài nguyên
ui/inventory/tab_recipe = Công thức
ui/inventory/tab_quest = Nhiệm vụ
ui/inventory/search = Tìm trong túi đồ...
ui/inventory/sort_type = Theo loại
ui/inventory/sort_name = Theo tên
ui/inventory/sort_cost = Theo giá
ui/inventory/sort_count = Theo số lượng
ui/inventory/shown = Hiển thị {0} trên {1}
ui/inventory/hint_select = Chuột trái — chọn · hành động trong phần chi tiết
ui/inventory/stack = Chồng
ui/inventory/id = ID
ui/inventory/action = Hành động
ui/inventory/verb = Động từ
ui/inventory/props_header = Thuộc tính · PropertyData
ui/inventory/props_none = không có thuộc tính
ui/inventory/no_action = Không có ItemAction — vật phẩm chưa dùng được
ui/inventory/cost = Giá
ui/inventory/empty = Túi đồ trống
ui/inventory/select = Chọn một vật phẩm trong danh sách
ui/inventory/ap = AP
ui/inventory/ap_cost = −1 AP
ui/inventory/used = Đã dùng: {0}
ui/inventory/used_recipe = Đã học công thức: {0}
ui/inventory/use_failed = UseAction → false: vật phẩm không bị tiêu hao
ui/inventory/dropped = Đã vứt: {0} ×{1}
ui/inventory/applied = Đã áp dụng: {0} → {1}
ui/inventory/applied_failed = Mục tiêu không nhận hiệu ứng — vật phẩm không bị tiêu hao
ui/inventory/drop_cant = Không thể vứt (CantDrop)
ui/inventory/type_quest = Nhiệm vụ
ui/inventory/type_used = Dùng được
ui/inventory/type_target = Có mục tiêu
ui/inventory/type_food = Đồ ăn
ui/inventory/type_resource = Tài nguyên
ui/inventory/type_recipe = Công thức
ui/inventory/type_cant_drop = Không thể vứt
ui/inventory/verb_use = Dùng
ui/inventory/verb_eat = Ăn
ui/inventory/verb_target = Dùng lên mục tiêu
ui/inventory/verb_recipe = Học công thức
ui/inventory/tag_recipe = công thức
ui/inventory/tag_target = mục tiêu
ui/inventory/tag_food = đồ uống
ui/inventory/tag_use = dùng
# --- Map ---
ui/map/title = Bản đồ
ui/map/close = Đóng
ui/map/subtitle = Quận phía bắc
ui/map/zone_count = {0} địa điểm
ui/map/legend_current = bạn ở đây
ui/map/legend_available = có thể đến
ui/map/legend_locked = đã đóng
ui/map/hint = Chuột trái vào điểm đánh dấu — chọn địa điểm · Esc — đóng bản đồ
ui/map/current = Bạn ở đây
ui/map/available = Có thể đến
ui/map/locked = Đã đóng
ui/map/you_are_here = bạn ở đây
ui/map/closed_note = đã đóng
ui/map/go = Di chuyển
ui/map/here = Bạn ở đây
ui/map/transition = Chuyển cảnh
ui/map/card_hint = Nút bên dưới chuyển cảnh. Esc — đóng bản đồ.
ui/map/locked_message = Con đường chưa mở.
ui/map/empty = Bản đồ không khả dụng
ui/map/empty_hint = Không tìm thấy phông nền map.png — hãy thêm phông nền và các tệp vùng map/*.json vào một mod.
# --- Skills ---
ui/skills/title = Kỹ năng
ui/skills/grants_action = Cấp một thẻ hành động
ui/skills/side_title = Nghiên cứu thẻ
ui/skills/collection = Bộ sưu tập
ui/skills/search = Tìm trong bộ sưu tập…
ui/skills/sort_rarity = Theo độ hiếm
ui/skills/sort_type = Theo loại
ui/skills/sort_name = Theo tên
ui/skills/filter_all = Tất cả
ui/skills/filter_rarities = Mọi độ hiếm
ui/skills/hint = Chuột trái — xem thẻ · khung và ánh sáng theo độ hiếm
ui/skills/reset = Đặt lại bộ lọc
ui/skills/empty = Chưa có kỹ năng nào
ui/skills/nothing_found = Không tìm thấy gì
ui/skills/no_effects = Chưa có hiệu ứng vĩnh viễn
ui/skills/collection_effect = Hiệu ứng bộ sưu tập
ui/skills/collection_effect_hint = Tổng của cả bộ sưu tập · cập nhật tức thì
ui/skills/rarity/common = Thường
ui/skills/rarity/uncommon = Khá hiếm
ui/skills/rarity/rare = Hiếm
ui/skills/rarity/epic = Sử thi
ui/skills/rarity/legendary = Huyền thoại
ui/skills/type/active = Chủ động
ui/skills/type/passive = Bị động
ui/skills/type/for_character = Cho nhân vật
# --- Quest Journal ---
ui/journal/title = Nhật ký
ui/journal/active = Đang làm
ui/journal/completed = Đã xong
ui/journal/close = Đóng
ui/journal/empty = Không có nhiệm vụ
ui/journal/toc = Mục lục
ui/journal/entry_header = Mục
ui/journal/entry_of = Mục {0} trên {1}
ui/journal/finished = Đã xong
ui/journal/seals = Ấn triện
ui/journal/no_entries = Các trang của mục này vẫn còn trống. Mực sẽ hiện ra khi câu chuyện tiếp diễn.
tools/unsaved_title = Thay đổi chưa lưu
tools/unsaved_text = Bạn có thay đổi chưa lưu. Đóng mà không lưu?
tools/unsaved_discard_text = Có thay đổi chưa lưu. Bỏ chúng?
tools/window_maximize = Phóng to / khôi phục (nhấp đúp vào tiêu đề)
tools/window_close = Đóng
tools/menu_title = Công cụ
+268
View File
@@ -0,0 +1,268 @@
# --- scenes/battle/winground.tscn ---
ui/battle/victory = 胜利
ui/battle/victory/return = 继续
ui/battle/victory/loot_title = 战利品:
# --- scenes/battle/deadground.tscn ---
ui/battle/defeat = 失败
ui/battle/endgame/to_main_menu = 主菜单
# --- scenes/battle/battle.tscn ---
ui/battle/next = 结束回合
# --- scenes/menu/main_menu.tscn ---
menu/newgame = 新游戏
ui/menu/load = 读取
menu/settings = 设置
menu/exit = 退出
# --- Mods ---
mods/title = 模组
mods/enable = 启用
mods/disable = 停用
mods/enabled = 已启用
mods/disabled = 已停用
mods/disabled_reason = 已停用
mods/dep_disabled_reason = 依赖"{0}"已停用
mods/not_in_game_reason = 不属于游戏"{0}"
mods/dependency_disabled = 依赖已停用
mods/invalid_dep_format = 依赖格式无效:"{0}"
mods/required_mod_not_loaded = 所需模组"{0}"未加载
mods/version_mismatch = 版本不匹配:需要 {0},找到 {1}
mods/restart_required = 需要重启才能使更改生效
mods/back = 返回
mods/author = 作者
mods/version = 版本
mods/depends = 依赖
mods/used_by = 被使用于
mods/overrides_title = 覆盖项
mods/reset_order = 重置顺序
mods/reset_order_title = 重置顺序
mods/reset_order_text = 确定要重置模组顺序吗?它会被自动重新计算
mods/reset_order_confirm = 是
mods/reset_order_cancel = 取消
mods/tab_device = 设备
mods/tab_network = 网络
mods/network_refresh = 刷新
mods/network_loading = 正在加载目录...
mods/network_ready = 目录已加载
mods/network_empty = 目录中没有模组
mods/network_error = 无法加载目录
mods/network_no_urls = 没有配置目录 URL
mods/status_installed = 已安装
mods/status_update_available = 有可用更新
mods/status_not_installed = 未安装
mods/status_modified = 本地已修改
mods/catalog_export = 导出到目录
mods/catalog_title = 导出目录条目
mods/catalog_download_url = 下载 URL
mods/catalog_category = 分类
mods/category_story = 剧情
mods/category_characters = 角色
mods/category_gameplay = 玩法
mods/category_visual = 视觉
mods/category_audio = 音频
mods/category_ui = 界面
mods/category_locale = 语言
mods/category_library = 库
mods/category_engine = 引擎
mods/category_overhaul = 大型改动
mods/category_tools = 工具
mods/category_editor = 编辑器
mods/category_debug = 调试
mods/category_other = 其他
mods/catalog_tags = 标签(逗号分隔)
mods/catalog_changelog = 更新日志
mods/catalog_status = 状态
mods/catalog_min_game_version = 最低游戏版本
mods/catalog_icon_url = 图标 URL
mods/catalog_copy = 复制 JSON
mods/catalog_export_file = 导出到文件
mods/catalog_close = 关闭
mods/install_button = 安装
mods/update_button = 更新
mods/overwrite_button = 覆盖
mods/downgrade_button = 降级
mods/restart_button = 需要重启
mods/installing = 正在安装...
mods/install_complete = 安装完成。需要重启。
mods/install_failed = 安装失败。详情请查看日志。
# --- Save/Load ---
ui/load/title = 读取
ui/save/title = 保存
ui/save/empty = 空
ui/save/overwrite_title = 覆盖存档
ui/save/overwrite_text = 这个栏位已有存档。要覆盖吗?
ui/save/save_button = 覆盖
ui/save/cancel_button = 取消
ui/save/game_day = 第 {day} 天,{time}
ui/save/party_size = 队伍:{count}
# --- GameMenu ---
menu/title = 菜单
menu/resume = 继续
menu/save = 保存
menu/load = 读取
menu/main_menu = 主菜单
# --- Character Panel ---
ui/character_panel/title = 角色行动
ui/character_panel/reset = 重置
ui/character_panel/close = 关闭
ui/character_panel/reset_title = 重置行动分配
ui/character_panel/reset_text = 确定要重置所有行动分配吗?角色的行动会恢复默认,收集到的行动会留在池中。
ui/character_panel/reset_ok = 重置
ui/character_panel/reset_cancel = 取消
ui/character_panel/party = 队伍
ui/character_panel/party_sub = 每个角色 3 个栏位 · 未使用的卡牌在池中等待
ui/character_panel/template = 模板:{0}
ui/character_panel/pool_title = 行动池
ui/character_panel/search = 按名称或 ID 搜索...
ui/character_panel/filter_all = 全部
ui/character_panel/type_enemy = 目标敌人
ui/character_panel/type_alias = 目标队友
ui/character_panel/type_self = 目标自己
ui/character_panel/type_passive = 被动
ui/character_panel/type_active = 主动
ui/character_panel/cost = {0} AP
ui/character_panel/cooldown = 冷却 {0}
ui/character_panel/slot = 栏位 {0}
ui/character_panel/slot_empty = + 空栏位——把卡牌拖到这里
ui/character_panel/assigned = 已分配 {0}/{1} · 池中 {2}
ui/character_panel/hint_select = 先选卡牌,再选栏位
ui/character_panel/drag_hint = 把卡牌拖进栏位 · 栏位之间可交换
ui/character_panel/pool_hint = 卡牌在池中时仍归玩家所有。允许重复。
ui/character_panel/pool_empty = 池是空的——收集到的行动会出现在这里
ui/character_panel/nothing_found = 什么都没找到
ui/character_panel/stat_hp = HP
ui/character_panel/stat_ap = AP
ui/character_panel/stat_atk = 攻击
ui/character_panel/stat_def = 防御
# --- Inventory ---
ui/inventory/title = 背包
ui/inventory/use = 使用
ui/inventory/drop = 丢弃
ui/inventory/drop_all = 全部
ui/inventory/drop_confirm = 丢弃
ui/inventory/drop_quantity = 丢弃
ui/inventory/close = 关闭
ui/inventory/no_ap = AP 不足
ui/inventory/select_target = 选择目标
ui/inventory/cancel = 取消
ui/inventory/tab_all = 全部
ui/inventory/tab_food = 食物
ui/inventory/tab_resource = 资源
ui/inventory/tab_recipe = 配方
ui/inventory/tab_quest = 任务
ui/inventory/search = 搜索背包...
ui/inventory/sort_type = 按类型
ui/inventory/sort_name = 按名称
ui/inventory/sort_cost = 按价格
ui/inventory/sort_count = 按数量
ui/inventory/shown = 显示 {1} 个中的 {0} 个
ui/inventory/hint_select = 左键——选择 · 行动在详情中
ui/inventory/stack = 堆叠
ui/inventory/id = ID
ui/inventory/action = 动作
ui/inventory/verb = 动词
ui/inventory/props_header = 属性 · PropertyData
ui/inventory/props_none = 没有属性
ui/inventory/no_action = 没有 ItemAction——物品暂时无法使用
ui/inventory/cost = 价格
ui/inventory/empty = 背包是空的
ui/inventory/select = 在列表中选择一个物品
ui/inventory/ap = AP
ui/inventory/ap_cost = −1 AP
ui/inventory/used = 已使用:{0}
ui/inventory/used_recipe = 学会了配方:{0}
ui/inventory/use_failed = UseAction → false:物品未被消耗
ui/inventory/dropped = 已丢弃:{0} ×{1}
ui/inventory/applied = 已作用:{0} → {1}
ui/inventory/applied_failed = 目标没有接受效果——物品未被消耗
ui/inventory/drop_cant = 无法丢弃(CantDrop)
ui/inventory/type_quest = 任务
ui/inventory/type_used = 可使用
ui/inventory/type_target = 需要目标
ui/inventory/type_food = 食物
ui/inventory/type_resource = 资源
ui/inventory/type_recipe = 配方
ui/inventory/type_cant_drop = 不可丢弃
ui/inventory/verb_use = 使用
ui/inventory/verb_eat = 吃
ui/inventory/verb_target = 对目标使用
ui/inventory/verb_recipe = 学习配方
ui/inventory/tag_recipe = 配方
ui/inventory/tag_target = 目标
ui/inventory/tag_food = 饮品
ui/inventory/tag_use = 使用
# --- Map ---
ui/map/title = 地图
ui/map/close = 关闭
ui/map/subtitle = 北区
ui/map/zone_count = {0} 个地点
ui/map/legend_current = 你在这里
ui/map/legend_available = 可前往
ui/map/legend_locked = 已关闭
ui/map/hint = 左键点击标记——选择地点 · Esc——关闭地图
ui/map/current = 你在这里
ui/map/available = 可前往
ui/map/locked = 已关闭
ui/map/you_are_here = 你在这里
ui/map/closed_note = 已关闭
ui/map/go = 前往
ui/map/here = 你在这里
ui/map/transition = 切换
ui/map/card_hint = 下方的按钮会切换场景。Esc——关闭地图。
ui/map/locked_message = 道路尚未开放。
ui/map/empty = 地图不可用
ui/map/empty_hint = 找不到背景 map.png——请在模组中加入该背景和 map/*.json 区域文件。
# --- Skills ---
ui/skills/title = 技能
ui/skills/grants_action = 授予一张行动卡
ui/skills/side_title = 研究卡牌
ui/skills/collection = 收藏
ui/skills/search = 搜索收藏…
ui/skills/sort_rarity = 按稀有度
ui/skills/sort_type = 按类型
ui/skills/sort_name = 按名称
ui/skills/filter_all = 全部
ui/skills/filter_rarities = 全部稀有度
ui/skills/hint = 左键——研究卡牌 · 边框和光芒随稀有度变化
ui/skills/reset = 重置筛选
ui/skills/empty = 还没有技能
ui/skills/nothing_found = 什么都没找到
ui/skills/no_effects = 还没有永久效果
ui/skills/collection_effect = 收藏效果
ui/skills/collection_effect_hint = 整个收藏的总和 · 即时更新
ui/skills/rarity/common = 普通
ui/skills/rarity/uncommon = 优秀
ui/skills/rarity/rare = 稀有
ui/skills/rarity/epic = 史诗
ui/skills/rarity/legendary = 传说
ui/skills/type/active = 主动
ui/skills/type/passive = 被动
ui/skills/type/for_character = 用于角色
# --- Quest Journal ---
ui/journal/title = 日志
ui/journal/active = 进行中
ui/journal/completed = 已完成
ui/journal/close = 关闭
ui/journal/empty = 没有任务
ui/journal/toc = 目录
ui/journal/entry_header = 条目
ui/journal/entry_of = 第 {0} 条,共 {1} 条
ui/journal/finished = 已完成
ui/journal/seals = 印记
ui/journal/no_entries = 这个条目的页面还是空的。随着故事推进,墨迹会出现。
tools/unsaved_title = 未保存的更改
tools/unsaved_text = 你有未保存的更改。不保存就关闭吗?
tools/unsaved_discard_text = 有未保存的更改。要放弃吗?
tools/window_maximize = 最大化 / 还原(双击标题)
tools/window_close = 关闭
tools/menu_title = 工具
+2
View File
@@ -0,0 +1,2 @@
mod_ui_base_name = UI
mod_ui_base_desc = Lớp giao diện mà mọi game dùng chung: thanh công cụ, menu trong game, biểu tượng lưu, thông báo và diện mạo thống nhất của bảng điều khiển, nút bấm, danh sách.
+2
View File
@@ -0,0 +1,2 @@
mod_ui_base_name = UI
mod_ui_base_desc = 所有游戏共用的界面层:工具栏、游戏内菜单、存档指示器、通知,以及面板、按钮和列表的统一外观。
+22 -4
View File
@@ -1,8 +1,26 @@
{ {
"id": "interface", "id": "ui_base",
"name": "Interface", "name": "UI",
"version": "1.0.0", "version": "1.0.0",
"author": "ct", "author": "ct",
"description": "Game interface", "description": "The interface layer every game shares: the toolbar, the in-game menu, the save indicator, notifications and the common look of panels, buttons and lists.",
"depends": ["core>=1"] "category": "ui",
"depends": [],
"sources": [
"src/**/*.cs"
],
"ui": [
{
"id": "game_menu",
"kind": "panel",
"slot": "ModBeforeUIPanel",
"scene_path": "scenes/menu/game_menu.tscn",
"button": {
"icon": "settings",
"locale": "menu/settings",
"index": 50
},
"priority": 10
}
]
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

+90
View File
@@ -0,0 +1,90 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://mods/ui_base/src/Menu/GameMenu.cs" id="GameMenuScript"]
[node name="GameMenu" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 1
script = ExtResource("GameMenuScript")
[node name="Bg" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0, 0, 0, 0.9)
[node name="Panel" type="Panel" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -200.0
offset_top = -220.0
offset_right = 200.0
offset_bottom = 220.0
grow_horizontal = 2
grow_vertical = 2
[node name="Margin" type="MarginContainer" parent="Panel"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 28
theme_override_constants/margin_top = 20
theme_override_constants/margin_right = 28
theme_override_constants/margin_bottom = 20
[node name="VBox" type="VBoxContainer" parent="Panel/Margin"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="Title" type="Label" parent="Panel/Margin/VBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 24
theme_override_colors/font_color = Color(0.956863, 0.941176, 0.905882, 1)
text = "Меню"
horizontal_alignment = 1
uppercase = true
[node name="ReturnBtn" type="Button" parent="Panel/Margin/VBox"]
layout_mode = 2
text = "Вернуться"
theme_type_variation = "CthangoverPrimary"
[node name="SaveBtn" type="Button" parent="Panel/Margin/VBox"]
layout_mode = 2
text = "Сохранить"
[node name="LoadBtn" type="Button" parent="Panel/Margin/VBox"]
layout_mode = 2
text = "Загрузить"
[node name="SettingsBtn" type="Button" parent="Panel/Margin/VBox"]
layout_mode = 2
text = "Настройки"
[node name="Spacer" type="Control" parent="Panel/Margin/VBox"]
layout_mode = 2
custom_minimum_size = Vector2(0, 16)
[node name="MainMenuBtn" type="Button" parent="Panel/Margin/VBox"]
layout_mode = 2
text = "Главное меню"
[node name="ExitBtn" type="Button" parent="Panel/Margin/VBox"]
layout_mode = 2
text = "Выход"
theme_type_variation = "CthangoverDanger"
+18
View File
@@ -0,0 +1,18 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://mods/ui_base/src/Messages/UiMessage.cs" id="1_msg"]
[node name="Message" type="Control" node_paths=PackedStringArray("textField")]
layout_mode = 3
anchors_preset = 0
script = ExtResource("1_msg")
textField = NodePath("Label")
[node name="Label" type="Label" parent="."]
layout_mode = 0
offset_left = 0.0
offset_top = 0.0
offset_right = 0.0
offset_bottom = 0.0
theme_override_colors/font_color = Color(0.909804, 0.898039, 0.870588, 1)
text = ""
Binary file not shown.
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
using Godot;
namespace Cthangover.Mods.Interface.Base
{
/// <summary>
/// Forwarder pattern for Godot input: proxies _Input to a virtual OnInput
/// so derived classes can handle input events without needing to know about
/// Godot's _Input override mechanics.
/// </summary>
public partial class InputHandlerNode : Node
{
/// <summary>Engine entry point: forwards the input event to <see cref="OnInput"/>.</summary>
public override void _Input(InputEvent @event)
{
OnInput(@event);
}
/// <summary>Override in derived classes to receive Godot input events. Called by <see cref="_Input"/> which proxies from the engine.</summary>
protected virtual void OnInput(InputEvent @event) { }
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Cthangover.Mods.Interface.Base.Lists
{
/// <summary>
/// Serializable margin/padding struct for list layout calculations.
/// Marked [System.Serializable] for potential JSON serialization in mod configs.
/// </summary>
[System.Serializable]
public struct Border
{
/// <summary>Left margin or padding, in pixels.</summary>
public float left;
/// <summary>Right margin or padding, in pixels.</summary>
public float right;
/// <summary>Top margin or padding, in pixels.</summary>
public float top;
/// <summary>Bottom margin or padding, in pixels.</summary>
public float bottom;
}
}
+22
View File
@@ -0,0 +1,22 @@
using Godot;
namespace Cthangover.Mods.Interface.Base.Lists
{
/// <summary>
/// Extends IListWidget with grid/cell dimensions. CellSize drives item sizing;
/// ViewCellsCount determines how many cells fit in the visible area (used for
/// scroll/viewport calculations).
/// </summary>
public interface ICellListWidget<TItem, TModel> : IListWidget<TItem, TModel>
where TItem : class, IListItem<TModel>
{
/// <summary>Pixel dimensions of each cell in the grid. Drives item sizing; derived from content width and aspect ratio.</summary>
Vector2 CellSize { get; }
/// <summary>Number of cells visible in the viewport (columns × rows). Used for scroll range and visibility calculations.</summary>
Vector2I ViewCellsCount { get; }
}
}
+27
View File
@@ -0,0 +1,27 @@
using Godot;
namespace Cthangover.Mods.Interface.Base.Lists
{
/// <summary>
/// Contract for a single item in a list widget. Construct binds a model;
/// Destruct tears down (typically QueueFree). The Rect getter exposes the
/// item's Control for layout positioning by the parent list.
/// </summary>
public interface IListItem<TModel>
{
/// <summary>The Control that this item occupies. Used by the parent list to position the item in layout.</summary>
Control Rect { get; }
/// <summary>The bound data model. Set via <see cref="Construct"/>.</summary>
TModel Model { get; }
/// <summary>Binds the item to a data model. Called by the list widget after instantiation, before layout positioning.</summary>
void Construct(TModel model);
/// <summary>Cleans up the item — typically disconnects signals and calls QueueFree. Called when the list is destroyed or refreshed.</summary>
void Destruct();
}
}
+45
View File
@@ -0,0 +1,45 @@
using System.Collections.Generic;
using Godot;
using Cthangover.Core.UI;
namespace Cthangover.Mods.Interface.Base.Lists
{
/// <summary>
/// Generic list widget contract: binds a collection of models to Item widgets.
/// The two-step CreateModels() → CreateContent() pattern separates data sourcing
/// from instantiation, so subclasses can pull models from anywhere (runtime state,
/// config, save data) while the framework handles layout. ConstructUI/DestructUI
/// manage the full lifecycle of child items.
/// </summary>
public interface IListWidget<TItem, TModel> : IWidget
where TItem : class, IListItem<TModel>
{
/// <summary>Padding between items in pixels, stored as Vector2(X, Y). The X component is horizontal gap, Y is vertical gap.</summary>
Vector2 ItemsCellPadding { get; }
/// <summary>The container Control that holds all child item nodes. Items are added as children of this node.</summary>
Control Content { get; }
/// <summary>The collection of instantiated item widgets. Populated by <see cref="ConstructUI"/> and cleared by <see cref="DestructUI"/>.</summary>
List<TItem> Items { get; set; }
/// <summary>Implementations return the collection of data models to display. Called once per <see cref="ConstructUI"/> cycle.</summary>
ICollection<TModel> CreateModels();
/// <summary>Instantiates item widgets from the model collection. Separates model sourcing from instantiation so subclasses control both steps.</summary>
List<TItem> CreateContent(ICollection<TModel> models);
/// <summary>Calculates the total content size in pixels for <paramref name="count"/> items, used for scroll container sizing.</summary>
Vector2 GetContentSize(int count);
/// <summary>Builds the full item tree: creates models, instantiates items, and positions them via layout.</summary>
void ConstructUI();
/// <summary>Tears down all child items by calling each item's <see cref="IListItem{TModel}.Destruct"/> and clearing the Items list.</summary>
void DestructUI();
}
}
@@ -0,0 +1,93 @@
using Godot;
namespace Cthangover.Mods.Interface.Base.Lists.Impls
{
/// <summary>
/// Grid-based list layout with configurable column count, aspect-ratio cells,
/// and border/padding offsets. Cell dimensions are computed dynamically from
/// the content area width, so they scale with the parent. GetContentSize
/// calculates the total scrollable height from item count and cell geometry,
/// enabling vertical scrolling through a grid. PutToLayout positions items
/// by flat-index-to-grid conversion. The isScaledCanvas flag supports
/// resolution-independent layouts.
/// </summary>
public abstract partial class ColumnCellListWidget<TItem, TModel> : ListWidget<TItem, TModel>, ICellListWidget<TItem, TModel>
where TItem : Control, IListItem<TModel>
{
[Export] private int columnCount;
[Export] private float aspectHeight = 1f;
[Export] private float borderLeft;
[Export] private float borderRight;
[Export] private float borderTop;
[Export] private float borderBottom;
[Export] private bool isScaledCanvas = true;
private Border contentBorder => new() { left = borderLeft, right = borderRight, top = borderTop, bottom = borderBottom };
/// <summary>Dynamically computed cell dimensions in pixels. Width = (content width - borders - padding * (columns - 1)) / columns; height = width * aspectHeight.</summary>
public Vector2 CellSize => GetCellSize(Content.Size);
private Vector2 GetCellSize(Vector2 contentSize)
{
var size = contentSize;
var effectiveWidth = size.X - contentBorder.left - contentBorder.right;
var cellWidth = (effectiveWidth - (columnCount - 1) * ItemsCellPadding.X) / columnCount;
var cellHeight = cellWidth * aspectHeight;
return new Vector2(cellWidth, cellHeight);
}
/// <summary>Number of cells visible in the viewport: X = columnCount, Y = floor((content height - borders) / (cell height + padding)).</summary>
public Vector2I ViewCellsCount
{
get
{
var size = Content.Size;
return new Vector2I(
columnCount,
Mathf.FloorToInt((size.Y - contentBorder.top - contentBorder.bottom) / (CellSize.Y + ItemsCellPadding.Y))
);
}
}
/// <summary>Calculates total scrollable content height: top border + bottom border + rows * cellHeight + (rows - 1) * padding.</summary>
public override Vector2 GetContentSize(int count)
{
if (count == 0) return Vector2.Zero;
var rect = Content.Size;
var effectiveWidth = rect.X - contentBorder.left - contentBorder.right;
var cellWidth = (effectiveWidth - (columnCount - 1) * ItemsCellPadding.X) / columnCount;
var cellHeight = cellWidth * aspectHeight;
var rows = Mathf.CeilToInt((float)count / columnCount);
var contentHeight = contentBorder.top + contentBorder.bottom + rows * cellHeight + (rows - 1) * ItemsCellPadding.Y;
var contentWidth = rect.X;
return new Vector2(contentWidth, contentHeight);
}
protected override void PutToLayout(TItem item, int index, Control container, Vector2 contentSize)
{
var rectTransform = item;
var cellIndex = GetIndex(index);
var cellSize = GetCellSize(contentSize);
if (rectTransform.GetParent() != container)
rectTransform.Reparent(container, false);
rectTransform.Position = new Vector2(
contentBorder.left + cellIndex.X * (cellSize.X + ItemsCellPadding.X),
contentBorder.top + cellIndex.Y * (cellSize.Y + ItemsCellPadding.Y)
);
rectTransform.Size = cellSize;
}
private Vector2I GetIndex(int flatIndex)
{
var count = ViewCellsCount;
return new Vector2I(
flatIndex % count.X,
flatIndex / count.X
);
}
}
}
@@ -0,0 +1,33 @@
using Godot;
namespace Cthangover.Mods.Interface.Base.Lists.Impls
{
/// <summary>
/// Simple vertical stack layout. Items are placed top-to-bottom with padding
/// between them, offset so the list grows upward from the content center.
/// Content height is the sum of all item heights plus padding — no pooling
/// or virtualization, so it's intended for short lists.
/// </summary>
public abstract partial class VerticalListWidget<TItem, TModel> : ListWidget<TItem, TModel>
where TItem : Control, IListItem<TModel>
{
protected override void PutToLayout(TItem item, int index, Control container, Vector2 contentSize)
{
var posY = index * (PrefabRect.Size.Y + ItemsCellPadding.Y);
var itemRect = item;
var pos = new Vector2(itemRect.Position.X, -posY + contentSize.Y * 0.5f);
itemRect.Position = pos;
}
/// <summary>Content height = count * (prefab height + Y padding). Width is inherited from the Content container.</summary>
public override Vector2 GetContentSize(int count)
{
var boxSize = Content.Size;
boxSize.Y = count * (PrefabRect.Size.Y + ItemsCellPadding.Y);
return boxSize;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
using Godot;
namespace Cthangover.Mods.Interface.Base.Lists
{
/// <summary>
/// Base list item: stores the bound model and provides a Rect for layout.
/// The virtual Construct saves the model reference; Destruct is abstract so
/// subclasses must explicitly implement cleanup (signal disconnection, QueueFree).
/// </summary>
public abstract partial class ListItem<TModel> : Control, IListItem<TModel>
{
/// <summary>The data model bound to this list item. Set by <see cref="Construct"/>.</summary>
public TModel Model { get; private set; }
/// <summary>The Control that this item occupies for layout. Returns itself — ListItem is its own layout root.</summary>
public Control Rect => this;
/// <summary>Saves the model reference. Override in subclasses to apply model data to UI elements.</summary>
public virtual void Construct(TModel model)
{
Model = model;
}
/// <summary>Required cleanup method. Subclasses must implement — typically disconnects signals and calls QueueFree.</summary>
public abstract void Destruct();
/// <summary>Engine callback; forwards to <see cref="OnReady"/> so mod subclasses receive the ready signal without their own engine bindings.</summary>
public override void _Ready() => OnReady();
/// <summary>Forwarded from <see cref="_Ready"/>. Override for mod item initialization.</summary>
protected virtual void OnReady() { }
}
}
+134
View File
@@ -0,0 +1,134 @@
using System.Collections.Generic;
using Godot;
namespace Cthangover.Mods.Interface.Base.Lists
{
/// <summary>
/// Abstract model-driven list: instantiates items from a PackedScene prefab
/// for each model in CreateModels(), then delegates positioning to the abstract
/// PutToLayout (defined by layout subclasses like ColumnCellListWidget or
/// VerticalListWidget). Refresh() tears down and rebuilds the list in place
/// for reactive updates. The virtual Create/Init hooks let subclasses inject
/// extra setup between instantiation and model binding without overriding
/// CreateContent.
/// </summary>
public abstract partial class ListWidget<T, M> : Widget, IListWidget<T, M>
where T : Control, IListItem<M>
{
[Export]
private Control content;
[Export]
private PackedScene itemScene;
[Export] private Vector2 itemsCellPadding = new(0f, 1f);
/// <summary>Editor-configured padding between items; exposed for layout size math.</summary>
public Vector2 ItemsCellPadding => itemsCellPadding;
/// <summary>Prefab instance for layout measurement. Instantiated on each access — not cached. Returns null if itemScene is not set.</summary>
protected T Prefab => itemScene?.Instantiate<T>();
/// <summary>The Control rect of the item prefab, used for layout size calculations.</summary>
protected Control PrefabRect => Prefab;
/// <summary>The container Control that holds child items. Set via [Export] in the Godot editor.</summary>
public Control Content => content;
/// <summary>Calculates the total content size for a given item count. Subclass-specific — different layouts have different formulas.</summary>
public abstract Vector2 GetContentSize(int count);
/// <summary>The instantiated item collection. Set by <see cref="ConstructUI"/> and cleared by <see cref="DestructUI"/>.</summary>
public List<T> Items { get; set; }
/// <summary>Shows the widget and rebuilds the UI tree via <see cref="ConstructUI"/>.</summary>
public override void Show()
{
base.Show();
ConstructUI();
}
/// <summary>Tears down child items via <see cref="DestructUI"/> then hides via base <see cref="Widget.Hide"/>.</summary>
public override void Hide()
{
DestructUI();
base.Hide();
}
/// <summary>Destructs and reconstructs the item tree in place. Use for reactive updates when the model collection changes.</summary>
public void Refresh()
{
if (!Visible)
return;
DestructUI();
ConstructUI();
}
/// <summary>Iterates <paramref name="models"/> and calls <see cref="Create"/> for each, building the item widget collection.</summary>
public List<T> CreateContent(ICollection<M> models)
{
var items = new List<T>();
foreach (var model in models)
{
var item = Create(model);
item.Construct(model);
items.Add(item);
}
return items;
}
/// <summary>
/// Full build pipeline: gets models via <see cref="CreateModels"/>, instantiates items via <see cref="CreateContent"/>,
/// sets the content container size via <see cref="CallDeferred"/> to size, then positions each item via <see cref="PutToLayout"/>.
/// </summary>
public void ConstructUI()
{
var rect = Content;
var models = CreateModels();
var items = CreateContent(models);
var size = GetContentSize(items.Count);
rect.CallDeferred("set_size", size);
for (int i = 0; i < items.Count; i++)
PutToLayout(items[i], i, rect, size);
Items = items;
}
/// <summary>Subclasses implement positioning logic. Called for each item with its flat index, the content container, and total content size.</summary>
protected abstract void PutToLayout(T item, int index, Control container, Vector2 contentSize);
/// <summary>Subclasses implement model sourcing. Called by <see cref="ConstructUI"/> before item instantiation.</summary>
public abstract ICollection<M> CreateModels();
/// <summary>Hook for subclasses to inject setup between instantiation and model binding. Called by <see cref="Create"/>.</summary>
protected virtual void Init(T item, M model) { }
/// <summary>Instantiates the PackedScene prefab, adds it to <see cref="Content"/>, and calls <see cref="Init"/> for subclass setup.</summary>
protected virtual T Create(M model)
{
var instance = itemScene.Instantiate<T>();
Content.AddChild(instance);
Init(instance, model);
return instance;
}
/// <summary>Calls <see cref="IListItem{TModel}.Destruct"/> on each child item, clears the list, and sets Items to null.</summary>
public void DestructUI()
{
if (Items == null || Items.Count == 0)
return;
foreach (var item in Items)
item.Destruct();
Items.Clear();
Items = null;
}
}
}
+38
View File
@@ -0,0 +1,38 @@
using Godot;
using Cthangover.Core.UI;
namespace Cthangover.Mods.Interface.Base.Lists
{
/// <summary>
/// Lightweight alternative to Widget for mod-authored controls. Has a simpler
/// lifecycle — just Construct() on first access and Destruct() on tree exit —
/// without the visibility interception and Show/Hide pipeline that Widget
/// carries. Use when the heavy Widget state machine would be overkill or
/// when the control's visibility is managed by Godot's native system.
/// </summary>
public abstract partial class ModWidget : Control, IUiLazyConstruct
{
private bool _constructed;
/// <summary>Idempotent construction gate: calls <see cref="Construct"/> exactly once, on the first invocation. Thread-safe for repeated external calls.</summary>
public void EnsureConstructed()
{
if (_constructed)
return;
_constructed = true;
Construct();
}
/// <summary>Runs <see cref="Destruct"/> once when the constructed control leaves the tree.</summary>
public override void _ExitTree()
{
if (_constructed)
Destruct();
}
protected abstract void Construct();
protected virtual void Destruct() { }
}
}
+128
View File
@@ -0,0 +1,128 @@
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Mods.Interface.Base.Lists
{
/// <summary>
/// Shader-based scene transition: animates a "progress" uniform on the
/// background's ShaderMaterial via a Godot Tween, then reveals content after
/// a configurable delay. The shader is resolved through the mod system
/// (ModManager.ResolveShader), allowing mods to replace the transition effect.
/// Uses an easeInOut curve for the progress ramp. Material is duplicated on
/// first use so each instance gets its own shader parameters. On tree exit,
/// the duplicated material is disposed to avoid leaks.
/// </summary>
public abstract partial class TransitionWidget : Widget
{
private static readonly StringName ProgressParam = "progress";
[Export] private TextureRect imgBackground;
[Export] private Control contentGroup;
[Export] private float transitionDuration = 0.8f;
[Export] private float contentDelay = 0.05f;
private ShaderMaterial materialInstance;
private Tween transitionTween;
/// <summary>
/// Shows the widget base, then creates or reuses a shader material for the background and starts the transition tween.
/// Material is duplicated from <c>imgBackground.Material</c> on first call so each instance gets independent shader parameters.
/// </summary>
public override void Show()
{
base.Show();
if (materialInstance == null && imgBackground != null)
{
materialInstance = imgBackground.Material?.Duplicate() as ShaderMaterial;
if (materialInstance == null)
{
materialInstance = new ShaderMaterial();
var shader = Shaders.Resolve("scene_transition");
if (shader != null)
materialInstance.Shader = shader;
}
imgBackground.Material = materialInstance;
materialInstance.SetShaderParameter(ProgressParam, 0f);
}
if (transitionTween != null)
{
transitionTween.Kill();
transitionTween = null;
}
RunShowAnimation();
}
/// <summary>Kills the active transition tween if one is running, then calls base <see cref="Widget.Hide"/>.</summary>
public override void Hide()
{
if (transitionTween != null)
{
transitionTween.Kill();
transitionTween = null;
}
base.Hide();
}
private void RunShowAnimation()
{
if (contentGroup != null)
{
contentGroup.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
contentGroup.MouseFilter = MouseFilterEnum.Ignore;
}
transitionTween = CreateTween();
transitionTween.SetProcessMode(Tween.TweenProcessMode.Physics);
transitionTween.TweenMethod(Callable.From<float>(OnProgressUpdate), 0f, 1f, transitionDuration);
transitionTween.Finished += OnTweenFinished;
}
private void OnProgressUpdate(float t)
{
float curvedT = EaseInOut(t);
if (imgBackground?.Material is ShaderMaterial mat)
mat.SetShaderParameter(ProgressParam, curvedT);
}
private void OnTweenFinished()
{
if (imgBackground?.Material is ShaderMaterial mat)
mat.SetShaderParameter(ProgressParam, 1f);
var timer = GetTree().CreateTimer(contentDelay);
timer.Timeout += () =>
{
if (contentGroup != null)
{
contentGroup.Modulate = UiTokens.White;
contentGroup.MouseFilter = MouseFilterEnum.Stop;
}
};
transitionTween = null;
}
private static float EaseInOut(float t)
{
return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f;
}
/// <summary>Disposes the per-instance shader material created for the transition.</summary>
public override void _ExitTree()
{
if (materialInstance != null)
{
materialInstance.Dispose();
materialInstance = null;
}
base._ExitTree();
}
}
}
+194
View File
@@ -0,0 +1,194 @@
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Core.UI;
namespace Cthangover.Mods.Interface.Base.Lists
{
/// <summary>
/// Base widget with a lazy construct/destruct lifecycle around Show/Hide.
/// On first Show(), OnceConstruct() fires once via EnsureConstructed — subsequent
/// Show/Hide calls trigger ShowConstruct/HideDestruct instead, allowing repeated
/// state reset without full reconstruction. Overrides Godot's built-in Visible
/// property to intercept visibility changes and route them through the lifecycle.
/// Maintains an independent isVisible flag because Godot's visibility has quirks
/// (e.g. parent visibility propagation) that would break the state machine.
/// The #if TOOLS _ValidateProperty syncs the editor inspector with the override.
/// </summary>
public abstract partial class Widget : Control, IWidget
{
private bool isConstructed;
/// <summary>Syncs the internal visibility flag with the scene-file state and hides properly when the widget starts hidden.</summary>
public override void _Ready()
{
GameLogger.Log("WIDGET", $"_Ready: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}", LogLevel.Debug);
isVisible = base.Visible;
if (!isVisible)
Hide();
OnReady();
}
/// <summary>Tears down a constructed widget when it leaves the tree, so hidden-while-freed widgets still release their resources.</summary>
public override void _ExitTree()
{
if (isConstructed)
OnceDestruct();
OnExitTree();
}
/// <summary>Engine callback; forwards to <see cref="OnInput"/> so mod subclasses receive input without their own engine bindings.</summary>
public override void _Input(InputEvent @event) => OnInput(@event);
/// <summary>Forwarded from <see cref="_Ready"/>. Override for mod widget initialization.</summary>
protected virtual void OnReady() { }
/// <summary>Forwarded from <see cref="_ExitTree"/>. Override for mod widget teardown.</summary>
protected virtual void OnExitTree() { }
/// <summary>Forwarded from <see cref="_Input"/>. Override to handle input in mod widgets.</summary>
protected virtual void OnInput(InputEvent @event) { }
/// <summary>Toggles visibility: calls <see cref="Hide"/> if visible, <see cref="Show"/> otherwise.</summary>
public void Switch()
{
if (Visible)
Hide();
else
Show();
}
/// <summary>
/// Makes visible and runs lifecycle. On first call, <see cref="EnsureConstructed"/> fires <see cref="OnceConstruct"/>.
/// Subsequent calls trigger <see cref="ShowConstruct"/> without reconstruction. Sets both the internal isVisible flag
/// and Godot's native visibility, then explicitly shows the Body canvas item.
/// </summary>
public new virtual void Show()
{
GameLogger.Log("WIDGET", $"Show ENTER: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}", LogLevel.Debug);
var wasConstructed = isConstructed;
EnsureConstructed();
if (Visible)
{
if (!wasConstructed)
ShowConstruct();
GameLogger.Log("WIDGET", $"Show SKIP: already visible", LogLevel.Debug);
return;
}
ShowConstruct();
isVisible = true;
base.Visible = true;
var canvasBody = Body as CanvasItem;
if (canvasBody != null)
canvasBody.Visible = true;
(Body as Control)?.QueueRedraw();
GameLogger.Log("WIDGET", $"Show EXIT: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}", LogLevel.Debug);
}
/// <summary>Hides the widget, fires <see cref="HideDestruct"/> if constructed, sets internal and Godot visibility to false, and hides the Body canvas item.</summary>
public new virtual void Hide()
{
if (!Visible)
return;
if (isConstructed)
HideDestruct();
isVisible = false;
base.Visible = false;
var canvasBody = Body as CanvasItem;
if (canvasBody != null)
canvasBody.Visible = false;
}
/// <summary>Idempotent construction gate: calls <see cref="OnceConstruct"/> exactly once, on the first invocation.</summary>
public void EnsureConstructed()
{
if (!isConstructed)
{
isConstructed = true;
OnceConstruct();
}
}
/// <summary>Called exactly once on the first <see cref="Show"/>. Override to perform one-time initialization.</summary>
protected virtual void OnceConstruct()
{
}
/// <summary>Called once on tree exit if constructed. Override for one-time cleanup of resources allocated in <see cref="OnceConstruct"/>.</summary>
protected virtual void OnceDestruct()
{
}
[Export] private bool isVisible = true;
[Export] private Node body;
/// <summary>
/// Shadows Godot's built-in visibility. Setting to true calls <see cref="Show"/>, setting to false calls <see cref="Hide"/>.
/// Maintains an independent <c>isVisible</c> flag because Godot's native visibility propagates through the parent chain,
/// which would break the explicit lifecycle state machine.
/// </summary>
public new bool Visible
{
get => isVisible;
set
{
if (isVisible == value)
return;
if (value)
Show();
else
Hide();
}
}
/// <summary>The rendered content subtree. May be a child node separate from the Rect layout root.</summary>
public Node Body => body;
/// <summary>The Control used for layout. For Widget this is the node itself, enabling it to serve as both layout root and widget.</summary>
public Control Rect => this;
/// <summary>Called each time <see cref="Show"/> runs after initial construction. Override to refresh state (e.g. reload data, re-enable input).</summary>
protected virtual void ShowConstruct()
{
}
/// <summary>Called each time <see cref="Hide"/> runs when constructed. Override to clean up display-only state without destroying permanent resources.</summary>
protected virtual void HideDestruct()
{
}
#if TOOLS
/// <summary>Keeps the inspector in sync with the overridden visibility: touching <c>visible</c> or <c>isVisible</c> in the editor routes through the lifecycle.</summary>
public override void _ValidateProperty(Godot.Collections.Dictionary property)
{
base._ValidateProperty(property);
if (property.TryGetValue("visible", out var _))
{
isVisible = base.Visible;
if (isVisible)
Show();
else
Hide();
}
else if (property.TryGetValue("isVisible", out var _))
{
if (isVisible)
Show();
else
Hide();
}
}
#endif
}
}
+38
View File
@@ -0,0 +1,38 @@
using Godot;
namespace Cthangover.Mods.Interface.Base
{
/// <summary>
/// Lifecycle forwarder for Control scripts compiled from mods — the Control
/// counterpart of <see cref="ModNode"/>. Godot's C# source generator only
/// runs in the main game assembly, so engine callbacks overridden inside a
/// runtime-compiled mod are never registered with the engine. Mod controls
/// derive from this base (which has bindings) and override the OnXxx methods.
/// </summary>
public partial class ModControl : Control
{
/// <summary>Engine callback; forwards to <see cref="OnEnterTree"/>.</summary>
public override void _EnterTree() => OnEnterTree();
/// <summary>Engine callback; forwards to <see cref="OnExitTree"/>.</summary>
public override void _ExitTree() => OnExitTree();
/// <summary>Engine callback; forwards to <see cref="OnReady"/>.</summary>
public override void _Ready() => OnReady();
/// <summary>Engine callback; forwards to <see cref="OnProcess"/>.</summary>
public override void _Process(double delta) => OnProcess(delta);
/// <summary>Forwarded from <see cref="Node._EnterTree"/>. Override in mod controls.</summary>
protected virtual void OnEnterTree() { }
/// <summary>Forwarded from <see cref="Node._ExitTree"/>. Override in mod controls.</summary>
protected virtual void OnExitTree() { }
/// <summary>Forwarded from <see cref="Node._Ready"/>. Override in mod controls.</summary>
protected virtual void OnReady() { }
/// <summary>Forwarded from <see cref="Node._Process"/>. Override in mod controls.</summary>
protected virtual void OnProcess(double delta) { }
}
}
+44
View File
@@ -0,0 +1,44 @@
using Godot;
namespace Cthangover.Mods.Interface.Base
{
/// <summary>
/// Lifecycle forwarder for Node scripts compiled from mods. Godot's C#
/// source generator only runs in the main game assembly, so virtual methods
/// overridden inside a runtime-compiled mod (e.g. <c>_Ready</c>, <c>_Process</c>)
/// are never registered with the engine and therefore never called. A mod node
/// must instead derive from a core base class — the engine drives the base
/// (which has bindings) and the base forwards every lifecycle callback to
/// virtual <c>OnXxx</c> methods the mod can override.
/// <para/>
/// The same trick is used by <see cref="InputHandlerNode"/> for input events
/// and by <see cref="ModWidget"/> for Controls. Use this wrapper for plain
/// <see cref="Node"/> scripts that need enter/exit/ready/process hooks.
/// </summary>
public partial class ModNode : Node
{
/// <summary>Engine callback; forwards to <see cref="OnEnterTree"/>.</summary>
public override void _EnterTree() => OnEnterTree();
/// <summary>Engine callback; forwards to <see cref="OnExitTree"/>.</summary>
public override void _ExitTree() => OnExitTree();
/// <summary>Engine callback; forwards to <see cref="OnReady"/>.</summary>
public override void _Ready() => OnReady();
/// <summary>Engine callback; forwards to <see cref="OnProcess"/>.</summary>
public override void _Process(double delta) => OnProcess(delta);
/// <summary>Forwarded from <see cref="Node._EnterTree"/>. Override in mod nodes.</summary>
protected virtual void OnEnterTree() { }
/// <summary>Forwarded from <see cref="Node._ExitTree"/>. Override in mod nodes.</summary>
protected virtual void OnExitTree() { }
/// <summary>Forwarded from <see cref="Node._Ready"/>. Override in mod nodes.</summary>
protected virtual void OnReady() { }
/// <summary>Forwarded from <see cref="Node._Process"/>. Override in mod nodes.</summary>
protected virtual void OnProcess(double delta) { }
}
}
+137
View File
@@ -0,0 +1,137 @@
using Godot;
namespace Cthangover.Mods.Interface.Event
{
/// <summary>
/// Stateless input abstraction that maps mouse to a virtual single-touch source.
/// Tracks mouse-down state between frames to derive touch phases (Began/Moved/
/// Ended/Stationary), enabling touch-aware code to work with mouse-only devices.
/// Provides both UI-space (UITouchPosition) and screen-space (TouchPosition)
/// coordinate access. Update() must be called each frame to refresh the
/// wasMouseDownLastFrame flag.
/// </summary>
public static class DeviceInput
{
/// <summary>
/// Index of the active touch (always 0 for the mouse-backed single-touch
/// model). Exposed for API compatibility with potential multi-touch support.
/// </summary>
public static int CurrentTouchIndex { get; set; }
/// <summary>
/// Derives a <see cref="TouchPhase"/> from mouse button state across frames.
/// Compares the current left-button state against <c>wasMouseDownLastFrame</c>:
/// Began on fresh press, Moved on hold, Ended on release.
/// </summary>
public static TouchPhase GetTouchPhase
{
get
{
if (Input.IsMouseButtonPressed(MouseButton.Left))
{
if (Input.IsMouseButtonPressed(MouseButton.Left) && !wasMouseDownLastFrame)
return TouchPhase.Began;
if (Input.IsMouseButtonPressed(MouseButton.Left) && wasMouseDownLastFrame)
return TouchPhase.Moved;
}
else
{
if (wasMouseDownLastFrame)
return TouchPhase.Ended;
}
return TouchPhase.Stationary;
}
}
private static bool wasMouseDownLastFrame;
/// <summary>
/// Must be called each frame to snapshot the mouse button state. The saved
/// state drives <see cref="GetTouchPhase"/> for press/release transitions.
/// </summary>
public static void Update()
{
wasMouseDownLastFrame = Input.IsMouseButtonPressed(MouseButton.Left);
}
/// <summary>
/// Returns 1 when the left mouse button is pressed, 0 otherwise.
/// Maps the mouse to a single-touch model.
/// </summary>
public static int TouchCount
{
get
{
return Input.IsMouseButtonPressed(MouseButton.Left) ? 1 : 0;
}
}
/// <summary>
/// Mouse position in UI space (top-left origin, Control coordinate system).
/// Used for hit-testing against Control nodes in the UI layer.
/// </summary>
public static Vector2 UITouchPosition
{
get
{
var mp = GetViewport()?.GetMousePosition() ?? Vector2.Zero;
// In Godot, UI coordinates match mouse coordinates (top-left origin in Control nodes)
return mp;
}
}
/// <summary>
/// Mouse position in screen space. Used for raycasting against 2D/3D
/// world objects.
/// </summary>
public static Vector2 TouchPosition
{
get
{
return GetViewport()?.GetMousePosition() ?? Vector2.Zero;
}
}
/// <summary>
/// Converts <see cref="TouchPosition"/> to a 3D vector (z=0) for ray-origin
/// calculations in the 3D input pipeline.
/// </summary>
public static Vector3 TouchPosition3D
{
get
{
var pos = TouchPosition;
return new Vector3(pos.X, pos.Y, 0);
}
}
/// <summary>
/// Last-frame mouse velocity from Godot's <c>Input.GetLastMouseVelocity()</c>.
/// Used by drag handlers to compute movement deltas.
/// </summary>
public static Vector2 TouchDelta
{
get
{
return Input.GetLastMouseVelocity();
}
}
/// <summary>
/// Returns the current touch position. The <paramref name="index"/> parameter
/// is ignored in the single-touch model — always returns <see cref="TouchPosition"/>.
/// Exists for multi-touch API compatibility.
/// </summary>
/// <param name="index">Touch index (ignored in current implementation).</param>
public static Vector2 GetTouchPosition(int index)
{
return TouchPosition;
}
private static Viewport GetViewport()
{
var tree = Godot.Engine.GetMainLoop() as SceneTree;
return tree?.Root?.GetViewport();
}
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Mods.Interface.Event
{
/// <summary>
/// iOS-style touch lifecycle phases. Used by DeviceInput to derive phase
/// from mouse button state between frames.
/// </summary>
public enum TouchPhase
{
Began,
Moved,
Stationary,
Ended,
Canceled
}
}
+14
View File
@@ -0,0 +1,14 @@
using Godot;
using Cthangover.Core.UI.Menu;
namespace Cthangover.Mods.Interface.Menu
{
/// <summary>
/// Full-screen black overlay Control. Acts as a blocking backdrop — placed
/// behind menus to darken the game view and absorb input. No custom logic
/// beyond what Control provides; scene configuration handles sizing/color.
/// </summary>
public partial class EmptyBlackBehaviour : Control
{
}
}
+146
View File
@@ -0,0 +1,146 @@
using System.Collections.Generic;
using Cthangover.Core.Scenes;
using Godot;
using Cthangover.Core.UI.Menu;
namespace Cthangover.Mods.Interface.Menu
{
/// <summary>
/// In-game pause menu: save/load/settings/quit. The settings and save/load
/// panels are not referenced by type: they are registered in the
/// <see cref="MenuRegistry"/> (by the <c>menu_base</c> mod or any other mod
/// that wants to provide them) and instantiated here by id, so the panel
/// implementations can live entirely inside mods. Both panels stay hidden
/// children created on _Ready to avoid loading delays when the player opens
/// the menu. RefreshSaveButton dynamically disables save if the current
/// scene doesn't allow saves (checked via
/// SceneManager.IsSaveAllowedForCurrentScene). Button text is translated
/// with a refresh-on-language-change subscription.
/// </summary>
public partial class GameMenu : Control
{
private ISettingsPanel _settingsPanel;
private ISaveLoadPanel _saveLoadPanel;
private Button _saveBtn;
private Label _title;
private readonly Dictionary<Button, string> _btnKeys = new();
/// <summary>Wires menu buttons, instantiates the hidden settings and save/load panels from the <see cref="MenuRegistry"/>, and subscribes to language changes.</summary>
public override void _Ready()
{
_saveBtn = GetNode<Button>("Panel/Margin/VBox/SaveBtn");
_saveBtn.Pressed += OnSaveClick;
GetNode<Button>("Panel/Margin/VBox/ReturnBtn").Pressed += OnReturnClick;
GetNode<Button>("Panel/Margin/VBox/LoadBtn").Pressed += OnLoadClick;
GetNode<Button>("Panel/Margin/VBox/SettingsBtn").Pressed += OnSettingsClick;
GetNode<Button>("Panel/Margin/VBox/MainMenuBtn").Pressed += OnMainMenuClick;
GetNode<Button>("Panel/Margin/VBox/ExitBtn").Pressed += OnExitClick;
_btnKeys[GetNode<Button>("Panel/Margin/VBox/ReturnBtn")] = "menu/resume";
_btnKeys[_saveBtn] = "menu/save";
_btnKeys[GetNode<Button>("Panel/Margin/VBox/LoadBtn")] = "menu/load";
_btnKeys[GetNode<Button>("Panel/Margin/VBox/SettingsBtn")] = "menu/settings";
_btnKeys[GetNode<Button>("Panel/Margin/VBox/MainMenuBtn")] = "menu/main_menu";
_btnKeys[GetNode<Button>("Panel/Margin/VBox/ExitBtn")] = "menu/exit";
_title = GetNode<Label>("Panel/Margin/VBox/Title");
AttachPanel(MenuRegistry.OpenPanel("settings"));
AttachPanel(MenuRegistry.OpenPanel("save_load"));
Visible = false;
RefreshButtons();
VisibilityChanged += OnVisibilityChanged;
}
/// <summary>
/// Adopts a registry-built panel as a hidden child and forwards its
/// typed behaviour to the menu handlers. Panels registered without a
/// matching interface still work as plain overlays.
/// </summary>
private void AttachPanel(Node panel)
{
if (panel == null)
return;
if (panel is Control control)
control.Visible = false;
AddChild(panel);
switch (panel)
{
case ISettingsPanel settings:
settings.LanguageChanged += RefreshButtons;
_settingsPanel = settings;
break;
case ISaveLoadPanel saveLoad:
_saveLoadPanel = saveLoad;
break;
}
}
/// <summary>
/// Refreshes the save button whenever the menu becomes visible. The panel is
/// opened through <see cref="UI.UiRegistry.Toggle"/>, so this signal replaces
/// the old toolbar callback that used to do the refresh before showing.
/// </summary>
private void OnVisibilityChanged()
{
if (Visible)
RefreshSaveButton();
}
private void RefreshButtons()
{
foreach (var kv in _btnKeys)
kv.Key.Text = TranslationServer.Translate(kv.Value);
_title.Text = TranslationServer.Translate("menu/title");
}
private void OnReturnClick()
{
Visible = false;
}
private void OnSaveClick()
{
_saveLoadPanel?.OpenForSave();
}
private bool IsSaveAllowed()
{
return SceneManager.IsSaveAllowedForCurrentScene();
}
/// <summary>
/// Enables or disables the save button based on whether the current scene permits saving.
/// Called by <see cref="ToolBox.OnSettingsClick"/> before showing the game menu, so the
/// button state reflects the current scene's save policy.
/// </summary>
public void RefreshSaveButton()
{
_saveBtn.Disabled = !IsSaveAllowed();
}
private void OnLoadClick()
{
_saveLoadPanel?.OpenForLoad();
}
private void OnSettingsClick()
{
_settingsPanel?.Open();
}
private void OnMainMenuClick()
{
var sceneService = SceneContextNode.FindNode<GodotSceneService>("GodotSceneService");
sceneService?.SwitchToMenu();
}
private void OnExitClick()
{
GetTree().Quit();
}
}
}
+165
View File
@@ -0,0 +1,165 @@
using System.Collections.Generic;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Core.UI.Menu;
using Cthangover.Mods.Interface.Base.Lists;
namespace Cthangover.Mods.Interface.Menu
{
/// <summary>
/// Materializes the buttons collected in the <see cref="MenuRegistry"/> into
/// the containers of a host menu. The host supplies a section → container
/// map (""game"", ""mods"", ""exit"", ""tools"", …); every registry button is
/// added to the container of its section, in registry order, and carries
/// its translation key in node metadata (<see cref="LocaleMetaKey"/>) so the
/// host can retranslate it on language change.
/// <para/>
/// A button is bound either to a scene path (pressed → the scene opens as a
/// fullscreen child of the menu root and owns its lifetime) or to a handler
/// delegate (pressed → the delegate runs). Unknown sections fall back to the
/// ""mods"" container; sections without a container are skipped loudly.
/// </summary>
public static class MenuBuilder
{
/// <summary>Node metadata key holding the button's translation key.</summary>
public const string LocaleMetaKey = "main_menu_locale";
/// <summary>
/// Creates one button per registry entry inside the section containers.
/// The host menu is passed in because pressed handlers instantiate mod
/// scenes as its children.
/// </summary>
public static void Build(Control menuRoot, IReadOnlyDictionary<string, Container> sections)
{
if (menuRoot == null || sections == null)
return;
foreach (var entry in AppServices.Current.Menus.Buttons)
{
if (entry.Visible != null && !SafeVisible(entry))
continue;
if (!sections.TryGetValue(entry.Section ?? MenuButtonEntry.SectionMods, out var container)
&& !sections.TryGetValue(MenuButtonEntry.SectionMods, out container))
{
GameLogger.Log("MENU_BUILDER", $"No container for section '{entry.Section}' (button '{entry.Id}')");
continue;
}
if (!GodotObject.IsInstanceValid(container))
{
GameLogger.Log("MENU_BUILDER", $"Container for section '{entry.Section}' is gone (button '{entry.Id}')");
continue;
}
container.AddChild(CreateButton(menuRoot, entry));
}
}
/// <summary>
/// Collects buttons created by <see cref="Build"/> (or by tools) that
/// carry a locale meta key into the host's translation map, so the host
/// keeps them localized across language switches.
/// </summary>
private static bool SafeVisible(MenuButtonEntry entry)
{
try
{
return entry.Visible();
}
catch (System.Exception ex)
{
GameLogger.Log("MENU_BUILDER", $"Visibility check of button '{entry.Id}' failed: {ex.Message}", LogLevel.Warning);
return true;
}
}
public static void CollectLocaleKeys(Container container, Dictionary<Button, string> keys)
{
if (container == null || keys == null)
return;
foreach (var child in container.GetChildren())
{
if (child is Button button && button.HasMeta(LocaleMetaKey))
keys[button] = button.GetMeta(LocaleMetaKey).AsString();
}
}
/// <summary>Largest icon width of a menu button, so an entry icon never makes its row taller than the text rows.</summary>
private const int MenuIconSize = 22;
private static Button CreateButton(Control menuRoot, MenuButtonEntry entry)
{
var title = TranslationServer.Translate(entry.Locale);
var button = new Button
{
Name = entry.Id,
Text = title,
TooltipText = title,
};
if (!string.IsNullOrEmpty(entry.Icon))
{
var icon = UIIconFactory.Instance.Get(entry.Icon);
if (icon != null)
{
// Entry icons are toolbar icons (60 px); in a menu row they stay text-sized.
button.Icon = icon;
button.AddThemeConstantOverride("icon_max_width", MenuIconSize);
}
}
if (!string.IsNullOrEmpty(entry.Variation))
button.ThemeTypeVariation = "Cthangover" + char.ToUpper(entry.Variation[0]) + entry.Variation[1..];
button.SetMeta(LocaleMetaKey, entry.Locale);
button.Pressed += () => Invoke(menuRoot, entry);
return button;
}
private static void Invoke(Control menuRoot, MenuButtonEntry entry)
{
if (entry.Handler != null)
{
GameLogger.Log("MENU_BUILDER", $"Pressed '{entry.Id}' (handler)");
entry.Handler(menuRoot);
return;
}
OpenScene(menuRoot, entry.ScenePath, entry.Id);
}
/// <summary>
/// Opens a mod scene as a fullscreen child of the menu root: the scene
/// owns its lifetime and closes itself, or is replaced by another menu
/// scene opening over it.
/// </summary>
/// <summary>Opens a mod scene as a full-rect overlay of the menu and returns it (null when it fails to build).</summary>
public static Node OpenScene(Control menuRoot, string scenePath, string label = null)
{
var node = new ModScenePrefab(scenePath).Instantiate();
if (node == null)
{
GameLogger.Log("MENU_BUILDER", $"Open '{label ?? scenePath}': scene '{scenePath}' failed to build", LogLevel.Error);
return null;
}
menuRoot.AddChild(node);
CthangoverTheme.Apply(node);
if (node is Control control)
control.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
if (node is ModWidget modWidget)
modWidget.EnsureConstructed();
GameLogger.Log("MENU_BUILDER", $"Opened '{label ?? scenePath}' → {scenePath}");
return node;
}
}
}
+20
View File
@@ -0,0 +1,20 @@
using Cthangover.Core.Factories.Impls;
using Godot;
using Cthangover.Core.UI.Menu;
namespace Cthangover.Mods.Interface.Menu
{
/// <summary>
/// Auto-loading save icon: on _Ready, resolves the "save" texture through
/// UIIconFactory if no Texture is already assigned. Allows both editor-set
/// textures and mod-overridden icons without scene modification.
/// </summary>
public partial class SaveIcon : TextureRect
{
public override void _Ready()
{
if (Texture == null)
Texture = UIIconFactory.Instance.Get("save");
}
}
}
+121
View File
@@ -0,0 +1,121 @@
using Cthangover.Core.Events;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Core.UI.Messages;
namespace Cthangover.Mods.Interface.Messages
{
/// <summary>
/// Static factory for floating UI messages (like "Quest updated" or item
/// pickup notifications). Manages a cycling position index of 0-5 so
/// consecutive messages stack vertically without overlapping. Each message
/// is instantiated from message.tscn and parented to the Background node
/// (found via scene tree search). Messages auto-fade and self-destruct
/// via UiMessage's OnUpdate.
/// </summary>
public static class MessagesHelper
{
private const int maxMessageIndex = 5;
private static int lastMessageIndex = 0;
private static ModScenePrefab prefab;
private static int GetNextIndex(int index)
{
if (index++ >= maxMessageIndex)
index = 0;
return index;
}
private static float GetPositionByLastIndex(int index)
{
return (index - 1) * 60;
}
/// <summary>
/// Resets the cycling position index to 0. Call when UI state resets
/// to prevent messages from accumulating at high Y positions.
/// </summary>
public static void ClearLastIndex()
{
lastMessageIndex = 0;
}
/// <summary>
/// Shows a message in the default warning colour at the next vertical slot.
/// </summary>
/// <param name="text">The message text to display.</param>
/// <param name="speed">Fade speed multiplier (higher = faster fade, default 1).</param>
public static void AddMessage(string text, float speed = 1f)
{
AddMessage(text, UiTokens.Warning, speed);
}
/// <summary>
/// Shows a message with a custom color at the next vertical slot.
/// Messages auto-fade and self-destruct after ~3 seconds of display plus
/// a fade duration determined by <paramref name="speed"/>.
/// </summary>
/// <param name="text">The message text to display.</param>
/// <param name="color">The text color.</param>
/// <param name="speed">Fade speed multiplier (higher = faster fade, default 1).</param>
public static void AddMessage(string text, Color color, float speed = 1f)
{
var nextIndex = GetNextIndex(lastMessageIndex);
AddMessage(GetPositionByLastIndex(nextIndex), text, color, speed);
lastMessageIndex = nextIndex;
}
/// <summary>
/// Shows a message in the default warning colour at a specific vertical
/// position, bypassing the cycling slot system.
/// </summary>
/// <param name="position">Y-offset in pixels for this message.</param>
/// <param name="text">The message text to display.</param>
/// <param name="speed">Fade speed multiplier (default 1).</param>
public static void AddMessage(float position, string text, float speed = 1f)
{
AddMessage(position, text, UiTokens.Warning, speed);
}
/// <summary>
/// Instantiates a <see cref="UiMessage"/> from <c>message.tscn</c>, parents
/// it to the Background node, and configures it for display and auto-fade.
/// </summary>
/// <param name="position">Y-offset in pixels for this message.</param>
/// <param name="text">The message text to display.</param>
/// <param name="color">The text color.</param>
/// <param name="speed">Fade speed multiplier (default 1).</param>
public static void AddMessage(float position, string text, Color color, float speed = 1f)
{
if (prefab == null)
prefab = new ModScenePrefab("scenes/ui/message.tscn");
var instance = prefab.Instantiate<UiMessage>();
var background = SceneContextNode.FindNode<Control>("Background");
if (background == null)
{
background = SceneContextNode.Instance?.GetTree()?.GetFirstNodeInGroup("Background") as Control;
if (background == null)
{
GameLogger.Log("UI", "Background node not found for messages", LogLevel.Error);
return;
}
}
background.AddChild(instance);
instance.Setup(position, text, speed, color);
ModEventBus.Publish(new NotificationRequestedEvent
{
Text = text,
Category = "message",
});
}
}
}
+206
View File
@@ -0,0 +1,206 @@
using System.Collections.Generic;
using Cthangover.Core.Events;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Event;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Core.UI.Messages;
namespace Cthangover.Mods.Interface.Messages
{
/// <summary>
/// Manages the visual notification list in the top-left corner of the scene.
/// Displays up to <c>MaxVisibleSlots</c> notification banners simultaneously.
/// Pending items wait in <see cref="NotificationBuffer"/> and are pulled
/// into visible slots as they become free. Each visible notification has a
/// 10-second lifetime; when it expires, it fades out and the remaining
/// notifications shift upward with a smooth animation.
/// </summary>
public partial class NotificationPanel : Control, IOnUpdateEvent
{
private const int MaxVisibleSlots = 4;
private const float SlotHeight = 40f;
private const float SlotSpacing = 6f;
private const float DisplayDuration = 10f;
private const float FadeInDuration = 0.3f;
private const float FadeOutDuration = 0.5f;
private readonly List<NotificationSlot> _slots = new();
private readonly List<int> _dyingIndices = new();
/// <summary>Update priority <c>1</c> — runs after the highest-priority listeners.</summary>
public int Priority => 1;
/// <summary>Subscribes to notification requests; text is queued in <see cref="NotificationBuffer"/>, not shown immediately.</summary>
public override void _EnterTree()
{
ModEventBus.Subscribe<NotificationRequestedEvent>(OnNotificationRequested);
}
/// <summary>Registers with the event controller for per-frame slot updates.</summary>
public override void _Ready()
{
MouseFilter = MouseFilterEnum.Ignore;
if (SceneContextNode.Instance != null)
{
var eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
eventController?.AddUpdateEventListener(this);
}
}
/// <summary>Unsubscribes from notification requests and detaches from the update controller.</summary>
public override void _ExitTree()
{
ModEventBus.Unsubscribe<NotificationRequestedEvent>(OnNotificationRequested);
if (SceneContextNode.Instance != null)
{
var eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
eventController?.RemoveUpdateEventListener(this);
}
}
/// <summary>
/// Per-frame update: expires old slots, collects dead ones,
/// pulls new notifications from the buffer, and repositions
/// all visible slots with a smooth slide animation.
/// </summary>
public void OnUpdate()
{
var currentTime = (float)Godot.Time.GetTicksMsec() / 1000f;
for (int i = _slots.Count - 1; i >= 0; i--)
{
var slot = _slots[i];
var elapsed = currentTime - slot.DisplayedAt;
if (elapsed >= DisplayDuration && !slot.IsFadingOut)
{
FadeOutSlot(i);
}
}
_dyingIndices.Clear();
for (int i = 0; i < _slots.Count; i++)
{
if (_slots[i].IsDead)
_dyingIndices.Add(i);
}
for (int j = _dyingIndices.Count - 1; j >= 0; j--)
{
var idx = _dyingIndices[j];
_slots[idx].Container.QueueFree();
_slots.RemoveAt(idx);
}
while (_slots.Count < MaxVisibleSlots && NotificationBuffer.TryDequeue(out var text))
{
CreateSlot(text, currentTime);
}
RepositionSlots();
}
private void CreateSlot(string text, float time)
{
var container = new Panel
{
SizeFlagsHorizontal = SizeFlags.ShrinkBegin,
MouseFilter = MouseFilterEnum.Ignore,
};
var style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.ToastBg, UiTokens.Line);
style.ContentMarginLeft = UiTokens.Space4;
style.ContentMarginRight = UiTokens.Space4;
style.ContentMarginTop = UiTokens.Space2;
style.ContentMarginBottom = UiTokens.Space2;
container.AddThemeStyleboxOverride("panel", style);
var label = new Label
{
Text = text,
Modulate = UiTokens.Alpha(Colors.White, 0f),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
};
label.AddThemeFontSizeOverride("font_size", UiTokens.FontLead);
label.AddThemeColorOverride("font_color", UiTokens.Ink);
container.AddChild(label);
AddChild(container);
var slot = new NotificationSlot
{
Container = container,
Text = text,
DisplayedAt = time,
};
_slots.Add(slot);
var tween = CreateTween();
tween.TweenProperty(label, "modulate", Colors.White, FadeInDuration);
}
private void FadeOutSlot(int index)
{
var slot = _slots[index];
slot.IsFadingOut = true;
var label = slot.Container.GetChildOrNull<Label>(0);
if (label != null)
{
var tween = CreateTween();
tween.TweenProperty(label, "modulate", UiTokens.Alpha(Colors.White, 0f), FadeOutDuration);
tween.TweenCallback(Callable.From(() =>
{
slot.IsDead = true;
}));
}
else
{
slot.IsDead = true;
}
}
private void RepositionSlots()
{
for (int i = 0; i < _slots.Count; i++)
{
var targetY = i * (SlotHeight + SlotSpacing);
var container = _slots[i].Container;
if (Mathf.Abs(container.Position.Y - targetY) > 0.5f)
{
var tween = CreateTween();
tween.TweenProperty(container, "position",
new Vector2(container.Position.X, targetY), 0.2f)
.SetTrans(Tween.TransitionType.Quad)
.SetEase(Tween.EaseType.Out);
}
}
}
private void OnNotificationRequested(NotificationRequestedEvent evt)
{
NotificationBuffer.Add(evt.Text);
}
private sealed class NotificationSlot
{
/// <summary>Visual container of the banner.</summary>
public Panel Container;
/// <summary>Message text kept for the banner label and re-use.</summary>
public string Text;
/// <summary>Timestamp of the moment the banner became visible; drives expiry.</summary>
public float DisplayedAt;
/// <summary>True while the exit tween is playing.</summary>
public bool IsFadingOut;
/// <summary>Set when the fade-out finished and the slot can be recycled.</summary>
public bool IsDead;
}
}
}
+104
View File
@@ -0,0 +1,104 @@
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Event;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Core.UI.Messages;
namespace Cthangover.Mods.Interface.Messages
{
/// <summary>
/// Floating text message with auto-fade. Appears at a vertical position,
/// stays visible for 3 seconds, then fades over 2/Speed seconds. Self-
/// destructs (QueueFree) when fully transparent. Registers with
/// SceneEventController for per-frame updates; unregisters on tree exit
/// to prevent leaks. Speed affects only the fade duration, not the display
/// duration, so faster speed = shorter fade = message disappears more quickly.
/// </summary>
public partial class UiMessage : Control, IOnUpdateEvent
{
[Export] private Label textField;
/// <summary>Tint of the message text; defaults to the warning colour.</summary>
[Export] public Color Color { get; set; } = UiTokens.Warning;
/// <summary>Fade speed multiplier — 1 is normal; higher values shorten the fade-out.</summary>
[Export] public float Speed { get; set; } = 1f;
private float timestamp;
/// <summary>
/// The text content displayed by this message. Reads/writes the child
/// <c>textField</c> label directly.
/// </summary>
public string Text
{
get => textField.Text;
set => textField.Text = value;
}
/// <summary>
/// Initializes the message: positions it, sets text/speed/color, records
/// the start timestamp, and registers with the <see cref="SceneEventController"/>
/// for per-frame updates. Called by <see cref="MessagesHelper"/>.
/// </summary>
/// <param name="pos">Vertical Y-offset in pixels.</param>
/// <param name="text">The message text.</param>
/// <param name="speed">Fade speed multiplier.</param>
/// <param name="color">The text color.</param>
public void Setup(float pos, string text, float speed, Color color)
{
OffsetLeft = 50;
OffsetRight = -10;
Size = new Vector2(Size.X, 50);
Position = new Vector2(0, pos + 200);
Text = text;
Speed = speed;
Color = color;
timestamp = (float)Godot.Time.GetTicksMsec() / 1000f;
if (SceneContextNode.Instance != null)
{
var eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
eventController?.AddUpdateEventListener(this);
}
}
/// <summary>Detaches from the event controller so a freed message stops receiving updates.</summary>
public override void _ExitTree()
{
if (SceneContextNode.Instance != null)
{
var eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
eventController?.RemoveUpdateEventListener(this);
}
}
/// <summary>
/// Priority <c>1</c> — lightweight enough for many concurrent messages.
/// </summary>
public int Priority => 1;
/// <summary>
/// Per-frame update: displays for 3 seconds, then fades out over
/// <c>2 / <see cref="Speed"/></c> seconds. Self-destructs via
/// <c>QueueFree</c> when fully transparent.
/// </summary>
public void OnUpdate()
{
var currentTime = (float)Godot.Time.GetTicksMsec() / 1000f;
var elapsed = currentTime - timestamp;
if (elapsed < 3f)
return;
var fadeDuration = 2f / Speed;
var fadeElapsed = elapsed - 3f;
var progress = Mathf.Clamp(fadeElapsed / fadeDuration, 0f, 1f);
textField.SelfModulate = UiTokens.Alpha(Colors.White, 1f - progress);
if (progress >= 1f)
QueueFree();
}
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Mods.Interface.Theming
{
/// <summary>
/// Shared source palettes for editable fields and read-only previews.
/// Each call returns a new highlighter: Godot binds it to one TextEdit and
/// keeps a per-document cache, so sharing a resource between editors would
/// mix their text. Scenario commands are supplied by the caller to keep
/// the UI toolkit independent of the dialog engine.
/// </summary>
public static class SourceSyntax
{
/// <summary>Colours JSON strings, numbers, punctuation and literal values, including unfinished input.</summary>
public static CodeHighlighter CreateJsonHighlighter()
{
var highlighter = CreateBase();
highlighter.AddColorRegion("\"", "\"", UiTokens.AccentBright);
return highlighter;
}
/// <summary>Colours Godot text scene/resource sections, strings, constructors, numbers and comments.</summary>
public static CodeHighlighter CreateTscnHighlighter()
{
var highlighter = CreateBase();
highlighter.FunctionColor = UiTokens.TypeTarget;
highlighter.AddColorRegion("\"", "\"", UiTokens.AccentBright);
highlighter.AddColorRegion(";", "", UiTokens.Faint, true);
foreach (var section in new[] { "gd_scene", "gd_resource", "ext_resource", "sub_resource", "resource", "node", "connection", "editable" })
highlighter.AddKeywordColor(section, UiTokens.TypeTarget);
return highlighter;
}
/// <summary>Colours scenario commands, named arguments, quoted dialogue, labels and comments.</summary>
public static CodeHighlighter CreateScenarioHighlighter(IEnumerable<string> commands)
{
var highlighter = CreateBase();
if (commands != null)
{
foreach (var command in commands)
highlighter.AddKeywordColor(command, UiTokens.Info);
}
highlighter.AddKeywordColor("select", UiTokens.Info);
highlighter.AddKeywordColor("option", UiTokens.Info);
foreach (var argument in new[] {
"key", "first", "second", "hide_color", "wait", "speed", "hidden",
"sprites", "loop", "target", "quest_id", "show", "hide", "duration"
})
highlighter.AddKeywordColor(argument, UiTokens.Accent);
highlighter.AddColorRegion("\"", "\"", UiTokens.AccentBright);
highlighter.AddColorRegion("#", "", UiTokens.Faint, true);
highlighter.AddColorRegion(":", " ", UiTokens.TypeTarget, true);
return highlighter;
}
private static CodeHighlighter CreateBase()
{
var highlighter = new CodeHighlighter
{
NumberColor = UiTokens.Info,
SymbolColor = UiTokens.Muted,
FunctionColor = UiTokens.Body,
MemberVariableColor = UiTokens.Body,
};
foreach (var literal in new[] { "true", "false", "null" })
highlighter.AddKeywordColor(literal, UiTokens.TypeTarget);
return highlighter;
}
}
}
+292
View File
@@ -0,0 +1,292 @@
using Cthangover.Core.Mods.Resolvers;
using Godot;
using Cthangover.Core.UI.Theming;
namespace Cthangover.Mods.Interface.Theming
{
/// <summary>
/// Visual variants of <see cref="UiKit.Button"/>, each mapped to a
/// <see cref="CthangoverTheme"/> type variation. Secondary is the unstyled default.
/// </summary>
public enum ButtonKind
{
Secondary,
Primary,
Danger,
Icon,
Tab,
}
/// <summary>
/// Runtime factories for composed UI pieces that are not standard Godot
/// controls (badges, chips, stat chips, empty states) plus token-aware
/// shortcuts for common controls. Everything built here uses
/// <see cref="UiTokens"/> and inherits the shared
/// <see cref="CthangoverTheme"/> once it is applied to a root.
/// </summary>
public static class UiKit
{
/// <summary>Rounded frame from tokens; pass transparent border to omit it.</summary>
public static StyleBoxFlat Frame(int radius, Color background, Color? border = null, int borderWidth = 1)
{
var style = new StyleBoxFlat
{
BgColor = background,
CornerRadiusTopLeft = radius,
CornerRadiusTopRight = radius,
CornerRadiusBottomLeft = radius,
CornerRadiusBottomRight = radius,
};
if (border.HasValue)
{
style.BorderColor = border.Value;
style.BorderWidthLeft = borderWidth;
style.BorderWidthRight = borderWidth;
style.BorderWidthTop = borderWidth;
style.BorderWidthBottom = borderWidth;
}
return style;
}
/// <summary>Creates a non-interactive label with the given size and optional colour.</summary>
public static Label Label(string text, int fontSize = UiTokens.FontBodyLarge, Color? color = null)
{
var label = new Label
{
Text = text ?? string.Empty,
MouseFilter = Control.MouseFilterEnum.Ignore,
};
label.AddThemeFontSizeOverride("font_size", fontSize);
if (color.HasValue)
label.AddThemeColorOverride("font_color", color.Value);
return label;
}
/// <summary>Creates an uppercase muted caption used as a section heading.</summary>
public static Label SectionTitle(string text, int fontSize = UiTokens.FontBodyLarge)
{
var label = Label(text.ToUpperInvariant(), fontSize, UiTokens.Muted);
label.AddThemeConstantOverride("outline_size", 0);
return label;
}
/// <summary>Creates a centered, wrapped faint label for "nothing here yet" placeholders.</summary>
public static Label EmptyState(string text, int fontSize = UiTokens.FontBodyLarge)
{
var label = Label(text, fontSize, UiTokens.Faint);
label.HorizontalAlignment = HorizontalAlignment.Center;
label.VerticalAlignment = VerticalAlignment.Center;
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
label.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
label.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
return label;
}
/// <summary>Creates a faint small label for helper text.</summary>
public static Label Hint(string text, int fontSize = UiTokens.FontBodySmall)
=> Label(text, fontSize, UiTokens.Faint);
/// <summary>Small bordered caption chip: fill 9%, border 50%, text in the accent colour.</summary>
public static Control Badge(string text, Color accent, int fontSize = UiTokens.FontTiny)
{
var style = Frame(UiTokens.RadiusXs, UiTokens.BadgeFill(accent), UiTokens.BadgeLine(accent));
style.ContentMarginLeft = 7;
style.ContentMarginRight = 7;
style.ContentMarginTop = 2;
style.ContentMarginBottom = 2;
var badge = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
badge.AddThemeStyleboxOverride("panel", style);
var label = Label(text, fontSize, accent);
label.Uppercase = true;
badge.AddChild(label);
return badge;
}
/// <summary>Property chip ("Attack 1.0") with a muted key and gold value.</summary>
public static Control PropertyChip(string key, string value)
{
var style = Frame(UiTokens.RadiusXs, UiTokens.AccentFaint, UiTokens.AccentLineWeak);
style.ContentMarginLeft = 6;
style.ContentMarginRight = 6;
style.ContentMarginTop = 1;
style.ContentMarginBottom = 1;
var chip = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
chip.AddThemeStyleboxOverride("panel", style);
var row = Row(UiTokens.Space1);
row.AddChild(Label(key, UiTokens.FontTiny, UiTokens.ChipKey));
row.AddChild(Label(value, UiTokens.FontTiny, UiTokens.ChipValue));
chip.AddChild(row);
return chip;
}
/// <summary>Character stat chip ("HP 100/100") on its own inset surface.</summary>
public static Control StatChip(string key, string value, Color? valueColor = null)
{
var style = Frame(UiTokens.RadiusXs, UiTokens.StatBg, UiTokens.StatLine);
style.ContentMarginLeft = 7;
style.ContentMarginRight = 7;
style.ContentMarginTop = 2;
style.ContentMarginBottom = 2;
var chip = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
chip.AddThemeStyleboxOverride("panel", style);
var row = Row(UiTokens.Space1);
var keyLabel = Label(key.ToUpperInvariant(), UiTokens.FontTiny, UiTokens.Muted);
keyLabel.AddThemeConstantOverride("outline_size", 0);
row.AddChild(keyLabel);
row.AddChild(Label(value, UiTokens.FontSmall, valueColor ?? UiTokens.Ink));
chip.AddChild(row);
return chip;
}
/// <summary>
/// Creates a styled button. Secondary uses the theme default; every other
/// kind opts the button into the matching <see cref="CthangoverTheme"/> variation.
/// </summary>
public static Button Button(string text, ButtonKind kind = ButtonKind.Secondary)
{
var button = new Button { Text = text ?? string.Empty };
switch (kind)
{
case ButtonKind.Primary:
CthangoverTheme.ApplyPrimary(button);
break;
case ButtonKind.Danger:
CthangoverTheme.ApplyDanger(button);
break;
case ButtonKind.Icon:
CthangoverTheme.ApplyIcon(button);
break;
case ButtonKind.Tab:
CthangoverTheme.ApplyTab(button);
break;
}
return button;
}
/// <summary>Creates an icon-glyph button (icon variation) with an optional tooltip.</summary>
public static Button IconButton(string glyph, string tooltip = null)
{
var button = Button(glyph, ButtonKind.Icon);
if (!string.IsNullOrEmpty(tooltip))
button.TooltipText = tooltip;
return button;
}
/// <summary>Creates a horizontal container with token spacing and input passthrough.</summary>
public static HBoxContainer Row(int separation = UiTokens.Space4)
{
var row = new HBoxContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
row.AddThemeConstantOverride("separation", separation);
return row;
}
/// <summary>Creates a vertical container with token spacing and input passthrough.</summary>
public static VBoxContainer Column(int separation = UiTokens.Space4)
{
var column = new VBoxContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
column.AddThemeConstantOverride("separation", separation);
return column;
}
/// <summary>Invisible expanding filler between controls.</summary>
public static Control Spacer()
{
return new Control
{
MouseFilter = Control.MouseFilterEnum.Ignore,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
}
/// <summary>Creates a square, aspect-preserving, non-interactive texture view of the given pixel size.</summary>
public static TextureRect Icon(Texture2D texture, int size)
{
return new TextureRect
{
Texture = texture,
CustomMinimumSize = new Vector2(size, size),
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered,
MouseFilter = Control.MouseFilterEnum.Ignore,
};
}
/// <summary>
/// Styles a layered ("pie") health bar out of two stacked ProgressBars
/// occupying the same rect: <paramref name="shieldBar"/> is the bottom
/// layer and owns the pill track plus the blue defence fill
/// (<see cref="UiTokens.HpShield"/>); <paramref name="healthBar"/> sits
/// on top and paints only its own fill, so its background becomes
/// transparent and the blue defence segment stays visible past the
/// health edge. Battle widgets refresh the values through
/// <see cref="UpdateShieldedHealthBar"/>; the health layer keeps its
/// threshold colours (green → warning → red).
/// </summary>
public static void StyleShieldedHealthBar(ProgressBar shieldBar, ProgressBar healthBar)
{
if (shieldBar != null)
{
shieldBar.AddThemeStyleboxOverride("background", Frame(UiTokens.RadiusSm, UiTokens.LineDisabled));
shieldBar.AddThemeStyleboxOverride("fill", Frame(UiTokens.RadiusSm, UiTokens.HpShield));
}
if (healthBar != null)
healthBar.AddThemeStyleboxOverride("background", new StyleBoxEmpty());
}
/// <summary>
/// Refreshes the defence segment of a layered bar built by
/// <see cref="StyleShieldedHealthBar"/>. The blue fill spans from the
/// health edge over <paramref name="defence"/> points scaled against
/// max health, clamped to the track, so incoming damage visually eats
/// the blue layer before the health layer — the same order the battle
/// executors subtract in. A defence of zero collapses the segment back
/// onto the health edge, leaving the plain health bar.
/// </summary>
public static void UpdateShieldedHealthBar(ProgressBar shieldBar, float healthPercent, float defence, float maxHealth)
{
if (shieldBar == null)
return;
var shieldPercent = maxHealth > 0 ? defence / maxHealth : 0f;
shieldBar.Value = Mathf.Clamp(healthPercent + shieldPercent, 0f, 1f) * 100f;
}
/// <summary>
/// Dresses the full-screen backdrop of a gameplay panel: the panel keeps
/// its own <see cref="ColorRect"/> but it becomes an opaque dark surface,
/// and the shared <c>panels_background</c> texture is stretched over the
/// whole rect. Panels sit above live scenes, and the previous 0.9-alpha
/// backdrop let scene buttons bleed through, which made the panel close
/// buttons look noisy; an opaque backing removes them. The colour stays
/// underneath as a fallback because the texture ships in the optional
/// "ui_base" mod and may not resolve, in which case the panel still
/// gets a solid surface.
/// </summary>
public static void ApplyPanelBackdrop(ColorRect backdrop)
{
if (backdrop == null)
return;
backdrop.Color = UiTokens.Page;
var texture = new TextureRect
{
Texture = Textures.Resolve("panels_background"),
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
StretchMode = TextureRect.StretchModeEnum.Scale,
MouseFilter = Control.MouseFilterEnum.Ignore,
};
texture.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
backdrop.AddChild(texture);
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using Cthangover.Core.Factories.Impls;
using Godot;
using Cthangover.Core.UI.Tool;
namespace Cthangover.Mods.Interface.Tool
{
/// <summary>
/// Mod-aware TextureButton: resolves IconId through UIIconFactory at _Ready
/// and applies the result to TextureNormal. Allows tool buttons to reference
/// icons by logical ID that mods can override.
/// </summary>
public partial class ButtonModIconLoader : TextureButton
{
/// <summary>
/// Logical icon ID resolved through <see cref="UIIconFactory"/>. Set in the editor
/// or via mod to reference a mod-overridable icon texture.
/// </summary>
[Export] public string IconId { get; set; }
public override void _Ready()
{
if (string.IsNullOrEmpty(IconId))
return;
var texture = UIIconFactory.Instance.Get(IconId);
if (texture != null)
TextureNormal = texture;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using Cthangover.Core.Factories.Impls;
using Godot;
using Cthangover.Core.UI.Tool;
namespace Cthangover.Mods.Interface.Tool
{
/// <summary>
/// Mod-aware TextureRect: resolves IconId through UIIconFactory at _Ready
/// and applies the result. Used as a base class for any TextureRect that
/// needs mod-overridable textures without scene modification.
/// </summary>
public partial class TextureRectModIconLoader : TextureRect
{
/// <summary>
/// Logical icon ID resolved through <see cref="UIIconFactory"/>. Set in the editor
/// or via mod to reference a mod-overridable icon texture.
/// </summary>
[Export] public string IconId { get; set; }
public override void _Ready()
{
if (string.IsNullOrEmpty(IconId))
return;
var texture = UIIconFactory.Instance.Get(IconId);
if (texture != null)
Texture = texture;
}
}
}
+149
View File
@@ -0,0 +1,149 @@
using System.Collections.Generic;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Core.UI.Tool;
namespace Cthangover.Mods.Interface.Tool
{
/// <summary>
/// Main toolbar/HUD container (the <c>TopView</c> shell node). It no longer
/// knows any concrete panel: buttons for mod-provided panels are handed to it
/// by <see cref="UiBuilder"/> from <see cref="UiRegistry.PanelEntries"/>, and
/// pressing one calls <see cref="UiRegistry.Toggle"/>. Tool buttons
/// (<see cref="IToolBoxButton"/>/<see cref="ToolFactory"/>) remain a separate,
/// self-registering mechanism and are added alongside the panel buttons.
/// </summary>
public partial class ToolBox : Control, IToolbarHost, ISaveIconHost
{
/// <summary>Side of a toolbar button and its icon: the icons are 64 px pixel-art sprites (scaled x4), so a
/// 64 px slot shows every sprite pixel; at 60 px the nearest filter dropped rows of them.</summary>
private const int SlotSize = 64;
private HBoxContainer _buttons;
private TextureRect _saveIcon;
/// <summary>Wires the save icon visibility and appends the dev-tool buttons registered in <see cref="ToolBoxButtonFactory"/>.</summary>
public override void _Ready()
{
_buttons = GetNodeOrNull<HBoxContainer>("Buttons");
_saveIcon = GetNodeOrNull<TextureRect>("CenterTools/Save");
UpdateSaveIconVisibility();
AddToolButtons(_buttons);
}
/// <summary>
/// Materializes a toolbar button for every entry. Called once per shell by
/// <see cref="UiBuilder"/>; entries arrive pre-sorted by the registry
/// (button index, then mod load order, then id).
/// </summary>
public void ApplyPanelButtons(IReadOnlyList<UiEntry> entries)
{
if (_buttons == null || entries == null)
return;
foreach (var entry in entries)
_buttons.AddChild(CreatePanelButton(entry));
}
private static BaseButton CreatePanelButton(UiEntry entry)
{
var title = TranslationServer.Translate(entry.Button.Locale);
var icon = UIIconFactory.Instance.Get(entry.Button.Icon);
var button = CreateToolbarButton(title, icon);
var id = entry.Id;
button.Pressed += () => AppServices.Current.Ui.ToggleEntry(id);
return button;
}
private static BaseButton CreateToolbarButton(string title, Texture2D icon)
{
var button = new Button
{
CustomMinimumSize = new Vector2(SlotSize, SlotSize),
SizeFlagsVertical = SizeFlags.ShrinkCenter,
TooltipText = title,
};
if (icon != null)
{
var iconRect = UiKit.Icon(icon, SlotSize);
button.AddChild(iconRect);
iconRect.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
}
else
{
button.Text = title;
}
button.AddThemeStyleboxOverride("normal", UiKit.Frame(UiTokens.RadiusSm, UiTokens.Surface0, UiTokens.Line));
button.AddThemeStyleboxOverride("hover", UiKit.Frame(UiTokens.RadiusSm, UiTokens.Surface1, UiTokens.AccentLine));
button.AddThemeStyleboxOverride("pressed", UiKit.Frame(UiTokens.RadiusSm, UiTokens.Surface1, UiTokens.AccentLineStrong));
button.AddThemeStyleboxOverride("disabled", UiKit.Frame(UiTokens.RadiusSm, UiTokens.Surface0, UiTokens.LineDisabled));
button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty());
return button;
}
/// <summary>
/// Adds one "tools" toolbar button whose popup lists every visible
/// <see cref="IToolBoxButton"/>. Tools used to get a text button each,
/// which did not fit the square icon row and overlapped its icons; a
/// single icon keeps the HUD row uniform no matter how many tools are
/// installed. Nothing is added when no tool button is visible.
/// </summary>
private void AddToolButtons(HBoxContainer container)
{
if (container == null)
return;
var visible = new List<IToolBoxButton>(ToolBoxButtonFactory.Instance.GetVisible());
if (visible.Count == 0)
return;
visible.Sort((a, b) => string.Compare(
TranslationServer.Translate(a.LocaleKey), TranslationServer.Translate(b.LocaleKey),
System.StringComparison.CurrentCulture));
var btn = CreateToolbarButton(TranslationServer.Translate("tools/menu_title"), UIIconFactory.Instance.Get("tools"));
var menu = new PopupMenu { Theme = ToolTheme.Get() };
foreach (var buttonDef in visible)
menu.AddItem(TranslationServer.Translate(buttonDef.LocaleKey));
menu.IndexPressed += index => OpenTool(visible[(int)index].ToolId);
btn.AddChild(menu);
btn.Pressed += () =>
{
var rect = btn.GetGlobalRect();
menu.Position = new Vector2I((int)rect.Position.X, (int)rect.End.Y + UiTokens.Space1);
menu.Popup();
};
container.AddChild(btn);
}
private static void OpenTool(string toolId)
{
var tool = ToolFactory.Instance.Get(toolId);
if (tool != null)
ToolWindow.ShowWindow(tool.CreateWindow());
}
/// <summary>
/// Shows or hides the save icon based on <see cref="SceneManager.IsSaveAllowedForCurrentScene"/>.
/// Call after scene transitions to reflect the new scene's save policy.
/// </summary>
public void UpdateSaveIconVisibility()
{
if (_saveIcon == null)
return;
_saveIcon.Visible = SceneManager.IsSaveAllowedForCurrentScene();
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using Godot;
using Cthangover.Core.UI.Tool;
namespace Cthangover.Mods.Interface.Tool
{
/// <summary>
/// Shared factory for the launcher buttons that tools contribute to the main
/// menu's tool block. Keeps the standard presentation (localized caption),
/// the translation-key metadata used for live language switching, and the
/// window-opening action in one place, so <see cref="IToolProvider.AddMenuButton"/>
/// implementations stay focused while remaining free to append extra controls.
/// </summary>
public static class ToolMenuButton
{
/// <summary>
/// Creates a button labelled with the tool's <see cref="IToolProvider.LocaleKey"/>,
/// wires it to open a fresh window via <see cref="ToolWindow.ShowWindow"/>, and adds
/// it to <paramref name="container"/>. Returns the created button so a tool can
/// decorate it further (icon, tooltip, extra handlers).
/// </summary>
public static Button Attach(Container container, IToolProvider tool)
{
var button = new Button
{
Text = TranslationServer.Translate(tool.LocaleKey),
};
button.SetMeta(Menu.MenuBuilder.LocaleMetaKey, tool.LocaleKey);
button.Pressed += () => ToolWindow.ShowWindow(tool.CreateWindow());
container.AddChild(button);
return button;
}
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace Cthangover.Mods.Interface.Tool
{
/// <summary>
/// Tone of a message in the <see cref="ToolWindow"/> status bar, mapped to the
/// style guide's inline messages: neutral info, ok (<c>.ct-message--ok</c>)
/// and error (<c>.ct-message--err</c>).
/// </summary>
public enum ToolStatusKind
{
Info,
Ok,
Error,
}
}
+53
View File
@@ -0,0 +1,53 @@
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Mods.Interface.Tool
{
/// <summary>
/// Dense variant of <see cref="CthangoverTheme"/> for developer tool windows.
/// The game theme is tuned for player panels (buttons padded 18×11, inputs
/// 12×9), which makes an editor toolbar of ten controls overflow even a
/// full-HD window. This theme keeps every colour, radius and type variation
/// of the shared theme — the style guide still holds — and only tightens
/// the content margins of the boxes, so tools fit more controls without a
/// second palette. Built once by copying the shared theme and replacing its
/// flat boxes with tightened duplicates; the shared theme is never mutated.
/// </summary>
public static class ToolTheme
{
private const int MaxVerticalMargin = 5;
private const int MaxHorizontalMargin = 10;
private static Theme instance;
/// <summary>Shared dense theme instance, built lazily from the game theme.</summary>
public static Theme Get() => instance ??= Build();
private static Theme Build()
{
var theme = (Theme)CthangoverTheme.Get().Duplicate();
theme.DefaultFontSize = UiTokens.FontBody;
foreach (var type in theme.GetTypeList())
{
foreach (var name in theme.GetStyleboxList(type))
{
if (theme.GetStylebox(name, type) is not StyleBoxFlat flat)
continue;
var dense = (StyleBoxFlat)flat.Duplicate();
dense.ContentMarginTop = Mathf.Min(dense.ContentMarginTop, MaxVerticalMargin);
dense.ContentMarginBottom = Mathf.Min(dense.ContentMarginBottom, MaxVerticalMargin);
dense.ContentMarginLeft = Mathf.Min(dense.ContentMarginLeft, MaxHorizontalMargin);
dense.ContentMarginRight = Mathf.Min(dense.ContentMarginRight, MaxHorizontalMargin);
theme.SetStylebox(name, type, dense);
}
if (theme.HasFontSize("font_size", type) && theme.GetFontSize("font_size", type) > UiTokens.FontBody)
theme.SetFontSize("font_size", type, UiTokens.FontBody);
}
return theme;
}
}
}
+831
View File
@@ -0,0 +1,831 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Core.UI.Tool;
namespace Cthangover.Mods.Interface.Tool
{
/// <summary>
/// Base for developer tool windows: borderless chrome that follows the UI
/// style guide (title left in the header, ✕ on the right, drag-to-move,
/// double-click/□ to maximize, edge resize), a status bar, dirty-state
/// tracking and keyboard shortcuts.
/// <para/>
/// Subclasses build their UI in the constructor and add it as direct
/// children; <see cref="SetupChrome"/> (run by <see cref="Open{T}"/> and
/// <see cref="ShowWindow"/>) later moves those children into the content
/// area between the header and the status bar. Status and hint text may be
/// set before the chrome exists — it is buffered and shown once built.
/// <para/>
/// Tool windows use <see cref="ToolTheme"/>, a denser copy of the game
/// theme: same tokens, smaller paddings, so editor toolbars fit.
/// <para/>
/// Shortcuts: Ctrl+S calls <see cref="OnSaveShortcut"/> even while a text
/// field has focus (checked in <c>_Input</c>, before the GUI); every other
/// key reaches <see cref="HandleKey"/> only if no focused control consumed
/// it (<c>_UnhandledInput</c>), so Delete in a text field never deletes a
/// selected object.
/// </summary>
public abstract partial class ToolWindow : Window
{
/// <summary>
/// Whether the window has unsaved changes. When <c>true</c>, the title bar shows a "*" prefix
/// and closing asks for confirmation first.
/// </summary>
protected bool _dirty;
private readonly string _baseTitle;
private bool _chromeBuilt;
private bool _isMaximized;
private Vector2I _restorePosition;
private Vector2I _restoreSize;
private Label _titleLabel;
private Button _maximizeBtn;
private HBoxContainer _titleBar;
private Label _statusLabel;
private Label _hintLabel;
private string _pendingStatus = string.Empty;
private ToolStatusKind _pendingStatusKind;
private string _pendingHint = string.Empty;
private bool _draggingTitle;
private Vector2 _titleDragMouseStart;
private Vector2I _titleDragWindowStart;
private enum ResizeEdge { None, Left, Right, Top, Bottom, TopLeft, TopRight, BottomLeft, BottomRight }
private bool _resizing;
private ResizeEdge _resizeEdge;
private Vector2 _resizeMouseStart;
private Vector2I _resizePosStart;
private Vector2I _resizeSizeStart;
private const int EDGE_ZONE = 6;
private const int CORNER_ZONE = 14;
private const int TITLE_BAR_HEIGHT = 40;
private const int TITLE_BUTTON_SIZE = 38;
private const float DefaultSizeRatio = 0.9f;
/// <summary>
/// Height of the in-game HUD icon row. HUD canvas layers draw above
/// embedded windows, so a window placed under it would have its title
/// bar covered; windows open below this line.
/// </summary>
private const int HudTopInset = 84;
private static Font _monospaceFont;
private static Vector2 GetGameViewportSize()
{
var tree = Godot.Engine.GetMainLoop() as SceneTree;
var vpSize = tree?.Root?.GetVisibleRect().Size;
if (vpSize.HasValue && vpSize.Value.X > 0 && vpSize.Value.Y > 0)
return vpSize.Value;
return DisplayServer.WindowGetSize();
}
// ── public API ──────────────────────────────────────────────
/// <summary>
/// Creates a new instance of <typeparamref name="T"/> and displays it as a centered popup
/// on the scene tree root. Returns the window so the caller can inspect it after closing.
/// </summary>
public static T Open<T>() where T : ToolWindow, new()
{
var window = new T();
ShowWindow(window);
return window;
}
/// <summary>
/// Displays an already-constructed <see cref="Window"/> as a centered popup on the scene
/// tree root. Used when the window was created via <see cref="IToolProvider.CreateWindow"/>.
/// </summary>
public static Window ShowWindow(Window window)
{
if (window is ToolWindow tw)
tw.SetupChrome();
else
CthangoverTheme.Apply(window);
var tree = Godot.Engine.GetMainLoop() as SceneTree;
tree?.Root.AddChild(window);
window.PopupCentered();
if (window is ToolWindow && window.Position.Y < HudTopInset)
window.Position = new Vector2I(window.Position.X, HudTopInset);
GameLogger.Log("TOOL_WINDOW", $"ShowWindow '{window.GetType().Name}' size=({window.Size.X},{window.Size.Y})");
return window;
}
// ── constructor & lifecycle ─────────────────────────────────
protected ToolWindow(string titleKey)
{
_baseTitle = TranslationServer.Translate(titleKey);
Title = _baseTitle;
Unresizable = false;
CloseRequested += RequestClose;
}
/// <summary>Runs the <see cref="Cleanup"/> teardown hook when the window leaves the tree.</summary>
public override void _ExitTree()
{
Cleanup();
}
/// <summary>Ctrl+S → <see cref="OnSaveShortcut"/>, checked before the GUI so it works inside text fields.</summary>
public override void _Input(InputEvent @event)
{
if (@event is not InputEventKey key || !key.Pressed || key.Echo)
return;
if (key.CtrlPressed && key.Keycode == Key.S)
{
OnSaveShortcut();
SetInputAsHandled();
}
}
/// <summary>Forwards keys no control consumed to <see cref="HandleKey"/>.</summary>
public override void _UnhandledInput(InputEvent @event)
{
if (@event is InputEventKey key && key.Pressed && HandleKey(key))
SetInputAsHandled();
}
/// <summary>
/// Override to release resources when the window is closed. Called from <c>_ExitTree</c>.
/// </summary>
protected virtual void Cleanup() { }
/// <summary>Ctrl+S handler; tools with a save action override it.</summary>
protected virtual void OnSaveShortcut() { }
/// <summary>
/// Tool-specific shortcut for a key no focused control consumed.
/// Return <c>true</c> when handled.
/// </summary>
protected virtual bool HandleKey(InputEventKey key) => false;
// ── chrome setup ────────────────────────────────────────────
/// <summary>
/// Wraps all currently-added children inside the chrome layout (title bar,
/// content area, status bar, resize zones). Called automatically by
/// <see cref="Open{T}"/> and <see cref="ShowWindow"/>; safe to call twice.
/// </summary>
internal void SetupChrome()
{
if (_chromeBuilt)
return;
_chromeBuilt = true;
Theme = ToolTheme.Get();
// Read the requested size first: assigning MinSize clamps Size at once.
var requested = Size;
var viewport = GetGameViewportSize();
Borderless = true;
MinSize = new Vector2I(800, 400);
MaxSize = (Vector2I)viewport;
if (requested.X < MinSize.X || requested.Y < MinSize.Y)
requested = (Vector2I)(viewport * DefaultSizeRatio);
var maxHeight = MaxSize.Y - HudTopInset - UiTokens.Space3;
Size = new Vector2I(Mathf.Min(requested.X, MaxSize.X), Mathf.Min(requested.Y, maxHeight));
var existingChildren = new List<Node>();
foreach (Node child in GetChildren())
existingChildren.Add(child);
foreach (var child in existingChildren)
RemoveChild(child);
var rootPanel = BuildRootPanel();
var rootVBox = new VBoxContainer();
rootVBox.AddThemeConstantOverride("separation", 0);
rootPanel.AddChild(rootVBox);
rootVBox.AddChild(BuildTitleBar());
var contentMargin = new MarginContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
contentMargin.AddThemeConstantOverride("margin_left", UiTokens.Space3);
contentMargin.AddThemeConstantOverride("margin_right", UiTokens.Space3);
contentMargin.AddThemeConstantOverride("margin_top", UiTokens.Space3);
contentMargin.AddThemeConstantOverride("margin_bottom", UiTokens.Space1);
rootVBox.AddChild(contentMargin);
var contentArea = new Control
{
Name = "ContentArea",
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
contentMargin.AddChild(contentArea);
foreach (var child in existingChildren)
contentArea.AddChild(child);
rootVBox.AddChild(BuildStatusBar());
AddResizeZones();
}
// ── dirty tracking ──────────────────────────────────────────
/// <summary>
/// Marks the window as having unsaved changes. Prepends "* " to the title bar text.
/// </summary>
protected void SetDirty()
{
if (_dirty)
return;
_dirty = true;
UpdateTitleText();
}
/// <summary>
/// Clears the dirty flag and restores the original title bar text.
/// </summary>
protected void MarkClean()
{
_dirty = false;
UpdateTitleText();
}
private void UpdateTitleText()
{
var text = _dirty ? "* " + _baseTitle : _baseTitle;
Title = text;
if (_titleLabel != null)
_titleLabel.Text = text;
}
// ── status bar ──────────────────────────────────────────────
/// <summary>Shows a message in the status bar, coloured by <paramref name="kind"/>.</summary>
protected void SetStatus(string text, ToolStatusKind kind = ToolStatusKind.Info)
{
_pendingStatus = text ?? string.Empty;
_pendingStatusKind = kind;
if (_statusLabel == null)
return;
_statusLabel.Text = _pendingStatus;
_statusLabel.AddThemeColorOverride("font_color", kind switch
{
ToolStatusKind.Ok => UiTokens.Ok,
ToolStatusKind.Error => UiTokens.DangerText,
_ => UiTokens.Body,
});
}
/// <summary>Sets the faint right-aligned hint of the status bar (usually the shortcut list).</summary>
protected void SetHint(string text)
{
_pendingHint = text ?? string.Empty;
if (_hintLabel != null)
_hintLabel.Text = _pendingHint;
}
// ── unsaved-changes dialogs ─────────────────────────────────
/// <summary>Asks "close without saving?" and runs <paramref name="onDiscard"/> on confirmation.</summary>
protected void ShowUnsavedDialog(Action onDiscard)
{
ShowConfirm("tools/unsaved_title", "tools/unsaved_text", onDiscard);
}
/// <summary>
/// Runs <paramref name="action"/> right away when there are no unsaved
/// changes, otherwise only after the author agrees to discard them; on
/// refusal <paramref name="onCancel"/> runs (e.g. to roll a file picker
/// back). Use before replacing the edited document.
/// </summary>
protected void RunDiscardingChanges(Action action, Action onCancel = null)
{
if (!_dirty)
{
action();
return;
}
ShowConfirm("tools/unsaved_title", "tools/unsaved_discard_text", () =>
{
MarkClean();
action();
}, onCancel);
}
/// <summary>Shows a themed OK/Cancel dialog; <paramref name="onConfirm"/> runs on OK, <paramref name="onCancel"/> otherwise.</summary>
protected void ShowConfirm(string titleKey, string textKey, Action onConfirm, Action onCancel = null)
{
var dialog = new ConfirmationDialog
{
Title = TranslationServer.Translate(titleKey),
DialogText = TranslationServer.Translate(textKey),
Theme = ToolTheme.Get()
};
dialog.Confirmed += () => { dialog.QueueFree(); onConfirm(); };
dialog.Canceled += () => { dialog.QueueFree(); onCancel?.Invoke(); };
AddChild(dialog);
dialog.PopupCentered();
}
/// <summary>Closes the window, asking first when there are unsaved changes.</summary>
protected void RequestClose()
{
if (_dirty)
ShowUnsavedDialog(QueueFree);
else
QueueFree();
}
// ── layout helpers ──────────────────────────────────────────
protected static VBoxContainer CreateFillContainer()
{
var c = new VBoxContainer();
c.AnchorRight = 1f;
c.AnchorBottom = 1f;
c.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
c.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
c.AddThemeConstantOverride("separation", UiTokens.Space3);
return c;
}
protected static HBoxContainer CreateToolbar()
{
var t = new HBoxContainer();
t.AddThemeConstantOverride("separation", UiTokens.Space2);
return t;
}
protected static PanelContainer CreateSidebar(VBoxContainer content, int width = 280)
{
var panel = new PanelContainer();
panel.CustomMinimumSize = new Vector2(width, 100);
panel.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
panel.AddThemeStyleboxOverride("panel", SidebarBox());
var scroll = new ScrollContainer();
scroll.HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled;
scroll.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
scroll.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
panel.AddChild(scroll);
content.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
content.AddThemeConstantOverride("separation", UiTokens.Space2);
scroll.AddChild(content);
return panel;
}
/// <summary>Inset sidebar surface: <c>--ct-surface-2</c>, line border, card radius.</summary>
protected static StyleBoxFlat SidebarBox()
{
var style = new StyleBoxFlat
{
BgColor = UiTokens.Surface2,
BorderColor = UiTokens.Line,
BorderWidthLeft = 1,
BorderWidthRight = 1,
BorderWidthTop = 1,
BorderWidthBottom = 1,
CornerRadiusTopLeft = UiTokens.RadiusMd,
CornerRadiusTopRight = UiTokens.RadiusMd,
CornerRadiusBottomLeft = UiTokens.RadiusMd,
CornerRadiusBottomRight = UiTokens.RadiusMd,
ContentMarginLeft = UiTokens.Space4,
ContentMarginRight = UiTokens.Space4,
ContentMarginTop = UiTokens.Space4,
ContentMarginBottom = UiTokens.Space4
};
return style;
}
/// <summary>
/// Monospace font for code, IDs and numbers (style guide: <c>--ct-mono</c>).
/// The game ships no mono font, so the OS one is requested by name with the
/// guide's fallbacks; Godot falls back to its default font if none exist.
/// </summary>
public static Font GetMonospaceFont()
{
return _monospaceFont ??= new SystemFont
{
FontNames = new[] { "Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Liberation Mono", "Courier New", "monospace" }
};
}
// ── chrome building ─────────────────────────────────────────
private PanelContainer BuildRootPanel()
{
var panel = new PanelContainer
{
Name = "RootPanel",
AnchorRight = 1f,
AnchorBottom = 1f
};
var style = new StyleBoxFlat
{
BgColor = UiTokens.Surface0,
BorderWidthLeft = 1,
BorderWidthRight = 1,
BorderWidthBottom = 1,
BorderWidthTop = 1,
BorderColor = UiTokens.Line,
CornerRadiusTopLeft = UiTokens.RadiusLg,
CornerRadiusTopRight = UiTokens.RadiusLg,
CornerRadiusBottomLeft = UiTokens.RadiusLg,
CornerRadiusBottomRight = UiTokens.RadiusLg,
ContentMarginLeft = 1,
ContentMarginRight = 1,
ContentMarginBottom = 1,
ContentMarginTop = 1
};
panel.AddThemeStyleboxOverride("panel", style);
AddChild(panel);
return panel;
}
private PanelContainer BuildTitleBar()
{
_titleBar = new HBoxContainer
{
Name = "TitleBar",
CustomMinimumSize = new Vector2(0, TITLE_BAR_HEIGHT)
};
_titleBar.AddThemeConstantOverride("separation", UiTokens.Space1);
_titleLabel = new Label
{
Text = _dirty ? "* " + _baseTitle : _baseTitle,
Uppercase = true,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
VerticalAlignment = VerticalAlignment.Center,
MouseFilter = Control.MouseFilterEnum.Ignore
};
_titleLabel.AddThemeColorOverride("font_color", UiTokens.InkBright);
_titleLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontLead);
_titleBar.AddChild(_titleLabel);
_maximizeBtn = CreateTitleButton("□", OnMaximizeToggle, UiTokens.BtnHover, UiTokens.InkBright);
_maximizeBtn.TooltipText = TranslationServer.Translate("tools/window_maximize");
_titleBar.AddChild(_maximizeBtn);
var closeBtn = CreateTitleButton("✕", RequestClose, UiTokens.DangerSoft, UiTokens.DangerText);
closeBtn.TooltipText = TranslationServer.Translate("tools/window_close");
_titleBar.AddChild(closeBtn);
_titleBar.GuiInput += OnTitleBarInput;
var titlePanel = new PanelContainer();
titlePanel.AddChild(_titleBar);
var titleBg = new StyleBoxFlat
{
BgColor = UiTokens.Surface1,
BorderColor = UiTokens.Line,
BorderWidthBottom = 1,
CornerRadiusTopLeft = UiTokens.RadiusLg,
CornerRadiusTopRight = UiTokens.RadiusLg,
ContentMarginLeft = UiTokens.Space7,
ContentMarginRight = UiTokens.Space1,
};
titlePanel.AddThemeStyleboxOverride("panel", titleBg);
return titlePanel;
}
private PanelContainer BuildStatusBar()
{
var bar = new HBoxContainer { Name = "StatusBar", CustomMinimumSize = new Vector2(0, 26) };
bar.AddThemeConstantOverride("separation", UiTokens.Space5);
_statusLabel = new Label
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
VerticalAlignment = VerticalAlignment.Center,
TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis
};
_statusLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
bar.AddChild(_statusLabel);
_hintLabel = new Label
{
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center
};
_hintLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
_hintLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
bar.AddChild(_hintLabel);
SetStatus(_pendingStatus, _pendingStatusKind);
SetHint(_pendingHint);
var panel = new PanelContainer();
panel.AddChild(bar);
var style = new StyleBoxFlat
{
BgColor = UiTokens.Surface2,
BorderColor = UiTokens.Line,
BorderWidthTop = 1,
CornerRadiusBottomLeft = UiTokens.RadiusLg,
CornerRadiusBottomRight = UiTokens.RadiusLg,
ContentMarginLeft = UiTokens.Space5,
ContentMarginRight = UiTokens.Space5,
};
panel.AddThemeStyleboxOverride("panel", style);
return panel;
}
private static Button CreateTitleButton(string text, Action pressed, Color hoverBg, Color hoverText)
{
var btn = new Button
{
Text = text,
CustomMinimumSize = new Vector2(TITLE_BUTTON_SIZE, TITLE_BUTTON_SIZE),
SizeFlagsHorizontal = Control.SizeFlags.ShrinkEnd,
SizeFlagsVertical = Control.SizeFlags.ShrinkCenter,
Flat = true,
FocusMode = Control.FocusModeEnum.None
};
btn.AddThemeColorOverride("font_color", UiTokens.Muted);
btn.AddThemeColorOverride("font_hover_color", hoverText);
btn.AddThemeColorOverride("font_pressed_color", hoverText);
btn.AddThemeColorOverride("font_hover_pressed_color", hoverText);
btn.AddThemeFontSizeOverride("font_size", UiTokens.FontLead);
var normalStyle = new StyleBoxFlat { BgColor = Colors.Transparent };
btn.AddThemeStyleboxOverride("normal", normalStyle);
var hoverStyle = new StyleBoxFlat
{
BgColor = hoverBg,
CornerRadiusTopLeft = UiTokens.RadiusSm,
CornerRadiusTopRight = UiTokens.RadiusSm,
CornerRadiusBottomLeft = UiTokens.RadiusSm,
CornerRadiusBottomRight = UiTokens.RadiusSm
};
btn.AddThemeStyleboxOverride("hover", hoverStyle);
btn.AddThemeStyleboxOverride("pressed", hoverStyle);
btn.AddThemeStyleboxOverride("focus", new StyleBoxEmpty());
btn.Pressed += pressed;
return btn;
}
// ── button actions ──────────────────────────────────────────
private void OnMaximizeToggle()
{
if (_isMaximized)
RestoreWindow();
else
MaximizeWindow();
}
private void MaximizeWindow()
{
_restorePosition = Position;
_restoreSize = Size;
_isMaximized = true;
_maximizeBtn.Text = "❐";
Position = Vector2I.Zero;
Size = (Vector2I)GetGameViewportSize();
}
private void RestoreWindow()
{
_isMaximized = false;
_maximizeBtn.Text = "□";
Position = _restorePosition;
Size = _restoreSize;
}
// ── title bar drag ──────────────────────────────────────────
private void OnTitleBarInput(InputEvent evt)
{
if (evt is InputEventMouseButton mb)
{
if (mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
{
if (mb.DoubleClick)
{
OnMaximizeToggle();
return;
}
if (_isMaximized)
{
_isMaximized = false;
_maximizeBtn.Text = "□";
var pos = DisplayServer.MouseGetPosition();
var viewportSize = GetGameViewportSize();
var ratio = pos.X / viewportSize.X;
_titleDragWindowStart = new Vector2I(
Mathf.RoundToInt(pos.X - _restoreSize.X * ratio),
Mathf.RoundToInt(pos.Y - 4));
_titleDragMouseStart = new Vector2(pos.X, pos.Y);
Size = _restoreSize;
}
else
{
_titleDragWindowStart = Position;
var mp = DisplayServer.MouseGetPosition();
_titleDragMouseStart = new Vector2(mp.X, mp.Y);
}
_draggingTitle = true;
}
else
{
_draggingTitle = false;
}
}
}
else if (evt is InputEventMouseMotion && _draggingTitle)
{
var mp = DisplayServer.MouseGetPosition();
var delta = new Vector2(mp.X, mp.Y) - _titleDragMouseStart;
Position = new Vector2I(
_titleDragWindowStart.X + Mathf.RoundToInt(delta.X),
_titleDragWindowStart.Y + Mathf.RoundToInt(delta.Y));
}
}
// ── resize zones ────────────────────────────────────────────
private void AddResizeZones()
{
AddEdgeH("ResizeLeft", ResizeEdge.Left, 0f, 0f, 0f, EDGE_ZONE);
AddEdgeH("ResizeRight", ResizeEdge.Right, 1f, 1f, -EDGE_ZONE, 0f);
AddEdgeV("ResizeTop", ResizeEdge.Top, 0f, 0f, 0f, EDGE_ZONE);
AddEdgeV("ResizeBottom",ResizeEdge.Bottom, 1f, 1f, -EDGE_ZONE, 0f);
AddCornerZone("ResizeTL", ResizeEdge.TopLeft,
0f, 0f, CORNER_ZONE, CORNER_ZONE, Control.CursorShape.Fdiagsize);
AddCornerZone("ResizeTR", ResizeEdge.TopRight,
1f, 0f, -CORNER_ZONE, CORNER_ZONE, Control.CursorShape.Bdiagsize);
AddCornerZone("ResizeBL", ResizeEdge.BottomLeft,
0f, 1f, CORNER_ZONE, -CORNER_ZONE, Control.CursorShape.Bdiagsize);
AddCornerZone("ResizeBR", ResizeEdge.BottomRight,
1f, 1f, -CORNER_ZONE, -CORNER_ZONE, Control.CursorShape.Fdiagsize);
}
private void AddEdgeH(string name, ResizeEdge edge,
float anchorLeft, float anchorRight, float offsetLeft, float offsetRight)
{
var zone = MakeResizeZone(name, edge, Control.CursorShape.Hsize);
zone.AnchorLeft = anchorLeft;
zone.AnchorRight = anchorRight;
zone.OffsetLeft = offsetLeft;
zone.OffsetRight = offsetRight;
zone.AnchorTop = 0f;
zone.AnchorBottom = 1f;
zone.OffsetTop = CORNER_ZONE;
zone.OffsetBottom = -CORNER_ZONE;
AddChild(zone);
}
private void AddEdgeV(string name, ResizeEdge edge,
float anchorTop, float anchorBottom, float offsetTop, float offsetBottom)
{
var zone = MakeResizeZone(name, edge, Control.CursorShape.Vsize);
zone.AnchorLeft = 0f;
zone.AnchorRight = 1f;
zone.OffsetLeft = CORNER_ZONE;
zone.OffsetRight = -CORNER_ZONE;
zone.AnchorTop = anchorTop;
zone.AnchorBottom = anchorBottom;
zone.OffsetTop = offsetTop;
zone.OffsetBottom = offsetBottom;
AddChild(zone);
}
private void AddCornerZone(string name, ResizeEdge edge,
float anchorH, float anchorV, int offsetW, int offsetH,
Control.CursorShape cursor)
{
var zone = MakeResizeZone(name, edge, cursor);
zone.AnchorLeft = anchorH;
zone.AnchorRight = anchorH;
zone.AnchorTop = anchorV;
zone.AnchorBottom = anchorV;
if (offsetW >= 0)
{
zone.OffsetLeft = 0;
zone.OffsetRight = offsetW;
}
else
{
zone.OffsetLeft = offsetW;
zone.OffsetRight = 0;
}
if (offsetH >= 0)
{
zone.OffsetTop = 0;
zone.OffsetBottom = offsetH;
}
else
{
zone.OffsetTop = offsetH;
zone.OffsetBottom = 0;
}
AddChild(zone);
}
private Control MakeResizeZone(string name, ResizeEdge edge, Control.CursorShape cursor)
{
var zone = new Control
{
Name = name,
MouseFilter = Control.MouseFilterEnum.Stop,
MouseDefaultCursorShape = cursor
};
zone.GuiInput += (InputEvent evt) => HandleResizeInput(evt, edge);
return zone;
}
private void HandleResizeInput(InputEvent evt, ResizeEdge edge)
{
if (evt is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
{
_resizing = true;
_resizeEdge = edge;
var mp = DisplayServer.MouseGetPosition();
_resizeMouseStart = new Vector2(mp.X, mp.Y);
_resizePosStart = Position;
_resizeSizeStart = Size;
}
else
{
_resizing = false;
_resizeEdge = ResizeEdge.None;
}
}
else if (evt is InputEventMouseMotion && _resizing)
{
var mp = DisplayServer.MouseGetPosition();
ApplyResize(new Vector2(mp.X, mp.Y));
}
}
private void ApplyResize(Vector2 globalMouse)
{
var delta = globalMouse - _resizeMouseStart;
var dx = Mathf.RoundToInt(delta.X);
var dy = Mathf.RoundToInt(delta.Y);
int newX = _resizePosStart.X;
int newY = _resizePosStart.Y;
int newW = _resizeSizeStart.X;
int newH = _resizeSizeStart.Y;
var left = _resizeEdge is ResizeEdge.Left or ResizeEdge.TopLeft or ResizeEdge.BottomLeft;
var right = _resizeEdge is ResizeEdge.Right or ResizeEdge.TopRight or ResizeEdge.BottomRight;
var top = _resizeEdge is ResizeEdge.Top or ResizeEdge.TopLeft or ResizeEdge.TopRight;
var bottom = _resizeEdge is ResizeEdge.Bottom or ResizeEdge.BottomLeft or ResizeEdge.BottomRight;
if (left) { newX += dx; newW -= dx; }
if (right) newW += dx;
if (top) { newY += dy; newH -= dy; }
if (bottom) newH += dy;
newX = Mathf.Clamp(newX, 0, MaxSize.X - 100);
newY = Mathf.Clamp(newY, 0, MaxSize.Y - 100);
newW = Mathf.Clamp(newW, MinSize.X, MaxSize.X);
newH = Mathf.Clamp(newH, MinSize.Y, MaxSize.Y);
if (newX + newW > MaxSize.X)
newX = MaxSize.X - newW;
if (newY + newH > MaxSize.Y)
newY = MaxSize.Y - newH;
Position = new Vector2I(newX, newY);
Size = new Vector2I(newW, newH);
}
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace Cthangover.Mods.Interface.View
{
/// <summary>
/// 9-point alignment grid for viewport content positioning. The naming
/// uses horizontal-first, vertical-second convention (LeftTop, CenterCenter,
/// RightBottom) — matching the intuitive order for CSS-like layout.
/// </summary>
public enum AlignType
{
/// <summary>Content aligned to top-left corner of the viewport.</summary>
LeftTop,
/// <summary>Content horizontally left, vertically centered in the viewport.</summary>
LeftCenter,
/// <summary>Content horizontally left, vertically at the bottom of the viewport.</summary>
LeftBottom,
/// <summary>Content horizontally centered, vertically at the top of the viewport.</summary>
CenterTop,
/// <summary>Content centered both horizontally and vertically in the viewport.</summary>
CenterCenter,
/// <summary>Content horizontally centered, vertically at the bottom of the viewport.</summary>
CenterBottom,
/// <summary>Content horizontally right, vertically at the top of the viewport.</summary>
RightTop,
/// <summary>Content horizontally right, vertically centered in the viewport.</summary>
RightCenter,
/// <summary>Content aligned to the bottom-right corner of the viewport.</summary>
RightBottom,
}
}
+299
View File
@@ -0,0 +1,299 @@
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Mods.Interface.View
{
/// <summary>
/// Core viewport container: manages background/foreground TextureRects with
/// a shared shader material (timed_sprite from mods) for time-of-day lighting.
/// Supports smooth background transitions via a black ColorRect overlay that
/// fades in, swaps the texture mid-transition, then fades out — a classic
/// crossfade approach that doesn't require a second TextureRect. The zoom
/// system scales the content Control between minZoom and maxZoom based on
/// a 0–1 input value. Alignment positions content within the viewport using
/// ViewBoxNavigator's 9-point grid. The #if TOOLS block forces content size
/// to defaultResolution for editor preview.
///
/// Interactive layers are Control containers placed between Background and
/// Foreground (and above Foreground for UI) that host interactive scene objects.
/// </summary>
public partial class ViewBox : Control, IBackgroundView
{
[Export] private Vector2I defaultResolution = new(1920, 1024);
[Export] private Control content;
[Export] private float minZoom;
[Export] private float maxZoom;
/// <summary>
/// The content node that holds all scene visuals. Scaling and alignment are applied
/// directly to this node.
/// </summary>
public Control Content => content;
/// <summary>Logical resolution of the content area (default 1920x1024). Used for normalised coordinate calculations.</summary>
public Vector2I LogicalSize => defaultResolution;
/// <summary>
/// The background <see cref="TextureRect"/> rendered behind all interactive layers.
/// Receives a <c>timed_sprite</c> shader material for time-of-day lighting when
/// no material is already assigned.
/// </summary>
public TextureRect Background { get; private set; }
/// <summary>
/// The foreground <see cref="TextureRect"/> rendered above interactive layers.
/// Receives the same <c>timed_sprite</c> shader as the background.
/// </summary>
public TextureRect Foreground { get; private set; }
/// <summary>Container for interactive objects rendered behind the foreground.</summary>
public Control InteractiveLayerBackground { get; private set; }
/// <summary>Container for interactive objects rendered above the foreground.</summary>
public Control InteractiveLayerForeground { get; private set; }
/// <summary>Container for interactive objects rendered above all scene visuals (UI layer).</summary>
public Control InteractiveLayerUI { get; private set; }
private ColorRect transitionOverlay;
private Tween transitionTween;
/// <summary>Resolves the background and foreground nodes and assigns the time-of-day shader when no material is set.</summary>
public override void _Ready()
{
Background ??= GetNodeOrNull<TextureRect>("Background");
Foreground ??= GetNodeOrNull<TextureRect>("Foreground");
var timedShader = Shaders.Resolve("timed_sprite");
if (timedShader != null)
{
if (Background != null && Background.Material == null)
{
Background.Material = new ShaderMaterial { Shader = timedShader };
GameLogger.Log("LIGHT", "ViewBox._Ready: created ShaderMaterial for Background", LogLevel.Debug);
}
if (Foreground != null && Foreground.Material == null)
{
Foreground.Material = new ShaderMaterial { Shader = timedShader };
GameLogger.Log("LIGHT", "ViewBox._Ready: created ShaderMaterial for Foreground", LogLevel.Debug);
}
}
SetZoom(0f);
SetAlign(AlignType.CenterCenter);
}
/// <summary>
/// Returns the interactive layer container for the given logical layer name.
/// Creates the layer lazily as a direct child of ViewBox if it doesn't exist yet.
/// </summary>
/// <param name="layerName">"background", "foreground" or "ui".</param>
/// <returns>The matching Control container.</returns>
public Control GetInteractiveLayer(string layerName)
{
var result = layerName?.ToLowerInvariant() switch
{
"background" => InteractiveLayerBackground ??= CreateSceneLayer("InteractiveLayer_Background"),
"foreground" => InteractiveLayerForeground ??= CreateSceneLayer("InteractiveLayer_Foreground"),
"ui" => InteractiveLayerUI ??= CreateLayerDirect("InteractiveLayer_UI", 5),
_ => InteractiveLayerForeground ??= CreateSceneLayer("InteractiveLayer_Foreground")
};
return result;
}
/// <summary>
/// Creates a layer that belongs to the picture itself. It keeps the default
/// z index and is ordered by its place among the ViewBox children instead: a
/// z index is global to the canvas, so it lifted scene objects over the dialog
/// box, the toolbar and the open panels that follow the ViewBox in the tree.
/// The background layer stays below the foreground one whichever comes first.
/// </summary>
private Control CreateSceneLayer(string name)
{
var layer = CreateLayerDirect(name, 0);
if (InteractiveLayerForeground != null)
MoveChild(layer, InteractiveLayerForeground.GetIndex());
return layer;
}
private Control CreateLayerDirect(string name, int zIndex)
{
var layer = new Control
{
Name = name,
MouseFilter = MouseFilterEnum.Ignore,
ZIndex = zIndex,
Size = new Vector2(defaultResolution.X, defaultResolution.Y)
};
AddChild(layer);
return layer;
}
/// <summary>
/// Assigns a texture to the background <see cref="TextureRect"/>. This is the
/// primary mechanism for scenes to set the backdrop visual. Logs the new background
/// ID from <see cref="SceneContextNode.LastBackgroundID"/> for debugging.
/// </summary>
public void SetBackgroundTexture(Texture2D texture)
{
if (Background != null)
{
Background.Texture = texture;
GameLogger.Log("VIEW", $"set background '" + SceneContextNode.LastBackgroundID + "'");
}
}
/// <summary>
/// Assigns a texture to the foreground <see cref="TextureRect"/>. Typically used
/// for scene overlays rendered above interactive objects but below UI.
/// </summary>
public void SetForegroundTexture(Texture2D texture)
{
if (Foreground != null)
{
Foreground.Texture = texture;
GameLogger.Log("VIEW", $"set foreground");
}
}
/// <summary>
/// Returns the currently assigned background texture, or <c>null</c> if none is set
/// or the <see cref="Background"/> node is missing.
/// </summary>
public Texture2D GetBackgroundTexture()
{
return Background?.Texture;
}
/// <summary>
/// Smoothly transitions the background to a new texture using a black overlay crossfade:
/// fades a <see cref="ColorRect"/> to full opacity over the first half of <paramref name="duration"/>,
/// swaps the background texture mid-transition, then fades back out over the second half.
/// If the target texture is already set or the background node is missing, no-op.
/// <see cref="KillTransitionTween"/> cancels any in-progress transition first.
/// </summary>
/// <param name="newTexture">The replacement background texture.</param>
/// <param name="duration">Total crossfade duration in seconds (default 0.5).</param>
public void TransitionBackground(Texture2D newTexture)
{
TransitionBackground(newTexture, 0.5f);
}
public void TransitionBackground(Texture2D newTexture, float duration = 0.5f)
{
if (Background == null)
{
if (newTexture != null)
SetBackgroundTexture(newTexture);
return;
}
if (Background.Texture == newTexture)
return;
KillTransitionTween();
var overlay = GetOrCreateTransitionOverlay();
overlay.Visible = true;
overlay.Modulate = UiTokens.Alpha(Colors.Black, 0f);
overlay.MouseFilter = MouseFilterEnum.Ignore;
var halfDuration = duration / 2f;
transitionTween = CreateTween();
transitionTween.TweenProperty(overlay, "modulate:a", 1f, halfDuration);
transitionTween.TweenCallback(Callable.From(() =>
{
SetBackgroundTexture(newTexture);
}));
transitionTween.TweenProperty(overlay, "modulate:a", 0f, halfDuration);
transitionTween.TweenCallback(Callable.From(() =>
{
overlay.Visible = false;
transitionTween = null;
}));
}
/// <summary>
/// Aborts any in-progress background transition by killing the tween and resetting
/// the overlay to invisible. Safe to call when no transition is active.
/// </summary>
public void KillTransitionTween()
{
if (transitionTween != null)
{
transitionTween.Kill();
transitionTween = null;
}
if (transitionOverlay != null)
{
transitionOverlay.Visible = false;
transitionOverlay.Modulate = UiTokens.Alpha(Colors.Black, 0f);
}
}
private ColorRect GetOrCreateTransitionOverlay()
{
if (transitionOverlay == null)
{
transitionOverlay = new ColorRect
{
Name = "TransitionOverlay",
Color = UiTokens.Alpha(Colors.Black, 1f),
MouseFilter = MouseFilterEnum.Ignore,
Visible = false
};
transitionOverlay.AnchorRight = 1f;
transitionOverlay.AnchorBottom = 1f;
transitionOverlay.GrowHorizontal = GrowDirection.Both;
transitionOverlay.GrowVertical = GrowDirection.Both;
AddChild(transitionOverlay);
}
return transitionOverlay;
}
/// <summary>
/// Applies a zoom level to the content node. The <paramref name="value"/> is a 0–1 lerp
/// factor between <c>minZoom</c> and <c>maxZoom</c>. Scale is uniform (X and Y are equal).
/// </summary>
/// <param name="value">Normalized zoom factor: 0 = minZoom, 1 = maxZoom.</param>
public void SetZoom(float value)
{
var zoom = minZoom + (maxZoom - minZoom) * value;
if (content != null)
content.Scale = Vector2.One * zoom;
}
/// <summary>
/// Positions the content node within the viewport using the 9-point alignment grid
/// from <see cref="ViewBoxNavigator.GetPositionByAlign"/>.
/// </summary>
/// <param name="align">Which corner/edge/center to align to.</param>
public void SetAlign(AlignType align)
{
if (content != null)
content.Position = ViewBoxNavigator.GetPositionByAlign(align, content.Size);
}
#if TOOLS && DEBUG
/// <summary>Editor-only: forces the content back to the default resolution rect while inspecting properties.</summary>
public override void _ValidateProperty(Godot.Collections.Dictionary property)
{
base._ValidateProperty(property);
if (content != null)
content.Size = defaultResolution;
}
#endif
}
}
+73
View File
@@ -0,0 +1,73 @@
using Godot;
namespace Cthangover.Mods.Interface.View
{
/// <summary>
/// Static utility that calculates content positioning offsets for the 9-point
/// AlignType grid. Uses the viewport's visible rect as the camera/view area
/// and computes offsets so the content aligns correctly within it. The
/// CenterCenter case negates X because the content position is treated as
/// a negative offset from top-left in Godot's coordinate space.
/// </summary>
public static class ViewBoxNavigator
{
/// <summary>
/// Calculates the content node position offset for the given 9-point <see cref="AlignType"/>.
/// Uses the visible viewport rect size as the camera area and computes offsets relative
/// to the top-left corner. Negative X coordinates are intentional: Godot positions content
/// relative to the container origin, so aligning center or right requires negative offsets
/// when content is larger than the viewport.
/// </summary>
/// <param name="align">The target alignment from the 9-point grid.</param>
/// <param name="contentSize">The size of the content node being positioned.</param>
/// <returns>A position offset that places the content correctly for the chosen alignment.</returns>
public static Vector2 GetPositionByAlign(AlignType align, Vector2 contentSize)
{
var viewport = Godot.Engine.GetMainLoop() as SceneTree;
var screenSize = viewport?.Root?.GetVisibleRect().Size ?? new Vector2(1920, 1024);
var cameraSize = screenSize;
var alignPos = Vector2.Zero;
switch (align)
{
case AlignType.LeftTop:
break;
case AlignType.LeftCenter:
alignPos.Y = (contentSize.Y - cameraSize.Y) * 0.5f;
break;
case AlignType.LeftBottom:
alignPos.Y = contentSize.Y - cameraSize.Y;
break;
case AlignType.CenterTop:
alignPos.X = -(contentSize.X - cameraSize.X) * 0.5f;
break;
case AlignType.CenterCenter:
alignPos = (contentSize - cameraSize) * 0.5f;
alignPos.X = -alignPos.X;
break;
case AlignType.CenterBottom:
alignPos.X = -(contentSize.X - cameraSize.X) * 0.5f;
alignPos.Y = contentSize.Y - cameraSize.Y;
break;
case AlignType.RightTop:
alignPos.X = -(contentSize.X - cameraSize.X);
break;
case AlignType.RightCenter:
alignPos.Y = (contentSize.Y - cameraSize.Y) * 0.5f;
alignPos.X = -(contentSize.X - cameraSize.X);
break;
case AlignType.RightBottom:
alignPos.X = -(contentSize.X - cameraSize.X);
alignPos.Y = contentSize.Y - cameraSize.Y;
break;
}
return alignPos;
}
}
}
+46
View File
@@ -0,0 +1,46 @@
namespace Cthangover.Mods.Interface.View
{
using System;
using System.Runtime.InteropServices;
using Godot;
/// <summary>
/// Takes a screenshot of a viewport the way the player sees it.
/// </summary>
/// <remarks>
/// The project renders 2D in HDR (<c>rendering/viewport/hdr_2d</c>): the viewport texture then holds linear
/// colour, and Godot converts it to sRGB only when it blits to the window. A plain
/// <c>GetTexture().GetImage().SavePng()</c> wrote those linear values, so every screenshot (the site's
/// gallery included) came out much darker than the game, shadows most of all. The conversion runs on
/// float data before the 8-bit quantisation, so dark gradients do not band.
/// </remarks>
public static class ViewportCapture
{
/// <summary>The current frame of <paramref name="viewport"/> as RGBA8 sRGB, or null without a frame.</summary>
public static Image Take(Viewport viewport)
{
var image = viewport.GetTexture()?.GetImage();
if (image == null || !viewport.UseHdr2D)
return image;
image.Convert(Image.Format.Rgbaf);
var floats = MemoryMarshal.Cast<byte, float>(image.GetData());
var bytes = new byte[floats.Length];
for (var i = 0; i < floats.Length; i++)
{
var v = Math.Clamp(floats[i], 0f, 1f);
if (i % 4 != 3)
v = v <= 0.0031308f ? v * 12.92f : 1.055f * MathF.Pow(v, 1f / 2.4f) - 0.055f;
bytes[i] = (byte)MathF.Round(v * 255f);
}
return Image.CreateFromData(image.GetWidth(), image.GetHeight(), false, Image.Format.Rgba8, bytes);
}
/// <summary>Saves the current frame of <paramref name="viewport"/> as a PNG; false without a frame.</summary>
public static bool SavePng(Viewport viewport, string path)
{
var image = Take(viewport);
return image != null && image.SavePng(path) == Error.Ok;
}
}
}
+31
View File
@@ -0,0 +1,31 @@
using Godot;
namespace Cthangover.Mods.Interface.View
{
/// <summary>
/// Thin adapter that exposes ViewBox.SetZoom to external callers (e.g. UI
/// sliders or scroll handlers) through a single method. Exists as a Node
/// so it can be placed in the scene tree and receive signals.
/// </summary>
public partial class ZoomViewBoxController : Node
{
[Export] private ViewBox viewBox;
/// <summary>
/// Delegates to <see cref="ViewBox.SetZoom"/> on the linked <see cref="ViewBox"/>.
/// Intended to be connected to a slider or scroll input signal. No-op if
/// <c>viewBox</c> is not assigned.
/// </summary>
/// <param name="value">Normalized zoom factor (0–1).</param>
public void OnChangeZoom(float value)
{
if (viewBox == null)
return;
viewBox.SetZoom(value);
}
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 B

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B