Compare commits
10
Commits
d673543dd5
...
9ecbbb4056
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ecbbb4056 | ||
|
|
70ff37d127 | ||
|
|
563b42ea9f | ||
|
|
b2a0f1c10a | ||
|
|
6884cd5374 | ||
|
|
7a30f4b8c5 | ||
|
|
dc965d7426 | ||
|
|
0703313f49 | ||
|
|
98d439fa1a | ||
|
|
80e8820ade |
No files matched your search
+139
-6
@@ -1,6 +1,6 @@
|
|||||||
# --- scenes/battle/winground.tscn ---
|
# --- scenes/battle/winground.tscn ---
|
||||||
mod_interface_name = Interface
|
mod_ui_base_name = UI
|
||||||
mod_interface_desc = Game interface
|
mod_ui_base_desc = Engine UI subsystem: widget toolkit (Widget/ModWidget/lists), ViewBox background viewport, ToolBox toolbar, GameMenu/SaveIcon, notification panels, UiKit design system and DeviceInput.
|
||||||
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 +17,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 +26,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 +67,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
|
||||||
@@ -112,6 +115,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 +152,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 +254,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
|
||||||
+139
-6
@@ -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 = Список изменений
|
||||||
@@ -110,6 +113,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 +150,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 +252,18 @@ 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 = Страницы этой записи ещё пусты. Чернила появятся, когда история сдвинется с места.
|
||||||
|
mod_ui_base_name = Интерфейс
|
||||||
|
mod_ui_base_desc = Подсистема UI движка: набор виджетов (Widget/ModWidget, списки), ViewBox фона, тулбар ToolBox, GameMenu/SaveIcon, панели уведомлений, дизайн-система UiKit и DeviceInput. Ядро работает с модом через контракты IWidget/IToolbarHost и мосты.
|
||||||
|
|
||||||
|
tools/unsaved_title = Есть изменения
|
||||||
|
tools/unsaved_text = У вас есть несохранённые изменения. Закрыть без сохранения?
|
||||||
|
tools/unsaved_discard_text = Есть несохранённые изменения. Отбросить их?
|
||||||
|
tools/window_maximize = Развернуть / восстановить (двойной клик по заголовку)
|
||||||
|
tools/window_close = Закрыть
|
||||||
|
tools/menu_title = Инструменты
|
||||||
+22
-4
@@ -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": "Engine UI subsystem: widget toolkit (Widget/ModWidget/lists), ViewBox background viewport, ToolBox toolbar, GameMenu/SaveIcon, notification panels, UiKit design system, DeviceInput. Kernel keeps only contracts (IWidget, IDialogBox, IToolbarHost, ISaveIconHost, IBackgroundView, IUiLazyConstruct, MenuRegistry, ToolFactory, NotificationBuffer, SceneEventController, CthangoverTheme/UiTokens boot styling) and drives this mod through those seams.",
|
||||||
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
@@ -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.
@@ -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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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() { }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
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 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";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
button.Icon = icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
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(60, 60),
|
||||||
|
SizeFlagsVertical = SizeFlags.ShrinkCenter,
|
||||||
|
TooltipText = title,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (icon != null)
|
||||||
|
{
|
||||||
|
var iconRect = UiKit.Icon(icon, 60);
|
||||||
|
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 60×60 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
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 ??= CreateLayerDirect("InteractiveLayer_Background", 1),
|
||||||
|
"foreground" => InteractiveLayerForeground ??= CreateLayerDirect("InteractiveLayer_Foreground", 3),
|
||||||
|
"ui" => InteractiveLayerUI ??= CreateLayerDirect("InteractiveLayer_UI", 5),
|
||||||
|
_ => InteractiveLayerForeground ??= CreateLayerDirect("InteractiveLayer_Foreground", 3)
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 119 B |
Reference in new issue
Block a user