commit ca52efbedcde9aebc69f75c6fa430f743e3feb3e Author: Danil Iarosh Date: Tue Sep 22 11:43:13 2026 +0300 Initial commit: Exchanger Theme DLC Builder Godot 4.7 project for authoring, previewing, validating, and building Exchanger PCK themes. Includes the example and space-pixel themes, binding contracts, editor tooling, validation/build scripts, and project documentation. diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 0000000..52d6dde --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,5 @@ +# Указатель + +Единственные актуальные инструкции агентов находятся в корневом [AGENTS.md](../AGENTS.md). +Перед работой прочитайте его полностью; этот файл не содержит отдельного набора правил. + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ec6790 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Godot и локальные результаты сборки +.godot/ +build/ +*.pck + +# Локальные визуальные референсы не входят в поставку +themes_src/**/references/ +themes/**/source/references/ + +# Метаданные ОС +Thumbs.db +.DS_Store diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..8b0fcbb --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "geequlim.godot-tools", + "ms-vscode.powershell" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2fe8bd5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "files.exclude": { + ".godot": true + }, + "search.exclude": { + ".godot": true, + "build": true, + "themes_src": true, + "preview_scenes": true + }, + "godotTools.editorPath.godot4": "d:\\Applications\\Godot\\Godot_v4.7.1-stable_mono_win64\\Godot_v4.7.1-stable_mono_win64.exe" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..6a81280 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,164 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Theme: активировать", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}\\activate-theme.ps1", + "-ThemeId", + "${input:themeId}" + ], + "problemMatcher": [] + }, + { + "label": "Theme: запустить preview", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}\\preview-theme.ps1", + "-ThemeId", + "${input:themeId}" + ], + "problemMatcher": [] + }, + { + "label": "Theme: проверить validator и preview smoke", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}\\test-theme.ps1", + "-ThemeId", + "${input:themeId}" + ], + "group": "test", + "problemMatcher": [] + }, + { + "label": "Theme: проверить только validator", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}\\test-theme.ps1", + "-ThemeId", + "${input:themeId}", + "-SkipPreviewSmoke" + ], + "group": "test", + "problemMatcher": [] + }, + { + "label": "Theme: проверить копии контракта", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}\\tools\\Test-PackagedBindingContract.ps1" + ], + "group": "test", + "problemMatcher": [] + }, + { + "label": "Theme: сверить контракт с Exchanger", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}\\tools\\Sync-HostBindingContract.ps1", + "-HostProject", + "${input:hostProject}", + "-Check" + ], + "group": "test", + "problemMatcher": [] + }, + { + "label": "Theme: собрать PCK", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}\\build-theme.ps1", + "-ThemeId", + "${input:themeId}" + ], + "group": "build", + "problemMatcher": [] + }, + { + "label": "Theme: собрать и установить PCK (заменяет установленную тему)", + "detail": "Перезаписывает active_theme.pck в пользовательском каталоге Exchanger.", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}\\build-theme.ps1", + "-ThemeId", + "${input:themeId}", + "-InstallToDefaultUserPath" + ], + "problemMatcher": [] + }, + { + "label": "Theme: полный локальный QA", + "dependsOrder": "sequence", + "dependsOn": [ + "Theme: проверить копии контракта", + "Theme: проверить validator и preview smoke", + "Theme: собрать PCK" + ], + "problemMatcher": [] + } + ], + "inputs": [ + { + "id": "themeId", + "type": "promptString", + "description": "ID каталога темы внутри themes/", + "default": "space-pixel" + }, + { + "id": "hostProject", + "type": "promptString", + "description": "Полный или относительный путь к совместимому проекту Exchanger", + "default": "..\\exchanger" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4fc742b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +# Инструкции для ИИ-агентов + +Этот файл — единственный авторитетный набор агентских правил для проекта +`ExchangerThemeDlcBuilder`. Перед любым изменением прочитайте его полностью. + +## Назначение и границы проекта + +- Проект создаёт, проверяет, показывает и собирает внешние PCK-темы Exchanger. +- Рабочая область агента по умолчанию ограничена этой папкой. Соседний проект Exchanger можно + читать для сверки host-контракта, но нельзя менять без отдельного явного поручения пользователя. +- Код бизнес-логики, оплаты, COM-контроллера и настроек автомата принадлежит Exchanger. Темы + считаются доверенными и могут содержать собственную GDScript-логику визуальных и вспомогательных + runtime-компонентов, но прямые зависимости от внутренних узлов host нужно документировать. +- Перед изменениями прочитайте [README.md](README.md), [docs/PROJECT_CONTEXT.md](docs/PROJECT_CONTEXT.md) + и профильный документ из [docs/README.md](docs/README.md). Для сцен обязательно прочитайте + [docs/BINDING_CONTRACT.md](docs/BINDING_CONTRACT.md). + +## Обязательная долговременная память + +- MCP-сервер `agentmemory` используется постоянно. Канонический project id: + `exchanger-theme-dlc-builder`. +- Первое действие с инструментами в каждой задаче — `memory_recall` или `memory_smart_search` по + затрагиваемой подсистеме. Повторите поиск перед работой с незнакомой частью проекта или при + противоречии памяти и файлов. +- После каждого устойчивого открытия, решения, изменения архитектуры/контракта/структуры, + найденной ошибки и законченного блока QA сохраняйте атомарную запись через `memory_save`. +- Записывайте назначение файлов, связи, команды и фактические результаты проверок, а не сырые логи. + Репозиторий и свежий запуск имеют приоритет над устаревшей памятью. +- Перед финальным ответом обязательно сохраните итоговое состояние, изменённые файлы, проверки и + незакрытые вопросы. При делегировании передайте подагенту эти же требования. + +## Godot и инструменты + +- **Ziva-Godot категорически запрещён без исключений.** Нельзя использовать сервер, tools, + plugins, skills, ресурсы или документацию с этим именем даже для чтения и диагностики. +- Для Godot Editor разрешён только MCP-сервер `godot-ai`. Перед работой со сценами проверьте + сессию, сведения о проекте, открытую иерархию и журнал. Если `godot-ai` недоступен, используйте + безопасные shell-команды и файловое редактирование, но не Ziva-Godot. +- Текстовые файлы меняйте точечно через `apply_patch`. Не перезаписывайте пользовательские файлы + shell-трюками и не запускайте форматирование всего проекта без необходимости. +- Сохраняйте чужие и незавершённые изменения. Перед удалением, переносом или массовой генерацией + точно проверьте цель. `themes//package/` является единственным исходником и не должен + очищаться неявно. Постоянного корневого staging-каталога нет. + +## Неподвижные правила темы + +- Целевой формат preview: `720×1280`, портретная ориентация. +- Внутри тематических сцен разрешены обычные возможности Godot: GDScript, сигналы, анимации, + шейдеры, частицы, аудио, видео и процедурные визуальные эффекты. +- Production-содержимое — только `themes//package/`. `source/`, references и preview tooling + не должны попадать в PCK. Канонический namespace `res://exchanger_theme_dlc/` создаётся только + внутри изолированного временного build-workspace и готового PCK. +- Все девять production-сцен имеют канонические пути/имена/root metadata из manifest v2. +- Production-сцены и их вложенные сцены могут содержать доверенный GDScript и любые необходимые + Godot-узлы, включая `Timer`, `AudioStreamPlayer*` и `HTTPRequest`. Скрипты темы исполняются и в + preview, и после инстанцирования PCK в Exchanger. +- Каждый обязательный элемент содержит уникальный `exchanger_binding_id`, точный тип и ID из + `contracts/screen_bindings.v2.json`. Каталог v2 содержит 9 экранов и 481 биндинг; его + package-копия должна совпадать байт-в-байт. +- При любом добавлении, удалении, переименовании или изменении binding ID, пути, типа, fallback, + структуры binding-каталога либо правил разрешения биндингов обязательно в рамках той же задачи + обновите `E:\Danil\Projects\Godot\ExchangerThemeDlcBuilder\docs\BINDING_CONTRACT.md`. Изменение + биндингов не считается завершённым, пока документ не соответствует фактическому контракту и не + проверен вместе с `contracts/screen_bindings.v2.json` и host-контрактом Exchanger. +- Production-сцена владеет каждым визуальным узлом, включая состояния Home, кнопки готовых сумм + и фиксированные строки настроек. Exchanger не должен добавлять, удалять, переносить или + стилизовать `Control` внутри внешнего ThemeView: host только находит существующие узлы, + подключает сигналы и обновляет значения/видимость/доступность. +- SETTINGS содержит заранее созданные скрытые панели текстовой и цифровой экранных клавиатур. + Host может менять подписи/видимость/доступность клавиш и подключать сигналы, но не создаёт и не + перестраивает их визуальное дерево. +- SETTINGS также содержит theme-owned раздел «Сервис» и скрытый диалог учёта жетонов. Заголовки + обоих хопперов имеют отдельные binding-id для точных остатков. Host меняет только остатки, + тексты, видимость/доступность и подписывает кнопки; создавать или перестраивать + эти элементы в runtime запрещено. +- Видимый русский текст должен быть естественным и понятным владельцу автомата. Используйте + «промо» и «промо-ролик», не «реклама». Внутренние English identifiers не переименовывайте. + +## Рабочий цикл + +1. Восстановите контекст из agentmemory и документов. +2. Активируйте нужную тему: `./activate-theme.ps1 -ThemeId `. +3. Меняйте `themes//package/` напрямую; preview загружает эти же файлы без промежуточной копии. +4. Проверяйте binding overlay и все девять вкладок preview в `720×1280`; сохраняйте статический + фон и центрирование содержимого. +5. Выполните `./tools/Test-PackagedBindingContract.ps1`, затем + `./test-theme.ps1 -ThemeId `. +6. Соберите `./build-theme.ps1 -ThemeId `. Установка в пользовательский каталог Exchanger — + отдельное внешнее изменение и выполняется только когда оно входит в поручение. +7. Через `godot-ai` проверьте preview и runtime-журнал. При изменении host-контракта отдельно + проверьте PCK в Exchanger. +8. Обновите относящиеся к изменению docs и agentmemory. + +## Делегирование и критерии готовности + +- Делегируйте только независимые, чётко ограниченные задачи. Укажите рабочую папку, запрет + Ziva-Godot, обязательную память, допустимые файлы и проверки. Не позволяйте нескольким агентам + одновременно править одни и те же файлы. +- Задача готова, когда исходники находятся в правильной теме, contract check и validator проходят, + preview smoke и визуальная проверка девяти экранов не выявляют ошибок, production PCK не содержит + builder tooling/референсов, документация соответствует фактам, а результат сохранён в памяти. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7b98b3c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Участие в разработке + +## Перед началом + +1. Прочитайте [быстрый старт](docs/QUICK_START.md) и + [контракт биндингов](docs/BINDING_CONTRACT.md). +2. Убедитесь, что установлен Godot 4.7.x и доступен PowerShell 7 или Windows PowerShell 5.1. +3. Выберите тему через `Редактор → Выбрать тему` и редактируйте только её + `themes//package/`. +4. Если работает ИИ-агент, он обязан следовать [AGENTS.md](AGENTS.md). + +## Внесение изменений + +- Одна задача должна менять один понятный аспект темы или tooling. +- Не меняйте binding-id, канонические имена сцен и package-копию контракта вручную. +- Скрипты и runtime-узлы темы разрешены. Не переносите в тему код оплаты, COM-контроллер и + остальные app-owned подсистемы без отдельного изменения host-контракта. +- Исходные материалы, лицензии и референсы храните в `source/`; оптимизированные runtime-копии — + только в `package/`. +- Новый видимый текст пишите по-русски, понятно для владельца автомата. Используйте «промо» и + «промо-ролик». + +## Проверка изменения + +```powershell +.\tools\Test-PackagedBindingContract.ps1 +.\test-theme.ps1 -ThemeId space-pixel +.\build-theme.ps1 -ThemeId space-pixel +``` + +После автоматических проверок просмотрите все девять вкладок preview при `720×1280`, сначала без +overlay, затем с `BINDINGS: ON`. Полный checklist находится в +[документе QA](docs/QA_AND_TROUBLESHOOTING.md). + +Команда сборки с `-InstallToDefaultUserPath` заменяет установленный PCK Exchanger. Запускайте её +только осознанно. diff --git a/README.md b/README.md new file mode 100644 index 0000000..366ee4d --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Exchanger Theme DLC Builder + +Самостоятельный Godot 4.7-проект для разработки, визуального просмотра, проверки и сборки +полносценовых DLC-тем Exchanger. Тема полностью рисует девять экранов, а приложение подключает к +размеченным элементам бизнес-логику через binding-id. + +## Быстрый путь + +Требуется Godot 4.7.x. Откройте эту папку в VS Code, установите рекомендованное расширение и +запустите проект в Godot Editor. В меню `Редактор` доступны четыре команды: + +- `Выбрать тему` — выбирает существующую тему и открывает preview прямо из её `package/`; +- `Создать тему` — создаёт полную тему из `example`, задаёт её ID/название и сразу активирует; +- `Экспортировать тему` — предлагает выбрать любую найденную тему по ID, затем открывает + стандартный диалог сохранения и собирает выбранный `.pck` без установки в приложение; +- `Применить тему к Exchanger` — в фоне проверяет и собирает активную тему, затем устанавливает + `active_theme.pck` в пользовательский каталог приложения. + +Выбор, создание, экспорт и применение сопровождаются окном прогресса: в нём видны текущий этап, +процент выполнения и итоговый путь либо диагностическое сообщение. + +Для автоматизации и CI те же операции доступны через VS Code tasks или PowerShell: + +```powershell +.\activate-theme.ps1 -ThemeId space-pixel +.\preview-theme.ps1 -ThemeId space-pixel +.\test-theme.ps1 -ThemeId space-pixel +.\build-theme.ps1 -ThemeId space-pixel -InstallToDefaultUserPath +``` + +После активации preview показывает реальные production-сцены всех девяти экранов в `720×1280`. +Кнопка `BINDINGS: ON/OFF` слева в верхней панели показывает, к какому binding-id подключится +каждый элемент. Изменения `themes/space-pixel/package/` подхватываются live reload. +На вкладке SETTINGS дополнительная кнопка `KEYBOARD: OFF/TEXT/NUM` показывает оба скрытых +состояния экранной клавиатуры и их биндинги. +Кнопка `INVENTORY: OFF/PANEL/DIALOG` рядом показывает theme-owned раздел «Сервис» и его диалог +пересчёта/ручного добавления, не создавая runtime-узлов. +Кнопка `PET` в правой части верхней панели открывает отдельную сцену просмотра интерактивного +питомца активной темы. В ней доступны все состояния, счётчик кадров, пауза, повтор, ручное +переключение и автоматический последовательный показ. Кнопка «СОСТАВНОЕ ДЕЙСТВИЕ: INTERACT» +запускает theme-owned сценарий через тестовый host-runtime с границами и верхней опорой; +`Esc` или «← ГАЛЕРЕЯ» возвращают к экранам. +Viewer берёт компонент из необязательного поля manifest `InteractivePetScene` и строит список +анимаций через публичный API сцены. Папки текстур и их имена определяет сама тема: builder не +сканирует и не требует каталог `alien_pet`. + +Preview не добавляет к сценам тему, фон, декорации или демонстрационные узлы: он только собирает +девять `.tscn` во вкладки и рисует отдельный диагностический overlay. Поэтому production-сцена, +открытая прямо из `themes//package/scenes/`, выглядит так же, как в preview и в Exchanger. +Home, CardAmount и Settings уже содержат все визуальные состояния и фиксированные строки; host +только подключает логику к существующим binding-id и не перестраивает ThemeView. + +Новую тему создавайте через `Редактор → Создать тему`. Ручное копирование `themes/example` +остаётся только скриптовым fallback. Подробности — в [быстром старте](docs/QUICK_START.md). + +Все каталоги внутри `themes/` всегда видны в Godot FileSystem. Открывайте и редактируйте +`themes//package/` напрямую; `.gdignore` в темах не используется и при создании новых тем не +появляется. При открытии проекта аддон автоматически удаляет такие файлы, оставшиеся от старой +структуры. + +## Что где находится + +```text +themes/example/ самодостаточная тема-шаблон со всеми сценами и биндингами +themes/space-pixel/ текущая оформленная тема для preview и сборки +themes//source/ оригиналы, лицензии и локальные references +themes//package/ единственный источник production PCK +contracts/ авторитетный binding-контракт builder +tools/preview/ галерея, просмотрщик питомца и binding overlay, не входят в PCK +addons/exchanger_theme_manager/ EditorPlugin выбора, создания, экспорта и применения тем +build/ PCK, active-theme.json и временный скрытый workspace сборки +``` + +В format v2 обязательны ровно девять канонических сцен, 481 immutable element-binding из JSON и +24 обязательных supplemental runtime-binding, проверяемых Builder Validator отдельно, и четыре +совместимых optional supplemental binding для новых текстов баннеров, выбора промо-изображения и +переключателя интерактивного питомца. Тема считается +доверенной: её GDScript и runtime-узлы входят в PCK и исполняются в preview и Exchanger. `source/`, +references и builder preview tooling в production PCK не экспортируются. + +## Документация + +- [Быстрый старт из VS Code](docs/QUICK_START.md) +- [Как применить тему в Exchanger](docs/APPLY_THEME.md) +- [Архитектура и границы проектов](docs/ARCHITECTURE.md) +- [Полный жизненный цикл темы](docs/WORKFLOW.md) +- [Manifest v2 и binding-контракт](docs/BINDING_CONTRACT.md) +- [Создание и оформление темы](docs/THEME_AUTHORING.md) +- [Скрипты и runtime-логика темы](docs/SCRIPTING.md) +- [Композитные анимации питомца: гайд для дизайнера](docs/COMPOSITE_PET_ANIMATIONS.md) +- [Установка и интеграция](docs/THEME_INTEGRATION.md) +- [QA и решение проблем](docs/QA_AND_TROUBLESHOOTING.md) +- [Текущее состояние и handoff для ИИ](docs/PROJECT_CONTEXT.md) +- [Особенности `space-pixel`](docs/SPACE_PIXEL.md) +- [Правила участия](CONTRIBUTING.md) + +Для этого проекта категорически запрещён Ziva-Godot. Godot-автоматизация выполняется только через +`godot-ai` или штатные безопасные команды Godot. ИИ-агент начинает с [AGENTS.md](AGENTS.md). diff --git a/activate-theme.ps1 b/activate-theme.ps1 new file mode 100644 index 0000000..cb908d3 --- /dev/null +++ b/activate-theme.ps1 @@ -0,0 +1,7 @@ +param( + [string]$ThemeId = "space-pixel", + [string]$ThemeSource = "" +) + +$ErrorActionPreference = "Stop" +& (Join-Path $PSScriptRoot "tools\Select-Theme.ps1") -ThemeId $ThemeId -ThemeSource $ThemeSource diff --git a/addons/exchanger_theme_manager/plugin.cfg b/addons/exchanger_theme_manager/plugin.cfg new file mode 100644 index 0000000..40c15e6 --- /dev/null +++ b/addons/exchanger_theme_manager/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Exchanger Theme Manager" +description="Выбор, создание, экспорт и установка полносценовых DLC-тем Exchanger из меню редактора." +author="Exchanger" +version="1.3.3" +script="theme_manager_plugin.gd" diff --git a/addons/exchanger_theme_manager/theme_manager_plugin.gd b/addons/exchanger_theme_manager/theme_manager_plugin.gd new file mode 100644 index 0000000..4bde900 --- /dev/null +++ b/addons/exchanger_theme_manager/theme_manager_plugin.gd @@ -0,0 +1,1118 @@ +@tool +extends EditorPlugin + +const THEMES_ROOT := "res://themes" +const EXAMPLE_THEME_ID := "example" +const ACTIVE_STATE_PATH := "res://build/active-theme.json" +const ACTIVATE_SCRIPT_PATH := "res://activate-theme.ps1" +const BUILD_SCRIPT_PATH := "res://build-theme.ps1" +const PREVIEW_SCENE_PATH := "res://tools/preview/preview.tscn" + +const MENU_SEPARATOR_ID := 1_947_300_100 +const MENU_SELECT_ID := 1_947_300_101 +const MENU_CREATE_ID := 1_947_300_102 +const MENU_APPLY_ID := 1_947_300_103 +const MENU_EXPORT_ID := 1_947_300_104 +const SELECT_MENU_TEXT := "Выбрать тему" +const CREATE_MENU_TEXT := "Создать тему" +const EXPORT_MENU_TEXT := "Экспортировать тему" +const APPLY_MENU_TEXT := "Применить тему к Exchanger" +const SELECT_DIALOG_SIZE := Vector2i(700, 390) +const CREATE_DIALOG_SIZE := Vector2i(700, 470) +const EXPORT_THEME_DIALOG_SIZE := Vector2i(720, 430) +const EXPORT_FILE_DIALOG_SIZE := Vector2i(900, 620) +const APPLY_DIALOG_SIZE := Vector2i(720, 430) +const PROGRESS_DIALOG_SIZE := Vector2i(700, 330) +const MESSAGE_DIALOG_SIZE := Vector2i(680, 260) +const BUILD_PROGRESS_POLL_INTERVAL := 0.1 +const BUILD_STAGE_LABELS := { + "select": "Выбор исходников темы…", + "prepare": "Подготовка пространства сборки…", + "import": "Импорт ресурсов темы в Godot…", + "validate": "Проверка manifest, сцен и биндингов…", + "export": "Экспорт production-ресурсов в PCK…", + "verify": "Проверка готового PCK…", + "finalize": "Завершение локальной сборки…", + "install": "Установка темы для Exchanger…", + "complete": "Тема успешно собрана", + "error": "Сборка завершилась с ошибкой", +} + +var _editor_menu: PopupMenu +var _uses_standard_tools_menu := false +var _select_dialog: ConfirmationDialog +var _theme_options: OptionButton +var _select_details: RichTextLabel +var _create_dialog: ConfirmationDialog +var _theme_id_input: LineEdit +var _display_name_input: LineEdit +var _create_validation: Label +var _create_details: RichTextLabel +var _export_theme_dialog: ConfirmationDialog +var _export_theme_options: OptionButton +var _export_theme_details: RichTextLabel +var _export_file_dialog: FileDialog +var _apply_dialog: ConfirmationDialog +var _apply_details: RichTextLabel +var _progress_dialog: AcceptDialog +var _progress_bar: ProgressBar +var _progress_stage: Label +var _progress_details: RichTextLabel +var _message_dialog: AcceptDialog +var _theme_id_regex := RegEx.new() +var _busy := false +var _pending_export_theme_id := "" +var _pending_apply_theme_id := "" +var _build_thread: Thread +var _active_build_theme_id := "" +var _active_build_output_path := "" +var _active_build_installs := false +var _active_progress_path := "" +var _progress_poll_elapsed := 0.0 + + +func _enter_tree() -> void: + set_process(false) + _theme_id_regex.compile("^[a-z0-9]+(?:-[a-z0-9]+)*$") + _remove_legacy_theme_ignores() + _create_dialogs() + call_deferred("_install_menu_items") + + +func _exit_tree() -> void: + _remove_menu_items() + if _build_thread != null and _build_thread.is_started(): + _build_thread.wait_to_finish() + _free_dialog(_select_dialog) + _free_dialog(_create_dialog) + _free_dialog(_export_theme_dialog) + _free_dialog(_export_file_dialog) + _free_dialog(_apply_dialog) + _free_dialog(_progress_dialog) + _free_dialog(_message_dialog) + + +func _install_menu_items() -> void: + _editor_menu = _find_editor_menu(EditorInterface.get_base_control()) + if _editor_menu != null: + _remove_direct_menu_item(MENU_SELECT_ID) + _remove_direct_menu_item(MENU_CREATE_ID) + _remove_direct_menu_item(MENU_EXPORT_ID) + _remove_direct_menu_item(MENU_APPLY_ID) + _remove_direct_menu_item(MENU_SEPARATOR_ID) + _editor_menu.add_separator("Темы Exchanger", MENU_SEPARATOR_ID) + _editor_menu.add_item(SELECT_MENU_TEXT, MENU_SELECT_ID) + _editor_menu.add_item(CREATE_MENU_TEXT, MENU_CREATE_ID) + _editor_menu.add_item(EXPORT_MENU_TEXT, MENU_EXPORT_ID) + _editor_menu.add_item(APPLY_MENU_TEXT, MENU_APPLY_ID) + if not _editor_menu.id_pressed.is_connected(_on_editor_menu_id_pressed): + _editor_menu.id_pressed.connect(_on_editor_menu_id_pressed) + print("[Theme Manager] Commands added to the Editor menu.") + return + + _uses_standard_tools_menu = true + add_tool_menu_item(SELECT_MENU_TEXT, _show_select_dialog) + add_tool_menu_item(CREATE_MENU_TEXT, _show_create_dialog) + add_tool_menu_item(EXPORT_MENU_TEXT, _show_export_dialog) + add_tool_menu_item(APPLY_MENU_TEXT, _show_apply_dialog) + push_warning("[Theme Manager] Editor menu was not found; commands were added to Project > Tools.") + + +func _remove_menu_items() -> void: + if _editor_menu != null and is_instance_valid(_editor_menu): + _remove_direct_menu_item(MENU_SELECT_ID) + _remove_direct_menu_item(MENU_CREATE_ID) + _remove_direct_menu_item(MENU_EXPORT_ID) + _remove_direct_menu_item(MENU_APPLY_ID) + _remove_direct_menu_item(MENU_SEPARATOR_ID) + if _editor_menu.id_pressed.is_connected(_on_editor_menu_id_pressed): + _editor_menu.id_pressed.disconnect(_on_editor_menu_id_pressed) + if _uses_standard_tools_menu: + remove_tool_menu_item(SELECT_MENU_TEXT) + remove_tool_menu_item(CREATE_MENU_TEXT) + remove_tool_menu_item(EXPORT_MENU_TEXT) + remove_tool_menu_item(APPLY_MENU_TEXT) + _uses_standard_tools_menu = false + _editor_menu = null + + +func _remove_direct_menu_item(item_id: int) -> void: + if _editor_menu == null or not is_instance_valid(_editor_menu): + return + var item_index := _editor_menu.get_item_index(item_id) + if item_index >= 0: + _editor_menu.remove_item(item_index) + + +func _find_editor_menu(root: Node) -> PopupMenu: + var pending: Array[Node] = [root] + while not pending.is_empty(): + var node := pending.pop_back() as Node + if node is MenuBar: + var menu_bar := node as MenuBar + for menu_index in range(menu_bar.get_menu_count()): + var title := menu_bar.get_menu_title(menu_index).replace("&", "").strip_edges().to_lower() + if title == "editor" or title == "редактор": + return menu_bar.get_menu_popup(menu_index) + for child in node.get_children(): + pending.push_back(child) + return null + + +func _remove_legacy_theme_ignores() -> void: + var themes_directory := DirAccess.open(THEMES_ROOT) + if themes_directory == null: + return + var removed_count := 0 + themes_directory.list_dir_begin() + while true: + var directory_name := themes_directory.get_next() + if directory_name.is_empty(): + break + if not themes_directory.current_is_dir() or directory_name.begins_with("."): + continue + var ignore_path := THEMES_ROOT.path_join(directory_name).path_join(".gdignore") + if not FileAccess.file_exists(ignore_path): + continue + var remove_error := DirAccess.remove_absolute(ProjectSettings.globalize_path(ignore_path)) + if remove_error == OK: + removed_count += 1 + else: + push_warning("[Theme Manager] Не удалось удалить %s: %s" % [ignore_path, error_string(remove_error)]) + themes_directory.list_dir_end() + if removed_count > 0: + print("[Theme Manager] Removed %d legacy .gdignore file(s); themes are visible in FileSystem." % removed_count) + EditorInterface.get_resource_filesystem().scan() + + +func _on_editor_menu_id_pressed(item_id: int) -> void: + match item_id: + MENU_SELECT_ID: + _show_select_dialog() + MENU_CREATE_ID: + _show_create_dialog() + MENU_EXPORT_ID: + _show_export_dialog() + MENU_APPLY_ID: + _show_apply_dialog() + + +func _create_dialogs() -> void: + var editor_root := EditorInterface.get_base_control() + + _select_dialog = ConfirmationDialog.new() + _select_dialog.title = "Выбрать тему Exchanger" + _configure_compact_dialog(_select_dialog, SELECT_DIALOG_SIZE) + _select_dialog.get_ok_button().text = "Выбрать" + _select_dialog.confirmed.connect(_on_select_confirmed) + editor_root.add_child(_select_dialog) + + var select_content := VBoxContainer.new() + select_content.custom_minimum_size = Vector2(620, 280) + select_content.add_theme_constant_override("separation", 10) + _select_dialog.add_child(select_content) + var select_hint := Label.new() + select_hint.text = "Выберите тему, с которой будет работать preview и команды редактора." + select_content.add_child(select_hint) + _theme_options = OptionButton.new() + _theme_options.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _theme_options.item_selected.connect(_on_select_theme_changed) + select_content.add_child(_theme_options) + _select_details = _make_details_panel(150) + select_content.add_child(_select_details) + select_content.add_child(_make_steps_label([ + "Остановка запущенного preview", + "Обновление active-theme.json", + "Сканирование ресурсов и открытие preview", + ])) + + _create_dialog = ConfirmationDialog.new() + _create_dialog.title = "Создать тему Exchanger" + _configure_compact_dialog(_create_dialog, CREATE_DIALOG_SIZE) + _create_dialog.get_ok_button().text = "Создать и выбрать" + _create_dialog.confirmed.connect(_on_create_confirmed) + editor_root.add_child(_create_dialog) + + var create_content := VBoxContainer.new() + create_content.custom_minimum_size = Vector2(620, 360) + create_content.add_theme_constant_override("separation", 7) + _create_dialog.add_child(create_content) + var create_hint := Label.new() + create_hint.text = "Новая тема будет копией example: 9 полностью оформленных сцен и 481 биндинг." + create_content.add_child(create_hint) + create_content.add_child(_make_field_label("ID темы")) + _theme_id_input = LineEdit.new() + _theme_id_input.placeholder_text = "например, my-theme" + _theme_id_input.text_changed.connect(_on_create_field_changed) + create_content.add_child(_theme_id_input) + create_content.add_child(_make_field_label("Название темы")) + _display_name_input = LineEdit.new() + _display_name_input.placeholder_text = "Например, Моя тема" + _display_name_input.text_changed.connect(_on_create_field_changed) + create_content.add_child(_display_name_input) + _create_validation = Label.new() + create_content.add_child(_create_validation) + _create_details = _make_details_panel(105) + _create_details.text = ( + "Этапы: проверка ID → копирование example → запись manifest.json → " + + "активация темы → открытие preview.\nИсходники будут созданы в themes//package/." + ) + create_content.add_child(_create_details) + + _export_theme_dialog = ConfirmationDialog.new() + _export_theme_dialog.title = "Экспортировать тему Exchanger" + _configure_compact_dialog(_export_theme_dialog, EXPORT_THEME_DIALOG_SIZE) + _export_theme_dialog.get_ok_button().text = "Выбрать файл…" + _export_theme_dialog.confirmed.connect(_on_export_theme_confirmed) + editor_root.add_child(_export_theme_dialog) + + var export_content := VBoxContainer.new() + export_content.custom_minimum_size = Vector2(640, 320) + export_content.add_theme_constant_override("separation", 10) + _export_theme_dialog.add_child(export_content) + var export_hint := Label.new() + export_hint.text = "Можно экспортировать любую тему из themes/, независимо от активной темы preview." + export_content.add_child(export_hint) + _export_theme_options = OptionButton.new() + _export_theme_options.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _export_theme_options.item_selected.connect(_on_export_theme_changed) + export_content.add_child(_export_theme_options) + _export_theme_details = _make_details_panel(155) + export_content.add_child(_export_theme_details) + export_content.add_child(_make_steps_label([ + "Изолированный импорт ресурсов", + "Проверка manifest, 9 сцен и 481 биндинга", + "Сборка и проверка готового PCK", + ])) + + _export_file_dialog = FileDialog.new() + _export_file_dialog.title = "Сохранить тему как PCK" + _export_file_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE + _export_file_dialog.access = FileDialog.ACCESS_FILESYSTEM + _export_file_dialog.filters = PackedStringArray(["*.pck ; Exchanger Theme PCK"]) + _export_file_dialog.file_selected.connect(_on_export_file_selected) + _export_file_dialog.canceled.connect(_on_export_canceled) + editor_root.add_child(_export_file_dialog) + + _apply_dialog = ConfirmationDialog.new() + _apply_dialog.title = "Применить тему к Exchanger" + _configure_compact_dialog(_apply_dialog, APPLY_DIALOG_SIZE) + _apply_dialog.get_ok_button().text = "Собрать и применить" + _apply_dialog.confirmed.connect(_on_apply_confirmed) + editor_root.add_child(_apply_dialog) + var apply_content := VBoxContainer.new() + apply_content.custom_minimum_size = Vector2(640, 320) + apply_content.add_theme_constant_override("separation", 10) + _apply_dialog.add_child(apply_content) + var apply_hint := Label.new() + apply_hint.text = "Активная тема будет полностью проверена, собрана и установлена для Exchanger." + apply_content.add_child(apply_hint) + _apply_details = _make_details_panel(170) + apply_content.add_child(_apply_details) + apply_content.add_child(_make_steps_label([ + "Проверка и сборка PCK", + "Проверка содержимого готового пакета", + "Установка active_theme.pck в каталог Exchanger", + ])) + + _progress_dialog = AcceptDialog.new() + _progress_dialog.title = "Операция с темой" + _configure_compact_dialog(_progress_dialog, PROGRESS_DIALOG_SIZE) + _progress_dialog.get_ok_button().text = "Закрыть" + editor_root.add_child(_progress_dialog) + var progress_content := VBoxContainer.new() + progress_content.custom_minimum_size = Vector2(620, 220) + progress_content.add_theme_constant_override("separation", 12) + _progress_dialog.add_child(progress_content) + _progress_stage = Label.new() + _progress_stage.text = "Подготовка…" + progress_content.add_child(_progress_stage) + _progress_bar = ProgressBar.new() + _progress_bar.custom_minimum_size = Vector2(0, 28) + _progress_bar.min_value = 0.0 + _progress_bar.max_value = 100.0 + _progress_bar.show_percentage = true + progress_content.add_child(_progress_bar) + _progress_details = _make_details_panel(120) + progress_content.add_child(_progress_details) + + _message_dialog = AcceptDialog.new() + _message_dialog.title = "Темы Exchanger" + _configure_compact_dialog(_message_dialog, MESSAGE_DIALOG_SIZE) + editor_root.add_child(_message_dialog) + + +func _configure_compact_dialog(dialog: Window, maximum_size: Vector2i) -> void: + # Godot 4.7 can calculate a huge first-popup minimum for an unlaid-out + # autowrapped Label. Keep plugin dialogs explicitly bounded instead. + dialog.wrap_controls = false + dialog.unresizable = true + dialog.max_size = maximum_size + + +func _make_field_label(text: String) -> Label: + var label := Label.new() + label.text = text + return label + + +func _make_details_panel(minimum_height: float) -> RichTextLabel: + var details := RichTextLabel.new() + details.custom_minimum_size = Vector2(0, minimum_height) + details.size_flags_horizontal = Control.SIZE_EXPAND_FILL + details.bbcode_enabled = true + details.fit_content = false + details.scroll_active = false + return details + + +func _make_steps_label(steps: Array[String]) -> Label: + var label := Label.new() + var lines: Array[String] = ["Этапы операции:"] + for step_index in range(steps.size()): + lines.append("%d. %s" % [step_index + 1, steps[step_index]]) + label.text = "\n".join(lines) + return label + + +func _free_dialog(dialog: Window) -> void: + if dialog != null and is_instance_valid(dialog): + dialog.queue_free() + + +func _show_select_dialog() -> void: + if _busy: + _show_message("Операция уже выполняется.", true) + return + _refresh_theme_options() + if _theme_options.item_count == 0: + _show_message("В themes/ не найдено ни одной валидной темы manifest v2.", true) + return + _select_dialog.popup_centered_clamped(SELECT_DIALOG_SIZE, 0.9) + + +func _refresh_theme_options() -> void: + _theme_options.clear() + var active_theme_id := _read_active_theme_id() + var selected_index := 0 + for theme in _list_themes(): + var item_index := _theme_options.item_count + var theme_id: String = theme["id"] + var display_name: String = theme["display_name"] + _theme_options.add_item("%s — %s" % [display_name, theme_id]) + _theme_options.set_item_metadata(item_index, theme_id) + if theme_id == active_theme_id: + selected_index = item_index + _theme_options.select(selected_index) + _on_select_theme_changed(selected_index) + + +func _on_select_theme_changed(selected_index: int) -> void: + if selected_index < 0 or selected_index >= _theme_options.item_count: + _select_details.text = "" + return + var theme_id := str(_theme_options.get_item_metadata(selected_index)) + _select_details.text = _format_theme_details(theme_id, theme_id == _read_active_theme_id()) + + +func _list_themes() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var themes_directory := DirAccess.open(THEMES_ROOT) + if themes_directory == null: + return result + themes_directory.list_dir_begin() + while true: + var directory_name := themes_directory.get_next() + if directory_name.is_empty(): + break + if not themes_directory.current_is_dir() or directory_name.begins_with("."): + continue + var manifest_path := THEMES_ROOT.path_join(directory_name).path_join("package/manifest.json") + var manifest := _read_json_dictionary(manifest_path) + if manifest.is_empty(): + continue + if manifest.get("Format", "") != "ExchangerThemeDlc" or int(manifest.get("FormatVersion", 0)) != 2: + continue + if str(manifest.get("ThemeId", "")) != directory_name: + continue + result.append({ + "id": directory_name, + "display_name": str(manifest.get("DisplayName", directory_name)), + "format_version": int(manifest.get("FormatVersion", 0)), + }) + themes_directory.list_dir_end() + result.sort_custom(_sort_theme_records) + return result + + +func _sort_theme_records(left: Dictionary, right: Dictionary) -> bool: + return str(left["id"]) < str(right["id"]) + + +func _read_active_theme_id() -> String: + var state := _read_json_dictionary(ACTIVE_STATE_PATH) + return str(state.get("ThemeId", "")) + + +func _find_theme_record(theme_id: String) -> Dictionary: + for theme in _list_themes(): + if str(theme["id"]) == theme_id: + return theme + return {} + + +func _format_theme_details(theme_id: String, is_active: bool) -> String: + var theme := _find_theme_record(theme_id) + if theme.is_empty(): + return "[color=#ff8877]Тема с ID %s больше не найдена.[/color]" % theme_id + var status := "[color=#83e69a]активна в preview[/color]" if is_active else "не активна в preview" + return ( + "[b]%s[/b]\nID: [code]%s[/code]\nФормат: ExchangerThemeDlc v%d\nСтатус: %s\n" + + "Production-исходники: [code]themes/%s/package/[/code]" + ) % [theme["display_name"], theme_id, theme["format_version"], status, theme_id] + + +func _on_select_confirmed() -> void: + var selected_index := _theme_options.selected + if selected_index < 0: + return + var theme_id := str(_theme_options.get_item_metadata(selected_index)) + _activate_theme(theme_id) + + +func _show_create_dialog() -> void: + if _busy: + _show_message("Операция уже выполняется.", true) + return + _theme_id_input.clear() + _display_name_input.clear() + _update_create_validation() + _create_dialog.popup_centered_clamped(CREATE_DIALOG_SIZE, 0.9) + _theme_id_input.grab_focus() + + +func _on_create_field_changed(_new_text: String) -> void: + _update_create_validation() + + +func _update_create_validation() -> void: + var theme_id := _theme_id_input.text.strip_edges() + var display_name := _display_name_input.text.strip_edges() + var validation_message := "" + if theme_id.is_empty(): + validation_message = "Введите ID темы." + elif _theme_id_regex.search(theme_id) == null: + validation_message = "ID: строчные латинские буквы, цифры и одиночные дефисы." + elif DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(THEMES_ROOT.path_join(theme_id))): + validation_message = "Тема с таким ID уже существует." + elif display_name.is_empty(): + validation_message = "Введите название темы." + + _create_validation.text = validation_message if not validation_message.is_empty() else "Будет создан themes/%s." % theme_id + _create_validation.modulate = Color(1.0, 0.55, 0.45) if not validation_message.is_empty() else Color(0.55, 0.9, 0.6) + _create_dialog.get_ok_button().disabled = not validation_message.is_empty() + if validation_message.is_empty(): + _create_details.text = ( + "Будет создана тема [b]%s[/b] с ID [code]%s[/code].\n" + + "Источник: [code]themes/example/[/code]\nНазначение: [code]themes/%s/[/code]\n" + + "После копирования тема сразу станет активной и откроется в preview." + ) % [display_name, theme_id, theme_id] + else: + _create_details.text = ( + "Этапы: проверка ID → копирование example → запись manifest.json → " + + "активация темы → открытие preview.\nИсходники будут созданы в themes//package/." + ) + + +func _on_create_confirmed() -> void: + var theme_id := _theme_id_input.text.strip_edges() + var display_name := _display_name_input.text.strip_edges() + if _busy: + return + _create_and_activate_theme(theme_id, display_name) + + +func _show_export_dialog() -> void: + if _busy: + _show_message("Операция уже выполняется.", true) + return + _refresh_export_theme_options() + if _export_theme_options.item_count == 0: + _show_message("В themes/ не найдено ни одной валидной темы manifest v2.", true) + return + _export_theme_dialog.popup_centered_clamped(EXPORT_THEME_DIALOG_SIZE, 0.9) + + +func _refresh_export_theme_options() -> void: + _export_theme_options.clear() + var active_theme_id := _read_active_theme_id() + var selected_index := 0 + for theme in _list_themes(): + var item_index := _export_theme_options.item_count + var theme_id := str(theme["id"]) + _export_theme_options.add_item("%s — ID: %s" % [theme["display_name"], theme_id]) + _export_theme_options.set_item_metadata(item_index, theme_id) + if theme_id == active_theme_id: + selected_index = item_index + _export_theme_options.select(selected_index) + _on_export_theme_changed(selected_index) + + +func _on_export_theme_changed(selected_index: int) -> void: + if selected_index < 0 or selected_index >= _export_theme_options.item_count: + _export_theme_details.text = "" + return + var theme_id := str(_export_theme_options.get_item_metadata(selected_index)) + _export_theme_details.text = ( + _format_theme_details(theme_id, theme_id == _read_active_theme_id()) + + "\n\nЭкспорт этой темы не переключит текущий preview." + ) + + +func _on_export_theme_confirmed() -> void: + var selected_index := _export_theme_options.selected + if selected_index < 0: + return + _pending_export_theme_id = str(_export_theme_options.get_item_metadata(selected_index)) + var theme := _find_theme_record(_pending_export_theme_id) + if theme.is_empty(): + _pending_export_theme_id = "" + _show_message("Выбранная тема больше не найдена. Обновите список и повторите экспорт.", true) + return + _export_file_dialog.title = "Экспортировать «%s» — %s" % [theme["display_name"], _pending_export_theme_id] + _export_file_dialog.current_dir = ProjectSettings.globalize_path("res://build") + _export_file_dialog.current_file = "%s.pck" % _pending_export_theme_id + _export_file_dialog.popup_centered_clamped(EXPORT_FILE_DIALOG_SIZE, 0.9) + + +func _on_export_file_selected(selected_path: String) -> void: + if _pending_export_theme_id.is_empty(): + return + var theme_id := _pending_export_theme_id + _pending_export_theme_id = "" + var output_path := selected_path.simplify_path() + if output_path.get_extension().to_lower() != "pck": + output_path += ".pck" + _start_theme_build(theme_id, output_path, false) + + +func _on_export_canceled() -> void: + _pending_export_theme_id = "" + + +func _show_apply_dialog() -> void: + if _busy: + _show_message("Операция уже выполняется.", true) + return + + var active_theme := _get_active_theme_record() + if active_theme.is_empty(): + _show_message("Сначала выберите тему через «Редактор → Выбрать тему».", true) + return + + var active_theme_id := str(active_theme["id"]) + _pending_apply_theme_id = active_theme_id + _apply_details.text = ( + "[b]%s[/b]\nID: [code]%s[/code]\n\n" + + "Файл назначения:\n[code]%s[/code]\n\n" + + "[color=#ffd27d]Перед применением закройте Exchanger. После установки запустите его заново.[/color]" + ) % [active_theme["display_name"], active_theme_id, _get_default_install_path()] + _apply_dialog.popup_centered_clamped(APPLY_DIALOG_SIZE, 0.9) + + +func _on_apply_confirmed() -> void: + if _busy or _pending_apply_theme_id.is_empty(): + return + var theme_id := _pending_apply_theme_id + _pending_apply_theme_id = "" + _start_theme_build(theme_id, "", true) + + +func _get_active_theme_record() -> Dictionary: + var active_theme_id := _read_active_theme_id() + for theme in _list_themes(): + if str(theme["id"]) == active_theme_id: + return theme + return {} + + +func _start_theme_build(theme_id: String, output_path: String, install_to_exchanger: bool) -> void: + if _busy: + return + _busy = true + _active_build_theme_id = theme_id + _active_build_output_path = output_path + _active_build_installs = install_to_exchanger + _active_progress_path = ProjectSettings.globalize_path( + "res://build/theme-manager-progress-%d.json" % OS.get_process_id() + ).simplify_path() + if FileAccess.file_exists(_active_progress_path): + DirAccess.remove_absolute(_active_progress_path) + _progress_poll_elapsed = 0.0 + var operation_title := "Сборка и применение темы" if install_to_exchanger else "Экспорт темы" + var destination := _get_default_install_path() if install_to_exchanger else output_path + _begin_operation_progress( + operation_title, + "Подготовка запуска…", + "Тема: %s\nНазначение: %s\nОжидание запуска PowerShell-сценария." + % [theme_id, destination], + 2, + ) + + var script_path := ProjectSettings.globalize_path(BUILD_SCRIPT_PATH) + _build_thread = Thread.new() + var start_error := _build_thread.start( + _build_theme_in_thread.bind( + theme_id, + script_path, + output_path, + install_to_exchanger, + _active_progress_path, + ) + ) + if start_error != OK: + _build_thread = null + _busy = false + _clear_active_build() + _finish_operation_progress( + "Не удалось запустить сборку", + "Ошибка запуска фоновой задачи: %s." % error_string(start_error), + true, + ) + return + set_process(true) + + +func _process(delta: float) -> void: + if _build_thread == null: + return + _progress_poll_elapsed += delta + if _progress_poll_elapsed >= BUILD_PROGRESS_POLL_INTERVAL: + _progress_poll_elapsed = 0.0 + _poll_build_progress() + if _build_thread.is_alive(): + return + _poll_build_progress() + var thread_result: Variant = _build_thread.wait_to_finish() + _build_thread = null + set_process(false) + _busy = false + + var result: Dictionary = {} + if thread_result is Dictionary: + result = thread_result + var exit_code := int(result.get("exit_code", -1)) + var output := str(result.get("output", "")) + if exit_code != 0: + var failure_heading := "Применение темы не выполнено" if _active_build_installs else "Экспорт темы не выполнен" + _finish_operation_progress( + failure_heading, + "Код ошибки: %d\n\n%s" % [exit_code, _format_output_tail(output)], + true, + ) + _clear_active_build() + return + + EditorInterface.get_resource_filesystem().scan() + if _active_build_installs: + _finish_operation_progress( + "Тема собрана и применена", + "ID: %s\nФайл: %s\n\nТеперь запустите Exchanger заново." + % [_active_build_theme_id, _get_default_install_path()], + ) + else: + if not FileAccess.file_exists(_active_build_output_path): + _finish_operation_progress( + "Экспорт не подтверждён", + "Сборка завершилась, но экспортированный PCK не найден:\n%s" + % _active_build_output_path, + true, + ) + else: + _finish_operation_progress( + "Тема успешно экспортирована", + "ID: %s\nГотовый PCK:\n%s" + % [_active_build_theme_id, _active_build_output_path], + ) + _clear_active_build() + + +func _build_theme_in_thread( + theme_id: String, + script_path: String, + output_path: String, + install_to_exchanger: bool, + progress_path: String, +) -> Dictionary: + var output: Array = [] + var arguments := PackedStringArray([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + script_path, + "-ThemeId", + theme_id, + "-ProgressPath", + progress_path, + ]) + if not output_path.is_empty(): + arguments.append("-OutputPath") + arguments.append(output_path) + if not install_to_exchanger: + arguments.append("-PreserveActiveTheme") + if install_to_exchanger: + arguments.append("-InstallToDefaultUserPath") + var exit_code := OS.execute( + "powershell.exe", + arguments, + output, + true, + false, + ) + var combined_output := "" + for line in output: + combined_output += str(line) + return { + "exit_code": exit_code, + "output": combined_output.strip_edges(), + } + + +func _clear_active_build() -> void: + if not _active_progress_path.is_empty() and FileAccess.file_exists(_active_progress_path): + DirAccess.remove_absolute(_active_progress_path) + _active_build_theme_id = "" + _active_build_output_path = "" + _active_build_installs = false + _active_progress_path = "" + _progress_poll_elapsed = 0.0 + + +func _get_default_install_path() -> String: + var app_data := OS.get_environment("APPDATA") + if app_data.is_empty(): + return "%APPDATA%\\Godot\\app_userdata\\Exchanger\\theme_dlc\\active_theme.pck" + return app_data.path_join("Godot/app_userdata/Exchanger/theme_dlc/active_theme.pck") + + +func _format_output_tail(output: String, maximum_lines := 10) -> String: + var stripped := output.strip_edges() + if stripped.is_empty(): + return "PowerShell не вернул диагностический вывод." + var lines := stripped.split("\n", false) + var first_line := maxi(0, lines.size() - maximum_lines) + return "\n".join(lines.slice(first_line)) + + +func _poll_build_progress() -> void: + if _active_progress_path.is_empty() or not FileAccess.file_exists(_active_progress_path): + return + var progress := _read_json_dictionary(_active_progress_path) + if progress.is_empty(): + return + var stage_id := str(progress.get("Stage", "build")) + # Windows PowerShell 5.1 читает UTF-8 scripts без BOM через системную + # code page. Поэтому пользовательский текст берём только из GDScript, + # а JSON сборщика используем как ASCII-протокол идентификаторов этапов. + var message := str(BUILD_STAGE_LABELS.get(stage_id, "Выполняется сборка…")) + var percent := int(progress.get("Percent", 0)) + var destination := str(progress.get("OutputPath", _active_build_output_path)) + if _active_build_installs: + destination = _get_default_install_path() + _update_operation_progress( + message, + "Тема: %s\nНазначение: %s\nПрогресс обновляется автоматически." + % [_active_build_theme_id, destination], + percent, + ) + + +func _begin_operation_progress( + title: String, + stage: String, + details: String, + percent: int = 0, +) -> void: + _message_dialog.hide() + _progress_dialog.hide() + _progress_dialog.title = title + _progress_dialog.get_ok_button().disabled = true + _progress_stage.modulate = Color.WHITE + _update_operation_progress(stage, details, percent) + _progress_dialog.popup_centered_clamped(PROGRESS_DIALOG_SIZE, 0.9) + + +func _update_operation_progress(stage: String, details: String, percent: int) -> void: + _progress_stage.text = stage + _progress_bar.value = clampi(percent, 0, 100) + _progress_details.text = details + + +func _finish_operation_progress(title: String, details: String, is_error := false) -> void: + _progress_dialog.title = "Ошибка управления темами" if is_error else title + _progress_stage.text = title + _progress_stage.modulate = Color(1.0, 0.55, 0.45) if is_error else Color(0.55, 0.9, 0.6) + _progress_bar.value = 100.0 + _progress_details.text = details + _progress_dialog.get_ok_button().disabled = false + if not _progress_dialog.visible: + _progress_dialog.popup_centered_clamped(PROGRESS_DIALOG_SIZE, 0.9) + + +func _create_and_activate_theme(theme_id: String, display_name: String) -> void: + _busy = true + _begin_operation_progress( + "Создание темы", + "Проверка параметров новой темы…", + "ID: %s\nНазвание: %s" % [theme_id, display_name], + 5, + ) + var created: bool = await _create_theme(theme_id, display_name) + if not created: + _busy = false + return + await _activate_theme( + theme_id, + "Тема «%s» создана и выбрана." % display_name, + true, + ) + + +func _create_theme(theme_id: String, display_name: String) -> bool: + _update_operation_progress( + "Проверка ID и путей…", + "ID: %s\nПроверяется themes/%s/." % [theme_id, theme_id], + 10, + ) + await get_tree().process_frame + if _theme_id_regex.search(theme_id) == null or display_name.is_empty(): + _finish_operation_progress("Тема не создана", "Некорректные данные новой темы.", true) + return false + + var source_path := ProjectSettings.globalize_path(THEMES_ROOT.path_join(EXAMPLE_THEME_ID)).simplify_path() + var themes_path := ProjectSettings.globalize_path(THEMES_ROOT).simplify_path() + var destination_path := themes_path.path_join(theme_id).simplify_path() + if not DirAccess.dir_exists_absolute(source_path): + _finish_operation_progress("Тема не создана", "Не найдена тема-шаблон themes/example.", true) + return false + if destination_path.get_base_dir() != themes_path or DirAccess.dir_exists_absolute(destination_path): + _finish_operation_progress( + "Тема не создана", + "Небезопасный или уже существующий путь новой темы.", + true, + ) + return false + + _update_operation_progress( + "Копирование темы-шаблона…", + "Источник: themes/example/\nНазначение: themes/%s/" % theme_id, + 30, + ) + await get_tree().process_frame + var copy_error := _copy_directory_recursive(source_path, destination_path) + if copy_error != OK: + _remove_created_theme(destination_path, themes_path) + _finish_operation_progress( + "Тема не создана", + "Не удалось скопировать example: ошибка %s." % error_string(copy_error), + true, + ) + return false + + _update_operation_progress( + "Запись manifest.json…", + "Назначаются ThemeId и DisplayName новой темы.", + 55, + ) + await get_tree().process_frame + var manifest_path := destination_path.path_join("package/manifest.json") + var manifest := _read_json_dictionary(manifest_path) + if manifest.is_empty(): + _remove_created_theme(destination_path, themes_path) + _finish_operation_progress( + "Тема не создана", + "В example отсутствует корректный manifest.json.", + true, + ) + return false + manifest["ThemeId"] = theme_id + manifest["DisplayName"] = display_name + var manifest_file := FileAccess.open(manifest_path, FileAccess.WRITE) + if manifest_file == null: + _remove_created_theme(destination_path, themes_path) + _finish_operation_progress("Тема не создана", "Не удалось записать manifest новой темы.", true) + return false + manifest_file.store_string(JSON.stringify(manifest, " ", false) + "\n") + _update_operation_progress( + "Тема создана. Выполняется активация…", + "Исходники готовы: themes/%s/package/." % theme_id, + 65, + ) + return true + + +func _copy_directory_recursive(source_path: String, destination_path: String) -> Error: + var make_error := DirAccess.make_dir_recursive_absolute(destination_path) + if make_error != OK: + return make_error + var source_directory := DirAccess.open(source_path) + if source_directory == null: + return DirAccess.get_open_error() + source_directory.list_dir_begin() + while true: + var entry_name := source_directory.get_next() + if entry_name.is_empty(): + break + if ( + entry_name == "." + or entry_name == ".." + or entry_name == ".gdignore" + or entry_name.get_extension() == "import" + or entry_name.get_extension() == "uid" + ): + continue + var source_entry := source_path.path_join(entry_name) + var destination_entry := destination_path.path_join(entry_name) + var entry_error := OK + if source_directory.current_is_dir(): + entry_error = _copy_directory_recursive(source_entry, destination_entry) + else: + entry_error = DirAccess.copy_absolute(source_entry, destination_entry) + if entry_error != OK: + source_directory.list_dir_end() + return entry_error + source_directory.list_dir_end() + return OK + + +func _remove_created_theme(destination_path: String, themes_path: String) -> void: + if destination_path.get_base_dir() != themes_path: + return + _remove_directory_recursive(destination_path) + + +func _remove_directory_recursive(directory_path: String) -> void: + var directory := DirAccess.open(directory_path) + if directory == null: + return + directory.list_dir_begin() + while true: + var entry_name := directory.get_next() + if entry_name.is_empty(): + break + if entry_name == "." or entry_name == "..": + continue + var entry_path := directory_path.path_join(entry_name) + if directory.current_is_dir(): + _remove_directory_recursive(entry_path) + else: + DirAccess.remove_absolute(entry_path) + directory.list_dir_end() + DirAccess.remove_absolute(directory_path) + + +func _activate_theme( + theme_id: String, + success_message: String = "", + continuing_operation: bool = false, +) -> void: + if _busy and not continuing_operation: + return + _busy = true + if not continuing_operation: + _begin_operation_progress( + "Выбор темы", + "Подготовка preview…", + "Тема: %s" % theme_id, + 5, + ) + var was_playing := EditorInterface.is_playing_scene() + _update_operation_progress( + "Остановка запущенного preview…" if was_playing else "Подготовка редактора…", + "Тема: %s\nТекущее состояние preview сохранено." % theme_id, + 70 if continuing_operation else 15, + ) + await get_tree().process_frame + if was_playing: + EditorInterface.stop_playing_scene() + await get_tree().process_frame + + _update_operation_progress( + "Активация темы…", + "Обновляется build/active-theme.json для ID %s." % theme_id, + 78 if continuing_operation else 40, + ) + await get_tree().process_frame + var output: Array = [] + var script_path := ProjectSettings.globalize_path(ACTIVATE_SCRIPT_PATH) + var exit_code := OS.execute( + "powershell.exe", + PackedStringArray([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + script_path, + "-ThemeId", + theme_id, + ]), + output, + true, + false, + ) + if exit_code != 0: + _busy = false + _finish_operation_progress( + "Тема не выбрана", + "Не удалось выбрать тему «%s».\n\n%s" % [theme_id, _join_output(output)], + true, + ) + return + + _update_operation_progress( + "Обновление ресурсов редактора…", + "Godot сканирует package выбранной темы и открывает общую preview-сцену.", + 90 if continuing_operation else 75, + ) + EditorInterface.get_resource_filesystem().scan() + await get_tree().process_frame + EditorInterface.open_scene_from_path(PREVIEW_SCENE_PATH) + if was_playing: + _update_operation_progress( + "Повторный запуск preview…", + "Ожидание обновления импортированных ресурсов.", + 96 if continuing_operation else 90, + ) + await get_tree().create_timer(0.6).timeout + EditorInterface.play_main_scene() + + _busy = false + var message := success_message + if message.is_empty(): + message = "Тема «%s» выбрана и открыта напрямую из package." % theme_id + _finish_operation_progress("Тема выбрана", message) + + +func _join_output(output: Array) -> String: + var text := "" + for line in output: + text += str(line) + return text.strip_edges() + + +func _read_json_dictionary(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) + return parsed if parsed is Dictionary else {} + + +func _show_message(message: String, is_error := false) -> void: + _message_dialog.hide() + _message_dialog.get_ok_button().disabled = false + _message_dialog.title = "Ошибка управления темами" if is_error else "Темы Exchanger" + _message_dialog.dialog_text = message + _message_dialog.popup_centered_clamped(MESSAGE_DIALOG_SIZE, 0.9) diff --git a/addons/exchanger_theme_manager/theme_manager_plugin.gd.uid b/addons/exchanger_theme_manager/theme_manager_plugin.gd.uid new file mode 100644 index 0000000..9c64071 --- /dev/null +++ b/addons/exchanger_theme_manager/theme_manager_plugin.gd.uid @@ -0,0 +1 @@ +uid://bnfxnaqsoqeaa diff --git a/addons/godot_ai/LICENSE b/addons/godot_ai/LICENSE new file mode 100644 index 0000000..7806d22 --- /dev/null +++ b/addons/godot_ai/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Godot AI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/addons/godot_ai/README.md b/addons/godot_ai/README.md new file mode 100644 index 0000000..f8a730d --- /dev/null +++ b/addons/godot_ai/README.md @@ -0,0 +1,53 @@ +# Godot AI + +Connect AI assistants to a live Godot editor via the [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP). + +Godot AI bridges Claude Code, Codex, Antigravity, DeepSeek Harness, and other MCP clients with your editor — inspect scenes, create nodes, modify properties, run tests, search project files, and more, all from a prompt. + +## Quick Start + +1. Copy `addons/godot_ai/` into your project's `addons/` folder +2. Enable the plugin: **Project > Project Settings > Plugins > Godot AI** +3. Pick your MCP client in the **Godot AI** dock and press **Configure** + +The plugin auto-starts the MCP server and connects over WebSocket. No manual configuration required. + +## Requirements + +- Godot 4.5+ (4.7+ recommended) +- [uv](https://docs.astral.sh/uv/) (used to install the Python server) +
+ Install uv + + **macOS / Linux:** + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + + **Windows (PowerShell):** + ```powershell + powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" + ``` + + **Homebrew (macOS / Linux):** + ```bash + brew install uv + ``` + + **pipx:** + ```bash + pipx install uv + ``` + + See the [uv install docs](https://docs.astral.sh/uv/getting-started/installation/) for more options. + +
+- An MCP client ([Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [Codex](https://openai.com/index/codex/) | [Antigravity](https://www.antigravity.dev/)) + +## Documentation + +Full documentation, contributing guide, and source code: [github.com/hi-godot/godot-ai](https://github.com/hi-godot/godot-ai) + +## License + +[MIT](LICENSE) diff --git a/addons/godot_ai/client_configurator.gd b/addons/godot_ai/client_configurator.gd new file mode 100644 index 0000000..58ae129 --- /dev/null +++ b/addons/godot_ai/client_configurator.gd @@ -0,0 +1,1879 @@ +@tool +class_name McpClientConfigurator +extends RefCounted + +## Public facade for the MCP client configuration system. +## +## Per-client logic lives in clients/*.gd (one descriptor per client) and is +## dispatched through clients/_registry.gd. This file: +## - owns server-side identifiers (SERVER_NAME, HTTP/WS port helpers) +## - registers the EditorSettings port overrides and resolves the live +## port/URL via `http_port()` / `ws_port()` / `http_url()` +## - keeps server-launch discovery (.venv → uvx → system godot-ai) +## - exposes string-id wrappers around configure / check_status / remove / +## manual_command so callers don't need to touch the registry directly +## +## To add a new client: drop a file in clients/, then preload it in +## clients/_registry.gd. No edits required here. + +const Client := preload("res://addons/godot_ai/clients/_base.gd") +const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd") +const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd") +const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd") +const YamlStrategy := preload("res://addons/godot_ai/clients/_yaml_strategy.gd") +const DshStrategy := preload("res://addons/godot_ai/clients/_dsh_strategy.gd") +const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd") +const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd") +const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd") +const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") + +const SERVER_NAME := "godot-ai" + +## Fallback ports. Live port selection goes through `http_port()` / `ws_port()`, +## which read overrides from EditorSettings first. Users on Windows whose 8000 +## is grabbed by Hyper-V / WSL2 / Docker can pick a different port in +## Editor Settings > Plugins > godot_ai without touching code. See #146 for +## the Windows-reservation diagnostics this is the escape hatch for. +const DEFAULT_HTTP_PORT := 8000 +const DEFAULT_WS_PORT := 9500 +const STARTUP_TRACE_ENV := "GODOT_AI_STARTUP_TRACE" +const MIN_PORT := 1024 +const MAX_PORT := 65535 +## Cap on `can_bind_local_port` probes per `suggest_free_port` call so a +## pathological run of occupied ports can't stall the (cold-path) caller. +## 64 localhost binds are sub-millisecond; finding a free port realistically +## takes one or two probes, so this only bounds the worst case. +const SUGGEST_PORT_MAX_PROBES := 64 +const SETTING_WS_PORT := "godot_ai/ws_port" +const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing" +const SETTING_KEEP_SERVER_ON_EXIT := "godot_ai/keep_server_on_exit" +## External cwd the user can set when an MCP client (Pi, code-server, …) reads +## project-tier config files from a directory this editor can't see. The +## merge-tier strategies include it as an extra `_project_candidate_paths` root +## so project overrides there are detected instead of silently shadowed by a +## global-tier write. Codex-review finding F1. +const SETTING_EXTERNAL_CLIENT_CWD := "godot_ai/external_client_cwd" +const _DISCOVERY_TIMEOUT_MS := 3000 +## Codex launches Windows console-subsystem MCP commands in a visible terminal. +## A GUI-subsystem Python keeps the bridge attached to Codex's redirected MCP +## pipes without allocating a console, then starts console launchers such as +## uvx with CREATE_NO_WINDOW. Keep stdin/stdout/stderr explicit: pythonw can use +## its own inherited pipes, but subprocess defaults do not reliably forward +## them to a child when no console exists. +## This string is a wire format written verbatim into user config `args`. +## Whitespace or formatting changes make every existing Windows entry report +## CONFIGURED_MISMATCH, so changing it is a deliberate migration decision, not +## a refactor. The inline `-c` script is required because the uvx and system +## tiers resolve a system interpreter where `godot_ai` is not importable. +const _WINDOWS_STDIO_BOOTSTRAP := ( + "import subprocess,sys; " + + "raise SystemExit(subprocess.call(sys.argv[1:], stdin=sys.stdin, stdout=sys.stdout, " + + "stderr=sys.stderr, creationflags=0x08000000))" +) + + +## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`. +static func http_port() -> int: + return _read_port_setting(McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT) + + +## Active WebSocket port: user override (if in range) or `DEFAULT_WS_PORT`. +static func ws_port() -> int: + return _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT) + + +static func http_url() -> String: + return "http://127.0.0.1:%d/mcp" % http_port() + + +## Read a URL already captured on the main thread without evaluating the +## EditorSettings-backed fallback unless the snapshot is genuinely incomplete. +static func server_url_from(launch_context: Dictionary) -> String: + if launch_context.has("server_url"): + return str(launch_context["server_url"]) + return http_url() + + +static func _read_port_setting(key: String, default_port: int) -> int: + var es := EditorInterface.get_editor_settings() + if es == null or not es.has_setting(key): + return default_port + var value: int = int(es.get_setting(key)) + if value < MIN_PORT or value > MAX_PORT: + return default_port + return value + + +## Register the port overrides in EditorSettings so they show up in the +## editor's Settings > Plugins section with a range hint. Called once from +## `plugin.gd._enter_tree` before `_start_server` so spawn args see the +## configured values. Safe to call repeatedly — `add_property_info` is +## idempotent and `set_initial_value` only seeds the default. +static func ensure_settings_registered() -> void: + var es := EditorInterface.get_editor_settings() + if es == null: + return + _register_port_setting(es, McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT) + _register_port_setting(es, SETTING_WS_PORT, DEFAULT_WS_PORT) + _register_bool_setting(es, SETTING_STARTUP_TRACE, false) + _register_bool_setting(es, SETTING_KEEP_SERVER_ON_EXIT, false) + _register_client_scope_setting(es) + _register_string_setting(es, SETTING_EXTERNAL_CLIENT_CWD, "") + + +static func _register_port_setting(es: EditorSettings, key: String, default_port: int) -> void: + if not es.has_setting(key): + es.set_setting(key, default_port) + es.set_initial_value(key, default_port, false) + es.add_property_info({ + "name": key, + "type": TYPE_INT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "%d,%d,1" % [MIN_PORT, MAX_PORT], + }) + + +## Surface the CLI registration scope as an enum in Settings > Plugins so it is +## discoverable without hand-editing editor_settings-4.tres. Kept at `user` by +## default; `project` writes the entry into /.mcp.json instead. +static func _register_client_scope_setting(es: EditorSettings) -> void: + var key := McpSettings.SETTING_CLIENT_SCOPE + if not es.has_setting(key): + es.set_setting(key, McpSettings.DEFAULT_CLIENT_SCOPE) + es.set_initial_value(key, McpSettings.DEFAULT_CLIENT_SCOPE, false) + es.add_property_info({ + "name": key, + "type": TYPE_STRING, + "hint": PROPERTY_HINT_ENUM, + "hint_string": ",".join(PackedStringArray(McpSettings.CLIENT_SCOPES)), + }) + + +static func _register_bool_setting(es: EditorSettings, key: String, default_value: bool) -> void: + if not es.has_setting(key): + es.set_setting(key, default_value) + es.set_initial_value(key, default_value, false) + es.add_property_info({ + "name": key, + "type": TYPE_BOOL, + }) + + +static func _register_string_setting(es: EditorSettings, key: String, default_value: String) -> void: + ## Same idempotent set-then-hint dance as the bool helper; `PROPERTY_HINT_NONE` + ## leaves the editor's plain text-input widget in place (the cwd string is + ## freeform). The hint-string slot is required by the API even when empty. + if not es.has_setting(key): + es.set_setting(key, default_value) + es.set_initial_value(key, default_value, false) + es.add_property_info({ + "name": key, + "type": TYPE_STRING, + "hint": PROPERTY_HINT_NONE, + "hint_string": "", + }) + + +static func startup_trace_enabled() -> bool: + ## env_lookup + _editor_setting_lookup for the same worker-thread + ## reason as mode_override (#691). + var raw := McpPathTemplate.env_lookup(STARTUP_TRACE_ENV).strip_edges().to_lower() + if raw == "1" or raw == "true" or raw == "yes" or raw == "on": + return true + var setting: Variant = _editor_setting_lookup(SETTING_STARTUP_TRACE) + if setting != null: + return bool(setting) + return false + + +## keep_server_on_exit (#800): when enabled, editor teardown detaches from +## the managed server instead of killing it, and the spawn env opts the +## server out of both self-reap paths (owner-PID watchdog, session-idle +## backstop) — so MCP clients connected over HTTP stay served across editor +## sessions, and the next editor start adopts the survivor. Off by default: +## "server dies with the editor" stays the shipped behavior. Read via +## _editor_setting_lookup for the same worker-thread reason as +## startup_trace_enabled (#691). +static func keep_server_on_exit() -> bool: + var setting: Variant = _editor_setting_lookup(SETTING_KEEP_SERVER_ON_EXIT) + if setting != null: + return bool(setting) + return false + + +## #691: EditorSettings counterpart of McpPathTemplate.env_lookup. The #678 +## startup walk's discovery worker reaches mode_override() (via +## get_server_command) and EditorInterface / EditorSettings are not +## thread-safe objects. Main thread: live read + mutex-guarded snapshot +## refresh. Worker thread: snapshot only — a never-warmed key reads as +## null (unset), never a live EditorInterface call. Warmed alongside the +## env snapshot in warm_env_snapshot(), which runs on the main thread +## before any worker dispatch. +static var _setting_snapshot := {} +static var _setting_snapshot_mutex := Mutex.new() +## The aggregate MCP status command runs on a worker thread. Keep the same +## main-thread-only LaunchContext contract used by dock workers by publishing a +## deep snapshot whenever capture_launch_context() runs on the main thread. +static var _launch_context_snapshot := {} +static var _launch_context_snapshot_mutex := Mutex.new() + + +static func _editor_setting_lookup(key: String) -> Variant: + if OS.get_thread_caller_id() == OS.get_main_thread_id(): + var live: Variant = null + if Engine.is_editor_hint(): + var es := EditorInterface.get_editor_settings() + if es != null and es.has_setting(key): + live = es.get_setting(key) + _setting_snapshot_mutex.lock() + _setting_snapshot[key] = live + _setting_snapshot_mutex.unlock() + return live + _setting_snapshot_mutex.lock() + var cached: Variant = _setting_snapshot.get(key, null) + _setting_snapshot_mutex.unlock() + return cached + + +## Read the `godot_ai/excluded_domains` EditorSetting as a canonicalized +## comma-separated list (sorted, deduplicated, whitespace-stripped). Returns +## "" when the setting is missing or resolves to an empty set — callers can +## skip appending the flag in that case so older servers that don't know +## `--exclude-domains` don't see an empty argument. +## +## Unknown domain names (e.g. a domain removed since the setting was last +## written) are dropped here, at the single chokepoint both the startup +## flag builder (plugin.gd) and the dock display read — the server's +## `parse_exclude_list` hard-fails on unknown names, so a stale setting +## would otherwise block server startup. +static func excluded_domains() -> String: + var es := EditorInterface.get_editor_settings() + if es == null or not es.has_setting(McpSettings.SETTING_EXCLUDED_DOMAINS): + return "" + return _canonicalize_excluded_domains(str(es.get_setting(McpSettings.SETTING_EXCLUDED_DOMAINS))) + + +## Pure canonicalizer shared by the main-thread LaunchContext capture and +## tests. Unknown domains are dropped for the same startup-safety reason as +## `excluded_domains()` above. +static func _canonicalize_excluded_domains(raw: String) -> String: + var parts := PackedStringArray() + for p in raw.split(","): + var t := p.strip_edges() + if t.is_empty() or parts.find(t) != -1: + continue + if not McpToolCatalog.is_excludable_domain(t): + continue + parts.append(t) + parts.sort() + return ",".join(parts) + + +## Snapshot every EditorSettings-backed value needed to render or verify an +## attach launch command. Main-thread calls refresh the snapshot; worker calls +## return that snapshot without touching EditorInterface (#691). Warm it on the +## main thread before dispatching a worker. +static func capture_launch_context() -> Dictionary: + if OS.get_thread_caller_id() != OS.get_main_thread_id(): + _launch_context_snapshot_mutex.lock() + var cached := _launch_context_snapshot.duplicate(true) + _launch_context_snapshot_mutex.unlock() + return cached + var captured_http_port := http_port() + var context := { + "http_port": captured_http_port, + "ws_port": ws_port(), + "excluded_domains": excluded_domains(), + "plugin_version": get_plugin_version(), + "allow_dev_venv": mode_override() != "user", + "platform": OS.get_name(), + "server_url": "http://127.0.0.1:%d/mcp" % captured_http_port, + "project_roots": capture_project_roots(), + ## The opt-out must ride the attach argv: the client spawns the bridge + ## (and the bridge its backend) with no editor in the loop, so the + ## env-injection path in server_lifecycle.gd never runs for them. + "telemetry_enabled": McpSettings.telemetry_enabled(), + } + _launch_context_snapshot_mutex.lock() + _launch_context_snapshot = context.duplicate(true) + _launch_context_snapshot_mutex.unlock() + return context + + +## Resolve roots used by cwd-relative client project config tiers while the +## engine singletons are safe to access. Workers receive this immutable snapshot. +static func capture_project_roots() -> PackedStringArray: + var current_access := DirAccess.open(".") + var current_root := "" if current_access == null else current_access.get_current_dir().simplify_path() + var project_root := ProjectSettings.globalize_path("res://").simplify_path() + # Codex F1: when the user sets `godot_ai/external_client_cwd` we probe that + # directory in addition to the two guessed roots. The setter / setter-snapshot + # pathway is the same as the existing EditorSettings (warm_env_snapshot + # pre-warms this key so worker-thread callers see the snapshot, not the live + # EditorInterface). Empty string is filtered out by `_canonicalize_roots_for_test`. + var external_cwd := str(_editor_setting_lookup(SETTING_EXTERNAL_CLIENT_CWD)) + return _canonicalize_roots_for_test(PackedStringArray([current_root, project_root, external_cwd])) + + +## Canonicalize a list of root paths to one filesystem representation per +## directory and deduplicate. `ProjectSettings.globalize_path` maps any +## `res://` / `user://` form to the absolute path and is idempotent on an +## already-absolute path, so a `res://`-form candidate and its absolute twin +## collapse to the same entry. Without this pass, `_project_candidate_paths` +## would probe `/.pi/mcp.json` against both representations of the same +## directory and `manual_target_details` would mis-report a single project +## override as multiple tiers (codex-review finding F4). Public-ish seam +## (named `_..._for_test`) so the clients suite can pin the behaviour without +## having to mock `DirAccess.open(".")`. +static func _canonicalize_roots_for_test(raws: PackedStringArray) -> PackedStringArray: + var seen := {} + var roots := PackedStringArray() + for raw in raws: + var s := str(raw).strip_edges() + if s.is_empty(): + continue + var canon := ProjectSettings.globalize_path(s).simplify_path() + if canon.is_empty() or seen.has(canon): + continue + seen[canon] = true + roots.append(canon) + return roots + + +static func _project_roots_from_context(context: Dictionary) -> PackedStringArray: + var roots = context.get("project_roots", PackedStringArray()) + return roots if roots is PackedStringArray else PackedStringArray() + + +## Read the `godot_ai/allow_remote_hosts` EditorSetting as a canonicalized +## comma-separated list of CIDRs / bare IPs (#507). Returns "" when the +## setting is missing or empty — callers skip appending `--allow-host` in +## that case so spawns stay byte-for-byte identical to the loopback-only +## default (and compatible with pre-#421 servers). Mirrors +## `excluded_domains()` above. +static func allow_hosts() -> String: + var es := EditorInterface.get_editor_settings() + if es == null or not es.has_setting(McpSettings.SETTING_ALLOW_HOSTS): + return "" + return McpAllowHosts.normalize(str(es.get_setting(McpSettings.SETTING_ALLOW_HOSTS))) + + +## Suggest a port the caller can actually switch to. Walks +## `candidate`..`candidate+span-1` and returns the first port that is both +## (a) NOT inside a Windows winnat reservation range (Hyper-V / WSL2 / Docker +## grab these; bind fails with WinError 10013 and netstat shows nothing) and +## (b) actually bindable right now on 127.0.0.1. The bind probe is what makes +## "free" honest on macOS/Linux, where the reservation table is empty but the +## next port up may still be occupied — the same suggestion feeds the dock +## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE +## log line. Falls back to the clamped candidate if nothing in the window +## clears both checks (caller surfaces it as a best-effort hint; the user can +## retry or pick another). Best-effort by nature: a TOCTOU window remains +## between the probe and the caller actually binding the port. The bind probe +## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't +## stall on a pathological run of occupied ports. +static func suggest_free_port(start: int, span: int = 2048) -> int: + var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1) + var limit := mini(candidate + span - 1, MAX_PORT) + var p := candidate + var probes := 0 + while p <= limit and probes < SUGGEST_PORT_MAX_PROBES: + ## Jump past a whole Windows-reserved range in one step (no-op on + ## POSIX: returns `p` unchanged), so we don't probe port-by-port + ## through the large adjacent ranges those services reserve. The + ## jump itself runs no bind probes, so it doesn't count against the cap. + var not_reserved := WindowsPortReservation.suggest_non_excluded_port(p, limit - p + 1, MAX_PORT) + if not_reserved < p or not_reserved > limit: + break + p = not_reserved + probes += 1 + if PortResolver.can_bind_local_port(p): + return p + p += 1 + return candidate + + +# --- Client operations (string id) --------------------------------------- + +static func client_ids() -> PackedStringArray: + return ClientRegistry.ids() + + +static func has_client(id: String) -> bool: + return ClientRegistry.has_id(id) + + +static func client_display_name(id: String) -> String: + var c := ClientRegistry.get_by_id(id) + return c.display_name if c != null else id + + +## Pass an explicit `url` when calling from a worker thread: `http_url()` +## reads `EditorInterface.get_editor_settings()`, which is main-thread-only. +## Empty defaults to the live server URL — appropriate for MCP-tool callers +## that always run on main. +static func configure(id: String, url: String = "", launch_context: Dictionary = {}) -> Dictionary: + if ClientRegistry.stale_session_detected(): + return {"status": "error", "message": ClientRegistry.RESTART_TO_FINISH_UPDATE} + var client := ClientRegistry.get_by_id(id) + if client == null: + return {"status": "error", "message": "Unknown client: %s" % id} + var path_error := _config_path_resolution_error(client) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + ## Capture `url` once so a port flip in EditorSettings between write and + ## verify can't trigger a spurious CONFIGURED_MISMATCH against an entry + ## that just landed correctly. + if url.is_empty(): + url = http_url() + var context := launch_context + if client.command_shape != Client.CommandShape.NONE and context.is_empty(): + if OS.get_thread_caller_id() != OS.get_main_thread_id(): + return { + "status": "error", + "message": "Cannot configure %s without a main-thread launch snapshot; retry from the dock." % client.display_name, + } + context = capture_launch_context() + var launch := ( + resolve_attach_launch(context) + if client.command_shape != Client.CommandShape.NONE + else {} + ) + var result := _dispatch_configure(client, url, launch, context) + ## Trust-but-verify: a strategy may report ok and have actually written the + ## file, yet the entry is missing/stale on the read-back path — most often + ## because the user's installed client is reading a different file than + ## `path_template` resolves to (issue #201). Re-read the live state and + ## surface a clear error before the dock reports a bogus green dot. + return _verify_post_state(client, result, Client.Status.CONFIGURED, url, "configure", launch, context) + + +static func check_status(id: String) -> Client.Status: + if ClientRegistry.stale_session_detected(): + return Client.Status.ERROR + var client := ClientRegistry.get_by_id(id) + if client == null: + return Client.Status.NOT_CONFIGURED + var context := capture_launch_context() if client.command_shape != Client.CommandShape.NONE else {} + return _dispatch_check_status(client, http_url(), context) + + +static func check_status_for_url_with_cli_path( + id: String, url: String, cli_path: String, launch_context: Dictionary = {} +) -> Client.Status: + return check_status_details_for_url_with_cli_path(id, url, cli_path, launch_context).get("status", Client.Status.NOT_CONFIGURED) + + +## True when 's stored entry verifies EXACTLY against the launch this +## editor would have rendered at `from_version` — same ports, exclusions, +## telemetry flag, command shape; nothing differs but the version pin. This +## is the post-update auto-repin gate: only such entries are provably "what +## Configure wrote before the update", so rewriting them to the current +## version restores the user's own prior intent. Anything else — entries +## pointing at another editor's ports (the smoke-fixture blast radius that +## motivated this gate), hand-edits, changed settings — stays untouched for +## the drift banner's human click. An empty `from_version` (marker written +## by a pre-gate runner) fails closed. +## +## The version-substituted context is safe to resolve and cache: +## `plugin_version` is part of both the attach-launch resolution +## (`_resolve_attach_launch_uncached`) and its cache key. +static func entry_drift_is_version_pin_only( + id: String, from_version: String, launch_context: Dictionary = {} +) -> bool: + var pinned_from := from_version.strip_edges() + if pinned_from.is_empty(): + return false + ## Never shell out from this gate — it runs on the MAIN thread from the + ## dock's sweep-completion callback (#890 review P2). The cli-descriptor + ## probe paths run a subprocess with a multi-second timeout (`claude mcp + ## get` is 6s), which would freeze the editor; those clients fail closed + ## to the drift banner's click flow, whose fan-out already runs on + ## worker threads. Every path left after these guards is a bounded + ## config-file read plus cached launch discovery (worst case one CLI + ## lookup on a cold CliFinder cache — the same bounded one-shot the + ## dock's `_reprobe_uv_if_negative` accepts on this thread). + var client := ClientRegistry.get_by_id(id) + if client == null: + return false + if client.config_type == "cli": + if client.command_shape == Client.CommandShape.NONE: + return false + if not client.has_json_fallback(): + return false + if _scope_diverges_from_json_fallback(client): + return false + var context := launch_context if not launch_context.is_empty() else capture_launch_context() + var old_context := context.duplicate(true) + old_context["plugin_version"] = pinned_from + ## Same URL under both versions: the URL carries the port, not the + ## version, so URL-mode entries can never be version-pin-only drift and + ## correctly fail this check. + var url := str(context.get("server_url", http_url())) + var status := check_status_for_url_with_cli_path(id, url, "", old_context) + return status == Client.Status.CONFIGURED + + +## Detailed variant used by the dock refresh worker. Returns +## `{"status": Status, "error_msg": String}` so the worker can surface +## "probe timed out" on the row instead of silently flipping it to +## NOT_CONFIGURED. Callers that only need the status can use the simpler +## helper above. +static func check_status_details_for_url_with_cli_path( + id: String, + url: String, + cli_path: String, + launch_context: Dictionary = {}, + resolved_launch: Dictionary = {}, +) -> Dictionary: + ## One comprehensible line per row beats a wall of per-field type errors — + ## the dock keeps painting, every row names the same repair (restart). + if ClientRegistry.stale_session_detected(): + return {"status": Client.Status.ERROR, "error_msg": ClientRegistry.RESTART_TO_FINISH_UPDATE} + var client := ClientRegistry.get_by_id(id) + if client == null: + return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""} + # A cli client with no resolved binary normally reads as NOT_CONFIGURED. + # Skip that shortcut when the client has a JSON fallback (#463): the + # dispatch below reads its config file directly so the status dot reflects + # a fallback-configured entry instead of always showing red. + if client.config_type == "cli" and cli_path.is_empty() and not client.has_json_fallback(): + return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""} + var path_error := _config_path_resolution_error(client) + if not path_error.is_empty(): + return {"status": Client.Status.ERROR, "error_msg": path_error} + if client.command_shape != Client.CommandShape.NONE and launch_context.is_empty(): + return { + "status": Client.Status.ERROR, + "error_msg": "Missing launch-context snapshot; retry the status refresh.", + } + return _dispatch_check_status_with_cli_path_details( + client, url, cli_path, launch_context, resolved_launch + ) + + +## #691: main-thread pre-warm of McpPathTemplate's env snapshot, covering +## the base vars plus every descriptor-declared config-file/config-home env +## (OPENCODE_CONFIG, CLAUDE_CONFIG_DIR, CODEX_HOME, …), so worker-thread config-path +## resolution never calls OS.get_environment concurrently with the spawn +## window's setenv/unsetenv. Also warms the EditorSettings snapshot for +## the mode/trace overrides so worker-thread mode_override() / +## startup_trace_enabled() never touch EditorInterface. Idempotent; +## called from plugin _enter_tree and before each dock worker dispatch. +static func warm_env_snapshot() -> void: + var extras := PackedStringArray() + for id in client_ids(): + var client := ClientRegistry.get_by_id(String(id)) + if client == null: + continue + ## Reflected get(): after an in-session self-update these fields can + ## read as Nil on stale instances, and String(Nil) is a hard error (#850). Skipping just degrades env-override + ## resolution until the restart the registry is already asking for. + for env_name in [client.get("config_file_env"), client.get("config_home_env")]: + if env_name is String and not env_name.is_empty() and not extras.has(env_name): + extras.append(env_name) + McpPathTemplate.warm_env_snapshot(extras) + _editor_setting_lookup(MODE_OVERRIDE_SETTING) + _editor_setting_lookup(SETTING_STARTUP_TRACE) + _editor_setting_lookup(SETTING_KEEP_SERVER_ON_EXIT) + _editor_setting_lookup(SETTING_EXTERNAL_CLIENT_CWD) + McpSettings.warm_client_scope() + # Publish the complete launch context while EditorInterface access is safe; + # worker callers of capture_launch_context() read this snapshot only. + capture_launch_context() + + +static func client_status_probe_snapshot(id: String) -> Dictionary: + var client := ClientRegistry.get_by_id(id) + if client == null: + return {} + var cli_path := "" + var installed := false + if client.config_type == "cli": + cli_path = CliStrategy.resolve_cli_path(client) + # #463: a JSON-fallback cli client (Claude Code as a VS Code extension) + # is "installed" when its fallback config exists, even with no binary. + installed = not cli_path.is_empty() or client.is_installed() + else: + installed = client.is_installed() + return {"id": id, "cli_path": cli_path, "installed": installed} + + +## Force lazy GDScript bytecode swaps to complete before a client-status +## worker reaches the registry and strategies. Pure-memory only: callers can +## run this on the handler thread without performing CLI or config probes. +static func warm_status_worker_bytecode() -> void: + var ids := client_ids() + if ids.is_empty(): + return + var any_client := ClientRegistry.get_by_id(String(ids[0])) + if any_client != null: + JsonStrategy.verify_entry(any_client, {}, "") + DshStrategy.command_launch_error(any_client, {}) + TomlStrategy.format_body(PackedStringArray(), "") + CliStrategy.format_args(PackedStringArray(), "", "") + # Compile the aggregate worker entry point on main as well. After a plugin + # reload, first-dereferencing this function from Thread can hang in Godot's + # lazy bytecode swap even when every strategy it calls was already warmed. + run_client_status_sweep({}, true) + + +## Worker entry point for the MCP aggregate status command. Every filesystem, +## CLI, and launch-discovery probe stays inside this function; the WebSocket +## handler only schedules it and returns the deferred sentinel. +static func run_client_status_sweep( + fallback_launch_context: Dictionary = {}, warm_only: bool = false +) -> Dictionary: + if warm_only: + return {} + var clients := [] + var launch_context := capture_launch_context() + if launch_context.is_empty(): + launch_context = fallback_launch_context.duplicate(true) + if launch_context.is_empty(): + return {"worker_error": "Client status launch context was not warmed on the main thread."} + var server_url := server_url_from(launch_context) + var resolved_launch := resolve_attach_launch(launch_context) + for client_id in client_ids(): + var probe := client_status_probe_snapshot(client_id) + var details := check_status_details_for_url_with_cli_path( + client_id, + server_url, + str(probe.get("cli_path", "")), + launch_context, + resolved_launch, + ) + clients.append(_client_status_sweep_entry( + client_id, details, bool(probe.get("installed", false)) + )) + return {"data": {"clients": clients}} + + +static func _client_status_sweep_entry( + client_id: String, details: Dictionary, installed: bool +) -> Dictionary: + var status = details.get("status", Client.Status.NOT_CONFIGURED) + var entry := { + "id": client_id, + "display_name": client_display_name(client_id), + "status": Client.status_label(status), + "installed": installed, + } + var error_msg := str(details.get("error_msg", "")) + if not error_msg.is_empty(): + entry["error"] = error_msg + return entry + + +## Pass an explicit `url` when calling from a worker thread — see +## `configure()` above for why. The url is only used to format the +## verify-after-write diagnostic message; the remove itself doesn't need it. +static func remove(id: String, url: String = "", launch_context: Dictionary = {}) -> Dictionary: + if ClientRegistry.stale_session_detected(): + return {"status": "error", "message": ClientRegistry.RESTART_TO_FINISH_UPDATE} + var client := ClientRegistry.get_by_id(id) + if client == null: + return {"status": "error", "message": "Unknown client: %s" % id} + var path_error := _config_path_resolution_error(client) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if url.is_empty(): + url = http_url() + var context := launch_context + if client.command_shape != Client.CommandShape.NONE and context.is_empty(): + if OS.get_thread_caller_id() != OS.get_main_thread_id(): + return { + "status": "error", + "message": "Cannot remove %s without a main-thread launch snapshot; retry from the dock." % client.display_name, + } + context = capture_launch_context() + var launch := ( + resolve_attach_launch(context) + if client.command_shape != Client.CommandShape.NONE + else {} + ) + var result := _dispatch_remove(client, context) + return _verify_post_state(client, result, Client.Status.NOT_CONFIGURED, url, "remove", launch, context) + + +## Resolve config-backed path errors before attach-launch discovery. This both +## gives ambiguity precedence over unrelated launcher failures and avoids +## spending the status worker's command budget on a Configure action that must +## fail closed regardless. CLI clients keep their existing CLI/fallback dispatch. +static func _config_path_resolution_error(client: Client) -> String: + if client.config_type == "cli": + return "" + return str(client.resolved_config_path_details().get("error", "")) + + +# --- Strategy dispatch + verify (testable seam) -------------------------- + +static func _dispatch_configure( + client: Client, url: String, launch: Dictionary = {}, launch_context: Dictionary = {} +) -> Dictionary: + launch = launch_for_client(client, launch) + match client.config_type: + "json": + return JsonStrategy.configure(client, SERVER_NAME, url, launch, _project_roots_from_context(launch_context)) + "toml": + return TomlStrategy.configure(client, SERVER_NAME, url, launch) + "yaml": + return YamlStrategy.configure(client, SERVER_NAME, url, launch) + "dsh": + return DshStrategy.configure(client, SERVER_NAME, url, launch) + "cli": + # #463: fall back to writing the config file directly when the CLI + # binary isn't on PATH (Claude Code as a VS Code/Cursor extension). + if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty(): + return _note_unhonoured_scope( + client, JsonStrategy.configure(client, SERVER_NAME, url, launch, _project_roots_from_context(launch_context)) + ) + return CliStrategy.configure(client, SERVER_NAME, url, launch) + return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]} + + +static func _dispatch_remove(client: Client, launch_context: Dictionary = {}) -> Dictionary: + match client.config_type: + "json": + return JsonStrategy.remove(client, SERVER_NAME, _project_roots_from_context(launch_context)) + "toml": + return TomlStrategy.remove(client, SERVER_NAME) + "yaml": + return YamlStrategy.remove(client, SERVER_NAME) + "dsh": + return DshStrategy.remove(client, SERVER_NAME) + "cli": + # #463: mirror the configure fallback so Remove also works without + # the CLI binary — otherwise a fallback-written entry is unremovable. + if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty(): + return JsonStrategy.remove(client, SERVER_NAME, _project_roots_from_context(launch_context)) + return CliStrategy.remove(client, SERVER_NAME) + return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]} + + +static func _dispatch_check_status( + client: Client, url: String, launch_context: Dictionary = {} +) -> Client.Status: + return _dispatch_check_status_with_cli_path(client, url, "", launch_context) + + +static func _dispatch_check_status_with_cli_path( + client: Client, url: String, cli_path: String, launch_context: Dictionary = {} +) -> Client.Status: + return _dispatch_check_status_with_cli_path_details(client, url, cli_path, launch_context).get("status", Client.Status.NOT_CONFIGURED) + + +static func _dispatch_check_status_with_cli_path_details( + client: Client, + url: String, + cli_path: String, + launch_context: Dictionary = {}, + resolved_launch: Dictionary = {}, +) -> Dictionary: + match client.config_type: + "json": + var launch := {} + if client.command_shape != Client.CommandShape.NONE: + launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return JsonStrategy.check_status_details(client, SERVER_NAME, url, launch, _project_roots_from_context(launch_context)) + "toml": + var launch := {} + if client.command_shape != Client.CommandShape.NONE: + launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return TomlStrategy.check_status_details(client, SERVER_NAME, url, launch) + "yaml": + var yaml_launch := {} + if client.command_shape != Client.CommandShape.NONE: + yaml_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return YamlStrategy.check_status_details(client, SERVER_NAME, url, yaml_launch) + "dsh": + var dsh_launch := {} + if client.command_shape != Client.CommandShape.NONE: + dsh_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return DshStrategy.check_status_details(client, SERVER_NAME, url, dsh_launch) + "cli": + # Command-shape CLI clients register through their CLI, but the entry + # lands in the same file the JSON fallback reads (`claude mcp add + # --scope user` writes mcpServers in ~/.claude.json). Reading that + # file gives exact launch-drift detection — a changed port, version + # pin, or exclusion list — which scanning `mcp list` stdout cannot, + # so it is preferred even when the CLI binary resolves. + # #872: ...but only while the selected scope is still the one + # `path_template` points at — see _scope_diverges_from_json_fallback. + if ( + client.command_shape != Client.CommandShape.NONE + and client.has_json_fallback() + and not _scope_diverges_from_json_fallback(client) + ): + var command_launch := _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return JsonStrategy.check_status_details(client, SERVER_NAME, url, command_launch, _project_roots_from_context(launch_context)) + var resolved_cli := cli_path if not cli_path.is_empty() else CliStrategy.resolve_cli_path(client) + # #463: with no CLI binary, read the JSON fallback config so a + # fallback-configured entry reports CONFIGURED instead of red. + if resolved_cli.is_empty() and client.has_json_fallback(): + var fallback_launch := {} + if client.command_shape != Client.CommandShape.NONE: + fallback_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return JsonStrategy.check_status_details(client, SERVER_NAME, url, fallback_launch, _project_roots_from_context(launch_context)) + var cli_launch := {} + if client.command_shape != Client.CommandShape.NONE: + cli_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + # #872: at a scope `path_template` can't see, a plain listing probe + # cannot tell our entry from a leftover one in another scope. When + # the descriptor offers a scope-aware probe, use it — same single + # subprocess, but it reports which scope actually resolved. + if ( + not client.cli_scope_status_template.is_empty() + and CliStrategy.uses_scope_token(client) + ): + return CliStrategy.check_scope_status_details( + client, SERVER_NAME, url, resolved_cli, cli_launch, McpSettings.client_scope() + ) + return CliStrategy.check_status_details(client, SERVER_NAME, url, resolved_cli, cli_launch) + return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""} + + +## #872: the #463 JSON fallback writes the user-scope file whatever +## `godot_ai/mcp_client_scope` says — that file IS `path_template`, and +## `clients/_path_template.gd` has no project-relative token to aim it +## elsewhere. Returning a bare "configured" would hide that the requested +## scope was not honoured, so say so in the message. +## +## Deliberately a message and not a status. `_verify_post_state` compares the +## post-write status against CONFIGURED and replaces the ok with an error on +## any mismatch (pinned by test_verify_post_state_treats_drift_as_failure_ +## after_configure), so reporting this write as NOT_CONFIGURED or MISMATCH +## would turn a *successful* configure into a user-facing failure — the same +## false-error class #872's blocker was about, just moved onto the fallback +## path. The entry is genuinely written and genuinely works; only its scope +## differs from the request, and that is a caveat, not a failure. +static func _note_unhonoured_scope(client: Client, result: Dictionary) -> Dictionary: + if result.get("status") != "ok": + return result + if not CliStrategy.uses_scope_token(client): + return result + var scope := McpSettings.client_scope() + if scope == McpSettings.DEFAULT_CLIENT_SCOPE: + return result + var noted := result.duplicate(true) + noted["message"] = ( + "%s — wrote %s scope, not %s: the %s CLI wasn't found, and the file fallback can only write %s" + % [ + str(result.get("message", "")), + McpSettings.DEFAULT_CLIENT_SCOPE, + scope, + client.display_name, + client.resolved_config_path(), + ] + ) + return noted + + +## True when this client's CLI writes its entry somewhere `path_template` +## cannot see, so reading that file would describe the wrong thing (#872). +## +## `claude mcp add --scope project` writes /.mcp.json and `--scope local` +## writes a per-project block inside ~/.claude.json — neither is the top-level +## `mcpServers` map `path_template` + `server_key_path` resolve to. Reading it +## anyway makes `_verify_post_state` turn every successful project-scope +## Configure into "configure ok but verification still reads Not configured", +## and pins the dock row red (or green, describing a stale user-scope entry). +## +## Diverging sends status down the CLI probe instead: `mcp list` is +## scope-agnostic and inherits the same cwd the register ran in, so read-back +## and write agree by construction. The cost is losing exact launch-drift +## detection — a stdout scan sees the command, not the full argv — which is the +## documented trade for a status that is merely coarse instead of wrong. +## +## Deliberately says nothing about whether the CLI resolves (#879). An earlier +## version walked PATH here and returned false for an unresolvable binary, on +## the reasoning that configure would then have taken the #463 JSON fallback — +## which writes user scope whatever the setting says — so the file was the +## right thing to read again. That extra clause could not change any outcome: +## this is only consulted from the one call site below, already guarded on +## `has_json_fallback()`, and when the CLI does not resolve the very next +## branch (`resolved_cli.is_empty() and client.has_json_fallback()`) takes the +## fallback read anyway. All it bought was a second full `McpCliFinder.find` +## sweep per status check, on top of the one that immediately follows. +static func _scope_diverges_from_json_fallback(client: Client) -> bool: + if not CliStrategy.uses_scope_token(client): + return false + return McpSettings.client_scope() != McpSettings.DEFAULT_CLIENT_SCOPE + + +static func _resolved_or_discovered_launch( + client: Client, resolved_launch: Dictionary, launch_context: Dictionary +) -> Dictionary: + var launch := ( + resolved_launch + if not resolved_launch.is_empty() + else resolve_attach_launch(launch_context) + ) + return launch_for_client(client, launch) + + +## After a configure/remove returns ok, re-read the live status. If it doesn't +## match `expected`, replace the result with an error that names the actual +## status and the resolved config path so the user can self-diagnose. The +## strategy's own error path is left untouched — already actionable. +static func _verify_post_state( + client: Client, + result: Dictionary, + expected: Client.Status, + url: String, + action: String, + resolved_launch: Dictionary = {}, + launch_context: Dictionary = {}, +) -> Dictionary: + if result.get("status") != "ok": + return result + var details := _dispatch_check_status_with_cli_path_details( + client, url, "", launch_context, resolved_launch + ) + var actual := details.get("status", Client.Status.NOT_CONFIGURED) + if actual == expected: + return result + var path_hint := _post_state_path_hint(client, str(details.get("resolved_scope", ""))) + return { + "status": "error", + "message": "%s reported %s ok but verification still reads %s (expected %s).%s" % [ + client.display_name, action, + Client.status_label(actual), Client.status_label(expected), + path_hint, + ], + } + + +## Where to send the user when verification finds an entry that should not be +## there. `resolved_config_path()` is `path_template` — right only for the +## default user scope. Naming ~/.claude.json for a project-scope survivor sends +## them to a file with nothing in it, which is worse than naming no file at all +## (#879). The scope probe supplies `resolved_scope`; `path_template` has no +## project-relative token (see `_note_unhonoured_scope`), so the project case is +## described rather than resolved to a path. +static func _post_state_path_hint(client: Client, resolved_scope: String) -> String: + match resolved_scope: + "project": + return ( + " The surviving entry is project-scoped: inspect the .mcp.json in the" + + " directory the editor was launched from and remove the godot-ai entry" + + " by hand if needed." + ) + "local": + var local_path := client.resolved_config_path() + if local_path.is_empty(): + return "" + return ( + " The surviving entry is local-scoped: inspect the block for the" + + " directory the editor was launched from in %s and remove the" + + " godot-ai entry by hand if needed." + ) % local_path + _: + var path := client.resolved_config_path() + if path.is_empty(): + return "" + return " Inspect %s and remove the godot-ai entry by hand if needed." % path + + +## #877: Configure's first act is the all-scope pre-cleanup — it deletes the +## `godot-ai` entry from every scope the descriptor can write to, including a +## `.mcp.json` resolved against the CLI's working directory, which need not be +## this project's folder. The manual-command panel that spells those removes +## out is only shown when Configure FAILS (`mcp_dock.gd`), so on the success +## path — the one where the sweep actually ran — this note is its only +## disclosure. Empty for descriptors without a scope token: their single +## implicit pass removes exactly the entry the register is about to rewrite, +## which needs no warning (the same rule `_sweep_caveat` applies). +## +## "attempted", not "cleared": `CliStrategy.configure` discards every +## pre-cleanup result, and `mcp remove` exits non-zero for an absent entry as +## well as for a real failure, so a timed-out or failed remove leaves the scope +## untouched while the register still returns ok. The note names what ran, +## not what it can prove. +static func configure_sweep_note(id: String) -> String: + var client := ClientRegistry.get_by_id(id) + if client == null or client.config_type != "cli": + return "" + if client.cli_unregister_template.is_empty() or not CliStrategy.uses_scope_token(client): + return "" + return "attempted to clear %s from %s" % [ + SERVER_NAME, ", ".join(CliStrategy.cleanup_scopes(client)), + ] + + +static func manual_command(id: String) -> String: + var client := ClientRegistry.get_by_id(id) + if client == null: + return "" + var path_resolution := client.resolved_config_path_details() + var path_error := str(path_resolution.get("error", "")) + if not path_error.is_empty(): + return "Config path unavailable: %s" % path_error + var context := capture_launch_context() if client.command_shape != Client.CommandShape.NONE else {} + var launch := ( + launch_for_client(client, resolve_attach_launch(context)) + if client.command_shape != Client.CommandShape.NONE + else {} + ) + var cmd := ManualCommand.build( + client, + SERVER_NAME, + server_url_from(context), + str(path_resolution.get("path", "")), + launch, + _project_roots_from_context(context), + ) + if cmd.is_empty(): + return cmd + ## #507: when the allow-host opt-in names a non-loopback range, also + ## surface the LAN URL so the user can copy-paste the right address into + ## a remote agent. Informational only — configure/remove still WRITE the + ## loopback URL above; nothing about the config-file contract changes. + var note := McpAllowHosts.lan_url_note(allow_hosts(), IP.get_local_addresses(), http_port()) + if not note.is_empty(): + cmd += "\n\n" + note + return cmd + + +static func config_path(id: String) -> String: + var client := ClientRegistry.get_by_id(id) + return client.resolved_config_path() if client != null else "" + + +## Resolve the highest-precedence config tier that actually contains the server +## entry. For Pi-style merge clients, returns the tier path the entry lives in +## (e.g. `~/.pi/agent/.mcp.json`) instead of the lowest-tier `path_template` +## `config_path()` returns. Falls back to `path_template` when no entry is +## found anywhere. Empty `launch_context` is allowed; command-shape clients +## synthesize one via `capture_launch_context()` so dock row construction can +## call this without first capturing launch state. +static func effective_config_path(id: String, launch_context: Dictionary = {}) -> String: + var client := ClientRegistry.get_by_id(id) + if client == null: + return "" + var resolution := client.resolved_config_path_details() + var fallback := str(resolution.get("path", "")) + var context := launch_context + if context.is_empty() and client.command_shape != Client.CommandShape.NONE: + context = capture_launch_context() + var roots := _project_roots_from_context(context) + var target := JsonStrategy.manual_target_details(client, SERVER_NAME, fallback, roots) + if bool(target.get("ok", false)): + return str(target.get("path", fallback)) + return fallback + + +## Path that `_check_status_merged` would consider authoritative for the +## server entry, or `path_template` when no entry is found anywhere. +## For Pi-style merge clients this is the latest project tier when one +## exists (matching the F2 last-wins status logic), falling back to the +## latest global tier, then to `path_template`. +## +## The dock uses this for Open/Reveal so the file the user inspects is +## the same file that drives the status check — `effective_config_path` +## fails closed (returns `path_template`) when there are multiple project +## tiers, which historically sent users to the wrong file (codex round 3, +## F-3-4). Splitting this from `effective_config_path` keeps the manual +## instructions fail-closed while letting the dock follow the +## authoritative tier. +static func effective_authoritative_path(id: String, launch_context: Dictionary = {}) -> String: + var client := ClientRegistry.get_by_id(id) + if client == null: + return "" + var resolution := client.resolved_config_path_details() + var fallback := str(resolution.get("path", "")) + var context := launch_context + if context.is_empty() and client.command_shape != Client.CommandShape.NONE: + context = capture_launch_context() + var roots := _project_roots_from_context(context) + var authoritative: String = JsonStrategy.authoritative_tier_path(client, SERVER_NAME, roots) + if not authoritative.is_empty(): + return authoritative + return fallback + + +static func is_installed(id: String) -> bool: + var client := ClientRegistry.get_by_id(id) + return client != null and client.is_installed() + + +# --- Server command discovery -------------------------------------------- +# +# Three-tier resolution: +# 1. .venv python — dev checkout, source code +# 2. uvx — user install, published package from PyPI +# 3. godot-ai CLI — system-wide pip/pipx/uv install + +static func get_plugin_version() -> String: + var cfg := ConfigFile.new() + if cfg.load("res://addons/godot_ai/plugin.cfg") == OK: + return cfg.get_value("plugin", "version", "0.0.1") + return "0.0.1" + + +## Strip PEP 440 local build metadata for PyPI pins: `3.0.2+local.1` → `3.0.2`. +## Pre-release segments (`3.1.0-rc1`) are preserved — only `+…` is removed. +static func _pypi_pin_version(version: String) -> String: + var v := version.strip_edges() + var plus := v.find("+") + if plus >= 0: + v = v.substr(0, plus) + return v + + +## Resolve the client-owned `godot-ai attach` command from a main-thread +## LaunchContext. Discovery itself is worker-safe: path/environment lookup is +## snapshot-backed and subprocess probes are wall-clock bounded. +## +## `discovery_override` is a data-only test seam. Supplying a key (including +## an empty value) bypasses that tier's live lookup; `system_version_result` +## bypasses the real `godot-ai --version` subprocess. +static func resolve_attach_launch( + launch_context: Dictionary, discovery_override: Dictionary = {} +) -> Dictionary: + ## Test overrides always bypass the session cache so fixture-controlled + ## discovery remains deterministic. Production results are keyed by every + ## setting that affects the rendered command; a port/domain/version change + ## therefore cannot reuse stale arguments. + if not discovery_override.is_empty(): + return _resolve_attach_launch_uncached(launch_context, discovery_override) + var cache_key := _attach_launch_cache_key(launch_context) + _attach_launch_cache_mutex.lock() + if _attach_launch_cache.has(cache_key): + var cached: Dictionary = _attach_launch_cache[cache_key].duplicate(true) + _attach_launch_cache_mutex.unlock() + return cached + ## Keep the cache lock through the bounded discovery probes. This cold path + ## runs at most once per distinct context and prevents simultaneous status + ## workers from repeating the same subprocess probes. Invalidation waits for + ## the in-flight result, then clears it, so stale work cannot repopulate a + ## freshly invalidated cache. + var resolved := _resolve_attach_launch_uncached(launch_context) + _attach_launch_cache[cache_key] = resolved.duplicate(true) + _attach_launch_cache_mutex.unlock() + return resolved + + +static func _resolve_attach_launch_uncached( + launch_context: Dictionary, discovery_override: Dictionary = {} +) -> Dictionary: + for key in ["http_port", "ws_port", "excluded_domains", "plugin_version", "allow_dev_venv", "platform"]: + if not launch_context.has(key): + return _attach_discovery_error("Launch context is missing `%s`; retry Configure." % key) + + var plugin_version := str(launch_context.get("plugin_version", "")).strip_edges() + if plugin_version.is_empty(): + return _attach_discovery_error("The bundled godot-ai version is unavailable; reinstall the plugin and retry Configure.") + + var common_args: Array[String] = [ + "attach", + "--port", str(int(launch_context.get("http_port", DEFAULT_HTTP_PORT))), + "--ws-port", str(int(launch_context.get("ws_port", DEFAULT_WS_PORT))), + ] + var exclusions := str(launch_context.get("excluded_domains", "")).strip_edges() + if not exclusions.is_empty(): + common_args.append_array(["--exclude-domains", exclusions]) + ## Default true when the key is absent (hand-built contexts in tests, stale + ## pre-upgrade snapshots) — matching the server's send-by-default posture. + ## Toggling the setting changes the rendered argv, so existing entries read + ## CONFIGURED_MISMATCH and the dock offers Reconfigure, like any other + ## launch-affecting value. + if not bool(launch_context.get("telemetry_enabled", true)): + common_args.append("--disable-telemetry") + + var venv_python := "" + if discovery_override.has("venv_python"): + venv_python = str(discovery_override["venv_python"]) + elif bool(launch_context.get("allow_dev_venv", true)): + venv_python = _cached_venv_python() + if bool(launch_context.get("allow_dev_venv", true)) and not venv_python.is_empty(): + var venv_args: Array[String] = ["-m", "godot_ai"] + venv_args.append_array(common_args) + return _finalize_attach_launch( + "dev_venv", venv_python, venv_args, launch_context, discovery_override + ) + + var uvx := "" + if discovery_override.has("uvx_path"): + uvx = str(discovery_override["uvx_path"]) + else: + ## Strict lookup for attach entries: never write a bare `uvx` command + ## that a GUI-launched client may be unable to resolve from its PATH. + uvx = find_uvx() + if not uvx.is_empty(): + var uvx_args: Array[String] = [ + "--link-mode", "copy", + "--from", "godot-ai==%s" % _pypi_pin_version(plugin_version), + "godot-ai", + ] + uvx_args.append_array(common_args) + return _finalize_attach_launch( + "uvx", uvx, uvx_args, launch_context, discovery_override + ) + + var system_cmd := "" + if discovery_override.has("system_path"): + system_cmd = str(discovery_override["system_path"]) + else: + system_cmd = _find_system_install() + if not system_cmd.is_empty(): + var probe: Dictionary + if discovery_override.has("system_version_result"): + probe = discovery_override["system_version_result"] as Dictionary + else: + probe = McpCliExec.run(system_cmd, ["--version"], _DISCOVERY_TIMEOUT_MS, false) + var version_check := _system_version_from_probe(probe) + if bool(version_check.get("ok", false)): + var found_version := str(version_check.get("version", "")) + if found_version == plugin_version: + return _finalize_attach_launch( + "system", system_cmd, common_args, launch_context, discovery_override + ) + return _attach_discovery_error( + "System godot-ai is version %s, but this plugin requires %s. Install uv or update the system package, then retry Configure." + % [found_version, plugin_version] + ) + if bool(probe.get("timed_out", false)): + return _attach_discovery_error( + "Timed out checking the system godot-ai version. Install uv or repair the system command, then retry Configure." + ) + return _attach_discovery_error( + "Could not verify the system godot-ai version. Install uv or repair the system command, then retry Configure." + ) + + return _attach_discovery_error( + "No compatible godot-ai launcher was found. Install uv (provides uvx), then retry Configure." + ) + + +## Return a launch shape that cannot allocate a visible console on Windows. +## The development tier can execute its sibling pythonw directly. uvx and the +## system entry point still need their own environments, so pythonw acts only +## as a stdio-preserving, CREATE_NO_WINDOW process bootstrap for those tiers. +static func _finalize_attach_launch( + tier: String, + command: String, + args: Array[String], + launch_context: Dictionary, + discovery_override: Dictionary, +) -> Dictionary: + if str(launch_context.get("platform", "")) != "Windows": + return {"ok": true, "tier": tier, "command": command, "args": args} + + var pythonw := _resolve_consoleless_python(command, tier, discovery_override) + if pythonw.is_empty(): + return _attach_discovery_error( + "Windows requires pythonw.exe to launch the MCP bridge without opening a terminal window. Repair this Python or uv installation, then retry Configure." + ) + + ## `console_command`/`console_args` carry the unwrapped console-subsystem + ## launch for clients that opt out of pythonw via + ## `needs_consoleless_launcher = false` (#863). Strategies only consume + ## `command`/`args`/`ok`; `launch_for_client` swaps the shapes per client. + if tier == "dev_venv": + return { + "ok": true, "tier": tier, "command": pythonw, "args": args, + "console_command": command, "console_args": args, + } + + var wrapped_args: Array[String] = ["-c", _WINDOWS_STDIO_BOOTSTRAP, command] + wrapped_args.append_array(args) + return { + "ok": true, "tier": tier, "command": pythonw, "args": wrapped_args, + "console_command": command, "console_args": args, + } + + +## Select the launch shape a specific client should see. Clients with +## `needs_consoleless_launcher = false` (Antigravity, #863) get the plain +## console command captured by `_finalize_attach_launch`; everyone else keeps +## the pythonw shape unchanged. Idempotent: the returned dict carries no +## console keys, so a second application is a no-op. +static func launch_for_client(client: Client, launch: Dictionary) -> Dictionary: + if client == null or client.needs_consoleless_launcher: + return launch + if not launch.has("console_command"): + return launch + var selected := launch.duplicate(true) + selected["command"] = selected["console_command"] + selected["args"] = selected["console_args"] + selected.erase("console_command") + selected.erase("console_args") + return selected + + +static func _resolve_consoleless_python( + command: String, tier: String, discovery_override: Dictionary +) -> String: + ## Data-only override keeps resolver tests independent of the host's Python. + if discovery_override.has("consoleless_python"): + return str(discovery_override["consoleless_python"]) + + ## Venv/system console-script launchers normally keep pythonw beside their + ## python.exe. The dev tier must use that exact interpreter so godot_ai is + ## imported from the selected checkout rather than some unrelated install. + var sibling := command.get_base_dir().path_join("pythonw.exe") + if FileAccess.file_exists(sibling): + return sibling + if tier == "dev_venv": + return "" + + ## uvx may be installed without a PATH-visible CPython. Ask its sibling uv + ## for the already-managed system interpreter; Godot AI's existing uvx + ## server launch ensures one normally exists before client configuration. + if tier == "uvx": + var uv := command.get_base_dir().path_join("uv.exe") + if not FileAccess.file_exists(uv): + uv = CliFinder.find(["uv.exe"]) + if not uv.is_empty(): + var probe := McpCliExec.run( + uv, ["python", "find", "--system"], _DISCOVERY_TIMEOUT_MS, false + ) + if int(probe.get("exit_code", -1)) == 0: + var python := str(probe.get("stdout", "")).strip_edges() + if not python.is_empty(): + var managed_pythonw := python.get_base_dir().path_join("pythonw.exe") + if FileAccess.file_exists(managed_pythonw): + return managed_pythonw + + ## A system Python GUI launcher is sufficient for the non-dev bootstrap; + ## it does not import godot_ai itself. + return CliFinder.find(["pythonw.exe"]) + + +static func _system_version_from_probe(probe: Dictionary) -> Dictionary: + if int(probe.get("exit_code", -1)) != 0: + return {"ok": false} + var output := str(probe.get("stdout", "")).strip_edges() + var pattern := RegEx.new() + if pattern.compile("^godot-ai\\s+([^\\s]+)(?:\\s|$)") != OK: + return {"ok": false} + var matched := pattern.search(output) + if matched == null: + return {"ok": false} + return {"ok": true, "version": matched.get_string(1)} + + +static func _attach_discovery_error(message: String) -> Dictionary: + return {"ok": false, "error": message} + + +## Override for the dev-vs-user heuristic. Accepted values: +## "dev" — force dev-checkout mode (skip update check + self-install) +## "user" — force user-install mode (run update check, allow self-install) +## as long as the data-safety guard (addons_dir_is_symlink) passes +## other / unset — "auto": fall back to the .venv-proximity heuristic +## +## Use `user` to test the AssetLib self-update flow from inside a dev +## checkout (there's a .venv nearby but `addons/godot_ai` is a plain copy — +## e.g. after unpacking a release zip into `test_project/`). +## +## Two ways to set it, resolved in priority order: +## 1. EditorSettings → `godot_ai/mode_override` — set manually via +## Editor Settings (no dock UI writes it today); persists +## per-editor-install and wins over the env var so an editor-side +## choice always takes effect without relaunching. +## 2. Env var `GODOT_AI_MODE` — useful for CLI launches and CI. +const MODE_OVERRIDE_ENV := "GODOT_AI_MODE" +const MODE_OVERRIDE_SETTING := "godot_ai/mode_override" + + +static func mode_override() -> String: + # 1. EditorSetting wins — the user explicitly set it via Editor Settings. + # _editor_setting_lookup handles the `Engine.is_editor_hint()` gate + # (no-op in the game subprocess; see CLAUDE.md "Game-side code") and + # serves worker threads from a main-thread-warmed snapshot — this + # runs on the #678 startup walk's discovery worker, and + # EditorInterface/EditorSettings are not thread-safe (#691). + var setting: Variant = _editor_setting_lookup(MODE_OVERRIDE_SETTING) + if setting != null: + var setting_val := str(setting).strip_edges().to_lower() + if setting_val == "dev" or setting_val == "user": + return setting_val + # 2. Env var fallback. env_lookup, not OS.get_environment: same + # worker-thread reason (#691). + var raw := McpPathTemplate.env_lookup(MODE_OVERRIDE_ENV).strip_edges().to_lower() + if raw == "dev" or raw == "user": + return raw + return "" + + +static func is_dev_checkout() -> bool: + match mode_override(): + "dev": + return true + "user": + return false + return not _find_venv_python().is_empty() + + +## Data-safety check for self-install: is `res://addons/godot_ai` a symbolic +## link? In a dev checkout this points at the canonical `plugin/` source +## tree, and writing files into it would clobber tracked source. This check +## is independent of `is_dev_checkout()` so a forced-user mode override +## still cannot extract a release zip over the symlink. +static func addons_dir_is_symlink() -> bool: + return _is_symlink(ProjectSettings.globalize_path("res://addons/godot_ai")) + + +## Mirrors the idiom used in `mcp_dock.gd::_resolve_plugin_symlink_target` — +## open the parent dir and ask Godot via `DirAccess.is_link()`, which +## handles symlinks on POSIX and reparse points on Windows natively. +static func _is_symlink(path: String) -> bool: + if path.is_empty(): + return false + var dir := DirAccess.open(path.get_base_dir()) + if dir == null: + ## This is a data-safety guard (a symlinked addons dir is a dev + ## checkout self-update must never write through). When the path + ## exists but its parent can't be opened, we can't PROVE it isn't + ## a link — fail closed and treat it as one (#711). + return DirAccess.dir_exists_absolute(path) or FileAccess.file_exists(path) + return dir.is_link(path) + + +## `refresh` forces uvx to re-fetch PyPI index metadata on spawn — used by +## `_start_server`'s one-shot retry when the first attempt exited fast with +## no pid-file on the uvx tier (stale-index-cache failure mode). No-op on +## other tiers: dev_venv and system resolve locally, so the flag has nowhere +## to go. See plugin.gd::_should_retry_with_refresh. +static func get_server_command(refresh: bool = false) -> Array[String]: + ## `mode_override() == "user"` skips the dev_venv tier even when a nearby + ## .venv exists — the override then becomes an actual workaround for + ## the "user venv misidentified as dev checkout" bug, not just a + ## cosmetic relabel. + if mode_override() != "user": + var venv_python := _cached_venv_python() + if not venv_python.is_empty(): + print("MCP | using dev venv: %s" % venv_python) + return [venv_python, "-m", "godot_ai"] + + var uvx := find_uvx() + if not uvx.is_empty(): + var version := get_plugin_version() + ## PEP 440 local build tags (e.g. 3.0.2+local.1) are not on PyPI. + ## Pin uvx to the public base version so the server still boots; + ## checkout-local extras need the dev_venv tier above + ## (symlink/junction → repo .venv). + var pypi_version := _pypi_pin_version(version) + ## Pin to the EXACT plugin version rather than `~=`. Under the + ## tilde form, uvx was happy to reuse a cached tool env that matched + ## the minor constraint — so an install that first spawned 1.2.0 kept + ## using 1.2.0 even after 1.2.1/1.2.2 landed. Exact pinning makes the + ## cache key version-specific: if the cached env matches, fast hit; + ## otherwise uvx installs the exact version fresh. Keeps plugin and + ## server version in lockstep without needing `--refresh-package` on + ## every spawn. See issue #133. + if pypi_version != version: + print( + "MCP | using uvx (godot-ai==%s; local plugin %s not on PyPI)%s" + % [pypi_version, version, " [refresh]" if refresh else ""] + ) + else: + print("MCP | using uvx (godot-ai==%s)%s" % [pypi_version, " [refresh]" if refresh else ""]) + var cmd: Array[String] = [uvx] + if refresh: + cmd.append("--refresh") + cmd.append_array(["--from", "godot-ai==%s" % pypi_version, "godot-ai"]) + return cmd + + var system_cmd := _find_system_install() + if not system_cmd.is_empty(): + print("MCP | using system install: %s" % system_cmd) + return [system_cmd] + + push_warning("MCP | no server found — install uv or run: pip install godot-ai") + return [] + + +## Which tier `get_server_command` would resolve to, without side-effects. +## Returned as a stable string so handshakes and session_list can expose it +## to MCP callers. Values track the `Literal` on the Python side. +static func get_server_launch_mode() -> String: + if mode_override() != "user" and not _cached_venv_python().is_empty(): + return "dev_venv" + if not find_uvx().is_empty(): + return "uvx" + if not _find_system_install().is_empty(): + return "system" + return "unknown" + + +## Wall-clock budget for the Configure-time pre-warm. Far above +## `McpCliExec.DEFAULT_TIMEOUT_MS` (8s) on purpose: this call is *expected* to +## take tens of seconds on a cold cache — downloading and unpacking the tool +## environment is the entire point — so the usual CLI-registry budget would +## kill it right when it is doing useful work. Still bounded so a wedged uv +## can't hold the worker thread forever. +const PREWARM_TIMEOUT_MS := 180000 + + +static func find_uvx() -> String: + return CliFinder.find(_uvx_cli_names()) + + +## Pre-build the uvx tool environment for `godot-ai==` in the +## background, so the FIRST spawn of that version is a warm cache hit. +## Fired when a self-update install starts: post-update the old backend is +## killed and the new one spawned, and a cold `uvx --from godot-ai==` +## resolve+install takes seconds — while an attach bridge pinned to the OLD +## version respawns its already-cached backend near-instantly, winning the +## port bind race every time (the INCOMPATIBLE dead end after every +## self-update with a live AI client). Warming the new env while the plugin +## zip downloads flips that race. +## +## `--version` makes the spawned process exit immediately after uv resolves +## and installs the env. Detached fire-and-forget: a failure only means the +## post-update spawn pays the cold cost it always used to. Returns the +## spawned PID, or -1 when skipped (no uvx on this machine — the dev-venv +## and system tiers have no per-version cache to warm — or no version). +static func prewarm_server_package(version: String) -> int: + var args := prewarm_server_package_argv(version) + if args.is_empty(): + return -1 + var uvx := find_uvx() + if uvx.is_empty(): + return -1 + var pid := OS.create_process(uvx, args) + if pid > 0: + print("MCP | pre-warming godot-ai==%s server package for the post-update restart" % version.strip_edges()) + return pid + + +## Pure argv builder for the pre-warm spawn, split out so tests can pin the +## exact command without spawning a process. Empty array when there is no +## version to pin, or when the version carries characters outside the PEP +## 440 alphabet — the value can originate from a GitHub release tag, and +## while OS.create_process argv can't be shell-injected, constraining the +## pin keeps a hostile tag from smuggling anything into the requirement +## spec uv parses (#890 review). +static func prewarm_server_package_argv(version: String) -> Array[String]: + var pinned := version.strip_edges() + if pinned.is_empty(): + return [] + ## No `*`: PEP 440 reserves it for prefix-match SPECIFIERS (`==3.2.*`), + ## not version identifiers — letting it through would turn the exact pin + ## into a prefix match (#890 CodeRabbit). + var re := RegEx.new() + if re.compile("^[A-Za-z0-9.+!-]+$") != OK or re.search(pinned) == null: + return [] + return ["--from", "godot-ai==%s" % pinned, "godot-ai", "--version"] + + +## Blocking sibling of `prewarm_server_package`, for the user-initiated +## Configure flow (#851, and the reconnect timeouts reported alongside it). +## +## Why Configure needs its own pre-warm: `prewarm_server_package` only fires +## on self-update and on server-lifecycle recovery. Neither covers the case +## that actually bites — the plugin ADOPTS an already-running server (no uvx +## spawn, so nothing warms the env), the user clicks Configure, and the first +## client launch is the one that pays for building the whole tool environment. +## That build is ~67 packages; a warm launch is ~0.1s. Two symptoms fall out +## of the cold path: a visible terminal window on Windows (#851), and a spawn +## that overruns the MCP client's default 30s connect timeout, which the user +## sees as the Godot AI tools silently disappearing. +## +## Why blocking, unlike the fire-and-forget server-side pre-warm: Configure is +## a deliberate click, so the cost is attributable and the dock can show it as +## progress rather than as an unexplained pause. Runs on the dock's client +## action worker thread — never the main thread — and `McpCliExec.run` bounds +## it, so a wedged uv cannot trap the worker. +## +## Best-effort by contract. The config file is already written and correct +## when this runs; a failure here only means the next client launch pays the +## cold cost it always used to. Callers must NOT downgrade a successful +## Configure because of this result. +## +## Returns the `McpCliExec.run` dict, plus `skipped` (true when there is no +## uvx tier to warm or no usable version pin). +static func prewarm_server_package_blocking( + version: String, + timeout_ms: int = PREWARM_TIMEOUT_MS, + cancel_check: Callable = Callable(), +) -> Dictionary: + var args := prewarm_server_package_argv(version) + if args.is_empty(): + return {"skipped": true, "reason": "no version pin"} + var uvx := find_uvx() + if uvx.is_empty(): + ## dev-venv and system tiers have no per-version cache to warm. + return {"skipped": true, "reason": "no uvx"} + var result := McpCliExec.run(uvx, args, timeout_ms, true, cancel_check) + result["skipped"] = false + return result + + +## Decide whether the freshly-written entry has an environment worth warming. +## +## Pure and side-effect free, split from the spawn below so tests can pin the +## decision without building a real uv environment — the same split +## `prewarm_server_package_argv` uses for the argv (#890). +## +## Warms only the `uvx` tier: dev-venv and system launches run an +## already-installed package and have no per-version environment to build. +## Resolving the tier here rather than in the dock keeps launcher knowledge in +## the configurator; the dock stays free of tier branching. +## +## Returns `{warm: bool, version: String, reason: String}`. +static func prewarm_attach_plan( + launch_context: Dictionary, discovery_override: Dictionary = {} +) -> Dictionary: + var launch := resolve_attach_launch(launch_context, discovery_override) + if not bool(launch.get("ok", false)): + return {"warm": false, "version": "", "reason": "launch discovery failed"} + var tier := str(launch.get("tier", "")) + if tier != "uvx": + return {"warm": false, "version": "", "reason": "tier %s has no env to warm" % tier} + var version := _pypi_pin_version(str(launch_context.get("plugin_version", ""))) + if version.is_empty(): + return {"warm": false, "version": "", "reason": "no version pin"} + return {"warm": true, "version": version, "reason": ""} + + +## Warm the environment the freshly-written entry will actually launch. +## Thin wrapper over `prewarm_attach_plan` + `prewarm_server_package_blocking`. +static func prewarm_attach_launch( + launch_context: Dictionary, + timeout_ms: int = PREWARM_TIMEOUT_MS, + discovery_override: Dictionary = {}, + cancel_check: Callable = Callable(), +) -> Dictionary: + var plan := prewarm_attach_plan(launch_context, discovery_override) + if not bool(plan.get("warm", false)): + return {"skipped": true, "reason": str(plan.get("reason", ""))} + return prewarm_server_package_blocking( + str(plan.get("version", "")), timeout_ms, cancel_check + ) + + +static func _uvx_cli_names() -> Array[String]: + var names: Array[String] = [] + names.append("uvx.exe" if OS.get_name() == "Windows" else "uvx") + return names + + +## Drop the `CliFinder` cache for the platform-specific uvx binary +## name. Pairs with `invalidate_uv_version_cache()` so the dock's +## `_on_install_uv` can refresh both caches with one call each. The +## OS-specific name matters: Windows caches under `uvx.exe`, every +## other platform under `uvx`; hard-coding `"uvx"` here would leave +## the CLI-path cache stale on Windows after a fresh install and the +## dock would keep showing "uv: not found" for the rest of the session. +static func invalidate_uvx_cli_cache() -> void: + for name in _uvx_cli_names(): + CliFinder.invalidate(name) + + +## Drop the entire `CliFinder` cache. Called from any explicit-user-action +## refresh path (`force=true` in `_request_client_status_refresh` — manual +## Refresh button, popup-open, compat wrapper, future external API) so a +## freshly-installed CLI (claude, codex, gemini, …) gets detected without +## an editor restart. Per-CLI invalidation (`invalidate_uvx_cli_cache`) is +## preferred when the dock knows which binary changed; this catch-all +## handles the "any CLI may have been installed since the last sweep" case. +## +## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched` +## with a mutex so it can race safely against worker threads calling +## `find()` from `_run_client_action_worker`. The mutex is held only +## across the dictionary clear, never across the bounded subprocess lookup, +## so this call can never block the main thread on a subprocess. +static func invalidate_cli_cache() -> void: + CliFinder.invalidate() + + +static var _uv_version_cache: String = "" +static var _uv_version_searched: bool = false + + +## Cached for the editor session. The dock's `_refresh_setup_status` +## (called via `call_deferred` from `_build_ui`) calls this on the +## main thread in user mode, so the cold `uvx --version` probe is +## wall-clock bounded and cached. Subsequent calls (focus-in refresh, +## manual Refresh clicks) reuse the cached string. +## +## Invalidate via `invalidate_uv_version_cache()` when the user +## installs / reinstalls uv via the dock so the next refresh reflects +## the new install. The dock's `_on_install_uv` calls this alongside +## `CliFinder.invalidate("uvx")` to clear both the path cache and +## the version cache in one place. +static func check_uv_version() -> String: + if _uv_version_searched: + return _uv_version_cache + var uvx := find_uvx() + if uvx.is_empty(): + _uv_version_searched = true + _uv_version_cache = "" + return "" + var result := McpCliExec.run(uvx, ["--version"], _DISCOVERY_TIMEOUT_MS, false) + if int(result.get("exit_code", -1)) == 0: + var lines := PackedStringArray(str(result.get("stdout", "")).split("\n")) + _uv_version_cache = lines[0].strip_edges() if lines.size() > 0 else "" + else: + _uv_version_cache = "" + _uv_version_searched = true + return _uv_version_cache + + +static func invalidate_uv_version_cache() -> void: + _uv_version_searched = false + _uv_version_cache = "" + + +## True when a probe has run this session and came back empty — i.e. the +## dock is currently rendering "uv: not found". Lets callers decide when +## a re-probe is worth paying for (server-connect transition, manual +## Refresh) without ever re-probing once uv has been found. +static func uv_probe_negative() -> bool: + return _uv_version_searched and _uv_version_cache.is_empty() + + +## Drop both uv caches — the resolved uvx path AND the cached +## `uvx --version` output — so the next check_uv_version() re-runs the +## full detection. #739: a probe that fails once at editor startup +## (contended spawn, cold Defender scan, stale PATH under a +## Steam-launched editor) used to pin "uv: not found" for the whole +## session; the Install-uv click was the only invalidation path. Callers +## invoke this on events that suggest the failure was transient. +static func invalidate_uv_detection() -> void: + invalidate_uvx_cli_cache() + invalidate_uv_version_cache() + _attach_launch_cache_mutex.lock() + _attach_launch_cache.clear() + _attach_launch_cache_mutex.unlock() + + +static var _venv_python_cache: String = "" +static var _venv_python_searched: bool = false +## #678 worker threads write this cache while main-thread callers read +## it; same lock discipline as McpCliFinder (clients/_cli_finder.gd). +static var _venv_mutex: Mutex = Mutex.new() +static var _attach_launch_cache := {} +static var _attach_launch_cache_mutex := Mutex.new() + + +static func _attach_launch_cache_key(launch_context: Dictionary) -> String: + return JSON.stringify([ + launch_context.get("http_port", null), + launch_context.get("ws_port", null), + launch_context.get("excluded_domains", null), + launch_context.get("plugin_version", null), + launch_context.get("allow_dev_venv", null), + launch_context.get("platform", null), + launch_context.get("telemetry_enabled", null), + ]) + + +static func _cached_venv_python() -> String: + _venv_mutex.lock() + if not _venv_python_searched: + _venv_python_cache = _find_venv_python() + _venv_python_searched = true + var cached := _venv_python_cache + _venv_mutex.unlock() + return cached + + +## Absolute path to `res://addons/godot_ai`, resolving Windows junctions / +## POSIX symlinks via `DirAccess.read_link`. Unresolved globalize_path only +## walks the *logical* project path (e.g. MyGame/addons/godot_ai → MyGame) +## and never reaches a fork checkout's `.venv` (…/godot-ai/.venv). +static func resolve_addons_realpath() -> String: + var addons_path := ProjectSettings.globalize_path("res://addons/godot_ai").rstrip("/").rstrip("\\") + if addons_path.is_empty(): + return "" + var parent := addons_path.get_base_dir() + var dir := DirAccess.open(parent) + if dir != null and dir.is_link(addons_path): + var target := dir.read_link(addons_path) + if not target.is_empty(): + if target.is_relative_path(): + target = parent.path_join(target).simplify_path() + return target.rstrip("/").rstrip("\\") + return addons_path + + +static func _find_venv_python() -> String: + ## Optional hard override (junction edge cases / CI). + var env_py := McpPathTemplate.env_lookup("GODOT_AI_VENV_PYTHON").strip_edges() + if not env_py.is_empty(): + if FileAccess.file_exists(env_py): + return env_py + ## An explicit override pointing nowhere is a misconfiguration the + ## user needs to see — falling through silently would make the dev + ## venv appear randomly ignored. + push_warning( + "godot-ai: GODOT_AI_VENV_PYTHON is set but no file exists at '%s'; ignoring override." + % env_py + ) + ## 1) Walk up from the open project (classic monorepo / test_project layout). + var from_project := _find_venv_python_in( + ProjectSettings.globalize_path("res://").rstrip("/").rstrip("\\") + ) + if not from_project.is_empty(): + return from_project + ## 2) Junctioned plugin: resolve reparse target, then walk up to fork root. + var addons_real := resolve_addons_realpath() + if not addons_real.is_empty(): + var from_addons := _find_venv_python_in(addons_real) + if not from_addons.is_empty(): + return from_addons + return "" + + +## Pure path-based lookup so tests can drive it with a scratch dir instead of +## monkey-patching `res://`. Only treats a `.venv/bin/python` as a godot-ai dev +## venv if a sibling `src/godot_ai/` exists in the same parent dir — otherwise +## an unrelated user venv (e.g. `~/.venv` from a data-science side project) +## gets picked up and `python -m godot_ai` fails with ModuleNotFoundError about +## 5s into startup, cascading into an infinite reconnect loop. The retry-with- +## refresh recovery in `plugin.gd::_should_retry_with_refresh` only fires on +## the uvx tier, so the dev_venv misidentification has no escape hatch — the +## detection has to be right the first time. +static func _find_venv_python_in(start_dir: String) -> String: + var dir := start_dir.rstrip("/").rstrip("\\") + var python_name := "python" if OS.get_name() != "Windows" else "python.exe" + var venv_dir := ".venv/bin/" if OS.get_name() != "Windows" else ".venv/Scripts/" + ## 8 hops: game project roots are shallow; junctioned plugins sit at + ## /plugin/addons/godot_ai (4) and nested worktrees may be deeper. + for i in 8: + var venv_path := dir.path_join(venv_dir + python_name) + if FileAccess.file_exists(venv_path) and DirAccess.dir_exists_absolute(dir.path_join("src/godot_ai")): + return venv_path + var parent := dir.get_base_dir() + if parent == dir or parent.is_empty(): + break + dir = parent + return "" + + +## Walk up from `start_dir` looking for a sibling `src/godot_ai/` — returns +## the absolute path of the enclosing `src/` dir, or "". Used by the dev +## server launcher to prepend the caller's own source to PYTHONPATH so a +## worktree-launched editor serves the worktree's Python, not the root +## repo's editable install. See #84. +static func find_worktree_src_dir(start_dir: String) -> String: + var dir := start_dir.rstrip("/") + for i in 5: + var candidate := dir.path_join("src/godot_ai") + if DirAccess.dir_exists_absolute(candidate): + return dir.path_join("src") + var parent := dir.get_base_dir() + if parent == dir: + break + dir = parent + return "" + + +## Delegates to McpCliFinder rather than shelling out to which/where +## directly: the finder adds the well-known-install-dirs and login-shell +## PATH tiers plus its per-exe cache, and this drops the private +## `_pick_best_path` cross-class reach (#711). +static func _find_system_install() -> String: + ## Built with append, not a ternary of untyped literals — assigning a + ## ternary's Array to Array[String] is a runtime error on newer Godot + ## builds (same idiom as _uvx_cli_names above). + var names: Array[String] = ["godot-ai"] + if OS.get_name() == "Windows": + names.push_front("godot-ai.exe") + return CliFinder.find(names) diff --git a/addons/godot_ai/client_configurator.gd.uid b/addons/godot_ai/client_configurator.gd.uid new file mode 100644 index 0000000..9182096 --- /dev/null +++ b/addons/godot_ai/client_configurator.gd.uid @@ -0,0 +1 @@ +uid://1kiy8hqyymyj diff --git a/addons/godot_ai/clients/_atomic_write.gd b/addons/godot_ai/clients/_atomic_write.gd new file mode 100644 index 0000000..3dbf72c --- /dev/null +++ b/addons/godot_ai/clients/_atomic_write.gd @@ -0,0 +1,251 @@ +@tool +class_name McpAtomicWrite +extends RefCounted + +## Write text to a file via temp + rename so a crash mid-write never leaves +## the user's MCP config truncated. Creates the parent dir if needed and +## keeps a one-shot `.backup` of the prior file. +## +## On filesystems where rename-over-existing fails (Windows under AV / lock +## pressure, some SMB shares), falls back to overwrite-copy plus a +## backup-restore on failure. The original file is never removed before the +## new bytes are verified on disk — if both the rename and the copy fail, +## the user's prior config is restored from the `.backup` snapshot. See +## issue #297 finding #10 for the data-loss scenario this guards against. + + +static func write(path: String, content: String) -> bool: + # Last line of defence for the path-resolution layer above. A relative (or + # empty) destination resolves against the EDITOR's working directory, not the + # caller's. The empty-path case is especially deceptive: editor safe-save + # runs mkstemp on "-XXXXXX", writes the payload into the project root, then + # fails only when renaming back to "" and leaves the random file behind. + # Callers gate this already (`McpClient.resolved_config_path_details`), but a + # writer that cannot tell where it is writing must refuse rather than guess. + if not path.is_absolute_path(): + return false + # If the target is a symlink (stow/chezmoi-managed dotfiles), rename-over + # would replace the LINK with a regular file, silently detaching the + # config from the user's dotfile repo (#534). Resolve the link chain and + # write to the real target so the symlink survives. + path = _resolve_symlink_target(path) + var dir_path := path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + if DirAccess.make_dir_recursive_absolute(dir_path) != OK: + return false + + # Decide the permission mode the final file (and its backup) must carry + # BEFORE we replace anything. A rewrite must preserve the prior file's + # mode: the Claude CLI creates ~/.claude.json as 0600 (it holds OAuth + # creds + history), and a naive FileAccess write + DirAccess copy would + # silently relax that to the umask default (0644) and leak it on shared + # machines. A brand-new config defaults to owner-only 0600 since these + # files routinely carry tokens. On platforms without POSIX permissions + # (Windows) the get/set calls no-op and this logic is inert. See #297 + # finding TC-1. + var had_original := FileAccess.file_exists(path) + var target_mode := _resolve_target_mode(path, had_original) + + # Suffix the temp name with this process's PID so two editors writing the + # same config concurrently (both clicking Configure) can't interleave + # bytes on a shared fixed ".tmp" path (#534). Each process stages its own + # temp file; the final rename remains the atomic commit point. + var tmp_path := "%s.tmp.%d" % [path, OS.get_process_id()] + var file := _open_restricted_temp(tmp_path, target_mode) + if file == null: + return false + file.store_string(content) + # Push Godot's internal buffer out to the OS before the rename. Godot + # exposes no fsync, so the bytes aren't guaranteed durable on the physical + # disk until the OS flushes its own cache — a power loss in that window can + # still lose the data. But flush() ensures the rename can't be ordered ahead + # of the write at the application layer, which is the failure this guards. + file.flush() + file.close() + # Re-assert the mode on the closed inode before rename. The temp was already + # restricted before this handle opened, so this is defence in depth rather + # than the first point at which secrets become owner-only. + if not _apply_mode(tmp_path, target_mode): + DirAccess.remove_absolute(tmp_path) + return false + + # Verify the staged temp landed intact before committing it anywhere. The + # copy-fallback path below already guards this (`_written_size_matches` at + # the rename-fallback check); the rename path was the one gap — under + # disk-full/quota the temp can be silently truncated, and an unverified + # rename would swap a truncated file over the live target while the + # caller is told the write succeeded (#687). + if not _written_size_matches(tmp_path, content): + DirAccess.remove_absolute(tmp_path) + return false + + # Best-effort: snapshot the prior file before we touch the target so we + # can restore on a failed swap. The backup is also kept on success as a + # one-shot rollback aid for the user — give it the same (preserved) mode + # so a 0600 config's backup isn't itself a world-readable copy. + # + # copy_absolute creates the backup at the umask default and we can only + # chmod it afterward, so there's a sub-millisecond window where the backup + # carries default perms. Accepted: it duplicates bytes already sitting at + # `path` (which the caller created 0600) inside the user's own config dir, + # and Godot exposes no API to create the copy pre-chmod'd. Not worth + # reimplementing copy by hand to shave that window. + var backup_path := path + ".backup" + var backup_made := false + if had_original: + DirAccess.remove_absolute(backup_path) + if DirAccess.copy_absolute(path, backup_path) == OK: + if not _apply_mode(backup_path, target_mode): + ## The backup contains the same secrets as the live config. Do + ## not leave that copy behind — or touch the destination — when + ## it cannot be restricted to the intended mode. + DirAccess.remove_absolute(backup_path) + DirAccess.remove_absolute(tmp_path) + return false + backup_made = true + else: + ## copy_absolute may leave a partial destination on failure. It + ## contains config bytes under the process umask, so remove it. + DirAccess.remove_absolute(backup_path) + + if DirAccess.rename_absolute(tmp_path, path) == OK: + return true + + # Rename-over-existing rejected (Windows + AV / lock timing, some SMB + # shares). Use overwrite-copy as the recovery path: copy_absolute never + # removes the original before writing the new bytes, so a failure here + # leaves the user's prior config in place rather than nuking it. + if DirAccess.copy_absolute(tmp_path, path) == OK and _written_size_matches(path, content): + # copy_absolute creates the destination with the default mode, so + # re-apply the preserved/owner-only mode after the copy lands. A + # permission failure is a failed write: fall through to the same + # restore/remove path as a partial copy instead of reporting success + # with a potentially exposed token-bearing config. + if _apply_mode(path, target_mode): + DirAccess.remove_absolute(tmp_path) + return true + + # Copy didn't land cleanly. Restore the destination to its pre-call state. + if backup_made: + # Restore the snapshot we took before the swap. `copy_absolute` + # overwrites the destination, so we don't pre-remove `path` — the + # pre-remove created a window where `path` was gone if the + # subsequent copy itself failed. If the restore copy fails now the + # user's prior bytes are still in `.backup` for manual recovery + # and the false return value tells the caller the swap didn't + # complete. + var restored := ( + DirAccess.copy_absolute(backup_path, path) == OK + and _apply_mode(path, target_mode) + ) + if not restored: + ## The backup's mode was already verified before the swap. Remove + ## any partial/default-mode destination, then move that restricted + ## inode back into place. If even the same-directory rename fails, + ## the complete prior contents remain in `.backup` for recovery. + DirAccess.remove_absolute(path) + DirAccess.rename_absolute(backup_path, path) + elif not had_original and FileAccess.file_exists(path): + # No prior file existed but copy_absolute landed partial bytes at + # `path`. Remove them so the failure leaves nothing on disk rather + # than a truncated/invalid new file. The `file_exists` guard keeps + # us off non-file destinations (a path that points at a directory + # yields `had_original=false` too, but we must not try to delete + # the directory). Issue #297 PR review. + DirAccess.remove_absolute(path) + # (If `had_original` is true but the snapshot couldn't be taken, the + # original on disk is whatever copy_absolute managed to write before + # failing. This is a best-effort path — the false return value tells the + # caller the swap didn't complete; recovery beyond that requires a + # backup we couldn't take.) + DirAccess.remove_absolute(tmp_path) + return false + + +## Follow a symlink chain at `path` and return the final real target, so the +## temp+rename lands on the linked-to file instead of replacing the link. +## +## Best-effort by design: DirAccess.is_link()/read_link() are only implemented +## on platforms with POSIX symlinks (Linux/macOS; on Windows and other +## platforms is_link() returns false), and opening the parent dir can fail for +## exotic paths. In every "can't tell" case we return `path` unchanged, which +## is exactly the pre-#534 behavior — never worse, symlink-preserving where +## the engine lets us detect one. +static func _resolve_symlink_target(path: String) -> String: + var resolved := path + # Bounded hops so a symlink cycle can't loop us forever. + for _hop in 8: + var base_dir := resolved.get_base_dir() + var da := DirAccess.open(base_dir) + if da == null or not da.is_link(resolved): + return resolved + var target := da.read_link(resolved) + if target.is_empty(): + return resolved + if target.is_relative_path(): + target = base_dir.path_join(target) + resolved = target.simplify_path() + return resolved + + +static func _resolve_target_mode(path: String, had_original: bool) -> int: + # Preserve the prior file's POSIX mode on a rewrite; default a brand-new + # config (or any case we can't read a mode for) to owner read+write (0600). + # + # get_unix_permissions returns 0 both on Windows (no POSIX perms) and for a + # genuine 0000 file. Treating 0 as "use the 0600 floor" is deliberate, not a + # missed case: these are config files the plugin must read and write, 0000 is + # unusable, and re-applying 0000 would lock the owner out next run. 0600 is + # still owner-only so this never widens access. (A genuinely-0000 file can't + # reach a rewrite through the config strategies anyway — their read-first + # guard fails to open it and refuses the write before we get here.) + if had_original: + var existing := FileAccess.get_unix_permissions(path) + if existing > 0: + return existing + return FileAccess.UNIX_READ_OWNER | FileAccess.UNIX_WRITE_OWNER + + +## Create/truncate an EMPTY temp file, close it, restrict its mode, then reopen +## it without truncation for the payload write. Godot 4.6 on macOS returns +## FAILED when set_unix_permissions targets a path whose FileAccess is still +## open; the old create -> chmod-while-open -> write sequence therefore emitted +## one warning per configured client and briefly wrote secrets under the umask +## mode. Closing the empty inode first makes the security ordering real and +## removes that platform-specific warning without suppressing genuine errors. +static func _open_restricted_temp(path: String, mode: int) -> FileAccess: + var created := FileAccess.open(path, FileAccess.WRITE) + if created == null: + return null + created.close() + if not _apply_mode(path, mode): + DirAccess.remove_absolute(path) + return null + return FileAccess.open(path, FileAccess.READ_WRITE) + + +static func _apply_mode(path: String, mode: int) -> bool: + # Best-effort. set_unix_permissions returns ERR_UNAVAILABLE on platforms + # without POSIX permissions (Windows); that's expected and ignored so the + # write still works there. mode <= 0 should never happen (resolve always + # returns >0) but is guarded so a future caller can't chmod a file to nothing. + if mode <= 0: + return true + var err := FileAccess.set_unix_permissions(path, mode) + if err == OK or err == ERR_UNAVAILABLE: + return true + # Surface a real chmod failure (not the Windows no-op) so permission + # hardening on a sensitive config doesn't fail completely silently. + push_warning("MCP | could not set permissions on %s: %s" % [path, error_string(err)]) + return false + + +static func _written_size_matches(path: String, content: String) -> bool: + # `store_string` writes UTF-8 bytes with no BOM and no newline translation, + # so the byte length on disk must match `to_utf8_buffer().size()` exactly. + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + return false + var on_disk := f.get_length() + f.close() + return on_disk == content.to_utf8_buffer().size() diff --git a/addons/godot_ai/clients/_atomic_write.gd.uid b/addons/godot_ai/clients/_atomic_write.gd.uid new file mode 100644 index 0000000..add9f7f --- /dev/null +++ b/addons/godot_ai/clients/_atomic_write.gd.uid @@ -0,0 +1 @@ +uid://6fkb5uau0r4h diff --git a/addons/godot_ai/clients/_base.gd b/addons/godot_ai/clients/_base.gd new file mode 100644 index 0000000..494646a --- /dev/null +++ b/addons/godot_ai/clients/_base.gd @@ -0,0 +1,528 @@ +@tool +class_name McpClient +extends RefCounted + +## Descriptor for one MCP client (Cursor, Claude Desktop, Codex, ...). +## +## Subclasses set fields in `_init()` and MUST NOT carry Callables — strategies +## (json/toml/cli) interpret the data. Enforced by +## `test_clients.gd::test_descriptors_are_data_only`. +## +## Why no Callables: per-client `.gd` files get hot-reloaded on disk-mtime +## change. A worker thread mid-call into a descriptor lambda races the +## bytecode swap and SEGVs (issue #229). Bonus: also obsoletes the stale- +## Callable workaround from #192. + +## CONFIGURED_MISMATCH = an entry with our `SERVER_NAME` exists in the user's +## client config, but its URL or launch command doesn't match the current +## ports/version/exclusions — typical after a setting change or update. +## Distinguishing this from `NOT_CONFIGURED` lets the dock surface a "your +## saved client configuration is stale" banner instead of conflating it with +## "you never configured this client". +enum Status { NOT_CONFIGURED, CONFIGURED, CONFIGURED_MISMATCH, ERROR } + + +## Lowercase string label for a `Status` value. Single source of truth so the +## MCP `client_status` tool, the dock, and the verify-after-write diagnostic +## in `McpClientConfigurator` all emit the same names — agents pattern-match +## against this set, so a fifth value being silently introduced would break +## them. +static func status_label(status: McpClient.Status) -> String: + match status: + Status.CONFIGURED: + return "configured" + Status.NOT_CONFIGURED: + return "not_configured" + Status.CONFIGURED_MISMATCH: + return "configured_mismatch" + return "error" + + +## One-line configure success message, shared by every strategy so the dock +## and the `client_manage` tool describe the transport that was actually +## written. Command-shape clients register the stdio `godot-ai attach` +## bridge — the URL-era "(HTTP: )" suffix would name a transport the +## write never touched (found live in the #838 Windows smoke). +static func configured_message(client: McpClient, server_url: String) -> String: + if client.command_shape != CommandShape.NONE: + return "%s configured (stdio attach)" % client.display_name + return "%s configured (HTTP: %s)" % [client.display_name, server_url] + +var id: String = "" ## stable key, e.g. "cursor" +var display_name: String = "" ## "Cursor" +var config_type: String = "" ## "json" | "toml" | "yaml" | "cli" | "dsh" + +# JSON / TOML clients ------------------------------------------------------ +## {"darwin": "~/...", "windows": "$APPDATA/...", "linux": "$XDG_CONFIG_HOME/..."} +## Keys may also use "unix" as a shorthand for darwin+linux. +var path_template: Dictionary = {} + +## Optional ordered path candidates by platform. Each value is an Array of +## templates; one `*` may appear in a directory segment so packaged-app roots +## can be discovered without hardcoding publisher hashes. +## +## Resolution contract: +## 1. Existing files win in descriptor order, except that a unique wildcard +## match is authoritative even before its config leaf exists. A matching +## package root therefore creates inside that package rather than writing +## a fallback path that may become invisible after copy-on-write. When +## that private leaf is new, Configure seeds it from the first later +## existing candidate so read-through content is not shadowed. +## 2. If no file or wildcard package match exists, the first non-wildcard +## template is the deterministic create target. +## 3. Multiple matches within any wildcard group are ambiguous and fail +## closed instead of choosing an arbitrary package. +## +## Exact-file and config-home environment overrides still have higher +## priority. When this map has no entry for the current platform, +## `path_template` remains the fallback. +var config_path_candidates: Dictionary = {} + +## Optional global JSON config files merged by the client from lowest to highest +## precedence. Unlike `config_path_candidates`, every existing file participates; +## Configure updates the effective last definition, status verifies it, and +## Remove clears every global definition transactionally. +var config_merge_path_templates: Dictionary = {} +## Project-relative tiers that may override the global files. Their root is the +## external client's working directory, which the Godot process cannot know. +## The strategy checks plausible roots and fails closed instead of mutating them. +var config_merge_project_paths: PackedStringArray = PackedStringArray() + +## De-duplicate persistent path-ambiguity warnings across recurring status +## refreshes. The actionable message still returns on every resolution; only +## the editor-console echo is single-shot until the ambiguity clears/changes. +var _last_config_path_warning := "" +var _config_path_warning_mutex := Mutex.new() + +## Path inside the config object where the per-server map lives. +## Cursor / Claude Desktop / most others: ["mcpServers"] +## VS Code: ["servers"] +## OpenCode: ["mcp"] +var server_key_path: PackedStringArray = PackedStringArray() +## Alternative server-map paths accepted by the client, in precedence order +## after `server_key_path`. JSON strategy operations select the first non-null +## existing path so Configure, status, and Remove agree with the client parser. +var server_key_path_aliases: Array[PackedStringArray] = [] + +## Field inside the entry dict that holds our server URL. +## "url" by default; some clients use "serverUrl" or "httpUrl". +var entry_url_field: String = "url" + +## Required entry fields — written on every Configure AND verified by the +## default verifier. Use this for transport pins (e.g. `type: +## "streamable-http"`) where a missing/wrong value breaks negotiation: a +## legacy entry without the pin fails verification and surfaces as drift. +## +## DO NOT put user-mutable state here (auto-approval lists, `disabled` +## flags, opt-in toggles). Verifying those treats every user customisation +## as drift, and Configure-All-Mismatched then silently overwrites them +## back to defaults — see the `entry_initial_fields` doc below. +var entry_extra_fields: Dictionary = {} + +## Default fields written ONLY when the entry doesn't yet exist. Reconfigure +## preserves whatever the user (or the client itself) has set; the verifier +## ignores these keys entirely. Use for opt-in flags and user-state arrays — +## e.g. Roo / Cline / Kilo `alwaysAllow` / `autoApprove` lists, `disabled: +## false`, `isActive: true`. The pre-#229 behaviour was equivalent: per- +## client `entry_builder` lambdas seeded these as defaults but the +## per-client `verify_entry` lambdas only checked transport pins, so a +## user-customised array was `CONFIGURED`, not drift. Splitting the field +## restores that contract under the data-only descriptor model. +var entry_initial_fields: Dictionary = {} + +## Client-owned stdio launch shape. Each strategy renders the shape in its +## config language: +## +## - FLAT — `command` string + `args` array as sibling keys. JSON and YAML +## strategies. A client whose docs require a type discriminator next to the +## flat keys (VS Code's `type: "stdio"`, Claude Code's fallback file) stays +## FLAT and declares it via `command_transport_key` / `command_transport_value`, +## so TYPED_FLAT remains reserved vocabulary. +## - COMMAND_ARRAY — the launch argv carried as one array. In the JSON +## strategy the entry's `command` field IS that array (OpenCode's +## `"command": ["uvx", …]`). In the TOML strategy the launcher renders as a +## `command = "…"` line plus an `args = […]` array (Codex, Grok) — the name +## refers to the argv-as-TOML-array body it emits. +## - NESTED_COMMAND — command/args nested inside a sub-object. Reserved; no +## current client needs it and strategies reject it with an actionable error. +## +## CLI-registered clients (`config_type == "cli"`) express the launch through +## `cli_register_template` tokens instead; their `command_shape` governs the +## JSON-fallback file rendering (Claude Code, #463). +## +## Values are data-only shared vocabulary; keeping them data-only avoids +## reintroducing the descriptor Callable race from #229. +enum CommandShape { NONE, FLAT, TYPED_FLAT, COMMAND_ARRAY, NESTED_COMMAND } +var command_shape: CommandShape = CommandShape.NONE + +## Whether manual instructions may offer the client's native URL transport as +## an alternative to its command shape. This is capability metadata, not a +## consequence of `command_shape`: Codex supports a URL block, while Claude +## Desktop's local `claude_desktop_config.json` entries are stdio-only. +var command_supports_url_fallback: bool = false + +## Optional discriminator required by a client's command transport shape +## (for example `type = "stdio"`). Empty means command+args are sufficient. +var command_transport_key: String = "" +var command_transport_value: Variant = null + +## Whether this client's Windows stdio entry must launch through the +## GUI-subsystem pythonw bootstrap (#827). The bootstrap exists for clients +## that run console-subsystem MCP commands in a visible terminal (Codex); +## Electron-family spawners hide child consoles themselves, and at least one +## (Antigravity) hangs tool calls when handed a GUI-subsystem executable +## (#863). Set false to write the plain console launcher on Windows. +var needs_consoleless_launcher: bool = true + +## Keys from the legacy transport that Configure must delete. Codex removes +## `url`, because Codex rejects a server entry containing both URL and stdio +## launch fields. +var command_legacy_keys: PackedStringArray = PackedStringArray() + +## Keys inside a preserved JSON `env` object that belonged to a legacy launch +## shape and must be removed during migration. Other environment values remain +## user-owned and survive Configure. Currently consumed by the JSON strategy. +var command_env_legacy_keys: PackedStringArray = PackedStringArray() + +## Defaults seeded only for a new entry. Reconfigure preserves user values. +## Codex uses this for enabled/startup/tool timeout defaults. +var command_initial_fields: Dictionary = {} + +## Declarative documentation of fields owned by the user and timeout fields +## supported by this client. Strategies preserve these values and tests pin +## the descriptor contract; no control flow lives on the descriptor. +var command_user_fields: PackedStringArray = PackedStringArray() +var command_timeout_fields: PackedStringArray = PackedStringArray() + +## Paths whose existence implies the user has this client installed. +## Used purely for the dock's "installed" badge. `is_installed()` additionally +## checks `resolved_config_path()`, so a config relocated via an environment +## override is detected without listing it here. +var detect_paths: PackedStringArray = PackedStringArray() + +# Config-path env overrides -------------------------------------------------- +## Some clients name the exact config file in an environment variable +## (OpenCode: `$OPENCODE_CONFIG`). When the variable is set and non-empty, it +## wins over directory-valued `config_home_env` and `path_template`. Relative +## values fail closed because the editor and client may have different working +## directories; auto-configuration cannot safely assume they resolve alike. +var config_file_env: String = "" + +## Some clients honor an env var that relocates their entire config home +## (Codex: `$CODEX_HOME/config.toml`; Claude Code: `$CLAUDE_CONFIG_DIR/.claude.json`). +## When `config_home_env` names an env var that is set and non-empty, +## `resolved_config_path()` returns `/` +## instead of resolving `path_template`. Both fields must be non-empty for the +## override to apply. Only declare a mapping when the client's docs guarantee +## the env var relocates the exact file we write — a wrong mapping writes the +## MCP entry somewhere the client never reads and Configure false-succeeds. +## Relative values fail closed for the same reason `config_file_env`'s do. +var config_home_env: String = "" +## Path of the config file relative to the env var's directory, e.g. +## "config.toml". Joined verbatim — no per-OS variants needed because the env +## value itself is already an absolute (or ~-prefixed) directory. +var config_home_env_subpath: String = "" + +# CLI clients -------------------------------------------------------------- +var cli_names: PackedStringArray = PackedStringArray() +## Argument templates with `{name}` and `{url}` tokens; the strategy +## substitutes them at call time. Tokens are matched verbatim — no escaping +## semantics, no shell expansion. Command-shape templates additionally use the +## whole-element tokens `{command}` / `{args...}` (see `McpCliStrategy.format_args`). +## Populated by CLI descriptors (currently `claude_code`; `kimi_code` moved to +## mcp.json in #813). +var cli_register_template: PackedStringArray = PackedStringArray() +var cli_unregister_template: PackedStringArray = PackedStringArray() +## Args run to read current state; stdout is scanned for the server name and +## URL. Presence of `name` AND `url` → CONFIGURED, name only → MISMATCH, +## neither → NOT_CONFIGURED. +var cli_status_args: PackedStringArray = PackedStringArray() +## Optional scope-aware status template with a `{name}` token, for descriptors +## that also take `{scope}` in their register template. `cli_status_args` lists +## whatever the CLI resolves for the current directory and cannot say WHICH +## scope an entry came from, so a leftover user-scope entry reads as CONFIGURED +## while the selected project scope is empty — the dock then shows green over +## exactly the "loaded in every workspace" state the scope setting exists to +## end (#872). This template must print a `Scope: …` line +## (`claude mcp get ` does) and exit non-zero when the server is absent. +## Only consulted when the selected scope isn't the one `path_template` reads. +var cli_scope_status_template: PackedStringArray = PackedStringArray() + +# Codex / TOML clients ----------------------------------------------------- +## Dotted TOML path under which our entry lives, e.g. ["mcp_servers", "godot-ai"]. +## Strategies build the [section."name"] header from this. +var toml_section_path: PackedStringArray = PackedStringArray() +var toml_legacy_section_aliases: PackedStringArray = PackedStringArray() +## Lines (without the [header]) emitted under the section, with `{url}` +## tokens. Substituted at call time. +var toml_body_template: PackedStringArray = PackedStringArray() + + +## Resolved absolute config path for this client on the current OS. Exact-file +## overrides win first, followed by directory-valued `config_home_env`, then +## ordered candidates / `path_template`. Ignoring either override can write a +## file the client never reads and false-succeed. +func resolved_config_path() -> String: + return str(resolved_config_path_details().get("path", "")) + + +## Detailed sibling used by status/configure/remove so safe resolution +## failures reach the dock instead of collapsing into NOT_CONFIGURED. `error` +## is empty for ordinary unsupported/missing path mappings to preserve the +## long-standing status behavior for clients not installed on this platform. +func resolved_config_path_details() -> Dictionary: + var details := _resolve_config_path_details() + var path := str(details.get("path", "")) + if path.is_empty() or path.is_absolute_path(): + return details + ## Last gate before every strategy's read/write. `McpPathTemplate.expand` + ## leaves a token it cannot resolve in place, so an unset `$USERPROFILE` (or + ## a `~` with no HOME) reaches here as a RELATIVE path rather than as the + ## root-relative `/godot` that sails through `is_absolute_path()`. Acting on + ## it would resolve against the editor's own working directory and create a + ## literal `$USERPROFILE` folder in the Godot project. No descriptor + ## template is intentionally relative — every one is `~`- or `$VAR`-rooted — + ## so a non-absolute survivor is always a resolution failure. Report it with + ## the unexpanded path, which still shows what could not be resolved. + return {"path": "", "error": unresolved_config_path_error(display_name, path)} + + +## Shared wording for a path template `McpPathTemplate.expand` could not fully +## resolve. Used by the guard above and by the merge-tier loader in +## `_json_strategy.gd`, which resolves its own templates and never passes +## through `resolved_config_path_details`. +static func unresolved_config_path_error(client_name: String, path: String) -> String: + return ( + "Could not resolve %s's config path: %s did not expand to an absolute " + + "path. Set HOME/USERPROFILE (or the variable the path names) in the " + + "environment the editor was launched from." + ) % [client_name, path] + + +func _resolve_config_path_details() -> Dictionary: + ## Reflected reads: after an in-session self-update, an instance created + ## before the update can answer Nil for vars the update added, and the + ## typed calls below would each hard-error (Nil -> Dictionary, #850's + ## per-row error wall). Fail with the + ## one repair message instead; the registry's coherence probe drives the + ## same text on the status path. + var candidates: Variant = get("config_path_candidates") + var template: Variant = get("path_template") + var file_env: Variant = get("config_file_env") + if not (candidates is Dictionary) or not (template is Dictionary) or not (file_env is String): + return {"path": "", "error": McpClientRegistry.RESTART_TO_FINISH_UPDATE} + var file_override := config_file_override_details() + if not str(file_override.get("path", "")).is_empty() or not str(file_override.get("error", "")).is_empty(): + _clear_config_path_warning() + return file_override + var home_override := config_home_override_details() + if not str(home_override.get("path", "")).is_empty() or not str(home_override.get("error", "")).is_empty(): + _clear_config_path_warning() + return home_override + var candidate_key := McpPathTemplate.platform_key(candidates) + if not candidate_key.is_empty(): + return _resolve_ordered_config_path_candidates(candidates[candidate_key]) + _clear_config_path_warning() + return {"path": McpPathTemplate.resolve(template), "error": ""} + + +## The exact-file env override plus any fail-closed diagnostic. Empty path and +## error means no override applies (no mapping, unset, or blank env var). +func config_file_override_details() -> Dictionary: + if config_file_env.is_empty(): + return {"path": "", "error": ""} + ## env_lookup, not OS.get_environment: this can run on dock workers (#691). + var raw_path := McpPathTemplate.env_lookup(config_file_env).strip_edges() + if raw_path.is_empty(): + return {"path": "", "error": ""} + var expanded := McpPathTemplate.expand(raw_path) + if not expanded.is_absolute_path(): + return { + "path": "", + "error": "%s's $%s override must be an absolute config-file path; got %s" % [ + display_name, config_file_env, raw_path, + ], + } + if DirAccess.dir_exists_absolute(expanded): + return { + "path": "", + "error": "%s's $%s override must point to a config file, not a directory: %s" % [ + display_name, config_file_env, expanded, + ], + } + return {"path": expanded, "error": ""} + + +func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary: + if not (templates is Array or templates is PackedStringArray): + _clear_config_path_warning() + return {"path": "", "error": ""} + var ordered_templates: Array = [] + for template_variant in templates: + ordered_templates.append(str(template_variant)) + var fallback_create_path := "" + for index in range(ordered_templates.size()): + var template := str(ordered_templates[index]) + var expanded_template := McpPathTemplate.expand(template) + # An empty wildcard group ordinarily means "this package is not + # installed", but an unresolved root token produces the same empty + # group. Do not silently treat "could not inspect" as "not present" and + # fall through to a later writable candidate. + if not expanded_template.is_absolute_path(): + _clear_config_path_warning() + return { + "path": "", + "error": unresolved_config_path_error(display_name, expanded_template), + } + var group := McpPathTemplate.expand_path_candidates(template) + if group.size() > 1: + var message := ( + "%s has multiple matching config package paths for %s: %s. " + + "Remove the stale package installation or edit the intended config manually." + ) % [display_name, template, ", ".join(group)] + _warn_config_path_once(message) + return {"path": "", "error": message} + if group.is_empty(): + continue + var path := String(group[0]) + if FileAccess.file_exists(path): + _clear_config_path_warning() + return {"path": path, "error": ""} + # A wildcard only resolves when its package directory exists. Treat that + # installation evidence as authoritative and create its private config + # directly instead of relying on copy-on-write read-through. Preserve + # anything currently visible through read-through by naming the first + # later existing candidate as a one-time seed source. + if template.contains("*"): + var seed := _first_existing_later_candidate(ordered_templates, index + 1) + if not str(seed.get("error", "")).is_empty(): + _clear_config_path_warning() + return {"path": "", "error": seed["error"]} + _clear_config_path_warning() + return {"path": path, "error": "", "seed_path": seed.get("path", "")} + if fallback_create_path.is_empty(): + fallback_create_path = path + _clear_config_path_warning() + return {"path": fallback_create_path, "error": ""} + + +## Find a readable seed for a newly-created authoritative wildcard target. +## An unresolved later root is an error, not an absent seed: writing without +## inspecting it could silently discard configuration visible through the +## package's read-through fallback. +func _first_existing_later_candidate(templates: Array, start_index: int) -> Dictionary: + for index in range(start_index, templates.size()): + var template := str(templates[index]) + var expanded_template := McpPathTemplate.expand(template) + if not expanded_template.is_absolute_path(): + return { + "path": "", + "error": unresolved_config_path_error(display_name, expanded_template), + } + var group := McpPathTemplate.expand_path_candidates(template) + # A seed is optional. Never choose among an ambiguous later wildcard; + # the authoritative target was already resolved by the earlier group. + if group.size() != 1: + continue + var path := String(group[0]) + if FileAccess.file_exists(path): + return {"path": path, "error": ""} + return {"path": "", "error": ""} + + +func _warn_config_path_once(message: String) -> void: + _config_path_warning_mutex.lock() + var should_warn := message != _last_config_path_warning + _last_config_path_warning = message + _config_path_warning_mutex.unlock() + if should_warn: + push_warning(message) + + +func _clear_config_path_warning() -> void: + _config_path_warning_mutex.lock() + _last_config_path_warning = "" + _config_path_warning_mutex.unlock() + + +## The config-home env override plus any fail-closed diagnostic. Empty path and +## error means no override applies (no mapping declared, env var unset, or env +## var empty/whitespace). +func config_home_override_details() -> Dictionary: + if config_home_env.is_empty() or config_home_env_subpath.is_empty(): + return {"path": "", "error": ""} + ## env_lookup, not OS.get_environment: this runs on dock worker threads, + ## which must not race the spawn window's setenv/unsetenv (#691). + var home := McpPathTemplate.env_lookup(config_home_env).strip_edges() + if home.is_empty(): + return {"path": "", "error": ""} + # Expand a leading ~ so `CODEX_HOME=~/codex-alt` behaves like the shell. + var expanded := McpPathTemplate.expand(home) + ## Same fail-closed rule as `config_file_override_details`: a relative value + ## resolves against the EDITOR's working directory, not the client's, so + ## honouring it silently aims the write at the Godot project directory + ## instead of the file the client reads. The write layer can only name the + ## mangled path it was handed; only here do we still know which env var + ## produced it, so this is where the user-facing explanation belongs. + if not expanded.is_absolute_path(): + return { + "path": "", + "error": "%s's $%s override must be an absolute config-home directory path; got %s" % [ + display_name, config_home_env, home, + ], + } + return {"path": expanded.path_join(config_home_env_subpath), "error": ""} + + +## The env-var-relocated config path, or "" when no override applies (no +## mapping declared, env var unset, env var empty/whitespace, or a value that +## failed closed — `config_home_override_details` carries that diagnostic). +func config_home_override() -> String: + return str(config_home_override_details().get("path", "")) + + +## True when a CLI client also declares where its config file lives, so it can +## fall back to writing that file directly when the CLI binary isn't on PATH. +## #463: Claude Code installed only as a VS Code / Cursor extension exposes no +## `claude` binary, but `claude mcp add --scope user` just writes `mcpServers` +## into ~/.claude.json — so we can produce the same entry ourselves. +func has_json_fallback() -> bool: + return config_type == "cli" and not path_template.is_empty() and not server_key_path.is_empty() + + +## True if the user appears to have this client installed locally. +func is_installed() -> bool: + if config_type == "cli": + if not McpCliFinder.find(_array_from_packed(cli_names)).is_empty(): + return true + # CLI not on PATH. A cli client with a JSON fallback (Claude Code as a + # VS Code/Cursor extension, #463) still counts as installed if its + # fallback config file already exists. + if has_json_fallback(): + var cfg := resolved_config_path() + return not cfg.is_empty() and FileAccess.file_exists(cfg) + return false + for p in detect_paths: + for resolved in McpPathTemplate.expand_path_candidates(p): + if FileAccess.file_exists(resolved) or DirAccess.dir_exists_absolute(resolved): + return true + # Fall back to "config file already exists" — usually means installed at some point. + var cfg := resolved_config_path() + return not cfg.is_empty() and FileAccess.file_exists(cfg) + + +static func _array_from_packed(packed: PackedStringArray) -> Array[String]: + var out: Array[String] = [] + for s in packed: + out.append(s) + return out + + +## Slice a PackedStringArray into a new PackedStringArray over [from, to). +## Used by `_toml_strategy` and `_manual_command` to peel the section path +## apart for `[a.b."c"]` header rendering. +static func _packed_slice(packed: PackedStringArray, from: int, to: int) -> PackedStringArray: + var out := PackedStringArray() + for i in range(from, to): + out.append(packed[i]) + return out diff --git a/addons/godot_ai/clients/_base.gd.uid b/addons/godot_ai/clients/_base.gd.uid new file mode 100644 index 0000000..5ea77d3 --- /dev/null +++ b/addons/godot_ai/clients/_base.gd.uid @@ -0,0 +1 @@ +uid://cyowqr1x12ilg diff --git a/addons/godot_ai/clients/_cli_exec.gd b/addons/godot_ai/clients/_cli_exec.gd new file mode 100644 index 0000000..995b91c --- /dev/null +++ b/addons/godot_ai/clients/_cli_exec.gd @@ -0,0 +1,176 @@ +@tool +class_name McpCliExec +extends RefCounted + +## Wall-clock-bounded CLI invocation. Every dock shell-out to a per-client +## CLI (`claude mcp list`, `claude mcp add ...`, etc.) goes through here so +## a hung subprocess can't trap the calling thread forever. +## +## Without the timeout, a contended `claude mcp list` has been observed to +## hang for 6+ minutes (issues #238, #239) — wedging the dock's status +## refresh worker, and on the Configure / Remove paths the editor main +## thread itself. +## +## Why poll/kill instead of `OS.execute(..., true)`: GDScript can't +## interrupt a blocking `OS.execute`, so a hung CLI takes its caller's +## thread with it. `OS.execute_with_pipe` returns immediately with a PID; +## we drive the wait ourselves and `OS.kill` the orphan if budget +## expires. CLI registry commands have bounded output (a few hundred +## bytes), so we don't bother draining the pipe during the poll loop — +## the kernel buffer absorbs it. +## +## Returns a Dictionary with: +## exit_code: process exit code (0 = success). -1 on timeout / spawn failure. +## stdout: captured stdout text. May be partial on timeout. +## stderr: captured stderr text. May be partial on timeout. Empty when +## `capture_stderr` is false. +## output: stdout + (newline + stderr if non-empty). Convenience for +## the common case of "show whatever the CLI said when it +## failed" — `claude mcp add` writes its real diagnostics to +## stderr, so callers that only read `stdout` would surface +## a generic "exit code 1" instead. +## timed_out: true if we killed the process at the wall-clock budget. +## cancelled: true if the caller requested a cooperative early stop. +## spawn_failed: true if `OS.execute_with_pipe` didn't return a usable PID. + +const DEFAULT_TIMEOUT_MS := 8000 +const _POLL_INTERVAL_MS := 50 +const _KILL_GRACE_MS := 500 + + +static func run( + exe: String, + args: Array, + timeout_ms: int = DEFAULT_TIMEOUT_MS, + capture_stderr: bool = true, + cancel_check: Callable = Callable(), +) -> Dictionary: + if exe.is_empty(): + return _spawn_failed_result() + return _run_piped(exe, args, timeout_ms, capture_stderr, cancel_check) + + +static func _run_piped( + exe: String, + args: Array, + timeout_ms: int, + capture_stderr: bool, + cancel_check: Callable, +) -> Dictionary: + + var spawn_exe := exe + var spawn_args := args + if OS.get_name() == "Windows": + var lower := exe.to_lower() + if lower.ends_with(".cmd") or lower.ends_with(".bat"): + ## CreateProcessW can't launch `.cmd` / `.bat` scripts on its + ## own — they're cmd.exe input, not PE binaries. Without this + ## wrap, the moment `McpCliFinder` resolves a Node-style shim + ## (npm's `claude.cmd`, pnpm's wrappers, …) the next + ## `OS.execute_with_pipe` surfaces "Could not create child + ## process: ..." in Godot's output log (#251). Passing + ## `exe` as a separate argv element keeps spaces in the path + ## quoted by Godot's standard quoter — no manual escaping. + spawn_exe = "cmd.exe" + spawn_args = ["/c", exe] + spawn_args.append_array(args) + + var info := OS.execute_with_pipe(spawn_exe, spawn_args) + if info.is_empty(): + return _spawn_failed_result() + + var pid: int = int(info.get("pid", -1)) + var stdio: Variant = info.get("stdio", null) + var stderr_pipe: Variant = info.get("stderr", null) + if pid <= 0: + _close_pipes(stdio, stderr_pipe) + return _spawn_failed_result() + + var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS) + while OS.is_process_running(pid): + var cancelled := cancel_check.is_valid() and bool(cancel_check.call()) + if cancelled or Time.get_ticks_msec() >= deadline: + ## Kill before draining: a pipe read can block while the child is + ## still alive. Once it exits, drain any buffered partial output. + OS.kill(pid) + var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS + while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline: + OS.delay_msec(_POLL_INTERVAL_MS) + + var partial_stdout := "" + var partial_stderr := "" + if not OS.is_process_running(pid): + partial_stdout = _drain_pipe(stdio) + partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else "" + _close_pipes(stdio, stderr_pipe) + return { + "exit_code": -1, + "stdout": partial_stdout, + "stderr": partial_stderr, + "output": _join_streams(partial_stdout, partial_stderr), + "timed_out": not cancelled, + "cancelled": cancelled, + "spawn_failed": false, + } + OS.delay_msec(_POLL_INTERVAL_MS) + + var stdout := _drain_pipe(stdio) + var stderr_text := _drain_pipe(stderr_pipe) if capture_stderr else "" + _close_pipes(stdio, stderr_pipe) + + return { + "exit_code": OS.get_process_exit_code(pid), + "stdout": stdout, + "stderr": stderr_text, + "output": _join_streams(stdout, stderr_text), + "timed_out": false, + "cancelled": false, + "spawn_failed": false, + } + + +static func _spawn_failed_result() -> Dictionary: + return { + "exit_code": -1, + "stdout": "", + "stderr": "", + "output": "", + "timed_out": false, + "cancelled": false, + "spawn_failed": true, + } + + +static func _drain_pipe(pipe: Variant) -> String: + if not (pipe is FileAccess): + return "" + var f := pipe as FileAccess + var bytes := PackedByteArray() + var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output. + while bytes.size() < max_bytes: + var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size())) + if chunk.is_empty(): + break + bytes.append_array(chunk) + if f.eof_reached(): + break + return bytes.get_string_from_utf8() + + +static func _join_streams(stdout: String, stderr_text: String) -> String: + ## Most CLIs write their actionable diagnostics to one stream or the + ## other, never both — so concatenation gives "the message" without + ## the caller having to guess which key to read. Newline-separate so + ## callers that grep don't see two lines run together. + if stderr_text.is_empty(): + return stdout + if stdout.is_empty(): + return stderr_text + return "%s\n%s" % [stdout, stderr_text] + + +static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void: + if stdio is FileAccess: + (stdio as FileAccess).close() + if stderr_pipe is FileAccess: + (stderr_pipe as FileAccess).close() diff --git a/addons/godot_ai/clients/_cli_exec.gd.uid b/addons/godot_ai/clients/_cli_exec.gd.uid new file mode 100644 index 0000000..4a97a2c --- /dev/null +++ b/addons/godot_ai/clients/_cli_exec.gd.uid @@ -0,0 +1 @@ +uid://dhoe3ypkhm12v diff --git a/addons/godot_ai/clients/_cli_finder.gd b/addons/godot_ai/clients/_cli_finder.gd new file mode 100644 index 0000000..394e76c --- /dev/null +++ b/addons/godot_ai/clients/_cli_finder.gd @@ -0,0 +1,181 @@ +@tool +class_name McpCliFinder +extends RefCounted + +## Generic three-tier CLI resolution for clients whose binary lives somewhere +## a GUI-launched Godot's minimal PATH won't see: +## 1. Well-known install locations (~/.local/bin, /opt/homebrew/bin, ...) +## 2. Login shell lookup (`bash -lc 'command -v '`) — picks up .zshrc / .bashrc +## 3. Plain `which` / `where` against the inherited PATH +## Caches per-exe so repeated dock refreshes don't fork a shell every frame. +## +## Thread safety: `find()` runs on action-worker threads +## (`_run_client_action_worker` in `mcp_dock.gd`), and `invalidate()` runs on +## the main thread (manual Refresh path). Godot `Dictionary` is not safe for +## concurrent mutation, so `_cache` / `_searched` access is guarded by +## `_mutex`. The mutex is held only across dictionary read/write — the slow +## `_resolve()` path (FileAccess + bounded subprocess lookup) runs unlocked, so a +## main-thread `invalidate()` can never block on a worker's subprocess. +## Two workers racing the same exe both call `_resolve()` and both write +## back the same answer; that's wasted work, not corruption. + + +static var _mutex: Mutex = Mutex.new() +static var _cache: Dictionary = {} # exe_name -> resolved path (or "") +static var _searched: Dictionary = {} + +const _LOOKUP_TIMEOUT_MS := 3000 + + +## Find any of the supplied exe names; returns the first hit. +## On Windows pass the .exe variant in `exe_names` if relevant. +static func find(exe_names: Array[String]) -> String: + for name in exe_names: + var hit := _find_one(name) + if not hit.is_empty(): + return hit + return "" + + +## Drop cache for one exe (call after the user installs / reinstalls). +static func invalidate(exe_name: String = "") -> void: + _mutex.lock() + if exe_name.is_empty(): + _cache.clear() + _searched.clear() + else: + _cache.erase(exe_name) + _searched.erase(exe_name) + _mutex.unlock() + + +static func _find_one(exe_name: String) -> String: + _mutex.lock() + var already_searched: bool = _searched.get(exe_name, false) + var cached: String = _cache.get(exe_name, "") + _mutex.unlock() + if already_searched: + return cached + # `_resolve()` does FileAccess + bounded subprocess lookup (forks + # `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that + # would let a concurrent `invalidate()` on the main thread freeze the + # editor for the duration of the subprocess — which defeats the whole + # point of running CLI lookup off the main thread. + var hit := _resolve(exe_name) + _mutex.lock() + _cache[exe_name] = hit + _searched[exe_name] = true + _mutex.unlock() + return hit + + +static func _resolve(exe_name: String) -> String: + var is_windows := OS.get_name() == "Windows" + + # 1. Well-known locations + for dir in _well_known_dirs(): + var full := dir.path_join(exe_name) + if FileAccess.file_exists(full): + return full + + # 2. Login shell lookup (Unix only) + if not is_windows: + ## env_lookup, not OS.get_environment: CLI resolution runs on dock + ## worker threads (configure/remove actions) and must not race the + ## spawn window's setenv/unsetenv (#691). + var shell := McpPathTemplate.env_lookup("SHELL") + if shell.is_empty(): + shell = "/bin/bash" + var stripped := exe_name.trim_suffix(".exe") + var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false) + if int(login_result.get("exit_code", -1)) == 0: + var login_found: String = str(login_result.get("stdout", "")).strip_edges() + if not login_found.is_empty() and FileAccess.file_exists(login_found): + return login_found + + # 3. which / where with inherited PATH + var lookup := "where" if is_windows else "which" + var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false) + if int(result.get("exit_code", -1)) == 0: + var output := str(result.get("stdout", "")) + var lines := PackedStringArray(output.split("\n")) + var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges() + if not found.is_empty(): + return found + return "" + + +## Executable extensions Windows' CreateProcessW can launch from a path +## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe` +## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is +## the legacy COM-format executable that some shims still ship. +const _WINDOWS_EXEC_EXTS := [".exe", ".cmd", ".bat", ".com"] + + +## Pick the best path from `where` output on Windows. +## +## npm-installed Node CLIs ship as BOTH `/` (a POSIX bash shim +## for WSL / Git Bash users) AND `/.cmd` (the actual Windows +## wrapper). `where ` lists both. CreateProcessW — the underlying +## syscall behind `OS.execute_with_pipe` — refuses to launch the +## extensionless POSIX shim, surfacing as +## `ERROR: Could not create child process: "...\claude" mcp list` +## in Godot's output log (#251). Picking a path with a real executable +## extension dodges that entirely. +## +## Extension scan is the OUTER loop so the order in `_WINDOWS_EXEC_EXTS` +## drives preference — `.exe` wins over `.cmd` even when the `.cmd` shows +## up first in `where` output (one fewer process per shell-out). Falls +## back to the first non-empty line when no entry has a recognised +## extension, so we never come up empty when `where` returned *something*. +static func _pick_best_path(lines: PackedStringArray) -> String: + var stripped := PackedStringArray() + for raw in lines: + var line := raw.strip_edges() + if not line.is_empty(): + stripped.append(line) + if stripped.is_empty(): + return "" + for ext in _WINDOWS_EXEC_EXTS: + for candidate in stripped: + if candidate.to_lower().ends_with(ext): + return candidate + return stripped[0] + + +static func _well_known_dirs() -> Array[String]: + ## env_lookup, not OS.get_environment — see _resolve()'s worker-thread + ## note (#691). + var home := McpPathTemplate.env_lookup("HOME") + if home.is_empty(): + home = McpPathTemplate.env_lookup("USERPROFILE") + match OS.get_name(): + "macOS": + return [ + home.path_join(".local/bin"), + home.path_join(".claude/local"), + home.path_join(".cargo/bin"), + "/opt/homebrew/bin", + "/usr/local/bin", + ] + "Windows": + var local := McpPathTemplate.env_lookup("LOCALAPPDATA") + var prog := McpPathTemplate.env_lookup("ProgramFiles") + var paths: Array[String] = [] + if not home.is_empty(): + paths.append(home.path_join(".claude/local")) + paths.append(home.path_join(".local/bin")) + paths.append(home.path_join(".cargo/bin")) + paths.append(home.path_join("AppData/Local/Programs/uv")) + if not local.is_empty(): + paths.append(local.path_join("Programs/uv")) + if not prog.is_empty(): + paths.append(prog.path_join("uv")) + return paths + _: + return [ + home.path_join(".local/bin"), + home.path_join(".claude/local"), + home.path_join(".cargo/bin"), + "/usr/local/bin", + ] diff --git a/addons/godot_ai/clients/_cli_finder.gd.uid b/addons/godot_ai/clients/_cli_finder.gd.uid new file mode 100644 index 0000000..9985270 --- /dev/null +++ b/addons/godot_ai/clients/_cli_finder.gd.uid @@ -0,0 +1 @@ +uid://cnp5b6fcwou2y diff --git a/addons/godot_ai/clients/_cli_strategy.gd b/addons/godot_ai/clients/_cli_strategy.gd new file mode 100644 index 0000000..0714115 --- /dev/null +++ b/addons/godot_ai/clients/_cli_strategy.gd @@ -0,0 +1,372 @@ +@tool +class_name McpCliStrategy +extends RefCounted + +## Strategy for MCP clients that own their own state via a CLI (e.g. +## `claude mcp add`). Reads `cli_register_template` / `cli_unregister_template` +## / `cli_status_args` from the descriptor and substitutes `{name}` / `{url}` +## tokens. Command-shape descriptors additionally use the whole-element launch +## tokens `{command}` / `{args...}` (see `format_args`). No descriptor-supplied +## Callables — see `_base.gd` for why. +## +## Every shell-out goes through `McpCliExec.run`, which wraps the call in a +## wall-clock timeout. A hung CLI (e.g. `claude mcp list` under +## inter-Claude-Code contention) gets killed at the budget instead of +## locking up the caller forever — see issues #238 / #239. + +## The descriptor token resolved from `godot_ai/mcp_client_scope` (#872). +## Only Claude Code takes it today; any `config_type = "cli"` descriptor can. +const SCOPE_TOKEN := "{scope}" + +const _CONFIGURE_TIMEOUT_MS := 10000 +const _REMOVE_TIMEOUT_MS := 10000 +const _STATUS_TIMEOUT_MS := 6000 + + +static func configure( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, +) -> Dictionary: + ## Fail closed before any subprocess runs: a command-shape client without a + ## verified attach launcher must not register anything (see + ## docs/client-configuration.md — an ERROR beats an entry known to be broken). + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": "error", "message": launch_error} + var cli := _resolve_cli(client) + if cli.is_empty(): + return {"status": "error", "message": "%s not found" % client.display_name} + + # Best-effort prior cleanup so re-configure is idempotent. Bounded to + # the same budget — a hung unregister shouldn't block the configure + # that follows. + if not client.cli_unregister_template.is_empty(): + ## #872: a `{scope}` descriptor sweeps EVERY scope, not just the + ## selected one. Flipping the setting user -> project and pressing + ## Configure would otherwise leave the old user-scope entry alive — + ## exactly the "loaded in every unrelated workspace" problem the + ## setting exists to fix. `mcp remove` on an absent entry is a no-op + ## and the result is discarded either way, so the extra passes are + ## safe; they cost one bounded subprocess per additional scope. + for pre_scope in _cleanup_scopes(client): + var pre_args := _format_args( + client.cli_unregister_template, server_name, server_url, {}, pre_scope + ) + McpCliExec.run(cli, pre_args, _REMOVE_TIMEOUT_MS) + + if client.cli_register_template.is_empty(): + return {"status": "error", "message": "%s descriptor missing cli_register_template" % client.display_name} + var args := _format_args(client.cli_register_template, server_name, server_url, launch) + var result := McpCliExec.run(cli, args, _CONFIGURE_TIMEOUT_MS) + if result.get("timed_out", false): + return { + "status": "error", + "message": "Configure %s timed out after %ds — see 'Run this manually' below to retry by hand" % [ + client.display_name, _CONFIGURE_TIMEOUT_MS / 1000, + ], + } + if result.get("spawn_failed", false): + return {"status": "error", "message": "Failed to spawn %s" % client.display_name} + if int(result.get("exit_code", -1)) == 0: + return {"status": "ok", "message": McpClient.configured_message(client, server_url)} + ## `claude mcp add` writes its real failure diagnostics to stderr, so + ## prefer `output` (stdout + stderr) over `stdout` alone — otherwise + ## the user sees "exit code 1" instead of the actual error. + var combined := str(result.get("output", "")).strip_edges() + var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1)) + return {"status": "error", "message": "Failed to configure %s: %s" % [client.display_name, err]} + + +## Run the descriptor's `cli_status_args`, scan stdout for `server_name` and +## the expected target. The matching rule is the only sensible one for "list +## MCP entries" output across CLI clients we currently support: name AND +## target present → CONFIGURED; name only → MISMATCH; neither → +## NOT_CONFIGURED. For URL descriptors the target is `server_url`; for +## command-shape descriptors it is the resolved attach launcher path (the +## listing prints the registered command line, not a URL). Command-shape CLI +## clients with a JSON fallback file get exact drift detection via the JSON +## strategy instead — the configurator prefers that path and only lands here +## for CLI clients whose state isn't file-readable. +static func check_status( + client: McpClient, server_name: String, server_url: String, launch: Dictionary = {} +) -> McpClient.Status: + return check_status_with_cli_path(client, server_name, server_url, _resolve_cli(client), launch) + + +static func check_status_with_cli_path( + client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {} +) -> McpClient.Status: + return check_status_details(client, server_name, server_url, cli, launch).get("status", McpClient.Status.NOT_CONFIGURED) + + +## Detailed variant used by the dock's refresh worker so it can surface a +## "probe timed out" badge on the affected row instead of silently +## conflating the timeout with NOT_CONFIGURED. Returns +## `{"status": Status, "error_msg": String}`. The caller plumbs +## `error_msg` straight into `_apply_row_status`. +static func check_status_details( + client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {} +) -> Dictionary: + if cli.is_empty(): + return _status_details(McpClient.Status.NOT_CONFIGURED) + if client.cli_status_args.is_empty(): + return _status_details(McpClient.Status.NOT_CONFIGURED) + var expected_target := server_url + if client.command_shape != McpClient.CommandShape.NONE: + ## Same fail-closed contract as configure: without a verified launcher + ## there is no target to compare against, and guessing would report a + ## broken entry as green. + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return _status_details(McpClient.Status.ERROR, launch_error) + expected_target = str(launch.get("command", "")) + var result := McpCliExec.run( + cli, + McpClient._array_from_packed(client.cli_status_args), + _STATUS_TIMEOUT_MS, + false + ) + if result.get("timed_out", false): + return _status_details(McpClient.Status.ERROR, "probe timed out") + if result.get("spawn_failed", false): + return _status_details(McpClient.Status.NOT_CONFIGURED) + if int(result.get("exit_code", -1)) != 0: + return _status_details(McpClient.Status.NOT_CONFIGURED) + var text := str(result.get("stdout", "")) + if text.find(server_name) < 0: + return _status_details(McpClient.Status.NOT_CONFIGURED) + ## Server registered, but pointing somewhere else — drift after a + ## port change. Surface as mismatch so the dock offers Reconfigure. + if text.find(expected_target) < 0: + return _status_details(McpClient.Status.CONFIGURED_MISMATCH) + return _status_details(McpClient.Status.CONFIGURED) + + +## Empty string when this client's launch requirements are satisfied. A +## command-shape descriptor requires a successfully resolved attach launch; +## URL descriptors (`CommandShape.NONE`) never require one. Mirrors +## `McpJsonStrategy.command_launch_error` / the TOML equivalent. +static func command_launch_error(client: McpClient, launch: Dictionary) -> String: + if client.command_shape == McpClient.CommandShape.NONE: + return "" + if not bool(launch.get("ok", false)): + return str(launch.get("error", "No compatible attach launcher was found.")) + return "" + + +static func _status_details(status: McpClient.Status, error_msg: String = "") -> Dictionary: + return {"status": status, "error_msg": error_msg} + + +static func remove(client: McpClient, server_name: String) -> Dictionary: + var cli := _resolve_cli(client) + if cli.is_empty(): + return {"status": "error", "message": "%s not found" % client.display_name} + if client.cli_unregister_template.is_empty(): + return {"status": "error", "message": "%s descriptor missing cli_unregister_template" % client.display_name} + var args := _format_args(client.cli_unregister_template, server_name, "") + var result := McpCliExec.run(cli, args, _REMOVE_TIMEOUT_MS) + if result.get("timed_out", false): + return { + "status": "error", + "message": "Remove %s timed out after %ds — see 'Run this manually' below to retry by hand" % [ + client.display_name, _REMOVE_TIMEOUT_MS / 1000, + ], + } + if result.get("spawn_failed", false): + return {"status": "error", "message": "Failed to spawn %s" % client.display_name} + if int(result.get("exit_code", -1)) == 0: + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + ## `claude mcp add` writes its real failure diagnostics to stderr, so + ## prefer `output` (stdout + stderr) over `stdout` alone — otherwise + ## the user sees "exit code 1" instead of the actual error. + var combined := str(result.get("output", "")).strip_edges() + var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1)) + return {"status": "error", "message": "Failed to remove %s: %s" % [client.display_name, err]} + + +## Substitute `{name}` and `{url}` tokens in every template entry. +## Tokens match verbatim — `{name_suffix}` is NOT touched, so callers don't +## have to worry about partial-token collisions in their argv. +## +## Launch tokens are whole-element only: an element that is exactly +## `{command}` becomes the resolved attach launcher path, and an element that +## is exactly `{args...}` is spliced into the argv as one element per launch +## arg. Whole-element matching keeps a literal brace inside a path or flag +## from ever triggering an expansion. +## +## `scope_override` forces a specific `{scope}` value instead of the live +## setting; the configure pre-cleanup uses it to sweep the scopes the user is +## NOT currently on. Empty means "resolve from the setting". +static func format_args( + template: PackedStringArray, + server_name: String, + server_url: String, + launch: Dictionary = {}, + scope_override: String = "", +) -> Array[String]: + return _format_args(template, server_name, server_url, launch, scope_override) + + +static func _format_args( + template: PackedStringArray, + server_name: String, + server_url: String, + launch: Dictionary = {}, + scope_override: String = "", +) -> Array[String]: + var scope := scope_override if not scope_override.is_empty() else McpSettings.client_scope() + var out: Array[String] = [] + for arg in template: + var s := String(arg) + if s == "{command}": + out.append(str(launch.get("command", ""))) + continue + if s == "{args...}": + for launch_arg in launch.get("args", []): + out.append(str(launch_arg)) + continue + s = s.replace("{name}", server_name) + s = s.replace("{url}", server_url) + ## Resolved per-call rather than baked into the descriptor so the + ## setting can change without an editor restart, and so the manual + ## command shown in the dock always matches what Configure would run. + s = s.replace(SCOPE_TOKEN, scope) + out.append(s) + return out + + +## Scope-aware status for descriptors that declare `cli_scope_status_template`. +## Returns the same `{"status", "error_msg"}` shape as check_status_details. +## +## Split from the subprocess call so the verdict is a pure function of what the +## CLI printed — `_scope_probe_verdict` is unit-tested against real recorded +## `claude mcp get` output rather than needing a `claude` binary on the runner. +static func check_scope_status_details( + client: McpClient, + server_name: String, + server_url: String, + cli: String, + launch: Dictionary, + expected_scope: String, +) -> Dictionary: + if cli.is_empty() or client.cli_scope_status_template.is_empty(): + return _status_details(McpClient.Status.NOT_CONFIGURED) + var expected_target := server_url + if client.command_shape != McpClient.CommandShape.NONE: + ## Same fail-closed contract as configure and the plain status probe. + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return _status_details(McpClient.Status.ERROR, launch_error) + expected_target = str(launch.get("command", "")) + var args := _format_args(client.cli_scope_status_template, server_name, server_url) + var result := McpCliExec.run(cli, args, _STATUS_TIMEOUT_MS, false) + if result.get("timed_out", false): + return _status_details(McpClient.Status.ERROR, "probe timed out") + if result.get("spawn_failed", false): + return _status_details(McpClient.Status.NOT_CONFIGURED) + return _scope_probe_verdict( + int(result.get("exit_code", -1)), + str(result.get("stdout", "")), + expected_scope, + expected_target, + ) + + +## The `Scope:` label the probe printed, normalised to a CLIENT_SCOPES value, +## or "" when no line was recognisable. Matched on the first word after the +## label so the parenthetical blurb ("(shared via .mcp.json)") can change +## wording between CLI releases without breaking detection. +static func _scope_from_probe_output(text: String) -> String: + for raw_line in text.split("\n"): + var line := String(raw_line).strip_edges() + var marker := line.find("Scope:") + if marker < 0: + continue + var rest := line.substr(marker + 6).strip_edges().to_lower() + for scope in McpSettings.CLIENT_SCOPES: + var name := String(scope) + if rest.begins_with(name): + return name + return "" + + +## Pure verdict for a scope probe. A non-zero exit is the CLI's "no such +## server" signal. An entry resolved from a scope other than the selected one +## is MISMATCH, not CONFIGURED: the dock offers Reconfigure, and Configure's +## all-scope pre-cleanup is what actually moves it. +## +## A missing or unrecognised `Scope:` line degrades to the target check alone, +## deliberately rather than failing closed. `_verify_post_state` turns any +## non-CONFIGURED status after a successful write into an error, so treating an +## unparsed label as MISMATCH would mean a future CLI release that reworded or +## dropped that line breaks Configure outright — a false error plus a +## permanently amber row whose Reconfigure button cannot clear it. The entry +## has already been matched by name and exact launcher path at that point; the +## only thing in doubt is which scope it came from, and a slightly optimistic +## dot is a much cheaper failure than a configure flow that reports success as +## failure. Today's claude (2.1.241) always prints the line, so this is a +## forward-compatibility hedge, not a live code path. +static func _scope_probe_verdict( + exit_code: int, text: String, expected_scope: String, expected_target: String +) -> Dictionary: + if exit_code != 0: + return _status_details(McpClient.Status.NOT_CONFIGURED) + ## `resolved_scope` rides along as structured data so callers can act on it + ## — `_verify_post_state`'s path hint needs to know WHICH scope survived to + ## name the right file, and parsing it back out of the human-facing message + ## would couple that hint to this wording (#879). Parsed BEFORE the target + ## check so both mismatch paths carry it: an entry whose launcher drifted + ## sends the user to the wrong file just as readily as one whose scope did. + var resolved := _scope_from_probe_output(text) + if not expected_target.is_empty() and text.find(expected_target) < 0: + var drifted := _status_details(McpClient.Status.CONFIGURED_MISMATCH) + if not resolved.is_empty(): + drifted["resolved_scope"] = resolved + return drifted + if resolved.is_empty(): + return _status_details(McpClient.Status.CONFIGURED) + if resolved != expected_scope: + var details := _status_details( + McpClient.Status.CONFIGURED_MISMATCH, + "registered at %s scope, not %s" % [resolved, expected_scope], + ) + details["resolved_scope"] = resolved + return details + return _status_details(McpClient.Status.CONFIGURED) + + +## True when the descriptor's register template takes `{scope}`, i.e. where +## its entry lands is decided by the `godot_ai/mcp_client_scope` setting rather +## than fixed by the descriptor. The configurator uses this to decide whether +## the JSON-fallback file is still a valid place to read status back from. +static func uses_scope_token(client: McpClient) -> bool: + return client.cli_register_template.has(SCOPE_TOKEN) + + +## Public view of the pre-cleanup sweep, for the manual-command text: what the +## dock tells the user to run has to match what Configure actually runs (#877). +static func cleanup_scopes(client: McpClient) -> Array[String]: + return _cleanup_scopes(client) + + +## Scopes the configure pre-cleanup removes from. A descriptor without the +## `{scope}` token has exactly one place its entry can live, so it keeps the +## single pass it always had — "" means "resolve the scope normally". +static func _cleanup_scopes(client: McpClient) -> Array[String]: + if not uses_scope_token(client): + return [""] + var scopes: Array[String] = [] + for scope in McpSettings.CLIENT_SCOPES: + scopes.append(String(scope)) + return scopes + + +static func _resolve_cli(client: McpClient) -> String: + return McpCliFinder.find(McpClient._array_from_packed(client.cli_names)) + + +static func resolve_cli_path(client: McpClient) -> String: + return _resolve_cli(client) diff --git a/addons/godot_ai/clients/_cli_strategy.gd.uid b/addons/godot_ai/clients/_cli_strategy.gd.uid new file mode 100644 index 0000000..f84a92b --- /dev/null +++ b/addons/godot_ai/clients/_cli_strategy.gd.uid @@ -0,0 +1 @@ +uid://bvib7d8eabbcm diff --git a/addons/godot_ai/clients/_dsh_strategy.gd b/addons/godot_ai/clients/_dsh_strategy.gd new file mode 100644 index 0000000..e18385c --- /dev/null +++ b/addons/godot_ai/clients/_dsh_strategy.gd @@ -0,0 +1,759 @@ +@tool +class_name McpDshStrategy +extends RefCounted + +## Configure / status / remove for DeepSeek Harness (dsh) MCP servers. +## +## DeepSeek Harness has no `mcp` CLI verb (verified against dsh +## 0.1.0-rc.6 — `dsh --help` exposes only `web` and `plugin`). MCP servers +## register as `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch +## layer `$DSH_HOME/cordis.patch.yml` — the machine-local layer dsh applies +## over EVERY profile's own patch layer (the web GUI included), so one entry +## covers every profile the user boots. +## +## The patch file is a top-level YAML array of loader patch entries +## (`@deepseek-ai/cordis-plugin-include`'s `applyEntryPatches`). A non-insert +## row only OVERRIDES an existing bundle row (a new id is skipped with a +## warning), so a brand-new server must be added with an `insert` row: +## +## - insert: +## - id: mcp-godot-ai +## name: '@deepseek-ai/dsh-mcp-client' +## config: +## serverName: godot-ai +## transport: stdio +## command: "..." +## args: [...] +## +## Both shapes verified live against the dsh loader +## (`dsh --profile web --dump-config` composes the inserted entry; a plain +## row for a new id warns "patch: entry not found" and is skipped). We write +## one dedicated insert row per server, preserve every other row byte-for-byte +## (including other users' insert rows, overrides, comments, and `!!js` +## expressions), and status-verify the entry we wrote — the same file-read +## drift contract the JSON/TOML/YAML strategies use. +## +## The entry's `config` nests the launch under `serverName`/`transport`/ +## `command`/`args`; `serverName` is the model-facing tool namespace +## (`mcp__godot-ai__`), so it carries the server name like the +## `server_key_path` map key does for the other strategies. + +## Loader entry id prefix inside the patch list. The full id is +## `` — "mcp-godot-ai" for the godot-ai server. +const ENTRY_ID_PREFIX := "mcp-" +## The loader plugin that bridges MCP servers into the harness. Must be +## installed in the profile's node_modules (dsh ships it as a dependency of +## the CLI package). Quoted in the file: `@` is a reserved YAML indicator and +## cannot start a plain scalar. +const PLUGIN_NAME := "@deepseek-ai/dsh-mcp-client" +## Indent of a nested entry header under `- insert:` (children step 2 spaces +## deeper), matching the shipped profile patch files. +const ENTRY_INDENT := 4 + + +static func entry_id(server_name: String) -> String: + return ENTRY_ID_PREFIX + server_name + + +static func configure( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, +) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty(): + return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} + ## Fail closed before touching the file — same contract as JSON/TOML/YAML. + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": "error", "message": launch_error} + + var read := _read(path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [path, read["error"]]} + ## Fail closed on content that is not a loader patch list: dsh's patch + ## parser rejects a non-array file at boot, so appending our row to a + ## top-level mapping would hand the user a broken file (#867 review). + ## `[]` is a valid empty sequence meaning "no rows"; the literal must not + ## survive an append (block rows after `[]` are malformed YAML), so only + ## that line is dropped — comments around it belong to the user and stay. + var text := String(read["data"]) + text = _strip_empty_flow_sequence(text) + if not _is_patch_sequence(text): + return { + "status": "error", + "message": "Refusing to overwrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Configure." % path, + } + + var existing_config: Variant = null + var lines := _split_lines(text) + var found := _find_entry_block(lines, entry_id(server_name), true) + if found.is_empty(): + var plain := _find_entry_block(lines, entry_id(server_name), false) + if not plain.is_empty(): + ## A plain `- id: mcp-godot-ai` row is inert for a new id — the + ## loader skips it with a warning and never registers the server. + ## Replacing the whole row with the insert form migrates it. + found = plain + if not found.is_empty(): + ## Pass the parsed config through so user-mutable fields (env, + ## toolCallTimeoutMs, reconnect) survive a reconfigure. + existing_config = _extract_config(lines, found) + var new_entry := build_entry(client, server_name, server_url, existing_config, launch) + ## Nested replacement must keep the sequence's existing header indent — + ## items of one block sequence share indentation, so a hand-written + ## 2-space file re-emitted at 4 spaces would no longer parse (#867 + ## review). Fresh rows and plain-row migrations keep the 4-space style. + var base_indent := ENTRY_INDENT + if not found.is_empty(): + var header_indent := int(found.get("indent", 0)) + if header_indent > 0: + base_indent = header_indent + var rendered := render_nested_entry(entry_id(server_name), new_entry, base_indent) + var out := "" + if found.is_empty(): + out = _append_row(lines, rendered) + else: + out = _replace_lines(lines, found, rendered) + if not McpAtomicWrite.write(path, out): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": McpClient.configured_message(client, server_url)} + + +static func check_status( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, +) -> McpClient.Status: + return check_status_details(client, server_name, server_url, launch)["status"] + + +## Same contract as the other strategies (#711): {status, error_msg}. An +## existing-but-unreadable config is ERROR with the diagnostic — not +## NOT_CONFIGURED — so the dock row can tell "no config" from "config the +## editor can't read" instead of offering a Configure that would fail. +static func check_status_details( + client: McpClient, server_name: String, server_url: String, launch: Dictionary = {} +) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": McpClient.Status.ERROR, "error_msg": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + var read := _read(path) + if not read["ok"]: + return { + "status": McpClient.Status.ERROR, + "error_msg": "Cannot read %s: %s" % [path, read["error"]], + } + var text := String(read["data"]) + if not _is_patch_sequence(text): + ## A top-level mapping/scalar is not a dsh patch list: nothing in it + ## can be "configured", and a nested `- id:` inside a mapping must + ## not read as ours (#867 review). + return { + "status": McpClient.Status.ERROR, + "error_msg": "%s is not a top-level YAML list of loader entries; fix or remove the file." % path, + } + var lines := _split_lines(text) + var found := _find_entry_block(lines, entry_id(server_name), true) + if found.is_empty(): + ## A plain row for our id is inert (the loader skips unknown ids), so + ## the server is NOT registered no matter what the file says. It only + ## becomes registered once Configure rewrites it as an insert row. + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + var config := _extract_config(lines, found) + if config.is_empty(): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + ## An entry exists but no verified launcher does — mirror the other + ## strategies: this is an environment ERROR, not entry drift. + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": McpClient.Status.ERROR, "error_msg": launch_error} + if verify_entry(client, config, server_name, server_url, launch): + return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} + + +static func remove(client: McpClient, server_name: String) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": "ok", "message": "Not configured"} + var read := _read(path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]} + var text := String(read["data"]) + if not _is_patch_sequence(text): + ## Same fail-closed contract as configure (#867 review): never modify + ## a file that dsh cannot load as a patch list. + return { + "status": "error", + "message": "Refusing to rewrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Remove." % path, + } + var lines := _split_lines(text) + var found := _find_entry_block(lines, entry_id(server_name), true) + if found.is_empty(): + found = _find_entry_block(lines, entry_id(server_name), false) + if found.is_empty(): + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + var out := _remove_lines(lines, found) + if _is_blank_or_comment_only(out): + ## dsh's patch parser requires a top-level YAML array: an empty or + ## comment-only patch file FAILS BOOT (`loadOptionalPatches` throws on + ## a non-array file), while an absent file means "no layer". After our + ## row is gone, an all-blank file must therefore be deleted, not + ## written back. + if FileAccess.file_exists(path): + var remove_err := DirAccess.remove_absolute(path) + if remove_err != OK: + return {"status": "error", "message": "Cannot remove %s (error %d)" % [path, remove_err]} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + if not McpAtomicWrite.write(path, out): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + + +## Build the `config` dict written inside the loader entry. Command mode +## (FLAT, #838) nests `serverName`/`transport`/`command`/`args`; URL mode nests +## `serverName`/`transport`/`url`. Existing user state (env, timeouts, +## reconnect) survives a reconfigure via the deep copy. +static func build_entry( + client: McpClient, + server_name: String, + server_url: String, + existing: Variant = null, + launch: Dictionary = {}, +) -> Dictionary: + var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} + entry["serverName"] = server_name + if client.command_shape == McpClient.CommandShape.FLAT: + entry["command"] = str(launch.get("command", "")) + entry["args"] = _array_copy(launch.get("args", [])) + if not client.command_transport_key.is_empty(): + entry[client.command_transport_key] = client.command_transport_value + for key in client.command_initial_fields: + if not entry.has(key): + entry[key] = client.command_initial_fields[key] + for key in client.command_legacy_keys: + entry.erase(String(key)) + return entry + if client.command_shape == McpClient.CommandShape.NONE: + ## Single URL-mode source of truth (#867 review): build_entry's URL + ## branch delegates to build_url_entry so the two can never drift. + return build_url_entry(client, server_name, server_url, existing) + return {} + + +## URL-mode sibling for the manual-instruction fallback text and the +## build_entry URL branch. DeepSeek Harness' mcp-client bridge accepts +## `transport: streamable-http` with a `url`, so the entry keeps the same +## `serverName` namespace with an HTTP transport instead of the stdio attach +## bridge. Existing user state survives via the deep copy. +static func build_url_entry( + client: McpClient, server_name: String, server_url: String, existing: Variant = null +) -> Dictionary: + var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} + entry["serverName"] = server_name + entry["transport"] = "streamable-http" + if not client.entry_url_field.is_empty(): + entry[client.entry_url_field] = server_url + return entry + + +## Verify a stored `config` dict matches the current launch. Command mode +## checks every launch-affecting value exactly and requires legacy URL keys +## gone; serverName must match the pinned namespace. URL mode checks the url +## and the streamable-http pin. +static func verify_entry( + client: McpClient, + config: Dictionary, + server_name: String, + server_url: String, + launch: Dictionary = {}, +) -> bool: + if config.get("serverName") != server_name: + return false + if client.command_shape != McpClient.CommandShape.NONE: + if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)): + return false + for key in client.command_legacy_keys: + if config.has(String(key)): + return false + if config.get("command") != launch.get("command"): + return false + if not _arrays_equal(config.get("args", null), launch.get("args", null)): + return false + if not client.command_transport_key.is_empty(): + if config.get(client.command_transport_key, null) != client.command_transport_value: + return false + return true + if config.get(client.entry_url_field, "") != server_url: + return false + return config.get("transport", "") == "streamable-http" + + +## Empty string when this client's launch requirements are satisfied. +## Mirrors `McpYamlStrategy.command_launch_error`; the patch-list dialect +## supports FLAT (and the URL fallback) only. +static func command_launch_error(client: McpClient, launch: Dictionary) -> String: + if client.command_shape == McpClient.CommandShape.NONE: + return "" + if client.command_shape != McpClient.CommandShape.FLAT: + return "%s uses a command shape not supported by DeepSeek Harness config yet" % client.display_name + if not bool(launch.get("ok", false)): + return str(launch.get("error", "No compatible attach launcher was found.")) + return "" + + +# --- Rendering -------------------------------------------------------------- + +## Render a full `- insert:` row containing one nested loader entry. Public +## seam for the dock's manual-instruction text so the pasted YAML matches +## what Configure would write byte-for-byte. +static func render_insert_row(entry_id_value: String, config: Dictionary) -> PackedStringArray: + var lines: PackedStringArray = ["- insert:"] + lines.append_array(render_nested_entry(entry_id_value, config)) + return lines + + +## Render one nested loader entry (the `- id:` block inside an `insert` +## list). `base_indent` defaults to the 4-space style used by the shipped +## patch files; a nested replacement reuses the indent the existing entry was +## found at so the block sequence keeps a single indentation (#867 review). +static func render_nested_entry( + entry_id_value: String, config: Dictionary, base_indent: int = ENTRY_INDENT +) -> PackedStringArray: + var lines: PackedStringArray = [] + lines.append("%s- id: %s" % [_indent_str(base_indent), entry_id_value]) + lines.append("%sname: '%s'" % [_indent_str(base_indent + 2), PLUGIN_NAME]) + lines.append("%sconfig:" % _indent_str(base_indent + 2)) + for key in config: + lines.append_array(_emit_field(String(key), config[key], base_indent + 4)) + return lines + + +## Emit one `key: value` line (or a nested block for Dictionary values) at +## the given indent. Arrays render in flow style; scalars through the shared +## YAML emitter so quoting rules stay identical across the two YAML dialects. +static func _emit_field(key: String, value: Variant, indent: int) -> PackedStringArray: + var lines: PackedStringArray = [] + var prefix := _indent_str(indent) + if value is Dictionary: + lines.append("%s%s:" % [prefix, key]) + for sub_key in value: + lines.append_array(_emit_field(String(sub_key), value[sub_key], indent + 2)) + elif value is Array or value is PackedStringArray: + lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_flow_array(_array_copy(value))]) + else: + lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_scalar(value)]) + return lines + + +# --- Line-level parsing (no YAML parser in Godot) --------------------------- + +## Split file text into lines, dropping only a single trailing newline so the +## remainder round-trips byte-for-byte. `text` is never split on the empty +## string, so an empty file yields an empty array. +static func _split_lines(text: String) -> PackedStringArray: + if text.is_empty(): + return PackedStringArray() + var lines := text.split("\n") + if lines.size() > 0 and String(lines[lines.size() - 1]).is_empty(): + lines.remove_at(lines.size() - 1) + return lines + + +## Locate the block owned by our loader entry: +## require_nested=true — the `- id: ` line inside an `insert` +## list (indent > 0), plus its indented children. +## require_nested=false — a plain top-level `- id: ` row (indent +## 0), plus its indented children. +## Returns {"start": int, "end": int, "indent": int} (end exclusive) or {} +## when absent. The header indent is detected dynamically so a hand-written +## file using non-2-space indentation is still found; children are lines +## deeper than the header. `end` stops right AFTER the last content line: +## trailing blank and comment lines between our entry and the next sibling +## belong to the file, not to our block, so `_replace_lines` / `_remove_lines` +## (which splice [start, end)) never delete them (#867 review). +static func _find_entry_block(lines: PackedStringArray, entry_id_value: String, require_nested: bool) -> Dictionary: + for i in range(lines.size()): + var indent := _indent_of(lines[i]) + if require_nested and indent <= 0: + continue + if not require_nested and indent != 0: + continue + var parsed := _parse_entry_header(lines[i]) + if parsed.is_empty() or parsed.get("id") != entry_id_value: + continue + ## A nested match must actually live inside an `- insert:` row: a + ## `- id:` list under some other top-level row (an override row's own + ## sequence, say) is not a loader entry and is not ours to touch. + if require_nested and not _under_insert_row(lines, i): + continue + var j := i + 1 + var last_content := i + while j < lines.size(): + var l := lines[j] + if _is_blank_or_comment(l): + j += 1 + continue + if _indent_of(l) <= indent: + break + last_content = j + j += 1 + return {"start": i, "end": last_content + 1, "indent": indent} + return {} + + +## True when the nearest preceding 0-indent content line above `index` is an +## `- insert:` row header. Blank and comment lines are skipped; hitting the +## top of the file without a header means the nested line is orphaned and +## cannot be a loader entry. +static func _under_insert_row(lines: PackedStringArray, index: int) -> bool: + for i in range(index - 1, -1, -1): + if _is_blank_or_comment(lines[i]): + continue + if _indent_of(lines[i]) == 0: + return lines[i].strip_edges().begins_with("- insert:") + return false + + +## Parse a `- id: ` header line into {"id": String}. Returns {} for +## any other line. The value may be plain or single/double-quoted. +static func _parse_entry_header(line: String) -> Dictionary: + var stripped := line.strip_edges() + if not stripped.begins_with("- "): + return {} + var body := stripped.substr(2).strip_edges() + if not body.begins_with("id:"): + return {} + var value := body.substr(3).strip_edges() + if value.begins_with("\"") and value.ends_with("\"") and value.length() >= 2: + var parsed: Variant = JSON.parse_string(value) + if parsed is String: + value = parsed + elif value.begins_with("'") and value.ends_with("'") and value.length() >= 2: + value = value.substr(1, value.length() - 2) + return {"id": value} + + +## Extract the `config:` sub-dict from a found entry block. Fields may nest +## (env, reconnect); deeper values are parsed with the same scalar coercion +## the YAML strategy uses. Returns {} when the block has no config block. +static func _extract_config(lines: PackedStringArray, block: Dictionary) -> Dictionary: + var start := int(block.get("start", -1)) + var end := int(block.get("end", -1)) + if start < 0: + return {} + var config_indent := -1 + var config_start := -1 + for i in range(start, end): + var stripped := lines[i].strip_edges() + if _is_blank_or_comment(lines[i]): + continue + if stripped == "config:": + config_indent = _indent_of(lines[i]) + config_start = i + break + if config_start < 0: + return {} + return _parse_block_lines(lines, config_start + 1, end, config_indent) + + +## Parse sibling `key: value` lines at a fixed minimum indent into a +## Dictionary. Nested blocks (a key with no inline value) recurse and their +## child lines are consumed by the recursion — the parent index advances past +## them so they are never re-parsed as top-level config fields (#867 review). +## Flow arrays parse back into Arrays via JSON. Quoted and plain scalars +## strip via the YAML strategy's shared `coerce_scalar`. +static func _parse_block_lines(lines: PackedStringArray, start: int, end: int, min_indent: int) -> Dictionary: + var out: Dictionary = {} + var i := start + while i < end: + var l := lines[i] + if _is_blank_or_comment(l): + i += 1 + continue + var indent := _indent_of(l) + if indent <= min_indent: + break + var stripped := l.strip_edges() + var colon := stripped.find(":") + if colon < 0: + i += 1 + continue + var key := stripped.substr(0, colon).strip_edges() + var value := stripped.substr(colon + 1).strip_edges() + if value.is_empty(): + ## Nested block: find where its children end (a line at or above + ## the block key's indent), parse only that span, and jump past it. + var child_end := i + 1 + while child_end < end: + if not _is_blank_or_comment(lines[child_end]) and _indent_of(lines[child_end]) <= indent: + break + child_end += 1 + out[key] = _parse_block_lines(lines, i + 1, child_end, indent) + i = child_end + else: + ## Shared with the YAML strategy so the two dialects can never + ## drift on quoting/typing rules (#867 review). + out[key] = McpYamlStrategy.coerce_scalar(value) + i += 1 + return out + + +# --- Rewrites --------------------------------------------------------------- + +## Append a new `- insert:` row at the end of the file. A blank separator +## line keeps rows visually distinct; the file always ends with a newline. +static func _append_row(lines: PackedStringArray, nested_lines: PackedStringArray) -> String: + var parts: Array[String] = [] + for l in lines: + parts.append(l) + ## Collapse trailing blank lines so the appended row is not orphaned + ## below stray empties; one separator blank keeps rows readable. + while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty(): + parts.remove_at(parts.size() - 1) + if parts.size() > 0: + parts.append("") + parts.append("- insert:") + for l in nested_lines: + parts.append(l) + return _join_lines(parts) + + +## Replace the lines owned by our entry. When the found block is a plain +## top-level row (indent 0 header), the whole row — including its children — +## is replaced by the insert form. When it is a nested block inside an +## insert row, only that block's lines are replaced, leaving sibling entries +## and comments in the same row byte-for-byte intact. +static func _replace_lines(lines: PackedStringArray, block: Dictionary, nested_lines: PackedStringArray) -> String: + var start := int(block.get("start", -1)) + var end := int(block.get("end", -1)) + var parts: Array[String] = [] + for i in range(lines.size()): + if i == start: + ## A nested block keeps the surrounding `- insert:` row, so only + ## the nested entry lines (indent > 0) are spliced in. + if _indent_of(lines[start]) > 0: + for l in nested_lines: + parts.append(l) + else: + parts.append("- insert:") + for l in nested_lines: + parts.append(l) + if i >= start and i < end: + continue + parts.append(lines[i]) + return _join_lines(parts) + + +## Remove our entry. A nested block is spliced out; if its enclosing insert +## row is left without any nested entries (only the bare `- insert:` header, +## blanks, and comments), the row header goes too so the file stays a valid +## patch list. A plain row for our id is removed wholesale. +static func _remove_lines(lines: PackedStringArray, block: Dictionary) -> String: + var start := int(block.get("start", -1)) + var end := int(block.get("end", -1)) + var nested := _indent_of(lines[start]) > 0 + var parts: Array[String] = [] + if nested: + ## Find the enclosing `- insert:` row header: the nearest preceding + ## 0-indent `- ` line. + var row_start := -1 + for i in range(start - 1, -1, -1): + if _indent_of(lines[i]) == 0 and lines[i].strip_edges().begins_with("- "): + row_start = i + break + ## Row end: first 0-indent non-blank/comment line after the block. + var row_end := lines.size() + for i in range(end, lines.size()): + if _indent_of(lines[i]) == 0 and not _is_blank_or_comment(lines[i]): + row_end = i + break + var has_other_entries := false + for i in range(row_start + 1, row_end): + if i >= start and i < end: + continue + if _is_blank_or_comment(lines[i]): + continue + if _indent_of(lines[i]) > 0 and lines[i].strip_edges().begins_with("- "): + has_other_entries = true + break + if has_other_entries or row_start < 0: + ## Only our block goes; sibling entries and any trailing + ## comments in the same row survive. + for i in range(lines.size()): + if i >= start and i < end: + continue + parts.append(lines[i]) + else: + ## The row held only our entry: drop the `- insert:` header and + ## our block, but keep trailing blank/comment lines — a comment + ## the user wrote above the next row must survive (#867 review). + for i in range(lines.size()): + if i >= row_start and i < end: + continue + parts.append(lines[i]) + parts = _trim_trailing_blanks(parts) + else: + ## Plain top-level row for our id: drop the whole block. + for i in range(lines.size()): + if i >= start and i < end: + continue + parts.append(lines[i]) + parts = _trim_trailing_blanks(parts) + return _join_lines(parts) + + +## Remove trailing blank lines left behind by a removal. +static func _trim_trailing_blanks(parts: Array[String]) -> Array[String]: + while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty(): + parts.remove_at(parts.size() - 1) + return parts + + +## True when the text holds no patch rows at all — blank lines and comments +## only. Such a file cannot be written back (see `remove`), because dsh's +## patch parser rejects a non-array file at boot. +static func _is_blank_or_comment_only(text: String) -> bool: + for line in text.split("\n"): + var stripped := line.strip_edges() + if not stripped.is_empty() and not stripped.begins_with("#"): + return false + return true + + +## When the document's only content line is the empty flow sequence `[]`, +## return the text with that one line removed — blanks and comments stay, +## because they belong to the user. Any other document comes back unchanged; +## a `[]` mixed with real rows is left for `_is_patch_sequence` to refuse. +static func _strip_empty_flow_sequence(text: String) -> String: + var lines := _split_lines(text) + var bracket_line := -1 + for i in range(lines.size()): + if _is_blank_or_comment(lines[i]): + continue + if bracket_line < 0 and lines[i].strip_edges() == "[]": + bracket_line = i + continue + return text + if bracket_line < 0: + return text + var parts: Array[String] = [] + for i in range(lines.size()): + if i != bracket_line: + parts.append(lines[i]) + if parts.is_empty(): + return "" + return _join_lines(parts) + + +## True when the text is a valid loader patch list (or empty/comment-only / +## the empty flow sequence `[]`, which all mean "no layer"). Anything else — +## a top-level mapping or scalar — would make dsh fail boot, so Configure +## refuses to write into it instead of producing a broken file (#867 review). +## Only 0-indent content lines define the top-level structure: they must all +## be `- ` rows. Deeper lines are row children and are not inspected. `[]` is +## valid only as the COMPLETE document — appending rows after the empty flow +## sequence would produce malformed YAML, so a mixed `[]` + rows file fails +## the check too. +static func _is_patch_sequence(text: String) -> bool: + if text.strip_edges().is_empty(): + return true + var saw_brackets := false + var content_count := 0 + for line in text.split("\n"): + var stripped := line.strip_edges() + if stripped.is_empty() or stripped.begins_with("#"): + continue + content_count += 1 + if stripped == "[]": + saw_brackets = true + continue + if _indent_of(line) == 0 and not stripped.begins_with("- "): + return false + if saw_brackets and content_count > 1: + return false + return true + + +static func _join_lines(parts: Array) -> String: + if parts.is_empty(): + return "" + var lines: Array[String] = [] + for p in parts: + lines.append(String(p)) + return "\n".join(lines) + "\n" + + +# --- Small helpers ---------------------------------------------------------- + +static func _indent_str(n: int) -> String: + var out := "" + for i in range(n): + out += " " + return out + + +static func _indent_of(line: String) -> int: + var n := 0 + while n < line.length() and (line[n] == " " or line[n] == "\t"): + n += 1 + return n + + +static func _is_blank_or_comment(line: String) -> bool: + var stripped := line.strip_edges() + return stripped.is_empty() or stripped.begins_with("#") + + +static func _array_copy(value: Variant) -> Array: + if value is Array: + return (value as Array).duplicate(true) + if value is PackedStringArray: + return McpClient._array_from_packed(value) + return [] + + +static func _arrays_equal(left: Variant, right: Variant) -> bool: + if not (left is Array or left is PackedStringArray): + return false + if not (right is Array or right is PackedStringArray): + return false + var left_array := _array_copy(left) + var right_array := _array_copy(right) + if left_array.size() != right_array.size(): + return false + for i in range(left_array.size()): + if left_array[i] != right_array[i]: + return false + return true + + +## Returns {"ok": true, "data": String} when the file is absent or readable, +## and {"ok": false, "error": String} when unreadable. Callers must NOT fall +## back to an empty string on the error path — doing so blows away the user's +## other patch rows on the next write. +static func _read(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {"ok": true, "data": ""} + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + var err := FileAccess.get_open_error() + return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)} + var t := f.get_as_text() + f.close() + return {"ok": true, "data": t} diff --git a/addons/godot_ai/clients/_dsh_strategy.gd.uid b/addons/godot_ai/clients/_dsh_strategy.gd.uid new file mode 100644 index 0000000..36703f4 --- /dev/null +++ b/addons/godot_ai/clients/_dsh_strategy.gd.uid @@ -0,0 +1 @@ +uid://ch0brn7swb6qx diff --git a/addons/godot_ai/clients/_json_strategy.gd b/addons/godot_ai/clients/_json_strategy.gd new file mode 100644 index 0000000..9fb40eb --- /dev/null +++ b/addons/godot_ai/clients/_json_strategy.gd @@ -0,0 +1,989 @@ +@tool +class_name McpJsonStrategy +extends RefCounted + +## Read–merge–write strategy for JSON-backed MCP clients. +## All knobs come from the McpClient descriptor as plain data — no Callables. +## See `_base.gd` for why descriptors are data-only. + + +static func configure( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, + project_roots: PackedStringArray = PackedStringArray(), +) -> Dictionary: + if _uses_merge_tiers(client): + return _configure_merged(client, server_name, server_url, launch, project_roots) + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty(): + return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} + + var seed_path := str(resolution.get("seed_path", "")) + var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path + var read := _read_or_init(read_path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]} + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": "error", "message": launch_error} + var config: Dictionary = read["data"] + var holder := _ensure_path(config, select_server_key_path(config, client)) + ## Pass the existing entry through so `build_entry` can preserve user-mutable + ## state (auto-approval lists, `disabled` toggles) instead of resetting it + ## to descriptor defaults on every Configure click. See `entry_initial_fields` + ## docs in `_base.gd`. + var existing: Variant = holder.get(server_name, null) + holder[server_name] = build_entry(client, server_url, existing, launch) + + # F5: refuse to re-serialize a file whose parsed integers above 2^53 would + # lose precision. Editing by hand keeps those values intact. + if _has_lossy_numbers(config): + return {"status": "error", "message": "Refusing to rewrite %s: contains integers above 2^53 that Godot's JSON parser would re-emit imprecise. Edit the file by hand." % path} + if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": McpClient.configured_message(client, server_url)} + +## Pi-style clients merge several global config files. Update the effective +## highest-precedence definition; fail closed when a project override exists +## because Pi's external working directory cannot be inferred safely here. +static func _configure_merged( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary, + project_roots: PackedStringArray, +) -> Dictionary: + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": "error", "message": launch_error} + var project := _load_project_definitions(client, server_name, project_roots) + if not project.get("ok", false): + return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))} + var project_tiers: Array = project.get("tiers", []) + if not project_tiers.is_empty(): + return {"status": "error", "message": _project_override_message(project_tiers, "update or remove", client.display_name, server_name)} + var loaded := _load_merge_tiers(client) + if not loaded.get("ok", false): + return {"status": "error", "message": str(loaded.get("error", "Cannot read merged config tiers"))} + var tiers: Array = loaded.get("tiers", []) + if tiers.is_empty(): + return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} + var target_index := 0 + for index in range(tiers.size()): + var config: Dictionary = tiers[index]["data"] + var holder := _walk_path(config, select_server_key_path(config, client)) + if holder is Dictionary and holder.has(server_name): + target_index = index + var tier: Dictionary = tiers[target_index] + var config: Dictionary = tier["data"] + var holder := _ensure_path(config, select_server_key_path(config, client)) + var existing: Variant = holder.get(server_name, null) + holder[server_name] = build_entry(client, server_url, existing, launch) + var path := str(tier["path"]) + # F5: refuse to re-serialize a tier whose parsed integers above 2^53 would + # lose precision. The same check guards the simple `configure` path. + if _has_lossy_numbers(config): + return {"status": "error", "message": "Refusing to rewrite %s: contains integers above 2^53 that Godot's JSON parser would re-emit imprecise. Edit the file by hand." % path} + if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)): + return {"status": "error", "message": "Cannot write to %s" % path} + # Codex F1 caveat: when we wrote a global tier but couldn't find any project + # override in the roots we probed AND the user hasn't told us where Pi (or any + # external cwd client) actually runs from, we can't prove this write is the + # effective config. Surface the actionable hint instead of silently passing. + # The early `not project_tiers.is_empty()` return above already covers the + # "project override exists in probed roots" case (we fail closed there), so + # reaching this point with `project_tiers.is_empty()` means: no probed + # override was found. + var message := McpClient.configured_message(client, server_url) + var external_cwd := str(McpClientConfigurator._editor_setting_lookup(McpClientConfigurator.SETTING_EXTERNAL_CLIENT_CWD)) + if external_cwd.is_empty() and project_tiers.is_empty(): + message += " If %s is launched from a cwd this editor cannot see, the effective entry may still be in a project-tier file — set godot_ai/external_client_cwd to that cwd to verify." % client.display_name + return {"status": "ok", "message": message} + + +static func check_status( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, + project_roots: PackedStringArray = PackedStringArray(), +) -> McpClient.Status: + return check_status_details(client, server_name, server_url, launch, project_roots).get("status", McpClient.Status.NOT_CONFIGURED) + + +## Detailed variant feeding the dock's error_msg plumbing (#711): a config +## file that EXISTS but can't be read or parsed is Status.ERROR carrying the +## read/parse error, not NOT_CONFIGURED — the write path refuses to touch +## such a file (see `_read_or_init`), so the status dot must say "broken +## file", not "click Configure". +static func check_status_details( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, + project_roots: PackedStringArray = PackedStringArray(), +) -> Dictionary: + if _uses_merge_tiers(client): + return _check_status_merged(client, server_name, server_url, launch, project_roots) + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": McpClient.Status.ERROR, "error_msg": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + var read := _read_or_init(path) + if not read["ok"]: + return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])} + var config: Dictionary = read["data"] + var holder := _walk_path(config, select_server_key_path(config, client)) + if not (holder is Dictionary) or not holder.has(server_name): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + return _entry_status_details(client, holder[server_name], server_url, launch) + + +## Verify the effective last definition after applying the client's merge order. +static func _check_status_merged( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary, + project_roots: PackedStringArray, +) -> Dictionary: + var loaded := _load_merge_tiers(client) + if not loaded.get("ok", false): + return {"status": McpClient.Status.ERROR, "error_msg": str(loaded.get("error", "Cannot read merged config tiers"))} + var effective: Variant = null + for tier in loaded.get("tiers", []): + var config: Dictionary = tier["data"] + var holder := _walk_path(config, select_server_key_path(config, client)) + if holder is Dictionary and holder.has(server_name): + effective = holder[server_name] + var project := _load_project_definitions(client, server_name, project_roots) + if not project.get("ok", false): + return {"status": McpClient.Status.ERROR, "error_msg": str(project.get("error", "Cannot inspect project config tiers"))} + var project_tiers: Array = project.get("tiers", []) + if not project_tiers.is_empty(): + # Last-definition-wins mirrors the global-tier fold above and how + # pi-codemode-mcp merges project tiers on disk. Earlier tiers are dead + # once a later one defines the same server, so an early-stale entry + # doesn't make Pi's effective config drift (codex-review finding F2). + # Pass `[latest]` to `_project_override_message` so the error names only + # the file the user actually has to edit. + var latest: Dictionary = project_tiers[project_tiers.size() - 1] + var details := _entry_status_details(client, latest["entry"], server_url, launch) + if details.get("status") != McpClient.Status.CONFIGURED: + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": _project_override_message([latest], "update or remove", client.display_name, server_name)} + return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} + if effective == null: + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + return _entry_status_details(client, effective, server_url, launch) + + +static func _entry_status_details( + client: McpClient, + entry: Variant, + server_url: String, + launch: Dictionary, +) -> Dictionary: + if not (entry is Dictionary): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": McpClient.Status.ERROR, "error_msg": launch_error} + if verify_entry(client, entry, server_url, launch): + return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} + + +static func remove( + client: McpClient, + server_name: String, + project_roots: PackedStringArray = PackedStringArray(), +) -> Dictionary: + if _uses_merge_tiers(client): + return _remove_merged(client, server_name, project_roots) + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": "ok", "message": "Not configured"} + # Token-preserving removal (F5): pull the original file text via + # `_read_file_text` and edit only the entry's bytes — keeps every other + # byte, including integers above 2^53, byte-for-byte identical to what + # the user wrote. The parsed-dict round-trip path is gone. + var file_read := _read_file_text(path) + if not file_read.get("ok", false): + return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, file_read.get("error", "")]} + var config: Dictionary = file_read.get("data", {}) + var key_path := select_server_key_path(config, client) + var original_text: String = str(file_read.get("original_text", "")) + var updated: String = _text_remove_server_entry(original_text, key_path, server_name) + if updated == original_text: + return {"status": "ok", "message": "Not configured"} + if not McpAtomicWrite.write(path, updated): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + + +static func _remove_merged( + client: McpClient, server_name: String, project_roots: PackedStringArray +) -> Dictionary: + var project := _load_project_definitions(client, server_name, project_roots) + if not project.get("ok", false): + return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))} + var project_tiers: Array = project.get("tiers", []) + if not project_tiers.is_empty(): + return {"status": "error", "message": _project_override_message(project_tiers, "remove", client.display_name, server_name)} + var loaded := _load_merge_tiers(client) + if not loaded.get("ok", false): + return {"status": "error", "message": str(loaded.get("error", "Cannot read merged config tiers"))} + var writes: Array[Dictionary] = [] + for tier in loaded.get("tiers", []): + if not tier.get("exists", false): + continue + var key_path := select_server_key_path(tier["data"], client) + var original: String = str(tier.get("original_text", "")) + # Token-preserving removal (F5): edit `original` textually instead of + # re-serialising the parsed config. This preserves every byte outside + # the entry block, so unrelated integers (including those above the + # IEEE-754 exact-int range that `_narrow_integral_numbers` cannot + # round-trip) keep their original literals. + var updated: String = _text_remove_server_entry(original, key_path, server_name) + if updated == original: + continue # entry not in this tier; skip + writes.append({ + "path": tier["path"], + "text": updated, + "original_text": original, + }) + if writes.is_empty(): + return {"status": "ok", "message": "Not configured"} + var written := _write_transaction(writes) + if not written.get("ok", false): + return {"status": "error", "message": written.get("error", "Cannot remove merged configuration")} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + + +## Synthesize the entry dict the strategy writes under +## `server_key_path[server_name]`. Both URL and command entries deep-copy the +## existing dict before overwriting strategy-owned fields, preserving unknown +## client additions as well as descriptor-documented user fields. +static func build_entry( + client: McpClient, + server_url: String, + existing: Variant = null, + launch: Dictionary = {}, +) -> Dictionary: + if _is_supported_command_shape(client.command_shape): + var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} + if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY: + ## OpenCode-style: the entry's `command` field IS the argv array. + ## A stale sibling `args` from a FLAT-style hand edit would be + ## ambiguous next to it, so it is strategy-owned and removed. + command_entry["command"] = _launch_argv(launch) + command_entry.erase("args") + else: + command_entry["command"] = str(launch.get("command", "")) + command_entry["args"] = _array_copy(launch.get("args", [])) + if not client.command_transport_key.is_empty(): + command_entry[client.command_transport_key] = client.command_transport_value + for key in client.command_initial_fields: + if not command_entry.has(key): + command_entry[key] = client.command_initial_fields[key] + for key in client.command_legacy_keys: + command_entry.erase(String(key)) + _remove_legacy_env_keys(command_entry, client.command_env_legacy_keys) + return command_entry + if client.command_shape != McpClient.CommandShape.NONE: + return {} + return build_url_entry(client, server_url, existing) + + +static func build_url_entry(client: McpClient, server_url: String, existing: Variant = null) -> Dictionary: + var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} + entry[client.entry_url_field] = server_url + for k in client.entry_extra_fields: + entry[k] = client.entry_extra_fields[k] + for k in client.entry_initial_fields: + if not entry.has(k): + entry[k] = client.entry_initial_fields[k] + return entry + + +## Default verifier for a stored entry. Command entries must match every +## launch-affecting value exactly; legacy URL or env keys are migration drift. +## For URL clients, assert `entry[entry_url_field] == url` AND every +## key in `entry_extra_fields` matches verbatim. Type-pinning for Cline / +## Roo / Kilo (`type: "streamable-http"` etc.) falls out of this — pre-fix +## entries that lack the type field fail verification and surface as drift. +static func verify_entry( + client: McpClient, + entry: Dictionary, + server_url: String, + launch: Dictionary = {}, +) -> bool: + if client.command_shape != McpClient.CommandShape.NONE: + if not _is_supported_command_shape(client.command_shape) or not bool(launch.get("ok", false)): + return false + for key in client.command_legacy_keys: + if entry.has(String(key)): + return false + var env = entry.get("env", null) + if env is Dictionary: + for key in client.command_env_legacy_keys: + if env.has(String(key)): + return false + if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY: + if not _arrays_equal(entry.get("command", null), _launch_argv(launch)): + return false + if entry.has("args"): + return false + else: + if entry.get("command") != launch.get("command"): + return false + if not _arrays_equal(entry.get("args", null), launch.get("args", null)): + return false + if not client.command_transport_key.is_empty(): + if not entry.has(client.command_transport_key): + return false + if entry.get(client.command_transport_key) != client.command_transport_value: + return false + return true + if entry.get(client.entry_url_field, "") != server_url: + return false + for k in client.entry_extra_fields: + if entry.get(k) != client.entry_extra_fields[k]: + return false + return true + + +static func command_launch_error(client: McpClient, launch: Dictionary) -> String: + if client.command_shape == McpClient.CommandShape.NONE: + return "" + if not _is_supported_command_shape(client.command_shape): + return "%s uses a command shape not supported by JSON yet" % client.display_name + if not bool(launch.get("ok", false)): + return str(launch.get("error", "No compatible attach launcher was found.")) + return "" + + +static func _is_supported_command_shape(shape: McpClient.CommandShape) -> bool: + return shape == McpClient.CommandShape.FLAT or shape == McpClient.CommandShape.COMMAND_ARRAY + + +## The full launch argv as one array: launcher path followed by every arg. +static func _launch_argv(launch: Dictionary) -> Array: + var argv: Array = [str(launch.get("command", ""))] + argv.append_array(_array_copy(launch.get("args", []))) + return argv + + +static func _remove_legacy_env_keys(entry: Dictionary, legacy_keys: PackedStringArray) -> void: + if legacy_keys.is_empty(): + return + var existing_env = entry.get("env", null) + if not (existing_env is Dictionary): + return + var env: Dictionary = (existing_env as Dictionary).duplicate(true) + for key in legacy_keys: + env.erase(String(key)) + if env.is_empty(): + entry.erase("env") + else: + entry["env"] = env + + +static func _array_copy(value: Variant) -> Array: + if value is Array: + return (value as Array).duplicate(true) + if value is PackedStringArray: + return McpClient._array_from_packed(value) + return [] + + +static func _arrays_equal(left: Variant, right: Variant) -> bool: + if not (left is Array or left is PackedStringArray): + return false + if not (right is Array or right is PackedStringArray): + return false + var left_array := _array_copy(left) + var right_array := _array_copy(right) + if left_array.size() != right_array.size(): + return false + for i in range(left_array.size()): + if left_array[i] != right_array[i]: + return false + return true + + +## Read a config file once and parse the captured text. Returns +## {"exists": bool, "ok": bool, "data": Dictionary, "original_text": String} +## when the file is absent or parses cleanly, and +## {"exists": true, "ok": false, "error": String, "original_text": String} +## when the file exists with non-empty content we cannot safely round-trip. +## Callers must NOT treat the error path as an empty config — doing so blows +## away the user's other MCP entries on the next write. The `original_text` +## is the exact captured source so transactional rollback can restore +## byte-for-byte; the UTF-8 BOM is stripped only from the parsing copy. +static func _read_file_text(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {"exists": false, "ok": true, "data": {}, "original_text": ""} + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + var open_err := FileAccess.get_open_error() + return {"exists": true, "ok": false, "error": "could not open for reading (error %d)" % open_err, "original_text": ""} + var content := file.get_as_text() + file.close() + if content.strip_edges().is_empty(): + return {"exists": true, "ok": true, "data": {}, "original_text": content} + var parse_copy := content + # Strip a UTF-8 BOM if present — some editors (notably on Windows) save + # JSON with a leading , which Godot's JSON.parse rejects outright. + # Previously this landed on the "unparseable → wipe" path. + if parse_copy.begins_with(""): + parse_copy = parse_copy.substr(1) + var json := JSON.new() + if json.parse(parse_copy) != OK: + var msg := "JSON parse error on line %d: %s" % [json.get_error_line(), json.get_error_message()] + push_warning("MCP | %s in %s" % [msg, path]) + return {"exists": true, "ok": false, "error": msg, "original_text": content} + if not (json.data is Dictionary): + return {"exists": true, "ok": false, "error": "top-level value is %s, expected object" % type_string(typeof(json.data)), "original_text": content} + return {"exists": true, "ok": true, "data": json.data, "original_text": content} + + +## Returns {"ok": true, "data": Dictionary} when the file is absent or parses +## cleanly, and {"ok": false, "error": String} when the file exists with +## non-empty content we cannot safely round-trip. Callers must NOT fall back +## to an empty dict on the error path — doing so blows away the user's other +## MCP entries on the next write. +static func _read_or_init(path: String) -> Dictionary: + var read := _read_file_text(path) + var result: Dictionary = {"ok": read.get("ok", false), "data": read.get("data", {})} + if not result.get("ok", false): + result["error"] = read.get("error", "") + return result + + +## Walk a key path, creating intermediate Dicts as needed. Returns the leaf Dict. +static func _ensure_path(root: Dictionary, key_path: PackedStringArray) -> Dictionary: + var cur := root + for key in key_path: + var next = cur.get(key) + if not (next is Dictionary): + next = {} + cur[key] = next + cur = next + return cur + + +## Walk a key path, returning the leaf Dict if all hops exist; else null. +static func _walk_path(root: Dictionary, key_path: PackedStringArray) -> Variant: + var cur: Variant = root + for key in key_path: + if not (cur is Dictionary) or not cur.has(key): + return null + cur = cur[key] + return cur + + +## Match clients that accept multiple server-map keys without creating a +## higher-precedence canonical map that shadows an existing legacy map. A +## non-null scalar still wins, matching nullish-coalescing parsers; Configure's +## `_ensure_path` then repairs it using the strategy's existing behavior. +static func select_server_key_path(root: Dictionary, client: McpClient) -> PackedStringArray: + var candidates: Array[PackedStringArray] = [client.server_key_path] + # Dynamic access keeps mixed-snapshot self-updates parse-safe when a new + # strategy is briefly loaded with an older McpClient base (#398/#736). + var aliases = client.get("server_key_path_aliases") + if aliases is Array: + for alias in aliases: + if alias is PackedStringArray: + candidates.append(alias) + for key_path in candidates: + if _walk_path(root, key_path) != null: + return key_path + return client.server_key_path + + +static func _uses_merge_tiers(client: McpClient) -> bool: + var templates = client.get("config_merge_path_templates") + return templates is Dictionary and not templates.is_empty() + + +## Resolve the global config tiers in the client's documented merge order. +static func _merge_paths(client: McpClient) -> PackedStringArray: + var result := PackedStringArray() + var templates = client.get("config_merge_path_templates") + if templates is Dictionary: + var platform_key := McpPathTemplate.platform_key(templates) + if not platform_key.is_empty(): + var raw_paths: Variant = templates.get(platform_key, []) + if raw_paths is Array or raw_paths is PackedStringArray: + for template in raw_paths: + var path := McpPathTemplate.expand(str(template)).simplify_path() + if not path.is_empty() and not result.has(path): + result.append(path) + return result + + +## Pi's project tiers are relative to Pi's process cwd, not Godot's. Inspect +## plausible roots only to detect overrides; callers fail closed rather than +## mutating both guesses. +static func _project_candidate_paths( + client: McpClient, roots: PackedStringArray +) -> PackedStringArray: + var result := PackedStringArray() + var project_paths = client.get("config_merge_project_paths") + if not (project_paths is PackedStringArray): + return result + for project_path in project_paths: + var absolute_path := String(project_path).simplify_path() + if absolute_path.is_absolute_path() and FileAccess.file_exists(absolute_path) and not result.has(absolute_path): + result.append(absolute_path) + for root in roots: + if String(root).is_empty(): + continue + for relative_path in project_paths: + if String(relative_path).is_absolute_path(): + continue + var path := String(root).path_join(String(relative_path)).simplify_path() + if FileAccess.file_exists(path) and not result.has(path): + result.append(path) + return result + + +static func _load_project_definitions( + client: McpClient, server_name: String, project_roots: PackedStringArray +) -> Dictionary: + var tiers: Array[Dictionary] = [] + for path in _project_candidate_paths(client, project_roots): + var read := _read_or_init(path) + if not read.get("ok", false): + return {"ok": false, "error": "Cannot inspect project config %s: %s" % [path, read.get("error", "invalid JSON")]} + var config: Dictionary = read["data"] + var key_path := select_server_key_path(config, client) + var holder := _walk_path(config, key_path) + if holder is Dictionary and holder.has(server_name): + tiers.append({ + "path": path, + "data": config, + "key_path": key_path, + "entry": holder[server_name], + }) + return {"ok": true, "tiers": tiers} + + +static func _project_override_message( + tiers: Array, action: String, client_name: String, server_name: String +) -> String: + var paths := PackedStringArray() + for tier in tiers: + paths.append(str(tier["path"])) + return "%s project config overrides %s at %s. %s resolves project files from its own working directory, so the dock cannot safely choose one; %s the entry manually." % [client_name, server_name, ", ".join(paths), client_name, action] + + +static func _load_merge_tiers(client: McpClient) -> Dictionary: + var tiers: Array[Dictionary] = [] + for path in _merge_paths(client): + ## These templates never pass through `resolved_config_path_details`, + ## so they need its fail-closed gate here: `McpPathTemplate.expand` + ## leaves a token it cannot resolve in place, and the relative survivor + ## would be read — and on Configure, written — against the editor's own + ## working directory. Fail the whole fold rather than dropping the tier: + ## a dropped tier reads as "the user has no config there", which is + ## exactly the wrong thing to conclude when we could not look. + if not path.is_absolute_path(): + return { + "ok": false, + "error": McpClient.unresolved_config_path_error(client.display_name, path), + } + var read := _read_file_text(path) + if not read.get("ok", false): + return { + "ok": false, + "error": "Refusing to rewrite %s: %s. Fix or move the file, then re-run Configure." % [path, read.get("error", "invalid JSON")], + } + tiers.append({ + "path": path, + "exists": read.get("exists", false), + "original_text": read.get("original_text", ""), + "data": read.get("data", {}), + }) + return {"ok": true, "tiers": tiers} + + +## Commit a multi-file removal with best-effort rollback. Each individual write +## is atomic; this layer restores earlier tiers if a later commit fails. +static func _write_transaction(writes: Array[Dictionary]) -> Dictionary: + var completed: Array[Dictionary] = [] + for write in writes: + var path := str(write["path"]) + if McpAtomicWrite.write(path, str(write["text"])): + completed.append(write) + continue + var rollback_failed := PackedStringArray() + completed.reverse() + for previous in completed: + if not McpAtomicWrite.write(str(previous["path"]), str(previous["original_text"])): + rollback_failed.append(str(previous["path"])) + var suffix := "" + if not rollback_failed.is_empty(): + suffix = " Rollback also failed for: %s" % ", ".join(rollback_failed) + return {"ok": false, "error": "Cannot write to %s; earlier tier changes were rolled back.%s" % [path, suffix]} + return {"ok": true} +## Path that `_check_status_merged`'s last-wins logic would consider +## authoritative for the server entry, or "" when no entry is found in +## any tier. Mirrors the same project-tier / global-tier resolution the +## status check uses so callers (notably the dock Open/Reveal buttons) +## land on the file that actually drives Pi's effective config. +## +## Resolution order (codex round 3, F-3-4): +## 1. Latest project tier containing the entry — F2 last-wins means the +## latest of `.pi/mcp.json` and `.mcp.json` wins (matches `_check_status_merged`). +## 2. Latest global tier containing the entry — already iterated in +## merge order, last-iterated-wins (same loop pattern as +## `manual_target_details` lines 660-665). +## 3. "" when no tier has the entry — caller decides what fallback +## (`path_template`) to use. +## +## Returns "" (not `path_template`) on the "nothing anywhere" branch so +## the dock can distinguish "no entry yet" from "entry is at the +## template path" if it wants to. +static func authoritative_tier_path( + client: McpClient, + server_name: String, + project_roots: PackedStringArray, +) -> String: + if not _uses_merge_tiers(client): + return "" + var project := _load_project_definitions(client, server_name, project_roots) + if not bool(project.get("ok", false)): + return "" + var project_tiers: Array = project.get("tiers", []) + if not project_tiers.is_empty(): + # F2 last-wins: latest project tier is authoritative. When there are + # multiple project tiers, the user-visible status is driven by the + # latest, so the Open/Reveal buttons should send them there too. + var latest: Dictionary = project_tiers[project_tiers.size() - 1] + return str(latest.get("path", "")) + var loaded := _load_merge_tiers(client) + if not bool(loaded.get("ok", false)): + return "" + var tiers: Array = loaded.get("tiers", []) + # Iterate in priority order, overwrite `selected_path` each time we + # find a tier containing the entry. With Pi's merge path order + # [mcp.json, .mcp.json] this leaves the higher-precedence `.mcp.json`. + var selected_path: String = "" + for tier in tiers: + var data: Dictionary = tier.get("data", {}) + var holder: Variant = _walk_path(data, select_server_key_path(data, client)) + if holder is Dictionary and holder.has(server_name): + selected_path = str(tier.get("path", "")) + return selected_path + + +## Pick the file and top-level map shown by the manual JSON instructions using +## the same merge and alias precedence as automatic Configure. +static func manual_target_details( + client: McpClient, + server_name: String, + fallback_path: String, + project_roots: PackedStringArray = PackedStringArray(), +) -> Dictionary: + if _uses_merge_tiers(client): + var project := _load_project_definitions(client, server_name, project_roots) + if not project.get("ok", false): + return {"ok": false, "error": project.get("error", "Cannot inspect project config tiers")} + var project_tiers: Array = project.get("tiers", []) + if project_tiers.size() > 1: + return {"ok": false, "error": _project_override_message(project_tiers, "update or remove", client.display_name, server_name)} + if project_tiers.size() == 1: + var selected_project: Dictionary = project_tiers[0] + return { + "ok": true, + "path": selected_project["path"], + "key_path": selected_project["key_path"], + } + var loaded := _load_merge_tiers(client) + if not loaded.get("ok", false): + return {"ok": false, "error": loaded.get("error", "Cannot read merged config tiers")} + var tiers: Array = loaded.get("tiers", []) + var selected: Variant = tiers[0] if not tiers.is_empty() else null + for tier in tiers: + var config: Dictionary = tier["data"] + var holder := _walk_path(config, select_server_key_path(config, client)) + if holder is Dictionary and holder.has(server_name): + selected = tier + if selected != null: + var config: Dictionary = selected["data"] + return { + "ok": true, + "path": selected["path"], + "key_path": select_server_key_path(config, client), + } + var read := _read_or_init(fallback_path) + if not read.get("ok", false): + return {"ok": false, "error": "Cannot inspect %s: %s" % [fallback_path, read.get("error", "invalid JSON")]} + return { + "ok": true, + "path": fallback_path, + "key_path": select_server_key_path(read["data"], client), + } + + +## Godot's JSON.parse turns every JSON number into a float, so a later +## JSON.stringify re-emits the user's integer fields as "8080.0" — which strict +## consumers (Go's encoding/json into an int field, etc.) reject, and which +## needlessly rewrites every number across the user's *other* entries. Re-narrow +## exactly-representable integral floats back to int so they serialize without +## the ".0". Walks dicts/arrays in place and returns the (same) value. +## +## Integers above 2^53 already lost precision when Godot parsed them to double, +## so they're left as the float Godot produced rather than faking exactness — +## byte-perfect preservation would require not parsing the file at all, and such +## magnitudes don't occur in MCP client configs. +static func _narrow_integral_numbers(value: Variant) -> Variant: + match typeof(value): + TYPE_FLOAT: + if is_finite(value) and value == floor(value) and absf(value) <= 9007199254740992.0: + return int(value) + TYPE_DICTIONARY: + for k in value: + value[k] = _narrow_integral_numbers(value[k]) + TYPE_ARRAY: + for i in value.size(): + value[i] = _narrow_integral_numbers(value[i]) + return value + + +## True when `value` contains any float outside the IEEE-754 exact-int range +## (`abs(f) > 2^53`) that would lose precision when JSON.stringify re-emits it. +## The configure path refuses such files instead of silently mutating unrelated +## integers; the remove path never needs this check because it does +## token-preserving surgery (F5). +static func _has_lossy_numbers(value: Variant) -> bool: + match typeof(value): + TYPE_FLOAT: + # `>=` not `>`: `2^53 + 1` rounds down to `2^53.0` when Godot parses + # the integer to double, so even a "clean" `2^53.0` after parse may + # have lost a least-significant bit we cannot recover. Refusing at + # the boundary is conservative but correct — and 2^53+ magnitudes + # don't appear in real MCP client configs anyway. + if is_finite(value) and absf(value) >= 9007199254740992.0: + return true + TYPE_DICTIONARY: + for k in value: + if _has_lossy_numbers(value[k]): + return true + TYPE_ARRAY: + for i in value.size(): + if _has_lossy_numbers(value[i]): + return true + return false + + +## Token-preserving entry removal. Walks the JSON text with a brace-balanced +## scanner to locate the entry at `key_path[0]/.../[server_name]` and returns +## `text` with that entry's bytes deleted (along with the surrounding +## whitespace + trailing comma). Everything else — including integers above +## the IEEE-754 exact-int range that `_narrow_integral_numbers` would silently +## mutate — is preserved byte-for-byte. Returns `text` unchanged when the +## entry isn't found in `text` (callers treat this as a no-op write). +static func _text_remove_server_entry(text: String, key_path: PackedStringArray, server_name: String) -> String: + # Descend through `key_path`. Each step finds `"":` directly inside the + # current container and moves the cursor past the value's opening `{` or `[`. + # After the loop, `cursor` is positioned just past the opening bracket of the + # container that holds our entry, and `container_depth` tracks how deeply + # nested that container is from the root (0 = root object/array). + var cursor := 0 + # Skip past the root's opening `{` or `[` (and any leading whitespace or + # UTF-8 BOM) so `_find_key_at_container_depth` starts scanning from + # inside the root container with `inner_depth == 0`. Without this step, + # the first character encountered is the root's `{`, which bumps + # `inner_depth` to 1 and makes the top-level key check fail. The BOM + # skip mirrors `_read_file_text`'s parse_copy BOM-strip above so the + # scanner sees the same view of the file the parser does; the BOM + # itself stays in `text` so the byte-survival F5 contract still holds + # (codex round 3, F-3-6 — without it, files saved with a Windows BOM + # left the entry in place after Remove). + while cursor < text.length() and _is_json_ws(text[cursor]): + cursor += 1 + if cursor < text.length() and text[cursor] == "": + cursor += 1 + if cursor < text.length() and (text[cursor] == "{" or text[cursor] == "["): + cursor += 1 + var container_depth := 0 + for key in key_path: + var key_bytes := JSON.stringify(String(key)) + var match := _find_key_at_container_depth(text, cursor, key_bytes) + if match.is_empty(): + return text + var open_char := text[match["value_start"]] + if open_char != "{" and open_char != "[": + return text # leaf isn't an object/array — path can't reach our entry + container_depth += 1 + cursor = match["value_start"] + 1 + # Now find `"":` at the top level of the container we descended + # into (the helper gates on `inner_depth == 0`). + var entry_bytes := JSON.stringify(server_name) + var entry_match := _find_key_at_container_depth(text, cursor, entry_bytes) + if entry_match.is_empty(): + return text + var key_start: int = entry_match["key_start"] + var value_end: int = entry_match["value_end"] + # Trim the surrounding comma in a mutually exclusive way: the trailing comma + # (after the entry's value) carries the burden when the entry is NOT the last + # in its container, and the leading comma (before the entry's key) carries it + # only when the entry IS the last. Trimming BOTH unconditionally corrupts a + # middle-position entry into `"x"}"y"` with no separator — codex-review + # finding F5 (regression after the initial round). + var remove_end := value_end + # Skip JSON whitespace before checking for the trailing comma — valid + # (if unusual) formats like `"godot-ai":{} ,"other":{}` put whitespace + # between the value and its separator. codex round 4 finding. + var trailing_scan := remove_end + while trailing_scan < text.length() and _is_json_ws(text[trailing_scan]): + trailing_scan += 1 + var had_trailing_comma := trailing_scan < text.length() and text[trailing_scan] == "," + if had_trailing_comma: + # Jump past the comma (and any whitespace before it we just skipped). + remove_end = trailing_scan + 1 + while remove_end < text.length() and _is_json_ws(text[remove_end]): + remove_end += 1 + var remove_start := key_start + if not had_trailing_comma: + while remove_start > 0 and _is_json_ws(text[remove_start - 1]): + remove_start -= 1 + if remove_start > 0 and text[remove_start - 1] == ",": + remove_start -= 1 + while remove_start > 0 and _is_json_ws(text[remove_start - 1]): + remove_start -= 1 + return text.substr(0, remove_start) + text.substr(remove_end) + + +## Look for `key_bytes` (a JSON-encoded string literal including the surrounding +## quotes) at the TOP LEVEL of the container we're inside, scanning `text` from +## `start` onwards. The caller is expected to advance `start` just past the +## container's opening `{` or `[`, so we begin with `inner_depth == 0` and a +## key matches when we hit it while `inner_depth` is still 0. The scan exits +## immediately if `inner_depth` drops below 0 (the target container closed) +## to prevent same-named keys in later sibling containers from being picked +## up. Returns `{key_start, value_start, value_end}` on success or `{}` if +## the key isn't present in that container. +static func _find_key_at_container_depth(text: String, start: int, key_bytes: String) -> Dictionary: + var i := start + var in_string := false + var escape := false + var inner_depth := 0 # depth *inside* the current container + while i < text.length(): + var c := text[i] + if in_string: + if escape: + escape = false + elif c == "\\": + escape = true + elif c == '"': + in_string = false + i += 1 + continue + if c == '"': + if text.substr(i, key_bytes.length()) == key_bytes: + var after_key := i + key_bytes.length() + while after_key < text.length() and _is_json_ws(text[after_key]): + after_key += 1 + if after_key < text.length() and text[after_key] == ":": + var value_start := after_key + 1 + while value_start < text.length() and _is_json_ws(text[value_start]): + value_start += 1 + var value_end := _json_value_span_end(text, value_start) + if value_end >= 0 and inner_depth == 0: + return {"key_start": i, "value_start": value_start, "value_end": value_end} + in_string = true + i += 1 + continue + if c == "{" or c == "[": + inner_depth += 1 + elif c == "}" or c == "]": + inner_depth -= 1 + # Bail out the moment we leave the container we're scanning. Without + # this guard, a later sibling container at the same level (e.g. a + # top-level `extensions` block after `mcpServers` closes) re-balances + # `inner_depth` back to 0 and a same-named key in that sibling would + # match — silently deleting data we never intended to touch + # (codex-review finding F5, regression after the initial round). + if inner_depth < 0: + return {} + i += 1 + return {} + + +## Given `value_start` pointing at the first char of a JSON value, return the +## position AFTER the value's last char. Handles objects, arrays, strings, +## and bare literals (numbers, true/false/null). Returns -1 if the value is +## unterminated. +static func _json_value_span_end(text: String, value_start: int) -> int: + if value_start >= text.length(): + return -1 + var c := text[value_start] + if c == "{" or c == "[": + var open_char := c + var close_char := "}" if c == "{" else "]" + var depth := 1 + var i := value_start + 1 + var in_string := false + var escape := false + while i < text.length() and depth > 0: + var ch := text[i] + if in_string: + if escape: + escape = false + elif ch == "\\": + escape = true + elif ch == '"': + in_string = false + i += 1 + continue + if ch == '"': + in_string = true + elif ch == open_char: + depth += 1 + elif ch == close_char: + depth -= 1 + i += 1 + return i if depth == 0 else -1 + if c == '"': + var i := value_start + 1 + var escape := false + while i < text.length(): + var ch := text[i] + if escape: + escape = false + elif ch == "\\": + escape = true + elif ch == '"': + return i + 1 + i += 1 + return -1 + # Bare literal: number / true / false / null. Walk until a JSON-significant + # delimiter (`,`, `}`, `]`, or whitespace). + var i := value_start + while i < text.length(): + var ch := text[i] + if ch == "," or ch == "}" or ch == "]" or _is_json_ws(ch): + break + i += 1 + return i + + +static func _is_json_ws(c: String) -> bool: + return c == " " or c == "\t" or c == "\n" or c == "\r" diff --git a/addons/godot_ai/clients/_json_strategy.gd.uid b/addons/godot_ai/clients/_json_strategy.gd.uid new file mode 100644 index 0000000..5e41fbb --- /dev/null +++ b/addons/godot_ai/clients/_json_strategy.gd.uid @@ -0,0 +1 @@ +uid://g8a4iijpk22w diff --git a/addons/godot_ai/clients/_manual_command.gd b/addons/godot_ai/clients/_manual_command.gd new file mode 100644 index 0000000..3e178ca --- /dev/null +++ b/addons/godot_ai/clients/_manual_command.gd @@ -0,0 +1,383 @@ +@tool +class_name McpManualCommand +extends RefCounted + +const SHELL_POSIX := "posix" +const SHELL_POWERSHELL := "powershell" +## Keep this intersection deliberately small. PowerShell treats a leading `@` +## as splatting syntax and commas as list separators, while POSIX shells accept +## both literally; quoting either is safer than trying to infer token position. +const _SHELL_BARE_SAFE_CHARS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+=:./-" + +## Synthesize the "Run this manually" string the dock surfaces when +## auto-configure can't find a CLI / write a file. Generated from the +## descriptor's declarative fields — there is no per-client builder +## Callable. See `_base.gd` for why descriptors are data-only. + + +static func build( + client: McpClient, + server_name: String, + server_url: String, + resolved_path: String, + launch: Dictionary = {}, + project_roots: PackedStringArray = PackedStringArray(), +) -> String: + match client.config_type: + "cli": + return _build_cli(client, server_name, server_url, resolved_path, launch, project_roots) + "json": + return _build_json(client, server_name, server_url, resolved_path, launch, project_roots) + "toml": + return _build_toml(client, server_name, server_url, resolved_path, launch) + "yaml": + return _build_yaml(client, server_name, server_url, resolved_path, launch) + "dsh": + return _build_dsh(client, server_name, server_url, resolved_path, launch) + return "" + + +## CLI clients: format the register template against the *short* CLI name so +## the user can paste it into a terminal regardless of where their binary +## lives. (The auto-configure path resolves to an absolute uvx-style path; +## that's noise for a paste-into-terminal hint. The attach launcher path +## inside a command-shape line stays absolute — status verification compares +## the registered command against the resolved launcher verbatim.) +static func _build_cli( + client: McpClient, + server_name: String, + server_url: String, + resolved_path: String = "", + launch: Dictionary = {}, + project_roots: PackedStringArray = PackedStringArray(), +) -> String: + if client.cli_register_template.is_empty() or client.cli_names.is_empty(): + return "" + var shell_kind := _shell_kind_for_platform() + var short_name: String = String(client.cli_names[0]) + # Prefer the non-.exe form for a cross-platform-looking command line. + for n in client.cli_names: + if not String(n).ends_with(".exe"): + short_name = String(n) + break + var cmd := "" + if client.command_shape != McpClient.CommandShape.NONE: + var launch_error := McpCliStrategy.command_launch_error(client, launch) + if not launch_error.is_empty(): + cmd = "Attach launch command unavailable: %s" % launch_error + else: + var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url, launch) + var parts: Array[String] = [short_name] + for arg in args: + parts.append(String(arg)) + var lines := _sweep_lines(client, server_name, server_url, short_name, shell_kind) + lines.append(_render_command_line(parts, shell_kind)) + cmd = _format_shell_block(lines, shell_kind) + _sweep_caveat(client) + else: + var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url) + var parts: Array[String] = [short_name] + for arg in args: + parts.append(String(arg)) + var lines := _sweep_lines(client, server_name, server_url, short_name, shell_kind) + lines.append(_render_command_line(parts, shell_kind)) + cmd = _format_shell_block(lines, shell_kind) + _sweep_caveat(client) + # #463: a CLI client with a JSON fallback (Claude Code) may have no `claude` + # binary at all — e.g. installed only as a VS Code/Cursor extension. The CLI + # line above is useless to that user, so also show the config-file edit that + # auto-configure falls back to writing. + if client.has_json_fallback() and not resolved_path.is_empty(): + return "%s\n\nNo `%s` CLI (e.g. installed as a VS Code/Cursor extension)? %s" % [ + cmd, short_name, _build_json(client, server_name, server_url, resolved_path, launch, project_roots), + ] + return cmd + + +## #877: Configure's first act is the pre-cleanup — it runs the unregister +## template once per scope the descriptor can write to (`_cli_strategy.gd` +## `configure`) before registering. Rendering only the register line made this +## hint disagree with what the button does, which is the worst property a +## "run this manually" string can have. The removes go in the SAME shell block +## as the register, in the order Configure runs them, so the whole thing stays +## one copy-paste rather than a labelled command plus loose prose. +static func _sweep_lines( + client: McpClient, + server_name: String, + server_url: String, + short_name: String, + shell_kind: String, +) -> Array[String]: + var lines: Array[String] = [] + if client.cli_unregister_template.is_empty(): + return lines + for pre_scope in McpCliStrategy.cleanup_scopes(client): + var args := McpCliStrategy.format_args( + client.cli_unregister_template, server_name, server_url, {}, pre_scope + ) + var parts: Array[String] = [short_name] + for arg in args: + parts.append(String(arg)) + lines.append(_render_command_line(parts, shell_kind)) + return lines + + +## The one thing the commands themselves cannot show: that the project pass +## resolves `.mcp.json` against the CLI's cwd, so it can delete an entry from a +## repository that has nothing to do with this project. Only for `{scope}` +## descriptors — a single implicit-scope pass removes exactly the entry the +## register is about to rewrite, which needs no warning. Kept outside the shell +## block so pasting the block never picks up prose. +static func _sweep_caveat(client: McpClient) -> String: + if client.cli_unregister_template.is_empty() or not McpCliStrategy.uses_scope_token(client): + return "" + return ( + "\n\nThe project-scope remove rewrites the .mcp.json in the directory the" + + " editor was launched from — not necessarily this project — so it drops a" + + " hand-maintained godot-ai entry there. Other servers in that file are left" + + " alone." + ) + + +static func _shell_kind_for_platform() -> String: + return SHELL_POWERSHELL if OS.get_name() == "Windows" else SHELL_POSIX + + +## Render a command for one explicitly named shell. The label is load-bearing: +## POSIX and PowerShell use different escaping for embedded single quotes, so +## presenting the command without its target shell invites a bad copy/paste. +static func _format_shell_command(parts: Array[String], shell_kind: String) -> String: + return _format_shell_block([_render_command_line(parts, shell_kind)], shell_kind) + + +## One label over N command lines. The label is per-block, not per-line: +## repeating "Run in PowerShell:" four times would bury the single line that +## actually registers. +static func _format_shell_block(lines: Array[String], shell_kind: String) -> String: + var label := "Run in PowerShell:" if shell_kind == SHELL_POWERSHELL else "Run in a POSIX shell:" + return "%s\n%s" % [label, "\n".join(lines)] + + +static func _render_command_line(parts: Array[String], shell_kind: String) -> String: + var rendered: Array[String] = [] + for part in parts: + rendered.append(_shell_display_arg(part, shell_kind)) + return " ".join(rendered) + + +## Quote one argv element for the paste-into-terminal hint. Single-quoted +## strings are literal in both supported shells, but embedded single quotes +## have shell-specific spellings. Backslashes, double quotes, dollar signs, +## and PowerShell backticks remain byte-for-byte unchanged inside the quotes. +static func _shell_display_arg(arg: String, shell_kind: String) -> String: + if arg.is_empty(): + return "''" + var stays_bare := true + for index in range(arg.length()): + if _SHELL_BARE_SAFE_CHARS.find(arg.substr(index, 1)) < 0: + stays_bare = false + break + if stays_bare: + return arg + if shell_kind == SHELL_POWERSHELL: + return "'%s'" % arg.replace("'", "''") + return "'%s'" % arg.replace("'", "'\"'\"'") + + +static func _build_json( + client: McpClient, + server_name: String, + server_url: String, + resolved_path: String, + launch: Dictionary = {}, + project_roots: PackedStringArray = PackedStringArray(), +) -> String: + var target := McpJsonStrategy.manual_target_details(client, server_name, resolved_path, project_roots) + var target_note := "" + if not target.get("ok", false): + target_note = "Target inspection failed: %s" % str(target.get("error", "cannot inspect config")) + target = {"path": resolved_path, "key_path": client.server_key_path} + var target_path := str(target.get("path", resolved_path)) + var key_path: PackedStringArray = target.get("key_path", client.server_key_path) + var key := key_path[0] if key_path.size() > 0 else "mcpServers" + if client.command_shape != McpClient.CommandShape.NONE: + var lines: Array[String] = [] + var launch_error := McpJsonStrategy.command_launch_error(client, launch) + if launch_error.is_empty(): + var command_entry := McpJsonStrategy.build_entry(client, server_url, null, launch) + lines.append("Edit %s and add under \"%s\":" % [target_path, key]) + lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(command_entry)]) + else: + lines.append("Attach launch command unavailable: %s" % launch_error) + if client.command_supports_url_fallback: + lines.append("") + lines.append("Advanced fallback — use this URL-mode entry instead; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.") + lines.append("Edit %s and add under \"%s\":" % [target_path, key]) + var fallback_entry := McpJsonStrategy.build_url_entry(client, server_url) + lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(fallback_entry)]) + if not target_note.is_empty(): + lines.append("") + lines.append(target_note) + return "\n".join(lines) + var entry := McpJsonStrategy.build_entry(client, server_url) + var instructions := "Edit %s and add under \"%s\":\n \"%s\": %s" % [target_path, key, server_name, _format_entry_inline(entry)] + if not target_note.is_empty(): + instructions += "\n\n" + target_note + return instructions + + +static func _build_toml( + client: McpClient, + _server_name: String, + server_url: String, + resolved_path: String, + launch: Dictionary = {}, +) -> String: + var header := _toml_header(client) + if client.command_shape != McpClient.CommandShape.NONE: + var lines: Array[String] = [] + var rendered := McpTomlStrategy.render_body(client, server_url, launch) + if bool(rendered.get("ok", false)): + lines.append("Edit %s and add:" % resolved_path) + lines.append(" %s" % header) + for body_line in rendered.get("lines", []): + lines.append(" %s" % str(body_line)) + else: + lines.append("Attach launch command unavailable: %s" % str(rendered.get("error", "no compatible launcher found"))) + if client.command_supports_url_fallback: + lines.append("") + lines.append("Advanced fallback — replace the command/args block above with this URL-mode block; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.") + lines.append("Edit %s and add:" % resolved_path) + lines.append(" %s" % header) + lines.append(" url = %s" % McpTomlStrategy.encode_basic_string(server_url)) + return "\n".join(lines) + var body := McpTomlStrategy.format_body(client.toml_body_template, server_url) + var lines: Array[String] = ["Edit %s and add:" % resolved_path, " %s" % header] + for b in body: + lines.append(" %s" % String(b)) + return "\n".join(lines) + + +static func _build_yaml( + client: McpClient, + server_name: String, + server_url: String, + resolved_path: String, + launch: Dictionary = {}, +) -> String: + var key := client.server_key_path[0] if client.server_key_path.size() > 0 else "mcp_servers" + if client.command_shape != McpClient.CommandShape.NONE: + var lines: Array[String] = [] + var launch_error := McpYamlStrategy.command_launch_error(client, launch) + if launch_error.is_empty(): + var command_entry := McpYamlStrategy.build_entry(client, server_url, null, launch) + lines.append("Edit %s and add under '%s':" % [resolved_path, key]) + for entry_line in McpYamlStrategy.render_entry_lines(server_name, command_entry): + lines.append(String(entry_line)) + else: + lines.append("Attach launch command unavailable: %s" % launch_error) + if client.command_supports_url_fallback: + lines.append("") + lines.append("Advanced fallback — use this URL-mode entry instead; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.") + lines.append("Edit %s and add under '%s':" % [resolved_path, key]) + var fallback_entry := {client.entry_url_field: server_url} + for entry_line in McpYamlStrategy.render_entry_lines(server_name, fallback_entry): + lines.append(String(entry_line)) + return "\n".join(lines) + var entry := McpYamlStrategy.build_entry(client, server_url) + var lines: Array[String] = [ + "Edit %s and add under '%s':" % [resolved_path, key], + " %s:" % server_name, + ] + for k in entry: + lines.append(" %s: %s" % [k, str(entry[k])]) + return "\n".join(lines) + + +## DeepSeek Harness: the user pastes a loader `insert` row into the home +## patch layer (`$DSH_HOME/cordis.patch.yml`). Auto-configure writes exactly +## these lines, so the manual text matches the file Configure would produce +## byte-for-byte. +static func _build_dsh( + client: McpClient, + server_name: String, + server_url: String, + resolved_path: String, + launch: Dictionary = {}, +) -> String: + var lines: Array[String] = ["Edit %s and add this loader entry (an `insert` row; a plain `- id:` row only overrides an existing bundle id and would be skipped):" % resolved_path] + var entry_id_value := McpDshStrategy.entry_id(server_name) + if client.command_shape != McpClient.CommandShape.NONE: + var launch_error := McpDshStrategy.command_launch_error(client, launch) + if launch_error.is_empty(): + var command_entry := McpDshStrategy.build_entry(client, server_name, server_url, null, launch) + for row_line in McpDshStrategy.render_insert_row(entry_id_value, command_entry): + lines.append(String(row_line)) + else: + lines.append("Attach launch command unavailable: %s" % launch_error) + if client.command_supports_url_fallback: + lines.append("") + ## No command/args block was rendered on the launch-error path, so + ## "replace the block above" would point at nothing. + var fallback_lead := ( + "replace the command/args block above with this URL-mode entry" + if launch_error.is_empty() + else "use this URL-mode entry instead" + ) + lines.append("Advanced fallback — %s; never configure both shapes together. URL mode depends on the harness' own reconnect behavior. If the server is down when the harness starts, restarting the harness may be required." % fallback_lead) + var fallback_entry := McpDshStrategy.build_url_entry(client, server_name, server_url) + for row_line in McpDshStrategy.render_insert_row(entry_id_value, fallback_entry): + lines.append(String(row_line)) + return "\n".join(lines) + var url_entry := McpDshStrategy.build_url_entry(client, server_name, server_url) + for row_line in McpDshStrategy.render_insert_row(entry_id_value, url_entry): + lines.append(String(row_line)) + return "\n".join(lines) + + +## Mirrors the [section."name"] header `_toml_strategy._primary_header` +## emits, kept here so the manual-command text matches the file we'd write. +static func _toml_header(client: McpClient) -> String: + var parts := client.toml_section_path + if parts.size() < 2: + return "[%s]" % ".".join(parts) + var section := ".".join(McpClient._array_from_packed(McpClient._packed_slice(parts, 0, parts.size() - 1))) + var name := parts[parts.size() - 1] + return "[%s.\"%s\"]" % [section, name] + + +## Format an entry dict as a single inline JSON-ish string, matching the +## pre-refactor manual-command style: `{ "k": v, "k": v }` with spaces. +## Pre-existing manual-command tests assert the exact substring shape; this +## keeps them stable. +## +## Uses `JSON.stringify` for every leaf String (key OR value) so paths +## containing backslashes / quotes / newlines render as syntactically valid +## JSON. A Windows uvx path like `C:\Users\foo\uvx.exe` would otherwise be +## emitted as `"C:\Users\foo\uvx.exe"` — invalid JSON, unsafe to paste. +static func _format_entry_inline(entry: Dictionary) -> String: + var parts: Array[String] = [] + for k in entry: + parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(entry[k])]) + if parts.is_empty(): + return "{}" + return "{ %s }" % ", ".join(parts) + + +static func _format_value(value: Variant) -> String: + # Strings, bools, numbers, null all round-trip correctly through JSON.stringify + # without spurious quoting of non-string scalars (true → `true`, 5 → `5`). + # Arrays and Dictionaries are formatted manually so the inline ` { k: v } ` + # spacing matches the pre-refactor manual-command output shape that tests + # pin with assert_contains. + if value is Array: + var arr_parts: Array[String] = [] + for v in value: + arr_parts.append(_format_value(v)) + return "[%s]" % ", ".join(arr_parts) + if value is Dictionary: + var d_parts: Array[String] = [] + for k in value: + d_parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(value[k])]) + if d_parts.is_empty(): + return "{}" + return "{ %s }" % ", ".join(d_parts) + return JSON.stringify(value) diff --git a/addons/godot_ai/clients/_manual_command.gd.uid b/addons/godot_ai/clients/_manual_command.gd.uid new file mode 100644 index 0000000..07a96f4 --- /dev/null +++ b/addons/godot_ai/clients/_manual_command.gd.uid @@ -0,0 +1 @@ +uid://ct1wmgfk408x0 diff --git a/addons/godot_ai/clients/_path_template.gd b/addons/godot_ai/clients/_path_template.gd new file mode 100644 index 0000000..8fc9753 --- /dev/null +++ b/addons/godot_ai/clients/_path_template.gd @@ -0,0 +1,242 @@ +@tool +class_name McpPathTemplate +extends RefCounted + +## Expands ~ / $HOME / $APPDATA / $XDG_CONFIG_HOME / $LOCALAPPDATA / $USERPROFILE +## inside path templates so per-client descriptors can declare paths declaratively +## without hand-rolling per-OS lookups. + +## #691: dock worker threads (client-status refresh, configure/remove +## actions) and the #678 startup walk's discovery worker expand these +## templates off the main thread, while the spawn step mutates the +## process-global environment around `OS.create_process` +## (`GODOT_AI_OWNER_PID`, `GODOT_AI_PLUGIN_SPAWNED`, `PYTHONPATH`, +## `GODOT_AI_DISABLE_TELEMETRY`). A glibc `getenv` racing a concurrent +## `setenv` can return a freed pointer — rare but process-fatal. All env +## reads in this layer therefore go through `env_lookup`: on the MAIN +## thread it reads live and refreshes a mutex-guarded snapshot; off the +## main thread it serves from the snapshot, so no `OS.get_environment` +## runs concurrently with the spawn window's mutations. Callers pre-warm +## every var their workers can touch via `warm_env_snapshot` (plugin +## `_enter_tree` and the dock's phase-1 refresh prep, both main-thread, +## both before any worker starts). +static var _env_snapshot := {} +static var _env_snapshot_mutex := Mutex.new() + +## Every var this layer and its sibling consumers (`_base.gd` +## `config_file_override_details`, `config_home_override_details`, `_cli_finder.gd` lookups, +## `client_configurator.gd` mode/trace reads) can touch off-main. +## Descriptor-declared config-file/config-home env names are passed as extras +## by the warm callers. +const _BASE_ENV_VARS: Array[String] = [ + "HOME", + "USERPROFILE", + "XDG_CONFIG_HOME", + "APPDATA", + "LOCALAPPDATA", + "SHELL", + "ProgramFiles", + "GODOT_AI_MODE", + "GODOT_AI_STARTUP_TRACE", + ## #804 (#752 adoption): _find_venv_python reads this via env_lookup on + ## the dock's worker path; without pre-warming, a set override reads as + ## empty there and is silently ignored — the exact misconfiguration the + ## push_warning in client_configurator.gd exists to surface. + "GODOT_AI_VENV_PYTHON", +] + + +## Thread-safe env read (#691). Main thread: live read + snapshot refresh. +## Worker thread: snapshot only, so it can never race a main-thread +## setenv/unsetenv. A worker read of a never-warmed var returns "" — the +## same value an unset var reads as — never a live OS.get_environment, +## which would reintroduce the race for exactly the vars nobody thought +## to warm. Missing warm-up degrades resolution; it must not touch the +## process-global environment off-main. +static func env_lookup(name: String) -> String: + if OS.get_thread_caller_id() == OS.get_main_thread_id(): + var live := OS.get_environment(name) + _env_snapshot_mutex.lock() + _env_snapshot[name] = live + _env_snapshot_mutex.unlock() + return live + _env_snapshot_mutex.lock() + var cached: Variant = _env_snapshot.get(name, null) + _env_snapshot_mutex.unlock() + if cached != null: + return str(cached) + return "" + + +## Main-thread pre-warm so subsequent worker reads never touch the real +## environment. Idempotent; safe to call before every worker dispatch. +static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray()) -> void: + for var_name in _BASE_ENV_VARS: + env_lookup(var_name) + for var_name in extra_vars: + if not String(var_name).is_empty(): + env_lookup(String(var_name)) + + +## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map. +static func resolve(template_map: Dictionary) -> String: + var key := platform_key(template_map) + if key.is_empty(): + return "" + var template: String = template_map[key] + return expand(template) + + +## Return the platform-specific key present in a descriptor map. `unix` is a +## shorthand for macOS and Linux. Public so descriptors can use the same +## platform selection for ordered path-candidate arrays as for one path. +static func platform_key(template_map: Dictionary) -> String: + var key := _os_key() + if template_map.has(key): + return key + if (key == "darwin" or key == "linux") and template_map.has("unix"): + return "unix" + return "" + + +## Expand one path template into zero or more concrete paths. A single `*` is +## allowed inside one DIRECTORY segment (for example `Packages/Claude_*`). The +## wildcard is resolved by enumerating that segment's parent; the remaining +## suffix may name a file that does not exist yet, which lets callers derive a +## deterministic create target for a fresh packaged-app install. +## +## Multiple wildcards fail closed and return no candidates. A wildcard final +## segment may identify an installation directory for `detect_paths`. Returned +## paths are sorted for deterministic tests and diagnostics; callers still +## reject ambiguous config groups rather than picking one. +static func expand_path_candidates(template: String) -> PackedStringArray: + var expanded := expand(template) + if expanded.is_empty(): + return PackedStringArray() + var star := expanded.find("*") + if star < 0: + return PackedStringArray([expanded]) + if expanded.find("*", star + 1) >= 0: + return PackedStringArray() + + var slash_before := maxi(expanded.rfind("/", star), expanded.rfind("\\", star)) + var forward_after := expanded.find("/", star) + var backward_after := expanded.find("\\", star) + var slash_after := forward_after + if slash_after < 0 or (backward_after >= 0 and backward_after < slash_after): + slash_after = backward_after + if slash_before < 0: + return PackedStringArray() + + var parent := expanded.substr(0, slash_before) + var pattern := ( + expanded.substr(slash_before + 1) + if slash_after < 0 + else expanded.substr(slash_before + 1, slash_after - slash_before - 1) + ) + var suffix := "" if slash_after < 0 else expanded.substr(slash_after + 1) + var pattern_star := pattern.find("*") + if pattern_star < 0: + return PackedStringArray() + var prefix := pattern.substr(0, pattern_star) + var ending := pattern.substr(pattern_star + 1) + var dir := DirAccess.open(parent) + if dir == null: + return PackedStringArray() + + var matches := PackedStringArray() + for child in dir.get_directories(): + if _wildcard_segment_matches(String(child), prefix, ending): + var matched_path := parent.path_join(String(child)) + matches.append(matched_path if suffix.is_empty() else matched_path.path_join(suffix)) + matches.sort() + return matches + + +## Substitute env vars and ~ in a single template string. +## +## A token that cannot be resolved — the var is unset and no home-derived +## fallback applies, or a dock worker's env snapshot was never warmed with it — +## is LEFT IN PLACE rather than replaced with "". Substituting "" is what turns +## `$USERPROFILE/godot` into `/godot`: a root-relative path that +## `is_absolute_path()` accepts, so every fail-closed guard downstream waves it +## through and the caller reads or writes a directory the user never named. +## `$USERPROFILE` is the easy repro (unset off Windows, and the only one of +## these vars with no fallback), but `$HOME`/`$XDG_CONFIG_HOME`/`$APPDATA`/ +## `$LOCALAPPDATA` reach the same state whenever `_home()` is empty, which the +## missing-warm-up degradation above makes reachable on a worker thread. +## +## The surviving token keeps the result non-absolute, so those guards catch it, +## and the unexpanded path they report still shows what failed to resolve. +## `~` is left alone for the same reason: collapsing `~/godot` to the relative +## `godot` aims it at the editor's own working directory. +static func expand(template: String) -> String: + if template.is_empty(): + return "" + var out := template + if out.begins_with("~/") or out == "~": + var home := _home() + if not home.is_empty(): + out = home if out == "~" else home.path_join(out.substr(2)) + # $HOME, $APPDATA, $LOCALAPPDATA, $USERPROFILE, $XDG_CONFIG_HOME + for var_name in ["XDG_CONFIG_HOME", "LOCALAPPDATA", "USERPROFILE", "APPDATA", "HOME"]: + var token := "$%s" % var_name + if out.find(token) < 0: + continue + var value := env_lookup(var_name) + if value.is_empty(): + value = _home_fallback(var_name) + if value.is_empty(): + continue + out = out.replace(token, value) + return out + + +## Home-derived default for a var the environment does not define. Returns "" +## when home itself is unresolvable so `expand` leaves the token alone: rooting +## these at an empty home yields a RELATIVE path, not an absent one — +## `"".path_join(".config")` is `.config`, which reads against the editor's +## working directory rather than the user's config dir. `USERPROFILE` has no +## fallback (it is the thing `_home()` itself falls back to). +static func _home_fallback(var_name: String) -> String: + var home := _home() + if home.is_empty(): + return "" + match var_name: + "XDG_CONFIG_HOME": + return home.path_join(".config") + "APPDATA": + return home.path_join("AppData/Roaming") + "LOCALAPPDATA": + return home.path_join("AppData/Local") + "HOME": + return home + return "" + + +static func _os_key() -> String: + match OS.get_name(): + "macOS": + return "darwin" + "Windows": + return "windows" + _: + return "linux" + + +static func _wildcard_segment_matches(value: String, prefix: String, ending: String) -> bool: + # Prefix/suffix tests alone allow the two fixed portions to overlap inside a + # too-short value. Glob semantics require room for both portions even when + # `*` matches an empty string. + if value.length() < prefix.length() + ending.length(): + return false + if OS.get_name() == "Windows": + return value.to_lower().begins_with(prefix.to_lower()) and value.to_lower().ends_with(ending.to_lower()) + return value.begins_with(prefix) and value.ends_with(ending) + + +static func _home() -> String: + var h := env_lookup("HOME") + if h.is_empty(): + h = env_lookup("USERPROFILE") + return h diff --git a/addons/godot_ai/clients/_path_template.gd.uid b/addons/godot_ai/clients/_path_template.gd.uid new file mode 100644 index 0000000..f2403d7 --- /dev/null +++ b/addons/godot_ai/clients/_path_template.gd.uid @@ -0,0 +1 @@ +uid://5pd418va35ms diff --git a/addons/godot_ai/clients/_registry.gd b/addons/godot_ai/clients/_registry.gd new file mode 100644 index 0000000..f61f667 --- /dev/null +++ b/addons/godot_ai/clients/_registry.gd @@ -0,0 +1,156 @@ +@tool +class_name McpClientRegistry +extends RefCounted + +## Central enumeration of every supported MCP client. Adding a new client +## means: drop a file in clients/, then append one path below. +## +## Paths, not preloads (#736): a preload array pulled all client descriptor +## scripts into the boot-time compile closure of everything that preloads +## this registry (plugin.gd via client_configurator.gd and mcp_dock.gd), +## stalling "Initializing plugins" on every editor boot. Descriptors are +## only needed when the dock refreshes client statuses or a client_* +## command runs, so they load lazily on first registry access. + +const _CLIENT_SCRIPT_PATHS := [ + "res://addons/godot_ai/clients/claude_code.gd", + "res://addons/godot_ai/clients/claude_desktop.gd", + "res://addons/godot_ai/clients/codex.gd", + "res://addons/godot_ai/clients/grok.gd", + "res://addons/godot_ai/clients/antigravity.gd", + "res://addons/godot_ai/clients/cursor.gd", + "res://addons/godot_ai/clients/windsurf.gd", + "res://addons/godot_ai/clients/vscode.gd", + "res://addons/godot_ai/clients/vscode_insiders.gd", + "res://addons/godot_ai/clients/zed.gd", + "res://addons/godot_ai/clients/gemini_cli.gd", + "res://addons/godot_ai/clients/cline.gd", + "res://addons/godot_ai/clients/kilo_code.gd", + "res://addons/godot_ai/clients/roo_code.gd", + "res://addons/godot_ai/clients/zoo_code.gd", + "res://addons/godot_ai/clients/kiro.gd", + "res://addons/godot_ai/clients/trae.gd", + "res://addons/godot_ai/clients/cherry_studio.gd", + "res://addons/godot_ai/clients/opencode.gd", + "res://addons/godot_ai/clients/qwen_code.gd", + "res://addons/godot_ai/clients/kimi_code.gd", + "res://addons/godot_ai/clients/hermes.gd", + "res://addons/godot_ai/clients/pi.gd", + "res://addons/godot_ai/clients/deepseek_harness.gd", +] + +static var _instances: Array[McpClient] = [] +static var _by_id: Dictionary = {} +## First registry access can come from the dock's client-status refresh +## worker thread while the main thread hits it via a client_* command — +## serialize the one-time load so a racing thread can never observe a +## half-built registry. load() itself is thread-safe via ResourceLoader. +static var _load_mutex := Mutex.new() +## True when even a fresh rebuild yields instances missing base-schema +## fields — the deep stale-script state after an in-session self-update +## (#850; docs/releasing.md release-shape rules). Only an editor restart +## heals it; callers surface RESTART_TO_FINISH_UPDATE instead of erroring +## per client. Never reset within a session: rebuilding again cannot help, +## it would only repeat the load work and warning on every dock sweep. +static var _stale_session := false + +const RESTART_TO_FINISH_UPDATE := ( + "Godot AI was updated in this editor session. Restart the editor to finish the update." +) + + +static func all() -> Array[McpClient]: + _ensure_loaded() + return _instances + + +static func get_by_id(id: String) -> McpClient: + _ensure_loaded() + return _by_id.get(id, null) + + +static func ids() -> PackedStringArray: + var out := PackedStringArray() + for c in all(): + out.append(c.id) + return out + + +static func has_id(id: String) -> bool: + _ensure_loaded() + return _by_id.has(id) + + +## True when this editor session is running a self-update whose script +## reloads left descriptor state unusable. Client operations short-circuit +## with RESTART_TO_FINISH_UPDATE rather than spamming per-field errors. +static func stale_session_detected() -> bool: + _ensure_loaded() + return _stale_session + + +## An instance is coherent when fields added to the CURRENT McpClient schema +## read back with their declared types. After an in-session self-update, +## hot-patched or pre-update instances answer Nil for vars the update added +## (#850: `config_path_candidates` and +## `config_file_env` read as Nil, crashing platform_key / String()). The +## reflected `get()` avoids typed-access errors on such instances. +static func _instance_is_coherent(inst: Object) -> bool: + if inst == null: + return false + return ( + inst.get("config_path_candidates") is Dictionary + and inst.get("config_file_env") is String + and inst.get("path_template") is Dictionary + and inst.get("server_key_path_aliases") is Array + and inst.get("config_merge_path_templates") is Dictionary + and inst.get("config_merge_project_paths") is PackedStringArray + ) + + +static func _cache_is_coherent() -> bool: + return not _instances.is_empty() and _instance_is_coherent(_instances[0]) + + +static func _ensure_loaded() -> void: + if _stale_session: + return + if _cache_is_coherent(): + return + _load_mutex.lock() + ## Re-check under the lock: another thread may have rebuilt (or concluded + ## staleness) while this one waited. + if not _stale_session and not _cache_is_coherent(): + ## Covers both the first load and the post-self-update rebuild: this + ## registry file can survive an update unchanged, so its statics keep + ## serving pre-update instances to freshly reloaded callers. A rebuild + ## instantiates from the reloaded descriptor scripts, which repairs + ## every case except a stale base Script object itself. + _load() + if not _instances.is_empty() and not _cache_is_coherent(): + _stale_session = true + push_warning("MCP | %s" % RESTART_TO_FINISH_UPDATE) + _load_mutex.unlock() + + +static func _load() -> void: + ## Build into locals and publish whole containers last, so the lock-free + ## fast path in _ensure_loaded can never see a partially-filled registry. + var instances: Array[McpClient] = [] + var by_id: Dictionary = {} + for path in _CLIENT_SCRIPT_PATHS: + var script := load(path) as GDScript + if script == null: + push_warning("MCP | failed to load client descriptor %s" % path) + continue + var inst: McpClient = script.new() + if inst.id.is_empty(): + push_warning("MCP | client descriptor %s has empty id" % path) + continue + if by_id.has(inst.id): + push_warning("MCP | duplicate client id: %s" % inst.id) + continue + instances.append(inst) + by_id[inst.id] = inst + _by_id = by_id + _instances = instances diff --git a/addons/godot_ai/clients/_registry.gd.uid b/addons/godot_ai/clients/_registry.gd.uid new file mode 100644 index 0000000..57edcf7 --- /dev/null +++ b/addons/godot_ai/clients/_registry.gd.uid @@ -0,0 +1 @@ +uid://bxougoq8xwg1 diff --git a/addons/godot_ai/clients/_toml_strategy.gd b/addons/godot_ai/clients/_toml_strategy.gd new file mode 100644 index 0000000..a929dbd --- /dev/null +++ b/addons/godot_ai/clients/_toml_strategy.gd @@ -0,0 +1,730 @@ +@tool +class_name McpTomlStrategy +extends RefCounted + +## TOML upsert for URL entries and client-owned command entries. +## +## This remains deliberately smaller than a general TOML parser, but the +## parts that affect migration are semantic: assignments retain their whole +## value span (including multiline arrays/strings), and command/args status +## verification decodes TOML strings and arrays rather than comparing text. + + +static func configure( + client: McpClient, + _server_name: String, + server_url: String, + launch: Dictionary = {}, +) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty(): + return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name} + + var seed_path := str(resolution.get("seed_path", "")) + var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path + var read := _read_or_init(read_path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]} + + var rendered := render_body(client, server_url, launch) + if not bool(rendered.get("ok", false)): + return {"status": "error", "message": str(rendered.get("error", "Could not build the TOML entry."))} + + var lines: Array[String] = _split_lines(String(read["data"])) + var body: Array[String] = rendered["lines"] + var pinned_keys: Dictionary = rendered["pinned_keys"] + var initial_keys: Dictionary = rendered["initial_keys"] + var removed_keys: Dictionary = rendered["removed_keys"] + + var section := _find_section(lines, _all_headers(client)) + var header := _primary_header(client) + var new_lines: Array[String] = [header] + + if section.is_empty(): + new_lines.append_array(body) + var output_fresh: Array[String] = [] + output_fresh.append_array(lines) + if not output_fresh.is_empty() and not output_fresh[-1].strip_edges().is_empty(): + output_fresh.append("") + output_fresh.append_array(new_lines) + if not McpAtomicWrite.write(path, "\n".join(output_fresh)): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": McpClient.configured_message(client, server_url)} + + var old_items := _value_items(lines, int(section["start"]) + 1, int(section["end"])) + var old_by_key := {} + for item in old_items: + var old_key := str(item.get("key", "")) + if not old_key.is_empty() and not old_by_key.has(old_key): + old_by_key[old_key] = item + + var body_items := _value_items(body, 0, body.size()) + var emitted_keys := {} + for item in body_items: + var key := str(item.get("key", "")) + if key.is_empty(): + new_lines.append_array(item["lines"]) + continue + emitted_keys[key] = true + if initial_keys.has(key) and old_by_key.has(key): + new_lines.append_array(old_by_key[key]["lines"]) + else: + ## Pinned keys always use the freshly rendered span. A generated key + ## that is neither pinned nor initial is also rendered deterministically. + new_lines.append_array(item["lines"]) + + ## Carry unknown/user-owned assignments and standalone comments verbatim. + ## Whole item spans prevent multiline arrays/strings from being truncated. + for item in old_items: + var key := str(item.get("key", "")) + if not key.is_empty(): + if emitted_keys.has(key) or pinned_keys.has(key) or removed_keys.has(key): + continue + new_lines.append_array(item["lines"]) + continue + var item_lines: Array = item.get("lines", []) + for line in item_lines: + if not str(line).strip_edges().is_empty(): + new_lines.append(str(line)) + + var output: Array[String] = [] + output.append_array(_slice(lines, 0, int(section["start"]))) + output.append_array(new_lines) + output.append_array(_slice(lines, int(section["end"]), lines.size())) + output = _rewrite_legacy_descendant_headers(output, client) + + if not McpAtomicWrite.write(path, "\n".join(output)): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": McpClient.configured_message(client, server_url)} + + +static func check_status( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, +) -> McpClient.Status: + return check_status_details(client, server_name, server_url, launch).get("status", McpClient.Status.NOT_CONFIGURED) + + +static func check_status_details( + client: McpClient, + _server_name: String, + server_url: String, + launch: Dictionary = {}, +) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": McpClient.Status.ERROR, "error_msg": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + var read := _read_or_init(path) + if not read["ok"]: + return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])} + var lines: Array[String] = _split_lines(String(read["data"])) + var section := _find_section(lines, _all_headers(client)) + if section.is_empty(): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + + var items := _value_items(lines, int(section["start"]) + 1, int(section["end"])) + var by_key := {} + for item in items: + var key := str(item.get("key", "")) + if not key.is_empty() and not by_key.has(key): + by_key[key] = item + + if client.command_shape != McpClient.CommandShape.NONE: + if not bool(launch.get("ok", false)): + return { + "status": McpClient.Status.ERROR, + "error_msg": str(launch.get("error", "No compatible attach launcher was found.")), + } + for legacy_key in client.command_legacy_keys: + if by_key.has(String(legacy_key)): + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} + if not by_key.has("command") or not by_key.has("args"): + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} + var command_value := _decode_toml_string(_item_value(by_key["command"])) + var args_value := _decode_toml_string_array(_item_value(by_key["args"])) + if not bool(command_value.get("ok", false)) or not bool(args_value.get("ok", false)): + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} + if str(command_value.get("value", "")) != str(launch.get("command", "")): + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} + if not _string_arrays_equal(args_value.get("value", []), launch.get("args", [])): + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} + if not client.command_transport_key.is_empty(): + var transport_key := client.command_transport_key + if not by_key.has(transport_key): + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} + var decoded_transport := _decode_toml_scalar(_item_value(by_key[transport_key])) + if not bool(decoded_transport.get("ok", false)) or decoded_transport.get("value") != client.command_transport_value: + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} + return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} + + if not by_key.has("url"): + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} + var url_value := _decode_toml_string(_item_value(by_key["url"])) + if not bool(url_value.get("ok", false)) or str(url_value.get("value", "")) != server_url: + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} + return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} + + +static func remove(client: McpClient, _server_name: String) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": "ok", "message": "Not configured"} + var read := _read_or_init(path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]} + var lines: Array[String] = _split_lines(String(read["data"])) + var headers := _all_headers(client) + var subtable_prefixes := _subtable_prefixes(headers) + + var output: Array[String] = [] + var i := 0 + while i < lines.size(): + if _matches_any_header(lines[i], headers) or _matches_subtable_prefix(lines[i], subtable_prefixes): + i += 1 + while i < lines.size() and not _is_any_section_header(lines[i]): + i += 1 + continue + output.append(lines[i]) + i += 1 + + if not McpAtomicWrite.write(path, "\n".join(output)): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + + +## Substitute `{url}` in every legacy URL body-template line. +static func format_body(template: PackedStringArray, server_url: String) -> PackedStringArray: + var out := PackedStringArray() + for line in template: + out.append(String(line).replace("{url}", server_url)) + return out + + +## Encode a TOML basic string. This is intentionally public for the +## cross-language fixture test that parses the rendered sample with tomllib. +static func encode_basic_string(value: String) -> String: + return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"').replace("\b", "\\b").replace("\t", "\\t").replace("\n", "\\n").replace("\f", "\\f").replace("\r", "\\r") + + +## Multi-line string-array encoding used by command-shape entries. +static func encode_string_array(values: Variant) -> Array[String]: + var out: Array[String] = ["["] + for value in values: + out.append(" %s," % encode_basic_string(str(value))) + out.append("]") + return out + + +static func render_body(client: McpClient, server_url: String, launch: Dictionary) -> Dictionary: + if client.command_shape == McpClient.CommandShape.NONE: + if client.toml_body_template.is_empty(): + return {"ok": false, "error": "%s descriptor missing toml_body_template" % client.display_name} + var legacy_body := format_body(client.toml_body_template, server_url) + var legacy_lines: Array[String] = [] + var pinned := {} + var initial := {} + for idx in range(legacy_body.size()): + legacy_lines.append(String(legacy_body[idx])) + var key := _line_key(String(client.toml_body_template[idx])) + if key.is_empty(): + continue + if String(client.toml_body_template[idx]).contains("{url}"): + pinned[key] = true + else: + initial[key] = true + return { + "ok": true, + "lines": legacy_lines, + "pinned_keys": pinned, + "initial_keys": initial, + "removed_keys": {}, + } + + if client.command_shape != McpClient.CommandShape.COMMAND_ARRAY: + return {"ok": false, "error": "%s uses a command shape not supported by TOML yet" % client.display_name} + if not bool(launch.get("ok", false)): + return {"ok": false, "error": str(launch.get("error", "No compatible attach launcher was found."))} + + var command_lines: Array[String] = [ + "command = %s" % encode_basic_string(str(launch.get("command", ""))), + ] + command_lines.append_array(_encode_assignment("args", launch.get("args", []))) + + var pinned_keys := {"command": true, "args": true} + if not client.command_transport_key.is_empty(): + var encoded_transport := _encode_scalar(client.command_transport_value) + if encoded_transport.is_empty(): + return { + "ok": false, + "error": "Unsupported TOML transport `%s` for %s" % [ + client.command_transport_key, client.display_name, + ], + } + command_lines.append("%s = %s" % [client.command_transport_key, encoded_transport]) + pinned_keys[client.command_transport_key] = true + + var initial_keys := {} + for key in client.command_initial_fields: + var encoded := _encode_assignment(str(key), client.command_initial_fields[key]) + if encoded.is_empty(): + return {"ok": false, "error": "Unsupported TOML default `%s` for %s" % [key, client.display_name]} + command_lines.append_array(encoded) + initial_keys[str(key)] = true + + var removed_keys := {} + for key in client.command_legacy_keys: + removed_keys[String(key)] = true + return { + "ok": true, + "lines": command_lines, + "pinned_keys": pinned_keys, + "initial_keys": initial_keys, + "removed_keys": removed_keys, + } + + +static func _encode_assignment(key: String, value: Variant) -> Array[String]: + if value is Array or value is PackedStringArray: + var lines := encode_string_array(value) + lines[0] = "%s = %s" % [key, lines[0]] + return lines + var encoded := _encode_scalar(value) + var lines: Array[String] = [] + if not encoded.is_empty(): + lines.append("%s = %s" % [key, encoded]) + return lines + + +static func _encode_scalar(value: Variant) -> String: + if value is String: + return encode_basic_string(value) + if value is bool: + return "true" if value else "false" + if value is int or value is float: + return str(value) + return "" + + +# --- span-aware merge helpers ------------------------------------------- + +static func _value_items(lines: Array[String], from: int, to: int) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + var i := from + while i < to: + var key := _line_key(lines[i]) + if key.is_empty(): + out.append({"key": "", "lines": [lines[i]]}) + i += 1 + continue + var end := _value_span_end(lines, i, to) + out.append({"key": key, "lines": _slice(lines, i, end)}) + i = end + return out + + +static func _value_span_end(lines: Array[String], start: int, limit: int) -> int: + var state := {"quote": "", "square": 0, "curly": 0, "escaped": false} + for i in range(start, limit): + var begin := 0 + if i == start: + var eq := _assignment_equal(lines[i]) + begin = eq + 1 if eq >= 0 else 0 + _scan_toml_value_line(lines[i], begin, state) + if str(state["quote"]).is_empty() and int(state["square"]) == 0 and int(state["curly"]) == 0: + return i + 1 + return limit + + +static func _scan_toml_value_line(line: String, begin: int, state: Dictionary) -> void: + var i := begin + while i < line.length(): + var quote := str(state["quote"]) + if quote == '"""' or quote == "'''": + if line.substr(i).begins_with(quote): + state["quote"] = "" + i += 3 + continue + if quote == '"""' and line.unicode_at(i) == 92 and not bool(state["escaped"]): + state["escaped"] = true + i += 1 + continue + state["escaped"] = false + i += 1 + continue + if quote == '"' or quote == "'": + var c := line.unicode_at(i) + if quote == '"' and c == 92 and not bool(state["escaped"]): + state["escaped"] = true + i += 1 + continue + if c == quote.unicode_at(0) and not bool(state["escaped"]): + state["quote"] = "" + state["escaped"] = false + i += 1 + continue + + if line.substr(i).begins_with('"""'): + state["quote"] = '"""' + i += 3 + continue + if line.substr(i).begins_with("'''"): + state["quote"] = "'''" + i += 3 + continue + var c := line.unicode_at(i) + if c == 34: + state["quote"] = '"' + elif c == 39: + state["quote"] = "'" + elif c == 35: + break + elif c == 91: + state["square"] = int(state["square"]) + 1 + elif c == 93: + state["square"] = maxi(0, int(state["square"]) - 1) + elif c == 123: + state["curly"] = int(state["curly"]) + 1 + elif c == 125: + state["curly"] = maxi(0, int(state["curly"]) - 1) + i += 1 + + +static func _line_key(line: String) -> String: + var eq := _assignment_equal(line) + if eq <= 0: + return "" + return line.substr(0, eq).strip_edges() + + +static func _assignment_equal(line: String) -> int: + var quote := 0 + var escaped := false + for i in range(line.length()): + var c := line.unicode_at(i) + if quote != 0: + if quote == 34 and c == 92 and not escaped: + escaped = true + continue + if c == quote and not escaped: + quote = 0 + escaped = false + continue + if c == 34 or c == 39: + quote = c + elif c == 35: + return -1 + elif c == 61: + return i + return -1 + + +# --- semantic value decoding -------------------------------------------- + +static func _item_value(item: Dictionary) -> String: + var item_lines: Array = item.get("lines", []) + if item_lines.is_empty(): + return "" + var first := str(item_lines[0]) + var eq := _assignment_equal(first) + if eq < 0: + return "" + var parts: Array[String] = [first.substr(eq + 1)] + for i in range(1, item_lines.size()): + parts.append(str(item_lines[i])) + return "\n".join(parts) + + +static func _decode_toml_scalar(raw: String) -> Dictionary: + var cleaned := _without_comments(raw).strip_edges() + if cleaned == "true": + return {"ok": true, "value": true} + if cleaned == "false": + return {"ok": true, "value": false} + var string_value := _decode_toml_string(cleaned) + if bool(string_value.get("ok", false)): + return string_value + if cleaned.is_valid_int(): + return {"ok": true, "value": cleaned.to_int()} + if cleaned.is_valid_float(): + return {"ok": true, "value": cleaned.to_float()} + return {"ok": false} + + +static func _decode_toml_string(raw: String) -> Dictionary: + var cleaned := _without_comments(raw).strip_edges() + if cleaned.length() >= 2 and cleaned.begins_with("'") and cleaned.ends_with("'"): + return {"ok": true, "value": cleaned.substr(1, cleaned.length() - 2)} + if cleaned.length() < 2 or not cleaned.begins_with('"') or not cleaned.ends_with('"'): + return {"ok": false} + var parsed: Variant = JSON.parse_string(cleaned) + if parsed is String: + return {"ok": true, "value": parsed} + return {"ok": false} + + +static func _decode_toml_string_array(raw: String) -> Dictionary: + var cleaned := _without_comments(raw).strip_edges() + if not cleaned.begins_with("["): + return {"ok": false} + var i := 1 + var values: Array[String] = [] + while true: + i = _skip_space(cleaned, i) + if i >= cleaned.length(): + return {"ok": false} + if cleaned.unicode_at(i) == 93: + i = _skip_space(cleaned, i + 1) + return {"ok": i == cleaned.length(), "value": values} + var parsed := _parse_string_at(cleaned, i) + if not bool(parsed.get("ok", false)): + return {"ok": false} + values.append(str(parsed.get("value", ""))) + i = _skip_space(cleaned, int(parsed.get("next", i))) + if i >= cleaned.length(): + return {"ok": false} + var c := cleaned.unicode_at(i) + if c == 44: + i += 1 + continue + if c == 93: + continue + return {"ok": false} + return {"ok": false} # Unreachable; keeps GDScript's return analysis explicit. + + +static func _parse_string_at(text: String, start: int) -> Dictionary: + if start >= text.length(): + return {"ok": false} + var quote := text.unicode_at(start) + if quote != 34 and quote != 39: + return {"ok": false} + var i := start + 1 + var escaped := false + while i < text.length(): + var c := text.unicode_at(i) + if quote == 34 and c == 92 and not escaped: + escaped = true + i += 1 + continue + if c == quote and not escaped: + var raw := text.substr(start, i - start + 1) + if quote == 39: + return {"ok": true, "value": raw.substr(1, raw.length() - 2), "next": i + 1} + var parsed: Variant = JSON.parse_string(raw) + if parsed is String: + return {"ok": true, "value": parsed, "next": i + 1} + return {"ok": false} + escaped = false + i += 1 + return {"ok": false} + + +static func _without_comments(raw: String) -> String: + var out: Array[String] = [] + for line in raw.split("\n"): + var quote := 0 + var escaped := false + var kept := "" + for i in range(line.length()): + var c := line.unicode_at(i) + if quote != 0: + kept += line.substr(i, 1) + if quote == 34 and c == 92 and not escaped: + escaped = true + continue + if c == quote and not escaped: + quote = 0 + escaped = false + continue + if c == 34 or c == 39: + quote = c + kept += line.substr(i, 1) + elif c == 35: + break + else: + kept += line.substr(i, 1) + out.append(kept) + return "\n".join(out) + + +static func _skip_space(text: String, start: int) -> int: + var i := start + while i < text.length() and text.substr(i, 1) in [" ", "\t", "\r", "\n"]: + i += 1 + return i + + +static func _string_arrays_equal(left: Variant, right: Variant) -> bool: + if not (left is Array or left is PackedStringArray): + return false + if not (right is Array or right is PackedStringArray): + return false + if left.size() != right.size(): + return false + for i in range(left.size()): + if str(left[i]) != str(right[i]): + return false + return true + + +# --- file / section helpers --------------------------------------------- + +static func _read_or_init(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {"ok": true, "data": ""} + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + var err := FileAccess.get_open_error() + return {"ok": false, "error": "could not open for reading (error %d)" % err} + var text := f.get_as_text() + f.close() + return {"ok": true, "data": text} + + +static func _split_lines(content: String) -> Array[String]: + var out: Array[String] = [] + for line in content.split("\n"): + out.append(line) + return out + + +static func _slice(lines: Array[String], from: int, to: int) -> Array[String]: + var out: Array[String] = [] + for i in range(from, to): + out.append(lines[i]) + return out + + +static func _primary_header(client: McpClient) -> String: + var parts := client.toml_section_path + if parts.size() < 2: + return "[%s]" % ".".join(parts) + var section := ".".join(McpClient._packed_slice(parts, 0, parts.size() - 1)) + var name := parts[parts.size() - 1] + return "[%s.\"%s\"]" % [section, name] + + +static func _all_headers(client: McpClient) -> Array[String]: + var primary := _primary_header(client) + var out: Array[String] = [primary] + var bare := _bare_key_header(client) + if not bare.is_empty() and bare != primary: + out.append(bare) + for legacy in client.toml_legacy_section_aliases: + out.append("[%s]" % legacy) + return out + + +static func _bare_key_header(client: McpClient) -> String: + var parts := client.toml_section_path + if parts.is_empty(): + return "" + for part in parts: + if not _is_bare_key(String(part)): + return "" + return "[%s]" % ".".join(parts) + + +static func _is_bare_key(value: String) -> bool: + if value.is_empty(): + return false + for i in range(value.length()): + var c := value.unicode_at(i) + var alpha := (c >= 65 and c <= 90) or (c >= 97 and c <= 122) + var digit := c >= 48 and c <= 57 + if not (alpha or digit or c == 45 or c == 95): + return false + return true + + +static func _subtable_prefixes(headers: Array[String]) -> Array[String]: + var out: Array[String] = [] + for header in headers: + if header.length() > 2 and header.ends_with("]"): + out.append(header.substr(0, header.length() - 1) + ".") + return out + + +static func _matches_subtable_prefix(line: String, prefixes: Array[String]) -> bool: + var trimmed := line.strip_edges() + for prefix in prefixes: + if not trimmed.begins_with(prefix): + continue + var rest := trimmed.substr(prefix.length()) + var bracket := rest.find("]") + if bracket < 0: + continue + var remainder := rest.substr(bracket + 1).strip_edges() + if remainder.is_empty() or remainder.begins_with("#"): + return true + return false + + +static func _matches_any_header(line: String, headers: Array[String]) -> bool: + var trimmed := line.strip_edges() + for header in headers: + if not trimmed.begins_with(header): + continue + var remainder := trimmed.substr(header.length()).strip_edges() + if remainder.is_empty() or remainder.begins_with("#"): + return true + return false + + +static func _find_section(lines: Array[String], headers: Array[String]) -> Dictionary: + for i in range(lines.size()): + if _matches_any_header(lines[i], headers): + var end := lines.size() + for j in range(i + 1, lines.size()): + if _is_any_section_header(lines[j]): + end = j + break + return {"start": i, "end": end} + return {} + + +static func _is_any_section_header(line: String) -> bool: + var trimmed := line.strip_edges() + if not trimmed.begins_with("["): + return false + var bracket := trimmed.find("]") + if bracket < 0: + return false + var remainder := trimmed.substr(bracket + 1).strip_edges() + return remainder.is_empty() or remainder.begins_with("#") + + +static func _rewrite_legacy_descendant_headers( + lines: Array[String], client: McpClient +) -> Array[String]: + if client.toml_legacy_section_aliases.is_empty(): + return lines + var primary := _primary_header(client) + var primary_prefix := primary.substr(0, primary.length() - 1) + "." + var out: Array[String] = [] + for line in lines: + var rewritten := line + var trimmed := line.strip_edges() + var indent_length := line.find("[") + var indent := line.substr(0, indent_length) if indent_length >= 0 else "" + for alias in client.toml_legacy_section_aliases: + var legacy_prefix := "[%s." % String(alias) + if trimmed.begins_with(legacy_prefix): + rewritten = indent + primary_prefix + trimmed.substr(legacy_prefix.length()) + break + out.append(rewritten) + return out diff --git a/addons/godot_ai/clients/_toml_strategy.gd.uid b/addons/godot_ai/clients/_toml_strategy.gd.uid new file mode 100644 index 0000000..723cb79 --- /dev/null +++ b/addons/godot_ai/clients/_toml_strategy.gd.uid @@ -0,0 +1 @@ +uid://cwdvxgn0aurqv diff --git a/addons/godot_ai/clients/_yaml_strategy.gd b/addons/godot_ai/clients/_yaml_strategy.gd new file mode 100644 index 0000000..0efd515 --- /dev/null +++ b/addons/godot_ai/clients/_yaml_strategy.gd @@ -0,0 +1,549 @@ +@tool +class_name McpYamlStrategy +extends RefCounted + +## Minimal YAML upsert for Hermes Agent MCP config. +## +## Hermes reads MCP servers from ~/.hermes/config.yaml under the +## `mcp_servers` key (snake_case, YAML). HTTP entries are transport-inferred: +## just `url` (plus optional `headers`), no `type` field. We only parse the +## `mcp_servers` block and re-emit it; other top-level keys in the user's +## config.yaml are preserved verbatim by round-tripping the raw lines around +## that block. No general YAML parser — Godot has none in stdlib, and Hermes +## only needs this one shape. See issue #640. + +const INDENT := " " # YAML forbids tab indentation; match the 2-space style of ~/.hermes/config.yaml + + +static func configure( + client: McpClient, + server_name: String, + server_url: String, + launch: Dictionary = {}, +) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty(): + return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} + ## Fail closed before touching the file — same contract as JSON/TOML. + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": "error", "message": launch_error} + + var seed_path := str(resolution.get("seed_path", "")) + var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path + var read := _read(read_path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]} + + var text: String = read["data"] + var block := _extract_block(text) + var entries: Dictionary = block["entries"] + + # Preserve existing entry's user-mutable keys; force the transport keys. + var existing: Dictionary = entries.get(server_name, {}) + var new_entry := build_entry(client, server_url, existing, launch) + entries[server_name] = new_entry + + var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"]) + if not McpAtomicWrite.write(path, out): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": McpClient.configured_message(client, server_url)} + + +static func check_status( + client: McpClient, server_name: String, server_url: String, launch: Dictionary = {} +) -> McpClient.Status: + return check_status_details(client, server_name, server_url, launch)["status"] + + +## Same contract as the JSON/TOML strategies (#711): {status, error_msg}. +## An existing-but-unreadable config is ERROR with the diagnostic — not +## NOT_CONFIGURED — so the dock row can tell "no config" from "config the +## editor can't read" instead of offering a Configure that would fail. +static func check_status_details( + client: McpClient, server_name: String, server_url: String, launch: Dictionary = {} +) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": McpClient.Status.ERROR, "error_msg": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + var read := _read(path) + if not read["ok"]: + return { + "status": McpClient.Status.ERROR, + "error_msg": "Cannot read %s: %s" % [path, read["error"]], + } + var block := _extract_block(String(read["data"])) + var entries: Dictionary = block["entries"] + if not entries.has(server_name): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + var entry: Variant = entries[server_name] + if not (entry is Dictionary): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + ## An entry exists but no verified launcher does — mirror JSON/TOML: this + ## is an environment ERROR, not entry drift. + var launch_error := command_launch_error(client, launch) + if not launch_error.is_empty(): + return {"status": McpClient.Status.ERROR, "error_msg": launch_error} + if verify_entry(client, entry, server_url, launch): + return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} + return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} + + +static func remove(client: McpClient, server_name: String) -> Dictionary: + var resolution := client.resolved_config_path_details() + var path := str(resolution.get("path", "")) + var path_error := str(resolution.get("error", "")) + if not path_error.is_empty(): + return {"status": "error", "message": path_error} + if path.is_empty() or not FileAccess.file_exists(path): + return {"status": "ok", "message": "Not configured"} + var read := _read(path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]} + var text: String = read["data"] + var block := _extract_block(text) + var entries: Dictionary = block["entries"] + if not entries.has(server_name): + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + entries.erase(server_name) + var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"]) + if not McpAtomicWrite.write(path, out): + return {"status": "error", "message": "Cannot write to %s" % path} + return {"status": "ok", "message": "%s configuration removed" % client.display_name} + + +## Build the entry dict written under mcp_servers[server_name]. +## +## URL mode (`CommandShape.NONE`): Hermes HTTP entries are transport-inferred +## — { url: } plus whatever user-mutable keys (headers, enabled, tools, +## ...) the existing entry carries. Stdio-bridge keys are the one exception +## (see _STDIO_BRIDGE_KEYS). No `type` field. +## +## Command mode (`CommandShape.FLAT`, #838): flat `command` + `args` keys, +## transport inferred the same way — which is exactly why the descriptor's +## `command_legacy_keys` (url, headers) must be scrubbed: a Hermes entry with +## both a url and a command picks the wrong transport. User keys survive. +static func build_entry( + client: McpClient, + server_url: String, + existing: Variant = null, + launch: Dictionary = {}, +) -> Dictionary: + if client.command_shape == McpClient.CommandShape.FLAT: + var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} + command_entry["command"] = str(launch.get("command", "")) + command_entry["args"] = _array_copy(launch.get("args", [])) + if not client.command_transport_key.is_empty(): + command_entry[client.command_transport_key] = client.command_transport_value + for key in client.command_initial_fields: + if not command_entry.has(key): + command_entry[key] = client.command_initial_fields[key] + for key in client.command_legacy_keys: + command_entry.erase(String(key)) + return command_entry + if client.command_shape != McpClient.CommandShape.NONE: + ## Every production caller checks command_launch_error first. Keep this + ## builder defensive too so a future unsupported shape cannot silently + ## degrade into a flat YAML entry. + return {} + var entry: Dictionary = {} + if existing is Dictionary: + ## User-mutable keys (headers, enabled, tools, ...) survive a + ## reconfigure — the same preservation contract the JSON strategy's + ## entry_initial_fields split implements. Only the stdio-bridge keys + ## are scrubbed (see _STDIO_BRIDGE_KEYS), then the url is repointed. + entry = (existing as Dictionary).duplicate(true) + for stale_key in _STDIO_BRIDGE_KEYS: + entry.erase(stale_key) + entry[client.entry_url_field] = server_url + return entry + + +## Keys a prior stdio-bridge entry (e.g. `command: uvx mcp-proxy`) may carry. +## These must NOT survive a URL reconfigure: a Hermes entry with both a url +## and a command picks the wrong transport. +const _STDIO_BRIDGE_KEYS := ["command", "args", "env"] + + +## Empty string when this client's launch requirements are satisfied. +## Mirrors `McpJsonStrategy.command_launch_error`; YAML supports FLAT only. +static func command_launch_error(client: McpClient, launch: Dictionary) -> String: + if client.command_shape == McpClient.CommandShape.NONE: + return "" + if client.command_shape != McpClient.CommandShape.FLAT: + return "%s uses a command shape not supported by YAML yet" % client.display_name + if not bool(launch.get("ok", false)): + return str(launch.get("error", "No compatible attach launcher was found.")) + return "" + + +## Verify a stored entry matches. +## +## URL mode: Hermes entries have no transport type pin, so verification is: +## url matches. Extra keys (headers, enabled, tools) are user-mutable and +## intentionally NOT checked (mirrors json entry_initial_fields). +## +## Command mode: every launch-affecting value must match exactly and legacy +## URL-transport keys must be gone — their presence is migration drift. +static func verify_entry( + client: McpClient, + entry: Dictionary, + server_url: String, + launch: Dictionary = {}, +) -> bool: + if client.command_shape != McpClient.CommandShape.NONE: + if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)): + return false + for key in client.command_legacy_keys: + if entry.has(String(key)): + return false + if entry.get("command") != launch.get("command"): + return false + if not _arrays_equal(entry.get("args", null), launch.get("args", null)): + return false + if not client.command_transport_key.is_empty(): + if entry.get(client.command_transport_key, null) != client.command_transport_value: + return false + return true + return entry.get(client.entry_url_field, "") == server_url + + +static func _array_copy(value: Variant) -> Array: + if value is Array: + return (value as Array).duplicate(true) + if value is PackedStringArray: + return McpClient._array_from_packed(value) + return [] + + +static func _arrays_equal(left: Variant, right: Variant) -> bool: + if not (left is Array or left is PackedStringArray): + return false + if not (right is Array or right is PackedStringArray): + return false + var left_array := _array_copy(left) + var right_array := _array_copy(right) + if left_array.size() != right_array.size(): + return false + for i in range(left_array.size()): + if left_array[i] != right_array[i]: + return false + return true + + +# --- YAML block handling (scoped to mcp_servers) ------------------------- + +## Parse the file into three regions: +## prefix_lines — everything before `mcp_servers:` (may be empty) +## entries — the map of server_name -> {url, ...} under mcp_servers +## suffix_lines — everything after the mcp_servers block (may be empty) +## This lets us rewrite only the mcp_servers block and keep the rest of the +## user's config.yaml byte-for-byte intact. +static func _extract_block(text: String) -> Dictionary: + ## allow_empty must stay true: dropping empty splits would silently strip + ## the user's blank lines from the preserved prefix/suffix regions on + ## every rewrite. The parse loops below already skip blank lines. + var lines := text.split("\n") + var prefix: PackedStringArray = [] + var entries: Dictionary = {} + var suffix: PackedStringArray = [] + var header_idx := -1 + for i in range(lines.size()): + if lines[i].strip_edges().begins_with("mcp_servers:"): + header_idx = i + break + if header_idx < 0: + # No mcp_servers yet — whole file is prefix; block will be appended. + prefix = lines.duplicate() + return {"prefix_lines": prefix, "entries": entries, "suffix_lines": [], "header_idx": -1} + + for i in range(0, header_idx): + prefix.append(lines[i]) + + # Determine the indent of the first entry so we can tell sibling + # entries (same indent) apart from nested keys (deeper indent) and + # parent-level keys (less indent). All server entries under + # `mcp_servers:` share one indent level; breaking on 0-indent alone + # mis-nests 2-space-indented siblings under the first entry. + var entry_indent := -1 + var probe := header_idx + 1 + while probe < lines.size() and _is_blank_or_comment(lines[probe]): + probe += 1 + if probe < lines.size(): + entry_indent = _indent_of(lines[probe]) + + # Empty block guard: the first nonblank line after the header must sit + # DEEPER than the header itself to be an entry. At or above the header's + # indent it is a sibling/parent key — parsing it as an entry would + # swallow the user's next top-level key and re-emit it nested under + # mcp_servers, corrupting the file. + if probe < lines.size() and entry_indent <= _indent_of(lines[header_idx]): + for j in range(header_idx + 1, lines.size()): + suffix.append(lines[j]) + return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx} + + var i := header_idx + 1 + while i < lines.size(): + var raw := lines[i] + ## Comment-only lines inside the block are skipped like blanks — + ## treating one as an entry header would re-emit it as a bogus + ## `# comment:` server on rewrite. (Comments INSIDE the rewritten + ## block are consequently dropped; comments outside the block live + ## in prefix/suffix and survive verbatim.) + if _is_blank_or_comment(raw): + i += 1 + continue + # Stop at any line indented less than a sibling entry (parent key + # or a new top-level section), or at the header's own level. + if _indent_of(raw) < entry_indent: + break + var entry := _parse_entry(raw, lines, i, entry_indent) + if not entry["name"].is_empty(): + entries[entry["name"]] = entry["data"] + i = entry["next_idx"] + + for j in range(i, lines.size()): + suffix.append(lines[j]) + + return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx} + + +## Parse one ` name:` entry starting at `lines[start]`. Consumes all deeper- +## indented sublines (url, headers, etc.) and returns the next sibling index. +static func _parse_entry(raw: String, lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary: + var name := raw.strip_edges().trim_suffix(":").strip_edges() + var data: Dictionary = {} + var i := start + 1 + while i < lines.size(): + var l := lines[i] + ## Comments inside an entry (e.g. ` # auth for CI`) would parse + ## as a `# auth for CI` key — skip them like blanks. + if _is_blank_or_comment(l): + i += 1 + continue + # A line at or above the entry's indent is a sibling/parent key. + if _indent_of(l) <= entry_indent: + break + var stripped := l.strip_edges() + var colon := stripped.find(":") + if colon < 0: + i += 1 + continue + var key := stripped.substr(0, colon).strip_edges() + var val := stripped.substr(colon + 1).strip_edges() + if val.is_empty(): + # Nested block (e.g. headers:). Parse as raw sub-dict lines for + # preservation; we don't introspect deeper than url at the top. + var sub := _parse_subblock(lines, i + 1, entry_indent) + data[key] = sub["value"] + i = sub["next_idx"] + else: + data[key] = coerce_scalar(val) + i += 1 + return {"name": name, "data": data, "next_idx": i} + + +## Parse a nested block (e.g. headers:) as a preserved sub-dictionary of +## scalar key/values. Deeper nesting is flattened into scalar strings — fine +## for Hermes' known shape (headers are flat key: value). +static func _parse_subblock(lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary: + var sub: Dictionary = {} + var i := start + while i < lines.size(): + var l := lines[i] + if _is_blank_or_comment(l): + i += 1 + continue + # A line at or above the parent entry's indent ends the nested block. + if _indent_of(l) <= entry_indent: + break + var stripped := l.strip_edges() + var colon := stripped.find(":") + if colon < 0: + i += 1 + continue + var key := stripped.substr(0, colon).strip_edges() + var val := stripped.substr(colon + 1).strip_edges() + if val.is_empty(): + i += 1 + continue + sub[key] = coerce_scalar(val) + i += 1 + return {"value": sub, "next_idx": i} + + +## Reassemble the full file text from prefix + a freshly built mcp_servers +## block + suffix. If the block didn't exist before, it is appended. +static func _assemble(_text: String, prefix: PackedStringArray, entries: Dictionary, suffix: PackedStringArray) -> String: + var out: PackedStringArray = [] + for l in prefix: + out.append(l) + # Trim trailing blank lines from prefix so we don't stack double blanks. + while out.size() > 0 and out[out.size() - 1].strip_edges().is_empty(): + out.remove_at(out.size() - 1) + + if not _text.contains("mcp_servers:"): + # File existed but had no mcp_servers block — append it. + if out.size() > 0: + out.append("") + out.append("mcp_servers:") + for name in entries: + out.append_array(_emit_entry(name, entries[name])) + else: + out.append("mcp_servers:") + for name in entries: + out.append_array(_emit_entry(name, entries[name])) + + # Suffix: keep as-is. + for l in suffix: + out.append(l) + return "\n".join(out) + + +## Public rendering seam for the dock's manual-instruction text, so the +## pasted YAML matches what Configure would write byte-for-byte. +static func render_entry_lines(name: String, data: Dictionary) -> PackedStringArray: + return _emit_entry(name, data) + + +## Emit one ` name:` entry with its scalar keys (top level only; headers +## sub-dict is re-emitted as nested scalars; arrays — the command entry's +## `args` — are emitted in flow style on one line). +static func _emit_entry(name: String, data: Dictionary) -> PackedStringArray: + var lines: PackedStringArray = [] + lines.append(INDENT + "%s:" % name) + for key in data: + var val = data[key] + if val is Dictionary: + lines.append(INDENT + INDENT + "%s:" % key) + for sk in val: + lines.append(INDENT + INDENT + INDENT + "%s: %s" % [sk, emit_scalar(val[sk])]) + elif val is Array or val is PackedStringArray: + lines.append(INDENT + INDENT + "%s: %s" % [key, emit_flow_array(_array_copy(val))]) + else: + lines.append(INDENT + INDENT + "%s: %s" % [key, emit_scalar(val)]) + return lines + + +## Flow-style sequence with every item double-quoted. JSON string quoting is +## valid YAML double-quote style (shared escape set), so the same encoding +## both writes the file and — via JSON.parse_string in `coerce_scalar` — +## reads it back for verification. Shared with `McpDshStrategy` for the +## DeepSeek Harness patch-list `args` lines. +static func emit_flow_array(values: Array) -> String: + var parts: Array[String] = [] + for v in values: + parts.append(JSON.stringify(str(v))) + return "[%s]" % ", ".join(parts) + + +static func emit_scalar(v: Variant) -> String: + match typeof(v): + TYPE_BOOL: + return "true" if bool(v) else "false" + TYPE_INT: + return str(int(v)) + TYPE_FLOAT: + return str(float(v)) + _: + return emit_string_scalar(str(v)) + + +## Plain YAML scalars cannot safely carry ": ", " #", quotes, flow +## indicators, or leading indicator characters — a Windows launcher path with +## spaces would silently corrupt the entry. Quote exactly when needed so +## existing plain values (urls, bools-as-strings) keep their current +## byte-shape on rewrite. Backslashes are quoted too: a Windows path parses +## either way, but quoting makes the written shape unambiguous for any +## consumer. Shared with `McpDshStrategy` for DeepSeek Harness patch entries. +static func emit_string_scalar(s: String) -> String: + if s.is_empty(): + return "\"\"" + var needs_quote := s.begins_with(" ") or s.ends_with(" ") + if not needs_quote: + for needle in [": ", " #", "\"", "'", "\n", "\t", "\\", "{", "}", "[", "]", ","]: + if s.contains(needle): + needs_quote = true + break + if not needs_quote: + for prefix in ["#", "-", "?", "&", "*", "!", "|", ">", "%", "@", "`"]: + if s.begins_with(prefix): + needs_quote = true + break + return JSON.stringify(s) if needs_quote else s + + +## Blank and comment-only lines carry no structure — every scan loop skips +## them the same way so a `# comment` can never be mistaken for an entry +## header or a key/value line. +static func _is_blank_or_comment(line: String) -> bool: + var stripped := line.strip_edges() + return stripped.is_empty() or stripped.begins_with("#") + + +## Returns the leading-whitespace indent width of a line (spaces + tabs +## counted as 1 each). Used to distinguish sibling entries (same indent) +## from nested keys (deeper indent) and parent-level keys (less indent). +static func _indent_of(line: String) -> int: + var n := 0 + while n < line.length() and (line[n] == " " or line[n] == "\t"): + n += 1 + return n + + +## Minimal scalar coercion for parsed YAML values. Quotes are stripped; +## bare true/false/numbers are typed; double-quoted flow sequences (the +## command entry's `args`) parse back into an Array. Good enough for Hermes' +## url/headers/command/args. A hand-edited args in block style or with +## unquoted items doesn't JSON-parse — it stays a raw string, compares +## unequal, and surfaces as CONFIGURED_MISMATCH, which Reconfigure +## normalizes back to the flow form. Public: the dsh strategy parses its +## patch-list scalars with the same rules, so both YAML dialects share one +## coercion (#867 review). +static func coerce_scalar(s: String) -> Variant: + var t := s.strip_edges() + if t.begins_with("[") and t.ends_with("]"): + var parsed_array: Variant = JSON.parse_string(t) + if parsed_array is Array: + return parsed_array + return t + if t.begins_with("\"") and t.ends_with("\""): + var parsed_string: Variant = JSON.parse_string(t) + if parsed_string is String: + return parsed_string + return t.substr(1, t.length() - 2) + if t.begins_with("'") and t.ends_with("'"): + return t.substr(1, t.length() - 2) + if t == "true": + return true + if t == "false": + return false + if t.is_valid_int(): + return t.to_int() + if t.is_valid_float(): + return t.to_float() + return t + + +## Returns {"ok": true, "data": String} when the file is absent or readable, +## and {"ok": false, "error": String} when unreadable. Callers must NOT fall +## back to an empty string on the error path — doing so blows away the user's +## other config.yaml entries on the next write. +static func _read(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {"ok": true, "data": ""} + var f := FileAccess.open(path, FileAccess.READ) + if f == null: + var err := FileAccess.get_open_error() + return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)} + var t := f.get_as_text() + f.close() + return {"ok": true, "data": t} diff --git a/addons/godot_ai/clients/_yaml_strategy.gd.uid b/addons/godot_ai/clients/_yaml_strategy.gd.uid new file mode 100644 index 0000000..d8adce8 --- /dev/null +++ b/addons/godot_ai/clients/_yaml_strategy.gd.uid @@ -0,0 +1 @@ +uid://cfnw4oe71ra1l diff --git a/addons/godot_ai/clients/antigravity.gd b/addons/godot_ai/clients/antigravity.gd new file mode 100644 index 0000000..a707735 --- /dev/null +++ b/addons/godot_ai/clients/antigravity.gd @@ -0,0 +1,39 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "antigravity" + display_name = "Antigravity" + config_type = "json" + ## Antigravity moved its shared MCP config from `~/.gemini/antigravity/` + ## to `~/.gemini/config/` (IDE + CLI now read the same file there); the + ## old path is left in `detect_paths` below so an existing install is + ## still recognized, but new/updated entries write to the current path. + path_template = { + "unix": "~/.gemini/config/mcp_config.json", + "windows": "$USERPROFILE/.gemini/config/mcp_config.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + entry_url_field = "serverUrl" + ## `disabled` is user-state (they may have flipped the entry off in the + ## UI); seeded on first Configure but preserved across reconfigure. + entry_initial_fields = {"disabled": false} + ## Attach migration (#838). Antigravity stdio entries are flat + ## command/args/env with no type discriminator — transport is inferred + ## from `command` vs `serverUrl` presence (antigravity.google/docs/mcp), + ## so the legacy `serverUrl` must not survive next to a command. + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["serverUrl"]) + command_initial_fields = {"disabled": false} + command_user_fields = PackedStringArray(["disabled", "disabledTools", "authProviderType", "env"]) + command_supports_url_fallback = true + ## Antigravity's spawner hangs stdio tool calls when the entry launches a + ## GUI-subsystem pythonw.exe (#863), and it hides child console windows + ## itself, so the visible-terminal problem the bootstrap solves (#827) + ## never applies. Write the plain console launcher on Windows. + needs_consoleless_launcher = false + detect_paths = PackedStringArray(path_template.values() + [ + "~/.gemini/antigravity/mcp_config.json", + "$USERPROFILE/.gemini/antigravity/mcp_config.json", + ]) diff --git a/addons/godot_ai/clients/antigravity.gd.uid b/addons/godot_ai/clients/antigravity.gd.uid new file mode 100644 index 0000000..0721a2f --- /dev/null +++ b/addons/godot_ai/clients/antigravity.gd.uid @@ -0,0 +1 @@ +uid://b4l1g0apa2hch diff --git a/addons/godot_ai/clients/cherry_studio.gd b/addons/godot_ai/clients/cherry_studio.gd new file mode 100644 index 0000000..873b9fe --- /dev/null +++ b/addons/godot_ai/clients/cherry_studio.gd @@ -0,0 +1,18 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "cherry_studio" + display_name = "Cherry Studio" + config_type = "json" + path_template = { + "darwin": "~/Library/Application Support/CherryStudio/mcp_servers.json", + "windows": "$APPDATA/CherryStudio/mcp_servers.json", + "linux": "$XDG_CONFIG_HOME/CherryStudio/mcp_servers.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + entry_extra_fields = {"type": "streamableHttp"} + ## `isActive` is user-state (they may have toggled the server off in the UI). + ## Seed on first Configure but preserve across reconfigure. + entry_initial_fields = {"isActive": true} diff --git a/addons/godot_ai/clients/cherry_studio.gd.uid b/addons/godot_ai/clients/cherry_studio.gd.uid new file mode 100644 index 0000000..7ada8cd --- /dev/null +++ b/addons/godot_ai/clients/cherry_studio.gd.uid @@ -0,0 +1 @@ +uid://dwbuykxvbv5f7 diff --git a/addons/godot_ai/clients/claude_code.gd b/addons/godot_ai/clients/claude_code.gd new file mode 100644 index 0000000..d0378b6 --- /dev/null +++ b/addons/godot_ai/clients/claude_code.gd @@ -0,0 +1,84 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "claude_code" + display_name = "Claude Code" + config_type = "cli" + cli_names = PackedStringArray(["claude", "claude.exe"] if OS.get_name() == "Windows" else ["claude"]) + ## Stdio registration through the client-owned `godot-ai attach` bridge + ## (#838). `--` stops claude's own flag parsing so the attach argv passes + ## through verbatim; stdio is the CLI's default transport. Scope comes from + ## the `godot_ai/mcp_client_scope` EditorSetting via the `{scope}` token; it + ## defaults to `user` — the same ~/.claude.json the pre-attach HTTP entry + ## lived in — and can be set to `project` to write /.mcp.json so the + ## server is not loaded in every unrelated workspace. + cli_register_template = PackedStringArray( + ["mcp", "add", "--scope", "{scope}", "{name}", "--", "{command}", "{args...}"] + ) + ## Explicit scope: an unscoped `mcp remove` deletes from whichever scope + ## matches first, so the flag keeps each removal aimed at one known place. + ## Note what that does and does not protect (#877): the dock's **Remove** + ## button runs this once, at the selected scope only. **Configure** runs it + ## once per scope in `CLIENT_SCOPES` before registering, so a Configure at + ## any setting does delete a `godot-ai` entry from /.mcp.json if one is + ## there — see the cwd caveat below for which directory that actually is. + cli_unregister_template = PackedStringArray(["mcp", "remove", "--scope", "{scope}", "{name}"]) + ## Scope caveat: `--scope project` resolves `.mcp.json` against the + ## *spawned CLI's* working directory, which is whatever the editor process + ## inherited at launch — not necessarily `res://`. Godot has no API to set + ## a child's cwd (`OS.execute_with_pipe` takes none), so this is an + ## accepted limitation rather than something the descriptor can pin. It is + ## self-consistent: `cli_status_args` runs from the same cwd, so the + ## read-back finds whatever the register wrote (see + ## `_scope_diverges_from_json_fallback` in client_configurator.gd). + cli_status_args = PackedStringArray(["mcp", "list"]) + ## `mcp list` prints the resolved entry for the cwd without saying which + ## scope won, so it cannot tell "registered at project scope" from "an old + ## user-scope entry is shadowing an empty project scope". `mcp get` prints + ## both the command and a `Scope: …` line, in the same single subprocess, + ## so scope-token clients probe with it instead (#872). Verified against + ## claude 2.1.241, which prints one of: + ## Scope: User config (available in all your projects) + ## Scope: Project config (shared via .mcp.json) + ## Scope: Local config (private to you in this project) + ## and exits 1 with "No MCP server named …" when the entry is absent. + cli_scope_status_template = PackedStringArray(["mcp", "get", "{name}"]) + ## #463: JSON fallback for when the `claude` binary isn't on PATH — e.g. + ## Claude Code installed only as a VS Code / Cursor extension. The CLI is + ## still preferred for Configure whenever it resolves; this is what gets + ## written otherwise. `claude mcp add --scope user -- ` + ## produces exactly this shape under `mcpServers` in ~/.claude.json + ## (verified live against claude CLI in an isolated CLAUDE_CONFIG_DIR): + ## "godot-ai": { "type": "stdio", "command": "", "args": [...], "env": {} } + ## The fallback writer omits the empty `env`; the verifier accepts both. + ## Status reads this file only while the selected scope is still `user` — + ## it is the CLI's own store for that scope, and file reads give exact + ## launch-drift detection that `mcp list` stdout scanning cannot. At + ## `project`/`local` the entry is somewhere `path_template` cannot see, so + ## `_scope_diverges_from_json_fallback` (client_configurator.gd) routes + ## status to `cli_scope_status_template` instead and this file is not read. + path_template = {"unix": "~/.claude.json", "windows": "~/.claude.json"} + server_key_path = PackedStringArray(["mcpServers"]) + ## URL-mode shape, used only for the manual-instruction fallback text — + ## `claude mcp add --scope --transport http` writes {type: http, url}, + ## where is whatever `godot_ai/mcp_client_scope` resolves to. + entry_extra_fields = {"type": "http"} + command_shape = McpClient.CommandShape.FLAT + command_transport_key = "type" + command_transport_value = "stdio" + ## Legacy HTTP entries carried a `url`; Claude Code rejects an entry mixing + ## url with command fields, and the stale `type: "http"` is repinned to + ## "stdio" by the transport key above. + command_legacy_keys = PackedStringArray(["url"]) + command_user_fields = PackedStringArray(["env"]) + command_supports_url_fallback = true + ## Documented: $CLAUDE_CONFIG_DIR relocates Claude Code's config home, + ## including .claude.json ($CLAUDE_CONFIG_DIR/.claude.json). The preferred + ## CLI path needs no help — the spawned `claude` binary inherits the + ## editor's environment and resolves the dir itself — but the JSON + ## fallback above would otherwise write ~/.claude.json that a relocated + ## install never reads (#617). + config_home_env = "CLAUDE_CONFIG_DIR" + config_home_env_subpath = ".claude.json" diff --git a/addons/godot_ai/clients/claude_code.gd.uid b/addons/godot_ai/clients/claude_code.gd.uid new file mode 100644 index 0000000..3d3335f --- /dev/null +++ b/addons/godot_ai/clients/claude_code.gd.uid @@ -0,0 +1 @@ +uid://cp1u1hdpa6f8d diff --git a/addons/godot_ai/clients/claude_desktop.gd b/addons/godot_ai/clients/claude_desktop.gd new file mode 100644 index 0000000..c1aa9bb --- /dev/null +++ b/addons/godot_ai/clients/claude_desktop.gd @@ -0,0 +1,38 @@ +@tool +extends McpClient + +## Claude Desktop's mcpServers entries launch a local stdio process. The +## client-owned `godot-ai attach` bridge keeps that stdio session stable while +## adopting or starting the shared HTTP backend as Godot editors come and go. + + +func _init() -> void: + id = "claude_desktop" + display_name = "Claude Desktop" + config_type = "json" + path_template = { + "darwin": "~/Library/Application Support/Claude/claude_desktop_config.json", + "windows": "$APPDATA/Claude/claude_desktop_config.json", + "linux": "$XDG_CONFIG_HOME/Claude/claude_desktop_config.json", + } + ## Store-installed Claude runs inside MSIX AppData virtualization. Godot is + ## outside that container, so `%APPDATA%` names a different physical file + ## once Claude has created its private copy. A unique Store package root is + ## authoritative even before the config leaf exists: create the private file + ## directly so a later copy-on-write cannot hide an entry written to roaming. + ## With no Store package, use the conventional roaming path. The wildcard + ## avoids coupling to the publisher-hash suffix. + config_path_candidates = { + "windows": [ + "$LOCALAPPDATA/Packages/Claude_*/LocalCache/Roaming/Claude/claude_desktop_config.json", + "$APPDATA/Claude/claude_desktop_config.json", + ], + } + detect_paths = PackedStringArray([ + "$LOCALAPPDATA/Packages/Claude_*", + ]) + server_key_path = PackedStringArray(["mcpServers"]) + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["url"]) + command_env_legacy_keys = PackedStringArray(["UV_LINK_MODE"]) + command_user_fields = PackedStringArray(["env", "disabled"]) diff --git a/addons/godot_ai/clients/claude_desktop.gd.uid b/addons/godot_ai/clients/claude_desktop.gd.uid new file mode 100644 index 0000000..9759a1a --- /dev/null +++ b/addons/godot_ai/clients/claude_desktop.gd.uid @@ -0,0 +1 @@ +uid://bilntn5n8oqe3 diff --git a/addons/godot_ai/clients/cline.gd b/addons/godot_ai/clients/cline.gd new file mode 100644 index 0000000..2a1f025 --- /dev/null +++ b/addons/godot_ai/clients/cline.gd @@ -0,0 +1,43 @@ +@tool +extends McpClient + +## Cline is a VS Code extension. Its MCP settings live in VS Code's +## globalStorage under the extension id `saoudrizwan.claude-dev`. + + +func _init() -> void: + id = "cline" + display_name = "Cline" + config_type = "json" + path_template = { + "darwin": "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", + "windows": "$APPDATA/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", + "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + ## Cline (like Roo) defaults a typeless entry to SSE transport, which + ## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin + ## the type explicitly. Cline's schema uses "streamableHttp" (camelCase, + ## see src/services/mcp/schemas.ts in the cline repo) — distinct from + ## Roo's "streamable-http" string. Parallel to the Roo fix in #190. + entry_extra_fields = {"type": "streamableHttp"} + ## `disabled` and `autoApprove` are user-state (they may have flipped the + ## entry off, or auto-approved specific tools). Seed on first Configure + ## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`. + entry_initial_fields = {"disabled": false, "autoApprove": []} + ## Attach migration (#838). Cline stdio entries are flat command/args/env; + ## its schema accepts `type: "stdio"` and normalizes typeless command + ## entries to it (apps/vscode/src/services/mcp/schemas.ts), so pin the + ## type — that also repins the legacy "streamableHttp" value instead of + ## letting it survive the deep-copy and misroute the transport. + command_shape = McpClient.CommandShape.FLAT + command_transport_key = "type" + command_transport_value = "stdio" + command_legacy_keys = PackedStringArray(["url", "headers"]) + command_initial_fields = {"disabled": false, "autoApprove": []} + command_user_fields = PackedStringArray([ + "disabled", "autoApprove", "timeout", "oauth", "metadata", + "remoteConfigured", "env", "cwd", + ]) + command_timeout_fields = PackedStringArray(["timeout"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/cline.gd.uid b/addons/godot_ai/clients/cline.gd.uid new file mode 100644 index 0000000..95e20f1 --- /dev/null +++ b/addons/godot_ai/clients/cline.gd.uid @@ -0,0 +1 @@ +uid://d36nywn2nkgts diff --git a/addons/godot_ai/clients/codex.gd b/addons/godot_ai/clients/codex.gd new file mode 100644 index 0000000..b8231bc --- /dev/null +++ b/addons/godot_ai/clients/codex.gd @@ -0,0 +1,45 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "codex" + display_name = "Codex" + config_type = "toml" + path_template = {"unix": "~/.codex/config.toml", "windows": "$USERPROFILE/.codex/config.toml"} + ## Documented: when $CODEX_HOME is set, Codex reads config.toml directly + ## from it instead of ~/.codex (#617). + config_home_env = "CODEX_HOME" + config_home_env_subpath = "config.toml" + toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"]) + # Older Codex builds used the unquoted form with underscore-substituted ids. + toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"]) + command_shape = McpClient.CommandShape.COMMAND_ARRAY + command_supports_url_fallback = true + command_legacy_keys = PackedStringArray(["url"]) + ## Initial-only: users may disable the entry or tune either timeout and + ## Configure preserves that choice. Codex currently defaults to 10s for + ## startup and 60s per tool; test_run legitimately has a 300s server + ## budget, so the generated config leaves transport margin at the client. + command_initial_fields = { + "enabled": true, + "startup_timeout_sec": 60, + "tool_timeout_sec": 360, + } + command_timeout_fields = PackedStringArray([ + "startup_timeout_sec", + "tool_timeout_sec", + ]) + command_user_fields = PackedStringArray([ + "enabled", + "required", + "startup_timeout_sec", + "tool_timeout_sec", + "enabled_tools", + "disabled_tools", + "default_tools_approval_mode", + "env", + "env_vars", + "cwd", + ]) + detect_paths = PackedStringArray(path_template.values()) diff --git a/addons/godot_ai/clients/codex.gd.uid b/addons/godot_ai/clients/codex.gd.uid new file mode 100644 index 0000000..1e1c3ae --- /dev/null +++ b/addons/godot_ai/clients/codex.gd.uid @@ -0,0 +1 @@ +uid://hdlwcfdr8mdk diff --git a/addons/godot_ai/clients/cursor.gd b/addons/godot_ai/clients/cursor.gd new file mode 100644 index 0000000..e4fe98d --- /dev/null +++ b/addons/godot_ai/clients/cursor.gd @@ -0,0 +1,21 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "cursor" + display_name = "Cursor" + config_type = "json" + path_template = {"unix": "~/.cursor/mcp.json", "windows": "$USERPROFILE/.cursor/mcp.json"} + server_key_path = PackedStringArray(["mcpServers"]) + ## Attach migration (#838). Cursor's stdio entries are flat command/args/env + ## (cursor.com/docs/context/mcp). The docs' reference table documents + ## `type: "stdio"`; pinning it also repins any hand-added `type: "http"` + ## left on the legacy URL entry, which would otherwise survive the + ## deep-copy migration and misroute the transport. + command_shape = McpClient.CommandShape.FLAT + command_transport_key = "type" + command_transport_value = "stdio" + command_legacy_keys = PackedStringArray(["url"]) + command_user_fields = PackedStringArray(["env", "envFile"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/cursor.gd.uid b/addons/godot_ai/clients/cursor.gd.uid new file mode 100644 index 0000000..e0c7ddf --- /dev/null +++ b/addons/godot_ai/clients/cursor.gd.uid @@ -0,0 +1 @@ +uid://bvpbssfanukef diff --git a/addons/godot_ai/clients/deepseek_harness.gd b/addons/godot_ai/clients/deepseek_harness.gd new file mode 100644 index 0000000..b9db2a5 --- /dev/null +++ b/addons/godot_ai/clients/deepseek_harness.gd @@ -0,0 +1,45 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "deepseek_harness" + display_name = "DeepSeek Harness" + ## DeepSeek Harness (dsh) has no `mcp` CLI verb — MCP servers register as + ## `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch layer + ## `$DSH_HOME/cordis.patch.yml`, which dsh applies over every profile's + ## own patch layer (the web GUI included). config_type "dsh" routes to + ## `McpDshStrategy`. Verified live against dsh 0.1.0-rc.6: new entries + ## must be `insert` rows; plain rows only override existing bundle ids. + config_type = "dsh" + ## Home patch layer, not a per-profile file: one entry covers the web + ## GUI, headless, tui, and any custom profile. + path_template = { + "unix": "~/.dsh/cordis.patch.yml", + "windows": "~/.dsh/cordis.patch.yml", + } + ## Documented: `$DSH_HOME` relocates the whole harness home, and the + ## loader reads `$DSH_HOME/cordis.patch.yml` (homePatchPath) — same + ## false-success-write class as CODEX_HOME (#617), so the override must + ## be declared. + config_home_env = "DSH_HOME" + config_home_env_subpath = "cordis.patch.yml" + ## Installed detection: the harness home directory itself (dsh GUI and + ## CLI both live there), or the config file once one exists. + detect_paths = PackedStringArray(["~/.dsh"]) + ## Attach migration (#838). The loader entry nests the launch under + ## `config`; the mcp-client plugin requires `transport` next to + ## command/args, and a `url` must never coexist with command fields. + command_shape = McpClient.CommandShape.FLAT + command_transport_key = "transport" + command_transport_value = "stdio" + command_legacy_keys = PackedStringArray(["url"]) + ## Fields the mcp-client plugin documents as user-owned; preserved on + ## reconfigure and never drift-checked. + command_user_fields = PackedStringArray([ + "env", "cwd", "toolCallTimeoutMs", "failOnStartupError", "reconnect", + ]) + command_timeout_fields = PackedStringArray(["toolCallTimeoutMs"]) + ## The bridge accepts `transport: streamable-http` with a `url`, so the + ## manual instructions may offer the HTTP alternative. + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/deepseek_harness.gd.uid b/addons/godot_ai/clients/deepseek_harness.gd.uid new file mode 100644 index 0000000..8cc2ed3 --- /dev/null +++ b/addons/godot_ai/clients/deepseek_harness.gd.uid @@ -0,0 +1 @@ +uid://cw3pghlevtql8 diff --git a/addons/godot_ai/clients/gemini_cli.gd b/addons/godot_ai/clients/gemini_cli.gd new file mode 100644 index 0000000..fda58a3 --- /dev/null +++ b/addons/godot_ai/clients/gemini_cli.gd @@ -0,0 +1,25 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "gemini_cli" + display_name = "Gemini CLI" + config_type = "json" + path_template = { + "unix": "~/.gemini/settings.json", + "windows": "$USERPROFILE/.gemini/settings.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + entry_url_field = "httpUrl" + ## Attach migration (#838). Gemini CLI stdio entries are flat + ## command/args/env(+cwd); the config is one-of `command` | `url` (SSE) | + ## `httpUrl` (docs/tools/mcp-server.md), so BOTH URL keys are legacy next + ## to a command. `trust` bypasses tool confirmations — never seed it. + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["httpUrl", "url"]) + command_user_fields = PackedStringArray([ + "timeout", "trust", "includeTools", "excludeTools", "env", "cwd", + ]) + command_timeout_fields = PackedStringArray(["timeout"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/gemini_cli.gd.uid b/addons/godot_ai/clients/gemini_cli.gd.uid new file mode 100644 index 0000000..2d4e85a --- /dev/null +++ b/addons/godot_ai/clients/gemini_cli.gd.uid @@ -0,0 +1 @@ +uid://b8288pxninajy diff --git a/addons/godot_ai/clients/grok.gd b/addons/godot_ai/clients/grok.gd new file mode 100644 index 0000000..7b6710e --- /dev/null +++ b/addons/godot_ai/clients/grok.gd @@ -0,0 +1,35 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "grok" + display_name = "Grok Build" + config_type = "toml" + # Grok Build reads MCP servers from ~/.grok/config.toml + # (https://x.ai / Grok user guide: MCP servers section). + path_template = { + "unix": "~/.grok/config.toml", + "windows": "$USERPROFILE/.grok/config.toml", + } + toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"]) + # Some docs / older notes used an underscore form. + toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"]) + ## Attach migration (#838). Grok's stdio sections are flat command/args/env + ## with no type discriminator (docs.x.ai/build/features/mcp-servers); + ## url/headers are the HTTP form and must not survive next to a command. + ## Docs default startup_timeout_sec to 30 — a cold `uvx` install of the + ## pinned package can exceed that, so new entries seed 60 (preserved once + ## the user tunes it). tool_timeout_sec's documented 6000s default already + ## clears test_run's 300s budget, so it is left alone. The old body + ## template's `enabled = true` line was never documented for Grok — it is + ## no longer seeded, and an existing value survives as a user key. + command_shape = McpClient.CommandShape.COMMAND_ARRAY + command_legacy_keys = PackedStringArray(["url", "headers"]) + command_initial_fields = {"startup_timeout_sec": 60} + command_user_fields = PackedStringArray([ + "env", "enabled", "startup_timeout_sec", "tool_timeout_sec", + ]) + command_timeout_fields = PackedStringArray(["startup_timeout_sec", "tool_timeout_sec"]) + command_supports_url_fallback = true + detect_paths = PackedStringArray(path_template.values()) diff --git a/addons/godot_ai/clients/grok.gd.uid b/addons/godot_ai/clients/grok.gd.uid new file mode 100644 index 0000000..f2d12c2 --- /dev/null +++ b/addons/godot_ai/clients/grok.gd.uid @@ -0,0 +1 @@ +uid://ckchsj5s3q1b0 diff --git a/addons/godot_ai/clients/hermes.gd b/addons/godot_ai/clients/hermes.gd new file mode 100644 index 0000000..64a66bb --- /dev/null +++ b/addons/godot_ai/clients/hermes.gd @@ -0,0 +1,46 @@ +@tool +extends McpClient + +func _init() -> void: + id = "hermes" + display_name = "Hermes Agent" + config_type = "yaml" + + # Hermes reads MCP config from ~/.hermes/config.yaml (YAML), NOT mcp.json. + # Verified against the official docs: + # https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp + # Windows: Hermes stores config under $LOCALAPPDATA/hermes (NOT $APPDATA, + # which is Roaming) — confirmed by where the running Hermes process reads. + # NOTE: _path_template.expand() only substitutes $VAR tokens, not %VAR%. + path_template = { + "unix": "~/.hermes/config.yaml", + "windows": "$LOCALAPPDATA/hermes/config.yaml" + } + + # Hermes uses the snake_case `mcp_servers` key (not `mcpServers`). + # PackedStringArray explicitly, matching every other descriptor — an + # untyped Array literal relies on implicit conversion that newer Godot + # builds enforce more strictly (the #722 CI lesson for Array[String]). + server_key_path = PackedStringArray(["mcp_servers"]) + + # HTTP entries use `url` (+ optional `headers`); transport is inferred — + # there is no `type` field in Hermes MCP config. + entry_url_field = "url" + + # No transport pin: Hermes infers streamable-http from the URL. + entry_extra_fields = {} + entry_initial_fields = {} + + ## Attach migration (#838). Hermes stdio entries are flat command/args/env + ## (hermes-agent.nousresearch.com/docs/user-guide/features/mcp), transport + ## inferred exactly like the URL form — which is why `url` and the + ## HTTP-only `headers` must not survive next to a command: an entry with + ## both picks the wrong transport. `enabled`/`tools`/`env` stay user-owned. + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["url", "headers"]) + command_user_fields = PackedStringArray(["enabled", "tools", "env"]) + command_supports_url_fallback = true + + # Hermes is "installed" wherever the config.yaml lives; presence of the + # file is sufficient for the dock's installed badge. + detect_paths = PackedStringArray() diff --git a/addons/godot_ai/clients/hermes.gd.uid b/addons/godot_ai/clients/hermes.gd.uid new file mode 100644 index 0000000..14cabd8 --- /dev/null +++ b/addons/godot_ai/clients/hermes.gd.uid @@ -0,0 +1 @@ +uid://ewmadhrvs5d7 diff --git a/addons/godot_ai/clients/kilo_code.gd b/addons/godot_ai/clients/kilo_code.gd new file mode 100644 index 0000000..4f7b3f7 --- /dev/null +++ b/addons/godot_ai/clients/kilo_code.gd @@ -0,0 +1,38 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "kilo_code" + display_name = "Kilo Code" + config_type = "json" + path_template = { + "darwin": "~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json", + "windows": "$APPDATA/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json", + "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + ## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which + ## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin + ## the type explicitly. Parallel to the Roo fix in #190. + entry_extra_fields = {"type": "streamable-http"} + ## `disabled` and `alwaysAllow` are user-state (they may have flipped the + ## entry off, or auto-approved specific tools). Seed on first Configure + ## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`. + entry_initial_fields = {"disabled": false, "alwaysAllow": []} + ## Attach migration (#838). UNLIKE its Roo siblings the stdio entry must be + ## TYPELESS: Kilo's v7 platform treats this file as a migration source and + ## routes on `type` — any http type value sends the entry down the remote + ## branch where it is dropped for lack of a url, and only a bare + ## command/args/env entry is verified to work in BOTH the legacy extension + ## and the v7 migrator (packages/opencode/src/kilocode/mcp-migrator.ts). + ## `type` therefore joins the legacy keys instead of being repinned. + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["url", "type", "headers"]) + command_initial_fields = {"disabled": false, "alwaysAllow": []} + command_user_fields = PackedStringArray([ + "disabled", "alwaysAllow", "timeout", "cwd", "watchPaths", + "disabledTools", "env", + ]) + command_timeout_fields = PackedStringArray(["timeout"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/kilo_code.gd.uid b/addons/godot_ai/clients/kilo_code.gd.uid new file mode 100644 index 0000000..3ee5152 --- /dev/null +++ b/addons/godot_ai/clients/kilo_code.gd.uid @@ -0,0 +1 @@ +uid://dc1x77i1cmb6w diff --git a/addons/godot_ai/clients/kimi_code.gd b/addons/godot_ai/clients/kimi_code.gd new file mode 100644 index 0000000..a80fef8 --- /dev/null +++ b/addons/godot_ai/clients/kimi_code.gd @@ -0,0 +1,34 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "kimi_code" + display_name = "Kimi Code" + ## Kimi Code has no `mcp` CLI subcommand (verified against v0.28.1 — + ## `kimi mcp` falls through to the root --help, and the docs at + ## moonshotai.github.io/kimi-code/en/customization/mcp confirm servers are + ## managed via ~/.kimi-code/mcp.json, not a CLI verb). JSON is therefore + ## the only working config method, not a fallback. + config_type = "json" + path_template = {"unix": "~/.kimi-code/mcp.json", "windows": "~/.kimi-code/mcp.json"} + server_key_path = PackedStringArray(["mcpServers"]) + entry_extra_fields = {"transport": "http"} + ## Documented: `$KIMI_CODE_HOME/mcp.json` relocates the config + ## (moonshotai.github.io/kimi-code/en/customization/mcp) — same + ## false-success-write class as CODEX_HOME (#617). + config_home_env = "KIMI_CODE_HOME" + config_home_env_subpath = "mcp.json" + ## Attach migration (#838). Kimi Code stdio entries are flat + ## command/args/env(+cwd): "Entries with a `command` field are stdio + ## servers". `transport` is only defined for SSE-with-url, so the legacy + ## `transport: "http"` must be removed alongside `url`. Timeout fields are + ## camelCase, unlike Codex's snake_case. + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["url", "transport"]) + command_user_fields = PackedStringArray([ + "enabled", "startupTimeoutMs", "toolTimeoutMs", "enabledTools", + "disabledTools", "env", "cwd", + ]) + command_timeout_fields = PackedStringArray(["startupTimeoutMs", "toolTimeoutMs"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/kimi_code.gd.uid b/addons/godot_ai/clients/kimi_code.gd.uid new file mode 100644 index 0000000..5a05a0e --- /dev/null +++ b/addons/godot_ai/clients/kimi_code.gd.uid @@ -0,0 +1 @@ +uid://d2whd6a5fofhg diff --git a/addons/godot_ai/clients/kiro.gd b/addons/godot_ai/clients/kiro.gd new file mode 100644 index 0000000..c9d4247 --- /dev/null +++ b/addons/godot_ai/clients/kiro.gd @@ -0,0 +1,23 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "kiro" + display_name = "Kiro" + config_type = "json" + path_template = { + "unix": "~/.kiro/settings/mcp.json", + "windows": "$USERPROFILE/.kiro/settings/mcp.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + ## `disabled` is user-state — preserved across reconfigure. + entry_initial_fields = {"disabled": false} + ## Attach migration (#838). Kiro stdio entries are flat command/args/env + ## with no type discriminator (kiro.dev/docs/mcp/configuration). + ## `autoApprove` is user-state, same contract as the URL entry's fields. + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["url"]) + command_initial_fields = {"disabled": false} + command_user_fields = PackedStringArray(["disabled", "autoApprove", "disabledTools", "env"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/kiro.gd.uid b/addons/godot_ai/clients/kiro.gd.uid new file mode 100644 index 0000000..dde3e87 --- /dev/null +++ b/addons/godot_ai/clients/kiro.gd.uid @@ -0,0 +1 @@ +uid://dqdmd2jw5qen7 diff --git a/addons/godot_ai/clients/opencode.gd b/addons/godot_ai/clients/opencode.gd new file mode 100644 index 0000000..cee555b --- /dev/null +++ b/addons/godot_ai/clients/opencode.gd @@ -0,0 +1,39 @@ +@tool +extends McpClient + +## OpenCode stores MCP servers under `mcp.` (not the typical mcpServers +## map) and uses `type: "remote"` for HTTP servers. + + +func _init() -> void: + id = "opencode" + display_name = "OpenCode" + config_type = "json" + ## `$HOME` on Windows is deliberate: OpenCode reads ~/.config/... on ALL + ## platforms (verified via `opencode debug paths`), and + ## McpPathTemplate._home() falls back to USERPROFILE when HOME is unset — + ## pinned by test_opencode_client_uses_home_config_on_windows. The documented + ## `OPENCODE_CONFIG` override names an exact file and must win over this + ## default for configure, status, remove, and manual instructions. + path_template = { + "unix": "~/.config/opencode/opencode.json", + "windows": "$HOME/.config/opencode/opencode.json", + } + config_file_env = "OPENCODE_CONFIG" + server_key_path = PackedStringArray(["mcp"]) + entry_extra_fields = {"type": "remote"} + ## `enabled` is user-state (they may have toggled the server off). + entry_initial_fields = {"enabled": true} + ## Attach migration (#838). OpenCode local entries carry the launch as ONE + ## argv array — `"command": ["uvx", …]` with no separate args key — plus a + ## schema-REQUIRED `type: "local"` (McpLocalConfig in opencode.ai/config.json; + ## env lives under `environment`, not `env`). The pin rewrites the legacy + ## `type: "remote"` in place; url/headers are remote-only and must go. + command_shape = McpClient.CommandShape.COMMAND_ARRAY + command_transport_key = "type" + command_transport_value = "local" + command_legacy_keys = PackedStringArray(["url", "headers"]) + command_initial_fields = {"enabled": true} + command_user_fields = PackedStringArray(["enabled", "timeout", "environment", "cwd"]) + command_timeout_fields = PackedStringArray(["timeout"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/opencode.gd.uid b/addons/godot_ai/clients/opencode.gd.uid new file mode 100644 index 0000000..dc2ad00 --- /dev/null +++ b/addons/godot_ai/clients/opencode.gd.uid @@ -0,0 +1 @@ +uid://s8n0vfirf2pj diff --git a/addons/godot_ai/clients/pi.gd b/addons/godot_ai/clients/pi.gd new file mode 100644 index 0000000..849fbb4 --- /dev/null +++ b/addons/godot_ai/clients/pi.gd @@ -0,0 +1,63 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "pi" + display_name = "Pi Agent" + config_type = "json" + # pi-codemode-mcp reads MCP server definitions from ~/.pi/agent/mcp.json + # (first merge tier; ~/.pi/agent/.mcp.json, .pi/mcp.json, and + # .mcp.json merge after, project-scope last). Documented at + # github.com/mitsuhiko/pi-codemode-mcp README "Configuration files" + # section. Windows path mirrors antigravity's $USERPROFILE choice so + # the descriptor round-trips on every supported platform. + path_template = { + "unix": "~/.pi/agent/mcp.json", + "windows": "$USERPROFILE/.pi/agent/mcp.json", + } + # Pi merges these files in order; later definitions of the same server win. + # Keep this separate from config_path_candidates, whose entries are alternatives + # rather than merge tiers. + config_merge_path_templates = { + "unix": PackedStringArray([ + "~/.pi/agent/mcp.json", + "~/.pi/agent/.mcp.json", + ]), + "windows": PackedStringArray([ + "$USERPROFILE/.pi/agent/mcp.json", + "$USERPROFILE/.pi/agent/.mcp.json", + ]), + } + config_merge_project_paths = PackedStringArray([".pi/mcp.json", ".mcp.json"]) + server_key_path = PackedStringArray(["mcpServers"]) + # pi-codemode-mcp resolves these with nullish precedence: + # mcpServers ?? mcp-servers ?? servers. Reuse whichever map already exists + # so Configure cannot shadow and deactivate the user's existing servers. + var aliases: Array[PackedStringArray] = [ + PackedStringArray(["mcp-servers"]), + PackedStringArray(["servers"]), + ] + server_key_path_aliases = aliases + entry_url_field = "url" + # pi-codemode-mcp selects stdio from `command` and remote transport from + # `url`; it ignores `type`. Keep generated stdio entries minimal and typeless. + entry_extra_fields = {} + entry_initial_fields = {} + # Attach migration (#838). Pi stdio entries are flat command/args/env. + # When Configure replaces a legacy URL entry, remove `url`, `headers`, and + # any redundant `type` field so the generated stdio entry stays canonical. + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["url", "headers", "type"]) + # `env` is the only user-mutable field pi's docs surface. Future + # fields still survive — the strategy deep-copies unknown keys. + command_user_fields = PackedStringArray(["env"]) + # Pi's loader also accepts pure-URL entries (the example mcp.json in + # the pi-codemode-mcp repo ships stdio + URL entries side-by-side). + # Keep the manual-instruction URL fallback alive. + command_supports_url_fallback = true + # ~/.pi/agent/mcp.json is created by the first pi launch, so its + # presence is the strongest install signal. Mirror antigravity's + # `detect_paths = path_template.values()` pattern so the dock shows + # the installed badge before Configure has run. + detect_paths = PackedStringArray(path_template.values()) \ No newline at end of file diff --git a/addons/godot_ai/clients/pi.gd.uid b/addons/godot_ai/clients/pi.gd.uid new file mode 100644 index 0000000..53aeb7c --- /dev/null +++ b/addons/godot_ai/clients/pi.gd.uid @@ -0,0 +1 @@ +uid://it00ohwkm2nd diff --git a/addons/godot_ai/clients/qwen_code.gd b/addons/godot_ai/clients/qwen_code.gd new file mode 100644 index 0000000..63bc7b2 --- /dev/null +++ b/addons/godot_ai/clients/qwen_code.gd @@ -0,0 +1,26 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "qwen_code" + display_name = "Qwen Code" + config_type = "json" + path_template = { + "unix": "~/.qwen/settings.json", + "windows": "$USERPROFILE/.qwen/settings.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + entry_url_field = "httpUrl" + ## Attach migration (#838). Qwen Code is a gemini-cli fork with the same + ## flat stdio shape and one-of `command` | `url` | `httpUrl` rule + ## (docs/users/features/mcp.md). Qwen adds `discoveryTimeoutMs` (stdio + ## discovery handshake cap, default 30s). + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["httpUrl", "url"]) + command_user_fields = PackedStringArray([ + "timeout", "trust", "includeTools", "excludeTools", "env", "cwd", + "discoveryTimeoutMs", + ]) + command_timeout_fields = PackedStringArray(["timeout", "discoveryTimeoutMs"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/qwen_code.gd.uid b/addons/godot_ai/clients/qwen_code.gd.uid new file mode 100644 index 0000000..5f2eb1a --- /dev/null +++ b/addons/godot_ai/clients/qwen_code.gd.uid @@ -0,0 +1 @@ +uid://qwb5udkf423q diff --git a/addons/godot_ai/clients/roo_code.gd b/addons/godot_ai/clients/roo_code.gd new file mode 100644 index 0000000..5d7bd6a --- /dev/null +++ b/addons/godot_ai/clients/roo_code.gd @@ -0,0 +1,42 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "roo_code" + display_name = "Roo Code" + config_type = "json" + path_template = { + "darwin": "~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json", + "windows": "$APPDATA/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json", + "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + ## Roo defaults an entry with no "type" to SSE transport — which returns + ## HTTP 400 against our streamable-http endpoint on `/mcp`. Pin the type + ## explicitly so Roo negotiates streamable-http (the current MCP spec's + ## recommended remote transport). See issue #189. The default verifier + ## requires every entry_extra_fields key to match, so a pre-#189 typeless + ## entry surfaces as drift instead of silently passing as configured. + entry_extra_fields = {"type": "streamable-http"} + ## `disabled` and `alwaysAllow` are user-state (they may have flipped the + ## entry off, or auto-approved specific tools like `session_manage`). + ## Seed on first Configure but preserve across reconfigure — without this + ## split, the Configure-All-Mismatched sweep silently wipes the user's + ## auto-approval list every time the type pin or URL drifts. + entry_initial_fields = {"disabled": false, "alwaysAllow": []} + ## Attach migration (#838). Roo stdio entries are flat command/args/env + ## (+cwd); docs state `type` defaults to "stdio" for command configs and + ## the stdio schema forbids url/headers. Pin type=stdio — it is documented + ## and repins the legacy "streamable-http" value in place. + command_shape = McpClient.CommandShape.FLAT + command_transport_key = "type" + command_transport_value = "stdio" + command_legacy_keys = PackedStringArray(["url", "headers"]) + command_initial_fields = {"disabled": false, "alwaysAllow": []} + command_user_fields = PackedStringArray([ + "disabled", "alwaysAllow", "timeout", "disabledTools", "watchPaths", + "env", "cwd", + ]) + command_timeout_fields = PackedStringArray(["timeout"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/roo_code.gd.uid b/addons/godot_ai/clients/roo_code.gd.uid new file mode 100644 index 0000000..1dcae84 --- /dev/null +++ b/addons/godot_ai/clients/roo_code.gd.uid @@ -0,0 +1 @@ +uid://denjdf50qrf66 diff --git a/addons/godot_ai/clients/trae.gd b/addons/godot_ai/clients/trae.gd new file mode 100644 index 0000000..afc7a68 --- /dev/null +++ b/addons/godot_ai/clients/trae.gd @@ -0,0 +1,23 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "trae" + display_name = "Trae" + config_type = "json" + path_template = { + "darwin": "~/Library/Application Support/Trae/User/mcp.json", + "windows": "$APPDATA/Trae/User/mcp.json", + "linux": "$XDG_CONFIG_HOME/Trae/User/mcp.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + ## Attach migration (#838). Trae stdio entries are flat command/args/env + ## with no type discriminator (docs.trae.cn/ide/add-mcp-servers); transport + ## is inferred from `command` vs `url`, so url/headers are legacy next to a + ## command. Entry stays minimal — Trae manages enable/disable in its UI, + ## and its startup/run timeouts ride user-owned env vars, which survive. + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["url", "headers"]) + command_user_fields = PackedStringArray(["env"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/trae.gd.uid b/addons/godot_ai/clients/trae.gd.uid new file mode 100644 index 0000000..f10046e --- /dev/null +++ b/addons/godot_ai/clients/trae.gd.uid @@ -0,0 +1 @@ +uid://cwpu48772vfj1 diff --git a/addons/godot_ai/clients/vscode.gd b/addons/godot_ai/clients/vscode.gd new file mode 100644 index 0000000..a18b97d --- /dev/null +++ b/addons/godot_ai/clients/vscode.gd @@ -0,0 +1,30 @@ +@tool +extends McpClient + +## VS Code (stable) reads MCP servers from per-user mcp.json under +## `servers.` with `{ "type": "http", "url": ... }`. + + +func _init() -> void: + id = "vscode" + display_name = "VS Code" + config_type = "json" + path_template = { + "darwin": "~/Library/Application Support/Code/User/mcp.json", + "windows": "$APPDATA/Code/User/mcp.json", + "linux": "$XDG_CONFIG_HOME/Code/User/mcp.json", + } + server_key_path = PackedStringArray(["servers"]) + entry_extra_fields = {"type": "http"} + ## Attach migration (#838). VS Code stdio entries are flat command/args/env + ## under `servers` with a documented `type: "stdio"` discriminator + ## (code.visualstudio.com/docs/agents/reference/mcp-configuration). The + ## stdio schema is `additionalProperties: false` (mcpConfiguration.ts), so + ## removing the legacy url/headers is load-bearing — leftovers invalidate + ## the whole entry, and the pin flips the legacy `type: "http"` in place. + command_shape = McpClient.CommandShape.FLAT + command_transport_key = "type" + command_transport_value = "stdio" + command_legacy_keys = PackedStringArray(["url", "headers"]) + command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/vscode.gd.uid b/addons/godot_ai/clients/vscode.gd.uid new file mode 100644 index 0000000..1c79881 --- /dev/null +++ b/addons/godot_ai/clients/vscode.gd.uid @@ -0,0 +1 @@ +uid://dl6cm044pihub diff --git a/addons/godot_ai/clients/vscode_insiders.gd b/addons/godot_ai/clients/vscode_insiders.gd new file mode 100644 index 0000000..7f6a65b --- /dev/null +++ b/addons/godot_ai/clients/vscode_insiders.gd @@ -0,0 +1,23 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "vscode_insiders" + display_name = "VS Code Insiders" + config_type = "json" + path_template = { + "darwin": "~/Library/Application Support/Code - Insiders/User/mcp.json", + "windows": "$APPDATA/Code - Insiders/User/mcp.json", + "linux": "$XDG_CONFIG_HOME/Code - Insiders/User/mcp.json", + } + server_key_path = PackedStringArray(["servers"]) + entry_extra_fields = {"type": "http"} + ## Attach migration (#838). Identical format to vscode.gd — Insiders ships + ## the same mcpConfiguration.ts schema; see that descriptor for citations. + command_shape = McpClient.CommandShape.FLAT + command_transport_key = "type" + command_transport_value = "stdio" + command_legacy_keys = PackedStringArray(["url", "headers"]) + command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/vscode_insiders.gd.uid b/addons/godot_ai/clients/vscode_insiders.gd.uid new file mode 100644 index 0000000..c763703 --- /dev/null +++ b/addons/godot_ai/clients/vscode_insiders.gd.uid @@ -0,0 +1 @@ +uid://cad5w4ofyg8a2 diff --git a/addons/godot_ai/clients/windsurf.gd b/addons/godot_ai/clients/windsurf.gd new file mode 100644 index 0000000..b3ef210 --- /dev/null +++ b/addons/godot_ai/clients/windsurf.gd @@ -0,0 +1,29 @@ +@tool +extends McpClient + + +func _init() -> void: + # #623: Windsurf was rebranded to Devin Desktop by Cognition (June 2026). + # The id stays "windsurf" — it is the stable registry key used for + # configured-status lookups. The MCP config path is unchanged by the + # rebrand: per the official docs (docs.devin.ai/desktop/cascade/mcp) the + # global config still lives under the platform's `.codeium/windsurf/` + # directory (~/.codeium/windsurf/ on unix, $USERPROFILE/.codeium/windsurf/ + # on Windows), and migrated installs carry their settings over in place. + id = "windsurf" + display_name = "Devin Desktop (Windsurf)" + config_type = "json" + path_template = { + "unix": "~/.codeium/windsurf/mcp_config.json", + "windows": "$USERPROFILE/.codeium/windsurf/mcp_config.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + entry_url_field = "serverUrl" + ## Attach migration (#838). Stdio entries are flat command/args/env + ## (docs.devin.ai/desktop/cascade/mcp); transport is inferred from + ## `command` vs `serverUrl` presence and no type field is documented — + ## do not write one. + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["serverUrl"]) + command_user_fields = PackedStringArray(["env"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/windsurf.gd.uid b/addons/godot_ai/clients/windsurf.gd.uid new file mode 100644 index 0000000..af34b60 --- /dev/null +++ b/addons/godot_ai/clients/windsurf.gd.uid @@ -0,0 +1 @@ +uid://b6pqiok2mlsmg diff --git a/addons/godot_ai/clients/zed.gd b/addons/godot_ai/clients/zed.gd new file mode 100644 index 0000000..fbd5f29 --- /dev/null +++ b/addons/godot_ai/clients/zed.gd @@ -0,0 +1,29 @@ +@tool +extends McpClient + +## Zed registers MCP servers under `context_servers.` and supports both +## stdio and streamable http transports. + + +func _init() -> void: + id = "zed" + display_name = "Zed" + config_type = "json" + path_template = { + "darwin": "~/.config/zed/settings.json", + "linux": "$XDG_CONFIG_HOME/zed/settings.json", + "windows": "$APPDATA/Zed/settings.json", + } + server_key_path = PackedStringArray(["context_servers"]) + ## Attach migration (#838). Current Zed's context_servers entries are an + ## untagged serde enum discriminated by shape: `command` (string) + args/env + ## → stdio, `url` → HTTP (zed.dev/docs/ai/mcp; settings_content/project.rs). + ## BECAUSE the enum is untagged, HTTP-only keys left next to `command` + ## (`url`, `headers`, `oauth`) make the entry match no variant and break it — + ## removing them on migration is load-bearing, not cosmetic. `enabled`, + ## `remote`, and `timeout` are user-state on the stdio variant. + command_shape = McpClient.CommandShape.FLAT + command_legacy_keys = PackedStringArray(["url", "headers", "oauth"]) + command_user_fields = PackedStringArray(["enabled", "remote", "timeout", "env"]) + command_timeout_fields = PackedStringArray(["timeout"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/zed.gd.uid b/addons/godot_ai/clients/zed.gd.uid new file mode 100644 index 0000000..b9b313a --- /dev/null +++ b/addons/godot_ai/clients/zed.gd.uid @@ -0,0 +1 @@ +uid://d152l0u0r6fsc diff --git a/addons/godot_ai/clients/zoo_code.gd b/addons/godot_ai/clients/zoo_code.gd new file mode 100644 index 0000000..870ff39 --- /dev/null +++ b/addons/godot_ai/clients/zoo_code.gd @@ -0,0 +1,36 @@ +@tool +extends McpClient + + +func _init() -> void: + id = "zoo_code" + display_name = "Zoo Code" + config_type = "json" + path_template = { + "darwin": "~/Library/Application Support/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json", + "windows": "$APPDATA/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json", + "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json", + } + server_key_path = PackedStringArray(["mcpServers"]) + ## Local validation against the installed extension shows Zoo stores MCP + ## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's + ## shape. Its changelog also references Streamable HTTP support, so pin the + ## transport explicitly to avoid any typeless entry falling back to SSE. + entry_extra_fields = {"type": "streamable-http"} + ## Preserve user-controlled state across reconfigure, parallel to Roo/Kilo. + entry_initial_fields = {"disabled": false, "alwaysAllow": []} + ## Attach migration (#838). Zoo's stdio zod schema is Roo's: flat + ## command/args/env(+cwd), `type: z.enum(["stdio"]).optional()`, and + ## url/headers explicitly forbidden on stdio entries (McpHub.ts) — so both + ## are legacy keys and the documented type pin repins "streamable-http". + command_shape = McpClient.CommandShape.FLAT + command_transport_key = "type" + command_transport_value = "stdio" + command_legacy_keys = PackedStringArray(["url", "headers"]) + command_initial_fields = {"disabled": false, "alwaysAllow": []} + command_user_fields = PackedStringArray([ + "disabled", "alwaysAllow", "timeout", "watchPaths", "disabledTools", + "env", "cwd", + ]) + command_timeout_fields = PackedStringArray(["timeout"]) + command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/zoo_code.gd.uid b/addons/godot_ai/clients/zoo_code.gd.uid new file mode 100644 index 0000000..dd3713e --- /dev/null +++ b/addons/godot_ai/clients/zoo_code.gd.uid @@ -0,0 +1 @@ +uid://fmp0nlzcm3ukl diff --git a/addons/godot_ai/connection.gd b/addons/godot_ai/connection.gd new file mode 100644 index 0000000..753a49e --- /dev/null +++ b/addons/godot_ai/connection.gd @@ -0,0 +1,1058 @@ +@tool +class_name McpConnection +extends Node + +## WebSocket transport to the Godot AI Python server. +## Only handles connect, reconnect, send, and receive. +## Command dispatch is owned by McpDispatcher. + +const RECONNECT_DELAYS: Array[float] = [1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 60.0] +const RECONNECT_VERBOSE_ATTEMPTS := 5 +const RECONNECT_LOG_HEARTBEAT_MSEC := 60_000 +## Backpressure policy: do not queue responses once the WebSocket's current +## outbound buffer plus the next payload would exceed this cap. Command +## responses get a compact structured error when that can still be sent; +## state events report failure so their callers can retry on a later tick. +const OUTBOUND_BUFFER_LIMIT_BYTES := 4 * 1024 * 1024 +## Cap the inbound packet drain per `_process` tick. A flooding peer or a +## fast batch could otherwise saturate `_handle_message` in one frame and +## blow the documented 4ms budget. Packets beyond this cap spill to the +## next frame; the cumulative spill counter is logged so flood patterns +## are observable in `logs_read`. See audit-v2 finding #12 (issue #356). +const PACKET_DRAIN_CAP_PER_TICK := 32 +## Mirror of the server's application close code for a handshake carrying a +## wrong auth token (#690; `websocket.py::_CLOSE_CODE_AUTH_TOKEN_MISMATCH`). +const CLOSE_CODE_AUTH_TOKEN_MISMATCH := 4003 +## After this many consecutive post-OPEN token-mismatch rejections, drop the +## token and handshake token-less (see `_note_post_open_close`). Two, not +## one: a transient stale-record race during a server swap gets one chance +## to resolve before the token is given up. +const AUTH_MISMATCH_FALLBACK_CLOSES := 2 +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Emitted whenever the underlying WebSocket open/closed state flips. +## Subscribers (e.g. the plugin-side telemetry helper) use this to drain +## events that were enqueued before the socket was ready. Emitted with +## ``true`` on first OPEN per connect, ``false`` on transition to CLOSED +## (including ``disconnect_from_server()``). +signal connection_state_changed(is_open: bool) + +var _peer := WebSocketPeer.new() +## Seeded by plugin.gd from the configured EditorSettings port before the +## first dial, then republished with the fully resolved port once the +## deferred startup walk (#678) finishes resolving/spawning. Each connect +## attempt recomputes the URL from the latest value, so reconnects keep +## dialing the port the Python server was asked to bind. +var ws_port := ClientConfigurator.DEFAULT_WS_PORT +## Per-launch handshake auth token (#690). Set by plugin.gd from the value +## it generated for the server spawn (also persisted in the managed-server +## editor-settings record so a reloaded plugin instance adopting the same +## server keeps sending it). Empty means "don't send the field" — servers +## we didn't spawn (dev servers, older servers) have no token to match. +var auth_token := "" +var _url := "" +var _connected := false +var _reconnect_attempt := 0 +var _reconnect_timer := 0.0 +## Pull-based reconnect observability. The peer owns CONNECTING/CLOSING +## timing; tracking entry time here lets logs and the dock distinguish those +## phases from the plugin-owned CLOSED-state backoff without changing policy. +var _observed_peer_state := WebSocketPeer.STATE_CLOSED +var _peer_state_entered_msec := 0 +var _last_reconnect_transition_log_msec := -1 +var _transient_diagnostic: Dictionary = {} +## One pre-OPEN failure diagnostic per WebSocketPeer. Without this guard the +## CLOSED state is polled every frame and would flood the editor log. +var _preopen_failure_logged_for_peer := false +var _session_id := "" +## Consecutive post-OPEN closes with CLOSE_CODE_AUTH_TOKEN_MISMATCH. NOT +## reset by `_clear_on_disconnect` — the streak is counted exactly at the +## close events it exists to observe, across reconnect attempts. Reset on +## any other close code and on a successful `handshake_ack`. +var _auth_mismatch_closes := 0 +## Godot-AI Python package version reported by the server in its `handshake_ack` +## reply. Empty until the ack lands. Older servers (pre-handshake_ack) leave +## this empty forever — callers that gate on it (the dock's mismatch banner) +## must treat empty as "unknown, don't raise a false alarm". +var server_version := "" + +var dispatcher +var log_buffer +var surfaced_error_tracker +## Set by plugin.gd. Lets the per-frame play-state poll end game-run +## bookkeeping when the game exits on its own (self-quit, crash) — the +## debugger session's stopped signal is not reliably connected, and no MCP +## stop op runs in that path (#642). +var debugger_plugin +## Set by plugin.gd when the HTTP port is occupied by an incompatible or +## unverified server. Keeping the Connection node alive lets handlers and the +## dock share one object, but no WebSocket is opened to the wrong server. +var connect_blocked := false +var connect_block_reason := "" +var _blocked_notice_logged := false +## Compatibility property used by existing handlers. Setting true increments +## the pause depth; setting false decrements it. Processing stays paused until +## every nested pause has resumed. +var pause_processing: bool: + get: return _pause_depth > 0 + set(value): + if value: + pause() + else: + resume() +var _pause_depth := 0 +## Cumulative count of inbound packets that didn't fit in their tick's drain +## budget and got deferred to a subsequent tick. Reset on disconnect so each +## connection starts with a clean spillover history. Logged whenever new +## spillover occurs so flood patterns surface in `logs_read`. +var _packet_spillover_total := 0 + + +func _ready() -> void: + _session_id = _make_session_id(ProjectSettings.globalize_path("res://")) + ## Increase outbound buffer for large messages (e.g. screenshot base64). + ## Default is 64 KB; screenshots can be several MB. + _peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES + ## Symmetric inbound bump (#690): the server sends up to 4 MB + ## (websocket.py max_size), but Godot's inbound default is 64 KB — a + ## large script/text write or batch_execute payload used to overflow + ## the peer buffer, drop the frame, and surface as an opaque 5s + ## timeout + reconnect with no error naming the size. + _peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES + if connect_blocked: + _log_blocked_notice_once() + set_process(false) + return + _connect_to_server() + _hook_editor_signals() + + +func _process(delta: float) -> void: + if pause_processing: + return + _peer.poll() + ## Run-stop bookkeeping must not wait behind the socket-state machine: + ## if the game stops while disconnected, the first command drained on + ## reconnect would still observe stale "live" state (PR #642 review). + _check_game_run_play_state(EditorInterface.is_playing_scene()) + + var peer_state := _peer.get_ready_state() + var transition := _observe_peer_state(peer_state, Time.get_ticks_msec()) + match peer_state: + WebSocketPeer.STATE_OPEN: + if not _connected: + _connected = true + _reconnect_attempt = 0 + log_buffer.log("connected to server") + _send_handshake() + ## Reset the edge detectors so the next _check_state_changes + ## tick re-emits any non-default scene/play state — the + ## handshake carries readiness only, so without this a + ## (re)connected server never learns the current scene. + _last_scene_path = "" + _last_play_state = false + connection_state_changed.emit(true) + + _drain_inbound_packets(_peer) + + _check_state_changes() + + if dispatcher: + for response in dispatcher.tick(): + _send_json(response) + + WebSocketPeer.STATE_CLOSED: + if _connected: + _connected = false + ## This peer reached OPEN, so its one close diagnostic is the + ## post-OPEN line below. Mark the peer consumed; otherwise a + ## stale reconnect delay leaves it in CLOSED for another frame + ## and the pre-OPEN branch emits a mislabeled duplicate. + _preopen_failure_logged_for_peer = true + _clear_on_disconnect() + var code := _peer.get_close_code() + var reason := _peer.get_close_reason() + var open_elapsed_sec := float(transition.get("previous_elapsed_sec", 0.0)) + var close_diagnostic := _note_post_open_close(code) + if close_diagnostic.is_empty(): + close_diagnostic = { + "reason_code": "connection_lost", + "reason": _close_reason_text(code, reason), + } + _transient_diagnostic = close_diagnostic + _log_reconnect_transition( + _postopen_close_diagnostic( + open_elapsed_sec, + code, + reason, + _url, + close_diagnostic, + ), + maxi(1, _reconnect_attempt), + true, + ) + connection_state_changed.emit(false) + elif not _preopen_failure_logged_for_peer: + _preopen_failure_logged_for_peer = true + ## A failed attempt never reached OPEN, so any post-OPEN reason + ## belongs to the previous peer and must not describe this one. + _transient_diagnostic.clear() + ## Initial failure is attempt 1 for diagnostics. Later transition + ## summaries are time-throttled so a missing listener stays + ## observable without tying log volume to attempt duration. + var failed_attempt := maxi(1, _reconnect_attempt) + var connecting_elapsed_sec := float( + transition.get("previous_elapsed_sec", 0.0) + ) + _log_reconnect_transition( + _preopen_failure_diagnostic( + failed_attempt, + connecting_elapsed_sec, + _reconnect_timer, + _peer.get_close_code(), + _peer.get_close_reason(), + _url + ), + failed_attempt, + ) + _reconnect_timer -= delta + if _reconnect_timer <= 0.0: + _attempt_reconnect() + + WebSocketPeer.STATE_CLOSING: + pass + WebSocketPeer.STATE_CONNECTING: + pass + +func get_session_id() -> String: + return _session_id + +## Drain up to PACKET_DRAIN_CAP_PER_TICK inbound packets and dispatch each +## via `_handle_message`. Anything past the cap stays in the peer's queue +## and gets picked up next tick. The cumulative spillover count is logged +## (via `log_buffer`) only when the cap was actually hit AND packets remain +## — sustained flood thus emits one log line per tick with the running +## total, while a normal-traffic frame stays silent. +## +## `peer` is untyped (Variant) so tests can inject a duck-typed fake with +## `get_available_packet_count()` + `get_packet()`. Production passes the +## real `_peer: WebSocketPeer`. +func _drain_inbound_packets(peer) -> Dictionary: + var drained := 0 + while peer.get_available_packet_count() > 0 and drained < PACKET_DRAIN_CAP_PER_TICK: + var raw: String = peer.get_packet().get_string_from_utf8() + _handle_message(raw) + drained += 1 + + var spilled := 0 + if drained >= PACKET_DRAIN_CAP_PER_TICK and peer.get_available_packet_count() > 0: + spilled = peer.get_available_packet_count() + _packet_spillover_total += spilled + if log_buffer: + log_buffer.log( + ( + "[backpressure] inbound drain capped at %d/tick;" + + " %d packets spilled to next frame (cumulative %d)" + ) + % [PACKET_DRAIN_CAP_PER_TICK, spilled, _packet_spillover_total] + ) + + return {"drained": drained, "spilled": spilled} + + +var is_connected: bool: + get: return _connected + + +func disconnect_from_server() -> void: + if _connected: + _peer.close(1000, "Plugin unloading") + _connected = false + ## This peer reached OPEN and is being closed deliberately, so neither + ## the post-OPEN nor pre-OPEN close diagnostic applies. Consume its one + ## diagnostic before the CLOSED tick observes the pre-cleared flag. + _preopen_failure_logged_for_peer = true + ## Pre-clearing _connected makes the STATE_CLOSED branch skip its + ## _clear_on_disconnect() — run it here so deliberate closes don't + ## leak the old server's version/deferred state into the next one. + _clear_on_disconnect() + connection_state_changed.emit(false) + + +## Reset per-connection state that was filled in by the previous server +## and must NOT bleed into the next one. `force_restart_server` swaps +## servers without reloading the plugin, so without this reset the dock +## would keep showing the killed server's version until the next ack. +## Also fires on plain reconnect-loop drops — correct either way. +func _clear_on_disconnect() -> void: + server_version = "" + ## Reset the spillover counter so a flood pattern from the previous + ## connection doesn't pollute the next one's `logs_read` baseline. + _packet_spillover_total = 0 + if dispatcher: + dispatcher.clear_deferred_responses() + ## Queued-but-unexecuted commands from the dead connection must not + ## run under the next one (#712): their requester's futures were + ## already failed server-side, so executing them after reconnect is + ## an uncorrelatable surprise write. + dispatcher.clear_command_queue() + + +## Full pre-free cleanup for plugin unload: stop _process, close the +## socket, and drop dispatcher/log_buffer refs so their Callable-held +## RefCounted handlers decref before plugin.gd clears _handlers. +## See issue #46 and plugin.gd::_exit_tree. +func teardown() -> void: + set_process(false) + disconnect_from_server() + dispatcher = null + log_buffer = null + + +func _connect_to_server() -> void: + _url = "ws://127.0.0.1:%d" % ws_port + var err := _peer.connect_to_url(_url) + if err != OK: + log_buffer.log("failed to initiate connection (error %d)" % err) + _observed_peer_state = _peer.get_ready_state() + _peer_state_entered_msec = Time.get_ticks_msec() + + +func _attempt_reconnect() -> void: + if connect_blocked: + _log_blocked_notice_once() + set_process(false) + return + var delay := _reconnect_delay_for_attempt(_reconnect_attempt) + _reconnect_attempt += 1 + _reconnect_timer = delay + _log_reconnect_transition( + "connecting to server (attempt %d)" % _reconnect_attempt, + _reconnect_attempt, + ) + ## Always create a fresh WebSocketPeer before reconnecting. A peer that has + ## reached STATE_CLOSED is terminal; reusing it can leave the editor stuck in + ## a quiet reconnect loop after the Python server restarts. + _peer = WebSocketPeer.new() + _preopen_failure_logged_for_peer = false + _peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES + ## Keep the reconnect peer symmetric with _ready()'s (#690). + _peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES + _connect_to_server() + + +func pause() -> void: + _pause_depth += 1 + + +func resume() -> void: + _pause_depth = maxi(0, _pause_depth - 1) + + +func pause_depth() -> int: + return _pause_depth + + +static func _reconnect_delay_for_attempt(attempt_index: int) -> float: + var delay_idx := mini(attempt_index, RECONNECT_DELAYS.size() - 1) + return RECONNECT_DELAYS[delay_idx] + + +static func _should_log_reconnect_transition( + attempt_number: int, + now_msec: int, + last_log_msec: int +) -> bool: + ## Keep the first few transitions visible, then emit at most one summary per + ## minute. Attempt-number throttling goes quiet for minutes when the engine + ## spends a long time in CONNECTING, which is the state this log explains. + return ( + attempt_number <= RECONNECT_VERBOSE_ATTEMPTS + or last_log_msec < 0 + or now_msec - last_log_msec >= RECONNECT_LOG_HEARTBEAT_MSEC + ) + + +func _log_reconnect_transition(message: String, attempt_number: int, force := false) -> void: + if not log_buffer: + return + var now_msec := Time.get_ticks_msec() + if not force and not _should_log_reconnect_transition( + attempt_number, + now_msec, + _last_reconnect_transition_log_msec, + ): + return + _last_reconnect_transition_log_msec = now_msec + log_buffer.log(message) + + +static func _preopen_failure_diagnostic( + attempt_number: int, + connecting_elapsed_sec: float, + retry_in_sec: float, + code: int, + reason: String, + url: String +) -> String: + var retry_text := "retrying now" + if retry_in_sec > 0.0: + retry_text = "retrying in %.0fs" % retry_in_sec + return ( + "connection attempt %d failed before OPEN after %.1fs; %s" + + " (code %d, reason %s, url %s)" + ) % [ + attempt_number, + maxf(0.0, connecting_elapsed_sec), + retry_text, + code, + _sanitized_close_reason(reason), + url, + ] + + +static func _postopen_close_diagnostic( + open_elapsed_sec: float, + code: int, + reason: String, + url: String, + diagnostic: Dictionary = {} +) -> String: + var message := ( + "connection lost after being open for %.1fs (code %d, reason %s, url %s); reconnecting" + % [maxf(0.0, open_elapsed_sec), code, _sanitized_close_reason(reason), url] + ) + match str(diagnostic.get("recovery_action", "")): + "retry_authenticated": + message += " with the current auth token (rejection 1/%d)" % AUTH_MISMATCH_FALLBACK_CLOSES + "retry_tokenless": + message += " with a token-less handshake (rejection %d/%d)" % [ + int(diagnostic.get("occurrence", AUTH_MISMATCH_FALLBACK_CLOSES)), + AUTH_MISMATCH_FALLBACK_CLOSES, + ] + return message + + +static func _sanitized_close_reason(reason: String) -> String: + var reason_label := reason.strip_edges() + if reason_label.is_empty(): + return "" + return reason_label.replace("\r", "\\r").replace("\n", "\\n") + + +static func _close_reason_text(code: int, reason: String) -> String: + return "Close code %d: %s" % [code, _sanitized_close_reason(reason)] + + +## Token-mismatch fallback (#690 follow-up). The server's auth token is +## fixed for its whole launch, so redialing with the same wrong token can +## never succeed — without this the reconnect loop 4003s forever. The +## reproduced multi-editor failure: a duplicate spawn overwrites the shared +## managed-server record with its fresh token, dies unable to bind, and +## this editor is left holding a token the surviving server never saw. +## After AUTH_MISMATCH_FALLBACK_CLOSES consecutive rejections, drop to a +## token-less handshake, which the server accepts by design (older plugins +## and adopted servers have no token, and the field is attacker-omittable — +## see websocket.py; omitting it gives up no security). Scope note: only +## this connection's copy of the token is dropped — the plugin static and +## the persisted record heal via the startup walk's adoption arms. +func _note_post_open_close(code: int) -> Dictionary: + if code != CLOSE_CODE_AUTH_TOKEN_MISMATCH or auth_token.is_empty(): + _auth_mismatch_closes = 0 + return {} + _auth_mismatch_closes += 1 + var occurrence := _auth_mismatch_closes + if _auth_mismatch_closes < AUTH_MISMATCH_FALLBACK_CLOSES: + return { + "reason_code": "auth_token_mismatch", + "reason": "Server rejected the editor auth token; retrying once in case of a server-swap race.", + "occurrence": occurrence, + "recovery_action": "retry_authenticated", + } + auth_token = "" + _auth_mismatch_closes = 0 + return { + "reason_code": "auth_token_mismatch", + "reason": "Server rejected the editor auth token twice; the next handshake will omit it.", + "occurrence": occurrence, + "recovery_action": "retry_tokenless", + } + + +## Record one peer-state transition and return the duration of the state that +## just ended. Kept separate from `_transport_status_snapshot` so tests can +## exercise the status contract with injected values and no live socket. +func _observe_peer_state(state: int, now_msec: int) -> Dictionary: + if _peer_state_entered_msec <= 0: + _observed_peer_state = state + _peer_state_entered_msec = now_msec + return {"changed": false, "previous_elapsed_sec": 0.0} + if state == _observed_peer_state: + return {"changed": false, "previous_elapsed_sec": 0.0} + var previous_elapsed_sec := maxf( + 0.0, + (now_msec - _peer_state_entered_msec) / 1000.0, + ) + var previous_state := _observed_peer_state + _observed_peer_state = state + _peer_state_entered_msec = now_msec + return { + "changed": true, + "previous_state": previous_state, + "previous_elapsed_sec": previous_elapsed_sec, + } + + +## Pure transport-status contract shared by the connection log and dock. It +## intentionally knows nothing about lifecycle diagnoses; the thin public +## wrapper below applies generic `blocked`, while server_lifecycle.gd remains +## authoritative for exact terminal states such as incompatible/foreign port. +static func _transport_status_snapshot( + state: int, + state_elapsed_sec: float, + attempt: int, + retry_timer: float +) -> Dictionary: + var phase := "closing" + match state: + WebSocketPeer.STATE_OPEN: + phase = "connected" + WebSocketPeer.STATE_CONNECTING: + phase = "connecting" + WebSocketPeer.STATE_CLOSED: + phase = "retrying" + WebSocketPeer.STATE_CLOSING: + phase = "closing" + var snapshot := { + "phase": phase, + "attempt": maxi(0, attempt), + "state_elapsed_sec": maxf(0.0, state_elapsed_sec), + } + ## `retry_in_sec` is deliberately unrepresentable outside CLOSED-state + ## backoff. CONNECTING is an in-flight attempt, never "retrying in 0s". + if phase == "retrying": + snapshot["retry_in_sec"] = maxf(0.0, retry_timer) + return snapshot + + +func get_transport_status() -> Dictionary: + var now_msec := Time.get_ticks_msec() + var elapsed_sec := 0.0 + if _peer_state_entered_msec > 0: + elapsed_sec = maxf(0.0, (now_msec - _peer_state_entered_msec) / 1000.0) + var snapshot := _transport_status_snapshot( + _peer.get_ready_state(), + elapsed_sec, + _reconnect_attempt, + _reconnect_timer, + ) + if connect_blocked: + snapshot["phase"] = "blocked" + snapshot.erase("retry_in_sec") + snapshot["reason_code"] = "connection_blocked" + if not connect_block_reason.is_empty(): + snapshot["reason"] = connect_block_reason + elif not _transient_diagnostic.is_empty(): + for key in _transient_diagnostic: + snapshot[key] = _transient_diagnostic[key] + return snapshot + + +func _log_blocked_notice_once() -> void: + if _blocked_notice_logged: + return + _blocked_notice_logged = true + if log_buffer and not connect_block_reason.is_empty(): + log_buffer.log(connect_block_reason) + + +func _send_handshake() -> void: + _last_readiness = get_readiness() + _send_json(_build_handshake()) + + +## Split from _send_handshake so tests can assert the payload shape +## without a live WebSocket peer. +func _build_handshake() -> Dictionary: + var payload := { + "type": "handshake", + "session_id": _session_id, + "godot_version": Engine.get_version_info().get("string", "unknown"), + "project_path": ProjectSettings.globalize_path("res://"), + "plugin_version": ClientConfigurator.get_plugin_version(), + "protocol_version": 1, + "readiness": _last_readiness, + "editor_pid": OS.get_process_id(), + "server_launch_mode": ClientConfigurator.get_server_launch_mode(), + } + ## Omit rather than send "" — the server treats an ABSENT token as a + ## compat-accepted older plugin, but a PRESENT wrong one as hostile. + if not auth_token.is_empty(): + payload["auth_token"] = auth_token + return payload + + +## Classify one raw inbound frame. Shared by the normal dispatch path +## (`_handle_message`, which enqueues commands) and the exclusive-run +## service path (`_service_handle_message`, which rejects them) — one +## parser, two sinks, so the paths can't drift. `kind` is one of: +## "ack", "command", "malformed_command", "ignore". +func _classify_message(raw: String) -> Dictionary: + var parsed = JSON.parse_string(raw) + if parsed == null: + push_warning("MCP: failed to parse message: %s" % raw) + return {"kind": "ignore", "parsed": null} + if not (parsed is Dictionary): + return {"kind": "ignore", "parsed": null} + if parsed.get("type", "") == "handshake_ack": + return {"kind": "ack", "parsed": parsed} + if parsed.has("request_id") and parsed.has("command"): + if ( + parsed.get("request_id") is String + and parsed.get("command") is String + and (not parsed.has("params") or parsed.get("params") is Dictionary) + ): + return {"kind": "command", "parsed": parsed} + return {"kind": "malformed_command", "parsed": parsed} + return {"kind": "ignore", "parsed": parsed} + + +func _handle_message(raw: String) -> void: + var classified := _classify_message(raw) + match classified["kind"]: + "ack": + _handle_handshake_ack(classified["parsed"]) + "command": + if dispatcher: + dispatcher.enqueue(classified["parsed"]) + "malformed_command": + _reply_malformed_command(classified["parsed"]) + + +func _handle_handshake_ack(parsed: Dictionary) -> void: + server_version = str(parsed.get("server_version", "")) + ## The server accepted our handshake — any token-mismatch streak is + ## over; a later unrelated 4003 starts a fresh one. + _auth_mismatch_closes = 0 + _transient_diagnostic.clear() + + +## Never enqueue a malformed command frame: the dispatcher's typed casts +## would error on the queue head every tick, wedging every later command +## behind it. Reply with an error when the request_id is usable so the +## server's pending future resolves instead of waiting out the full +## command timeout. +func _reply_malformed_command(parsed: Dictionary) -> void: + push_warning("MCP: dropping malformed command frame (request_id/command must be String, params a Dictionary)") + var rid: Variant = parsed.get("request_id") + if rid is String and not String(rid).is_empty(): + var response := ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Malformed command frame: request_id/command must be strings and params a dict" + ) + response["request_id"] = rid + response["readiness"] = get_readiness() + _stamp_error_watermark(response) + _send_json(response) + + +## Send a state event to the server (not a command response). +func send_event(event_name: String, data: Dictionary = {}) -> bool: + return _send_json({"type": "event", "event": event_name, "data": data}) + + +## Push a command response for a request_id whose handler deferred its reply +## (see McpDispatcher.DEFERRED_RESPONSE). `payload` must carry either a `data` +## or `error` field in the same shape handlers normally return. +func send_deferred_response(request_id: String, payload: Dictionary) -> void: + if dispatcher != null and not dispatcher.has_pending_deferred_response(request_id): + if log_buffer: + log_buffer.log("[defer] dropped late response for expired request %s" % request_id) + return + var response := payload.duplicate() + response["request_id"] = request_id + if not response.has("status"): + response["status"] = "ok" if payload.has("data") else "error" + ## Symmetric with McpDispatcher::_dispatch — stamp live readiness on the + ## deferred reply so the server's session cache self-heals from any + ## response, not just the synchronous ones. Lets `project_stop` (the + ## main deferred-response producer) stay correct even if its bespoke + ## `readiness_after` payload field were ever dropped. + if not response.has("readiness"): + response["readiness"] = get_readiness() + if not response.has("error_watermark"): + _stamp_error_watermark(response) + if _send_json(response) and dispatcher != null: + dispatcher.complete_deferred_response(request_id) + + +## Result of one cooperative transport-servicing pass during an exclusive +## synchronous run (currently only the test runner). PAUSED is an +## invariant violation for callers, not a healthy state: a pause held +## across servicing checkpoints would silently starve the heartbeat. +enum ServiceStatus { SERVICED, DISCONNECTED, PAUSED, BLOCKED } + +## Cumulative cap on application packets processed across ONE exclusive +## run. Counts every drained packet — valid command, malformed frame, or +## ack-like — so no frame kind evades it. Past the cap the connection is +## closed (1013): bounded rejects, never unbounded stale buffering. 2048 +## leaves headroom under Godot's default max_queued_packets (4096) and +## sits above stormtest's ~1000-call default workload; tune with +## telemetry/benchmarks if rejection traffic ever extends a checkpoint. +const EXCLUSIVE_RUN_PACKET_CAP := 2048 +const CLOSE_CODE_EXCLUSIVE_RUN_FLOOD := 1013 +## Reject-log throttle: first few rejects verbatim, then periodic totals. +const _SERVICE_REJECT_LOG_FIRST := 5 +const _SERVICE_REJECT_LOG_EVERY := 100 + +## Service the WebSocket transport from inside a long synchronous handler +## (an "exclusive run" — the test runner). The editor main thread is +## blocked, so `_process` cannot poll; without this the server keepalive +## (20s ping interval / 20s timeout) closes the session mid-run. See +## docs/test-run-transport-starvation-plan.md. +## +## Contract — do NOT extend this method to dispatch: +## - `WebSocketPeer.poll()` has no heartbeat-only mode; it also buffers +## application frames. Buffering them past this call would replay them +## STALE after their server-side futures expire (the #712 hazard), so +## every drained command frame is REJECTED immediately with a retryable +## EDITOR_NOT_READY / EDITOR_TEST_RUNNING error instead. +## - Drains to quiescence: poll → drain everything available → poll again, +## until no packets remain. A full packet queue could hide a ping deeper +## in the TCP stream, so nothing may spill to a later checkpoint. +## - `run_state` is caller-owned mutable state carrying the cumulative +## packet counter under "packets_serviced" — no connection-global +## lifecycle that could leak if the run dies. +func service_transport_during_exclusive_run(run_state: Dictionary) -> ServiceStatus: + if connect_blocked: + return ServiceStatus.BLOCKED + if pause_processing: + return ServiceStatus.PAUSED + while true: + _peer.poll() + if _peer.get_ready_state() != WebSocketPeer.STATE_OPEN: + return ServiceStatus.DISCONNECTED + if _peer.get_available_packet_count() == 0: + return ServiceStatus.SERVICED + while _peer.get_available_packet_count() > 0: + var raw: String = _peer.get_packet().get_string_from_utf8() + if _service_note_packet(run_state): + if log_buffer: + log_buffer.log( + "[busy] packet flood during test run (%d > cap %d) — closing connection" + % [int(run_state.get("packets_serviced", 0)), EXCLUSIVE_RUN_PACKET_CAP] + ) + _peer.close(CLOSE_CODE_EXCLUSIVE_RUN_FLOOD, "command flood during test run") + return ServiceStatus.DISCONNECTED + _service_handle_message(raw, int(run_state.get("packets_serviced", 0))) + ## Unreachable: every exit above returns. Keeps the typed signature happy. + return ServiceStatus.SERVICED + + +## Shared between-phase checkpoint for exclusive runs: deadline first +## (cheap), then transport servicing via `service_cb`. Returns "" to +## continue, or a terminal outcome: "timeout" | "transport_lost" | +## "paused". Static and stateless so the runner's between-test checkpoints +## and the handler's discovery checkpoints share ONE outcome mapping — +## PAUSED is abort-worthy (a held pause would silently skip every later +## poll and starve the heartbeat), and DISCONNECTED/BLOCKED both mean "no +## live transport". +static func exclusive_run_checkpoint( + service_cb: Callable, deadline_ticks_ms: int, run_state: Dictionary +) -> String: + if deadline_ticks_ms > 0 and Time.get_ticks_msec() >= deadline_ticks_ms: + return "timeout" + if not service_cb.is_valid(): + return "" + var status: int = service_cb.call(run_state) + if status == ServiceStatus.SERVICED: + return "" + if status == ServiceStatus.PAUSED: + return "paused" + return "transport_lost" + + +## Count one application packet against the exclusive-run cap. Counts EVERY +## drained packet regardless of kind (valid command, malformed, ack-like) +## so no frame kind can evade the flood limit. Returns true once the cap is +## exceeded — the caller closes the connection. +static func _service_note_packet(run_state: Dictionary) -> bool: + var count: int = int(run_state.get("packets_serviced", 0)) + 1 + run_state["packets_serviced"] = count + return count > EXCLUSIVE_RUN_PACKET_CAP + + +## Exclusive-run sink for `_classify_message`: acks are still processed, +## malformed frames keep their normal reply, and valid commands are +## rejected without touching the dispatcher. +func _service_handle_message(raw: String, packets_serviced: int) -> void: + var classified := _classify_message(raw) + match classified["kind"]: + "ack": + _handle_handshake_ack(classified["parsed"]) + "command": + _service_reject_command(classified["parsed"], packets_serviced) + "malformed_command": + _reply_malformed_command(classified["parsed"]) + + +func _service_reject_command(parsed: Dictionary, packets_serviced: int) -> void: + _send_json(_build_service_reject(parsed)) + if log_buffer and ( + packets_serviced <= _SERVICE_REJECT_LOG_FIRST + or packets_serviced % _SERVICE_REJECT_LOG_EVERY == 0 + ): + ## Ring-buffer only (echo=false): a flood must not bury the console. + log_buffer.log( + "[busy] rejected '%s' during test run (packet %d)" + % [parsed.get("command", ""), packets_serviced], + false, + ) + + +## Build the busy-reject response for a valid command frame that arrived +## mid-run. Split from the send so tests can assert the exact wire shape. +func _build_service_reject(parsed: Dictionary) -> Dictionary: + var command: String = parsed.get("command", "") + var response := ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_TEST_RUNNING, + ( + "A test run is in progress on this editor — '%s' was not executed. " + + "Retry when the run completes, or fetch results afterward with " + + "test_manage(op=\"results_get\")." + ) % command, + true, + ) + response["request_id"] = parsed.get("request_id", "") + response["readiness"] = get_readiness() + _stamp_error_watermark(response) + return response + + +func _hook_editor_signals() -> void: + # Scene change: poll in _process since there's no direct signal for scene switch + # Play state: EditorInterface signals + EditorInterface.get_editor_settings() # ensure interface is ready + _last_scene_path = _get_current_scene_path() + _last_play_state = EditorInterface.is_playing_scene() + _last_play_state_for_run = _last_play_state + + +var _last_scene_path := "" +var _last_play_state := false +## Separate edge tracker for game-run bookkeeping: _last_play_state only +## advances when the play_state_changed event sends successfully, but ending +## run tracking must not depend on the websocket being up. +var _last_play_state_for_run := false +var _last_readiness := "" + + +## Compute current editor readiness from live Godot state. +static func get_readiness() -> String: + if EditorInterface.get_resource_filesystem().is_scanning(): + return "importing" + if EditorInterface.is_playing_scene(): + return "playing" + if EditorInterface.get_edited_scene_root() == null: + return "no_scene" + return "ready" + + +## Check for scene/play state changes each frame (lightweight polling). +func _check_state_changes() -> void: + var scene_path := _get_current_scene_path() + if scene_path != _last_scene_path: + if send_event("scene_changed", {"current_scene": scene_path}): + _last_scene_path = scene_path + if log_buffer: + log_buffer.log("[event] scene_changed -> %s" % scene_path) + + var playing := EditorInterface.is_playing_scene() + if playing != _last_play_state: + var state := "playing" if playing else "stopped" + if send_event("play_state_changed", {"play_state": state}): + _last_play_state = playing + if log_buffer: + log_buffer.log("[event] play_state_changed -> %s" % state) + + var readiness := get_readiness() + if readiness != _last_readiness: + if send_event("readiness_changed", {"readiness": readiness}): + _last_readiness = readiness + if log_buffer: + ## echo=false: readiness flips on every filesystem scan + ## (each import cycles importing -> ready), so echoing to + ## console spams every install during normal editing (#626). + ## The line stays in the ring for the dock's log panel. + log_buffer.log("[event] readiness -> %s" % readiness, false) + + +## Stopped↔playing edge handler for game-run bookkeeping. Handles both the +## stopped→playing edge (adopting editor-started runs) and the playing→stopped +## edge (ending runs). Runs every process tick (any socket state) so a +## self-quit game's run ends even while the transport is down or reconnecting. +func _check_game_run_play_state(playing: bool) -> void: + if playing == _last_play_state_for_run: + return + if debugger_plugin != null: + if playing: + ## #891: adopt a play the user started from the editor. The + ## debugger session's own adoption in `_setup_session` only fires + ## when `is_playing_scene()` is already true when the session + ## attaches, which an F5/F6 launch routinely misses; this edge is + ## the other half of that race. Polled every tick regardless of + ## socket state, same as the stop edge below. + debugger_plugin.note_editor_play_started() + else: + debugger_plugin.note_editor_play_stopped() + _last_play_state_for_run = playing + + +func _get_current_scene_path() -> String: + var scene_root := EditorInterface.get_edited_scene_root() + return scene_root.scene_file_path if scene_root else "" + + +func _send_json(data: Dictionary) -> bool: + if not _connected: + return false + var text := JSON.stringify(data) + var buffered_bytes := _peer.get_current_outbound_buffered_amount() + ## `send_text` encodes the string to UTF-8 internally, so an exact + ## `to_utf8_buffer().size()` here would encode every payload twice. Almost + ## all payloads sit far below the limit, so gate on a cheap upper bound + ## (<= 4 UTF-8 bytes per code point) and only pay for the exact count when + ## the estimate lands near the backpressure ceiling. + if _might_exceed_outbound_backpressure(buffered_bytes, text.length()): + var message_bytes := text.to_utf8_buffer().size() + if _would_exceed_outbound_backpressure(buffered_bytes, message_bytes): + return _handle_outbound_backpressure(data, buffered_bytes, message_bytes) + var err := _peer.send_text(text) + if err != OK: + if log_buffer: + log_buffer.log("[send] websocket send_text failed: %s" % error_string(err)) + return false + return true + + +static func _would_exceed_outbound_backpressure(buffered_bytes: int, message_bytes: int) -> bool: + return buffered_bytes + message_bytes > OUTBOUND_BUFFER_LIMIT_BYTES + + +## Cheap pre-check on the code-point count: UTF-8 uses at most 4 bytes per code +## point, so `char_count * 4` upper-bounds the encoded size. When even that +## upper bound fits under the ceiling the payload is definitely safe and we can +## skip the exact encode; only a positive here warrants `to_utf8_buffer()`. +static func _might_exceed_outbound_backpressure(buffered_bytes: int, char_count: int) -> bool: + return buffered_bytes + char_count * 4 > OUTBOUND_BUFFER_LIMIT_BYTES + + +func _handle_outbound_backpressure( + data: Dictionary, + buffered_bytes: int, + message_bytes: int, +) -> bool: + var request_id: String = data.get("request_id", "") + if request_id.is_empty(): + if log_buffer: + log_buffer.log( + "[send] requestless payload blocked by websocket backpressure " + + "(buffered=%d, message=%d, limit=%d)" + % [buffered_bytes, message_bytes, OUTBOUND_BUFFER_LIMIT_BYTES] + ) + return false + + var err_response := _make_backpressure_error(request_id, buffered_bytes, message_bytes) + _stamp_error_watermark(err_response) + var err_text := JSON.stringify(err_response) + var err_bytes := err_text.to_utf8_buffer().size() + if _would_exceed_outbound_backpressure(buffered_bytes, err_bytes): + if log_buffer: + log_buffer.log( + "[send] dropped response for request %s due to websocket backpressure " + + "(buffered=%d, message=%d, limit=%d)" + % [request_id, buffered_bytes, message_bytes, OUTBOUND_BUFFER_LIMIT_BYTES] + ) + return false + + var send_err := _peer.send_text(err_text) + if send_err != OK: + if log_buffer: + log_buffer.log("[send] websocket backpressure error send failed: %s" % error_string(send_err)) + return false + if log_buffer: + log_buffer.log( + "[send] %s -> error: outbound websocket backpressure" + % data.get("command", "response") + ) + return true + + +static func _make_backpressure_error( + request_id: String, + buffered_bytes: int, + message_bytes: int, +) -> Dictionary: + return { + "request_id": request_id, + "status": "error", + "data": {}, + ## Stamp readiness on the backpressure error too — the server's + ## per-response self-heal applies to every response shape the + ## plugin emits, and the next legitimate reply may already be + ## queued behind this one. + "readiness": get_readiness(), + "error": { + "code": ErrorCodes.INTERNAL_ERROR, + "message": ( + "Outbound WebSocket buffer is full; dropped response before queueing " + + "more data. Retry with a smaller payload (for screenshots, lower " + + "max_resolution or set include_image=false)." + ), + "data": { + "buffered_bytes": buffered_bytes, + "message_bytes": message_bytes, + "limit_bytes": OUTBOUND_BUFFER_LIMIT_BYTES, + }, + }, + } + + +func _stamp_error_watermark(response: Dictionary) -> void: + McpSurfacedErrorTracker.stamp_watermark(response, surfaced_error_tracker) + + +## Build a human-readable session ID of form "@<4hex>" from the project path. +## The slug is derived from the project directory name so agents can recognize +## which editor they're targeting; the hex suffix disambiguates same-project twins. +static func _make_session_id(project_path: String) -> String: + var base := project_path.rstrip("/\\").get_file() + if base == "": + base = "project" + var slug := _slugify(base) + if slug == "": + slug = "project" + var suffix := _rand_hex(4) + return "%s@%s" % [slug, suffix] + + +static func _slugify(s: String) -> String: + var out := "" + var prev_dash := false + for c in s.to_lower(): + if (c >= "a" and c <= "z") or (c >= "0" and c <= "9"): + out += c + prev_dash = false + elif not prev_dash and out != "": + out += "-" + prev_dash = true + return out.trim_suffix("-") + + +static func _rand_hex(n: int) -> String: + var bytes := PackedByteArray() + var byte_count := int(ceil(float(n) / 2.0)) + for i in byte_count: + bytes.append(randi() % 256) + return bytes.hex_encode().substr(0, n) diff --git a/addons/godot_ai/connection.gd.uid b/addons/godot_ai/connection.gd.uid new file mode 100644 index 0000000..ff78405 --- /dev/null +++ b/addons/godot_ai/connection.gd.uid @@ -0,0 +1 @@ +uid://bmnk8rsotiks2 diff --git a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd new file mode 100644 index 0000000..45ace63 --- /dev/null +++ b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd @@ -0,0 +1,66 @@ +@tool +extends RefCounted + +## bridge between dispatcher and addon handler + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +var _spec: McpCustomToolSpec +var _locator: McpServiceLocator +var _handler_instance = null # lazily loaded from _spec.script_path + +func _init(spec: McpCustomToolSpec, locator: McpServiceLocator) -> void: + _spec = spec + _locator = locator + +## Invoked by dispatcher._call_handler as .call(params) — SINGLE ARG. +## Internally splits into (clean_params, ctx) for the addon handler. +func invoke(params: Dictionary) -> Dictionary: + ## Dock enable/disable gate: a disabled tool is dropped from the + ## catalog push, but a client holding a stale list (or batch_execute) + ## can still name it — reject at dispatch too. + var registry := McpToolRegistry.get_instance() + if registry != null and not registry.is_tool_enabled(_spec.name): + return ErrorCodes.make(ErrorCodes.CUSTOM_TOOL_DISABLED, + "Custom tool '%s' is disabled in the Godot AI dock" % _spec.name) + ## Readiness gate (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1): block writes during play/import. + ## Checked BEFORE the lazy load so a gated tool's handler script (and any + ## _init side effects) never runs while the editor is busy. + var _readiness := McpConnection.get_readiness() + if _spec.requires_writable and (_readiness == "importing" or _readiness == "playing"): + return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "Editor is '%s' — write blocked for custom tool '%s'" % [_readiness, _spec.name]) + if _handler_instance == null: + var script := load(_spec.script_path) as GDScript + if script == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Cannot load %s" % _spec.script_path) + _handler_instance = script.new() # no-arg; per-call context arrives via ctx + if not _handler_instance.has_method(_spec.method): + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s has no method '%s' for custom tool '%s'" % [_spec.script_path, _spec.method, _spec.name]) + ## Extract _request_id (dispatcher injected it) and strip from params + ## so the addon sees clean params matching its declared schema (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2). + var request_id: String= params.get("_request_id", "") + var clean_params := params.duplicate() + clean_params.erase("_request_id") + ## Construct ctx with transport metadata + live-object locator. + var ctx := McpCallContext.new() + ctx.request_id = request_id + ctx.session_id = _locator.get_connection().get_session_id() + ctx.spec = _spec + ctx.attach_locator(_locator) + ctx.deadline_msec = Time.get_ticks_msec() + _spec.timeout_ms + var result: Dictionary = _handler_instance.call(_spec.method, clean_params, ctx) + if result.get("_deferred", false): + ## Enforce the declared contract: batch_execute and the server's + ## timeout budget both trust spec.deferred, so a non-deferred spec + ## whose handler defers anyway would report success while its real + ## reply arrives uncorrelated later. + if not _spec.deferred: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Custom tool '%s' returned a deferred response but its spec declares deferred=false" % _spec.name) + if _spec.timeout_ms > 0: + ## Handlers return the SHARED McpDispatcher.DEFERRED_RESPONSE + ## const, which Godot makes read-only — stamping the per-spec + ## budget on it directly is a script error that aborts the call. + result = result.duplicate() + result["_deferred_timeout_ms"] = _spec.timeout_ms + return result diff --git a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid new file mode 100644 index 0000000..ff02cb9 --- /dev/null +++ b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid @@ -0,0 +1 @@ +uid://b68uel6lsb7jr diff --git a/addons/godot_ai/custom_tools/mcp_call_context.gd b/addons/godot_ai/custom_tools/mcp_call_context.gd new file mode 100644 index 0000000..6e40d86 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_call_context.gd @@ -0,0 +1,44 @@ +@tool +class_name McpCallContext +extends RefCounted + +## Per-call context handed to addon handlers. +## +## STABLE addon-facing surface (see AGENTS.md — published class_names are +## permanent compat surface): `request_id`, `session_id`, `deadline_msec`, +## `spec` (read-only), `is_expired()`, `send_deferred()`. Anything +## underscore-prefixed is internal wiring owned by the wrapper and may +## change between releases — addons must not reach into it. Keeping this +## surface narrow now is deliberate: it is much cheaper than breaking +## published addons later (#875 review). + +var request_id: String = "" +var session_id: String = "" +var deadline_msec: int = 0 +var spec: McpCustomToolSpec = null + +## Internal — injected by custom_tool_wrapper via attach_locator(). Do not +## use from addon code; the capability methods below are the contract. +var _locator: McpServiceLocator = null + + +## Internal — called by the wrapper during ctx construction. +func attach_locator(locator: McpServiceLocator) -> void: + _locator = locator + + +func is_expired() -> bool: + if deadline_msec == 0: + return false + return Time.get_ticks_msec() > deadline_msec + + +func send_deferred(payload: Dictionary) -> void: + if _locator == null: + push_error("McpCallContext: cannot send deferred response, locator is null") + return + var conn := _locator.get_connection() + if conn == null: + push_error("McpCallContext: cannot send deferred response, connection is null") + return + conn.send_deferred_response(request_id, payload) diff --git a/addons/godot_ai/custom_tools/mcp_call_context.gd.uid b/addons/godot_ai/custom_tools/mcp_call_context.gd.uid new file mode 100644 index 0000000..e37765b --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_call_context.gd.uid @@ -0,0 +1 @@ +uid://bdgqe4gyian2o diff --git a/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd new file mode 100644 index 0000000..8c5a551 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd @@ -0,0 +1,66 @@ +@tool +class_name McpCustomToolSpec +extends RefCounted + +## Descriptor for one custom tool registered by an addon. Mirrors the +## clients/_base.gd data-only descriptor pattern: fields are set by the +## caller, NO Callables (handler instance is materialized lazily by the +## dispatcher from script_path + method, so hot-reload can't SEGV a +## worker mid-call — same rationale as McpClient, issue #229). +## +## Construction: instantiate and set fields, then pass to +## McpToolRegistry.register(spec). Do NOT subclass — custom tools are +## dynamic (one addon may register several), not file-system-scanned +## like clients. + +# --- identity --- +var name: String = "" ## tool name, e.g. "gdunit_run". Must not shadow a built-in. +var description: String = "" ## shown to the agent by the MCP server +var params_schema: Dictionary = {} ## JSON Schema; advertised to the agent for shaping calls. Params are forwarded UNVALIDATED — the handler must validate its own input. + +# --- handler resolution (lazy materialization by dispatcher) --- +var script_path: String = "" ## "res://addons/.../handler.gd"; load()ed on first call +var method: StringName = &"" ## method on the handler; signature: (params: Dictionary, ctx: McpCallContext) -> Dictionary + +# --- source identity (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) --- +var source_path: String = "" ## "plugin.cfg" path: "res://addons/gdunit4_mcp/plugin.cfg". Same path = same addon (replace allowed); different path colliding = reject + dock warning. NOTE: self-declared — a collision/ownership policy for cooperating addons, NOT a security boundary (any in-editor code can claim any path). +var source: String = "" ## display: "gdunit4_mcp". If empty, registry reads [plugin] name from source_path. + +# --- exposure --- +var promoted: bool = false ## opt-in: ask the server to ALSO register this tool as a first-class MCP tool ("custom_") with params_schema attached, so agents get native schemas/validation instead of the custom_manage indirection. The server caps promoted count; overflow stays reachable via custom_manage. + +# --- execution contract --- +var timeout_ms: int = 4500 ## deferred timeout → DEFERRED_TIMEOUT_MS_BY_COMMAND["custom:"] +var deferred: bool = false ## true → handler may return DEFERRED_RESPONSE, push later via ctx.send_deferred(payload) +var requires_writable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1: readiness gate. false → reads run any time; true → blocks during play/import +var undoable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #6: must be true to participate in undo=true batch_execute + +# --- budgets (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #5, enforced at register time) --- +const MAX_DESCRIPTION_CHARS := 600 +const MAX_SCHEMA_BYTES := 8192 +const MAX_TIMEOUT_MS := 120000 +const MIN_TIMEOUT_MS := 500 + + +## Returns an array of human-readable validation errors (empty = valid). +## Called by McpToolRegistry.register() and usable standalone for early +## feedback in addon _enter_tree before the registry is even live. +func validate() -> Array[String]: + var errors: Array[String] = [] + if name.is_empty(): + errors.append("name is empty\n") + if not name.is_valid_ascii_identifier(): + errors.append("name '%s' is not a valid identifier\n" % name) + if description.length() > MAX_DESCRIPTION_CHARS: + errors.append("description exceeds %d chars\n" % MAX_DESCRIPTION_CHARS) + if JSON.stringify(params_schema).to_utf8_buffer().size() > MAX_SCHEMA_BYTES: + errors.append("params_schema exceeds %d bytes\n" % MAX_SCHEMA_BYTES) + if script_path.is_empty() or not ResourceLoader.exists(script_path): + errors.append("script_path '%s' does not exist" % script_path) + if method.is_empty(): + errors.append("method is empty") + if timeout_ms < MIN_TIMEOUT_MS or timeout_ms > MAX_TIMEOUT_MS: + errors.append("timeout_ms %d out of range [%d, %d]" % [timeout_ms, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS]) + if source_path.is_empty() or not source_path.ends_with("plugin.cfg") or not FileAccess.file_exists(source_path): + errors.append("source_path '%s' must be an existing plugin.cfg path" % source_path) + return errors diff --git a/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid new file mode 100644 index 0000000..42fe408 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid @@ -0,0 +1 @@ +uid://h7k8j5tpscwy diff --git a/addons/godot_ai/custom_tools/mcp_service_locator.gd b/addons/godot_ai/custom_tools/mcp_service_locator.gd new file mode 100644 index 0000000..4e50ac5 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_service_locator.gd @@ -0,0 +1,16 @@ +@tool +class_name McpServiceLocator +extends RefCounted + +var _connection: McpConnection = null +var _log_buffer: McpLogBuffer = null + +func setup(connection: McpConnection, log_buffer: McpLogBuffer) -> void: + _connection = connection + _log_buffer = log_buffer + +func get_connection() -> McpConnection: + return _connection + +func get_log_buffer() -> McpLogBuffer: + return _log_buffer diff --git a/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid b/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid new file mode 100644 index 0000000..1815007 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid @@ -0,0 +1 @@ +uid://dfvphmvo7en3i diff --git a/addons/godot_ai/custom_tools/mcp_tool_registry.gd b/addons/godot_ai/custom_tools/mcp_tool_registry.gd new file mode 100644 index 0000000..407d405 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_tool_registry.gd @@ -0,0 +1,277 @@ +@tool +class_name McpToolRegistry +extends RefCounted + +## Central registry for custom tools. INSTANCE SINGLETON (not static) — +## unlike McpClientRegistry which is read-only/loaded-once/query-only (static +## is fine there), this registry is MUTABLE, has LIFECYCLE (clear on teardown), +## must EMIT SIGNALS to multiple consumers, and needs DEPENDENCY INJECTION +## (dispatcher, locator). Static funcs can't emit instance signals in GDScript, +## and static vars create #46 freed-instance regressions across reloads. +## +## plugin.gd owns the instance (new + setup + clear). Addons access it via +## get_instance() so they don't hold a reference. Source identity is +## spec.source_path (explicit plugin.cfg path, https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) — same source_path +## = same addon (hot-reload replace); different source_path colliding on +## a name = reject + dock warning. + +## singleton instance +static var _instance: McpToolRegistry = null + +## Singleton accessor for addons. Returns null if Godot AI isn't loaded. +static func get_instance() -> McpToolRegistry: + return _instance + + +## Instance fields (NOT static — lifecycle tied to plugin.gd) +var _specs: Dictionary = {} # name -> McpCustomToolSpec +var _by_source_path: Dictionary = {} # source_path -> Array[McpCustomToolSpec] (batch unregister index) +var _dispatcher: McpDispatcher = null # injected by plugin.gd via setup() +var _locator: McpServiceLocator = null # injected by plugin.gd via setup() +var _ready: bool = false +var _promoted_cnt = 0 + +## Instance signals (LEGAL — instance funcs can emit these). Multiple consumers +## (plugin.gd, dock, addons) can connect. +signal tools_changed # triggers custom_tools_changed push to server +signal registry_ready # https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8: emit after Godot AI reload so addons can re-register + + +## Called by plugin.gd in _enter_tree. Injects live dependencies and registers +## the singleton. Addons can now reach the registry via get_instance(). +func setup(dispatcher: McpDispatcher, locator: McpServiceLocator) -> void: + _dispatcher = dispatcher + _locator = locator + _load_disabled_tools() + _instance = self + + +func register(spec: McpCustomToolSpec) -> bool: + if not _validate_one(spec): + return false + _commit_one(spec) + tools_changed.emit() + return true + + +## Atomic batch registration: validate ALL specs first (phase 1, zero +## mutation), then commit ALL (phase 2, cannot fail), then emit ONCE. +## A phase-1 failure leaves the registry AND dispatcher untouched — a +## per-spec register-then-bail loop would leave earlier specs committed +## and dispatchable while the server's tool list never hears about them. +func batch_register(specs: Array[McpCustomToolSpec]) -> bool: + if specs.is_empty(): + return true + ## Phase 1 — validate only. Also reject duplicate names WITHIN the + ## batch: _validate_one checks against committed state (nothing from + ## this batch is committed yet), and silently letting the last + ## duplicate win would hide an addon packaging bug. + var batch_names := {} + for spec in specs: + if not _validate_one(spec): + return false + if batch_names.has(spec.name): + push_warning("McpToolRegistry: duplicate name '%s' within batch — rejected" % spec.name) + return false + batch_names[spec.name] = true + ## Phase 2 — commit everything, emit once. + for spec in specs: + _commit_one(spec) + tools_changed.emit() + return true + + +## Phase 1 helper: validation + collision policy. NO registry/dispatcher +## mutation — the only side effect is filling the display-source fallback +## on the spec itself, which is harmless on reject. +func _validate_one(spec: McpCustomToolSpec) -> bool: + var errors := spec.validate() + if not errors.is_empty(): + push_error("McpToolRegistry: rejecting spec '%s': %s" % [spec.name, ", ".join(errors)]) + return false + ## Fill display source from plugin.cfg if addon left it empty. + if spec.source.is_empty(): + spec.source = _read_plugin_name(spec.source_path) + ## reject-on-collision: same source_path (hot-reload) replaces, different rejects + var existing: McpCustomToolSpec= _specs.get(spec.name) + if existing != null and existing.source_path != spec.source_path: + push_warning("McpToolRegistry: name '%s' collision between %s and %s — rejected" % [spec.name, existing.source_path, spec.source_path]) + return false + return true + + +## Phase 2 helper: commit one ALREADY-VALIDATED spec. Handles the +## hot-reload replace (same source_path — guaranteed by _validate_one). +## Never fails, never emits — callers own the tools_changed emit. +func _commit_one(spec: McpCustomToolSpec) -> void: + var existing: McpCustomToolSpec = _specs.get(spec.name) + if existing != null: + ## Replace: purge the OLD registration from all four dispatcher + ## dicts. Command name is "custom_tool:" — passing the bare + ## name would leave a materialized Callable in _handlers and the + ## hot-reloaded spec would silently never take effect. + _dispatcher.unregister("custom_tool:" + existing.name, "custom:" + existing.name) + _erase_from_source_path_index_locked(existing) + _specs[spec.name] = spec + _by_source_path.get_or_add(spec.source_path, []).append(spec) + ## Bridge to dispatcher lazy registration: register_lazy_handler + register_lazy. + ## handler_key = "custom:"; wrapper is CustomToolWrapper (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2). + + var handler_key := "custom:" + spec.name + var wrapper_path := "res://addons/godot_ai/custom_tools/custom_tool_wrapper.gd" + _dispatcher.register_lazy_handler(handler_key, wrapper_path, [spec, _locator]) + _dispatcher.register_lazy("custom_tool:" + spec.name, handler_key, &"invoke") + + +func unregister(name: String) -> bool: + var spec: McpCustomToolSpec = _specs.get(name) + if spec == null: + return false + if _dispatcher != null: + _dispatcher.unregister("custom_tool:" + name, "custom:" + name) + _specs.erase(name) + _erase_from_source_path_index_locked(spec) + tools_changed.emit() + return true + + +## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8: batch-unregister all tools from one addon. Pass the source_path +## (plugin.cfg path) — the registry removes every spec whose source_path matches. +func unregister_source(source_path: String) -> int: + var specs: Array = _by_source_path.get(source_path, []) + var count := specs.size() + for spec in specs.duplicate(): + if _dispatcher != null: + _dispatcher.unregister("custom_tool:" + spec.name, "custom:" + spec.name) + _specs.erase(spec.name) + _by_source_path.erase(source_path) + if count > 0: + tools_changed.emit() + return count + + +func all() -> Array[McpCustomToolSpec]: + var out: Array[McpCustomToolSpec] = [] + for spec in _specs.values(): + out.append(spec) + return out + + +## Enabled specs only — the catalog-push source. Disabled tools stay +## registered (the dock still lists them for re-enabling) but are never +## advertised to the server and are rejected at dispatch. +func enabled() -> Array[McpCustomToolSpec]: + var out: Array[McpCustomToolSpec] = [] + for spec in _specs.values(): + if is_tool_enabled(spec.name): + out.append(spec) + return out + + +# --- per-tool enable state (dock UI) --- +## Persisted per-project via EditorSettings project metadata (the same +## store the editor uses for per-project UI state — no project.godot +## churn, no cross-project bleed). Applies LIVE: toggling re-emits +## tools_changed, which re-pushes the filtered catalog to the server. + +const _META_SECTION := "godot_ai" +const _META_KEY_DISABLED := "disabled_custom_tools" + +var _disabled_tools: Dictionary = {} # name -> true + + +func is_tool_enabled(name: String) -> bool: + return not _disabled_tools.has(name) + + +func set_tool_enabled(name: String, tool_enabled: bool) -> void: + if tool_enabled: + if not _disabled_tools.has(name): + return + _disabled_tools.erase(name) + else: + if _disabled_tools.has(name): + return + _disabled_tools[name] = true + _save_disabled_tools() + tools_changed.emit() + + +func _load_disabled_tools() -> void: + _disabled_tools.clear() + var es := _editor_settings() + if es == null: + return + var stored: Variant = es.get_project_metadata(_META_SECTION, _META_KEY_DISABLED, PackedStringArray()) + for name in PackedStringArray(stored): + _disabled_tools[String(name)] = true + + +func _save_disabled_tools() -> void: + var es := _editor_settings() + if es == null: + return + var names := PackedStringArray() + for name in _disabled_tools.keys(): + names.append(String(name)) + es.set_project_metadata(_META_SECTION, _META_KEY_DISABLED, names) + + +static func _editor_settings() -> EditorSettings: + if not Engine.is_editor_hint(): + return null + return EditorInterface.get_editor_settings() + + +func get_spec(name: String) -> McpCustomToolSpec: + var out: McpCustomToolSpec = _specs.get(name) + return out + + +func is_ready() -> bool: + return _ready + + +## Called from plugin.gd teardown — wipes everything so stale specs/Callables +## don't survive a Godot AI reload (#46 regression class). Signals die with +## the instance (consumers disconnect via plugin.gd's own teardown). +func clear() -> void: + _specs.clear() + _by_source_path.clear() + _disabled_tools.clear() # in-memory only; persistence reloads on next setup() + _ready = false + _dispatcher = null + _locator = null + _instance = null + + +## Called from plugin.gd _enter_tree after setup(). Addons that missed +## _enter_tree during a Godot AI reload listen for registry_ready (via +## get_instance().registry_ready.connect(...)) and re-register deterministically. +func mark_ready() -> void: + _ready = true + registry_ready.emit() + + +# --- internal helpers --- + +func _erase_from_source_path_index_locked(spec: McpCustomToolSpec) -> void: + var specs: Array = _by_source_path.get(spec.source_path, []) + specs.erase(spec) + if specs.is_empty(): + _by_source_path.erase(spec.source_path) + + +## Read [plugin] name from a plugin.cfg for display purposes. +## Returns "" on any error — display source is best-effort. +static func _read_plugin_name(plugin_cfg_path: String) -> String: + var cfg := ConfigFile.new() + if cfg.load(plugin_cfg_path) != OK: + return "" + return cfg.get_value("plugin", "name", "") + +static func _get_command_name(spec: McpCustomToolSpec) -> String: + return "custom_tool:" + spec.name + +static func _get_handler_name(spec: McpCustomToolSpec) -> String: + return "custom:" + spec.name diff --git a/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid b/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid new file mode 100644 index 0000000..23b9822 --- /dev/null +++ b/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid @@ -0,0 +1 @@ +uid://bq2btxtm8flhb diff --git a/addons/godot_ai/debugger/mcp_debugger_plugin.gd b/addons/godot_ai/debugger/mcp_debugger_plugin.gd new file mode 100644 index 0000000..bfa8e10 --- /dev/null +++ b/addons/godot_ai/debugger/mcp_debugger_plugin.gd @@ -0,0 +1,1699 @@ +@tool +class_name McpDebuggerPlugin +extends EditorDebuggerPlugin + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Editor-side half of the game-process capture bridge. +## +## The game-side counterpart (`plugin/addons/godot_ai/runtime/game_helper.gd`, +## registered as autoload `_mcp_game_helper`) listens on EngineDebugger's +## message channel. This plugin sends "mcp:take_screenshot" requests and +## routes the replies back through the WebSocket McpConnection using the +## request_id the MCP dispatcher threaded through params. +## +## Why this exists: the game always runs as a separate OS process. Even +## "Embed Game Mode" on Windows/Linux (and macOS 4.5+) just reparents the +## game's window into the editor — the game's framebuffer is never reachable +## from the editor's Viewport. The debugger channel is the engine's own +## supported IPC and works identically regardless of embed mode. + +const CAPTURE_PREFIX := "mcp" +## CI runners under xvfb can be slow to spin up the game subprocess and +## register the autoload's capture. 8s keeps the message responsive for +## interactive users while still covering slow-CI startup. +const DEFAULT_TIMEOUT_SEC := 8.0 +## How long to wait for the game-side autoload to beacon mcp:hello +## before sending the screenshot request. Godot's debugger drops +## messages whose prefix has no registered capture, so sending +## take_screenshot before the game registers its "mcp" capture is a +## silent black hole. On CI the game subprocess has been observed +## taking ~15s to boot + register. +const GAME_READY_WAIT_SEC := 20.0 +## #500: how long to wait for the game-side autoload to beacon mcp:hello before +## issuing a game_eval. This is deliberately MUCH shorter than the 20s +## screenshot wait above: the eval path's total editor-side budget is this wait +## plus the 250ms liveness probe and 10s eval backstop +## (request_game_eval's timeout_sec), and that total MUST stay below the 15s +## game_eval timeout enforced at two layers: the Python server's send_command +## budget (src/godot_ai/handlers/editor.py::game_eval) and this plugin's own +## deferred budget (dispatcher.gd's 15000ms game_eval entry, editor/plugin-side +## — not server-side). Either firing produces the opaque tail. +## With the 20s screenshot wait, a not-yet-ready game made the editor poll past +## the 15s deadline, so the server gave up first with an opaque +## ~15s TimeoutError instead of the actionable "Is the game actually running?" +## error below ever reaching the client (#500's residual TimeoutError bucket). +## 3s wait + 0.25s probe + 10s backstop = 13.25s, comfortably under the 15s +## server timeout, so the actionable error always wins. A game launched moments +## before the eval still has the 3s grace to register; if it needs longer, the +## user gets a fast, clear "is it running?" rather than a 15s hang. +const EVAL_READY_WAIT_SEC := 3.0 +## #859: once the helper has registered, confirm its game loop is still +## advancing before dispatching an eval. The helper replies synchronously from +## the debugger capture, so 250ms is ample while keeping not-ready failures +## below the telemetry target of 0.5s. +const EVAL_LIVENESS_WAIT_SEC := 0.25 +## #490: how long to wait for the game's mcp:eval_compiled beacon before +## concluding the eval source failed to compile. A parse error aborts the +## game-side handler before it can reply, so without this we'd wait the +## full eval timeout for a syntax mistake. reload() of valid source is +## sub-millisecond, so 3s is comfortably clear of false positives. +const EVAL_COMPILE_GRACE_SEC := 3.0 +## #490: once an eval compiles, the editor polls the game every this many +## seconds with mcp:eval_check. A backgrounded play-in-editor game has a +## frozen idle loop (no _process / SceneTreeTimer ticks) so it can't +## self-report a runtime error that aborted the eval — but its debugger +## capture callback still answers a probe. The editor's own loop keeps +## ticking, so it drives the poll. 0.35s keeps detection well under a second +## without flooding the channel; most evals reply before the first probe. +const EVAL_PROBE_INTERVAL_SEC := 0.35 + +const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd") + +var _log_buffer: McpLogBuffer +var _game_log_buffer: McpGameLogBuffer +var _editor_log_buffer: McpEditorLogBuffer +var _surfaced_error_tracker +var vision_routing: VisionRoutingScript = null + +## Pending request_id -> phase-specific data. Every eval phase carries kind +## and connection; timer-backed phases also retain their bound timeout lambda +## so `_clear_pending` can disconnect it on success/error. Otherwise the +## SceneTreeTimer pins the captured request_id until its timeout elapses. +var _pending: Dictionary = {} + +## Testing seam for the readiness wait below. Production leaves this invalid +## and awaits SceneTree.process_frame; editor tests inject a synchronous +## callable because their runner does not pump frames while a test is running. +var _eval_ready_frame_waiter: Callable = Callable() + +## Flipped true when the game-side autoload sends its "mcp:hello" boot +## beacon for the current project_run. Reset as soon as a new run is +## requested, before Godot has attached the fresh debugger session, so +## editor_state cannot leak readiness from the previous game process. +var _game_ready := false +var _game_run_token := 0 +var _ready_run_token := -1 +var _game_session_id := -1 +var _game_run_active := false +## Debugger session id of an `mcp:hello` that arrived before the run was +## adopted, or -1 when none is held (#891). A manually started play can beat +## its own adoption: the helper's boot beacon is a one-shot, so discarding it +## left the runtime bridge dead for the whole run with no retry. Buffer it and +## replay on adoption instead. Cleared whenever a run ends, so a beacon can +## never carry across into a later run. +var _pending_hello_session_id := -1 +## True when holding a beacon already rotated the game-log run id, so the +## adoption that consumes it must not rotate again (#891 review). +var _pre_adoption_log_rotated := false +var _manual_run_armed := false +var _game_run_started_msec := 0 +var _game_run_started_editor_cursor := 0 +var _game_run_started_debugger_cursor := 0 +var _game_helper_expected := true + +## #645: a GDScript parse error hit while an editor-launched game boots calls +## GDScriptLanguage::debug_break_parse — the game parks in a remote-debugger +## break BEFORE the helper's mcp:hello and before any record reaches the +## Errors tab, the editor Logger, or the game log. The only editor-side traces +## are the debugger break signals; the stack frames land in the Stack Trace +## panel a few frames later. Track the break here so game_status can report +## status="break" and a synthesized error record can name the failure. +var _break_active := false +var _break_can_debug := false +var _break_reason := "" +var _break_pre_live := false +var _break_run_token := -1 +var _break_record_synthesized := false + +## #645: how long after the break signal to scrape the Stack Trace panel for +## frames. The editor requests the stack dump from the game separately, so the +## panel is empty at signal time; ~0.5s has it populated. The late tick +## synthesizes with whatever is available so a scrape failure still yields a +## record carrying the break reason. +const BREAK_FRAME_SCRAPE_DELAYS_SEC: Array[float] = [0.5, 2.0] + + + +func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, surfaced_error_tracker = null, vision_routing: VisionRoutingScript = null) -> void: + _log_buffer = log_buffer + _game_log_buffer = game_log_buffer + _editor_log_buffer = editor_log_buffer + _surfaced_error_tracker = surfaced_error_tracker + self.vision_routing = vision_routing + + +func _has_capture(prefix: String) -> bool: + return prefix == CAPTURE_PREFIX + + +## Fires when a debugger session attaches — once for the editor's own +## self-session at startup, and again each time the user hits Play and a +## new game subprocess connects. Reset _game_ready so the next capture +## request waits for the (new) game's mcp:hello beacon before sending, +## avoiding stale-flag timeouts across Play→Stop→Play cycles. +## +## Do NOT log here: add_debugger_plugin() triggers this virtual before +## plugin.gd's _enter_tree logs "plugin loaded", and ci-reload-test +## asserts "plugin loaded" is the first line after a plugin reload. +func _setup_session(session_id: int) -> void: + _connect_session_stopped(session_id) + _connect_session_break_signals(session_id) + if EditorInterface.is_playing_scene() and not _game_run_active: + _begin_game_run_tracking(_editor_log_cursor(), true, true, true, true, true) + else: + _game_ready = false + _ready_run_token = -1 + _game_session_id = session_id + + +func begin_game_run(editor_log_cursor: int = 0, helper_expected: bool = true) -> void: + _begin_game_run_tracking(editor_log_cursor, helper_expected, true, true) + + +func _begin_game_run_tracking( + editor_log_cursor: int = 0, + helper_expected: bool = true, + rotate_game_log: bool = true, + sticky_debugger_scan: bool = true, + quiet: bool = false, + manual_armed: bool = false, + keep_session_id: int = -1, +) -> void: + _game_run_token += 1 + _game_run_active = true + _manual_run_armed = manual_armed + _game_ready = false + _ready_run_token = -1 + ## Adopting a run whose game is ALREADY attached keeps that session id + ## (#891 review): clearing it would leave every staleness guard comparing + ## against -1, so a foreign session's `stopped` could end this live run — + ## `_on_debugger_session_stopped` lets a manual-armed run end on any + ## session while the id is unknown. Spawn-first callers pass -1 and keep + ## the historical clear, since their game has not attached yet. + _game_session_id = keep_session_id + clear_debug_break() + _game_run_started_msec = Time.get_ticks_msec() + _game_run_started_editor_cursor = maxi(0, editor_log_cursor) + if _surfaced_error_tracker != null: + _surfaced_error_tracker.note_game_run_started(sticky_debugger_scan) + _game_run_started_debugger_cursor = _surfaced_error_tracker.debugger_promoted_total() + else: + _game_run_started_debugger_cursor = 0 + _game_helper_expected = helper_expected + var run_id := "" + if _game_log_buffer and rotate_game_log: + if _pre_adoption_log_rotated: + ## Already rotated when this game's beacon was held, so its boot + ## output is tagged with the run id this adoption is about to + ## announce. Rotating again here would orphan those lines under a + ## superseded id and `logs_read(source="game")` — which returns the + ## CURRENT run only — would report an empty log for a game whose + ## only output happens in `_ready()` (#891 review). + run_id = _game_log_buffer.run_id() + else: + run_id = _game_log_buffer.clear_for_new_run() + _pre_adoption_log_rotated = false + if _log_buffer and not quiet: + var log_text := "[debug] game capture pending run token %d" % _game_run_token + if not run_id.is_empty(): + log_text += " (run %s)" % run_id + _log_buffer.log(log_text) + ## #891: a beacon that beat this adoption is held rather than dropped — + ## consume it now so the bridge comes up without waiting for a second + ## hello the game will never send. + _replay_pending_hello() + + +func _editor_log_cursor() -> int: + return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0 + + +func end_game_run() -> void: + _fail_pending_evals_not_ready( + "The game run ended before game_eval completed — the game stopped, crashed, or is restarting. Confirm it is running and retry." + ) + ## Never carry a held beacon across a run boundary (#891). + _pending_hello_session_id = -1 + _pre_adoption_log_rotated = false + _game_run_active = false + _manual_run_armed = false + _game_ready = false + _ready_run_token = -1 + _game_session_id = -1 + clear_debug_break() + if _surfaced_error_tracker != null: + _surfaced_error_tracker.note_game_run_stopped() + + +## Editor play-state edge, stopped -> playing (#891). `_setup_session` adopts a +## manually started play only when `EditorInterface.is_playing_scene()` is +## ALREADY true at the instant Godot attaches the debugger session — an F5/F6 +## launch routinely loses that race, and the run was then never adopted at all, +## leaving game_eval, game logs and runtime inspection silently dead for its +## whole lifetime. This closes the race from the other side: whichever of the +## two edges lands second performs the adoption. No-op for MCP-started runs, +## which `project_run` already adopted via `begin_game_run`. +func note_editor_play_started() -> void: + if _game_run_active: + return + ## State belonging to the game that is already talking must survive the + ## adoption that is about to reset run bookkeeping (#891 review). + var attached_session := _game_session_id + var had_break := _break_active + var break_can_debug := _break_can_debug + var break_reason := _break_reason + _begin_game_run_tracking( + _editor_log_cursor(), true, true, true, true, true, attached_session + ) + if had_break: + ## A boot parse error can break the game before the play-state edge + ## lands. `_begin_game_run_tracking` clears break state, which would + ## drop the actionable #645 diagnosis and leave game_status reporting + ## "not_live" instead of "break". Re-record it against the new run so + ## `_break_pre_live` and the synthetic-error scan are computed for THIS + ## run — the beacon will never arrive for a game parked at a break. + note_debug_break(break_can_debug, break_reason) + + +## Authoritative fallback for runs whose debugger `stopped` signal never +## fired or was never connected: the editor's play state falling to stopped +## means the game process is gone. A game that exits on its own +## (get_tree().quit(), crash) has no MCP stop op to run the bookkeeping, and +## without this game_status stayed "live" until the next run (#642 smoke). +## Called on the playing→stopped edge only, so the pre-play launch window +## (run tracking begun, is_playing_scene() not yet true) is never clipped. +func note_editor_play_stopped() -> void: + if not _game_run_active: + ## A beacon held for an adoption that never happened must not survive + ## into the next run (#891). + _pending_hello_session_id = -1 + _pre_adoption_log_rotated = false + return + end_game_run() + + +## Apply the game helper's boot beacon: the game has registered its "mcp" +## capture and is safe to send take_screenshot / eval requests to — before +## this, Godot's debugger drops our messages silently. Shared by the live +## `mcp:hello` branch in `_capture` and by the buffered replay in +## `_replay_pending_hello`, so a held beacon produces exactly the same state +## and side effects as one that arrived after adoption (#891). +func _accept_game_hello(session_id: int) -> void: + _pending_hello_session_id = -1 + ## Bind the run to the game that actually announced itself. Adoption + ## clears `_game_session_id`, so without this a replayed beacon would + ## leave the run bound to nothing and the staleness checks toothless + ## until Godot's next `_setup_session` (#891 review). + if session_id != -1: + _game_session_id = session_id + _game_ready = true + _ready_run_token = _game_run_token + ## #641: boot-time parse errors race the hello beacon — both ride + ## the same debugger channel, and the editor inserts Errors-tab + ## rows with a per-frame throttle, so rows can land moments after + ## the run is declared live. Arm forced scans so those rows get + ## promoted into the watermark even if no tool call follows. + if _surfaced_error_tracker != null: + _surfaced_error_tracker.schedule_deferred_scans() + if _log_buffer: + if _game_log_buffer: + _log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % _game_log_buffer.run_id()) + else: + _log_buffer.log("[debug] <- mcp:hello from game_helper") + + +## Consume a beacon that arrived before this run was adopted. Called at the end +## of run-tracking setup; no-op when nothing is held (#891). +func _replay_pending_hello() -> void: + if _pending_hello_session_id == -1: + return + var held := _pending_hello_session_id + if _game_session_id != -1 and held != _game_session_id: + ## Belongs to a different debugger session than the one this run is + ## bound to — drop it rather than declare the wrong game live. + _pending_hello_session_id = -1 + if _log_buffer: + _log_buffer.log("[debug] dropped held mcp:hello from debugger session %d (current %d)" % [held, _game_session_id]) + return + if _log_buffer: + _log_buffer.log("[debug] replaying held mcp:hello from debugger session %d" % held) + _accept_game_hello(held) + + +func _connect_session_stopped(session_id: int) -> void: + var session = get_session(session_id) + if session == null: + return + var stopped := Callable(self, "_on_debugger_session_stopped").bind(session_id) + if not session.stopped.is_connected(stopped): + session.stopped.connect(stopped) + + +func _on_debugger_session_stopped(session_id: int) -> void: + if _game_session_id != -1 and session_id != _game_session_id: + return + ## MCP-started runs normally end via project_manage(op="stop"), but a game + ## that exits on its own (get_tree().quit(), crash) emits only this signal. + ## Without ending the run here, game_status stays "live" until the next + ## run's bookkeeping rewrites it (#642 live smoke). Before the game session + ## attaches (_game_session_id == -1) only manual runs may end on this + ## signal — a foreign session's stop must not cancel a launching MCP run. + if not _manual_run_armed and _game_session_id == -1: + return + end_game_run() + + +## #859: a closed debugger session is authoritative. Do not leave evals on +## their 10s backstop after the game process has already gone away. Other +## pending request kinds keep their existing ownership and timeout behavior. +func _fail_pending_evals_not_ready(message: String) -> void: + for request_id in _pending.keys(): + var pending: Dictionary = _pending.get(request_id, {}) + if str(pending.get("kind", "")) not in [ + "eval_ready_wait", "eval_liveness", "eval" + ]: + continue + var connection: McpConnection = pending.get("connection") + _clear_pending(str(request_id)) + if connection != null and is_instance_valid(connection): + _send_error(connection, str(request_id), ErrorCodes.EVAL_GAME_NOT_READY, message) + + +func _is_current_game_run(run_token: int) -> bool: + return _game_run_active and _game_run_token == run_token + + +func _fail_eval_not_ready(request_id: String, message: String) -> void: + var pending: Dictionary = _pending.get(request_id, {}) + var connection: McpConnection = pending.get("connection") + _clear_pending(request_id) + if connection != null and is_instance_valid(connection): + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, message) + + +## --- #645: boot-time debugger breaks --------------------------------------- + +func _connect_session_break_signals(session_id: int) -> void: + var session = get_session(session_id) + if session != null: + var breaked_cb := Callable(self, "_on_debugger_session_breaked").bind(session_id) + if not session.breaked.is_connected(breaked_cb): + session.breaked.connect(breaked_cb) + var continued_cb := Callable(self, "_on_debugger_session_continued").bind(session_id) + if not session.continued.is_connected(continued_cb): + session.continued.connect(continued_cb) + _connect_script_debugger_breaked() + + +## The session-level `breaked` signal carries only can_debug; the underlying +## ScriptEditorDebugger's own `breaked` also carries the human-readable break +## reason ("Parser Error: ..."), which is otherwise visible only in the +## Debugger UI. EditorDebuggerSession does not expose its debugger node, so +## locate ScriptEditorDebugger instances by walking the editor UI — the same +## approach as the tracker's Errors-tab scrape. +func _connect_script_debugger_breaked() -> void: + var base := EditorInterface.get_base_control() + if base == null: + return + var debuggers: Array[Node] = [] + _collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers) + for dbg in debuggers: + if not dbg.has_signal("breaked"): + continue + var cb := Callable(self, "_on_script_debugger_breaked") + if not dbg.is_connected("breaked", cb): + dbg.connect("breaked", cb) + + +static func _collect_nodes_of_class(node: Node, klass: String, out: Array[Node]) -> void: + if node.get_class() == klass: + out.append(node) + for child in node.get_children(): + _collect_nodes_of_class(child, klass, out) + + +func _on_debugger_session_breaked(can_debug: bool, session_id: int) -> void: + if _game_session_id != -1 and session_id != _game_session_id: + return + note_debug_break(can_debug, "") + + +func _on_debugger_session_continued(session_id: int) -> void: + if _game_session_id != -1 and session_id != _game_session_id: + return + clear_debug_break() + + +## reallydid=false is Godot's "left the break" notification (debug_exit). +func _on_script_debugger_breaked(reallydid: bool, can_debug: bool, reason: String, _has_stackdump: bool) -> void: + if not reallydid: + clear_debug_break() + return + note_debug_break(can_debug, reason) + + +## Record that the game process is parked in a remote-debugger break. Fires +## once per break from the session signal (no reason text) and again moments +## later from the ScriptEditorDebugger signal (with reason) — the notices +## merge into one break. Public so tests can drive break state directly. +func note_debug_break(can_debug: bool, reason: String) -> void: + var first_notice := not _break_active + _break_active = true + _break_can_debug = can_debug + if not reason.is_empty(): + _break_reason = reason + if not first_notice: + return + _break_run_token = _game_run_token + _break_pre_live = _game_run_active and not is_game_capture_ready() + _break_record_synthesized = false + if _log_buffer: + _log_buffer.log("[debug] debugger break (pre_live=%s can_debug=%s)" % [str(_break_pre_live), str(can_debug)]) + if _break_pre_live: + _schedule_break_record_synthesis() + + +func clear_debug_break() -> void: + _break_active = false + _break_can_debug = false + _break_reason = "" + _break_pre_live = false + _break_run_token = -1 + _break_record_synthesized = false + + +## Stack frames land in the Stack Trace panel a few frames after the break +## signal (the editor requests the stack dump separately), so the record is +## synthesized on short timers rather than at signal time. +func _schedule_break_record_synthesis() -> void: + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + return + var token := _break_run_token + for i in BREAK_FRAME_SCRAPE_DELAYS_SEC.size(): + var final := i == BREAK_FRAME_SCRAPE_DELAYS_SEC.size() - 1 + var timer := tree.create_timer(BREAK_FRAME_SCRAPE_DELAYS_SEC[i]) + timer.timeout.connect(func() -> void: _on_break_scrape_tick(token, final)) + + +func _on_break_scrape_tick(run_token: int, final: bool) -> void: + if not _break_active or _break_record_synthesized or run_token != _break_run_token: + return + var frames := _scrape_break_stack_frames() + if frames.is_empty() and not final: + return + synthesize_break_error_record(frames) + + +## Read the debugger's Stack Trace panel rows. Row metadata is a Dictionary +## {frame, file, function, line} regardless of editor locale, so stack trees +## are identified by metadata shape rather than the translated column title. +func _scrape_break_stack_frames() -> Array[Dictionary]: + var base := EditorInterface.get_base_control() + if base == null: + return [] + var debuggers: Array[Node] = [] + _collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers) + for dbg in debuggers: + var trees: Array[Node] = [] + _collect_nodes_of_class(dbg, "Tree", trees) + for t in trees: + var frames := _frames_from_stack_tree(t as Tree) + if not frames.is_empty(): + return frames + return [] + + +static func _frames_from_stack_tree(tree: Tree) -> Array[Dictionary]: + var frames: Array[Dictionary] = [] + var root := tree.get_root() + if root == null: + return frames + var item := root.get_first_child() + while item != null: + var meta = item.get_metadata(0) + if not (meta is Dictionary and meta.has("file") and meta.has("frame")): + return [] + frames.append({ + "path": str(meta.get("file", "")), + "line": int(meta.get("line", 0)), + "function": str(meta.get("function", "")), + }) + item = item.get_next() + return frames + + +## Build the Errors-tab-shaped record for a boot-time break and promote it via +## the tracker so recent_editor_errors_since / logs_read / the watermark all +## surface it — the break itself produces no record anywhere else (#645). +## Public so tests can synthesize without waiting on scrape timers. +func synthesize_break_error_record(frames: Array[Dictionary]) -> void: + if _break_record_synthesized: + return + _break_record_synthesized = true + if _surfaced_error_tracker == null: + return + var reason := _break_reason + if reason.is_empty(): + reason = "Game process broke into the debugger during startup (script parse/load error; reason not captured)" + var top: Dictionary = frames[0] if not frames.is_empty() else {} + var location := { + "path": str(top.get("path", "")), + "line": int(top.get("line", 0)), + "function": str(top.get("function", "")), + } + var entry := { + "source": "editor", + "level": "error", + "text": reason, + "path": location["path"], + "line": location["line"], + "function": location["function"], + "details": { + "debugger_tab": "Stack Trace", + "message": reason, + "error_type_name": "debugger_break", + "source": location.duplicate(true), + "resolved": location.duplicate(true), + "frames": frames.duplicate(true), + }, + } + _surfaced_error_tracker.record_synthetic_error(entry) + if _log_buffer: + _log_buffer.log("[debug] synthesized boot-break error record: %s" % McpSurfacedErrorTracker.format_editor_error_summary(entry)) + + +## --- end #645 --------------------------------------------------------------- + + +func is_game_capture_ready() -> bool: + return _game_run_active and _game_ready and _ready_run_token == _game_run_token + + +static func with_liveness_flags(status: Dictionary) -> Dictionary: + var enriched := status.duplicate(true) + var state := str(enriched.get("status", "stopped")) + enriched["helper_live"] = state == "live" + enriched["session_active"] = not state in ["not_live", "stopped"] + return enriched + + +func get_game_status(now_msec: int = -1, ready_wait_sec: float = GAME_READY_WAIT_SEC) -> Dictionary: + var resolved_now := Time.get_ticks_msec() if now_msec < 0 else now_msec + var ready_wait_msec := maxi(0, int(ready_wait_sec * 1000.0)) + var elapsed_msec := maxi(0, resolved_now - _game_run_started_msec) if _game_run_active else 0 + ## "stopped" also covers idle/never-ran; no game run is currently active. + var status := "stopped" + if _game_run_active: + ## #645: a parked process takes precedence over "live" — a game frozen + ## in a remote-debugger break cannot service game-side tools even when + ## its helper registered before the break. + if _break_active: + status = "break" + elif is_game_capture_ready(): + status = "live" + elif not _game_helper_expected: + status = "no_helper" + elif elapsed_msec >= ready_wait_msec: + status = "not_live" + else: + status = "launching" + var out := { + "status": status, + "run_token": _game_run_token, + "active": _game_run_active, + "ready": is_game_capture_ready(), + "helper_expected": _game_helper_expected, + "run_started_msec": _game_run_started_msec, + "elapsed_msec": elapsed_msec, + "ready_wait_msec": ready_wait_msec, + "editor_log_cursor": _game_run_started_editor_cursor, + } + if status == "break": + out["break"] = { + "reason": _break_reason, + "can_debug": _break_can_debug, + "pre_live": _break_pre_live, + } + return with_liveness_flags(out) + + +func _explain_not_live(status: Dictionary, code: String = ErrorCodes.INTERNAL_ERROR) -> Dictionary: + var state := str(status.get("status", "stopped")) + var errors_info := recent_editor_errors_since(int(status.get("editor_log_cursor", 0))) + var recent_errors: Array = errors_info.get("errors", []) + var recent_errors_scope := str(errors_info.get("scope", "none")) + var truncated := bool(errors_info.get("truncated", false)) + var data := { + "game_status": status.duplicate(true), + "recent_errors": recent_errors, + "recent_errors_scope": recent_errors_scope, + "recent_errors_may_predate_run": recent_errors_scope == "retained_recent", + "recent_errors_truncated": truncated, + } + data.merge(split_errors_by_scope(recent_errors, recent_errors_scope), true) + var message := "" + match state: + "not_live": + if not recent_errors.is_empty() and recent_errors_scope == "run": + message = "The game failed to load or crashed before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0]) + if truncated: + message += " Editor logs since this run may be truncated; showing retained errors." + elif not recent_errors.is_empty(): + message = "The game is not responding and reported no load errors during this run. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0]) + else: + message = "The game is not responding and reported no load errors before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and retry." + "break": + var break_info: Dictionary = status.get("break", {}) + var break_reason := str(break_info.get("reason", "")) + var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else "" + if bool(break_info.get("pre_live", true)): + message = "The game hit a script error during startup and is frozen at a debugger break%s. It cannot become live; call project_manage(op='stop') to end the run, fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % reason_suffix + else: + message = "The game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix + "no_helper": + message = "The running game has no _mcp_game_helper autoload, so game-side tools cannot connect. If this is a headless or custom-main-loop project, use editor_screenshot(source='viewport') where applicable. Otherwise, re-enable the plugin and relaunch the game." + "launching": + message = "The game is still starting (%.1fs elapsed); the Godot AI game helper has not registered yet. Retry shortly." % (float(status.get("elapsed_msec", 0)) / 1000.0) + "stopped": + message = "The game is not running. Start the project and retry the game-side tool." + _: + message = "The game-side tool could not confirm the game is live (status=%s). Check logs_read(source='editor', include_details=true) and retry." % state + var err := ErrorCodes.make(code, message) + var inner: Dictionary = err.get("error", {}) + inner["data"] = data + err["error"] = inner + return err + + +static func split_errors_by_scope(recent_errors: Array, scope: String) -> Dictionary: + var current_run_errors: Array = [] + var retained_errors: Array = [] + if scope == "run": + current_run_errors = recent_errors + elif scope == "retained_recent": + retained_errors = recent_errors + return { + "current_run_errors": current_run_errors, + "retained_errors": retained_errors, + } + + +## `force_debugger_scan` bypasses the tracker's scan gate for one read. Keep it +## false on per-frame polling paths (the run-liveness loop) — a forced scan +## walks the Debugger dock UI — and pass true only for one-shot reads that must +## see rows which landed after the last gated scan (#641). +func recent_editor_errors_since(cursor: int, force_debugger_scan: bool = false) -> Dictionary: + return _recent_editor_errors_since(cursor, force_debugger_scan) + + +func _recent_editor_errors_since(cursor: int, force_debugger_scan: bool = false) -> Dictionary: + var out: Array[Dictionary] = [] + var truncated := false + if _surfaced_error_tracker != null: + var captured_by_tracker: Dictionary = _surfaced_error_tracker.editor_entries_since( + maxi(0, cursor), + _game_run_started_debugger_cursor, + force_debugger_scan, + ) + truncated = bool(captured_by_tracker.get("truncated", false)) + for raw_entry in captured_by_tracker.get("entries", []): + var compact := _compact_editor_error(raw_entry) + if compact.is_empty(): + continue + out.append(compact) + if out.size() >= 5: + break + if not out.is_empty(): + return {"errors": out, "truncated": truncated, "scope": "run"} + for raw_entry in _surfaced_error_tracker.retained_recent_editor_entries(): + var compact := _compact_editor_error(raw_entry, true) + if compact.is_empty(): + continue + out.append(compact) + if out.size() >= 5: + break + if not out.is_empty(): + return {"errors": out, "truncated": false, "scope": "retained_recent"} + return {"errors": out, "truncated": false, "scope": "none"} + if _editor_log_buffer == null: + return {"errors": out, "truncated": false, "scope": "none"} + var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, cursor), -1) + truncated = bool(captured.get("truncated", false)) + for raw_entry in captured.get("entries", []): + var compact := _compact_editor_error(raw_entry) + if compact.is_empty(): + continue + out.append(compact) + if out.size() >= 5: + break + if not out.is_empty(): + return {"errors": out, "truncated": truncated, "scope": "run"} + + for raw_entry in _reversed_entries(_editor_log_buffer.get_recent(McpEditorLogBuffer.MAX_LINES)): + var compact := _compact_editor_error(raw_entry, true) + if compact.is_empty(): + continue + out.append(compact) + if out.size() >= 5: + break + if not out.is_empty(): + return {"errors": out, "truncated": false, "scope": "retained_recent"} + return {"errors": out, "truncated": false, "scope": "none"} + + +func _compact_editor_error(raw_entry: Variant, fallback_recent: bool = false) -> Dictionary: + if not raw_entry is Dictionary: + return {} + var entry := raw_entry as Dictionary + if str(entry.get("level", "info")) != "error": + return {} + var path := str(entry.get("path", "")) + if fallback_recent and _is_diagnostic_noise_path(path): + return {} + var compact := { + "source": "editor", + "level": "error", + "text": str(entry.get("text", "")), + "path": path, + "line": int(entry.get("line", 0)), + "function": str(entry.get("function", "")), + } + if entry.has("details"): + compact["details"] = entry["details"].duplicate(true) + return compact + + +func _is_diagnostic_noise_path(path: String) -> bool: + return path.begins_with("res://addons/godot_ai/") or path.begins_with("res://tests/") + + +func _reversed_entries(entries: Array[Dictionary]) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + for i in range(entries.size() - 1, -1, -1): + out.append(entries[i]) + return out + + +func _format_editor_error_summary(entry: Dictionary) -> String: + return McpSurfacedErrorTracker.format_editor_error_summary(entry) + + +func _capture(message: String, data: Array, session_id: int) -> bool: + ## Godot passes the full "prefix:tail" string as `message`. + match message: + "mcp:screenshot_response": + _on_screenshot_response(data) + return true + "mcp:screenshot_error": + _on_screenshot_error(data) + return true + "mcp:log_batch": + _on_log_batch(data) + return true + "mcp:hello": + ## Staleness is checked FIRST, before the buffering branch below: + ## `_begin_game_run_tracking` clears `_game_session_id`, so a beacon + ## validated only at replay time could never be rejected (#891 + ## review). A beacon from a session other than the attached one is + ## some other game's — never hold it, never apply it. + if _game_session_id != -1 and session_id != _game_session_id: + if _log_buffer: + _log_buffer.log("[debug] ignored stale mcp:hello from debugger session %d (current %d)" % [session_id, _game_session_id]) + return true + if not _game_run_active: + ## #891: the run is not adopted YET — a manually started play + ## whose debugger session attached before the editor flipped + ## `is_playing_scene()` lands here. The beacon is a one-shot, + ## so hold it for the imminent adoption instead of dropping it + ## (which left game_eval waiting forever for a hello that had + ## already been thrown away). + _pending_hello_session_id = session_id + ## The beacon is this game's first word, and its boot output + ## follows immediately (the helper flushes on its first + ## `_process`). Rotate the run id NOW so those lines are tagged + ## with the identity adoption will announce, instead of landing + ## under the previous run and vanishing from + ## `logs_read(source="game")` (#891 review). + if _game_log_buffer and not _pre_adoption_log_rotated: + _game_log_buffer.clear_for_new_run() + _pre_adoption_log_rotated = true + if _log_buffer: + _log_buffer.log("[debug] holding mcp:hello from debugger session %d until the run is adopted" % session_id) + return true + _accept_game_hello(session_id) + return true + "mcp:eval_liveness_response": + _on_eval_liveness_response(data) + return true + "mcp:eval_response": + _on_eval_response(data) + return true + "mcp:eval_error": + _on_eval_error(data) + return true + "mcp:eval_ack": + _on_eval_ack(data) + return true + "mcp:eval_compiled": + _on_eval_compiled(data) + return true + "mcp:eval_runtime_error": + _on_eval_runtime_error(data) + return true + "mcp:game_command_response": + _on_game_command_response(data) + return true + "mcp:game_command_error": + _on_game_command_error(data) + return true + return false + + +func _on_log_batch(data: Array) -> void: + if _game_log_buffer == null: + return + ## data layout: [[[level, text, details?], ...]] + if data.is_empty() or not (data[0] is Array): + return + var entries: Array = data[0] + for entry in entries: + if entry is Dictionary: + var dict_details: Dictionary = {} + var raw_dict_details = entry.get("details", {}) + if raw_dict_details is Dictionary: + dict_details = raw_dict_details + _game_log_buffer.append(str(entry.get("level", "info")), str(entry.get("text", "")), dict_details) + continue + if not (entry is Array) or entry.size() < 2: + continue + var details: Dictionary = {} + if entry.size() > 2 and entry[2] is Dictionary: + details = entry[2] + _game_log_buffer.append(str(entry[0]), str(entry[1]), details) + + +## Request a game-process framebuffer capture over the debugger channel. +## Reply is pushed back through `connection` out-of-band because the MCP +## dispatcher has already returned a deferred-response marker for this +## request_id. Synchronous from the caller's perspective — if the +## game-side autoload hasn't beaconed yet, the wait + send run as a +## fire-and-forget coroutine kicked off from here. Structured this way +## so the call site in EditorHandler stays a plain non-await invocation. +func request_game_screenshot( + request_id: String, + max_resolution: int, + connection: McpConnection, + timeout_sec: float = DEFAULT_TIMEOUT_SEC, +) -> void: + if request_id.is_empty(): + push_warning("MCP debugger: screenshot request missing request_id") + return + + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Editor main loop is not a SceneTree — cannot schedule capture") + return + + if is_game_capture_ready(): + _send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec) + return + + ## Not ready yet — run the wait-then-send flow as a detached + ## coroutine. It keeps itself alive via the signal subscription on + ## tree.process_frame; the caller doesn't need to (and shouldn't) + ## await this entrypoint. + if _log_buffer: + _log_buffer.log("[debug] waiting for game_helper hello (%s)" % request_id) + _wait_then_send(tree, request_id, max_resolution, connection, timeout_sec) + + +## Coroutine: poll each editor frame until the mcp:hello beacon arrives +## (flipping _game_ready true) or the deadline elapses. Once resolved, +## either dispatch the capture or return an actionable timeout error. +func _wait_then_send( + tree: SceneTree, + request_id: String, + max_resolution: int, + connection: McpConnection, + timeout_sec: float, +) -> void: + var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0) + ## #645: always yield at least one frame — the dispatcher registers the + ## deferred request only after the handler returns DEFERRED_RESPONSE, so a + ## same-frame error reply would be dropped as an expired request. The break + ## check then bails out with the actionable break error instead of waiting + ## out the full window (a game parked in a debugger break never beacons). + await tree.process_frame + while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline: + await tree.process_frame + if not is_game_capture_ready(): + _send_error_response(connection, request_id, + _explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR)) + return + _send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec) + + +## Send the mcp:take_screenshot message and arm the reply timeout. +## Assumes _game_ready is true. +func _send_take_screenshot( + tree: SceneTree, + request_id: String, + max_resolution: int, + connection: McpConnection, + timeout_sec: float, +) -> void: + var session: EditorDebuggerSession = _first_active_session() + if session == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "No active debugger session — is the game actually running and started from this editor?") + return + + var timer: SceneTreeTimer = tree.create_timer(timeout_sec) + var timeout_callable := func() -> void: _on_timeout(request_id) + timer.timeout.connect(timeout_callable) + _pending[request_id] = { + "connection": connection, + "timer": timer, + "timeout_callable": timeout_callable, + } + + session.send_message("mcp:take_screenshot", [request_id, max_resolution]) + if _log_buffer: + _log_buffer.log("[debug] -> mcp:take_screenshot (%s)" % request_id) + + +func _first_active_session() -> EditorDebuggerSession: + for s in get_sessions(): + if s is EditorDebuggerSession and s.is_active(): + return s + return null + + +func _on_screenshot_response(data: Array) -> void: + if data.size() < 6: + push_warning("MCP debugger: malformed screenshot response (expected 6 fields, got %d)" % data.size()) + return + var request_id: String = data[0] + var pending = _pending.get(request_id) + if pending == null: + ## Timed out or unknown — silently drop. + return + _clear_pending(request_id) + + var connection: McpConnection = pending.connection + if connection == null or not is_instance_valid(connection): + return + + var payload := { + "source": "game", + "width": int(data[2]), + "height": int(data[3]), + "original_width": int(data[4]), + "original_height": int(data[5]), + "format": "png", + "image_base64": data[1], + } + ## #777: game helpers append frames_drawn + a stale flag so a capture + ## taken while the game's main loop is frozen (backgrounded window) is + ## honestly labeled instead of timing out. Older helpers send six fields + ## — leave the keys absent rather than guessing. + if data.size() >= 8: + payload["frames_drawn"] = int(data[6]) + payload["stale_frame"] = bool(data[7]) + if bool(data[7]): + payload["note"] = ("The game window appears backgrounded or its main loop is " + + "stalled; returning the last rendered frame. Focus the game window and " + + "retry for a current frame.") + ## Vision Routing: when enabled, describe the frame through the configured + ## vision provider on a + ## worker thread and reply with the text description instead of the raw + ## image (see vision_routing.gd). The router replies for us in that case. + if vision_routing != null and vision_routing.is_routing_enabled(): + if vision_routing.route_game_payload(connection, request_id, {"data": payload}): + return + connection.send_deferred_response(request_id, {"data": payload}) + if _log_buffer: + _log_buffer.log("[debug] <- mcp:screenshot_response (%s)" % request_id) + + +func _on_screenshot_error(data: Array) -> void: + if data.size() < 2: + return + var request_id: String = data[0] + var message: String = data[1] + var pending = _pending.get(request_id) + if pending == null: + return + _clear_pending(request_id) + var connection: McpConnection = pending.connection + if connection == null or not is_instance_valid(connection): + return + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, message) + + +## #777: the 8s reply timer fired — the screenshot request reached (or should +## have reached) the game helper and no reply came back. Mirror the eval +## timeout split (#518): a not-live game gets the attributed +## _explain_not_live payload; a live game gets GAME_HELPER_TIMEOUT instead of +## the former opaque INTERNAL_ERROR. With the game side's stalled-loop +## stale-frame fallback, a live game only lands here when it has nothing +## rendered to fall back on, its debugger servicing is itself wedged, or the +## helper died mid-run. +func _on_timeout(request_id: String) -> void: + var pending = _pending.get(request_id) + if pending == null: + return + _pending.erase(request_id) + var connection: McpConnection = pending.connection + if connection == null or not is_instance_valid(connection): + return + var status := get_game_status(-1, GAME_READY_WAIT_SEC) + var err: Dictionary + if status.get("status", "") != "live": + err = _explain_not_live(status, ErrorCodes.INTERNAL_ERROR) + else: + err = ErrorCodes.make(ErrorCodes.GAME_HELPER_TIMEOUT, + "The game process did not return a frame. The game window may be backgrounded or its main loop blocked — focus the game window and retry, or use game_command to confirm liveness.") + _send_error_response(connection, request_id, err) + if _log_buffer: + _log_buffer.log("[debug] !! screenshot timeout (%s)" % request_id) + + +func _send_error(connection: McpConnection, request_id: String, code: String, message: String) -> void: + _send_error_response(connection, request_id, ErrorCodes.make(code, message)) + + +func _send_error_response(connection: McpConnection, request_id: String, err: Dictionary) -> void: + if connection == null or not is_instance_valid(connection): + return + connection.send_deferred_response(request_id, err) + + +func _clear_pending(request_id: String) -> void: + var pending: Dictionary = _pending.get(request_id, {}) + var timer: SceneTreeTimer = pending.get("timer") + var cb: Callable = pending.get("timeout_callable", Callable()) + if timer != null and timer.timeout.is_connected(cb): + timer.timeout.disconnect(cb) + ## #490: eval requests also carry a compile-grace timer and a runtime probe. + var grace: SceneTreeTimer = pending.get("grace_timer") + var gcb: Callable = pending.get("grace_callable", Callable()) + if grace != null and grace.timeout.is_connected(gcb): + grace.timeout.disconnect(gcb) + var probe: SceneTreeTimer = pending.get("probe_timer") + var pcb: Callable = pending.get("probe_callable", Callable()) + if probe != null and probe.timeout.is_connected(pcb): + probe.timeout.disconnect(pcb) + _pending.erase(request_id) + + +## --- game_eval: execute arbitrary GDScript in the running game --- + +## Editor-side fallback timer for game_eval. MUST stay above the game-side +## EVAL_TIMEOUT_SEC (8.0) in runtime/game_helper.gd and below the dispatcher's +## game_eval budget (15000 ms) in dispatcher.gd — i.e. game 8s < editor 10s < +## dispatcher 15s. This timer only fires when the game never replies at all; +## _on_eval_timeout then attributes the failure (game not live vs never-acked +## vs started-and-hung, #518). Drop timeout_sec at/below 8s and it pre-empts +## the game's more specific "Eval exceeded 8s" message — see the TIMEOUT +## ORDERING note on EVAL_TIMEOUT_SEC. +## +## #500/#859: the *not-ready* path adds EVAL_READY_WAIT_SEC (3s) and the +## EVAL_LIVENESS_WAIT_SEC probe (0.25s) on top of this 10s backstop. That sum +## (13.25s) must also stay below the dispatcher/server 15s budget, or a +## not-yet-ready game makes the server time out opaquely before the editor's +## actionable error returns — exactly the residual ~15s TimeoutError bucket +## #500 tracked down. The cross-file contract is enforced by +## tests/unit/test_game_eval_timeout_ordering.py. +func request_game_eval( + code: String, + request_id: String, + connection: McpConnection, + timeout_sec: float = 10.0, +) -> void: + if request_id.is_empty(): + push_warning("MCP debugger: eval request missing request_id") + return + + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Editor main loop is not a SceneTree — cannot schedule eval") + return + + var run_token := _game_run_token + if is_game_capture_ready(): + _probe_then_eval(tree, code, request_id, connection, timeout_sec, run_token) + return + + if _log_buffer: + _log_buffer.log("[debug] waiting for game_helper hello before eval (%s)" % request_id) + _wait_then_eval(tree, code, request_id, connection, timeout_sec, run_token) + + +func _wait_then_eval( + tree: SceneTree, + code: String, + request_id: String, + connection: McpConnection, + timeout_sec: float, + run_token: int, +) -> void: + ## #500: eval uses EVAL_READY_WAIT_SEC (not the 20s GAME_READY_WAIT_SEC) so + ## the not-ready path returns its actionable error before the 15s server-side + ## command timeout fires an opaque TimeoutError. See EVAL_READY_WAIT_SEC. + _pending[request_id] = { + "kind": "eval_ready_wait", + "connection": connection, + "run_token": run_token, + } + var deadline := Time.get_ticks_msec() + int(EVAL_READY_WAIT_SEC * 1000.0) + ## #645: the leading yield guarantees the dispatcher has registered the + ## deferred request before any reply (a same-frame reply is dropped as + ## expired); the break check bails out early because a parked game never + ## registers its capture. + if _eval_ready_frame_waiter.is_valid(): + await _eval_ready_frame_waiter.call() + else: + await tree.process_frame + while ( + _pending.has(request_id) + and _is_current_game_run(run_token) + and not is_game_capture_ready() + and not _break_active + and Time.get_ticks_msec() < deadline + ): + if _eval_ready_frame_waiter.is_valid(): + await _eval_ready_frame_waiter.call() + else: + await tree.process_frame + ## end_game_run() owns the reply once it has cleared this tracked wait. + if not _pending.has(request_id): + return + if not _is_current_game_run(run_token): + _fail_eval_not_ready(request_id, + "The game run changed before game_eval could start — the game stopped or restarted. Retry against the current run.") + return + if not is_game_capture_ready(): + ## #518: EVAL_GAME_NOT_READY (not INTERNAL_ERROR) — the play session is up + ## but the game-side capture didn't register within the short wait. Fast + ## and caller-actionable; classifying it apart from the opaque 10s hang + ## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung". + _clear_pending(request_id) + _send_error_response(connection, request_id, + _explain_not_live(get_game_status(-1, EVAL_READY_WAIT_SEC), ErrorCodes.EVAL_GAME_NOT_READY)) + return + _clear_pending(request_id) + _probe_then_eval(tree, code, request_id, connection, timeout_sec, run_token) + + +## #859: registration is run-scoped but not a liveness guarantee. Ping the +## helper's debugger capture and let it report whether its _process beacon is +## still advancing before arming the much longer eval backstop. +func _probe_then_eval( + tree: SceneTree, + code: String, + request_id: String, + connection: McpConnection, + timeout_sec: float, + run_token: int, +) -> void: + if not _is_current_game_run(run_token) or not is_game_capture_ready(): + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "The game run changed before game_eval could be checked — the game stopped or restarted. Retry against the current run.") + return + var session: EditorDebuggerSession = _first_active_session() + if session == null: + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "Game-side capture registered but its debugger session is no longer active — the game likely just stopped or is restarting. Confirm it is running and retry.") + return + + var timer := tree.create_timer(EVAL_LIVENESS_WAIT_SEC) + var timeout_callable := func() -> void: _on_eval_liveness_timeout(request_id) + timer.timeout.connect(timeout_callable) + _pending[request_id] = { + "kind": "eval_liveness", + "connection": connection, + "timer": timer, + "timeout_callable": timeout_callable, + "code": code, + "eval_timeout_sec": timeout_sec, + "run_token": run_token, + } + session.send_message("mcp:eval_liveness", [request_id]) + if _log_buffer: + _log_buffer.log("[debug] -> mcp:eval_liveness (%s)" % request_id) + + +func _on_eval_liveness_timeout(request_id: String) -> void: + var pending_entry = _pending.get(request_id) + if pending_entry == null or str(pending_entry.get("kind", "")) != "eval_liveness": + return + var connection: McpConnection = pending_entry.get("connection") + _clear_pending(request_id) + if connection == null or not is_instance_valid(connection): + return + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + ("The game helper did not answer the liveness probe within %.0fms — the game may be backgrounded, frozen, stopped, or restarting. Focus the game window (or relaunch it) and retry." + % (EVAL_LIVENESS_WAIT_SEC * 1000.0))) + + +func _on_eval_liveness_response(data: Array) -> void: + if data.size() < 2: + push_warning("MCP debugger: malformed eval liveness response") + return + var request_id := str(data[0]) + var pending_entry = _pending.get(request_id) + if pending_entry == null or str(pending_entry.get("kind", "")) != "eval_liveness": + return + var connection: McpConnection = pending_entry.get("connection") + var code := str(pending_entry.get("code", "")) + var timeout_sec := float(pending_entry.get("eval_timeout_sec", 10.0)) + var run_token := int(pending_entry.get("run_token", -1)) + var loop_live := bool(data[1]) + _clear_pending(request_id) + if connection == null or not is_instance_valid(connection): + return + if not _is_current_game_run(run_token) or not is_game_capture_ready(): + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "The game run changed while game_eval liveness was being checked — the game stopped or restarted. Retry against the current run.") + return + if not loop_live: + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "The game helper is registered but its main loop is not advancing — the game window may be backgrounded or the game may be frozen. Focus the game window (or relaunch it) and retry.") + return + var current_tree := Engine.get_main_loop() as SceneTree + if current_tree == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Editor main loop is not a SceneTree — cannot schedule eval") + return + _send_eval(current_tree, code, request_id, connection, timeout_sec) + + +func _send_eval( + tree: SceneTree, + code: String, + request_id: String, + connection: McpConnection, + timeout_sec: float, +) -> void: + var session: EditorDebuggerSession = _first_active_session() + if session == null: + ## #518: capture reported ready but the debugger session is no longer live + ## (the game just stopped / is restarting) — a not-ready race, so the same + ## caller-actionable EVAL_GAME_NOT_READY rather than the opaque hang bucket. + _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "Game-side capture registered but its debugger session is no longer active — the game likely just stopped or is restarting. Confirm it's running and retry.") + return + + var timer: SceneTreeTimer = tree.create_timer(timeout_sec) + var timeout_callable := func() -> void: _on_eval_timeout(request_id, timeout_sec) + timer.timeout.connect(timeout_callable) + + ## #490: arm the compile-grace timer. _on_eval_grace concludes a parse error + ## only when the game acked the eval (it received the message and started + ## reload()) but never sent mcp:eval_compiled — see there for why a missing + ## ack must NOT be read as a compile error. + var grace: SceneTreeTimer = tree.create_timer(EVAL_COMPILE_GRACE_SEC) + var grace_callable := func() -> void: _on_eval_grace(request_id) + grace.timeout.connect(grace_callable) + + _pending[request_id] = { + "kind": "eval", + "connection": connection, + "run_token": _game_run_token, + "timer": timer, + "timeout_callable": timeout_callable, + "grace_timer": grace, + "grace_callable": grace_callable, + "acked": false, + "compiled": false, + } + + session.send_message("mcp:eval", [request_id, code]) + if _log_buffer: + _log_buffer.log("[debug] -> mcp:eval (%s)" % request_id) + + +## #518: the 10s editor-side backstop fired — the game never replied at all. +## Attribute the failure instead of emitting a one-size-fits-all INTERNAL_ERROR: +## +## - game not live anymore (parked in a debugger break, stopped, crashed): +## the eval couldn't run/finish for a *game-state* reason. Reply with the +## same caller-actionable EVAL_GAME_NOT_READY + `_explain_not_live` payload +## the pre-hello break path already uses — a break freezes the game's idle +## loop, so any awaiting eval parks here even though sync evals still work. +## - game live but never acked the eval: its main thread never serviced the +## debugger message (long frame/load, CPU-bound prior eval, or a +## backgrounded window whose idle loop is frozen). +## - game live, acked, compiled: the eval genuinely started and never +## finished, and the game couldn't even self-report via its own 8s guard +## (which needs a ticking idle loop) — hung await, CPU-bound loop, or a +## reply the debugger channel dropped. +## +## The live branches reply EVAL_HUNG: the eval code never finished. That code +## plus the game-side 8s guard (also EVAL_HUNG, via mcp:eval_error's code +## element) empties the former INTERNAL_ERROR bucket on this path. +func _on_eval_timeout(request_id: String, timeout_sec: float) -> void: + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + var run_token := int(pending_entry.get("run_token", _game_run_token)) + _clear_pending(request_id) + var conn: McpConnection = pending_entry.connection + if conn == null or not is_instance_valid(conn): + return + if run_token != _game_run_token: + _send_error(conn, request_id, ErrorCodes.EVAL_GAME_NOT_READY, + "The game run changed before game_eval completed — the original game stopped or restarted. Retry against the current run.") + if _log_buffer: + _log_buffer.log("[debug] !! eval timeout from prior run (%s, run=%d, current=%d)" + % [request_id, run_token, _game_run_token]) + return + var status := get_game_status(-1, EVAL_READY_WAIT_SEC) + if str(status.get("status", "")) != "live": + _send_error_response(conn, request_id, + _explain_not_live(status, ErrorCodes.EVAL_GAME_NOT_READY)) + if _log_buffer: + _log_buffer.log("[debug] !! eval timeout, game not live (%s, status=%s)" + % [request_id, str(status.get("status", ""))]) + return + var message: String + if not bool(pending_entry.get("acked", false)): + message = ("Game eval was sent but the game never picked it up within %.0fs — " + + "its main thread is busy or frozen (a long frame/load, a CPU-bound " + + "prior eval, or a backgrounded game window whose loop is throttled). " + + "Check logs_read(source='game') and retry.") % timeout_sec + else: + message = ("Game eval compiled and started running but never returned within " + + "%.0fs — the code is likely stuck in an infinite loop or awaiting a " + + "signal/timer that never fires (a backgrounded game window also freezes " + + "awaits). Check logs_read(source='game').") % timeout_sec + _send_error(conn, request_id, ErrorCodes.EVAL_HUNG, message) + if _log_buffer: + _log_buffer.log("[debug] !! eval timeout (%s)" % request_id) + + +func _on_eval_response(data: Array) -> void: + if data.size() < 2: + push_warning("MCP debugger: malformed eval response (expected 2 fields, got %d)" % data.size()) + return + var request_id: String = data[0] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + _clear_pending(request_id) + + var connection: McpConnection = pending_entry.connection + if connection == null or not is_instance_valid(connection): + return + + var result_json: String = data[1] if data.size() > 1 else "null" + var json := JSON.new() + var parse_err := json.parse(result_json) + connection.send_deferred_response(request_id, { + "data": { + "result": json.data if parse_err == OK else result_json, + "source": "game", + } + }) + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_response (%s)" % request_id) + + +## #518: codes the game side may attach as mcp:eval_error's optional third +## payload element. Allowlisted so a game process can't mint arbitrary +## top-level error codes over the debugger channel; anything else (including +## the legacy two-element payload from an older game helper mid-update) +## falls back to INTERNAL_ERROR exactly as before. +const _GAME_EVAL_ERROR_CODES: Array[String] = [ + ErrorCodes.EVAL_HUNG, + ErrorCodes.EVAL_RESULT_TOO_LARGE, +] + + +func _on_eval_error(data: Array) -> void: + if data.size() < 2: + return + var request_id: String = data[0] + var message: String = data[1] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + _clear_pending(request_id) + var connection: McpConnection = pending_entry.connection + if connection == null or not is_instance_valid(connection): + return + var code := ErrorCodes.INTERNAL_ERROR + if data.size() > 2 and str(data[2]) in _GAME_EVAL_ERROR_CODES: + code = str(data[2]) + _send_error(connection, request_id, code, message) + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_error (%s): %s" % [request_id, message]) + + +## #490: the game sends this at the top of _handle_eval, BEFORE reload() (so it +## survives a parse-error abort). It positively signals "the game received this +## eval and started compiling it" — letting _on_eval_grace tell a real parse +## error (acked, never compiled) apart from a message the game hasn't serviced +## yet (never acked — main thread blocked by a long frame/load or a CPU-bound +## prior eval). +func _on_eval_ack(data: Array) -> void: + if data.is_empty(): + return + var request_id: String = data[0] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + pending_entry["acked"] = true + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_ack (%s)" % request_id) + + +## #490: compile-grace timer fired. Conclude a parse error ONLY when the game +## acked the eval (started reload()) but never sent mcp:eval_compiled. If it +## never acked, the game simply hasn't serviced the message yet — NOT a parse +## error — so leave _pending intact and let the normal eval timeout handle it +## rather than false-failing a valid eval and dropping its eventual real reply. +func _on_eval_grace(request_id: String) -> void: + var pending_entry = _pending.get(request_id) + if pending_entry == null or pending_entry.get("compiled", false): + return + if not pending_entry.get("acked", false): + if _log_buffer: + _log_buffer.log("[debug] eval grace: no ack yet, deferring to timeout (%s)" % request_id) + return + _clear_pending(request_id) + var conn: McpConnection = pending_entry.connection + if conn == null or not is_instance_valid(conn): + return + _send_error(conn, request_id, ErrorCodes.EVAL_COMPILE_ERROR, + "Game eval failed to compile — likely a GDScript syntax/parse error. The parse error text is in the editor's Output/Debugger panel; it is not capturable from the running game. Check your eval code's syntax.") + if _log_buffer: + _log_buffer.log("[debug] !! eval compile error (%s)" % request_id) + + +## #490: the game sends this the instant reload() of the eval source +## succeeds. Flips the pending entry's `compiled` flag so the compile-grace +## timer won't fire a false EVAL_COMPILE_ERROR. +func _on_eval_compiled(data: Array) -> void: + if data.is_empty(): + return + var request_id: String = data[0] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + pending_entry["compiled"] = true + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_compiled (%s)" % request_id) + ## #490: compiled OK — start polling for a runtime error that may have + ## aborted execute(). A backgrounded game can't self-report it, so the + ## editor probes via mcp:eval_check until the eval resolves. + _arm_eval_probe(request_id) + + +## #490: the game reported a runtime error that aborted the eval — either +## from its _process fast path (focused game) or in answer to an editor +## eval_check probe (backgrounded game). Reply fast with the real error text +## instead of waiting for the hang timeout. +func _on_eval_runtime_error(data: Array) -> void: + if data.size() < 2: + return + var request_id: String = data[0] + var message: String = data[1] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + _clear_pending(request_id) + var connection: McpConnection = pending_entry.connection + if connection == null or not is_instance_valid(connection): + return + var msg := "Game eval raised a runtime error: %s" % message if not message.is_empty() else "Game eval raised a runtime error (no message captured). Check logs_read(source='game')." + _send_error(connection, request_id, ErrorCodes.EVAL_RUNTIME_ERROR, msg) + if _log_buffer: + _log_buffer.log("[debug] <- mcp:eval_runtime_error (%s): %s" % [request_id, message]) + + +## #490: arm one probe tick for an in-flight eval. Re-arms itself each tick +## until the request resolves — eval_response / eval_runtime_error / +## eval_compile_error / hang-timeout all call _clear_pending, which erases the +## entry and stops the chain. Uses the editor's own SceneTreeTimer because the +## editor loop keeps ticking even while a backgrounded game's loop is frozen. +func _arm_eval_probe(request_id: String) -> void: + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + return + var probe_timer: SceneTreeTimer = tree.create_timer(EVAL_PROBE_INTERVAL_SEC) + var probe_callable := func() -> void: _on_eval_probe_tick(request_id) + pending_entry["probe_timer"] = probe_timer + pending_entry["probe_callable"] = probe_callable + probe_timer.timeout.connect(probe_callable) + + +## #490: poke the game for a runtime-error verdict, then re-arm. The game's +## _handle_eval_check answers with mcp:eval_runtime_error if a script error +## aborted this eval, else stays silent and we poll again next interval. +func _on_eval_probe_tick(request_id: String) -> void: + if not _pending.has(request_id): + return ## resolved — stop probing + var session: EditorDebuggerSession = _first_active_session() + if session != null and session.is_active(): + session.send_message("mcp:eval_check", [request_id]) + _arm_eval_probe(request_id) + + +## --- game_command: curated runtime game operations --- + +func request_game_command( + op: String, + params: Dictionary, + request_id: String, + connection: McpConnection, + timeout_sec: float = 10.0, +) -> void: + if request_id.is_empty(): + push_warning("MCP debugger: game command request missing request_id") + return + + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "Editor main loop is not a SceneTree — cannot schedule game command") + return + + if is_game_capture_ready(): + _send_game_command(tree, op, params, request_id, connection, timeout_sec) + return + + if _log_buffer: + _log_buffer.log("[debug] waiting for game_helper hello before game_command (%s)" % request_id) + _wait_then_game_command(tree, op, params, request_id, connection, timeout_sec) + + +func _wait_then_game_command( + tree: SceneTree, + op: String, + params: Dictionary, + request_id: String, + connection: McpConnection, + timeout_sec: float, +) -> void: + var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0) + ## #645: the leading yield guarantees the dispatcher has registered the + ## deferred request before any reply (a same-frame reply is dropped as + ## expired); the break check bails out early because a parked game never + ## registers its capture. + await tree.process_frame + while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline: + await tree.process_frame + if not is_game_capture_ready(): + _send_error_response(connection, request_id, + _explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR)) + return + _send_game_command(tree, op, params, request_id, connection, timeout_sec) + + +func _send_game_command( + tree: SceneTree, + op: String, + params: Dictionary, + request_id: String, + connection: McpConnection, + timeout_sec: float, +) -> void: + var session: EditorDebuggerSession = _first_active_session() + if session == null: + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, + "No active debugger session — is the game actually running?") + return + + var timer: SceneTreeTimer = tree.create_timer(timeout_sec) + var timeout_callable := func() -> void: + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + _pending.erase(request_id) + var conn: McpConnection = pending_entry.connection + if conn == null or not is_instance_valid(conn): + return + _send_error(conn, request_id, ErrorCodes.INTERNAL_ERROR, + "Game command '%s' timed out after %.0fs" % [op, timeout_sec]) + if _log_buffer: + _log_buffer.log("[debug] !! game_command timeout (%s)" % request_id) + timer.timeout.connect(timeout_callable) + _pending[request_id] = { + "connection": connection, + "timer": timer, + "timeout_callable": timeout_callable, + } + + session.send_message("mcp:game_command", [request_id, op, JSON.stringify(params)]) + if _log_buffer: + _log_buffer.log("[debug] -> mcp:game_command %s (%s)" % [op, request_id]) + + +func _on_game_command_response(data: Array) -> void: + if data.size() < 2: + push_warning("MCP debugger: malformed game_command response (expected 2 fields, got %d)" % data.size()) + return + var request_id: String = data[0] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + _clear_pending(request_id) + + var connection: McpConnection = pending_entry.connection + if connection == null or not is_instance_valid(connection): + return + + var result_json: String = data[1] if data.size() > 1 else "{}" + var json := JSON.new() + var parse_err := json.parse(result_json) + connection.send_deferred_response(request_id, { + "data": json.data if parse_err == OK else {"source": "game", "result": result_json} + }) + if _log_buffer: + _log_buffer.log("[debug] <- mcp:game_command_response (%s)" % request_id) + + +func _on_game_command_error(data: Array) -> void: + if data.size() < 2: + return + var request_id: String = data[0] + var message: String = data[1] + var pending_entry = _pending.get(request_id) + if pending_entry == null: + return + _clear_pending(request_id) + var connection: McpConnection = pending_entry.connection + if connection == null or not is_instance_valid(connection): + return + _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, message) + if _log_buffer: + _log_buffer.log("[debug] <- mcp:game_command_error (%s): %s" % [request_id, message]) diff --git a/addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid b/addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid new file mode 100644 index 0000000..1d5c148 --- /dev/null +++ b/addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid @@ -0,0 +1 @@ +uid://bd1k63iye1bsl diff --git a/addons/godot_ai/dispatcher.gd b/addons/godot_ai/dispatcher.gd new file mode 100644 index 0000000..c834e8e --- /dev/null +++ b/addons/godot_ai/dispatcher.gd @@ -0,0 +1,476 @@ +@tool +class_name McpDispatcher +extends RefCounted + +## Routes incoming commands to handlers and manages the command queue +## with a per-frame time budget. + +var _command_queue: Array[Dictionary] = [] +var _handlers: Dictionary = {} # command_name -> Callable +## Lazy handler registration (#736): plugin.gd registers command names +## against a handler key plus a per-handler script path and constructor +## args, and the handler script is load()ed and instantiated at the FIRST +## dispatch of one of its commands. This keeps the ~30 handler scripts +## (and everything they preload) out of plugin.gd's eager compile +## closure, which stalled "Initializing plugins" on every editor boot. +## Materialized commands are promoted into `_handlers`, so the lazy dicts +## are only consulted on the first call per command. +var _lazy_handler_specs: Dictionary = {} # handler_key -> {path: String, args: Array} +var _lazy_handler_cache: Dictionary = {} # handler_key -> handler instance +var _lazy_commands: Dictionary = {} # command_name -> {handler: String, method: StringName} +var _pending_deferred: Dictionary = {} # request_id -> {command, started_ms, timeout_ms} +var _log_buffer +var _surfaced_error_tracker +## The McpConnection whose pause_processing handlers flip around unsafe +## editor operations (#288 guard). Set by plugin.gd; untyped to honor the +## self-update field-storage policy. When set, _call_handler restores the +## pause depth a crashed handler left unbalanced (#712) — without this a +## single handler crash inside a pause window freezes the transport +## forever (pause has no watchdog or disconnect reset by design). +var pause_target +var mcp_logging := true +var deferred_timeout_overrides_ms: Dictionary = {} + +const DEFAULT_DEFERRED_TIMEOUT_MS := 4500 +const DEFERRED_TIMEOUT_MS_BY_COMMAND := { + "create_script": 4500, + ## Fresh-`.gd` writes defer through the same import-settle window as + ## create_script (#714) — same headroom over IMPORT_SETTLE_MAX_MSEC. + "write_file": 4500, + "stop_project": 4500, + "run_project": 6000, + "take_screenshot": 30000, + "check_client_status": 30000, + "game_eval": 15000, + "game_command": 15000, + "scan_filesystem": 30000, +} +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd") + + +func _init(log_buffer: McpLogBuffer, surfaced_error_tracker = null) -> void: + _log_buffer = log_buffer + _surfaced_error_tracker = surfaced_error_tracker + + +## Register a command handler. The callable receives (params: Dictionary) -> Dictionary. +func register(command_name: String, handler: Callable) -> void: + _handlers[command_name] = handler + + +## Declare a lazily-constructed handler (#736). `script_path` is load()ed +## and instantiated with `ctor_args` at the first dispatch of any command +## registered against `handler_key` via register_lazy. `ctor_args` may hold +## plugin-lifetime objects (connection, buffers, the dispatcher itself for +## batch); clear() drops them so teardown ordering matches the old eager +## registration (#46). +func register_lazy_handler(handler_key: String, script_path: String, ctor_args: Array) -> void: + _lazy_handler_specs[handler_key] = {"path": script_path, "args": ctor_args} + + +## Register a command that resolves to `method` on the lazily-constructed +## handler declared under `handler_key`. Same dispatch semantics as +## register(); only construction timing differs. +func register_lazy(command_name: String, handler_key: String, method: StringName) -> void: + _lazy_commands[command_name] = {"handler": handler_key, "method": method} + +func unregister(command_name: String, handler_key: String) -> void: + _handlers.erase(command_name) + _lazy_commands.erase(command_name) + ## Drop the lazy spec/cache only when no remaining command routes to + ## this handler key. Custom tools use 1:1 keys ("custom:") so this + ## always erases for them, but built-in keys serve many commands — + ## erasing the shared spec would strand the siblings on + ## "No lazy handler declared" at dispatch time. + for entry in _lazy_commands.values(): + if entry.get("handler", "") == handler_key: + return + _lazy_handler_cache.erase(handler_key) + _lazy_handler_specs.erase(handler_key) + +## Drop registered handlers, queued commands, and the log buffer ref so +## plugin.gd can release RefCounted handlers before Godot reloads their +## class_name scripts (issue #46). After clear(), the dispatcher is inert. +func clear() -> void: + ## Stop lazy handlers before releasing the cache. Handler-owned polling + ## coroutines retain any in-flight worker and deferred-response connection + ## across frames, then join only after the worker is no longer alive. + for instance in _lazy_handler_cache.values(): + if is_instance_valid(instance) and instance.has_method("prepare_for_teardown"): + instance.call("prepare_for_teardown") + _handlers.clear() + ## Release lazily-constructed handler instances (and the ctor args that + ## reference plugin-lifetime objects) at the same teardown point where + ## eager handler Callables used to be dropped — their destructors must + ## run while their scripts are still loaded (#46). This also breaks the + ## dispatcher -> batch handler -> dispatcher ref cycle. + _lazy_handler_specs.clear() + _lazy_handler_cache.clear() + _lazy_commands.clear() + _command_queue.clear() + _pending_deferred.clear() + _log_buffer = null + _surfaced_error_tracker = null + pause_target = null +## Drop queued-but-unexecuted commands. Called by the connection on +## disconnect (#712): commands queued by the previous connection must not +## execute under the next one — the requester is gone, its in-flight +## futures were already failed server-side, and a mutation landing after +## reconnect is a surprise write nobody can correlate. Deferred bookkeeping +## has its own reset (clear_deferred_responses). +func clear_command_queue() -> void: + _command_queue.clear() + + +## Invoke a registered handler directly by name. Returns the handler's raw +## response dict (no request_id or status wrapping). Returns an UNKNOWN_COMMAND +## error dict if the command is not registered. Used by batch_execute. +func dispatch_direct(command: String, params: Dictionary) -> Dictionary: + if not has_command(command): + return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command) + ## Strip the reserved deferred-reply key: only _dispatch may thread it. + ## A caller-supplied _request_id (e.g. inside a batch_execute + ## sub-command's params) would flip a deferred-capable handler into + ## deferred mode against a request id the dispatcher never registered — + ## the direct caller would get the DEFERRED sentinel instead of a result + ## and the out-of-band reply would be dropped as expired. + if params.has("_request_id"): + params = params.duplicate() + params.erase("_request_id") + return _call_handler(command, params) + + +## Whether a command is registered (eagerly or lazily). +func has_command(command: String) -> bool: + return _handlers.has(command) or _lazy_commands.has(command) + + +## Rank registered commands by similarity to `cmd_name` and return the top `limit` +## matches. Uses Godot's built-in String.similarity() (0.0–1.0). Returns an empty +## array if no candidates clear the threshold. Used by batch_execute to surface +## "did you mean" suggestions when an unknown command is passed. +func suggest_similar(cmd_name: String, limit: int = 3, threshold: float = 0.5) -> Array[String]: + return FuzzySuggestions.rank(cmd_name, _registered_command_names(), limit, threshold, 0.0, 0.0) + + +## Union of eagerly-registered and lazily-registered command names. +## Materialized lazy commands live in both dicts, so dedupe via keys. +func _registered_command_names() -> Array: + var names: Dictionary = {} + for command in _handlers: + names[command] = true + for command in _lazy_commands: + names[command] = true + return names.keys() + + +## Enqueue a raw command dict received from the WebSocket. +func enqueue(cmd: Dictionary) -> void: + _command_queue.append(cmd) + + +func pending_deferred_count() -> int: + return _pending_deferred.size() + + +func clear_deferred_responses() -> void: + _pending_deferred.clear() + + +func has_pending_deferred_response(request_id: String) -> bool: + return request_id.is_empty() or _pending_deferred.has(request_id) + + +func complete_deferred_response(request_id: String) -> bool: + if request_id.is_empty(): + return true + if not _pending_deferred.has(request_id): + return false + _pending_deferred.erase(request_id) + return true + + +## Handlers whose response flows out-of-band (e.g. debugger-channel capture) +## return this marker so tick() skips auto-sending a response. The handler is +## responsible for pushing the final response via McpConnection._send_json when +## the async operation completes. The dispatcher tracks the request_id and emits +## DEFERRED_TIMEOUT if the out-of-band response never arrives. The request_id is +## threaded through params under the "_request_id" key so the handler can +## correlate the response. +const DEFERRED_RESPONSE := {"_deferred": true} + + +## Process queued commands within a frame budget (milliseconds). +## Returns an array of response dictionaries to send back. +func tick(budget_ms: float = 4.0) -> Array[Dictionary]: + var responses: Array[Dictionary] = _collect_deferred_timeouts() + var start := Time.get_ticks_msec() + var idx := 0 + + while idx < _command_queue.size() and (Time.get_ticks_msec() - start) < budget_ms: + var cmd: Dictionary = _command_queue[idx] + var response := _dispatch(cmd) + if not response.get("_deferred", false): + responses.append(response) + idx += 1 + + if idx > 0: + _command_queue = _command_queue.slice(idx) + + return responses + + +func _dispatch(cmd: Dictionary) -> Dictionary: + var request_id: String = cmd.get("request_id", "") + var command: String = cmd.get("command", "") + var raw_params: Dictionary = cmd.get("params", {}) + ## Duplicate so the internal _request_id key we thread through doesn't + ## mutate the queued command's params (which is the same dict we're + ## about to JSON-log below, and which later readers like batch_execute + ## shouldn't see dispatcher-internal metadata from). + var params: Dictionary = raw_params.duplicate() + params["_request_id"] = request_id + + if mcp_logging: + _log_buffer.log("[recv] %s(%s)" % [command, JSON.stringify(raw_params)]) + + var result: Dictionary + + if has_command(command): + result = _call_handler(command, params) + else: + result = ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command) + + if result.get("_deferred", false): + ## A handler may attach `_deferred_timeout_ms` to its deferred sentinel + ## to claim a per-request budget larger than its command's shared entry + ## (e.g. game_command's `input_sequence`, which steps frames well past + ## the 15s that suits one-shot game ops). 0/absent falls back to the + ## per-command table. + _register_deferred(request_id, command, int(result.get("_deferred_timeout_ms", 0))) + if mcp_logging: + _log_buffer.log("[defer] %s (request %s)" % [command, request_id]) + return result + + result["request_id"] = request_id + if not result.has("status"): + result["status"] = "ok" + ## Stamp live editor readiness onto every command-response envelope so + ## the server's `Session.readiness` cache self-heals on the very next + ## tool call. Without this, a single dropped `readiness_changed` event + ## (or a one-frame race around `pause_processing`) leaves the cache + ## stuck at "playing" / "importing" long after the editor has settled, + ## and write tools fail with EDITOR_NOT_READY against a writable editor. + ## See connection.gd::send_deferred_response for the deferred-response + ## counterpart, which stamps the same field. + result["readiness"] = McpConnection.get_readiness() + _stamp_error_watermark(result) + + if mcp_logging: + var status: String = result.get("status", "ok") + if status == "ok": + _log_buffer.log("[send] %s -> ok" % command) + else: + var err_msg: String = result.get("error", {}).get("message", "unknown") + _log_buffer.log("[send] %s -> error: %s" % [command, err_msg]) + + return result + + +## Truncate JSON-stringified args at this many chars when stuffing them into +## a malformed-result error message — large dicts shouldn't bloat the +## response, but a few hundred chars usually pinpoints which param was the +## wrong shape. +const _MALFORMED_ARGS_MAX := 400 + + +func _call_handler(command: String, params: Dictionary) -> Dictionary: + if not _handlers.has(command): + var materialize_error := _materialize_lazy_command(command) + if not materialize_error.is_empty(): + return materialize_error + ## #712: a handler that crashes between pause_processing = true and its + ## matching false leaves the pause depth unbalanced — GDScript swallows + ## the error, the dispatcher reports "malformed result", and the + ## transport stays paused FOREVER (no watchdog, no disconnect reset). + ## Restore balance at this boundary: the depth a handler leaves behind + ## must equal the depth it started with. + var pause_depth_before: int = pause_target.pause_depth() if pause_target != null else 0 + var result: Dictionary = _handlers[command].call(params) + if pause_target != null and pause_target.pause_depth() > pause_depth_before: + var leaked: int = pause_target.pause_depth() - pause_depth_before + while pause_target.pause_depth() > pause_depth_before: + pause_target.resume() + if mcp_logging and _log_buffer != null: + _log_buffer.log( + "[error] %s leaked %d pause_processing level(s) — restored (handler crash?)" + % [command, leaked] + ) + ## Handlers must return {"data": ...} on success or {"error": ...} on failure. + ## Anything else (null, empty, missing keys) means the handler crashed + ## mid-call — GDScript swallows the error and returns an empty dict. + if result == null or not (result.has("data") or result.has("error") or result.has("_deferred")): + var safe_params := params.duplicate() + safe_params.erase("_request_id") + var args_json := JSON.stringify(safe_params) + if args_json.length() > _MALFORMED_ARGS_MAX: + args_json = args_json.substr(0, _MALFORMED_ARGS_MAX) + "..." + var backtrace := _capture_compact_backtrace() + var msg := ( + "Handler '%s' returned malformed result — likely a runtime error in the handler " + + "(e.g. param type mismatch). Args received: %s" + ) % [command, args_json] + if not backtrace.is_empty(): + msg += "\nBacktrace:\n%s" % backtrace + if mcp_logging and _log_buffer != null: + var compact_backtrace := backtrace.replace("\n", " | ") + _log_buffer.log( + "[error] %s -> malformed result; args=%s; backtrace=%s" + % [command, args_json, compact_backtrace] + ) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, msg) + return result + + +## Resolve a lazily-registered command into a live Callable in `_handlers`. +## Loads + constructs the owning handler on first use (cached per handler +## key, so one load() covers every command the handler serves). Returns an +## empty dict on success or a protocol error dict on failure — a missing +## script or method is a plugin packaging bug and must surface loudly, not +## as a silent no-op. +func _materialize_lazy_command(command: String) -> Dictionary: + var command_spec: Dictionary = _lazy_commands.get(command, {}) + if command_spec.is_empty(): + return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command) + var handler_key: String = command_spec["handler"] + var instance = _lazy_handler_cache.get(handler_key) + if instance == null: + var handler_spec: Dictionary = _lazy_handler_specs.get(handler_key, {}) + if handler_spec.is_empty(): + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "No lazy handler '%s' declared for command '%s'" % [handler_key, command] + ) + ## Existence-check first so a missing script surfaces as one clean + ## protocol error instead of also spraying engine load errors. + if not ResourceLoader.exists(handler_spec["path"]): + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Missing handler script '%s' for command '%s'" % [handler_spec["path"], command] + ) + var script := load(handler_spec["path"]) as GDScript + if script == null: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to load handler script '%s' for command '%s'" % [handler_spec["path"], command] + ) + instance = script.callv("new", handler_spec["args"]) + if instance == null: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to construct handler '%s' for command '%s'" % [handler_key, command] + ) + _lazy_handler_cache[handler_key] = instance + var method: StringName = command_spec["method"] + if not instance.has_method(method): + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Handler '%s' has no method '%s' for command '%s'" % [handler_key, method, command] + ) + _handlers[command] = Callable(instance, method) + return {} + + +func _register_deferred(request_id: String, command: String, timeout_override_ms: int = 0) -> void: + if request_id.is_empty(): + return + ## A positive per-request override wins over the per-command table so a + ## single deferred call can claim more headroom without globally widening + ## the command's budget (see _dispatch: input_sequence needs ~30s, but the + ## other game_command ops must keep their tight 15s). + var timeout_ms: int = ( + timeout_override_ms if timeout_override_ms > 0 + else _deferred_timeout_ms_for_command(command) + ) + _pending_deferred[request_id] = { + "command": command, + "started_ms": Time.get_ticks_msec(), + "timeout_ms": timeout_ms, + } + + +func _deferred_timeout_ms_for_command(command: String) -> int: + if deferred_timeout_overrides_ms.has(command): + return int(deferred_timeout_overrides_ms[command]) + return int(DEFERRED_TIMEOUT_MS_BY_COMMAND.get(command, DEFAULT_DEFERRED_TIMEOUT_MS)) + + +func _collect_deferred_timeouts() -> Array[Dictionary]: + var responses: Array[Dictionary] = [] + if _pending_deferred.is_empty(): + return responses + var now := Time.get_ticks_msec() + for request_id in _pending_deferred.keys(): + var entry: Dictionary = _pending_deferred[request_id] + var timeout_ms: int = entry.get("timeout_ms", DEFAULT_DEFERRED_TIMEOUT_MS) + var elapsed_ms := now - int(entry.get("started_ms", now)) + if elapsed_ms < timeout_ms: + continue + _pending_deferred.erase(request_id) + var command: String = entry.get("command", "") + var response := ErrorCodes.make( + ErrorCodes.DEFERRED_TIMEOUT, + "Deferred response for '%s' timed out after %dms" % [command, timeout_ms] + ) + response["request_id"] = request_id + response["error"]["data"] = { + "command": command, + "elapsed_ms": elapsed_ms, + "timeout_ms": timeout_ms, + } + ## Same envelope-level readiness stamp as `_dispatch` — keep the + ## self-heal channel symmetric across every reply shape the + ## dispatcher emits so the server cache can't drift just because + ## the editor happened to time out a deferred command. + response["readiness"] = McpConnection.get_readiness() + _stamp_error_watermark(response) + responses.append(response) + if mcp_logging and _log_buffer != null: + _log_buffer.log("[defer] %s (request %s) -> timeout" % [command, request_id]) + return responses + + +func _stamp_error_watermark(response: Dictionary) -> void: + McpSurfacedErrorTracker.stamp_watermark(response, _surfaced_error_tracker) + + +static func _capture_compact_backtrace(max_frames: int = 8) -> String: + var traces: Array = Engine.capture_script_backtraces(false) + for bt in traces: + if bt != null and not bt.is_empty(): + return _trim_backtrace_string(bt.format(0, 2), max_frames) + return _format_stack_frames(get_stack(), max_frames) + + +static func _trim_backtrace_string(text: String, max_frames: int) -> String: + var lines := text.strip_edges().split("\n") + var kept: Array[String] = [] + for i in range(min(lines.size(), max_frames)): + kept.append(lines[i].strip_edges()) + return "\n".join(kept) + + +static func _format_stack_frames(frames: Array, max_frames: int) -> String: + var lines: Array[String] = [] + for i in range(min(frames.size(), max_frames)): + var frame: Dictionary = frames[i] + lines.append( + "%s:%s in %s" + % [ + frame.get("source", "?"), + frame.get("line", 0), + frame.get("function", "?"), + ] + ) + return "\n".join(lines) diff --git a/addons/godot_ai/dispatcher.gd.uid b/addons/godot_ai/dispatcher.gd.uid new file mode 100644 index 0000000..25a05bd --- /dev/null +++ b/addons/godot_ai/dispatcher.gd.uid @@ -0,0 +1 @@ +uid://ctldk7ivsoo3i diff --git a/addons/godot_ai/dock_panels/log_viewer.gd b/addons/godot_ai/dock_panels/log_viewer.gd new file mode 100644 index 0000000..1d7b986 --- /dev/null +++ b/addons/godot_ai/dock_panels/log_viewer.gd @@ -0,0 +1,100 @@ +@tool +extends VBoxContainer + +## Dock subpanel — renders the MCP request/response log buffer. Owns its own +## UI subtree, the line-count cursor, and the display-visibility toggle. Emits +## `logging_enabled_changed` so the dock can route the flag onto the +## connection dispatcher without the panel knowing the routing exists. +## +## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at +## the top of mcp_dock.gd for the broader extraction story. + +signal logging_enabled_changed(enabled: bool) + +const Dock := preload("res://addons/godot_ai/mcp_dock.gd") +## Preload (not the McpSettings class_name) for consistency with the parse +## hazard note on `_log_buffer` below. +const Settings := preload("res://addons/godot_ai/utils/settings.gd") + +## Untyped: a `: McpLogBuffer` annotation hits the class_name registry at +## script-load and trips the self-update parse hazard (#398). The type fence +## stays on the `setup(log_buffer: McpLogBuffer)` parameter. +var _log_buffer +var _log_display: RichTextLabel +var _log_toggle: CheckButton +## Last `McpLogBuffer.total_logged()` value painted into the display. Tracking +## the buffer's monotonic sequence (rather than its bounded `total_count()`) +## keeps the viewer painting once the ring fills — a size-based cursor would +## freeze at MAX_LINES on every subsequent append. See PR #392 for the bug. +var _last_log_seq := 0 + + +## Build the UI synchronously here so callers (and detached-tree tests that +## instantiate the dock with `McpDockScript.new()` and never enter the tree) +## can interact with the panel's controls right after `setup()`. Mirrors the +## pre-extraction inline-build behavior that test_dock.gd relies on. +## +## Idempotent: `_log_display == null` covers an unlikely double-`setup()` call +## without rebuilding (which would orphan the prior controls). +func setup(log_buffer: McpLogBuffer) -> void: + _log_buffer = log_buffer + if _log_display == null: + _build_ui() + + +func _build_ui() -> void: + add_child(HSeparator.new()) + + var log_header_row := HBoxContainer.new() + var log_header := Dock._make_header("MCP Log") + log_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL + log_header_row.add_child(log_header) + + _log_toggle = CheckButton.new() + _log_toggle.text = "Log" + ## Restore the persisted choice — a hardcoded `true` here meant the + ## toggle reset to noisy on every editor restart (#626). + _log_toggle.button_pressed = Settings.mcp_logging_enabled() + _log_toggle.toggled.connect(_on_log_toggled) + log_header_row.add_child(_log_toggle) + + add_child(log_header_row) + + _log_display = RichTextLabel.new() + _log_display.custom_minimum_size = Vector2(0, 80) + _log_display.scroll_following = true + _log_display.bbcode_enabled = false + _log_display.selection_enabled = true + _log_display.visible = _log_toggle.button_pressed + add_child(_log_display) + + +## Called from McpDock._process when the panel is visible. Appends any new +## log lines since the last tick. +func tick() -> void: + if _log_buffer == null or _log_display == null: + return + var seq: int = _log_buffer.total_logged() + if seq == _last_log_seq: + return + if seq < _last_log_seq: + ## Buffer cleared via `McpLogBuffer.clear()` (the `clear_logs` MCP + ## tool / `logs_clear` handler). The buffer resets `_total_logged` + ## to 0, flipping the sequence backward. Without this branch the + ## display would keep showing pre-clear lines forever — the viewer + ## drifts permanently out of sync with the buffer. Reset display + + ## cursor so the next append paints over a clean slate. + _log_display.clear() + _last_log_seq = 0 + if seq == 0: + return + var new_lines: Array[String] = _log_buffer.get_recent(seq - _last_log_seq) + for line in new_lines: + _log_display.add_text(line + "\n") + _last_log_seq = seq + + +func _on_log_toggled(enabled: bool) -> void: + Settings.set_mcp_logging_enabled(enabled) + _log_display.visible = enabled + logging_enabled_changed.emit(enabled) diff --git a/addons/godot_ai/dock_panels/log_viewer.gd.uid b/addons/godot_ai/dock_panels/log_viewer.gd.uid new file mode 100644 index 0000000..c261627 --- /dev/null +++ b/addons/godot_ai/dock_panels/log_viewer.gd.uid @@ -0,0 +1 @@ +uid://cr5nbnd6vj3b8 diff --git a/addons/godot_ai/dock_panels/port_picker_panel.gd b/addons/godot_ai/dock_panels/port_picker_panel.gd new file mode 100644 index 0000000..30c3ed3 --- /dev/null +++ b/addons/godot_ai/dock_panels/port_picker_panel.gd @@ -0,0 +1,78 @@ +@tool +extends VBoxContainer + +## Dock subpanel — port-change escape hatch surfaced inside the spawn-failure +## crash panel when the HTTP port is contested (PORT_EXCLUDED, FOREIGN_PORT). +## Emits `port_apply_requested(new_port)` after range-validation; the dock +## handles writing the EditorSetting and reloading the plugin. +## +## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at +## the top of mcp_dock.gd for the broader extraction story. + +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") + +signal port_apply_requested(new_port: int) + +var _spinbox: SpinBox + + +## Build the UI synchronously here so callers (and detached-tree tests that +## instantiate the dock with `McpDockScript.new()` and never enter the tree) +## can interact with the panel's controls right after `setup()`. Mirrors the +## pre-extraction inline-build behavior that test_dock.gd relies on. +## +## Idempotent: `_spinbox == null` covers an unlikely double-`setup()` call +## without rebuilding (which would orphan the prior controls). +func setup() -> void: + if _spinbox == null: + _build_ui() + + +func _build_ui() -> void: + add_theme_constant_override("separation", 4) + visible = false + + var picker_row := HBoxContainer.new() + picker_row.add_theme_constant_override("separation", 6) + + _spinbox = SpinBox.new() + _spinbox.min_value = ClientConfigurator.MIN_PORT + _spinbox.max_value = ClientConfigurator.MAX_PORT + _spinbox.step = 1 + _spinbox.value = ClientConfigurator.http_port() + _spinbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + picker_row.add_child(_spinbox) + + var apply_btn := Button.new() + apply_btn.text = "Apply + Reload" + apply_btn.tooltip_text = ( + "Saves godot_ai/http_port to Editor Settings and reloads the plugin so" + + " the server spawns on the new port." + ) + apply_btn.pressed.connect(_on_apply_pressed) + picker_row.add_child(apply_btn) + + add_child(picker_row) + + +## Re-seed the spinbox with a fresh suggestion every time the panel surfaces, +## so a stale value from a previous spawn-failure round can't carry over. Note +## that this OVERWRITES any unsaved user input — fine in practice because the +## dock's `_update_crash_panel` only calls this on `server_status` transitions +## (`if server_status == _last_server_status: return` short-circuit), so a +## user typing into the spinbox between transitions keeps their value. If the +## state flips while the picker is visible (e.g. `PORT_EXCLUDED` → `FOREIGN_PORT`), +## the in-flight edit is clobbered — accept that, the suggestion is more current. +func seed_suggested_port() -> void: + if _spinbox == null: + return + _spinbox.value = ClientConfigurator.suggest_free_port( + ClientConfigurator.http_port() + 1 + ) + + +func _on_apply_pressed() -> void: + var new_port: int = int(_spinbox.value) + if new_port < ClientConfigurator.MIN_PORT or new_port > ClientConfigurator.MAX_PORT: + return + port_apply_requested.emit(new_port) diff --git a/addons/godot_ai/dock_panels/port_picker_panel.gd.uid b/addons/godot_ai/dock_panels/port_picker_panel.gd.uid new file mode 100644 index 0000000..38b6320 --- /dev/null +++ b/addons/godot_ai/dock_panels/port_picker_panel.gd.uid @@ -0,0 +1 @@ +uid://hlggbo1q65eq diff --git a/addons/godot_ai/export/mcp_export_plugin.gd b/addons/godot_ai/export/mcp_export_plugin.gd new file mode 100644 index 0000000..5a8b885 --- /dev/null +++ b/addons/godot_ai/export/mcp_export_plugin.gd @@ -0,0 +1,69 @@ +@tool +extends EditorExportPlugin + +## Strips the MCP game-helper autoload from exported builds (#740). +## +## plugin.gd writes `autoload/_mcp_game_helper` into project.godot so the +## editor-spawned game process loads the helper. Exports bake project +## settings into the pack's project.binary, so without this plugin every +## export ships the autoload — and users who exclude addons/godot_ai/** +## in their export preset get three "Failed to instantiate an autoload" +## errors at game start. Even when the files ARE shipped, the helper is +## editor-tooling: no exported build should carry it. +## +## Strip mechanics: clear the in-memory ProjectSettings entry in +## _export_begin, restore it in _export_end. The export pipeline reads +## the live ProjectSettings when it bakes project.binary, which happens +## after _export_begin — verified end-to-end by +## script/ci-export-strip-smoke, which exports a real pack and asserts +## the autoload is absent inside it. We never call ProjectSettings.save() +## while stripped, so project.godot on disk keeps the autoload +## throughout; only the export snapshot loses it. +## +## Failure containment: if an export aborts so hard that _export_end +## never fires, the damage is bounded to the editor's in-memory settings +## — the running game reads project.godot from disk, and plugin.gd's +## _ensure_game_helper_autoload() re-asserts the entry on the next +## plugin enable / editor launch. + +## Must equal "autoload/" + plugin.gd's GAME_HELPER_AUTOLOAD_NAME. +## Duplicated (not preloaded from plugin.gd) to avoid a cyclic preload — +## plugin.gd preloads this script. The pairing is locked by +## test_export_strip.gd's constants-contract test. +const AUTOLOAD_KEY := "autoload/_mcp_game_helper" + +var _saved_value: Variant = null +var _stripped := false + + +func _get_name() -> String: + return "GodotAIStripAutoload" + + +func _export_begin(_features: PackedStringArray, _is_debug: bool, _path: String, _flags: int) -> void: + ## `_stripped` guard: if a previous export died before _export_end, + ## don't overwrite the genuinely-saved value with the already-cleared + ## state — restore semantics stay anchored to the original value. + if _stripped: + return + if not ProjectSettings.has_setting(AUTOLOAD_KEY): + return + _saved_value = ProjectSettings.get_setting(AUTOLOAD_KEY) + ## Setting a project setting to null erases it. + ProjectSettings.set_setting(AUTOLOAD_KEY, null) + _stripped = true + print("MCP | export: stripping %s from the exported pack (restored in the editor after export)" % AUTOLOAD_KEY) + + +func _export_end() -> void: + if not _stripped: + return + ProjectSettings.set_setting(AUTOLOAD_KEY, _saved_value) + ## Mirror _ensure_game_helper_autoload()'s registration shape so the + ## restored entry is indistinguishable from the original: initial + ## value "" keeps project.godot diff-clean, basic keeps it visible in + ## the non-advanced settings view. + ProjectSettings.set_initial_value(AUTOLOAD_KEY, "") + ProjectSettings.set_as_basic(AUTOLOAD_KEY, true) + _saved_value = null + _stripped = false diff --git a/addons/godot_ai/export/mcp_export_plugin.gd.uid b/addons/godot_ai/export/mcp_export_plugin.gd.uid new file mode 100644 index 0000000..f2a3634 --- /dev/null +++ b/addons/godot_ai/export/mcp_export_plugin.gd.uid @@ -0,0 +1 @@ +uid://np0cp6fwpim7 diff --git a/addons/godot_ai/handlers/_node_validator.gd b/addons/godot_ai/handlers/_node_validator.gd new file mode 100644 index 0000000..61bbe10 --- /dev/null +++ b/addons/godot_ai/handlers/_node_validator.gd @@ -0,0 +1,71 @@ +@tool +class_name McpNodeValidator +extends RefCounted + +## Shared resolve-or-error helper that subsumes the 38+ sites where +## handlers each rolled their own "is the editor ready, does the path +## resolve, otherwise return EDITOR_NOT_READY / NODE_NOT_FOUND" guard. +## +## audit-v2 #20 (issue #364). Uses the audit-v2 #21 (issue #365) error +## vocabulary. + +## Local const names alias the preloaded scripts. The naming choice is +## stylistic, not an upgrade-safety boundary: bare `McpErrorCodes.MEMBER` +## and `ErrorCodes.MEMBER` both depend on the Script object Godot has for +## `error_codes.gd`. The transient #398 parse errors were caused by the +## old runner scanning a mixed old/new plugin snapshot and seeing stale +## Script-object content; the runner now writes one v(N+1) snapshot before +## its scan. +const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd") +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + + +## Resolve a scene-relative path to the live Node, or return a structured +## error dict. +## +## Success shape: `{"node": Node, "scene_root": Node, "path": String}`. +## Error shape: matches `ErrorCodes.make(...)` so callers can +## `return resolved` to propagate. +## +## Errors (in order checked): +## - `MISSING_REQUIRED_PARAM`: `node_path` is empty +## - `EDITOR_NOT_READY`: no scene open +## - `EDITED_SCENE_MISMATCH`: caller pinned `scene_file` and the open +## scene's path doesn't match +## - `NODE_NOT_FOUND`: `node_path` doesn't resolve under the scene root +## +## `param_name` is the agent-facing name reported in the +## `MISSING_REQUIRED_PARAM` message — handlers pass "node_path", +## "player_path", "target_path", etc. so the error reads like the +## hand-written messages it replaces. +static func resolve_or_error( + node_path: String, + param_name: String = "path", + scene_file: String = "", +) -> Dictionary: + if node_path.is_empty(): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "Missing required param: %s" % param_name, + ) + var scene_check := ScenePath.require_edited_scene(scene_file) + if scene_check.has("error"): + return scene_check + var scene_root: Node = scene_check.node + var node := ScenePath.resolve(node_path, scene_root) + if node == null: + return ErrorCodes.make( + ErrorCodes.NODE_NOT_FOUND, + ScenePath.format_node_error(node_path, scene_root), + ) + return {"node": node, "scene_root": scene_root, "path": node_path} + + +## When the caller needs the scene root but no specific node yet — e.g. +## handlers that walk children or filter by group. Returns either +## `{"scene_root": Node}` or an `ErrorCodes.make(...)` error dict. +static func require_scene_or_error(scene_file: String = "") -> Dictionary: + var scene_check := ScenePath.require_edited_scene(scene_file) + if scene_check.has("error"): + return scene_check + return {"scene_root": scene_check.node} diff --git a/addons/godot_ai/handlers/_node_validator.gd.uid b/addons/godot_ai/handlers/_node_validator.gd.uid new file mode 100644 index 0000000..b3153a6 --- /dev/null +++ b/addons/godot_ai/handlers/_node_validator.gd.uid @@ -0,0 +1 @@ +uid://dn75jifad0ghx diff --git a/addons/godot_ai/handlers/_param_validators.gd b/addons/godot_ai/handlers/_param_validators.gd new file mode 100644 index 0000000..a316b32 --- /dev/null +++ b/addons/godot_ai/handlers/_param_validators.gd @@ -0,0 +1,30 @@ +@tool +class_name McpParamValidators +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Type-check a JSON-decoded param Variant before assigning it into a typed +## GDScript local. The dispatcher only catches handler crashes as an opaque +## "malformed result" (issue #210), so a typed assignment like +## var group: String = params.get("group", "") +## will runtime-error and bubble up without telling the caller which param +## was the wrong shape. Only string params are guarded — int/bool params +## can't be: Godot's JSON parser decodes every number as float (a wire `5` +## arrives as `5.0`), so a strict int check would reject every legitimate +## integer a client sends, and GDScript's typed assignment already converts +## numeric Variants safely. Bool params arrive as real bools and a wrong +## type surfaces through the dispatcher's malformed-result path. + + +## Returns null iff `value` is a String or StringName. On any other type +## returns an INVALID_PARAMS error dict whose message names both `name` and +## the actual Variant type (via Godot's built-in `type_string`). +static func require_string(name: String, value: Variant) -> Variant: + var t := typeof(value) + if t == TYPE_STRING or t == TYPE_STRING_NAME: + return null + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Param '%s' must be a String, got %s" % [name, type_string(t)], + ) diff --git a/addons/godot_ai/handlers/_param_validators.gd.uid b/addons/godot_ai/handlers/_param_validators.gd.uid new file mode 100644 index 0000000..cd2061e --- /dev/null +++ b/addons/godot_ai/handlers/_param_validators.gd.uid @@ -0,0 +1 @@ +uid://difa877m8dsla diff --git a/addons/godot_ai/handlers/_property_errors.gd b/addons/godot_ai/handlers/_property_errors.gd new file mode 100644 index 0000000..316b941 --- /dev/null +++ b/addons/godot_ai/handlers/_property_errors.gd @@ -0,0 +1,82 @@ +@tool +class_name McpPropertyErrors +extends RefCounted + +## Shared helper for building "Property not found" error messages that include +## "did you mean" suggestions and a tail of available property names. All +## handlers that validate user-supplied property names against a target Object +## (Node, Resource, …) should route through build_message() so agents get +## consistent, actionable errors on typos. +## +## Ranking combines Godot's built-in String.similarity() with a substring +## bonus so both "radus" → "radius" (edit distance) and "top" → "top_radius" +## (substring) surface naturally. + +const _SIMILARITY_THRESHOLD: float = 0.4 +const _SUBSTRING_BONUS: float = 0.5 +const _MAX_SUGGESTIONS: int = 5 +const _MAX_TAIL: int = 10 + + +static func build_message(target: Object, bad_name: String) -> String: + if target == null: + return "Property '%s' not found" % bad_name + var class_label := _class_label(target) + var available := _available_property_names(target) + if available.is_empty(): + return "Property '%s' not found on %s" % [bad_name, class_label] + + var msg := "Property '%s' not found on %s" % [bad_name, class_label] + var suggestions := _rank_suggestions(bad_name, available) + if not suggestions.is_empty(): + msg += ". Did you mean: %s?" % ", ".join(suggestions) + + var tail_names := available.slice(0, min(_MAX_TAIL, available.size())) + msg += " (available: %s" % ", ".join(tail_names) + if available.size() > tail_names.size(): + msg += ", ..." + msg += ")" + return msg + + +## Prefer a scripted class_name if the target has one, else the engine class. +static func _class_label(target: Object) -> String: + var scr := target.get_script() + if scr != null and scr.has_method("get_global_name"): + var gcn: String = scr.get_global_name() + if not gcn.is_empty(): + return gcn + return target.get_class() + + +## Editor-visible properties, alphabetised, with internal/category entries dropped. +static func _available_property_names(target: Object) -> Array: + var names: Array = [] + for p in target.get_property_list(): + var usage: int = int(p.get("usage", 0)) + if (usage & PROPERTY_USAGE_EDITOR) == 0: + continue + var name: String = p.get("name", "") + if name.is_empty() or name.begins_with("_"): + continue + names.append(name) + names.sort() + return names + + +static func _rank_suggestions(bad: String, available: Array) -> Array: + if bad.is_empty(): + return [] + var bad_lower := bad.to_lower() + var scored: Array = [] + for n in available: + var score: float = bad.similarity(n) + if n.to_lower().find(bad_lower) != -1 or bad_lower.find(n.to_lower()) != -1: + score += _SUBSTRING_BONUS + if score >= _SIMILARITY_THRESHOLD: + scored.append([score, n]) + scored.sort_custom(func(a, b): return a[0] > b[0]) + var result: Array = [] + for i in range(min(_MAX_SUGGESTIONS, scored.size())): + result.append(scored[i][1]) + return result diff --git a/addons/godot_ai/handlers/_property_errors.gd.uid b/addons/godot_ai/handlers/_property_errors.gd.uid new file mode 100644 index 0000000..c29d21c --- /dev/null +++ b/addons/godot_ai/handlers/_property_errors.gd.uid @@ -0,0 +1 @@ +uid://c74d560g4l86b diff --git a/addons/godot_ai/handlers/animation_handler.gd b/addons/godot_ai/handlers/animation_handler.gd new file mode 100644 index 0000000..8000b78 --- /dev/null +++ b/addons/godot_ai/handlers/animation_handler.gd @@ -0,0 +1,825 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles AnimationPlayer authoring: creating players, animations, tracks, +## keyframes, autoplay, and dev-ergonomics playback. +## +## Animations live inside an AnimationLibrary attached to an AnimationPlayer +## node in the scene. They save with the .tscn — no separate resource file +## needed. Undo callables hold direct Animation references (not paths). +## +## Split (issue #342, audit finding #13): +## - animation_presets.gd → preset_fade / slide / shake / pulse + helpers +## - animation_values.gd → animation_list / get / validate + shared +## value coercion / serialization +## Both submodules hold a WeakRef back to this handler. The handler's +## preset_* / list / get / validate methods are thin proxies so existing +## dispatcher registrations and test fixtures don't change. + +const AnimationPresets := preload("res://addons/godot_ai/handlers/animation_presets.gd") +const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd") + +var _undo_redo: EditorUndoRedoManager +var _presets +var _values + +const _LOOP_MODES := { + "none": Animation.LOOP_NONE, + "linear": Animation.LOOP_LINEAR, + "pingpong": Animation.LOOP_PINGPONG, +} + +const _INTERP_MODES := { + "nearest": Animation.INTERPOLATION_NEAREST, + "linear": Animation.INTERPOLATION_LINEAR, + "cubic": Animation.INTERPOLATION_CUBIC, +} + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + _presets = AnimationPresets.new(self) + _values = AnimationValues.new(self) + + +# ============================================================================ +# animation_player_create +# ============================================================================ + +func create_player(params: Dictionary) -> Dictionary: + var parent_path: String = params.get("parent_path", "") + var node_name: String = params.get("name", "AnimationPlayer") + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + var player := AnimationPlayer.new() + if not node_name.is_empty(): + player.name = node_name + + # Attach the default library before adding to tree — it persists on redo. + var library := AnimationLibrary.new() + player.add_animation_library("", library) + + _undo_redo.create_action("MCP: Create AnimationPlayer %s" % player.name) + _undo_redo.add_do_method(parent, "add_child", player, true) + _undo_redo.add_do_method(player, "set_owner", scene_root) + _undo_redo.add_do_reference(player) + _undo_redo.add_do_reference(library) + _undo_redo.add_undo_method(parent, "remove_child", player) + _undo_redo.commit_action() + + return { + "data": { + "path": McpScenePath.from_node(player, scene_root), + "parent_path": McpScenePath.from_node(parent, scene_root), + "name": String(player.name), + "undoable": true, + } + } + + +# ============================================================================ +# animation_create +# ============================================================================ + +func create_animation(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("name", "") + var length: float = float(params.get("length", 1.0)) + var loop_mode_str: String = params.get("loop_mode", "none") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if anim_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") + if length <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "length must be > 0 (got %s)" % length) + + if not _LOOP_MODES.has(loop_mode_str): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid loop_mode '%s'. Valid: %s" % [loop_mode_str, ", ".join(_LOOP_MODES.keys())]) + + var resolved := _resolve_player(player_path, true) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + var library: AnimationLibrary = resolved.library + var created_player: bool = resolved.get("player_created", false) + var player_parent: Node = resolved.get("player_parent", null) + var created_library := false + if library == null: + library = AnimationLibrary.new() + created_library = true + + var overwrite: bool = params.get("overwrite", false) + var old_anim: Animation = null + if library.has_animation(anim_name): + if not overwrite: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) + old_anim = library.get_animation(anim_name) + + var anim := Animation.new() + anim.length = length + anim.loop_mode = _LOOP_MODES[loop_mode_str] + + _commit_animation_add("MCP: Create animation %s" % anim_name, + player, library, created_library, anim_name, anim, old_anim, + created_player, player_parent) + + return { + "data": { + "player_path": player_path, + "name": anim_name, + "length": length, + "loop_mode": loop_mode_str, + "library_created": created_library or created_player, + "animation_player_created": created_player, + "overwritten": old_anim != null, + "undoable": true, + } + } + + +# ============================================================================ +# animation_delete +# ============================================================================ + +func delete_animation(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("animation_name", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if anim_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + # Use _resolve_animation so we can delete from ANY library, not just the + # default. Mirrors the read-side symmetry with animation_get / animation_play + # which already search all libraries via _resolve_animation. + var anim_resolved := _resolve_animation(player, anim_name) + if anim_resolved.has("error"): + return anim_resolved + var old_anim: Animation = anim_resolved.animation + var library: AnimationLibrary = anim_resolved.library + # Clip key within the owning library — strips the "libname/" prefix if the + # caller passed a qualified name. + var clip_key: String = anim_name + var slash := anim_name.find("/") + if slash >= 0: + clip_key = anim_name.substr(slash + 1) + + _undo_redo.create_action("MCP: Delete animation %s" % anim_name) + _undo_redo.add_do_method(library, "remove_animation", clip_key) + _undo_redo.add_undo_method(library, "add_animation", clip_key, old_anim) + _undo_redo.add_do_reference(old_anim) # prevent GC so undo→redo works + _undo_redo.commit_action() + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "library_key": anim_resolved.get("library_key", ""), + "undoable": true, + } + } + + +# ============================================================================ +# animation_add_property_track +# ============================================================================ + +func add_property_track(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("animation_name", "") + var track_path: String = params.get("track_path", "") + var keyframes = params.get("keyframes", []) + var interp_str: String = params.get("interpolation", "linear") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if anim_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") + if track_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, + "Missing required param: track_path (format: 'NodeName:property', e.g. 'Panel:modulate')") + if not track_path.contains(":"): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "track_path must include ':property' suffix (e.g. 'Panel:modulate', '.:position')") + if not _INTERP_MODES.has(interp_str): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid interpolation '%s'. Valid: %s" % [interp_str, ", ".join(_INTERP_MODES.keys())]) + if typeof(keyframes) != TYPE_ARRAY or keyframes.is_empty(): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "keyframes must be a non-empty array") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + var anim_resolved := _resolve_animation(player, anim_name) + if anim_resolved.has("error"): + return anim_resolved + var anim: Animation = anim_resolved.animation + + # Validate + pre-coerce keyframes before mutating. Coercion errors + # surface as INVALID_PARAMS rather than silently inserting garbage keys. + # Resolve the target property's type ONCE — dense clips used to re-walk + # get_property_list() per keyframe. + var ctx := AnimationValues.resolve_track_prop_context(track_path, player) + if ctx.has("error"): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, ctx.error) + var coerced_keyframes: Array = [] + for kf in keyframes: + if typeof(kf) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each keyframe must be a dictionary") + if not "time" in kf: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'time' field") + if not "value" in kf: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'value' field") + var coerce_result := AnimationValues.coerce_with_context(kf.get("value"), ctx) + if coerce_result.has("error"): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, coerce_result.error) + coerced_keyframes.append({ + "time": kf.get("time"), + "value": coerce_result.ok, + "transition": kf.get("transition", "linear"), + }) + + _create_scene_pinned_action("MCP: Add property track %s to %s" % [track_path, anim_name]) + _undo_redo.add_do_method(self, "_do_add_property_track", anim, track_path, interp_str, coerced_keyframes) + # Undo locates the track by (path, type) at undo time rather than caching + # an index captured at do time. Cached indices go stale if any other track + # mutation lands between do and undo (Godot editor, another MCP call, etc.) + _undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, track_path, Animation.TYPE_VALUE) + _undo_redo.commit_action() + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "track_path": track_path, + "interpolation": interp_str, + "keyframe_count": keyframes.size(), + "undoable": true, + } + } + + +## Insert a pre-coerced track into the animation. Callers must coerce +## values against the target property before calling this (see +## AnimationValues.coerce_value_for_track) — this method runs inside the +## undo do-method path where error propagation isn't possible. +func _do_add_property_track( + anim: Animation, + track_path: String, + interp_str: String, + keyframes: Array, +) -> void: + var idx := anim.add_track(Animation.TYPE_VALUE) + anim.track_set_path(idx, NodePath(track_path)) + anim.track_set_interpolation_type(idx, _INTERP_MODES.get(interp_str, Animation.INTERPOLATION_LINEAR)) + for kf in keyframes: + var t: float = float(kf.get("time", 0.0)) + var trans: float = AnimationValues.parse_transition(kf.get("transition", "linear")) + anim.track_insert_key(idx, t, kf.get("value"), trans) + + +# ============================================================================ +# animation_add_method_track +# ============================================================================ + +func add_method_track(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("animation_name", "") + var target_path: String = params.get("target_node_path", "") + var keyframes = params.get("keyframes", []) + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if anim_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") + if target_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_node_path") + if target_path.contains(":"): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "target_node_path is a bare NodePath without ':property' (got '%s'). " % target_path + + "Method name goes in each keyframe's 'method' field, not the path.") + if typeof(keyframes) != TYPE_ARRAY or keyframes.is_empty(): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "keyframes must be a non-empty array") + + for kf in keyframes: + if typeof(kf) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each keyframe must be a dictionary") + if not "time" in kf: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'time' field") + if not "method" in kf: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'method' field") + var method_field = kf.get("method") + if typeof(method_field) != TYPE_STRING or (method_field as String).is_empty(): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'method' must be a non-empty string") + if kf.has("args") and typeof(kf.get("args")) != TYPE_ARRAY: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "'args' must be an array if provided (got %s)" % type_string(typeof(kf.get("args")))) + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + var anim_resolved := _resolve_animation(player, anim_name) + if anim_resolved.has("error"): + return anim_resolved + var anim: Animation = anim_resolved.animation + + _create_scene_pinned_action("MCP: Add method track %s to %s" % [target_path, anim_name]) + _undo_redo.add_do_method(self, "_do_add_method_track", anim, target_path, keyframes) + # Undo locates the track by (path, type) at undo time — see add_property_track. + _undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, target_path, Animation.TYPE_METHOD) + _undo_redo.commit_action() + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "target_node_path": target_path, + "keyframe_count": keyframes.size(), + "undoable": true, + } + } + + +## Remove a track identified by (path, type) at undo time. Robust to +## history interleaving: if another track was added since the do, the +## find_track call still resolves to the correct index. Returns silently +## if the track is no longer present (e.g. a prior undo already removed it). +func _undo_remove_track_by_path(anim: Animation, track_path: String, track_type: int) -> void: + var idx := anim.find_track(NodePath(track_path), track_type) + if idx >= 0: + anim.remove_track(idx) + + +func _do_add_method_track(anim: Animation, target_path: String, keyframes: Array) -> void: + var idx := anim.add_track(Animation.TYPE_METHOD) + anim.track_set_path(idx, NodePath(target_path)) + for kf in keyframes: + var t: float = float(kf.get("time", 0.0)) + var method_name: String = str(kf.get("method", "")) + var args: Array = kf.get("args", []) + anim.track_insert_key(idx, t, {"method": method_name, "args": args}) + + +# ============================================================================ +# animation_set_autoplay +# ============================================================================ + +func set_autoplay(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("animation_name", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + # Allow empty string to clear autoplay; otherwise validate the name exists. + if not anim_name.is_empty() and not player.has_animation(anim_name): + return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Animation '%s' not found on player at %s" % [anim_name, player_path]) + + var old_autoplay: String = player.autoplay + + _undo_redo.create_action("MCP: Set autoplay %s on %s" % [anim_name, player_path]) + _undo_redo.add_do_property(player, "autoplay", anim_name) + _undo_redo.add_undo_property(player, "autoplay", old_autoplay) + _undo_redo.commit_action() + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "previous_autoplay": old_autoplay, + "cleared": anim_name.is_empty(), + "undoable": true, + } + } + + +# ============================================================================ +# animation_play (dev ergonomics — not saved with scene) +# ============================================================================ + +func play(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("animation_name", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + if not anim_name.is_empty() and not player.has_animation(anim_name): + return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Animation '%s' not found on player at %s" % [anim_name, player_path]) + + player.play(anim_name) + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "undoable": false, + "reason": "Runtime playback state — not saved with scene", + } + } + + +# ============================================================================ +# animation_stop (dev ergonomics — not saved with scene) +# ============================================================================ + +func stop(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + player.stop() + + return { + "data": { + "player_path": player_path, + "undoable": false, + "reason": "Runtime playback state — not saved with scene", + } + } + + +# ============================================================================ +# animation_create_simple (composer) +# ============================================================================ + +func create_simple(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("name", "") + var tweens = params.get("tweens", []) + var loop_mode_str: String = params.get("loop_mode", "none") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if anim_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") + if typeof(tweens) != TYPE_ARRAY or tweens.is_empty(): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tweens must be a non-empty array") + if not _LOOP_MODES.has(loop_mode_str): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid loop_mode '%s'. Valid: %s" % [loop_mode_str, ", ".join(_LOOP_MODES.keys())]) + + # Validate all tween specs before touching the scene. + var seen_paths := {} + for spec in tweens: + if typeof(spec) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each tween spec must be a dictionary") + for field in ["target", "property", "from", "to", "duration"]: + if not field in spec: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, + "Each tween spec must have '%s'" % field) + if float(spec.get("duration", 0.0)) <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "tween 'duration' must be > 0") + var dup_key: String = str(spec.target) + ":" + str(spec.property) + if seen_paths.has(dup_key): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Duplicate tween target '%s' — merge keyframes into a single track " % dup_key + + "via animation_add_property_track instead of two separate tweens.") + seen_paths[dup_key] = true + + # Compute/validate length before resolving the player — a fresh auto-created + # AnimationPlayer is a detached Node that leaks if we return after creation. + var has_length: bool = params.has("length") and params.get("length") != null + var computed_length: float = 0.0 + if has_length: + computed_length = float(params.get("length")) + if computed_length <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "'length' must be > 0 when provided (got %s)" % str(params.get("length"))) + else: + for spec in tweens: + var end_time: float = float(spec.get("delay", 0.0)) + float(spec.get("duration", 0.0)) + if end_time > computed_length: + computed_length = end_time + if computed_length <= 0.0: + computed_length = 1.0 + + var resolved := _resolve_player(player_path, true) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + var library: AnimationLibrary = resolved.library + var created_player: bool = resolved.get("player_created", false) + var player_parent: Node = resolved.get("player_parent", null) + var created_library := false + if library == null: + library = AnimationLibrary.new() + created_library = true + + var overwrite: bool = params.get("overwrite", false) + var old_anim: Animation = null + if library.has_animation(anim_name): + if not overwrite: + if created_player: + player.queue_free() + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) + old_anim = library.get_animation(anim_name) + + # Pre-coerce all tween values before touching the anim — coercion errors + # surface as INVALID_PARAMS, not silent garbage keyframes. + # When the player was auto-created, it isn't in the tree yet — pass its + # future parent so the coercer can still resolve target property types. + var coerce_root: Node = player_parent if created_player else null + var per_track_keyframes: Array = [] + for spec in tweens: + var target: String = str(spec.get("target", "")) + var property: String = str(spec.get("property", "")) + var track_path: String = target + ":" + property + var duration: float = float(spec.get("duration", 1.0)) + var delay: float = float(spec.get("delay", 0.0)) + var trans_str = spec.get("transition", "linear") + var from_result := AnimationValues.coerce_value_for_track(spec.get("from"), track_path, player, coerce_root) + if from_result.has("error"): + if created_player: + player.queue_free() + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, from_result.error]) + var to_result := AnimationValues.coerce_value_for_track(spec.get("to"), track_path, player, coerce_root) + if to_result.has("error"): + if created_player: + player.queue_free() + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, to_result.error]) + per_track_keyframes.append({ + "track_path": track_path, + "keyframes": [ + {"time": delay, "value": from_result.ok, "transition": trans_str}, + {"time": delay + duration, "value": to_result.ok, "transition": trans_str}, + ], + }) + + # Build the animation fully in memory before touching the undo stack. + var anim := Animation.new() + anim.length = computed_length + anim.loop_mode = _LOOP_MODES[loop_mode_str] + + for entry in per_track_keyframes: + _do_add_property_track(anim, entry.track_path, "linear", entry.keyframes) + + # One atomic undo action — bundles player creation (if any), library + # creation (if any), and the animation add. A single Ctrl-Z rolls back all. + _commit_animation_add("MCP: Create animation %s (%d tracks)" % [anim_name, anim.get_track_count()], + player, library, created_library, anim_name, anim, old_anim, + created_player, player_parent) + + return { + "data": { + "player_path": player_path, + "name": anim_name, + "length": computed_length, + "loop_mode": loop_mode_str, + "track_count": anim.get_track_count(), + "library_created": created_library or created_player, + "animation_player_created": created_player, + "overwritten": old_anim != null, + "undoable": true, + } + } + + +# ============================================================================ +# Proxies — preset_* and read methods live in the submodules. Kept here so +# the dispatcher registrations and `_handler.method(...)` test fixtures stay +# unchanged across the split. +# ============================================================================ + +func preset_fade(params: Dictionary) -> Dictionary: + return _presets.preset_fade(params) + + +func preset_slide(params: Dictionary) -> Dictionary: + return _presets.preset_slide(params) + + +func preset_shake(params: Dictionary) -> Dictionary: + return _presets.preset_shake(params) + + +func preset_pulse(params: Dictionary) -> Dictionary: + return _presets.preset_pulse(params) + + +func list_animations(params: Dictionary) -> Dictionary: + return _values.list_animations(params) + + +func get_animation(params: Dictionary) -> Dictionary: + return _values.get_animation(params) + + +func validate_animation(params: Dictionary) -> Dictionary: + return _values.validate_animation(params) + + +# ============================================================================ +# Helpers — undo +# ============================================================================ + +## Shared undo setup for create_animation and create_simple. Handles fresh- +## create, overwrite, library auto-create, and player auto-create in a single +## atomic action. When `created_player` is true, the player already has the +## library attached (eagerly, from `_instantiate_player`) and the library +## doesn't need its own undo bookkeeping — it rides along with the add_child. +func _commit_animation_add( + action_label: String, + player: AnimationPlayer, + library: AnimationLibrary, + created_library: bool, + anim_name: String, + anim: Animation, + old_anim: Animation, ## null when not overwriting + created_player: bool = false, + player_parent: Node = null, +) -> void: + _undo_redo.create_action(action_label) + if created_player: + var scene_root := EditorInterface.get_edited_scene_root() + _undo_redo.add_do_method(player_parent, "add_child", player, true) + _undo_redo.add_do_method(player, "set_owner", scene_root) + _undo_redo.add_do_reference(player) + _undo_redo.add_do_reference(library) + _undo_redo.add_undo_method(player_parent, "remove_child", player) + elif created_library: + _undo_redo.add_do_method(player, "add_animation_library", "", library) + _undo_redo.add_undo_method(player, "remove_animation_library", "") + _undo_redo.add_do_reference(library) + if old_anim != null: + _undo_redo.add_do_method(library, "remove_animation", anim_name) + _undo_redo.add_do_method(library, "add_animation", anim_name, anim) + if old_anim != null: + _undo_redo.add_undo_method(library, "remove_animation", anim_name) + _undo_redo.add_undo_method(library, "add_animation", anim_name, old_anim) + _undo_redo.add_do_reference(old_anim) + else: + _undo_redo.add_undo_method(library, "remove_animation", anim_name) + _undo_redo.add_do_reference(anim) + _undo_redo.commit_action() + + +## Open a `create_action` pinned to the edited scene's history. +## +## Without an explicit context, `add_do_method(self, ...)` against a +## RefCounted handler lands in GLOBAL_HISTORY while sibling actions whose +## first do-target is a Resource (e.g. AnimationLibrary) land in the scene's +## history. Mismatched histories make the test-side `editor_undo` helper +## (walks scene first) undo the wrong action, and break batch_handler's +## rollback. Mirrors `camera_handler.gd`'s identical pinning rationale. +func _create_scene_pinned_action(action_label: String) -> void: + _undo_redo.create_action( + action_label, UndoRedo.MERGE_DISABLE, EditorInterface.get_edited_scene_root(), + ) + + +# ============================================================================ +# Helpers — resolution +# ============================================================================ + +## Resolve an AnimationPlayer and its default library for write operations. +## Returns {player, library, player_created, player_parent} on success, or an +## error dict. library is null if the player exists but has no default library +## yet — callers bundle an `add_animation_library` step into their undo action. +## +## When `create_if_missing` is true and `player_path` resolves to nothing, a +## fresh AnimationPlayer is instantiated (with an empty default library attached +## eagerly) but is NOT added to the scene tree — callers must bundle the +## add_child step into their undo action via `_commit_animation_add`. +## If the resolved node exists but isn't an AnimationPlayer, that's still an +## error — we don't clobber an existing node of a different type. +func _resolve_player(player_path: String, create_if_missing: bool = false) -> Dictionary: + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + var node := McpScenePath.resolve(player_path, scene_root) + if node == null: + if not create_if_missing: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(player_path, scene_root)) + return _instantiate_player(player_path, scene_root) + if not node is AnimationPlayer: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, + "Node at %s is not an AnimationPlayer (got %s)" % [player_path, node.get_class()]) + var player := node as AnimationPlayer + var lib: AnimationLibrary = null + if player.has_animation_library(""): + lib = player.get_animation_library("") + return {"player": player, "library": lib, "player_created": false, "player_parent": null} + + +## Build a new AnimationPlayer (with empty default library) for insertion under +## the parent implied by `player_path`. Returns an error dict if the parent +## can't be resolved or the path has no usable leaf name. +func _instantiate_player(player_path: String, scene_root: Node) -> Dictionary: + var slash := player_path.rfind("/") + var parent_path: String + var player_name: String + if slash < 0: + parent_path = "" + player_name = player_path + else: + parent_path = player_path.substr(0, slash) + player_name = player_path.substr(slash + 1) + if player_name.is_empty(): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Cannot auto-create AnimationPlayer: player_path '%s' has no leaf name" % player_path) + var parent: Node + if parent_path.is_empty(): + parent = scene_root + else: + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, + "Cannot auto-create AnimationPlayer at %s: %s" % [ + player_path, McpScenePath.format_parent_error(parent_path, scene_root)]) + var new_player := AnimationPlayer.new() + new_player.name = player_name + var lib := AnimationLibrary.new() + new_player.add_animation_library("", lib) + return { + "player": new_player, + "library": lib, + "player_created": true, + "player_parent": parent, + } + + +## Resolve for read operations (no library requirement). +func _resolve_player_read(player_path: String) -> Dictionary: + var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path") + if resolved.has("error"): + return resolved + var node: Node = resolved.node + if not node is AnimationPlayer: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, + "Node at %s is not an AnimationPlayer (got %s)" % [player_path, node.get_class()]) + return {"player": node as AnimationPlayer} + + +## Resolve an animation by name, searching all libraries. +## Accepts bare clip names ("idle") and library-qualified names ("moves/idle") +## as returned by `list_animations` for non-default libraries. +func _resolve_animation(player: AnimationPlayer, anim_name: String) -> Dictionary: + if not player.has_animation(anim_name): + return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Animation '%s' not found on player. Available: %s" % [ + anim_name, + ", ".join(Array(player.get_animation_list())) + ]) + # If the caller passed "library/clip", look up in that specific library. + var slash := anim_name.find("/") + if slash >= 0: + var lib_key := anim_name.substr(0, slash) + var clip_key := anim_name.substr(slash + 1) + if player.has_animation_library(lib_key): + var lib: AnimationLibrary = player.get_animation_library(lib_key) + if lib.has_animation(clip_key): + return {"animation": lib.get_animation(clip_key), "library": lib, "library_key": lib_key} + # Otherwise scan libraries for a bare clip name. + for lib_name in player.get_animation_library_list(): + var lib2: AnimationLibrary = player.get_animation_library(lib_name) + if lib2.has_animation(anim_name): + return {"animation": lib2.get_animation(anim_name), "library": lib2, "library_key": lib_name} + # Fallback — shouldn't happen if has_animation returned true. + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Animation found by player but not in any library") diff --git a/addons/godot_ai/handlers/animation_handler.gd.uid b/addons/godot_ai/handlers/animation_handler.gd.uid new file mode 100644 index 0000000..934f665 --- /dev/null +++ b/addons/godot_ai/handlers/animation_handler.gd.uid @@ -0,0 +1 @@ +uid://c0jrius46xsd4 diff --git a/addons/godot_ai/handlers/animation_presets.gd b/addons/godot_ai/handlers/animation_presets.gd new file mode 100644 index 0000000..40f1ddc --- /dev/null +++ b/addons/godot_ai/handlers/animation_presets.gd @@ -0,0 +1,536 @@ +@tool +extends RefCounted + +## Curated motion presets for the AnimationPlayer surface. +## +## Each preset_* method: +## 1. Validates params + resolves the player (auto-creating its default lib). +## 2. Resolves the target node + classifies it as control / 2d / 3d. +## 3. Builds a single-track Animation with shape-appropriate keyframes. +## 4. Commits the add through the handler's shared `_commit_animation_add` +## so a single Ctrl-Z rolls back any auto-created library + the animation. +## +## Holds a WeakRef back to the AnimationHandler instance so the handler can +## continue to own this module strongly via `_presets` without forming a +## RefCounted cycle. Resolution / undo helpers live on the handler — keeping +## the `_undo_redo` member single-source there avoids drift. + + +const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd") +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd") + + +var _handler_weak: WeakRef + + +func _init(handler) -> void: + _handler_weak = weakref(handler) + + +func _h(): + return _handler_weak.get_ref() + + +# ============================================================================ +# animation_preset_fade +# ============================================================================ + +func preset_fade(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var target_path: String = params.get("target_path", "") + var mode: String = params.get("mode", "in") + var duration: float = float(params.get("duration", 0.5)) + var anim_name: String = params.get("animation_name", "") + var overwrite: bool = params.get("overwrite", false) + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if target_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path") + if mode != "in" and mode != "out": + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid mode '%s'. Valid: 'in', 'out'" % mode) + if duration <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0") + + var handler = _h() + if handler == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "AnimationHandler not available", false) + var resolved: Dictionary = handler._resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + var library: AnimationLibrary = resolved.library + var created_library := false + if library == null: + library = AnimationLibrary.new() + created_library = true + + var target_resolved := _resolve_preset_target(player, target_path) + if target_resolved.has("error"): + return target_resolved + var target: Node = target_resolved.node + var track_target: String = target_resolved.track_path_root + + # Fade requires a `modulate` property (CanvasItem/Control/Node2D/Sprite3D/etc). + var has_modulate := false + for p in target.get_property_list(): + if p.name == "modulate": + has_modulate = true + break + if not has_modulate: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, + "Target '%s' (class %s) has no 'modulate' property — fade requires a CanvasItem, Control, Node2D, or Sprite3D" + % [target_path, target.get_class()]) + + if anim_name.is_empty(): + anim_name = "fade_%s" % mode + + var old_anim: Animation = null + if library.has_animation(anim_name): + if not overwrite: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) + old_anim = library.get_animation(anim_name) + + var start_a: float = 0.0 if mode == "in" else 1.0 + var end_a: float = 1.0 if mode == "in" else 0.0 + + var anim := Animation.new() + anim.length = duration + anim.loop_mode = Animation.LOOP_NONE + + var track_path := "%s:modulate:a" % track_target + handler._do_add_property_track(anim, track_path, "linear", [ + {"time": 0.0, "value": start_a, "transition": "linear"}, + {"time": duration, "value": end_a, "transition": "linear"}, + ]) + + handler._commit_animation_add( + "MCP: Create animation %s" % anim_name, + player, library, created_library, anim_name, anim, old_anim, + ) + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "mode": mode, + "length": duration, + "track_count": anim.get_track_count(), + "library_created": created_library, + "overwritten": old_anim != null, + "undoable": true, + } + } + + +# ============================================================================ +# animation_preset_slide +# ============================================================================ + +func preset_slide(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var target_path: String = params.get("target_path", "") + var direction: String = params.get("direction", "left") + var mode: String = params.get("mode", "in") + var duration: float = float(params.get("duration", 0.4)) + var anim_name: String = params.get("animation_name", "") + var overwrite: bool = params.get("overwrite", false) + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if target_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path") + if not ["left", "right", "up", "down"].has(direction): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid direction '%s'. Valid: 'left', 'right', 'up', 'down'" % direction) + if mode != "in" and mode != "out": + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid mode '%s'. Valid: 'in', 'out'" % mode) + if duration <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0") + + var handler = _h() + if handler == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "AnimationHandler not available", false) + var resolved: Dictionary = handler._resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + var library: AnimationLibrary = resolved.library + var created_library := false + if library == null: + library = AnimationLibrary.new() + created_library = true + + var target_resolved := _resolve_preset_target(player, target_path) + if target_resolved.has("error"): + return target_resolved + var target = target_resolved.node + var kind: String = target_resolved.kind + var track_target: String = target_resolved.track_path_root + + # Default distance picks 3D units vs screen pixels based on target kind. + var default_distance: float = 1.0 if kind == "3d" else 100.0 + var distance: float = float(params.get("distance", default_distance)) + if distance == 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'distance' must be non-zero") + + var offset: Variant = _direction_offset(kind, direction, distance) + var current_pos: Variant = target.position + var start_pos: Variant + var end_pos: Variant + if mode == "in": + start_pos = current_pos + offset + end_pos = current_pos + else: + start_pos = current_pos + end_pos = current_pos + offset + + if anim_name.is_empty(): + anim_name = "slide_%s_%s" % [mode, direction] + + var old_anim: Animation = null + if library.has_animation(anim_name): + if not overwrite: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) + old_anim = library.get_animation(anim_name) + + var anim := Animation.new() + anim.length = duration + anim.loop_mode = Animation.LOOP_NONE + + var track_path := "%s:position" % track_target + handler._do_add_property_track(anim, track_path, "linear", [ + {"time": 0.0, "value": start_pos, "transition": "linear"}, + {"time": duration, "value": end_pos, "transition": "linear"}, + ]) + + handler._commit_animation_add( + "MCP: Create animation %s" % anim_name, + player, library, created_library, anim_name, anim, old_anim, + ) + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "direction": direction, + "mode": mode, + "distance": distance, + "length": duration, + "track_count": anim.get_track_count(), + "library_created": created_library, + "overwritten": old_anim != null, + "undoable": true, + } + } + + +# ============================================================================ +# animation_preset_shake +# ============================================================================ + +func preset_shake(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var target_path: String = params.get("target_path", "") + var duration: float = float(params.get("duration", 0.3)) + var frequency: float = float(params.get("frequency", 30.0)) + var rng_seed: int = int(params.get("seed", 0)) + var anim_name: String = params.get("animation_name", "") + var overwrite: bool = params.get("overwrite", false) + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if target_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path") + if duration <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0") + if frequency <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'frequency' must be > 0") + + var handler = _h() + if handler == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "AnimationHandler not available", false) + var resolved: Dictionary = handler._resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + var library: AnimationLibrary = resolved.library + var created_library := false + if library == null: + library = AnimationLibrary.new() + created_library = true + + var target_resolved := _resolve_preset_target(player, target_path) + if target_resolved.has("error"): + return target_resolved + var target = target_resolved.node + var kind: String = target_resolved.kind + var track_target: String = target_resolved.track_path_root + + var default_intensity: float = 0.1 if kind == "3d" else 10.0 + var intensity: float = float(params.get("intensity", default_intensity)) + if intensity <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'intensity' must be > 0") + + if anim_name.is_empty(): + anim_name = "shake" + + var old_anim: Animation = null + if library.has_animation(anim_name): + if not overwrite: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) + old_anim = library.get_animation(anim_name) + + var rng := RandomNumberGenerator.new() + if rng_seed != 0: + rng.seed = rng_seed + else: + rng.randomize() + + # Samples between t=0 and t=duration (exclusive); bookended by at-rest keys. + var sample_count: int = int(ceil(frequency * duration)) + if sample_count < 2: + sample_count = 2 + + var current_pos: Variant = target.position + var kfs: Array = [] + kfs.append({"time": 0.0, "value": current_pos, "transition": "linear"}) + for i in range(1, sample_count): + var t: float = (float(i) / float(sample_count)) * duration + var jx: float = rng.randf_range(-intensity, intensity) + var jy: float = rng.randf_range(-intensity, intensity) + var jittered: Variant + if kind == "3d": + var jz: float = rng.randf_range(-intensity, intensity) + jittered = current_pos + Vector3(jx, jy, jz) + else: + jittered = current_pos + Vector2(jx, jy) + kfs.append({"time": t, "value": jittered, "transition": "linear"}) + kfs.append({"time": duration, "value": current_pos, "transition": "linear"}) + + var anim := Animation.new() + anim.length = duration + anim.loop_mode = Animation.LOOP_NONE + + var track_path := "%s:position" % track_target + handler._do_add_property_track(anim, track_path, "linear", kfs) + + handler._commit_animation_add( + "MCP: Create animation %s" % anim_name, + player, library, created_library, anim_name, anim, old_anim, + ) + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "length": duration, + "frequency": frequency, + "intensity": intensity, + "keyframe_count": kfs.size(), + "track_count": anim.get_track_count(), + "library_created": created_library, + "overwritten": old_anim != null, + "undoable": true, + } + } + + +# ============================================================================ +# animation_preset_pulse +# ============================================================================ + +func preset_pulse(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var target_path: String = params.get("target_path", "") + var from_scale: float = float(params.get("from_scale", 1.0)) + var to_scale: float = float(params.get("to_scale", 1.1)) + var duration: float = float(params.get("duration", 0.4)) + var anim_name: String = params.get("animation_name", "") + var overwrite: bool = params.get("overwrite", false) + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if target_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path") + if duration <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0") + if from_scale <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'from_scale' must be > 0") + if to_scale <= 0.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'to_scale' must be > 0") + + var handler = _h() + if handler == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "AnimationHandler not available", false) + var resolved: Dictionary = handler._resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + var library: AnimationLibrary = resolved.library + var created_library := false + if library == null: + library = AnimationLibrary.new() + created_library = true + + var target_resolved := _resolve_preset_target(player, target_path) + if target_resolved.has("error"): + return target_resolved + var kind: String = target_resolved.kind + var track_target: String = target_resolved.track_path_root + + if anim_name.is_empty(): + anim_name = "pulse" + + var old_anim: Animation = null + if library.has_animation(anim_name): + if not overwrite: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) + old_anim = library.get_animation(anim_name) + + var from_vec: Variant + var to_vec: Variant + if kind == "3d": + from_vec = Vector3(from_scale, from_scale, from_scale) + to_vec = Vector3(to_scale, to_scale, to_scale) + else: + from_vec = Vector2(from_scale, from_scale) + to_vec = Vector2(to_scale, to_scale) + + var anim := Animation.new() + anim.length = duration + anim.loop_mode = Animation.LOOP_NONE + + var track_path := "%s:scale" % track_target + handler._do_add_property_track(anim, track_path, "linear", [ + {"time": 0.0, "value": from_vec, "transition": "linear"}, + {"time": duration * 0.5, "value": to_vec, "transition": "linear"}, + {"time": duration, "value": from_vec, "transition": "linear"}, + ]) + + handler._commit_animation_add( + "MCP: Create animation %s" % anim_name, + player, library, created_library, anim_name, anim, old_anim, + ) + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "from_scale": from_scale, + "to_scale": to_scale, + "length": duration, + "track_count": anim.get_track_count(), + "library_created": created_library, + "overwritten": old_anim != null, + "undoable": true, + } + } + + +# ============================================================================ +# Helpers — preset resolution +# ============================================================================ + +## Resolve a preset target node and classify its transform kind. +## +## Accepts two `target_path` shapes: +## * Scene-absolute (starts with "/") — resolved through `ScenePath.resolve`, +## matching the convention used by every other scene-mutating tool. Targets +## outside the player's `root_node` subtree are converted to `..`-prefixed +## paths via `root_node.get_path_to(target)`, mirroring what the relative +## form accepts and how Godot stores track paths. +## * Relative — used as-is against the player's `root_node`, matching how +## animation tracks themselves are stored. +## +## Returns `{node, kind, track_path_root}` where `track_path_root` is the path +## (relative to `root_node`) that callers should embed in the track path. For +## scene-absolute inputs this is the converted relative path; for relative +## inputs it equals the input. `kind` ∈ {"control", "2d", "3d"}. +## +## Mirrors the same root-node fallback that +## `AnimationValues.resolve_track_prop_context` uses so tool inputs match how +## the track path will resolve at playback. +func _resolve_preset_target(player: AnimationPlayer, target_path: String) -> Dictionary: + var root_node := AnimationValues.player_root_node(player) + if root_node == null: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "AnimationPlayer at %s has no resolvable root_node (is the scene open?)" % str(player.get_path())) + + var target: Node = null + var track_path_root: String = target_path + if target_path.begins_with("/"): + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root == null: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Cannot resolve scene-absolute target_path '%s': no scene open" % target_path) + target = ScenePath.resolve(target_path, scene_root) + if target == null: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + ScenePath.format_node_error(target_path, scene_root)) + # Convert to a root_node-relative path. For targets outside the + # subtree this yields a `..`-prefixed path, matching what the + # relative form already accepts (root_node.get_node_or_null + # resolves `..` segments) and what Godot's animation engine + # stores natively. + track_path_root = str(root_node.get_path_to(target)) + else: + target = root_node.get_node_or_null(target_path) + if target == null: + # root_node.get_path() leaks the editor's SubViewport-wrapped + # path; use the clean scene-relative form so the hint is + # actionable. + var scene_root := EditorInterface.get_edited_scene_root() + var root_hint := ScenePath.from_node(root_node, scene_root) if scene_root != null else str(root_node.name) + var abs_example := "/%s/path/to/target" % scene_root.name if scene_root != null else "/SceneRoot/path/to/target" + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + ("Target node not found at '%s' (resolved relative to AnimationPlayer's root_node '%s'). " + + "Pass a path relative to root_node (e.g. \"path/to/target\") or a scene-absolute path (e.g. \"%s\").") + % [target_path, root_hint, abs_example]) + + var kind: String + if target is Control: + kind = "control" + elif target is Node2D: + kind = "2d" + elif target is Node3D: + kind = "3d" + else: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, + "Target '%s' must be a Control, Node2D, or Node3D (got %s)" % [target_path, target.get_class()]) + return {"node": target, "kind": kind, "track_path_root": track_path_root} + + +## Build a directional offset for slide presets. +## Axis conventions: +## Control + Node2D (screen-space, y-down): left/right = ∓x, up = -y, down = +y +## Node3D (world-up): left/right = ∓x, up = +y, down = -y +static func _direction_offset(kind: String, direction: String, distance: float) -> Variant: + if kind == "3d": + match direction: + "left": return Vector3(-distance, 0.0, 0.0) + "right": return Vector3(distance, 0.0, 0.0) + "up": return Vector3(0.0, distance, 0.0) + "down": return Vector3(0.0, -distance, 0.0) + else: + match direction: + "left": return Vector2(-distance, 0.0) + "right": return Vector2(distance, 0.0) + "up": return Vector2(0.0, -distance) + "down": return Vector2(0.0, distance) + return null diff --git a/addons/godot_ai/handlers/animation_presets.gd.uid b/addons/godot_ai/handlers/animation_presets.gd.uid new file mode 100644 index 0000000..f463501 --- /dev/null +++ b/addons/godot_ai/handlers/animation_presets.gd.uid @@ -0,0 +1 @@ +uid://c4s3h78bwvr6w diff --git a/addons/godot_ai/handlers/animation_values.gd b/addons/godot_ai/handlers/animation_values.gd new file mode 100644 index 0000000..b1553df --- /dev/null +++ b/addons/godot_ai/handlers/animation_values.gd @@ -0,0 +1,442 @@ +@tool +extends RefCounted + +const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd") + +## Read-only animation introspection + shared value-coercion / serialization. +## +## Holds: +## - Static helpers used by both the write handler (track building, simple +## composer) and the preset module (target/property resolution). +## - Instance methods that back the read MCP ops: animation_list, +## animation_get, animation_validate. +## +## The instance methods need the handler to resolve players / animations. +## To keep that without introducing a RefCounted cycle (the handler holds a +## strong ref to this module via `_values`), the back-pointer is a WeakRef. +## When the handler is freed during plugin teardown, _h() returns null and +## the (no-longer-routable) calls short-circuit to a generic editor-not-ready +## error — matches the dispatcher already being torn down at that point. + + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const PropertyErrors := preload("res://addons/godot_ai/handlers/_property_errors.gd") + + +const _NAMED_TRANSITIONS := { + "linear": 1.0, + "ease_in": 2.0, + "ease_out": 0.5, + "ease_in_out": -2.0, +} + +## Component letters accepted on each aggregate base type, paired with the +## scalar Variant type the component resolves to. A subpath like `position:y` +## on a Vector3 maps to TYPE_FLOAT; on a Vector3i it maps to TYPE_INT. +const _SUBPATH_COMPONENTS := { + TYPE_VECTOR2: ["xy", TYPE_FLOAT], + TYPE_VECTOR3: ["xyz", TYPE_FLOAT], + TYPE_VECTOR4: ["xyzw", TYPE_FLOAT], + TYPE_QUATERNION: ["xyzw", TYPE_FLOAT], + TYPE_COLOR: ["rgba", TYPE_FLOAT], + TYPE_VECTOR2I: ["xy", TYPE_INT], + TYPE_VECTOR3I: ["xyz", TYPE_INT], + TYPE_VECTOR4I: ["xyzw", TYPE_INT], +} + + +var _handler_weak: WeakRef + + +func _init(handler) -> void: + _handler_weak = weakref(handler) + + +func _h(): + return _handler_weak.get_ref() + + +# ============================================================================ +# animation_list (read) +# ============================================================================ + +func list_animations(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var handler = _h() + if handler == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "AnimationHandler not available", false) + var resolved: Dictionary = handler._resolve_player_read(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + var animations: Array[Dictionary] = [] + for lib_name in player.get_animation_library_list(): + var lib: AnimationLibrary = player.get_animation_library(lib_name) + for anim_name in lib.get_animation_list(): + var anim: Animation = lib.get_animation(anim_name) + var display_name: String = anim_name if lib_name == "" else "%s/%s" % [lib_name, anim_name] + animations.append({ + "name": display_name, + "length": anim.length, + "loop_mode": loop_mode_to_string(anim.loop_mode), + "track_count": anim.get_track_count(), + }) + + return { + "data": { + "player_path": player_path, + "animations": animations, + "count": animations.size(), + } + } + + +# ============================================================================ +# animation_get (read) +# ============================================================================ + +func get_animation(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("animation_name", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if anim_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") + + var handler = _h() + if handler == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "AnimationHandler not available", false) + var resolved: Dictionary = handler._resolve_player_read(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + var anim_resolved: Dictionary = handler._resolve_animation(player, anim_name) + if anim_resolved.has("error"): + return anim_resolved + var anim: Animation = anim_resolved.animation + + var tracks: Array[Dictionary] = [] + for i in anim.get_track_count(): + var track_type := anim.track_get_type(i) + var type_name := track_type_to_string(track_type) + var keys: Array[Dictionary] = [] + for k in anim.track_get_key_count(i): + var key_val = anim.track_get_key_value(i, k) + keys.append({ + "time": anim.track_get_key_time(i, k), + "value": serialize_value(key_val), + "transition": anim.track_get_key_transition(i, k), + }) + tracks.append({ + "index": i, + "type": type_name, + "path": str(anim.track_get_path(i)), + "interpolation": interp_to_string(anim.track_get_interpolation_type(i)), + "key_count": keys.size(), + "keys": keys, + }) + + return { + "data": { + "player_path": player_path, + "name": anim_name, + "length": anim.length, + "loop_mode": loop_mode_to_string(anim.loop_mode), + "track_count": anim.get_track_count(), + "tracks": tracks, + } + } + + +# ============================================================================ +# animation_validate (read-only) +# ============================================================================ + +func validate_animation(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var anim_name: String = params.get("animation_name", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if anim_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") + + var handler = _h() + if handler == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "AnimationHandler not available", false) + var resolved: Dictionary = handler._resolve_player_read(player_path) + if resolved.has("error"): + return resolved + var player: AnimationPlayer = resolved.player + + if not player.has_animation(anim_name): + return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Animation '%s' not found on player at %s" % [anim_name, player_path]) + + var anim: Animation = player.get_animation(anim_name) + + var root_node := player_root_node(player) + + var broken_tracks: Array[Dictionary] = [] + var valid_count := 0 + + for i in anim.get_track_count(): + var track_path_str := str(anim.track_get_path(i)) + # Split on the FIRST colon (node↔property boundary), not the last. + # Godot's get_node_or_null strips the ":property" tail natively, so + # the valid/broken classification is the same either way — but for + # BROKEN tracks the broken_tracks[].node_path field is what callers + # read to diagnose the missing node, and rfind would surface + # "MissingTarget:modulate" instead of "MissingTarget" for subpath + # tracks like the "Target:modulate:a" shape preset_fade emits. + var colon := track_path_str.find(":") + var node_part: String + if colon >= 0: + node_part = track_path_str.substr(0, colon) + else: + node_part = track_path_str + + var target_node: Node = null + if root_node != null: + target_node = root_node.get_node_or_null(node_part) + + if target_node == null: + broken_tracks.append({ + "index": i, + "path": track_path_str, + "type": track_type_to_string(anim.track_get_type(i)), + "issue": "node_not_found", + "node_path": node_part, + }) + else: + valid_count += 1 + + return { + "data": { + "player_path": player_path, + "animation_name": anim_name, + "track_count": anim.get_track_count(), + "valid_count": valid_count, + "broken_count": broken_tracks.size(), + "broken_tracks": broken_tracks, + "valid": broken_tracks.is_empty(), + } + } + + +# ============================================================================ +# Static helpers — shared with handler + presets +# ============================================================================ + +## Resolve the effective root node an AnimationPlayer animates against. +## Falls back to the player's parent when the explicit root_node NodePath is +## empty or unresolvable. Returns null when the player isn't in the tree. +## +## Mirrors the resolution Godot does at playback time so the validator, +## preset target resolver, and track-property coercer all see the same root. +static func player_root_node(player: AnimationPlayer) -> Node: + if not player.is_inside_tree(): + return null + var rn := player.root_node + if rn != NodePath(): + var n := player.get_node_or_null(rn) + if n != null: + return n + return player.get_parent() + + +## Coerce a JSON value to match the expected Godot type for the given +## track_path. Returns {"ok": value} or {"error": msg}. +## Passes the raw value through when the target node isn't in the scene +## yet (authoring-time path). Errors when the target exists but the +## property doesn't, or when parsing a typed value (Color/Vector2/Vector3) +## clearly fails — better to reject than silently store garbage. +## `override_root_node` lets callers supply the root to resolve target paths +## against when the player isn't in the tree yet (auto-create flow) — the +## player's future parent stands in for the root the AnimationPlayer will +## eventually use. +static func coerce_value_for_track(value: Variant, track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary: + var ctx := resolve_track_prop_context(track_path, player, override_root_node) + if ctx.has("error"): + return {"error": ctx.error} + return coerce_with_context(value, ctx) + + +## Resolve a track_path's target property type once, so callers coercing many +## keyframes avoid walking `get_property_list()` on every one. Returns: +## {pass_through: true} — no resolution / authoring-time +## {pass_through: false, prop_type, prop_name} — coerce against this type +## {error: msg} — property not found on target +## +## Supports Godot's native NodePath subpath form `property:sub` (e.g. +## `position:y`, `modulate:a`) — splits on the FIRST colon (node↔property +## boundary), resolves the base property on the target, and for known +## scalar subpaths (x/y/z/w on vectors, r/g/b/a on Color) narrows the +## coerce target to TYPE_FLOAT so JSON numbers land as floats, not dicts. +static func resolve_track_prop_context(track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary: + var colon := track_path.find(":") + if colon < 0: + return {"pass_through": true} + + var node_part := track_path.substr(0, colon) + var prop_full := track_path.substr(colon + 1) + + # Property may include a subpath: "position:y", "modulate:a", etc. + var sub_colon := prop_full.find(":") + var prop_base := prop_full if sub_colon < 0 else prop_full.substr(0, sub_colon) + var prop_sub := "" if sub_colon < 0 else prop_full.substr(sub_colon + 1) + + var root_node: Node = override_root_node + if root_node == null: + root_node = player_root_node(player) + if root_node == null: + return {"pass_through": true} + + var target: Node = root_node.get_node_or_null(node_part) + if target == null: + # Target node isn't in the scene yet — authoring-time path. Pass through. + return {"pass_through": true} + + for p in target.get_property_list(): + if p.name == prop_base: + var base_type: int = p.get("type", TYPE_NIL) + var coerce_type := base_type + if not prop_sub.is_empty(): + var sub_type := subpath_component_type(base_type, prop_sub) + if sub_type == TYPE_NIL: + # Unknown subpath component — pass through so Godot's own + # NodePath resolution raises at playback if it's truly bogus, + # rather than fabricating a coerce error for a valid-but- + # uncommon form (e.g. Transform3D subpaths). + return {"pass_through": true} + coerce_type = sub_type + return { + "pass_through": false, + "prop_type": coerce_type, + "prop_name": prop_full, + } + + # Target exists but the property doesn't. Reject loudly — silently storing + # the raw value here produces garbage keyframes at playback time. + return {"error": + "%s (target path: '%s')" % + [PropertyErrors.build_message(target, prop_base), node_part]} + + +## Map a `property:sub` subpath to its scalar component type. Returns +## TYPE_NIL when the base type / subkey pair isn't one we recognise — +## callers pass-through in that case rather than mis-coerce. +static func subpath_component_type(base_type: int, sub: String) -> int: + var entry = _SUBPATH_COMPONENTS.get(base_type) + if entry == null or sub.length() != 1: + return TYPE_NIL + return entry[1] if (entry[0] as String).contains(sub) else TYPE_NIL + + +static func coerce_with_context(value: Variant, ctx: Dictionary) -> Dictionary: + if ctx.get("pass_through", false): + return {"ok": value} + return coerce_for_type(value, ctx.prop_type, ctx.prop_name) + + +## Coerce a single value to the given Godot variant type. Returns +## {"ok": coerced} or {"error": msg}. Unknown types pass through. +static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary: + match prop_type: + TYPE_COLOR: + ## Canonical strict parser (#714): same shapes as every other + ## color-accepting handler, including [r,g,b(,a)] arrays. + var col = McpJsonValues.parse_color(value) + if col != null: + return {"ok": col} + return {"error": "Cannot coerce value to Color for property '%s' (expected \"#rrggbb(aa)\"/named string, {r,g,b[,a]}, [r,g,b(,a)], or Color)" % prop_name} + TYPE_VECTOR2: + var v2 = McpJsonValues.parse_vector2(value) + if v2 != null: + return {"ok": v2} + return {"error": "Cannot coerce value to Vector2 for property '%s' (expected {x,y}, [x,y], or Vector2)" % prop_name} + TYPE_VECTOR3: + var v3 = McpJsonValues.parse_vector3(value) + if v3 != null: + return {"ok": v3} + return {"error": "Cannot coerce value to Vector3 for property '%s' (expected {x,y,z}, [x,y,z], or Vector3)" % prop_name} + TYPE_FLOAT: + if value is int or value is float: + return {"ok": float(value)} + TYPE_INT: + if value is float or value is int: + return {"ok": int(value)} + TYPE_BOOL: + if value is int or value is float or value is bool: + return {"ok": bool(value)} + return {"ok": value} + + +# ============================================================================ +# Static helpers — parsing + serializing +# ============================================================================ + +## Parse a transition value: named string or raw float. +## Named values live in `_NAMED_TRANSITIONS` so the mapping has a single source. +static func parse_transition(v: Variant) -> float: + if v is float or v is int: + return float(v) + if v is String: + var key: String = (v as String).to_lower() + if _NAMED_TRANSITIONS.has(key): + return float(_NAMED_TRANSITIONS[key]) + return 1.0 + + +## Map an Animation.TrackType enum to a stable string. Unknown types report +## as "unknown" rather than being silently coerced to "method" — callers that +## only produce value/method tracks can ignore the others; clients that want +## to round-trip bezier/audio/etc. get an honest label to key off. +static func track_type_to_string(track_type: int) -> String: + match track_type: + Animation.TYPE_VALUE: return "value" + Animation.TYPE_METHOD: return "method" + Animation.TYPE_POSITION_3D: return "position_3d" + Animation.TYPE_ROTATION_3D: return "rotation_3d" + Animation.TYPE_SCALE_3D: return "scale_3d" + Animation.TYPE_BLEND_SHAPE: return "blend_shape" + Animation.TYPE_BEZIER: return "bezier" + Animation.TYPE_AUDIO: return "audio" + Animation.TYPE_ANIMATION: return "animation" + _: return "unknown" + + +static func loop_mode_to_string(mode: int) -> String: + match mode: + Animation.LOOP_LINEAR: return "linear" + Animation.LOOP_PINGPONG: return "pingpong" + _: return "none" + + +static func interp_to_string(mode: int) -> String: + match mode: + Animation.INTERPOLATION_NEAREST: return "nearest" + Animation.INTERPOLATION_CUBIC: return "cubic" + _: return "linear" + + +## Convert a Godot Variant to a JSON-safe value. +static func serialize_value(value: Variant) -> Variant: + ## Delegates to the shared serializer (#714) — the drifted private copy + ## stringified rotation_3d keyframe Quaternions into opaque text where + ## McpVariantSerializer emits the {x,y,z,w} dict callers can round-trip + ## (it also NaN/Inf-guards floats, matching the wire contract). + return VariantSerializer.serialize(value) diff --git a/addons/godot_ai/handlers/animation_values.gd.uid b/addons/godot_ai/handlers/animation_values.gd.uid new file mode 100644 index 0000000..5d2a8b7 --- /dev/null +++ b/addons/godot_ai/handlers/animation_values.gd.uid @@ -0,0 +1 @@ +uid://bguta2eb8blgf diff --git a/addons/godot_ai/handlers/api_handler.gd b/addons/godot_ai/handlers/api_handler.gd new file mode 100644 index 0000000..acfeda5 --- /dev/null +++ b/addons/godot_ai/handlers/api_handler.gd @@ -0,0 +1,89 @@ +@tool +extends RefCounted + +## Read-only access to version-correct Godot class metadata. + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd") +const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd") + +func get_class_info(params: Dictionary) -> Dictionary: + var requested_class: String = params.get("class_name", "") + if requested_class.is_empty(): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "Missing required param: class_name" + ) + if not ClassDB.class_exists(requested_class): + var script_class := _global_script_class(requested_class) + if not script_class.is_empty(): + return _script_class_error(requested_class, script_class) + return _unknown_class_error(requested_class) + if params.has("limit") and int(params.get("limit")) < 0: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "limit must be >= 0; use limit=0 only when an unlimited section is needed" + ) + var section_check := ClassIntrospection.validate_sections( + params.get("sections", ClassIntrospection.DEFAULT_SECTIONS) + ) + if not section_check.invalid.is_empty(): + return _invalid_sections_error(section_check.invalid) + return {"data": ClassIntrospection.build(requested_class, params)} + + +static func _unknown_class_error(requested_class: String) -> Dictionary: + var suggestions := _suggest_classes(requested_class) + var message := "Unknown Godot class: %s" % requested_class + if not suggestions.is_empty(): + message += ". Did you mean: %s?" % ", ".join(suggestions) + var result := ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, message) + result["error"]["data"] = {"suggestions": suggestions} + return result + + +static func _suggest_classes(requested_class: String) -> Array[String]: + return FuzzySuggestions.rank(requested_class, ClassDB.get_class_list()) + + +static func _global_script_class(requested_class: String) -> Dictionary: + for raw_info in ProjectSettings.get_global_class_list(): + var info: Dictionary = raw_info + if info.get("class", "") == requested_class: + return info + return {} + + +static func _script_class_error(requested_class: String, script_class: Dictionary) -> Dictionary: + var path := str(script_class.get("path", "")) + var base := str(script_class.get("base", "")) + var message := ( + "%s is a project script class, not a ClassDB class. " + + "Use script_manage(op=\"find_symbols\", params={\"path\": \"%s\"}) for script symbols." + ) % [requested_class, path] + var result := ErrorCodes.make(ErrorCodes.WRONG_TYPE, message) + result["error"]["data"] = { + "script_class": true, + "class_name": requested_class, + "base_class": base, + "path": path, + } + return result + + +static func _invalid_sections_error(invalid_sections: Array[String]) -> Dictionary: + var suggestions := {} + for section in invalid_sections: + suggestions[section] = FuzzySuggestions.rank( + section, + ClassIntrospection.SUGGESTABLE_SECTION_TOKENS, + 3, + 0.3 + ) + var message := "Unknown class-info section(s): %s. Valid sections: %s (or \"all\" for all documentation sections; \"inheritors\" must be requested by name)" % [ + ", ".join(invalid_sections), + ", ".join(ClassIntrospection.KNOWN_SECTIONS), + ] + var result := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message) + result["error"]["data"] = {"suggestions": suggestions} + return result diff --git a/addons/godot_ai/handlers/api_handler.gd.uid b/addons/godot_ai/handlers/api_handler.gd.uid new file mode 100644 index 0000000..f519c48 --- /dev/null +++ b/addons/godot_ai/handlers/api_handler.gd.uid @@ -0,0 +1 @@ +uid://v3rkd7ueunii diff --git a/addons/godot_ai/handlers/audio_handler.gd b/addons/godot_ai/handlers/audio_handler.gd new file mode 100644 index 0000000..79dea56 --- /dev/null +++ b/addons/godot_ai/handlers/audio_handler.gd @@ -0,0 +1,361 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles AudioStreamPlayer / 2D / 3D authoring — node creation, stream +## assignment, playback-property edits, and real editor preview playback. +## +## Stream assignment loads a Godot-imported AudioStream resource from +## res:// (the editor's import step converts .ogg / .wav / .mp3 into a +## streamable AudioStream subclass before we ever see it). +## +## play() / stop() call the live node method directly — no undo, no +## persistence; they match what the inspector's play button does. + + +const _VALID_TYPES := { + "1d": "AudioStreamPlayer", + "2d": "AudioStreamPlayer2D", + "3d": "AudioStreamPlayer3D", +} + +## Whitelist of playback properties settable via audio_player_set_playback. +## Each value is the expected Variant type of the param dict value. +const _PLAYBACK_KEYS := { + "volume_db": TYPE_FLOAT, + "pitch_scale": TYPE_FLOAT, + "autoplay": TYPE_BOOL, + "bus": TYPE_STRING, +} + + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +# ============================================================================ +# audio_player_create +# ============================================================================ + +func create_player(params: Dictionary) -> Dictionary: + var parent_path: String = params.get("parent_path", "") + var node_name: String = params.get("name", "AudioStreamPlayer") + var type_str: String = params.get("type", "1d") + + if not _VALID_TYPES.has(type_str): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid audio player type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] + ) + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + var node := _instantiate_player(type_str) + if node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate audio player") + if not node_name.is_empty(): + node.name = node_name + + _undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name]) + _undo_redo.add_do_method(parent, "add_child", node, true) + _undo_redo.add_do_method(node, "set_owner", scene_root) + _undo_redo.add_do_reference(node) + _undo_redo.add_undo_method(parent, "remove_child", node) + _undo_redo.commit_action() + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "parent_path": McpScenePath.from_node(parent, scene_root), + "name": String(node.name), + "type": type_str, + "class": _VALID_TYPES[type_str], + "undoable": true, + } + } + + +# ============================================================================ +# audio_player_set_stream +# ============================================================================ + +func set_stream(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var stream_path: String = params.get("stream_path", "") + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + if stream_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: stream_path") + + var stream_path_err = McpPathValidator.loadable_error(stream_path, "stream_path") + if stream_path_err != null: + return stream_path_err + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: Node = resolved.player + + if not ResourceLoader.exists(stream_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "AudioStream not found: %s" % stream_path) + var loaded := ResourceLoader.load(stream_path) + if loaded == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load AudioStream: %s" % stream_path) + if not (loaded is AudioStream): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Resource at %s is not an AudioStream (got %s)" % [stream_path, loaded.get_class()] + ) + + var old_stream: AudioStream = player.stream + + _undo_redo.create_action("MCP: Set audio stream on %s" % player.name) + _undo_redo.add_do_property(player, "stream", loaded) + _undo_redo.add_undo_property(player, "stream", old_stream) + _undo_redo.commit_action() + + return { + "data": { + "player_path": player_path, + "stream_path": stream_path, + "stream_class": loaded.get_class(), + "duration_seconds": float(loaded.get_length()), + "undoable": true, + } + } + + +# ============================================================================ +# audio_player_set_playback +# ============================================================================ + +func set_playback(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: Node = resolved.player + + var updates: Dictionary = {} + for key in _PLAYBACK_KEYS: + if params.has(key): + var expected_type: int = _PLAYBACK_KEYS[key] + var value = params.get(key) + var coerced = _coerce_playback_value(value, expected_type) + if coerced == null: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Invalid value for %s: expected %s, got %s" % [ + key, type_string(expected_type), type_string(typeof(value)) + ] + ) + updates[key] = coerced + + if updates.is_empty(): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "At least one of %s is required" % ", ".join(_PLAYBACK_KEYS.keys()) + ) + + var old_values: Dictionary = {} + for key in updates: + old_values[key] = player.get(key) + + _undo_redo.create_action("MCP: Update playback on %s" % player.name) + for key in updates: + _undo_redo.add_do_property(player, key, updates[key]) + _undo_redo.add_undo_property(player, key, old_values[key]) + _undo_redo.commit_action() + + return { + "data": { + "player_path": player_path, + "applied": updates.keys(), + "values": updates, + "undoable": true, + } + } + + +# ============================================================================ +# audio_play (runtime preview — not saved with scene) +# ============================================================================ + +func play(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + var from_position: float = float(params.get("from_position", 0.0)) + + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: Node = resolved.player + + if player.stream == null: + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "Player has no stream assigned — call audio_player_set_stream first" + ) + + player.play(from_position) + + return { + "data": { + "player_path": player_path, + "from_position": from_position, + "playing": bool(player.playing), + "undoable": false, + "reason": "Runtime playback state — not saved with scene", + } + } + + +# ============================================================================ +# audio_stop (runtime preview — not saved with scene) +# ============================================================================ + +func stop(params: Dictionary) -> Dictionary: + var player_path: String = params.get("player_path", "") + if player_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") + + var resolved := _resolve_player(player_path) + if resolved.has("error"): + return resolved + var player: Node = resolved.player + + player.stop() + + return { + "data": { + "player_path": player_path, + "playing": bool(player.playing), + "undoable": false, + "reason": "Runtime playback state — not saved with scene", + } + } + + +# ============================================================================ +# audio_list (read — scan project for AudioStream resources) +# ============================================================================ + +func list_streams(params: Dictionary) -> Dictionary: + var root: String = params.get("root", "res://") + var include_duration: bool = bool(params.get("include_duration", true)) + + var root_err = McpPathValidator.path_error(root, "root") + if root_err != null: + return root_err + + var efs := EditorInterface.get_resource_filesystem() + if efs == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "EditorFileSystem not available", false) + + var results: Array[Dictionary] = [] + var start_dir := efs.get_filesystem_path(root) + if start_dir == null: + start_dir = efs.get_filesystem() + _scan_audio(start_dir, root, include_duration, results) + return { + "data": { + "root": root, + "streams": results, + "count": results.size(), + } + } + + +func _scan_audio(dir: EditorFileSystemDirectory, root: String, include_duration: bool, out: Array[Dictionary]) -> void: + if dir == null: + return + for i in dir.get_file_count(): + var file_path := dir.get_file_path(i) + if not file_path.begins_with(root): + continue + var file_type := dir.get_file_type(i) + var is_audio := file_type == "AudioStream" or ClassDB.is_parent_class(file_type, "AudioStream") + if not is_audio: + continue + var entry: Dictionary = { + "path": file_path, + "class": file_type, + } + if include_duration: + var res := ResourceLoader.load(file_path) + if res is AudioStream: + entry["duration_seconds"] = float((res as AudioStream).get_length()) + else: + entry["duration_seconds"] = 0.0 + out.append(entry) + for i in dir.get_subdir_count(): + _scan_audio(dir.get_subdir(i), root, include_duration, out) + + +# ============================================================================ +# Helpers +# ============================================================================ + +static func _instantiate_player(type_str: String) -> Node: + match type_str: + "1d": + return AudioStreamPlayer.new() + "2d": + return AudioStreamPlayer2D.new() + "3d": + return AudioStreamPlayer3D.new() + return null + + +func _resolve_player(player_path: String) -> Dictionary: + var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path") + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var is_player := node is AudioStreamPlayer \ + or node is AudioStreamPlayer2D \ + or node is AudioStreamPlayer3D + if not is_player: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node at %s is not an AudioStreamPlayer/2D/3D (got %s)" % [player_path, node.get_class()] + ) + return {"player": node} + + +## Coerce a playback param value to the expected type. int→float is allowed +## so JSON integers pass through; everything else requires the exact type. +## Returns the coerced value, or null on type mismatch. +static func _coerce_playback_value(value: Variant, expected_type: int) -> Variant: + match expected_type: + TYPE_FLOAT: + if value is float or value is int: + return float(value) + TYPE_BOOL: + if value is bool: + return value + TYPE_STRING: + if value is String: + return value + return null diff --git a/addons/godot_ai/handlers/audio_handler.gd.uid b/addons/godot_ai/handlers/audio_handler.gd.uid new file mode 100644 index 0000000..2510ee7 --- /dev/null +++ b/addons/godot_ai/handlers/audio_handler.gd.uid @@ -0,0 +1 @@ +uid://cjtvod52xxocs diff --git a/addons/godot_ai/handlers/autoload_handler.gd b/addons/godot_ai/handlers/autoload_handler.gd new file mode 100644 index 0000000..22e7764 --- /dev/null +++ b/addons/godot_ai/handlers/autoload_handler.gd @@ -0,0 +1,91 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles autoload listing, adding, and removing via ProjectSettings. + + +func list_autoloads(_params: Dictionary) -> Dictionary: + var autoloads: Array[Dictionary] = [] + for prop in ProjectSettings.get_property_list(): + var key: String = prop.get("name", "") + if not key.begins_with("autoload/"): + continue + var name := key.substr("autoload/".length()) + var raw_value: String = ProjectSettings.get_setting(key, "") + var is_singleton := raw_value.begins_with("*") + var path := raw_value.substr(1) if is_singleton else raw_value + autoloads.append({ + "name": name, + "path": path, + "singleton": is_singleton, + }) + return {"data": {"autoloads": autoloads, "count": autoloads.size()}} + + +func add_autoload(params: Dictionary) -> Dictionary: + var name: String = params.get("name", "") + var path: String = params.get("path", "") + var singleton: bool = params.get("singleton", true) + + if name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + var path_err = McpPathValidator.path_error(path, "path") + if path_err != null: + return path_err + if not FileAccess.file_exists(path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path) + + var key := "autoload/%s" % name + if ProjectSettings.has_setting(key): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Autoload '%s' already exists" % name) + + var value := ("*" if singleton else "") + path + ProjectSettings.set_setting(key, value) + ProjectSettings.set_initial_value(key, "") + ProjectSettings.set_as_basic(key, true) + var err := ProjectSettings.save() + if err != OK: + ProjectSettings.clear(key) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Failed to save project settings while adding autoload '%s': %s (error %d)" % [name, error_string(err), err]) + + return { + "data": { + "name": name, + "path": path, + "singleton": singleton, + "undoable": false, + "reason": "Autoload changes are saved to project.godot", + } + } + + +func remove_autoload(params: Dictionary) -> Dictionary: + var name: String = params.get("name", "") + if name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") + + var key := "autoload/%s" % name + if not ProjectSettings.has_setting(key): + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "Autoload '%s' not found" % name) + + var old_value: String = ProjectSettings.get_setting(key, "") + ProjectSettings.clear(key) + var err := ProjectSettings.save() + if err != OK: + ProjectSettings.set_setting(key, old_value) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Failed to save project settings while removing autoload '%s': %s (error %d)" % [name, error_string(err), err]) + + return { + "data": { + "name": name, + "removed": true, + "undoable": false, + "reason": "Autoload changes are saved to project.godot", + } + } diff --git a/addons/godot_ai/handlers/autoload_handler.gd.uid b/addons/godot_ai/handlers/autoload_handler.gd.uid new file mode 100644 index 0000000..921ed4e --- /dev/null +++ b/addons/godot_ai/handlers/autoload_handler.gd.uid @@ -0,0 +1 @@ +uid://bb0inov044jn6 diff --git a/addons/godot_ai/handlers/batch_handler.gd b/addons/godot_ai/handlers/batch_handler.gd new file mode 100644 index 0000000..c6c1474 --- /dev/null +++ b/addons/godot_ai/handlers/batch_handler.gd @@ -0,0 +1,190 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Executes a list of sub-commands through the dispatcher with stop-on-first-error +## semantics. When undo=true (default), any successful sub-commands are rolled +## back via the scene's UndoRedo history if a later sub-command fails. + +## Commands that cannot run as batch sub-commands, each with the reason a batch +## can't host it. +## - batch_execute: would recurse. +## - run_tests: a batch executes synchronously inside one dispatcher tick with +## NO transport servicing, so a full suite starves the WebSocket heartbeat +## (the exact disconnect the serviced test_run path exists to prevent) and +## the Python batch handler only allows 30s anyway — call the test_run tool +## directly. +## - game_command: it is deferred — its reply flows out-of-band correlated by a +## _request_id that dispatch_direct deliberately strips (see +## McpDispatcher.dispatch_direct), so a game op nested in a batch would have +## no completion channel and hang or lose its reply. input_sequence made this +## concrete (#814); the whole game_command surface shares the deferred path. +const FORBIDDEN_SUBCOMMANDS := { + "batch_execute": "batch_execute cannot be nested inside another batch", + "run_tests": + "run_tests is not allowed as a sub-command — a batch runs synchronously " + + "with no transport servicing; call the test_run tool directly", + "game_command": + "game_command ops are deferred (their reply arrives out-of-band) and " + + "have no completion channel inside a batch — run them as their own tool call", +} + +## The whole batch executes synchronously inside one dispatcher tick, +## outside the 4ms frame budget — an unbounded array freezes the editor +## for the batch's full duration. 500 is far above any legitimate scene +## edit while keeping worst-case stalls in check. +const MAX_BATCH_COMMANDS := 500 + +var _dispatcher: McpDispatcher +var _undo_redo: EditorUndoRedoManager + + +func _init(dispatcher: McpDispatcher, undo_redo: EditorUndoRedoManager) -> void: + _dispatcher = dispatcher + _undo_redo = undo_redo + + +func batch_execute(params: Dictionary) -> Dictionary: + var commands = params.get("commands", null) + if typeof(commands) != TYPE_ARRAY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands must be a list") + if commands.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands must not be empty") + if commands.size() > MAX_BATCH_COMMANDS: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "commands exceeds the %d-command batch cap (got %d) — split into multiple batches" % [MAX_BATCH_COMMANDS, commands.size()] + ) + + var undo: bool = params.get("undo", true) + + for idx in range(commands.size()): + var item = commands[idx] + if typeof(item) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d] must be a dict" % idx) + var cmd_name: String = item.get("command", "") + if cmd_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands[%d] missing 'command' field" % idx) + if FORBIDDEN_SUBCOMMANDS.has(cmd_name): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "commands[%d]: %s" % [idx, FORBIDDEN_SUBCOMMANDS[cmd_name]]) + if not _dispatcher.has_command(cmd_name): + return _unknown_command_error(idx, cmd_name) + ## Pre-validate params type: the execution loop's typed Dictionary + ## local would hard-error on a non-dict mid-batch, aborting AFTER + ## earlier mutations committed. Catching it here keeps the + ## all-or-nothing contract for malformed input. + if typeof(item.get("params", {})) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d].params must be a dict" % idx) + if cmd_name.begins_with("custom_tool:"): + var _custom_tools := McpToolRegistry.get_instance() + if _custom_tools: + var spec := _custom_tools.get_spec(cmd_name.trim_prefix("custom_tool:")) + ## A deferred tool's real reply arrives via ctx.send_deferred + ## AFTER the batch has already returned its synchronous + ## results — the sub-command would report an opaque + ## "_deferred" placeholder and the actual payload would be + ## orphaned. Reject up-front, matching the FORBIDDEN_SUBCOMMANDS + ## contract for run_tests. + if spec != null and spec.deferred: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "commands[%d] custom_tool:%s is deferred — its reply outlives the batch response; call it directly instead" % [idx, cmd_name.trim_prefix("custom_tool:")] + ) + if undo and spec != null and not spec.undoable: + return ErrorCodes.make( + ErrorCodes.CUSTOM_TOOL_NOT_UNDOABLE, + "commands[%d] custom_tool:%s is not undoable" % [idx, cmd_name.trim_prefix("custom_tool:")] + ) + + var results: Array = [] + var succeeded := 0 + var stopped_at = null + var all_undoable := true + # Captured after the first successful commit — get_history_undo_redo() + # errors if called before any action exists in the history_map. + var histories: Array = [] + + for idx in range(commands.size()): + var item: Dictionary = commands[idx] + var cmd_name: String = item["command"] + var sub_params: Dictionary = item.get("params", {}) + + var raw_result: Dictionary = _dispatcher.dispatch_direct(cmd_name, sub_params) + var status: String = raw_result.get("status", "ok") + + var result_entry: Dictionary = {"command": cmd_name, "status": status} + if status == "error": + result_entry["error"] = raw_result.get("error", {}) + results.append(result_entry) + stopped_at = idx + break + else: + var data: Dictionary = raw_result.get("data", raw_result) + result_entry["data"] = data + if typeof(data) == TYPE_DICTIONARY and data.get("undoable", false) != true: + all_undoable = false + results.append(result_entry) + succeeded += 1 + _capture_histories(histories) + + var rolled_back := false + if stopped_at != null and undo and succeeded > 0: + rolled_back = _rollback(succeeded, histories) + + var response_data: Dictionary = { + "succeeded": succeeded, + "stopped_at": stopped_at, + "results": results, + "undo": undo, + "rolled_back": rolled_back, + "undoable": stopped_at == null and all_undoable and not rolled_back, + } + if stopped_at != null: + response_data["error"] = results[-1]["error"] + return {"data": response_data} + + +## Capture the scene's UndoRedo reference for batch rollback. Safe to call +## multiple times; appends only the new reference. MCP write handlers all pin +## their actions to the scene history, so the scene UndoRedo is the only one +## rollback needs. Must be called only after at least one action has been +## committed to the scene history. +func _capture_histories(histories: Array) -> void: + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root == null: + return + var scene_id := _undo_redo.get_object_history_id(scene_root) + var scene_ur := _undo_redo.get_history_undo_redo(scene_id) + if scene_ur != null and not scene_ur in histories: + histories.append(scene_ur) + + +## Build the unknown-command error for a sub-command. Clarifies that +## batch_execute expects plugin command names (not MCP tool names) and +## surfaces fuzzy suggestions in both the message and structured data. +func _unknown_command_error(idx: int, cmd_name: String) -> Dictionary: + var suggestions := _dispatcher.suggest_similar(cmd_name) + var msg := "commands[%d]: unknown plugin command '%s'. batch_execute expects plugin command names (e.g. 'create_node'), not MCP tool names (e.g. 'node_create')." % [idx, cmd_name] + if not suggestions.is_empty(): + msg += " Did you mean: %s?" % ", ".join(suggestions) + var err := ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, msg) + err["error"]["data"] = {"suggestions": suggestions} + return err + + +## Undo `count` actions by calling undo() on captured histories in LIFO order. +## Returns true iff all undo calls succeeded. +func _rollback(count: int, histories: Array) -> bool: + if histories.is_empty(): + return false + for _i in range(count): + var undone := false + for ur in histories: + if ur.undo(): + undone = true + break + if not undone: + return false + return true diff --git a/addons/godot_ai/handlers/batch_handler.gd.uid b/addons/godot_ai/handlers/batch_handler.gd.uid new file mode 100644 index 0000000..630a596 --- /dev/null +++ b/addons/godot_ai/handlers/batch_handler.gd.uid @@ -0,0 +1 @@ +uid://dt7um75oofdrh diff --git a/addons/godot_ai/handlers/camera_handler.gd b/addons/godot_ai/handlers/camera_handler.gd new file mode 100644 index 0000000..fb986e5 --- /dev/null +++ b/addons/godot_ai/handlers/camera_handler.gd @@ -0,0 +1,1145 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles Camera2D / Camera3D authoring — create, configure, bounds, damping, +## node-parent-based follow, presets. +## +## All writes are bundled into a single EditorUndoRedoManager action. +## Setting current=true auto-unmarks previously-current cameras of the same +## class in the same action so one Ctrl-Z reverts the switch. + +const CameraValues := preload("res://addons/godot_ai/handlers/camera_values.gd") +const CameraPresets := preload("res://addons/godot_ai/handlers/camera_presets.gd") + +const _VALID_TYPES := { + "2d": "Camera2D", + "3d": "Camera3D", +} + +const _KEYS_2D := [ + "zoom", + "offset", + "anchor_mode", + "ignore_rotation", + "enabled", + "current", + "process_callback", + "position_smoothing_enabled", + "position_smoothing_speed", + "rotation_smoothing_enabled", + "rotation_smoothing_speed", + "drag_horizontal_enabled", + "drag_vertical_enabled", + "drag_horizontal_offset", + "drag_vertical_offset", + "drag_left_margin", + "drag_top_margin", + "drag_right_margin", + "drag_bottom_margin", + "limit_left", + "limit_right", + "limit_top", + "limit_bottom", + "limit_smoothed", +] + +const _KEYS_3D := [ + "fov", + "near", + "far", + "size", + "projection", + "keep_aspect", + "cull_mask", + "doppler_tracking", + "h_offset", + "v_offset", + "current", +] + +# Transform-shaped keys live on Node2D / Node3D, not in the camera-specific +# schema — rejecting them without a hint sends agents searching for the wrong +# tool. +const _NODE_TRANSFORM_KEYS := [ + "position", "rotation", "scale", "transform", + "global_position", "global_rotation", "global_scale", "global_transform", +] + +const _DAMPING_MARGIN_KEYS := ["left", "top", "right", "bottom"] +const _CURRENT_SETTLE_ATTEMPTS := 8 +const _CURRENT_SETTLE_DELAY_MSEC := 10 + + +var _undo_redo: EditorUndoRedoManager + +# Per-scene logical-current bookkeeping. Keys are scene-root InstanceIDs; +# values are { "2d": NodePath-as-String, "3d": NodePath-as-String } with +# missing keys meaning "no logical current for that class." +# +# Stored on the handler instance (NOT as Node metadata on the scene root) +# because set_meta() persists into the .tscn on save, contaminating user +# scene files with MCP-internal sidecar state that lingers across reloads +# and travels in commits. +var _logical_current: Dictionary = {} + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +# Camera2D doesn't expose `current` as a settable property in Godot 4 — +# only is_current() / make_current() / clear_current(). Camera3D exposes +# both, but using methods uniformly avoids per-class branching. +static func _is_current(cam: Node) -> bool: + if cam == null: + return false + return bool(cam.is_current()) + + +static func _viewport_current_camera(scene_root: Node) -> Node: + if scene_root == null: + return null + var viewport := scene_root.get_viewport() + if viewport == null: + return null + var current_2d := viewport.get_camera_2d() + if current_2d != null and scene_root.is_ancestor_of(current_2d): + return current_2d + var current_3d := viewport.get_camera_3d() + if current_3d != null and scene_root.is_ancestor_of(current_3d): + return current_3d + return null + + +static func _is_effective_current(cam: Node) -> bool: + if _is_current(cam): + return true + if cam is Camera2D: + var viewport_2d := cam.get_viewport() + return viewport_2d != null and viewport_2d.get_camera_2d() == cam + if cam is Camera3D: + var viewport_3d := cam.get_viewport() + return viewport_3d != null and viewport_3d.get_camera_3d() == cam + return false + + +# Logical-current bookkeeping. Updated from inside _apply_make_current / +# _apply_clear_current so DO and UNDO callables stamp the same logical +# slot they touch in the viewport. Reads consult the logical slot first +# and treat it as authoritative when set — the viewport read is the +# fallback for "MCP never touched this scene's cameras." + +func _set_logical_current(cam: Node) -> void: + if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree(): + return + var type_str := _camera_type_str(cam) + if type_str.is_empty(): + return + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root == null or not scene_root.is_ancestor_of(cam): + return + var slot: Dictionary = _logical_current.get(scene_root.get_instance_id(), {}) + slot[type_str] = McpScenePath.from_node(cam, scene_root) + _logical_current[scene_root.get_instance_id()] = slot + + +func _clear_logical_current(cam: Node) -> void: + if cam == null: + return + var type_str := _camera_type_str(cam) + if type_str.is_empty(): + return + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root == null: + return + var key := scene_root.get_instance_id() + if not _logical_current.has(key): + return + var slot: Dictionary = _logical_current[key] + if not slot.has(type_str): + return + # Only clear if the logical slot still points at this camera; otherwise + # a later make_current already took the slot and we'd stomp it. + var current_path := "" + if is_instance_valid(cam) and cam.is_inside_tree() and scene_root.is_ancestor_of(cam): + current_path = McpScenePath.from_node(cam, scene_root) + if String(slot[type_str]) == current_path: + slot.erase(type_str) + if slot.is_empty(): + _logical_current.erase(key) + else: + _logical_current[key] = slot + + +func _logical_current_camera(scene_root: Node, type_str: String = "") -> Node: + if scene_root == null: + return null + var key := scene_root.get_instance_id() + if not _logical_current.has(key): + return null + var slot: Dictionary = _logical_current[key] + var types: Array[String] = [] + if type_str == "2d" or type_str == "3d": + types = [type_str] + else: + types = ["2d", "3d"] + for t in types: + if not slot.has(t): + continue + var path := String(slot[t]) + if path.is_empty(): + slot.erase(t) + continue + var node := McpScenePath.resolve(path, scene_root) + if node == null or not _is_camera(node) or _camera_type_str(node) != t: + slot.erase(t) + continue + return node + if slot.is_empty(): + _logical_current.erase(key) + else: + _logical_current[key] = slot + return null + + +# Public introspection for tests that need to distinguish "handler has a +# logical marker" from "handler is falling back to engine state". `get_camera` +# / `list_cameras` both use `_resolve_current` which falls through to +# `_is_effective_current` when no marker is set — that's correct for callers +# but masks the marker presence from anyone trying to gate on +# "did the handler actually record this state?". Returns the logical-current +# Camera2D / Camera3D for the given type ("2d" / "3d" / "" for either), or +# null when no marker is set. See #316 PR #372 review feedback. +func peek_logical_current(scene_root: Node, type_str: String = "") -> Node: + return _logical_current_camera(scene_root, type_str) + + +# Authoritative answer for "is `cam` the current camera of its class?" +# +# When a logical marker exists for the camera's class, it is the single +# source of truth — only the marker's referenced camera reports current, +# every other camera of that class reports false even if the viewport +# slot still points at one of them (the headless-CI lag in #140 / #278 / +# #301). Without a logical marker, fall through to the viewport read so +# scenes MCP never touched still answer correctly. +func _resolve_current(scene_root: Node, cam: Node) -> bool: + if scene_root == null or cam == null: + return false + var logical := _logical_current_camera(scene_root, _camera_type_str(cam)) + if logical != null: + return logical == cam + return _is_effective_current(cam) + + +# list_cameras pre-fetches the per-class logical pointers once; this +# variant takes those pointers to avoid an O(n²) walk over the meta +# bookkeeping for each camera in the scene. +func _resolve_current_with_logicals(cam: Node, logical_2d: Node, logical_3d: Node) -> bool: + if cam == null: + return false + if cam is Camera2D: + if logical_2d != null: + return logical_2d == cam + elif cam is Camera3D: + if logical_3d != null: + return logical_3d == cam + return _is_effective_current(cam) + + +# Register a current=true switch on `node` in the open undo action, +# unmarking previously-current siblings of the same class so a single +# Ctrl-Z reverts the whole switch. +# +# Both DO and UNDO route through `_apply_make_current` / `_apply_clear_current` +# on the handler itself rather than calling Camera.make_current() directly. +# The helpers do the make_current (or clear_current) call plus bounded sync +# settling when the viewport hasn't yet reflected the change — headless CI +# occasionally reports `is_current() == false` immediately after a committed +# make_current (observed CI run 24682342469) and symmetrically still reports +# the displaced camera as current immediately after an undo (observed CI runs +# 24682342469, 24692250322, 24696571517, 25079965242 — tracked in #140). +# Later #278 runs broadened the same current-camera timing flake across more +# platforms and assertions, so the settle budget is deliberately above one +# fast local frame. +# +# Because those callables bind to `self` (a RefCounted handler, not a scene +# node), every action that calls this helper must pin its history via +# `create_action(name, MERGE_DISABLE, scene_root)` — otherwise the +# handler-bound ops land in GLOBAL_HISTORY while the scene-node ops land in +# the scene's history, and a single editor_undo reverts only half the action. +# +# Both DO and UNDO use a single make_current() call — never a +# clear_current() + make_current() pair. make_current() takes over the +# viewport slot atomically (Godot enforces one current camera per class +# per viewport), so the displaced camera naturally returns +# is_current() == false without an explicit clear. The two-step approach +# leaves the viewport temporarily with no current camera between the +# clear and the make, which races with editor cleanup on macOS headless +# (observed flaking CI runs 24674252085, 24675424785). +func _add_make_current_to_action(node: Node, type_str: String, scene_root: Node) -> void: + var prev_current: Node = null + for cam in _list_cameras_in_scene(scene_root, type_str): + if cam == node: + continue + if _resolve_current(scene_root, cam): + prev_current = cam + break + _undo_redo.add_do_method(self, "_apply_make_current", node) + if prev_current != null: + _undo_redo.add_undo_method(self, "_apply_make_current", prev_current) + else: + _undo_redo.add_undo_method(self, "_apply_clear_current", node) + + +# Apply make_current on `cam` with bounded synchronous settling. Registered as the +# do/undo callable by `_add_make_current_to_action`. See that function's +# comment for why the undo path needs the retry inside the action itself. +# Safe against a freed camera node — short-circuits if the node is gone +# or not in the tree. +func _apply_make_current(cam: Node) -> void: + if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree(): + return + _set_logical_current(cam) + var scene_root := EditorInterface.get_edited_scene_root() + var type_str := _camera_type_str(cam) + for attempt in range(_CURRENT_SETTLE_ATTEMPTS): + cam.make_current() + _force_camera_refresh(cam) + # Godot's make_current is supposed to atomically displace siblings, + # but on macOS headless the displaced camera occasionally still + # answers is_current() == true after this returns (#140 / #278 / #301). + # Sweep same-class siblings and clear any that lag. + _force_clear_other_currents(cam, type_str, scene_root) + if not _is_current_settled(cam): + _displace_stale_camera_2d(cam) + _force_clear_other_currents(cam, type_str, scene_root) + var waited_this_attempt := false + if _is_current_settled(cam): + if not (cam is Camera2D): + return + OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) + waited_this_attempt = true + _force_camera_refresh(cam) + _force_clear_other_currents(cam, type_str, scene_root) + if _is_current_settled(cam): + return + if attempt < _CURRENT_SETTLE_ATTEMPTS - 1 and not waited_this_attempt: + OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) + + +# Walk same-class siblings and force-clear any that still report is_current(). +# Best-effort: clear_current errors when called on a non-current camera, so +# guard. Camera2D's clear_current path also flushes the viewport slot, which +# is the one we actually care about settling for #301. +func _force_clear_other_currents(target: Node, type_str: String, scene_root: Node) -> void: + if scene_root == null or type_str.is_empty(): + return + for sibling in _list_cameras_in_scene(scene_root, type_str): + if sibling == target: + continue + if not is_instance_valid(sibling) or not sibling.is_inside_tree(): + continue + if not _is_current(sibling): + # Even if is_current() reports false, the viewport slot can + # still point at this sibling on macOS — re-make target to + # take it back. Cheap (idempotent) when the slot is fine. + if sibling is Camera2D: + var vp_other: Viewport = (sibling as Camera2D).get_viewport() + if vp_other != null and vp_other.get_camera_2d() == sibling: + target.make_current() + _force_camera_refresh(target) + continue + sibling.clear_current() + if sibling is Camera2D: + (sibling as Camera2D).force_update_scroll() + + +# Call after commit_action() whenever the action registered a make_current DO. +# The undo path cannot use a post-undo hook, so it relies on `_apply_make_current` +# directly; create/configure/apply_preset get this extra post-commit verifier. +func _verify_current_after_commit(node: Node) -> void: + _apply_make_current(node) + + +func _force_camera_refresh(cam: Node) -> void: + if cam is Camera2D: + (cam as Camera2D).force_update_scroll() + + +func _is_current_settled(cam: Node) -> bool: + if not _is_current(cam): + return false + if cam is Camera2D: + var viewport := cam.get_viewport() + if viewport != null and viewport.get_camera_2d() != cam: + return false + return true + + +func _displace_stale_camera_2d(target: Node) -> void: + if not (target is Camera2D): + return + var viewport := target.get_viewport() + if viewport == null: + return + var stale := viewport.get_camera_2d() + if stale == null or stale == target or not is_instance_valid(stale): + _nudge_camera_2d_current(target) + return + var was_enabled := stale.enabled + if was_enabled: + stale.enabled = false + target.make_current() + _force_camera_refresh(target) + if was_enabled: + stale.enabled = true + target.make_current() + _force_camera_refresh(target) + + +func _nudge_camera_2d_current(target: Node) -> void: + if not (target is Camera2D): + return + var cam := target as Camera2D + if not cam.enabled: + return + cam.enabled = false + _force_camera_refresh(cam) + cam.enabled = true + cam.make_current() + _force_camera_refresh(cam) + + +# Symmetric counterpart to `_apply_make_current` for the "no previous +# current camera" branch (create_camera with make_current=true and no +# sibling was current). clear_current errors in Godot if called on a +# non-current camera, so guard on is_current first. +func _apply_clear_current(cam: Node) -> void: + if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree(): + return + _clear_logical_current(cam) + for attempt in range(_CURRENT_SETTLE_ATTEMPTS): + if _is_clear_settled(cam): + return + if _is_current(cam): + cam.clear_current() + _force_camera_refresh(cam) + # Camera2D-only: is_current() may answer false while the viewport + # slot still points at cam. Toggle enabled to force the viewport + # to release, then restore. + if cam is Camera2D: + var vp := cam.get_viewport() + if vp != null and vp.get_camera_2d() == cam: + var was_enabled := (cam as Camera2D).enabled + if was_enabled: + (cam as Camera2D).enabled = false + _force_camera_refresh(cam) + if was_enabled: + (cam as Camera2D).enabled = true + if _is_clear_settled(cam): + return + if attempt < _CURRENT_SETTLE_ATTEMPTS - 1: + OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) + + +func _is_clear_settled(cam: Node) -> bool: + if cam == null: + return true + if _is_current(cam): + return false + if cam is Camera2D: + var vp := cam.get_viewport() + if vp != null and vp.get_camera_2d() == cam: + return false + return true + + +# ============================================================================ +# camera_create +# ============================================================================ + +func create_camera(params: Dictionary) -> Dictionary: + var parent_path: String = params.get("parent_path", "") + var node_name: String = params.get("name", "Camera") + var type_str: String = params.get("type", "2d") + var make_current: bool = bool(params.get("make_current", false)) + + if not _VALID_TYPES.has(type_str): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid camera type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] + ) + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + var node := _instantiate_camera(type_str) + if node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate camera") + if not node_name.is_empty(): + node.name = node_name + + _undo_redo.create_action( + "MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name], + UndoRedo.MERGE_DISABLE, scene_root + ) + _undo_redo.add_do_method(parent, "add_child", node, true) + _undo_redo.add_do_method(node, "set_owner", scene_root) + _undo_redo.add_do_reference(node) + if make_current: + # Must land AFTER add_child: making current before the node is in the + # tree is a silent no-op on the viewport. + _add_make_current_to_action(node, type_str, scene_root) + _undo_redo.add_undo_method(parent, "remove_child", node) + _undo_redo.commit_action() + if make_current: + _verify_current_after_commit(node) + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "parent_path": McpScenePath.from_node(parent, scene_root), + "name": String(node.name), + "type": type_str, + "class": _VALID_TYPES[type_str], + "current": bool(make_current), + "undoable": true, + } + } + + +# ============================================================================ +# camera_configure +# ============================================================================ + +func configure(params: Dictionary) -> Dictionary: + var resolved := _resolve_camera(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var type_str: String = resolved.type + var scene_root: Node = resolved.scene_root + + var properties: Dictionary = params.get("properties", {}) + if properties.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty") + + var valid_keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D + var prop_types := _property_type_map(node) + var coerced: Dictionary = {} + var old_values: Dictionary = {} + # `current` is special-cased via methods (Camera2D doesn't expose it as a property). + var current_request: Variant = null + + for property in properties: + var prop_name: String = String(property) + if not (prop_name in valid_keys): + var msg := "Property '%s' not valid for %s. Valid: %s" % [ + prop_name, _VALID_TYPES[type_str], ", ".join(valid_keys) + ] + if prop_name in _NODE_TRANSFORM_KEYS: + msg += ( + ". Transforms live on the Node, not on the camera config — " + + "use node_set_property(path=%s, property=\"%s\", value=...)" % [node_path, prop_name] + ) + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg) + if prop_name == "current": + current_request = bool(properties[prop_name]) + continue + var prop_type: int = prop_types.get(prop_name, TYPE_NIL) + if prop_type == TYPE_NIL: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Property '%s' not present on %s" % [prop_name, node.get_class()] + ) + var coerce_result := CameraValues.coerce(prop_name, properties[prop_name], prop_type) + if not coerce_result.ok: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) + coerced[prop_name] = coerce_result.value + old_values[prop_name] = node.get(prop_name) + + _undo_redo.create_action( + "MCP: Configure camera %s" % node.name, + UndoRedo.MERGE_DISABLE, scene_root + ) + for prop_name in coerced: + _undo_redo.add_do_property(node, prop_name, coerced[prop_name]) + _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) + var verify_current_after := false + if current_request != null: + var want_on: bool = bool(current_request) + var was_on: bool = _resolve_current(scene_root, node) + if want_on and not was_on: + _add_make_current_to_action(node, type_str, scene_root) + verify_current_after = true + elif not want_on and was_on: + _undo_redo.add_do_method(self, "_apply_clear_current", node) + _undo_redo.add_undo_method(self, "_apply_make_current", node) + _undo_redo.commit_action() + if verify_current_after: + _verify_current_after_commit(node) + + var applied: Array[String] = [] + var serialized: Dictionary = {} + for prop_name in coerced: + applied.append(prop_name) + serialized[prop_name] = CameraValues.serialize(coerced[prop_name]) + if current_request != null: + applied.append("current") + serialized["current"] = bool(current_request) + + return { + "data": { + "path": node_path, + "type": type_str, + "class": node.get_class(), + "applied": applied, + "values": serialized, + "undoable": true, + } + } + + +# ============================================================================ +# camera_set_limits_2d +# ============================================================================ + +func set_limits_2d(params: Dictionary) -> Dictionary: + var resolved := _resolve_camera(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var type_str: String = resolved.type + + if type_str != "2d": + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "camera_set_limits_2d requires a Camera2D (got %s)" % node.get_class() + ) + + var applied: Dictionary = {} + var old_values: Dictionary = {} + var edges := { + "left": "limit_left", + "right": "limit_right", + "top": "limit_top", + "bottom": "limit_bottom", + } + for edge in edges: + var v = params.get(edge) + if v != null: + var prop_name: String = edges[edge] + applied[prop_name] = int(v) + old_values[prop_name] = node.get(prop_name) + + var smoothed = params.get("smoothed") + if smoothed != null: + applied["limit_smoothed"] = bool(smoothed) + old_values["limit_smoothed"] = node.get("limit_smoothed") + + if applied.is_empty(): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "No limits specified; provide at least one of left, right, top, bottom, smoothed" + ) + + _undo_redo.create_action("MCP: Set camera limits on %s" % node.name) + for prop_name in applied: + _undo_redo.add_do_property(node, prop_name, applied[prop_name]) + _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) + _undo_redo.commit_action() + + var values: Dictionary = {} + for prop_name in applied: + values[prop_name] = applied[prop_name] + + return { + "data": { + "path": node_path, + "applied": applied.keys(), + "values": values, + "undoable": true, + } + } + + +# ============================================================================ +# camera_set_damping_2d +# ============================================================================ + +func set_damping_2d(params: Dictionary) -> Dictionary: + var resolved := _resolve_camera(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var type_str: String = resolved.type + + if type_str != "2d": + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "camera_set_damping_2d requires a Camera2D (got %s)" % node.get_class() + ) + + var applied: Dictionary = {} + var old_values: Dictionary = {} + + # position_speed: set position_smoothing_speed AND toggle position_smoothing_enabled. + var pos_v = params.get("position_speed") + if pos_v != null: + var pos_speed := float(pos_v) + var pos_enable := pos_speed > 0.0 + applied["position_smoothing_enabled"] = pos_enable + old_values["position_smoothing_enabled"] = node.get("position_smoothing_enabled") + if pos_enable: + applied["position_smoothing_speed"] = pos_speed + old_values["position_smoothing_speed"] = node.get("position_smoothing_speed") + + # rotation_speed: same pattern for rotation_smoothing_*. + var rot_v = params.get("rotation_speed") + if rot_v != null: + var rot_speed := float(rot_v) + var rot_enable := rot_speed > 0.0 + applied["rotation_smoothing_enabled"] = rot_enable + old_values["rotation_smoothing_enabled"] = node.get("rotation_smoothing_enabled") + if rot_enable: + applied["rotation_smoothing_speed"] = rot_speed + old_values["rotation_smoothing_speed"] = node.get("rotation_smoothing_speed") + + for flag in ["drag_horizontal_enabled", "drag_vertical_enabled"]: + var flag_v = params.get(flag) + if flag_v != null: + applied[flag] = bool(flag_v) + old_values[flag] = node.get(flag) + + # drag_margins: dict {left, top, right, bottom} floats in [0,1]; null/missing keys untouched. + var margins_v = params.get("drag_margins") + if margins_v != null: + if not (margins_v is Dictionary): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "drag_margins must be a dict with optional keys left/top/right/bottom" + ) + var margins: Dictionary = margins_v + for edge in _DAMPING_MARGIN_KEYS: + var margin_v = margins.get(edge) + if margin_v == null: + continue + var v := float(margin_v) + if v < 0.0 or v > 1.0: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "drag_margins.%s must be in [0, 1] (got %s)" % [edge, v] + ) + var prop_name: String = "drag_%s_margin" % edge + applied[prop_name] = v + old_values[prop_name] = node.get(prop_name) + + if applied.is_empty(): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "No damping params specified; provide at least one of position_speed, rotation_speed, drag_margins, drag_horizontal_enabled, drag_vertical_enabled" + ) + + _undo_redo.create_action("MCP: Set camera damping on %s" % node.name) + for prop_name in applied: + _undo_redo.add_do_property(node, prop_name, applied[prop_name]) + _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "applied": applied.keys(), + "values": applied, + "undoable": true, + } + } + + +# ============================================================================ +# camera_follow_2d +# ============================================================================ + +func follow_2d(params: Dictionary) -> Dictionary: + var resolved := _resolve_camera(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var type_str: String = resolved.type + var scene_root: Node = resolved.scene_root + + if type_str != "2d": + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "camera_follow_2d requires a Camera2D (got %s)" % node.get_class() + ) + + var target_path: String = params.get("target_path", "") + if target_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path") + var target := McpScenePath.resolve(target_path, scene_root) + if target == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, + "target_path: %s" % McpScenePath.format_node_error(target_path, scene_root)) + if not (target is Node2D) and target != scene_root: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Follow target must be a Node2D (got %s)" % target.get_class() + ) + if target == node: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Camera cannot follow itself") + if target.is_ancestor_of(node) and node.get_parent() != target: + # A non-parent ancestor — still valid to reparent under (direct parent). + pass + if node.is_ancestor_of(target): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Cannot follow a descendant of the camera" + ) + + var smoothing_speed := float(params.get("smoothing_speed", 5.0)) + var zero_transform: bool = bool(params.get("zero_transform", true)) + + var old_parent := node.get_parent() + var old_idx: int = node.get_index() if old_parent != null else 0 + var old_position = node.get("position") + var old_rotation = node.get("rotation") + var old_smoothing_enabled: bool = bool(node.get("position_smoothing_enabled")) + var old_smoothing_speed: float = float(node.get("position_smoothing_speed")) + + var already_child: bool = old_parent == target + var reparented: bool = not already_child + + _undo_redo.create_action("MCP: Camera follow %s" % target.name) + if reparented: + _undo_redo.add_do_method(old_parent, "remove_child", node) + _undo_redo.add_do_method(target, "add_child", node, true) + _undo_redo.add_do_method(node, "set_owner", scene_root) + _undo_redo.add_do_reference(node) + if zero_transform: + if target is Node2D: + _undo_redo.add_do_property(node, "position", Vector2.ZERO) + _undo_redo.add_undo_property(node, "position", old_position) + _undo_redo.add_do_property(node, "rotation", 0.0) + _undo_redo.add_undo_property(node, "rotation", old_rotation) + _undo_redo.add_do_property(node, "position_smoothing_enabled", true) + _undo_redo.add_undo_property(node, "position_smoothing_enabled", old_smoothing_enabled) + if smoothing_speed > 0.0: + _undo_redo.add_do_property(node, "position_smoothing_speed", smoothing_speed) + _undo_redo.add_undo_property(node, "position_smoothing_speed", old_smoothing_speed) + if reparented: + _undo_redo.add_undo_method(target, "remove_child", node) + _undo_redo.add_undo_method(old_parent, "add_child", node, true) + _undo_redo.add_undo_method(old_parent, "move_child", node, old_idx) + _undo_redo.add_undo_method(node, "set_owner", scene_root) + _undo_redo.add_undo_reference(node) + _undo_redo.commit_action() + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "target_path": McpScenePath.from_node(target, scene_root), + "reparented": reparented, + "smoothing_speed": smoothing_speed, + "zero_transform": zero_transform and (target is Node2D), + "undoable": true, + } + } + + +# ============================================================================ +# camera_get +# ============================================================================ + +func get_camera(params: Dictionary) -> Dictionary: + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var camera_path: String = params.get("camera_path", "") + var node: Node = null + var resolved_via: String = "" + if camera_path.is_empty(): + # Empty: prefer the viewport's active camera. In headless editor CI, + # Camera2D.is_current() can lag make_current() briefly even after the + # viewport slot has switched; falling through to "first" during that + # window makes camera_get("") nondeterministic. + var all_cams := _list_cameras_in_scene(scene_root, "") + var logical_current := _logical_current_camera(scene_root) + if logical_current != null and all_cams.has(logical_current): + node = logical_current + resolved_via = "current" + var viewport_current := _viewport_current_camera(scene_root) + if node == null and viewport_current != null and all_cams.has(viewport_current): + node = viewport_current + resolved_via = "current" + for cam in all_cams: + if node != null: + break + if _is_current(cam): + node = cam + resolved_via = "current" + break + if node == null and not all_cams.is_empty(): + node = all_cams[0] + resolved_via = "first" + else: + node = McpScenePath.resolve(camera_path, scene_root) + if node == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(camera_path, scene_root)) + if not _is_camera(node): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node %s is not a camera (got %s)" % [camera_path, node.get_class()] + ) + resolved_via = "path" + + if node == null: + return { + "data": { + "path": "", + "type": "", + "class": "", + "current": false, + "properties": {}, + "resolved_via": "not_found", + } + } + + var type_str := _camera_type_str(node) + var keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D + var prop_types := _property_type_map(node) + var props: Dictionary = {} + var is_current_effective := _resolve_current(scene_root, node) + for key in keys: + if key == "current": + props[key] = is_current_effective + continue + if prop_types.has(key): + props[key] = CameraValues.serialize(node.get(key)) + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "type": type_str, + "class": node.get_class(), + "current": is_current_effective, + "properties": props, + "resolved_via": resolved_via, + } + } + + +# ============================================================================ +# camera_list +# ============================================================================ + +func list_cameras(_params: Dictionary) -> Dictionary: + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var cams := _list_cameras_in_scene(scene_root, "") + var out: Array[Dictionary] = [] + var logical_2d := _logical_current_camera(scene_root, "2d") + var logical_3d := _logical_current_camera(scene_root, "3d") + for cam in cams: + out.append({ + "path": McpScenePath.from_node(cam, scene_root), + "class": cam.get_class(), + "type": _camera_type_str(cam), + "current": _resolve_current_with_logicals(cam, logical_2d, logical_3d), + }) + return {"data": {"cameras": out}} + + +# ============================================================================ +# camera_apply_preset +# ============================================================================ + +func apply_preset(params: Dictionary) -> Dictionary: + var preset_name: String = params.get("preset", "") + if preset_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset") + + var overrides: Dictionary = params.get("overrides", {}) + var blueprint = CameraPresets.build(preset_name, overrides) + if blueprint == null: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(CameraPresets.list_presets())] + ) + + var parent_path: String = params.get("parent_path", "") + var node_name: String = params.get("name", "") + var type_str: String = params.get("type", String(blueprint.get("default_type", "2d"))) + var make_current: bool = bool(params.get("make_current", true)) + if node_name.is_empty(): + node_name = preset_name.capitalize() + if not _VALID_TYPES.has(type_str): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid camera type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] + ) + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + var node := _instantiate_camera(type_str) + node.name = node_name + + var preset_props: Dictionary = blueprint.get("properties", {}) + var valid_keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D + var prop_types := _property_type_map(node) + var applied: Array[String] = [] + for prop in preset_props: + var prop_name := String(prop) + if not (prop_name in valid_keys): + continue # Silently skip preset keys that don't apply to this camera class. + # `current` lives on methods, not as a writable property on Camera2D — + # always handled via the make_current path below. + if prop_name == "current": + continue + var prop_type: int = prop_types.get(prop_name, TYPE_NIL) + if prop_type == TYPE_NIL: + continue + var coerce_result := CameraValues.coerce(prop_name, preset_props[prop_name], prop_type) + if not coerce_result.ok: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) + node.set(prop_name, coerce_result.value) + applied.append(prop_name) + + _undo_redo.create_action( + "MCP: Apply camera preset %s" % preset_name, + UndoRedo.MERGE_DISABLE, scene_root + ) + _undo_redo.add_do_method(parent, "add_child", node, true) + _undo_redo.add_do_method(node, "set_owner", scene_root) + _undo_redo.add_do_reference(node) + if make_current: + _add_make_current_to_action(node, type_str, scene_root) + _undo_redo.add_undo_method(parent, "remove_child", node) + _undo_redo.commit_action() + if make_current: + _verify_current_after_commit(node) + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "parent_path": McpScenePath.from_node(parent, scene_root), + "name": node_name, + "preset": preset_name, + "type": type_str, + "class": _VALID_TYPES[type_str], + "applied": applied, + "current": bool(make_current), + "undoable": true, + } + } + + +# ============================================================================ +# Helpers +# ============================================================================ + +static func _instantiate_camera(type_str: String) -> Node: + match type_str: + "2d": + return Camera2D.new() + "3d": + return Camera3D.new() + return null + + +static func _is_camera(node: Node) -> bool: + return node is Camera2D or node is Camera3D + + +static func _camera_type_str(node: Node) -> String: + if node is Camera2D: + return "2d" + if node is Camera3D: + return "3d" + return "" + + +func _resolve_camera(params: Dictionary) -> Dictionary: + var resolved := McpNodeValidator.resolve_or_error( + params.get("camera_path", ""), "camera_path", + ) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + if not _is_camera(node): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node %s is not a camera (got %s)" % [node_path, node.get_class()] + ) + return { + "node": node, + "path": node_path, + "type": _camera_type_str(node), + "scene_root": scene_root, + } + + +## Walk the edited scene for cameras. class_filter: "2d", "3d", or "" for all. +static func _list_cameras_in_scene(scene_root: Node, class_filter: String) -> Array: + var result: Array = [] + if scene_root == null: + return result + _collect_cameras(scene_root, class_filter, result) + return result + + +static func _collect_cameras(node: Node, class_filter: String, out: Array) -> void: + var matches := false + match class_filter: + "2d": + matches = node is Camera2D + "3d": + matches = node is Camera3D + _: + matches = node is Camera2D or node is Camera3D + if matches: + out.append(node) + for child in node.get_children(): + _collect_cameras(child, class_filter, out) + + +## Build a name -> property-type dict from the object's property list. +## Single walk of get_property_list() amortizes lookups across a batch of +## properties in configure / apply_preset. +static func _property_type_map(obj: Object) -> Dictionary: + var out: Dictionary = {} + if obj == null: + return out + for prop in obj.get_property_list(): + out[prop.name] = int(prop.get("type", TYPE_NIL)) + return out diff --git a/addons/godot_ai/handlers/camera_handler.gd.uid b/addons/godot_ai/handlers/camera_handler.gd.uid new file mode 100644 index 0000000..86d2aae --- /dev/null +++ b/addons/godot_ai/handlers/camera_handler.gd.uid @@ -0,0 +1 @@ +uid://c0lcviccrlrl8 diff --git a/addons/godot_ai/handlers/camera_presets.gd b/addons/godot_ai/handlers/camera_presets.gd new file mode 100644 index 0000000..a1922c2 --- /dev/null +++ b/addons/godot_ai/handlers/camera_presets.gd @@ -0,0 +1,81 @@ +@tool +extends RefCounted + +## Opinionated Camera2D / Camera3D presets. +## +## build(preset_name, overrides) -> {default_type, properties} | null +## properties are merged with caller overrides (overrides win). + + +const _PRESETS := { + # Top-down roguelite / arena — damped follow feel, drag deadzone. + "topdown_2d": { + "default_type": "2d", + "properties": { + "zoom": {"x": 2.0, "y": 2.0}, + "anchor_mode": "drag_center", + "position_smoothing_enabled": true, + "position_smoothing_speed": 5.0, + "rotation_smoothing_enabled": false, + "drag_horizontal_enabled": true, + "drag_vertical_enabled": true, + "drag_left_margin": 0.2, + "drag_right_margin": 0.2, + "drag_top_margin": 0.2, + "drag_bottom_margin": 0.2, + }, + }, + # Platformer — tight horizontal follow, vertical snap with smoothing on. + "platformer_2d": { + "default_type": "2d", + "properties": { + "zoom": {"x": 1.5, "y": 1.5}, + "anchor_mode": "drag_center", + "position_smoothing_enabled": true, + "position_smoothing_speed": 8.0, + "drag_horizontal_enabled": true, + "drag_vertical_enabled": false, + "drag_left_margin": 0.15, + "drag_right_margin": 0.15, + }, + }, + # Cinematic 3D — narrow FOV, long range. Good for dramatic wide shots. + "cinematic_3d": { + "default_type": "3d", + "properties": { + "fov": 40.0, + "near": 0.1, + "far": 500.0, + "projection": "perspective", + }, + }, + # Action 3D — wider FOV for first/third-person action gameplay. + "action_3d": { + "default_type": "3d", + "properties": { + "fov": 70.0, + "near": 0.1, + "far": 200.0, + "projection": "perspective", + }, + }, +} + + +static func list_presets() -> Array: + return _PRESETS.keys() + + +## Build a preset blueprint. Returns null if preset_name is unknown. +## overrides is merged on top of preset defaults (caller values win). +static func build(preset_name: String, overrides: Dictionary) -> Variant: + if not _PRESETS.has(preset_name): + return null + var preset: Dictionary = _PRESETS[preset_name] + var properties: Dictionary = (preset.get("properties", {}) as Dictionary).duplicate(true) + for key in overrides: + properties[key] = overrides[key] + return { + "default_type": preset.get("default_type", "2d"), + "properties": properties, + } diff --git a/addons/godot_ai/handlers/camera_presets.gd.uid b/addons/godot_ai/handlers/camera_presets.gd.uid new file mode 100644 index 0000000..9f9f839 --- /dev/null +++ b/addons/godot_ai/handlers/camera_presets.gd.uid @@ -0,0 +1 @@ +uid://bl3rfy72o3wy5 diff --git a/addons/godot_ai/handlers/camera_values.gd b/addons/godot_ai/handlers/camera_values.gd new file mode 100644 index 0000000..e3c8dea --- /dev/null +++ b/addons/godot_ai/handlers/camera_values.gd @@ -0,0 +1,132 @@ +@tool +extends RefCounted + +## Value coercion helpers for camera authoring. +## +## Handles: +## - enum-by-name (keep_aspect="keep_height" -> Camera3D.KEEP_HEIGHT) +## - {x, y} dict -> Vector2 (zoom, offset, drag_*_offset) +## - serialization back to JSON-friendly shapes + + +const _ENUM_TABLES := { + "projection": { + "perspective": Camera3D.PROJECTION_PERSPECTIVE, + "orthogonal": Camera3D.PROJECTION_ORTHOGONAL, + "frustum": Camera3D.PROJECTION_FRUSTUM, + }, + "keep_aspect": { + "keep_width": Camera3D.KEEP_WIDTH, + "keep_height": Camera3D.KEEP_HEIGHT, + }, + "anchor_mode": { + "fixed_top_left": Camera2D.ANCHOR_MODE_FIXED_TOP_LEFT, + "drag_center": Camera2D.ANCHOR_MODE_DRAG_CENTER, + }, + "doppler_tracking": { + "disabled": Camera3D.DOPPLER_TRACKING_DISABLED, + "idle_step": Camera3D.DOPPLER_TRACKING_IDLE_STEP, + "physics_step": Camera3D.DOPPLER_TRACKING_PHYSICS_STEP, + }, + "process_callback": { + "physics": Camera2D.CAMERA2D_PROCESS_PHYSICS, + "idle": Camera2D.CAMERA2D_PROCESS_IDLE, + }, +} + + +## Return the enum int for (property, string_name), or null if not a known enum string. +static func resolve_enum(property: String, value: Variant) -> Variant: + if not (value is String): + return null + if not _ENUM_TABLES.has(property): + return null + var table: Dictionary = _ENUM_TABLES[property] + var key: String = String(value).to_lower() + if table.has(key): + return table[key] + return null + + +## Valid enum names for a property, for error messages. +static func enum_keys(property: String) -> Array: + if not _ENUM_TABLES.has(property): + return [] + return (_ENUM_TABLES[property] as Dictionary).keys() + + +static func parse_vector2(value: Variant) -> Variant: + ## Camera-specific sugar kept from the pre-#714 copy: a bare number is + ## a uniform zoom, splatted to both axes. Everything else goes through + ## the canonical strict parser. + if value is int or value is float: + return Vector2(float(value), float(value)) + return McpJsonValues.parse_vector2(value) + + +static func parse_vector3(value: Variant) -> Variant: + return McpJsonValues.parse_vector3(value) + + +## Coerce a JSON-shaped value for a camera property against the declared type. +## Returns {ok: true, value: ...} or {ok: false, error: "..."}. +static func coerce(property: String, value: Variant, target_type: int) -> Dictionary: + # Enum-by-name: must match before generic TYPE_INT coercion. + if _ENUM_TABLES.has(property): + if value is String: + var enum_val = resolve_enum(property, value) + if enum_val == null: + return { + "ok": false, + "error": "Invalid %s value: '%s'. Valid: %s" % [ + property, value, ", ".join(enum_keys(property)) + ], + } + return {"ok": true, "value": int(enum_val)} + if value is int or value is float: + return {"ok": true, "value": int(value)} + + match target_type: + TYPE_VECTOR2: + var v2 = parse_vector2(value) + if v2 == null: + return {"ok": false, "error": "Invalid vector2 for %s: %s" % [property, value]} + return {"ok": true, "value": v2} + TYPE_VECTOR3: + var v3 = parse_vector3(value) + if v3 == null: + return {"ok": false, "error": "Invalid vector3 for %s: %s" % [property, value]} + return {"ok": true, "value": v3} + TYPE_BOOL: + if value is bool: + return {"ok": true, "value": value} + if value is int or value is float: + return {"ok": true, "value": bool(value)} + return {"ok": false, "error": "Expected bool for %s" % property} + TYPE_INT: + if value is int: + return {"ok": true, "value": value} + if value is float: + return {"ok": true, "value": int(value)} + return {"ok": false, "error": "Expected int for %s" % property} + TYPE_FLOAT: + if value is float: + return {"ok": true, "value": value} + if value is int: + return {"ok": true, "value": float(value)} + return {"ok": false, "error": "Expected number for %s" % property} + TYPE_STRING: + return {"ok": true, "value": String(value)} + + return {"ok": true, "value": value} + + +## Serialize a Variant into a JSON-friendly shape for responses. +static func serialize(value: Variant) -> Variant: + if value == null: + return null + if value is Vector2: + return {"x": value.x, "y": value.y} + if value is Vector3: + return {"x": value.x, "y": value.y, "z": value.z} + return value diff --git a/addons/godot_ai/handlers/camera_values.gd.uid b/addons/godot_ai/handlers/camera_values.gd.uid new file mode 100644 index 0000000..45e3c09 --- /dev/null +++ b/addons/godot_ai/handlers/camera_values.gd.uid @@ -0,0 +1 @@ +uid://bgjnubgnv6ses diff --git a/addons/godot_ai/handlers/client_handler.gd b/addons/godot_ai/handlers/client_handler.gd new file mode 100644 index 0000000..40e019f --- /dev/null +++ b/addons/godot_ai/handlers/client_handler.gd @@ -0,0 +1,127 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles MCP client configuration commands. + +var _connection +var _fallback_launch_context := {} +var _status_workers: Array[Thread] = [] +var _status_tearing_down := false + + +func _init(connection = null, fallback_launch_context = null) -> void: + _connection = connection + # Lazy-loading this handler can reload ClientConfigurator's static script and + # clear its warmed snapshot. Retain the plugin-start capture as a safe + # fallback; the worker still prefers capture_launch_context()'s live snapshot. + if fallback_launch_context is Dictionary: + _fallback_launch_context = fallback_launch_context.duplicate(true) + + +func configure_client(params: Dictionary) -> Dictionary: + var client_id: String = params.get("client", "") + if not McpClientConfigurator.has_client(client_id): + var valid := ", ".join(McpClientConfigurator.client_ids()) + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid]) + var result := McpClientConfigurator.configure(client_id) + if result.get("status") == "error": + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + result.get("message", "Configuration failed for '%s'" % client_id)) + return {"data": result} + + +func remove_client(params: Dictionary) -> Dictionary: + var client_id: String = params.get("client", "") + if not McpClientConfigurator.has_client(client_id): + var valid := ", ".join(McpClientConfigurator.client_ids()) + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid]) + var result := McpClientConfigurator.remove(client_id) + if result.get("status") == "error": + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + result.get("message", "Removal failed for '%s'" % client_id)) + return {"data": result} + + +func check_client_status(params: Dictionary) -> Dictionary: + var request_id: String = params.get("_request_id", "") + if _connection == null or request_id.is_empty(): + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Client status requires a deferred request context.", + ) + if _status_tearing_down: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Client status handler is shutting down.") + # Match the dock refresh worker's cold-load guard. This is pure-memory and + # performs no launcher discovery, CLI lookup, or config/status probe. + McpClientConfigurator.warm_status_worker_bytecode() + # Refresh cwd/project roots on main before the status worker starts while + # preserving the startup launch snapshot for the deferred worker. + var status_context := _fallback_launch_context.duplicate(true) + status_context["project_roots"] = McpClientConfigurator.capture_project_roots() + # The aggregate command has a 30-second budget. Its worker resolves the + # shared Claude Desktop/Codex attach launch once, then reuses it for every + # command-shaped client instead of repeating cold launcher discovery. + # Start the worker from the deferred finisher after its first frame. That + # lets the dispatcher register this request before any probe can complete. + _finish_client_status_deferred( + McpClientConfigurator.run_client_status_sweep.bind(status_context), + request_id, + _connection, + ) + return McpDispatcher.DEFERRED_RESPONSE + + +## Called by McpDispatcher.clear() before releasing this lazy handler. Marking +## teardown is deliberately non-blocking: the in-flight coroutine retains this +## handler and its connection across frames, then joins each worker only after +## is_alive() becomes false. New sweeps are rejected immediately. +func prepare_for_teardown() -> void: + _status_tearing_down = true + + +## This instance coroutine intentionally keeps the lazily-created handler and +## deferred-response connection alive until its worker has been polled and +## joined. The first-frame yield is load-bearing: check_client_status() must +## return the deferred sentinel before the worker can produce a response. +func _finish_client_status_deferred( + worker_callable: Callable, request_id: String, connection +) -> void: + if not is_instance_valid(connection): + return + var tree: SceneTree = connection.get_tree() + if tree == null: + return + await tree.process_frame + if not is_instance_valid(connection) or _status_tearing_down: + return + var worker := Thread.new() + _status_workers.append(worker) + var start_error := worker.start(worker_callable) + if start_error != OK: + _status_workers.erase(worker) + connection.send_deferred_response(request_id, ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Could not start client status worker (error %d)." % start_error, + )) + return + while worker.is_alive(): + await tree.process_frame + var payload: Variant = worker.wait_to_finish() + _status_workers.erase(worker) + if _status_tearing_down: + return + if not is_instance_valid(connection): + return + if not payload is Dictionary: + payload = ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Client status worker returned an invalid response.", + ) + elif payload.has("worker_error"): + payload = ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + str(payload.get("worker_error", "Client status worker failed.")), + ) + connection.send_deferred_response(request_id, payload) diff --git a/addons/godot_ai/handlers/client_handler.gd.uid b/addons/godot_ai/handlers/client_handler.gd.uid new file mode 100644 index 0000000..25687f5 --- /dev/null +++ b/addons/godot_ai/handlers/client_handler.gd.uid @@ -0,0 +1 @@ +uid://bmo4foc5fq75c diff --git a/addons/godot_ai/handlers/control_draw_recipe_handler.gd b/addons/godot_ai/handlers/control_draw_recipe_handler.gd new file mode 100644 index 0000000..29e65be --- /dev/null +++ b/addons/godot_ai/handlers/control_draw_recipe_handler.gd @@ -0,0 +1,318 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles the control_draw_recipe MCP command. Attaches a shared DrawRecipe +## script to a Control and stores the caller's ordered draw ops in node +## metadata under "_ops". The DrawRecipe script dispatches each op to a +## CanvasItem draw_* call in _draw(). One Ctrl+Z reverts script + meta as a +## single undo step. + +const DRAW_RECIPE_SCRIPT := preload("res://addons/godot_ai/runtime/draw_recipe.gd") +const UiHandler := preload("res://addons/godot_ai/handlers/ui_handler.gd") + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +func control_draw_recipe(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + var ops_raw: Variant = params.get("ops", null) + var clear_existing: bool = bool(params.get("clear_existing", true)) + + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + if typeof(ops_raw) != TYPE_ARRAY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "ops must be an Array") + + var _resolved := McpNodeValidator.resolve_or_error(path, "path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var scene_root: Node = _resolved.scene_root + if not node is Control: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "control_draw_recipe requires a Control node, got %s" % node.get_class() + ) + + var coerced := _coerce_ops(ops_raw) + if coerced.has("error"): + return coerced + var coerced_ops: Array = coerced.ops + + var old_script: Variant = node.get_script() + if old_script != null and old_script != DRAW_RECIPE_SCRIPT: + if not clear_existing: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + ( + "Node %s already has a script. Pass clear_existing=true to replace." + % path + ) + ) + + var had_meta := node.has_meta("_ops") + var old_ops: Variant = node.get_meta("_ops") if had_meta else null + + _undo_redo.create_action("MCP: Draw recipe on %s" % node.name) + _undo_redo.add_do_method(node, "set_script", DRAW_RECIPE_SCRIPT) + _undo_redo.add_do_method(node, "set_meta", "_ops", coerced_ops) + _undo_redo.add_do_method(node, "queue_redraw") + _undo_redo.add_undo_method(node, "set_script", old_script) + if had_meta: + _undo_redo.add_undo_method(node, "set_meta", "_ops", old_ops) + else: + _undo_redo.add_undo_method(node, "remove_meta", "_ops") + _undo_redo.add_undo_method(node, "queue_redraw") + _undo_redo.commit_action() + + return { + "data": + { + "path": McpScenePath.from_node(node, scene_root), + "ops_count": coerced_ops.size(), + "script_attached": old_script == null, + "script_replaced": old_script != null and old_script != DRAW_RECIPE_SCRIPT, + "undoable": true, + } + } + + + +## Validate and coerce every op dict. Returns {"ops": Array} or an error dict. +func _coerce_ops(ops: Array) -> Dictionary: + var result: Array = [] + for i in ops.size(): + var op: Variant = ops[i] + if typeof(op) != TYPE_DICTIONARY: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, "ops[%d] must be a dictionary" % i + ) + var coerced := _coerce_single_op(op, i) + if coerced.has("error"): + return coerced + result.append(coerced.op) + return {"ops": result} + + +func _coerce_single_op(op: Dictionary, idx: int) -> Dictionary: + var draw_type: String = op.get("draw", "") + if draw_type.is_empty(): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d]: missing 'draw' field" % idx + ) + match draw_type: + "line": + return _coerce_line(op, idx) + "rect": + return _coerce_rect(op, idx) + "arc": + return _coerce_arc(op, idx) + "circle": + return _coerce_circle(op, idx) + "polyline": + return _coerce_polyline_or_polygon(op, idx, "polyline") + "polygon": + return _coerce_polyline_or_polygon(op, idx, "polygon") + "string": + return _coerce_string(op, idx) + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "ops[%d]: unknown draw type '%s'" % [idx, draw_type] + ) + + +func _require_fields(op: Dictionary, idx: int, kind: String, fields: Array) -> Dictionary: + for f in fields: + if not op.has(f): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "ops[%d] (%s): missing '%s'" % [idx, kind, f] + ) + return {} + + +func _coerce_typed(value: Variant, prop_type: int, idx: int, kind: String, field: String) -> Dictionary: + var r := UiHandler._coerce_for_type(value, prop_type) + if r.ok: + return {"ok": true, "value": r.value} + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d] (%s): invalid '%s'" % [idx, kind, field] + ) + + +func _coerce_line(op: Dictionary, idx: int) -> Dictionary: + var missing := _require_fields(op, idx, "line", ["from", "to", "color"]) + if missing.has("error"): + return missing + var frm := _coerce_typed(op.from, TYPE_VECTOR2, idx, "line", "from") + if frm.has("error"): + return frm + var to_ := _coerce_typed(op.to, TYPE_VECTOR2, idx, "line", "to") + if to_.has("error"): + return to_ + var c := _coerce_typed(op.color, TYPE_COLOR, idx, "line", "color") + if c.has("error"): + return c + var out := {"draw": "line", "from": frm.value, "to": to_.value, "color": c.value} + if op.has("width"): + out["width"] = float(op.width) + if op.has("antialiased"): + out["antialiased"] = bool(op.antialiased) + return {"op": out} + + +func _coerce_rect(op: Dictionary, idx: int) -> Dictionary: + var missing := _require_fields(op, idx, "rect", ["rect", "color"]) + if missing.has("error"): + return missing + var r := _coerce_typed(op.rect, TYPE_RECT2, idx, "rect", "rect") + if r.has("error"): + return r + var c := _coerce_typed(op.color, TYPE_COLOR, idx, "rect", "color") + if c.has("error"): + return c + var out := {"draw": "rect", "rect": r.value, "color": c.value} + if op.has("filled"): + out["filled"] = bool(op.filled) + if op.has("width"): + out["width"] = float(op.width) + return {"op": out} + + +func _coerce_arc(op: Dictionary, idx: int) -> Dictionary: + var missing := _require_fields( + op, idx, "arc", ["center", "radius", "start_angle", "end_angle", "color"] + ) + if missing.has("error"): + return missing + var center := _coerce_typed(op.center, TYPE_VECTOR2, idx, "arc", "center") + if center.has("error"): + return center + var c := _coerce_typed(op.color, TYPE_COLOR, idx, "arc", "color") + if c.has("error"): + return c + var out := { + "draw": "arc", + "center": center.value, + "radius": float(op.radius), + "start_angle": float(op.start_angle), + "end_angle": float(op.end_angle), + "color": c.value, + } + if op.has("point_count"): + out["point_count"] = int(op.point_count) + if op.has("width"): + out["width"] = float(op.width) + if op.has("antialiased"): + out["antialiased"] = bool(op.antialiased) + return {"op": out} + + +func _coerce_circle(op: Dictionary, idx: int) -> Dictionary: + var missing := _require_fields(op, idx, "circle", ["center", "radius", "color"]) + if missing.has("error"): + return missing + var center := _coerce_typed(op.center, TYPE_VECTOR2, idx, "circle", "center") + if center.has("error"): + return center + var c := _coerce_typed(op.color, TYPE_COLOR, idx, "circle", "color") + if c.has("error"): + return c + return { + "op": + { + "draw": "circle", + "center": center.value, + "radius": float(op.radius), + "color": c.value, + } + } + + +func _coerce_polyline_or_polygon(op: Dictionary, idx: int, kind: String) -> Dictionary: + if not op.has("points"): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d] (%s): missing 'points'" % [idx, kind] + ) + if typeof(op.points) != TYPE_ARRAY: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "ops[%d] (%s): 'points' must be an Array" % [idx, kind] + ) + var points := PackedVector2Array() + for j in op.points.size(): + var p := UiHandler._coerce_for_type(op.points[j], TYPE_VECTOR2) + if not p.ok: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "ops[%d] (%s): points[%d] invalid" % [idx, kind, j] + ) + points.append(p.value) + + var out := {"draw": kind, "points": points} + + if op.has("colors"): + if typeof(op.colors) != TYPE_ARRAY: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "ops[%d] (%s): 'colors' must be an Array" % [idx, kind] + ) + var colors := PackedColorArray() + for k in op.colors.size(): + var ck := UiHandler._coerce_for_type(op.colors[k], TYPE_COLOR) + if not ck.ok: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "ops[%d] (%s): colors[%d] invalid" % [idx, kind, k] + ) + colors.append(ck.value) + out["colors"] = colors + elif op.has("color"): + var c := UiHandler._coerce_for_type(op.color, TYPE_COLOR) + if not c.ok: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d] (%s): invalid 'color'" % [idx, kind] + ) + out["color"] = c.value + else: + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "ops[%d] (%s): missing 'color' or 'colors'" % [idx, kind] + ) + + if op.has("width"): + out["width"] = float(op.width) + if op.has("antialiased"): + out["antialiased"] = bool(op.antialiased) + return {"op": out} + + +func _coerce_string(op: Dictionary, idx: int) -> Dictionary: + var missing := _require_fields(op, idx, "string", ["position", "text", "color"]) + if missing.has("error"): + return missing + var pos := _coerce_typed(op.position, TYPE_VECTOR2, idx, "string", "position") + if pos.has("error"): + return pos + var c := _coerce_typed(op.color, TYPE_COLOR, idx, "string", "color") + if c.has("error"): + return c + var out := { + "draw": "string", + "position": pos.value, + "text": str(op.text), + "color": c.value, + } + if op.has("font_size"): + out["font_size"] = int(op.font_size) + if op.has("align"): + out["align"] = int(op.align) + if op.has("max_width"): + out["max_width"] = float(op.max_width) + return {"op": out} diff --git a/addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid b/addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid new file mode 100644 index 0000000..da0aaf9 --- /dev/null +++ b/addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid @@ -0,0 +1 @@ +uid://buat1mt0fjlqb diff --git a/addons/godot_ai/handlers/csg_handler.gd b/addons/godot_ai/handlers/csg_handler.gd new file mode 100644 index 0000000..6380a9a --- /dev/null +++ b/addons/godot_ai/handlers/csg_handler.gd @@ -0,0 +1,118 @@ +@tool +extends RefCounted + +## CSG authoring — create CSG shapes (box, sphere, cylinder, torus, prism) +## and set their boolean operation (union / intersection / subtraction) so +## agents can carve geometry (holes, caves, tunnels) directly in the editor. +## +## All ops target nodes in the currently edited scene by scene-relative +## path. All write ops are undoable via EditorUndoRedoManager. Sibling CSG +## shapes under the same parent combine automatically; use a CSGCombiner3D +## parent when you need explicit grouping. + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +const SHAPES := { + "box": "CSGBox3D", + "sphere": "CSGSphere3D", + "cylinder": "CSGCylinder3D", + "torus": "CSGTorus3D", + "polygon": "CSGPolygon3D", +} + +const OPERATIONS := { + "union": CSGShape3D.OPERATION_UNION, + "intersection": CSGShape3D.OPERATION_INTERSECTION, + "subtraction": CSGShape3D.OPERATION_SUBTRACTION, +} + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +## Create a CSG shape under a Node3D parent. +## params: {parent_path, name="", shape="box", operation="union"} +## Returns: {path, name, shape, operation, undoable} +func create(params: Dictionary) -> Dictionary: + var parent_path: String = params.get("parent_path", "") + var shape: String = params.get("shape", "box") + var operation: String = params.get("operation", "union") + var scene_file: String = params.get("scene_file", "") + + var shape_class: String = SHAPES.get(shape, "") + if shape_class.is_empty(): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Unknown shape: %s. Valid shapes: %s" % [shape, ", ".join(SHAPES.keys())]) + if not OPERATIONS.has(operation): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Unknown operation: %s. Valid operations: %s" % [operation, ", ".join(OPERATIONS.keys())]) + + var scene_check := McpScenePath.require_edited_scene(scene_file) + if scene_check.has("error"): + return scene_check + var scene_root: Node = scene_check.node + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, + McpScenePath.format_parent_error(parent_path, scene_root)) + if not parent is Node3D: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, + "CSG parent must be a Node3D (got %s)" % parent.get_class()) + + var node: CSGShape3D = ClassDB.instantiate(shape_class) + if node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % shape_class) + var node_name: String = params.get("name", "") + if node_name.is_empty(): + node_name = shape_class + node.name = node_name + node.operation = OPERATIONS[operation] + + _undo_redo.create_action("MCP: Create %s" % node.name) + _undo_redo.add_do_method(parent, "add_child", node, true) + _undo_redo.add_do_method(node, "set_owner", scene_root) + _undo_redo.add_do_reference(node) + _undo_redo.add_undo_method(parent, "remove_child", node) + _undo_redo.commit_action() + + return {"data": { + "path": McpScenePath.from_node(node, scene_root), + "name": node.name, + "shape": shape, + "operation": operation, + "undoable": true, + }} + + +## Set the boolean operation of a CSG shape. +## params: {path, operation} +## Returns: {operation, undoable} +func set_operation(params: Dictionary) -> Dictionary: + var operation: String = params.get("operation", "") + if not OPERATIONS.has(operation): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Unknown operation: %s. Valid operations: %s" % [operation, ", ".join(OPERATIONS.keys())]) + var resolved := McpNodeValidator.resolve_or_error( + params.get("path", ""), "path", params.get("scene_file", "")) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + if not node is CSGShape3D: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, + "Node is not a CSGShape3D: %s" % params.get("path", "")) + var shape: CSGShape3D = node + var prev: int = shape.operation + var next: int = OPERATIONS[operation] + ## Target the node in both callbacks so the action lands in the scene + ## undo history (first-target routing); `set` exists on every Object. + _undo_redo.create_action("MCP: CSG set_operation") + _undo_redo.add_do_method(shape, "set", "operation", next) + _undo_redo.add_undo_method(shape, "set", "operation", prev) + _undo_redo.commit_action() + return {"data": {"operation": operation, "undoable": true}} diff --git a/addons/godot_ai/handlers/csg_handler.gd.uid b/addons/godot_ai/handlers/csg_handler.gd.uid new file mode 100644 index 0000000..0f7b976 --- /dev/null +++ b/addons/godot_ai/handlers/csg_handler.gd.uid @@ -0,0 +1 @@ +uid://c4mb0tptu52x2 diff --git a/addons/godot_ai/handlers/curve_handler.gd b/addons/godot_ai/handlers/curve_handler.gd new file mode 100644 index 0000000..e21c47a --- /dev/null +++ b/addons/godot_ai/handlers/curve_handler.gd @@ -0,0 +1,243 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Replaces all points on a Curve / Curve2D / Curve3D resource. The point +## list shape depends on resource type (see `set_points` for the schemas). +## +## Dedicated tool rather than a property set because Curve2D/Curve3D.add_point +## is a method call, not a property — resource_create's `properties` dict can't +## reach it. + +const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") + +var _undo_redo: EditorUndoRedoManager +var _connection: McpConnection + + +func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: + _undo_redo = undo_redo + _connection = connection + + +func set_points(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + var property: String = params.get("property", "") + var resource_path: String = params.get("resource_path", "") + var new_points: Array = params.get("points", []) + + var home_err := McpResourceIO.validate_home(params) + if home_err != null: + return home_err + var has_file_target := not resource_path.is_empty() + if not (new_points is Array): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "points must be an array") + + var curve: Resource + var node: Node = null + var curve_created := false + if has_file_target: + var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path") + if rpath_err != null: + return rpath_err + if not ResourceLoader.exists(resource_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path) + # ResourceLoader.load() returns Godot's cached Resource. Duplicate + # before mutating so: (a) open scenes holding a reference to this + # .tres don't silently see the new points outside any undo action, + # and (b) if ResourceSaver.save() fails we haven't corrupted the + # in-memory cache (cache/disk divergence). Also guard against + # ResourceLoader.exists() succeeding but load() returning null + # (corrupt .tres, unregistered class) — otherwise curve.get_class() + # on the response line below would crash the plugin. + var loaded_curve: Resource = ResourceLoader.load(resource_path) + if loaded_curve == null: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to load curve from %s (file exists but load returned null — may be corrupt)" % resource_path + ) + curve = loaded_curve.duplicate() + else: + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + node = McpScenePath.resolve(node_path, scene_root) + if node == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(node_path, scene_root)) + if not (property in node): + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + McpPropertyErrors.build_message(node, property) + ) + curve = node.get(property) + # Auto-create a fresh Curve subclass if the slot is empty. Infer the + # concrete class from the property's hint_string (e.g. Path3D.curve's + # hint is "Curve3D"). Creation is bundled into the same undo action + # as the point-set below, so Ctrl-Z rolls back both. + if curve == null: + var inferred := _infer_curve_class(node, property) + if inferred.is_empty(): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Curve slot on %s.%s is null and the Curve class can't be inferred from the property hint — create one first with resource_create (type=Curve3D/Curve2D/Curve)" % [node.get_class(), property] + ) + curve = ClassDB.instantiate(inferred) + if curve == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % inferred) + curve_created = true + + if not (curve is Curve or curve is Curve2D or curve is Curve3D): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Resource is %s — must be Curve, Curve2D, or Curve3D" % curve.get_class() + ) + + var coerced := _coerce_points(curve, new_points) + if coerced.has("error"): + return coerced.error + + var new_snapshot: Array = coerced.snapshot + + if has_file_target: + _apply_snapshot_to_curve(curve, new_snapshot) + # curve_set_points EDITS an existing .tres, so override the default + # "delete to revert" message via extra_fields. + return McpResourceIO.save_to_disk(curve, resource_path, true, "Curve", { + "curve_class": curve.get_class(), + "point_count": new_snapshot.size(), + "reason": "File save is persistent; edit the .tres file manually to revert", + }, _connection) + + # Inline (node-attached) path: swap the curve property so the action lands + # cleanly in scene history, mirroring the resource-swap pattern used by + # material_handler::assign_material. When curve_created is true the + # "old" value is null — undo clears the slot back to empty. + var new_curve: Resource = curve if curve_created else curve.duplicate() + _apply_snapshot_to_curve(new_curve, new_snapshot) + var old_curve: Resource = null if curve_created else curve + + _undo_redo.create_action("MCP: Set %d points on %s.%s" % [new_snapshot.size(), node.name, property]) + _undo_redo.add_do_property(node, property, new_curve) + _undo_redo.add_undo_property(node, property, old_curve) + _undo_redo.add_do_reference(new_curve) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "property": property, + "curve_class": new_curve.get_class(), + "point_count": new_snapshot.size(), + "curve_created": curve_created, + "undoable": true, + } + } + + +## Infer the concrete Curve class to instantiate for a null property slot. +## Reads the property's hint_string (set by Godot on resource-typed exports) +## to get the exact accepted class name (e.g. "Curve3D" for Path3D.curve). +## Returns empty string if no viable curve class can be determined. +static func _infer_curve_class(node: Node, property: String) -> String: + for prop in node.get_property_list(): + if prop.name != property: + continue + var hint_string: String = prop.get("hint_string", "") + if hint_string.is_empty(): + return "" + if not ClassDB.class_exists(hint_string): + return "" + if hint_string == "Curve" or hint_string == "Curve2D" or hint_string == "Curve3D": + return hint_string + # Some custom properties may list a parent class; require an exact + # match against our three supported types to avoid surprises. + return "" + return "" + + +## Convert input `points` into a normalized snapshot of typed values for +## the given curve type. Returns {snapshot: Array} on success or +## {error: ...} on failure. +static func _coerce_points(curve: Resource, points: Array) -> Dictionary: + var snapshot: Array = [] + if curve is Curve: + for i in range(points.size()): + var p = points[i] + if not (p is Dictionary) or not p.has("offset") or not p.has("value"): + return {"error": ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Curve points[%d] must be {offset, value, [left_tangent, right_tangent]}" % i + )} + snapshot.append({ + "offset": float(p["offset"]), + "value": float(p["value"]), + "left_tangent": float(p.get("left_tangent", 0.0)), + "right_tangent": float(p.get("right_tangent", 0.0)), + }) + elif curve is Curve2D: + var zero2 := {"x": 0, "y": 0} + for i in range(points.size()): + var p2 = points[i] + if not (p2 is Dictionary) or not p2.has("position"): + return {"error": ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Curve2D points[%d] must have 'position' (and optional 'in', 'out')" % i + )} + var axes2 := { + "position": p2["position"], + "in": p2.get("in", zero2), + "out": p2.get("out", zero2), + } + var coerced2 := {} + for field in ["position", "in", "out"]: + var v = NodeHandler._coerce_value(axes2[field], TYPE_VECTOR2) + var err := NodeHandler._check_coerced(v, TYPE_VECTOR2, "Curve2D points[%d].%s" % [i, field]) + if err != null: + return {"error": err} + coerced2[field] = v + snapshot.append(coerced2) + else: # Curve3D + var zero3 := {"x": 0, "y": 0, "z": 0} + for i in range(points.size()): + var p3 = points[i] + if not (p3 is Dictionary) or not p3.has("position"): + return {"error": ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Curve3D points[%d] must have 'position' (and optional 'in', 'out', 'tilt')" % i + )} + var axes3 := { + "position": p3["position"], + "in": p3.get("in", zero3), + "out": p3.get("out", zero3), + } + var coerced3 := {} + for field in ["position", "in", "out"]: + var v = NodeHandler._coerce_value(axes3[field], TYPE_VECTOR3) + var err := NodeHandler._check_coerced(v, TYPE_VECTOR3, "Curve3D points[%d].%s" % [i, field]) + if err != null: + return {"error": err} + coerced3[field] = v + coerced3["tilt"] = float(p3.get("tilt", 0.0)) + snapshot.append(coerced3) + return {"snapshot": snapshot} + + +func _apply_snapshot_to_curve(curve: Resource, snapshot: Array) -> void: + curve.clear_points() + if curve is Curve: + for p: Dictionary in snapshot: + curve.add_point( + Vector2(p.offset, p.value), + p.left_tangent, + p.right_tangent + ) + elif curve is Curve2D: + for p: Dictionary in snapshot: + curve.add_point(p.position, p["in"], p.out) + elif curve is Curve3D: + for i in range(snapshot.size()): + var p: Dictionary = snapshot[i] + curve.add_point(p.position, p["in"], p.out) + curve.set_point_tilt(i, p.tilt) diff --git a/addons/godot_ai/handlers/curve_handler.gd.uid b/addons/godot_ai/handlers/curve_handler.gd.uid new file mode 100644 index 0000000..8eb5b25 --- /dev/null +++ b/addons/godot_ai/handlers/curve_handler.gd.uid @@ -0,0 +1 @@ +uid://dboqr06a1fvqx diff --git a/addons/godot_ai/handlers/editor_handler.gd b/addons/godot_ai/handlers/editor_handler.gd new file mode 100644 index 0000000..8516eed --- /dev/null +++ b/addons/godot_ai/handlers/editor_handler.gd @@ -0,0 +1,1060 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const Telemetry := preload("res://addons/godot_ai/telemetry.gd") +const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd") + +## Handles editor state, selection, log, screenshot, and performance commands. + +const UpdateMixedState := preload("res://addons/godot_ai/utils/update_mixed_state.gd") + +var _log_buffer: McpLogBuffer +var _connection: McpConnection +var _debugger_plugin: McpDebuggerPlugin +var _game_log_buffer: McpGameLogBuffer +var _editor_log_buffer: McpEditorLogBuffer +var _debugger_errors_root: Node +var _surfaced_error_tracker +var _vision_routing: VisionRoutingScript = null + + +func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_plugin: McpDebuggerPlugin = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, debugger_errors_root: Node = null, surfaced_error_tracker = null, vision_routing: VisionRoutingScript = null) -> void: + _log_buffer = log_buffer + _connection = connection + _debugger_plugin = debugger_plugin + _game_log_buffer = game_log_buffer + _editor_log_buffer = editor_log_buffer + _debugger_errors_root = debugger_errors_root + _surfaced_error_tracker = surfaced_error_tracker + _vision_routing = vision_routing + if _surfaced_error_tracker == null: + _surfaced_error_tracker = McpSurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer, _debugger_errors_root) + + +func get_editor_state(_params: Dictionary) -> Dictionary: + var scene_root := EditorInterface.get_edited_scene_root() + var game_status := _current_game_status() + var data := { + "godot_version": Engine.get_version_info().get("string", "unknown"), + "project_name": ProjectSettings.get_setting("application/config/name", ""), + "current_scene": scene_root.scene_file_path if scene_root else "", + "is_playing": EditorInterface.is_playing_scene(), + "readiness": McpConnection.get_readiness(), + ## True once the game subprocess autoload has beaconed mcp:hello; + ## false between Play→Stop cycles. Lets capture-source=game callers + ## poll for a real ready signal instead of guessing with sleep(). + "game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(), + "game_status": game_status, + "helper_live": bool(game_status.get("helper_live", false)), + "session_active": bool(game_status.get("session_active", false)), + } + ## Half-installed addon tree from a failed self-update rollback. When + ## non-empty, the agent / dock paint the operator-facing recovery copy + ## from `update_mixed_state.gd::diagnose`. Field omitted when the + ## addons tree is clean so editor_state's normal payload stays small. + ## See issue #354 / audit-v2 #10. + var mixed_state := UpdateMixedState.diagnose() + if not mixed_state.is_empty(): + data["mixed_state"] = mixed_state + return {"data": data} + + +func get_selection(_params: Dictionary) -> Dictionary: + var scene_root := EditorInterface.get_edited_scene_root() + var selected := EditorInterface.get_selection().get_selected_nodes() + var paths: Array[String] = [] + for node in selected: + paths.append(McpScenePath.from_node(node, scene_root)) + return {"data": {"selected_paths": paths, "count": paths.size()}} + + +const VALID_LOG_SOURCES := ["plugin", "game", "editor", "all"] + +## Deferred budget for the `input_sequence` game op. Unlike the one-shot game +## ops (covered by game_command's 15s entry), it drives the game forward one +## frame per step, so the reply legitimately takes seconds. The game side caps +## the sequence length (GameHelper.MAX_SEQUENCE_FRAMES) well inside this; the +## budget is the backstop for a frozen game loop, mirroring take_screenshot. +const INPUT_SEQUENCE_TIMEOUT_SEC := 30.0 + + +func get_logs(params: Dictionary) -> Dictionary: + ## Coerce defensively — MCP clients can send JSON numbers as floats or + ## stray `null` values that would otherwise fail the typed locals + ## before we ever reach the INVALID_PARAMS return below. + var count: int = maxi(0, int(params.get("count", 50))) + var offset: int = maxi(0, int(params.get("offset", 0))) + var source: String = str(params.get("source", "plugin")) + var include_details: bool = bool(params.get("include_details", false)) + var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null + var since_cursor: int = maxi(0, int(params.get("since_cursor", 0))) + var since_run_id := "" if params.get("since_run_id", null) == null else str(params.get("since_run_id", "")) + if not source in VALID_LOG_SOURCES: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid source '%s' — use 'plugin', 'game', 'editor', or 'all'" % source, + ) + + match source: + "plugin": + return _get_plugin_logs(count, offset) + "game": + return _get_game_logs(count, offset, include_details, since_run_id) + "editor": + return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor) + "all": + return _get_all_logs(count, offset, include_details) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable") + + +func _current_game_status() -> Dictionary: + if _debugger_plugin == null: + return McpDebuggerPlugin.with_liveness_flags({ + "status": "stopped", + "active": false, + "ready": false, + "helper_expected": true, + }) + return _debugger_plugin.get_game_status() + + +func _get_plugin_logs(count: int, offset: int) -> Dictionary: + var all_lines := _log_buffer.get_recent(_log_buffer.total_count()) + var page: Array[Dictionary] = [] + var stop := mini(all_lines.size(), offset + count) + for i in range(mini(offset, all_lines.size()), stop): + page.append({"source": "plugin", "level": "info", "text": all_lines[i]}) + return { + "data": { + "source": "plugin", + "lines": page, + "total_count": all_lines.size(), + "returned_count": page.size(), + "offset": offset, + } + } + + +func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id: String = "") -> Dictionary: + var game_status := _current_game_status() + var helper_live := bool(game_status.get("helper_live", false)) + var session_active := bool(game_status.get("session_active", false)) + if _game_log_buffer == null: + return { + "data": { + "source": "game", + "lines": [], + "total_count": 0, + "returned_count": 0, + "offset": offset, + "run_id": "", + "current_run_id": "", + "is_running": session_active, + "helper_live": helper_live, + "session_active": session_active, + "game_status": game_status, + "dropped_count": 0, + "stale_run_id": false, + } + } + var current_run_id := _game_log_buffer.run_id() + var target_run_id := since_run_id if not since_run_id.is_empty() else current_run_id + var stale_run_id := not since_run_id.is_empty() and since_run_id != current_run_id + var run_page := _game_log_buffer.get_run_page(target_run_id, offset, count) + var page := _entries_for_response(run_page.get("entries", []), include_details) + var data := { + "source": "game", + "lines": page, + "total_count": int(run_page.get("total_count", 0)), + "returned_count": page.size(), + "offset": offset, + "run_id": target_run_id, + "current_run_id": current_run_id, + "is_running": session_active, + "helper_live": helper_live, + "session_active": session_active, + "game_status": game_status, + "dropped_count": _game_log_buffer.dropped_count(), + "stale_run_id": stale_run_id, + } + _merge_editor_errors_hint(data, game_status) + return {"data": data} + + +## #641: boot-time parse errors happen while autoload scripts compile — before +## the game helper's logger attaches via OS.add_logger — so they can NEVER +## appear in the game buffer. They surface only through the editor scope +## (Errors-tab rows + editor logger). Cross-reference them here so an +## empty/clean game log is not mistaken for a clean launch. +func _merge_editor_errors_hint(data: Dictionary, game_status: Dictionary) -> void: + if _debugger_plugin == null: + return + ## A since_run_id read of a prior run must not carry the CURRENT run's + ## editor errors — the hint interprets the run being read. + if bool(data.get("stale_run_id", false)): + return + ## run_token == 0 means no tracked run ever started this session; the + ## run-start cursor would be 0 and every retained editor error would be + ## misattributed to "this run". + if int(game_status.get("run_token", 0)) <= 0: + return + ## One-shot read — force the scan so rows that landed after the last + ## gated scan (and before the deferred timers fire) make the FIRST + ## logs_read(source='game') response, not just a later one. + var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since( + int(game_status.get("editor_log_cursor", 0)), true) + if str(errors_info.get("scope", "none")) != "run": + return + var errors: Array = errors_info.get("errors", []) + if errors.is_empty(): + return + data["editor_errors_count"] = errors.size() + data["editor_errors_hint"] = ( + "%d editor-side error%s from this run (first: %s) missing from the game log — boot-time parse/load errors occur before the game helper's logger attaches. Read logs_read(source='editor', include_details=true)." + % [errors.size(), "s" if errors.size() != 1 else "", _format_editor_error_summary(errors[0])] + ) + + +func _format_editor_error_summary(entry: Dictionary) -> String: + return McpSurfacedErrorTracker.format_editor_error_summary(entry) + + +func _get_editor_logs(count: int, offset: int, include_details: bool, has_since_cursor: bool = false, since_cursor: int = 0) -> Dictionary: + ## Editor-process script errors (parse errors, @tool runtime errors, + ## EditorPlugin errors, push_error/push_warning). Captured by + ## editor_logger.gd via OS.add_logger and gated on Godot 4.5+; on older + ## engines the buffer can be null. Godot also sends GDScript reload + ## warnings/errors straight to the Debugger dock's Errors tab; those do + ## not flow through OS.add_logger, so merge the visible tree rows here. + if has_since_cursor: + return _get_editor_logs_since(count, since_cursor, include_details) + var all_entries := _collect_editor_log_entries() + var page := _entries_for_response(_slice_entries(all_entries, offset, count), include_details) + var appended_total := _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0 + return { + "data": { + "source": "editor", + "lines": page, + "total_count": all_entries.size(), + "returned_count": page.size(), + "offset": offset, + "dropped_count": _editor_log_buffer.dropped_count() if _editor_log_buffer != null else 0, + "next_cursor": appended_total, + "appended_total": appended_total, + } + } + + +func _get_editor_logs_since(count: int, since_cursor: int, include_details: bool) -> Dictionary: + ## Cursor reads are defined over the monotonic editor logger ring only. + ## Visible Debugger Errors-tab rows are live UI state, not ring entries, + ## so regular offset reads still merge them while since_cursor polling + ## reports only Logger-backed entries. + var captured := { + "cursor": since_cursor, + "oldest_cursor": 0, + "next_cursor": 0, + "appended_total": 0, + "truncated": false, + "has_more": false, + "entries": [], + } + var dropped := 0 + if _editor_log_buffer != null: + captured = _editor_log_buffer.get_since(since_cursor, count) + dropped = _editor_log_buffer.dropped_count() + var page := _entries_for_response(captured.get("entries", []), include_details) + return { + "data": { + "source": "editor", + "lines": page, + "total_count": int(captured.get("appended_total", 0)), + "returned_count": page.size(), + "offset": 0, + "dropped_count": dropped, + "cursor": int(captured.get("cursor", since_cursor)), + "oldest_cursor": int(captured.get("oldest_cursor", 0)), + "next_cursor": int(captured.get("next_cursor", 0)), + "appended_total": int(captured.get("appended_total", 0)), + "truncated": bool(captured.get("truncated", false)), + "has_more": bool(captured.get("has_more", false)), + } + } + + +func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary: + ## Plugin lines have no timestamp, so we can't merge chronologically. + ## Concatenate plugin → editor → game and apply the offset/count window + ## over the combined list. The per-line `source` field tells callers + ## where each entry came from. Editor goes between plugin and game so + ## script errors stay grouped near the plugin recv/send traffic that + ## triggered them, with game runtime logs at the end. + var combined: Array[Dictionary] = [] + for line in _log_buffer.get_recent(_log_buffer.total_count()): + combined.append({"source": "plugin", "level": "info", "text": line}) + for entry in _collect_editor_log_entries(): + combined.append(entry) + var run_id := "" + var current_run_id := "" + var dropped := 0 + if _game_log_buffer != null: + run_id = _game_log_buffer.run_id() + current_run_id = run_id + dropped = _game_log_buffer.dropped_count() + var run_page := _game_log_buffer.get_run_page(run_id, 0, McpGameLogBuffer.MAX_LINES) + for entry in run_page.get("entries", []): + combined.append(entry) + var stop := mini(combined.size(), offset + count) + var page: Array[Dictionary] = [] + for i in range(mini(offset, combined.size()), stop): + page.append(combined[i]) + page = _entries_for_response(page, include_details) + if _editor_log_buffer != null: + dropped += _editor_log_buffer.dropped_count() + var game_status := _current_game_status() + return { + "data": { + "source": "all", + "lines": page, + "total_count": combined.size(), + "returned_count": page.size(), + "offset": offset, + "run_id": run_id, + "current_run_id": current_run_id, + "is_running": bool(game_status.get("session_active", false)), + "helper_live": bool(game_status.get("helper_live", false)), + "session_active": bool(game_status.get("session_active", false)), + "game_status": game_status, + "dropped_count": dropped, + } + } + + +func _entries_for_response(entries: Array[Dictionary], include_details: bool) -> Array[Dictionary]: + ## Compact responses only drop the top-level "details" key, so a shallow + ## copy is enough; the deep copy is reserved for the opt-in details path + ## where nested dicts leave the buffer. + var out: Array[Dictionary] = [] + for entry in entries: + if include_details: + out.append(entry.duplicate(true)) + else: + var copy: Dictionary = entry.duplicate(false) + copy.erase("details") + out.append(copy) + return out + + +func _collect_editor_log_entries() -> Array[Dictionary]: + return _surfaced_error_tracker.collect_editor_log_entries() + + +static func _slice_entries(entries: Array[Dictionary], offset: int, count: int) -> Array[Dictionary]: + var page: Array[Dictionary] = [] + var stop := mini(entries.size(), offset + count) + for i in range(mini(offset, entries.size()), stop): + page.append(entries[i]) + return page + + +## Map of human-readable monitor names to Performance.Monitor enum values. +const MONITORS := { + "time/fps": Performance.TIME_FPS, + "time/process": Performance.TIME_PROCESS, + "time/physics_process": Performance.TIME_PHYSICS_PROCESS, + "time/navigation_process": Performance.TIME_NAVIGATION_PROCESS, + "memory/static": Performance.MEMORY_STATIC, + "memory/static_max": Performance.MEMORY_STATIC_MAX, + "memory/message_buffer_max": Performance.MEMORY_MESSAGE_BUFFER_MAX, + "object/count": Performance.OBJECT_COUNT, + "object/resource_count": Performance.OBJECT_RESOURCE_COUNT, + "object/node_count": Performance.OBJECT_NODE_COUNT, + "object/orphan_node_count": Performance.OBJECT_ORPHAN_NODE_COUNT, + "render/total_objects_in_frame": Performance.RENDER_TOTAL_OBJECTS_IN_FRAME, + "render/total_primitives_in_frame": Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME, + "render/total_draw_calls_in_frame": Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME, + "render/video_mem_used": Performance.RENDER_VIDEO_MEM_USED, + "physics_2d/active_objects": Performance.PHYSICS_2D_ACTIVE_OBJECTS, + "physics_2d/collision_pairs": Performance.PHYSICS_2D_COLLISION_PAIRS, + "physics_2d/island_count": Performance.PHYSICS_2D_ISLAND_COUNT, + "physics_3d/active_objects": Performance.PHYSICS_3D_ACTIVE_OBJECTS, + "physics_3d/collision_pairs": Performance.PHYSICS_3D_COLLISION_PAIRS, + "physics_3d/island_count": Performance.PHYSICS_3D_ISLAND_COUNT, + "navigation/active_maps": Performance.NAVIGATION_ACTIVE_MAPS, + "navigation/region_count": Performance.NAVIGATION_REGION_COUNT, + "navigation/agent_count": Performance.NAVIGATION_AGENT_COUNT, + "navigation/link_count": Performance.NAVIGATION_LINK_COUNT, + "navigation/polygon_count": Performance.NAVIGATION_POLYGON_COUNT, + "navigation/edge_count": Performance.NAVIGATION_EDGE_COUNT, + "navigation/edge_merge_count": Performance.NAVIGATION_EDGE_MERGE_COUNT, + "navigation/edge_connection_count": Performance.NAVIGATION_EDGE_CONNECTION_COUNT, + "navigation/edge_free_count": Performance.NAVIGATION_EDGE_FREE_COUNT, +} + + +## Compute coverage angles from the target's AABB geometry. +## Returns an establishing perspective shot (faces the longest ground axis) +## and an orthographic top-down for spatial layout. The AI iterates from +## there with explicit elevation/azimuth/fov for closeups and detail shots. +func _compute_coverage_angles(aabb: AABB) -> Array[Dictionary]: + var size := aabb.size + var ground_x := maxf(size.x, 0.01) + var ground_z := maxf(size.z, 0.01) + + ## Face the longest ground axis — establishing shot shows maximum extent + var estab_azimuth: float + if ground_x >= ground_z: + estab_azimuth = 0.0 # face along Z, showing X width + else: + estab_azimuth = 90.0 # face along X, showing Z width + + ## FOV: wider for spread-out subjects, narrower for compact ones + var ground_ratio := maxf(ground_x, ground_z) / minf(ground_x, ground_z) + var estab_fov := clampf(40.0 + ground_ratio * 5.0, 45.0, 65.0) + + return [ + {"label": "establishing", "elevation": 25.0, "azimuth": estab_azimuth + 20.0, + "fov": estab_fov, "ortho": false, "padding": 1.8}, + {"label": "top", "elevation": 90.0, "azimuth": 0.0, + "fov": 0.0, "ortho": true}, + ] + + +func take_screenshot(params: Dictionary) -> Dictionary: + ## Vision Routing hook: when enabled, the capture is described by the + ## configured vision provider on a worker thread and the text description + ## is returned instead of the raw image (see vision_routing.gd). Off, no + ## key, or non-image results keep the original behavior. The single source + ## of truth for the `match source:` dispatch lives in _take_screenshot_impl + ## (pinned by tests/unit/test_docs_screenshot_sources.py). + if _vision_routing != null and _vision_routing.is_routing_enabled(): + return _vision_routing.route_editor_screenshot(params, Callable(self, "_take_screenshot_impl"), _connection) + return _take_screenshot_impl(params) + + +func _take_screenshot_impl(params: Dictionary) -> Dictionary: + var source: String = params.get("source", "viewport") + var max_resolution: int = params.get("max_resolution", 0) + var view_target: String = params.get("view_target", "") + var coverage: bool = params.get("coverage", false) + var custom_elevation = params.get("elevation", null) + var custom_azimuth = params.get("azimuth", null) + var custom_fov = params.get("fov", null) + + var viewport: Viewport + match source: + "viewport": + viewport = EditorInterface.get_editor_viewport_3d() + if viewport == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_VIEWPORT_UNAVAILABLE, + "No 3D viewport available", false) + ## The 3D viewport's texture is empty when the edited scene + ## has no Node3D content (2D-only scene, or no scene open), + ## and the empty-image guard further down used to surface + ## that as INTERNAL_ERROR — leaving callers with no signal + ## that the failure was caller-side. Reject up front with a + ## structured hint so the LLM can pick a sensible next step + ## (open a 3D scene, switch to source="cinematic", etc.). + var precheck := viewport_screenshot_precheck(EditorInterface.get_edited_scene_root()) + if precheck.has("error"): + return precheck + "game": + if not EditorInterface.is_playing_scene(): + ## Same editor state as game_eval/game_command's gate below — + ## same EDITOR_NOT_READY shape, not INVALID_PARAMS (the params + ## were fine; the editor just isn't in the required state). + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_GAME_NOT_RUNNING, + "Game is not running — start the project first", false, + "Use source='viewport' for the editor viewport, or start the game with project_run and retry.") + ## The game is always a separate OS process (embedded mode just + ## reparents its window into the editor). Reach the framebuffer + ## via the debugger channel: the `_mcp_game_helper` autoload + ## inside the game process replies with a PNG, and + ## McpDebuggerPlugin pushes the response back through our + ## WebSocket with the same request_id via McpConnection.send_deferred_response. + if _debugger_plugin == null or _connection == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Debugger bridge unavailable — plugin may not be fully initialised") + var request_id: String = params.get("_request_id", "") + if request_id.is_empty(): + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Missing request_id — cannot correlate deferred response") + _debugger_plugin.request_game_screenshot(request_id, max_resolution, _connection) + return McpDispatcher.DEFERRED_RESPONSE + "cinematic": + return _take_cinematic_screenshot(max_resolution) + "viewport_2d": + viewport = EditorInterface.get_editor_viewport_2d() + if viewport == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_VIEWPORT_UNAVAILABLE, + "No 2D viewport available", false) + var scene_root_2d := EditorInterface.get_edited_scene_root() + if scene_root_2d == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_NO_SCENE, + "No scene open — open a scene first", false, + "Call scene_open with a scene path (e.g. \"res://main.tscn\") first.") + if not view_target.is_empty() or coverage or custom_elevation != null or custom_azimuth != null or custom_fov != null: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "view_target, coverage, elevation, azimuth, and fov are not supported with source='viewport_2d'" + ) + ## Capture the 2D editor viewport directly; no view_target/coverage for 2D. + RenderingServer.force_draw(false) + var image_2d: Image = viewport.get_texture().get_image() + if image_2d == null or image_2d.is_empty(): + return _empty_image_error( + "viewport_2d", + "Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet." + ) + return _finalize_image(image_2d, "viewport_2d", max_resolution) + _: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'viewport_2d', 'cinematic', or 'game'" % source) + + ## Handle view_target: temporarily reposition the editor's own camera to + ## frame one or more target nodes, force a render, capture, then restore. + if not view_target.is_empty() and source == "viewport": + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + ## Parse comma-separated paths, deduplicate + var raw_paths := view_target.split(",") + var seen := {} + var unique_paths: Array[String] = [] + for rp in raw_paths: + var p := rp.strip_edges() + if not p.is_empty() and not seen.has(p): + seen[p] = true + unique_paths.append(p) + + ## Resolve each path, collect valid Node3D targets + var targets: Array[Node3D] = [] + var not_found: Array[String] = [] + for p in unique_paths: + var node := McpScenePath.resolve(p, scene_root) + if node == null: + not_found.append(p) + elif not node is Node3D: + not_found.append(p) + else: + targets.append(node as Node3D) + + if targets.is_empty(): + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "No valid Node3D targets found: %s" % ", ".join(not_found)) + + var cam := viewport.get_camera_3d() + if cam == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_VIEWPORT_UNAVAILABLE, + "No camera in 3D viewport", false) + + ## Merge AABBs from all targets + var combined_aabb := _get_visual_aabb(targets[0]) + for i in range(1, targets.size()): + combined_aabb = combined_aabb.merge(_get_visual_aabb(targets[i])) + + var cam_rid := cam.get_camera_rid() + var saved_xform := cam.global_transform + var saved_fov := cam.fov + var saved_near := cam.near + var saved_far := cam.far + + ## --- Coverage path: multi-angle sweep --- + if coverage: + var images: Array[Dictionary] = [] + for preset in _compute_coverage_angles(combined_aabb): + if preset.get("ortho", false): + ## Orthographic top-down view + var ortho_size := combined_aabb.size.length() * 1.8 + var cam_height := maxf(combined_aabb.size.length() * 3.0, 10.0) + var center := combined_aabb.get_center() + var xform := Transform3D(Basis.IDENTITY, center + Vector3.UP * cam_height) + xform = xform.looking_at(center, Vector3.FORWARD) + RenderingServer.camera_set_orthogonal(cam_rid, ortho_size, saved_near, maxf(saved_far, cam_height * 2.0)) + RenderingServer.camera_set_transform(cam_rid, xform) + else: + ## Perspective view — padding per preset (wide for establishing, tight for detail) + var pad: float = preset.get("padding", 2.5) + var xform := _frame_transform_for_aabb(combined_aabb, preset.fov, preset.elevation, preset.azimuth, pad) + RenderingServer.camera_set_perspective(cam_rid, preset.fov, saved_near, saved_far) + RenderingServer.camera_set_transform(cam_rid, xform) + RenderingServer.force_draw(false) + var img: Image = viewport.get_texture().get_image() + if img != null and not img.is_empty(): + var entry := _finalize_image(img, "viewport", max_resolution) + entry.data["label"] = preset.label + entry.data["elevation"] = preset.elevation + entry.data["azimuth"] = preset.azimuth + entry.data["fov"] = preset.fov + entry.data["ortho"] = preset.get("ortho", false) + images.append(entry.data) + + ## Restore camera state (back to perspective + original transform) + RenderingServer.camera_set_perspective(cam_rid, saved_fov, saved_near, saved_far) + RenderingServer.camera_set_transform(cam_rid, saved_xform) + + ## Consistent with single-shot path: error if no frames rendered + ## (e.g. headless mode where force_draw produces no output). + if images.is_empty(): + return _empty_image_error( + "viewport", + "Coverage sweep rendered no images. The 3D viewport produced no output across any of the preset angles — typically because the editor is in headless mode (force_draw has no rendered output) or the 3D viewport has not drawn a frame yet." + ) + + var aabb_center := combined_aabb.get_center() + var aabb_size := combined_aabb.size + var result_data := { + "source": "viewport", + "view_target": view_target, + "view_target_count": targets.size(), + "coverage": true, + "images": images, + "aabb_center": [aabb_center.x, aabb_center.y, aabb_center.z], + "aabb_size": [aabb_size.x, aabb_size.y, aabb_size.z], + "aabb_longest_ground_axis": "x" if aabb_size.x >= aabb_size.z else "z", + } + if not not_found.is_empty(): + result_data["view_target_not_found"] = not_found + return {"data": result_data} + + ## --- Custom angle / FOV path --- + var use_elev: float = 25.0 if custom_elevation == null else float(custom_elevation) + var use_azim: float = 30.0 if custom_azimuth == null else float(custom_azimuth) + var use_fov: float = saved_fov if custom_fov == null else float(custom_fov) + + var cam_xform := _frame_transform_for_aabb(combined_aabb, use_fov, use_elev, use_azim) + + if custom_fov != null: + RenderingServer.camera_set_perspective(cam_rid, use_fov, saved_near, saved_far) + RenderingServer.camera_set_transform(cam_rid, cam_xform) + RenderingServer.force_draw(false) + + var image: Image = viewport.get_texture().get_image() + + ## Restore camera state + if custom_fov != null: + RenderingServer.camera_set_perspective(cam_rid, saved_fov, saved_near, saved_far) + RenderingServer.camera_set_transform(cam_rid, saved_xform) + + if image == null or image.is_empty(): + return _empty_image_error( + "viewport", + "Framed viewport rendered an empty image after repositioning the camera onto the view_target. The 3D viewport produced no output — typically headless mode or the 3D viewport has not drawn a frame yet." + ) + + var result := _finalize_image(image, "viewport", max_resolution) + result.data["view_target"] = view_target + result.data["view_target_count"] = targets.size() + var aabb_c := combined_aabb.get_center() + var aabb_s := combined_aabb.size + result.data["aabb_center"] = [aabb_c.x, aabb_c.y, aabb_c.z] + result.data["aabb_size"] = [aabb_s.x, aabb_s.y, aabb_s.z] + result.data["aabb_longest_ground_axis"] = "x" if aabb_s.x >= aabb_s.z else "z" + if custom_elevation != null or custom_azimuth != null: + result.data["elevation"] = use_elev + result.data["azimuth"] = use_azim + if custom_fov != null: + result.data["fov"] = use_fov + if not not_found.is_empty(): + result.data["view_target_not_found"] = not_found + return result + + var image: Image = viewport.get_texture().get_image() + + if image == null or image.is_empty(): + return _empty_image_error( + source, + "Captured an empty image from %s. The 3D viewport produced no output — typically headless mode or the 3D viewport has not drawn a frame yet." % source + ) + + return _finalize_image(image, source, max_resolution) + + +## Render the edited scene through its active Camera3D without running the +## game. Mirrors Godot's "Cinematic Preview" display mode but via a +## throwaway SubViewport, so the output has no editor gizmos, selection +## outlines, or grid lines. +func _take_cinematic_screenshot(max_resolution: int) -> Dictionary: + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var scene_camera := _find_current_camera_3d(scene_root) + if scene_camera == null: + return ErrorCodes.make( + ErrorCodes.NODE_NOT_FOUND, + "No current Camera3D in scene — mark a Camera3D as `current` or add one to the scene", + ) + + ## Default to a 16:9 HD capture; size is overridden by _finalize_image's + ## `max_resolution` downscale step when requested. + var render_size := Vector2i(1920, 1080) + var edit_vp := EditorInterface.get_editor_viewport_3d() + if edit_vp != null: + var vs := edit_vp.get_visible_rect().size + if vs.x >= 1.0 and vs.y >= 1.0: + render_size = Vector2i(int(vs.x), int(vs.y)) + + var sub_vp := SubViewport.new() + sub_vp.size = render_size + sub_vp.own_world_3d = false + sub_vp.transparent_bg = false + sub_vp.render_target_update_mode = SubViewport.UPDATE_ONCE + + var cam := Camera3D.new() + cam.fov = scene_camera.fov + cam.near = scene_camera.near + cam.far = scene_camera.far + cam.projection = scene_camera.projection + cam.size = scene_camera.size + cam.keep_aspect = scene_camera.keep_aspect + cam.cull_mask = scene_camera.cull_mask + cam.environment = scene_camera.environment + cam.attributes = scene_camera.attributes + cam.current = true + + sub_vp.add_child(cam) + scene_root.add_child(sub_vp) + ## global_transform is resolved against the ancestor Node3D chain, so it + ## must be set after parenting — otherwise the camera ends up at origin. + cam.global_transform = scene_camera.global_transform + ## NOTIFICATION_TRANSFORM_CHANGED is delivered deferred (next frame's + ## flush_transform_notifications), but force_draw renders immediately — + ## without this flush the RenderingServer still has the identity + ## transform pushed at ENTER_WORLD and the capture shows only sky + ## instead of the camera's actual view (issue #650). + cam.force_update_transform() + + RenderingServer.force_draw(false) + var image: Image = sub_vp.get_texture().get_image() + + scene_root.remove_child(sub_vp) + sub_vp.queue_free() + + if image == null or image.is_empty(): + return _empty_image_error( + "cinematic", + "Cinematic render produced an empty image. The SubViewport returned no texture — typically headless mode (force_draw has no rendered output) or the scene's Camera3D is positioned so nothing visible is in frame." + ) + + var result := _finalize_image(image, "cinematic", max_resolution) + result.data["camera_path"] = McpScenePath.from_node(scene_camera, scene_root) + return result + + +## Reject a `source="viewport"` screenshot before we ever pull the +## texture if the edited scene has no Node3D content. The 3D viewport +## returns an empty (or stale) image in that case; surfacing it as +## INTERNAL_ERROR ("Failed to capture image from viewport") gave LLM +## callers no signal that the right move is to switch source or open a +## 3D scene. 152 hits / 63 uuids in 24h across plugin versions 2.5.0 -> +## 2.5.6 traced back to this. Returns `{}` on success. +## +## Caller passes `EditorInterface.get_edited_scene_root()`; the static +## form lets tests exercise the branches with a synthetic scene root +## without driving the editor. +static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary: + if scene_root == null: + var no_scene_err := _make_viewport_not_3d_error( + "", + "The editor 3D viewport is empty because no scene is open. Open a scene with `scene_open` first." + ) + ## The honest state here is "no scene", not "scene lacks 3D content" + ## — relabel the sub-code so telemetry doesn't conflate the two. + ## `editor_state` stays "viewport_not_3d" for pre-#651 consumers. + no_scene_err["error"]["data"]["sub_code"] = ErrorCodes.SUB_EDITOR_NO_SCENE + return no_scene_err + ## A scene with any Node3D content — root or descendant — has + ## something the 3D viewport can render. Walking the tree (rather + ## than only checking the root type) avoids a false reject on the + ## common `Node` / `Node2D` root + Node3D descendant pattern. + if _scene_has_node3d_content(scene_root): + return {} + var root_type := scene_root.get_class() + var hint: String + var is_2d_scene := scene_root is CanvasItem + if is_2d_scene: + hint = ( + "The 3D viewport is empty because the current scene is 2D (%s root) with no Node3D descendants. " + + "Options: (a) open a 3D scene, " + + "(b) use source=\"cinematic\" if a Camera3D exists in the scene, " + + "(c) use source=\"viewport_2d\" to capture the 2D editor viewport directly, " + + "(d) call scene_get_hierarchy first to inspect what's available." + ) % root_type + else: + hint = ( + "The 3D viewport is empty because the current scene (%s root) has no Node3D content anywhere in the tree. " + + "Options: (a) open or add a Node3D, " + + "(b) use source=\"cinematic\" if a Camera3D exists in the scene, " + + "(c) call scene_get_hierarchy first to inspect what's available." + ) % root_type + var err := _make_viewport_not_3d_error(root_type, hint) + if is_2d_scene: + err["error"]["data"]["suggestion"] = "use source='viewport_2d' for 2D scenes" + return err + + +## True if scene_root is itself a Node3D or owns any Node3D descendant. +## DFS short-circuits on the first hit so empty 2D scenes stay cheap. +static func _scene_has_node3d_content(scene_root: Node) -> bool: + if scene_root is Node3D: + return true + var stack: Array[Node] = [scene_root] + while not stack.is_empty(): + var node: Node = stack.pop_back() + for child in node.get_children(): + if child is Node3D: + return true + stack.append(child) + return false + + +static func _make_viewport_not_3d_error(scene_root_type: String, hint: String) -> Dictionary: + ## `hint` becomes `error.message`; not duplicated into `data` because + ## `GodotCommandError`'s string form already appends every `data` key + ## as a suffix on the agent-visible error. + var err := ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_VIEWPORT_NOT_3D, hint, false) + err["error"]["data"]["editor_state"] = "viewport_not_3d" + err["error"]["data"]["scene_root_type"] = scene_root_type + return err + + +## Reached only when the precheck passed but the texture still came +## back empty — headless rendering, a freshly opened editor whose 3D +## viewport hasn't drawn a frame, or a SubViewport that lost its target. +static func _empty_image_error(source: String, hint: String) -> Dictionary: + ## retryable=false: an empty capture is usually headless mode, where a + ## retry loops forever — the not-yet-drawn-frame case is transient but + ## indistinguishable from here, so don't invite a retry loop. + var err := ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_VIEWPORT_EMPTY, hint, false) + err["error"]["data"]["editor_state"] = "viewport_empty" + err["error"]["data"]["source"] = source + return err + + +## Return the Camera3D that would be active if the scene were running. +## Preference: a descendant with `current=true`, else the first Camera3D +## found in a depth-first walk. +func _find_current_camera_3d(root: Node) -> Camera3D: + var first: Camera3D = null + var stack: Array[Node] = [root] + while not stack.is_empty(): + var node: Node = stack.pop_back() + if node is Camera3D: + if node.current: + return node + if first == null: + first = node + for child in node.get_children(): + stack.append(child) + return first + + +func _finalize_image(image: Image, source: String, max_resolution: int) -> Dictionary: + ## Shared with the game-process copy in runtime/game_helper.gd (#716). + var encoded := McpScreenshotEncode.downscale_and_encode(image, max_resolution) + return { + "data": { + "source": source, + "width": encoded.width, + "height": encoded.height, + "original_width": encoded.original_width, + "original_height": encoded.original_height, + "format": "png", + "image_base64": encoded.base64, + } + } + + +## Recursively compute the visual bounding box of a Node3D and its children. +func _get_visual_aabb(node: Node3D) -> AABB: + var aabb := AABB() + var found := false + if node is VisualInstance3D: + aabb = node.global_transform * node.get_aabb() + found = true + for child in node.get_children(): + if child is Node3D: + var child_aabb := _get_visual_aabb(child) + if child_aabb.size != Vector3.ZERO: + if found: + aabb = aabb.merge(child_aabb) + else: + aabb = child_aabb + found = true + if not found: + aabb = AABB(node.global_position - Vector3(0.5, 0.5, 0.5), Vector3(1, 1, 1)) + return aabb + + +## Calculate a camera Transform3D that frames the given AABB nicely. +## elevation_deg: camera elevation (0 = level, 90 = directly above). Default 25. +## azimuth_deg: camera azimuth (0 = front, 90 = right side). Default 30. +## padding: distance multiplier for breathing room (1.2 = tight, 2.5 = context). Default 1.8. +func _frame_transform_for_aabb(aabb: AABB, fov_degrees: float = 75.0, elevation_deg: float = 25.0, azimuth_deg: float = 30.0, padding: float = 1.8) -> Transform3D: + var center := aabb.get_center() + var radius := aabb.size.length() * 0.5 + var fov_rad := deg_to_rad(fov_degrees) + var distance := radius / tan(fov_rad * 0.5) * padding + ## Floor with an absolute offset so unit-scale AABBs don't place the camera + ## inside or against the target. `radius * 2.0` alone scales to zero as the + ## AABB shrinks; the +1.0 guarantees a minimum of ~1 world-unit of standoff. + distance = maxf(distance, radius * 2.0 + 1.0) + var elev := deg_to_rad(elevation_deg) + var azim := deg_to_rad(azimuth_deg) + var cam_pos := center + Vector3( + distance * cos(elev) * sin(azim), + distance * sin(elev), + distance * cos(elev) * cos(azim), + ) + var xform := Transform3D(Basis.IDENTITY, cam_pos) + ## At ~90° elevation the view direction is parallel to Vector3.UP — use + ## FORWARD as the up hint so looking_at doesn't degenerate. + var up := Vector3.FORWARD if elevation_deg > 85.0 else Vector3.UP + return xform.looking_at(center, up) + + +func get_performance_monitors(params: Dictionary) -> Dictionary: + var filter: Array = params.get("monitors", []) + var result := {} + + if filter.is_empty(): + for key in MONITORS: + result[key] = Performance.get_monitor(MONITORS[key]) + else: + for key in filter: + if MONITORS.has(key): + result[key] = Performance.get_monitor(MONITORS[key]) + + return { + "data": { + "monitors": result, + "monitor_count": result.size(), + } + } + + +func clear_logs(params: Dictionary) -> Dictionary: + var count := _log_buffer.total_count() + _log_buffer.clear() + var data := {"cleared_count": count} + ## The Debugger Errors panel is user-visible editor UI, not an MCP-owned + ## buffer — wiping it stays behind an explicit opt-in. + if bool(params.get("clear_debugger_errors", false)): + data["debugger_errors_cleared"] = _clear_debugger_error_trees() + return {"data": data} + + +func _clear_debugger_error_trees() -> int: + return _surfaced_error_tracker.clear_debugger_error_trees() + + +func reload_plugin(_params: Dictionary) -> Dictionary: + _log_buffer.log("reload_plugin requested, reloading next frame") + ## Persist a pending plugin_reload telemetry event *before* the + ## disable kills the live WebSocket. The re-enabled plugin's + ## _enter_tree flushes via `_telemetry.flush_pending_plugin_reload()`. + Telemetry.record_pending_plugin_reload("mcp_tool") + _do_reload_plugin.call_deferred() + return {"data": {"status": "reloading", "message": "Plugin reload initiated"}} + + +## Force a filesystem rescan before toggling the plugin, so Godot's +## class-name registry picks up any .gd files added since the last scan +## (e.g. via git pull or an agent-driven sync). Without this, re-enable can +## fail with "Could not find type X" when new class_name scripts are on disk +## but not yet registered, leaving the plugin disabled with no recovery path +## short of killing the editor. See issue #83. +# `static` is load-bearing: the deferred coroutine captures no `self`, so +# it survives even if the EditorHandler RefCounted is freed mid-await — +# which is exactly what reload does to this handler's owner. An instance +# coroutine here resumes on a freed object under reload churn. +static func _do_reload_plugin() -> void: + var fs := EditorInterface.get_resource_filesystem() + fs.scan() + var tree := Engine.get_main_loop() as SceneTree + # Cap the wait so a long scan (huge project) doesn't hang reload. + var deadline_ms := Time.get_ticks_msec() + 5000 + while fs.is_scanning() and Time.get_ticks_msec() < deadline_ms: + await tree.process_frame + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) + + +func quit_editor(_params: Dictionary) -> Dictionary: + _log_buffer.log("quit_editor requested, quitting next frame") + ## Defer the quit so the response is sent back before the editor exits. + EditorInterface.get_base_control().get_tree().call_deferred("quit") + return {"data": {"status": "quitting", "message": "Editor quit initiated"}} + + +func game_eval(params: Dictionary) -> Dictionary: + var code: String = params.get("code", "") + if code.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "code is required") + + if _debugger_plugin == null or _connection == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Debugger bridge unavailable — plugin may not be fully initialised") + + if not EditorInterface.is_playing_scene(): + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_GAME_NOT_RUNNING, + "Game is not running — start the project first", false, + "Start the game with project_run (or wait for the user to run it), then retry.") + + var request_id: String = params.get("_request_id", "") + if request_id.is_empty(): + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Missing request_id — cannot correlate deferred response") + + _debugger_plugin.request_game_eval(code, request_id, _connection) + return McpDispatcher.DEFERRED_RESPONSE + + +func game_command(params: Dictionary) -> Dictionary: + var op: String = str(params.get("op", "")) + if op.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "op is required") + + if _debugger_plugin == null or _connection == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Debugger bridge unavailable — plugin may not be fully initialised") + + if not EditorInterface.is_playing_scene(): + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_GAME_NOT_RUNNING, + "Game is not running — start the project first", false, + "Start the game with project_run (or wait for the user to run it), then retry.") + + var request_id: String = params.get("_request_id", "") + if request_id.is_empty(): + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Missing request_id — cannot correlate deferred response") + + var command_params: Dictionary = params.get("params", {}) + + ## input_sequence steps the game forward frame-by-frame in one call, so it + ## needs a far larger budget than the one-shot game ops that share the + ## `game_command` deferred entry (15s). Widen both timers only for it: the + ## debugger-side pending timer (below) and the dispatcher-side deferred + ## budget (via the sentinel's `_deferred_timeout_ms`). Every other op keeps + ## request_game_command's tight default. + if op == "input_sequence": + _debugger_plugin.request_game_command( + op, command_params, request_id, _connection, INPUT_SEQUENCE_TIMEOUT_SEC + ) + return { + "_deferred": true, + "_deferred_timeout_ms": int(INPUT_SEQUENCE_TIMEOUT_SEC * 1000.0), + } + + _debugger_plugin.request_game_command(op, command_params, request_id, _connection) + return McpDispatcher.DEFERRED_RESPONSE diff --git a/addons/godot_ai/handlers/editor_handler.gd.uid b/addons/godot_ai/handlers/editor_handler.gd.uid new file mode 100644 index 0000000..16d785f --- /dev/null +++ b/addons/godot_ai/handlers/editor_handler.gd.uid @@ -0,0 +1 @@ +uid://dcro7yc8bor6v diff --git a/addons/godot_ai/handlers/environment_handler.gd b/addons/godot_ai/handlers/environment_handler.gd new file mode 100644 index 0000000..6729f1e --- /dev/null +++ b/addons/godot_ai/handlers/environment_handler.gd @@ -0,0 +1,181 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Creates an Environment (+ optional Sky + ProceduralSkyMaterial) chain and +## either assigns it to a WorldEnvironment node or saves it to a .tres file. +## Bundles sub-resource creation + assignment in a single undo action. + +const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd") + +var _undo_redo: EditorUndoRedoManager +var _connection: McpConnection + + +func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: + _undo_redo = undo_redo + _connection = connection + + +const _PRESETS := { + "default": {"sky": true, "fog": false}, + "clear": {"sky": true, "fog": false}, + "sunset": {"sky": true, "fog": false}, + "night": {"sky": true, "fog": false}, + "fog": {"sky": true, "fog": true}, +} + + +func create_environment(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + var resource_path: String = params.get("resource_path", "") + var overwrite: bool = params.get("overwrite", false) + var preset: String = params.get("preset", "default") + var properties: Dictionary = params.get("properties", {}) + var sky_param = params.get("sky", null) # nullable — falls back to preset default + + # environment_create targets the whole WorldEnvironment node (no separate + # `property` param) — pass require_property=false. + var home_err := McpResourceIO.validate_home(params, false) + if home_err != null: + return home_err + + if not _PRESETS.has(preset): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid preset '%s'. Valid: %s" % [preset, ", ".join(_PRESETS.keys())] + ) + + var preset_config: Dictionary = _PRESETS[preset] + var want_sky: bool = preset_config.sky + var sky_properties: Dictionary = {} + if sky_param != null: + if sky_param is bool: + want_sky = sky_param + elif sky_param is Dictionary: + var sky_config: Dictionary = (sky_param as Dictionary).duplicate() + var material_type: String = String(sky_config.get("sky_material", "procedural")).to_lower() + if material_type != "procedural": + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "sky.sky_material must be 'procedural' when sky is a dictionary" + ) + sky_config.erase("sky_material") + sky_properties = sky_config + want_sky = true + else: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "sky must be a bool, null, or dictionary of ProceduralSkyMaterial properties" + ) + + var env := Environment.new() + var sky: Sky = null + var sky_material: ProceduralSkyMaterial = null + if want_sky: + sky_material = ProceduralSkyMaterial.new() + sky = Sky.new() + sky.sky_material = sky_material + env.background_mode = Environment.BG_SKY + env.sky = sky + else: + env.background_mode = Environment.BG_CLEAR_COLOR + + _apply_preset(env, sky_material, preset) + if not sky_properties.is_empty(): + var sky_apply_err := ResourceHandler._apply_resource_properties(sky_material, sky_properties) + if sky_apply_err != null: + return sky_apply_err + if preset_config.fog: + env.volumetric_fog_enabled = true + env.volumetric_fog_density = 0.03 + + if not properties.is_empty(): + var apply_err := ResourceHandler._apply_resource_properties(env, properties) + if apply_err != null: + return apply_err + + if not resource_path.is_empty(): + return _save_environment(env, sky, sky_material, resource_path, overwrite, preset) + return _assign_environment(env, sky, sky_material, node_path, preset) + + +static func _apply_preset(env: Environment, sky_material: ProceduralSkyMaterial, preset: String) -> void: + match preset: + "default", "clear": + if sky_material != null: + sky_material.sky_top_color = Color(0.38, 0.45, 0.55) + sky_material.sky_horizon_color = Color(0.65, 0.67, 0.7) + sky_material.ground_horizon_color = Color(0.65, 0.67, 0.7) + sky_material.ground_bottom_color = Color(0.2, 0.17, 0.13) + sky_material.sun_angle_max = 30.0 + env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY + env.ambient_light_energy = 1.0 + "sunset": + if sky_material != null: + sky_material.sky_top_color = Color(0.25, 0.3, 0.55) + sky_material.sky_horizon_color = Color(1.0, 0.55, 0.3) + sky_material.ground_horizon_color = Color(0.85, 0.4, 0.25) + sky_material.ground_bottom_color = Color(0.2, 0.12, 0.1) + env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY + env.ambient_light_color = Color(1.0, 0.75, 0.55) + env.ambient_light_energy = 0.8 + "night": + if sky_material != null: + sky_material.sky_top_color = Color(0.02, 0.02, 0.07) + sky_material.sky_horizon_color = Color(0.05, 0.07, 0.15) + sky_material.ground_horizon_color = Color(0.04, 0.05, 0.1) + sky_material.ground_bottom_color = Color(0.0, 0.0, 0.02) + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.2, 0.22, 0.35) + env.ambient_light_energy = 0.4 + "fog": + if sky_material != null: + sky_material.sky_top_color = Color(0.65, 0.65, 0.7) + sky_material.sky_horizon_color = Color(0.8, 0.8, 0.82) + sky_material.ground_horizon_color = Color(0.7, 0.7, 0.72) + sky_material.ground_bottom_color = Color(0.3, 0.3, 0.32) + env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY + env.ambient_light_energy = 0.7 + + +func _assign_environment(env: Environment, sky: Sky, sky_material: ProceduralSkyMaterial, node_path: String, preset: String) -> Dictionary: + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var _scene_root: Node = _resolved.scene_root + if not (node is WorldEnvironment): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node at %s is %s — must be WorldEnvironment" % [node_path, node.get_class()] + ) + + var old_env = (node as WorldEnvironment).environment + + _undo_redo.create_action("MCP: Create Environment (%s) for %s" % [preset, node.name]) + _undo_redo.add_do_property(node, "environment", env) + _undo_redo.add_undo_property(node, "environment", old_env) + _undo_redo.add_do_reference(env) + if sky != null: + _undo_redo.add_do_reference(sky) + if sky_material != null: + _undo_redo.add_do_reference(sky_material) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "preset": preset, + "sky_created": sky != null, + "sky_material_class": sky_material.get_class() if sky_material != null else "", + "undoable": true, + } + } + + +func _save_environment(env: Environment, _sky: Sky, _sky_material: ProceduralSkyMaterial, resource_path: String, overwrite: bool, preset: String) -> Dictionary: + return McpResourceIO.save_to_disk(env, resource_path, overwrite, "Environment", { + "preset": preset, + }, _connection) diff --git a/addons/godot_ai/handlers/environment_handler.gd.uid b/addons/godot_ai/handlers/environment_handler.gd.uid new file mode 100644 index 0000000..f495f8f --- /dev/null +++ b/addons/godot_ai/handlers/environment_handler.gd.uid @@ -0,0 +1 @@ +uid://b1k7jldwjp5jt diff --git a/addons/godot_ai/handlers/filesystem_handler.gd b/addons/godot_ai/handlers/filesystem_handler.gd new file mode 100644 index 0000000..b17ccc9 --- /dev/null +++ b/addons/godot_ai/handlers/filesystem_handler.gd @@ -0,0 +1,312 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const ScriptHandler := preload("res://addons/godot_ai/handlers/script_handler.gd") + +## Handles file read/write operations and reimport within the Godot project. + +## Bounds for the deferred scan wait. `write_file`/`reimport` register single +## files with `update_file()` (cheap, no global-class rebuild); `scan_filesystem` +## is the heavier, explicit "rebuild the class registry" path agents call after +## adding `class_name` scripts headlessly (no window focus to trigger it). +## Kept under the dispatcher's "scan_filesystem" deferred timeout (30s) so we +## always send a real reply before a DEFERRED_TIMEOUT is synthesised. +const _SCAN_START_GRACE_MSEC := 750 +const _SCAN_SETTLE_MAX_MSEC := 28000 + +## Sidecar the editor writes next to every imported resource. `reimport` reads +## it to tell imported assets from files that merely have a filesystem entry +## (see `_is_imported_resource`). +const IMPORT_SIDECAR_SUFFIX := ".import" + +## Shared single-flight latch for scan_filesystem. `is_scanning()` alone can't +## enforce single-flight: `EditorFileSystem.scan()` doesn't flip `is_scanning()` +## for a frame or two (hence _SCAN_START_GRACE_MSEC), so a second request landing +## in that window would observe `false` and stack another scan() — the exact +## stacked-worker SIGABRT this op exists to avoid (dsarno/godot#6). The latch is +## set before the first scan() and cleared when its settle coroutine finishes; +## concurrent requests coalesce onto the running scan instead of starting one. +## `static` so it's shared across handler instances; it resets on plugin reload +## (script re-parse), which self-heals any latch orphaned by a mid-await teardown. +static var _scan_in_flight := false + +var _connection: McpConnection + + +func _init(connection: McpConnection = null) -> void: + _connection = connection + + +func read_file(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + + var path_err = McpPathValidator.path_error(path, "path") + if path_err != null: + return path_err + + if not FileAccess.file_exists(path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path) + + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path) + + var content := file.get_as_text() + file.close() + + return { + "data": { + "path": path, + "content": content, + "size": content.length(), + "line_count": content.count("\n") + (1 if not content.is_empty() else 0), + } + } + + +func write_file(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + var content: String = params.get("content", "") + + var path_err = McpPathValidator.path_error(path, "path", true) + if path_err != null: + return path_err + + var existed_before := FileAccess.file_exists(path) + + # Shared write path (#714): parent mkdir + write/flush + explicit error + # check live on McpResourceIO so this can't drift from create_script. + var write_failure: Variant = McpResourceIO.write_text_to_disk(path, content) + if write_failure != null: + return write_failure + + # Single-file register, not a full scan() — a scan() per write stacks + # filesystem WorkerThreadPool tasks under concurrent writes and can SIGABRT + # in the global-class update (see dsarno/godot#6 and create_script in + # script_handler.gd). update_file() is what reimport()/material/theme use. + var efs := EditorInterface.get_resource_filesystem() + if efs != null: + efs.update_file(path) + + var data := { + "path": path, + "size": content.length(), + "undoable": false, + "reason": "File system operations cannot be undone via editor undo", + } + var is_gdscript := path.ends_with(".gd") + ## A .gd written through the filesystem tool used to skip the parse + ## diagnostics create_script attaches (#714) — the agent's broken + ## script reported plain success and the parse error surfaced only in + ## later editor logs. Same shared check, same response fields. A bare + ## ScriptHandler works here: the diagnostics path touches no instance + ## state (it stays an instance method only for test stubbing). + if is_gdscript: + ScriptHandler.new(null)._attach_gdscript_diagnostics(data, path, content) + data["committed"] = true + data["import_settled"] = existed_before + data["import_settle"] = "already_known" if existed_before else "not_waited" + McpResourceIO.attach_cleanup_hint(data, existed_before, [path]) + + ## Fresh `.gd` writes take create_script's import-settle deferral (#714, + ## #261): reply only once ResourceLoader can see the new resource (or the + ## bounded window elapses), so write_file -> script_attach back-to-back + ## can't 404 on the not-yet-imported script. This CHANGES write_file's + ## response timing for that case — the reply lands up to + ## McpResourceIO.IMPORT_SETTLE_MAX_MSEC later instead of immediately. + ## Scoped to .gd: ResourceLoader never learns plain text files, so an + ## unconditional wait would burn the full window on every fresh .txt. + ## Overwrites, batch_execute (no request_id) and unit-test contexts (no + ## connection) keep the synchronous reply. + var request_id: String = params.get("_request_id", "") + if is_gdscript and not existed_before and _connection != null and not request_id.is_empty(): + McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data) + return McpDispatcher.DEFERRED_RESPONSE + + return {"data": data} + + +func reimport(params: Dictionary) -> Dictionary: + var paths: Array = params.get("paths", []) + + if paths.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: paths (non-empty array)") + + var efs := EditorInterface.get_resource_filesystem() + if efs == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "EditorFileSystem not available", false) + + var reimported: Array[String] = [] + var skipped_non_imported: Array[String] = [] + var not_found: Array[String] = [] + + for path_variant in paths: + var path: String = str(path_variant) + var path_err := McpPathValidator.validate_resource_path(path) + if not path_err.is_empty(): + not_found.append("%s (%s)" % [path, path_err]) + continue + if not FileAccess.file_exists(path): + not_found.append("%s (file does not exist)" % path) + continue + efs.update_file(path) + if _is_imported_resource(path): + reimported.append(path) + else: + skipped_non_imported.append(path) + + var data := { + "reimported": reimported, + "skipped_non_imported": skipped_non_imported, + "not_found": not_found, + "reimported_count": reimported.size(), + "skipped_non_imported_count": skipped_non_imported.size(), + "not_found_count": not_found.size(), + "undoable": false, + "reason": "Reimport is a file system operation", + } + ## Only when it applies: a hint on every call would cost tokens on the + ## all-assets path this op is actually for. + if not skipped_non_imported.is_empty(): + data["skipped_non_imported_hint"] = ( + "%d path(s) are not imported resources. Their editor filesystem entry was " + + "refreshed, but no import ran — a success here is not evidence that a " + + "script parsed or that diagnostics were produced. Use script_patch/" + + "script_create for GDScript diagnostics, or filesystem_manage(op=\"scan\") " + + "for an asset the editor has not imported yet." + ) % skipped_non_imported.size() + return {"data": data} + + +## #778: `update_file()` registers a path with the resource pipeline; it only +## runs an *import* for files that have one. Scripts, scenes and hand-written +## `.tres` are not imported resources, so listing them under `reimported` reads +## as proof that a parse or import ran when nothing did. +## +## The `.import` sidecar is the editor's own record that a path goes through +## the import pipeline, so it decides the split. An extension allow-list was +## rejected: importers come and go with plugins, so the list would drift out of +## agreement with the editor it claims to describe. +## +## Known edge: an asset the editor has never imported (just written, no scan +## yet) has no sidecar and reports as non-imported. That is accurate at the +## moment of the call — `update_file()` did not import it either — and the +## hint names `scan` as the way through. +## +## Behaviour is unchanged for every path: `update_file()` still runs on all of +## them, because refreshing an externally-edited `.tscn`/`.tres` is a real use +## of this op. This splits the report, not the work. +static func _is_imported_resource(path: String) -> bool: + if path.ends_with(IMPORT_SIDECAR_SUFFIX): + return false ## The sidecar itself is not an imported resource. + return FileAccess.file_exists(path + IMPORT_SIDECAR_SUFFIX) + + +## Force a full EditorFileSystem scan and wait for it to settle. This is the +## headless equivalent of the editor regaining window focus: `update_file()` +## (used by write_file/reimport/script_create) registers a single file with the +## resource pipeline but does NOT rebuild the global `class_name` table, so a +## freshly-created `class_name MyThing extends Resource` stays invisible to +## `ClassDB`/`ProjectSettings.get_global_class_list()` until a scan runs. Agents +## driving the editor without focus call this once after a batch of script +## creates to make new types instantiable/referenceable. See issue #83. +func scan_filesystem(params: Dictionary) -> Dictionary: + var efs := EditorInterface.get_resource_filesystem() + if efs == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "EditorFileSystem not available", false) + + var request_id: String = params.get("_request_id", "") + # Async path: a scan can't be awaited on the calling frame without freezing + # the editor, so hand control back to the dispatcher (DEFERRED_RESPONSE) and + # push the real reply from a static coroutine once the scan settles — by + # which point new class_names are registered. + if _connection != null and not request_id.is_empty(): + _finish_scan_deferred(_connection, request_id, efs) + return McpDispatcher.DEFERRED_RESPONSE + + # Synchronous fallback: batch_execute (no request_id) and unit-test contexts + # (no connection) can't await, so kick a single-flight scan and return + # immediately without the settle confirmation. Respect the latch so we don't + # stack onto a deferred scan; don't set it (there's no coroutine here to + # clear it — the brief is_scanning() window covers the rest). + var already := _scan_in_flight or efs.is_scanning() + if not already: + efs.scan() + return { + "data": { + "scan_completed": false, + "scan_settle": "not_waited", + "was_already_scanning": already, + "global_class_count": ProjectSettings.get_global_class_list().size(), + # Present in both paths for a consistent response shape; the sync + # path doesn't await, so it can't measure a delta. + "global_classes_registered_delta": 0, + "undoable": false, + "reason": "Filesystem scan is an editor operation", + } + } + + +## `static` is load-bearing for the same reason as ScriptHandler's deferred +## finish: the coroutine must outlive the handler RefCounted, which can be freed +## mid-await (e.g. an editor_reload_plugin fired during the scan). Parameterise +## everything; reference no instance state. +static func _finish_scan_deferred( + connection: McpConnection, + request_id: String, + efs: EditorFileSystem, +) -> void: + if not is_instance_valid(connection): + return + var tree := connection.get_tree() + if tree == null: + return + var classes_before := ProjectSettings.get_global_class_list().size() + # Single-flight via the shared `_scan_in_flight` latch (NOT is_scanning(), + # which lags scan() by a frame or two — see the latch declaration). Only the + # request that sets the latch calls scan(); concurrent requests coalesce and + # just await the running scan. This is what actually prevents the stacked + # scan() SIGABRT (dsarno/godot#6), even within the start-grace window. + var was_already_scanning := _scan_in_flight or efs.is_scanning() + var we_started := not was_already_scanning + if we_started: + _scan_in_flight = true + efs.scan() + # Hand back a frame so _dispatch() registers this request as deferred before + # the coroutine can push a reply (mirrors McpResourceIO.finish_text_write_deferred). + await tree.process_frame + var deadline_ms := Time.get_ticks_msec() + _SCAN_SETTLE_MAX_MSEC + var start_grace_ms := Time.get_ticks_msec() + _SCAN_START_GRACE_MSEC + var saw_scanning := efs.is_scanning() + while Time.get_ticks_msec() < deadline_ms: + if efs.is_scanning(): + saw_scanning = true + elif saw_scanning or Time.get_ticks_msec() > start_grace_ms: + # Either the scan ran and finished, or it never flipped is_scanning() + # within the grace window (a no-op scan because nothing changed). + break + await tree.process_frame + # Clear the latch in all paths (no try/finally in GDScript): do it before the + # is_instance_valid early-return so a freed connection can't orphan it. + if we_started: + _scan_in_flight = false + if not is_instance_valid(connection): + return + var completed := not efs.is_scanning() + var classes_after := ProjectSettings.get_global_class_list().size() + connection.send_deferred_response(request_id, { + "data": { + "scan_completed": completed, + "scan_settle": "settled" if completed else "timeout", + "was_already_scanning": was_already_scanning, + "global_class_count": classes_after, + "global_classes_registered_delta": classes_after - classes_before, + "undoable": false, + "reason": "Filesystem scan is an editor operation", + } + }) diff --git a/addons/godot_ai/handlers/filesystem_handler.gd.uid b/addons/godot_ai/handlers/filesystem_handler.gd.uid new file mode 100644 index 0000000..94135a4 --- /dev/null +++ b/addons/godot_ai/handlers/filesystem_handler.gd.uid @@ -0,0 +1 @@ +uid://c7ovtpdiumtju diff --git a/addons/godot_ai/handlers/gridmap_handler.gd b/addons/godot_ai/handlers/gridmap_handler.gd new file mode 100644 index 0000000..75016e4 --- /dev/null +++ b/addons/godot_ai/handlers/gridmap_handler.gd @@ -0,0 +1,192 @@ +@tool +extends RefCounted + +## GridMap authoring — set, fill, clear, and read 3D cells plus mesh-library +## items directly in the editor scene with full undo/redo support. +## +## All ops target GridMap nodes in the currently edited scene by +## scene-relative path (e.g. "/Main/Terrain"). All write ops are undoable +## via EditorUndoRedoManager. + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const MAX_FILL_CELLS := 4096 + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +## Set a single cell item. item = -1 erases the cell. +## params: {path, item, map_x, map_y, map_z, orientation=0} +## Returns: {map_x, map_y, map_z, item, orientation, undoable} +func set_item(params: Dictionary) -> Dictionary: + var gm := _resolve_gridmap(params) + if gm.has("error"): return gm + var node: GridMap = gm.node + var pos := Vector3i(int(params.get("map_x", 0)), int(params.get("map_y", 0)), int(params.get("map_z", 0))) + var item := int(params.get("item", 0)) + var orientation := int(params.get("orientation", 0)) + if orientation < 0 or orientation > 24: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "orientation must be in 0..24 (GridMap baked rotations), got %d" % orientation) + var prev := _capture_cell_state(node, pos) + _undo_redo.create_action("MCP: GridMap set_item") + _undo_redo.add_do_method(node, "set_cell_item", pos, item, orientation) + _undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev) + _undo_redo.commit_action() + return {"data": {"map_x": pos.x, "map_y": pos.y, "map_z": pos.z, + "item": item, "orientation": orientation, "undoable": true}} + + +## Fill a box region with one item in a single undo action. +## params: {path, item, rect_x, rect_y, rect_z, rect_w, rect_h, rect_d, orientation=0} +## Returns: {cells_filled, rect: {x, y, z, w, h, d}} +func fill(params: Dictionary) -> Dictionary: + var gm := _resolve_gridmap(params) + if gm.has("error"): return gm + var node: GridMap = gm.node + var item := int(params.get("item", 0)) + var orientation := int(params.get("orientation", 0)) + if orientation < 0 or orientation > 24: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "orientation must be in 0..24 (GridMap baked rotations), got %d" % orientation) + var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0)); var rz := int(params.get("rect_z", 0)) + var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1)); var rd := int(params.get("rect_d", 1)) + if rw <= 0 or rh <= 0 or rd <= 0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "rect_w, rect_h and rect_d must be > 0 (got %d x %d x %d)" % [rw, rh, rd]) + var cell_count := rw * rh * rd + if cell_count > MAX_FILL_CELLS: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Region too large: %d cells exceeds max %d" % [cell_count, MAX_FILL_CELLS]) + var snapshot: Array[Dictionary] = [] + for x in range(rx, rx + rw): + for y in range(ry, ry + rh): + for z in range(rz, rz + rd): + var pos := Vector3i(x, y, z) + snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)}) + _undo_redo.create_action("MCP: GridMap fill %dx%dx%d" % [rw, rh, rd]) + ## First callback targets the node so the action lands in the scene undo + ## history (first-target routing); _apply_fill batches the rest of the + ## region into a single history entry instead of one per cell. + _undo_redo.add_do_method(node, "set_cell_item", snapshot[0].pos, item, orientation) + _undo_redo.add_do_method(self, "_apply_fill", node, snapshot, item, orientation) + _undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot) + _undo_redo.commit_action() + return {"data": {"cells_filled": snapshot.size(), + "rect": {"x": rx, "y": ry, "z": rz, "w": rw, "h": rh, "d": rd}, "undoable": true}} + + +## Remove all cells from the GridMap. +## params: {path} +## Returns: {cleared: true} +func clear_layer(params: Dictionary) -> Dictionary: + var gm := _resolve_gridmap(params) + if gm.has("error"): return gm + var node: GridMap = gm.node + var used := node.get_used_cells() + if used.size() > MAX_FILL_CELLS: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "GridMap has %d cells, exceeds max %d for undoable clear" + % [used.size(), MAX_FILL_CELLS]) + var snapshot := _capture_used_cells_snapshot(node) + _undo_redo.create_action("MCP: GridMap clear") + _undo_redo.add_do_method(node, "clear") + _undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot) + _undo_redo.commit_action() + return {"data": {"cleared": true, "undoable": true}} + + +## Return all used cell coordinates. +## params: {path} +## Returns: {cells: [{x, y, z}, ...], count: int} +func get_used_cells(params: Dictionary) -> Dictionary: + var gm := _resolve_gridmap(params) + if gm.has("error"): return gm + var node: GridMap = gm.node + var result: Array = [] + for c in node.get_used_cells(): + result.append({"x": c.x, "y": c.y, "z": c.z}) + return {"data": {"cells": result, "count": result.size()}} + + +## List the items available in the GridMap's MeshLibrary, so agents can +## discover item ids and names before placing cells (the 3D analogue of +## tileset atlas inspection). +## params: {path} +## Returns: {library: res:// path or "", items: [{item, name, mesh}...], count} +func list_library_items(params: Dictionary) -> Dictionary: + var gm := _resolve_gridmap(params) + if gm.has("error"): return gm + var node: GridMap = gm.node + var library: MeshLibrary = node.mesh_library + if library == null: + return {"data": {"library": "", "items": [], "count": 0}} + var items: Array = [] + var ids := library.get_item_list() + ids.sort() + for item in ids: + var mesh: Mesh = library.get_item_mesh(item) + var mesh_path := mesh.resource_path if mesh != null else "" + items.append({ + "item": item, + "name": library.get_item_name(item), + "mesh": mesh_path, + }) + return {"data": {"library": library.resource_path, "items": items, "count": items.size()}} + + +## Resolve a GridMap node from params["path"] in the currently edited +## scene. Returns {"node": GridMap} on success, or an error dict. +func _resolve_gridmap(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + var scene_file: String = params.get("scene_file", "") + var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + if not node is GridMap: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, + "Node is not a GridMap: %s" % path) + return {"node": node} + + +func _capture_cell_state(node: GridMap, pos: Vector3i) -> Dictionary: + var item := node.get_cell_item(pos) + if item == -1: + return {"has_item": false} + return {"has_item": true, "item": item, "orientation": node.get_cell_item_orientation(pos)} + + +func _capture_used_cells_snapshot(node: GridMap) -> Array[Dictionary]: + var snapshot: Array[Dictionary] = [] + for pos in node.get_used_cells(): + snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)}) + return snapshot + + +func _restore_cells_snapshot(node: GridMap, snapshot: Array[Dictionary]) -> void: + node.clear() + for entry in snapshot: + _restore_cell_state(node, entry.pos, entry.state) + + +## Batched do-method for fill: one undo-history entry applies the whole +## region instead of one entry per cell. +func _apply_fill(node: GridMap, snapshot: Array[Dictionary], item: int, orientation: int) -> void: + for entry in snapshot: + node.set_cell_item(entry.pos, item, orientation) + + +func _restore_rect_snapshot(node: GridMap, snapshot: Array[Dictionary]) -> void: + for entry in snapshot: + _restore_cell_state(node, entry.pos, entry.state) + + +func _restore_cell_state(node: GridMap, pos: Vector3i, state: Dictionary) -> void: + if not state.get("has_item", false): + node.set_cell_item(pos, -1) + return + node.set_cell_item(pos, int(state.get("item", 0)), int(state.get("orientation", 0))) diff --git a/addons/godot_ai/handlers/gridmap_handler.gd.uid b/addons/godot_ai/handlers/gridmap_handler.gd.uid new file mode 100644 index 0000000..35bb1d0 --- /dev/null +++ b/addons/godot_ai/handlers/gridmap_handler.gd.uid @@ -0,0 +1 @@ +uid://cdv1yrjeyo5es diff --git a/addons/godot_ai/handlers/input_handler.gd b/addons/godot_ai/handlers/input_handler.gd new file mode 100644 index 0000000..78ddd22 --- /dev/null +++ b/addons/godot_ai/handlers/input_handler.gd @@ -0,0 +1,462 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles input action listing, creation, removal, and event binding. +## Actions are persisted via ProjectSettings so they survive editor restarts. + + +func list_actions(params: Dictionary) -> Dictionary: + var include_builtin: bool = params.get("include_builtin", false) + ## Authoritative source for user-authored actions is the ``[input]`` + ## section of ``project.godot``. ``ProjectSettings.has_setting`` is not + ## reliable here because Godot registers ``ui_*`` defaults via + ## ``GLOBAL_DEF_BASIC``, which makes ``has_setting`` return true for + ## them. Reading the file via ``ConfigFile`` distinguishes the user's + ## entries from engine-registered defaults regardless of namespace. + ## See #213. + var user_authored := _read_user_authored_actions() + var actions: Array[Dictionary] = [] + var seen := {} + for action_name in InputMap.get_actions(): + var name_str := str(action_name) + var is_user_action := user_authored.has(name_str) + if not include_builtin and not is_user_action: + continue + seen[name_str] = true + var events: Array[Dictionary] = [] + for event in InputMap.action_get_events(action_name): + events.append(_serialize_event(event)) + actions.append({ + "name": name_str, + "events": events, + "event_count": events.size(), + "is_builtin": not is_user_action, + "loaded_in_input_map": true, + }) + for action_name in user_authored.keys(): + var name_str := str(action_name) + if seen.has(name_str): + continue + var setting: Dictionary = user_authored.get(name_str, {}) + var events: Array[Dictionary] = [] + for event in setting.get("events", []): + if event is InputEvent: + events.append(_serialize_event(event)) + else: + events.append({"type": type_string(typeof(event)), "string": str(event)}) + actions.append({ + "name": name_str, + "events": events, + "event_count": events.size(), + "is_builtin": false, + "loaded_in_input_map": false, + }) + return {"data": {"actions": actions, "count": actions.size()}} + + +func _read_user_authored_actions() -> Dictionary: + var cfg := ConfigFile.new() + if cfg.load("res://project.godot") != OK: + return {} + if not cfg.has_section("input"): + return {} + var result: Dictionary = {} + for key in cfg.get_section_keys("input"): + var value = cfg.get_value("input", key, {}) + result[key] = value if value is Dictionary else {} + return result + + +func add_action(params: Dictionary) -> Dictionary: + var action: String = params.get("action", "") + var deadzone: float = params.get("deadzone", 0.5) + + if action.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action") + + var deadzone_error := _validate_deadzone(deadzone) + if deadzone_error.has("error"): + return deadzone_error + + if InputMap.has_action(action): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Action '%s' already exists" % action) + + InputMap.add_action(action, deadzone) + + var key := "input/%s" % action + ProjectSettings.set_setting(key, { + "deadzone": deadzone, + "events": [], + }) + var err := ProjectSettings.save() + if err != OK: + InputMap.erase_action(action) + ProjectSettings.clear(key) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Failed to save project settings while adding action '%s': %s (error %d)" % [action, error_string(err), err]) + + return { + "data": { + "action": action, + "deadzone": deadzone, + "undoable": false, + "reason": "Input actions are saved to project.godot", + } + } + + +func ensure_action(params: Dictionary) -> Dictionary: + var action: String = params.get("action", "") + var deadzone: float = params.get("deadzone", 0.5) + + if action.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action") + + var deadzone_error := _validate_deadzone(deadzone) + if deadzone_error.has("error"): + return deadzone_error + + var result := _ensure_action_state(action, deadzone) + if result.has("error"): + return result + return {"data": result} + + +func remove_action(params: Dictionary) -> Dictionary: + var action: String = params.get("action", "") + if action.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action") + + var key := "input/%s" % action + var was_loaded := InputMap.has_action(action) + var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null + + ## An action can live in the editor process's InputMap, in project.godot, + ## or both. Actions persisted by a previous editor session exist only on + ## disk (`loaded_in_input_map: false` in list_actions) — those must still + ## be removable. #632 + if not was_loaded and old_setting == null: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Action '%s' not found" % action) + + if was_loaded: + InputMap.erase_action(action) + + if old_setting != null: + ProjectSettings.clear(key) + var err := ProjectSettings.save() + if err != OK: + if was_loaded: + var dz: float = old_setting.get("deadzone", 0.5) if old_setting is Dictionary else 0.5 + InputMap.add_action(action, dz) + if old_setting is Dictionary: + for ev in old_setting.get("events", []): + if ev is InputEvent: + InputMap.action_add_event(action, ev) + ProjectSettings.set_setting(key, old_setting) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Failed to save project settings while removing action '%s': %s (error %d)" % [action, error_string(err), err]) + + return { + "data": { + "action": action, + "removed": true, + "was_loaded": was_loaded, + "undoable": false, + "reason": "Input actions are saved to project.godot", + } + } + + +func bind_event(params: Dictionary) -> Dictionary: + var action: String = params.get("action", "") + var event_type: String = params.get("event_type", "") + + if action.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action") + if event_type.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type") + + if not InputMap.has_action(action): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Action '%s' not found. Call input_map_manage(op='add_action', params={action: '%s'}) first." % [action, action]) + + var event_or_error = _create_event(event_type, params) + if event_or_error is Dictionary: + return event_or_error + var event: InputEvent = event_or_error + + InputMap.action_add_event(action, event) + + var err := _save_action_events(action) + if err != OK: + InputMap.action_erase_event(action, event) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err]) + + return { + "data": { + "action": action, + "event": _serialize_event(event), + "undoable": false, + "reason": "Input bindings are saved to project.godot", + } + } + + +func ensure_binding(params: Dictionary) -> Dictionary: + var action: String = params.get("action", "") + var event_type: String = params.get("event_type", "") + var deadzone: float = params.get("deadzone", 0.5) + + if action.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action") + if event_type.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type") + + var deadzone_error := _validate_deadzone(deadzone) + if deadzone_error.has("error"): + return deadzone_error + + var event_or_error = _create_event(event_type, params) + if event_or_error is Dictionary: + return event_or_error + var event: InputEvent = event_or_error + + var ensured := _ensure_action_state(action, deadzone) + if ensured.has("error"): + return ensured + + for existing in InputMap.action_get_events(action): + if _events_match(existing, event): + return { + "data": { + "action": action, + "event": _serialize_event(existing), + "already_bound": true, + "action_created": ensured.get("created", false), + "undoable": false, + "reason": "Input binding already exists", + } + } + + InputMap.action_add_event(action, event) + var err := _save_action_events(action) + if err != OK: + InputMap.action_erase_event(action, event) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err]) + + return { + "data": { + "action": action, + "event": _serialize_event(event), + "already_bound": false, + "action_created": ensured.get("created", false), + "undoable": false, + "reason": "Input bindings are saved to project.godot", + } + } + + +func _validate_deadzone(deadzone: float) -> Dictionary: + if deadzone < 0.0 or deadzone > 1.0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone) + return {} + + +func _ensure_action_state(action: String, deadzone: float) -> Dictionary: + var key := "input/%s" % action + var user_authored := _read_user_authored_actions() + var existed_in_input_map := InputMap.has_action(action) + var existed_in_project := user_authored.has(action) or ProjectSettings.has_setting(key) + var old_setting = user_authored.get(action, null) if user_authored.has(action) else null + if old_setting == null and ProjectSettings.has_setting(key): + old_setting = ProjectSettings.get_setting(key) + + if not existed_in_input_map: + var dz := deadzone + if old_setting is Dictionary: + dz = float(old_setting.get("deadzone", deadzone)) + InputMap.add_action(action, dz) + if old_setting is Dictionary: + for ev in old_setting.get("events", []): + if ev is InputEvent: + InputMap.action_add_event(action, ev) + + if not existed_in_project: + var err := _save_action_events(action) + if err != OK: + if not existed_in_input_map: + InputMap.erase_action(action) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Failed to save project settings while ensuring action '%s': %s (error %d)" % [action, error_string(err), err]) + + var stored_deadzone := deadzone + if ProjectSettings.has_setting(key): + var stored = ProjectSettings.get_setting(key) + if stored is Dictionary: + stored_deadzone = float(stored.get("deadzone", deadzone)) + return { + "action": action, + "deadzone": stored_deadzone, + "created": not existed_in_input_map and not existed_in_project, + "already_exists": existed_in_input_map or existed_in_project, + "loaded_in_input_map": true, + "persisted": true, + "undoable": false, + "reason": "Input actions are saved to project.godot", + } + + +## Returns an InputEvent on success, or a Dictionary error on failure. +## Caller must check ``result is Dictionary`` before treating it as an event. +func _create_event(event_type: String, params: Dictionary): + match event_type: + "key": + var ev := InputEventKey.new() + var keycode_str: String = params.get("keycode", "") + if keycode_str.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, + "event_type='key' requires keycode (e.g. 'Space', 'A', 'Enter', 'Escape', 'F1').") + ev.keycode = OS.find_keycode_from_string(keycode_str) + if ev.keycode == KEY_NONE: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid keycode '%s'. Use Godot keycode names like 'A', 'Space', 'Enter', 'Escape', 'F1', 'Left', 'Right'." % keycode_str) + ev.ctrl_pressed = params.get("ctrl", false) + ev.alt_pressed = params.get("alt", false) + ev.shift_pressed = params.get("shift", false) + ev.meta_pressed = params.get("meta", false) + ev.device = -1 + return ev + "mouse_button": + if not params.has("button"): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, + "event_type='mouse_button' requires button (1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down).") + var button: int = int(params.get("button", 0)) + if button <= 0: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "mouse_button button must be > 0 (got %d). Use 1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down." % button) + var ev := InputEventMouseButton.new() + ev.button_index = button + ev.device = -1 + return ev + "joy_button": + if not params.has("button"): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, + "event_type='joy_button' requires button (JoyButton index, e.g. 0=A/Cross, 1=B/Circle).") + var ev := InputEventJoypadButton.new() + ev.button_index = int(params.get("button", 0)) + return ev + "joy_axis": + var axis_param = params.get("axis", null) + if axis_param == null: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, + "event_type='joy_axis' requires axis (JoyAxis index, e.g. 0=left stick X, 1=left stick Y).") + var axis: int + match typeof(axis_param): + TYPE_INT: + axis = axis_param + TYPE_FLOAT: + if axis_param != floor(axis_param): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "joy_axis axis must be an integer JoyAxis index (got %s)." % str(axis_param)) + axis = int(axis_param) + TYPE_STRING: + var axis_text := str(axis_param) + if not axis_text.is_valid_int(): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, + "joy_axis axis must be an integer JoyAxis index (got '%s')." % axis_text) + axis = int(axis_text) + _: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, + "joy_axis axis must be an integer JoyAxis index (got %s)." % type_string(typeof(axis_param))) + var ev := InputEventJoypadMotion.new() + ev.axis = axis + ev.axis_value = float(params.get("axis_value", 1.0)) + return ev + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Unsupported event_type: '%s'. Use 'key', 'mouse_button', 'joy_button', or 'joy_axis'." % event_type) + + +func _serialize_event(event: InputEvent) -> Dictionary: + if event is InputEventKey: + return { + "type": "key", + "keycode": OS.get_keycode_string(event.keycode), + "physical_keycode": OS.get_keycode_string(event.physical_keycode), + "ctrl": event.ctrl_pressed, + "alt": event.alt_pressed, + "shift": event.shift_pressed, + "meta": event.meta_pressed, + } + if event is InputEventMouseButton: + return { + "type": "mouse_button", + "button": event.button_index, + } + if event is InputEventJoypadButton: + return { + "type": "joy_button", + "button": event.button_index, + } + if event is InputEventJoypadMotion: + return { + "type": "joy_axis", + "axis": event.axis, + "axis_value": event.axis_value, + } + return {"type": event.get_class(), "string": str(event)} + + +func _events_match(a: InputEvent, b: InputEvent) -> bool: + if a is InputEventKey and b is InputEventKey: + return _key_events_match(a as InputEventKey, b as InputEventKey) + return _serialize_event(a) == _serialize_event(b) + + +func _key_events_match(a: InputEventKey, b: InputEventKey) -> bool: + if a.ctrl_pressed != b.ctrl_pressed: + return false + if a.alt_pressed != b.alt_pressed: + return false + if a.shift_pressed != b.shift_pressed: + return false + if a.meta_pressed != b.meta_pressed: + return false + var a_codes := [a.keycode, a.physical_keycode] + var b_codes := [b.keycode, b.physical_keycode] + for a_code in a_codes: + if int(a_code) == KEY_NONE: + continue + for b_code in b_codes: + if int(b_code) != KEY_NONE and int(a_code) == int(b_code): + return true + return false + + +func _save_action_events(action: String) -> int: + var events: Array = [] + for event in InputMap.action_get_events(action): + events.append(event) + var key := "input/%s" % action + var had_setting := ProjectSettings.has_setting(key) + var old_setting = ProjectSettings.get_setting(key) if had_setting else null + var deadzone: float = 0.5 + if old_setting is Dictionary: + deadzone = old_setting.get("deadzone", 0.5) + elif InputMap.has_action(action): + deadzone = InputMap.action_get_deadzone(action) + ProjectSettings.set_setting(key, { + "deadzone": deadzone, + "events": events, + }) + var err := ProjectSettings.save() + if err != OK: + if had_setting: + ProjectSettings.set_setting(key, old_setting) + else: + ProjectSettings.clear(key) + return err diff --git a/addons/godot_ai/handlers/input_handler.gd.uid b/addons/godot_ai/handlers/input_handler.gd.uid new file mode 100644 index 0000000..7d509f4 --- /dev/null +++ b/addons/godot_ai/handlers/input_handler.gd.uid @@ -0,0 +1 @@ +uid://buk68rbwssqwp diff --git a/addons/godot_ai/handlers/material_handler.gd b/addons/godot_ai/handlers/material_handler.gd new file mode 100644 index 0000000..d8a4981 --- /dev/null +++ b/addons/godot_ai/handlers/material_handler.gd @@ -0,0 +1,809 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles Material authoring: creating .tres files, setting BaseMaterial3D +## properties / shader uniforms, assigning to nodes, high-level presets. +## +## File-resource lifecycle mirrors ThemeHandler (create/load/mutate/save). +## Undo pattern mirrors AnimationHandler (single create_action bundles +## every dependency spawn). + +const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd") +const MaterialPresets := preload("res://addons/godot_ai/handlers/material_presets.gd") + +const _TYPE_TO_CLASS := { + "standard": "StandardMaterial3D", + "orm": "ORMMaterial3D", + "canvas_item": "CanvasItemMaterial", + "shader": "ShaderMaterial", +} + +const _SUPPORTED_SUFFIXES := [".tres", ".material", ".res"] + + +var _undo_redo: EditorUndoRedoManager +var _connection: McpConnection + + +func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: + _undo_redo = undo_redo + _connection = connection + + +# ============================================================================ +# material_create +# ============================================================================ + +func create_material(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + var type_str: String = params.get("type", "standard") + var shader_path: String = params.get("shader_path", "") + var overwrite: bool = params.get("overwrite", false) + + var err := _validate_material_path(path, "path", true) + if err != null: + return err + + if not _TYPE_TO_CLASS.has(type_str): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid material type '%s'. Valid: %s" % [type_str, ", ".join(_TYPE_TO_CLASS.keys())] + ) + + var existed_before := FileAccess.file_exists(path) + if existed_before and not overwrite: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Material already exists at %s (pass overwrite=true to replace)" % path + ) + + var mat := _instantiate_material(type_str) + if mat == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate material") + + if type_str == "shader": + if shader_path.is_empty(): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "ShaderMaterial requires shader_path (res:// / uid:// / user:// path to a .gdshader)" + ) + var shader_path_err = McpPathValidator.loadable_error(shader_path, "shader_path") + if shader_path_err != null: + return shader_path_err + if not ResourceLoader.exists(shader_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Shader not found: %s" % shader_path) + var shader_res := ResourceLoader.load(shader_path) + if not (shader_res is Shader): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Shader" % shader_path) + (mat as ShaderMaterial).shader = shader_res + + var dir_path := path.get_base_dir() + var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path) + if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to create directory: %s (error %d)" % [dir_path, mkdir_err] + ) + + var save_err := McpResourceIO.guarded_save(mat, path, _connection) + if save_err != OK: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to save material to %s (error %d)" % [path, save_err] + ) + + var efs := EditorInterface.get_resource_filesystem() + if efs != null: + efs.update_file(path) + + return { + "data": { + "path": path, + "type": type_str, + "class": mat.get_class(), + "shader_path": shader_path, + "overwritten": existed_before, + "undoable": false, + "reason": "File creation is persistent; delete the file manually to revert", + } + } + + +# ============================================================================ +# material_set_param +# ============================================================================ + +func set_param(params: Dictionary) -> Dictionary: + var load_result := _load_material_from_path(params.get("path", ""), true) + if load_result.has("error"): + return load_result + var mat: Material = load_result.material + var mat_path: String = load_result.path + + var property: String = params.get("param", "") + if property.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: param") + + if not ("value" in params): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") + + var raw_value = params.get("value") + + # Probe the property. We allow any property present in get_property_list, + # plus `shader` on ShaderMaterial. + var prop_type: int = TYPE_NIL + var property_exists := false + for prop in mat.get_property_list(): + if prop.name == property: + property_exists = true + prop_type = prop.get("type", TYPE_NIL) + break + if not property_exists: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + McpPropertyErrors.build_message(mat, property) + ) + + var coerced := MaterialValues.coerce_material_value(property, raw_value, prop_type) + if not coerced.ok: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error)) + var new_value = coerced.value + + var old_value = mat.get(property) + + _undo_redo.create_action("MCP: Set material %s.%s" % [mat_path.get_file(), property]) + _undo_redo.add_do_method(self, "_apply_param", mat_path, property, new_value, false) + _undo_redo.add_undo_method(self, "_apply_param", mat_path, property, old_value, false) + _undo_redo.commit_action() + + return { + "data": { + "path": mat_path, + "property": property, + "value": MaterialValues.serialize_value(new_value), + "previous_value": MaterialValues.serialize_value(old_value), + "undoable": true, + } + } + + +# ============================================================================ +# material_set_shader_param +# ============================================================================ + +func set_shader_param(params: Dictionary) -> Dictionary: + var load_result := _load_material_from_path(params.get("path", ""), true) + if load_result.has("error"): + return load_result + var mat: Material = load_result.material + var mat_path: String = load_result.path + + if not (mat is ShaderMaterial): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Material at %s is %s, not ShaderMaterial" % [mat_path, mat.get_class()] + ) + var shader_mat := mat as ShaderMaterial + if shader_mat.shader == null: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "ShaderMaterial at %s has no shader assigned" % mat_path + ) + + var param_name: String = params.get("param", "") + if param_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: param") + + if not ("value" in params): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") + + # Verify the uniform exists in the shader. + var uniform_type := _shader_uniform_type(shader_mat.shader, param_name) + if uniform_type == TYPE_NIL: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Shader uniform '%s' not declared on shader at %s" % [param_name, shader_mat.shader.resource_path] + ) + + var raw_value = params.get("value") + var coerced := MaterialValues.coerce_material_value(param_name, raw_value, uniform_type) + if not coerced.ok: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error)) + var new_value = coerced.value + + var old_value = shader_mat.get_shader_parameter(param_name) + + _undo_redo.create_action("MCP: Set shader param %s.%s" % [mat_path.get_file(), param_name]) + _undo_redo.add_do_method(self, "_apply_shader_param", mat_path, param_name, new_value) + _undo_redo.add_undo_method(self, "_apply_shader_param", mat_path, param_name, old_value) + _undo_redo.commit_action() + + return { + "data": { + "path": mat_path, + "param": param_name, + "value": MaterialValues.serialize_value(new_value), + "previous_value": MaterialValues.serialize_value(old_value), + "undoable": true, + } + } + + +# ============================================================================ +# material_get +# ============================================================================ + +func get_material(params: Dictionary) -> Dictionary: + var load_result := _load_material_from_path(params.get("path", "")) + if load_result.has("error"): + return load_result + var mat: Material = load_result.material + var mat_path: String = load_result.path + + var properties: Array[Dictionary] = [] + for prop in mat.get_property_list(): + var usage: int = prop.get("usage", 0) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + var name: String = prop.name + if name.begins_with("shader_parameter/"): + continue # handled below + var value = mat.get(name) + if value == null and prop.type != TYPE_NIL: + continue + properties.append({ + "name": name, + "type": type_string(prop.type), + "value": MaterialValues.serialize_value(value), + }) + + var shader_params: Array[Dictionary] = [] + if mat is ShaderMaterial: + var shader_mat := mat as ShaderMaterial + if shader_mat.shader != null: + for u in shader_mat.shader.get_shader_uniform_list(): + var u_name: String = u.get("name", "") + if u_name.is_empty(): + continue + shader_params.append({ + "name": u_name, + "type": type_string(u.get("type", TYPE_NIL)), + "value": MaterialValues.serialize_value(shader_mat.get_shader_parameter(u_name)), + }) + + var reverse_type_map := _reverse_type_map() + + var shader_path_str := "" + if mat is ShaderMaterial: + var sm := mat as ShaderMaterial + if sm.shader != null: + shader_path_str = sm.shader.resource_path + + return { + "data": { + "path": mat_path, + "class": mat.get_class(), + "type": reverse_type_map.get(mat.get_class(), ""), + "properties": properties, + "property_count": properties.size(), + "shader_parameters": shader_params, + "shader_path": shader_path_str, + } + } + + +# ============================================================================ +# material_list +# ============================================================================ + +func list_materials(params: Dictionary) -> Dictionary: + var root: String = params.get("root", "res://") + var type_filter: String = params.get("type", "") + + var root_err = McpPathValidator.path_error(root, "root") + if root_err != null: + return root_err + + var efs := EditorInterface.get_resource_filesystem() + if efs == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "EditorFileSystem not available", false) + + var results: Array[Dictionary] = [] + var start_dir := efs.get_filesystem_path(root) + if start_dir == null: + start_dir = efs.get_filesystem() + _scan_materials(start_dir, type_filter, root, results) + + return {"data": {"materials": results, "count": results.size()}} + + +func _scan_materials(dir: EditorFileSystemDirectory, type_filter: String, root: String, out: Array[Dictionary]) -> void: + if dir == null: + return + for i in dir.get_file_count(): + var file_path := dir.get_file_path(i) + if not file_path.begins_with(root): + continue + var file_type := dir.get_file_type(i) + var is_material := file_type == "Material" or ClassDB.is_parent_class(file_type, "Material") + if not is_material: + # Some material variants serialize as specific classes. + if not (file_type in _TYPE_TO_CLASS.values()): + continue + + if not type_filter.is_empty(): + if file_type != type_filter and not ClassDB.is_parent_class(file_type, type_filter): + continue + + out.append({"path": file_path, "class": file_type}) + + for i in dir.get_subdir_count(): + _scan_materials(dir.get_subdir(i), type_filter, root, out) + + +# ============================================================================ +# material_assign +# ============================================================================ + +func assign_material(params: Dictionary) -> Dictionary: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path") + + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var _scene_root: Node = _resolved.scene_root + + var slot: String = params.get("slot", "override") + var resource_path: String = params.get("resource_path", "") + var create_if_missing: bool = params.get("create_if_missing", false) + var type_str: String = params.get("type", "standard") + + var slot_result := _resolve_slot_property(node, slot) + if slot_result.has("error"): + return slot_result + var property: String = slot_result.property + + # Load or create the material. + var mat: Material = null + var material_created := false + if not resource_path.is_empty(): + var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path") + if rpath_err != null: + return rpath_err + if not ResourceLoader.exists(resource_path): + if create_if_missing: + # We'd need to create a new file here — refuse; callers should + # use material_create first or omit resource_path to get an + # inline material. + return ErrorCodes.make( + ErrorCodes.RESOURCE_NOT_FOUND, + "Resource not found: %s. Create it first with material_create or omit resource_path for an inline material." % resource_path + ) + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path) + var loaded := ResourceLoader.load(resource_path) + if not (loaded is Material): + var loaded_class := "null" + if loaded != null: + loaded_class = loaded.get_class() + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Resource at %s is not a Material (got %s)" % [resource_path, loaded_class] + ) + mat = loaded + else: + if not create_if_missing: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Missing resource_path (pass create_if_missing=true to create a new inline material)" + ) + if not _TYPE_TO_CLASS.has(type_str): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid material type '%s'" % type_str + ) + mat = _instantiate_material(type_str) + material_created = true + + var old_value = node.get(property) + + _undo_redo.create_action("MCP: Assign material to %s.%s" % [node.name, property]) + _undo_redo.add_do_property(node, property, mat) + _undo_redo.add_undo_property(node, property, old_value) + if material_created: + _undo_redo.add_do_reference(mat) + _undo_redo.commit_action() + + return { + "data": { + "node_path": node_path, + "property": property, + "slot": slot, + "resource_path": resource_path, + "material_class": mat.get_class(), + "material_created": material_created, + "undoable": true, + } + } + + +# ============================================================================ +# material_apply_to_node +# ============================================================================ + +func apply_to_node(params: Dictionary) -> Dictionary: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path") + + var type_str: String = params.get("type", "standard") + if not _TYPE_TO_CLASS.has(type_str): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid material type '%s'. Valid: %s" % [type_str, ", ".join(_TYPE_TO_CLASS.keys())] + ) + + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var _scene_root: Node = _resolved.scene_root + + var slot: String = params.get("slot", "override") + var slot_result := _resolve_slot_property(node, slot) + if slot_result.has("error"): + return slot_result + var property: String = slot_result.property + + var mat := _instantiate_material(type_str) + + var props_to_set: Dictionary = params.get("params", {}) + var applied: Array[String] = [] + for prop_name in props_to_set: + var apply_err := _apply_one_param_on_instance(mat, String(prop_name), props_to_set[prop_name]) + if apply_err != null: + return apply_err + applied.append(String(prop_name)) + + var save_to: String = params.get("save_to", "") + var saved := false + var overwritten := false + if not save_to.is_empty(): + var save_err_validation := _validate_material_path(save_to, "save_to", true) + if save_err_validation != null: + return save_err_validation + # Same clobber guard as create_material/apply_preset: agents reuse + # names like res://materials/metal.tres, and a silent save here + # destroys a hand-authored file that undo can't restore (undo only + # reverts the node's slot assignment, not file contents). See #685. + var existed_before := FileAccess.file_exists(save_to) + if existed_before and not params.get("overwrite", false): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Material already exists at %s (pass overwrite=true to replace)" % save_to + ) + overwritten = existed_before + var dir_path := save_to.get_base_dir() + var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path) + if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path) + var save_err := McpResourceIO.guarded_save(mat, save_to, _connection) + if save_err != OK: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save material to %s (error %d)" % [save_to, save_err]) + var efs := EditorInterface.get_resource_filesystem() + if efs != null: + efs.update_file(save_to) + # Prefer the on-disk reference (keeps the scene ref small), but fall + # back to the in-memory material if the reload fails — otherwise a null + # would clear the slot and crash mat.get_class() below. + var reloaded := ResourceLoader.load(save_to) + if reloaded != null: + mat = reloaded + saved = true + + var old_value = node.get(property) + + _undo_redo.create_action("MCP: Apply %s material to %s" % [type_str, node.name]) + _undo_redo.add_do_property(node, property, mat) + _undo_redo.add_undo_property(node, property, old_value) + _undo_redo.add_do_reference(mat) + _undo_redo.commit_action() + + return { + "data": { + "node_path": node_path, + "property": property, + "slot": slot, + "type": type_str, + "class": mat.get_class(), + "applied_params": applied, + "material_created": true, + "saved_to": save_to if saved else "", + "overwritten": overwritten, + "undoable": true, + } + } + + +# ============================================================================ +# material_apply_preset +# ============================================================================ + +func apply_preset(params: Dictionary) -> Dictionary: + var preset_name: String = params.get("preset", "") + if preset_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset") + + var overrides: Dictionary = params.get("overrides", {}) + var blueprint = MaterialPresets.build(preset_name, overrides) + if blueprint == null: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(MaterialPresets.list())] + ) + + var type_str: String = blueprint.get("type", "standard") + var preset_params: Dictionary = blueprint.get("params", {}) + + var path: String = params.get("path", "") + var node_path: String = params.get("node_path", "") + + if path.is_empty() and node_path.is_empty(): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "Pass at least one of: path (save to disk), node_path (assign to node)" + ) + + # If both path and node_path, save to disk, then assign the saved resource. + # If only path, save to disk. + # If only node_path, inline material via apply_to_node. + + if not node_path.is_empty() and path.is_empty(): + # Inline + var inline_result := apply_to_node({ + "node_path": node_path, + "type": type_str, + "params": preset_params, + "slot": params.get("slot", "override"), + }) + if inline_result.has("data"): + inline_result.data["preset"] = preset_name + inline_result.data["assigned"] = true + inline_result.data["path"] = "" + inline_result.data["saved_to_disk"] = false + inline_result.data["reason"] = "Inline material assigned to node" + return inline_result + + # Save-to-disk path. Validate the path BEFORE the exists/overwrite + # check, matching create_material's order — an invalid path should + # always be reported as invalid, not as an overwrite conflict. + var path_err := _validate_material_path(path, "path", true) + if path_err != null: + return path_err + + var existed_before := FileAccess.file_exists(path) + if existed_before and not params.get("overwrite", false): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Material already exists at %s (pass overwrite=true to replace)" % path + ) + + var mat := _instantiate_material(type_str) + for prop_name in preset_params: + var apply_err := _apply_one_param_on_instance(mat, String(prop_name), preset_params[prop_name]) + if apply_err != null: + return apply_err + + var dir_path := path.get_base_dir() + var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path) + if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path) + + var save_err := McpResourceIO.guarded_save(mat, path, _connection) + if save_err != OK: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save material: %s" % path) + + var efs := EditorInterface.get_resource_filesystem() + if efs != null: + efs.update_file(path) + + var assigned := false + if not node_path.is_empty(): + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var _scene_root: Node = _resolved.scene_root + var slot_result := _resolve_slot_property(node, params.get("slot", "override")) + if slot_result.has("error"): + return slot_result + var property: String = slot_result.property + var saved_mat := ResourceLoader.load(path) + var old_value = node.get(property) + _undo_redo.create_action("MCP: Apply preset %s to %s" % [preset_name, node.name]) + _undo_redo.add_do_property(node, property, saved_mat) + _undo_redo.add_undo_property(node, property, old_value) + _undo_redo.commit_action() + assigned = true + + return { + "data": { + "preset": preset_name, + "type": type_str, + "path": path, + "node_path": node_path, + "material_created": true, + "assigned": assigned, + "saved_to_disk": true, + "undoable": assigned, # assign is undoable; save is not + "reason": "" if assigned else "File save is not undoable", + } + } + + +# ============================================================================ +# Undo-callable: applies a param on the loaded resource and saves. +# ============================================================================ + +func _apply_param(mat_path: String, property: String, value: Variant, _is_shader: bool) -> void: + var mat: Material = ResourceLoader.load(mat_path) + if mat == null: + push_warning("MCP: Failed to load material for undo/redo: %s" % mat_path) + return + mat.set(property, value) + McpResourceIO.guarded_save(mat, mat_path, _connection) + + +func _apply_shader_param(mat_path: String, param_name: String, value: Variant) -> void: + var mat: Material = ResourceLoader.load(mat_path) + if mat == null or not (mat is ShaderMaterial): + push_warning("MCP: Failed to load shader material for undo/redo: %s" % mat_path) + return + (mat as ShaderMaterial).set_shader_parameter(param_name, value) + McpResourceIO.guarded_save(mat, mat_path, _connection) + + +# ============================================================================ +# Helpers +# ============================================================================ + +static func _instantiate_material(type_str: String) -> Material: + match type_str: + "standard": + return StandardMaterial3D.new() + "orm": + return ORMMaterial3D.new() + "canvas_item": + return CanvasItemMaterial.new() + "shader": + return ShaderMaterial.new() + return null + + +static func _reverse_type_map() -> Dictionary: + var out := {} + for k in _TYPE_TO_CLASS: + out[_TYPE_TO_CLASS[k]] = k + return out + + +static func _validate_material_path(path: String, param_name: String, for_write: bool = false) -> Variant: + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name) + var path_err := McpPathValidator.validate_resource_path(path, for_write) + if not path_err.is_empty(): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err]) + var has_suffix := false + for s in _SUPPORTED_SUFFIXES: + if path.ends_with(s): + has_suffix = true + break + if not has_suffix: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "%s must end with one of %s (got %s)" % [param_name, ", ".join(_SUPPORTED_SUFFIXES), path] + ) + return null + + +func _load_material_from_path(path: String, for_write: bool = false) -> Dictionary: + var err := _validate_material_path(path, "path", for_write) + if err != null: + return err + if not ResourceLoader.exists(path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % path) + var res := ResourceLoader.load(path) + if res == null or not (res is Material): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % path) + return {"material": res, "path": path} + + +## Map a slot name to a Godot property name on the given node. +## Returns {property: "..."} or an error dict. +func _resolve_slot_property(node: Node, slot: String) -> Dictionary: + if slot == "override": + if node is MeshInstance3D or node is CSGShape3D: + return {"property": "material_override"} + if node is CanvasItem: + return {"property": "material"} + if node is GPUParticles3D or node is GPUParticles2D or node is CPUParticles3D or node is CPUParticles2D: + return {"property": "material_override"} if node is GeometryInstance3D else {"property": "material"} + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Slot 'override' not supported on %s" % node.get_class() + ) + if slot == "canvas": + if node is CanvasItem: + return {"property": "material"} + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Slot 'canvas' requires a CanvasItem (got %s)" % node.get_class() + ) + if slot == "process": + if node is GPUParticles3D or node is GPUParticles2D: + return {"property": "process_material"} + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Slot 'process' requires a GPUParticles2D/3D (got %s)" % node.get_class() + ) + if slot.begins_with("surface_"): + if not (node is MeshInstance3D): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Slot '%s' requires a MeshInstance3D (got %s)" % [slot, node.get_class()] + ) + var idx_str := slot.substr(len("surface_")) + if not idx_str.is_valid_int(): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid surface slot: %s" % slot) + var idx := int(idx_str) + var mi := node as MeshInstance3D + var surf_count := mi.mesh.get_surface_count() if mi.mesh != null else 0 + if idx < 0 or idx >= surf_count: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Surface index %d out of range (mesh has %d surfaces)" % [idx, surf_count] + ) + return {"property": "surface_material_override/%d" % idx} + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Unknown slot '%s'. Valid: override, canvas, process, surface_N" % slot + ) + + +## Apply one property to an in-memory material instance; returns null on +## success or an error dict on failure. +func _apply_one_param_on_instance(mat: Material, property: String, raw_value: Variant) -> Variant: + var prop_type: int = TYPE_NIL + var property_exists := false + for prop in mat.get_property_list(): + if prop.name == property: + property_exists = true + prop_type = prop.get("type", TYPE_NIL) + break + if not property_exists: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + McpPropertyErrors.build_message(mat, property) + ) + var coerced := MaterialValues.coerce_material_value(property, raw_value, prop_type) + if not coerced.ok: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error)) + mat.set(property, coerced.value) + return null + + +## Inspect a shader to get the Variant type of a uniform. Returns TYPE_NIL if +## the uniform is not declared. +static func _shader_uniform_type(shader: Shader, name: String) -> int: + if shader == null: + return TYPE_NIL + for u in shader.get_shader_uniform_list(): + if u.get("name", "") == name: + return int(u.get("type", TYPE_NIL)) + return TYPE_NIL diff --git a/addons/godot_ai/handlers/material_handler.gd.uid b/addons/godot_ai/handlers/material_handler.gd.uid new file mode 100644 index 0000000..fdf789a --- /dev/null +++ b/addons/godot_ai/handlers/material_handler.gd.uid @@ -0,0 +1 @@ +uid://blh4norn3rjga diff --git a/addons/godot_ai/handlers/material_presets.gd b/addons/godot_ai/handlers/material_presets.gd new file mode 100644 index 0000000..db48036 --- /dev/null +++ b/addons/godot_ai/handlers/material_presets.gd @@ -0,0 +1,92 @@ +@tool +extends RefCounted + +## Curated material preset blueprints. +## +## Each preset returns {type, params}. Handler applies them through the +## normal material build path so they get undo + validation for free. + + +const _PRESETS := { + "metal": { + "type": "orm", + "params": { + "metallic": 1.0, + "roughness": 0.25, + "albedo_color": {"r": 0.85, "g": 0.85, "b": 0.88, "a": 1.0}, + }, + }, + "glass": { + "type": "standard", + "params": { + "transparency": "alpha", + "albedo_color": {"r": 0.9, "g": 0.95, "b": 1.0, "a": 0.3}, + "metallic": 0.0, + "metallic_specular": 0.5, + "roughness": 0.05, + "refraction_enabled": true, + "refraction_scale": 0.05, + }, + }, + "emissive": { + "type": "standard", + "params": { + "emission_enabled": true, + "emission_energy_multiplier": 3.0, + "emission": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}, + "albedo_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}, + }, + }, + "unlit": { + "type": "standard", + "params": { + "shading_mode": "unshaded", + "albedo_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}, + }, + }, + "matte": { + "type": "standard", + "params": { + "roughness": 1.0, + "metallic": 0.0, + "albedo_color": {"r": 0.7, "g": 0.7, "b": 0.7, "a": 1.0}, + }, + }, + "ceramic": { + "type": "standard", + "params": { + "roughness": 0.4, + "metallic": 0.0, + "clearcoat_enabled": true, + "clearcoat": 0.7, + "clearcoat_roughness": 0.15, + "albedo_color": {"r": 0.95, "g": 0.95, "b": 0.95, "a": 1.0}, + }, + }, +} + + +static func list() -> Array: + return _PRESETS.keys() + + +static func has(preset_name: String) -> bool: + return _PRESETS.has(preset_name) + + +## Returns a deep-copied {type, params} blueprint for the named preset, or +## null if the preset is unknown. Overrides are merged into params. +static func build(preset_name: String, overrides: Dictionary) -> Variant: + if not _PRESETS.has(preset_name): + return null + var entry: Dictionary = _PRESETS[preset_name].duplicate(true) + var params: Dictionary = entry.get("params", {}) + # Allow overrides to change type, too. + if overrides.has("type"): + entry["type"] = overrides["type"] + for key in overrides: + if key == "type": + continue + params[key] = overrides[key] + entry["params"] = params + return entry diff --git a/addons/godot_ai/handlers/material_presets.gd.uid b/addons/godot_ai/handlers/material_presets.gd.uid new file mode 100644 index 0000000..2f46051 --- /dev/null +++ b/addons/godot_ai/handlers/material_presets.gd.uid @@ -0,0 +1 @@ +uid://bnuwye1r8ow7g diff --git a/addons/godot_ai/handlers/material_values.gd b/addons/godot_ai/handlers/material_values.gd new file mode 100644 index 0000000..d6a7f1d --- /dev/null +++ b/addons/godot_ai/handlers/material_values.gd @@ -0,0 +1,194 @@ +@tool +extends RefCounted + +## Value coercion helpers for material authoring. +## +## Extends node_handler._coerce_value with material-specific cases: +## - enum-by-name (transparency="alpha" → TRANSPARENCY_ALPHA) +## - texture path → Texture2D +## - {r,g,b,a} dict → Color (also handled by node coerce, but we want it inline) + + +const _ENUM_TABLES := { + "transparency": { + "disabled": BaseMaterial3D.TRANSPARENCY_DISABLED, + "alpha": BaseMaterial3D.TRANSPARENCY_ALPHA, + "alpha_scissor": BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR, + "alpha_hash": BaseMaterial3D.TRANSPARENCY_ALPHA_HASH, + "alpha_depth_pre_pass": BaseMaterial3D.TRANSPARENCY_ALPHA_DEPTH_PRE_PASS, + }, + "shading_mode": { + "unshaded": BaseMaterial3D.SHADING_MODE_UNSHADED, + "per_pixel": BaseMaterial3D.SHADING_MODE_PER_PIXEL, + "per_vertex": BaseMaterial3D.SHADING_MODE_PER_VERTEX, + }, + "blend_mode": { + "mix": BaseMaterial3D.BLEND_MODE_MIX, + "add": BaseMaterial3D.BLEND_MODE_ADD, + "sub": BaseMaterial3D.BLEND_MODE_SUB, + "mul": BaseMaterial3D.BLEND_MODE_MUL, + }, + "cull_mode": { + "back": BaseMaterial3D.CULL_BACK, + "front": BaseMaterial3D.CULL_FRONT, + "disabled": BaseMaterial3D.CULL_DISABLED, + }, + "depth_draw_mode": { + "opaque_only": BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY, + "always": BaseMaterial3D.DEPTH_DRAW_ALWAYS, + "disabled": BaseMaterial3D.DEPTH_DRAW_DISABLED, + }, + "diffuse_mode": { + "burley": BaseMaterial3D.DIFFUSE_BURLEY, + "lambert": BaseMaterial3D.DIFFUSE_LAMBERT, + "lambert_wrap": BaseMaterial3D.DIFFUSE_LAMBERT_WRAP, + "toon": BaseMaterial3D.DIFFUSE_TOON, + }, + "specular_mode": { + "schlick_ggx": BaseMaterial3D.SPECULAR_SCHLICK_GGX, + "toon": BaseMaterial3D.SPECULAR_TOON, + "disabled": BaseMaterial3D.SPECULAR_DISABLED, + }, + "billboard_mode": { + "disabled": BaseMaterial3D.BILLBOARD_DISABLED, + "enabled": BaseMaterial3D.BILLBOARD_ENABLED, + "fixed_y": BaseMaterial3D.BILLBOARD_FIXED_Y, + "particles": BaseMaterial3D.BILLBOARD_PARTICLES, + }, + "texture_filter": { + "nearest": BaseMaterial3D.TEXTURE_FILTER_NEAREST, + "linear": BaseMaterial3D.TEXTURE_FILTER_LINEAR, + "nearest_mipmap": BaseMaterial3D.TEXTURE_FILTER_NEAREST_WITH_MIPMAPS, + "linear_mipmap": BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS, + }, +} + + +## Return the enum int for (property, string_name), or null if not a known enum string. +static func resolve_enum(property: String, value: Variant) -> Variant: + if not (value is String): + return null + if not _ENUM_TABLES.has(property): + return null + var table: Dictionary = _ENUM_TABLES[property] + var key: String = String(value).to_lower() + if table.has(key): + return table[key] + return null + + +## Parse a color from Color, "#rrggbb(aa)", named string, {r,g,b[,a]} dict, +## or [r,g,b(,a)] array. Delegates to the canonical parser (#714); returns +## null if the input cannot be parsed. +static func parse_color(value: Variant) -> Variant: + return McpJsonValues.parse_color(value) + + +static func parse_vector3(value: Variant) -> Variant: + return McpJsonValues.parse_vector3(value) + + +static func parse_vector2(value: Variant) -> Variant: + return McpJsonValues.parse_vector2(value) + + +## Load a Texture2D from a res:// / uid:// / user:// path (validate_loadable_path). +## Returns null on failure (including a path that fails confinement / traversal). +static func load_texture(path: String) -> Texture2D: + if not McpPathValidator.validate_loadable_path(path).is_empty(): + return null + if not ResourceLoader.exists(path): + return null + var res := ResourceLoader.load(path) + if res is Texture2D: + return res + return null + + +## Coerce a JSON-shaped value for a material property. +## Returns a dict {ok: true, value: ...} on success, or {ok: false, error: "..."} on failure. +## For properties the coercer doesn't have special logic for, falls back to target_type. +static func coerce_material_value(property: String, value: Variant, target_type: int) -> Dictionary: + # Enum-by-name: must match before generic TYPE_INT coercion. + if _ENUM_TABLES.has(property): + if value is String: + var enum_val = resolve_enum(property, value) + if enum_val == null: + return { + "ok": false, + "error": "Invalid %s value: '%s'. Valid: %s" % [ + property, value, ", ".join(_ENUM_TABLES[property].keys()) + ], + } + return {"ok": true, "value": int(enum_val)} + if value is int or value is float: + return {"ok": true, "value": int(value)} + + match target_type: + TYPE_COLOR: + var c = parse_color(value) + if c == null: + return {"ok": false, "error": "Invalid color for %s: %s" % [property, value]} + return {"ok": true, "value": c} + TYPE_VECTOR3: + var v3 = parse_vector3(value) + if v3 == null: + return {"ok": false, "error": "Invalid vector3 for %s: %s" % [property, value]} + return {"ok": true, "value": v3} + TYPE_VECTOR2: + var v2 = parse_vector2(value) + if v2 == null: + return {"ok": false, "error": "Invalid vector2 for %s: %s" % [property, value]} + return {"ok": true, "value": v2} + TYPE_BOOL: + if value is bool: + return {"ok": true, "value": value} + if value is int or value is float: + return {"ok": true, "value": bool(value)} + return {"ok": false, "error": "Expected bool for %s" % property} + TYPE_INT: + if value is int: + return {"ok": true, "value": value} + if value is float: + return {"ok": true, "value": int(value)} + return {"ok": false, "error": "Expected int for %s" % property} + TYPE_FLOAT: + if value is float: + return {"ok": true, "value": value} + if value is int: + return {"ok": true, "value": float(value)} + return {"ok": false, "error": "Expected number for %s" % property} + TYPE_OBJECT: + if value == null: + return {"ok": true, "value": null} + if value is Object: + return {"ok": true, "value": value} + if value is String: + var tex := load_texture(value) + if tex == null: + return {"ok": false, "error": "Resource not found or wrong type: %s" % value} + return {"ok": true, "value": tex} + return {"ok": false, "error": "Expected resource path (string) for %s" % property} + TYPE_STRING: + return {"ok": true, "value": String(value)} + + # Unknown target type — pass through. + return {"ok": true, "value": value} + + +## Serialize a Variant into JSON-friendly shape for responses. +static func serialize_value(value: Variant) -> Variant: + if value == null: + return null + if value is Color: + return {"r": value.r, "g": value.g, "b": value.b, "a": value.a} + if value is Vector3: + return {"x": value.x, "y": value.y, "z": value.z} + if value is Vector2: + return {"x": value.x, "y": value.y} + if value is Resource: + var path := (value as Resource).resource_path + if path.is_empty(): + return {"type": value.get_class(), "path": ""} + return {"type": value.get_class(), "path": path} + return value diff --git a/addons/godot_ai/handlers/material_values.gd.uid b/addons/godot_ai/handlers/material_values.gd.uid new file mode 100644 index 0000000..7f8bbe9 --- /dev/null +++ b/addons/godot_ai/handlers/material_values.gd.uid @@ -0,0 +1 @@ +uid://daqgjkflia8nk diff --git a/addons/godot_ai/handlers/node_handler.gd b/addons/godot_ai/handlers/node_handler.gd new file mode 100644 index 0000000..6d7e1ff --- /dev/null +++ b/addons/godot_ai/handlers/node_handler.gd @@ -0,0 +1,1390 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd") + +## Handles node creation and manipulation with undo/redo support. + +const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd") + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +func create_node(params: Dictionary) -> Dictionary: + var node_type: String = params.get("type", "") + var node_name: String = params.get("name", "") + var parent_path: String = params.get("parent_path", "") + var scene_path: String = params.get("scene_path", "") + + var scene_check := McpScenePath.require_edited_scene(params.get("scene_file", "")) + if scene_check.has("error"): + return scene_check + var scene_root: Node = scene_check.node + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + var new_node: Node + + if not scene_path.is_empty(): + # Scene instancing path — load and instantiate a PackedScene. + # GEN_EDIT_STATE_INSTANCE makes the editor treat the result as a real + # scene instance (foldout icon, the .tscn stores a reference instead of + # an exploded subtree). Descendants remain owned by their sub-scene; + # setting their owner to our scene_root would break the instance link. + var scene_path_err = McpPathValidator.loadable_error(scene_path, "scene_path") + if scene_path_err != null: + return scene_path_err + if not ResourceLoader.exists(scene_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % scene_path) + var packed_scene = ResourceLoader.load(scene_path) + if packed_scene == null or not packed_scene is PackedScene: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a PackedScene" % scene_path) + new_node = packed_scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE) + if new_node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate scene: %s" % scene_path) + else: + # ClassDB path — create by type. + if node_type.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type (or provide scene_path)") + if not ClassDB.class_exists(node_type): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown node type: %s" % node_type) + if not ClassDB.is_parent_class(node_type, "Node"): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type) + new_node = ClassDB.instantiate(node_type) + if new_node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type) + + if not node_name.is_empty(): + new_node.name = node_name + + _undo_redo.create_action("MCP: Create %s" % new_node.name) + _undo_redo.add_do_method(parent, "add_child", new_node, true) + _undo_redo.add_do_method(new_node, "set_owner", scene_root) + _undo_redo.add_do_reference(new_node) + _undo_redo.add_undo_method(parent, "remove_child", new_node) + _undo_redo.commit_action() + + var response := { + "name": new_node.name, + "type": new_node.get_class(), + "path": McpScenePath.from_node(new_node, scene_root), + "parent_path": McpScenePath.from_node(parent, scene_root), + "undoable": true, + } + if not scene_path.is_empty(): + response["scene_path"] = scene_path + return {"data": response} + + +func delete_node(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var root_err := _reject_if_scene_root(node, scene_root, "delete") + if root_err != null: + return root_err + + var parent := node.get_parent() + var idx := node.get_index() + + _undo_redo.create_action("MCP: Delete %s" % node.name) + _undo_redo.add_do_method(parent, "remove_child", node) + _undo_redo.add_undo_method(parent, "add_child", node, true) + _undo_redo.add_undo_method(parent, "move_child", node, idx) + _undo_redo.add_undo_method(node, "set_owner", scene_root) + _undo_redo.add_undo_reference(node) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "undoable": true, + } + } + + +func reparent_node(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var new_parent_path: String = params.get("new_parent", "") + if new_parent_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_parent") + + var new_parent := McpScenePath.resolve(new_parent_path, scene_root) + if new_parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(new_parent_path, scene_root)) + + var root_err := _reject_if_scene_root(node, scene_root, "reparent") + if root_err != null: + return root_err + + # Prevent reparenting a node to itself or to one of its own descendants. + # Godot's `A.is_ancestor_of(B)` returns true iff B is a descendant of A, so + # the direction here matters: we want `node.is_ancestor_of(new_parent)` to + # catch "new_parent is below node in the tree" and thus would create a + # cycle. The previous direction (`new_parent.is_ancestor_of(node)`) asked + # the opposite question — whether we were trying to move a node to one of + # its own ancestors — which is a perfectly valid operation. See issue #121. + if node == new_parent or node.is_ancestor_of(new_parent): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot reparent a node to itself or its descendant") + + var old_parent := node.get_parent() + var old_idx := node.get_index() + + _undo_redo.create_action("MCP: Reparent %s" % node.name) + _undo_redo.add_do_method(old_parent, "remove_child", node) + _undo_redo.add_do_method(new_parent, "add_child", node, true) + _undo_redo.add_do_method(node, "set_owner", scene_root) + _undo_redo.add_do_reference(node) + _undo_redo.add_undo_method(new_parent, "remove_child", node) + _undo_redo.add_undo_method(old_parent, "add_child", node, true) + _undo_redo.add_undo_method(old_parent, "move_child", node, old_idx) + _undo_redo.add_undo_method(node, "set_owner", scene_root) + _undo_redo.add_undo_reference(node) + _undo_redo.commit_action() + + # Re-set owner for all descendants (reparent can break ownership chain) + _set_owner_recursive(node, scene_root) + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "old_parent": McpScenePath.from_node(old_parent, scene_root), + "new_parent": McpScenePath.from_node(new_parent, scene_root), + "undoable": true, + } + } + + +func set_property(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var property: String = params.get("property", "") + if property.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property") + + if not "value" in params: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") + + var value = params.get("value") + + var found := false + var prop_type: int = TYPE_NIL + for prop in node.get_property_list(): + if prop.name == property: + found = true + prop_type = prop.get("type", TYPE_NIL) + break + if not found: + return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property)) + + var old_value = node.get(property) + # Prefer declared property type; fall back to runtime type for dynamic props + # (scripted @export vars can report TYPE_NIL in the property list). + var target_type: int = prop_type if prop_type != TYPE_NIL else typeof(old_value) + + var instantiated_resource := false + + # Some MCP clients (Cline) stringify the documented {"__class__": "BoxMesh", ...} + # value before sending. Promote that string back to a Dictionary here so the + # `__class__` branch below handles it, instead of the next branch treating + # the JSON blob as a res:// path and emitting "Resource not found: {...}". + # See #206. + if target_type == TYPE_OBJECT and value is String and value.begins_with("{"): + var json := JSON.new() + if json.parse(value) == OK and json.data is Dictionary and (json.data as Dictionary).has("__class__"): + value = json.data + + var nil_resource_string: bool = target_type == TYPE_NIL and (value == "" or (value is String and value.begins_with("res://"))) + var resource_string_value: bool = value is String and (target_type == TYPE_OBJECT or nil_resource_string) + if resource_string_value: + if value == "": + value = null + else: + var value_path_err = McpPathValidator.loadable_error(value, "value") + if value_path_err != null: + return value_path_err + if not ResourceLoader.exists(value): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value) + var loaded := ResourceLoader.load(value) + if loaded == null: + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value) + value = loaded + elif target_type == TYPE_OBJECT and value is Dictionary and value.has("__class__"): + # Shortcut: {"__class__": "BoxMesh", "size": {...}} instantiates a + # fresh Resource subclass and applies the remaining keys as + # properties. Mirrors resource_create's inline-assign path but + # avoids a separate tool call for the common case. + var type_str: String = value.get("__class__", "") + var made := ResourceHandler._instantiate_resource(type_str) + if made is Dictionary: + return made + var res: Resource = made + var remaining: Dictionary = (value as Dictionary).duplicate() + remaining.erase("__class__") + if not remaining.is_empty(): + var apply_err := ResourceHandler._apply_resource_properties(res, remaining) + if apply_err != null: + return apply_err + value = res + instantiated_resource = true + elif target_type == TYPE_ARRAY and old_value is Array and (old_value as Array).is_typed(): + ## Typed Array[T] slot (#612): the generic TYPE_ARRAY passthrough + ## hands an untyped Array to Godot's typed setter, which rejects it + ## wholesale and leaves the slot at its default — with success still + ## reported. Route through the element-aware coercer instead; errors + ## name the offending element index. + var typed_out: Variant = _coerce_typed_array(value, old_value) + if typed_out is Dictionary: + return typed_out + value = typed_out + elif ( + target_type == TYPE_DICTIONARY + and old_value is Dictionary + and (old_value as Dictionary).is_typed() + ): + ## Typed Dictionary[K, V] slot (#612 stage 3) — same silent-drop + ## family as typed arrays. A successful result is always a TYPED + ## Dictionary (a cleared duplicate of the slot), while the error + ## envelope is an untyped {"error": ...} — that's the discriminator + ## (a legit payload could contain an "error" key; typedness can't lie). + var typed_dict_out: Dictionary = _coerce_typed_dictionary(value, old_value) + if not typed_dict_out.is_typed(): + return typed_dict_out + value = typed_dict_out + else: + value = _coerce_value(value, target_type) + ## Refuse any value that didn't land as the target compound Variant + ## — wrong-shape dict (#123) or non-dict input like list / JSON string + ## that used to silently default-construct Vector3.ZERO (#191). + var coerce_err := _check_coerced(value, target_type) + if coerce_err != null: + return coerce_err + + _undo_redo.create_action("MCP: Set %s.%s" % [node.name, property]) + _undo_redo.add_do_property(node, property, value) + _undo_redo.add_undo_property(node, property, old_value) + if instantiated_resource: + _undo_redo.add_do_reference(value) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "property": property, + "value": _serialize_value(node.get(property)), + "old_value": _serialize_value(old_value), + "undoable": true, + } + } + + +func rename_node(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var new_name: String = params.get("new_name", "") + if new_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_name") + + ## The scene root's name is baked into the .tscn serialization and is + ## referenced by every NodePath that starts with `/` (AnimationPlayer + ## tracks, RemoteTransform3D targets, exported NodePath @vars, etc.). + ## Renaming it silently breaks those references. The MCP tool's docstring + ## has always promised "Cannot rename the scene root" — enforce it. #122 + if node == scene_root: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot rename the scene root") + + if new_name.validate_node_name() != new_name: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid characters in name: %s" % new_name) + + var old_name := String(node.name) + if old_name == new_name: + return { + "data": { + "path": node_path, + "name": new_name, + "old_name": old_name, + "unchanged": true, + "undoable": false, + "reason": "Name unchanged", + } + } + + var parent := node.get_parent() + for sibling in parent.get_children(): + if sibling != node and String(sibling.name) == new_name: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "A sibling already has the name '%s'" % new_name) + + _undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name]) + _undo_redo.add_do_property(node, "name", new_name) + _undo_redo.add_undo_property(node, "name", old_name) + _undo_redo.commit_action() + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "old_path": node_path, + "name": String(node.name), + "old_name": old_name, + "undoable": true, + } + } + + +func duplicate_node(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var root_err := _reject_if_scene_root(node, scene_root, "duplicate") + if root_err != null: + return root_err + + var parent := node.get_parent() + var dup: Node = node.duplicate() + if dup == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to duplicate node") + + # Apply optional name + var new_name: String = params.get("name", "") + if not new_name.is_empty(): + dup.name = new_name + + _undo_redo.create_action("MCP: Duplicate %s" % node.name) + _undo_redo.add_do_method(parent, "add_child", dup, true) + _undo_redo.add_do_method(dup, "set_owner", scene_root) + _undo_redo.add_do_reference(dup) + _undo_redo.add_undo_method(parent, "remove_child", dup) + _undo_redo.commit_action() + + # Set owner for all descendants of the duplicate + _set_owner_recursive(dup, scene_root) + + return { + "data": { + "path": McpScenePath.from_node(dup, scene_root), + "original_path": node_path, + "name": dup.name, + "type": dup.get_class(), + "undoable": true, + } + } + + +func move_node(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var root_err := _reject_if_scene_root(node, scene_root, "reorder") + if root_err != null: + return root_err + + if not "index" in params: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: index") + + var new_index: int = params.get("index", 0) + var parent := node.get_parent() + var old_index := node.get_index() + var sibling_count := parent.get_child_count() + + if new_index < 0 or new_index >= sibling_count: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Index %d out of range (0..%d)" % [new_index, sibling_count - 1]) + + _undo_redo.create_action("MCP: Move %s to index %d" % [node.name, new_index]) + _undo_redo.add_do_method(parent, "move_child", node, new_index) + _undo_redo.add_undo_method(parent, "move_child", node, old_index) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "old_index": old_index, + "new_index": new_index, + "undoable": true, + } + } + + +func add_to_group(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + + var group_value: Variant = params.get("group", "") + var type_err := McpParamValidators.require_string("group", group_value) + if type_err != null: + return type_err + var group := String(group_value) + if group.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group") + + if node.is_in_group(group): + return {"data": {"path": node_path, "group": group, "already_member": true, "undoable": false, "reason": "No change made"}} + + _undo_redo.create_action("MCP: Add %s to group %s" % [node.name, group]) + _undo_redo.add_do_method(node, "add_to_group", group, true) + _undo_redo.add_undo_method(node, "remove_from_group", group) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "group": group, + "undoable": true, + } + } + + +func remove_from_group(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + + var group_value: Variant = params.get("group", "") + var type_err := McpParamValidators.require_string("group", group_value) + if type_err != null: + return type_err + var group := String(group_value) + if group.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group") + + if not node.is_in_group(group): + return {"data": {"path": node_path, "group": group, "not_member": true, "undoable": false, "reason": "Node not in group"}} + + _undo_redo.create_action("MCP: Remove %s from group %s" % [node.name, group]) + _undo_redo.add_do_method(node, "remove_from_group", group) + _undo_redo.add_undo_method(node, "add_to_group", group, true) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "group": group, + "undoable": true, + } + } + + +func set_selection(params: Dictionary) -> Dictionary: + var paths: Array = params.get("paths", []) + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var selection := EditorInterface.get_selection() + selection.clear() + + var selected: Array[String] = [] + var not_found: Array[String] = [] + for path_variant in paths: + var path: String = str(path_variant) + var node := McpScenePath.resolve(path, scene_root) + if node: + selection.add_node(node) + selected.append(path) + else: + not_found.append(path) + + return { + "data": { + "selected": selected, + "not_found": not_found, + "count": selected.size(), + "undoable": false, + "reason": "Selection changes are not tracked in undo history", + } + } + + +func _set_owner_recursive(node: Node, owner: Node) -> void: + for child in node.get_children(): + child.set_owner(owner) + _set_owner_recursive(child, owner) + + +## Canonical dict-key sets for dict→Variant coercion. Alpha on `COLOR_KEYS` +## is optional — the coercer defaults it to 1.0 when absent. +const VECTOR2_KEYS: Array[String] = ["x", "y"] +const VECTOR3_KEYS: Array[String] = ["x", "y", "z"] +const VECTOR4_KEYS: Array[String] = ["x", "y", "z", "w"] +const COLOR_KEYS: Array[String] = ["r", "g", "b"] + + +## End-to-end coerce check for compound JSON-shaped targets +## (Vector2/Vector3/Color). Returns a full `make(...)`-shaped error dict +## if `value` didn't land as the target Variant after `_coerce_value`, +## else null. Wrong-shape dicts get the `_check_dict_coerce_failed` +## message (expected-vs-got keys); non-dict inputs (Array, String, +## primitive) name the received type and a JSON shape hint. No-op for +## non-compound targets — Godot's setter handles those. +## +## Used by set_property, resource_handler, and validation handlers +## (curve, texture). Issue #191 — passing a list, JSON string, or +## anything else to a Vector3 property used to silently store +## Vector3.ZERO; this gates that path. +static func _check_coerced(value: Variant, target_type: int, prefix: String = "") -> Variant: + var ok := false + match target_type: + TYPE_VECTOR2: + ok = value is Vector2 + TYPE_VECTOR3: + ok = value is Vector3 + TYPE_COLOR: + ok = value is Color + TYPE_PACKED_VECTOR2_ARRAY: + ok = value is PackedVector2Array + TYPE_PACKED_VECTOR3_ARRAY: + ok = value is PackedVector3Array + TYPE_PACKED_VECTOR4_ARRAY: + ok = value is PackedVector4Array + TYPE_PACKED_COLOR_ARRAY: + ok = value is PackedColorArray + TYPE_PACKED_INT32_ARRAY: + ok = value is PackedInt32Array + TYPE_PACKED_INT64_ARRAY: + ok = value is PackedInt64Array + TYPE_PACKED_FLOAT32_ARRAY: + ok = value is PackedFloat32Array + TYPE_PACKED_FLOAT64_ARRAY: + ok = value is PackedFloat64Array + TYPE_PACKED_STRING_ARRAY: + ok = value is PackedStringArray + TYPE_VECTOR2I: ok = value is Vector2i + TYPE_VECTOR3I: ok = value is Vector3i + TYPE_VECTOR4: ok = value is Vector4 + TYPE_VECTOR4I: ok = value is Vector4i + TYPE_QUATERNION: ok = value is Quaternion + TYPE_RECT2: ok = value is Rect2 + TYPE_RECT2I: ok = value is Rect2i + TYPE_AABB: ok = value is AABB + TYPE_PLANE: ok = value is Plane + TYPE_BASIS: ok = value is Basis + TYPE_TRANSFORM2D: ok = value is Transform2D + TYPE_TRANSFORM3D: ok = value is Transform3D + TYPE_PROJECTION: ok = value is Projection + _: + # null / untyped-TYPE_NIL / already-correct-type are handled by + # Godot's setter; anything else would silently no-op, so error. + if value == null or target_type == TYPE_NIL or typeof(value) == target_type: + return null + var unsupported := ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Cannot write %s to a %s property; godot-ai has no coercion for that type" % [ + type_string(typeof(value)), type_string(target_type), + ], + ) + return ErrorCodes.prefix_message(unsupported, prefix) + if ok: + return null + var dict_err := _check_dict_coerce_failed(value, target_type) + if dict_err != null: + return ErrorCodes.prefix_message(dict_err, prefix) + ## Wording stays neutral on shape — `_shape_hint` already produces a + ## dict-shaped string for Vector2/3/Color and a list-shaped one for + ## the Packed*Array slots. The old "expected a dict like [...]" phrasing + ## read self-contradictory for packed targets (PR #424 review). + var err := ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Cannot coerce %s to %s; expected %s" % [ + type_string(typeof(value)), type_string(target_type), _shape_hint(target_type), + ], + ) + return ErrorCodes.prefix_message(err, prefix) + + +## Build a "{\"x\":1,...}" hint string from the canonical key constants +## so adding a key (e.g. Vector4) only touches VECTORN_KEYS. Packed*Array +## targets short-circuit to a literal list-shaped hint. +static func _shape_hint(target_type: int) -> String: + match target_type: + TYPE_PACKED_VECTOR2_ARRAY: + return "[{\"x\":0,\"y\":0}, ...]" + TYPE_PACKED_VECTOR3_ARRAY: + return "[{\"x\":0,\"y\":0,\"z\":0}, ...]" + TYPE_PACKED_VECTOR4_ARRAY: + return "[{\"x\":0,\"y\":0,\"z\":0,\"w\":0}, ...]" + TYPE_PACKED_COLOR_ARRAY: + return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]" + TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY: + return "[int, ...]" + TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY: + return "[float, ...]" + TYPE_PACKED_STRING_ARRAY: + return "[\"...\", ...]" + TYPE_VECTOR2I: + return "{\"x\":0,\"y\":0}" + TYPE_VECTOR3I: + return "{\"x\":0,\"y\":0,\"z\":0}" + TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION: + return "{\"x\":0,\"y\":0,\"z\":0,\"w\":0}" + TYPE_RECT2, TYPE_RECT2I, TYPE_AABB: + return "{\"position\":{...},\"size\":{...}}" + TYPE_PLANE: + return "{\"normal\":{...},\"d\":0}" + TYPE_BASIS: + return "{\"x\":{...},\"y\":{...},\"z\":{...}}" + TYPE_TRANSFORM2D: + return "{\"x\":{...},\"y\":{...},\"origin\":{...}}" + TYPE_TRANSFORM3D: + return "{\"basis\":{...},\"origin\":{...}}" + TYPE_PROJECTION: + return "{\"x\":{...},\"y\":{...},\"z\":{...},\"w\":{...}}" + var keys: Array[String] = [] + match target_type: + TYPE_VECTOR2: keys = VECTOR2_KEYS + TYPE_VECTOR3: keys = VECTOR3_KEYS + TYPE_COLOR: keys = COLOR_KEYS + var pairs: Array[String] = [] + for k in keys: + pairs.append("\"%s\":0" % k) + return "{" + ",".join(pairs) + "}" + + +## Detect a failed dict→typed-Variant coercion. Returns an INVALID_PARAMS +## error dict if `value` is still a Dictionary after a coercion attempt +## targeting a Vector2/Vector3/Color slot, else null. Message names the +## expected keys and the keys actually received so agents self-correct +## on the next retry. +static func _check_dict_coerce_failed(value: Variant, target_type: int) -> Variant: + if not (value is Dictionary): + return null + var expected: Array[String] = [] + var type_name := "" + match target_type: + TYPE_VECTOR2: + expected = VECTOR2_KEYS + type_name = "Vector2" + TYPE_VECTOR3: + expected = VECTOR3_KEYS + type_name = "Vector3" + TYPE_COLOR: + expected = COLOR_KEYS + type_name = "Color" + _: + return null + var got_keys: Array = (value as Dictionary).keys() + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Cannot coerce dict to %s: expected keys %s; got %s" % [type_name, str(expected), str(got_keys)] + ) + + +## Coerce JSON-shaped values into Godot Variants when the target property +## type is known. Returns the coerced value on success, or the input +## unchanged on failure — callers detect the type mismatch via an +## `is ` check (curve_handler, texture_handler) or via the +## `_check_dict_coerce_failed` helper (set_property, resource_handler). +## +## Dictionary→Vector2/Vector3/Color cases REQUIRE all canonical keys; +## wrong-shape dicts flow through unchanged. See issue #123 — previous +## `dict.get(key, 0)` defaults silently zero-filled missing axes. +static func _coerce_value(value: Variant, target_type: int) -> Variant: + match target_type: + ## Vector2/Vector3/Color route through the canonical strict parser + ## (#714): same dict/array/string shapes as every other handler, and + ## non-numeric components fall through (returning the original + ## value) so _check_coerced flags them instead of crashing a typed + ## constructor or silently writing black/zeros. + TYPE_VECTOR2: + var v2 = McpJsonValues.parse_vector2(value) + if v2 != null: + return v2 + TYPE_VECTOR3: + var v3 = McpJsonValues.parse_vector3(value) + if v3 != null: + return v3 + TYPE_COLOR: + var col = McpJsonValues.parse_color(value) + if col != null: + return col + TYPE_BOOL: + if value is float or value is int: + return bool(value) + TYPE_INT: + if value is float: + return int(value) + TYPE_FLOAT: + if value is int: + return float(value) + TYPE_STRING_NAME: + if value is String: + return StringName(value) + TYPE_NODE_PATH: + if value is String: + return NodePath(value) + if value == null: + return NodePath() + TYPE_OBJECT: + # Resource loading is handled in set_property so we can return a + # typed error; here we only pass through cleared values. + if value == null: + return null + TYPE_ARRAY: + if value is Array: + return value + TYPE_DICTIONARY: + if value is Dictionary: + return value + TYPE_PACKED_VECTOR2_ARRAY: + if value is Array: + var out := PackedVector2Array() + for item in value: + if item is Vector2: + out.append(item) + elif item is Dictionary and item.has_all(VECTOR2_KEYS): + out.append(Vector2(item["x"], item["y"])) + else: + return value # leave for _check_coerced to flag + return out + TYPE_PACKED_VECTOR3_ARRAY: + if value is Array: + var out := PackedVector3Array() + for item in value: + if item is Vector3: + out.append(item) + elif item is Dictionary and item.has_all(VECTOR3_KEYS): + out.append(Vector3(item["x"], item["y"], item["z"])) + else: + return value + return out + TYPE_PACKED_VECTOR4_ARRAY: + if value is Array: + var out := PackedVector4Array() + for item in value: + if item is Vector4: + out.append(item) + elif item is Dictionary and item.has_all(VECTOR4_KEYS): + out.append(Vector4(item["x"], item["y"], item["z"], item["w"])) + else: + return value + return out + TYPE_PACKED_COLOR_ARRAY: + if value is Array: + var out := PackedColorArray() + for item in value: + if item is Color: + out.append(item) + elif item is Dictionary and item.has_all(COLOR_KEYS): + out.append(Color(item["r"], item["g"], item["b"], item.get("a", 1.0))) + elif item is String: + out.append(Color(item)) + else: + return value + return out + TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY: + if value is Array: + var out: Variant = PackedInt32Array() if target_type == TYPE_PACKED_INT32_ARRAY else PackedInt64Array() + for item in value: + if item is int or item is float: + out.append(int(item)) + else: + return value + return out + TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY: + if value is Array: + var out: Variant = PackedFloat32Array() if target_type == TYPE_PACKED_FLOAT32_ARRAY else PackedFloat64Array() + for item in value: + if item is float or item is int: + out.append(float(item)) + else: + return value + return out + TYPE_PACKED_STRING_ARRAY: + if value is Array: + var out := PackedStringArray() + for item in value: + if item is String: + out.append(item) + else: + return value + return out + TYPE_VECTOR2I: + if value is Dictionary and value.has_all(VECTOR2_KEYS): + return Vector2i(int(value["x"]), int(value["y"])) + TYPE_VECTOR3I: + if value is Dictionary and value.has_all(VECTOR3_KEYS): + return Vector3i(int(value["x"]), int(value["y"]), int(value["z"])) + TYPE_VECTOR4: + if value is Dictionary and value.has_all(VECTOR4_KEYS): + return Vector4(value["x"], value["y"], value["z"], value["w"]) + TYPE_VECTOR4I: + if value is Dictionary and value.has_all(VECTOR4_KEYS): + return Vector4i(int(value["x"]), int(value["y"]), int(value["z"]), int(value["w"])) + TYPE_QUATERNION: + if value is Dictionary and value.has_all(VECTOR4_KEYS): + return Quaternion(value["x"], value["y"], value["z"], value["w"]) + TYPE_RECT2: + if value is Dictionary and value.has("position") and value.has("size"): + var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2) + var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2) + if p is Vector2 and s is Vector2: + return Rect2(p, s) + TYPE_RECT2I: + if value is Dictionary and value.has("position") and value.has("size"): + var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2I) + var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2I) + if p is Vector2i and s is Vector2i: + return Rect2i(p, s) + TYPE_AABB: + if value is Dictionary and value.has("position") and value.has("size"): + var p: Variant = _coerce_value(value["position"], TYPE_VECTOR3) + var s: Variant = _coerce_value(value["size"], TYPE_VECTOR3) + if p is Vector3 and s is Vector3: + return AABB(p, s) + TYPE_PLANE: + if value is Dictionary and value.has("normal") and value.has("d"): + var n: Variant = _coerce_value(value["normal"], TYPE_VECTOR3) + if n is Vector3: + return Plane(n, float(value["d"])) + TYPE_BASIS: + if value is Dictionary and value.has_all(["x", "y", "z"]): + var bx: Variant = _coerce_value(value["x"], TYPE_VECTOR3) + var by: Variant = _coerce_value(value["y"], TYPE_VECTOR3) + var bz: Variant = _coerce_value(value["z"], TYPE_VECTOR3) + if bx is Vector3 and by is Vector3 and bz is Vector3: + return Basis(bx, by, bz) + TYPE_TRANSFORM2D: + if value is Dictionary and value.has_all(["x", "y", "origin"]): + var tx: Variant = _coerce_value(value["x"], TYPE_VECTOR2) + var ty: Variant = _coerce_value(value["y"], TYPE_VECTOR2) + var to_: Variant = _coerce_value(value["origin"], TYPE_VECTOR2) + if tx is Vector2 and ty is Vector2 and to_ is Vector2: + return Transform2D(tx, ty, to_) + TYPE_TRANSFORM3D: + if value is Dictionary and value.has("basis") and value.has("origin"): + var b: Variant = _coerce_value(value["basis"], TYPE_BASIS) + var o: Variant = _coerce_value(value["origin"], TYPE_VECTOR3) + if b is Basis and o is Vector3: + return Transform3D(b, o) + TYPE_PROJECTION: + if value is Dictionary and value.has_all(VECTOR4_KEYS): + var px: Variant = _coerce_value(value["x"], TYPE_VECTOR4) + var py: Variant = _coerce_value(value["y"], TYPE_VECTOR4) + var pz: Variant = _coerce_value(value["z"], TYPE_VECTOR4) + var pw: Variant = _coerce_value(value["w"], TYPE_VECTOR4) + if px is Vector4 and py is Vector4 and pz is Vector4 and pw is Vector4: + return Projection(px, py, pz, pw) + # PackedByteArray intentionally unhandled — needs design decision + # (base64 string vs. raw int list); JSON has no native byte type. + return value + + +## Fill a typed `Array[T]` slot from a JSON list (#612 stage 1: value-element +## types). `slot_value` is the property's current typed Array — Godot's getter +## returns the (possibly empty) typed container, which carries the element +## type, so no PROPERTY_HINT_TYPE_STRING parsing is needed. Elements coerce +## one at a time through the existing `_coerce_value` / `_check_coerced` +## pair, then bulk-move via `Array.assign()` with a post-assign size check, +## so a wrong element can never silently drop the write: it errors naming +## the element index. Returns the filled typed Array on success, or a +## `make(...)`-shaped error Dictionary (callers discriminate on +## `result is Dictionary` — a successful result is always an Array). +## +## Object elements (Array[Texture2D], Array[MyResource], ...) coerce per +## element through `_coerce_object_element` (#612 stage 2), mirroring the +## single-slot TYPE_OBJECT paths: res:// strings load, {"__class__": ...} +## instantiates, and each landed element is conformance-checked against the +## slot's element class/script so a wrong-class Resource errors naming the +## index instead of being rejected wholesale by `assign`. +static func _coerce_typed_array(value: Variant, slot_value: Array, prefix: String = "") -> Variant: + var elem_label := _typed_array_element_label(slot_value) + if not (value is Array): + var err := ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Cannot write %s to a typed Array[%s] property; expected a list" % [ + type_string(typeof(value)), elem_label, + ], + ) + return ErrorCodes.prefix_message(err, prefix) + var elem_type := slot_value.get_typed_builtin() + var staging: Array = [] + var in_list: Array = value + for i in in_list.size(): + var elem_prefix := ("element %d" % i) if prefix.is_empty() else "%s element %d" % [prefix, i] + var coerced: Variant + if elem_type == TYPE_OBJECT: + coerced = _coerce_object_element(in_list[i], elem_prefix) + if coerced is Dictionary: + ## Object elements are never legit Dictionaries (a dict input + ## is either {"__class__"} — consumed above — or an error), so + ## a Dictionary return is unambiguously the error envelope. + return coerced + if coerced != null and not _object_element_conforms(coerced, slot_value): + var conform_err := ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "element is %s, which is not a %s" % [ + (coerced as Object).get_class(), elem_label, + ], + ) + return ErrorCodes.prefix_message(conform_err, elem_prefix) + else: + coerced = _coerce_value(in_list[i], elem_type) + var elem_err := _check_coerced(coerced, elem_type, elem_prefix) + if elem_err != null: + return elem_err + if coerced == null: + ## Prefix with `elem_prefix` (which already folds in `prefix`), + ## not `prefix` again — the latter double-stamped the property + ## context (PR #682 review). Object arrays allow null entries + ## (Godot typed object arrays store null); value-type arrays + ## don't. + var null_err := ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "cannot store null in Array[%s]" % elem_label, + ) + return ErrorCodes.prefix_message(null_err, elem_prefix) + staging.append(coerced) + var out := slot_value.duplicate() + out.clear() + out.assign(staging) + if out.size() != staging.size(): + ## Backstop for element shapes `_check_coerced` waves through but the + ## typed container still rejects — `assign` loud-rejects and leaves a + ## short array, which without this check would be a partial write. + var assign_err := ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Array[%s] element conversion failed during assign (%d of %d elements landed)" % [ + elem_label, out.size(), staging.size(), + ], + ) + return ErrorCodes.prefix_message(assign_err, prefix) + return out + + +## "int" / "Vector3" / "Texture2D" / "MyItemData" — element-type name of a +## typed Array slot, for error messages. +static func _typed_array_element_label(slot_value: Array) -> String: + if slot_value.get_typed_builtin() == TYPE_OBJECT: + return _object_type_label(slot_value.get_typed_class_name(), slot_value.get_typed_script()) + return type_string(slot_value.get_typed_builtin()) + + +## Coerce one element of an object-typed Array (#612 stage 2). Mirrors the +## single-slot TYPE_OBJECT paths in set_property: a res:// path string loads +## the Resource; {"__class__": "X", ...} (including the #206 stringified +## form) instantiates via ResourceHandler and applies the remaining keys; +## "" / null store a null entry (typed object arrays allow them). Returns +## the Object (or null), or a make(...)-shaped error Dictionary with +## `elem_prefix` already folded in. +static func _coerce_object_element(elem: Variant, elem_prefix: String) -> Variant: + if elem == null: + return null + if elem is Object: + return elem + if elem is String and (elem as String).begins_with("{"): + var json := JSON.new() + if json.parse(elem) == OK and json.data is Dictionary and (json.data as Dictionary).has("__class__"): + elem = json.data + if elem is String: + if String(elem).is_empty(): + return null + var path_err = McpPathValidator.loadable_error(elem, "value") + if path_err != null: + return ErrorCodes.prefix_message(path_err, elem_prefix) + if not ResourceLoader.exists(elem): + return ErrorCodes.prefix_message( + ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % elem), + elem_prefix, + ) + var loaded := ResourceLoader.load(elem) + if loaded == null: + return ErrorCodes.prefix_message( + ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % elem), + elem_prefix, + ) + return loaded + if elem is Dictionary and (elem as Dictionary).has("__class__"): + var type_str: String = (elem as Dictionary).get("__class__", "") + var made := ResourceHandler._instantiate_resource(type_str) + if made is Dictionary: + return ErrorCodes.prefix_message(made, elem_prefix) + var res: Resource = made + var remaining: Dictionary = (elem as Dictionary).duplicate() + remaining.erase("__class__") + if not remaining.is_empty(): + var apply_err: Variant = ResourceHandler._apply_resource_properties(res, remaining) + if apply_err != null: + return ErrorCodes.prefix_message(apply_err, elem_prefix) + return res + return ErrorCodes.prefix_message( + ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + 'cannot convert %s to an Object element; pass a res:// path or {"__class__": ...}' + % type_string(typeof(elem)), + ), + elem_prefix, + ) + + +## True when `elem` satisfies the object-typed slot's element constraint — +## script type when the slot is Array[MyScriptClass], native class +## otherwise. Checked per element so a wrong-class Resource errors naming +## the index instead of being rejected wholesale by `Array.assign()`. +static func _object_element_conforms(elem: Object, slot_value: Array) -> bool: + return _object_conforms(elem, slot_value.get_typed_class_name(), slot_value.get_typed_script()) + + +## Shared class/script conformance predicate for typed Array elements and +## typed Dictionary values (#612 stages 2–3). +static func _object_conforms(elem: Object, cls_name: StringName, script: Variant) -> bool: + if script is Script: + return is_instance_of(elem, script) + var cls := String(cls_name) + return cls.is_empty() or elem.is_class(cls) + + +## Fill a typed `Dictionary[K, V]` slot from a JSON object (#612 stage 3). +## `slot_value` is the property's current typed Dictionary — the getter +## returns the (possibly empty) typed container carrying both constraint +## sides. Keys coerce via `_coerce_typed_dict_key` (JSON object keys are +## always Strings, so int/float/StringName key slots parse the string and +## fail closed on anything inexact); values mirror the typed-array element +## rules — object values through `_coerce_object_element` + conformance, +## everything else through `_coerce_value`/`_check_coerced`. Never partial: +## any bad key or value errors naming the key and nothing is written. +## +## Returns the filled TYPED Dictionary on success or an UNTYPED +## `make(...)`-shaped error Dictionary — callers discriminate on +## `is_typed()`, since a success result is always a duplicate of the typed +## slot and error envelopes are plain dicts. +static func _coerce_typed_dictionary( + value: Variant, slot_value: Dictionary, prefix: String = "" +) -> Dictionary: + var label := _typed_dictionary_label(slot_value) + if not (value is Dictionary): + var err := ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Cannot write %s to a typed %s property; expected an object" % [ + type_string(typeof(value)), label, + ], + ) + return ErrorCodes.prefix_message(err, prefix) + var key_type := slot_value.get_typed_key_builtin() if slot_value.is_typed_key() else TYPE_NIL + var value_type := ( + slot_value.get_typed_value_builtin() if slot_value.is_typed_value() else TYPE_NIL + ) + var out := slot_value.duplicate() + out.clear() + var in_dict: Dictionary = value + for raw_key in in_dict.keys(): + var key_prefix := ( + 'key "%s"' % str(raw_key) if prefix.is_empty() + else '%s key "%s"' % [prefix, str(raw_key)] + ) + var key: Variant = _coerce_typed_dict_key(raw_key, key_type, label, key_prefix) + if key is Dictionary: + ## Scalar-only key coercion never returns a legit Dictionary key, + ## so a Dictionary here is unambiguously the error envelope. + return key + var raw_value: Variant = in_dict[raw_key] + var coerced: Variant + if value_type == TYPE_NIL: + ## Untyped value side (e.g. Dictionary[String, Variant]). + coerced = raw_value + elif value_type == TYPE_OBJECT: + coerced = _coerce_object_element(raw_value, key_prefix) + if coerced is Dictionary: + return coerced + if coerced != null and not _object_conforms( + coerced, + slot_value.get_typed_value_class_name(), + slot_value.get_typed_value_script(), + ): + var conform_err := ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "value is %s, which is not a %s" % [ + (coerced as Object).get_class(), + _object_type_label( + slot_value.get_typed_value_class_name(), + slot_value.get_typed_value_script(), + ), + ], + ) + return ErrorCodes.prefix_message(conform_err, key_prefix) + else: + coerced = _coerce_value(raw_value, value_type) + var value_err := _check_coerced(coerced, value_type, key_prefix) + if value_err != null: + return value_err + if coerced == null: + var null_err := ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "cannot store null as a %s value in %s" % [type_string(value_type), label], + ) + return ErrorCodes.prefix_message(null_err, key_prefix) + out[key] = coerced + if out.size() != in_dict.size(): + ## Two input keys collapsing onto one coerced key ("1" and "01" both + ## parse to int 1) would silently lose an entry — refuse instead. + var collide_err := ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "%s keys collide after coercion (%d of %d entries landed)" % [ + label, out.size(), in_dict.size(), + ], + ) + return ErrorCodes.prefix_message(collide_err, prefix) + return out + + +## Coerce one JSON-object key onto a typed Dictionary's key slot. JSON keys +## are always Strings, so int/float/StringName key types accept exactly the +## strings that parse cleanly; everything else fails closed naming the key. +## Object/compound key types are unreachable from JSON and refuse loudly. +static func _coerce_typed_dict_key( + raw_key: Variant, key_type: int, label: String, key_prefix: String +) -> Variant: + if key_type == TYPE_NIL or typeof(raw_key) == key_type: + return raw_key + if raw_key is String: + var key_str := raw_key as String + match key_type: + TYPE_STRING_NAME: + return StringName(key_str) + TYPE_INT: + if key_str.is_valid_int(): + return int(key_str) + TYPE_FLOAT: + if key_str.is_valid_float(): + return float(key_str) + ## String key that didn't parse: JSON object keys are ALWAYS strings, + ## so blaming the String-ness would imply the caller could somehow + ## send a non-string key — name the expected key type instead. + var parse_err := ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "key does not parse as %s (the %s key type)" % [type_string(key_type), label], + ) + return ErrorCodes.prefix_message(parse_err, key_prefix) + if raw_key is float and key_type == TYPE_INT and is_equal_approx(raw_key, roundf(raw_key)): + ## Whole JSON numbers arrive as floats through some non-JSON callers. + return int(raw_key) + var err := ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "cannot use %s as a %s key" % [type_string(typeof(raw_key)), label], + ) + return ErrorCodes.prefix_message(err, key_prefix) + + +## "Dictionary[String, int]" / "Dictionary[int, Texture2D]" — for error +## messages. Untyped sides read as Variant. +static func _typed_dictionary_label(slot_value: Dictionary) -> String: + var key_label := "Variant" + if slot_value.is_typed_key(): + key_label = ( + _object_type_label( + slot_value.get_typed_key_class_name(), slot_value.get_typed_key_script() + ) + if slot_value.get_typed_key_builtin() == TYPE_OBJECT + else type_string(slot_value.get_typed_key_builtin()) + ) + var value_label := "Variant" + if slot_value.is_typed_value(): + value_label = ( + _object_type_label( + slot_value.get_typed_value_class_name(), slot_value.get_typed_value_script() + ) + if slot_value.get_typed_value_builtin() == TYPE_OBJECT + else type_string(slot_value.get_typed_value_builtin()) + ) + return "Dictionary[%s, %s]" % [key_label, value_label] + + +## Class/script display name for an object-typed constraint side. +static func _object_type_label(cls_name: StringName, script: Variant) -> String: + var cls := String(cls_name) + if script is Script and not String((script as Script).get_global_name()).is_empty(): + cls = String((script as Script).get_global_name()) + return cls if not cls.is_empty() else "Object" + + +func get_node_properties(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + # Optional token-reducing filter: `fields` restricts the response to a + # named subset. Defaults off (empty), so existing callers see the full + # dump unchanged. The MCP tool types this as a list, but batch_execute and + # raw callers bypass that, so validate the shape here before iterating. + var fields_param: Variant = params.get("fields", []) + if fields_param == null: + fields_param = [] + if not (fields_param is Array): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "'fields' must be an array of property names, got %s (%s)" % [ + type_string(typeof(fields_param)), str(fields_param), + ], + ) + var field_filter := {} + for f in fields_param: + ## Property names are strings on the wire; anything else is a + ## malformed filter (e.g. [123] or [["fov"]]) — reject rather than + ## silently stringify into a filter that matches nothing (#123/#126: + ## strict within the accepted shape). StringName is allowed for + ## editor-side callers. + if not (f is String or f is StringName): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "'fields' elements must be property-name strings, got %s in %s" % [ + type_string(typeof(f)), str(fields_param), + ], + ) + field_filter[str(f)] = true + var use_field_filter := not field_filter.is_empty() + + var properties: Array[Dictionary] = [] + var editor_property_count := 0 + var matched_fields := {} + for prop in node.get_property_list(): + var usage: int = prop.get("usage", 0) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + editor_property_count += 1 + if use_field_filter: + if not field_filter.has(prop.name): + continue + matched_fields[prop.name] = true + # Null reads are values, not omissions: `script` on an unscripted node + # and unset Resource slots (mesh, material, …) read back null and must + # appear as "value": null with their declared type, so callers can tell + # "Object-typed, currently unset" from "doesn't exist" (#771). + properties.append({ + "name": prop.name, + "type": type_string(prop.type), + "value": _serialize_value(node.get(prop.name)), + }) + # Requested names that matched no editor-visible property — distinguishes + # "you asked for something that doesn't exist" from "exists and is null". + var unknown_fields: Array[String] = [] + for f in field_filter: + if not matched_fields.has(f): + unknown_fields.append(f) + return { + "data": { + "path": node_path, + "node_type": node.get_class(), + "properties": properties, + "count": properties.size(), + # Total editor-visible properties before field filtering, so a + # caller that passed `fields` knows how many were withheld. + # Invariant: an unfiltered call returns every editor-visible + # property, so count == total_count; only the `fields` filter + # can make count < total_count. + "total_count": editor_property_count, + "unknown_fields": unknown_fields, + } + } + + +func get_children(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var scene_root: Node = resolved.scene_root + + var children: Array[Dictionary] = [] + for child in node.get_children(): + children.append({ + "name": child.name, + "type": child.get_class(), + "path": McpScenePath.from_node(child, scene_root), + "children_count": child.get_child_count(), + }) + return { + "data": { + "parent_path": node_path, + "children": children, + "count": children.size(), + } + } + + +func get_groups(params: Dictionary) -> Dictionary: + var resolved := _resolve_node(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + + var groups: Array[String] = [] + for group in node.get_groups(): + # Skip internal groups (start with underscore) + if not str(group).begins_with("_"): + groups.append(str(group)) + return { + "data": { + "path": node_path, + "groups": groups, + "count": groups.size(), + } + } + + +## Validate path param, resolve to node. Returns dict with node/path/scene_root +## on success, or an error dict (has "error" key) on failure. Thin wrapper +## around the shared `McpNodeValidator.resolve_or_error` helper (audit-v2 #20). +func _resolve_node(params: Dictionary) -> Dictionary: + return McpNodeValidator.resolve_or_error( + params.get("path", ""), "path", params.get("scene_file", ""), + ) + + +## Reject operations targeting the scene root. Returns an INVALID_PARAMS error +## dict with "Cannot the scene root", or null if `node` is not the root. +static func _reject_if_scene_root(node: Node, scene_root: Node, op: String) -> Variant: + if node == scene_root: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot %s the scene root" % op) + return null + + +## Convert a Godot Variant to a JSON-safe value. Compound geometry types +## (AABB, Rect2, Transforms, …) and packed arrays serialize as structured +## dicts/arrays so agents can inspect fields instead of parsing Godot's +## debug repr — see issue #214. +static func _serialize_value(value: Variant) -> Variant: + return VariantSerializer.serialize(value) diff --git a/addons/godot_ai/handlers/node_handler.gd.uid b/addons/godot_ai/handlers/node_handler.gd.uid new file mode 100644 index 0000000..86149b4 --- /dev/null +++ b/addons/godot_ai/handlers/node_handler.gd.uid @@ -0,0 +1 @@ +uid://qhhd5mm5awym diff --git a/addons/godot_ai/handlers/particle_handler.gd b/addons/godot_ai/handlers/particle_handler.gd new file mode 100644 index 0000000..5604355 --- /dev/null +++ b/addons/godot_ai/handlers/particle_handler.gd @@ -0,0 +1,860 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles particle emitter authoring (GPU + CPU, 2D + 3D). +## +## All write operations bundle node creation and sub-resource spawns +## (ParticleProcessMaterial, default QuadMesh) in a single create_action +## so Ctrl-Z rolls back the whole effect atomically. + +const ParticleValues := preload("res://addons/godot_ai/handlers/particle_values.gd") +const ParticlePresets := preload("res://addons/godot_ai/handlers/particle_presets.gd") + +const _VALID_TYPES := { + "gpu_3d": "GPUParticles3D", + "gpu_2d": "GPUParticles2D", + "cpu_3d": "CPUParticles3D", + "cpu_2d": "CPUParticles2D", +} + +const _MAIN_KEYS := [ + "amount", + "lifetime", + "one_shot", + "explosiveness", + "preprocess", + "speed_scale", + "randomness", + "fixed_fps", + "emitting", + "local_coords", + "interp_to_end", +] + + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +# ============================================================================ +# particle_create +# ============================================================================ + +func create_particle(params: Dictionary) -> Dictionary: + var parent_path: String = params.get("parent_path", "") + var node_name: String = params.get("name", "Particles") + var type_str: String = params.get("type", "gpu_3d") + + if not _VALID_TYPES.has(type_str): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid particle type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] + ) + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + var node := _instantiate_particle(type_str) + if node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate particle node") + if not node_name.is_empty(): + node.name = node_name + + var process_mat: ParticleProcessMaterial = null + var process_material_created := false + var draw_mesh: Mesh = null + var draw_material: StandardMaterial3D = null + var draw_pass_mesh_created := false + var draw_material_created := false + + if type_str == "gpu_3d" or type_str == "gpu_2d": + process_mat = ParticleProcessMaterial.new() + process_material_created = true + if type_str == "gpu_3d": + draw_mesh = QuadMesh.new() + (draw_mesh as QuadMesh).size = Vector2(0.25, 0.25) + # Without a material, the mesh renders flat white — ignoring + # ParticleProcessMaterial.color_ramp entirely. Give it the standard + # billboard + vertex-color-as-albedo setup so color_ramp works. + draw_material = ParticleValues.build_draw_material({}).material + (draw_mesh as QuadMesh).material = draw_material + draw_pass_mesh_created = true + draw_material_created = true + + _undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name]) + _undo_redo.add_do_method(parent, "add_child", node, true) + _undo_redo.add_do_method(node, "set_owner", scene_root) + if process_mat != null: + _undo_redo.add_do_property(node, "process_material", process_mat) + _undo_redo.add_do_reference(process_mat) + if draw_mesh != null: + _undo_redo.add_do_property(node, "draw_pass_1", draw_mesh) + _undo_redo.add_do_reference(draw_mesh) + if draw_material != null: + _undo_redo.add_do_reference(draw_material) + _undo_redo.add_do_reference(node) + _undo_redo.add_undo_method(parent, "remove_child", node) + _undo_redo.commit_action() + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "parent_path": McpScenePath.from_node(parent, scene_root), + "name": String(node.name), + "type": type_str, + "class": _VALID_TYPES[type_str], + "process_material_created": process_material_created, + "draw_pass_mesh_created": draw_pass_mesh_created, + "draw_material_created": draw_material_created, + "undoable": true, + } + } + + +# ============================================================================ +# particle_set_main +# ============================================================================ + +func set_main(params: Dictionary) -> Dictionary: + var resolved := _resolve_particle(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + + var properties: Dictionary = params.get("properties", {}) + if properties.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty") + + var coerced: Dictionary = {} + var old_values: Dictionary = {} + for property in properties: + var prop_name: String = String(property) + if not (prop_name in _MAIN_KEYS): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Unknown main property '%s'. Valid: %s" % [prop_name, ", ".join(_MAIN_KEYS)] + ) + var prop_type := _node_property_type(node, prop_name) + if prop_type == TYPE_NIL: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Property '%s' not present on %s" % [prop_name, node.get_class()] + ) + var coerce_result := ParticleValues.coerce(prop_name, properties[prop_name], prop_type) + if not coerce_result.ok: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) + coerced[prop_name] = coerce_result.value + old_values[prop_name] = node.get(prop_name) + + _undo_redo.create_action("MCP: Set particle main on %s" % node.name) + for prop_name in coerced: + _undo_redo.add_do_property(node, prop_name, coerced[prop_name]) + _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) + _undo_redo.commit_action() + + var applied: Array[String] = [] + var serialized_values: Dictionary = {} + for prop_name in coerced: + applied.append(prop_name) + serialized_values[prop_name] = ParticleValues.serialize(coerced[prop_name]) + + return { + "data": { + "path": node_path, + "applied": applied, + "values": serialized_values, + "undoable": true, + } + } + + +# ============================================================================ +# particle_set_process +# ============================================================================ + +func set_process(params: Dictionary) -> Dictionary: + var resolved := _resolve_particle(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + + var properties: Dictionary = params.get("properties", {}) + if properties.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty") + + # GPU: work through process_material; CPU: properties live on node directly. + if node is GPUParticles3D or node is GPUParticles2D: + return _set_process_gpu(node, node_path, properties) + return _set_process_cpu(node, node_path, properties) + + +func _set_process_gpu(node: Node, node_path: String, properties: Dictionary) -> Dictionary: + var existing_mat: ParticleProcessMaterial = node.process_material as ParticleProcessMaterial + var process_material_created := false + var mat: ParticleProcessMaterial = existing_mat + if mat == null: + mat = ParticleProcessMaterial.new() + process_material_created = true + + var coerced: Dictionary = {} + for property in properties: + var prop_name: String = String(property) + var prop_type := _object_property_type(mat, prop_name) + if prop_type == TYPE_NIL: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Property '%s' not present on ParticleProcessMaterial" % prop_name + ) + var coerce_result := ParticleValues.coerce(prop_name, properties[prop_name], prop_type) + if not coerce_result.ok: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) + coerced[prop_name] = coerce_result.value + + _undo_redo.create_action("MCP: Set particle process on %s" % node.name) + if process_material_created: + _undo_redo.add_do_property(node, "process_material", mat) + _undo_redo.add_undo_property(node, "process_material", null) + _undo_redo.add_do_reference(mat) + # Apply new values directly on the (newly created) material. No old values to restore. + for prop_name in coerced: + mat.set(prop_name, coerced[prop_name]) + else: + # Use the reusable apply/restore pattern for existing material. + var old_values: Dictionary = {} + for prop_name in coerced: + old_values[prop_name] = mat.get(prop_name) + for prop_name in coerced: + _undo_redo.add_do_property(mat, prop_name, coerced[prop_name]) + _undo_redo.add_undo_property(mat, prop_name, old_values[prop_name]) + _undo_redo.commit_action() + + var applied: Array[String] = [] + var serialized: Dictionary = {} + for prop_name in coerced: + applied.append(prop_name) + serialized[prop_name] = ParticleValues.serialize(mat.get(prop_name)) + + return { + "data": { + "path": node_path, + "applied": applied, + "values": serialized, + "process_material_created": process_material_created, + "undoable": true, + } + } + + +func _set_process_cpu(node: Node, node_path: String, properties: Dictionary) -> Dictionary: + # CPU particles expose the same property vocabulary directly on the node, + # so property names pass through unchanged. + var coerced: Dictionary = {} + var old_values: Dictionary = {} + + for property in properties: + var prop_name: String = String(property) + var prop_type := _node_property_type(node, prop_name) + if prop_type == TYPE_NIL: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Property '%s' not present on %s" % [prop_name, node.get_class()] + ) + var coerce_result := ParticleValues.coerce(prop_name, properties[property], prop_type) + if not coerce_result.ok: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) + coerced[prop_name] = coerce_result.value + old_values[prop_name] = node.get(prop_name) + + _undo_redo.create_action("MCP: Set particle process on %s" % node.name) + for prop_name in coerced: + _undo_redo.add_do_property(node, prop_name, coerced[prop_name]) + _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) + _undo_redo.commit_action() + + var applied: Array[String] = [] + var serialized: Dictionary = {} + for prop_name in coerced: + applied.append(prop_name) + serialized[prop_name] = ParticleValues.serialize(coerced[prop_name]) + + return { + "data": { + "path": node_path, + "applied": applied, + "values": serialized, + "process_material_created": false, + "undoable": true, + } + } + + +# ============================================================================ +# particle_set_draw_pass +# ============================================================================ + +func set_draw_pass(params: Dictionary) -> Dictionary: + var resolved := _resolve_particle(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + + var pass_idx: int = int(params.get("pass", 1)) + var mesh_path: String = params.get("mesh", "") + var texture_path: String = params.get("texture", "") + var material_path: String = params.get("material", "") + + if node is GPUParticles3D: + return _set_draw_pass_gpu_3d(node, node_path, pass_idx, mesh_path, material_path) + if node is CPUParticles3D: + return _set_draw_pass_cpu_3d(node, node_path, mesh_path, material_path) + if node is GPUParticles2D or node is CPUParticles2D: + return _set_draw_pass_2d(node, node_path, texture_path) + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Node %s is not a particle node" % node.get_class()) + + +func _set_draw_pass_gpu_3d(node: GPUParticles3D, node_path: String, pass_idx: int, mesh_path: String, material_path: String) -> Dictionary: + if pass_idx < 1 or pass_idx > 4: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "pass must be 1..4 (got %d)" % pass_idx) + + var mesh: Mesh = null + var mesh_created := false + var property_name := "draw_pass_%d" % pass_idx + # draw_pass_N is only a live property when draw_passes >= N. Probe via + # get_property_list so we don't read a ghost value. + var existing_mesh: Mesh = null + if int(node.draw_passes) >= pass_idx: + existing_mesh = node.get(property_name) as Mesh + if not mesh_path.is_empty(): + var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path") + if mesh_path_err != null: + return mesh_path_err + if not ResourceLoader.exists(mesh_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path) + var loaded := ResourceLoader.load(mesh_path) + if not (loaded is Mesh): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Mesh" % mesh_path) + mesh = loaded + else: + if existing_mesh == null: + mesh = QuadMesh.new() + (mesh as QuadMesh).size = Vector2(0.25, 0.25) + mesh_created = true + else: + mesh = existing_mesh + + var material: Material = null + if not material_path.is_empty(): + var material_path_err = McpPathValidator.loadable_error(material_path, "material_path") + if material_path_err != null: + return material_path_err + if not ResourceLoader.exists(material_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path) + var loaded_mat := ResourceLoader.load(material_path) + if not (loaded_mat is Material): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % material_path) + material = loaded_mat + + var old_draw_passes: int = int(node.draw_passes) + var new_draw_passes: int = max(old_draw_passes, pass_idx) + var old_value = existing_mesh # Null if draw_passes < pass_idx + var old_material: Material = null + if material != null: + old_material = node.material_override + + _undo_redo.create_action("MCP: Set %s.draw_pass_%d" % [node.name, pass_idx]) + # Grow draw_passes first so draw_pass_N property exists before we set it. + if new_draw_passes != old_draw_passes: + _undo_redo.add_do_property(node, "draw_passes", new_draw_passes) + _undo_redo.add_undo_property(node, "draw_passes", old_draw_passes) + if not mesh_path.is_empty() or mesh_created: + _undo_redo.add_do_property(node, property_name, mesh) + _undo_redo.add_undo_property(node, property_name, old_value) + if mesh_created: + _undo_redo.add_do_reference(mesh) + if material != null: + _undo_redo.add_do_property(node, "material_override", material) + _undo_redo.add_undo_property(node, "material_override", old_material) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "pass": pass_idx, + "mesh_path": mesh_path, + "mesh_class": mesh.get_class() if mesh else "", + "material_path": material_path, + "draw_pass_mesh_created": mesh_created, + "draw_passes_grown": new_draw_passes != old_draw_passes, + "undoable": true, + } + } + + +func _set_draw_pass_cpu_3d(node: CPUParticles3D, node_path: String, mesh_path: String, material_path: String) -> Dictionary: + if mesh_path.is_empty() and material_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "CPUParticles3D requires mesh or material param") + + var mesh: Mesh = node.mesh + var old_mesh: Mesh = mesh + if not mesh_path.is_empty(): + var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path") + if mesh_path_err != null: + return mesh_path_err + if not ResourceLoader.exists(mesh_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path) + var loaded := ResourceLoader.load(mesh_path) + if not (loaded is Mesh): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Mesh" % mesh_path) + mesh = loaded + + var material: Material = null + var old_material: Material = node.material_override + if not material_path.is_empty(): + var material_path_err = McpPathValidator.loadable_error(material_path, "material_path") + if material_path_err != null: + return material_path_err + if not ResourceLoader.exists(material_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path) + var loaded_mat := ResourceLoader.load(material_path) + if not (loaded_mat is Material): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % material_path) + material = loaded_mat + + _undo_redo.create_action("MCP: Set CPU particle draw on %s" % node.name) + if not mesh_path.is_empty(): + _undo_redo.add_do_property(node, "mesh", mesh) + _undo_redo.add_undo_property(node, "mesh", old_mesh) + if material != null: + _undo_redo.add_do_property(node, "material_override", material) + _undo_redo.add_undo_property(node, "material_override", old_material) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "mesh_path": mesh_path, + "material_path": material_path, + "draw_pass_mesh_created": false, + "undoable": true, + } + } + + +func _set_draw_pass_2d(node: Node, node_path: String, texture_path: String) -> Dictionary: + if texture_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "2D particles require texture param") + var texture_path_err = McpPathValidator.loadable_error(texture_path, "texture_path") + if texture_path_err != null: + return texture_path_err + if not ResourceLoader.exists(texture_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path) + var tex := ResourceLoader.load(texture_path) + if not (tex is Texture2D): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Texture2D" % texture_path) + + var old_texture: Texture2D = node.get("texture") + + _undo_redo.create_action("MCP: Set 2D particle texture on %s" % node.name) + _undo_redo.add_do_property(node, "texture", tex) + _undo_redo.add_undo_property(node, "texture", old_texture) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "texture_path": texture_path, + "undoable": true, + } + } + + +# ============================================================================ +# particle_restart +# ============================================================================ + +func restart_particle(params: Dictionary) -> Dictionary: + var resolved := _resolve_particle(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + if node.has_method("restart"): + node.restart() + return { + "data": { + "path": node_path, + "undoable": false, + "reason": "Restart is a runtime operation, not tracked in undo history", + } + } + + +# ============================================================================ +# particle_get +# ============================================================================ + +func get_particle(params: Dictionary) -> Dictionary: + var resolved := _resolve_particle(params) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + + var type_str := "" + for key in _VALID_TYPES: + if node.get_class() == _VALID_TYPES[key]: + type_str = key + break + + var main_values: Dictionary = {} + var node_prop_names := _property_names(node) + for key in _MAIN_KEYS: + if node_prop_names.has(key): + main_values[key] = ParticleValues.serialize(node.get(key)) + + var process_data: Dictionary = {} + if node is GPUParticles3D or node is GPUParticles2D: + var mat: ParticleProcessMaterial = node.process_material as ParticleProcessMaterial + if mat != null: + var process_props: Dictionary = {} + for prop in mat.get_property_list(): + var usage: int = prop.get("usage", 0) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + var v = mat.get(prop.name) + if v == null: + continue + process_props[prop.name] = ParticleValues.serialize(v) + process_data = { + "class": "ParticleProcessMaterial", + "properties": process_props, + } + + var draw_passes: Array[Dictionary] = [] + if node is GPUParticles3D: + var active_draw_pass_count: int = min(int(node.draw_passes), 4) + for i in range(1, active_draw_pass_count + 1): + var prop_name := "draw_pass_%d" % i + var m: Mesh = node.get(prop_name) as Mesh + draw_passes.append({ + "pass": i, + "mesh_class": m.get_class() if m != null else "", + }) + + var texture_path := "" + if node is GPUParticles2D or node is CPUParticles2D: + var t: Texture2D = node.get("texture") + if t != null: + texture_path = t.resource_path + + return { + "data": { + "path": node_path, + "type": type_str, + "class": node.get_class(), + "main": main_values, + "process": process_data, + "draw_passes": draw_passes, + "texture_path": texture_path, + } + } + + +# ============================================================================ +# particle_apply_preset +# ============================================================================ + +func apply_preset(params: Dictionary) -> Dictionary: + var preset_name: String = params.get("preset", "") + if preset_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset") + + var overrides: Dictionary = params.get("overrides", {}) + var blueprint = ParticlePresets.build(preset_name, overrides) + if blueprint == null: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(ParticlePresets.list())] + ) + + var parent_path: String = params.get("parent_path", "") + var node_name: String = params.get("name", "") + var type_str: String = params.get("type", "gpu_3d") + if node_name.is_empty(): + node_name = preset_name.capitalize() + if not _VALID_TYPES.has(type_str): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid particle type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] + ) + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var parent: Node = scene_root + if not parent_path.is_empty(): + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + var node := _instantiate_particle(type_str) + node.name = node_name + + var is_gpu := type_str == "gpu_3d" or type_str == "gpu_2d" + var is_3d := type_str == "gpu_3d" or type_str == "cpu_3d" + + var process_mat: ParticleProcessMaterial = null + var process_material_created := false + if is_gpu: + process_mat = ParticleProcessMaterial.new() + process_material_created = true + + # User-supplied override keys per group. Preset-blueprint keys may skip + # silently on types they don't apply to (presets are cross-type by + # design); user-requested overrides must apply or error (#770). + var user_keys: Dictionary = blueprint.get("user_keys", {}) + var user_main: Dictionary = user_keys.get("main", {}) + var user_process: Dictionary = user_keys.get("process", {}) + var user_draw: Dictionary = user_keys.get("draw", {}) + + var draw_mesh: Mesh = null + var draw_material: StandardMaterial3D = null + var draw_pass_mesh_created := false + var draw_material_created := false + var applied_draw: Array[String] = [] + var draw_config: Dictionary = blueprint.get("draw", {}) + if type_str == "gpu_3d": + var draw_result := ParticleValues.build_draw_material(draw_config) + if not draw_result.ok: + node.free() + var msg := String(draw_result.error) + if draw_result.get("unknown_key", false): + msg = _draw_key_unsupported_message(String(draw_result.key), type_str) + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg) + draw_mesh = QuadMesh.new() + (draw_mesh as QuadMesh).size = Vector2(0.25, 0.25) + draw_material = draw_result.material + (draw_mesh as QuadMesh).material = draw_material + draw_pass_mesh_created = true + draw_material_created = true + for applied_key in draw_result.applied: + applied_draw.append(String(applied_key)) + elif type_str == "gpu_2d": + # GPUParticles2D has no draw-pass material; the one draw override it + # supports is draw.texture → the node's texture property. + for key in user_draw: + if String(key) != "texture": + node.free() + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + _draw_key_unsupported_message(String(key), type_str) + ) + if user_draw.has("texture"): + var tex_result := _load_draw_texture(draw_config.get("texture")) + if tex_result.has("error"): + node.free() + return tex_result + node.set("texture", tex_result.texture) + applied_draw.append("texture") + else: + # cpu_3d / cpu_2d: no draw support — reject user draw overrides + # instead of dropping them. + for key in user_draw: + node.free() + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + _draw_key_unsupported_message(String(key), type_str) + ) + + # Pre-apply preset values to in-memory targets (no undo needed; nodes not in tree yet). + var main_values: Dictionary = blueprint.get("main", {}) + var process_values: Dictionary = blueprint.get("process", {}) + var applied_main: Array[String] = [] + var applied_process: Array[String] = [] + + for prop in main_values: + var prop_name := String(prop) + var prop_type := _object_property_type(node, prop_name) + if prop_type == TYPE_NIL: + if user_main.has(prop_name): + var node_class := node.get_class() + node.free() + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Override main.%s does not apply to type '%s' (no such property on %s)" % [ + prop_name, type_str, node_class + ] + ) + continue # Blueprint key: not all main keys apply to all types. + var coerce_result := ParticleValues.coerce(prop_name, main_values[prop_name], prop_type) + if not coerce_result.ok: + node.free() + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) + node.set(prop_name, coerce_result.value) + applied_main.append(prop_name) + + # Apply process: GPU targets the ParticleProcessMaterial; CPU targets the node. + var process_target: Object = process_mat if is_gpu else node + for prop in process_values: + var prop_name := String(prop) + var prop_type := _object_property_type(process_target, prop_name) + if prop_type == TYPE_NIL: + if user_process.has(prop_name): + var target_class := process_target.get_class() + node.free() + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Override process.%s does not apply to type '%s' (no such property on %s)" % [ + prop_name, type_str, target_class + ] + ) + continue # Blueprint key: preset property doesn't apply to this variant. + var coerce_result := ParticleValues.coerce(prop_name, process_values[prop_name], prop_type) + if not coerce_result.ok: + node.free() + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) + process_target.set(prop_name, coerce_result.value) + applied_process.append(prop_name) + + _undo_redo.create_action("MCP: Apply preset %s" % preset_name) + _undo_redo.add_do_method(parent, "add_child", node, true) + _undo_redo.add_do_method(node, "set_owner", scene_root) + _undo_redo.add_do_reference(node) + if process_mat != null: + _undo_redo.add_do_property(node, "process_material", process_mat) + _undo_redo.add_do_reference(process_mat) + if draw_mesh != null: + _undo_redo.add_do_property(node, "draw_pass_1", draw_mesh) + _undo_redo.add_do_reference(draw_mesh) + if draw_material != null: + _undo_redo.add_do_reference(draw_material) + _undo_redo.add_undo_method(parent, "remove_child", node) + _undo_redo.commit_action() + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "parent_path": McpScenePath.from_node(parent, scene_root), + "name": node_name, + "preset": preset_name, + "type": type_str, + "class": _VALID_TYPES[type_str], + "applied_main": applied_main, + "applied_process": applied_process, + "applied_draw": applied_draw, + "process_material_created": process_material_created, + "draw_pass_mesh_created": draw_pass_mesh_created, + "draw_material_created": draw_material_created, + "is_3d": is_3d, + "undoable": true, + } + } + + +# ============================================================================ +# Helpers +# ============================================================================ + +## Actionable rejection for a user draw override that can't apply to the +## selected particle type: name the key and where it IS supported. +static func _draw_key_unsupported_message(key: String, type_str: String) -> String: + if key == "texture": + return "draw.texture is only supported for gpu_2d (got type '%s')" % type_str + var probe := StandardMaterial3D.new() + if _object_property_type(probe, key) != TYPE_NIL: + return "draw.%s is only supported for gpu_3d (got type '%s')" % [key, type_str] + return ( + "Unknown draw key '%s' (draw overrides configure the gpu_3d " + + "draw-pass StandardMaterial3D; gpu_2d supports only draw.texture)" + ) % key + + +## Load a Texture2D for the gpu_2d draw.texture override. Returns +## {texture: Texture2D} or an error dict (same validation as set_draw_pass). +static func _load_draw_texture(value: Variant) -> Dictionary: + if not (value is String) or String(value).is_empty(): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "draw.texture must be a non-empty res:// path string (got %s)" % type_string(typeof(value)) + ) + var texture_path := String(value) + var texture_path_err = McpPathValidator.loadable_error(texture_path, "draw.texture") + if texture_path_err != null: + return texture_path_err + if not ResourceLoader.exists(texture_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path) + var tex := ResourceLoader.load(texture_path) + if not (tex is Texture2D): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Texture2D" % texture_path) + return {"texture": tex} + + +static func _instantiate_particle(type_str: String) -> Node: + match type_str: + "gpu_3d": + return GPUParticles3D.new() + "gpu_2d": + return GPUParticles2D.new() + "cpu_3d": + return CPUParticles3D.new() + "cpu_2d": + return CPUParticles2D.new() + return null + + +func _resolve_particle(params: Dictionary) -> Dictionary: + var resolved := McpNodeValidator.resolve_or_error( + params.get("node_path", ""), "node_path", + ) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + var node_path: String = resolved.path + var is_particle := node is GPUParticles3D or node is GPUParticles2D \ + or node is CPUParticles3D or node is CPUParticles2D + if not is_particle: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node %s is not a particle node (got %s)" % [node_path, node.get_class()] + ) + return {"node": node, "path": node_path} + + +static func _node_property_type(node: Object, name: String) -> int: + return _object_property_type(node, name) + + +static func _object_property_type(obj: Object, name: String) -> int: + if obj == null: + return TYPE_NIL + for prop in obj.get_property_list(): + if prop.name == name: + return int(prop.get("type", TYPE_NIL)) + return TYPE_NIL + + +static func _property_names(obj: Object) -> Dictionary: + var out: Dictionary = {} + if obj == null: + return out + for prop in obj.get_property_list(): + out[prop.name] = true + return out diff --git a/addons/godot_ai/handlers/particle_handler.gd.uid b/addons/godot_ai/handlers/particle_handler.gd.uid new file mode 100644 index 0000000..ead3722 --- /dev/null +++ b/addons/godot_ai/handlers/particle_handler.gd.uid @@ -0,0 +1 @@ +uid://byfc0pnyb5qww diff --git a/addons/godot_ai/handlers/particle_presets.gd b/addons/godot_ai/handlers/particle_presets.gd new file mode 100644 index 0000000..3bbf1f4 --- /dev/null +++ b/addons/godot_ai/handlers/particle_presets.gd @@ -0,0 +1,293 @@ +@tool +extends RefCounted + +## Curated particle effect blueprints. +## +## Each preset returns {main, process, draw}. The handler applies them +## through the normal write path (one undo action wraps all spawns). + + +## Each preset has {main, process, draw}. `draw` configures the StandardMaterial3D +## attached to the auto-created QuadMesh in draw_pass_1 (GPU 3D only); if +## omitted, the handler falls back to a sensible billboard-particles default. +## `blend_mode: "add"` is what makes fire/magic/explosion glow — without +## additive blending, additively-layered particles just stack to gray. +const _PRESETS := { + "fire": { + "main": { + "amount": 80, + "lifetime": 1.2, + "one_shot": false, + "explosiveness": 0.0, + "preprocess": 0.5, + "local_coords": false, + }, + "process": { + "emission_shape": "sphere", + "emission_sphere_radius": 0.3, + "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, + "spread": 15.0, + "initial_velocity_min": 2.0, + "initial_velocity_max": 4.0, + "gravity": {"x": 0.0, "y": 1.0, "z": 0.0}, # buoyancy + "scale_min": 0.4, + "scale_max": 0.8, + "color_ramp": { + "stops": [ + {"time": 0.0, "color": [1.0, 1.0, 0.9, 1.0]}, + {"time": 0.3, "color": [1.0, 0.6, 0.1, 1.0]}, + {"time": 0.7, "color": [0.8, 0.1, 0.05, 0.7]}, + {"time": 1.0, "color": [0.2, 0.05, 0.05, 0.0]}, + ] + }, + }, + "draw": {"blend_mode": "add"}, + }, + "smoke": { + "main": { + "amount": 40, + "lifetime": 3.0, + "one_shot": false, + "explosiveness": 0.0, + "local_coords": false, + }, + "process": { + "emission_shape": "sphere", + "emission_sphere_radius": 0.4, + "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, + "spread": 20.0, + "initial_velocity_min": 0.5, + "initial_velocity_max": 1.5, + "gravity": {"x": 0.0, "y": 0.2, "z": 0.0}, + "scale_min": 0.6, + "scale_max": 1.4, + "color_ramp": { + "stops": [ + {"time": 0.0, "color": [0.3, 0.3, 0.3, 0.0]}, + {"time": 0.25, "color": [0.35, 0.35, 0.35, 0.7]}, + {"time": 0.75, "color": [0.2, 0.2, 0.2, 0.5]}, + {"time": 1.0, "color": [0.1, 0.1, 0.1, 0.0]}, + ] + }, + }, + # Smoke uses regular alpha blending so it darkens the background. + "draw": {"blend_mode": "mix"}, + }, + "spark_burst": { + "main": { + "amount": 60, + "lifetime": 0.8, + "one_shot": true, + "explosiveness": 1.0, + "local_coords": false, + }, + "process": { + "emission_shape": "point", + "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, + "spread": 180.0, + "initial_velocity_min": 5.0, + "initial_velocity_max": 12.0, + "gravity": {"x": 0.0, "y": -9.8, "z": 0.0}, + "scale_min": 0.05, + "scale_max": 0.12, + "color": {"r": 1.0, "g": 0.9, "b": 0.2, "a": 1.0}, + }, + "draw": { + "blend_mode": "add", + "emission_enabled": true, + "emission": {"r": 1.0, "g": 0.8, "b": 0.2, "a": 1.0}, + "emission_energy_multiplier": 2.0, + }, + }, + "magic_swirl": { + "main": { + "amount": 120, + "lifetime": 2.0, + "one_shot": false, + "explosiveness": 0.0, + "local_coords": false, + }, + "process": { + "emission_shape": "ring", + "emission_ring_radius": 0.8, + "emission_ring_inner_radius": 0.6, + "emission_ring_height": 0.0, + "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, + "spread": 30.0, + "initial_velocity_min": 1.0, + "initial_velocity_max": 2.0, + "gravity": {"x": 0.0, "y": 0.0, "z": 0.0}, + "angular_velocity_min": 90.0, + "angular_velocity_max": 180.0, + "scale_min": 0.1, + "scale_max": 0.2, + "color_ramp": { + "stops": [ + {"time": 0.0, "color": [0.4, 0.9, 1.0, 0.0]}, + {"time": 0.3, "color": [0.5, 0.7, 1.0, 1.0]}, + {"time": 0.7, "color": [1.0, 0.4, 0.9, 1.0]}, + {"time": 1.0, "color": [0.8, 0.2, 0.7, 0.0]}, + ] + }, + }, + "draw": {"blend_mode": "add"}, + }, + "rain": { + "main": { + "amount": 500, + "lifetime": 1.5, + "one_shot": false, + "explosiveness": 0.0, + "local_coords": false, + }, + "process": { + "emission_shape": "box", + "emission_box_extents": {"x": 10.0, "y": 0.1, "z": 10.0}, + "direction": {"x": 0.0, "y": -1.0, "z": 0.0}, + "spread": 2.0, + "initial_velocity_min": 15.0, + "initial_velocity_max": 18.0, + "gravity": {"x": 0.0, "y": -2.0, "z": 0.0}, + "scale_min": 0.02, + "scale_max": 0.04, + "color": {"r": 0.7, "g": 0.85, "b": 1.0, "a": 0.5}, + }, + # Rain drops render as streaks; fixed_y aligns them vertically. + "draw": {"billboard_mode": "fixed_y", "blend_mode": "mix"}, + }, + "explosion": { + "main": { + "amount": 200, + "lifetime": 1.5, + "one_shot": true, + "explosiveness": 1.0, + "local_coords": false, + }, + "process": { + "emission_shape": "sphere", + "emission_sphere_radius": 0.1, + "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, + "spread": 180.0, + "initial_velocity_min": 6.0, + "initial_velocity_max": 10.0, + "gravity": {"x": 0.0, "y": -4.0, "z": 0.0}, + "scale_min": 0.3, + "scale_max": 0.7, + "color_ramp": { + "stops": [ + {"time": 0.0, "color": [1.0, 0.95, 0.5, 1.0]}, + {"time": 0.2, "color": [1.0, 0.4, 0.1, 1.0]}, + {"time": 0.7, "color": [0.3, 0.15, 0.1, 0.7]}, + {"time": 1.0, "color": [0.1, 0.1, 0.1, 0.0]}, + ] + }, + }, + "draw": { + "blend_mode": "add", + "emission_enabled": true, + "emission": {"r": 1.0, "g": 0.5, "b": 0.1, "a": 1.0}, + "emission_energy_multiplier": 1.5, + }, + }, + "lightning": { + # Short, bright, electric-blue spark burst. One-shot — call + # particle_restart to re-trigger. Pairs well with a scene-wide flash. + "main": { + "amount": 40, + "lifetime": 0.35, + "one_shot": true, + "explosiveness": 1.0, + "local_coords": false, + }, + "process": { + "emission_shape": "box", + "emission_box_extents": {"x": 0.1, "y": 1.5, "z": 0.1}, + "direction": {"x": 0.0, "y": -1.0, "z": 0.0}, + "spread": 8.0, + "initial_velocity_min": 18.0, + "initial_velocity_max": 28.0, + "gravity": {"x": 0.0, "y": 0.0, "z": 0.0}, + "scale_min": 0.08, + "scale_max": 0.18, + "color_ramp": { + "stops": [ + {"time": 0.0, "color": [1.0, 1.0, 1.0, 1.0]}, + {"time": 0.2, "color": [0.6, 0.85, 1.0, 1.0]}, + {"time": 0.6, "color": [0.3, 0.5, 1.0, 0.9]}, + {"time": 1.0, "color": [0.1, 0.2, 0.7, 0.0]}, + ] + }, + }, + "draw": { + "blend_mode": "add", + "emission_enabled": true, + "emission": {"r": 0.5, "g": 0.8, "b": 1.0, "a": 1.0}, + "emission_energy_multiplier": 4.0, + }, + }, +} + + +static func list() -> Array: + return _PRESETS.keys() + + +static func has(preset_name: String) -> bool: + return _PRESETS.has(preset_name) + + +## Return deep-copied {main, process, draw} blueprint with overrides merged in, +## plus "user_keys" ({main/process/draw: {key: true}}) recording which keys the +## caller supplied. The handler needs that distinction: preset-blueprint keys +## may skip silently on types they don't apply to (presets are cross-type by +## design), but user-requested overrides must apply or error (#770). +## Overrides may include top-level "main" / "process" / "draw" dicts, or bare +## keys routed to main (_MAIN_KEYS) or process — draw keys must be nested. +static func build(preset_name: String, overrides: Dictionary) -> Variant: + if not _PRESETS.has(preset_name): + return null + var entry: Dictionary = _PRESETS[preset_name].duplicate(true) + var main: Dictionary = entry.get("main", {}) + var process: Dictionary = entry.get("process", {}) + var draw: Dictionary = entry.get("draw", {}) + var user_keys := {"main": {}, "process": {}, "draw": {}} + for key in overrides: + var val = overrides[key] + if key == "main" and val is Dictionary: + for k in val: + main[k] = val[k] + user_keys.main[String(k)] = true + elif key == "process" and val is Dictionary: + for k in val: + process[k] = val[k] + user_keys.process[String(k)] = true + elif key == "draw" and val is Dictionary: + for k in val: + draw[k] = val[k] + user_keys.draw[String(k)] = true + elif _MAIN_KEYS.has(key): + main[key] = val + user_keys.main[String(key)] = true + else: + process[key] = val + user_keys.process[String(key)] = true + entry["main"] = main + entry["process"] = process + entry["draw"] = draw + entry["user_keys"] = user_keys + return entry + + +const _MAIN_KEYS := { + "amount": true, + "lifetime": true, + "one_shot": true, + "explosiveness": true, + "preprocess": true, + "speed_scale": true, + "randomness": true, + "fixed_fps": true, + "emitting": true, + "local_coords": true, + "interp_to_end": true, +} diff --git a/addons/godot_ai/handlers/particle_presets.gd.uid b/addons/godot_ai/handlers/particle_presets.gd.uid new file mode 100644 index 0000000..7c1aac9 --- /dev/null +++ b/addons/godot_ai/handlers/particle_presets.gd.uid @@ -0,0 +1 @@ +uid://bss2ccpmsxo4p diff --git a/addons/godot_ai/handlers/particle_values.gd b/addons/godot_ai/handlers/particle_values.gd new file mode 100644 index 0000000..66782f6 --- /dev/null +++ b/addons/godot_ai/handlers/particle_values.gd @@ -0,0 +1,246 @@ +@tool +extends RefCounted + +## Value coercion + gradient/curve builders for particle properties. + +const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd") + +const _EMISSION_SHAPES := { + "point": ParticleProcessMaterial.EMISSION_SHAPE_POINT, + "sphere": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, + "sphere_surface": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE, + "box": ParticleProcessMaterial.EMISSION_SHAPE_BOX, + "points": ParticleProcessMaterial.EMISSION_SHAPE_POINTS, + "directed_points": ParticleProcessMaterial.EMISSION_SHAPE_DIRECTED_POINTS, + "ring": ParticleProcessMaterial.EMISSION_SHAPE_RING, +} + + +## Resolve a shape name to the int enum, or return null. +static func resolve_emission_shape(value: Variant) -> Variant: + if value is int: + return value + if value is float: + return int(value) + if value is String: + var key := String(value).to_lower() + if _EMISSION_SHAPES.has(key): + return _EMISSION_SHAPES[key] + return null + + +static func emission_shape_names() -> Array: + return _EMISSION_SHAPES.keys() + + +## Build a Gradient from {stops: [{time, color}]} dict. +static func build_gradient(value: Variant) -> Variant: + if value is Gradient: + return value + if value is GradientTexture1D: + return (value as GradientTexture1D).gradient + if not (value is Dictionary): + return null + var d: Dictionary = value + if not d.has("stops"): + return null + var stops_array = d.get("stops") + if not (stops_array is Array): + return null + var offsets := PackedFloat32Array() + var colors := PackedColorArray() + for stop in stops_array: + if not (stop is Dictionary): + return null + offsets.append(float(stop.get("time", 0.0))) + var c = MaterialValues.parse_color(stop.get("color")) + if c == null: + return null + colors.append(c) + var grad := Gradient.new() + grad.offsets = offsets + grad.colors = colors + return grad + + +## Build a GradientTexture1D wrapping a Gradient (what ParticleProcessMaterial.color_ramp wants). +static func build_gradient_texture(value: Variant) -> Variant: + if value is GradientTexture1D: + return value + var grad = build_gradient(value) + if grad == null: + return null + var tex := GradientTexture1D.new() + tex.gradient = grad + return tex + + +## Build a Curve from [{time, value}] or {points: [...]} (float-over-time). +static func build_curve(value: Variant) -> Variant: + if value is Curve: + return value + if value is CurveTexture: + return (value as CurveTexture).curve + var points_array: Variant = null + if value is Array: + points_array = value + elif value is Dictionary and value.has("points"): + points_array = value["points"] + if not (points_array is Array): + return null + var curve := Curve.new() + for pt in points_array: + if not (pt is Dictionary): + return null + var t := float(pt.get("time", 0.0)) + var v := float(pt.get("value", 0.0)) + curve.add_point(Vector2(t, v)) + return curve + + +static func build_curve_texture(value: Variant) -> Variant: + if value is CurveTexture: + return value + var curve = build_curve(value) + if curve == null: + return null + var tex := CurveTexture.new() + tex.curve = curve + return tex + + +## Coerce a particle property value to the appropriate type. +## Handles: Vector3/gravity/direction, Color, float, int, bool, enum strings. +## For color_ramp returns a GradientTexture1D; for *_curve returns CurveTexture. +static func coerce(property: String, value: Variant, target_type: int) -> Dictionary: + # Special-cased properties. + if property == "emission_shape": + var shape = resolve_emission_shape(value) + if shape == null: + return { + "ok": false, + "error": "Invalid emission_shape '%s'. Valid: %s" % [ + value, ", ".join(emission_shape_names()) + ], + } + return {"ok": true, "value": int(shape)} + + if property == "color_ramp" or property == "color_initial_ramp": + var tex = build_gradient_texture(value) + if tex == null: + return {"ok": false, "error": "Invalid gradient for %s (expected {stops: [{time, color}]})" % property} + return {"ok": true, "value": tex} + + if property == "color" and value is Dictionary and not (value as Dictionary).has("stops"): + # color is a single Color, not a ramp. + var c = MaterialValues.parse_color(value) + if c == null: + return {"ok": false, "error": "Invalid color"} + return {"ok": true, "value": c} + + if property.ends_with("_curve"): + var tex = build_curve_texture(value) + if tex == null: + return {"ok": false, "error": "Invalid curve for %s (expected [{time, value}])" % property} + return {"ok": true, "value": tex} + + # Fall through to the material coercer (handles Color/Vec3/Vec2/float/int/bool/enum). + return MaterialValues.coerce_material_value(property, value, target_type) + + +## Build a StandardMaterial3D suitable for GPUParticles3D draw-pass rendering. +## +## Godot's default Mesh has no material, which means ParticleProcessMaterial's +## color_ramp (which drives the COLOR varying) gets ignored and particles +## render as flat white squares that don't face the camera. A correct default +## must have vertex_color_use_as_albedo=true, billboard=particles, unshaded, +## and alpha transparency so the gradient actually modulates the pixels. +## +## Config is an optional dict that overrides individual properties. Supported +## keys match BaseMaterial3D properties (plus enum-by-name via MaterialValues): +## blend_mode: "mix" | "add" | "sub" | "mul" +## transparency: "disabled" | "alpha" | "alpha_scissor" | "alpha_hash" | "alpha_depth_pre_pass" +## shading_mode: "unshaded" | "per_pixel" | "per_vertex" +## billboard_mode: "disabled" | "enabled" | "fixed_y" | "particles" +## vertex_color_use_as_albedo: bool +## emission_enabled: bool +## emission: Color +## emission_energy_multiplier: float +## albedo_color: Color +## albedo_texture: res:// path +## (anything else accepted by BaseMaterial3D.set()) +## +## Returns {ok: true, material: StandardMaterial3D, applied: Array[String]}, +## or {ok: false, key, error, unknown_key} when a config key is not a +## StandardMaterial3D property or its value fails coercion — draw config must +## not disappear silently (#770). +static func build_draw_material(config: Dictionary) -> Dictionary: + var mat := StandardMaterial3D.new() + # Sensible defaults for particle draw-pass rendering. + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.vertex_color_use_as_albedo = true + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES + mat.billboard_keep_scale = true + # Configure from dict overrides. + var applied: Array[String] = [] + for key in config: + var prop_name := String(key) + var prop_type := _object_property_type(mat, prop_name) + if prop_type == TYPE_NIL: + return { + "ok": false, + "key": prop_name, + "unknown_key": true, + "error": "Unknown draw key '%s' (not a StandardMaterial3D property)" % prop_name, + } + var coerce_result := MaterialValues.coerce_material_value( + prop_name, config[prop_name], prop_type + ) + if not coerce_result.ok: + return { + "ok": false, + "key": prop_name, + "unknown_key": false, + "error": "draw.%s: %s" % [prop_name, coerce_result.error], + } + mat.set(prop_name, coerce_result.value) + applied.append(prop_name) + return {"ok": true, "material": mat, "applied": applied} + + +static func _object_property_type(obj: Object, name: String) -> int: + if obj == null: + return TYPE_NIL + for prop in obj.get_property_list(): + if prop.name == name: + return int(prop.get("type", TYPE_NIL)) + return TYPE_NIL + + +## Serialize for response. +static func serialize(value: Variant) -> Variant: + if value == null: + return null + if value is GradientTexture1D: + var grad := (value as GradientTexture1D).gradient + if grad == null: + return {"type": "GradientTexture1D", "stops": []} + var stops: Array = [] + for i in grad.offsets.size(): + var c: Color = grad.colors[i] + stops.append({ + "time": grad.offsets[i], + "color": {"r": c.r, "g": c.g, "b": c.b, "a": c.a}, + }) + return {"type": "GradientTexture1D", "stops": stops} + if value is CurveTexture: + var curve := (value as CurveTexture).curve + if curve == null: + return {"type": "CurveTexture", "points": []} + var points: Array = [] + for i in curve.get_point_count(): + var p := curve.get_point_position(i) + points.append({"time": p.x, "value": p.y}) + return {"type": "CurveTexture", "points": points} + return MaterialValues.serialize_value(value) diff --git a/addons/godot_ai/handlers/particle_values.gd.uid b/addons/godot_ai/handlers/particle_values.gd.uid new file mode 100644 index 0000000..71f6adc --- /dev/null +++ b/addons/godot_ai/handlers/particle_values.gd.uid @@ -0,0 +1 @@ +uid://bnnnjq06dmclc diff --git a/addons/godot_ai/handlers/physics_shape_handler.gd b/addons/godot_ai/handlers/physics_shape_handler.gd new file mode 100644 index 0000000..dc5156c --- /dev/null +++ b/addons/godot_ai/handlers/physics_shape_handler.gd @@ -0,0 +1,338 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's +## bounds. Auto-creates the concrete Shape subclass when the slot is empty +## or the requested type differs — bundling creation and sizing in a single +## undo action. +## +## Shape type defaults: Box for 3D, Rectangle for 2D. + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +const _SHAPE_3D_CLASSES := { + "box": "BoxShape3D", + "sphere": "SphereShape3D", + "capsule": "CapsuleShape3D", + "cylinder": "CylinderShape3D", +} + +const _SHAPE_2D_CLASSES := { + "rectangle": "RectangleShape2D", + "circle": "CircleShape2D", + "capsule": "CapsuleShape2D", +} + + +func autofit(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + if node_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var scene_root: Node = _resolved.scene_root + + var is_3d := node is CollisionShape3D + var is_2d := node is CollisionShape2D + if not (is_3d or is_2d): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node at %s is %s — must be CollisionShape3D or CollisionShape2D" % [node_path, node.get_class()] + ) + + var source_path: String = params.get("source_path", "") + var source: Node = null + if source_path.is_empty(): + var search := _find_bounds_visual(node, is_3d, scene_root) + if search.has("error"): + return search.error + source = search.source + else: + source = McpScenePath.resolve(source_path, scene_root) + if source == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, + "source_path: %s" % McpScenePath.format_node_error(source_path, scene_root)) + + var shape_type: String = params.get("shape_type", "box" if is_3d else "rectangle") + var type_map := _SHAPE_3D_CLASSES if is_3d else _SHAPE_2D_CLASSES + # Accept either the short form ("box") or the matching Godot class name + # ("BoxShape3D") — every other tool in the server takes class names, and + # resource_get_info(type="Shape3D") surfaces concrete_subclasses by class. + if not type_map.has(shape_type): + for short_form in type_map: + if type_map[short_form] == shape_type: + shape_type = short_form + break + if not type_map.has(shape_type): + var valid_pairs: Array[String] = [] + for short_form in type_map: + valid_pairs.append("%s (%s)" % [short_form, type_map[short_form]]) + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid shape_type '%s' for %s. Valid: %s" % [shape_type, node.get_class(), ", ".join(valid_pairs)] + ) + var shape_class: String = type_map[shape_type] + + # Measure the visual. + var bounds := _measure_bounds(source, is_3d) + if bounds.has("error"): + return bounds.error + + # Reuse the existing shape if it already matches the requested class; + # otherwise create a fresh one of the right type in the same undo action. + var existing_shape: Shape3D = null + var existing_shape_2d: Shape2D = null + if is_3d: + existing_shape = node.shape + else: + existing_shape_2d = node.shape + + var needs_new_shape := false + if is_3d: + needs_new_shape = existing_shape == null or existing_shape.get_class() != shape_class + else: + needs_new_shape = existing_shape_2d == null or existing_shape_2d.get_class() != shape_class + + var target_shape: Resource + if needs_new_shape: + var instance := ClassDB.instantiate(shape_class) + if instance == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % shape_class) + target_shape = instance + else: + target_shape = existing_shape if is_3d else existing_shape_2d + + # Compute and apply size. + var size_info := _apply_shape_size(target_shape, shape_type, bounds, is_3d) + var old_shape = existing_shape if is_3d else existing_shape_2d + + _undo_redo.create_action("MCP: Autofit %s on %s" % [shape_class, node.name]) + if needs_new_shape: + _undo_redo.add_do_property(node, "shape", target_shape) + _undo_redo.add_undo_property(node, "shape", old_shape) + _undo_redo.add_do_reference(target_shape) + else: + # Existing shape stays, but its size changes — snapshot size for undo. + for key in size_info.applied: + var new_val = target_shape.get(key) + var old_val = size_info.previous.get(key) + _undo_redo.add_do_property(target_shape, key, new_val) + _undo_redo.add_undo_property(target_shape, key, old_val) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "source_path": McpScenePath.from_node(source, scene_root) if source_path.is_empty() else source_path, + "shape_type": shape_type, + "shape_class": shape_class, + "shape_created": needs_new_shape, + "size": size_info.size_response, + "undoable": true, + } + } + + +## Returns `{source: Node}` on success, `{error: }` on failure. +## Ambiguous tier-2 matches put candidate scene paths in +## `error.data.candidates` so callers can pick one explicitly. +static func _find_bounds_visual(collision_node: Node, is_3d: bool, scene_root: Node) -> Dictionary: + var parent := collision_node.get_parent() + if parent == null: + return {"error": _no_visual_error(is_3d)} + + # Tier 1: direct siblings of the collision shape. Uses the broad + # VisualInstance3D filter for backwards compatibility — callers who put + # the visual directly next to the collision picked it on purpose. + var siblings := _measurable_visuals(parent.get_children(), collision_node, is_3d, false) + if not siblings.is_empty(): + return {"source": siblings[0]} + + # Tier 2: parent siblings (uncles). Tighten the filter to + # GeometryInstance3D so we don't auto-pick a Light3D / DirectionalLight3D + # as a collision source. Auto-pick only when unambiguous; surface + # multiple candidates so the agent chooses. + var grandparent := parent.get_parent() + if grandparent == null: + return {"error": _no_visual_error(is_3d)} + var uncles := _measurable_visuals(grandparent.get_children(), parent, is_3d, true) + if uncles.size() == 1: + return {"source": uncles[0]} + if uncles.size() > 1: + var paths: Array[String] = [] + for n in uncles: + paths.append(McpScenePath.from_node(n, scene_root)) + var msg := "Multiple visual candidates near %s — pass source_path explicitly. Candidates: %s" % [ + McpScenePath.from_node(collision_node, scene_root), + ", ".join(paths), + ] + var err := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg) + err["error"]["data"] = {"candidates": paths} + return {"error": err} + return {"error": _no_visual_error(is_3d)} + + +## Filter `nodes` for ones we can measure as a collision source. When +## `strict` is true (tier 2 / uncles) only GeometryInstance3D counts in 3D — +## avoids picking up lights as accidental sources. 2D filter is already +## narrow enough that strictness doesn't change behavior. +static func _measurable_visuals(nodes: Array, exclude: Node, is_3d: bool, strict: bool) -> Array[Node]: + var out: Array[Node] = [] + for n in nodes: + if n == exclude: + continue + if is_3d: + if strict: + if n is GeometryInstance3D: + out.append(n) + elif n is VisualInstance3D: + out.append(n) + elif n is Sprite2D or n is TextureRect: + out.append(n) + return out + + +static func _no_visual_error(is_3d: bool) -> Dictionary: + var hint := "MeshInstance3D" if is_3d else "Sprite2D" + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "No visual found near collision shape — searched siblings and parent-siblings. Pass source_path explicitly (e.g. a %s)" % hint, + ) + + +## Measure the visual bounds of `source`. Returns {aabb: AABB} for 3D or +## {rect: Rect2} for 2D on success, or {error: ...} on failure. +## Bounds are returned in world-ish size (local extents scaled by the source +## node's own transform scale) so a MeshInstance3D at scale=(2,2,2) gives an +## 8× volume collider, not a unit collider. +static func _measure_bounds(source: Node, is_3d: bool) -> Dictionary: + if is_3d: + if source is VisualInstance3D: + var aabb: AABB = (source as VisualInstance3D).get_aabb() + # get_aabb() is local-space; pre-multiply by the source's scale + # so the collider tracks what you actually see in the viewport. + var scale_3d: Vector3 = (source as Node3D).transform.basis.get_scale() + aabb.position = aabb.position * scale_3d + aabb.size = aabb.size * scale_3d + return {"aabb": aabb} + return {"error": ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Source %s has no measurable 3D bounds (must be VisualInstance3D subclass)" % source.get_class() + )} + # 2D + if source is Sprite2D: + var s: Sprite2D = source + var srect: Rect2 = s.get_rect() + # get_rect() reports the local texture rect and ignores scale. + srect.position = srect.position * s.scale + srect.size = srect.size * s.scale + return {"rect": srect} + if source is TextureRect: + var tr: TextureRect = source + # tr.size is the Control's laid-out size, which is Vector2.ZERO + # before the first layout pass (e.g. just after the node was created + # via MCP). Fall back to the texture's own size when that happens, + # so autofit doesn't silently produce a zero-sized shape. + var tr_size: Vector2 = tr.size + if tr_size.is_zero_approx(): + if tr.texture != null: + tr_size = tr.texture.get_size() * tr.scale + else: + return {"error": ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "TextureRect at %s has zero layout size and no texture to fall back to — autofit would produce a zero-sized shape" % source.name + )} + return {"rect": Rect2(Vector2.ZERO, tr_size)} + return {"error": ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Source %s has no measurable 2D bounds (must be Sprite2D or TextureRect)" % source.get_class() + )} + + +## Apply size to `shape` based on `bounds` and the requested shape_type. +## Returns {applied: [property_names], previous: {name: old_value}, size_response: dict}. +static func _apply_shape_size(shape: Resource, shape_type: String, bounds: Dictionary, is_3d: bool) -> Dictionary: + var applied: Array[String] = [] + var previous := {} + var size_response := {} + + if is_3d: + var aabb: AABB = bounds.aabb + var size_v: Vector3 = aabb.size + match shape_type: + "box": + previous["size"] = shape.get("size") + (shape as BoxShape3D).size = size_v + applied.append("size") + size_response = {"x": size_v.x, "y": size_v.y, "z": size_v.z} + "sphere": + var r := maxf(maxf(size_v.x, size_v.y), size_v.z) * 0.5 + previous["radius"] = shape.get("radius") + (shape as SphereShape3D).radius = r + applied.append("radius") + size_response = {"radius": r} + "capsule": + var cap := shape as CapsuleShape3D + var r2 := maxf(size_v.x, size_v.z) * 0.5 + var h := size_v.y + previous["radius"] = cap.radius + previous["height"] = cap.height + # CapsuleShape3D enforces height >= 2*radius and silently + # clamps setters that would violate it. Read back the + # stored values so the response reflects reality. + cap.radius = r2 + cap.height = h + applied.append("radius") + applied.append("height") + size_response = {"radius": cap.radius, "height": cap.height} + "cylinder": + var cyl := shape as CylinderShape3D + var r3 := maxf(size_v.x, size_v.z) * 0.5 + var ch := size_v.y + previous["radius"] = cyl.radius + previous["height"] = cyl.height + cyl.radius = r3 + cyl.height = ch + applied.append("radius") + applied.append("height") + size_response = {"radius": cyl.radius, "height": cyl.height} + else: + var rect: Rect2 = bounds.rect + var sz: Vector2 = rect.size + match shape_type: + "rectangle": + previous["size"] = shape.get("size") + (shape as RectangleShape2D).size = sz + applied.append("size") + size_response = {"x": sz.x, "y": sz.y} + "circle": + var cr := maxf(sz.x, sz.y) * 0.5 + previous["radius"] = shape.get("radius") + (shape as CircleShape2D).radius = cr + applied.append("radius") + size_response = {"radius": cr} + "capsule": + var cap2 := shape as CapsuleShape2D + var cr2 := sz.x * 0.5 + var ch2 := sz.y + previous["radius"] = cap2.radius + previous["height"] = cap2.height + # CapsuleShape2D has the same height >= 2*radius invariant + # as its 3D counterpart; read back what Godot actually kept. + cap2.radius = cr2 + cap2.height = ch2 + applied.append("radius") + applied.append("height") + size_response = {"radius": cap2.radius, "height": cap2.height} + + return {"applied": applied, "previous": previous, "size_response": size_response} diff --git a/addons/godot_ai/handlers/physics_shape_handler.gd.uid b/addons/godot_ai/handlers/physics_shape_handler.gd.uid new file mode 100644 index 0000000..09acb4a --- /dev/null +++ b/addons/godot_ai/handlers/physics_shape_handler.gd.uid @@ -0,0 +1 @@ +uid://cdg8kthqla1cj diff --git a/addons/godot_ai/handlers/project_handler.gd b/addons/godot_ai/handlers/project_handler.gd new file mode 100644 index 0000000..eb7d36f --- /dev/null +++ b/addons/godot_ai/handlers/project_handler.gd @@ -0,0 +1,572 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles project settings and filesystem search commands. + +const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") +const RUN_READY_WAIT_SEC := 3.0 + +## ProjectSettings keys that decide what the engine EXECUTES at project +## startup. `McpPathValidator._reject_sensitive_write` already refuses direct +## writes to `res://project.godot`, `res://override.cfg` and `res://.godot/` +## for exactly this reason — but `set_project_setting` writes that same +## manifest through the settings API, so without this list the guard is +## trivially side-steppable: +## +## settings_set key="autoload/Boot" value="*res://../../evil.gd" +## +## would land arbitrary code on the next project open, and a `project.godot` +## diff is easy to miss in review. Autoloads have a validated route of their +## own (`autoload_handler.add_autoload`, which runs the path through +## `McpPathValidator`); the rest are refused outright because there is no +## legitimate agent workflow for repointing the engine's startup execution. +## +## Deliberately NARROW. A denylist that over-blocks turns settings_set into a +## tool agents can't use, so only keys that actually carry code (or a command +## line) are listed — not their whole section. `application/run/` is an exact +## entry for `main_scene` precisely because its siblings (`max_fps`, +## `low_processor_mode`, …) are inert and must stay writable; likewise +## `application/boot_splash/image` is data, not code, and is NOT blocked. +## +## Prefix entries match the key and anything beneath it, and are used only +## where every key under the prefix is code-bearing. Exact entries match only +## themselves. Comparison is case-folded — ProjectSettings keys are +## case-sensitive, but a case variant that Godot would reject is still a +## clearer error coming from here than from a half-applied save. +const STARTUP_EXECUTION_KEY_PREFIXES: Array[String] = [ + "autoload/", ## every key under it is a script path + "editor_plugins/", ## enabled-plugin list; each entry is loaded as code +] +const STARTUP_EXECUTION_KEYS_EXACT: Array[String] = [ + "application/run/main_scene", ## the scene the game boots into + "editor/script/templates_search_path", ## where the editor loads templates from + "editor/run/main_run_args", ## command line for the run +] + + +## Returns "" when `key` may be written via set_project_setting, or a +## human-readable refusal reason otherwise. Static so it is unit-testable +## without instancing the handler. +static func startup_execution_key_refusal(key: String) -> String: + var lowered := key.strip_edges().to_lower() + for prefix in STARTUP_EXECUTION_KEY_PREFIXES: + if lowered.begins_with(prefix): + if prefix == "autoload/": + return ( + "Refusing to set '%s' — autoloads run code at project startup. " % key + + "Use autoload_manage(op='add'), which validates the script path." + ) + return ( + "Refusing to set '%s' — keys under '%s' are loaded as code " % [key, prefix] + + "at project startup." + ) + for exact in STARTUP_EXECUTION_KEYS_EXACT: + if lowered == exact: + return ( + "Refusing to set '%s' — this key controls what the engine loads " % key + + "or executes at project startup." + ) + return "" + +var _connection: McpConnection +var _debugger_plugin +var _editor_log_buffer + + +func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void: + _connection = connection + _debugger_plugin = debugger_plugin + _editor_log_buffer = editor_log_buffer + + +func get_project_setting(params: Dictionary) -> Dictionary: + var key: String = params.get("key", "") + if key.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key") + + if not ProjectSettings.has_setting(key): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Setting not found: %s" % key) + + var value = ProjectSettings.get_setting(key) + return { + "data": { + "key": key, + "value": NodeHandler._serialize_value(value), + "type": type_string(typeof(value)), + } + } + + +func set_project_setting(params: Dictionary) -> Dictionary: + var key: String = params.get("key", "") + if key.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key") + + if not params.has("value"): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") + + ## Refuse the startup-execution surface before touching ProjectSettings — + ## see STARTUP_EXECUTION_KEY_PREFIXES for why this guard exists here and + ## not only in McpPathValidator. + var refusal := startup_execution_key_refusal(key) + if not refusal.is_empty(): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, refusal) + + var value = params.get("value") + var had_setting := ProjectSettings.has_setting(key) + var old_value = ProjectSettings.get_setting(key) if had_setting else null + # JSON has no distinct int type: Godot parses `1920` as float. If the + # existing setting is TYPE_INT, coerce whole-number floats back to int so + # we don't silently flip typed-int settings (viewport_width, etc.) to + # floats on disk. See issue #31. + if had_setting and typeof(old_value) == TYPE_INT and typeof(value) == TYPE_FLOAT and float(int(value)) == value: + value = int(value) + ProjectSettings.set_setting(key, value) + var err := ProjectSettings.save() + if err != OK: + if had_setting: + ProjectSettings.set_setting(key, old_value) + else: + ProjectSettings.clear(key) + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save project settings (error %d)" % err) + + return { + "data": { + "key": key, + "value": NodeHandler._serialize_value(value), + "old_value": NodeHandler._serialize_value(old_value), + "type": type_string(typeof(value)), + "undoable": false, + "reason": "ProjectSettings changes are saved to disk", + } + } + + +func run_project(params: Dictionary) -> Dictionary: + var mode: String = params.get("mode", "main") + var autosave: bool = params.get("autosave", true) + # Idempotent: a project that's already running satisfies the caller's intent. + # Returning INVALID_PARAMS here punished agents that legitimately called run + # to ensure the project is playing (87+ installs/day hit the matching + # stop-not-running case in telemetry). Surface state via was_already_running + # so a caller wanting a *different* scene can detect and stop+restart. + if EditorInterface.is_playing_scene(): + return _run_project_current_liveness_response( + _run_project_base_data( + mode, + str(params.get("scene", "")), + autosave, + true, + "Project was already running; no action taken" + ) + ) + + var validation_error: Variant = null + if mode == "custom": + var custom_scene: String = params.get("scene", "") + if custom_scene.is_empty(): + validation_error = ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: scene (required when mode='custom')") + else: + ## play_custom_scene() was the last path-taking op in the plugin + ## with no containment check; every sibling that accepts a scene + ## path validates it (scene_handler.open_scene, + ## node_handler.create_node's scene_path). + validation_error = McpPathValidator.loadable_error(custom_scene, "scene") + elif mode != "main" and mode != "current": + validation_error = ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid mode '%s' — use 'main', 'current', or 'custom'" % mode) + if validation_error != null: + return validation_error + + # play_*_scene internally triggers try_autosave() → _save_scene_with_preview() + # which renders a preview thumbnail and calls frame processing. If our + # WebSocket connection's _process() re-enters during that render, the + # engine crashes (SIGABRT in _save_scene_with_preview). Pause processing + # around the play call — same pattern as SceneHandler.save_scene. + if _connection: + _connection.pause_processing = true + + # try_autosave() reads run/auto_save/save_before_running every call, so + # toggling it off around the play call suppresses the save without + # touching the user's persisted preference. Issue #81. + var autosave_key := "run/auto_save/save_before_running" + var editor_settings: EditorSettings = null + if not autosave: + editor_settings = EditorInterface.get_editor_settings() + var prior_autosave: bool = true + var restore_setting := false + if editor_settings != null and editor_settings.has_setting(autosave_key): + prior_autosave = bool(editor_settings.get_setting(autosave_key)) + editor_settings.set_setting(autosave_key, false) + restore_setting = true + + if _debugger_plugin != null: + _debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected()) + + match mode: + "main": + EditorInterface.play_main_scene() + "current": + EditorInterface.play_current_scene() + "custom": + var scene_path: String = params.get("scene", "") + EditorInterface.play_custom_scene(scene_path) + + if restore_setting: + editor_settings.set_setting(autosave_key, prior_autosave) + + if _connection: + _connection.pause_processing = false + + var base_data := _run_project_base_data( + mode, + str(params.get("scene", "")), + autosave, + false, + "Play/stop is a runtime action" + ) + var request_id: String = params.get("_request_id", "") + if _connection != null and _debugger_plugin != null and not request_id.is_empty(): + _finish_run_project_deferred(request_id, base_data, _connection, _debugger_plugin) + return McpDispatcher.DEFERRED_RESPONSE + + return _run_project_current_liveness_response(base_data) + + +func _editor_log_cursor() -> int: + return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0 + + +func _game_helper_autoload_expected() -> bool: + return ProjectSettings.has_setting("autoload/_mcp_game_helper") + + +static func _run_project_base_data( + mode: String, + scene: String, + autosave: bool, + was_already_running: bool, + reason: String +) -> Dictionary: + return { + "mode": mode, + "scene": scene, + "autosave": autosave, + "was_already_running": was_already_running, + "undoable": false, + "reason": reason, + } + + +func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary: + if _debugger_plugin == null: + return {"data": base_data} + var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC) + ## One-shot read — force a Debugger-tab scan so boot errors that landed + ## after the last gated scan are in this response (#641). + var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true) + return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info)) + + +## `static` is load-bearing (#712, same rationale as the script/filesystem +## handlers and editor_handler._do_reload_plugin): this coroutine awaits +## across frames, and a plugin reload frees this RefCounted handler +## mid-await — resuming an instance coroutine on a freed object errors. +## `connection` and `debugger_plugin` are parameterized explicitly and +## re-validated after every await; the response is dropped silently when +## either died (the server's command timeout surfaces the failure). +static func _finish_run_project_deferred( + request_id: String, base_data: Dictionary, connection, debugger_plugin +) -> void: + var tree: SceneTree = connection.get_tree() + while true: + await tree.process_frame + if not is_instance_valid(connection) or not is_instance_valid(debugger_plugin): + return + var pre_status: Dictionary = debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC) + if ( + not EditorInterface.is_playing_scene() + and int(pre_status.get("elapsed_msec", 0)) > 100 + and str(pre_status.get("status", "stopped")) == "launching" + ): + debugger_plugin.end_game_run() + var status: Dictionary = debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC) + var errors_info: Dictionary = debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0))) + var decision := _run_project_liveness_decision(status, errors_info) + if not bool(decision.get("resolve", false)): + continue + ## #641: the loop above polls with gated (cheap) scans; boot parse + ## errors can land in the Errors tab in the same frames the run goes + ## live. Re-gather once with a forced scan before replying so the + ## response reports them instead of leaving them to a later + ## logs_read. Rebuilding the decision with strictly-more errors can + ## only keep it resolved (errors never un-resolve a decision). + errors_info = debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true) + decision = _run_project_liveness_decision(status, errors_info) + connection.send_deferred_response(request_id, _run_project_response(base_data, decision)) + return + + +static func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary: + var data := base_data.duplicate(true) + var game_status: Dictionary = decision.get("game_status", {}) + data["game_status"] = game_status + data["helper_live"] = bool(game_status.get("helper_live", false)) + data["session_active"] = bool(game_status.get("session_active", false)) + if bool(data.get("was_already_running", false)): + data["reason"] = _run_project_already_running_message(decision) + else: + data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action")) + data["recent_errors"] = decision.get("recent_errors", []) + data["recent_errors_scope"] = decision.get("recent_errors_scope", "none") + data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false) + data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false) + data.merge(McpDebuggerPlugin.split_errors_by_scope(data["recent_errors"], data["recent_errors_scope"]), true) + return {"data": data} + + +static func _run_project_already_running_message(decision: Dictionary) -> String: + var state := str(decision.get("liveness_status", "unknown")) + match state: + "live": + var live_errors: Array = decision.get("recent_errors", []) + if not live_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run": + return ( + "Project was already running; the Godot AI game helper is live, but %d editor error%s surfaced during this run (first: %s). Check logs_read(source='editor', include_details=true)." + % [live_errors.size(), "s" if live_errors.size() != 1 else "", _format_editor_error_summary(live_errors[0])] + ) + return "Project was already running; the Godot AI game helper is live." + "not_live": + var errors: Array = decision.get("recent_errors", []) + var scope := str(decision.get("recent_errors_scope", "none")) + if not errors.is_empty() and scope == "run": + return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0]) + if not errors.is_empty(): + return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0]) + return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state." + "break": + var break_errors: Array = decision.get("recent_errors", []) + if not break_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run": + return "Project was already running but the game is parked at a debugger break: %s. Call project_manage(op='stop') to end the run, fix the error, and relaunch." % _format_editor_error_summary(break_errors[0]) + if not break_errors.is_empty(): + return "Project was already running but the game is parked at a debugger break. A recent editor error may be related, but may predate this run: %s. Call project_manage(op='stop') to end the run." % _format_editor_error_summary(break_errors[0]) + return "Project was already running but the game is parked at a debugger break. Call project_manage(op='stop') to end the run; the break reason is in the editor's Debugger panel." + "no_helper": + return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness." + "launching": + return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly." + "stopped": + return "Project was already marked playing by the editor, but no active game liveness run exists." + _: + return "Project was already running; current liveness status is %s." % state + + +## Static (with the rest of the deferred-finisher chain) so the #712 +## load-bearing-static coroutines above can call it after their owner +## handler was freed. Uses no instance state. +static func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary: + var enriched_status := McpDebuggerPlugin.with_liveness_flags(status) + var state := str(status.get("status", "stopped")) + var recent_errors: Array = errors_info.get("errors", []) + var errors_scope := str(errors_info.get("scope", "none")) + var truncated := bool(errors_info.get("truncated", false)) + ## Clear errors that predate this run's window — they don't belong in + ## a successful launch response and only add noise. They remain + ## reachable via logs_read(source='editor') and the retained buffer so + ## failed-run debugging is unaffected. + ## #635 tradeoff: a genuine in-run error whose Errors-tab row carries + ## an empty or byte-identical time text can be misclassified as + ## retained_recent and will be dropped here. Still reachable via + ## logs_read. + if state == "live" and errors_scope == "retained_recent": + recent_errors = [] + errors_scope = "none" + var correlated_error := not recent_errors.is_empty() and errors_scope == "run" + var elapsed_msec := int(status.get("elapsed_msec", 0)) + var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0))) + var decision := { + "resolve": false, + "game_status": enriched_status, + "liveness_status": state, + "recent_errors": recent_errors, + "recent_errors_scope": errors_scope, + "recent_errors_may_predate_run": errors_scope == "retained_recent", + "recent_errors_truncated": truncated, + "message": "", + } + if state == "live": + decision["resolve"] = true + if correlated_error: + ## #641: "live" only means the helper autoload registered — scripts + ## can still have failed to parse or load during boot (a broken + ## node script does not stop the game from running). Surface those + ## errors in the success message so agents don't read a clean + ## launch into a run that silently lost scripts. + decision["message"] = ( + "Game launched and the Godot AI game helper is live, but %d editor error%s surfaced during startup (first: %s) — likely a script that failed to parse or load. Check logs_read(source='editor', include_details=true)." + % [recent_errors.size(), "s" if recent_errors.size() != 1 else "", _format_editor_error_summary(recent_errors[0])] + ) + if truncated: + decision["message"] += " Editor logs since this run may be truncated; showing retained errors." + else: + decision["message"] = "Game launched and the Godot AI game helper is live." + elif state == "break": + ## #645: the game process is parked in a remote-debugger break. A + ## boot-time parse error (GDScriptLanguage::debug_break_parse) produces + ## no Errors-tab row, no Logger entry, and no game-log line — the + ## synthesized break record is the only evidence, and it lands a + ## moment after the break signal (stack frames arrive async). Wait for + ## it (correlated_error) before resolving; the ready window is the + ## fallback if synthesis never lands. + var break_info: Dictionary = status.get("break", {}) + var break_reason := str(break_info.get("reason", "")) + if bool(break_info.get("pre_live", true)): + decision["resolve"] = correlated_error or elapsed_msec >= ready_wait_msec + var summary := break_reason + if correlated_error: + summary = _format_editor_error_summary(recent_errors[0]) + if summary.is_empty(): + summary = "script parse/load error (reason not captured)" + decision["message"] = "Game hit a script error during startup and is frozen at a debugger break before the Godot AI game helper registered: %s. The run cannot continue; call project_manage(op='stop'), fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % summary + else: + var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else "" + decision["resolve"] = true + decision["message"] = "Game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix + elif correlated_error: + decision["resolve"] = true + decision["liveness_status"] = "not_live" + decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0]) + if truncated: + decision["message"] += " Editor logs since this run may be truncated; showing retained errors." + elif state == "not_live": + decision["resolve"] = true + if not recent_errors.is_empty(): + decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0]) + else: + decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state." + elif state == "no_helper": + decision["resolve"] = true + decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable." + elif state == "stopped": + decision["resolve"] = true + decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live." + elif state == "launching" and elapsed_msec >= ready_wait_msec: + decision["resolve"] = true + decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0) + return decision + + +static func _format_editor_error_summary(entry: Dictionary) -> String: + return McpSurfacedErrorTracker.format_editor_error_summary(entry) + + +func stop_project(params: Dictionary) -> Dictionary: + # Idempotent: a project that's already stopped satisfies the caller's intent. + # Returning INVALID_PARAMS here was the largest single source of fleet-wide + # project_manage failures (87 installs/24h). was_running=false lets callers + # distinguish a no-op stop from one that actually halted a running session. + if not EditorInterface.is_playing_scene(): + return { + "data": { + "stopped": true, + "was_running": false, + "undoable": false, + "reason": "Project was not running; no action taken", + } + } + + if _debugger_plugin != null: + _debugger_plugin.end_game_run() + EditorInterface.stop_playing_scene() + + # stop_playing_scene() is async — is_playing_scene() only flips to false on + # the next frame, and readiness_changed follows in _process. Defer the + # response so we can reply with authoritative readiness instead of letting + # the server poll for the event. Issue #29. + var request_id: String = params.get("_request_id", "") + if _connection != null and not request_id.is_empty(): + _finish_stop_project_deferred(request_id, _connection) + return McpDispatcher.DEFERRED_RESPONSE + + # Fallback for contexts without a connection (e.g. batch_execute via + # dispatch_direct, or unit tests that instantiate the handler with null). + return { + "data": { + "stopped": true, + "was_running": true, + "undoable": false, + "reason": "Play/stop is a runtime action", + } + } + + +# Wait two frames so Godot can tick the stop-play state change. After this +# is_playing_scene() reflects truth and get_readiness() is authoritative. +# If the plugin tears down (_exit_tree frees _connection) during the await, +# is_instance_valid() goes false and we drop the response silently — the +# server's 5s request timeout will surface the failure to the caller. +# `static` is load-bearing (#712): see _finish_run_project_deferred. +static func _finish_stop_project_deferred(request_id: String, connection) -> void: + var tree: SceneTree = connection.get_tree() + await tree.process_frame + await tree.process_frame + if not is_instance_valid(connection): + return + connection.send_deferred_response(request_id, { + "data": { + "stopped": true, + "was_running": true, + "undoable": false, + "reason": "Play/stop is a runtime action", + "readiness_after": McpConnection.get_readiness(), + } + }) + + +func search_filesystem(params: Dictionary) -> Dictionary: + var name_filter: String = params.get("name", "") + var type_filter: String = params.get("type", "") + var path_filter: String = params.get("path", "") + + if name_filter.is_empty() and type_filter.is_empty() and path_filter.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, path) is required") + + var efs := EditorInterface.get_resource_filesystem() + if efs == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "EditorFileSystem not available", false) + + var results: Array[Dictionary] = [] + _scan_directory(efs.get_filesystem(), name_filter, type_filter, path_filter, results) + return {"data": {"files": results, "count": results.size()}} + + +func _scan_directory(dir: EditorFileSystemDirectory, name_filter: String, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void: + for i in dir.get_file_count(): + var file_path := dir.get_file_path(i) + var file_type := dir.get_file_type(i) + + var matches := true + + if not name_filter.is_empty(): + if file_path.get_file().to_lower().find(name_filter.to_lower()) == -1: + matches = false + + if matches and not type_filter.is_empty(): + if file_type != type_filter: + matches = false + + if matches and not path_filter.is_empty(): + if file_path.to_lower().find(path_filter.to_lower()) == -1: + matches = false + + if matches: + out.append({ + "path": file_path, + "type": file_type, + }) + + for i in dir.get_subdir_count(): + _scan_directory(dir.get_subdir(i), name_filter, type_filter, path_filter, out) diff --git a/addons/godot_ai/handlers/project_handler.gd.uid b/addons/godot_ai/handlers/project_handler.gd.uid new file mode 100644 index 0000000..ec2b5d7 --- /dev/null +++ b/addons/godot_ai/handlers/project_handler.gd.uid @@ -0,0 +1 @@ +uid://brf8u32hvha68 diff --git a/addons/godot_ai/handlers/resource_handler.gd b/addons/godot_ai/handlers/resource_handler.gd new file mode 100644 index 0000000..553ed9b --- /dev/null +++ b/addons/godot_ai/handlers/resource_handler.gd @@ -0,0 +1,591 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd") + +## Handles resource search, inspection, and assignment to nodes. + +const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") + +var _undo_redo: EditorUndoRedoManager +var _connection: McpConnection + + +func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: + _undo_redo = undo_redo + _connection = connection + + +func search_resources(params: Dictionary) -> Dictionary: + var type_filter: String = params.get("type", "") + var path_filter: String = params.get("path", "") + + if type_filter.is_empty() and path_filter.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (type, path) is required") + + var efs := EditorInterface.get_resource_filesystem() + if efs == null: + return ErrorCodes.make_not_ready( + ErrorCodes.SUB_EDITOR_UNAVAILABLE, + "EditorFileSystem not available", false) + + var results: Array[Dictionary] = [] + _scan_resources(efs.get_filesystem(), type_filter, path_filter, results) + return {"data": {"resources": results, "count": results.size()}} + + +func _scan_resources(dir: EditorFileSystemDirectory, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void: + for i in dir.get_file_count(): + var file_path := dir.get_file_path(i) + var file_type := dir.get_file_type(i) + + var matches := true + + if not type_filter.is_empty(): + # Check if the file type matches or is a subclass of the requested type + if file_type != type_filter and not ClassDB.is_parent_class(file_type, type_filter): + matches = false + + if matches and not path_filter.is_empty(): + if file_path.to_lower().find(path_filter.to_lower()) == -1: + matches = false + + if matches: + out.append({ + "path": file_path, + "type": file_type, + }) + + for i in dir.get_subdir_count(): + _scan_resources(dir.get_subdir(i), type_filter, path_filter, out) + + +func load_resource(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + var path_err = McpPathValidator.loadable_error(path, "path") + if path_err != null: + return path_err + + if not ResourceLoader.exists(path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % path) + + var res: Resource = load(path) + if res == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load resource: %s" % path) + + var properties: Array[Dictionary] = [] + for prop in res.get_property_list(): + var usage: int = prop.get("usage", 0) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + var value = res.get(prop.name) + if value == null and prop.type != TYPE_NIL: + continue + properties.append({ + "name": prop.name, + "type": type_string(prop.type), + "value": NodeHandler._serialize_value(value), + }) + + return { + "data": { + "path": path, + "type": res.get_class(), + "properties": properties, + "property_count": properties.size(), + } + } + + +func assign_resource(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + var property: String = params.get("property", "") + var resource_path: String = params.get("resource_path", "") + + if node_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + if property.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property") + + if resource_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: resource_path") + + var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path") + if rpath_err != null: + return rpath_err + + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var _scene_root: Node = _resolved.scene_root + + # Verify property exists + var found := false + for prop in node.get_property_list(): + if prop.name == property: + found = true + break + if not found: + return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property)) + + if not ResourceLoader.exists(resource_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path) + + var res: Resource = load(resource_path) + if res == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load resource: %s" % resource_path) + + var old_value = node.get(property) + + _undo_redo.create_action("MCP: Assign %s to %s.%s" % [resource_path.get_file(), node.name, property]) + _undo_redo.add_do_property(node, property, res) + _undo_redo.add_undo_property(node, property, old_value) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "property": property, + "resource_path": resource_path, + "resource_type": res.get_class(), + "undoable": true, + } + } + + +## Instantiate a built-in Resource subclass, optionally apply `properties`, +## and either assign it to a node slot (undoable) or save it to a .tres file +## (not undoable — mirrors material_create). Exactly one home is required; +## a resource with no home would be GC'd after the handler returns. +func create_resource(params: Dictionary) -> Dictionary: + var type_str: String = params.get("type", "") + if type_str.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type") + + var properties: Dictionary = params.get("properties", {}) + var node_path: String = params.get("path", "") + var property: String = params.get("property", "") + var resource_path: String = params.get("resource_path", "") + var overwrite: bool = params.get("overwrite", false) + + var home_err := McpResourceIO.validate_home(params) + if home_err != null: + return home_err + var has_file_target := not resource_path.is_empty() + + var made := _instantiate_resource(type_str) + if made is Dictionary: + return made + var res: Resource = made + + if not properties.is_empty(): + var apply_err := _apply_resource_properties(res, properties) + if apply_err != null: + return apply_err + + if has_file_target: + return _save_created_resource(res, type_str, resource_path, overwrite, properties.size()) + return _assign_created_resource(res, type_str, node_path, property, properties.size()) + + +## Validate that `type_str` names a concrete Resource subclass that we can +## instantiate. Returns an error dict on failure, or null on success. +static func _validate_resource_class(type_str: String) -> Variant: + if not ClassDB.class_exists(type_str): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str) + if ClassDB.is_parent_class(type_str, "Node"): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "%s is a Node type, not a Resource — use node_create instead" % type_str + ) + if not ClassDB.is_parent_class(type_str, "Resource"): + var parent := ClassDB.get_parent_class(type_str) + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "%s is not a Resource type (extends %s)" % [type_str, parent] + ) + if not ClassDB.can_instantiate(type_str): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "%s is abstract and cannot be instantiated — use a concrete subclass (e.g. BoxMesh, BoxShape3D, StyleBoxFlat)" % type_str + ) + return null + + +## Build the "Unknown resource type" error with a steer toward op="scan". A type +## that reaches here is neither an engine built-in (ClassDB) nor a registered +## project class (the global script-class registry). In an agent-driven workflow +## the most common cause is a `class_name` script just made via script_create +## that isn't registered yet — the global class table only rebuilds on a +## filesystem scan (normally an editor-focus event). Point the caller at the one +## cheap call that fixes that, so it doesn't fall back to a full plugin reload. +## See #614 for the headless scan op. +static func _unknown_resource_type_error(type_str: String) -> Dictionary: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + ( + "Unknown resource type: %s — not an engine built-in or a registered project class. " + + "If you just created it with script_create, the global class table is stale until a " + + "scan: call filesystem_manage(op=\"scan\"), then retry. Otherwise check the spelling." + ) % type_str + ) + + +## Resolve a resource type name to a fresh instance. Handles engine built-ins +## (ClassDB) and project `class_name` Resources (the global script-class +## registry). Returns a Resource on success, or an error dict on failure. +static func _instantiate_resource(type_str: String) -> Variant: + if ClassDB.class_exists(type_str): + var class_err: Variant = _validate_resource_class(type_str) + if class_err != null: + return class_err + var built_in := ClassDB.instantiate(type_str) + if built_in == null or not (built_in is Resource): + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str) + return built_in + for entry in ProjectSettings.get_global_class_list(): + if entry.get("class", "") == type_str: + var script_path: String = entry.get("path", "") + var scr: Variant = load(script_path) + # Reject non-Resource script classes BEFORE constructing them: + # scr.new() runs _init(), and an @tool class_name extending a + # non-RefCounted type (e.g. Node) would otherwise build — and leak — + # an orphan instance this path never frees. get_instance_base_type() + # resolves to the native base, so multi-level custom Resource + # hierarchies (B extends A extends Resource) still pass. + var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path) + if base_or_err is Dictionary: + return base_or_err + var base_type: StringName = base_or_err + if not ClassDB.is_parent_class(base_type, "Resource"): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type]) + if not scr.can_instantiate(): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated in the editor (abstract, or a non-@tool script — add @tool to instantiate it here)" % type_str) + # Reject scripts whose _init() requires arguments BEFORE scr.new(): + # scr.new() passes no args, so a required-arg _init raises and aborts + # this handler mid-call, null-cascading into a generic "malformed + # result" error instead of a clean rejection. get_script_method_list() + # reports the effective (incl. inherited) _init; required args = + # args - default_args. Statically detectable only — a _init that runs + # but throws still falls through to scr.new() and the dispatcher catch. + for method in scr.get_script_method_list(): + if method.get("name", "") == "_init": + var required_args: int = (method.get("args", []) as Array).size() - (method.get("default_args", []) as Array).size() + if required_args > 0: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated: its _init() requires arguments" % type_str) + break + var made: Variant = scr.new() + if made == null or not (made is Resource): + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str) + return made + return _unknown_resource_type_error(type_str) + + +## Maximum nesting depth for the {"__class__": ...} sub-resource shortcut. +## Caller-supplied dicts recurse through _apply_resource_properties; without a +## cap a deeply nested payload overflows the GDScript call stack and crashes +## the editor (#536). 32 is far beyond any legitimate sub-resource chain. +const MAX_NESTED_RESOURCE_DEPTH := 32 + + +## Apply a dict of property values to a freshly-instantiated Resource, +## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed. +## Returns null on success or an error dict on failure. +## `depth` is internal recursion bookkeeping for the nested {"__class__": ...} +## shortcut — external callers use the default of 0. +static func _apply_resource_properties(res: Resource, properties: Dictionary, depth: int = 0) -> Variant: + if depth > MAX_NESTED_RESOURCE_DEPTH: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Nested resource properties exceed the maximum depth of %d — flatten the {\"__class__\": ...} nesting or create the deep sub-resources in separate calls" % MAX_NESTED_RESOURCE_DEPTH + ) + var prop_types := {} + for prop in res.get_property_list(): + prop_types[prop.name] = prop.get("type", TYPE_NIL) + for key in properties.keys(): + if not prop_types.has(key): + var valid: Array[String] = [] + for prop in res.get_property_list(): + if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR: + valid.append(prop.name) + valid.sort() + # Name the script's class_name (e.g. MyTestResource) rather than the + # native base (Resource) so the hint names the type the agent created, + # and point at the real MCP verb — resource_manage(op="get_info") now + # answers for project class_name Resources too. + var type_label := res.get_class() + var res_script: Variant = res.get_script() + if res_script is Script and not String(res_script.get_global_name()).is_empty(): + type_label = String(res_script.get_global_name()) + var err := ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Property '%s' not found on %s. Call resource_manage(op=\"get_info\", params={\"type\": \"%s\"}) to list available properties." % [key, type_label, type_label] + ) + err["error"]["data"] = {"valid_properties": valid} + return err + var target_type: int = prop_types[key] + if target_type == TYPE_NIL: + target_type = typeof(res.get(key)) + var v = properties[key] + if target_type == TYPE_OBJECT and v is String: + if v == "": + v = null + else: + var vpath_err = McpPathValidator.loadable_error(v, "property '%s'" % key) + if vpath_err != null: + return vpath_err + var loaded := ResourceLoader.load(v) + if loaded == null: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Resource not found at path '%s' for property '%s'" % [v, key] + ) + v = loaded + elif target_type == TYPE_OBJECT and v is Dictionary and v.has("__class__"): + # Nested shortcut: the same {"__class__": "X", ...} form that + # node_handler.set_property accepts, now also supported here so + # resource_create/environment_create callers can populate + # sub-resource slots (ShaderMaterial.shader, etc.) in one shot. + var sub_type: String = v.get("__class__", "") + # Resolve via the shared helper so the nested shortcut accepts both + # engine built-ins (ClassDB) and project `class_name` Resources, + # exactly like the top-level resource_create path. + var sub_made := _instantiate_resource(sub_type) + if sub_made is Dictionary: + # Preserve the property-slot context the inline path used to add. + sub_made["error"]["message"] = "%s (for property '%s')" % [sub_made["error"]["message"], key] + return sub_made + var sub_res: Resource = sub_made + var remaining: Dictionary = (v as Dictionary).duplicate() + remaining.erase("__class__") + if not remaining.is_empty(): + var nested_err := _apply_resource_properties(sub_res, remaining, depth + 1) + if nested_err != null: + return nested_err + v = sub_res + else: + var slot_value: Variant = res.get(key) + if target_type == TYPE_ARRAY and slot_value is Array and (slot_value as Array).is_typed(): + ## Typed Array[T] slot (#612): mirror set_property's dispatch — + ## the generic passthrough would hand an untyped Array to the + ## typed setter, which drops it silently while we report success. + var typed_out: Variant = NodeHandler._coerce_typed_array( + v, slot_value, "Property '%s'" % key + ) + if typed_out is Dictionary: + return typed_out + v = typed_out + elif ( + target_type == TYPE_DICTIONARY + and slot_value is Dictionary + and (slot_value as Dictionary).is_typed() + ): + ## Typed Dictionary[K, V] slot (#612 stage 3): success is a + ## typed duplicate of the slot; the error envelope is untyped. + var typed_dict_out: Dictionary = NodeHandler._coerce_typed_dictionary( + v, slot_value, "Property '%s'" % key + ) + if not typed_dict_out.is_typed(): + return typed_dict_out + v = typed_dict_out + else: + v = NodeHandler._coerce_value(v, target_type) + ## Mirror set_property's coerce check: wrong-shape dicts (#123) and + ## non-dict inputs that don't land as the target compound Variant + ## (#191) both error here instead of writing zero-filled Variants. + var coerce_err := NodeHandler._check_coerced(v, target_type, "Property '%s'" % key) + if coerce_err != null: + return coerce_err + res.set(key, v) + return null + + +func _assign_created_resource(res: Resource, type_str: String, node_path: String, property: String, applied_count: int) -> Dictionary: + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var _scene_root: Node = _resolved.scene_root + + var found := false + var prop_type: int = TYPE_NIL + for prop in node.get_property_list(): + if prop.name == property: + found = true + prop_type = prop.get("type", TYPE_NIL) + break + if not found: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + McpPropertyErrors.build_message(node, property) + ) + if prop_type != TYPE_NIL and prop_type != TYPE_OBJECT: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Property '%s' on %s is not an Object slot (type %s)" % [property, node.get_class(), type_string(prop_type)] + ) + + var old_value = node.get(property) + + _undo_redo.create_action("MCP: Create %s for %s.%s" % [type_str, node.name, property]) + _undo_redo.add_do_property(node, property, res) + _undo_redo.add_undo_property(node, property, old_value) + _undo_redo.add_do_reference(res) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "property": property, + "type": type_str, + "resource_class": res.get_class(), + "properties_applied": applied_count, + "undoable": true, + } + } + + +func _save_created_resource(res: Resource, type_str: String, resource_path: String, overwrite: bool, applied_count: int) -> Dictionary: + return McpResourceIO.save_to_disk(res, resource_path, overwrite, "Resource", { + "type": type_str, + "resource_class": res.get_class(), + "properties_applied": applied_count, + }, _connection) + + +## Introspect a Resource class — return its editor-visible properties, parent, +## whether it's abstract, and (for abstract bases) the list of concrete +## subclasses that resource_create can instantiate. Read-only. +func get_resource_info(params: Dictionary) -> Dictionary: + var type_str: String = params.get("type", "") + if type_str.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type") + + if not ClassDB.class_exists(type_str): + # Project class_name Resources aren't in ClassDB; resolve them through the + # global script-class registry so get_info answers for the same custom + # types resource_create can make. Read-only — never instantiates. + var custom_info: Variant = _custom_resource_info(type_str) + if custom_info != null: + return custom_info + return _unknown_resource_type_error(type_str) + if ClassDB.is_parent_class(type_str, "Node"): + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "%s is a Node type, not a Resource — use node_* tools for node introspection" % type_str + ) + if not ClassDB.is_parent_class(type_str, "Resource") and type_str != "Resource": + var parent := ClassDB.get_parent_class(type_str) + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "%s is not a Resource type (extends %s)" % [type_str, parent] + ) + + var can_instantiate: bool = ClassDB.can_instantiate(type_str) + var class_info := ClassIntrospection.build(type_str, { + "sections": ["properties"], + "include_inherited": true, + "include_inheritors": not can_instantiate, + "limit": 0, + }) + var data: Dictionary = { + "type": type_str, + "parent_class": class_info.parent_class, + "can_instantiate": can_instantiate, + "is_abstract": not can_instantiate, + "properties": class_info.properties, + "property_count": class_info.property_count, + } + + # For abstract bases (Shape3D, Material, Texture, StyleBox, ...) surface + # the concrete Resource subclasses an agent could try next. + if not can_instantiate: + data["concrete_subclasses"] = class_info.concrete_inheritors + + return {"data": data} + + +## Resolve a loaded global-class script to its native base type, or an error if +## the script failed to load (not a Script) or to compile (empty base type). +## Shared by the create and get_info custom-Resource paths so both report a +## compile failure rather than a misleading "is not a Resource type (extends )". +static func _script_base_type_or_error(scr: Variant, type_str: String, script_path: String) -> Variant: + if not (scr is Script): + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script class %s from %s" % [type_str, script_path]) + var base_type: StringName = scr.get_instance_base_type() + if String(base_type).is_empty(): + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s failed to compile or parse (script %s)" % [type_str, script_path]) + return base_type + + +## get_info for a project `class_name` Resource (not in ClassDB). Returns an info +## dict, an error dict (for a class_name whose native base is not a Resource), or +## null if `type_str` is not a registered global class. Read-only: resolves +## properties from the script + its native base WITHOUT instantiating (no _init()). +static func _custom_resource_info(type_str: String) -> Variant: + for entry in ProjectSettings.get_global_class_list(): + if entry.get("class", "") != type_str: + continue + var script_path: String = entry.get("path", "") + var scr: Variant = load(script_path) + var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path) + if base_or_err is Dictionary: + return base_or_err + var base_type: StringName = base_or_err + if not ClassDB.is_parent_class(base_type, "Resource"): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type]) + var can_instantiate: bool = scr.can_instantiate() + # Inherited (native) properties come from the engine base via ClassDB... + var class_info := ClassIntrospection.build(String(base_type), { + "sections": ["properties"], + "include_inherited": true, + "limit": 0, + }) + var props: Array = [] + for native_prop in class_info.properties: + props.append(native_prop) + # ...and the script's own (and inherited script) exported properties come + # from the Script itself, so we never construct the resource. A real + # default isn't available without instantiating, so script props carry an + # explicit null — keeping one uniform key set across the array (native + # props carry their real default). + for raw_prop in scr.get_script_property_list(): + var prop: Dictionary = raw_prop + var usage := int(prop.get("usage", 0)) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + props.append({ + "name": str(prop.get("name", "")), + "type": type_string(int(prop.get("type", TYPE_NIL))), + "class_name": str(prop.get("class_name", "")), + "hint": int(prop.get("hint", PROPERTY_HINT_NONE)), + "hint_string": str(prop.get("hint_string", "")), + "usage": usage, + "default": null, + }) + props.sort_custom(func(a, b): return a.name < b.name) + # parent_class is the immediate script parent when there is one (so a + # multi-level chain B -> A -> Resource reports A), else the native base. + var parent_name := String(base_type) + var base_script: Variant = scr.get_base_script() + if base_script is Script and not String(base_script.get_global_name()).is_empty(): + parent_name = String(base_script.get_global_name()) + return {"data": { + "type": type_str, + "parent_class": parent_name, + "can_instantiate": can_instantiate, + # is_abstract reflects real abstractness (the @abstract annotation), + # NOT editor-instantiability — a non-@tool concrete Resource has + # can_instantiate()==false in-editor but is not abstract. + "is_abstract": scr.is_abstract(), + "properties": props, + "property_count": props.size(), + }} + return null diff --git a/addons/godot_ai/handlers/resource_handler.gd.uid b/addons/godot_ai/handlers/resource_handler.gd.uid new file mode 100644 index 0000000..d79d3cf --- /dev/null +++ b/addons/godot_ai/handlers/resource_handler.gd.uid @@ -0,0 +1 @@ +uid://dwwd0n3c56ir diff --git a/addons/godot_ai/handlers/scene_handler.gd b/addons/godot_ai/handlers/scene_handler.gd new file mode 100644 index 0000000..1c1f2b4 --- /dev/null +++ b/addons/godot_ai/handlers/scene_handler.gd @@ -0,0 +1,420 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles scene tree reading and node search. + +var _connection: McpConnection +var _save_scene_callable: Callable = Callable() +var _save_scene_as_callable: Callable = Callable() + + +func _init(connection: McpConnection = null) -> void: + _connection = connection + + +func get_scene_tree(params: Dictionary) -> Dictionary: + var max_depth: int = params.get("depth", 10) + var offset: int = maxi(0, int(params.get("offset", 0))) + # limit <= 0 means "no limit" (the hierarchy resource reads the whole tree); + # the scene_get_hierarchy tool passes an explicit positive limit. Paginating + # here — rather than walking + serializing the full tree and slicing on the + # Python side — means only the requested window builds node dicts and clean + # scene paths, and only the window crosses the WebSocket. + var limit: int = int(params.get("limit", 0)) + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root == null: + return {"data": { + "nodes": [], + "total_count": 0, + "offset": offset, + "limit": limit, + "has_more": false, + "message": "No scene open", + }} + + var nodes: Array[Dictionary] = [] + # index_ref[0] is the running DFS index shared across the recursion (Arrays + # pass by reference in GDScript). The walk still visits every node to get an + # accurate total_count, but only materializes those inside the window. + var index_ref: Array[int] = [0] + # _walk_tree self-seeds the root's path for full reads; pass "" explicitly. + _walk_tree(scene_root, nodes, 0, max_depth, scene_root, offset, limit, index_ref, "") + var total: int = index_ref[0] + return {"data": { + "nodes": nodes, + "total_count": total, + "offset": offset, + "limit": limit, + "has_more": limit > 0 and offset + limit < total, + }} + + +func get_open_scenes(_params: Dictionary) -> Dictionary: + var scene_paths := EditorInterface.get_open_scenes() + var scene_root := EditorInterface.get_edited_scene_root() + var current := scene_root.scene_file_path if scene_root else "" + return { + "data": { + "scenes": scene_paths, + "current_scene": current, + "count": scene_paths.size(), + } + } + + +func find_nodes(params: Dictionary) -> Dictionary: + var name_filter: String = params.get("name", "") + var type_filter: String = params.get("type", "") + var group_filter: String = params.get("group", "") + + if name_filter.is_empty() and type_filter.is_empty() and group_filter.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, group) is required") + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var results: Array[Dictionary] = [] + _find_recursive(scene_root, scene_root, name_filter, type_filter, group_filter, results) + return {"data": {"nodes": results, "count": results.size()}} + + +func _find_recursive(node: Node, scene_root: Node, name_filter: String, type_filter: String, group_filter: String, out: Array[Dictionary]) -> void: + var matches := true + + if not name_filter.is_empty(): + if node.name.to_lower().find(name_filter.to_lower()) == -1: + matches = false + + if matches and not type_filter.is_empty(): + if node.get_class() != type_filter: + matches = false + + if matches and not group_filter.is_empty(): + if not node.is_in_group(group_filter): + matches = false + + if matches: + out.append({ + "name": node.name, + "type": node.get_class(), + "path": McpScenePath.from_node(node, scene_root), + }) + + for child in node.get_children(): + _find_recursive(child, scene_root, name_filter, type_filter, group_filter, out) + + +## Create a new scene with the given root node type, save to disk, and open it. +func create_scene(params: Dictionary) -> Dictionary: + var root_type: String = params.get("root_type", "Node3D") + var path: String = params.get("path", "") + + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + var path_err = McpPathValidator.path_error(path, "path", true) + if path_err != null: + return path_err + + if not path.ends_with(".tscn") and not path.ends_with(".scn"): + path += ".tscn" + + if not ClassDB.class_exists(root_type): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown node type: %s" % root_type) + if not ClassDB.is_parent_class(root_type, "Node"): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % root_type) + + # Ensure parent directory exists + var dir_path := path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + var err := DirAccess.make_dir_recursive_absolute(dir_path) + if err != OK: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path) + + var root: Node = ClassDB.instantiate(root_type) + if root == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % root_type) + + var root_name: String = params.get("root_name", "") + if root_name.is_empty(): + root_name = path.get_file().get_basename() + root.name = root_name + + if _connection: + _connection.pause_processing = true + var err := _pack_and_save_with_uid(root, path) + if err == OK: + EditorInterface.open_scene_from_path(path) + if _connection: + _connection.pause_processing = false + + if err != OK: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save scene: %s" % error_string(err)) + + return { + "data": { + "path": path, + "root_type": root_type, + "root_name": root_name, + "undoable": false, + "reason": "Scene creation involves file system operations", + } + } + + +## Pack `root` and save it to `path`, embedding a fresh uid or preserving the +## one `path` already had — the exact save sequence `create_scene` runs, +## minus the `pause_processing` guard (the caller owns that, since it also +## needs to bracket `open_scene_from_path`) and minus opening the scene +## (switching the editor's active scene isn't safe inside the shared test +## runner, so tests call this directly instead of going through +## `create_scene` end-to-end). Frees `root`. Returns `OK`, or the first +## `Error` encountered. +func _pack_and_save_with_uid(root: Node, path: String) -> Error: + var packed := PackedScene.new() + packed.pack(root) + root.free() + + # Captured BEFORE the save below overwrites the file — see + # McpResourceIO.ensure_uid's doc comment. + var prior_uid := ResourceLoader.get_resource_uid(path) if FileAccess.file_exists(path) else ResourceUID.INVALID_ID + + var err := ResourceSaver.save(packed, path) + if err == OK: + err = McpResourceIO.ensure_uid(path, prior_uid) + return err + + +## How long open_scene waits for the editor to actually switch to the +## requested scene before replying switched=false. Tab switches normally land +## within a few frames; keep this under the dispatcher's 4500 ms deferred +## default so the coroutine always answers before DEFERRED_TIMEOUT fires. +const _OPEN_SETTLE_MAX_MSEC := 3000 + + +## Open an existing scene by file path. +func open_scene(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + var force_reload: bool = params.get("force_reload", false) + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + var path_err = McpPathValidator.loadable_error(path, "path") + if path_err != null: + return path_err + + if not ResourceLoader.exists(path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % path) + + var scene_root := EditorInterface.get_edited_scene_root() + var current_path := scene_root.scene_file_path if scene_root else "" + ## Instance id of the root at call time. A completed open OR reload always + ## replaces the edited-scene root with a NEW instance, so this is the + ## reliable completion signal — unlike scene_file_path, which is unchanged + ## across a force_reload of the already-open scene (#633 review). + var prev_root_id := scene_root.get_instance_id() if scene_root else 0 + var payload := { + "path": path, + "force_reload": force_reload, + "reloaded_from_disk": false, + "previous_scene_path": current_path, + "undoable": false, + "reason": "Scene navigation cannot be undone via editor undo", + } + + if current_path == path and not force_reload: + ## Already the edited scene — nothing switches, reply immediately. + payload["switched"] = true + payload["settle"] = "already_current" + return {"data": payload} + + if force_reload and current_path == path: + EditorInterface.reload_scene_from_path(path) + payload["reloaded_from_disk"] = true + else: + EditorInterface.open_scene_from_path(path) + + ## The tab switch completes asynchronously; replying now lets an immediate + ## follow-up write land on the PREVIOUS scene (#633 — a scene_save issued + ## right after open_scene saved the old scene). Defer the reply until the + ## edited scene actually is `path` AND its root is a fresh instance, so + ## success means "the editor is now editing the (re)loaded scene". + var request_id: String = params.get("_request_id", "") + if _connection != null and not request_id.is_empty(): + _finish_open_scene_deferred(_connection, request_id, path, prev_root_id, payload) + return McpDispatcher.DEFERRED_RESPONSE + + ## Synchronous fallback (batch_execute and unit-test contexts can't await): + ## preserve the old reply-immediately behavior, flagged as not waited on. + payload["switched"] = false + payload["settle"] = "not_waited" + return {"data": payload} + + +## `static` is load-bearing (same reason as FilesystemHandler's deferred scan +## finish): the coroutine must outlive this RefCounted handler, which can be +## freed mid-await by an editor_reload_plugin. Parameterise everything; +## reference no instance state. +static func _finish_open_scene_deferred( + connection: McpConnection, + request_id: String, + path: String, + prev_root_id: int, + payload: Dictionary, +) -> void: + if not is_instance_valid(connection): + return + var tree := connection.get_tree() + if tree == null: + return + # Hand back a frame so _dispatch() registers this request as deferred + # before the coroutine can push a reply. + await tree.process_frame + var deadline_ms := Time.get_ticks_msec() + _OPEN_SETTLE_MAX_MSEC + while Time.get_ticks_msec() < deadline_ms: + var root := EditorInterface.get_edited_scene_root() + # Require BOTH the target path AND a fresh root instance: a + # force_reload keeps scene_file_path == path across the reload, so the + # instance swap is what proves the (re)load actually completed rather + # than the coroutine settling on the stale pre-reload root. + if root != null and root.scene_file_path == path and root.get_instance_id() != prev_root_id: + if not is_instance_valid(connection): + return + payload["switched"] = true + payload["settle"] = "settled" + connection.send_deferred_response(request_id, {"data": payload}) + return + await tree.process_frame + if not is_instance_valid(connection): + return + payload["switched"] = false + payload["settle"] = "timeout" + connection.send_deferred_response(request_id, {"data": payload}) + + +## Save the currently edited scene. +## Pauses WebSocket processing during save to prevent re-entrant _process() +## calls during EditorNode::_save_scene_with_preview's thumbnail render. +func save_scene(_params: Dictionary) -> Dictionary: + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var path := scene_root.scene_file_path + if path.is_empty(): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Current scene has never been saved; call scene_manage(op='save_as') with a res://... path ending in .tscn or .scn." + ) + + if _connection: + _connection.pause_processing = true + var err := _save_current_scene() + if _connection: + _connection.pause_processing = false + + if err != OK: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save scene: %s" % error_string(err)) + + return { + "data": { + "path": path, + "undoable": false, + "reason": "File save cannot be undone via editor undo", + } + } + + +## Save the currently edited scene to a new file path. +func save_scene_as(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + var path_err = McpPathValidator.path_error(path, "path", true) + if path_err != null: + return path_err + + if not path.ends_with(".tscn") and not path.ends_with(".scn"): + path += ".tscn" + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + # Ensure parent directory exists + var dir_path := path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + var err := DirAccess.make_dir_recursive_absolute(dir_path) + if err != OK: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path) + + if _connection: + _connection.pause_processing = true + _save_current_scene_as(path) + if _connection: + _connection.pause_processing = false + + return { + "data": { + "path": path, + "undoable": false, + "reason": "File save cannot be undone via editor undo", + } + } + + +func _save_current_scene() -> int: + if _save_scene_callable.is_valid(): + return int(_save_scene_callable.call()) + return EditorInterface.save_scene() + + +func _save_current_scene_as(path: String) -> void: + if _save_scene_as_callable.is_valid(): + _save_scene_as_callable.call(path) + return + EditorInterface.save_scene_as(path) + + +func _walk_tree(node: Node, out: Array[Dictionary], depth: int, max_depth: int, scene_root: Node, offset: int, limit: int, index_ref: Array[int], node_path: String) -> void: + if depth > max_depth: + return + var idx: int = index_ref[0] + index_ref[0] = idx + 1 + # Materialize only nodes inside the [offset, offset+limit) window. Outside + # it we still recurse (to count total_count) but skip the per-node dict. + # + # Path build strategy depends on the read shape (identical output either way): + # * A whole-tree read (offset == 0 and limit <= 0 — the resource-style read + # backing godot://scene/hierarchy) threads the parent's clean path down the + # DFS: each node's path is one O(1) concat reusing the descent, instead of + # McpScenePath.from_node's two native walks back up (is_ancestor_of + + # get_path_to). Benchmarked ~1.8x faster on a ~1.5k-node tree, up to ~5x on + # deep chains. + # * Any windowed read (limit > 0, or an offset > 0 skip) keeps from_node for + # just the emitted nodes: threading would concatenate a path for every node + # visited for total_count, which benchmarks ~20% slower for a small window. + # + # `node_path` is self-seeded at the scene root below, so a caller cannot leave + # a full read unseeded (it has no default — pass "" for windowed reads). + var incremental := limit <= 0 and offset == 0 + if incremental and node == scene_root: + node_path = "/" + String(scene_root.name) + var in_window := idx >= offset and (limit <= 0 or idx < offset + limit) + if in_window: + out.append({ + "name": node.name, + "type": node.get_class(), + "path": node_path if incremental else McpScenePath.from_node(node, scene_root), + "children_count": node.get_child_count(), + }) + for child in node.get_children(): + var child_path := (node_path + "/" + String(child.name)) if incremental else "" + _walk_tree(child, out, depth + 1, max_depth, scene_root, offset, limit, index_ref, child_path) diff --git a/addons/godot_ai/handlers/scene_handler.gd.uid b/addons/godot_ai/handlers/scene_handler.gd.uid new file mode 100644 index 0000000..af3711f --- /dev/null +++ b/addons/godot_ai/handlers/scene_handler.gd.uid @@ -0,0 +1 @@ +uid://7ms40gm6t2r4 diff --git a/addons/godot_ai/handlers/script_handler.gd b/addons/godot_ai/handlers/script_handler.gd new file mode 100644 index 0000000..698b462 --- /dev/null +++ b/addons/godot_ai/handlers/script_handler.gd @@ -0,0 +1,501 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_capture.gd") +const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd") + +## Handles script creation, reading, attaching, detaching, and symbol inspection. + +var _undo_redo: EditorUndoRedoManager +var _connection: McpConnection + +# The bounded import-settle window and the deferred completion coroutine +# live on McpResourceIO since #714 — write_file's fresh-`.gd` path shares +# them, so create_script and write_file can't drift apart again (#261). + +func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: + _undo_redo = undo_redo + _connection = connection + + +func create_script(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + var content: String = params.get("content", "") + + var path_err = McpPathValidator.path_error(path, "path", true) + if path_err != null: + return path_err + + if not path.ends_with(".gd"): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd") + + var existed_before := FileAccess.file_exists(path) + + # Shared write path (#714): parent mkdir + write/flush + explicit error + # check live on McpResourceIO so write_file can't drift from this again. + var write_failure: Variant = McpResourceIO.write_text_to_disk(path, content) + if write_failure != null: + return write_failure + + var data := { + "path": path, + "size": content.length(), + "committed": true, + "import_settled": existed_before, + "import_settle": "already_known" if existed_before else "not_waited", + "undoable": false, + "reason": "File system operations cannot be undone via editor undo", + } + _attach_gdscript_diagnostics(data, path, content) + + # A freshly-declared `class_name` is NOT in the global class table until a + # filesystem scan runs — update_file() below registers the file with the + # resource pipeline but not the class registry (see the scan() comment). + # Surface that precisely (only when the class isn't already registered) so a + # headless caller knows to follow up with filesystem_manage(op="scan") + # instead of hitting a confusing "Unknown type" / "Unknown resource type" on + # the very next call. We don't scan here — a scan() per create is the exact + # SIGABRT race documented below; the explicit op is single-flight. + # Skip the hint when the script failed to parse: a scan won't register a + # class from a broken script, so pointing at op="scan" would steer the caller + # away from the real fix (the parse error already attached above). + var declared_class := _extract_class_name(content) + if ( + not declared_class.is_empty() + and not _script_has_error_diagnostics(data) + and not _class_name_registered(declared_class) + ): + data["class_name"] = declared_class + data["class_registration"] = "scan_required" + data["class_registration_hint"] = ( + "New class_name '%s' isn't in the global class table yet. " % declared_class + + "Call filesystem_manage(op=\"scan\") if it won't resolve on the next " + + "call (e.g. resource_manage op=\"create\", or used as a type in another " + + "script). The editor also registers it on its next filesystem scan or " + + "when its window regains focus." + ) + + # Register just this file with the editor instead of a full recursive + # scan(). A scan() per write stacks `update_scripts_classes` / + # `update_script_paths_documentation` WorkerThreadPool tasks under concurrent + # script creation ("Task ... already exists" / "!tasks.has(p_task)"), which + # races the global-class registry and can SIGABRT in + # ScriptServer::remove_global_class_by_path (see dsarno/godot#6). + # update_file() is the single-file path the rest of the plugin already uses. + var efs := EditorInterface.get_resource_filesystem() + if efs != null: + efs.update_file(path) + + # `.gd.uid` is the sidecar Godot generates on scan; list both so the caller + # can rm the full set in one go. + McpResourceIO.attach_cleanup_hint(data, existed_before, [path, path + ".uid"]) + + # scan() is async — ResourceLoader.exists(path) returns false until Godot's + # filesystem pipeline finishes. If we reply now, an immediate attach_script + # races and 404s (#261). Defer the response until the resource is visible + # (or a bounded timeout elapses). For freshly-created files we wait; on + # overwrite the resource was already known to ResourceLoader, so reply now. + var request_id: String = params.get("_request_id", "") + if not existed_before and _connection != null and not request_id.is_empty(): + McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data) + return McpDispatcher.DEFERRED_RESPONSE + + # Synchronous fallback: batch_execute (no request_id) and unit-test contexts + # (no connection) get the immediate reply that the previous behaviour gave. + return {"data": data} + + +## Extract the `class_name` a script declares, or "" if none. A cheap line scan +## (no full parse) for create_script's "scan_required" hint. Stops at the first +## space/tab or comma so all three valid forms yield just the name: +## `class_name Foo`, `class_name Foo extends Bar`, and the icon form +## `class_name Foo, "res://icon.svg"`. +static func _extract_class_name(content: String) -> String: + for raw_line in content.split("\n"): + var line := raw_line.strip_edges() + if line.begins_with("class_name "): + var rest := line.substr(11).strip_edges() + var cut := rest.length() + for i in rest.length(): + var ch := rest[i] + if ch == " " or ch == "\t" or ch == ",": + cut = i + break + return rest.substr(0, cut) + return "" + + +## True if create_script's diagnostics captured a parse error for this script. +## Used to suppress the "scan_required" hint when the class can't register +## anyway — see create_script. +static func _script_has_error_diagnostics(data: Dictionary) -> bool: + for diag in data.get("diagnostics", []): + if diag is Dictionary and diag.get("level", "") == "error": + return true + return false + + +## True if `cn` is already usable as a type — an engine built-in (ClassDB) or an +## already-registered project global class. A brand-new class_name returns false +## until a filesystem scan registers it. +static func _class_name_registered(cn: String) -> bool: + if ClassDB.class_exists(cn): + return true + for entry in ProjectSettings.get_global_class_list(): + if entry.get("class", "") == cn: + return true + return false + + +func read_script(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + + var path_err = McpPathValidator.path_error(path, "path") + if path_err != null: + return path_err + + if not FileAccess.file_exists(path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path) + + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path) + + var content := file.get_as_text() + file.close() + + return { + "data": { + "path": path, + "content": content, + "size": content.length(), + "line_count": content.count("\n") + (1 if not content.is_empty() else 0), + } + } + + +## Instance (not static) despite using no instance state: tests stub +## `_capture_gdscript_load_diagnostics` via subclass override, and static +## calls bind lexically — see test_script.gd. filesystem_handler shares +## this by instantiating a bare ScriptHandler (#714). +func _attach_gdscript_diagnostics(data: Dictionary, path: String, content: String) -> void: + var validation := _validate_gdscript_source(content) + var diagnostics: Array = [] + var diagnostics_detail := "none" + var diagnostics_status := "checked" + + if not validation.get("ok", true): + var capture := _capture_gdscript_load_diagnostics(path) + diagnostics = capture.get("diagnostics", []) + diagnostics_detail = capture.get("diagnostics_detail", "none") + diagnostics_status = capture.get("diagnostics_status", "checked") + if not validation.get("ok", true) and diagnostics.is_empty(): + diagnostics.append(_fallback_gdscript_diagnostic(path, validation.get("error_code", FAILED), content)) + diagnostics_detail = "fallback" + data["diagnostics"] = diagnostics + data["diagnostics_detail"] = diagnostics_detail + data["diagnostics_scope"] = "this_file" + data["diagnostics_status"] = diagnostics_status + + +static func _validate_gdscript_source(content: String) -> Dictionary: + var script := GDScript.new() + script.source_code = content + ## Keep validation off the live cached resource: assigning resource_path to + ## this ephemeral Script can collide with loaded instances. reload() still + ## performs normal GDScript analysis, including static initializer work, so + ## this check is intentionally scoped to `.gd` writes where the editor would + ## compile the file on scan anyway. + var err := script.reload() + return { + "ok": err == OK, + "error_code": err, + } + + +func _capture_gdscript_load_diagnostics(path: String) -> Dictionary: + var buffer := McpEditorLogBuffer.new() + var logger := ValidationLogger.new(buffer) + var capture := DiagnosticsCapture.capture_this_file(buffer, path, func() -> Dictionary: + OS.add_logger(logger) + # ResourceLoader.load() reports parse failure instead of throwing, and + # a failed GDScript parse does not execute user code; remove immediately + # after the synchronous load to keep the private capture window tiny. + ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) + OS.remove_logger(logger) + return {} + ) + return capture + + +static func _fallback_gdscript_diagnostic(path: String, error_code: int, content: String) -> Dictionary: + var line := _fallback_gdscript_error_line(content) + return { + "source": "editor", + "level": "error", + "text": "GDScript reload failed with error code %d." % error_code, + "path": path, + "line": line, + "function": "GDScript::reload", + "details": { + "code": "gdscript_reload_failed", + "error_code": error_code, + "fallback_line": true, + "source": { + "path": path, + "line": line, + }, + }, + } + + +static func _fallback_gdscript_error_line(content: String) -> int: + var lines := content.split("\n") + for i in range(lines.size() - 1, -1, -1): + if not str(lines[i]).strip_edges().is_empty(): + return i + 1 + return 1 + + +func patch_script(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + var old_text: String = params.get("old_text", "") + var new_text: String = params.get("new_text", "") + var replace_all: bool = params.get("replace_all", false) + + var path_err = McpPathValidator.path_error(path, "path", true) + if path_err != null: + return path_err + if not "old_text" in params: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text") + if not "new_text" in params: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text") + if not path.ends_with(".gd"): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd (use filesystem_write_text for other text files)") + if old_text.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty") + + var read := FileAccess.open(path, FileAccess.READ) + if read == null: + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found or unreadable: %s" % path) + var content := read.get_as_text() + read.close() + + var match_count := content.count(old_text) + if match_count == 0: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "old_text not found in %s" % path) + if match_count > 1 and not replace_all: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "old_text matches %d times; pass replace_all=true or provide a more specific snippet" % match_count, + ) + + var new_content: String + var replacements: int + if replace_all: + new_content = content.replace(old_text, new_text) + replacements = match_count + else: + var idx := content.find(old_text) + new_content = content.substr(0, idx) + new_text + content.substr(idx + old_text.length()) + replacements = 1 + + # Shared write path (#714). No import-settle deferral here: the file + # already exists, so ResourceLoader knows it and there is no scan to wait + # for — same rationale as create_script's overwrite arm. + var write_failure: Variant = McpResourceIO.write_text_to_disk(path, new_content) + if write_failure != null: + return write_failure + + var data := { + "path": path, + "replacements": replacements, + "size": new_content.length(), + "old_size": content.length(), + "undoable": false, + "reason": "File system operations cannot be undone via editor undo", + } + _attach_gdscript_diagnostics(data, path, new_content) + + # Single-file register, not a full scan() — see create_script (dsarno/godot#6). + var efs := EditorInterface.get_resource_filesystem() + if efs != null: + efs.update_file(path) + + return {"data": data} + + +func attach_script(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + var script_path: String = params.get("script_path", "") + + if node_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + if script_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: script_path") + + var spath_err = McpPathValidator.loadable_error(script_path, "script_path") + if spath_err != null: + return spath_err + + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var _scene_root: Node = _resolved.scene_root + + if not ResourceLoader.exists(script_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Script not found: %s" % script_path) + + var script: Script = load(script_path) + if script == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script: %s" % script_path) + + var old_script: Script = node.get_script() + + _undo_redo.create_action("MCP: Attach script to %s" % node.name) + _undo_redo.add_do_method(node, "set_script", script) + _undo_redo.add_undo_method(node, "set_script", old_script) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "script_path": script_path, + "had_previous_script": old_script != null, + "undoable": true, + } + } + + +func detach_script(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + + if node_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var _scene_root: Node = _resolved.scene_root + + var old_script: Script = node.get_script() + if old_script == null: + return {"data": {"path": node_path, "had_script": false, "undoable": false, "reason": "No script attached"}} + + _undo_redo.create_action("MCP: Detach script from %s" % node.name) + _undo_redo.add_do_method(node, "set_script", null) + _undo_redo.add_undo_method(node, "set_script", old_script) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "removed_script": old_script.resource_path if old_script.resource_path else "(inline)", + "undoable": true, + } + } + + +func find_symbols(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + + var path_err = McpPathValidator.path_error(path, "path") + if path_err != null: + return path_err + + if not FileAccess.file_exists(path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path) + + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path) + + var content := file.get_as_text() + file.close() + + var functions: Array[Dictionary] = [] + var signals_list: Array[String] = [] + var exports: Array[Dictionary] = [] + var class_name_str := "" + var extends_str := "" + + var lines := content.split("\n") + for i in lines.size(): + var line := lines[i].strip_edges() + + # class_name — same cut logic as _extract_class_name so the + # `extends Bar` / icon-form tails don't leak into the symbol name. + if line.begins_with("class_name "): + var cn_rest := line.substr(11).strip_edges() + var cn_cut := cn_rest.length() + for ci in cn_rest.length(): + var cn_ch := cn_rest[ci] + if cn_ch == " " or cn_ch == "\t" or cn_ch == ",": + cn_cut = ci + break + class_name_str = cn_rest.substr(0, cn_cut) + + # extends + if line.begins_with("extends "): + extends_str = line.substr(8).strip_edges() + + # signal + if line.begins_with("signal "): + var sig_text := line.substr(7).strip_edges() + # Strip any parameters for the name + var paren_idx := sig_text.find("(") + if paren_idx >= 0: + signals_list.append(sig_text.substr(0, paren_idx).strip_edges()) + else: + signals_list.append(sig_text) + + # func (including `static func` — strip the leading `static ` first) + var func_line := line.substr(7).strip_edges() if line.begins_with("static func ") else line + if func_line.begins_with("func "): + var func_text := func_line.substr(5).strip_edges() + var paren_idx := func_text.find("(") + if paren_idx >= 0: + functions.append({ + "name": func_text.substr(0, paren_idx).strip_edges(), + "line": i + 1, + }) + + # @export + if line.begins_with("@export"): + # Next non-empty line should have the var declaration + # But often export and var are on the same logical flow + # Try to find "var" on the same line or the next line + var var_line := line + if var_line.find("var ") == -1 and i + 1 < lines.size(): + var_line = lines[i + 1].strip_edges() + var var_idx := var_line.find("var ") + if var_idx >= 0: + var rest := var_line.substr(var_idx + 4).strip_edges() + # Extract variable name (up to : or = or end) + var end_idx := rest.length() + for ch_idx in rest.length(): + if rest[ch_idx] == ":" or rest[ch_idx] == "=" or rest[ch_idx] == " ": + end_idx = ch_idx + break + exports.append({ + "name": rest.substr(0, end_idx), + "line": i + 1, + }) + + return { + "data": { + "path": path, + "class_name": class_name_str, + "extends": extends_str, + "functions": functions, + "signals": signals_list, + "exports": exports, + "function_count": functions.size(), + "signal_count": signals_list.size(), + "export_count": exports.size(), + } + } diff --git a/addons/godot_ai/handlers/script_handler.gd.uid b/addons/godot_ai/handlers/script_handler.gd.uid new file mode 100644 index 0000000..6be1ac4 --- /dev/null +++ b/addons/godot_ai/handlers/script_handler.gd.uid @@ -0,0 +1 @@ +uid://dhub87454jxb3 diff --git a/addons/godot_ai/handlers/signal_handler.gd b/addons/godot_ai/handlers/signal_handler.gd new file mode 100644 index 0000000..ad85c7d --- /dev/null +++ b/addons/godot_ai/handlers/signal_handler.gd @@ -0,0 +1,274 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles signal listing, connecting, and disconnecting on scene nodes. + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +func list_signals(params: Dictionary) -> Dictionary: + var path_value: Variant = params.get("path", "") + var path_type_err = McpParamValidators.require_string("path", path_value) + if path_type_err != null: + return path_type_err + ## String(...) conversion: require_string accepts StringName too, and + ## a bare typed assignment from StringName would defeat the guard. + var path: String = String(path_value) + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + var _resolved := McpNodeValidator.resolve_or_error(path, "path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var scene_root: Node = _resolved.scene_root + + ## Default: hide editor-internal connections (SceneTreeEditor observers + ## live on every scene node and would otherwise dominate the response). + ## Pass include_editor=true to see them. See #213. + var include_editor: bool = params.get("include_editor", false) + + var signals: Array[Dictionary] = [] + for sig in node.get_signal_list(): + var args: Array[Dictionary] = [] + for arg in sig.get("args", []): + args.append({"name": arg.get("name", ""), "type": type_string(arg.get("type", 0))}) + signals.append({ + "name": sig.get("name", ""), + "args": args, + }) + + var connections: Array[Dictionary] = [] + var editor_connection_count := 0 + for sig in signals: + for conn in node.get_signal_connection_list(sig.name): + var callable: Callable = conn.get("callable", Callable()) + var target := callable.get_object() + if target == null: + continue # skip connections to freed objects + if not include_editor and _is_editor_internal_target(target, scene_root): + editor_connection_count += 1 + continue + connections.append({ + "signal": sig.name, + "target": _format_target_path(target, scene_root), + "method": callable.get_method(), + }) + + return { + "data": { + "path": McpScenePath.from_node(node, scene_root), + "signals": signals, + "signal_count": signals.size(), + "connections": connections, + "connection_count": connections.size(), + "editor_connection_count": editor_connection_count, + } + } + + +## A target is "editor-internal" when it's a Node sitting outside the edited +## scene tree AND not anywhere under a declared autoload — typical case is +## the SceneTreeEditor dock listening for visibility/script/state changes on +## every scene node. Connections to autoloads (declared under ``autoload/*`` +## in ProjectSettings) are user-authored even though they live under +## ``/root/`` rather than under the edited scene root, so the autoload +## root *and* any descendant of it stay visible. Non-Node targets +## (anonymous Callables, RefCounted listeners etc.) also stay visible — we +## can't reliably classify them. +func _is_editor_internal_target(target: Object, scene_root: Node) -> bool: + if not (target is Node): + return false + var node_target: Node = target + if node_target == scene_root: + return false + if scene_root.is_ancestor_of(node_target): + return false + if _is_under_autoload(node_target): + return false + return true + + +## True if `node` is a declared autoload root or sits anywhere under one. +## When the node is in the SceneTree we read its absolute path +## (``/root//...``) and check the first segment after ``/root/``; +## this covers connections to deep descendants of editor-instanced +## autoloads (e.g. ``/root/MyAutoload/Foo/Bar``). When the node isn't in +## the tree (test fixtures often construct nodes in isolation), we walk +## the parent chain and match each ancestor's ``name`` against the +## autoload key as a best-effort fallback. +static func _is_under_autoload(node: Node) -> bool: + if node.is_inside_tree(): + var path := str(node.get_path()) + if not path.begins_with("/root/"): + return false + var first_segment := path.substr(6).split("/", true, 1)[0] + return ProjectSettings.has_setting("autoload/" + first_segment) + var cursor: Node = node + while cursor != null: + if ProjectSettings.has_setting("autoload/" + str(cursor.name)): + return true + cursor = cursor.get_parent() + return false + + +## Serialize a connection's target path. Descendants of (or equal to) the +## edited scene root render as the usual scene-relative form +## (``/Main/Camera3D``). Non-descendants — autoload subtrees in particular +## — render as their canonical absolute SceneTree path +## (``/root/MyAutoload/Child``) instead of a scene-relative path full of +## ``..`` segments, which agents can't navigate back to. Non-Node targets +## (anonymous Callables, etc.) fall back to their string representation. +static func _format_target_path(target: Object, scene_root: Node) -> String: + if not (target is Node): + return str(target) + var node_target: Node = target + if node_target == scene_root or scene_root.is_ancestor_of(node_target): + return McpScenePath.from_node(node_target, scene_root) + if node_target.is_inside_tree(): + return str(node_target.get_path()) + return McpScenePath.from_node(node_target, scene_root) + + +func connect_signal(params: Dictionary) -> Dictionary: + var resolved := _resolve_signal_params(params) + if resolved.has("error"): + return resolved + + var source: Node = resolved.source + var target: Node = resolved.target + var signal_name: String = resolved.signal_name + var method: String = resolved.method + var scene_root: Node = resolved.scene_root + + if not source.has_signal(signal_name): + return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Signal '%s' not found on %s" % [signal_name, params.path]) + + if not target.has_method(method): + return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Method '%s' not found on %s" % [method, params.target]) + + var callable := Callable(target, method) + if source.is_connected(signal_name, callable): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method]) + + _undo_redo.create_action("MCP: Connect signal %s" % signal_name) + _undo_redo.add_do_method(source, "connect", signal_name, callable, Object.CONNECT_PERSIST) + _undo_redo.add_undo_method(source, "disconnect", signal_name, callable) + _undo_redo.commit_action() + + return {"data": _signal_response(source, signal_name, target, method, scene_root)} + + +func disconnect_signal(params: Dictionary) -> Dictionary: + var resolved := _resolve_signal_params(params) + if resolved.has("error"): + return resolved + + var source: Node = resolved.source + var target: Node = resolved.target + var signal_name: String = resolved.signal_name + var method: String = resolved.method + var scene_root: Node = resolved.scene_root + + var callable := Callable(target, method) + if not source.is_connected(signal_name, callable): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method]) + + # Capture the connection's current flags so undo restores it exactly as it + # was, not unconditionally as CONNECT_PERSIST. Hardcoding PERSIST here would + # silently promote a runtime-only connection into one that serializes on the + # next save. (The connection still exists at this point — checked above.) + var reconnect_flags := 0 + for conn in source.get_signal_connection_list(signal_name): + if conn.get("callable", Callable()) == callable: + reconnect_flags = int(conn.get("flags", 0)) + break + + _undo_redo.create_action("MCP: Disconnect signal %s" % signal_name) + _undo_redo.add_do_method(source, "disconnect", signal_name, callable) + _undo_redo.add_undo_method(source, "connect", signal_name, callable, reconnect_flags) + _undo_redo.commit_action() + + return {"data": _signal_response(source, signal_name, target, method, scene_root)} + + +func _resolve_signal_params(params: Dictionary) -> Dictionary: + for key in ["path", "signal", "target", "method"]: + ## Type-check before calling .is_empty(): a non-string value (e.g. an + ## int or dict) has no is_empty() and would crash the handler, which + ## the dispatcher only reports as an opaque "malformed result" (#210). + var value = params.get(key, "") + var type_err = McpParamValidators.require_string(key, value) + if type_err != null: + return type_err + if value.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % key) + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var source_result := _resolve_node_or_autoload(params.path, scene_root, "Source") + if source_result.has("error"): + return source_result + var source: Node = source_result.node + + var target_result := _resolve_node_or_autoload(params.target, scene_root, "Target") + if target_result.has("error"): + return target_result + var target: Node = target_result.node + + return { + "source": source, + "target": target, + "signal_name": params.signal, + "method": params.method, + "scene_root": scene_root, + } + + +## Resolve a path to a Node, with three distinct outcomes: +## 1. Found in the edited scene tree → returns {node} +## 2. Declared as an autoload AND instantiated at edit time → returns {node} +## 3. Declared as an autoload but NOT instantiated at edit time → returns +## INVALID_PARAMS with guidance. Most autoloads are runtime-only, so a +## silent "not found" hides the real reason the connection can't be made. +## 4. Not in scene and not a declared autoload → returns INVALID_PARAMS. +func _resolve_node_or_autoload(path: String, scene_root: Node, role: String) -> Dictionary: + var node := McpScenePath.resolve(path, scene_root) + if node != null: + return {"node": node} + + var name := path.trim_prefix("/") + if ProjectSettings.has_setting("autoload/" + name): + # Autoload is declared — see if the editor has it instanced. + var tree := Engine.get_main_loop() + if tree is SceneTree: + var live := (tree as SceneTree).root.get_node_or_null(name) + if live != null: + return {"node": live} + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "%s '%s' is a declared autoload but isn't instantiated in the editor. " % [role, name] + + "Most autoloads are runtime-only; edit-time signal connection isn't supported for them. " + + "Connect it from a script attached to the scene using @onready + connect(), " + + "or enable editor-instancing for this autoload in Project Settings > Autoload.") + + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, + "%s node not found: %s (not in scene tree or autoloads)" % [role, path]) + + +func _signal_response(source: Node, signal_name: String, target: Node, method: String, scene_root: Node) -> Dictionary: + return { + "source": McpScenePath.from_node(source, scene_root), + "signal": signal_name, + "target": McpScenePath.from_node(target, scene_root), + "method": method, + "undoable": true, + } diff --git a/addons/godot_ai/handlers/signal_handler.gd.uid b/addons/godot_ai/handlers/signal_handler.gd.uid new file mode 100644 index 0000000..d95e1d3 --- /dev/null +++ b/addons/godot_ai/handlers/signal_handler.gd.uid @@ -0,0 +1 @@ +uid://b4n8byjeqeddm diff --git a/addons/godot_ai/handlers/test_handler.gd b/addons/godot_ai/handlers/test_handler.gd new file mode 100644 index 0000000..2c0f4ea --- /dev/null +++ b/addons/godot_ai/handlers/test_handler.gd @@ -0,0 +1,349 @@ +@tool +extends RefCounted + +## Discovers and runs McpTestSuite scripts from res://tests/. +## Exposes run_tests and get_test_results as MCP commands. +## +## Live MCP runs service the WebSocket transport between tests +## (McpConnection.service_transport_during_exclusive_run) so a long suite +## can no longer starve the server's keepalive, and abort at a per-call +## ceiling derived from the server-provided time budget. Direct callers, +## unit-test fixtures, and batch contexts (no request id / no connection) +## keep the legacy fully-synchronous behavior with no ceiling. +## See docs/test-run-transport-starvation-plan.md. + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Clamp bounds for the server-provided ``timeout_budget_sec`` param. The +## floor is purely defensive (a malformed or buggy server value must not +## abort every run instantly); the param is not user-facing. +const BUDGET_MIN_SEC := 30.0 +const BUDGET_MAX_SEC := 3600.0 +## Conservative default when the server sent no (or an invalid) budget: an +## old server's own test_run timeout is 120s, and the plugin must abort +## and reply before that future expires. +const BUDGET_DEFAULT_SEC := 110.0 +## Abort this long before the server would time the call out, so the +## partial-results reply beats the server-side timeout. +const CEILING_MARGIN_SEC := 10.0 + +var _runner: McpTestRunner +var _undo_redo: EditorUndoRedoManager +var _log_buffer: McpLogBuffer +## Live plugin dispatcher, exposed to suites via ctx so tests can prove the +## lazy handler registrations (#736) materialize with their real ctor args. +## Optional third arg keeps old two-arg fixtures working; untyped because +## the dispatcher constructs this handler (avoids a load-time type cycle). +var _dispatcher +## Live connection for exclusive-run transport servicing. Null in unit-test +## fixtures and batch contexts, which keep the legacy synchronous path. +var _connection: McpConnection + + +func _init( + undo_redo: EditorUndoRedoManager, + log_buffer: McpLogBuffer, + dispatcher = null, + connection: McpConnection = null, +) -> void: + _runner = McpTestRunner.new() + _undo_redo = undo_redo + _log_buffer = log_buffer + _dispatcher = dispatcher + _connection = connection + + +func run_tests(params: Dictionary) -> Dictionary: + var suite_filter: String = params.get("suite", "") + var test_filter: String = params.get("test_name", "") + var exclude_test_filter: String = params.get("exclude_test_name", "") + var verbose: bool = params.get("verbose", false) + + var request_id: String = params.get("_request_id", "") + var live := _connection != null and not request_id.is_empty() + var service_cb := Callable() + var deadline_ticks_ms := 0 + var budget_sec := 0.0 + var started_ms := Time.get_ticks_msec() + var run_state := {} + if live: + budget_sec = _validated_budget_sec(params) + service_cb = Callable(_connection, "service_transport_during_exclusive_run") + deadline_ticks_ms = started_ms + int((budget_sec - CEILING_MARGIN_SEC) * 1000.0) + + ## Clear the previous run's results BEFORE discovery so an abort at any + ## later point can never expose a stale prior run via get_test_results. + _runner.clear() + + var discovery := _discover_suites(service_cb, deadline_ticks_ms, run_state) + var discovery_outcome: String = discovery.get("outcome", "") + if not discovery_outcome.is_empty(): + ## Aborted during discovery: no suite has begun, so there is no + ## suite teardown to run. Same outcome mapping as the run itself. + var empty_results: Dictionary = _runner.get_results(verbose) + if not discovery.errors.is_empty(): + empty_results["load_errors"] = discovery.errors + return _map_outcome( + discovery_outcome, "discovery", empty_results, 0, started_ms, budget_sec + ) + + var suites: Array = discovery.suites + ## #882: an explicit `suite` filter that matches nothing must say so, not + ## return {"total": 0} — a caller cannot otherwise distinguish "suite has + ## no tests" from "suite was never registered" (truncated discovery). + ## Checked BEFORE the no-suites branch so an empty discovery still names + ## the unmatched filter (with suites_available: []). + var unknown := unknown_suite_error(suite_filter, suites) + if not unknown.is_empty(): + if not discovery.errors.is_empty(): + unknown["error"]["data"]["load_errors"] = discovery.errors + return unknown + + if suites.is_empty(): + var msg := "No test suites found in res://tests/" + if not discovery.errors.is_empty(): + msg += " (%d script(s) failed to load: %s)" % [ + discovery.errors.size(), + ", ".join(discovery.errors), + ] + var no_suites := {"error": msg, "total": 0, "load_errors": discovery.errors} + ## Keep the edited_scene annotation on the no-suites error payload too, + ## so the response contract is consistent across every return path. + _annotate_edited_scene(no_suites) + return {"data": no_suites} + + var ctx := { + "undo_redo": _undo_redo, + "log_buffer": _log_buffer, + "dispatcher": _dispatcher, + } + + var run: Dictionary = _runner.run_suites_serviced( + suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter, + service_cb, deadline_ticks_ms, run_state + ) + var results: Dictionary = run["results"] + if not discovery.errors.is_empty(): + results["load_errors"] = discovery.errors + return _map_outcome( + run["outcome"], "run", results, run["tests_not_run"], started_ms, budget_sec + ) + + +## Empty when `suite_filter` is empty or names a discovered suite; otherwise +## the INVALID_PARAMS protocol error run_tests returns (same ErrorCodes.make +## shape as the paused/timeout aborts — never an error inside a success +## envelope). Static + pure so the suite can pin it without calling +## run_tests() end-to-end (which would recursively re-run the whole corpus +## from inside itself — see test_serviced_test_run.gd). +static func unknown_suite_error(suite_filter: String, suites: Array) -> Dictionary: + if suite_filter.is_empty(): + return {} + var names := PackedStringArray() + for suite: McpTestSuite in suites: + names.append(suite.suite_name()) + if names.has(suite_filter): + return {} + var err := ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + ( + "No suite named '%s' is registered (%d discovered)." + + " If it should exist, discovery may be truncated —" + + " re-import and restart the editor (#882)." + ) % [suite_filter, names.size()], + ) + err["error"]["data"] = { + "suite": suite_filter, + "suites_available": Array(names), + } + return err + + +## Map a runner/discovery outcome onto the response envelope. Ownership is +## deliberately here, not in the runner: the runner reports WHAT happened, +## the handler decides how it goes over the wire (plan D2). +func _map_outcome( + outcome: String, + phase: String, + results: Dictionary, + tests_not_run: int, + started_ms: int, + budget_sec: float, +) -> Dictionary: + var elapsed_ms := Time.get_ticks_msec() - started_ms + match outcome: + "completed": + _annotate_edited_scene(results) + return {"data": results} + "transport_lost": + ## The peer is gone (or flood-closed); the send will fail against + ## the dead socket regardless, but a sync handler must return an + ## envelope. Partials stay retrievable via get_test_results after + ## the plugin reconnects. + results["aborted"] = "transport_lost" + results["tests_not_run"] = tests_not_run + _annotate_edited_scene(results) + return {"data": results} + "paused": + var depth := _connection.pause_depth() if _connection != null else 0 + if _log_buffer != null: + _log_buffer.log( + "[error] test run aborted in %s: transport paused at checkpoint (depth %d)" + % [phase, depth] + ) + var paused_err := ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + ( + "Test run aborted: the MCP transport was paused at a between-test " + + "checkpoint (pause depth %d) — a paused transport cannot service " + + "the WebSocket heartbeat, so continuing would starve the session. " + + "Partial results: test_manage(op=\"results_get\")." + ) % depth + ) + paused_err["error"]["data"] = _abort_data( + phase, results, tests_not_run, elapsed_ms, budget_sec, {"pause_depth": depth} + ) + return paused_err + "timeout": + var timeout_err := ErrorCodes.make( + ErrorCodes.TEST_RUN_TIMEOUT, + ( + "Test run hit its abort ceiling after %.1fs (budget %.0fs, ceiling = " + + "budget - %.0fs): %d passed, %d failed, %d of the selected tests " + + "never ran. Narrow the run with suite=/test_name= filters, or fetch " + + "the partial results with test_manage(op=\"results_get\")." + ) % [ + elapsed_ms / 1000.0, budget_sec, CEILING_MARGIN_SEC, + int(results.get("passed", 0)), int(results.get("failed", 0)), + tests_not_run, + ] + ) + timeout_err["error"]["data"] = _abort_data( + phase, results, tests_not_run, elapsed_ms, budget_sec, {} + ) + return timeout_err + ## Unknown outcome is a runner bug — surface it loudly. + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, "Unknown test run outcome '%s'" % outcome + ) + + +func _abort_data( + phase: String, + results: Dictionary, + tests_not_run: int, + elapsed_ms: int, + budget_sec: float, + extra: Dictionary, +) -> Dictionary: + var data := { + "phase": phase, + "elapsed_ms": elapsed_ms, + "budget_sec": budget_sec, + "passed": int(results.get("passed", 0)), + "failed": int(results.get("failed", 0)), + "skipped": int(results.get("skipped", 0)), + "total": int(results.get("total", 0)), + "tests_not_run": tests_not_run, + } + data.merge(extra) + return data + + +## Strict validation of the server-provided per-call budget: numeric, +## finite, positive, then clamped to [BUDGET_MIN_SEC, BUDGET_MAX_SEC]. +## Everything else (missing, wrong type, NaN/inf, non-positive) falls back +## to BUDGET_DEFAULT_SEC. typeof() so bool never sneaks through as int. +func _validated_budget_sec(params: Dictionary) -> float: + var raw: Variant = params.get("timeout_budget_sec", null) + var t := typeof(raw) + if t == TYPE_FLOAT or t == TYPE_INT: + var v := float(raw) + if is_finite(v) and v > 0.0: + return clampf(v, BUDGET_MIN_SEC, BUDGET_MAX_SEC) + return BUDGET_DEFAULT_SEC + + +## Many suites assume the project's main scene is the edited scene (they read +## /Main/... nodes directly). Running with another scene open produces a flood +## of phantom failures that look like real regressions. Surface the edited +## scene and a warning when it differs from run/main_scene so the failures are +## attributable at a glance instead of costing a debugging round (#635). +func _annotate_edited_scene(results: Dictionary) -> void: + var scene_root := EditorInterface.get_edited_scene_root() + var edited := scene_root.scene_file_path if scene_root else "" + results["edited_scene"] = edited + var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", "")) + if main_scene.is_empty() or edited == main_scene: + return + if int(results.get("failed", 0)) <= 0: + return + results["scene_warning"] = ( + "Edited scene is '%s' but the project main scene is '%s'. Many suites " + % [edited if not edited.is_empty() else "", main_scene] + + "assume the main scene is open and will report phantom failures " + + "otherwise. If these failures are unexpected, scene_open('%s') and re-run." % main_scene + ) + + +func get_test_results(params: Dictionary) -> Dictionary: + var verbose: bool = params.get("verbose", false) + return {"data": _runner.get_results(verbose)} + + +## Returns {"suites": Array, "errors": Array[String], "outcome": String}. +## Resilient: a broken script doesn't kill discovery of the rest. A +## non-empty outcome ("timeout" / "transport_lost" / "paused") means a +## between-load checkpoint aborted discovery — script loading is itself an +## atomic phase, and a directory of heavy scripts must neither starve the +## heartbeat nor escape the run budget. +func _discover_suites( + service_cb: Callable = Callable(), + deadline_ticks_ms: int = 0, + run_state: Dictionary = {}, +) -> Dictionary: + var suites := [] + var errors: Array[String] = [] + var dir := DirAccess.open("res://tests") + if dir == null: + return { + "suites": suites, + "errors": ["DirAccess.open('res://tests') returned null — directory may not exist"], + "outcome": "", + } + + dir.list_dir_begin() + var file_name := dir.get_next() + while not file_name.is_empty(): + if file_name.begins_with("test_") and file_name.ends_with(".gd"): + var stop := _discovery_checkpoint(service_cb, deadline_ticks_ms, run_state) + if not stop.is_empty(): + return {"suites": suites, "errors": errors, "outcome": stop} + var path := "res://tests/" + file_name + var script = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) + if script == null: + errors.append("%s (load failed — check for parse errors or duplicate methods)" % file_name) + elif script.can_instantiate(): + var instance = script.new() + if instance is McpTestSuite: + suites.append(instance) + else: + errors.append("%s (not a McpTestSuite subclass)" % file_name) + else: + errors.append("%s (cannot instantiate — abstract or broken)" % file_name) + file_name = dir.get_next() + + ## Sort by suite name for deterministic order. + suites.sort_custom(func(a, b) -> bool: + return a.suite_name() < b.suite_name() + ) + return {"suites": suites, "errors": errors, "outcome": ""} + + +## Discovery-phase twin of McpTestRunner._checkpoint. Both delegate to the +## shared McpConnection.exclusive_run_checkpoint so the outcome mapping +## cannot drift between the discovery and between-test paths. +func _discovery_checkpoint( + service_cb: Callable, deadline_ticks_ms: int, run_state: Dictionary +) -> String: + return McpConnection.exclusive_run_checkpoint(service_cb, deadline_ticks_ms, run_state) diff --git a/addons/godot_ai/handlers/test_handler.gd.uid b/addons/godot_ai/handlers/test_handler.gd.uid new file mode 100644 index 0000000..e56fce1 --- /dev/null +++ b/addons/godot_ai/handlers/test_handler.gd.uid @@ -0,0 +1 @@ +uid://bfg3c6iinhwmx diff --git a/addons/godot_ai/handlers/texture_handler.gd b/addons/godot_ai/handlers/texture_handler.gd new file mode 100644 index 0000000..4e90d18 --- /dev/null +++ b/addons/godot_ai/handlers/texture_handler.gd @@ -0,0 +1,199 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Creates procedural textures — GradientTexture2D (wrapping a Gradient) +## and NoiseTexture2D (wrapping a FastNoiseLite). Assigns to a node slot +## (undoable, bundles sub-resources) or saves to a .tres file. + +const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") + +var _undo_redo: EditorUndoRedoManager +var _connection: McpConnection + + +func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: + _undo_redo = undo_redo + _connection = connection + + +const _FILL_MODES := { + "linear": GradientTexture2D.FILL_LINEAR, + "radial": GradientTexture2D.FILL_RADIAL, + "square": GradientTexture2D.FILL_SQUARE, +} + +const _NOISE_TYPES := { + "simplex": FastNoiseLite.TYPE_SIMPLEX, + "simplex_smooth": FastNoiseLite.TYPE_SIMPLEX_SMOOTH, + "perlin": FastNoiseLite.TYPE_PERLIN, + "cellular": FastNoiseLite.TYPE_CELLULAR, + "value": FastNoiseLite.TYPE_VALUE, + "value_cubic": FastNoiseLite.TYPE_VALUE_CUBIC, +} + + +# ============================================================================ +# gradient_texture_create +# ============================================================================ + +func create_gradient_texture(params: Dictionary) -> Dictionary: + var stops: Array = params.get("stops", []) + var width: int = params.get("width", 256) + var height: int = params.get("height", 1) + var fill: String = params.get("fill", "linear") + + if stops.size() < 2: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "gradient_texture_create requires at least 2 stops, got %d" % stops.size() + ) + if not _FILL_MODES.has(fill): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid fill '%s'. Valid: %s" % [fill, ", ".join(_FILL_MODES.keys())] + ) + + var home_err := McpResourceIO.validate_home(params) + if home_err != null: + return home_err + + var gradient := Gradient.new() + var offsets := PackedFloat32Array() + var colors := PackedColorArray() + for i in range(stops.size()): + var stop = stops[i] + if not stop is Dictionary: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "stops[%d] must be a dict with 'offset' and 'color' keys" % i + ) + if not stop.has("offset") or not stop.has("color"): + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "stops[%d] missing 'offset' or 'color' key" % i + ) + offsets.append(float(stop["offset"])) + var color_value = NodeHandler._coerce_value(stop["color"], TYPE_COLOR) + var color_err := NodeHandler._check_coerced(color_value, TYPE_COLOR, "stops[%d].color" % i) + if color_err != null: + return color_err + colors.append(color_value) + gradient.offsets = offsets + gradient.colors = colors + + var tex := GradientTexture2D.new() + tex.gradient = gradient + tex.width = width + tex.height = height + tex.fill = _FILL_MODES[fill] + + return _finalize(tex, [gradient], params, "Gradient texture", { + "texture_class": "GradientTexture2D", + "gradient_class": "Gradient", + "stop_count": stops.size(), + "fill": fill, + }) + + +# ============================================================================ +# noise_texture_create +# ============================================================================ + +func create_noise_texture(params: Dictionary) -> Dictionary: + var noise_type: String = params.get("noise_type", "simplex_smooth") + var width: int = params.get("width", 512) + var height: int = params.get("height", 512) + var frequency: float = params.get("frequency", 0.01) + var seed_value: int = params.get("seed", 0) + var fractal_octaves: int = params.get("fractal_octaves", 0) # 0 = leave default + + if not _NOISE_TYPES.has(noise_type): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid noise_type '%s'. Valid: %s" % [noise_type, ", ".join(_NOISE_TYPES.keys())] + ) + + var home_err := McpResourceIO.validate_home(params) + if home_err != null: + return home_err + + var noise := FastNoiseLite.new() + noise.noise_type = _NOISE_TYPES[noise_type] + noise.frequency = frequency + noise.seed = seed_value + if fractal_octaves > 0: + noise.fractal_octaves = fractal_octaves + + var tex := NoiseTexture2D.new() + tex.noise = noise + tex.width = width + tex.height = height + + return _finalize(tex, [noise], params, "Noise texture", { + "texture_class": "NoiseTexture2D", + "noise_class": "FastNoiseLite", + "noise_type": noise_type, + }) + + +# ============================================================================ +# shared helpers +# ============================================================================ + +func _finalize(tex: Resource, sub_resources: Array, params: Dictionary, label: String, extra: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + var property: String = params.get("property", "") + var resource_path: String = params.get("resource_path", "") + var overwrite: bool = params.get("overwrite", false) + + if not resource_path.is_empty(): + return McpResourceIO.save_to_disk(tex, resource_path, overwrite, label, extra, _connection) + return _assign_texture(tex, sub_resources, node_path, property, label, extra) + + +func _assign_texture(tex: Resource, sub_resources: Array, node_path: String, property: String, label: String, extra: Dictionary) -> Dictionary: + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var _scene_root: Node = _resolved.scene_root + + var found := false + var prop_type: int = TYPE_NIL + for prop in node.get_property_list(): + if prop.name == property: + found = true + prop_type = prop.get("type", TYPE_NIL) + break + if not found: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + McpPropertyErrors.build_message(node, property) + ) + if prop_type != TYPE_NIL and prop_type != TYPE_OBJECT: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Property '%s' on %s is not an Object slot" % [property, node.get_class()] + ) + + var old_value = node.get(property) + + _undo_redo.create_action("MCP: Create %s for %s.%s" % [label, node.name, property]) + _undo_redo.add_do_property(node, property, tex) + _undo_redo.add_undo_property(node, property, old_value) + _undo_redo.add_do_reference(tex) + for sub in sub_resources: + _undo_redo.add_do_reference(sub) + _undo_redo.commit_action() + + var data := { + "path": node_path, + "property": property, + "undoable": true, + } + data.merge(extra) + return {"data": data} + + diff --git a/addons/godot_ai/handlers/texture_handler.gd.uid b/addons/godot_ai/handlers/texture_handler.gd.uid new file mode 100644 index 0000000..a0a0f73 --- /dev/null +++ b/addons/godot_ai/handlers/texture_handler.gd.uid @@ -0,0 +1 @@ +uid://cmloikhre8lhe diff --git a/addons/godot_ai/handlers/theme_handler.gd b/addons/godot_ai/handlers/theme_handler.gd new file mode 100644 index 0000000..a71c745 --- /dev/null +++ b/addons/godot_ai/handlers/theme_handler.gd @@ -0,0 +1,476 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles Theme resource authoring: creating, modifying color/constant/font-size/ +## stylebox slots, and applying a theme to a Control subtree. +## +## Themes are Godot's equivalent of USS: a Theme holds (class, name) -> value +## entries (colors, constants, fonts, font_sizes, styleboxes, icons) which +## cascade down a Control subtree when the theme is assigned at any ancestor. +## One well-authored theme replaces hundreds of per-node property sets. + +const _COLOR_HINT := "expected hex #rrggbb, named color, or {r,g,b,a} dict" + +var _undo_redo: EditorUndoRedoManager +var _connection: McpConnection + + +func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: + _undo_redo = undo_redo + _connection = connection + + +# ============================================================================ +# theme_create +# ============================================================================ + +func create_theme(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + var overwrite: bool = params.get("overwrite", false) + + var err := _validate_res_path(path, ".tres", "path", true) + if err != null: + return err + + # Capture whether the file was already there BEFORE the save so we can + # report `overwritten` accurately (after save the file always exists). + var existed_before := FileAccess.file_exists(path) + if existed_before and not overwrite: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Theme already exists at %s (pass overwrite=true to replace)" % path + ) + + # Ensure parent directory exists. make_dir_recursive is idempotent — + # no need to check dir_exists first (avoids TOCTOU race). + var dir_path := path.get_base_dir() + var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path) + if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to create directory: %s (error %d)" % [dir_path, mkdir_err] + ) + + var theme := Theme.new() + var save_err := McpResourceIO.guarded_save(theme, path, _connection) + if save_err != OK: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to save theme to %s: %s (error %d)" % [path, error_string(save_err), save_err] + ) + + # Make sure the editor's filesystem picks up the new file. + var efs := EditorInterface.get_resource_filesystem() + if efs != null: + efs.update_file(path) + + return { + "data": { + "path": path, + "overwritten": existed_before, + "undoable": false, + "reason": "File creation is persistent; delete the file manually to revert", + } + } + + +# ============================================================================ +# theme_set_color / theme_set_constant / theme_set_font_size +# ============================================================================ + +func set_color(params: Dictionary) -> Dictionary: + return _set_scalar(params, "color", func(theme, name, cls): return theme.get_color(name, cls), + func(theme, name, cls, val): theme.set_color(name, cls, val), + func(theme, name, cls): theme.clear_color(name, cls), + func(theme, name, cls): return theme.has_color(name, cls), + func(v): return _parse_color(v)) + + +# constant / font_size parsers validate before coercing: int("abc")/int({})/int([]) +# all return 0 in GDScript (never null), so a bare `int(v)` would silently store +# garbage as 0 and report success. Returning null for non-numeric input lets +# _set_scalar's null guard surface a VALUE_OUT_OF_RANGE error, matching the +# color path's contract. +func set_constant(params: Dictionary) -> Dictionary: + return _set_scalar(params, "constant", func(theme, name, cls): return theme.get_constant(name, cls), + func(theme, name, cls, val): theme.set_constant(name, cls, int(val)), + func(theme, name, cls): theme.clear_constant(name, cls), + func(theme, name, cls): return theme.has_constant(name, cls), + func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null) + + +func set_font_size(params: Dictionary) -> Dictionary: + return _set_scalar(params, "font_size", func(theme, name, cls): return theme.get_font_size(name, cls), + func(theme, name, cls, val): theme.set_font_size(name, cls, int(val)), + func(theme, name, cls): theme.clear_font_size(name, cls), + func(theme, name, cls): return theme.has_font_size(name, cls), + func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null) + + +# Shared implementation for scalar Theme slots (color, constant, font_size). +# Captures old value, applies new value, saves to disk, registers undo that +# restores the old value and saves again. +func _set_scalar( + params: Dictionary, + kind: String, + getter: Callable, + setter: Callable, + clearer: Callable, + has_fn: Callable, + parser: Callable, +) -> Dictionary: + var load_result := _load_theme_from_params(params) + if load_result.has("error"): + return load_result + var theme: Theme = load_result.theme + var theme_path: String = load_result.path + + var class_name_param: String = params.get("class_name", "") + if class_name_param.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name") + + var name: String = params.get("name", "") + if name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") + + if not "value" in params: + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") + + var raw_value = params.get("value") + if raw_value == null: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid %s value: null (pass a concrete value; use the appropriate clear command to remove a slot)" % kind + ) + var parsed = parser.call(raw_value) + if parsed == null: + ## color slots want a color hint; constant/font_size are integer slots. + var hint := _COLOR_HINT if kind == "color" else "expected an integer" + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, + "Invalid %s value: %s (%s)" % [kind, raw_value, hint]) + + var had_before: bool = has_fn.call(theme, name, class_name_param) + var before_value = getter.call(theme, name, class_name_param) if had_before else null + + _undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name]) + _undo_redo.add_do_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, parsed) + if had_before: + _undo_redo.add_undo_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, before_value) + else: + _undo_redo.add_undo_method(self, "_clear_scalar", theme_path, clearer, name, class_name_param) + _undo_redo.commit_action() + + return { + "data": { + "path": theme_path, + "kind": kind, + "class_name": class_name_param, + "name": name, + "value": _serialize_value(parsed), + "previous_value": _serialize_value(before_value) if had_before else null, + "undoable": true, + } + } + + +func _apply_scalar(theme_path: String, setter: Callable, name: String, class_name_param: String, value: Variant) -> void: + var theme: Theme = ResourceLoader.load(theme_path) + if theme == null: + push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path) + return + setter.call(theme, name, class_name_param, value) + McpResourceIO.guarded_save(theme, theme_path, _connection) + + +func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> void: + var theme: Theme = ResourceLoader.load(theme_path) + if theme == null: + push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path) + return + clearer.call(theme, name, class_name_param) + McpResourceIO.guarded_save(theme, theme_path, _connection) + + +# ============================================================================ +# theme_set_stylebox_flat +# ============================================================================ + +## Compose a StyleBoxFlat and assign it to a theme slot. +## +## Parameters (beyond theme_path / class_name / name): +## bg_color (Color, "#rrggbb", "#rrggbbaa", or {r,g,b,a}) +## border_color (Color) +## border {all|top|bottom|left|right: int} — side keys override `all` +## corners {all|top_left|top_right|bottom_left|bottom_right: int} +## margins {all|top|bottom|left|right: float} +## shadow {color, size: int, offset_x: float, offset_y: float} +## anti_aliasing (bool) +## +## Unknown keys inside any nested dict are rejected with INVALID_PARAMS so +## typos fail loudly instead of silently being ignored. +func set_stylebox_flat(params: Dictionary) -> Dictionary: + var load_result := _load_theme_from_params(params) + if load_result.has("error"): + return load_result + var theme: Theme = load_result.theme + var theme_path: String = load_result.path + + var class_name_param: String = params.get("class_name", "") + if class_name_param.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name") + + var name: String = params.get("name", "") + if name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") + + var sb := StyleBoxFlat.new() + if params.has("bg_color"): + var bg := _parse_color(params.bg_color) + if bg == null: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid bg_color: %s (%s)" % [str(params.bg_color), _COLOR_HINT]) + sb.bg_color = bg + if params.has("border_color"): + var bc := _parse_color(params.border_color) + if bc == null: + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid border_color: %s (%s)" % [str(params.border_color), _COLOR_HINT]) + sb.border_color = bc + + # border: {all, top, bottom, left, right} — int widths + if params.has("border"): + var err := _apply_sides(sb, params.border, "border", + ["top", "bottom", "left", "right"], + "border_width_", + TYPE_INT) + if err != "": + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err) + + # corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii + if params.has("corners"): + var err2 := _apply_sides(sb, params.corners, "corners", + ["top_left", "top_right", "bottom_left", "bottom_right"], + "corner_radius_", + TYPE_INT) + if err2 != "": + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err2) + + # margins: {all, top, bottom, left, right} — float padding + if params.has("margins"): + var err3 := _apply_sides(sb, params.margins, "margins", + ["top", "bottom", "left", "right"], + "content_margin_", + TYPE_FLOAT) + if err3 != "": + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err3) + + # shadow: {color, size, offset_x, offset_y} + if params.has("shadow"): + if typeof(params.shadow) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'shadow' must be a dict with color/size/offset_x/offset_y") + var shadow: Dictionary = params.shadow + var allowed_shadow_keys := {"color": true, "size": true, "offset_x": true, "offset_y": true} + for k in shadow.keys(): + if not allowed_shadow_keys.has(k): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Unknown key in 'shadow': %s (valid: color, size, offset_x, offset_y)" % k) + if shadow.has("color"): + var sc := _parse_color(shadow.color) + if sc == null: + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, + "Invalid shadow.color: %s (%s)" % [str(shadow.color), _COLOR_HINT]) + sb.shadow_color = sc + if shadow.has("size"): + sb.shadow_size = int(shadow.size) + if shadow.has("offset_x") or shadow.has("offset_y"): + sb.shadow_offset = Vector2( + float(shadow.get("offset_x", 0)), + float(shadow.get("offset_y", 0)), + ) + + if params.has("anti_aliasing"): + sb.anti_aliasing = bool(params.anti_aliasing) + + var had_before := theme.has_stylebox(name, class_name_param) + var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null + + _undo_redo.create_action("MCP: Theme set stylebox %s/%s" % [class_name_param, name]) + _undo_redo.add_do_method(self, "_apply_stylebox", theme_path, name, class_name_param, sb) + if had_before: + _undo_redo.add_undo_method(self, "_apply_stylebox", theme_path, name, class_name_param, before_sb) + else: + _undo_redo.add_undo_method(self, "_clear_stylebox", theme_path, name, class_name_param) + _undo_redo.commit_action() + + return { + "data": { + "path": theme_path, + "class_name": class_name_param, + "name": name, + "stylebox_class": "StyleBoxFlat", + "bg_color": _serialize_value(sb.bg_color), + "border": { + "top": sb.border_width_top, + "bottom": sb.border_width_bottom, + "left": sb.border_width_left, + "right": sb.border_width_right, + }, + "corners": { + "top_left": sb.corner_radius_top_left, + "top_right": sb.corner_radius_top_right, + "bottom_left": sb.corner_radius_bottom_left, + "bottom_right": sb.corner_radius_bottom_right, + }, + "margins": { + "top": sb.content_margin_top, + "bottom": sb.content_margin_bottom, + "left": sb.content_margin_left, + "right": sb.content_margin_right, + }, + "undoable": true, + } + } + + +## Parse a {all, , , ...} dict and apply it to StyleBoxFlat via +## its set_ properties. Returns "" on success, an error +## message on failure. Validates that only known keys are present. +func _apply_sides(sb: StyleBoxFlat, sides_dict: Variant, dict_name: String, + side_names: Array, prop_prefix: String, value_type: int) -> String: + if typeof(sides_dict) != TYPE_DICTIONARY: + return "'%s' must be a dict with 'all' and/or side-specific keys" % dict_name + var valid_keys := {"all": true} + for s in side_names: + valid_keys[s] = true + for k in sides_dict.keys(): + if not valid_keys.has(k): + return "Unknown key in '%s': %s (valid: all, %s)" % [ + dict_name, k, ", ".join(side_names) + ] + # Apply `all` first, then override with side-specific keys. + if sides_dict.has("all"): + var all_val: Variant = sides_dict.all + for s in side_names: + var v: Variant = int(all_val) if value_type == TYPE_INT else float(all_val) + sb.set(prop_prefix + s, v) + for s in side_names: + if sides_dict.has(s): + var v2: Variant = int(sides_dict[s]) if value_type == TYPE_INT else float(sides_dict[s]) + sb.set(prop_prefix + s, v2) + return "" + + +func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> void: + var theme: Theme = ResourceLoader.load(theme_path) + if theme == null: + push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path) + return + theme.set_stylebox(name, class_name_param, sb) + McpResourceIO.guarded_save(theme, theme_path, _connection) + + +func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> void: + var theme: Theme = ResourceLoader.load(theme_path) + if theme == null: + push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path) + return + theme.clear_stylebox(name, class_name_param) + McpResourceIO.guarded_save(theme, theme_path, _connection) + + +# ============================================================================ +# theme_apply — assign a theme to a Control +# ============================================================================ + +func apply_theme(params: Dictionary) -> Dictionary: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path") + + var theme_path: String = params.get("theme_path", "") + var theme: Theme = null + if not theme_path.is_empty(): + var path_err := _validate_res_path(theme_path, ".tres") + if path_err != null: + return path_err + if not ResourceLoader.exists(theme_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path) + theme = ResourceLoader.load(theme_path) + if theme == null or not theme is Theme: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Theme" % theme_path) + + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var _scene_root: Node = _resolved.scene_root + if not node is Control and not node is Window: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node %s is not a Control or Window (got %s)" % [node_path, node.get_class()] + ) + + var before_theme: Theme = node.theme + _undo_redo.create_action("MCP: Apply theme to %s" % node.name) + _undo_redo.add_do_property(node, "theme", theme) + _undo_redo.add_undo_property(node, "theme", before_theme) + _undo_redo.commit_action() + + return { + "data": { + "node_path": node_path, + "theme_path": theme_path if theme != null else "", + "cleared": theme == null, + "undoable": true, + } + } + + +# ============================================================================ +# Helpers +# ============================================================================ + +func _load_theme_from_params(params: Dictionary) -> Dictionary: + var theme_path: String = params.get("theme_path", "") + var err := _validate_res_path(theme_path, ".tres", "theme_path", true) + if err != null: + return err + if not ResourceLoader.exists(theme_path): + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path) + var theme: Theme = ResourceLoader.load(theme_path) + if theme == null or not theme is Theme: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Theme" % theme_path) + return {"theme": theme, "path": theme_path} + + +static func _validate_res_path(path: String, required_suffix: String, param_name: String = "theme_path", for_write: bool = false) -> Variant: + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name) + var path_err := McpPathValidator.validate_resource_path(path, for_write) + if not path_err.is_empty(): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err]) + if not path.ends_with(required_suffix): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "%s must end with %s (got %s)" % [param_name, required_suffix, path] + ) + return null + + +## Parse a color from Color, "#rrggbb", "#rrggbbaa", named (red/blue/...) or dict. +## Returns null if the input cannot be parsed. +## Delegates to the canonical parser (#714) — gains [r,g,b(,a)] array +## support and strict key/component checking, same shapes as every other +## color-accepting handler. +static func _parse_color(value: Variant) -> Variant: + return McpJsonValues.parse_color(value) + + +static func _serialize_value(value: Variant) -> Variant: + if value == null: + return null + if value is Color: + return {"r": value.r, "g": value.g, "b": value.b, "a": value.a} + if value is Vector2: + return {"x": value.x, "y": value.y} + return value diff --git a/addons/godot_ai/handlers/theme_handler.gd.uid b/addons/godot_ai/handlers/theme_handler.gd.uid new file mode 100644 index 0000000..b77af13 --- /dev/null +++ b/addons/godot_ai/handlers/theme_handler.gd.uid @@ -0,0 +1 @@ +uid://gjyldaddj7mu diff --git a/addons/godot_ai/handlers/tilemap_handler.gd b/addons/godot_ai/handlers/tilemap_handler.gd new file mode 100644 index 0000000..ac68e34 --- /dev/null +++ b/addons/godot_ai/handlers/tilemap_handler.gd @@ -0,0 +1,163 @@ +@tool +extends RefCounted + +## TileMap / TileMapLayer authoring — set, fill, clear, and read tile cells +## directly in the editor scene with full undo/redo support. +## +## All ops target TileMapLayer nodes in the currently edited scene by +## scene-relative path (e.g. "/LavaLake20x20/Ground"). + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const MAX_RECT_FILL_CELLS := 4096 + +var _undo_redo: EditorUndoRedoManager + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +## Set a single tile cell. +## params: {path, source_id, atlas_col, atlas_row, map_x, map_y} +## Returns: {map_x, map_y, source_id, atlas_col, atlas_row} +func set_cell(params: Dictionary) -> Dictionary: + var layer := _resolve_layer(params) + if layer.has("error"): return layer + var node: TileMapLayer = layer.node + var pos := Vector2i(params.get("map_x", 0), params.get("map_y", 0)) + var src := int(params.get("source_id", 0)) + var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0)) + var prev := _capture_cell_state(node, pos) + _undo_redo.create_action("MCP: TileMap set_cell") + _undo_redo.add_do_method(node, "set_cell", pos, src, atlas) + _undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev) + _undo_redo.commit_action() + return {"data": {"map_x": pos.x, "map_y": pos.y, "source_id": src, + "atlas_col": atlas.x, "atlas_row": atlas.y, "undoable": true}} + + +## Fill a rectangular region with one tile type in a single undo action. +## params: {path, source_id, atlas_col, atlas_row, rect_x, rect_y, rect_w, rect_h} +## Returns: {cells_filled, rect: {x, y, w, h}} +func set_cells_rect(params: Dictionary) -> Dictionary: + var layer := _resolve_layer(params) + if layer.has("error"): return layer + var node: TileMapLayer = layer.node + var src := int(params.get("source_id", 0)) + var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0)) + var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0)) + var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1)) + if rw <= 0 or rh <= 0: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "rect_w and rect_h must be > 0 (got %d x %d)" % [rw, rh] + ) + var cell_count := rw * rh + if cell_count > MAX_RECT_FILL_CELLS: + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Rect too large: %d cells exceeds max %d" % [cell_count, MAX_RECT_FILL_CELLS] + ) + var cells: Array[Vector2i] = [] + var snapshot: Array[Dictionary] = [] + for x in range(rx, rx + rw): + for y in range(ry, ry + rh): + var pos := Vector2i(x, y) + cells.append(pos) + snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)}) + _undo_redo.create_action("MCP: TileMap set_cells_rect %dx%d" % [rw, rh]) + for pos in cells: + _undo_redo.add_do_method(node, "set_cell", pos, src, atlas) + _undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot) + _undo_redo.commit_action() + return {"data": {"cells_filled": cells.size(), + "rect": {"x": rx, "y": ry, "w": rw, "h": rh}, "undoable": true}} + + +## Remove all tiles from a TileMapLayer. +## params: {path} +## Returns: {cleared: true} +func clear_layer(params: Dictionary) -> Dictionary: + var layer := _resolve_layer(params) + if layer.has("error"): return layer + var node: TileMapLayer = layer.node + var snapshot := _capture_used_cells_snapshot(node) + _undo_redo.create_action("MCP: TileMap clear") + _undo_redo.add_do_method(node, "clear") + _undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot) + _undo_redo.commit_action() + return {"data": {"cleared": true, "undoable": true}} + + +## Return all used cell coordinates. +## params: {path} +## Returns: {cells: [{x, y}, ...], count: int} +func get_used_cells(params: Dictionary) -> Dictionary: + var layer := _resolve_layer(params) + if layer.has("error"): return layer + var node: TileMapLayer = layer.node + var cells := node.get_used_cells() + var result: Array = [] + for c in cells: + result.append({"x": c.x, "y": c.y}) + return {"data": {"cells": result, "count": result.size()}} + + +## Resolve a TileMapLayer node from params["path"] in the currently edited +## scene. Returns {"node": TileMapLayer} on success, or an error dict. +func _resolve_layer(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + var scene_file: String = params.get("scene_file", "") + var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file) + if resolved.has("error"): + return resolved + var node: Node = resolved.node + if not node is TileMapLayer: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, + "Node is not a TileMapLayer: %s" % path) + return {"node": node} + + +func _capture_cell_state(node: TileMapLayer, pos: Vector2i) -> Dictionary: + var source_id := node.get_cell_source_id(pos) + if source_id == -1: + return {"has_tile": false} + var atlas: Vector2i = node.get_cell_atlas_coords(pos) + var alternative := node.get_cell_alternative_tile(pos) + return { + "has_tile": true, + "source_id": source_id, + "atlas_col": atlas.x, + "atlas_row": atlas.y, + "alternative": alternative, + } + + +func _capture_used_cells_snapshot(node: TileMapLayer) -> Array[Dictionary]: + var snapshot: Array[Dictionary] = [] + for pos in node.get_used_cells(): + snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)}) + return snapshot + + +func _restore_cells_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void: + node.clear() + for entry in snapshot: + _restore_cell_state(node, entry.pos, entry.state) + + +func _restore_rect_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void: + for entry in snapshot: + _restore_cell_state(node, entry.pos, entry.state) + + +func _restore_cell_state(node: TileMapLayer, pos: Vector2i, state: Dictionary) -> void: + if not state.get("has_tile", false): + node.erase_cell(pos) + return + node.set_cell( + pos, + int(state.get("source_id", -1)), + Vector2i(int(state.get("atlas_col", -1)), int(state.get("atlas_row", -1))), + int(state.get("alternative", 0)) + ) diff --git a/addons/godot_ai/handlers/tilemap_handler.gd.uid b/addons/godot_ai/handlers/tilemap_handler.gd.uid new file mode 100644 index 0000000..8cbed53 --- /dev/null +++ b/addons/godot_ai/handlers/tilemap_handler.gd.uid @@ -0,0 +1 @@ +uid://cm8s7a0ey2q6k diff --git a/addons/godot_ai/handlers/tileset_handler.gd b/addons/godot_ai/handlers/tileset_handler.gd new file mode 100644 index 0000000..a443b01 --- /dev/null +++ b/addons/godot_ai/handlers/tileset_handler.gd @@ -0,0 +1,178 @@ +@tool +extends RefCounted + +## TileSet management — atlas inspection helpers. + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + + +func _init() -> void: + pass + + +## Query all occupied atlas tile positions for a single source. +## +## params: +## tileset_path — res:// path to the TileSet resource (required, non-empty) +## source_id — raw TileSet source id (required) +## +## Returns: +## {"data": {"tiles": [{"col": int, "row": int}, ...], "count": int}} +## on success (including empty sources, where tiles=[] and count=0) +## ErrorCodes.make(code, message) on any validation or load failure +## +## Error codes: +## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent +## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false +## WRONG_TYPE — loaded resource is not a TileSet, or source is +## not a TileSetAtlasSource +## VALUE_OUT_OF_RANGE — source_id not present in TileSet +## +## This method is read-only: it never calls ResourceSaver or modifies any resource. +func get_atlas_tiles(params: Dictionary) -> Dictionary: + var resolved := _resolve_atlas_source(params) + if resolved.has("error"): + return resolved + var src: TileSetAtlasSource = resolved.src + + var tiles: Array = [] + for i in range(src.get_tiles_count()): + var v: Vector2i = src.get_tile_id(i) + tiles.append({"col": v.x, "row": v.y}) + + return {"data": {"tiles": tiles, "count": tiles.size()}} + + +## Return the atlas texture of a TileSetAtlasSource as a Base64-encoded PNG. +## +## params: +## tileset_path — res:// path to the TileSet resource (required, non-empty) +## source_id — raw TileSet source id (required) +## max_size — optional int; if > 0, the image is scaled so its longest +## edge is at most max_size pixels (default 0 = full res) +## +## Returns: +## {"data": {"image_base64": String, "width": int, "height": int, +## "original_width": int, "original_height": int, "format": "png"}} +## on success +## ErrorCodes.make(code, message) on any validation or load failure +## +## Error codes: +## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent +## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false +## WRONG_TYPE — loaded resource is not a TileSet, or source is +## not a TileSetAtlasSource, or texture is null +## VALUE_OUT_OF_RANGE — source_id not present in TileSet +## +## This method is read-only: it never calls ResourceSaver or modifies anything. +func get_atlas_image(params: Dictionary) -> Dictionary: + var resolved := _resolve_atlas_source(params) + if resolved.has("error"): + return resolved + var source_id: int = resolved.source_id + var src: TileSetAtlasSource = resolved.src + + var tex: Texture2D = src.texture + if tex == null: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Source %d has no texture assigned" % source_id + ) + + var img: Image = tex.get_image() + if img == null: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Could not retrieve image data from texture of source %d" % source_id + ) + if img.is_compressed(): + var decompress_err := img.decompress() + if decompress_err != OK: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Could not decompress texture of source %d: %s" % [source_id, error_string(decompress_err)] + ) + + var original_width: int = img.get_width() + var original_height: int = img.get_height() + + var max_size: int = params.get("max_size", 0) + if max_size > 0: + var longest_edge: int = max(original_width, original_height) + if longest_edge > max_size: + var scale: float = float(max_size) / float(longest_edge) + var new_w: int = max(1, int(original_width * scale)) + var new_h: int = max(1, int(original_height * scale)) + img.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS) + + var png_bytes: PackedByteArray = img.save_png_to_buffer() + if png_bytes.is_empty(): + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "PNG encoding produced empty output for source %d" % source_id + ) + var b64: String = Marshalls.raw_to_base64(png_bytes) + + return { + "data": { + "image_base64": b64, + "width": img.get_width(), + "height": img.get_height(), + "original_width": original_width, + "original_height": original_height, + "format": "png", + } + } + + +func _resolve_atlas_source(params: Dictionary) -> Dictionary: + var tileset_path: String = params.get("tileset_path", "") + if tileset_path.is_empty(): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "'tileset_path' parameter is required and must not be empty" + ) + + if not params.has("source_id"): + return ErrorCodes.make( + ErrorCodes.MISSING_REQUIRED_PARAM, + "'source_id' parameter is required" + ) + + var tileset_path_err = McpPathValidator.loadable_error(tileset_path, "tileset_path") + if tileset_path_err != null: + return tileset_path_err + + if not ResourceLoader.exists(tileset_path): + return ErrorCodes.make( + ErrorCodes.RESOURCE_NOT_FOUND, + "TileSet resource not found: %s" % tileset_path + ) + + var ts = load(tileset_path) + if not ts is TileSet: + var loaded_type := "null" if ts == null else ts.get_class() + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Resource at '%s' is not a TileSet (got %s)" % [tileset_path, loaded_type] + ) + + var source_id: int = int(params.get("source_id", -999)) + if source_id < 0 or not ts.has_source(source_id): + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "source_id %d does not exist in TileSet" % source_id + ) + + var src = ts.get_source(source_id) + if not src is TileSetAtlasSource: + var source_type: String = "null" if src == null else src.get_class() + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Source %d is not a TileSetAtlasSource (got %s)" % [source_id, source_type] + ) + + return { + "source_id": source_id, + "src": src, + } diff --git a/addons/godot_ai/handlers/tileset_handler.gd.uid b/addons/godot_ai/handlers/tileset_handler.gd.uid new file mode 100644 index 0000000..643cfd3 --- /dev/null +++ b/addons/godot_ai/handlers/tileset_handler.gd.uid @@ -0,0 +1 @@ +uid://de3v4m1pnk7tr diff --git a/addons/godot_ai/handlers/ui_handler.gd b/addons/godot_ai/handlers/ui_handler.gd new file mode 100644 index 0000000..9795097 --- /dev/null +++ b/addons/godot_ai/handlers/ui_handler.gd @@ -0,0 +1,525 @@ +@tool +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Handles UI-specific (Control) layout helpers: anchor presets, etc. +## +## Anchors/offsets are the worst part of Control layout to set one-property-at-a-time. +## This handler wraps Godot's built-in presets (FULL_RECT, CENTER, TOP_LEFT, ...) so +## callers can set a whole layout with one command, with proper undo. + +var _undo_redo: EditorUndoRedoManager + + +const _PRESETS := { + "top_left": Control.PRESET_TOP_LEFT, + "top_right": Control.PRESET_TOP_RIGHT, + "bottom_left": Control.PRESET_BOTTOM_LEFT, + "bottom_right": Control.PRESET_BOTTOM_RIGHT, + "center_left": Control.PRESET_CENTER_LEFT, + "center_top": Control.PRESET_CENTER_TOP, + "center_right": Control.PRESET_CENTER_RIGHT, + "center_bottom": Control.PRESET_CENTER_BOTTOM, + "center": Control.PRESET_CENTER, + "left_wide": Control.PRESET_LEFT_WIDE, + "top_wide": Control.PRESET_TOP_WIDE, + "right_wide": Control.PRESET_RIGHT_WIDE, + "bottom_wide": Control.PRESET_BOTTOM_WIDE, + "vcenter_wide": Control.PRESET_VCENTER_WIDE, + "hcenter_wide": Control.PRESET_HCENTER_WIDE, + "full_rect": Control.PRESET_FULL_RECT, +} + +const _RESIZE_MODES := { + "minsize": Control.PRESET_MODE_MINSIZE, + "keep_width": Control.PRESET_MODE_KEEP_WIDTH, + "keep_height": Control.PRESET_MODE_KEEP_HEIGHT, + "keep_size": Control.PRESET_MODE_KEEP_SIZE, +} + +const _ANCHOR_OFFSET_PROPS := [ + "anchor_left", "anchor_top", "anchor_right", "anchor_bottom", + "offset_left", "offset_top", "offset_right", "offset_bottom", +] + + +func _init(undo_redo: EditorUndoRedoManager) -> void: + _undo_redo = undo_redo + + +## Apply a Control layout preset (anchors + offsets) to a UI node. +## +## Params: +## path - scene path to a Control node (required) +## preset - preset name: full_rect, center, top_left, ... (required) +## resize_mode - minsize | keep_width | keep_height | keep_size (default: minsize) +## margin - integer margin in pixels from the anchor edges (default: 0) +func set_anchor_preset(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + if node_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + var preset_name: String = str(params.get("preset", "")).to_lower() + if preset_name.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset") + if not _PRESETS.has(preset_name): + var names := _PRESETS.keys() + names.sort() + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(names)] + ) + + var resize_mode_name: String = str(params.get("resize_mode", "minsize")).to_lower() + if not _RESIZE_MODES.has(resize_mode_name): + var names := _RESIZE_MODES.keys() + names.sort() + return ErrorCodes.make( + ErrorCodes.VALUE_OUT_OF_RANGE, + "Unknown resize_mode '%s'. Valid: %s" % [resize_mode_name, ", ".join(names)] + ) + + var margin: int = int(params.get("margin", 0)) + + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var scene_root: Node = _resolved.scene_root + if not node is Control: + var got_class: String = node.get_class() + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node %s is not a Control (got %s)%s" % [ + node_path, got_class, _canvas_layer_overlay_hint(got_class) + ] + ) + + var control := node as Control + var preset_value: int = _PRESETS[preset_name] + var resize_mode_value: int = _RESIZE_MODES[resize_mode_name] + + # Snapshot before so we can undo every property the preset may have touched. + var before: Dictionary = {} + for prop in _ANCHOR_OFFSET_PROPS: + before[prop] = control.get(prop) + + _undo_redo.create_action("MCP: Set %s anchor preset %s" % [control.name, preset_name]) + _undo_redo.add_do_method( + control, "set_anchors_and_offsets_preset", preset_value, resize_mode_value, margin + ) + for prop in _ANCHOR_OFFSET_PROPS: + _undo_redo.add_undo_property(control, prop, before[prop]) + _undo_redo.commit_action() + + var after: Dictionary = {} + for prop in _ANCHOR_OFFSET_PROPS: + after[prop] = control.get(prop) + + return { + "data": { + "path": node_path, + "preset": preset_name, + "resize_mode": resize_mode_name, + "margin": margin, + "anchors": { + "left": after.anchor_left, + "top": after.anchor_top, + "right": after.anchor_right, + "bottom": after.anchor_bottom, + }, + "offsets": { + "left": after.offset_left, + "top": after.offset_top, + "right": after.offset_right, + "bottom": after.offset_bottom, + }, + "undoable": true, + } + } + + +## Set the visible `text` property on a UI Control (Label, Button + subclasses, +## LineEdit, TextEdit, RichTextLabel, LinkButton). Undoable. +func set_text(params: Dictionary) -> Dictionary: + var node_path: String = params.get("path", "") + if node_path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") + + if not params.has("text"): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: text") + var text_value: Variant = params["text"] + if typeof(text_value) != TYPE_STRING: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "text must be a string") + + var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") + if _resolved.has("error"): + return _resolved + var node: Node = _resolved.node + var scene_root: Node = _resolved.scene_root + var node_type := node.get_class() + if not node is Control: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Node %s is not a Control (got %s)" % [node_path, node_type] + ) + # Scan get_property_list() (matches set_property / _apply_property in this + # repo) so we can both confirm `text` exists and that it's actually a String + # — guards against a custom Control whose `text` happens to be some other + # type, where set()-ing a String would silently mis-coerce. + var text_prop_type := TYPE_NIL + var has_text := false + for prop in node.get_property_list(): + if prop.get("name", "") == "text": + has_text = true + text_prop_type = prop.get("type", TYPE_NIL) + break + if not has_text: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Control %s has no 'text' property (got %s)" % [node_path, node_type] + ) + if text_prop_type != TYPE_STRING: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + "Control %s has a non-string 'text' property (got %s)" % [node_path, node_type] + ) + + var old_value: String = node.get("text") + + _undo_redo.create_action("MCP: Set %s text" % node.name) + _undo_redo.add_do_property(node, "text", text_value) + _undo_redo.add_undo_property(node, "text", old_value) + _undo_redo.commit_action() + + return { + "data": { + "path": node_path, + "text": text_value, + "old_text": old_value, + "node_type": node_type, + "undoable": true, + } + } + + +# ============================================================================ +# build_layout — declarative nested-dict → Control tree in one undo action +# ============================================================================ + +## Build a tree of Control nodes atomically. +## +## Params: +## tree - Dictionary describing the root node. Required fields: "type". +## Optional: "name", "properties" (dict), "anchor_preset", +## "anchor_margin", "theme" (res://, uid:// or user:// path), "children" (array). +## parent_path - Parent scene path. Empty or "/" = scene root. +## +## Validation is done before any scene mutation: class names, property +## existence, and res:// paths are all checked up-front. If anything is +## invalid, no node is created. +func build_layout(params: Dictionary) -> Dictionary: + var tree = params.get("tree") + if not params.has("tree"): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: tree") + if typeof(tree) != TYPE_DICTIONARY: + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tree must be a dictionary") + + var _scene_check := McpNodeValidator.require_scene_or_error() + if _scene_check.has("error"): + return _scene_check + var scene_root: Node = _scene_check.scene_root + + var parent_path: String = params.get("parent_path", "") + var parent: Node = scene_root + if not parent_path.is_empty() and parent_path != "/": + parent = McpScenePath.resolve(parent_path, scene_root) + if parent == null: + return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) + + # Validate + build in memory first; if anything fails, free and bail. + var built := _build_subtree(tree) + if built.has("error"): + return built + var root_node: Node = built.node + var created: Array[Node] = built.created + + _undo_redo.create_action("MCP: Build UI layout (%d nodes)" % created.size()) + _undo_redo.add_do_method(parent, "add_child", root_node, true) + _undo_redo.add_do_method(root_node, "set_owner", scene_root) + for n in created: + _undo_redo.add_do_method(n, "set_owner", scene_root) + _undo_redo.add_do_reference(n) + _undo_redo.add_undo_method(parent, "remove_child", root_node) + _undo_redo.commit_action() + + return { + "data": { + "root_path": McpScenePath.from_node(root_node, scene_root), + "node_count": created.size(), + "undoable": true, + } + } + + +## Recursively instantiate + configure a node and its children in memory. +## Returns {"node": root, "created": [all descendants incl. root]} or {"error": ...}. +func _build_subtree(spec: Dictionary) -> Dictionary: + var node_type: String = spec.get("type", "") + if node_type.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Every layout node requires a 'type'") + if not ClassDB.class_exists(node_type): + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown type: %s" % node_type) + if not ClassDB.is_parent_class(node_type, "Node"): + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type) + + var node: Node = ClassDB.instantiate(node_type) + if node == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type) + + var node_name: String = spec.get("name", "") + if not node_name.is_empty(): + node.name = node_name + + # Properties. + if spec.has("properties"): + var props = spec.get("properties") + if typeof(props) != TYPE_DICTIONARY: + node.free() + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "properties must be a dictionary") + for key in props: + var value = props[key] + var apply_err := _apply_property(node, str(key), value) + if apply_err != null: + node.free() + return apply_err + + # Theme (res:// / uid:// / user:// path -> Resource). + if spec.has("theme"): + var theme_path: String = str(spec.get("theme", "")) + if not theme_path.is_empty(): + var theme_path_err = McpPathValidator.loadable_error(theme_path, "theme") + if theme_path_err != null: + node.free() + return theme_path_err + if not ResourceLoader.exists(theme_path): + node.free() + return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path) + var theme_res: Resource = ResourceLoader.load(theme_path) + if theme_res == null or not theme_res is Theme: + node.free() + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "theme path must point to a Theme resource: %s" % theme_path) + if not node is Control and not node is Window: + node.free() + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "theme can only be set on Control / Window (got %s)%s" % [ + node_type, _canvas_layer_overlay_hint(node_type) + ] + ) + node.theme = theme_res as Theme + + # Anchor preset — applied before children so children inherit sensible anchors. + if spec.has("anchor_preset"): + var preset_name: String = str(spec.get("anchor_preset", "")).to_lower() + if not _PRESETS.has(preset_name): + node.free() + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown anchor_preset: %s" % preset_name) + if not node is Control: + node.free() + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "anchor_preset requires a Control (got %s)%s" % [ + node_type, _canvas_layer_overlay_hint(node_type) + ] + ) + var preset_value: int = _PRESETS[preset_name] + var margin: int = int(spec.get("anchor_margin", 0)) + (node as Control).set_anchors_and_offsets_preset(preset_value, Control.PRESET_MODE_MINSIZE, margin) + + var created: Array[Node] = [node] + if spec.has("children"): + var children = spec.get("children") + if typeof(children) != TYPE_ARRAY: + node.free() + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "children must be an array") + for child_spec in children: + if typeof(child_spec) != TYPE_DICTIONARY: + node.free() + return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "each child must be a dictionary") + var child_result := _build_subtree(child_spec) + if child_result.has("error"): + node.free() + return child_result + var child_node: Node = child_result.node + node.add_child(child_node) + for n in child_result.created: + created.append(n) + return {"node": node, "created": created} + + +## Mapping from theme_override_* property prefixes to their add/remove methods. +const _THEME_OVERRIDE_MAP := { + "theme_override_colors/": { + "add": "add_theme_color_override", + "remove": "remove_theme_color_override", + "coerce_type": TYPE_COLOR, + }, + "theme_override_constants/": { + "add": "add_theme_constant_override", + "remove": "remove_theme_constant_override", + "coerce_type": TYPE_INT, + }, + "theme_override_font_sizes/": { + "add": "add_theme_font_size_override", + "remove": "remove_theme_font_size_override", + "coerce_type": TYPE_INT, + }, + "theme_override_styles/": { + "add": "add_theme_stylebox_override", + "remove": "remove_theme_stylebox_override", + "coerce_type": TYPE_OBJECT, + }, +} + + +## Apply a property to a newly-instantiated node. Handles Color/Vector2/NodePath +## coercion from JSON-friendly forms. Returns null on success, error dict on failure. +func _apply_property(node: Node, prop: String, value: Variant) -> Variant: + # Handle theme_override_* pseudo-properties before the regular property scan. + for prefix in _THEME_OVERRIDE_MAP: + if prop.begins_with(prefix): + if not node is Control: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "theme_override_* requires a Control node (got %s)" % node.get_class() + ) + var override_name := prop.substr(prefix.length()) + var info: Dictionary = _THEME_OVERRIDE_MAP[prefix] + var coerce_type: int = info.coerce_type + + # For stylebox overrides, load from a res:// / uid:// / user:// path. + if coerce_type == TYPE_OBJECT: + if value is String and (value.begins_with("res://") or value.begins_with("uid://") or value.begins_with("user://")): + var style_path_err = McpPathValidator.loadable_error(value, "stylebox") + if style_path_err != null: + return style_path_err + var res := ResourceLoader.load(value) + if res == null or not res is StyleBox: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "Style resource not found or not a StyleBox: %s" % value + ) + node.call(info.add, override_name, res) + else: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "theme_override_styles/ expects a res:// / uid:// / user:// path to a StyleBox" + ) + else: + var coercion := _coerce_for_type(value, coerce_type) + if not coercion.ok: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Cannot coerce '%s' for %s" % [value, prop] + ) + node.call(info.add, override_name, coercion.value) + return null + + var found := false + var prop_type := TYPE_NIL + for p in node.get_property_list(): + if p.name == prop: + found = true + prop_type = p.get("type", TYPE_NIL) + break + if not found: + return ErrorCodes.make( + ErrorCodes.PROPERTY_NOT_ON_CLASS, + McpPropertyErrors.build_message(node, prop) + ) + + var coercion := _coerce_for_type(value, prop_type) + if not coercion.ok: + return ErrorCodes.make( + ErrorCodes.WRONG_TYPE, + "Property '%s' on %s expects type %s (cannot coerce %s)" % [ + prop, node.get_class(), type_string(prop_type), value + ] + ) + node.set(prop, coercion.value) + return null + + +## Coerce a JSON-friendly value to the target Godot type. Returns +## {"ok": true, "value": coerced} on success, {"ok": false} on failure. +## For types we don't explicitly coerce, the value is returned as-is +## (Godot will typecheck at set() time and fail loudly if it disagrees). +static func _coerce_for_type(value: Variant, prop_type: int) -> Dictionary: + match prop_type: + TYPE_COLOR: + ## Canonical parser (#714): adds [r,g,b(,a)] array support and + ## strict key/component checking, same shapes everywhere. + var parsed_color = McpJsonValues.parse_color(value) + if parsed_color != null: + return {"ok": true, "value": parsed_color} + return {"ok": false} + TYPE_VECTOR2: + ## Same canonical parser as TYPE_COLOR (CodeRabbit review): + ## keeping the inline copy here would re-introduce exactly the + ## permissive-vs-strict drift this PR removes elsewhere. + var parsed_v2 = McpJsonValues.parse_vector2(value) + if parsed_v2 != null: + return {"ok": true, "value": parsed_v2} + return {"ok": false} + TYPE_VECTOR2I: + if value is Vector2i: + return {"ok": true, "value": value} + if value is Dictionary and value.has("x") and value.has("y"): + return {"ok": true, "value": Vector2i(int(value.x), int(value.y))} + if value is Array and value.size() == 2: + return {"ok": true, "value": Vector2i(int(value[0]), int(value[1]))} + return {"ok": false} + TYPE_RECT2: + if value is Rect2: + return {"ok": true, "value": value} + if value is Array and value.size() == 4: + return { + "ok": true, + "value": + Rect2(float(value[0]), float(value[1]), float(value[2]), float(value[3])), + } + if value is Dictionary: + if value.has("x") and value.has("y") and value.has("w") and value.has("h"): + return { + "ok": true, + "value": + Rect2(float(value.x), float(value.y), float(value.w), float(value.h)), + } + if value.has("position") and value.has("size"): + var pos := _coerce_for_type(value.position, TYPE_VECTOR2) + var sz := _coerce_for_type(value.size, TYPE_VECTOR2) + if pos.ok and sz.ok: + return {"ok": true, "value": Rect2(pos.value, sz.value)} + return {"ok": false} + TYPE_NODE_PATH: + if value is NodePath: + return {"ok": true, "value": value} + if value is String: + return {"ok": true, "value": NodePath(value)} + return {"ok": false} + return {"ok": true, "value": value} + + +# CanvasLayer is the canonical HUD parent but isn't a Control, so applying +# Control-only properties (theme, anchor_preset) to it is a common mistake. +# The recovery shape is always the same: nest a Control child under the layer. +static func _canvas_layer_overlay_hint(node_class: String) -> String: + if node_class != "CanvasLayer": + return "" + return ( + ". CanvasLayer is not a Control — add a Control (e.g. Panel or Control " + + "with anchor_preset=full_rect) as its child and apply theme / " + + "anchor_preset to that overlay." + ) diff --git a/addons/godot_ai/handlers/ui_handler.gd.uid b/addons/godot_ai/handlers/ui_handler.gd.uid new file mode 100644 index 0000000..f48f841 --- /dev/null +++ b/addons/godot_ai/handlers/ui_handler.gd.uid @@ -0,0 +1 @@ +uid://ckm6f1objpgvw diff --git a/addons/godot_ai/mcp_dock.gd b/addons/godot_ai/mcp_dock.gd new file mode 100644 index 0000000..fef75fc --- /dev/null +++ b/addons/godot_ai/mcp_dock.gd @@ -0,0 +1,3784 @@ +@tool +class_name McpDock +extends VBoxContainer + +## Editor dock panel showing MCP connection status, client config, and command log. +## +## Audit-v2 #360 partial extraction. Two cohesive subpanels live in +## res://addons/godot_ai/dock_panels/: +## - log_viewer.gd: MCP request/response log (dev-mode only). +## - port_picker_panel.gd: spawn-failure escape hatch nested in the crash panel. +## +## The audit also called for ServerStatusPanel and ClientRowController +## extractions; those were *deliberately deferred*. Their UI scatters across +## the dock layout (status icon at top, crash panel mid, setup section lower; +## client rows + drift banner + scroll grid spread similarly), so a clean +## extract-by-panel needs either visible UI reorganization or a coordinator- +## Node pattern with property-accessor façades on McpDock that re-tangle the +## very state they claim to move. +## +## A future refactor probably wants extract-by-concern instead — e.g. +## `utils/mcp_async_refresh_state_machine.gd` owning the IDLE → RUNNING → +## RUNNING_TIMED_OUT → DEFERRED_FOR_FILESYSTEM → SHUTTING_DOWN transitions +## and pending-flag triplet, `utils/mcp_client_action_dispatcher.gd` owning +## the per-row Configure/Remove worker pool. The dock would keep UI +## construction and lose the state-machine ownership. See issue #360. + +const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") +const ClientRefreshStateScript := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd") +const Telemetry := preload("res://addons/godot_ai/telemetry.gd") +const UpdateManagerScript := preload("res://addons/godot_ai/utils/update_manager.gd") +const UpdateMixedStateScript := preload("res://addons/godot_ai/utils/update_mixed_state.gd") +const Client := preload("res://addons/godot_ai/clients/_base.gd") +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd") +const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd") +const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd") +const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd") +const ToolCatalog := preload("res://addons/godot_ai/tool_catalog.gd") +const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.gd") +const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd") +const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd") + +const DEV_MODE_SETTING := "godot_ai/dev_mode" +## "Change the port + reconfigure your clients" guide. Surfaced from the crash +## panel when a foreign process holds the HTTP port — the one piece of recovery +## (per-client config rewrite) that doesn't fit in the inline crash body. +## Resolved against the installed plugin version at click time (see +## `_port_conflict_docs_url`) so a shipped build opens the guide as it shipped, +## not tip-of-main, which may have drifted from that build's UI. +const PORT_CONFLICT_DOCS_PATH := "docs/port-conflicts.md" +const REPO_BLOB_BASE := "https://github.com/hi-godot/godot-ai/blob" +## Opened by the "How to install uv" button. See _on_install_uv for why the +## dock links here instead of running an installer itself. +const UV_INSTALL_DOCS_URL := "https://docs.astral.sh/uv/getting-started/installation/" +const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000 +const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000 +const CLIENT_ACTION_TIMEOUT_MSEC := 30 * 1000 +static var COLOR_MUTED := Color(0.7, 0.7, 0.7) +static var COLOR_HEADER := Color(0.95, 0.95, 0.95) +## Used for "in-progress" / "stale, action needed" UI: the startup-grace +## status icon, the spawn-failure suggested-port hint, the drift banner, +## and the per-row mismatch dot. One constant so a future palette tweak +## doesn't have to find every literal. +static var COLOR_AMBER := Color(1.0, 0.75, 0.25) + +var _connection +var _log_buffer +var _plugin: EditorPlugin + +# Always visible +var _redock_btn: Button +var _status_icon: ColorRect +var _status_label: Label +var _body_scroll: ScrollContainer +var _body: VBoxContainer +var _client_grid: VBoxContainer +var _client_configure_all_btn: Button +var _client_empty_cta_btn: Button +var _clients_summary_label: Label +var _clients_window: Window +var _dev_mode_toggle: CheckButton +var _install_label: Label + +# Tools tab (secondary window, Tab 2) — domain-exclusion UI for clients +# that cap total tool count (Antigravity: 100). Pending set is mutated by +# checkbox clicks; saved set reflects what the spawned server actually +# sees. `Apply and Restart Server` writes pending → setting and triggers a +# plugin reload so the new server comes up with the trimmed list. +var _tools_pending_excluded: PackedStringArray = PackedStringArray() +var _tools_saved_excluded: PackedStringArray = PackedStringArray() +## Custom (addon-registered) tools list — rebuilt live on registry +## tools_changed; per-tool checkboxes apply immediately (no restart). +var _custom_tools_list: VBoxContainer +var _custom_tools_count_label: Label +var _tools_domain_checkboxes: Dictionary = {} +var _tools_count_label: Label +var _tools_apply_btn: Button +var _tools_reset_btn: Button +var _tools_dirty_warning: Label +var _tools_close_confirm: ConfirmationDialog +var _telemetry_toggle: CheckButton +var _telemetry_pending_enabled: bool = true +var _telemetry_saved_enabled: bool = true + +# Settings tab (secondary window, Tab 3) — Vision Routing section plus the +# LAN opt-in (#507): "Allow remote hosts (CIDR)" behind a collapsed +# "Remote access (advanced)" disclosure (auto-expands when a non-empty +# allowlist is configured). The value feeds `--allow-host` at server spawn +# (see plugin.gd::_build_server_flags). The LineEdit's live text is the +# pending state; `_allow_hosts_saved` mirrors the persisted EditorSetting, +# same pending/saved shape as the Tools tab above. +var _allow_hosts_section: VBoxContainer +var _allow_hosts_fold: FoldableContainer +var _allow_hosts_edit: LineEdit +var _allow_hosts_hint: Label +var _allow_hosts_apply_btn: Button +var _allow_hosts_saved: String = "" + +## Per-client UI handles, keyed by client id. Each entry holds the row's +## status dot, configure/remove buttons, config-file buttons, and manual panel. +var _client_rows: Dictionary = {} + +# Drift banner — surfaced near the Clients section when one or more clients +# have a stored entry whose URL no longer matches `http_url()` (typical after +# the user changes `godot_ai/http_port`). Refreshes are stale-while-refreshing: +# cached row dots/banner remain visible while a background worker performs the +# potentially blocking config/CLI probes, then the main thread applies results. +# Automatic focus-in refreshes use a short cooldown to avoid repeated sweeps +# during tab-away/tab-back churn. See #166 and #226. +var _drift_banner: VBoxContainer +var _drift_label: Label +## Set when the user clicks "How to install uv"; consumed by the next +## application focus-in so the uv row is re-probed after the user has had a +## chance to install, not immediately. See _on_install_uv and _notification. +## (Deliberately spelled without the focus-in constant name: the guard in +## tests/unit/test_editor_focus_refocus.py locates the notification handler +## by first occurrence of that token.) +var _uv_recheck_pending := false +## Handles for the Setup section's "Server" row. `_update_status` keeps +## the label text/color in sync with `McpConnection.server_version` so the +## dock reports the TRUE running server version, not the plugin's +## expected version. See #174 follow-up — a plugin upgrade via self- +## update can leave the plugin connected to an older adopted server +## (foreign-port branch never sets `_server_pid`, so `_stop_server` +## can't kill it); the line has to show the mismatch honestly. +var _setup_server_label: Label +## Last rendered server-version string. `_update_status` runs every +## frame; early-outs text repaint when nothing changed. Empty means +## "no line rendered yet" (dev-checkout branch doesn't render a +## user-mode Server line). +var _last_rendered_server_text: String = "" +## Restart-server button shown next to the Setup container when +## `McpConnection.server_version` drifts from the plugin version. Hidden +## in the match case so the UI stays calm. +var _version_restart_btn: Button +var _server_restart_in_progress := false +## Sorted snapshot of the most recent mismatched-client set. Powers two things: +## (a) the Reconfigure button reuses this list instead of re-running +## `check_status` per row (saves ~18 filesystem reads per click), and +## (b) `_refresh_drift_banner` early-returns when the set is unchanged so +## repeated explicit refreshes don't repaint identical text. Mirrors the +## `_last_server_status` pattern used by the crash panel. +var _last_mismatched_ids: Array[String] = [] +## One-shot post-self-update auto-repin (armed by plugin.gd via +## `notify_self_update_success`). After an update, every client configured +## through this plugin still pins the OLD server version — the drift banner +## names them, but until they're rewritten each attach-bridge (re)start +## launches the outdated backend. The first completed status sweep with a +## healthy server consumes this flag and runs the same reconfigure the +## banner button would, so the user isn't left owing a manual click for a +## state the update itself created. Sweeps that land while the server is +## still INCOMPATIBLE (stale-occupant recovery in flight) keep it pending — +## their rows read ERROR, not CONFIGURED_MISMATCH. +var _pending_post_update_repin: bool = false +## The version the completed update replaced — consumed with the flag +## above; the pin-only gate renders each entry against it. +var _post_update_from_version: String = "" +var _client_status_refresh_thread: Thread +## Single source of truth for the refresh-sweep state machine. See +## `ClientRefreshStateScript` for the transition table. Replaces the +## previously scattered booleans (`_in_flight`, `_timed_out`, +## `_deferred_until_filesystem_ready`, `_shutdown_requested`). +var _refresh_state: int = ClientRefreshStateScript.IDLE +## Pending-request flags. Kept separate from `_refresh_state` because +## they're "what should the next refresh look like" — not state of +## any current refresh. A pending request is queued when a refresh +## arrives during RUNNING / RUNNING_TIMED_OUT and consumed by +## `_apply_client_status_refresh_results` once the in-flight worker +## drains. `_pending_force` also captures forced retries deferred via +## DEFERRED_FOR_FILESYSTEM so a pending user click survives the wait. +var _client_status_refresh_pending: bool = false +var _client_status_refresh_pending_force: bool = false +var _client_status_refresh_pending_initial: bool = false +var _last_client_status_refresh_completed_msec: int = 0 +var _client_status_refresh_started_msec: int = 0 +var _client_status_refresh_generation: int = 0 +## Owns the self-update slice: GitHub Releases poll, ZIP download, install +## orchestration, and the install-in-flight gate. Dock keeps banner UI +## only and consults the gate via `_is_self_update_in_progress()`. +var _update_manager +static var _orphaned_client_status_refresh_threads: Array[Thread] = [] + +## Per-row worker state for Configure / Remove. Issue #239: shelling out +## to a hung CLI on main hangs the editor. We dispatch each click to its +## own thread (one slot per client), then `_process` reaps completed workers +## and applies returned payloads on main. The buttons stay disabled while +## the slot is busy so the user can't queue a re-click on the same row. +## +## Per-client (not single-slot) so Configure-all can fan out — the +## workers are independent, only the row UI is shared, and McpCliExec +## bounds the wall-clock for each. +## +## A watchdog can abandon a slot when a worker fails to report completion. +## The thread object is retained in `_orphaned_client_action_threads` until +## it finishes so GDScript does not destroy a live Thread object. +var _client_action_threads: Dictionary = {} +var _client_action_generations: Dictionary = {} +var _client_action_started_msec: Dictionary = {} +var _client_action_names: Dictionary = {} +## Timed-out Configure/Remove workers are abandoned but retained here until +## they finish, so GDScript does not destroy a live Thread object. +static var _orphaned_client_action_threads: Array[Thread] = [] +## Which client each abandoned worker belonged to, so a row whose worker was +## abandoned but is STILL RUNNING can't start a second one on top of it. +## The flat array above exists to keep Thread objects alive; it carries no +## client association, and `_abandon_client_action_thread` erases the row's +## `_client_action_threads` slot — so the dispatch guard alone would let a +## re-click spawn an overlapping worker. Two concurrent Configure workers for +## one client means two uv builds and two writers on the same config file. +## Static for the same reason as the array: a script reload must not GC a +## live Thread. +static var _orphaned_client_action_owners: Dictionary = {} +## Cooperative stop shared across dock instances because orphan action +## threads survive script/dock replacement. A new action clears its row's +## flag before starting; teardown sets every active/orphaned row before join. +static var _client_action_cancel_mutex := Mutex.new() +static var _client_action_cancelled_clients: Dictionary = {} + +# Dev-mode only +var _dev_section: VBoxContainer +var _server_label: Label +var _reload_btn: Button +var _setup_section: VBoxContainer +var _setup_container: VBoxContainer +## Primary dev-section button — always (re)starts a `--reload` dev server. +## Same-version Python edits get adopted as compatible by the lifecycle, so +## neither the drift nor the crash Restart button surfaces; this is the +## unconditional kick contributors need to pick up source changes without +## a version bump. +var _dev_primary_btn: Button +## Small "✕" affordance next to the primary — stops the dev server without +## spawning a replacement. Disabled when no dev server is running. +var _dev_stop_btn: Button +var _log_viewer: LogViewerScript +## Vision Routing (optional) - set by plugin.gd; builds the "Vision Routing" +## tab in Clients & Tools and the quick toggle under Developer mode. +var vision_routing: VisionRoutingScript = null + +var _last_connected := false +var _last_status_text := "" +var _last_status_tooltip := "" +var _startup_grace_until_msec: int = 0 + +# Spawn-failure panel — rendered when `get_server_status` reports a +# non-OK `state`. One panel, one body paragraph per state, no cascading +# booleans. See `_crash_body_for_state`. +var _crash_panel: VBoxContainer +var _crash_output: RichTextLabel +var _crash_restart_btn: Button +var _crash_reload_btn: Button +## Help link — visible only for the genuinely-foreign-occupant INCOMPATIBLE +## case (no `can_recover_incompatible` proof). The inline body names a free +## port; this button carries the per-client reconfigure steps that don't fit +## inline. See `PORT_CONFLICT_DOCS` and `_update_crash_panel`. +var _crash_docs_btn: Button +## Port-picker escape hatch — visible inside the crash panel when the root +## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes +## the EditorSetting and reloads the plugin in response to the panel's +## `port_apply_requested` signal. +var _port_picker_panel: PortPickerPanelScript +## Last status Dict rendered into the panel — used to skip re-population +## when nothing changed, which would otherwise reset the user's scroll +## position on every frame. GDScript Dicts compare by value with `==`. +var _last_server_status: Dictionary = {} + +# First-run grace: uvx installs 60+ Python packages on first run (can take +# 10-30s on a slow connection). Don't scare users with "Disconnected" during +# that window — show "Starting server…" instead. After this expires, fall +# back to the normal disconnect UI. +const STARTUP_GRACE_MSEC := 60 * 1000 + +# Update banner — visible UI only. Releases polling, ZIP download, and +# the install pipeline live on `_update_manager`. +var _update_banner: VBoxContainer +var _update_label: Label +var _update_btn: Button + +# Mixed-state banner — surfaces when `addons/godot_ai/` contains +# `*.update_backup` files left by a self-update whose rollback failed +# (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`). Without this banner +# the user sees "plugin won't start" with no actionable context, re-runs +# the update, and compounds the mismatch (issue #354 / audit-v2 #10). +var _mixed_state_banner: VBoxContainer +var _mixed_state_label: Label +var _mixed_state_files: RichTextLabel +var _mixed_state_rescan_btn: Button + + +func setup(connection: McpConnection, log_buffer: McpLogBuffer, plugin: EditorPlugin) -> void: + _connection = connection + _log_buffer = log_buffer + _plugin = plugin + _startup_grace_until_msec = Time.get_ticks_msec() + STARTUP_GRACE_MSEC + + +func _ready() -> void: + _build_ui() + + +func _process(_delta: float) -> void: + _prune_orphaned_client_status_refresh_threads() + _prune_orphaned_client_action_threads() + _poll_completed_client_status_refresh_thread() + _poll_completed_client_action_threads() + _check_client_status_refresh_timeout() + _check_client_action_timeouts() + if _connection == null: + return + _retry_deferred_client_status_refresh() + _update_status() + if _log_viewer != null and _log_viewer.visible: + _log_viewer.tick() + + +func _exit_tree() -> void: + ## Block on any in-flight refresh worker before letting the dock leave the + ## tree. The plugin disable path (editor_reload_plugin, Project Settings + ## toggle) reloads the McpDock script class — which wipes the static + ## `_orphaned_client_status_refresh_threads`, GCs the Thread objects mid- + ## execution, and triggers `~Thread … destroyed without its completion + ## having been realized` plus GDScript VM corruption (Opcode: 0, IP-bounds + ## errors, intermittent SIGSEGV). Probes finish in well under a second + ## under normal conditions; if a CLI probe genuinely hung, the runtime + ## timeout path (`_abandon_client_status_refresh_thread`) has already + ## moved that thread into the orphan list, so we drain it here too. + ## + ## `wait_to_finish` is unbounded by design: GDScript's Thread API has no + ## timeout, and a polling/abandon fallback would just re-introduce the + ## GC-mid-execution crash this fix exists to prevent. Blocking the editor + ## briefly on plugin-reload is strictly better than the SIGSEGV. + _refresh_state = ClientRefreshStateScript.SHUTTING_DOWN + _drain_client_status_refresh_workers() + _drain_client_action_workers() + + +## Public drain entry consulted by `McpUpdateManager._install_zip` before +## any disk write. Pairs both worker pools so the manager doesn't reach +## into private dock methods. `_exit_tree` still calls the two underlying +## drains directly because it has additional state-machine work +## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit. +func prepare_for_self_update_drain() -> void: + _poll_completed_client_status_refresh_thread() + _poll_completed_client_action_threads() + _drain_client_status_refresh_workers() + _drain_client_action_workers() + + +func _drain_client_status_refresh_workers() -> void: + ## Block until any in-flight refresh worker (and any orphaned workers from + ## a prior timeout) finish, then clear refresh state. Same blocking + ## semantics as the `_exit_tree` drain — see #232. Used by `_exit_tree` + ## (dock teardown) and `McpUpdateManager._install_zip` (before extract + ## overwrites plugin scripts on disk). + _client_status_refresh_generation += 1 + if _client_status_refresh_thread != null: + _client_status_refresh_thread.wait_to_finish() + _client_status_refresh_thread = null + for thread in _orphaned_client_status_refresh_threads: + if thread != null: + thread.wait_to_finish() + _orphaned_client_status_refresh_threads.clear() + ## Don't transition out of SHUTTING_DOWN — the drain is called from + ## `_exit_tree` (sticky shutdown) and from + ## `McpUpdateManager._install_zip`'s post-drain reset, which writes + ## the state explicitly. + if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: + _refresh_state = ClientRefreshStateScript.IDLE + _client_status_refresh_pending = false + _client_status_refresh_pending_force = false + _client_status_refresh_pending_initial = false + + +func _drain_client_action_workers() -> void: + ## Same drain semantics as the refresh worker (see comment above): the + ## plugin disable / install-update path reloads our script class, so any + ## live Thread must finish before its slot is GC'd or we hit + ## `~Thread … destroyed without its completion having been realized` → + ## VM corruption. Normal UI recovery is handled by the per-row watchdog; + ## teardown still blocks because GDScript's Thread API has no kill/timeout + ## primitive and destroying a live Thread corrupts the VM. + ## + ## Generation-bumped per-row so any result from a worker that finished + ## after we started draining detects the generation mismatch and + ## short-circuits without touching freed UI state. + ## + ## After draining, restore the row UI for any in-flight rows: bare + ## `_client_action_threads.clear()` would leave the dock stuck showing + ## "Configuring…" / "Removing…" with disabled buttons forever — a + ## user-visible failure mode for the install-update bail-out branch + ## (zip extract failure on the manager clears `_install_in_flight` and + ## the dock stays alive). + ## Signal every worker before joining any one of them. Normal Configure + ## keeps its full cold-install budget, while shutdown/update can stop all + ## in-flight uvx poll loops concurrently and avoid a 180s editor stall. + for client_id in _client_action_threads.keys(): + _set_client_action_cancel_requested(String(client_id), true) + for client_id in _orphaned_client_action_owners.keys(): + _set_client_action_cancel_requested(String(client_id), true) + for client_id in _client_action_threads.keys(): + var t: Thread = _client_action_threads[client_id] + if t != null: + t.wait_to_finish() + _client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1 + _client_action_started_msec.erase(client_id) + _client_action_names.erase(client_id) + _clear_client_action_phase(String(client_id)) + _finalize_action_buttons(String(client_id)) + var row: Dictionary = _client_rows.get(String(client_id), {}) + if not row.is_empty(): + _apply_row_status( + String(client_id), + row.get("status", Client.Status.NOT_CONFIGURED), + "" + ) + _client_action_threads.clear() + for thread in _orphaned_client_action_threads: + if thread != null: + thread.wait_to_finish() + _orphaned_client_action_threads.clear() + _orphaned_client_action_owners.clear() + _client_action_started_msec.clear() + _client_action_names.clear() + _client_action_phase_mutex.lock() + _client_action_phases.clear() + _client_action_phase_mutex.unlock() + _client_action_phase_shown.clear() + _client_action_cancel_mutex.lock() + _client_action_cancelled_clients.clear() + _client_action_cancel_mutex.unlock() + + +func _check_client_action_timeouts() -> void: + var now := Time.get_ticks_msec() + for client_id in _client_action_threads.keys(): + if not _client_action_started_msec.has(client_id): + continue + var started := int(_client_action_started_msec.get(client_id, 0)) + if now - started >= _client_action_budget_msec(String(client_id)): + _abandon_client_action_thread(String(client_id)) + + +## Watchdog budget for an in-flight client action. +## +## The 30s default is sized for a CLI registry call. Once the worker reports it +## has moved on to building the pinned uv environment, that budget is far too +## short: a cold build is *expected* to run for tens of seconds, and abandoning +## it would report a false Configure timeout for exactly the slow cold start the +## pre-warm exists to absorb — re-enabling the row and discarding the worker's +## completion while the build is still running and about to succeed. +## +## The prewarm phase therefore gets the base budget plus the pre-warm's own +## ceiling. The action still cannot hang forever: `McpCliExec.run` bounds the +## build at `PREWARM_TIMEOUT_MS` on its own, so this is a backstop above a +## backstop rather than the only limit. +func _client_action_budget_msec(client_id: String) -> int: + if _read_client_action_phase(client_id) == _PHASE_PREWARM: + return CLIENT_ACTION_TIMEOUT_MSEC + ClientConfigurator.PREWARM_TIMEOUT_MS + return CLIENT_ACTION_TIMEOUT_MSEC + + +func _abandon_client_action_thread(client_id: String) -> void: + if not _client_action_threads.has(client_id): + return + var thread: Thread = _client_action_threads[client_id] + var elapsed := Time.get_ticks_msec() - int(_client_action_started_msec.get(client_id, Time.get_ticks_msec())) + var worker_alive := thread != null and thread.is_alive() + if thread != null: + _orphaned_client_action_threads.append(thread) + if worker_alive: + ## The worker can cross the base watchdog immediately before it + ## announces PREWARM. Cancel now so it cannot become an orphan and + ## then begin a fresh 180s uvx operation outside timeout tracking. + _set_client_action_cancel_requested(client_id, true) + var owned: Array = _orphaned_client_action_owners.get(client_id, []) + owned.append(thread) + _orphaned_client_action_owners[client_id] = owned + _client_action_threads.erase(client_id) + _client_action_started_msec.erase(client_id) + _clear_client_action_phase(client_id) + var action := str(_client_action_names.get(client_id, "configure")) + _client_action_names.erase(client_id) + _client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1 + ## Only hand the row back when nothing is still running for it. Re-enabling + ## while the abandoned worker is mid-build invites a second worker on top + ## of the first; the prune below re-enables the row once it actually ends. + if not worker_alive: + _finalize_action_buttons(client_id) + print("MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s" % [ + client_id, + action, + elapsed, + str(worker_alive), + ]) + var label := "Remove" if action == "remove" else "Configure" + var detail := ( + "%s is taking longer than expected and is still running; refreshing current status." % label + if worker_alive + else "%s did not report completion in time; refreshing current status." % label + ) + _apply_row_status(client_id, Client.Status.ERROR, detail) + _refresh_clients_summary() + if is_inside_tree(): + _request_client_status_refresh(true) + + +func _prune_orphaned_client_action_threads() -> void: + var completed_orphan := false + for i in range(_orphaned_client_action_threads.size() - 1, -1, -1): + var thread := _orphaned_client_action_threads[i] + if thread == null: + _orphaned_client_action_threads.remove_at(i) + elif not thread.is_alive(): + thread.wait_to_finish() + _orphaned_client_action_threads.remove_at(i) + completed_orphan = true + _release_finished_orphan_owners() + if completed_orphan and is_inside_tree(): + _request_client_action_completion_refresh() + + +## Hand a row back once its abandoned worker has actually finished. Pairs with +## `_abandon_client_action_thread`, which deliberately leaves the buttons +## disabled while the orphan is still running — without this the row would stay +## disabled forever. +func _release_finished_orphan_owners() -> void: + for client_id in _orphaned_client_action_owners.keys(): + var owned: Array = _orphaned_client_action_owners[client_id] + for i in range(owned.size() - 1, -1, -1): + var t: Thread = owned[i] + if t == null or not t.is_alive(): + owned.remove_at(i) + if owned.is_empty(): + _orphaned_client_action_owners.erase(client_id) + if not _client_action_threads.has(client_id): + _finalize_action_buttons(String(client_id)) + + +## True while a previously-abandoned worker for this client is still running. +static func _has_live_orphan(client_id: String) -> bool: + var owned: Array = _orphaned_client_action_owners.get(client_id, []) + for t in owned: + if t != null and (t as Thread).is_alive(): + return true + return false + + +func _request_client_action_completion_refresh() -> void: + _request_client_status_refresh(true) + + +func _notification(what: int) -> void: + # Detect dock/undock by watching for reparenting events. + if what == NOTIFICATION_PARENTED or what == NOTIFICATION_UNPARENTED: + _update_redock_visibility.call_deferred() + elif what == NOTIFICATION_APPLICATION_FOCUS_IN: + if _should_refresh_client_statuses_on_focus_in(): + _request_client_status_refresh(false) + ## Re-probe uv only when the user actually went off to install it + ## (see _on_install_uv). `check_uv_version()` is cached, so an + ## ungated refresh here would usually be free — but after the + ## button invalidated that cache it costs one blocking + ## `uvx --version`, and this notification must not grow a probe on + ## the common focus-in path. One-shot: clear before refreshing. + if _uv_recheck_pending: + _uv_recheck_pending = false + _refresh_setup_status.call_deferred() + + +func _should_refresh_client_statuses_on_focus_in() -> bool: + ## Focus-in is part of Godot/editor window activation. Keep automatic refresh, + ## but only through the async/cooldown-protected path; never run a blocking + ## client-status sweep directly from this notification. + return true + + +func _is_floating() -> bool: + var p := get_parent() + while p != null: + if p is Window: + return p != get_tree().root + p = p.get_parent() + return false + + +func _update_redock_visibility() -> void: + if _redock_btn == null: + return + var floating := _is_floating() + if _redock_btn.visible != floating: + _redock_btn.visible = floating + + +func _on_redock() -> void: + # When floating, our Window is NOT the editor root. Closing it triggers + # Godot's internal dock-return logic (same as clicking the window's X). + var win := get_window() + if win != null and win != get_tree().root: + win.close_requested.emit() + + +func _build_margin_container(margin: int = 12) -> MarginContainer: + var margin_container := MarginContainer.new() + margin_container.add_theme_constant_override("margin_left", margin) + margin_container.add_theme_constant_override("margin_right", margin) + margin_container.add_theme_constant_override("margin_top", margin) + margin_container.add_theme_constant_override("margin_bottom", margin) + return margin_container + + +func _build_ui() -> void: + add_theme_constant_override("separation", 8) + + # --- Top row: status indicator + redock button (when floating) --- + var status_row := HBoxContainer.new() + status_row.add_theme_constant_override("separation", 8) + + _status_icon = ColorRect.new() + _status_icon.custom_minimum_size = Vector2(14, 14) + # Amber on first paint — matches the "Starting server…" label text and + # distinguishes from a real disconnect (red). + _status_icon.color = COLOR_AMBER + var icon_center := CenterContainer.new() + icon_center.add_child(_status_icon) + status_row.add_child(icon_center) + + _status_label = Label.new() + # Start in grace state — _update_status will take over on the next frame + # once the connection is available. Never show bare "Disconnected" on + # first paint because that's misleading while the server is still + # spinning up. + _status_label.text = "Starting server…" + _status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + status_row.add_child(_status_label) + + _redock_btn = Button.new() + _redock_btn.text = "Dock" + _redock_btn.tooltip_text = "Return this panel to the editor dock" + _redock_btn.visible = false + _redock_btn.pressed.connect(_on_redock) + status_row.add_child(_redock_btn) + + add_child(status_row) + + # Install-mode line — so a git-clone user doesn't press the yellow Update + # banner below and silently downgrade from main to the last release tag. + # See #144. + _install_label = Label.new() + _install_label.add_theme_color_override("font_color", COLOR_MUTED) + _install_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _install_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _install_label.text = _install_mode_text() + _install_label.tooltip_text = _install_mode_tooltip() + _install_label.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(_install_label) + + _body_scroll = ScrollContainer.new() + _body_scroll.name = "DockBodyScroll" + _body_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _body_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _body_scroll.custom_minimum_size = Vector2(0, 48) + _body_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + add_child(_body_scroll) + + _body = VBoxContainer.new() + _body.name = "DockBody" + _body.add_theme_constant_override("separation", 8) + _body.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _body_scroll.add_child(_body) + + # --- Spawn-failure panel (shown when `_start_server` reports a non-OK + # state via `get_server_status`). One body paragraph + the matching + # action; the top status label already carries the state headline. + _crash_panel = VBoxContainer.new() + _crash_panel.add_theme_constant_override("separation", 6) + _crash_panel.visible = false + + _crash_output = RichTextLabel.new() + _crash_output.custom_minimum_size = Vector2(0, 60) + _crash_output.bbcode_enabled = false + _crash_output.selection_enabled = true + _crash_output.scroll_following = false + _crash_output.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _crash_output.fit_content = true + _crash_panel.add_child(_crash_output) + + _port_picker_panel = PortPickerPanelScript.new() + _port_picker_panel.setup() + _port_picker_panel.port_apply_requested.connect(_on_port_apply_requested) + _crash_panel.add_child(_port_picker_panel) + + _crash_restart_btn = Button.new() + _crash_restart_btn.text = "Restart Server" + _crash_restart_btn.tooltip_text = "Stop the old server on this port and start the bundled godot-ai server" + _crash_restart_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _crash_restart_btn.add_theme_color_override("font_color", Color.WHITE) + _crash_restart_btn.add_theme_color_override("font_hover_color", Color.WHITE) + _crash_restart_btn.add_theme_color_override("font_pressed_color", Color.WHITE) + _crash_restart_btn.pressed.connect(_on_restart_stale_server) + _crash_restart_btn.visible = false + _crash_panel.add_child(_crash_restart_btn) + + _crash_reload_btn = Button.new() + _crash_reload_btn.text = "Reload Plugin" + _crash_reload_btn.tooltip_text = "Re-run the spawn after fixing the underlying issue" + _crash_reload_btn.pressed.connect(_on_reload_plugin) + _crash_panel.add_child(_crash_reload_btn) + + _crash_docs_btn = Button.new() + _crash_docs_btn.text = "How to change the port" + _crash_docs_btn.tooltip_text = "Open the guide: change godot_ai/http_port and reconfigure your MCP clients" + _crash_docs_btn.visible = false + _crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url())) + _crash_panel.add_child(_crash_docs_btn) + + _crash_panel.add_child(HSeparator.new()) + _body.add_child(_crash_panel) + + _build_mixed_state_banner() + _refresh_mixed_state_banner() + + # --- Update banner (top of dock, hidden until check finds a newer version) --- + _update_banner = VBoxContainer.new() + _update_banner.add_theme_constant_override("separation", 4) + _update_banner.visible = false + + _update_label = Label.new() + _update_label.add_theme_font_size_override("font_size", 15) + _update_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.3)) + ## Wrap long banner text (e.g. the < 4.5 support-floor guidance) instead + ## of letting a single line stretch the whole dock wide. The dock is a + ## fixed-width side panel, so constrain horizontally and wrap. + _update_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _update_label.size_flags_horizontal = Control.SIZE_FILL + _update_label.custom_minimum_size = Vector2(0, 0) + _update_banner.add_child(_update_label) + + var update_btn_row := HBoxContainer.new() + update_btn_row.add_theme_constant_override("separation", 6) + + _update_btn = Button.new() + _update_btn.text = "Update" + _update_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _update_btn.pressed.connect(_on_update_pressed) + update_btn_row.add_child(_update_btn) + + var release_link := Button.new() + release_link.text = "Release notes" + release_link.pressed.connect(func(): OS.shell_open(UpdateManagerScript.RELEASES_PAGE)) + update_btn_row.add_child(release_link) + + _update_banner.add_child(update_btn_row) + _update_banner.add_child(HSeparator.new()) + + _body.add_child(_update_banner) + + if _update_manager == null: + _update_manager = UpdateManagerScript.new() + _update_manager.setup(_plugin, self) + _update_manager.update_check_completed.connect(_on_update_check_result) + _update_manager.install_state_changed.connect(_on_install_state_changed) + _body.add_child(_update_manager) + _update_manager.check_for_updates.call_deferred() + + # --- Dev-only connection extras (server label + reload button) --- + _dev_section = VBoxContainer.new() + _dev_section.add_theme_constant_override("separation", 6) + _body.add_child(_dev_section) + + _server_label = Label.new() + _server_label.add_theme_color_override("font_color", COLOR_MUTED) + _dev_section.add_child(_server_label) + _refresh_server_label() + + var btn_row := HBoxContainer.new() + btn_row.add_theme_constant_override("separation", 6) + + _reload_btn = Button.new() + _reload_btn.text = "Dev: Reload Plugin" + _reload_btn.tooltip_text = "Developer utility: reload the GDScript plugin. This does not restart or replace the server." + _reload_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _reload_btn.pressed.connect(_on_reload_plugin) + btn_row.add_child(_reload_btn) + + _dev_section.add_child(btn_row) + + # --- Setup section (dev-only or when uv missing) --- + _setup_section = VBoxContainer.new() + _setup_section.add_theme_constant_override("separation", 6) + _body.add_child(_setup_section) + + _setup_section.add_child(HSeparator.new()) + _setup_section.add_child(_make_header("Setup")) + _setup_container = VBoxContainer.new() + _setup_container.add_theme_constant_override("separation", 6) + _setup_section.add_child(_setup_container) + + _body.add_child(HSeparator.new()) + + # --- Clients --- + var clients_header_row := HBoxContainer.new() + clients_header_row.add_theme_constant_override("separation", 8) + + var clients_header := _make_header("Clients") + clients_header_row.add_child(clients_header) + + _clients_summary_label = Label.new() + _clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED) + _clients_summary_label.clip_text = true + _clients_summary_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + clients_header_row.add_child(_clients_summary_label) + + var clients_actions := HFlowContainer.new() + clients_actions.add_theme_constant_override("h_separation", 8) + clients_actions.add_theme_constant_override("v_separation", 4) + + var clients_refresh_btn := Button.new() + clients_refresh_btn.text = "Refresh" + clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run." + clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed) + clients_actions.add_child(clients_refresh_btn) + + var clients_open_btn := Button.new() + clients_open_btn.text = "Clients & Tools" + clients_open_btn.tooltip_text = "Open the Clients & Tools window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)." + clients_open_btn.pressed.connect(_on_open_clients_window) + clients_actions.add_child(clients_open_btn) + + _body.add_child(clients_header_row) + _body.add_child(clients_actions) + + _client_empty_cta_btn = Button.new() + _client_empty_cta_btn.text = "Configure an AI client ->" + _client_empty_cta_btn.tooltip_text = "Open the Clients tab to configure an AI coding client for this Godot AI server." + _client_empty_cta_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _client_empty_cta_btn.visible = false + _client_empty_cta_btn.pressed.connect(_on_open_clients_window) + _body.add_child(_client_empty_cta_btn) + + # Drift banner — hidden until a sweep finds at least one mismatched client. + _drift_banner = VBoxContainer.new() + _drift_banner.add_theme_constant_override("separation", 4) + _drift_banner.visible = false + _drift_label = Label.new() + _drift_label.add_theme_color_override("font_color", COLOR_AMBER) + _drift_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _drift_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _drift_banner.add_child(_drift_label) + var drift_btn := Button.new() + drift_btn.text = "Reconfigure mismatched" + drift_btn.tooltip_text = "Re-run Configure on every client whose stored URL doesn't match the current server URL." + drift_btn.pressed.connect(_on_reconfigure_mismatched) + _drift_banner.add_child(drift_btn) + _body.add_child(_drift_banner) + + _clients_window = Window.new() + _clients_window.title = "Godot AI Settings" + ## `Vector2i * float` yields Vector2; wrap the result back to Vector2i. + _clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale()) + _clients_window.visible = false + _clients_window.close_requested.connect(_on_clients_window_close_requested) + add_child(_clients_window) + + ## Tabbed secondary window: Clients (per-client rows), Tools (domain- + ## exclusion checkboxes for clients that cap total tool count, like + ## Antigravity at 100), and Settings (allow-host LAN opt-in, #507). + ## Adding another tab is one more _build_*_tab call — no surgery on the + ## rest of the window. + var tabs := TabContainer.new() + tabs.anchor_right = 1.0 + tabs.anchor_bottom = 1.0 + _clients_window.add_child(tabs) + + var clients_tab := VBoxContainer.new() + clients_tab.add_theme_constant_override("separation", 8) + var clients_margin := _build_margin_container() + clients_margin.name = "Clients" + clients_margin.add_child(clients_tab) + tabs.add_child(clients_margin) + + _client_configure_all_btn = Button.new() + _client_configure_all_btn.text = "Configure all" + _client_configure_all_btn.tooltip_text = "Configure every client that isn't already pointing at this server" + _client_configure_all_btn.size_flags_horizontal = Control.SIZE_SHRINK_END + _client_configure_all_btn.pressed.connect(_on_configure_all_clients) + clients_tab.add_child(_client_configure_all_btn) + + var clients_scroll := ScrollContainer.new() + clients_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + clients_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + clients_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + clients_tab.add_child(clients_scroll) + + _client_grid = VBoxContainer.new() + _client_grid.add_theme_constant_override("separation", 4) + _client_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + clients_scroll.add_child(_client_grid) + + for client_id in ClientConfigurator.client_ids(): + _build_client_row(client_id) + + _build_tools_tab(tabs) + _build_settings_tab(tabs) + + _body.add_child(HSeparator.new()) + + # --- Dev mode toggle (always visible) --- + var dev_toggle_row := HBoxContainer.new() + var dev_toggle_label := Label.new() + dev_toggle_label.text = "Developer mode" + dev_toggle_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + dev_toggle_row.add_child(dev_toggle_label) + + _dev_mode_toggle = CheckButton.new() + _dev_mode_toggle.button_pressed = _load_dev_mode() + _dev_mode_toggle.toggled.connect(_on_dev_mode_toggled) + dev_toggle_row.add_child(_dev_mode_toggle) + _body.add_child(dev_toggle_row) + + # --- Log section (dev-only) --- + _log_viewer = LogViewerScript.new() + _log_viewer.setup(_log_buffer) + _log_viewer.logging_enabled_changed.connect(_on_log_logging_enabled_changed) + _body.add_child(_log_viewer) + + # Apply initial dev-mode visibility + _apply_dev_mode_visibility() + _refresh_setup_status.call_deferred() + _perform_initial_client_status_refresh() + + +## Static so `dock_panels/*.gd` subpanels can call it via `McpDock._make_header(...)` +## without re-declaring identical helpers + COLOR_HEADER constants. +static func _make_header(text: String) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 18) + label.add_theme_color_override("font_color", COLOR_HEADER) + return label + + +func _build_client_row(client_id: String) -> void: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var dot := ColorRect.new() + dot.custom_minimum_size = Vector2(10, 10) + dot.color = COLOR_MUTED + var dot_center := CenterContainer.new() + dot_center.add_child(dot) + row.add_child(dot_center) + + var name_label := Label.new() + name_label.text = ClientConfigurator.client_display_name(client_id) + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + ## #838/#816 step 11: say which transport Configure will write — the + ## client-owned attach bridge or the client's native URL mode. + var transport_tag := Label.new() + transport_tag.text = _client_transport_tag(client_id) + transport_tag.add_theme_color_override("font_color", COLOR_MUTED) + transport_tag.tooltip_text = ( + "Configure writes a local `godot-ai attach` launch command for this client." + if transport_tag.text == "attach" + else "Configure writes this client's native URL entry." + ) + ## Long error messages from `_verify_post_state` (e.g. "reported remove ok + ## but verification still reads configured…") used to push the Retry / + ## Configure button off-screen — the row's Label wanted its full text + ## width as minimum size, so the buttons got squeezed out. Wrap onto + ## multiple lines instead so the row keeps its right edge stable and + ## the buttons remain visible; the user can also read the whole message + ## without resizing the window. + name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + row.add_child(name_label) + row.add_child(transport_tag) + + var configure_btn := Button.new() + configure_btn.text = "Configure" + configure_btn.pressed.connect(_on_configure_client.bind(client_id)) + row.add_child(configure_btn) + + var remove_btn := Button.new() + remove_btn.text = "Remove" + remove_btn.visible = false + remove_btn.pressed.connect(_on_remove_client.bind(client_id)) + row.add_child(remove_btn) + + # F-3-4: use the authoritative facade so Open/Reveal land on the same + # file `_check_status_merged` drives (last-wins across project tiers, + # matching the F2 status fix). + var config_path := ClientConfigurator.effective_authoritative_path(client_id) + var open_config_btn := Button.new() + _apply_editor_icon(open_config_btn, "ExternalLink", "Open") + open_config_btn.custom_minimum_size = Vector2(28, 28) + open_config_btn.visible = not config_path.is_empty() + open_config_btn.pressed.connect(_on_open_config_file.bind(client_id)) + row.add_child(open_config_btn) + + var reveal_btn := Button.new() + _apply_editor_icon(reveal_btn, "Folder", "Reveal") + reveal_btn.custom_minimum_size = Vector2(28, 28) + reveal_btn.visible = not config_path.is_empty() + reveal_btn.pressed.connect(_on_reveal_config_folder.bind(client_id)) + row.add_child(reveal_btn) + + _client_grid.add_child(row) + + var manual_panel := VBoxContainer.new() + manual_panel.add_theme_constant_override("separation", 4) + manual_panel.visible = false + + var manual_hint := Label.new() + manual_hint.text = "Run this manually:" + manual_hint.add_theme_color_override("font_color", COLOR_MUTED) + manual_panel.add_child(manual_hint) + + var manual_text := TextEdit.new() + manual_text.editable = false + manual_text.custom_minimum_size = Vector2(0, 60) + manual_text.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + manual_panel.add_child(manual_text) + + var copy_btn := Button.new() + copy_btn.text = "Copy" + copy_btn.pressed.connect(_on_copy_manual_command.bind(client_id)) + manual_panel.add_child(copy_btn) + + _client_grid.add_child(manual_panel) + + _client_rows[client_id] = { + "dot": dot, + "status": Client.Status.NOT_CONFIGURED, + "name_label": name_label, + "configure_btn": configure_btn, + "remove_btn": remove_btn, + "open_config_btn": open_config_btn, + "reveal_btn": reveal_btn, + "config_path": config_path, + "manual_panel": manual_panel, + "manual_text": manual_text, + } + _refresh_client_config_file_buttons(client_id) + + +func _apply_editor_icon(button: Button, icon_name: String, fallback_text: String) -> void: + if has_theme_icon(icon_name, "EditorIcons"): + button.icon = get_theme_icon(icon_name, "EditorIcons") + else: + button.text = fallback_text + + +# --- Status updates --- + +func _update_status() -> void: + var connected: bool = _connection != null and _connection.is_connected + ## Pull the connection's transport snapshot on this existing refresh tick. + ## `has_method` preserves the plugin self-update seam while an older + ## Connection instance is still alive under a hot-reloaded dock script. + var transport_status: Dictionary = ( + _connection.get_transport_status() + if _connection != null and _connection.has_method("get_transport_status") + else {} + ) + ## During plugin self-update there's a brief window where this dock + ## script is already the new version (Godot hot-reloads scripts on + ## file change) but `_plugin` is still the old `EditorPlugin` instance + ## (only `set_plugin_enabled(false, true)` re-instantiates that). When + ## the new dock calls a method the old plugin doesn't have, `_process` + ## errors every frame until `McpUpdateManager._reload_after_update` + ## lands. Guard every `_plugin.()` call with `has_method` + ## so that window stays silent. See #168. + var server_status: Dictionary = ( + _plugin.get_server_status() + if _plugin != null and _plugin.has_method("get_server_status") + else {} + ) + var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) + if ServerStateScript.blocks_client_health(state): + connected = false + + ## One `match`/`elif` chain, one source of truth. Adding a new + ## spawn outcome = one `ServerStateScript` constant + one arm here + + ## one body string in `_crash_body_for_state`. + ## Default covers both a missing/old Connection instance and an unknown + ## future transport phase. Every recognized state below overrides it, so + ## startup grace and settled disconnect have one rendering path. + var inside_startup_grace := Time.get_ticks_msec() < _startup_grace_until_msec + var status_text := "Starting server…" if inside_startup_grace else "Disconnected" + var status_color := COLOR_AMBER if inside_startup_grace else Color.RED + if _server_restart_in_progress: + status_text = "Restarting server..." + status_color = COLOR_AMBER + elif connected: + status_text = _connected_status_text() + status_color = Color.GREEN + elif state == ServerStateScript.CRASHED: + var exit_ms: int = server_status.get("exit_ms", 0) + status_text = "Server exited after %.1fs" % (exit_ms / 1000.0) + status_color = Color.RED + elif state == ServerStateScript.PORT_EXCLUDED: + status_text = "Port %d reserved by Windows" % ClientConfigurator.http_port() + status_color = Color.RED + elif state == ServerStateScript.INCOMPATIBLE: + status_text = "Incompatible server on port %d" % ClientConfigurator.http_port() + status_color = Color.RED + elif state == ServerStateScript.FOREIGN_PORT: + ## #647: the post-crash probe names the actual conflicting port + ## (HTTP or WS) — don't blame port 8000 when 9500 is the occupant. + var conflict_port: int = int(server_status.get("conflict_port", 0)) + if conflict_port <= 0: + conflict_port = ClientConfigurator.http_port() + status_text = "Port %d held by another process" % conflict_port + status_color = Color.RED + elif state == ServerStateScript.NO_COMMAND: + status_text = "No server command found" + status_color = Color.RED + elif not transport_status.is_empty(): + var transport_phase := str(transport_status.get("phase", "")) + if transport_phase == "connecting": + status_text = _transport_status_text(transport_status) + status_color = COLOR_AMBER + elif transport_phase == "retrying": + status_text = _transport_status_text(transport_status) + status_color = COLOR_AMBER + elif transport_phase == "closing": + status_text = _transport_status_text(transport_status) + status_color = COLOR_AMBER + elif transport_phase == "blocked": + ## Exact terminal labels come from lifecycle state above. This is a + ## generic fallback for a blocked connection without a diagnosis. + status_text = _transport_status_text(transport_status) + status_color = Color.RED + + ## keep_server_on_exit (#800): the reaper env opt-outs are staged at + ## spawn, so a mid-session toggle only lands on the next server start — + ## say so while the running server still carries the old behavior. + if connected and ClientConfigurator.keep_server_on_exit() != bool(server_status.get("keep_alive", false)): + status_text += " — keep-server-on-exit applies after Restart" + + _update_crash_panel(server_status) + _refresh_server_version_label(server_status) + _refresh_server_label(server_status) + + ## A transient disconnect reason remains in the transport snapshot until + ## handshake_ack. Once the dock renders the connection as OPEN, do not pair + ## its green label with the previous peer's recovery diagnostic. + var status_tooltip := "" if connected else str(transport_status.get("reason", "")) + var changed: bool = ( + connected != _last_connected + or status_text != _last_status_text + or status_tooltip != _last_status_tooltip + ) + if not changed: + return + var just_connected: bool = connected and not _last_connected + _last_connected = connected + _last_status_text = status_text + _last_status_tooltip = status_tooltip + _status_icon.color = status_color + _status_label.text = status_text + _status_label.tooltip_text = status_tooltip + if just_connected: + ## #739: the server just came up. If the startup uv probe failed + ## (the reporter's screenshot: green "Server connected" beside a + ## red "uv: not found" row), the failure was transient — re-probe + ## instead of pinning the red row for the whole session. Runs + ## AFTER the label writes above and via the deferred queue, so the + ## status-machine state is committed before the probe can block. + _schedule_uv_reprobe() + + ## Status transitions are exactly when "is the launch still settling?" + ## can change (Starting server… -> connected / Disconnected / terminal + ## diagnosis), so re-evaluate the Setup section's visibility here (#744). + ## Cheap: runs only on `changed`, and the uv probe result is cached. + _apply_dev_mode_visibility() + + _update_dev_section_buttons() + + +## Render the diagnostic panel body for a given spawn state. The top +## status label already names the problem; this answers "what do I do?". +## Panel shows for any non-OK state; picker shows only when moving the HTTP +## port alone is a valid recovery. Incompatible godot-ai servers commonly +## hold both HTTP and WS ports, so their message points to Editor Settings +## instead of offering the HTTP-only quick picker. +func _update_crash_panel(server_status: Dictionary) -> void: + var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) + if not ServerStateScript.is_terminal_diagnosis(state): + if _crash_panel.visible: + _crash_panel.visible = false + _last_server_status = {} + return + if server_status == _last_server_status: + return + _last_server_status = server_status.duplicate() + _crash_panel.visible = true + _crash_output.clear() + _crash_output.add_text(_crash_body_for_state(state, server_status)) + var show_recovery_restart := ( + state == ServerStateScript.INCOMPATIBLE + and bool(server_status.get("can_recover_incompatible", false)) + ) + if _crash_restart_btn != null: + _crash_restart_btn.visible = show_recovery_restart + _crash_restart_btn.disabled = _server_restart_in_progress + _crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server" + if _crash_reload_btn != null: + _crash_reload_btn.visible = ( + not show_recovery_restart + and state != ServerStateScript.INCOMPATIBLE + ) + ## Docs link only for the genuinely-foreign occupant: a recoverable + ## (older godot-ai) server gets Restart Server instead, and the inline + ## body already names a free port — the link carries the per-client + ## reconfigure steps that don't fit inline. + if _crash_docs_btn != null: + _crash_docs_btn.visible = ( + state == ServerStateScript.INCOMPATIBLE + and not bool(server_status.get("can_recover_incompatible", false)) + ) + + ## #647: the quick picker only moves `godot_ai/http_port`, so hide it + ## when the diagnosed conflict is on the WebSocket port — the crash + ## body already points at `godot_ai/ws_port` in Editor Settings. + var conflict_port := int(server_status.get("conflict_port", 0)) + var http_conflict := conflict_port <= 0 or conflict_port == ClientConfigurator.http_port() + var port_picker_visible := ( + state == ServerStateScript.PORT_EXCLUDED + or (state == ServerStateScript.FOREIGN_PORT and http_conflict) + ) + _port_picker_panel.visible = port_picker_visible + if port_picker_visible: + ## Seed the spinbox with a suggested non-reserved port each time the + ## panel surfaces. Idempotent when the user already has a good + ## candidate queued up. + _port_picker_panel.seed_suggested_port() + + +static func _crash_body_for_state(state: int, server_status: Dictionary = {}) -> String: + ## Single sentence per state. The top status label already names the + ## problem; don't repeat it here. This copy answers "what do I do?". + var port := ClientConfigurator.http_port() + match state: + ServerStateScript.PORT_EXCLUDED: + return "Windows (Hyper-V / WSL2 / Docker) reserved port %d. Pick a free port or try `net stop winnat; net start winnat` in an admin shell." % port + ServerStateScript.INCOMPATIBLE: + var message := str(server_status.get("message", "")) + if bool(server_status.get("can_recover_incompatible", false)): + var expected := str(server_status.get("expected_version", "")) + if expected.is_empty(): + expected = ClientConfigurator.get_plugin_version() + if not message.is_empty(): + return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected] + return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected] + ## Genuinely foreign occupant (no recovery proof). Name a concrete + ## free port so the user doesn't have to hunt for one, and let the + ## crash panel's "How to change the port" link carry the per-client + ## reconfigure steps. `suggest_free_port` already routes through the + ## Windows reservation table, so the named port won't itself fail + ## with WinError 10013. + var hint := _free_port_hint(port) + if not message.is_empty(): + return "%s %s" % [message, hint] + return "Port %d is occupied by an incompatible server. %s" % [port, hint] + ServerStateScript.FOREIGN_PORT: + ## #647: prefer the lifecycle's diagnosis (it names the right + ## port — HTTP vs WS — and the Editor Setting to change) over + ## the generic HTTP-port fallback. + var foreign_message := str(server_status.get("message", "")) + if not foreign_message.is_empty(): + return foreign_message + return "Another process is already bound to port %d. Pick a free port or stop the other process." % port + ServerStateScript.CRASHED: + ## #805: a specific crash diagnosis from the lifecycle (e.g. the + ## flapping-occupant latch) beats the generic launch-mode copy. + ## Generic crash paths clear the message, so stale text from an + ## earlier state can't leak in here. + var crash_message := str(server_status.get("message", "")) + if not crash_message.is_empty(): + return crash_message + ## Both spawn attempts failed on the uvx tier — stock releases: + ## PyPI lag. Local builds (version with +metadata): almost always the + ## dev venv was not found (unresolved junction/symlink) so uvx tried + ## a pin that may lack checkout-local extras. + if ClientConfigurator.get_server_launch_mode() == "uvx": + var version := ClientConfigurator.get_plugin_version() + var pin := ClientConfigurator._pypi_pin_version(version) + if pin != version: + ## `%` binds tighter than `+` in GDScript — format the fully + ## concatenated string, never the last fragment alone. + return ( + "The server exited before the WebSocket handshake. " + + "Local plugin version is %s (PEP 440 local build metadata) — uvx pins PyPI godot-ai==%s. " + + "If you need checkout-local server code, ensure addons/godot_ai resolves to your " + + "dev tree (symlink/junction) with a `.venv`, or set GODOT_AI_VENV_PYTHON to that " + + "venv's python binary, then Reload Plugin. Log should show 'MCP | using dev venv: ...'." + ) % [version, pin] + return ( + "The server exited before the WebSocket handshake, even after a `uvx --refresh` retry. " + + "If this is a brand-new release, PyPI's index may still be propagating (~10 min). " + + "Wait a moment and click Reload Plugin to retry, or check Godot's output log for Python's traceback. " + + "Target: godot-ai==%s." + ) % pin + return "The server exited before the WebSocket handshake. Check Godot's output log (bottom panel) for Python's traceback." + ServerStateScript.NO_COMMAND: + return "No godot-ai server found. Install `uv` via the Setup panel above, or run `pip install godot-ai`." + _: + return "" + + +## One sentence naming concrete free ports for the user to switch to. Names +## BOTH http and ws: this branch also fires for an incompatible godot-ai +## server we can't prove we own, which commonly holds both ports — moving only +## http would then leave the new server unable to bind ws. Both suggestions are +## routed through `suggest_free_port` so they clear Windows' winnat reservation +## table (no point suggesting a port that 10013s on bind). Only the http port +## reaches client configs; the ws port is server↔plugin, hence the wording. +## The per-client reconfigure steps live behind the crash panel's docs link. +static func _free_port_hint(port: int) -> String: + var free_http := ClientConfigurator.suggest_free_port(port + 1) + var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1) + return "Ports %d (HTTP) and %d (WS) are free — set `godot_ai/http_port` and `godot_ai/ws_port` in Editor Settings, then update your client config with the new HTTP port (How to change the port, below)." % [free_http, free_ws] + + +## URL for the port-conflict guide, pinned to the release tag that matches the +## installed plugin version (releases are tagged `v`). The crash-panel +## button only exists in builds that ship `docs/port-conflicts.md`, so the +## versioned ref always resolves — and a shipped build never points users at a +## tip-of-main guide that has drifted from its own UI. +static func _port_conflict_docs_url() -> String: + var version := ClientConfigurator.get_plugin_version() + var git_ref := ("v%s" % version) if not version.is_empty() else "main" + return "%s/%s/%s" % [REPO_BLOB_BASE, git_ref, PORT_CONFLICT_DOCS_PATH] + + +## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner` +## confirms `*.update_backup` files exist in the addons tree. Mirrors the +## issue #354 fix shape: structured, agent-readable diagnostic that survives +## a normal editor restart so the user can act on it instead of re-running +## the update. +func _build_mixed_state_banner() -> void: + _mixed_state_banner = VBoxContainer.new() + _mixed_state_banner.add_theme_constant_override("separation", 4) + _mixed_state_banner.visible = false + + _mixed_state_label = Label.new() + _mixed_state_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _mixed_state_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _mixed_state_label.add_theme_color_override("font_color", Color.RED) + _mixed_state_banner.add_child(_mixed_state_label) + + _mixed_state_files = RichTextLabel.new() + _mixed_state_files.bbcode_enabled = false + _mixed_state_files.fit_content = true + _mixed_state_files.autowrap_mode = TextServer.AUTOWRAP_OFF + _mixed_state_files.selection_enabled = true + _mixed_state_files.scroll_active = true + _mixed_state_files.custom_minimum_size = Vector2(0, 90) + _mixed_state_files.add_theme_color_override("default_color", COLOR_AMBER) + _mixed_state_banner.add_child(_mixed_state_files) + + _mixed_state_rescan_btn = Button.new() + _mixed_state_rescan_btn.text = "Re-scan" + _mixed_state_rescan_btn.tooltip_text = ( + "Scan addons/godot_ai/ for *.update_backup files again." + + " Click after restoring the addon manually to dismiss this banner." + ) + _mixed_state_rescan_btn.pressed.connect(func(): _refresh_mixed_state_banner(true)) + _mixed_state_banner.add_child(_mixed_state_rescan_btn) + + _mixed_state_banner.add_child(HSeparator.new()) + _body.add_child(_mixed_state_banner) + + +func _refresh_mixed_state_banner(force: bool = false) -> void: + ## Re-scan button passes `force=true` to bypass the scanner's TTL + ## cache so a manual fix is reflected immediately. + _apply_mixed_state_banner_diagnostic(UpdateMixedStateScript.diagnose( + UpdateMixedStateScript.ADDON_DIR, force + )) + + +## Render seam exposed for testing — the GDScript test suite drives this +## directly with synthetic diagnostics so dock banner contracts can be +## pinned without polluting the real `addons/godot_ai/` tree with backup +## files. Callers from production go through `_refresh_mixed_state_banner`. +func _apply_mixed_state_banner_diagnostic(diag: Dictionary) -> void: + if _mixed_state_banner == null: + return + if diag.is_empty(): + _mixed_state_banner.visible = false + return + _mixed_state_banner.visible = true + ## `Dictionary.get(...)` returns Variant; Label.text is typed String. + ## Explicit cast keeps the type contract honest and dodges some Godot + ## 4.x point-release quirks around Variant→typed-property assignment. + _mixed_state_label.text = String(diag.get("message", "")) + _mixed_state_files.clear() + for path in diag.get("backup_files", []): + _mixed_state_files.add_text(String(path)) + _mixed_state_files.newline() + if bool(diag.get("truncated", false)): + _mixed_state_files.add_text( + "… (list truncated at %d entries)" % UpdateMixedStateScript.MAX_BACKUP_RESULTS + ) + _mixed_state_files.newline() + + +## Signal handler for the extracted LogViewer — the panel owns its own +## display visibility, the dock owns logging routing. Routes to BOTH the +## dispatcher (gates [recv]/[send] recording) and the log buffer's console +## echo — the connection logs [event]/[defer] lines directly to the buffer, +## bypassing the dispatcher, so gating only `mcp_logging` left the console +## spamming with the toggle off (#626). Ring recording is unaffected, so +## the dock's log panel keeps working while the console stays quiet. +func _on_log_logging_enabled_changed(enabled: bool) -> void: + if _connection and _connection.dispatcher: + _connection.dispatcher.mcp_logging = enabled + if _log_buffer != null: + _log_buffer.enabled = enabled + + +## Signal handler for the extracted PortPickerPanel — the panel range-validates +## the spinbox value before emitting, so we just write the EditorSetting and +## reload the plugin here. +func _on_port_apply_requested(new_port: int) -> void: + var es := EditorInterface.get_editor_settings() + if es != null: + es.set_setting(McpSettings.SETTING_HTTP_PORT, new_port) + ## Every saved client config now points at the old port. Re-sweep so the + ## drift banner appears in the same frame the user committed the change — + ## the plugin reload below will run a second sweep on its own first paint, + ## but we want the banner up immediately rather than after the reload + ## handshake races to completion. See #166. + _refresh_all_client_statuses() + ## Reload after the setting is committed so `_start_server` reads the new + ## port on the re-enabled plugin instance. + _on_reload_plugin() + + +func _refresh_server_label(server_status: Dictionary = {}) -> void: + if _server_label == null: + return + var ws_port := ClientConfigurator.ws_port() + if _plugin != null and _plugin.has_method("get_resolved_ws_port"): + ws_port = int(_plugin.get_resolved_ws_port()) + var text := "WS: %d HTTP: %d" % [ws_port, ClientConfigurator.http_port()] + if server_status.is_empty() and _plugin != null and _plugin.has_method("get_server_status"): + server_status = _plugin.get_server_status() + if _plugin != null and _plugin.has_method("get_server_pid"): + var ownership := _server_ownership_tag( + int(server_status.get("state", ServerStateScript.UNINITIALIZED)), + int(_plugin.get_server_pid()), + ) + if not ownership.is_empty(): + text += " · %s" % ownership + _server_label.text = text + + +## #838/#816 step 11: name which backend flavor the editor is riding. +## Diagnostic display only — never kill proof (external adoption clears PID +## authority, see server_lifecycle.gd::adopt_compatible_server / #669). +static func _server_ownership_tag(state: int, server_pid: int) -> String: + if state != ServerStateScript.READY: + return "" + return "plugin-managed backend" if server_pid > 0 else "externally adopted backend" + + +## "attach" when Configure writes a client-owned launch command for this +## client, "URL" when it writes the client's native URL entry. Derived from +## descriptor data so the tag can never disagree with what Configure does. +static func _client_transport_tag(client_id: String) -> String: + var client := ClientRegistry.get_by_id(client_id) + if client == null: + return "" + return "URL" if client.command_shape == Client.CommandShape.NONE else "attach" + + +# --- Telemetry setting persistence --- + + +## Returns true if GODOT_AI_DISABLE_TELEMETRY or DISABLE_TELEMETRY is set +## to a truthy value, false if either is set and non-truthy, null if neither +## env var is present at all. +func _is_telemetry_disabled_via_env() -> Variant: + if not (OS.has_environment("GODOT_AI_DISABLE_TELEMETRY") or OS.has_environment("DISABLE_TELEMETRY")): + return null + return McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY") + + +## Reads the telemetry preference, applying env-var override when present. +## Initialises _telemetry_pending_enabled / _telemetry_saved_enabled and +## sets the checkbox state + locked tooltip. Call after _telemetry_toggle +## has been created. +func _load_telemetry_setting() -> void: + var es := EditorInterface.get_editor_settings() + var env_disabled = _is_telemetry_disabled_via_env() + + var enabled: bool + if env_disabled != null: + ## Env var present: resolve and save to EditorSettings so future sessions without + ## the env var honour the last-set value. + enabled = not bool(env_disabled) + if es != null: + es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, enabled) + else: + ## No env var: read (or create) the EditorSettings key. + if es != null and es.has_setting(McpSettings.SETTING_TELEMETRY_ENABLED): + enabled = bool(es.get_setting(McpSettings.SETTING_TELEMETRY_ENABLED)) + else: + enabled = true + if es != null: + es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, true) + + _telemetry_pending_enabled = enabled + _telemetry_saved_enabled = enabled + + if _telemetry_toggle == null: + return + _telemetry_toggle.set_pressed_no_signal(enabled) + if env_disabled != null: + _telemetry_toggle.disabled = true + _telemetry_toggle.tooltip_text = ( + "Telemetry is controlled by an environment variable " + + "(GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY)." + ) + else: + _telemetry_toggle.disabled = false + _telemetry_toggle.tooltip_text = "" + + +func _on_telemetry_toggled(pressed: bool) -> void: + _telemetry_pending_enabled = pressed + _refresh_tools_ui_state() + + +# --- Dev mode persistence --- + + +func _load_dev_mode() -> bool: + # Default OFF for every install (including dev checkouts). Contributors + # who want the extra diagnostic UI (Reload Plugin, MCP log + # panel, Start/Stop Dev Server) can flip the toggle once — editor + # settings persist across sessions. + var es := EditorInterface.get_editor_settings() + if es == null: + return false + if not es.has_setting(DEV_MODE_SETTING): + es.set_setting(DEV_MODE_SETTING, false) + return false + return bool(es.get_setting(DEV_MODE_SETTING)) + + +func _on_dev_mode_toggled(enabled: bool) -> void: + var es := EditorInterface.get_editor_settings() + if es != null: + es.set_setting(DEV_MODE_SETTING, enabled) + _apply_dev_mode_visibility() + _refresh_setup_status() + + +func _apply_dev_mode_visibility() -> void: + if _dev_mode_toggle == null: + return ## dock UI not built yet (unit tests, teardown window) + var dev := _dev_mode_toggle.button_pressed + _dev_section.visible = dev + if _log_viewer != null: + _log_viewer.visible = dev + # Setup section: visible in dev mode, OR in user mode when uv is missing + # (so users can install uv from the dock) — but not while the server + # launch is still settling (#744): mid-launch a red "uv: not found" row + # is usually a transient probe failure (#739) or irrelevant because the + # launch is succeeding via the .venv or system tiers. `_update_status` + # re-applies visibility on every status transition, so the section + # appears the moment the launch outcome makes it relevant. + var is_dev := ClientConfigurator.is_dev_checkout() + var uv_missing := not is_dev and ClientConfigurator.check_uv_version().is_empty() + _setup_section.visible = _setup_section_should_show(dev, uv_missing, _server_launch_pending()) + + +## Pure visibility decision for the Setup section (#744). Split out so the +## truth table is unit-testable without faking the uv probe or a dev +## checkout: dev toggle always shows the section; a missing uv only shows +## it once the server launch has settled. +static func _setup_section_should_show( + dev_toggle: bool, uv_missing: bool, launch_pending: bool +) -> bool: + return dev_toggle or (uv_missing and not launch_pending) + + +## True while the server launch outcome is still unknown: not connected, +## no terminal diagnosis yet, and the startup grace window ("Starting +## server…" in the status row) is still running. Mirrors the status-label +## logic in `_update_status` so the Setup section and the amber status +## text agree on what "still launching" means. +func _server_launch_pending() -> bool: + if _last_connected: + return false + var server_status: Dictionary = ( + _plugin.get_server_status() + if _plugin != null and _plugin.has_method("get_server_status") + else {} + ) + var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) + if ServerStateScript.is_terminal_diagnosis(state): + return false + return Time.get_ticks_msec() < _startup_grace_until_msec + + +# --- Button handlers --- + + +func _do_plugin_reload() -> void: + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) + + +func _on_reload_plugin() -> void: + # Persist a pending plugin_reload telemetry event *before* the + # disable kills the live WebSocket — the new plugin's _enter_tree + # flushes it via `_telemetry.flush_pending_plugin_reload()`. + Telemetry.record_pending_plugin_reload("dock_button") + # Defer the toggle so any in-flight input event finishes propagating + # before the dock (and its Window children) leave the tree. Calling + # set_plugin_enabled synchronously from a button press frees the + # viewport mid-dispatch. + _do_plugin_reload.call_deferred() + + +## Setup-section "Server" row: always report the TRUE running server +## version (from the handshake_ack) rather than the plugin's expected +## version, and highlight the mismatch so self-update drift is visible +## at a glance instead of silently masked by a green label. +## +## Render states, keyed off live version metadata: +## - empty (pre-ack): show the expected version only as an unverified target +## - matches plugin: show it green, no Restart button +## - dev mismatch: show amber with an explicit dev marker +## - release mismatch: show actual vs expected; only surface Restart when the +## plugin has ownership proof for the process +func _refresh_server_version_label(server_status: Dictionary = {}) -> void: + if _setup_server_label == null: + return + var plugin_ver := ClientConfigurator.get_plugin_version() + if server_status.is_empty(): + ## Re-fetch only when called outside `_update_status`'s frame + ## (e.g. from `_apply_new_port`, `_on_restart_*`). Inside the + ## per-frame loop, the caller threads its cached snapshot through + ## so we don't allocate a fresh Dictionary every frame. + server_status = ( + _plugin.get_server_status() + if _plugin != null and _plugin.has_method("get_server_status") + else {} + ) + var server_ver: String = _connection.server_version if _connection != null else "" + if server_ver.is_empty(): + server_ver = str(server_status.get("actual_version", "")) + var expected_ver := str(server_status.get("expected_version", "")) + if expected_ver.is_empty(): + expected_ver = plugin_ver + var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) + if _server_restart_in_progress and ( + server_ver == expected_ver + or ( + ServerStateScript.is_terminal_diagnosis(state) + and state != ServerStateScript.INCOMPATIBLE + ) + ): + _server_restart_in_progress = false + var text: String + var color: Color + var show_restart := false + if _server_restart_in_progress: + text = "restarting server..." + color = COLOR_AMBER + show_restart = true + elif server_ver.is_empty(): + text = "checking live version (expected godot-ai == %s)" % expected_ver + color = COLOR_MUTED + elif server_ver == expected_ver: + text = "godot-ai == %s" % server_ver + color = Color.GREEN + else: + text = "godot-ai == %s (expected %s)" % [server_ver, expected_ver] + var is_incompatible: bool = state == ServerStateScript.INCOMPATIBLE + color = Color.RED if is_incompatible else COLOR_AMBER + var has_managed_proof: bool = ( + _plugin != null + and _plugin.has_method("can_restart_managed_server") + and _plugin.can_restart_managed_server() + ) + var can_recover: bool = bool(server_status.get("can_recover_incompatible", false)) + show_restart = ( + (not is_incompatible and has_managed_proof) + ## Recoverable incompatible servers get the primary action in + ## the top error panel. Duplicating it in Setup made the UI + ## look like it had multiple restart paths. + or (is_incompatible and can_recover and _crash_restart_btn == null) + ) + if text == _last_rendered_server_text: + _setup_server_label.add_theme_color_override("font_color", color) + _update_restart_button(show_restart) + return + _last_rendered_server_text = text + _setup_server_label.text = text + _setup_server_label.add_theme_color_override("font_color", color) + _update_restart_button(show_restart) + + +func _update_restart_button(visible: bool) -> void: + if _version_restart_btn != null: + _version_restart_btn.visible = visible + _version_restart_btn.disabled = _server_restart_in_progress + _version_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart" + if _crash_restart_btn != null: + _crash_restart_btn.disabled = _server_restart_in_progress + _crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server" + + +func _on_restart_stale_server() -> void: + if _plugin == null or _server_restart_in_progress: + return + _server_restart_in_progress = true + _last_rendered_server_text = "" + _refresh_server_version_label() + if not is_inside_tree(): + await _dispatch_stale_server_restart() + _server_restart_in_progress = false + _last_rendered_server_text = "" + _refresh_server_version_label() + return + call_deferred("_restart_stale_server_after_feedback") + + +func _restart_stale_server_after_feedback() -> void: + await get_tree().create_timer(0.15).timeout + if not await _dispatch_stale_server_restart(): + _server_restart_in_progress = false + _last_rendered_server_text = "" + _refresh_server_version_label() + + +func _dispatch_stale_server_restart() -> bool: + if _plugin == null: + return false + var status: Dictionary = ( + _plugin.get_server_status() + if _plugin.has_method("get_server_status") + else {} + ) + if int(status.get("state", ServerStateScript.UNINITIALIZED)) == ServerStateScript.INCOMPATIBLE: + if _plugin.has_method("recover_incompatible_server"): + ## Coroutine in production (#678): recovery reports success only + ## after the respawn walk completes and the connection unblocks. + return bool(await _plugin.recover_incompatible_server()) + elif _plugin.has_method("force_restart_server"): + _plugin.force_restart_server() + return true + return false + + +# --- Setup section --- + +## #739: a `uvx --version` probe that failed once at editor startup used +## to pin "uv: not found" for the whole session — the Install-uv click +## was the only invalidation path, so the fix users discovered was +## re-clicking Install on every launch. Re-probe on events that suggest +## the failure was transient (server-connect transition, manual Refresh). +## No-op once uv has been found, so this costs nothing in the healthy +## steady state; when uv is genuinely absent, the re-probe is a fast +## negative (CliFinder's well-known-dir walk plus one bounded `where`). +## Runs on the main thread like the initial probe — same wall-clock +## bound, and the triggering events are rare (once per connect / click). +## +## Callers go through _schedule_uv_reprobe() rather than calling this +## inline: the cache-miss probe shells out (bounded at 3s) on the +## calling thread, and both call sites sit mid-flow in UI handlers — +## the connect transition wants its status-label writes committed +## first, and the Refresh click wants the client sweep dispatched +## without waiting on the probe. Same deferred convention as +## _on_install_uv. (The deferred queue still flushes on the main +## thread, so a worst-case 3s probe delays that frame — acceptable for +## a rare, bounded event; a worker thread would be the heavier cure.) +func _schedule_uv_reprobe() -> void: + _reprobe_uv_if_negative.call_deferred() + + +func _reprobe_uv_if_negative() -> void: + if not ClientConfigurator.uv_probe_negative(): + return + ClientConfigurator.invalidate_uv_detection() + _refresh_setup_status() + _apply_dev_mode_visibility() + + +func _refresh_setup_status() -> void: + if _setup_container == null: + return + for child in _setup_container.get_children(): + child.queue_free() + _dev_primary_btn = null + _dev_stop_btn = null + + var is_dev := ClientConfigurator.is_dev_checkout() + if is_dev: + _setup_container.add_child(_make_status_row("Mode", "Dev (venv)", Color.CYAN)) + + var btn_row := HBoxContainer.new() + btn_row.add_theme_constant_override("separation", 4) + btn_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + _dev_primary_btn = Button.new() + _dev_primary_btn.text = "Restart Dev Server" + _dev_primary_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _dev_primary_btn.pressed.connect(_on_dev_primary_pressed) + btn_row.add_child(_dev_primary_btn) + + _dev_stop_btn = Button.new() + _dev_stop_btn.text = "✕" + _dev_stop_btn.tooltip_text = "Stop the dev server without spawning a replacement." + _dev_stop_btn.pressed.connect(_on_dev_stop_pressed) + btn_row.add_child(_dev_stop_btn) + + _setup_container.add_child(btn_row) + _update_dev_section_buttons() + return + + # User mode — check for uv + var uv_version := ClientConfigurator.check_uv_version() + if not uv_version.is_empty(): + var compact_uv_version := _compact_uv_version_text(uv_version) + var uv_tooltip := uv_version if compact_uv_version != uv_version else "" + _setup_container.add_child(_make_status_row("uv", compact_uv_version, Color.GREEN, uv_tooltip)) + ## Build the Server row with a placeholder label we can update every + ## frame. `_refresh_server_version_label` replaces the text + color + ## once `McpConnection.server_version` lands via `handshake_ack`, and + ## flips to amber + "(plugin X)" on drift. Pre-ack we show the + ## plugin's expected version so the row isn't blank. + var server_row := HBoxContainer.new() + server_row.add_theme_constant_override("separation", 8) + var key_label := Label.new() + key_label.text = "Server" + key_label.add_theme_color_override("font_color", COLOR_MUTED) + key_label.custom_minimum_size = Vector2(60, 0) + server_row.add_child(key_label) + _setup_server_label = Label.new() + _setup_server_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + server_row.add_child(_setup_server_label) + _version_restart_btn = Button.new() + _version_restart_btn.text = "Restart" + _version_restart_btn.tooltip_text = "Kill the server on port %d and respawn with the plugin's bundled version" % ClientConfigurator.http_port() + _version_restart_btn.pressed.connect(_on_restart_stale_server) + _version_restart_btn.visible = false + server_row.add_child(_version_restart_btn) + _setup_container.add_child(server_row) + _last_rendered_server_text = "" + _refresh_server_version_label() + else: + _setup_container.add_child(_make_status_row("uv", "not found", Color.RED)) + var install_btn := Button.new() + install_btn.text = "How to install uv" + install_btn.tooltip_text = ( + "Opens the official uv installation docs. Godot AI deliberately does " + + "not run the installer for you — see _on_install_uv." + ) + install_btn.pressed.connect(_on_install_uv) + _setup_container.add_child(install_btn) + + +func _install_mode_text() -> String: + if ClientConfigurator.is_dev_checkout(): + return "Install: dev checkout — update via git pull" + return "Install: v%s" % ClientConfigurator.get_plugin_version() + + +func _install_mode_tooltip() -> String: + if not ClientConfigurator.is_dev_checkout(): + return "Plugin installed from a release ZIP, Asset Library, or source copy. Update button in this dock downloads the latest GitHub release." + var target := _resolve_plugin_symlink_target() + if target.is_empty(): + return "Plugin source tree resolved via local .venv — press Reload Plugin after editing." + return "Plugin source: %s\nPress Reload Plugin after editing." % target + + +func _resolve_plugin_symlink_target() -> String: + var logical := ProjectSettings.globalize_path("res://addons/godot_ai").rstrip("/").rstrip("\\") + var resolved := ClientConfigurator.resolve_addons_realpath() + if resolved.is_empty() or resolved == logical: + return "" + return resolved + + +static func _compact_uv_version_text(uv_version: String) -> String: + var text := uv_version.strip_edges() + if text.ends_with(")"): + var metadata_start := text.rfind(" (") + if metadata_start >= 0: + return text.substr(0, metadata_start).strip_edges() + return text + + +func _make_status_row( + label_text: String, + value_text: String, + value_color: Color, + tooltip_text: String = "" +) -> HBoxContainer: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + if not tooltip_text.is_empty(): + row.tooltip_text = tooltip_text + + var label := Label.new() + label.text = label_text + label.add_theme_color_override("font_color", COLOR_MUTED) + label.custom_minimum_size.x = 60 + if not tooltip_text.is_empty(): + label.tooltip_text = tooltip_text + row.add_child(label) + + var value := Label.new() + value.text = value_text + value.add_theme_color_override("font_color", value_color) + if not tooltip_text.is_empty(): + value.tooltip_text = tooltip_text + row.add_child(value) + + return row + + +## Pure helper for the primary "Restart Dev Server" button. Always enabled +## (clicking with nothing running just spawns fresh); tooltip adapts to +## whether a kill+respawn or fresh spawn is what'll happen. +static func _dev_primary_btn_state(has_managed: bool, dev_running: bool) -> Dictionary: + var port := ClientConfigurator.http_port() + if has_managed or dev_running: + return { + "text": "Restart Dev Server", + "tooltip": ( + "Kill the server on port %d and start a fresh --reload dev server. " + + "Use this to pick up Python source changes that don't bump the version." + ) % port, + } + return { + "text": "Start Dev Server", + "tooltip": "Spawn a --reload dev server on port %d. Auto-restarts when you edit Python sources." % port, + } + + +## Pure helper for the small "✕" stop button — only enabled when a dev +## server is actually running. Stops without respawning; intentionally +## never targets a managed server (that's the lifecycle's responsibility). +static func _dev_stop_btn_state(dev_running: bool) -> Dictionary: + if dev_running: + return {"enabled": true, "tooltip": "Stop the dev server without spawning a replacement."} + return {"enabled": false, "tooltip": "No --reload dev server to stop."} + + +func _on_dev_primary_pressed() -> void: + if _plugin == null or _server_restart_in_progress: + return + if not _plugin.has_method("force_restart_or_start_dev_server"): + return + if _plugin.has_method("record_dev_server_toggle"): + _plugin.record_dev_server_toggle("start") + _server_restart_in_progress = true + _update_dev_section_buttons() + if not is_inside_tree(): + ## Test path — no scene tree means no timer; run synchronously + ## so suite assertions see the dispatch without `await`. + _plugin.force_restart_or_start_dev_server() + _server_restart_in_progress = false + return + call_deferred("_perform_dev_restart_after_feedback") + + +func _on_dev_stop_pressed() -> void: + if _plugin == null: + return + if _plugin.has_method("stop_dev_server"): + _plugin.stop_dev_server() + if _plugin.has_method("record_dev_server_toggle"): + _plugin.record_dev_server_toggle("stop") + _update_dev_section_buttons.call_deferred() + + +func _perform_dev_restart_after_feedback() -> void: + ## Brief paint cycle so the user sees "Restarting..." before the + ## blocking _wait_for_port_free freezes the editor for up to 5s. + await get_tree().create_timer(0.15).timeout + ## Re-check has_method post-await — a self-update mixed-state window + ## could swap _plugin's script class while we were sleeping, leaving + ## the old reference pointing at a class that no longer carries the + ## new method. Same #168 guard pattern as _update_dev_section_buttons. + if _plugin != null and _plugin.has_method("force_restart_or_start_dev_server"): + _plugin.force_restart_or_start_dev_server() + ## start_dev_server's spawn happens via a 0.5s SceneTree timer; give + ## it time to land plus a buffer for the WS reconnect before clearing + ## the busy state. The unconditional clear matches sibling restart + ## buttons — overshoot is fine because subsequent _update_status calls + ## refresh the button against live plugin state. + await get_tree().create_timer(2.0).timeout + _server_restart_in_progress = false + _update_dev_section_buttons() + + +## Single-scan refresh of every dev-section button state. Both buttons +## key off the same `has_managed_server` / `is_dev_server_running` pair, +## and the latter scrapes lsof/ps — so doing the discovery once and +## applying to both avoids the duplicate subprocess fork on every +## connection-state transition. +func _update_dev_section_buttons() -> void: + if _plugin == null: + return + if not (_plugin.has_method("has_managed_server") and _plugin.has_method("is_dev_server_running")): + return + var has_managed: bool = _plugin.has_managed_server() + var dev_running: bool = _plugin.is_dev_server_running() + if _dev_primary_btn != null: + if _server_restart_in_progress: + _dev_primary_btn.disabled = true + _dev_primary_btn.text = "Restarting..." + _dev_primary_btn.tooltip_text = "Killing the current server and respawning..." + else: + var primary_state := _dev_primary_btn_state(has_managed, dev_running) + _dev_primary_btn.disabled = false + _dev_primary_btn.text = primary_state["text"] + _dev_primary_btn.tooltip_text = primary_state["tooltip"] + if _dev_stop_btn != null: + var stop_state := _dev_stop_btn_state(dev_running) + _dev_stop_btn.disabled = (not stop_state["enabled"]) or _server_restart_in_progress + _dev_stop_btn.tooltip_text = stop_state["tooltip"] + + +func _client_status_refresh_has_completed() -> bool: + return _last_client_status_refresh_completed_msec > 0 + + +func _connected_status_text() -> String: + return "Server connected" + + +static func _transport_status_text(snapshot: Dictionary) -> String: + ## Total over the transport enum for isolated consumers/tests. The dock's + ## connected fast path renders `_connected_status_text()` before calling it. + var phase := str(snapshot.get("phase", "")) + var attempt := maxi(1, int(snapshot.get("attempt", 0))) + match phase: + "connected": + return "Server connected" + "connecting": + return "Connecting — attempt %d" % attempt + "retrying": + var retry_in_sec := ceili(maxf(0.0, float(snapshot.get("retry_in_sec", 0.0)))) + return "Retrying in %ds — attempt %d" % [retry_in_sec, attempt] + "closing": + return "Disconnecting…" + "blocked": + return "Connection blocked" + return "Disconnected" + + +## Open uv's official install documentation rather than executing an +## installer on the user's behalf. +## +## This used to shell out to `curl -LsSf https://astral.sh/uv/install.sh | sh` +## (and the PowerShell `irm … | iex` equivalent). That is arbitrary remote +## code execution as the editor user, one dock click deep, with no version +## pin, no checksum, and no signature — while this same plugin verifies its +## OWN updates with an RSA-4096 signature over a SHA-256 sidecar, pinned to a +## GitHub host and this repo's release-asset path. Holding a third-party +## installer to a weaker standard than our own payload is the wrong trade, +## and pinning a digest here would only cover the bootstrap script, not the +## uv binary it goes on to fetch. +## +## Opening the docs keeps the discovery value of the button (the user still +## learns uv is missing and how to get it) while leaving the decision to +## install — and the choice of install method — with the user. Mirrors the +## dock's existing "Run this manually" fallback for client CLIs. +func _on_install_uv() -> void: + OS.shell_open(UV_INSTALL_DOCS_URL) + ## Drop the cached uvx path AND the cached `uvx --version` so that once + ## the user has installed uv (in a terminal, from the docs we just + ## opened), the dock finds the new binary instead of replaying the + ## cached "not found" result for the rest of the session. + ## Routing through the configurator matters on Windows, where the + ## CLI-finder cache key is `uvx.exe` — invalidating just `"uvx"` + ## would leave the cache stale and the dock would keep showing + ## "uv: not found" for the rest of the session. + ClientConfigurator.invalidate_uv_detection() + ## Deliberately do NOT refresh here. `OS.shell_open` returns as soon as + ## the browser is handed the URL, so an immediate refresh would run long + ## before the user could install anything and would simply re-cache + ## "not found" — undoing the invalidation above. (The old shell-out was + ## a blocking `OS.execute`, so refreshing straight after it was correct + ## then; it stopped being correct when the installer call went away.) + ## Re-probe when the editor regains focus instead — see _notification. + _uv_recheck_pending = true + + +# --- Client section --- + +func _on_configure_client(client_id: String) -> void: + if _server_blocks_client_health(): + _apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message()) + _refresh_clients_summary() + return + _dispatch_client_action(client_id, "configure") + + +func _on_remove_client(client_id: String) -> void: + _dispatch_client_action(client_id, "remove") + + +## Spawn a worker thread for Configure / Remove so a hung CLI can't lock +## the editor (issue #239). The action verbs are: "configure" → calls +## `ClientConfigurator.configure`; "remove" → calls +## `ClientConfigurator.remove`. Both routes shell out to the per-client +## CLI via `McpCliExec.run`, which is wall-clock-bounded. +## +## Per-row in-flight rules: +## - One worker at a time per client (the row's slot). +## - Both buttons disabled while the slot is busy — prevents a +## double-click queueing a stale Configure on top of a still-running +## Remove. +## - The dot turns amber and the row label gets a "Configuring…" / +## "Removing…" suffix so the user can see the click was registered. +func _dispatch_client_action(client_id: String, action: String) -> void: + if _is_self_update_in_progress(): + ## Same gate as the refresh worker — the install window overwrites + ## plugin scripts on disk, and a worker mid-call into them would + ## SIGABRT in `GDScriptFunction::call`. See `_update_manager`. + return + if _client_action_threads.has(client_id): + return + ## An abandoned-but-still-running worker owns this row's config file just + ## as much as a tracked one does. Defensive: `_abandon_client_action_thread` + ## already leaves the buttons disabled in that state. + if _has_live_orphan(client_id): + return + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + + ## Drop any phase left behind by a previous action on this row — a + ## generation-mismatch or shutdown return can skip the normal finalize, + ## and a stale phase would suppress the label for this new action. + _clear_client_action_phase(client_id) + _set_client_action_cancel_requested(client_id, false) + _set_row_action_in_flight(client_id, action) + ## Snapshot `server_url` on main: `http_url()` reads + ## `EditorInterface.get_editor_settings()`, which is main-thread-only. + ## The status-refresh worker uses the same pattern — see + ## `_perform_initial_client_status_refresh` and + ## `_request_client_status_refresh`. + var launch_context := ClientConfigurator.capture_launch_context() + var server_url := ClientConfigurator.server_url_from(launch_context) + ## #691: refresh the env snapshot on main before this worker starts — + ## configure/remove resolve CLI + config paths off-thread and must not + ## race a concurrent spawn window's setenv/unsetenv. + ClientConfigurator.warm_env_snapshot() + var generation := int(_client_action_generations.get(client_id, 0)) + 1 + _client_action_generations[client_id] = generation + var thread := Thread.new() + _client_action_threads[client_id] = thread + _client_action_started_msec[client_id] = Time.get_ticks_msec() + _client_action_names[client_id] = action + var err := thread.start( + Callable(self, "_run_client_action_worker").bind( + client_id, action, server_url, launch_context, generation + ) + ) + if err != OK: + _client_action_threads.erase(client_id) + _client_action_started_msec.erase(client_id) + _client_action_names.erase(client_id) + _finalize_action_buttons(client_id) + _apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread") + _refresh_clients_summary() + + +func _run_client_action_worker( + client_id: String, + action: String, + server_url: String, + launch_context: Dictionary, + generation: int, +) -> Dictionary: + var result: Dictionary + var prewarm: Dictionary = {} + if action == "remove": + result = ClientConfigurator.remove(client_id, server_url, launch_context) + else: + result = ClientConfigurator.configure(client_id, server_url, launch_context) + ## #851: the entry we just wrote pins an exact `godot-ai==X`. If uv has + ## never built that environment, the FIRST client spawn builds it — + ## ~67 packages — which is what flashes a terminal window on Windows + ## and what can push a bridge spawn past the MCP client's default 30s + ## connect timeout, so the tools appear to vanish. Pay that cost here, + ## on a deliberate click the dock can label, instead of on the client's + ## critical path. + ## + ## Best-effort: the config file is already written and correct. A + ## failed or timed-out warm only means the next launch pays the cold + ## cost it always used to, so `result` is deliberately left untouched. + if result.get("status") == "ok": + _set_client_action_phase(client_id, _PHASE_PREWARM) + prewarm = ClientConfigurator.prewarm_attach_launch( + launch_context, + ClientConfigurator.PREWARM_TIMEOUT_MS, + {}, + Callable(self, "_is_client_action_cancel_requested").bind(client_id), + ) + return { + "client_id": client_id, + "action": action, + "result": result, + "prewarm": prewarm, + "generation": generation, + } + + +func _poll_completed_client_action_threads() -> void: + for client_id in _client_action_threads.keys(): + var thread: Thread = _client_action_threads[client_id] + if thread == null: + continue + if thread.is_alive(): + _apply_client_action_phase(String(client_id)) + continue + var payload: Variant = thread.wait_to_finish() + _client_action_threads[client_id] = null + if payload is Dictionary: + var data := payload as Dictionary + var result: Dictionary = data.get("result", {}) + _report_prewarm_outcome( + String(data.get("client_id", client_id)), data.get("prewarm", {}) + ) + _apply_client_action_result( + String(data.get("client_id", client_id)), + String(data.get("action", _client_action_names.get(client_id, "configure"))), + result, + int(data.get("generation", _client_action_generations.get(client_id, 0))) + ) + else: + _apply_client_action_result( + String(client_id), + String(_client_action_names.get(client_id, "configure")), + {"status": "error", "message": "worker returned no result"}, + int(_client_action_generations.get(client_id, 0)) + ) + + +func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void: + if int(_client_action_generations.get(client_id, 0)) != generation: + if _client_action_threads.get(client_id, null) == null: + _client_action_threads.erase(client_id) + _client_action_started_msec.erase(client_id) + _client_action_names.erase(client_id) + return + if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: + return + if _client_action_threads.has(client_id): + var t: Thread = _client_action_threads[client_id] + if t != null: + t.wait_to_finish() + _client_action_threads.erase(client_id) + _client_action_started_msec.erase(client_id) + _client_action_names.erase(client_id) + _clear_client_action_phase(client_id) + _finalize_action_buttons(client_id) + if _server_blocks_client_health(): + _apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message()) + _refresh_clients_summary() + return + + var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED + if result.get("status") == "ok": + ## #877: Remove targets only the selected scope, so a configure is the + ## only action with an all-scope sweep to disclose. The manual panel + ## that lists those removes is shown on the failure path below, which + ## left the success path — where the sweep actually ran — silent. + var sweep_note := ( + ClientConfigurator.configure_sweep_note(client_id) if action == "configure" else "" + ) + _apply_row_status(client_id, success_status, sweep_note) + var row: Dictionary = _client_rows.get(client_id, {}) + if not row.is_empty(): + (row["manual_panel"] as VBoxContainer).visible = false + else: + _apply_row_status(client_id, Client.Status.ERROR, str(result.get("message", "failed"))) + if action == "configure": + _show_manual_command_for(client_id) + _refresh_clients_summary() + + +## Phase label for the Configure worker's pre-warm step. The config write +## itself is fast; building the pinned uv environment is the part that can +## run for tens of seconds, so it gets its own label rather than sitting +## under a motionless "Configuring…". +const _PHASE_PREWARM := "prewarm" + +## Worker-written, main-read. `Dictionary` writes are not atomic across +## threads, so both sides take the mutex — the same discipline `CliFinder` +## uses for its cache. Held only across the dictionary access, never across +## the subprocess, so the main thread can never block on uv. +var _client_action_phase_mutex := Mutex.new() +var _client_action_phases: Dictionary = {} +## Rows whose button text already reflects their phase, so the per-frame poll +## rewrites the label once instead of on every frame. +var _client_action_phase_shown: Dictionary = {} + + +## Thread-safe cancellation state read by McpCliExec's 50ms poll loop. +func _set_client_action_cancel_requested(client_id: String, requested: bool) -> void: + _client_action_cancel_mutex.lock() + if requested: + _client_action_cancelled_clients[client_id] = true + else: + _client_action_cancelled_clients.erase(client_id) + _client_action_cancel_mutex.unlock() + + +func _is_client_action_cancel_requested(client_id: String) -> bool: + _client_action_cancel_mutex.lock() + var requested := bool(_client_action_cancelled_clients.get(client_id, false)) + _client_action_cancel_mutex.unlock() + return requested + + +func _set_client_action_phase(client_id: String, phase: String) -> void: + _client_action_phase_mutex.lock() + _client_action_phases[client_id] = phase + _client_action_phase_mutex.unlock() + + +func _read_client_action_phase(client_id: String) -> String: + _client_action_phase_mutex.lock() + var phase := String(_client_action_phases.get(client_id, "")) + _client_action_phase_mutex.unlock() + return phase + + +func _clear_client_action_phase(client_id: String) -> void: + _client_action_phase_mutex.lock() + _client_action_phases.erase(client_id) + _client_action_phase_mutex.unlock() + _client_action_phase_shown.erase(client_id) + + +## Per-frame, for a still-running worker: promote the button label when the +## worker reports it has moved on to warming the package environment. Keeps +## the dock honest about why Configure is taking a while — otherwise a cold +## uv build looks like a hang. +func _apply_client_action_phase(client_id: String) -> void: + if _read_client_action_phase(client_id) != _PHASE_PREWARM: + return + if _client_action_phase_shown.get(client_id, "") == _PHASE_PREWARM: + return + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + _client_action_phase_shown[client_id] = _PHASE_PREWARM + (row["configure_btn"] as Button).text = "Installing…" + + +## One line per Configure so a cold build is attributable after the fact — +## the dock label is transient, and #851's symptom (a terminal window on +## Windows) is easiest to correlate against a timestamped log entry. Silent +## on the skip paths: a dev-venv/system tier having no env to warm is the +## normal case, not news. +func _report_prewarm_outcome(client_id: String, prewarm: Variant) -> void: + if not (prewarm is Dictionary): + return + var data := prewarm as Dictionary + if data.is_empty() or bool(data.get("skipped", false)): + return + if bool(data.get("timed_out", false)): + print( + "MCP | %s: package pre-warm timed out; the first client launch will build the environment" + % client_id + ) + return + if int(data.get("exit_code", -1)) != 0: + print( + "MCP | %s: package pre-warm failed (exit %d); the first client launch will build the environment" + % [client_id, int(data.get("exit_code", -1))] + ) + return + print("MCP | %s: package environment pre-warmed" % client_id) + + +## In-flight visual: rewrite the verb onto the button the user just +## clicked ("Configuring…" / "Removing…") so the feedback lands where +## their attention already is. Don't pollute the row label — that'd +## clobber any drift hint ("URL out of date") still relevant to the row. +## The dot turns amber so the row reads as "busy" at a glance, not as +## green (premature success) or red (premature failure). Both buttons +## go disabled so a double-click or second action can't queue stale +## work behind the in-flight worker. +func _set_row_action_in_flight(client_id: String, action: String) -> void: + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + var configure_btn: Button = row["configure_btn"] + var remove_btn: Button = row["remove_btn"] + configure_btn.disabled = true + remove_btn.disabled = true + if action == "remove": + remove_btn.text = "Removing…" + else: + configure_btn.text = "Configuring…" + (row["dot"] as ColorRect).color = COLOR_AMBER + + +## Re-enable both buttons and reset their text back to canonical labels. +## `_apply_row_status` sets `configure_btn.text` per the resulting +## Status (Configure / Reconfigure / Retry), so we only need to reset +## `remove_btn.text` here — its sibling visibility toggle already +## handles whether to show it at all. +func _finalize_action_buttons(client_id: String) -> void: + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + (row["configure_btn"] as Button).disabled = false + var remove_btn: Button = row["remove_btn"] + remove_btn.disabled = false + remove_btn.text = "Remove" + + +func _on_refresh_clients_pressed() -> void: + ## Explicit user action — also give a failed uv probe another chance + ## (#739), mirroring how the same click already re-sweeps client CLIs. + _schedule_uv_reprobe() + _request_client_status_refresh(true) + + +func _on_configure_all_clients() -> void: + if _server_blocks_client_health(): + for client_id in _client_rows: + _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) + _refresh_clients_summary() + return + if ClientRefreshStateScript.should_disable_client_actions(_refresh_state): + return + for client_id in _client_rows: + var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED) + if status == Client.Status.CONFIGURED: + continue + _on_configure_client(String(client_id)) + _refresh_clients_summary() + + +func _on_open_clients_window() -> void: + if _clients_window == null: + return + ## Re-sweep before the user has time to act on stale dot colors. The request + ## is async/stale-while-refreshing so the popup paints immediately with + ## last-known state; the fresh colors land when the background worker returns. + ## This is an explicit user action, so it bypasses the focus-in cooldown. + _request_client_status_refresh(true) + ## Also re-sync the Tools tab from the persisted setting — another + ## editor instance (or a hand-edit of editor_settings-4.tres) may have + ## changed the excluded list while the window was closed. + _reset_tools_pending_from_setting() + _refresh_tools_ui_state() + if vision_routing != null: + vision_routing.refresh_ui() + # popup_centered() with a minsize forces the window to that size and + # centers on the parent viewport. Setting .size on a hidden Window + # doesn't always take effect, so we force it at popup time here. + _clients_window.popup_centered(Vector2i(640, 600)) + + +func _settings_are_dirty() -> bool: + return ( + _tools_pending_excluded != _tools_saved_excluded + or _telemetry_pending_enabled != _telemetry_saved_enabled + or _allow_hosts_is_dirty() + ) + + +func _on_clients_window_close_requested() -> void: + if _clients_window == null: + return + ## If the user has unapplied settings, a close would silently throw the + ## pending state away. Prompt before discarding current options and if + ## they confirm, reset pending → saved so the window shows the persisted + ## state the next time they open it. + if _settings_are_dirty(): + _show_tools_close_confirm() + return + _clients_window.hide() + + +# --- Tools tab (domain exclusion) --- + +func _build_tools_tab(tabs: TabContainer) -> void: + ## Tab 2 — domain-exclusion checkboxes. Rendered once, on dock construction. + ## `_reset_tools_pending_from_setting()` re-syncs checkbox state from the + ## saved setting each time the window opens. + var tools_tab := VBoxContainer.new() + tools_tab.add_theme_constant_override("separation", 8) + var tools_margin := _build_margin_container() + tools_margin.name = "Tools" + tools_margin.add_child(tools_tab) + tabs.add_child(tools_margin) + + var intro := Label.new() + intro.text = ( + "Some MCP clients cap tools per connection (Antigravity: 100). " + + "Uncheck a domain to drop its non-core tools from this server. " + + "Core tools stay on. Changes require a server restart." + ) + intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + intro.add_theme_color_override("font_color", COLOR_MUTED) + intro.size_flags_horizontal = Control.SIZE_EXPAND_FILL + tools_tab.add_child(intro) + + var count_row := HBoxContainer.new() + count_row.add_theme_constant_override("separation", 8) + var count_header := Label.new() + count_header.text = "Tools Enabled:" + count_header.add_theme_color_override("font_color", COLOR_MUTED) + count_row.add_child(count_header) + _tools_count_label = Label.new() + _tools_count_label.add_theme_font_size_override("font_size", 15) + count_row.add_child(_tools_count_label) + _tools_dirty_warning = Label.new() + _tools_dirty_warning.add_theme_color_override("font_color", COLOR_AMBER) + _tools_dirty_warning.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _tools_dirty_warning.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _tools_dirty_warning.visible = false + _tools_dirty_warning.text = "Unapplied changes" + count_row.add_child(_tools_dirty_warning) + tools_tab.add_child(count_row) + + tools_tab.add_child(HSeparator.new()) + + var scroll := ScrollContainer.new() + scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + tools_tab.add_child(scroll) + + var grid := VBoxContainer.new() + grid.add_theme_constant_override("separation", 4) + grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.add_child(grid) + + ## Core pseudo-row — disabled checkbox, always checked. Shows the 5 + ## always-loaded tools as a single line item so the user can see where + ## their baseline tool budget goes without listing individual core names + ## inline (tooltip has them). + var core_row := HBoxContainer.new() + core_row.add_theme_constant_override("separation", 8) + var core_chk := CheckBox.new() + core_chk.button_pressed = true + core_chk.disabled = true + core_chk.focus_mode = Control.FOCUS_NONE + core_row.add_child(core_chk) + var core_label := Label.new() + core_label.text = "Core (always on)" + core_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + core_row.add_child(core_label) + var core_count := Label.new() + core_count.text = "%d tools" % (ToolCatalog.CORE_TOOLS.size() + ToolCatalog.ALWAYS_ON_TOOLS.size()) + core_count.add_theme_color_override("font_color", COLOR_MUTED) + core_row.add_child(core_count) + core_row.tooltip_text = "%s · always on: %s" % [ + ", ".join(ToolCatalog.CORE_TOOLS), + ", ".join(ToolCatalog.ALWAYS_ON_TOOLS), + ] + grid.add_child(core_row) + + grid.add_child(HSeparator.new()) + + _tools_domain_checkboxes.clear() + for entry in ToolCatalog.DOMAINS: + _build_tools_domain_row(grid, entry) + + ## Custom (addon-registered) tools — unlike the domain rows above, + ## toggles apply LIVE: the registry re-pushes the filtered catalog to + ## the server on every change, no restart needed. + grid.add_child(HSeparator.new()) + var custom_header_row := HBoxContainer.new() + custom_header_row.add_theme_constant_override("separation", 8) + var custom_header := Label.new() + custom_header.text = "Custom tools (addon-registered)" + custom_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL + custom_header_row.add_child(custom_header) + _custom_tools_count_label = Label.new() + _custom_tools_count_label.add_theme_color_override("font_color", COLOR_MUTED) + custom_header_row.add_child(_custom_tools_count_label) + grid.add_child(custom_header_row) + var custom_hint := Label.new() + custom_hint.text = "Applies immediately — disabled tools are hidden from agents and rejected if called." + custom_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + custom_hint.add_theme_color_override("font_color", COLOR_MUTED) + custom_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL + grid.add_child(custom_hint) + _custom_tools_list = VBoxContainer.new() + _custom_tools_list.add_theme_constant_override("separation", 4) + _custom_tools_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + grid.add_child(_custom_tools_list) + _connect_custom_tool_registry() + _refresh_custom_tools_rows() + + tools_tab.add_child(HSeparator.new()) + + var telemetry_row := HBoxContainer.new() + telemetry_row.add_theme_constant_override("separation", 8) + var telemetry_label := Label.new() + telemetry_label.text = "Telemetry" + telemetry_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + telemetry_row.add_child(telemetry_label) + _telemetry_toggle = CheckButton.new() + _telemetry_toggle.toggled.connect(_on_telemetry_toggled) + telemetry_row.add_child(_telemetry_toggle) + tools_tab.add_child(telemetry_row) + + tools_tab.add_child(HSeparator.new()) + + var footer := HBoxContainer.new() + footer.add_theme_constant_override("separation", 8) + + _tools_apply_btn = Button.new() + _tools_apply_btn.text = "Apply and Restart Server" + _tools_apply_btn.tooltip_text = "Save the excluded list to Editor Settings and reload the plugin so the server respawns with --exclude-domains." + _tools_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _tools_apply_btn.pressed.connect(_on_tools_apply) + footer.add_child(_tools_apply_btn) + + _tools_reset_btn = Button.new() + _tools_reset_btn.text = "Reset to defaults" + _tools_reset_btn.tooltip_text = "Re-enable every domain (no --exclude-domains flag). Still needs Apply." + _tools_reset_btn.pressed.connect(_on_tools_reset) + footer.add_child(_tools_reset_btn) + + tools_tab.add_child(footer) + + _tools_close_confirm = ConfirmationDialog.new() + _tools_close_confirm.title = "Discard unapplied changes?" + ## Generic wording: _settings_are_dirty() covers domain toggles, the + ## telemetry switch, AND the Settings tab's allow-host field (#507) — + ## the old "checked/unchecked domains" text misled non-domain edits. + _tools_close_confirm.dialog_text = ( + "You have unapplied changes in this window.\n" + + "Close it and discard those changes?" + ) + _tools_close_confirm.ok_button_text = "Discard" + _tools_close_confirm.confirmed.connect(_on_tools_discard_confirmed) + add_child(_tools_close_confirm) + + _reset_tools_pending_from_setting() + _refresh_tools_ui_state() + + +## --- Custom (addon-registered) tools section --- + +func _connect_custom_tool_registry() -> void: + var registry := McpToolRegistry.get_instance() + if registry == null: + return + if not registry.tools_changed.is_connected(_on_custom_tool_registry_changed): + registry.tools_changed.connect(_on_custom_tool_registry_changed) + + +func _on_custom_tool_registry_changed() -> void: + ## Deferred: tools_changed can fire from inside a checkbox toggle in + ## this very list — rebuilding synchronously would free the emitting + ## control mid-signal. + _refresh_custom_tools_rows.call_deferred() + + +func _refresh_custom_tools_rows() -> void: + if _custom_tools_list == null or not is_instance_valid(_custom_tools_list): + return + for child in _custom_tools_list.get_children(): + child.queue_free() + var registry := McpToolRegistry.get_instance() + var specs: Array = [] if registry == null else registry.all() + specs.sort_custom(func(a, b): return String(a.name) < String(b.name)) + var enabled_count := 0 + for spec in specs: + if registry.is_tool_enabled(spec.name): + enabled_count += 1 + _custom_tools_list.add_child(_build_custom_tool_row(registry, spec)) + if _custom_tools_count_label != null and is_instance_valid(_custom_tools_count_label): + _custom_tools_count_label.text = "%d/%d enabled" % [enabled_count, specs.size()] + if specs.is_empty(): + var empty := Label.new() + empty.text = "None registered. Addons add tools via McpToolRegistry — see docs/plugin-architecture.md." + empty.add_theme_color_override("font_color", COLOR_MUTED) + empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + empty.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _custom_tools_list.add_child(empty) + + +func _build_custom_tool_row(registry: McpToolRegistry, spec: McpCustomToolSpec) -> HBoxContainer: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + var chk := CheckBox.new() + chk.button_pressed = registry.is_tool_enabled(spec.name) + chk.toggled.connect(func(pressed: bool): registry.set_tool_enabled(spec.name, pressed)) + row.add_child(chk) + var name_label := Label.new() + name_label.text = spec.name + (" · promoted" if spec.promoted else "") + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(name_label) + var source_label := Label.new() + source_label.text = spec.source if not spec.source.is_empty() else spec.source_path.get_base_dir().get_file() + source_label.add_theme_color_override("font_color", COLOR_MUTED) + row.add_child(source_label) + ## Tooltip = the agent-facing description plus provenance, so the user + ## can judge what they're enabling without leaving the dock. + row.tooltip_text = "%s\n\nsource: %s%s" % [ + spec.description, + spec.source_path, + "\npromoted: registers as first-class MCP tool custom_%s" % spec.name if spec.promoted else "", + ] + name_label.tooltip_text = row.tooltip_text + return row + + +func _build_tools_domain_row(parent: VBoxContainer, entry: Dictionary) -> void: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + + var chk := CheckBox.new() + chk.button_pressed = true # default; `_reset_tools_pending_from_setting` corrects + chk.toggled.connect(_on_tools_domain_toggled.bind(String(entry["id"]))) + row.add_child(chk) + + var name_label := Label.new() + name_label.text = String(entry["label"]) + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(name_label) + + var count_label := Label.new() + count_label.text = "%d tools" % int(entry["count"]) + count_label.add_theme_color_override("font_color", COLOR_MUTED) + row.add_child(count_label) + + ## Hover tooltip = flat list of tool names in this domain. Lets the + ## user decide without leaving the dock (e.g. "I just want to drop + ## `animation_preset_*` — do I lose anything else?"). + var tools_list: Array = entry.get("tools", []) + row.tooltip_text = ", ".join(tools_list) + name_label.tooltip_text = row.tooltip_text + count_label.tooltip_text = row.tooltip_text + + parent.add_child(row) + _tools_domain_checkboxes[String(entry["id"])] = chk + + +func _reset_tools_pending_from_setting() -> void: + ## Read the saved setting → pending/saved arrays, then sync checkbox state. + ## Unknown domain names in the setting (e.g. from an older plugin + ## version) are dropped from the display here (only ids with a checkbox + ## survive). The startup path is protected separately: + ## `ClientConfigurator.excluded_domains()` filters unknown names before + ## they reach `--exclude-domains`, whose `parse_exclude_list` hard-fails + ## on them. + var saved_raw := ClientConfigurator.excluded_domains() + var saved := PackedStringArray() + if not saved_raw.is_empty(): + for part in saved_raw.split(","): + var t := part.strip_edges() + if t.is_empty(): + continue + if _tools_domain_checkboxes.has(t) and saved.find(t) == -1: + saved.append(t) + saved.sort() + _tools_saved_excluded = saved + _tools_pending_excluded = saved.duplicate() + for id in _tools_domain_checkboxes: + var chk: CheckBox = _tools_domain_checkboxes[id] + ## `set_pressed_no_signal` — mutating programmatically should not + ## fire the toggled handler, which would mutate pending back. + chk.set_pressed_no_signal(_tools_pending_excluded.find(id) == -1) + ## Also reset telemetry pending state from the persisted setting. + if _telemetry_toggle != null: + _load_telemetry_setting() + ## And the Settings tab's allow-host field (#507) — same window-open / + ## discard-confirm re-sync contract as the tools checkboxes. + _reset_allow_hosts_from_setting() + + +func _on_tools_domain_toggled(pressed: bool, domain_id: String) -> void: + var idx := _tools_pending_excluded.find(domain_id) + if pressed and idx != -1: + _tools_pending_excluded.remove_at(idx) + elif not pressed and idx == -1: + _tools_pending_excluded.append(domain_id) + _tools_pending_excluded.sort() + _refresh_tools_ui_state() + + +func _refresh_tools_ui_state() -> void: + if _tools_count_label == null: + return + var enabled := ToolCatalog.enabled_tool_count(_tools_pending_excluded) + var total := ToolCatalog.total_tool_count() + _tools_count_label.text = "%d / %d" % [enabled, total] + var dirty := _settings_are_dirty() + _tools_dirty_warning.visible = dirty + _tools_apply_btn.disabled = not dirty + ## Color the count when the user is over Antigravity's cap — a soft + ## signal that their selection still won't fit. 100 is the Antigravity + ## limit; other clients may cap higher, so this is advisory only. + if enabled > 100: + _tools_count_label.add_theme_color_override("font_color", COLOR_AMBER) + else: + _tools_count_label.remove_theme_color_override("font_color") + + +func _on_tools_apply() -> void: + var canonical_excluded := ToolCatalog.canonical(_tools_pending_excluded) + var es := EditorInterface.get_editor_settings() + if es != null: + es.set_setting(McpSettings.SETTING_EXCLUDED_DOMAINS, canonical_excluded) + es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, _telemetry_pending_enabled) + _tools_saved_excluded = _tools_pending_excluded.duplicate() + _telemetry_saved_enabled = _telemetry_pending_enabled + _refresh_tools_ui_state() + ## Plugin reload respawns the server with the new `--exclude-domains` flag + ## (see `plugin.gd::_build_server_flags`) and telemetry option. Mirrors the + ## port-change Apply flow. + _on_reload_plugin() + + +func _on_tools_reset() -> void: + ## Resets only the tool-domain exclusions, not the telemetry toggle. + ## Telemetry is a privacy preference users typically want to set once + ## and have honored — flipping it back to "on" via a generic Reset + ## button would be a surprising privacy regression. The button label + ## is scoped to tools accordingly. + _tools_pending_excluded = PackedStringArray() + for id in _tools_domain_checkboxes: + var chk: CheckBox = _tools_domain_checkboxes[id] + chk.set_pressed_no_signal(true) + _refresh_tools_ui_state() + + +func _show_tools_close_confirm() -> void: + if _tools_close_confirm == null: + return + _tools_close_confirm.popup_centered() + + +func _on_tools_discard_confirmed() -> void: + _reset_tools_pending_from_setting() + _refresh_tools_ui_state() + if _clients_window != null: + _clients_window.hide() + + +# --- Settings tab (allow-host LAN opt-in, #507) --- + +func _build_settings_tab(tabs: TabContainer) -> void: + ## Tab 3 — settings-style controls that don't fit Clients or Tools: the + ## Vision Routing section plus the `--allow-host` LAN opt-in behind a + ## collapsed "Remote access (advanced)" disclosure, so its security + ## warning renders exactly at the point of configuration. Rendered once + ## on dock construction, mirroring `_build_tools_tab`; + ## `_reset_allow_hosts_from_setting()` and `vision_routing.refresh_ui()` + ## re-sync each time the window opens (via + ## `_reset_tools_pending_from_setting` / `_on_open_clients_window`). + var settings_tab := VBoxContainer.new() + settings_tab.add_theme_constant_override("separation", 8) + var settings_margin := _build_margin_container() + settings_margin.name = "Settings" + settings_margin.add_child(settings_tab) + tabs.add_child(settings_margin) + + ## Vision Routing is configuration, not status — it lives here rather + ## than in the dock. Not dev-gated: it is the Settings tab's primary + ## content and must work for every user. + if vision_routing != null: + vision_routing.build_section(settings_tab) + + ## Remote access (advanced): collapsed by default; auto-expands when a + ## non-empty CIDR allowlist is already configured so an active + ## off-loopback bind is never hidden behind a collapsed header. The + ## disclosure replaces the former developer-mode gate for this block. + _allow_hosts_fold = FoldableContainer.new() + _allow_hosts_fold.title = "Remote access (advanced)" + _allow_hosts_fold.folded = true + settings_tab.add_child(_allow_hosts_fold) + + _allow_hosts_section = VBoxContainer.new() + _allow_hosts_section.add_theme_constant_override("separation", 6) + _allow_hosts_fold.add_child(_allow_hosts_section) + + _allow_hosts_section.add_child(_make_header("Allow remote hosts (CIDR)")) + + var intro := Label.new() + intro.text = ( + "Comma-separated CIDRs or bare IPs (e.g. 192.168.1.0/24, 10.0.0.5). " + + "When non-empty, the server binds off loopback and accepts MCP " + + "connections from these ranges (--allow-host)." + ) + intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + intro.add_theme_color_override("font_color", COLOR_MUTED) + intro.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _allow_hosts_section.add_child(intro) + + ## Warning banner — the DNS-rebinding guard is widened to every machine + ## in the named ranges, so make the user name a network they trust + ## instead of offering a blanket "expose everything" toggle (#507). + var warning := Label.new() + warning.text = ( + "Warning: every machine in these ranges can drive this Godot editor, " + + "and the DNS-rebinding guard's Host allowlist is widened to match. " + + "Only name networks you trust. On untrusted or shared networks, " + + "prefer an SSH tunnel or Tailscale instead of exposing the port." + ) + warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + warning.add_theme_color_override("font_color", COLOR_AMBER) + warning.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _allow_hosts_section.add_child(warning) + + _allow_hosts_edit = LineEdit.new() + _allow_hosts_edit.placeholder_text = "e.g. 192.168.1.0/24, 10.0.0.5 — empty = loopback only" + _allow_hosts_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _allow_hosts_edit.text_changed.connect(_on_allow_hosts_text_changed) + _allow_hosts_section.add_child(_allow_hosts_edit) + + _allow_hosts_hint = Label.new() + _allow_hosts_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _allow_hosts_hint.add_theme_color_override("font_color", Color.RED) + _allow_hosts_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _allow_hosts_hint.visible = false + _allow_hosts_section.add_child(_allow_hosts_hint) + + _allow_hosts_apply_btn = Button.new() + _allow_hosts_apply_btn.text = "Apply and Restart Server" + _allow_hosts_apply_btn.tooltip_text = ( + "Save the allowlist to Editor Settings and reload the plugin so the " + + "server respawns with --allow-host. Clear the field and Apply to " + + "return to loopback-only." + ) + _allow_hosts_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _allow_hosts_apply_btn.pressed.connect(_on_allow_hosts_apply) + _allow_hosts_section.add_child(_allow_hosts_apply_btn) + + _reset_allow_hosts_from_setting() + + +func _reset_allow_hosts_from_setting() -> void: + _allow_hosts_saved = ClientConfigurator.allow_hosts() + _refresh_allow_hosts_fold_state() + if _allow_hosts_edit == null: + return + _allow_hosts_edit.text = _allow_hosts_saved + _refresh_allow_hosts_ui_state() + + +## Auto-expands the "Remote access (advanced)" disclosure whenever a +## non-empty allowlist is configured, so an active off-loopback bind is +## never hidden behind a collapsed header. +func _refresh_allow_hosts_fold_state() -> void: + if _allow_hosts_fold == null: + return + if ClientConfigurator.allow_hosts().is_empty(): + _allow_hosts_fold.fold() + else: + _allow_hosts_fold.expand() + + +func _allow_hosts_is_dirty() -> bool: + if _allow_hosts_edit == null: + return false + return McpAllowHosts.normalize(_allow_hosts_edit.text) != _allow_hosts_saved + + +func _on_allow_hosts_text_changed(_new_text: String) -> void: + _refresh_allow_hosts_ui_state() + + +func _refresh_allow_hosts_ui_state() -> void: + if _allow_hosts_edit == null or _allow_hosts_apply_btn == null: + return + var invalid := McpAllowHosts.invalid_tokens(_allow_hosts_edit.text) + if invalid.is_empty(): + _allow_hosts_hint.visible = false + else: + ## Name the accepted syntax in the hint — matches the server's + ## `parse_allow_hosts` (CIDR / bare IP, comma-separated). + _allow_hosts_hint.text = ( + "Invalid entries (must be a CIDR like 192.168.1.0/24 or a bare IP, comma-separated): %s" + % ", ".join(invalid) + ) + _allow_hosts_hint.visible = true + _allow_hosts_apply_btn.disabled = not _allow_hosts_is_dirty() or not invalid.is_empty() + + +func _on_allow_hosts_apply() -> void: + if _allow_hosts_edit == null: + return + var normalized := McpAllowHosts.normalize(_allow_hosts_edit.text) + if not McpAllowHosts.invalid_tokens(normalized).is_empty(): + return + var es := EditorInterface.get_editor_settings() + if es != null: + es.set_setting(McpSettings.SETTING_ALLOW_HOSTS, normalized) + _allow_hosts_saved = normalized + _allow_hosts_edit.text = normalized + _refresh_allow_hosts_ui_state() + ## Plugin reload respawns the server with the new `--allow-host` flag + ## (see `plugin.gd::_build_server_flags`). Mirrors the Tools-tab Apply + ## and port-change flows. + _on_reload_plugin() + + +func _refresh_clients_summary() -> void: + # Count from cached row status values — `_apply_row_status` is the single + # source of truth, and reading cached status avoids re-running + # filesystem/CLI-hitting checks on every refresh. The same cache re-derives + # the drift banner so per-row mutations (Configure/Reconfigure/Remove on a + # row in the Clients & Tools window) keep the dock-level banner in sync + # without an extra sweep. See #166 and #226. + if _clients_summary_label == null: + return + var configured := 0 + var mismatched_ids: Array[String] = [] + for client_id in _client_rows: + var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED) + if status == Client.Status.CONFIGURED: + configured += 1 + elif status == Client.Status.CONFIGURED_MISMATCH: + mismatched_ids.append(client_id) + var text := "%d / %d configured" % [configured, _client_rows.size()] + if mismatched_ids.size() > 0: + text += " (%d stale)" % mismatched_ids.size() + if ClientRefreshStateScript.should_show_checking_badge(_refresh_state): + text += ( + " (checking...)" + if _refresh_state != ClientRefreshStateScript.RUNNING_TIMED_OUT + else " (client probe still running)" + ) + _clients_summary_label.text = text + if _client_configure_all_btn != null: + _client_configure_all_btn.disabled = ClientRefreshStateScript.should_disable_client_actions(_refresh_state) + if _client_empty_cta_btn != null: + _client_empty_cta_btn.visible = configured == 0 and _client_status_refresh_has_completed() + _refresh_drift_banner(mismatched_ids) + _update_status() + + +func _show_manual_command_for(client_id: String) -> void: + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + var cmd := ClientConfigurator.manual_command(client_id) + if cmd.is_empty(): + row["manual_panel"].visible = false + return + row["manual_text"].text = cmd + row["manual_panel"].visible = true + ## #680: for rows low in the list the panel materializes below the + ## visible scroll area and the Configure click looks like a no-op. + ## Deferred so the just-shown panel has a settled rect to scroll to. + _scroll_manual_panel_into_view.call_deferred(row["manual_panel"]) + + +func _scroll_manual_panel_into_view(panel: Control) -> void: + if panel == null or not panel.is_inside_tree(): + return + var ancestor := panel.get_parent() + while ancestor != null and not (ancestor is ScrollContainer): + ancestor = ancestor.get_parent() + if ancestor != null: + (ancestor as ScrollContainer).ensure_control_visible(panel) + + +func _on_copy_manual_command(client_id: String) -> void: + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + DisplayServer.clipboard_set(row["manual_text"].text) + + +func _on_open_config_file(client_id: String) -> void: + var path := _client_config_path_for_row(client_id) + if path.is_empty(): + return + if FileAccess.file_exists(path): + OS.shell_open(path) + return + _reveal_config_folder(path) + + +func _on_reveal_config_folder(client_id: String) -> void: + var path := _client_config_path_for_row(client_id) + if path.is_empty(): + return + _reveal_config_folder(path) + + +func _client_config_path_for_row(client_id: String) -> String: + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return "" + return String(row.get("config_path", "")) + + +func _reveal_config_folder(path: String) -> void: + var dir := path.get_base_dir() + if dir.is_empty(): + return + OS.shell_open(dir) + + +func _refresh_all_client_statuses() -> void: + ## Compatibility wrapper for older explicit call sites. Treat this as a manual + ## refresh: it bypasses focus-in cooldown but still runs probes off the editor + ## main thread. + if _server_blocks_client_health(): + for client_id in _client_rows: + _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) + _refresh_clients_summary() + return + _request_client_status_refresh(true) + + +func _is_client_status_refresh_in_cooldown() -> bool: + if _last_client_status_refresh_completed_msec <= 0: + return false + return Time.get_ticks_msec() - _last_client_status_refresh_completed_msec < CLIENT_STATUS_REFRESH_COOLDOWN_MSEC + + +func _has_client_status_refresh_timed_out() -> bool: + if not ClientRefreshStateScript.has_worker_alive(_refresh_state): + return false + if _client_status_refresh_started_msec <= 0: + return false + return Time.get_ticks_msec() - _client_status_refresh_started_msec >= CLIENT_STATUS_REFRESH_TIMEOUT_MSEC + + +func _check_client_status_refresh_timeout() -> void: + if not _has_client_status_refresh_timed_out(): + return + if _refresh_state == ClientRefreshStateScript.RUNNING_TIMED_OUT: + return + _refresh_state = ClientRefreshStateScript.RUNNING_TIMED_OUT + _refresh_clients_summary() + + +func _abandon_client_status_refresh_thread() -> void: + ## GDScript cannot interrupt a blocking `OS.execute(..., true)` call in a + ## worker. If a CLI probe hangs, orphan this run, bump the generation so any + ## late result becomes a no-op, and let a forced/manual refresh start a fresh + ## probe slot. Completed orphan threads are pruned from `_process`. + _client_status_refresh_generation += 1 + if _client_status_refresh_thread != null: + _orphaned_client_status_refresh_threads.append(_client_status_refresh_thread) + _client_status_refresh_thread = null + if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: + _refresh_state = ClientRefreshStateScript.IDLE + ## Reset the full pending-request triplet, not just the + ## focus-in / cooldown half. A timed-out worker has already + ## warmed bytecode, so any stale `_pending_initial` from an + ## earlier deferred-during-busy startup is no longer load-bearing + ## — leaving it set would cause `_retry_deferred_*` to dispatch + ## `_perform_initial_*` a second time after this abandon + ## (which would then no-op because no fresh worker is needed + ## but still re-warm bytecode and walk the row set redundantly). + _client_status_refresh_pending = false + _client_status_refresh_pending_force = false + _client_status_refresh_pending_initial = false + _client_status_refresh_started_msec = 0 + _refresh_clients_summary() + + +func _prune_orphaned_client_status_refresh_threads() -> void: + for i in range(_orphaned_client_status_refresh_threads.size() - 1, -1, -1): + var thread := _orphaned_client_status_refresh_threads[i] + if thread == null: + _orphaned_client_status_refresh_threads.remove_at(i) + elif not thread.is_alive(): + thread.wait_to_finish() + _orphaned_client_status_refresh_threads.remove_at(i) + + +func _perform_initial_client_status_refresh() -> void: + ## Pre-warm strategy bytecode on main, then hand every client probe + ## (JSON / TOML / CLI alike) to the worker. + ## + ## Godot's GDScript hot-reload of overwritten plugin files is lazy: the + ## bytecode swap happens on first dereference, not at `set_plugin_enabled` + ## time. A worker thread spawned from a fresh `_build_ui` walks into + ## `_json_strategy.*` / `_cli_strategy.*` / `client_configurator.*` while + ## bytecode pages are mid-swap → SIGABRT. Dereferencing those scripts on + ## main first forces the swap to complete here; the worker then finds + ## stable bytecode. Filesystem signals don't bracket the swap window + ## (they fire before bytecode replacement), and FOCUS_IN doesn't fire on + ## in-place plugin reload because the editor stays focused — so neither + ## works as a gate. See #233 / #235. + ## + ## Phase 1 (sync, on main): a single explicit `_warm_strategy_bytecode` + ## call invokes a pure-memory helper on each strategy script — + ## `_json_strategy.gd`, `_toml_strategy.gd`, `_cli_strategy.gd`, plus + ## `client_configurator.gd` via `client_ids()` / `get_by_id`. No disk, + ## no `OS.execute`, no JSON parse on main. `client_status_probe_snapshot` + ## per client adds the `installed` flag and (for CLI clients) a cached + ## CLI path to each probe. + ## + ## Phase 2 (worker): every probe — JSON, TOML, CLI — runs through the + ## same `_run_client_status_refresh_worker` pipeline. Disk reads + JSON + ## parses for the ~17 non-CLI clients now happen off the main thread, + ## so the dock paints immediately on cold open instead of stalling + ## behind ~16 sync `FileAccess.open` + `JSON.parse_string` calls. + ## + ## No-op outside the tree — GDScript tests instantiate via `new()`. + if not is_inside_tree(): + return + if _client_rows.is_empty(): + return + if ClientRefreshStateScript.is_blocked_for_spawn(_refresh_state): + return + if _is_self_update_in_progress(): + return + if _is_editor_filesystem_busy(): + _defer_initial_client_status_refresh_until_filesystem_ready() + return + if ClientRefreshStateScript.has_worker_alive(_refresh_state): + return + + if _server_blocks_client_health(): + for client_id in _client_rows: + _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) + _refresh_clients_summary() + return + + _warm_strategy_bytecode() + + var generation := _begin_client_status_refresh_run() + var launch_context := ClientConfigurator.capture_launch_context() + var server_url := ClientConfigurator.server_url_from(launch_context) + var all_probes: Array[Dictionary] = [] + + for client_id in _client_rows: + var probe := ClientConfigurator.client_status_probe_snapshot(String(client_id)) + if probe.is_empty(): + continue + all_probes.append(probe) + _refresh_clients_summary() + + if all_probes.is_empty(): + _finalize_completed_refresh() + return + + _client_status_refresh_thread = Thread.new() + var err := _client_status_refresh_thread.start( + Callable(self, "_run_client_status_refresh_worker").bind( + all_probes, server_url, launch_context, generation + ) + ) + if err != OK: + _refresh_state = ClientRefreshStateScript.IDLE + _client_status_refresh_thread = null + _refresh_clients_summary() + + +## Force GDScript's lazy bytecode swap to complete for every script the +## worker thread will reach into. Each call is pure-memory — no disk, no +## network, no `OS.execute` — so it only costs the bytecode dereference +## itself. See `_perform_initial_client_status_refresh` for context and +## #233 / #235 for the SIGABRT this exists to prevent. +func _warm_strategy_bytecode() -> void: + var ids := ClientConfigurator.client_ids() + if ids.is_empty(): + return + var any_client := ClientRegistry.get_by_id(String(ids[0])) + if any_client != null: + JsonStrategy.verify_entry(any_client, {}, "") + TomlStrategy.format_body(PackedStringArray(), "") + CliStrategy.format_args(PackedStringArray(), "", "") + ## #691: refresh the env snapshot on main before the worker starts, so + ## its config-path expansions read the snapshot instead of racing a + ## concurrent spawn window's setenv/unsetenv. + ClientConfigurator.warm_env_snapshot() + + +func _begin_client_status_refresh_run() -> int: + ## Marks a refresh as starting and returns the new generation token. + ## Generation is bumped here (not at completion) so that a worker result + ## reaped after `_abandon_client_status_refresh_thread` or `_exit_tree` + ## fires can be detected as stale via generation mismatch. + _refresh_state = ClientRefreshStateScript.RUNNING + _client_status_refresh_pending = false + _client_status_refresh_pending_force = false + _client_status_refresh_started_msec = Time.get_ticks_msec() + _client_status_refresh_generation += 1 + _refresh_clients_summary() + return _client_status_refresh_generation + + +func _finalize_completed_refresh() -> void: + ## Stamps cooldown and clears in-flight state. Called at the end of every + ## refresh that successfully applied results — the worker reaping path + ## and the no-CLI fast path in `_perform_initial_client_status_refresh`. + _last_client_status_refresh_completed_msec = Time.get_ticks_msec() + if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: + _refresh_state = ClientRefreshStateScript.IDLE + _refresh_clients_summary() + ## After the summary pass so `_last_mismatched_ids` reflects the sweep + ## that just completed. + _maybe_auto_repin_after_update() + + +func _request_client_status_refresh(force: bool = false) -> bool: + ## Stale-while-refreshing: do not clear dots, summary, or the drift banner + ## when a refresh is requested. The existing UI remains visible until the + ## background worker's result is applied on the main thread. + if _server_blocks_client_health(): + for client_id in _client_rows: + _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) + _refresh_clients_summary() + return false + if _is_self_update_in_progress(): + ## Self-update is overwriting plugin scripts on disk; spawning a worker + ## now would crash it inside `GDScriptFunction::call` once the bytecode + ## swap reaches a script the worker is mid-call into. Focus-in / + ## manual button / cooldown timer all funnel through here, so one + ## gate covers every spawn path during the install window. The flag + ## lives on `_update_manager` and dies with the dock instance during + ## `set_plugin_enabled(false)`. + return false + if ClientRefreshStateScript.has_worker_alive(_refresh_state): + if force and _has_client_status_refresh_timed_out(): + _abandon_client_status_refresh_thread() + else: + _client_status_refresh_pending = true + _client_status_refresh_pending_force = _client_status_refresh_pending_force or force + _refresh_clients_summary() + return false + if ClientRefreshStateScript.is_blocked_for_spawn(_refresh_state): + return false + if not force and _is_client_status_refresh_in_cooldown(): + return false + if _client_rows.is_empty(): + return false + if _is_editor_filesystem_busy(): + if force: + _defer_client_status_refresh_until_filesystem_ready(force) + return false + + ## Manual refresh (any `force=true` path: button click, popup open, + ## external API caller) implies "may have installed a CLI since the + ## last sweep" — flush CliFinder so freshly-installed binaries get + ## re-detected. Focus-in (`force=false`) stays cached so the cheap + ## case stays cheap. Per-CLI invalidation + ## (`invalidate_uvx_cli_cache`) still pairs with specific events + ## like `_on_install_uv` where the binary name is known. + if force: + ClientConfigurator.invalidate_cli_cache() + + ## Force the bytecode swap on the same scripts the worker will reach + ## into — same #233/#235 guard `_perform_initial_*` already had. + ## Without this, a manual refresh dispatched before the initial sweep + ## has run (e.g. user clicks Refresh during the deferred-initial + ## window after `_defer_client_status_refresh_until_filesystem_ready` + ## cleared `_pending_initial`) walks into mid-swap bytecode and + ## SIGABRTs. + _warm_strategy_bytecode() + + var client_probes: Array[Dictionary] = [] + for client_id in _client_rows: + client_probes.append(ClientConfigurator.client_status_probe_snapshot(String(client_id))) + var launch_context := ClientConfigurator.capture_launch_context() + var server_url := ClientConfigurator.server_url_from(launch_context) + + var generation := _begin_client_status_refresh_run() + _client_status_refresh_thread = Thread.new() + var err := _client_status_refresh_thread.start( + Callable(self, "_run_client_status_refresh_worker").bind( + client_probes, server_url, launch_context, generation + ) + ) + if err != OK: + _refresh_state = ClientRefreshStateScript.IDLE + _client_status_refresh_thread = null + _refresh_clients_summary() + return false + return true + + +func _is_editor_filesystem_busy() -> bool: + var fs := EditorInterface.get_resource_filesystem() + return fs != null and fs.is_scanning() + + +func _defer_initial_client_status_refresh_until_filesystem_ready() -> void: + _refresh_state = ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM + _client_status_refresh_pending_initial = true + + +func _defer_client_status_refresh_until_filesystem_ready(force: bool) -> void: + ## Godot can still be reparsing/reloading plugin scripts while the editor + ## filesystem is busy. Do not spawn a worker into that window: the worker + ## can call plugin GDScript while the main thread is reloading it, which + ## crashes in `GDScriptFunction::call`. + ## + ## A manual refresh request is more recent intent than any earlier + ## deferred-initial sweep, so we clear `_pending_initial` here. + ## `_request_client_status_refresh` warms strategy bytecode itself + ## now (see #233/#235), so the safety net the initial path provided + ## still applies to the replayed manual refresh. + _refresh_state = ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM + _client_status_refresh_pending_force = _client_status_refresh_pending_force or force + _client_status_refresh_pending_initial = false + + +func _retry_deferred_client_status_refresh() -> void: + if _refresh_state != ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM: + return + if _is_self_update_in_progress(): + return + if _is_editor_filesystem_busy(): + return + + var initial := _client_status_refresh_pending_initial + var force := _client_status_refresh_pending_force + _refresh_state = ClientRefreshStateScript.IDLE + _client_status_refresh_pending_force = false + _client_status_refresh_pending_initial = false + if initial: + _perform_initial_client_status_refresh() + else: + _request_client_status_refresh(force) + + +func _run_client_status_refresh_worker( + client_probes: Array[Dictionary], + server_url: String, + launch_context: Dictionary, + generation: int, +) -> Dictionary: + var results: Dictionary = {} + # Command-shaped clients share one attach launch. Discovery can be the + # dominant cold-cache cost, so resolve it once per refresh worker rather + # than once for Claude Desktop and again for Codex. + var resolved_launch := ClientConfigurator.resolve_attach_launch(launch_context) + for probe in client_probes: + var client_id := String(probe.get("id", "")) + if client_id.is_empty(): + continue + var details := ClientConfigurator.check_status_details_for_url_with_cli_path( + client_id, + server_url, + String(probe.get("cli_path", "")), + launch_context, + resolved_launch, + ) + var installed := bool(probe.get("installed", false)) + results[client_id] = { + "status": details.get("status", Client.Status.NOT_CONFIGURED), + "installed": installed, + "error_msg": details.get("error_msg", ""), + } + return {"results": results, "generation": generation} + + +func _poll_completed_client_status_refresh_thread() -> void: + if _client_status_refresh_thread == null: + return + if _client_status_refresh_thread.is_alive(): + return + var payload: Variant = _client_status_refresh_thread.wait_to_finish() + _client_status_refresh_thread = null + if payload is Dictionary: + var data := payload as Dictionary + var results: Dictionary = data.get("results", {}) + _apply_client_status_refresh_results( + results, + int(data.get("generation", _client_status_refresh_generation)) + ) + else: + _apply_client_status_refresh_results({}, _client_status_refresh_generation) + + +func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void: + if generation != _client_status_refresh_generation or _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: + return + if _client_status_refresh_thread != null: + _client_status_refresh_thread.wait_to_finish() + _client_status_refresh_thread = null + if _server_blocks_client_health(): + for client_id in _client_rows: + _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) + _finalize_completed_refresh() + return + + for client_id in results: + ## Skip rows whose Configure / Remove worker is still running so the + ## status refresh doesn't overwrite the "Configuring…" / "Removing…" + ## badge with a stale dot color. The action's own completion handler + ## will repaint the row when it lands. + if _client_action_threads.has(String(client_id)): + continue + var result: Dictionary = results[client_id] + _apply_row_status( + String(client_id), + result.get("status", Client.Status.NOT_CONFIGURED), + str(result.get("error_msg", "")), + result.get("installed", false) + ) + _finalize_completed_refresh() + + if _client_status_refresh_pending: + var pending_force := _client_status_refresh_pending_force + _client_status_refresh_pending = false + _client_status_refresh_pending_force = false + _request_client_status_refresh(pending_force) + + +func _server_blocks_client_health() -> bool: + if _plugin == null or not _plugin.has_method("get_server_status"): + return false + var status: Dictionary = _plugin.get_server_status() + return ServerStateScript.blocks_client_health( + int(status.get("state", ServerStateScript.UNINITIALIZED)) + ) + + +func _server_blocked_client_message() -> String: + if _plugin == null or not _plugin.has_method("get_server_status"): + return "server incompatible" + var status: Dictionary = _plugin.get_server_status() + var message := str(status.get("message", "")) + return message if not message.is_empty() else "server incompatible" + + +func _refresh_drift_banner(mismatched_ids: Array[String]) -> void: + if _drift_banner == null: + return + ## Sort so set-equality is order-independent — `_client_rows` iteration + ## order is dict-insertion order, but a future change to the iteration + ## site shouldn't make us repaint identical content. + mismatched_ids = mismatched_ids.duplicate() + mismatched_ids.sort() + if mismatched_ids == _last_mismatched_ids: + return + _last_mismatched_ids = mismatched_ids + if mismatched_ids.is_empty(): + _drift_banner.visible = false + return + var names: Array[String] = [] + for id in mismatched_ids: + names.append(ClientConfigurator.client_display_name(id)) + ## Active server URL is already shown on the WS:/HTTP: line above the + ## Clients section, so it doesn't need to repeat here. Lead with the + ## client names — that's the only thing the user can act on. + var verb := "needs" if mismatched_ids.size() == 1 else "need" + _drift_label.text = "%s %s to be reconfigured." % [", ".join(names), verb] + _drift_banner.visible = true + + +func _on_reconfigure_mismatched() -> void: + ## Re-Configure every client whose URL is currently stale. Iterates the + ## cached list from the most recent sweep instead of re-running + ## `check_status` per row (saves ~18 filesystem reads per click). The + ## trailing `_refresh_all_client_statuses()` re-sweeps anyway, so any + ## entries the user manually fixed between sweep and click get re-counted + ## as CONFIGURED there. + for client_id in _last_mismatched_ids: + if _client_rows.has(client_id): + _on_configure_client(client_id) + _refresh_all_client_statuses() + + +## Arm the one-shot post-update repin (see `_pending_post_update_repin`). +## Called by plugin.gd when it drains a `status == "success"` self-update +## marker, i.e. exactly once per completed update. `from_version` is the +## plugin version the update replaced — required by the pin-only gate +## below; an empty value (marker from a pre-gate runner) arms nothing, so +## those updates keep the manual drift-banner path. +func notify_self_update_success(from_version: String = "") -> void: + if from_version.strip_edges().is_empty(): + return + _post_update_from_version = from_version.strip_edges() + _pending_post_update_repin = true + + +## Consume `_pending_post_update_repin` on the first completed status sweep +## that could actually observe drift. Runs from `_finalize_completed_refresh` +## AFTER `_refresh_clients_summary()` has rebuilt `_last_mismatched_ids` +## from the sweep that just landed. +## +## Blast-radius gate: only entries whose SOLE drift is the old version pin +## are repinned (`entry_drift_is_version_pin_only`). A mismatched entry can +## also mean "points at a different editor's ports" — this dock ran inside +## a side project with custom ports, or the user hand-tuned the entry — and +## auto-rewriting those hijacks every AI client on the machine to THIS +## editor's ports (observed live: the self-update smoke fixture on port +## 18000 repinned 21 real client configs). Those stay on the drift banner's +## human click. +func _maybe_auto_repin_after_update() -> void: + if not _pending_post_update_repin: + return + ## While the server is INCOMPATIBLE (post-update stale-occupant recovery + ## still in flight) every row reads ERROR, not CONFIGURED_MISMATCH — a + ## consume here would see an empty mismatch list and drop the repin on + ## the floor. Stay pending for the sweep that lands after recovery. + if _server_blocks_client_health(): + return + if ClientRefreshStateScript.should_disable_client_actions(_refresh_state): + return + _pending_post_update_repin = false + var from_version := _post_update_from_version + _post_update_from_version = "" + if _last_mismatched_ids.is_empty(): + return + var launch_context := ClientConfigurator.capture_launch_context() + var pin_only: Array[String] = [] + for client_id in _last_mismatched_ids: + if _entry_drift_is_version_pin_only(String(client_id), from_version, launch_context): + pin_only.append(String(client_id)) + if pin_only.is_empty(): + print( + "MCP | self-update complete — no client entry drifts by version pin alone; leaving %d drifted config(s) to the Reconfigure banner" + % _last_mismatched_ids.size() + ) + return + print( + "MCP | self-update complete — repinning %d client config(s) from v%s to v%s" + % [pin_only.size(), from_version, ClientConfigurator.get_plugin_version()] + ) + for client_id in pin_only: + if _client_rows.has(client_id): + _on_configure_client(client_id) + _refresh_all_client_statuses() + + +## Instance seam over the static gate so the dock test suite can fake the +## per-client verdict without real config files on disk. +func _entry_drift_is_version_pin_only( + client_id: String, from_version: String, launch_context: Dictionary +) -> bool: + return ClientConfigurator.entry_drift_is_version_pin_only( + client_id, from_version, launch_context + ) + + +func _apply_row_status( + client_id: String, + status: Client.Status, + error_msg: String = "", + installed_override: Variant = null, +) -> void: + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + row["status"] = status + var dot: ColorRect = row["dot"] + var configure_btn: Button = row["configure_btn"] + var remove_btn: Button = row["remove_btn"] + var name_label: Label = row["name_label"] + var base_name := ClientConfigurator.client_display_name(client_id) + _refresh_client_config_file_buttons(client_id) + match status: + Client.Status.CONFIGURED: + dot.color = Color.GREEN + configure_btn.text = "Reconfigure" + remove_btn.visible = true + ## `error_msg` doubles as a detail slot on the green path: a + ## successful configure passes the sweep note (#877). Transient by + ## design — the next status refresh re-applies CONFIGURED with no + ## detail, so the row settles back to its plain name. + name_label.text = ( + "%s (%s)" % [base_name, error_msg] if not error_msg.is_empty() else base_name + ) + Client.Status.NOT_CONFIGURED: + dot.color = COLOR_MUTED + configure_btn.text = "Configure" + remove_btn.visible = false + var installed: bool = installed_override if installed_override != null else ClientConfigurator.is_installed(client_id) + name_label.text = base_name if installed else "%s (not detected)" % base_name + Client.Status.CONFIGURED_MISMATCH: + ## Amber matches the dock-level drift banner so a glance at the + ## row + the banner read as the same condition. + dot.color = COLOR_AMBER + configure_btn.text = "Reconfigure" + remove_btn.visible = true + ## Drift is usually a stale URL, but a `{scope}` client can also be + ## registered in a scope the user did not select (#872), and + ## "URL out of date" would be a wrong description of that. Prefer + ## the probe's own words whenever it supplied any. + name_label.text = ( + "%s (%s)" % [base_name, error_msg] + if not error_msg.is_empty() + else "%s (URL out of date)" % base_name + ) + _: + dot.color = Color.RED + configure_btn.text = "Retry" + remove_btn.visible = false + name_label.text = "%s — %s" % [base_name, error_msg] if not error_msg.is_empty() else base_name + + +func _refresh_client_config_file_buttons(client_id: String) -> void: + var row: Dictionary = _client_rows.get(client_id, {}) + if row.is_empty(): + return + # Re-resolve on every refresh so the Open/Reveal buttons follow the entry + # when it lands in (or moves to) a higher-precedence merge tier (codex F3). + # F-3-4: switched from `effective_config_path` to the authoritative facade + # so multi-project-tier cases resolve to the LATEST tier (matching F2 + # status semantics) instead of failing closed to `path_template`. + row["config_path"] = ClientConfigurator.effective_authoritative_path(client_id) + var config_path := String(row["config_path"]) + var has_path := not config_path.is_empty() + var open_config_btn: Button = row["open_config_btn"] + var reveal_btn: Button = row["reveal_btn"] + open_config_btn.visible = has_path + reveal_btn.visible = has_path + open_config_btn.disabled = not has_path + reveal_btn.disabled = not has_path + if has_path: + open_config_btn.tooltip_text = "Open config file:\n%s" % config_path + reveal_btn.tooltip_text = "Reveal in folder:\n%s" % config_path.get_base_dir() + else: + open_config_btn.tooltip_text = "" + reveal_btn.tooltip_text = "" + + +# --- Update check & self-update --- + +## Tolerates a null manager so test fixtures that build the dock without +## `_build_ui()` don't false-positive on the worker-spawn gate. +func _is_self_update_in_progress() -> bool: + return _update_manager != null and bool(_update_manager.is_install_in_flight()) + + +func _on_update_pressed() -> void: + if _update_manager != null: + _update_manager.start_install() + + +func _on_update_check_result(result: Dictionary) -> void: + _update_label.text = String(result.get("label_text", "")) + _update_banner.visible = true + + +## Apply only the keys present so the manager can ship partial updates +## (e.g. button-text-only during the download phase) without clobbering +## banner state. +func _on_install_state_changed(state: Dictionary) -> void: + if state.has("button_text") and _update_btn != null: + _update_btn.text = String(state["button_text"]) + if state.has("button_disabled") and _update_btn != null: + _update_btn.disabled = bool(state["button_disabled"]) + if state.has("label_text") and _update_label != null: + _update_label.text = String(state["label_text"]) + if state.has("banner_visible") and _update_banner != null: + _update_banner.visible = bool(state["banner_visible"]) + if String(state.get("outcome", "")) == "success" and _update_label != null: + ## Visual confirmation for successful terminal update states. + _update_label.add_theme_color_override("font_color", Color.GREEN) diff --git a/addons/godot_ai/mcp_dock.gd.uid b/addons/godot_ai/mcp_dock.gd.uid new file mode 100644 index 0000000..5868719 --- /dev/null +++ b/addons/godot_ai/mcp_dock.gd.uid @@ -0,0 +1 @@ +uid://b8yknttdjanm5 diff --git a/addons/godot_ai/plugin.cfg b/addons/godot_ai/plugin.cfg new file mode 100644 index 0000000..ff997ab --- /dev/null +++ b/addons/godot_ai/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Godot AI" +description="MCP server and AI tools for Godot" +author="Godot AI" +version="3.2.4" +script="plugin.gd" diff --git a/addons/godot_ai/plugin.gd b/addons/godot_ai/plugin.gd new file mode 100644 index 0000000..db0de5c --- /dev/null +++ b/addons/godot_ai/plugin.gd @@ -0,0 +1,2144 @@ +@tool +extends EditorPlugin + +const GAME_HELPER_AUTOLOAD_NAME := "_mcp_game_helper" +const GAME_HELPER_AUTOLOAD_PATH := "res://addons/godot_ai/runtime/game_helper.gd" + +## Editor-process Logger subclass — captures parse errors, @tool runtime +## errors, and push_error/push_warning so the LLM can read them via +## `logs_read(source="editor")`. +const EditorLogger := preload("res://addons/godot_ai/runtime/editor_logger.gd") + +## EditorSettings keys used to remember which server process the plugin +## spawned — survives editor restarts, lets a later editor session adopt +## and manage a server it didn't spawn itself. See #135. +const MANAGED_SERVER_PID_SETTING := "godot_ai/managed_server_pid" +const MANAGED_SERVER_VERSION_SETTING := "godot_ai/managed_server_version" +const MANAGED_SERVER_WS_PORT_SETTING := "godot_ai/managed_server_ws_port" +## Per-launch WS handshake auth token (#690), generated at spawn and handed +## to the server via the GODOT_AI_WS_TOKEN spawn env. Persisted alongside +## the managed-server record so a reloaded plugin instance adopting the +## same server keeps authenticating; cleared with the rest of the record. +const MANAGED_SERVER_WS_TOKEN_SETTING := "godot_ai/managed_server_ws_token" +## keep_server_on_exit (#800): records whether the managed server was +## launched with the keep-alive env opt-outs, so a later session adopting +## the survivor routes its own editor exit through detach too. The live +## setting can't answer that — it may have changed since the spawn. +const MANAGED_SERVER_KEEP_ALIVE_SETTING := "godot_ai/managed_server_keep_alive" +const UPDATE_RELOAD_RUNNER_SCRIPT := preload("res://addons/godot_ai/update_reload_runner.gd") + +## Server lifecycle + port discovery extracted from this file (#297 PR 5). +## State enums + version-check seam extracted in PR 6 (#297). Plugin.gd +## keeps thin shims so the dock and characterization tests see an +## unchanged public surface; spawn-machinery state now lives in the +## lifecycle manager. +const ServerLifecycleManager := preload("res://addons/godot_ai/utils/server_lifecycle.gd") +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") +const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") + +## Plugin-class scripts used by this file. The script-local preload aliases +## are ordinary dependency shorthand and keep construction sites compact. +## They are not the self-update safety boundary; #398 was stale Script-object +## content from a mixed old/new snapshot, fixed by the runner's single-phase +## write-before-scan model. +const Connection := preload("res://addons/godot_ai/connection.gd") +const Dispatcher := preload("res://addons/godot_ai/dispatcher.gd") +const Telemetry := preload("res://addons/godot_ai/telemetry.gd") +const LogBuffer := preload("res://addons/godot_ai/utils/log_buffer.gd") +const GameLogBuffer := preload("res://addons/godot_ai/utils/game_log_buffer.gd") +const EditorLogBuffer := preload("res://addons/godot_ai/utils/editor_log_buffer.gd") +const SurfacedErrorTracker := preload("res://addons/godot_ai/utils/surfaced_error_tracker.gd") +const Dock := preload("res://addons/godot_ai/mcp_dock.gd") +const DebuggerPlugin := preload("res://addons/godot_ai/debugger/mcp_debugger_plugin.gd") +const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd") +const ExportPlugin := preload("res://addons/godot_ai/export/mcp_export_plugin.gd") +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") +const McpToolRegistry := preload("res://addons/godot_ai/custom_tools/mcp_tool_registry.gd") +const McpServiceLocator := preload("res://addons/godot_ai/custom_tools/mcp_service_locator.gd") + +## Handlers are intentionally NOT preloaded here (#736). The old +## `const X := preload("res://addons/godot_ai/handlers/...")` block pulled +## every handler — and everything handlers preload — into plugin.gd's +## compile closure, so Godot parsed/compiled ~119 addon scripts before the +## first instruction of _enter_tree ran. GDScript has no cross-restart +## compile cache, so that stalled "Initializing plugins" on every editor +## boot and every plugin re-enable. Handlers are now registered by script +## path via McpDispatcher.register_lazy_handler / register_lazy and are +## load()ed at the first dispatch of one of their commands. +## +## Handlers remain preload-style scripts with no `class_name` so they don't +## pollute the project-wide global scope (#253): a user project that happens +## to define its own `InputHandler`, `SceneHandler`, etc. would otherwise +## hard-error on plugin enable. +const HANDLERS_DIR := "res://addons/godot_ai/handlers/" + +## The Python server writes its own PID here on startup (passed as +## `--pid-file`) and unlinks on clean exit. Deterministic replacement +## for scraping `netstat -ano` to find the port owner — especially on +## Windows where `OS.kill` on the uvx launcher doesn't take the Python +## child with it, and the scrape was the only path to the real PID. +## See issue for #154-era Windows update friction. +## Re-export of PortResolver.SERVER_PID_FILE so the spawn flags, the +## resolver, and characterization tests share one source of truth. +const SERVER_PID_FILE := PortResolver.SERVER_PID_FILE + +## How long we watch the spawned server for early exit once it has PROVEN it +## started — i.e. published its pid-file. If the process is still alive when +## this expires, we stop watching. Mid-session crashes after this point get +## caught by the WebSocket disconnect flow. +const SERVER_WATCH_MS := 30 * 1000 +## Watch ceiling for a spawn that has NOT yet published its pid-file (#896). +## +## The short window above justifies itself with "mid-session crashes surface +## via WebSocket disconnect" — which is only true for a server that got far +## enough to be connected to. A cold `uvx` spawn on a slow link can still be +## resolving and downloading its ~67-package environment at 30s, having proven +## nothing; stopping the watch there means a launcher that dies at 45s is never +## observed, `_diagnose_spawn_fast_exit` never runs (it is only reachable from +## inside the watch), and the plugin redials forever while the dock shows a +## bare "Disconnected". +## +## Sized to cover that download. Independent of any pre-warm: this window has +## to hold even when nothing warmed the cache first. +const SERVER_COLD_START_WATCH_MS := 180 * 1000 +## Python's import graph (FastMCP + Rich + uvicorn) plus the pid-file write +## take a beat on cold starts, especially on Windows. Hold off on declaring +## a spawn a crash until this window elapses so the watch loop has time to +## observe either the pid-file (dev venv) or the port listening (uvx). +const SPAWN_GRACE_MS := 5 * 1000 +## Windows only (#797). A uv-created venv launches the real server under a +## different PID than the one `OS.create_process` returns, and that watched PID +## has been seen dying on a healthy boot while the server was still starting +## and had not written its pid-file yet. Past SPAWN_GRACE_MS that reads as +## "server exited" and only the crash-survivor adoption path rescues the +## session. While no pid-file has appeared we keep watching until this longer +## window closes, rather than calling a handoff an exit. Sized to cover a cold +## uvx resolve on top of the launcher hop, and kept well under SERVER_WATCH_MS +## so a genuinely dead Windows spawn is still diagnosed inside the watch rather +## than falling off the end of it. +const SPAWN_HANDOFF_MS := 15 * 1000 +const SERVER_STATUS_PATH := "/godot-ai/status" +const SERVER_STATUS_PROBE_TIMEOUT_MS := 800 +const STARTUP_TRACE_COUNTER_NAMES := [ + "powershell", + "netstat", + "netsh", + "lsof", + "http_status_probe", + "server_command_discovery", +] + +## Untyped on purpose — see policy below. Type fences move to handler `_init` +## sites that take typed parameters. +## +## Self-update field and load-surface policy: plugin entry-load fields that +## survive reload stay untyped. Typed fields against plugin-defined classes +## were the #242 / #244 crash class: Godot can reparse a long-lived script +## while its old field storage and the new type shape disagree. Static-var +## initializers are the most dangerous form because they execute at +## script-load; a top-level typed Dictionary/Array storage change can fail +## before `_enter_tree` runs. +## +## The mitigation is two-part: +## (1) Field declarations are untyped (this block). +## (2) Construction and static access use local names declared at the top +## of the file (e.g. `Connection`, `Dispatcher`, `LogBuffer`, +## `ClientConfigurator`, `WindowsPortReservation`, ...), which keeps +## this entry script's load surface explicit and reviewable. +## +## Constructors, constants, and static methods on `Mcp*` classes are not the +## self-update safety metric under the single-phase runner. The old syntactic +## lint counted bare `Mcp*.MEMBER` references, but #398 was caused by the +## runner scanning a mixed old/new snapshot and reusing stale Script-object +## content. Bare names and preload aliases can both be parsed against stale +## content under an old two-phase runner; from the fixed runner onward the +## full v(N+1) snapshot is written before the scan. In short: preload aliases +## are not the self-update safety metric. +## +## `tests/unit/test_plugin_self_update_safety.py` locks this wording in. +## +var _connection +var _dispatcher +var _telemetry +var _log_buffer +var _game_log_buffer +var _editor_log_buffer +var _surfaced_error_tracker +var _editor_logger: Logger +var _dock +var _debugger_plugin +var _vision_routing +var _export_plugin +var _custom_tool_registry +var _custom_tool_service_locator +## Spawn / stop / adopt orchestration plus state machine; allocated in +## `_init` so test fixtures (which never enter the tree) can drive +## `_start_server`. Owns `_server_pid`, `_server_state`, the version- +## check seam, and the adoption-confirmation deadline — see +## `utils/server_lifecycle.gd`. +var _lifecycle +static var _server_started_this_session := false # guard against re-entrant spawns +static var _resolved_ws_port := ClientConfigurator.DEFAULT_WS_PORT +## True once a startup walk has published a port via `_set_resolved_ws_port` +## this editor session. Gates the `_enter_tree` pre-resolution seed: a fresh +## session seeds `_resolved_ws_port` from the configured EditorSettings +## value, but a plugin reload must keep the prior instance's published +## resolution, which can legitimately differ from the configured value +## (Windows-reservation remap, adopted-server record). +static var _ws_port_resolution_published := false +## Per-launch WS handshake auth token (#690). Static for the same reason as +## _resolved_ws_port: a plugin reload in the same editor session adopts the +## server the previous instance spawned, and must keep its token. Empty +## when this editor never spawned a token-carrying server (dev servers, +## fresh installs) — the handshake then omits the field. +static var _ws_auth_token := "" + +## Server-watch timer lives on the plugin because it's a Node — the +## manager is RefCounted and can't host children. +var _server_watch_timer: Timer = null +var _headless_disabled := false +var _startup_trace_enabled := false +var _startup_trace_start_ms := 0 +var _startup_trace_last_ms := 0 +var _startup_trace_counters: Dictionary = {} +## Startup-path probes can now run on a worker thread (#678); the trace +## counters they bump are shared with the main thread, so serialize. +var _startup_trace_mutex := Mutex.new() +var _startup_trace_netsh_start_count := 0 + + +func _init() -> void: + _lifecycle = ServerLifecycleManager.new(self) + + +func _enter_tree() -> void: + _startup_trace_begin() + + ## `_process` is only used by the adoption-confirmation watcher; keep + ## it off until `_watch_for_adoption_confirmation` arms it, so the + ## plugin has zero per-frame cost in the common case. + set_process(false) + + ## #740: register the export plugin BEFORE the headless guard so + ## `godot --headless --export-*` runs strip the game-helper autoload + ## from exported packs too — CI export pipelines are headless. The + ## export plugin is inert outside exports: no server, no sockets. + _export_plugin = ExportPlugin.new() + add_export_plugin(_export_plugin) + + if _mcp_disabled_for_headless_launch(): + _headless_disabled = true + print("MCP | plugin disabled in headless mode") + return + + ## Self-update extracts over the live addon and doesn't prune files that + ## disappeared from the new ZIP. Remove obsolete Logger-loader quarantine + ## files/folders once so upgraders match a fresh install. + _cleanup_legacy_logger_scripts() + + ## Register port overrides before spawn so `http_port()` / `ws_port()` + ## return the user's configured values (if any) when `_start_server` + ## builds the CLI args. + ClientConfigurator.ensure_settings_registered() + _startup_trace_phase("settings_registered") + + ## With the startup walk's blocking port resolution deferred to a worker + ## (#678), the Connection below dials before `_set_resolved_ws_port` + ## publishes. Seed the pre-resolution port from the configured + ## EditorSettings value (a cheap main-thread read, not a blocking probe) + ## so the first dial honors a `godot_ai/ws_port` override — without this + ## it targeted the compile-time default and the override only took + ## effect on the 1s retry. + _resolved_ws_port = _startup_ws_port_seed( + _ws_port_resolution_published, + _resolved_ws_port, + ClientConfigurator.ws_port(), + ) + + ## #691: pre-warm the env snapshot on the main thread before any worker + ## exists, so worker-thread env reads (dock refresh/action workers, the + ## #678 startup walk's discovery worker) serve from the snapshot and can + ## never race the spawn window's setenv/unsetenv around + ## OS.create_process. + ClientConfigurator.warm_env_snapshot() + + _log_buffer = LogBuffer.new() + ## Apply the persisted dock "Log" toggle before anything logs through the + ## buffer. Without this the choice only took effect after a manual toggle + ## and reset to noisy on every editor restart (#626). + _log_buffer.enabled = McpSettings.mcp_logging_enabled() + ## #678: in the real editor, run the startup path's blocking probes and + ## kill-drain waits off the main thread so a contended port can't freeze + ## plugin init/reload. Set here (not _init) so test fixtures — which + ## extend this plugin but never enter the tree — keep the synchronous + ## default and can call-then-assert. + _lifecycle.defer_blocking_work = true + ## A completed self-update means the user's Update click already + ## authorized replacing the previous-version backend. Armed BEFORE the + ## startup walk (a peek, not a drain — `_flush_pending_self_update_telemetry` + ## below still owns the read-and-clear) so the walk can weak-proof-kill + ## the stale server an attach bridge kept alive, and bounded retries + ## absorb the bridge-respawn port race, instead of latching INCOMPATIBLE + ## and waiting for a manual recovery click. + if _pending_self_update_succeeded(): + _lifecycle.authorize_stale_recovery() + _start_server() + _startup_trace_phase("server_start") + + _game_log_buffer = GameLogBuffer.new() + _editor_log_buffer = EditorLogBuffer.new() + _surfaced_error_tracker = SurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer) + _attach_editor_logger() + _dispatcher = Dispatcher.new(_log_buffer, _surfaced_error_tracker) + _dispatcher.mcp_logging = _log_buffer.enabled + _startup_trace_phase("core_objects") + + _connection = Connection.new() + _connection.log_buffer = _log_buffer + _connection.surfaced_error_tracker = _surfaced_error_tracker + _connection.ws_port = _resolved_ws_port + ## Restore the token before the first connect: after an editor restart + ## the static is empty but the managed-server record still names the + ## token the running server was spawned with (#690). A fresh spawn later + ## overwrites both via _set_ws_auth_token. + if _ws_auth_token.is_empty(): + _ws_auth_token = str(_read_managed_server_record().get("ws_token", "")) + _connection.auth_token = _ws_auth_token + ## Pause-depth restore boundary (#712): the dispatcher rebalances any + ## pause_processing level a crashed handler leaked. + _dispatcher.pause_target = _connection + _connection.connect_blocked = _lifecycle.is_connection_blocked() + _connection.connect_block_reason = _lifecycle.get_status_dict().get("message", "") + if ( + not _lifecycle.is_connection_blocked() + and not ServerStateScript.is_terminal_diagnosis(_lifecycle.get_state()) + ): + _arm_server_version_check() + ## Replay the custom-tool catalog on (re)connect so tools registered + ## before the initial connection or during a disconnect window reach + ## the server — send_event silently drops while _connected is false. + _connection.connection_state_changed.connect(_on_connection_state_changed) + + _telemetry = Telemetry.new(_connection) + + _debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer, _editor_log_buffer, _surfaced_error_tracker) + _vision_routing = VisionRoutingScript.new() + _vision_routing.log_buffer = _log_buffer + _debugger_plugin.vision_routing = _vision_routing + add_debugger_plugin(_debugger_plugin) + _connection.debugger_plugin = _debugger_plugin + _ensure_game_helper_autoload() + + ## Lazy handler registration (#736): declare each handler's script path + ## and constructor args, then map every command to (handler_key, method). + ## The dispatcher load()s + constructs a handler at the first dispatch of + ## one of its commands and caches the instance, so this block is the + ## authoritative command list without pulling any handler script into the + ## boot-time compile closure. Constructor args are captured now (they are + ## all plugin-lifetime objects) and released by _dispatcher.clear() in + ## _exit_tree. + var undo := get_undo_redo() + _dispatcher.register_lazy_handler("editor", HANDLERS_DIR + "editor_handler.gd", [_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer, null, _surfaced_error_tracker, _vision_routing]) + _dispatcher.register_lazy_handler("scene", HANDLERS_DIR + "scene_handler.gd", [_connection]) + _dispatcher.register_lazy_handler("node", HANDLERS_DIR + "node_handler.gd", [undo]) + _dispatcher.register_lazy_handler("project", HANDLERS_DIR + "project_handler.gd", [_connection, _debugger_plugin, _editor_log_buffer]) + _dispatcher.register_lazy_handler( + "client", + HANDLERS_DIR + "client_handler.gd", + [_connection, ClientConfigurator.capture_launch_context()], + ) + _dispatcher.register_lazy_handler("script", HANDLERS_DIR + "script_handler.gd", [undo, _connection]) + _dispatcher.register_lazy_handler("resource", HANDLERS_DIR + "resource_handler.gd", [undo, _connection]) + _dispatcher.register_lazy_handler("api", HANDLERS_DIR + "api_handler.gd", []) + _dispatcher.register_lazy_handler("filesystem", HANDLERS_DIR + "filesystem_handler.gd", [_connection]) + _dispatcher.register_lazy_handler("signal", HANDLERS_DIR + "signal_handler.gd", [undo]) + _dispatcher.register_lazy_handler("autoload", HANDLERS_DIR + "autoload_handler.gd", []) + _dispatcher.register_lazy_handler("input", HANDLERS_DIR + "input_handler.gd", []) + _dispatcher.register_lazy_handler("test", HANDLERS_DIR + "test_handler.gd", [undo, _log_buffer, _dispatcher, _connection]) + _dispatcher.register_lazy_handler("batch", HANDLERS_DIR + "batch_handler.gd", [_dispatcher, undo]) + _dispatcher.register_lazy_handler("ui", HANDLERS_DIR + "ui_handler.gd", [undo]) + _dispatcher.register_lazy_handler("theme", HANDLERS_DIR + "theme_handler.gd", [undo, _connection]) + _dispatcher.register_lazy_handler("animation", HANDLERS_DIR + "animation_handler.gd", [undo]) + _dispatcher.register_lazy_handler("material", HANDLERS_DIR + "material_handler.gd", [undo, _connection]) + _dispatcher.register_lazy_handler("particle", HANDLERS_DIR + "particle_handler.gd", [undo]) + _dispatcher.register_lazy_handler("camera", HANDLERS_DIR + "camera_handler.gd", [undo]) + _dispatcher.register_lazy_handler("audio", HANDLERS_DIR + "audio_handler.gd", [undo]) + _dispatcher.register_lazy_handler("physics_shape", HANDLERS_DIR + "physics_shape_handler.gd", [undo]) + _dispatcher.register_lazy_handler("environment", HANDLERS_DIR + "environment_handler.gd", [undo, _connection]) + _dispatcher.register_lazy_handler("texture", HANDLERS_DIR + "texture_handler.gd", [undo, _connection]) + _dispatcher.register_lazy_handler("curve", HANDLERS_DIR + "curve_handler.gd", [undo, _connection]) + _dispatcher.register_lazy_handler("control_draw_recipe", HANDLERS_DIR + "control_draw_recipe_handler.gd", [undo]) + _dispatcher.register_lazy_handler("tilemap", HANDLERS_DIR + "tilemap_handler.gd", [undo]) + _dispatcher.register_lazy_handler("tileset", HANDLERS_DIR + "tileset_handler.gd", []) + _dispatcher.register_lazy_handler("gridmap", HANDLERS_DIR + "gridmap_handler.gd", [undo]) + _dispatcher.register_lazy_handler("csg", HANDLERS_DIR + "csg_handler.gd", [undo]) + + _dispatcher.register_lazy("get_editor_state", "editor", &"get_editor_state") + _dispatcher.register_lazy("get_scene_tree", "scene", &"get_scene_tree") + _dispatcher.register_lazy("get_open_scenes", "scene", &"get_open_scenes") + _dispatcher.register_lazy("find_nodes", "scene", &"find_nodes") + _dispatcher.register_lazy("create_scene", "scene", &"create_scene") + _dispatcher.register_lazy("open_scene", "scene", &"open_scene") + _dispatcher.register_lazy("save_scene", "scene", &"save_scene") + _dispatcher.register_lazy("save_scene_as", "scene", &"save_scene_as") + _dispatcher.register_lazy("get_selection", "editor", &"get_selection") + _dispatcher.register_lazy("create_node", "node", &"create_node") + _dispatcher.register_lazy("delete_node", "node", &"delete_node") + _dispatcher.register_lazy("reparent_node", "node", &"reparent_node") + _dispatcher.register_lazy("set_property", "node", &"set_property") + _dispatcher.register_lazy("rename_node", "node", &"rename_node") + _dispatcher.register_lazy("duplicate_node", "node", &"duplicate_node") + _dispatcher.register_lazy("move_node", "node", &"move_node") + _dispatcher.register_lazy("add_to_group", "node", &"add_to_group") + _dispatcher.register_lazy("remove_from_group", "node", &"remove_from_group") + _dispatcher.register_lazy("set_selection", "node", &"set_selection") + _dispatcher.register_lazy("get_node_properties", "node", &"get_node_properties") + _dispatcher.register_lazy("get_children", "node", &"get_children") + _dispatcher.register_lazy("get_groups", "node", &"get_groups") + _dispatcher.register_lazy("get_logs", "editor", &"get_logs") + _dispatcher.register_lazy("clear_logs", "editor", &"clear_logs") + _dispatcher.register_lazy("take_screenshot", "editor", &"take_screenshot") + _dispatcher.register_lazy("get_performance_monitors", "editor", &"get_performance_monitors") + _dispatcher.register_lazy("reload_plugin", "editor", &"reload_plugin") + _dispatcher.register_lazy("quit_editor", "editor", &"quit_editor") + _dispatcher.register_lazy("game_eval", "editor", &"game_eval") + _dispatcher.register_lazy("game_command", "editor", &"game_command") + _dispatcher.register_lazy("get_project_setting", "project", &"get_project_setting") + _dispatcher.register_lazy("set_project_setting", "project", &"set_project_setting") + _dispatcher.register_lazy("run_project", "project", &"run_project") + _dispatcher.register_lazy("stop_project", "project", &"stop_project") + _dispatcher.register_lazy("search_filesystem", "project", &"search_filesystem") + _dispatcher.register_lazy("configure_client", "client", &"configure_client") + _dispatcher.register_lazy("remove_client", "client", &"remove_client") + _dispatcher.register_lazy("check_client_status", "client", &"check_client_status") + _dispatcher.register_lazy("create_script", "script", &"create_script") + _dispatcher.register_lazy("patch_script", "script", &"patch_script") + _dispatcher.register_lazy("read_script", "script", &"read_script") + _dispatcher.register_lazy("attach_script", "script", &"attach_script") + _dispatcher.register_lazy("detach_script", "script", &"detach_script") + _dispatcher.register_lazy("find_symbols", "script", &"find_symbols") + _dispatcher.register_lazy("search_resources", "resource", &"search_resources") + _dispatcher.register_lazy("load_resource", "resource", &"load_resource") + _dispatcher.register_lazy("assign_resource", "resource", &"assign_resource") + _dispatcher.register_lazy("create_resource", "resource", &"create_resource") + _dispatcher.register_lazy("get_resource_info", "resource", &"get_resource_info") + _dispatcher.register_lazy("get_class_info", "api", &"get_class_info") + _dispatcher.register_lazy("read_file", "filesystem", &"read_file") + _dispatcher.register_lazy("write_file", "filesystem", &"write_file") + _dispatcher.register_lazy("reimport", "filesystem", &"reimport") + _dispatcher.register_lazy("scan_filesystem", "filesystem", &"scan_filesystem") + _dispatcher.register_lazy("list_signals", "signal", &"list_signals") + _dispatcher.register_lazy("connect_signal", "signal", &"connect_signal") + _dispatcher.register_lazy("disconnect_signal", "signal", &"disconnect_signal") + _dispatcher.register_lazy("list_autoloads", "autoload", &"list_autoloads") + _dispatcher.register_lazy("add_autoload", "autoload", &"add_autoload") + _dispatcher.register_lazy("remove_autoload", "autoload", &"remove_autoload") + _dispatcher.register_lazy("list_actions", "input", &"list_actions") + _dispatcher.register_lazy("add_action", "input", &"add_action") + _dispatcher.register_lazy("ensure_action", "input", &"ensure_action") + _dispatcher.register_lazy("remove_action", "input", &"remove_action") + _dispatcher.register_lazy("bind_event", "input", &"bind_event") + _dispatcher.register_lazy("ensure_binding", "input", &"ensure_binding") + _dispatcher.register_lazy("run_tests", "test", &"run_tests") + _dispatcher.register_lazy("get_test_results", "test", &"get_test_results") + _dispatcher.register_lazy("batch_execute", "batch", &"batch_execute") + _dispatcher.register_lazy("set_anchor_preset", "ui", &"set_anchor_preset") + _dispatcher.register_lazy("set_text", "ui", &"set_text") + _dispatcher.register_lazy("build_layout", "ui", &"build_layout") + _dispatcher.register_lazy("create_theme", "theme", &"create_theme") + _dispatcher.register_lazy("theme_set_color", "theme", &"set_color") + _dispatcher.register_lazy("theme_set_constant", "theme", &"set_constant") + _dispatcher.register_lazy("theme_set_font_size", "theme", &"set_font_size") + _dispatcher.register_lazy("theme_set_stylebox_flat", "theme", &"set_stylebox_flat") + _dispatcher.register_lazy("apply_theme", "theme", &"apply_theme") + _dispatcher.register_lazy("animation_player_create", "animation", &"create_player") + _dispatcher.register_lazy("animation_create", "animation", &"create_animation") + _dispatcher.register_lazy("animation_add_property_track", "animation", &"add_property_track") + _dispatcher.register_lazy("animation_add_method_track", "animation", &"add_method_track") + _dispatcher.register_lazy("animation_set_autoplay", "animation", &"set_autoplay") + _dispatcher.register_lazy("animation_play", "animation", &"play") + _dispatcher.register_lazy("animation_stop", "animation", &"stop") + _dispatcher.register_lazy("animation_list", "animation", &"list_animations") + _dispatcher.register_lazy("animation_get", "animation", &"get_animation") + _dispatcher.register_lazy("animation_create_simple", "animation", &"create_simple") + _dispatcher.register_lazy("animation_delete", "animation", &"delete_animation") + _dispatcher.register_lazy("animation_validate", "animation", &"validate_animation") + _dispatcher.register_lazy("animation_preset_fade", "animation", &"preset_fade") + _dispatcher.register_lazy("animation_preset_slide", "animation", &"preset_slide") + _dispatcher.register_lazy("animation_preset_shake", "animation", &"preset_shake") + _dispatcher.register_lazy("animation_preset_pulse", "animation", &"preset_pulse") + _dispatcher.register_lazy("material_create", "material", &"create_material") + _dispatcher.register_lazy("material_set_param", "material", &"set_param") + _dispatcher.register_lazy("material_set_shader_param", "material", &"set_shader_param") + _dispatcher.register_lazy("material_get", "material", &"get_material") + _dispatcher.register_lazy("material_list", "material", &"list_materials") + _dispatcher.register_lazy("material_assign", "material", &"assign_material") + _dispatcher.register_lazy("material_apply_to_node", "material", &"apply_to_node") + _dispatcher.register_lazy("material_apply_preset", "material", &"apply_preset") + _dispatcher.register_lazy("particle_create", "particle", &"create_particle") + _dispatcher.register_lazy("particle_set_main", "particle", &"set_main") + _dispatcher.register_lazy("particle_set_process", "particle", &"set_process") + _dispatcher.register_lazy("particle_set_draw_pass", "particle", &"set_draw_pass") + _dispatcher.register_lazy("particle_restart", "particle", &"restart_particle") + _dispatcher.register_lazy("particle_get", "particle", &"get_particle") + _dispatcher.register_lazy("particle_apply_preset", "particle", &"apply_preset") + _dispatcher.register_lazy("camera_create", "camera", &"create_camera") + _dispatcher.register_lazy("camera_configure", "camera", &"configure") + _dispatcher.register_lazy("camera_set_limits_2d", "camera", &"set_limits_2d") + _dispatcher.register_lazy("camera_set_damping_2d", "camera", &"set_damping_2d") + _dispatcher.register_lazy("camera_follow_2d", "camera", &"follow_2d") + _dispatcher.register_lazy("camera_get", "camera", &"get_camera") + _dispatcher.register_lazy("camera_list", "camera", &"list_cameras") + _dispatcher.register_lazy("camera_apply_preset", "camera", &"apply_preset") + _dispatcher.register_lazy("audio_player_create", "audio", &"create_player") + _dispatcher.register_lazy("audio_player_set_stream", "audio", &"set_stream") + _dispatcher.register_lazy("audio_player_set_playback", "audio", &"set_playback") + _dispatcher.register_lazy("audio_play", "audio", &"play") + _dispatcher.register_lazy("audio_stop", "audio", &"stop") + _dispatcher.register_lazy("audio_list", "audio", &"list_streams") + _dispatcher.register_lazy("physics_shape_autofit", "physics_shape", &"autofit") + _dispatcher.register_lazy("environment_create", "environment", &"create_environment") + _dispatcher.register_lazy("gradient_texture_create", "texture", &"create_gradient_texture") + _dispatcher.register_lazy("noise_texture_create", "texture", &"create_noise_texture") + _dispatcher.register_lazy("curve_set_points", "curve", &"set_points") + _dispatcher.register_lazy("control_draw_recipe", "control_draw_recipe", &"control_draw_recipe") + _dispatcher.register_lazy("tilemap_set_cell", "tilemap", &"set_cell") + _dispatcher.register_lazy("tilemap_set_cells_rect", "tilemap", &"set_cells_rect") + _dispatcher.register_lazy("tilemap_clear", "tilemap", &"clear_layer") + _dispatcher.register_lazy("tilemap_get_cells", "tilemap", &"get_used_cells") + _dispatcher.register_lazy("tileset_get_atlas_tiles", "tileset", &"get_atlas_tiles") + _dispatcher.register_lazy("tileset_get_atlas_image", "tileset", &"get_atlas_image") + _dispatcher.register_lazy("gridmap_set_item", "gridmap", &"set_item") + _dispatcher.register_lazy("gridmap_fill", "gridmap", &"fill") + _dispatcher.register_lazy("gridmap_clear", "gridmap", &"clear_layer") + _dispatcher.register_lazy("gridmap_get_used_cells", "gridmap", &"get_used_cells") + _dispatcher.register_lazy("gridmap_list_library_items", "gridmap", &"list_library_items") + _dispatcher.register_lazy("csg_create", "csg", &"create") + _dispatcher.register_lazy("csg_set_operation", "csg", &"set_operation") + + _connection.dispatcher = _dispatcher + add_child(_connection) + _startup_trace_phase("handlers_registered") + + # Custom tool registry + _custom_tool_service_locator = McpServiceLocator.new() + _custom_tool_service_locator.setup(_connection, _log_buffer) + _custom_tool_registry = McpToolRegistry.new() + _custom_tool_registry.setup(_dispatcher, _custom_tool_service_locator) + _custom_tool_registry.tools_changed.connect(_on_custom_tools_changed) + _custom_tool_registry.mark_ready() + _startup_trace_phase("custom_tools_ready") + + # Dock panel + _dock = Dock.new() + _dock.vision_routing = _vision_routing + _dock.name = "Godot AI" + _dock.setup(_connection, _log_buffer, self) + add_control_to_dock(DOCK_SLOT_RIGHT_BL, _dock) + _startup_trace_phase("dock_attached") + + _log_buffer.log("plugin loaded") + if _telemetry != null: + _telemetry.record_dock_startup() + _flush_pending_self_update_telemetry() + _telemetry.flush_pending_plugin_reload() + ## The startup-trace 'done' line is stamped by _start_server after the + ## (possibly suspended) walk completes — not here (#682 review). + + +## Public wrapper around the dev-server-toggle telemetry emit. Lets the +## dock (or any other caller) record without reaching into ``_telemetry`` +## directly — keeps the plugin's internal field encapsulated. The dev +## server is a Python subprocess unrelated to the plugin's own +## lifecycle, so emission can be synchronous (no EditorSettings persist +## dance like ``plugin_reload`` / ``self_update``). +func record_dev_server_toggle(action: String) -> void: + if _telemetry == null: + return + _telemetry.record_dev_server_toggle(action) + + +## Non-draining read of the runner's pending self-update marker. Peeked +## before `_start_server` (the drain in `_flush_pending_self_update_telemetry` +## runs later, after the dock attaches) so the startup walk knows an update +## just completed. Only a `status == "success"` marker counts: a failed +## install left the OLD plugin version enabled, and killing a same-version +## backend is not this path's business. +func _pending_self_update_succeeded() -> bool: + var settings := EditorInterface.get_editor_settings() + if settings == null: + return false + var key := UPDATE_RELOAD_RUNNER_SCRIPT.PENDING_SELF_UPDATE_TELEMETRY_KEY + if not settings.has_setting(key): + return false + var raw := str(settings.get_setting(key)) + if raw.is_empty(): + return false + var parsed = JSON.parse_string(raw) + if typeof(parsed) != TYPE_DICTIONARY: + return false + return str(parsed.get("status", "")) == "success" + + +## Drain any self_update event written by `update_reload_runner` during the +## previous disable -> enable window. +func _flush_pending_self_update_telemetry() -> void: + var key := UPDATE_RELOAD_RUNNER_SCRIPT.PENDING_SELF_UPDATE_TELEMETRY_KEY + var parsed = Telemetry._drain_editor_setting_dict(key) + if parsed == null: + return + var status := str(parsed.get("status", "unknown")) + var error := str(parsed.get("error", "")) + ## Positional args: GDScript doesn't support keyword args in calls + ## (unlike Python). from/to versions ride the marker since the repin + ## gate landed; markers written by older runners simply lack them. + var from_version := str(parsed.get("from_version", "")) + var to_version := str(parsed.get("to_version", "")) + _telemetry.record_self_update(status, from_version, to_version, error) + ## After a successful update, previously-configured client entries still + ## pin the old server version; arm the dock's one-shot auto-repin so its + ## first healthy status sweep rewrites them without the user having to + ## click through the drift banner. The dock repins ONLY entries whose + ## sole drift is the old version pin — it needs `from_version` to render + ## that comparison, so a marker from an older runner (no version fields) + ## arms nothing and the drift banner stays the manual path. `has_method` + ## (not a typed call): the dock script is one of the files the update + ## just overwrote, and the untyped-reference convention applies across + ## that boundary. + if status == "success" and _dock != null and _dock.has_method("notify_self_update_success"): + _dock.notify_self_update_success(from_version) + + + + +func _exit_tree() -> void: + ## Registered before the headless guard in _enter_tree, so it must be + ## removed before the headless early-return here too. + if _export_plugin != null: + remove_export_plugin(_export_plugin) + _export_plugin = null + + if _headless_disabled: + _server_started_this_session = false + _headless_disabled = false + return + + if _custom_tool_registry != null: + _custom_tool_registry.clear() + _custom_tool_registry = null + + _custom_tool_service_locator = null + + ## Outer-to-inner teardown. Dispatcher Callables hold RefCounted handlers + ## alive past the point where Godot reloads their class_name scripts — the + ## first post-reload call into a typed-array-holding handler (e.g. + ## McpGameLogBuffer._storage) then SIGSEGVs against a stale class descriptor. + ## See issue #46. + + # Stop inbound work first so _process can't enqueue new commands or + # null-deref log_buffer on the next tick mid-teardown. + if _connection: + _connection.teardown() + + # Drop the dispatcher's Callables AND its lazily-constructed handler + # instances (#736: handlers live in the dispatcher's cache now). Handler + # destructors run here, while their scripts are still loaded. + if _dispatcher: + _dispatcher.clear() + if _vision_routing: + _vision_routing.shutdown() + _vision_routing = null + + if _dock: + remove_control_from_docks(_dock) + _dock.queue_free() + _dock = null + if _connection: + _connection.queue_free() + _connection = null + if _debugger_plugin: + remove_debugger_plugin(_debugger_plugin) + _debugger_plugin = null + + ## Detach the editor logger BEFORE nulling the buffer. After remove_logger + ## returns, Godot guarantees no further virtual calls — so the logger's + ## next access to `_buffer` (if any in flight) lands on a still-live + ## ref-counted buffer, not a freed one. + _detach_editor_logger() + + _dispatcher = null + _log_buffer = null + _game_log_buffer = null + _editor_log_buffer = null + _surfaced_error_tracker = null + + ## keep_server_on_exit (#800): the manager routes on the spawn-time + ## keep-alive flag (persisted in the managed-server record), NOT the + ## live setting — detach leaves the server for the next session (or a + ## same-session disable/enable cycle) to adopt. Explicit stops (dock + ## Restart, update reload) still kill via _stop_server. + _lifecycle.teardown_for_editor_exit() + ## Symmetric with prepare_for_update_reload: the static guard persists + ## across disable/enable within a single editor session, so the re-enabled + ## plugin instance's _start_server would short-circuit and never respawn. + ## Pre-#159 this was masked — the old kill path usually left Python alive + ## and the new instance adopted it on port 8000. Now that _stop_server is + ## deterministic, nothing is left to adopt and the reload hangs. + _server_started_this_session = false + print("MCP | plugin unloaded") + + +## Attach editor_logger.gd as a Godot logger so editor-process script +## errors (parse errors, @tool runtime errors, EditorPlugin errors, +## push_error/push_warning) flow into _editor_log_buffer for +## logs_read(source="editor"). +## +## Limitation called out in the issue: parse errors fired *before* the +## plugin's _enter_tree (e.g. during the editor's initial filesystem +## scan, or for scripts that fail on first project open) happen before +## add_logger is called and are not captured. There's no public API to +## drain the editor's already-emitted error history; rescanning the +## file would re-emit them but at the cost of disrupting the user's +## editing state, so we accept the gap. +func _attach_editor_logger() -> void: + _editor_logger = EditorLogger.new(_editor_log_buffer) + OS.add_logger(_editor_logger) + + +## Remove old Logger-quarantine artifacts left by extract-over-live +## self-update. Idempotent: existence-guarded, so it's a no-op on fresh +## installs and symlinked dev checkouts. +func _cleanup_legacy_logger_scripts() -> void: + var legacy_files := [ + "res://addons/godot_ai/runtime/logger_loader.gd", + "res://addons/godot_ai/runtime/logger_loader.gd.uid", + "res://addons/godot_ai/testing/script_error_capture_loader.gd", + "res://addons/godot_ai/testing/script_error_capture_loader.gd.uid", + ] + for res_path in legacy_files: + if FileAccess.file_exists(res_path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(res_path)) + var legacy_dirs := [ + "res://addons/godot_ai/runtime/loggers", + "res://addons/godot_ai/testing/loggers", + ] + for res_path in legacy_dirs: + var absolute := ProjectSettings.globalize_path(res_path) + if DirAccess.dir_exists_absolute(absolute): + _remove_dir_recursive_absolute(absolute) + + +static func _remove_dir_recursive_absolute(path: String) -> void: + var dir := DirAccess.open(path) + if dir == null: + return + dir.list_dir_begin() + var name := dir.get_next() + while not name.is_empty(): + var child := path.path_join(name) + if dir.current_is_dir(): + _remove_dir_recursive_absolute(child) + else: + DirAccess.remove_absolute(child) + name = dir.get_next() + dir.list_dir_end() + DirAccess.remove_absolute(path) + + +func _detach_editor_logger() -> void: + if _editor_logger != null: + OS.remove_logger(_editor_logger) + _editor_logger = null + + +## Register the game-side autoload on plugin enable. Runs the helper inside +## the game process so the editor-side debugger plugin can request +## framebuffer captures over EngineDebugger messages. Removed on +## _disable_plugin so disabling the plugin leaves project.godot clean. +func _enable_plugin() -> void: + if _mcp_disabled_for_headless_launch(): + return + _ensure_game_helper_autoload() + + +static func _mcp_disabled_for_headless_launch() -> bool: + return _mcp_disabled_for_headless( + OS.get_cmdline_args(), + DisplayServer.get_name(), + OS.get_environment("GODOT_AI_ALLOW_HEADLESS") + ) + + +static func _mcp_disabled_for_headless(args: PackedStringArray, display_name: String, allow_value: String) -> bool: + if McpSettings.truthy(allow_value): + return false + return _args_request_headless(args) or display_name.to_lower() == "headless" + + +static func _args_request_headless(args: PackedStringArray) -> bool: + for i in range(args.size()): + var arg := args[i] + if arg == "--headless": + return true + if arg == "--display-driver" and i + 1 < args.size() and args[i + 1] == "headless": + return true + if arg.begins_with("--display-driver=") and arg.get_slice("=", 1) == "headless": + return true + return false + + + + +func _disable_plugin() -> void: + var key := "autoload/" + GAME_HELPER_AUTOLOAD_NAME + if not ProjectSettings.has_setting(key): + return + ProjectSettings.clear(key) + ProjectSettings.save() + + +func _ensure_game_helper_autoload() -> void: + ## Write the autoload directly to ProjectSettings and save immediately. + ## EditorPlugin.add_autoload_singleton only mutates in-memory settings — + ## the on-disk project.godot is only persisted when the editor saves + ## (e.g. on quit). CI spawns the game subprocess before any save fires, + ## so the child process never sees the autoload and the capture times + ## out. Mirror AutoloadHandler's pattern: set_setting + save(). + var key := "autoload/" + GAME_HELPER_AUTOLOAD_NAME + var value := "*" + GAME_HELPER_AUTOLOAD_PATH # "*" prefix = singleton + if ProjectSettings.get_setting(key, "") == value: + return ## already registered with the right target + ProjectSettings.set_setting(key, value) + ProjectSettings.set_initial_value(key, "") + ProjectSettings.set_as_basic(key, true) + var err := ProjectSettings.save() + if err != OK: + push_warning("MCP: failed to save project.godot after registering %s autoload (error %d)" + % [GAME_HELPER_AUTOLOAD_NAME, err]) + + +func _startup_trace_begin() -> void: + _startup_trace_enabled = ClientConfigurator.startup_trace_enabled() + if not _startup_trace_enabled: + return + _startup_trace_start_ms = Time.get_ticks_msec() + _startup_trace_last_ms = _startup_trace_start_ms + _startup_trace_netsh_start_count = WindowsPortReservation.netsh_query_count() + _startup_trace_counters.clear() + for counter in STARTUP_TRACE_COUNTER_NAMES: + _startup_trace_counters[counter] = 0 + print( + "MCP startup trace | begin platform=%s http_port=%d ws_port=%d" + % [ + OS.get_name(), + ClientConfigurator.http_port(), + ClientConfigurator.ws_port(), + ] + ) + + +func _startup_trace_count(counter: String, amount: int = 1) -> void: + if not _startup_trace_enabled: + return + _startup_trace_mutex.lock() + _startup_trace_counters[counter] = int(_startup_trace_counters.get(counter, 0)) + amount + _startup_trace_mutex.unlock() + + +func _startup_trace_phase(name: String) -> void: + if not _startup_trace_enabled: + return + var now := Time.get_ticks_msec() + print( + "MCP startup trace | phase=%s delta_ms=%d total_ms=%d" + % [name, now - _startup_trace_last_ms, now - _startup_trace_start_ms] + ) + _startup_trace_last_ms = now + + +func _startup_trace_finish(path: String) -> void: + if not _startup_trace_enabled: + return + var now := Time.get_ticks_msec() + ## Same lock as _startup_trace_count — a worker probe may still be + ## bumping counters while this reads/writes the shared dictionary. + _startup_trace_mutex.lock() + _startup_trace_counters["netsh"] = ( + WindowsPortReservation.netsh_query_count() - _startup_trace_netsh_start_count + ) + var counters_snapshot: Dictionary = _startup_trace_counters.duplicate() + _startup_trace_mutex.unlock() + print( + "MCP startup trace | done path=%s total_ms=%d counters=%s" + % [path, now - _startup_trace_start_ms, str(counters_snapshot)] + ) + + +func _start_server() -> void: + ## Fire-and-forget: the walk is a coroutine in production (#678). Its + ## completion continuation must NOT live in this method — a reload can + ## free this plugin while the walk is suspended, and resuming a freed + ## Node's coroutine errors out. The manager calls + ## `_finish_startup_trace_after_walk` on walk completion instead, + ## guarded by is_instance_valid. + _lifecycle.start_server() + + +## Called by the lifecycle manager when the (possibly suspended) startup +## walk completes — the point where the real startup outcome is known, so +## the trace 'done' line reports the true contended-port path and duration +## instead of a pre-walk placeholder (#682 review). +func _finish_startup_trace_after_walk() -> void: + var startup_path: String = str(_lifecycle.get_startup_path()) + _startup_trace_finish(startup_path if not startup_path.is_empty() else "loaded") + + +## Test-fixture shim — characterization tests in test_plugin_lifecycle +## reach for this instance method directly. Delegates to the manager's +## state-owning copy. +func _set_incompatible_server(live: Dictionary, expected_version: String, port: int) -> void: + _lifecycle._set_incompatible_server(live, expected_version, port) + + +## Static shim — kept on the plugin class because the characterization +## tests assert against `GodotAiPlugin._incompatible_server_message`. +## Implementation moved to ServerLifecycleManager. +static func _incompatible_server_message( + live: Dictionary, + expected_version: String, + port: int, + expected_ws_port: int +) -> String: + return ServerLifecycleManager._incompatible_server_message( + live, expected_version, port, expected_ws_port + ) + + +static func _server_version_compatibility( + actual_version: String, expected_version: String +) -> Dictionary: + return ServerLifecycleManager._server_version_compatibility( + actual_version, expected_version + ) + + +static func _server_status_compatibility( + actual_version: String, + expected_version: String, + actual_ws_port: int, + expected_ws_port: int, +) -> Dictionary: + return ServerLifecycleManager._server_status_compatibility( + actual_version, expected_version, actual_ws_port, expected_ws_port + ) + + +static func _managed_record_has_version_drift(record_version: String, current_version: String) -> bool: + return ServerLifecycleManager._managed_record_has_version_drift(record_version, current_version) + + +static func _probe_live_server_status(port: int, timeout_ms: int = SERVER_STATUS_PROBE_TIMEOUT_MS) -> Dictionary: + var result := { + "reachable": false, + "version": "", + "name": "", + "ws_port": 0, + "status_code": 0, + "error": "", + } + var client := HTTPClient.new() + var err := client.connect_to_host("127.0.0.1", port) + if err != OK: + result["error"] = "connect_%d" % err + return result + var deadline := Time.get_ticks_msec() + timeout_ms + while client.get_status() == HTTPClient.STATUS_RESOLVING or client.get_status() == HTTPClient.STATUS_CONNECTING: + client.poll() + if Time.get_ticks_msec() >= deadline: + result["error"] = "connect_timeout" + return result + OS.delay_msec(10) + if client.get_status() != HTTPClient.STATUS_CONNECTED: + result["error"] = "connect_status_%d" % client.get_status() + return result + err = client.request(HTTPClient.METHOD_GET, SERVER_STATUS_PATH, ["Accept: application/json"]) + if err != OK: + result["error"] = "request_%d" % err + return result + var body := PackedByteArray() + while true: + var status := client.get_status() + if status == HTTPClient.STATUS_REQUESTING: + client.poll() + elif status == HTTPClient.STATUS_BODY: + client.poll() + var chunk := client.read_response_body_chunk() + if chunk.size() > 0: + body.append_array(chunk) + elif status == HTTPClient.STATUS_CONNECTED: + break + else: + result["error"] = "response_status_%d" % status + return result + if Time.get_ticks_msec() >= deadline: + result["error"] = "response_timeout" + return result + OS.delay_msec(10) + var response_code := client.get_response_code() + result["status_code"] = response_code + if response_code != 200: + result["error"] = "http_%d" % response_code + return result + var parsed = JSON.parse_string(body.get_string_from_utf8()) + if not (parsed is Dictionary): + result["error"] = "invalid_json" + return result + result.merge(_project_status_payload(parsed), true) + return result + + +## Project a parsed `/godot-ai/status` body into the probe's result shape. +## +## Extracted from the probe so it can be tested against a real payload. The +## probe is a whitelist — a field the server publishes does not reach callers +## unless it is copied here — and that is silent: the consumer just sees a +## missing key. #824's lease check shipped reading `active_lease_count` while +## this projection dropped it, so the branch was dead on every platform, and +## the tests could not see it because they hand-built the result dict this +## function is supposed to produce. Add new fields here, and cover them with a +## projection test rather than a fabricated `live_status`. +static func _project_status_payload(parsed: Dictionary) -> Dictionary: + var projected := { + "reachable": true, + "name": str(parsed.get("name", "")), + "version": _extract_server_version(parsed), + "ws_port": int(parsed.get("ws_port", 0)), + ## `package_path` was added in v2.4.4 (#416) so the dock's + ## "Incompatible server" banner can name the source of a version + ## skew. Older servers omit it; treat the missing field as "". + "package_path": str(parsed.get("package_path", "")), + } + ## #824: advisory attach-lease count, consumed by teardown to decide + ## detach-vs-kill. Absent stays absent rather than defaulting to 0, so + ## `ServerLifecycleManager.active_lease_count` keeps distinguishing "backend + ## too old to publish this" from "backend reports zero leases" — both stop + ## the server, but only one of them is a compatibility statement. + ## Anything that is not a finite whole number is dropped, for the same + ## reason the value is clamped downstream: a malformed count must not read + ## as occupancy and keep a server alive. Godot parses every JSON number as + ## a float, so the whole-number test is what distinguishes a real count + ## from junk — truncating 1.5 to 1 would manufacture a held lease. + var raw: Variant = parsed.get("active_lease_count") + if raw is int or raw is float: + var numeric := float(raw) + if is_finite(numeric) and numeric == floor(numeric): + projected["active_lease_count"] = int(numeric) + return projected + + +func _probe_live_server_status_for_port(port: int) -> Dictionary: + _startup_trace_count("http_status_probe") + return _probe_live_server_status(port) + + +static func _extract_server_version(payload: Dictionary) -> String: + var version := str(payload.get("server_version", "")) + if version.is_empty(): + version = str(payload.get("version", "")) + return version + + +static func _live_status_identifies_godot_ai(live: Dictionary) -> bool: + return ServerLifecycleManager._live_status_identifies_godot_ai(live) + + +func _verified_status_version(live: Dictionary) -> String: + if not ServerLifecycleManager._live_status_identifies_godot_ai(live): + return "" + return str(live.get("version", "")) + + +func _verified_status_ws_port(live: Dictionary) -> int: + if not ServerLifecycleManager._live_status_identifies_godot_ai(live): + return 0 + return int(live.get("ws_port", 0)) + + +func _refresh_dock_client_statuses() -> bool: + if _dock == null: + return false + if not _dock.has_method("_refresh_all_client_statuses"): + return false + _dock.call("_refresh_all_client_statuses") + return true + + +## Test-fixture shim — characterization tests in test_plugin_lifecycle +## still drive the first-writer-wins terminal-diagnosis behaviour through +## this method. Delegates to the manager's `set_terminal_diagnosis` +## (which preserves the same first-writer-wins contract). +func _set_spawn_state(state: int) -> void: + _lifecycle.set_terminal_diagnosis(state) + + +## Arm the one-shot connection watcher. Called from `_start_server`'s +## FOREIGN_PORT branch: we flagged the diagnostic preemptively assuming +## the port holder doesn't speak MCP, but if it turns out to be another +## editor's server our WebSocket will open and we need to retract the +## diagnostic. +## +## We intentionally poll `_connection.is_connected` from `_process` +## instead of wiring a new signal on McpConnection. A signal added in the +## same release as a new consumer would be another shape-coupled update: +## old two-phase runners can parse the consumer while the McpConnection +## Script object still reflects v(N). Polling only reads `is_connected` +## (present on every shipped McpConnection), so old-runner upgrade windows +## do not depend on a same-release signal addition. +## +## The watch self-disarms after SPAWN_GRACE_MS so per-frame cost drops +## back to zero if it is ever armed by a legacy adoption path. +func _watch_for_adoption_confirmation() -> void: + _lifecycle.arm_adoption_watch() + _update_process_enabled() + + +func _arm_server_version_check() -> void: + ## `arm_version_check` resolves an empty expected via the plugin + ## version, so we can pass the raw field value through. + _lifecycle.arm_version_check(_connection, str(_lifecycle._server_expected_version)) + _update_process_enabled() + + +func _update_process_enabled() -> void: + if _lifecycle == null: + set_process(false) + return + set_process( + _lifecycle.get_adoption_watch_deadline_ms() > 0 + or _lifecycle.is_awaiting_server_version() + ) + + +func _process(_delta: float) -> void: + ## Guard: during script-reload / dual-plugin enable races `_lifecycle` + ## can be null while process is still armed — spam would otherwise flood + ## the Output dock every frame. + if _lifecycle == null: + set_process(false) + return + var now := Time.get_ticks_msec() + var version_check = _lifecycle.get_version_check() + if version_check != null: + version_check.tick(now) + _lifecycle.tick_adoption_watch(now) + _update_process_enabled() + + +## A WebSocket opening only proves the occupant speaks enough of the editor +## protocol to accept a session. Compatibility is decided by the server +## version in `handshake_ack`, so this only arms that check. +func _on_connection_established() -> void: + if _lifecycle.get_state() == ServerStateScript.FOREIGN_PORT: + _arm_server_version_check() + + +## Test-fixture shim — characterization tests poke the verified path +## directly. Delegates to the version-check seam; the manager resolves +## an empty expected version via `_resolve_expected_version`. +func _on_server_version_verified(version: String) -> void: + _lifecycle.handle_server_version_verified( + str(_lifecycle._server_expected_version), version + ) + _update_process_enabled() + + +## Test-fixture shim — same shape as `_on_server_version_verified`. +func _on_server_version_unverified() -> void: + _lifecycle.handle_server_version_unverified( + str(_lifecycle._server_expected_version) + ) + _update_process_enabled() + + +## Start a 1s-tick timer that watches the spawned server through its cold-start +## window, then for SERVER_WATCH_MS after pid-file publication. If the process +## dies inside the active window we drain the captured pipes and mark the server +## as crashed so the dock can surface what went wrong. After the window expires +## we close the pipes so they don't pin file descriptors or fill their kernel +## buffers. See #146 and #896. +func _start_server_watch() -> void: + _stop_server_watch() + _server_watch_timer = Timer.new() + _server_watch_timer.wait_time = 1.0 + _server_watch_timer.one_shot = false + _server_watch_timer.timeout.connect(_check_server_health) + add_child(_server_watch_timer) + _server_watch_timer.start() + + +func _stop_server_watch() -> void: + if _server_watch_timer != null: + _server_watch_timer.stop() + _server_watch_timer.queue_free() + _server_watch_timer = null + + +func _check_server_health() -> void: + _lifecycle.check_server_health() + + +## True when the first spawn looks like a stale-uvx-index failure and we +## haven't already retried. Fail signal: launcher process already declared +## dead by the caller, pid-file was never written (Python never got to +## argparse), and we're on the uvx tier (the only tier where `--refresh` +## means anything). Bug #172 — after a fresh PyPI publish, uvx's local +## index metadata keeps saying the new version doesn't exist for ~10 min, +## which cascaded into an infinite reconnect loop pre-#171. Retry-at-spawn +## catches every entry path (Update, Reload Plugin, Reconnect, editor +## restart, crash recovery) — unlike the older Update-only precheck. +func _should_retry_with_refresh() -> bool: + return _retry_with_refresh_allowed( + _lifecycle._refresh_retried, + ClientConfigurator.get_server_launch_mode(), + _read_pid_file(), + ) + + +## Pure decision helper — environment-state readers stay in the instance +## method above, the logic lives here so tests can drive the three inputs +## directly without spoofing static caches or pid-files on disk. +static func _retry_with_refresh_allowed(already_retried: bool, launch_mode: String, pid_from_file: int) -> bool: + return ( + not already_retried + and launch_mode == "uvx" + and pid_from_file == 0 + ) + + +func _respawn_with_refresh() -> void: + _lifecycle.respawn_with_refresh() + + +## Snapshot of the server-spawn outcome for the dock. +## +## `state` is one of the `McpServerState.*` int constants; the dock owns +## the UI copy per state via its own `_crash_body_for_state`. `exit_ms` +## is only meaningful for `CRASHED`. +func get_server_status() -> Dictionary: + return _lifecycle.get_status_dict() + + +## Diagnostic accessor for the dock's ownership label. Positive = a PID this +## plugin instance spawned (or re-acquired via the managed record); -1 = an +## adopted external/attach-owned backend. Display only — adoption transfers +## end-of-life responsibility, so this value is never kill proof (#669). +func get_server_pid() -> int: + return _lifecycle.get_server_pid() + + +func get_resolved_ws_port() -> int: + return _resolved_ws_port + + +func _set_resolved_ws_port(port: int) -> void: + _ws_port_resolution_published = true + _resolved_ws_port = port + if _connection != null: + _connection.ws_port = port + + +## Pure decision helper — environment-state reads (the published flag, the +## EditorSettings port) stay in `_enter_tree`; the logic lives here so tests +## can drive the three inputs directly without mutating the shared statics. +static func _startup_ws_port_seed( + resolution_published: bool, + session_ws_port: int, + configured_ws_port: int +) -> int: + return session_ws_port if resolution_published else configured_ws_port + + +func _resolve_ws_port() -> int: + return PortResolver.resolve_ws_port( + ClientConfigurator.ws_port(), + ClientConfigurator.MAX_PORT, + _log_buffer, + ) + + +## Test-compat shim — characterization tests call this static directly. +static func _resolved_ws_port_for_existing_server( + record_ws_port: int, + record_version: String, + current_version: String, + fresh_resolved: int +) -> int: + return PortResolver.resolved_ws_port_for_existing_server( + record_ws_port, + record_version, + current_version, + fresh_resolved, + ) + + +static func _resolve_ws_port_from_output( + configured_port: int, + netsh_output: String, + span: int = 2048 +) -> int: + return PortResolver.resolve_ws_port_from_output( + configured_port, + netsh_output, + ClientConfigurator.MAX_PORT, + span, + ) + + +## Plugin-level shim around the resolver — keeps the startup-trace +## counter wiring and the `_ProofPlugin` override hook on the plugin. +## The scrape takes `_startup_trace_count` directly so the counter names +## track the scraper that actually ran (Windows can fall through netstat +## → PowerShell; the fallback used to hide under the `netstat` count). +func _is_port_in_use(port: int) -> bool: + if PortResolver.can_bind_local_port(port): + ## POSIX can still have an IPv6 wildcard listener on this port + ## even when an IPv4 loopback bind succeeds. Confirm through + ## lsof so startup and kill-path discovery agree. + if OS.get_name() != "Windows": + return PortResolver.is_port_in_use_via_scrape(port, _startup_trace_count) + return false + return PortResolver.is_port_in_use_via_scrape(port, _startup_trace_count) + + +## Pass `_startup_trace_count` so the resolver bumps the right counter +## per scraper that actually ran (Windows can fall through netstat → +## PowerShell — counting both unconditionally would over-report). +func _find_pid_on_port(port: int) -> int: + return PortResolver.find_pid_on_port(port, _startup_trace_count) + + +func _find_all_pids_on_port(port: int) -> Array[int]: + return PortResolver.find_all_pids_on_port(port, _startup_trace_count) + + +static func _execute_windows_powershell(script: String, output: Array) -> int: + return PortResolver.execute_windows_powershell(script, output) + + +static func _windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]: + return PortResolver.windows_listener_pids_from_execute_result(exit_code, output) + + +static func _windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool: + return PortResolver.windows_listener_execute_result_in_use(exit_code, output) + + +static func _parse_lsof_pids(raw: String) -> Array[int]: + return PortResolver.parse_lsof_pids(raw) + + +static func _parse_pid_lines(raw: String) -> Array[int]: + return PortResolver.parse_pid_lines(raw) + + +## Find the managed server PID deterministically: prefer the pid-file +## the Python server writes on startup (see runtime_info.py), fall back +## to scraping `netstat -ano` / `lsof` only when the file is missing or +## stale. This is the replacement for raw port-scraping: on Windows the +## uvx launcher PID doesn't cover the Python child, and netstat parsing +## is fragile. +## +## Returns 0 when no server can be identified. +func _find_managed_pid(port: int) -> int: + var pid := _read_pid_file() + if pid > 0 and _pid_alive(pid): + return pid + return _find_pid_on_port(port) + + +## `live` is the result of a prior `_probe_live_server_status_for_port` +## call that the caller already has on hand. When non-empty it short- +## circuits the internal probe at the bottom of this helper, so a single +## `_start_server` invocation that probes once at the top can thread the +## same snapshot through compatibility check + recovery without paying +## for a second ~500 ms localhost HTTPClient poll loop. Default `{}` +## preserves the historical behavior for callers outside the spawn flow +## (`can_recover_incompatible_server`, the dock's UI buttons), where a +## fresh probe is the right thing. +## `record_override`: a managed-server record snapshot the caller already +## read. Non-empty skips the internal `_read_managed_server_record()` — +## required when this helper runs on a worker thread (#678), because the +## record lives in EditorSettings, which is main-thread-only. `{}` keeps +## the historical read-it-here behavior for synchronous callers +## (`_read_managed_server_record` never returns a bare `{}`, so the +## sentinel is unambiguous). +func _evaluate_strong_port_occupant_proof(port: int, live: Dictionary = {}, record_override: Dictionary = {}) -> Dictionary: + var result := {"proof": "", "pids": []} + var listener_pids := _find_all_pids_on_port(port) + if listener_pids.is_empty(): + return result + + var record: Dictionary = record_override if not record_override.is_empty() else _read_managed_server_record() + var record_pid := int(record.get("pid", 0)) + var record_version := str(record.get("version", "")) + + if record_pid > 1 and record_pid != OS.get_process_id(): + ## Brand-verify the recorded PID before trusting it as a kill target. + ## A recorded PID can outlive the server it named and be recycled by + ## the kernel for an unrelated process that happens to bind the same + ## port — without the cmdline brand gate (the same one the + ## `pidfile_listener` branch enforces) that process could be killed. + ## See #525. + if ( + listener_pids.has(record_pid) + and _pid_alive_for_proof(record_pid) + and _pid_cmdline_is_godot_ai_for_proof(record_pid) + ): + return {"proof": "managed_record", "pids": [record_pid]} + + var legacy_targets := _legacy_pidfile_kill_targets(port, listener_pids) + if not legacy_targets.is_empty(): + return {"proof": "pidfile_listener", "pids": legacy_targets} + + var current_live: Dictionary = live if not live.is_empty() else _probe_live_server_status_for_port(port) + if ( + _live_status_identifies_godot_ai(current_live) + and not record_version.is_empty() + and str(current_live.get("version", "")) == record_version + ): + ## Brand-check every listener before returning it as a kill target + ## (#686): the /godot-ai/status match proves *a* godot-ai server owns + ## the port, but `listener_pids` is a raw scrape that can include an + ## unrelated process sharing the port number (e.g. a ::1-only + ## listener lsof reports alongside our IPv4 one). The other two tiers + ## brand-check every target (#525); this tier feeds the fully + ## automatic start_server drift-kill path, so it must too. + var branded_listeners: Array[int] = [] + for pid in listener_pids: + var listener_pid := int(pid) + if _pid_cmdline_is_godot_ai_for_proof(listener_pid): + branded_listeners.append(listener_pid) + if not branded_listeners.is_empty(): + return {"proof": "status_matches_record", "pids": branded_listeners} + + return result + + +## See `_evaluate_strong_port_occupant_proof` for the `live` and +## `record_override` contracts. Threads both through the strong-proof +## delegate so neither helper probes when the caller already knows the +## port-owner status, and so callers running this on a worker thread +## (#712) can inject the EditorSettings record read on the main thread. +func _evaluate_recovery_port_occupant_proof( + port: int, live: Dictionary = {}, record_override: Dictionary = {} +) -> Dictionary: + var proof := _evaluate_strong_port_occupant_proof(port, live, record_override) + if not str(proof.get("proof", "")).is_empty(): + return proof + + var current_live: Dictionary = live if not live.is_empty() else _probe_live_server_status_for_port(port) + if _live_status_identifies_godot_ai(current_live): + return {"proof": "status_name", "pids": _find_all_pids_on_port(port)} + + return {"proof": "", "pids": []} + + +## Seam over the static pre-warm so lifecycle recovery flows can fire it +## through the host (`_host._prewarm_server_package(...)`) and test stubs +## can record the call instead of spawning a real uvx process. Worker-safe: +## the static touches only CliFinder (mutex-guarded) and OS.create_process. +func _prewarm_server_package(version: String) -> int: + return ClientConfigurator.prewarm_server_package(version) + + +func _recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool: + ## `await` because the manager method is a coroutine in production + ## (#678); with `defer_blocking_work` off it completes synchronously + ## and this await is a pass-through. + return await _lifecycle.recover_strong_port_occupant(port, wait_s, pre_kill_live) + + +func _legacy_pidfile_kill_targets(_port: int, listener_pids: Array[int]) -> Array[int]: + var targets: Array[int] = [] + var pidfile_pid := _read_pid_file_for_proof() + if pidfile_pid <= 1 or pidfile_pid == OS.get_process_id(): + return targets + ## An alive, branded pid-file PID is sufficient ownership proof. Under + ## `uvicorn --reload` the reloader writes the pid-file but a child worker + ## binds the port, so `listener_pids` never contains the reloader PID. + ## Requiring `listener_pids.has(pidfile_pid)` here used to silently skip + ## the kill path for the entire reload-shaped server family. The branded + ## listener loop below still does the per-PID brand check so we never + ## kill an unrelated process that happens to share the port. + if not _pid_alive_for_proof(pidfile_pid) or not _pid_cmdline_is_godot_ai_for_proof(pidfile_pid): + return targets + + for pid in listener_pids: + if pid <= 1 or pid == OS.get_process_id(): + continue + ## Reuse the brand result already proven above when this listener is + ## the same PID as the pidfile — saves a parent-chain walk and a + ## shell-out (PowerShell on Windows, /proc on Linux, ps on macOS) per + ## startup proof evaluation. + if pid == pidfile_pid or _pid_cmdline_is_godot_ai_for_proof(pid): + targets.append(pid) + ## Also kill the reloader/launcher itself when it isn't already a listener. + ## Without this, `--reload` workers would be killed but their parent would + ## immediately respawn a replacement and the port would never free. + if not targets.has(pidfile_pid): + targets.append(pidfile_pid) + return targets + + +func _read_pid_file_for_proof() -> int: + return _read_pid_file() + + +func _pid_alive_for_proof(pid: int) -> bool: + return _pid_alive(pid) + + +func _pid_cmdline_is_godot_ai_for_proof(pid: int) -> bool: + return _pid_cmdline_is_godot_ai(pid) + + +static func _parse_windows_netstat_pid(stdout: String, port: int) -> int: + return PortResolver.parse_windows_netstat_pid(stdout, port) + + +static func _parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]: + return PortResolver.parse_windows_netstat_pids(stdout, port) + + +static func _parse_windows_netstat_listening(stdout: String, port: int) -> bool: + return PortResolver.parse_windows_netstat_listening(stdout, port) + + +static func _split_on_whitespace(s: String) -> PackedStringArray: + return PortResolver.split_on_whitespace(s) + + +static func _read_pid_file() -> int: + return PortResolver.read_pid_file() + + +static func _clear_pid_file() -> void: + PortResolver.clear_pid_file() + + +func _stop_server() -> void: + _lifecycle.stop_server() + + + + +## Clear the managed-server record and pid-file only if `port` is free. +## Returns true when state was cleared. Extracted from `_stop_server` so +## the "preserve on failed kill" contract is independently testable. +func _finalize_stop_if_port_free(port: int) -> bool: + if _is_port_in_use(port): + return false + _clear_managed_server_record() + _clear_pid_file() + return true + + +## Shared tail of the server CLI: transport, ports, and `--pid-file`. Both +## the initial spawn in `_start_server` and the `--refresh` retry in +## `_respawn_with_refresh` go through here so a new flag added in one place +## can't silently drop out of the other. +static func _build_server_flags(port: int, ws_port: int) -> Array[String]: + var flags: Array[String] = [] + flags.assign([ + "--transport", "streamable-http", + "--port", str(port), + "--ws-port", str(ws_port), + "--pid-file", ProjectSettings.globalize_path(SERVER_PID_FILE), + ]) + ## Append `--exclude-domains` only when the user has actually picked at + ## least one domain to drop. Skipping the empty case keeps spawns + ## compatible with older (pre-1.4.2) servers that don't know the flag — + ## relevant during staggered plugin/server upgrades in user-mode installs. + var excluded := ClientConfigurator.excluded_domains() + if not excluded.is_empty(): + flags.append("--exclude-domains") + flags.append(excluded) + ## LAN opt-in (#507, server core #421): pass `--allow-host` only when the + ## developer-mode Settings tab named at least one CIDR / bare IP. Skipping + ## the empty case keeps the default spawn byte-for-byte identical and + ## compatible with older servers that don't know the flag — same pattern + ## as `--exclude-domains` above. + var allow_hosts := ClientConfigurator.allow_hosts() + if not allow_hosts.is_empty(): + flags.append("--allow-host") + flags.append(allow_hosts) + return flags + + +## Returns true only when we can prove `pid`'s command line carries the +## `godot-ai` brand AND a server flag (`--pid-file` / `--transport`). Used by +## automatic kill paths (`_legacy_pidfile_kill_targets`) so a stale pidfile +## whose PID has been recycled by an unrelated listener can't hand us a +## kill target. If the OS lookup fails or returns an empty cmdline we +## conservatively return false — better to surface incompatible-server and +## let the user click Restart than to kill the wrong process. +func _pid_cmdline_is_godot_ai(pid: int) -> bool: + ## Walks up the parent chain so a uvicorn `--reload` worker whose + ## cmdline is just `multiprocessing.spawn` still matches when its + ## parent reloader carries the godot_ai brand. Bound the walk so a + ## hypothetical loop or runaway PPID can't stall the editor. + var current := pid + for _i in range(5): + if current <= 1: + return false + var cmd := "" + if OS.get_name() == "Windows": + cmd = _windows_pid_commandline(current) + else: + cmd = _posix_pid_commandline(current) + if _commandline_is_godot_ai_server(cmd): + return true + current = _pid_parent(current) + return false + + +func _pid_parent(pid: int) -> int: + if pid <= 1: + return 0 + if OS.get_name() == "Windows": + var output: Array = [] + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "Select-Object -ExpandProperty ParentProcessId" + ) % pid + _startup_trace_count("powershell") + if _execute_windows_powershell(script, output) != 0 or output.is_empty(): + return 0 + return int(str(output[0]).strip_edges()) + var output_posix: Array = [] + if OS.execute("ps", ["-o", "ppid=", "-p", str(pid)], output_posix, true) != 0 or output_posix.is_empty(): + return 0 + return int(str(output_posix[0]).strip_edges()) + + +static func _commandline_is_godot_ai_server(cmd: String) -> bool: + if cmd.is_empty(): + return false + var lower := cmd.to_lower() + ## The server is invoked with `--pid-file /godot_ai_server.pid`, + ## so the path itself contains "godot_ai". A naive substring brand + ## search would falsely match an unrelated process whose cmdline + ## happens to reference a similarly-named pidfile path. Strip the + ## value (but leave the bare flag for the has_flag check) before + ## brand matching. + var brand_search := _strip_pidfile_value(lower) + var has_brand := brand_search.find("godot-ai") >= 0 or brand_search.find("godot_ai") >= 0 + var has_flag := lower.find("--pid-file") >= 0 or lower.find("--transport") >= 0 + return has_brand and has_flag + + +static func _strip_pidfile_value(cmd: String) -> String: + var rx := RegEx.new() + ## Match `--pid-file=` and `--pid-file `; keep the bare + ## flag so the flag-presence check still succeeds for a real server. + if rx.compile("--pid-file(?:=|\\s+)\\S+") != OK: + return cmd + return rx.sub(cmd, "--pid-file ", true) + + +func _windows_pid_commandline(pid: int) -> String: + var output: Array = [] + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "Select-Object -ExpandProperty CommandLine" + ) % pid + _startup_trace_count("powershell") + var exit_code := _execute_windows_powershell(script, output) + if exit_code != 0 or output.is_empty(): + return "" + return str(output[0]) + + +## POSIX command-line lookup. Linux exposes `/proc//cmdline` as +## NUL-separated argv — read it directly so we avoid a `ps` fork on Linux +## and get the full argv rather than the truncated/quoted form some `ps` +## builds emit. Falls back to `ps -ww -p -o args=` on macOS / *BSD, +## which lack a Linux-style `/proc//cmdline`. Returns "" on failure +## so callers conservatively reject the PID rather than killing it blind. +func _posix_pid_commandline(pid: int) -> String: + var proc_path := "/proc/%d/cmdline" % pid + if FileAccess.file_exists(proc_path): + var f := FileAccess.open(proc_path, FileAccess.READ) + if f != null: + ## procfs pseudo-files report length 0 (the kernel generates + ## content on read). `get_length()` therefore returns 0 and + ## `get_buffer(0)` reads nothing. Read in chunks until EOF + ## instead. Cap at ARG_MAX-class bound so a hypothetically + ## misbehaving file can never stall the editor frame. + var bytes := PackedByteArray() + var max_bytes := 1 << 20 # 1 MiB + while bytes.size() < max_bytes: + var chunk := f.get_buffer(4096) + if chunk.is_empty(): + break + bytes.append_array(chunk) + if f.eof_reached(): + break + f.close() + ## /proc cmdline is NUL-separated argv; convert NULs to spaces + ## so the substring fingerprint matches the same way it does on + ## the Windows path. Empty (kernel threads, exited processes) + ## bubbles up as "" via the strip below. + for i in range(bytes.size()): + if bytes[i] == 0: + bytes[i] = 0x20 + return bytes.get_string_from_utf8().strip_edges() + ## `-ww` removes ps's column-width truncation so trailing flags like + ## --pid-file / --transport aren't dropped from the args= field. + ## Both procps (Linux) and BSD ps (macOS / *BSD) accept the + ## double-w form. + var output: Array = [] + var exit_code := OS.execute("ps", ["-ww", "-p", str(pid), "-o", "args="], output, true) + if exit_code != 0 or output.is_empty(): + return "" + return str(output[0]).strip_edges() + + +## True if the given PID corresponds to a live (non-zombie) process. +## POSIX uses `ps -o stat=` (see inline comment for the zombie rationale); +## Windows uses `tasklist`. Called by `_start_server` to distinguish a live +## managed server that outlived its editor from a stale EditorSettings +## record, and by `_check_server_health` to detect a fast-failing launcher. +static func _pid_alive(pid: int) -> bool: + return PortResolver.pid_alive(pid) + + +## Calls `_is_port_in_use` (not `PortResolver.wait_for_port_free`) so +## `_ProofPlugin` overrides keep driving the loop. +func _wait_for_port_free(port: int, timeout_s: float) -> void: + var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0) + while _is_port_in_use(port): + if Time.get_ticks_msec() >= deadline: + push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s]) + return + OS.delay_msec(100) + + +func _read_managed_server_record() -> Dictionary: + var es := EditorInterface.get_editor_settings() + if es == null: + return {"pid": 0, "version": "", "ws_port": 0, "ws_token": "", "keep_alive": false} + var pid: int = 0 + if es.has_setting(MANAGED_SERVER_PID_SETTING): + pid = int(es.get_setting(MANAGED_SERVER_PID_SETTING)) + var version: String = "" + if es.has_setting(MANAGED_SERVER_VERSION_SETTING): + version = str(es.get_setting(MANAGED_SERVER_VERSION_SETTING)) + var ws_port: int = 0 + if es.has_setting(MANAGED_SERVER_WS_PORT_SETTING): + ws_port = int(es.get_setting(MANAGED_SERVER_WS_PORT_SETTING)) + var ws_token: String = "" + if es.has_setting(MANAGED_SERVER_WS_TOKEN_SETTING): + ws_token = str(es.get_setting(MANAGED_SERVER_WS_TOKEN_SETTING)) + var keep_alive := false + if es.has_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING): + keep_alive = bool(es.get_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING)) + return { + "pid": pid, + "version": version, + "ws_port": ws_port, + "ws_token": ws_token, + "keep_alive": keep_alive, + } + + +func _write_managed_server_record(pid: int, version: String, keep_alive: bool = false) -> void: + var es := EditorInterface.get_editor_settings() + if es == null: + return + es.set_setting(MANAGED_SERVER_PID_SETTING, pid) + es.set_setting(MANAGED_SERVER_VERSION_SETTING, version) + es.set_setting(MANAGED_SERVER_WS_PORT_SETTING, _resolved_ws_port) + es.set_setting(MANAGED_SERVER_WS_TOKEN_SETTING, _ws_auth_token) + es.set_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING, keep_alive) + + +## Keep the in-memory token, the connection's handshake field, and (via the +## next _write_managed_server_record) the persisted record in one place so +## the three can't drift. Empty token = "send no auth_token field". +func _set_ws_auth_token(token: String) -> void: + _ws_auth_token = token + if _connection != null: + _connection.auth_token = token + + +func _clear_managed_server_record() -> void: + ## Drop the in-memory token together with the persisted one: a cleared + ## record means "no managed server", and a surviving static would make + ## the next handshake send a stale token — the exact present-but-wrong + ## shape a newer spawned server rejects with 4003. (Runs before the + ## es == null early return on purpose: the in-memory scrub must not + ## depend on EditorSettings being available.) + _set_ws_auth_token("") + var es := EditorInterface.get_editor_settings() + if es == null: + return + if es.has_setting(MANAGED_SERVER_PID_SETTING): + es.set_setting(MANAGED_SERVER_PID_SETTING, 0) + if es.has_setting(MANAGED_SERVER_VERSION_SETTING): + es.set_setting(MANAGED_SERVER_VERSION_SETTING, "") + if es.has_setting(MANAGED_SERVER_WS_PORT_SETTING): + es.set_setting(MANAGED_SERVER_WS_PORT_SETTING, 0) + if es.has_setting(MANAGED_SERVER_WS_TOKEN_SETTING): + es.set_setting(MANAGED_SERVER_WS_TOKEN_SETTING, "") + if es.has_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING): + es.set_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING, false) + + +func prepare_for_update_reload() -> void: + if _dispatcher != null: + # Stop accepting handler work and hand any live status worker to its + # frame-polled teardown coroutine. _exit_tree() calls clear() again; the + # second call is intentionally inert because the caches are empty. + _dispatcher.clear() + _lifecycle.prepare_for_update_reload() + + +func _adopt_compatible_server( + record_version: String, + current_version: String, + owner: int, + record_owns_listener: bool = false +) -> String: + return _lifecycle.adopt_compatible_server( + record_version, + current_version, + owner, + record_owns_listener + ) + + +static func _compatible_adoption_log_message( + owner_label: String, + owned_pid: int, + observed_owner_pid: int, + live_version: String, + live_ws_port: int, + current_version: String +) -> String: + if owner_label == "managed": + return "MCP | adopted managed server (PID %d, live v%s, WS %d, plugin v%s)" % [ + owned_pid, + live_version, + live_ws_port, + current_version + ] + return "MCP | adopted external server owner_pid=%d (live v%s, WS %d, plugin v%s)" % [ + observed_owner_pid, + live_version, + live_ws_port, + current_version + ] + + +## Hand the self-update over to a tiny runner that is not owned by this +## EditorPlugin. The runner keeps the editor process alive, but disables this +## plugin before extracting/scanning the new scripts so every plugin-owned +## instance tears down on pre-update bytecode and pre-update field storage. +func install_downloaded_update(zip_path: String, temp_dir: String, source_dock: Control) -> void: + prepare_for_update_reload() + + var detached_dock = null + if _dock != null and is_instance_valid(_dock): + detached_dock = _dock + remove_control_from_docks(_dock) + _dock = null + elif source_dock != null and is_instance_valid(source_dock): + detached_dock = source_dock + remove_control_from_docks(source_dock) + + var runner = UPDATE_RELOAD_RUNNER_SCRIPT.new() + var parent: Node = EditorInterface.get_base_control() + if parent == null: + parent = get_tree().root + parent.add_child(runner) + runner.start(zip_path, temp_dir, detached_dock) + + +func can_recover_incompatible_server() -> bool: + return _lifecycle.can_recover_incompatible_server() + + +func _resume_connection_after_recovery() -> void: + if _connection == null: + return + var state: int = _lifecycle.get_state() + if ( + _lifecycle.is_connection_blocked() + or ( + state != ServerStateScript.SPAWNING + and state != ServerStateScript.READY + ) + ): + return + _connection.connect_blocked = false + _connection.connect_block_reason = "" + _connection.server_version = "" + _connection.set_process(true) + _arm_server_version_check() + + +func recover_incompatible_server(user_initiated: bool = true, stale_version: String = "") -> bool: + ## A user's click (the dock's Restart) authorizes the bounded + ## stale-occupant retry for this episode, so a bridge respawning the old + ## version and winning the post-kill bind race gets re-killed + ## automatically instead of dead-ending the click in a terminal state. + ## The automatic triggers (post-update handshake mismatch, fast-exit + ## re-walk) call with `user_initiated=false` and only SPEND from the + ## already-authorized budget — re-arming there would unbound the + ## kill/respawn loop against a persistent respawner. + if user_initiated: + _lifecycle.authorize_stale_recovery() + ## `await` because the manager's recovery is a coroutine in production + ## (#678): `_resume_connection_after_recovery` gates on the post-walk + ## state, so it must not run until the respawn walk has completed. With + ## `defer_blocking_work` off this completes synchronously. + if not await _lifecycle.recover_incompatible_server(stale_version): + return false + _resume_connection_after_recovery() + return true + + +## Kill whichever process is holding `http_port()` right now — by resolving +## the port-owning PID via pid-file / netstat / lsof, independent of whether +## we ever set the manager's `_server_pid` — then clear ownership state +## and respawn via the lifecycle manager. The dock's version-mismatch +## banner wires here when the plugin adopted a foreign server whose +## `server_version` drifts from the current plugin version. +func force_restart_server() -> void: + _lifecycle.force_restart_server() + + +## Single entry point for the dock's primary "Restart Dev Server" button. +## The user clicking Restart is explicit consent to take over the HTTP port, +## so this is aggressive: any PID holding the port gets killed (managed, +## branded-dev, or orphan multiprocessing.spawn workers whose parent died +## so brand detection misses them). After the port frees we spawn a fresh +## --reload dev server. Returns true if a kill happened, false if the port +## was already free and we just spawned. +func force_restart_or_start_dev_server() -> bool: + var port := ClientConfigurator.http_port() + var killed := false + if has_managed_server(): + _lifecycle.reset_for_force_restart() + if _is_port_in_use(port): + _kill_processes_and_windows_spawn_children(_find_all_pids_on_port(port)) + killed = true + if killed: + ## OS.kill returns synchronously but uvicorn's listener can take + ## longer to release the port. Without this wait, start_dev_server's + ## fixed 500ms timer races the old shutdown and the new --reload + ## spawn fails to bind. + _wait_for_port_free(port, 5.0) + start_dev_server() + return killed + + +func start_dev_server() -> void: + ## Start a dev server with --reload that survives plugin reloads. + ## Kills any managed server first, waits for the port to free, then spawns. + ## + ## PYTHONPATH handling: when `res://` sits inside a checkout that owns a + ## `src/godot_ai/` (root repo or a git worktree), prepend that `src/` to + ## PYTHONPATH so `import godot_ai` and uvicorn's `reload_dirs` both pick + ## up *this* tree's source rather than the root repo's editable install. + ## On the root repo the path matches the installed package, so this is a + ## no-op; in a worktree it's what makes `--reload` actually watch the + ## worktree's Python. See #84. + _stop_server() + get_tree().create_timer(0.5).timeout.connect(func(): + var server_cmd := ClientConfigurator.get_server_command() + if server_cmd.is_empty(): + push_warning("MCP | could not find server command for dev server") + return + + var cmd: String = server_cmd[0] + _set_resolved_ws_port(_resolve_ws_port()) + var inner_args: Array[String] = [] + inner_args.assign(server_cmd.slice(1)) + inner_args.append_array([ + "--transport", "streamable-http", + "--port", str(ClientConfigurator.http_port()), + "--ws-port", str(_resolved_ws_port), + "--reload", + ]) + + var worktree_src := ClientConfigurator.find_worktree_src_dir(ProjectSettings.globalize_path("res://")) + var prev_pythonpath := OS.get_environment("PYTHONPATH") + if not worktree_src.is_empty(): + var sep := ";" if OS.get_name() == "Windows" else ":" + var new_pp := worktree_src if prev_pythonpath.is_empty() else worktree_src + sep + prev_pythonpath + OS.set_environment("PYTHONPATH", new_pp) + + var injected_telemetry: bool = _lifecycle._inject_telemetry_env() + var pid := OS.create_process(cmd, inner_args) + if injected_telemetry: + OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") + + ## Restore PYTHONPATH immediately — the spawned child has already + ## copied the env, so the editor's own process state returns to + ## baseline. Leaving it set would leak to any later OS.create_process + ## from unrelated paths. + if not worktree_src.is_empty(): + if prev_pythonpath.is_empty(): + OS.unset_environment("PYTHONPATH") + else: + OS.set_environment("PYTHONPATH", prev_pythonpath) + + if pid > 0: + ## Match `server_lifecycle.gd::start_server`'s log wording — + ## "prefix" since we prepended to any pre-existing PYTHONPATH, + ## not replaced it. See #429 review. + var suffix := " (PYTHONPATH prefix=%s)" % worktree_src if not worktree_src.is_empty() else "" + print("MCP | started dev server with --reload (PID %d): %s %s%s" % [pid, cmd, " ".join(inner_args), suffix]) + else: + push_warning("MCP | failed to start dev server") + ) + + +func stop_dev_server() -> void: + ## Stop any server running on the HTTP port (by port, not PID). + ## Used for dev servers whose PID we don't track across reloads. + if _lifecycle.get_server_pid() > 0: + # We have a managed server — use normal stop + _stop_server() + return + ## A suspended startup walk holds pre-kill probe results; without this + ## it can resume against the listener we are about to kill and adopt a + ## dead server. + _lifecycle._invalidate_async_startup() + var port := ClientConfigurator.http_port() + var candidates: Array[int] = [] + for pid in _find_all_pids_on_port(port): + var candidate := int(pid) + if _pid_cmdline_is_godot_ai(candidate): + candidates.append(candidate) + var killed := _kill_processes_and_windows_spawn_children(candidates) + if not killed.is_empty(): + print("MCP | stopped dev server on port %d" % port) + + +## `verify_brand`: re-check `pid_alive` + the godot-ai cmdline brand +## immediately before the kill (#686). Pass true when the proof that +## nominated `pids` was evaluated in an earlier scheduling window (e.g. +## `recover_strong_port_occupant`'s proof runs in one `_run_blocking` task +## and the kill in a second, with main-thread frames in between) — a branded +## target that exits in that gap can have its PID recycled to an innocent +## process. Default false preserves the intentionally-unbranded call sites +## (the dock's explicit-consent Restart button, orphan spawn workers whose +## parent died so brand detection misses them). +func _kill_processes_and_windows_spawn_children(pids: Array[int], verify_brand: bool = false) -> Array[int]: + var unique: Array[int] = [] + for pid in pids: + if pid <= 0 or unique.has(pid): + continue + if verify_brand and not (_pid_alive_for_proof(pid) and _pid_cmdline_is_godot_ai_for_proof(pid)): + continue + unique.append(pid) + if OS.get_name() == "Windows": + for child_pid in _find_windows_spawn_children(unique): + if not unique.has(child_pid): + unique.append(child_pid) + var killed: Array[int] = [] + for pid in unique: + if OS.get_name() == "Windows": + var output: Array = [] + var exit_code := OS.execute("taskkill", ["/PID", str(pid), "/T", "/F"], output, true) + if exit_code == 0 or not _pid_alive(pid): + killed.append(pid) + else: + ## Mirror the Windows branch: only report the PID as killed if + ## the kill succeeded or the process is verifiably gone. + if OS.kill(pid) == OK or not _pid_alive(pid): + killed.append(pid) + return killed + + +func _find_windows_spawn_children(parent_pids: Array[int]) -> Array[int]: + if parent_pids.is_empty(): + var empty: Array[int] = [] + return empty + var found: Array[int] = [] + for parent_pid in parent_pids: + var output: Array = [] + var script := ( + "Get-CimInstance Win32_Process | " + + "Where-Object { $_.CommandLine -like '*spawn_main(parent_pid=%d*' } | " + + "ForEach-Object { $_.ProcessId }" + ) % parent_pid + _startup_trace_count("powershell") + var exit_code := _execute_windows_powershell(script, output) + if exit_code != 0 or output.is_empty(): + continue + for pid in _parse_pid_lines(str(output[0])): + if not found.has(pid): + found.append(pid) + return found + + +func is_dev_server_running() -> bool: + ## Returns true if a branded dev server is running on the HTTP port + ## that we didn't start as managed. + if _lifecycle.get_server_pid() > 0: + return false + for pid in _find_all_pids_on_port(ClientConfigurator.http_port()): + if _pid_cmdline_is_godot_ai(int(pid)): + return true + return false + + +func has_managed_server() -> bool: + ## Returns true if the plugin is currently managing a server process it spawned. + return _lifecycle.has_managed_server() + + +func can_restart_managed_server() -> bool: + ## Restart is allowed only when we have ownership proof. A live PID + ## means this plugin spawned/adopted a managed server; a non-empty + ## managed record is the cross-session proof used by the drift branch. + return _lifecycle.can_restart_managed_server() + + +func _on_custom_tools_changed() -> void: + if _connection == null: + push_warning("MCP | connection isn't established") + return + var tool_list: Array[Dictionary] = [] + ## Send all definitions plus their state: the server hides disabled tools + ## from fresh tools/list responses but retains a callable tombstone so a + ## client using a cached promoted name receives CUSTOM_TOOL_DISABLED. + for spec in _custom_tool_registry.all(): + tool_list.append({ + "name": spec.name, + "description": spec.description, + "params_schema": spec.params_schema, + "source": spec.source, + "deferred": spec.deferred, + "timeout_ms": spec.timeout_ms, + "requires_writable": spec.requires_writable, + "undoable": spec.undoable, + "promoted": spec.promoted, + "enabled": _custom_tool_registry.is_tool_enabled(spec.name) + }) + _connection.send_event("custom_tools_changed", {"tools": tool_list}) + + +## On (re)connect, replay the current custom-tool catalog so tools +## registered before the initial connection or during a disconnect +## window reach the server. send_event silently drops while +## _connected is false (connection.gd::_send_json), so without this +## replay those tools only surface on the next registry mutation. +func _on_connection_state_changed(is_open: bool) -> void: + if is_open and _custom_tool_registry != null: + _on_custom_tools_changed() diff --git a/addons/godot_ai/plugin.gd.uid b/addons/godot_ai/plugin.gd.uid new file mode 100644 index 0000000..4c550dd --- /dev/null +++ b/addons/godot_ai/plugin.gd.uid @@ -0,0 +1 @@ +uid://d3ui3yx6vdigl diff --git a/addons/godot_ai/runtime/draw_recipe.gd b/addons/godot_ai/runtime/draw_recipe.gd new file mode 100644 index 0000000..8204bd7 --- /dev/null +++ b/addons/godot_ai/runtime/draw_recipe.gd @@ -0,0 +1,86 @@ +@tool +extends Control + +## Runtime helper attached by control_draw_recipe. +## Reads an array of op dicts from node metadata under key "_ops" and dispatches +## each to a CanvasItem draw call in _draw(). The ops list is set by the handler +## via set_meta; this script is deterministic — re-setting meta + queue_redraw +## is enough to update the visuals. + +const META_KEY := "_ops" + + +func _ready() -> void: + queue_redraw() + + +func _draw() -> void: + if not has_meta(META_KEY): + return + var ops: Variant = get_meta(META_KEY) + if typeof(ops) != TYPE_ARRAY: + return + for op in ops: + if typeof(op) != TYPE_DICTIONARY: + continue + match op.get("draw", ""): + "line": + draw_line( + op.from, + op.to, + op.color, + float(op.get("width", 1.0)), + bool(op.get("antialiased", false)) + ) + "rect": + # Godot warns if `width` is passed when `filled` is true — + # width has no effect on filled rects. Split the call so we + # only pass width when stroking an outline. + var filled := bool(op.get("filled", true)) + if filled: + draw_rect(op.rect, op.color, true) + else: + draw_rect( + op.rect, + op.color, + false, + float(op.get("width", 1.0)) + ) + "arc": + draw_arc( + op.center, + float(op.radius), + float(op.start_angle), + float(op.end_angle), + int(op.get("point_count", 32)), + op.color, + float(op.get("width", 1.0)), + bool(op.get("antialiased", false)) + ) + "circle": + draw_circle(op.center, float(op.radius), op.color) + "polyline": + draw_polyline( + op.points, + op.color, + float(op.get("width", 1.0)), + bool(op.get("antialiased", false)) + ) + "polygon": + var colors: PackedColorArray = ( + op.colors if op.has("colors") else PackedColorArray([op.color]) + ) + draw_polygon(op.points, colors) + "string": + var font: Font = get_theme_default_font() + if font == null: + continue + draw_string( + font, + op.position, + str(op.text), + int(op.get("align", HORIZONTAL_ALIGNMENT_LEFT)), + float(op.get("max_width", -1.0)), + int(op.get("font_size", 16)), + op.color + ) diff --git a/addons/godot_ai/runtime/draw_recipe.gd.uid b/addons/godot_ai/runtime/draw_recipe.gd.uid new file mode 100644 index 0000000..5de2df2 --- /dev/null +++ b/addons/godot_ai/runtime/draw_recipe.gd.uid @@ -0,0 +1 @@ +uid://da3fqfqv6gtgm diff --git a/addons/godot_ai/runtime/editor_logger.gd b/addons/godot_ai/runtime/editor_logger.gd new file mode 100644 index 0000000..2d19daa --- /dev/null +++ b/addons/godot_ai/runtime/editor_logger.gd @@ -0,0 +1,139 @@ +@tool +extends Logger + +## Editor-process Logger subclass. +## +## NOTE: deliberately no `class_name`. Registered from plugin.gd::_enter_tree +## so we can intercept editor-process script errors — parse errors, @tool +## runtime errors, EditorPlugin errors, push_error/push_warning — and surface +## them via `logs_read(source="editor")`. Without this, the LLM sees nothing +## in `logs_read` while the same errors show in red lines in Godot's Output +## panel. +## +## Why only `_log_error` and not `_log_message`: +## `_log_message(msg, error)` covers print() and printerr(), which is the +## firehose path — running editors print thousands of internal info lines +## a session. The issue (#231) explicitly asks to filter so the buffer +## isn't drowned. Errors and warnings flow through `_log_error` (parse +## errors, push_error/push_warning, runtime errors), which is what +## debugging callers actually need. If we discover @tool printerr() is a +## valuable source later, _log_message can be added behind the same filter. +## +## Logger virtuals can be called from any thread (e.g. async script +## loaders push parse errors off the main thread). McpEditorLogBuffer is +## mutex-protected so we can append directly without an intermediate queue. + +const ADDON_PATH_MARKER := "/addons/godot_ai/" + +## Resolve McpLogBacktrace by path, not by the `McpLogBacktrace` class_name. +## A bare class_name reference depends on the global class-name table being populated +## at compile time, which isn't guaranteed on a cold editor enable mid-scan. +## `const preload` resolves at compile time independent of the registry — +## matches game_logger.gd's deliberate choice for the same reason. +const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd") + +## Constructor-injected so the hot path doesn't need a per-call null check. +var _buffer + + +func _init(buffer = null) -> void: + _buffer = buffer + + +func _log_error( + function: String, + file: String, + line: int, + code: String, + rationale: String, + _editor_notify: bool, + error_type: int, + script_backtraces: Array, +) -> void: + if _buffer == null: + return + ## Cheap reject for the firehose: when `file` is already non-user (the + ## bulk of editor-internal C++ chatter), there's no backtrace to remap + ## from, and the message doesn't name a project resource, the resolved + ## path can only stay non-user — drop without paying for resolve_error's + ## call frame + dict allocation. + var message := rationale if not rationale.is_empty() else code + var message_res_path := _extract_user_res_path(message) + if not _is_user_script(file) and script_backtraces.is_empty() and message_res_path.is_empty(): + return + var resolved := _LogBacktrace.resolve_error( + function, file, line, code, rationale, error_type, script_backtraces, + ) + if not _is_user_script(resolved.path): + if message_res_path.is_empty(): + return + resolved.path = message_res_path + resolved.line = 0 + resolved.function = function + _update_resolved_details(resolved) + if _is_in_godot_ai_addon(resolved.path): + return + if not message_res_path.is_empty() and _is_in_godot_ai_addon(message_res_path): + return + var details: Dictionary = resolved.get("details", {}) + _buffer.append(resolved.level, resolved.message, resolved.path, resolved.line, resolved.function, details) + + +static func _update_resolved_details(resolved: Dictionary) -> void: + var details: Dictionary = resolved.get("details", {}) + if details.is_empty(): + return + details["resolved"] = { + "path": resolved.get("path", ""), + "line": resolved.get("line", 0), + "function": resolved.get("function", ""), + } + resolved["details"] = details + + +## Predicate broken out so tests can drive the path-filter logic without +## constructing real Logger calls. +static func _is_user_script(path: String) -> bool: + if path.is_empty(): + return false + ## Match .gd / .cs (case-insensitively to handle .GD on case-insensitive + ## filesystems). C# scripts compile elsewhere but the parser path can + ## still surface .cs files for assembly load failures. + var lower := path.to_lower() + return lower.ends_with(".gd") or lower.ends_with(".cs") + + +## Path-substring check works for both `res://addons/godot_ai/foo.gd` and +## globalized absolute paths (`/Users/.../addons/godot_ai/foo.gd`) that +## Godot can also report depending on where the error originated. +static func _is_in_godot_ai_addon(path: String) -> bool: + if path.begins_with("res://addons/godot_ai/"): + return true + return path.find(ADDON_PATH_MARKER) >= 0 + + +## Some engine-origin errors have no ScriptBacktrace even though they are +## project-relevant, notably ResourceLoader failures: +## `Failed loading resource: res://does/not/exist.tres.`. Capture these by +## extracting a named `res://` path from the message while keeping editor +## internals and this addon's own resources filtered. +static func _extract_user_res_path(message: String) -> String: + var start := message.find("res://") + if start < 0: + return "" + var end := message.length() + var quote_end := message.find("'", start) + if quote_end >= 0: + end = mini(end, quote_end) + quote_end = message.find("\"", start) + if quote_end >= 0: + end = mini(end, quote_end) + quote_end = message.find("`", start) + if quote_end >= 0: + end = mini(end, quote_end) + var path := message.substr(start, end - start).strip_edges() + while not path.is_empty() and path.substr(path.length() - 1, 1) in [".", ",", ";", ":", ")"]: + path = path.substr(0, path.length() - 1) + if path.is_empty() or _is_in_godot_ai_addon(path): + return "" + return path diff --git a/addons/godot_ai/runtime/editor_logger.gd.uid b/addons/godot_ai/runtime/editor_logger.gd.uid new file mode 100644 index 0000000..9db6035 --- /dev/null +++ b/addons/godot_ai/runtime/editor_logger.gd.uid @@ -0,0 +1 @@ +uid://cxfregddqj5b8 diff --git a/addons/godot_ai/runtime/game_helper.gd b/addons/godot_ai/runtime/game_helper.gd new file mode 100644 index 0000000..ff4fb4e --- /dev/null +++ b/addons/godot_ai/runtime/game_helper.gd @@ -0,0 +1,1317 @@ +extends Node + +## Godot AI MCP — game-process helper. +## +## Registered as an autoload by plugin.gd when the Godot AI plugin is enabled. +## Runs in the running game process (separate from the editor) so the plugin +## can request the game's framebuffer over the editor-debugger channel. +## +## The editor never has direct access to the game's pixels: even when "Embed +## Game Mode" is on, the game is still a separate OS child process whose +## window is reparented into the editor via Win32 SetParent / X11 +## XReparentWindow / macOS remote layer (Godot PR godotengine/godot#99010). +## So viewport-texture capture on the editor side never contains game pixels. +## This autoload solves that by replying to "mcp:take_screenshot" debug +## messages with a PNG of Viewport.get_texture() from inside the game. +## +## No-ops in the editor (Engine.is_editor_hint) and silently sits idle +## when the debugger channel is inactive (e.g. exported release builds) +## — register_message_capture is safe to call either way, it's +## send_message that requires an active channel. + +const CAPTURE_PREFIX := "mcp" +## Cap per-frame flush so a runaway print loop can't blow the debugger's +## packet budget in a single send. Surplus stays queued for the next frame. +const FLUSH_BATCH_LIMIT := 200 +## How long take_screenshot waits for the game's first real presentation +## before reading the viewport texture back. The "mcp" capture registers in +## this autoload's _ready(), which runs BEFORE the main scene enters the tree +## and before the renderer has presented anything — so a request arriving +## right after mcp:hello would otherwise read back the clear-color +## framebuffer (observed as a uniform RGB(77,77,77) PNG on GitHub's +## GPU-less paravirtualized macOS runners, where the first present lags +## seconds behind boot). MUST stay below the editor-side reply timer +## (DEFAULT_TIMEOUT_SEC = 8.0 in debugger/mcp_debugger_plugin.gd) so a +## game that genuinely can't render falls through to the existing +## texture/image error replies before the editor gives up with its +## generic timeout. +const FIRST_FRAME_WAIT_SEC := 6.0 +## #777: how long the main loop can go without ticking _process before +## _handle_take_screenshot treats it as frozen and commits a synchronous +## stale-frame capture instead of awaiting frames that will never come. +## A backgrounded/minimized play-in-editor game stops iterating its main +## loop entirely, so any real threshold works; 1s keeps a merely-slow game +## (heavy frame, low FPS) on the fresh-frame await path. +const MAIN_LOOP_STALL_MSEC := 1000 +## How long frames_drawn can stay flat before _handle_take_screenshot treats +## rendering as suppressed and commits the synchronous stale-frame capture. +## On Windows, minimizing the game window freezes frame presentation but NOT +## the main loop — _process keeps ticking, so the MAIN_LOOP_STALL_MSEC beacon +## never trips and every capture used to burn the full FIRST_FRAME_WAIT_SEC +## await before replying stale (issue #794 smoke, item 1b). Larger than the +## loop threshold so a heavy-but-rendering game (~1 FPS frame gaps) stays on +## the fresh-frame await path; a sub-0.7 FPS game that trips this still gets +## an honestly stale-flagged image immediately instead of a 6s wait. +const RENDER_STALL_MSEC := 1500 + +const GameLogger := preload("res://addons/godot_ai/runtime/game_logger.gd") +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +## Shared with the editor-side copy in editor_handler.gd (#716). Preload by +## path, not class_name: this autoload runs in the game process and must not +## depend on the editor's global-class cache being warm. +const ScreenshotEncode := preload("res://addons/godot_ai/utils/screenshot_encode.gd") + +var _registered := false +## Captures game-process print, warning, and error output for the editor. +var _logger: Logger +var _logger_attached := false +## Entries drained from the logger but not yet sent over the debugger +## channel. Holds the tail of one drain() so we can bleed it out across +## frames at FLUSH_BATCH_LIMIT per frame rather than blasting the whole +## queue in a single _process tick. +var _pending_outbound: Array = [] +## #490: in-flight evals, keyed by request_id (multiple deferred game_evals +## can run at once). Each entry: {node:Node, token:String, baseline:int}. +## `token` names this eval's unique wrapper function so a runtime error is +## attributed only to the eval that actually raised it — not an unrelated +## background game error, and not a sibling overlapping eval. `baseline` is the +## logger's script-error seq just before this eval ran. The editor's eval_check +## probe (and #488's in-flight poll loop, when the game is focused) consult +## these to report a runtime error that aborted execute() before the reply. +var _inflight_evals: Dictionary = {} +var _eval_token_counter: int = 0 +## #777: last time _process ran, in ticks msec. The debugger message capture +## stays live while a backgrounded game's main loop is frozen, so this is how +## _handle_take_screenshot (running inside that capture) detects the freeze +## synchronously. -1 until the first tick. +var _last_loop_tick_msec: int = -1 +## Rendering-freeze beacon for the Windows-minimize state (#794 smoke, 1b): +## the frames_drawn value last observed in _process, and when it last +## advanced. -1 until the first observed advance, so a booting or +## render-less game (frames_drawn stuck at 0) can never read as +## render-stalled and keeps the fresh-frame await path's error replies. +var _last_frames_drawn_seen: int = -1 +var _last_frames_advance_msec: int = -1 +## #859: test/diagnostic seam for the synchronous eval liveness reply. The +## debugger capture remains callable while the game loop is parked, so the +## reply reports whether the _process beacon is actually advancing rather +## than merely proving that the capture was registered once. +var _last_eval_liveness_reply: Dictionary = {} + + +func _ready() -> void: + ## Only run in the game process, not in the editor. Use is_editor_hint + ## — NOT OS.has_feature("editor"), which is a BUILD-config check + ## (TOOLS_ENABLED) and returns true in the game subprocess too because + ## the game is spawned with the same editor binary. is_editor_hint is + ## the runtime-context check: true only inside the editor GUI, false + ## in play-from-editor. The earlier has_feature check was causing us + ## to skip registration in the game and time out every capture. + if Engine.is_editor_hint(): + return + ## Keep ticking while the tree is paused: _process both ferries game logs + ## and timestamps main-loop liveness for the stalled-loop screenshot + ## fallback (#777). A paused game still iterates its loop and renders, and + ## must not be misread as frozen. + process_mode = Node.PROCESS_MODE_ALWAYS + ## register_message_capture is safe to call before the debugger + ## handshake completes; the capture sits until a message arrives. + EngineDebugger.register_message_capture(CAPTURE_PREFIX, _on_debug_message) + _registered = true + ## Capture print() / printerr() / push_error() / push_warning() and + ## ferry them to the editor in mcp:log_batch messages flushed from + ## _process. + _logger = GameLogger.new() + OS.add_logger(_logger) + _logger_attached = true + ## Routed to the editor's Output panel via Godot's remote-stdout + ## forwarder — handy when diagnosing why capture timed out. + print("[godot_ai game_helper] registered mcp capture (debugger active=%s, logger=%s)" + % [EngineDebugger.is_active(), _logger_attached]) + ## Boot beacon so the editor side can confirm the autoload ran even + ## if no screenshot was ever requested. + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:hello", []) + + +func _process(_delta: float) -> void: + ## #777: liveness beacon for _handle_take_screenshot's stalled-loop check. + ## Recorded before the early returns below so the signal stays truthful + ## even when the logger or debugger channel is unavailable. + _last_loop_tick_msec = Time.get_ticks_msec() + ## Rendering beacon: on Windows a minimized game keeps ticking _process + ## while presentation stops, so frames_drawn stagnation — not loop + ## silence — is the observable freeze signal there (#794 smoke, 1b). + var frames_now := Engine.get_frames_drawn() + if frames_now != _last_frames_drawn_seen: + _last_frames_drawn_seen = frames_now + _last_frames_advance_msec = _last_loop_tick_msec + ## Drain the logger queue on the main thread (Logger virtuals can fire + ## from any thread; EngineDebugger.send_message is only safe from main). + ## Send at most one FLUSH_BATCH_LIMIT-sized batch per frame so a runaway + ## print loop can't stall the game by shoving thousands of entries + ## through the debugger packet path in a single tick. Surplus stays in + ## `_pending_outbound` and bleeds out across subsequent frames. + if not _logger_attached or _logger == null: + return + if not EngineDebugger.is_active(): + return + if _pending_outbound.is_empty(): + if not _logger.has_pending(): + return + _pending_outbound = _logger.drain() + var batch := _pending_outbound.slice(0, FLUSH_BATCH_LIMIT) + _pending_outbound = _pending_outbound.slice(FLUSH_BATCH_LIMIT) + EngineDebugger.send_message("mcp:log_batch", [batch]) + + +func _exit_tree() -> void: + if _registered: + EngineDebugger.unregister_message_capture(CAPTURE_PREFIX) + _registered = false + if _logger_attached and _logger != null: + OS.remove_logger(_logger) + _logger_attached = false + _logger = null + + +## Dispatched for messages prefixed "mcp:" on the debugger channel. +## Godot passes the full message ("mcp:take_screenshot") to the capture +## callable; trim defensively so tests can still call the helper with either +## form. +func _on_debug_message(message: String, data: Array) -> bool: + var action := message.trim_prefix("mcp:") + match action: + "take_screenshot": + _handle_take_screenshot(data) + return true + "eval_liveness": + _reply_eval_liveness(data) + return true + "eval": + _handle_eval(data) + return true + "eval_check": + _handle_eval_check(data) + return true + "game_command": + _handle_game_command(data) + return true + return false + + +## #859: answer from the debugger capture without awaiting a game frame. The +## capture can still run while a backgrounded/frozen game's main loop cannot, +## so the _process beacon is the liveness signal that matters for game_eval. +func _reply_eval_liveness(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + var loop_live := not _main_loop_appears_stalled() + _last_eval_liveness_reply = {"request_id": request_id, "loop_live": loop_live} + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:eval_liveness_response", [request_id, loop_live]) + + +func _handle_take_screenshot(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + var max_resolution: int = int(data[1]) if data.size() > 1 else 0 + + var tree := get_tree() + var viewport := tree.root if tree != null else null + if viewport == null: + _reply_error(request_id, "No game root viewport available") + return + + ## #777: this function runs inside the debugger message capture, which + ## stays live even when a backgrounded/minimized play-in-editor game has + ## frozen its main loop. In that state awaiting `process_frame` parks + ## this coroutine forever — no reply is ever sent, and the game side + ## cannot self-timeout because timers need the same frozen loop. Commit a + ## synchronous capture of the last rendered frame instead: stale, but a + ## real image, flagged as such in the reply. Only fall through to the + ## fresh-frame awaits when the loop is demonstrably alive. + if _should_capture_stale_sync( + _main_loop_appears_stalled(), + _rendering_appears_stalled(), + tree.current_scene != null, + Engine.get_frames_drawn() + ): + _capture_and_reply(request_id, viewport, max_resolution, Engine.get_frames_drawn()) + return + + ## Wait (bounded — see FIRST_FRAME_WAIT_SEC) until the main scene is in + ## the tree and at least one frame has been drawn after this request, so + ## the readback never precedes the first real present. Past the deadline, + ## fall through anyway: current_scene stays null under a custom main + ## loop, and frames_drawn never advances in a render-less game — both + ## are handled by the texture/image error replies below. + var deadline := Time.get_ticks_msec() + int(FIRST_FRAME_WAIT_SEC * 1000.0) + while tree.current_scene == null and Time.get_ticks_msec() < deadline: + await tree.process_frame + var frames_at_request := Engine.get_frames_drawn() + while Engine.get_frames_drawn() <= frames_at_request and Time.get_ticks_msec() < deadline: + await tree.process_frame + + _capture_and_reply(request_id, viewport, max_resolution, frames_at_request) + + +## #777: pure decision for the synchronous stale-frame path. Sync capture is +## only worth committing when awaiting can't produce a fresh frame — the main +## loop is frozen (macOS/suspend), or the loop still ticks but presentation +## is suppressed (Windows minimize, #794 smoke 1b) — AND the viewport +## plausibly holds a real frame: the main scene is in the tree and at least +## one frame was presented. Without those, the stale readback would be the +## boot clear-color framebuffer — worse than the honest timeout. +static func _should_capture_stale_sync( + loop_stalled: bool, render_stalled: bool, has_current_scene: bool, frames_drawn: int +) -> bool: + return (loop_stalled or render_stalled) and has_current_scene and frames_drawn > 0 + + +## #777: true when _process hasn't ticked within MAIN_LOOP_STALL_MSEC — +## i.e. the main loop is frozen (backgrounded window) or has never run. +func _main_loop_appears_stalled() -> bool: + ## The hello→probe round trip normally follows at least one _process tick. + ## Treat the never-ticked sentinel as stalled defensively rather than + ## dispatching an eval into a game whose main loop has not started. + if _last_loop_tick_msec < 0: + return true + return Time.get_ticks_msec() - _last_loop_tick_msec > MAIN_LOOP_STALL_MSEC + + +## True when frames_drawn has sat flat past RENDER_STALL_MSEC while _process +## kept ticking — Windows minimize suppresses presentation without freezing +## the loop, so the loop beacon alone misses it (#794 smoke, 1b). False until +## the first observed frame advance: a game that has never presented has no +## trustworthy frame to return, and must fall through to the await path's +## texture/image error replies instead. +func _rendering_appears_stalled() -> bool: + if _last_frames_advance_msec < 0: + return false + return Time.get_ticks_msec() - _last_frames_advance_msec > RENDER_STALL_MSEC + + +## Read back the viewport texture and reply — fully synchronous, so it is +## safe to call from the debugger capture while the main loop is frozen. +## `frames_at_request` is Engine.get_frames_drawn() at request receipt: if no +## further frame was drawn by capture time, the image predates the request +## and the reply is flagged stale. +func _capture_and_reply( + request_id: String, viewport: Viewport, max_resolution: int, frames_at_request: int +) -> void: + var texture := viewport.get_texture() + if texture == null: + _reply_error(request_id, "Root viewport has no texture (headless?)") + return + + var image := texture.get_image() + if image == null or image.is_empty(): + _reply_error(request_id, "Captured an empty image from game viewport") + return + + var encoded: Dictionary = ScreenshotEncode.downscale_and_encode(image, max_resolution) + var frames_drawn := Engine.get_frames_drawn() + var stale := frames_drawn <= frames_at_request + + _last_screenshot_reply = { + "kind": "response", + "request_id": request_id, + "frames_drawn": frames_drawn, + "stale": stale, + "width": encoded.width, + "height": encoded.height, + } + if EngineDebugger.is_active(): + ## Fields 7+8 are new in #777; older editors read the first six and + ## ignore the rest. + EngineDebugger.send_message("mcp:screenshot_response", [ + request_id, + encoded.base64, + encoded.width, + encoded.height, + encoded.original_width, + encoded.original_height, + frames_drawn, + stale, + ]) + + +## Testing seam: the last screenshot reply (response or error), recorded +## before hitting the EngineDebugger channel (inactive in the editor-side +## test harness). Mirrors _last_eval_reply. +var _last_screenshot_reply: Dictionary = {} + + +func _reply_error(request_id: String, message: String) -> void: + _last_screenshot_reply = {"kind": "error", "request_id": request_id, "message": message} + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:screenshot_error", [request_id, message]) + + +## --- game_command: curated runtime inspection and input --- + +func _handle_game_command(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + var op: String = data[1] if data.size() > 1 else "" + var params_json: String = data[2] if data.size() > 2 else "{}" + + if request_id.is_empty(): + return + if op.is_empty(): + _reply_game_command_error(request_id, "No op provided") + return + + var json := JSON.new() + var parse_err := json.parse(params_json) + if parse_err != OK or not (json.data is Dictionary): + _reply_game_command_error(request_id, "Invalid params JSON") + return + + var result: Dictionary + match op: + "get_scene_tree": + result = _game_get_scene_tree(json.data) + "get_node_info": + result = _game_get_node_info(json.data) + "get_ui_elements": + result = _game_get_ui_elements(json.data) + "input_key": + result = _game_input_key(json.data) + "input_mouse": + result = _game_input_mouse(json.data) + "input_gamepad": + result = _game_input_gamepad(json.data) + "input_action": + result = _game_input_action(json.data) + "input_state": + result = _game_input_state(json.data) + "input_sequence": + ## Async: steps frames and replies itself (deferred), so bail out + ## before the synchronous send below — same shape as the eval and + ## screenshot capture paths. + _run_input_sequence(request_id, json.data) + return + _: + _reply_game_command_error(request_id, "Unknown game op: %s" % op) + return + + result["source"] = "game" + result["op"] = op + EngineDebugger.send_message("mcp:game_command_response", + [request_id, JSON.stringify(_variant_to_json(result))]) + + +func _reply_game_command_error(request_id: String, message: String) -> void: + EngineDebugger.send_message("mcp:game_command_error", [request_id, message]) + + +func _game_get_scene_tree(params: Dictionary) -> Dictionary: + var depth := maxi(0, int(params.get("depth", 10))) + var root := _resolve_runtime_node(str(params.get("root_path", ""))) + if root == null: + return {"root": "", "nodes": [], "total_count": 0, "not_found": params.get("root_path", "")} + + var nodes: Array[Dictionary] = [] + _collect_runtime_nodes(root, 0, depth, nodes) + return { + "root": _runtime_path(root), + "nodes": nodes, + "total_count": nodes.size(), + } + + +func _collect_runtime_nodes(node: Node, current_depth: int, max_depth: int, out: Array[Dictionary]) -> void: + out.append({ + "name": node.name, + "type": node.get_class(), + "path": _runtime_path(node), + "children_count": node.get_child_count(), + }) + if current_depth >= max_depth: + return + for child in node.get_children(): + if child is Node: + _collect_runtime_nodes(child, current_depth + 1, max_depth, out) + + +func _game_get_node_info(params: Dictionary) -> Dictionary: + var path := str(params.get("path", "")) + var node := _resolve_runtime_node(path) + if node == null: + return {"path": path, "found": false} + + var info := { + "path": _runtime_path(node), + "name": node.name, + "type": node.get_class(), + "children_count": node.get_child_count(), + "groups": node.get_groups(), + "found": true, + } + if bool(params.get("include_properties", true)): + info["properties"] = _runtime_node_properties(node) + return info + + +func _game_get_ui_elements(params: Dictionary) -> Dictionary: + var max_depth := maxi(0, int(params.get("max_depth", 10))) + var include_hidden := bool(params.get("include_hidden", false)) + var include_disabled := bool(params.get("include_disabled", true)) + var root_path := str(params.get("root_path", "")) + var root := _resolve_runtime_node(root_path) + if root == null: + return {"root": "", "elements": [], "total_count": 0, "not_found": root_path} + + var elements: Array[Dictionary] = [] + _collect_ui_elements(root, 0, max_depth, include_hidden, include_disabled, elements) + return { + "root": _runtime_path(root), + "elements": elements, + "total_count": elements.size(), + } + + +func _collect_ui_elements( + node: Node, + current_depth: int, + max_depth: int, + include_hidden: bool, + include_disabled: bool, + out: Array[Dictionary] +) -> void: + if node is Control: + var control := node as Control + var visible := _control_visible_in_tree(control) + var disabled := _control_disabled(control) + if (include_hidden or visible) and (include_disabled or not disabled): + out.append(_ui_element_info(control, visible, disabled)) + + if current_depth >= max_depth: + return + for child in node.get_children(): + if child is Node: + _collect_ui_elements( + child, + current_depth + 1, + max_depth, + include_hidden, + include_disabled, + out + ) + + +func _ui_element_info(control: Control, visible: bool, disabled: bool) -> Dictionary: + var info := { + "path": _runtime_path(control), + "name": control.name, + "type": control.get_class(), + "visible": visible, + "disabled": disabled, + "rect": _variant_to_json(control.get_rect()), + "global_rect": _variant_to_json(control.get_global_rect()), + } + if _object_has_property(control, "text"): + info["text"] = str(control.get("text")) + return info + + +func _control_disabled(control: Control) -> bool: + if _object_has_property(control, "disabled"): + return bool(control.get("disabled")) + return false + + +func _control_visible_in_tree(control: Control) -> bool: + if not control.visible: + return false + var parent := control.get_parent() + while parent != null: + if parent is CanvasItem and not (parent as CanvasItem).visible: + return false + parent = parent.get_parent() + if Engine.is_editor_hint(): + return true + return control.is_visible_in_tree() + + +static var _property_name_cache: Dictionary = {} + + +func _object_has_property(obj: Object, property_name: String) -> bool: + var key := _property_cache_key(obj) + if not _property_name_cache.has(key): + var names := {} + for prop in obj.get_property_list(): + names[str(prop.get("name", ""))] = true + _property_name_cache[key] = names + return (_property_name_cache[key] as Dictionary).has(property_name) + + +func _property_cache_key(obj: Object) -> String: + var script = obj.get_script() + if script == null: + return obj.get_class() + var script_id := str(script.get_instance_id()) + if not script.resource_path.is_empty(): + script_id = script.resource_path + return "%s:%s" % [obj.get_class(), script_id] + + +func _runtime_node_properties(node: Node) -> Dictionary: + var props := {} + for p in node.get_property_list(): + var name := str(p.get("name", "")) + var usage := int(p.get("usage", 0)) + if name.is_empty() or (usage & PROPERTY_USAGE_EDITOR) == 0: + continue + props[name] = _variant_to_json(node.get(name)) + return props + + +func _resolve_runtime_node(path: String) -> Node: + var scene_root := _current_scene_root() + if scene_root == null: + return null + if path.is_empty() or path == "/": + return scene_root + + if path.begins_with("/root/"): + return get_tree().root.get_node_or_null(path.trim_prefix("/root/")) + + var scene_path := path.trim_prefix("/") + if scene_path == str(scene_root.name): + return scene_root + var prefix := str(scene_root.name) + "/" + if scene_path.begins_with(prefix): + scene_path = scene_path.substr(prefix.length()) + return scene_root.get_node_or_null(scene_path) + + +func _runtime_path(node: Node) -> String: + var scene_root := _current_scene_root() + if scene_root == null: + return str(node.get_path()) + if node == scene_root: + return "/" + str(scene_root.name) + return "/" + str(scene_root.name) + "/" + str(scene_root.get_path_to(node)) + + +func _current_scene_root() -> Node: + var tree := get_tree() + if tree == null: + return null + var scene_root := tree.current_scene + if scene_root == null and Engine.is_editor_hint(): + # Look the editor singleton up by name rather than referencing the bare + # `EditorInterface` identifier: that identifier is compiled out of export + # templates, so the GDScript parser rejects it ("Identifier + # "EditorInterface" not declared in the current scope") in an exported + # build even though `Engine.is_editor_hint()` would never run it there. + # That parse failure stops this autoload from loading in every export. + var editor := Engine.get_singleton(&"EditorInterface") + if editor: + scene_root = editor.get_edited_scene_root() + return scene_root + + +func _game_input_key(params: Dictionary) -> Dictionary: + var key_name := str(params.get("key", "")) + var keycode := OS.find_keycode_from_string(key_name) + if keycode == KEY_NONE: + return {"sent": false, "error": "Unknown key: %s" % key_name} + var ev := InputEventKey.new() + ev.keycode = keycode + ev.physical_keycode = keycode + ev.pressed = bool(params.get("pressed", true)) + ev.echo = bool(params.get("echo", false)) + Input.parse_input_event(ev) + return {"sent": true, "key": key_name, "pressed": ev.pressed} + + +func _game_input_mouse(params: Dictionary) -> Dictionary: + var event := str(params.get("event", "button")) + var pos_result := _resolve_mouse_position(params.get("position")) + if pos_result.has("error"): + return {"sent": false, "event": event, "error": pos_result.error} + var pos: Vector2 = pos_result.position + match event: + "motion": + var motion := InputEventMouseMotion.new() + motion.position = pos + motion.global_position = pos + Input.parse_input_event(motion) + return {"sent": true, "event": "motion", "position": _variant_to_json(pos)} + "button": + var button_event := InputEventMouseButton.new() + button_event.position = pos + button_event.global_position = pos + button_event.button_index = _mouse_button_index(str(params.get("button", "left"))) + button_event.pressed = bool(params.get("pressed", true)) + Input.parse_input_event(button_event) + return { + "sent": true, + "event": "button", + "button": params.get("button", "left"), + "pressed": button_event.pressed, + "position": _variant_to_json(pos), + } + return {"sent": false, "error": "Invalid mouse event: %s" % event} + + +func _game_input_gamepad(params: Dictionary) -> Dictionary: + var device := int(params.get("device", 0)) + var control := str(params.get("control", "button")) + match control: + "button": + var button := InputEventJoypadButton.new() + button.device = device + button.button_index = int(params.get("index", 0)) + button.pressed = bool(params.get("pressed", true)) + Input.parse_input_event(button) + return {"sent": true, "control": "button", "device": device, "index": button.button_index, "pressed": button.pressed} + "axis": + var axis := InputEventJoypadMotion.new() + axis.device = device + axis.axis = int(params.get("index", 0)) + axis.axis_value = float(params.get("value", 0.0)) + Input.parse_input_event(axis) + return {"sent": true, "control": "axis", "device": device, "index": axis.axis, "value": axis.axis_value} + return {"sent": false, "error": "Invalid gamepad control: %s" % control} + + +func _game_input_action(params: Dictionary) -> Dictionary: + var action := str(params.get("action", "")) + if action.is_empty(): + return {"sent": false, "error": "Missing action"} + if not InputMap.has_action(action): + return {"sent": false, "action": action, "error": "Unknown action: %s" % action} + var pressed := bool(params.get("pressed", true)) + var strength := clampf(float(params.get("strength", 1.0)), 0.0, 1.0) + if pressed: + Input.action_press(action, strength) + else: + Input.action_release(action) + return { + "sent": true, + "action": action, + "pressed": pressed, + "strength": strength, + "delivery": "action_state", + } + + +func _game_input_state(params: Dictionary) -> Dictionary: + var actions: Array = params.get("actions", []) + if actions.is_empty(): + actions = InputMap.get_actions() + var states := {} + for action in actions: + var name := str(action) + states[name] = Input.is_action_pressed(name) + return {"actions": states} + + +## --- input_sequence: frame-timed action timeline (deferred) --- +## +## Per-step round-trips can't hit a target frame — network jitter lands each +## input on whatever frame its reply happens to arrive on, so a jump arc or a +## timed combo is unreproducible (#814). input_sequence takes the whole +## timeline in one call and drives it game-side, applying each step's action on +## its scheduled frame, then replies once (deferred). Frame count (not ms) is +## the timing basis: it's what reproduces identically across runs. + +## Hard caps mirrored by the server-side schema (see game handlers). The game +## side re-checks them so a malformed direct message can't park the coroutine +## on an unbounded await; the server rejects the same cases up front with a +## clearer error. +## +## The frame cap bounds the sequence in *frames*, which is a wall-clock time +## only at a given FPS: 600 frames is ~10s at 60fps but longer under load or on +## a throttled runner. It is not sized to the ~30s deferred budget +## (editor_handler.INPUT_SEQUENCE_TIMEOUT_SEC) — the two are independent +## safeguards. If a genuinely slow run exceeds the budget, the dispatcher +## returns a clean DEFERRED_TIMEOUT rather than hanging, so the cap can stay a +## simple frame count. +const MAX_SEQUENCE_STEPS := 256 +const MAX_SEQUENCE_FRAMES := 600 + +## Testing seam: the last input_sequence reply (response or error), recorded +## before the EngineDebugger channel (inactive in the editor-side test +## harness). Mirrors _last_screenshot_reply / _last_eval_reply. +var _last_game_command_reply: Dictionary = {} + +## Testing seam: overrides the per-frame wait in _run_input_sequence. Left +## invalid in production (real `process_frame` awaits). A test sets it to a +## synchronously-returning Callable so the multi-frame loop runs to completion +## in one call — the editor test runner invokes tests synchronously and never +## pumps `process_frame`, so a real frame-await would suspend and record zero +## assertions. Timing itself (one frame per step) is engine-guaranteed; this +## seam covers the scheduling/application/reply logic layered on top. +var _frame_waiter: Callable = Callable() + + +## Validate + normalize an input_sequence request. Pure (no engine state), so +## the ordering/cap/shape rules are unit-testable without a running game. +## Returns {"error": String} or {"steps": Array, "end_frame": int}. +func _plan_input_sequence(params: Dictionary) -> Dictionary: + var raw_steps: Variant = params.get("steps", null) + if not (raw_steps is Array): + return {"error": "steps must be an array"} + var steps_arr: Array = raw_steps + if steps_arr.is_empty(): + return {"error": "steps must not be empty"} + if steps_arr.size() > MAX_SEQUENCE_STEPS: + return {"error": "steps exceeds cap of %d (got %d)" % [MAX_SEQUENCE_STEPS, steps_arr.size()]} + + ## Validate field *kinds* rather than coercing them: the server already + ## rejects bad shapes, but this planner is also the backstop for a + ## malformed direct debugger message, so it must not silently turn + ## pressed="false" into true or at_frame="oops" into 0. _is_number accepts + ## int or float (JSON round-trips whole numbers as either) but not bool or + ## string, so this stays consistent with the server without tripping on + ## JSON's number typing. + var settle_raw: Variant = params.get("settle_frames", 0) + if not _is_number(settle_raw): + return {"error": "settle_frames must be a number"} + var settle_frames := int(settle_raw) + if settle_frames < 0: + return {"error": "settle_frames must be >= 0"} + + var normalized: Array = [] + var prev_frame := -1 + for i in steps_arr.size(): + var raw: Variant = steps_arr[i] + if not (raw is Dictionary): + return {"error": "steps[%d] must be an object" % i} + var step: Dictionary = raw + if not (step.get("action", "") is String) or str(step.get("action", "")).is_empty(): + return {"error": "steps[%d].action is required" % i} + var action: String = step["action"] + var at_frame_raw: Variant = step.get("at_frame", 0) + if not _is_number(at_frame_raw): + return {"error": "steps[%d].at_frame must be a number" % i} + var at_frame := int(at_frame_raw) + if at_frame < 0: + return {"error": "steps[%d].at_frame must be >= 0" % i} + if at_frame < prev_frame: + return {"error": "steps must be ordered by at_frame (steps[%d]=%d < previous %d)" % [i, at_frame, prev_frame]} + prev_frame = at_frame + var pressed_raw: Variant = step.get("pressed", true) + if not (pressed_raw is bool): + return {"error": "steps[%d].pressed must be a boolean" % i} + var strength_raw: Variant = step.get("strength", 1.0) + if not _is_number(strength_raw): + return {"error": "steps[%d].strength must be a number" % i} + normalized.append({ + "at_frame": at_frame, + "action": action, + "pressed": pressed_raw, + "strength": clampf(float(strength_raw), 0.0, 1.0), + }) + + var end_frame: int = int(normalized[-1]["at_frame"]) + settle_frames + if end_frame > MAX_SEQUENCE_FRAMES: + return {"error": "sequence spans %d frames, exceeds cap of %d" % [end_frame, MAX_SEQUENCE_FRAMES]} + return {"steps": normalized, "end_frame": end_frame} + + +## Async: apply each step's action on its scheduled frame, awaiting one +## process_frame per frame, then reply (deferred). Bails out before applying +## anything if the plan is invalid or any action is unknown to the running +## game's InputMap — a half-applied timeline leaves inputs in an undefined +## state, so it's all-or-nothing on the pre-checks. +func _run_input_sequence(request_id: String, params: Dictionary) -> void: + var plan := _plan_input_sequence(params) + if plan.has("error"): + _reply_input_sequence_error(request_id, plan["error"]) + return + + var steps: Array = plan["steps"] + var end_frame: int = plan["end_frame"] + + ## Resolve action names against the *game's* InputMap up front — the server + ## can't see it, so this is the first place unknown actions surface. + for step in steps: + if not InputMap.has_action(step["action"]): + _reply_input_sequence_error(request_id, "Unknown action: %s" % step["action"]) + return + + var tree := get_tree() + if tree == null: + _reply_input_sequence_error(request_id, "No SceneTree available for input sequence") + return + + var applied: Array = [] + var step_i := 0 + for f in range(0, end_frame + 1): + while step_i < steps.size() and int(steps[step_i]["at_frame"]) == f: + var step: Dictionary = steps[step_i] + _game_input_action(step) + applied.append({"at_frame": f, "action": step["action"], "pressed": step["pressed"]}) + step_i += 1 + if f < end_frame: + if _frame_waiter.is_valid(): + await _frame_waiter.call() + else: + await tree.process_frame + + _reply_input_sequence_ok(request_id, { + "completed": true, + "steps_applied": applied.size(), + "frames_elapsed": end_frame, + "applied": applied, + "actions_pressed_at_end": _actions_pressed_at_end(steps), + }) + + +## Distinct actions the sequence touched that are still held at the end, so the +## caller knows what it must release (a press with no matching release leaves +## the action stuck on across the next frames). +func _actions_pressed_at_end(steps: Array) -> Array: + var seen := {} + var pressed: Array = [] + for step in steps: + var action: String = step["action"] + if seen.has(action): + continue + seen[action] = true + if Input.is_action_pressed(action): + pressed.append(action) + return pressed + + +func _reply_input_sequence_ok(request_id: String, result: Dictionary) -> void: + result["source"] = "game" + result["op"] = "input_sequence" + _last_game_command_reply = {"kind": "response", "op": "input_sequence", "result": result} + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:game_command_response", + [request_id, JSON.stringify(_variant_to_json(result))]) + + +func _reply_input_sequence_error(request_id: String, message: String) -> void: + _last_game_command_reply = {"kind": "error", "op": "input_sequence", "message": message} + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:game_command_error", [request_id, message]) + + +## Resolve a mouse-position param. Absent (null, or an empty {}) falls back to +## the live cursor position — a deliberate default. A present but wrong-shaped +## value is rejected instead of silently substituting the cursor, which +## previously hid caller bugs (#635). Accepts a {x, y} dict or an [x, y] array; +## returns {position: Vector2} or {error: String}. +func _resolve_mouse_position(value: Variant) -> Dictionary: + var viewport := get_viewport() + var fallback := viewport.get_mouse_position() if viewport != null else Vector2.ZERO + if value == null: + return {"position": fallback} + if value is Dictionary: + var dict: Dictionary = value + if dict.is_empty(): + return {"position": fallback} + # A non-empty dict that carries neither coordinate is a caller mistake, + # not "use the default" — reject rather than silently substitute. + if not dict.has("x") and not dict.has("y"): + return {"error": "position object must have an 'x' and/or 'y' key (got keys %s)" % str(dict.keys())} + var x_val: Variant = dict.get("x", fallback.x) + var y_val: Variant = dict.get("y", fallback.y) + if not _is_number(x_val) or not _is_number(y_val): + return {"error": "position x/y must be numbers (got x=%s, y=%s)" % [type_string(typeof(x_val)), type_string(typeof(y_val))]} + return {"position": Vector2(float(x_val), float(y_val))} + if value is Array: + var arr: Array = value + if arr.size() != 2: + return {"error": "position array must be [x, y] (got %d elements)" % arr.size()} + if not _is_number(arr[0]) or not _is_number(arr[1]): + return {"error": "position array elements must be numbers (got [%s, %s])" % [type_string(typeof(arr[0])), type_string(typeof(arr[1]))]} + return {"position": Vector2(float(arr[0]), float(arr[1]))} + return {"error": "position must be a {x, y} object or [x, y] array (got %s)" % type_string(typeof(value))} + + +func _is_number(v: Variant) -> bool: + return typeof(v) == TYPE_INT or typeof(v) == TYPE_FLOAT + + +func _mouse_button_index(name: String) -> int: + match name: + "right": + return MOUSE_BUTTON_RIGHT + "middle": + return MOUSE_BUTTON_MIDDLE + "wheel_up": + return MOUSE_BUTTON_WHEEL_UP + "wheel_down": + return MOUSE_BUTTON_WHEEL_DOWN + return MOUSE_BUTTON_LEFT + + +## --- game_eval: execute arbitrary GDScript in the running game --- + +## Wall-clock ceiling for a single game_eval. Evaluated code that awaits +## something which never completes (a signal that never fires, a timer on a +## paused tree) would otherwise pin the request open until the dispatcher's +## 15s deferred budget / the server's 15s command timeout fires it as an +## opaque INTERNAL_ERROR — with the temp eval Node leaked into the tree. +## Bounding it here lets us free the node and reply with an actionable +## message instead. See hi-godot/godot-ai#487. +## +## TIMEOUT ORDERING — load-bearing across three files: this value MUST stay +## below the editor-side fallback timer in +## `debugger/mcp_debugger_plugin.gd::request_game_eval` (`timeout_sec`, +## default 10.0), which in turn stays below the dispatcher's `game_eval` +## budget in `dispatcher.gd` (15000 ms). So: game 8s < editor 10s < +## dispatcher 15s. Only this game-side guard emits the specific +## "Eval exceeded 8s" message (both it and the editor backstop now carry the +## EVAL_HUNG code, #518, but the editor's message can't name the cause). +## Raise this at/above the editor timer (or drop that timer below this) and +## the less specific editor message wins the race, silently losing the +## diagnostic this fix exists to provide. The cross-file contract is enforced +## by tests/unit/test_game_eval_timeout_ordering.py. +## +## NOTE: this catches a hung `await`, not a CPU-bound loop with no `await` — +## a tight `while true:` with no yield blocks the main thread, so nothing +## (including this poll) runs until it yields. That case is out of scope. +const EVAL_TIMEOUT_SEC := 8.0 + + +func _handle_eval(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + var code: String = data[1] if data.size() > 1 else "" + + if code.is_empty(): + _reply_eval_error(request_id, "No code provided") + return + + ## Wrap user code in an execute() coroutine (so it can `await` internally) + ## whose inner function is uniquely named per eval. A runtime error's + ## backtrace then carries `_mcp_run_`, letting us attribute it to + ## THIS eval — not an unrelated background game error, and not a sibling + ## overlapping eval. (#490) + _eval_token_counter += 1 + var token := str(_eval_token_counter) + var run_fn := "_mcp_run_%s" % token + var script_source := ( + "extends Node\n" + + "func execute():\n" + + "\treturn await %s()\n\n" % run_fn + + "func %s():\n" % run_fn + + _indent_eval_code(code) + ) + + ## Snapshot the logger's script-error seq BEFORE running so we only attribute + ## errors raised by this eval. In a debug build a parse error aborts reload() + ## and a runtime error aborts execute() — either way this function may never + ## reach its reply: the editor infers a compile error from the missing + ## mcp:eval_compiled beacon, and a runtime error is reported (via the + ## eval_check probe / the in-flight poll loop) once a logged error past this + ## baseline carries this eval's token. + var baseline: int = _logger.script_error_seq() if _logger != null else 0 + + var script: GDScript = GDScript.new() + script.source_code = script_source + ## #490: ack BEFORE reload(). A parse error aborts this function at reload() + ## without a return code in a debug build, so this is our only chance to tell + ## the editor "received + about to compile." The editor uses that to tell a + ## real parse error (acked, never compiled) apart from a message it simply + ## hasn't serviced yet (never acked); see mcp_debugger_plugin._on_eval_grace. + EngineDebugger.send_message("mcp:eval_ack", [request_id]) + ## reload() ABORTS this function on a parse error in a debug build (it does + ## not return a non-OK code there), so the lines below only run when the + ## source compiled. Keep reload() INLINE — moving it behind a timer/await + ## poisons subsequent evals (#490). The err branch still matters for the + ## editor process (handler unit tests), where reload() does return. + var err: int = script.reload() + if err != OK: + _reply_eval_error(request_id, + "Failed to compile GDScript (error %d). Check syntax." % err) + return + + ## Compiled OK — tell the editor so its grace timer doesn't flag a compile + ## error and so it begins probing for a runtime error. + EngineDebugger.send_message("mcp:eval_compiled", [request_id]) + + var temp_node := Node.new() + temp_node.set_script(script) + temp_node.process_mode = Node.PROCESS_MODE_ALWAYS + add_child(temp_node) + + if not temp_node.has_method("execute"): + temp_node.queue_free() + _reply_eval_error(request_id, "Internal error: eval wrapper is missing execute().") + return + + ## Register in-flight BEFORE running: a runtime error aborts execute() (and + ## may unwind this function) before we could record it afterward, and the + ## editor probe / poll loop need the entry to attribute and report the error. + _inflight_evals[request_id] = {"node": temp_node, "token": token, "baseline": baseline} + + ## Drive execute() as a fire-and-forget coroutine that records its outcome + ## into `holder`, then poll frames until it finishes or the deadline passes + ## (#488's hung-await guard). A plain `await temp_node.execute()` has no + ## escape hatch: if user code never returns, we never reach the reply/cleanup + ## below and the request hangs with the node leaked. + var holder := {"done": false, "value": null, "abandoned": false} + _drive_eval(temp_node, holder) + + var tree := get_tree() + var deadline_ms := int(EVAL_TIMEOUT_SEC * 1000.0) + var start_ms := Time.get_ticks_msec() + while not holder["done"] and (Time.get_ticks_msec() - start_ms) < deadline_ms: + ## #490 focused fast path: a runtime error aborts _drive_eval (holder + ## never completes), so check each frame whether THIS eval's token now + ## appears in a logged error and report it immediately. (Backgrounded, + ## this loop is frozen and the editor probe does the same job.) + if _try_report_eval_runtime_error(request_id): + holder["abandoned"] = true + return + await tree.process_frame + + if not holder["done"]: + ## Past the 8s deadline. Disambiguate a runtime error (its token is in a + ## logged error) from a genuine hung await before the generic timeout. + holder["abandoned"] = true + if _try_report_eval_runtime_error(request_id): + return + _inflight_evals.erase(request_id) + if is_instance_valid(temp_node): + remove_child(temp_node) + _reply_eval_error(request_id, + ("Eval exceeded %ds and was aborted — the code likely awaits " + + "something that never completes (a signal that never fires, a timer on " + + "a paused tree) or loops forever. Check logs_read(source='game').") + % int(EVAL_TIMEOUT_SEC), + ErrorCodes.EVAL_HUNG) + return + + ## Clean finish. + _inflight_evals.erase(request_id) + temp_node.queue_free() + _reply_eval_response(request_id, holder["value"]) + + +## Run the compiled eval node's execute() and stash the result. Kept +## separate from _handle_eval so the latter can race it against a deadline +## via frame polling. If the eval was abandoned (timed out) before this +## resumes, drop the result and free the now-detached node — _handle_eval +## has already replied. +## +## RESIDUAL LEAK (accepted): if the awaited thing *never* fires, this +## coroutine never resumes, so the `node` it holds is detached (via +## _handle_eval's remove_child) but never freed — one orphaned Node per such +## timeout, for the game-process lifetime. GDScript has no way to cancel a +## suspended coroutine, so this is the best achievable in-process. It is still +## strictly better than the pre-#487 behavior, where the node leaked *into* +## the live tree and the request hung to the 15s ceiling. +func _drive_eval(node: Node, holder: Dictionary) -> void: + var value = await node.execute() + if holder.get("abandoned", false): + if is_instance_valid(node): + node.queue_free() + return + holder["value"] = value + holder["done"] = true + + +## #518: cap on the serialized eval result. Godot's remote-debugger TCP peer +## silently discards any single message over ~8 MiB, so a bigger reply never +## reaches the editor and the request rides to the 10s backstop as a phantom +## "hang". (Results over the editor↔server WebSocket buffer cap of 4 MiB fail +## there with their own explicit error; this game-side cap only needs to stay +## under the debugger peer's drop threshold to keep the failure visible.) +const EVAL_RESULT_MAX_BYTES := 6 * 1024 * 1024 + +## Testing seam: the last eval reply, recorded before hitting the +## EngineDebugger channel (inactive in the editor-side test harness). +var _last_eval_reply: Dictionary = {} + + +## `code` (optional) rides as a third payload element so the editor can map +## the reply to a specific error code instead of the generic INTERNAL_ERROR; +## the editor allowlists the value (see mcp_debugger_plugin._on_eval_error). +func _reply_eval_error(request_id: String, message: String, code: String = "") -> void: + _last_eval_reply = {"kind": "error", "request_id": request_id, + "message": message, "code": code} + var payload := [request_id, message] + if not code.is_empty(): + payload.append(code) + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:eval_error", payload) + + +func _reply_eval_response(request_id: String, value: Variant) -> void: + var serialized := JSON.stringify(_variant_to_json(value)) + var serialized_bytes := serialized.to_utf8_buffer().size() + if serialized_bytes > EVAL_RESULT_MAX_BYTES: + _reply_eval_error(request_id, + ("Eval result too large to return (%d bytes serialized, limit %d). " + + "Return a smaller slice instead — e.g. counts, node paths, or a " + + "truncated substring.") % [serialized_bytes, EVAL_RESULT_MAX_BYTES], + ErrorCodes.EVAL_RESULT_TOO_LARGE) + return + _last_eval_reply = {"kind": "response", "request_id": request_id} + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:eval_response", [request_id, serialized]) + + +## #490: if a logged script error past THIS eval's baseline carries its unique +## wrapper-function token, a runtime error aborted it before it could reply — +## report it with the real text + line. Returns true if it reported. Called +## from the editor's eval_check probe (the reliable path when a backgrounded +## game's idle loop is frozen — the debugger capture callback still runs) and +## from _handle_eval's poll loop (the focused fast path). Token + baseline +## matching means an unrelated background error, or a sibling overlapping +## eval's error, can never fail this request. +func _try_report_eval_runtime_error(request_id: String) -> bool: + if _logger == null: + return false + var entry = _inflight_evals.get(request_id) + if entry == null: + return false + var text: String = _logger.find_script_error_since( + int(entry["baseline"]), "_mcp_run_%s" % str(entry["token"])) + if text.is_empty(): + return false + _inflight_evals.erase(request_id) + var node: Node = entry["node"] + if node != null and is_instance_valid(node): + node.queue_free() + if EngineDebugger.is_active(): + EngineDebugger.send_message("mcp:eval_runtime_error", [request_id, text]) + return true + + +## #490: answer an editor eval_check probe. The editor polls this once the +## eval has compiled but not yet replied. This runs in the debugger capture +## callback, which stays live even when the backgrounded game's _process is +## frozen — so it's the reliable channel for reporting a runtime error that +## aborted the eval. Report if one is detected for this request, else stay +## silent (the editor keeps polling until the real reply or the hang timeout). +func _handle_eval_check(data: Array) -> void: + var request_id: String = data[0] if data.size() > 0 else "" + if request_id.is_empty(): + return + _try_report_eval_runtime_error(request_id) + + +func _indent_eval_code(code: String) -> String: + var lines: PackedStringArray = code.split("\n") + var out := "" + for line in lines: + out += "\t" + line + "\n" + return out + + +## Serialize any Godot Variant to a JSON-safe dictionary/array/primitive. +## Ported from godot-mcp's mcp_interaction_server.gd. +func _variant_to_json(value: Variant) -> Variant: + if value == null: + return null + if value is bool or value is int or value is float or value is String: + return value + if value is Vector2: + return {"x": value.x, "y": value.y} + if value is Vector3: + return {"x": value.x, "y": value.y, "z": value.z} + if value is Vector4: + return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} + if value is Vector2i: + return {"x": value.x, "y": value.y} + if value is Vector3i: + return {"x": value.x, "y": value.y, "z": value.z} + if value is Vector4i: + return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} + if value is Color: + return {"r": value.r, "g": value.g, "b": value.b, "a": value.a} + if value is Quaternion: + return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} + if value is Basis: + return { + "x": _variant_to_json(value.x), + "y": _variant_to_json(value.y), + "z": _variant_to_json(value.z), + } + if value is Transform3D: + return { + "basis": _variant_to_json(value.basis), + "origin": _variant_to_json(value.origin), + } + if value is Transform2D: + return { + "x": _variant_to_json(value.x), + "y": _variant_to_json(value.y), + "origin": _variant_to_json(value.origin), + } + if value is Rect2: + return { + "position": _variant_to_json(value.position), + "size": _variant_to_json(value.size), + } + if value is Rect2i: + return { + "position": _variant_to_json(value.position), + "size": _variant_to_json(value.size), + } + if value is AABB: + return { + "position": _variant_to_json(value.position), + "size": _variant_to_json(value.size), + } + if value is NodePath or value is StringName: + return str(value) + if value is Plane: + return { + "normal": _variant_to_json(value.normal), + "d": value.d, + } + if value is Projection: + return { + "x": _variant_to_json(value.x), + "y": _variant_to_json(value.y), + "z": _variant_to_json(value.z), + "w": _variant_to_json(value.w), + } + ## Packed arrays + if value is PackedByteArray: + var arr: Array = [] + for item in value: arr.append(item) + return arr + if value is PackedInt32Array or value is PackedInt64Array: + var arr: Array = [] + for item in value: arr.append(item) + return arr + if value is PackedFloat32Array or value is PackedFloat64Array: + var arr: Array = [] + for item in value: arr.append(item) + return arr + if value is PackedStringArray: + var arr: Array = [] + for item in value: arr.append(item) + return arr + if value is PackedVector2Array: + var arr: Array = [] + for item in value: arr.append({"x": item.x, "y": item.y}) + return arr + if value is PackedVector3Array: + var arr: Array = [] + for item in value: arr.append({"x": item.x, "y": item.y, "z": item.z}) + return arr + if value is PackedVector4Array: + var arr: Array = [] + for item in value: arr.append({"x": item.x, "y": item.y, "z": item.z, "w": item.w}) + return arr + if value is PackedColorArray: + var arr: Array = [] + for item in value: arr.append({"r": item.r, "g": item.g, "b": item.b, "a": item.a}) + return arr + ## Generic arrays and dictionaries — recurse + if value is Array: + var arr: Array = [] + for item in value: + arr.append(_variant_to_json(item)) + return arr + if value is Dictionary: + var dict: Dictionary = {} + for key in value.keys(): + dict[str(key)] = _variant_to_json(value[key]) + return dict + ## Fallback: string representation + return str(value) diff --git a/addons/godot_ai/runtime/game_helper.gd.uid b/addons/godot_ai/runtime/game_helper.gd.uid new file mode 100644 index 0000000..7224a50 --- /dev/null +++ b/addons/godot_ai/runtime/game_helper.gd.uid @@ -0,0 +1 @@ +uid://gfybkdtsclti diff --git a/addons/godot_ai/runtime/game_logger.gd b/addons/godot_ai/runtime/game_logger.gd new file mode 100644 index 0000000..6384960 --- /dev/null +++ b/addons/godot_ai/runtime/game_logger.gd @@ -0,0 +1,142 @@ +@tool +extends Logger + +## Game-process Logger subclass. +## +## NOTE: deliberately no `class_name`. Registered from inside the running +## game so we can intercept print(), printerr(), push_error(), and +## push_warning() and ferry them back to the editor over the EngineDebugger +## channel — the same bridge PR #76 uses for screenshots. +## +## Logger virtuals can be called from any thread (e.g. async loaders push +## errors off the main thread). We accumulate into _pending under a Mutex +## and the host (game_helper.gd) flushes once per frame from the main +## thread, where EngineDebugger.send_message is safe to call. + +## `McpLogBacktrace` is published as a `class_name` on log_backtrace.gd, but a +## freshly-launched game subprocess (no prior editor scan; e.g. CI launching +## `--headless --path`) hits this autoload before the global class_name table +## is populated, and parsing this script fails with +## "Identifier 'McpLogBacktrace' not declared in the current scope". Using +## `const preload` resolves the path at parse time and is independent of the +## class_name registry — matches the project convention in CLAUDE.md +## ("Internals … skip class_name entirely and load via const preload"). +const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd") + +var _pending: Array = [] +var _mutex := Mutex.new() +## #490: a monotonic sequence + a small ring of recent GDScript runtime +## (script-type) errors, each with its text AND the function names in its +## backtrace. game_helper uses this to attribute a runtime error to the +## *specific* eval that raised it: each eval's wrapper has a uniquely named +## inner function, and game_helper asks find_script_error_since() whether any +## error past its pre-eval baseline carries that function in its stack. This +## avoids failing an eval on an unrelated background game error that merely +## advanced a global counter, and keeps overlapping evals from cross- +## attributing. Gated on ERROR_TYPE_SCRIPT (2) so push_error()/push_warning() +## (types 0/1) never count. Mutex-guarded: _log_error can fire from any thread. +const _ERROR_TYPE_SCRIPT := 2 +const _MAX_RECENT_SCRIPT_ERRORS := 64 +var _script_error_seq: int = 0 +var _recent_script_errors: Array = [] + + +func _log_message(message: String, error: bool) -> void: + ## `error` is true for printerr(), false for print(). + var level := "error" if error else "info" + _append(level, message) + + +func _log_error( + function: String, + file: String, + line: int, + code: String, + rationale: String, + _editor_notify: bool, + error_type: int, + script_backtraces: Array, +) -> void: + ## EngineDebugger's payload shape is `[level, text]` — the source + ## location has nowhere structured to land for the game side, so we + ## inline it into `text`. editor_logger keeps the resolved fields + ## as structured columns instead. + var resolved := _LogBacktrace.resolve_error( + function, file, line, code, rationale, error_type, script_backtraces, + ) + var loc := "" + if not resolved.path.is_empty(): + loc = "%s:%d @ %s" % [resolved.path, resolved.line, resolved.function] if not resolved.function.is_empty() else "%s:%d" % [resolved.path, resolved.line] + var text: String = "%s (%s)" % [resolved.message, loc] if not loc.is_empty() else resolved.message + var details: Dictionary = resolved.get("details", {}) + _append(resolved.level, text, details) + if error_type == _ERROR_TYPE_SCRIPT: + ## Collect every function name in the first non-empty backtrace so + ## game_helper can match its eval's uniquely named wrapper function. + var funcs := PackedStringArray() + for bt: RefCounted in script_backtraces: + if bt != null and bt.get_frame_count() > 0: + for i: int in bt.get_frame_count(): + funcs.append(bt.get_frame_function(i)) + break + _mutex.lock() + _script_error_seq += 1 + _recent_script_errors.append({"seq": _script_error_seq, "text": text, "funcs": funcs}) + if _recent_script_errors.size() > _MAX_RECENT_SCRIPT_ERRORS: + _recent_script_errors.remove_at(0) + _mutex.unlock() + + +func _append(level: String, text: String, details: Dictionary = {}) -> void: + _mutex.lock() + if details.is_empty(): + _pending.append([level, text]) + else: + _pending.append([level, text, details.duplicate(true)]) + _mutex.unlock() + + +## Drain the pending queue and return entries as [[level, text], ...]. +## Called from the main thread by game_helper each frame. +func drain() -> Array: + _mutex.lock() + var out := _pending + _pending = [] + _mutex.unlock() + return out + + +func has_pending() -> bool: + _mutex.lock() + var any := not _pending.is_empty() + _mutex.unlock() + return any + + +## #490: monotonic count of script-type runtime errors seen this run. +## game_helper snapshots this before an eval to use as the `since_seq` +## baseline for find_script_error_since(). Mutex-guarded. +func script_error_seq() -> int: + _mutex.lock() + var v := _script_error_seq + _mutex.unlock() + return v + + +## #490: text of the most recent script error with seq > since_seq whose +## backtrace includes `function_name`, or "" if none. Lets game_helper +## attribute a runtime error to the exact eval whose uniquely named wrapper +## function appears in the stack — ignoring unrelated game errors and errors +## from before the eval started. Mutex-guarded. +func find_script_error_since(since_seq: int, function_name: String) -> String: + _mutex.lock() + var found := "" + for i in range(_recent_script_errors.size() - 1, -1, -1): + var rec: Dictionary = _recent_script_errors[i] + if int(rec["seq"]) <= since_seq: + break + if (rec["funcs"] as PackedStringArray).has(function_name): + found = rec["text"] + break + _mutex.unlock() + return found diff --git a/addons/godot_ai/runtime/game_logger.gd.uid b/addons/godot_ai/runtime/game_logger.gd.uid new file mode 100644 index 0000000..d4e263e --- /dev/null +++ b/addons/godot_ai/runtime/game_logger.gd.uid @@ -0,0 +1 @@ +uid://dwcs6d1y7vhqi diff --git a/addons/godot_ai/runtime/validation_logger.gd b/addons/godot_ai/runtime/validation_logger.gd new file mode 100644 index 0000000..672b11f --- /dev/null +++ b/addons/godot_ai/runtime/validation_logger.gd @@ -0,0 +1,43 @@ +@tool +extends Logger + +## Short-lived Logger used only for per-write validation loads. +## +## Unlike editor_logger.gd this deliberately has no addon feedback-loop filter: +## the caller attaches it around one ResourceLoader.load() call, reads its +## private buffer, and immediately removes it. The shared editor logger should +## still drop these validation-load errors so logs_read(source="editor") stays +## clean. + +const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd") + +var _buffer + + +func _init(buffer = null) -> void: + _buffer = buffer + + +func _log_error( + function: String, + file: String, + line: int, + code: String, + rationale: String, + _editor_notify: bool, + error_type: int, + script_backtraces: Array, +) -> void: + if _buffer == null: + return + var resolved := _LogBacktrace.resolve_error( + function, + file, + line, + code, + rationale, + error_type, + script_backtraces, + ) + var details: Dictionary = resolved.get("details", {}) + _buffer.append(resolved.level, resolved.message, resolved.path, resolved.line, resolved.function, details) diff --git a/addons/godot_ai/runtime/validation_logger.gd.uid b/addons/godot_ai/runtime/validation_logger.gd.uid new file mode 100644 index 0000000..4e693b4 --- /dev/null +++ b/addons/godot_ai/runtime/validation_logger.gd.uid @@ -0,0 +1 @@ +uid://b2ff3aot6t2l4 diff --git a/addons/godot_ai/telemetry.gd b/addons/godot_ai/telemetry.gd new file mode 100644 index 0000000..dc90bf9 --- /dev/null +++ b/addons/godot_ai/telemetry.gd @@ -0,0 +1,198 @@ +## Plugin-side telemetry helper. +## +## Relays plugin-only events (dock startup, self-update outcome, plugin +## reload, dev-server toggle) to the Python MCP server via the existing +## `send_event("plugin_event", {...})` channel. The server's +## `transport/websocket.py` allowlists event names and forwards into the +## central telemetry pipeline — meaning opt-out, endpoint, customer_uuid +## and the bounded-queue worker stay in one place (Python), not +## duplicated in GDScript. +## +## Opt-out options priority: +## 1. `GODOT_AI_DISABLE_TELEMETRY` / `DISABLE_TELEMETRY` env vars — +## checked first so CI / operators can force-disable without touching +## EditorSettings. +## 2. The `godot_ai/telemetry_enabled` EditorSetting — set through the +## MCP dock and persisted between sessions. +## +## When telemetry is disabled, events are never buffered or sent. Only a +## *truthy* env var force-disables; a falsey or absent env var falls through +## to the EditorSetting (which defaults to enabled). See McpSettings.telemetry_enabled. +## +## Buffering: events recorded before the WebSocket is connected go into +## a small bounded buffer and flush on the next `record_event` call once +## connected. The buffer is intentionally small (`_MAX_BUFFER`); plugin +## events are sparse, and a flood means something is misconfigured. + +extends RefCounted + +## Allowlist mirrored on the Python side in +## `src/godot_ai/transport/websocket.py::_PLUGIN_EVENT_NAMES`. Update +## both together. +const _ALLOWED_EVENTS := [ + "dock_startup", + "plugin_reload", + "self_update", + "dev_server_toggle", +] + +const _MAX_BUFFER := 32 + +## EditorSetting key used to defer a ``plugin_reload`` event across the +## disable -> enable boundary. Callers that trigger plugin reload (the +## dock reload button, ``editor_reload_plugin`` MCP-tool path) write +## here *before* the disable kills the live WebSocket; the new +## plugin's ``_enter_tree`` flushes via ``flush_pending_plugin_reload``. +const PENDING_PLUGIN_RELOAD_KEY := "godot_ai/pending_plugin_reload_event" + + +## Persist a ``plugin_reload`` event so the re-enabled plugin instance +## can emit it once its new WebSocket is up. Static so callers without +## a telemetry instance handle (e.g. ``editor_handler.reload_plugin``) +## can use it via the preloaded const alias. +static func record_pending_plugin_reload(source: String) -> void: + var settings := EditorInterface.get_editor_settings() + if settings == null: + return + settings.set_setting( + PENDING_PLUGIN_RELOAD_KEY, + JSON.stringify({"source": source, "success": true}), + ) + + +## Read + clear an EditorSetting JSON-encoded event payload. Returns +## the parsed dict, or ``null`` if the key is absent / empty / +## malformed. Used by ``flush_pending_plugin_reload`` (below) and by +## ``plugin.gd::_flush_pending_self_update_telemetry``. Centralising +## the read-and-clear dance keeps both flush sites symmetric with the +## ``record_pending_*`` writers and prevents the "key gets stuck" +## class of bug if a future flush helper forgets the clear step. +static func _drain_editor_setting_dict(key: String): + var settings := EditorInterface.get_editor_settings() + if settings == null: + return null + if not settings.has_setting(key): + return null + var raw := str(settings.get_setting(key)) + settings.set_setting(key, "") + if raw == "": + return null + var parsed = JSON.parse_string(raw) + if typeof(parsed) != TYPE_DICTIONARY: + return null + return parsed + +var _connection +var _disabled: bool = false +var _pending: Array = [] # of {name: String, data: Dictionary} + +func _init(connection) -> void: + _connection = connection + _disabled = not McpSettings.telemetry_enabled() + ## Subscribe to ``connection_state_changed`` so events buffered before + ## the WebSocket handshake (e.g. ``record_dock_startup`` from + ## ``plugin._enter_tree``) actually leave the editor. Without this, + ## the buffer only drained on the next ``record_event`` call — when + ## that call never came (the common single-session case), the very + ## events we cared about most sat in the queue forever. + if _connection != null and _connection.has_signal("connection_state_changed"): + _connection.connection_state_changed.connect(_on_connection_state_changed) + + +func record_event(name: String, data: Dictionary = {}) -> void: + if _disabled: + return + if not _ALLOWED_EVENTS.has(name): + ## Drop silently — matches the server's behavior for unknown + ## names, and avoids editor yellow-bar noise from third-party + ## callers or stale event names mid-rollout. + return + if _connection != null and _connection.is_connected: + _flush() + _send_one(name, data) + return + ## Pre-handshake: stash in a small bounded buffer. Drained on the + ## first ``connection_state_changed(true)`` after this point (see + ## ``_on_connection_state_changed``). Falling back to "drain on the + ## next record_event" is a footgun: the most useful plugin events + ## (``dock_startup``, pending ``self_update``) fire from + ## ``plugin._enter_tree`` before the handshake, and a single-session + ## editor may never emit a second event — so without the signal- + ## driven flush they sat buffered forever. + if _pending.size() >= _MAX_BUFFER: + _pending.pop_front() + _pending.append({"name": name, "data": data}) + + +func _on_connection_state_changed(is_open: bool) -> void: + if is_open: + _flush() + +func _flush() -> void: + if _pending.is_empty(): + return + var to_send := _pending.duplicate() + _pending.clear() + for entry in to_send: + _send_one(entry["name"], entry["data"]) + +func _send_one(name: String, data: Dictionary) -> void: + if _connection == null: + return + _connection.send_event("plugin_event", {"name": name, "data": data}) + +# --- convenience emitters -------------------------------------------------- + +func record_dock_startup(extra: Dictionary = {}) -> void: + record_event("dock_startup", extra) + +func record_self_update( + status: String, + from_version: String = "", + to_version: String = "", + error: String = "", +) -> void: + var data := {"status": status} + if from_version != "": + data["from_version"] = from_version + if to_version != "": + data["to_version"] = to_version + if error != "": + data["error"] = error.substr(0, 200) + record_event("self_update", data) + +func record_dev_server_toggle(action: String) -> void: + record_event("dev_server_toggle", {"action": action}) + + +## Drain a pending ``plugin_reload`` event written by the previous +## instance before it disabled itself. Pending events are currently +## always success=true — ``record_pending_plugin_reload`` above is the +## only writer and hardcodes it (a reload that fails never reaches the +## flush anyway; there is no new instance to drain the key). The +## error/success parsing below stays tolerant for forward compat with +## a writer that records failures. +func flush_pending_plugin_reload() -> void: + var parsed = _drain_editor_setting_dict(PENDING_PLUGIN_RELOAD_KEY) + if parsed == null: + return + var data := { + "success": bool(parsed.get("success", true)), + "source": str(parsed.get("source", "unknown")), + } + var error := str(parsed.get("error", "")) + if error != "": + data["error"] = error.substr(0, 200) + record_event("plugin_reload", data) + +# --- test seam ------------------------------------------------------------- + +## Inject a fake connection or force the disabled flag for unit tests +## that don't have a live WebSocket. Production code does not call this. +func _test_set_state(connection, disabled: bool) -> void: + _connection = connection + _disabled = disabled + _pending.clear() + +func _test_pending_count() -> int: + return _pending.size() diff --git a/addons/godot_ai/telemetry.gd.uid b/addons/godot_ai/telemetry.gd.uid new file mode 100644 index 0000000..6f46be6 --- /dev/null +++ b/addons/godot_ai/telemetry.gd.uid @@ -0,0 +1 @@ +uid://dlul2gculiy1p diff --git a/addons/godot_ai/testing/script_error_capture.gd b/addons/godot_ai/testing/script_error_capture.gd new file mode 100644 index 0000000..92c53ed --- /dev/null +++ b/addons/godot_ai/testing/script_error_capture.gd @@ -0,0 +1,49 @@ +@tool +extends Logger + +## Captures GDScript runtime errors emitted while a test is running. +## +## Deliberately no class_name: this is an internal test helper. +## +## Only ERROR_TYPE_SCRIPT is captured. push_error(), push_warning(), and +## engine-internal ERR_FAIL_* checks are often valid negative-path assertions and +## should not abort the test. + +var _mutex := Mutex.new() +var _capturing := false +var _errors := PackedStringArray() + + +func begin_capture() -> void: + _mutex.lock() + _capturing = true + _errors.clear() + _mutex.unlock() + + +func end_capture() -> PackedStringArray: + _mutex.lock() + var captured := _errors.duplicate() + _capturing = false + _errors.clear() + _mutex.unlock() + return captured + + +func _log_error( + function: String, + file: String, + line: int, + code: String, + rationale: String, + _editor_notify: bool, + error_type: int, + _script_backtraces: Array, +) -> void: + if error_type != ERROR_TYPE_SCRIPT: + return + _mutex.lock() + if _capturing: + var text := rationale if not rationale.is_empty() else code + _errors.append("%s (%s:%d in %s)" % [text, file, line, function]) + _mutex.unlock() diff --git a/addons/godot_ai/testing/script_error_capture.gd.uid b/addons/godot_ai/testing/script_error_capture.gd.uid new file mode 100644 index 0000000..6b2d712 --- /dev/null +++ b/addons/godot_ai/testing/script_error_capture.gd.uid @@ -0,0 +1 @@ +uid://crk8w2nei087v diff --git a/addons/godot_ai/testing/test_runner.gd b/addons/godot_ai/testing/test_runner.gd new file mode 100644 index 0000000..1aa0041 --- /dev/null +++ b/addons/godot_ai/testing/test_runner.gd @@ -0,0 +1,441 @@ +@tool +class_name McpTestRunner +extends RefCounted + +## Lightweight test runner for MCP plugin tests. Discovers test_* methods +## on McpTestSuite instances, runs them, and collects structured results. + +const ScriptErrorCapture := preload("res://addons/godot_ai/testing/script_error_capture.gd") + +var _results: Array[Dictionary] = [] +var _last_run_ms: int = 0 +var _script_error_capture: ScriptErrorCapture = null +var _capture_registered := false + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE and _capture_registered and _script_error_capture != null: + OS.remove_logger(_script_error_capture) + _capture_registered = false + + +func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filter: String = "") -> void: + var owns_capture := not _capture_registered + if owns_capture: + _register_capture() + + _run_suite_tests(suite, test_filter, exclude_test_filter, Callable(), 0, {}) + + if owns_capture: + _unregister_capture() + + +## Shared per-test loop for both the legacy synchronous path and the +## serviced driver. Returns "" when the loop completed, or a terminal +## outcome ("timeout" / "transport_lost" / "paused") when a checkpoint +## aborted it. Checkpoints run BETWEEN tests — an atomic test body is +## never preempted (see docs/test-run-transport-starvation-plan.md). +func _run_suite_tests( + suite: McpTestSuite, + test_filter: String, + exclude_test_filter: String, + service_cb: Callable, + deadline_ticks_ms: int, + run_state: Dictionary, +) -> String: + var name := suite.suite_name() + var methods := _get_test_methods(suite) + var exclusions := _parse_exclusions(exclude_test_filter) + + for method_name in methods: + if not test_filter.is_empty() and method_name.find(test_filter) == -1: + continue + _selected_processed += 1 + if _matches_any_exclusion(method_name, exclusions): + _results.append({ + "suite": name, + "test": method_name, + "passed": true, + "skipped": true, + "message": "Excluded by exclude_test_name filter", + "assertion_count": 0, + "duration_ms": 0, + }) + continue + + var test_start := Time.get_ticks_msec() + var entry := _run_one_test(suite, name, method_name) + entry["duration_ms"] = Time.get_ticks_msec() - test_start + _results.append(entry) + + var stop := _checkpoint(service_cb, deadline_ticks_ms, run_state) + if not stop.is_empty(): + return stop + return "" + + +## Execute one test method and return its result entry (not yet appended; +## the caller stamps `duration_ms`). Extracted from run_suite so the legacy +## and serviced drivers share one execution core — behavior must stay +## byte-identical to the pre-refactor loop body. +func _run_one_test(suite: McpTestSuite, name: String, method_name: String) -> Dictionary: + suite._reset() + _begin_script_error_capture() + suite.setup() + suite.call(method_name) + suite.teardown() + var script_errors := suite._unexpected_script_errors(_end_script_error_capture()) + suite._free_tracked() + + ## Issue #19 defence: free any `_McpTest*` nodes the test created, even + ## nested ones. If the scene gets auto-saved mid-test while one of these + ## exists, the reference bakes into main.tscn and breaks the next open + ## with a "missing dependency" error. Runs after every test, not just at + ## suite boundaries, so a test that fails mid-flow can't leave a trap + ## for the next test or for scene autosave. + var scene_root_for_cleanup := _edited_scene_root() + if scene_root_for_cleanup != null and scene_root_for_cleanup.is_inside_tree(): + _free_mcp_test_nodes_recursive(scene_root_for_cleanup) + + if not script_errors.is_empty(): + var abort_message := "Aborted by SCRIPT ERROR: %s" % "; ".join(script_errors) + if suite._failed: + abort_message += " (after assertion failure: %s)" % suite._message + return { + "suite": name, + "test": method_name, + "passed": false, + "message": abort_message, + "assertion_count": suite._assertion_count, + } + + ## A failed assertion always wins over a later skip(): a test that + ## fails and then hits a skip-guard must report the failure, not + ## park itself as green-skipped. + if suite._skipped and not suite._failed: + return { + "suite": name, + "test": method_name, + "passed": true, + "skipped": true, + "message": suite._skip_reason, + "assertion_count": 0, + } + + var passed := not suite._failed + var msg := suite._message + + ## Warn about zero-assertion tests (likely silently skipped logic). + if passed and suite._assertion_count == 0: + passed = false + msg = "Test completed with 0 assertions (likely skipped its logic)" + + return { + "suite": name, + "test": method_name, + "passed": passed, + "message": msg, + "assertion_count": suite._assertion_count, + } + + +func run_suites(suites: Array, suite_filter: String = "", test_filter: String = "", ctx: Dictionary = {}, verbose: bool = false, exclude_test_filter: String = "") -> Dictionary: + ## Legacy synchronous API — unchanged signature and semantics for direct + ## callers, unit-test fixtures, and any batch context. No servicing, no + ## ceiling: with an invalid Callable and deadline 0 every checkpoint is a + ## no-op and the outcome is always "completed". + var run := run_suites_serviced(suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter) + return run["results"] + + +## Serviced driver for live MCP runs. Between tests and at suite +## boundaries it (a) aborts once `deadline_ticks_ms` passes and (b) calls +## `service_cb` (McpConnection.service_transport_during_exclusive_run) so +## the WebSocket heartbeat stays alive while the suite monopolizes the +## main thread. Returns: +## { +## "outcome": "completed" | "timeout" | "transport_lost" | "paused", +## "results": , +## "tests_not_run": , +## } +## Every outcome path — including aborts — runs the current suite's +## teardown + leak cleanup, restores console echo, and unregisters the +## capture logger. `run_state` is caller-owned and threaded through to the +## service callback (cumulative packet counter lives there). +func run_suites_serviced( + suites: Array, + suite_filter: String = "", + test_filter: String = "", + ctx: Dictionary = {}, + verbose: bool = false, + exclude_test_filter: String = "", + service_cb: Callable = Callable(), + deadline_ticks_ms: int = 0, + run_state: Dictionary = {}, +) -> Dictionary: + _results.clear() + _selected_processed = 0 + var selected_total := _count_selected_tests(suites, suite_filter, test_filter) + var start := Time.get_ticks_msec() + var outcome := "" + + ## Silence the plugin's ring-buffer console echo while tests run. Negative- + ## path suites deliberately fill the ring with 500 lines and log malformed- + ## result errors; echoing all of that buries an all-green run in scary + ## console output. The ring contents tests assert on are untouched, and + ## the flag is restored after the run so live logging resumes. + var _prev_console_echo := McpLogBuffer.console_echo + McpLogBuffer.console_echo = false + + ## If a prior run was interrupted after registering the logger but before + ## normal teardown, remove that stale registration before starting fresh. + _unregister_capture() + _register_capture() + + for suite: McpTestSuite in suites: + if not suite_filter.is_empty() and suite.suite_name() != suite_filter: + continue + + ## Snapshot scene children before the suite so we can clean up leaks. + var scene_root := _edited_scene_root() + var before_children: Array[Node] = [] + if scene_root != null: + before_children = _get_children_snapshot(scene_root) + + suite._reset_suite_state() + suite.suite_setup(ctx.duplicate(true)) + + ## fail_setup() / skip_suite() gives suites a clean way to bail out of + ## suite_setup without leaving N tests to fail with "0 assertions". We + ## emit ONE suite-level result and skip individual tests entirely. + if suite._suite_failed: + _results.append({ + "suite": suite.suite_name(), + "test": "", + "passed": false, + "message": "suite_setup() failed: %s (subsequent tests not run)" % suite._suite_failed_message, + "assertion_count": 0, + }) + ## The bailed suite's tests are accounted for, not "not run". + _selected_processed += _count_selected_tests([suite], "", test_filter) + elif suite._suite_skipped: + _results.append({ + "suite": suite.suite_name(), + "test": "", + "passed": true, + "skipped": true, + "message": "suite_setup() skipped: %s" % suite._suite_skipped_reason, + "assertion_count": 0, + }) + _selected_processed += _count_selected_tests([suite], "", test_filter) + else: + outcome = _checkpoint(service_cb, deadline_ticks_ms, run_state) + if outcome.is_empty(): + outcome = _run_suite_tests( + suite, test_filter, exclude_test_filter, + service_cb, deadline_ticks_ms, run_state + ) + ## Suite epilogue runs on EVERY path, including aborts: the suite has + ## begun, so its teardown and leak cleanup must not be skipped. + suite.suite_teardown() + suite._free_tracked() + + ## Remove any nodes the suite left behind (failed undo, missing cleanup). + if scene_root != null and scene_root.is_inside_tree(): + _cleanup_leaked_nodes(scene_root, before_children) + + if not outcome.is_empty(): + break + + outcome = _checkpoint(service_cb, deadline_ticks_ms, run_state) + if not outcome.is_empty(): + break + + _last_run_ms = Time.get_ticks_msec() - start + McpLogBuffer.console_echo = _prev_console_echo + _unregister_capture() + if outcome.is_empty(): + outcome = "completed" + return { + "outcome": outcome, + "results": get_results(verbose), + "tests_not_run": maxi(0, selected_total - _selected_processed), + } + + +## Count of selected (filter-surviving) tests already looped over this run, +## including exclusion-skips and bailed-suite accounting. Drives the +## `tests_not_run` estimate on aborted runs. +var _selected_processed := 0 + + +func _count_selected_tests(suites: Array, suite_filter: String, test_filter: String) -> int: + var count := 0 + for suite: McpTestSuite in suites: + if not suite_filter.is_empty() and suite.suite_name() != suite_filter: + continue + for method_name in _get_test_methods(suite): + if not test_filter.is_empty() and method_name.find(test_filter) == -1: + continue + count += 1 + return count + + +## Between-phase checkpoint: "" to continue, or a terminal outcome. +## Delegates to the shared mapping so the discovery checkpoints in +## test_handler.gd can never drift from the between-test ones (plan §9 Q2). +func _checkpoint(service_cb: Callable, deadline_ticks_ms: int, run_state: Dictionary) -> String: + return McpConnection.exclusive_run_checkpoint(service_cb, deadline_ticks_ms, run_state) + + +func _register_capture() -> void: + if _capture_registered: + return + if _script_error_capture == null: + _script_error_capture = ScriptErrorCapture.new() + if _script_error_capture == null: + return + OS.add_logger(_script_error_capture) + _capture_registered = true + + +func _unregister_capture() -> void: + if not _capture_registered: + return + if _script_error_capture == null: + _capture_registered = false + return + OS.remove_logger(_script_error_capture) + _capture_registered = false + + +func _begin_script_error_capture() -> void: + if _script_error_capture != null and _capture_registered: + _script_error_capture.begin_capture() + + +func _end_script_error_capture() -> PackedStringArray: + if _script_error_capture == null or not _capture_registered: + return PackedStringArray() + return _script_error_capture.end_capture() + + +static func _edited_scene_root() -> Node: + if not Engine.is_editor_hint(): + return null + return EditorInterface.get_edited_scene_root() + + +func get_results(verbose: bool = false) -> Dictionary: + var passed := 0 + var failed := 0 + var skipped := 0 + var failures: Array[Dictionary] = [] + var suites_seen := {} + for r in _results: + suites_seen[r.suite] = true + if r.get("skipped", false): + skipped += 1 + elif r.passed: + passed += 1 + else: + failed += 1 + failures.append(r) + + var result := { + "passed": passed, + "failed": failed, + "skipped": skipped, + "total": _results.size(), + "duration_ms": _last_run_ms, + "suites_run": suites_seen.keys(), + "suite_count": suites_seen.size(), + } + + if not failures.is_empty(): + result["failures"] = failures + + if verbose: + result["results"] = _results + + return result + + +func clear() -> void: + _results.clear() + _last_run_ms = 0 + + +func _get_test_methods(obj: Object) -> Array[String]: + var methods: Array[String] = [] + for m in obj.get_method_list(): + var name: String = m.get("name", "") + if name.begins_with("test_"): + methods.append(name) + methods.sort() + return methods + + +func _get_children_snapshot(node: Node) -> Array[Node]: + var children: Array[Node] = [] + for child in node.get_children(): + children.append(child) + return children + + +## Remove any nodes in scene_root that weren't present before the suite ran, +## plus any _McpTest* named nodes anywhere in the tree (catches nested leaks). +## NOTE: this bypasses EditorUndoRedoManager by design — the test runner +## owns these leaks and needs to clear them unconditionally. Don't Ctrl-Z in +## the editor immediately after a test run that triggered cleanup; the undo +## stack may reference freed nodes. +func _cleanup_leaked_nodes(scene_root: Node, before: Array[Node]) -> void: + var before_set := {} + for n in before: + before_set[n] = true + for child in scene_root.get_children(): + if not before_set.has(child): + scene_root.remove_child(child) + child.queue_free() + + +## Recursively free every node whose name starts with `_McpTest`, anywhere in +## the scene. Intentionally bypasses undo — these are test leaks, not user +## work. Walk breadth-first so we can collect victims before mutating the tree. +func _free_mcp_test_nodes_recursive(root: Node) -> void: + var victims: Array[Node] = [] + var queue: Array[Node] = [root] + while not queue.is_empty(): + var node: Node = queue.pop_back() + for child in node.get_children(): + if str(child.name).begins_with("_McpTest"): + victims.append(child) + else: + queue.append(child) + for v in victims: + if v.get_parent() != null: + v.get_parent().remove_child(v) + v.queue_free() + + +## Split the `exclude_test_name` filter into individual substring matchers. +## Comma-separated so the CI smoke harness can list multiple flaky tests +## without shipping a richer schema (single names still work — same string, +## no comma, same one-element list). Whitespace around each name is stripped +## so `"a, b"` and `"a,b"` behave identically. +static func _parse_exclusions(filter: String) -> Array[String]: + var out: Array[String] = [] + if filter.is_empty(): + return out + for part in filter.split(","): + var trimmed := part.strip_edges() + if not trimmed.is_empty(): + out.append(trimmed) + return out + + +static func _matches_any_exclusion(method_name: String, exclusions: Array[String]) -> bool: + for ex in exclusions: + if method_name.find(ex) != -1: + return true + return false diff --git a/addons/godot_ai/testing/test_runner.gd.uid b/addons/godot_ai/testing/test_runner.gd.uid new file mode 100644 index 0000000..c0befb0 --- /dev/null +++ b/addons/godot_ai/testing/test_runner.gd.uid @@ -0,0 +1 @@ +uid://367b77qh5grt diff --git a/addons/godot_ai/testing/test_suite.gd b/addons/godot_ai/testing/test_suite.gd new file mode 100644 index 0000000..57b3bef --- /dev/null +++ b/addons/godot_ai/testing/test_suite.gd @@ -0,0 +1,330 @@ +@tool +class_name McpTestSuite +extends RefCounted + +## Base class for MCP test suites. Provides assertion methods and +## lifecycle hooks. Subclass this, add test_* methods, and drop the +## script in res://tests/. + +## Override to return a short name for this suite (e.g. "scene", "node"). +func suite_name() -> String: + return "unnamed" + + +## Called once before the suite runs. Override to create handlers. +func suite_setup(_ctx: Dictionary) -> void: + pass + + +## Called before each test method. +func setup() -> void: + pass + + +## Called after each test method. +func teardown() -> void: + pass + + +## Called once after the suite finishes. +func suite_teardown() -> void: + pass + + +# ----- tracked allocations (freed by the runner after each test) ----- + +var _tracked_objects: Array[Object] = [] + + +## Register a manually-managed Object (plain Object or out-of-tree Node) so +## the runner frees it after the current test. RefCounted instances are +## accepted but ignored because they manage their own lifetime. +func track(obj: Object) -> Object: + if obj != null and not obj is RefCounted: + _tracked_objects.append(obj) + return obj + + +## Free everything registered via track(). Called by the runner after each +## test's teardown() and again after suite_teardown(). +func _free_tracked() -> void: + for obj in _tracked_objects: + if not is_instance_valid(obj) or (obj is Node and obj.is_queued_for_deletion()): + continue + if obj is Node: + var parent := (obj as Node).get_parent() + if parent != null: + parent.remove_child(obj) + obj.free() + _tracked_objects.clear() + + +# ----- assertion state (managed by McpTestRunner) ----- + +var _failed: bool = false +var _message: String = "" +var _assertion_count: int = 0 +var _skipped: bool = false +var _skip_reason: String = "" +var _expected_script_error_substrings: Array[String] = [] + +# ----- suite-level state (managed by McpTestRunner) ----- + +var _suite_failed: bool = false +var _suite_failed_message: String = "" +var _suite_skipped: bool = false +var _suite_skipped_reason: String = "" + + +func _reset() -> void: + _failed = false + _message = "" + _assertion_count = 0 + _skipped = false + _skip_reason = "" + _expected_script_error_substrings.clear() + + +func _reset_suite_state() -> void: + _suite_failed = false + _suite_failed_message = "" + _suite_skipped = false + _suite_skipped_reason = "" + + +## Mark the current test as skipped. Use when a precondition isn't met +## (e.g. no scene open, no Node3D in scene) and the test can't run. +## Skipped tests count separately from passed/failed. +func skip(reason: String = "") -> void: + _skipped = true + _skip_reason = reason + + +## Bail out of suite_setup() with a failure. Subsequent tests in this suite +## are not run; the runner reports a single suite-level failure with the +## given reason instead of N zero-assertion noise lines per test. +## +## Example: +## func suite_setup(ctx): +## var arena = preload("res://game/arena.gd").new() +## if arena == null: +## fail_setup("arena.gd failed to instantiate in @tool scope") +## return +func fail_setup(reason: String) -> void: + _suite_failed = true + _suite_failed_message = reason + + +## Bail out of suite_setup() because a precondition isn't met (no scene open, +## no game running, etc.). Subsequent tests are not run and the runner emits +## a single suite-level skip rather than per-test skip noise. +func skip_suite(reason: String) -> void: + _suite_skipped = true + _suite_skipped_reason = reason + + +## Mark the current test as skipped when the running Godot is older than +## `min_version` (a "major.minor" string like "4.6"). Use for tests that +## exercise an engine API or behavior that only exists on newer Godot. +## Returns true when the test was skipped, so callers can `return` from +## the test body. +## +## Example: +## func test_uses_46_only_api() -> void: +## if skip_on_godot_lt("4.6", "example API requires Godot 4.6+"): +## return +## ... +func skip_on_godot_lt(min_version: String, reason: String = "") -> bool: + var v := Engine.get_version_info() + var current_major := int(v.get("major", 0)) + var current_minor := int(v.get("minor", 0)) + var parts := min_version.split(".") + var want_major := int(parts[0]) if parts.size() > 0 else 0 + var want_minor := int(parts[1]) if parts.size() > 1 else 0 + if ( + current_major < want_major + or (current_major == want_major and current_minor < want_minor) + ): + var msg := reason if not reason.is_empty() else "requires Godot %s+" % min_version + skip(msg + " (running %d.%d)" % [current_major, current_minor]) + return true + return false + + +## Allow one captured SCRIPT ERROR whose text contains `substring`. +## Use only for negative-path tests that intentionally compile or execute +## invalid GDScript and assert on the resulting diagnostics. +func expect_script_error_containing(substring: String) -> void: + _expected_script_error_substrings.append(substring) + + +func _unexpected_script_errors(captured: PackedStringArray) -> PackedStringArray: + var unexpected := PackedStringArray() + var remaining := _expected_script_error_substrings.duplicate() + for error in captured: + var matched_index := -1 + for i in range(remaining.size()): + if error.find(remaining[i]) != -1: + matched_index = i + break + if matched_index == -1: + unexpected.append(error) + else: + remaining.remove_at(matched_index) + return unexpected + + +## Trigger an undo against whichever history (scene or global) holds the most +## recent action. `EditorUndoRedoManager` in Godot 4.x doesn't expose `.undo()` +## directly — you resolve the history's underlying UndoRedo and call it there. +## Actions registered via `add_do_method(self, …)` with a non-scene target land +## in GLOBAL_HISTORY, while actions on scene nodes land in the scene's history, +## so we try both (matches the pattern in batch_handler.gd). +func editor_undo(undo_redo: EditorUndoRedoManager) -> bool: + for ur in _collect_histories(undo_redo): + if ur.undo(): + return true + return false + + +## Mirror of `editor_undo` for redo. +func editor_redo(undo_redo: EditorUndoRedoManager) -> bool: + for ur in _collect_histories(undo_redo): + if ur.redo(): + return true + return false + + +func _collect_histories(undo_redo: EditorUndoRedoManager) -> Array: + var out: Array = [] + if undo_redo == null: + return out + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root != null: + var scene_id := undo_redo.get_object_history_id(scene_root) + var scene_ur := undo_redo.get_history_undo_redo(scene_id) + if scene_ur != null: + out.append(scene_ur) + var global_ur := undo_redo.get_history_undo_redo(EditorUndoRedoManager.GLOBAL_HISTORY) + if global_ur != null and not global_ur in out: + out.append(global_ur) + return out + + +# ----- assertions ----- + +func assert_true(condition: bool, msg: String = "") -> void: + _assertion_count += 1 + if _failed: + return + if not condition: + _failed = true + _message = msg if msg else "Expected true" + + +func assert_false(condition: bool, msg: String = "") -> void: + _assertion_count += 1 + if _failed: + return + if condition: + _failed = true + _message = msg if msg else "Expected false" + + +func assert_eq(actual: Variant, expected: Variant, msg: String = "") -> void: + _assertion_count += 1 + if _failed: + return + if actual != expected: + _failed = true + _message = msg if msg else "Expected %s, got %s" % [str(expected), str(actual)] + + +func assert_ne(actual: Variant, not_expected: Variant, msg: String = "") -> void: + _assertion_count += 1 + if _failed: + return + if actual == not_expected: + _failed = true + _message = msg if msg else "Expected value != %s" % str(not_expected) + + +func assert_gt(actual: Variant, threshold: Variant, msg: String = "") -> void: + _assertion_count += 1 + if _failed: + return + if not (actual > threshold): + _failed = true + _message = msg if msg else "Expected %s > %s" % [str(actual), str(threshold)] + + +func assert_has_key(dict: Variant, key: String, msg: String = "") -> void: + _assertion_count += 1 + if _failed: + return + if not dict is Dictionary: + _failed = true + _message = msg if msg else "Expected Dictionary, got %s" % type_string(typeof(dict)) + return + if not dict.has(key): + _failed = true + _message = msg if msg else "Missing key: %s (keys: %s)" % [key, str(dict.keys())] + + +func assert_contains(haystack: Variant, needle: Variant, msg: String = "") -> void: + _assertion_count += 1 + if _failed: + return + if haystack is String: + if haystack.find(str(needle)) == -1: + _failed = true + _message = msg if msg else "'%s' not found in '%s'" % [str(needle), haystack] + elif haystack is Array: + if not haystack.has(needle): + _failed = true + _message = msg if msg else "%s not found in array" % str(needle) + else: + _failed = true + _message = msg if msg else "assert_contains requires String or Array" + + +func assert_is_error(result: Dictionary, expected_code: String = "", msg: String = "") -> void: + _assertion_count += 1 + if _failed: + return + if not result.has("error"): + _failed = true + _message = msg if msg else "Expected error response, got: %s" % str(result.keys()) + return + if expected_code and result.error.get("code", "") != expected_code: + _failed = true + _message = msg if msg else "Expected error code %s, got %s" % [expected_code, result.error.get("code", "")] + + +# ----- scene helpers (shared across suites that create/remove Controls) ----- + +## Add a Control under the scene root. Creates a Panel if ctl is null. +## Returns the scene path, or "" when no scene is open — in which case a +## caller-supplied ctl is freed to prevent leaks. +func _add_control(ctl_name: String, ctl: Control = null) -> String: + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root == null: + if ctl != null: + ctl.queue_free() + return "" + if ctl == null: + ctl = Panel.new() + ctl.name = ctl_name + scene_root.add_child(ctl) + ctl.owner = scene_root + return "/" + scene_root.name + "/" + ctl_name + + +func _remove_control(path: String) -> void: + var scene_root := EditorInterface.get_edited_scene_root() + if scene_root == null: + return + var node := McpScenePath.resolve(path, scene_root) + if node != null: + node.get_parent().remove_child(node) + node.queue_free() diff --git a/addons/godot_ai/testing/test_suite.gd.uid b/addons/godot_ai/testing/test_suite.gd.uid new file mode 100644 index 0000000..b75e726 --- /dev/null +++ b/addons/godot_ai/testing/test_suite.gd.uid @@ -0,0 +1 @@ +uid://dlrq2s7jhp71s diff --git a/addons/godot_ai/tool_catalog.gd b/addons/godot_ai/tool_catalog.gd new file mode 100644 index 0000000..d1e8b4a --- /dev/null +++ b/addons/godot_ai/tool_catalog.gd @@ -0,0 +1,110 @@ +@tool +class_name McpToolCatalog +extends RefCounted + +## Mirror of src/godot_ai/tools/domains.py — drives the dock's Tools tab +## so the UI can render checkboxes, tool counts, and tooltips without +## round-tripping to a running server. +## +## DO NOT EDIT by hand. tests/unit/test_tool_domains.py verifies this file +## against actual tool registration and fails CI when they drift; the +## failure message prints the up-to-date catalog body for paste-over. +## +## The four core tools are always registered and cannot be excluded — they +## render as a single grayed-out "Core" row in the UI. Each non-core domain +## now exposes one or two named verbs plus a single rolled-up +## `_manage` tool. + +const CORE_TOOLS := [ + "editor_state", + "node_get_properties", + "scene_get_hierarchy", + "session_activate", +] + +## Non-core tools that live in a NON-excludable domain (only `session` +## today), so they appear in no DOMAINS row yet are always registered. +## Counted alongside CORE_TOOLS so the dock's totals match the real +## server surface. +const ALWAYS_ON_TOOLS := [ + "session_manage", +] + +## Ordered list of user-toggleable domains. Each entry: +## id: matches the name passed to `--exclude-domains` +## label: human-friendly display (same as id for now, kept separate so +## a future renaming doesn't break the setting) +## count: number of NON-CORE tools in this domain +## tools: flat list of tool names registered by this domain (non-core only) +const DOMAINS := [ + {"id": "animation", "label": "animation", "count": 2, "tools": ["animation_create", "animation_manage"]}, + {"id": "api", "label": "api", "count": 1, "tools": ["api_manage"]}, + {"id": "audio", "label": "audio", "count": 1, "tools": ["audio_manage"]}, + {"id": "autoload", "label": "autoload", "count": 1, "tools": ["autoload_manage"]}, + {"id": "batch", "label": "batch", "count": 1, "tools": ["batch_execute"]}, + {"id": "camera", "label": "camera", "count": 1, "tools": ["camera_manage"]}, + {"id": "client", "label": "client", "count": 1, "tools": ["client_manage"]}, + {"id": "custom", "label": "custom", "count": 1, "tools": ["custom_manage"]}, + {"id": "editor", "label": "editor", "count": 4, "tools": ["editor_manage", "editor_reload_plugin", "editor_screenshot", "logs_read"]}, + {"id": "filesystem", "label": "filesystem", "count": 1, "tools": ["filesystem_manage"]}, + {"id": "game", "label": "game", "count": 1, "tools": ["game_manage"]}, + {"id": "input_map", "label": "input_map", "count": 1, "tools": ["input_map_manage"]}, + {"id": "material", "label": "material", "count": 1, "tools": ["material_manage"]}, + {"id": "node", "label": "node", "count": 4, "tools": ["node_create", "node_find", "node_manage", "node_set_property"]}, + {"id": "particle", "label": "particle", "count": 1, "tools": ["particle_manage"]}, + {"id": "project", "label": "project", "count": 2, "tools": ["project_manage", "project_run"]}, + {"id": "resource", "label": "resource", "count": 1, "tools": ["resource_manage"]}, + {"id": "scene", "label": "scene", "count": 3, "tools": ["scene_manage", "scene_open", "scene_save"]}, + {"id": "script", "label": "script", "count": 4, "tools": ["script_attach", "script_create", "script_manage", "script_patch"]}, + {"id": "signal", "label": "signal", "count": 1, "tools": ["signal_manage"]}, + {"id": "testing", "label": "testing", "count": 2, "tools": ["test_manage", "test_run"]}, + {"id": "theme", "label": "theme", "count": 1, "tools": ["theme_manage"]}, + {"id": "tilemap", "label": "tilemap", "count": 1, "tools": ["tilemap_manage"]}, + {"id": "tileset", "label": "tileset", "count": 1, "tools": ["tileset_manage"]}, + {"id": "gridmap", "label": "gridmap", "count": 1, "tools": ["gridmap_manage"]}, + {"id": "csg", "label": "csg", "count": 1, "tools": ["csg_manage"]}, + {"id": "ui", "label": "ui", "count": 1, "tools": ["ui_manage"]}, +] + + +## Whether `id` is a real, excludable domain in this plugin version. Used to +## drop stale names (e.g. a domain removed since the setting was written) so +## they never reach the server's `--exclude-domains`, whose `parse_exclude_list` +## hard-fails on unknown names. +static func is_excludable_domain(id: String) -> bool: + for d in DOMAINS: + if d["id"] == id: + return true + return false + + +## Total tool count when no domains are excluded. Used for the "Enabled: N / M" +## readout in the Tools tab without looping the catalog on every repaint. +static func total_tool_count() -> int: + var n := CORE_TOOLS.size() + ALWAYS_ON_TOOLS.size() + for d in DOMAINS: + n += int(d["count"]) + return n + + +## Tool count remaining after excluding the given set of domain ids. +static func enabled_tool_count(excluded: PackedStringArray) -> int: + var n := CORE_TOOLS.size() + ALWAYS_ON_TOOLS.size() + for d in DOMAINS: + if excluded.find(d["id"]) == -1: + n += int(d["count"]) + return n + + +## Canonical comma-separated string for a set of domain ids — sorted and +## deduplicated so two equivalent settings (entered in different orders) +## hash to the same EditorSetting value. Matches `excluded_domains()` in +## client_configurator.gd. +static func canonical(excluded: PackedStringArray) -> String: + var seen := PackedStringArray() + for e in excluded: + var t := e.strip_edges() + if not t.is_empty() and seen.find(t) == -1: + seen.append(t) + seen.sort() + return ",".join(seen) diff --git a/addons/godot_ai/tool_catalog.gd.uid b/addons/godot_ai/tool_catalog.gd.uid new file mode 100644 index 0000000..c0c179b --- /dev/null +++ b/addons/godot_ai/tool_catalog.gd.uid @@ -0,0 +1 @@ +uid://d1vqyt4uyo378 diff --git a/addons/godot_ai/update_reload_runner.gd b/addons/godot_ai/update_reload_runner.gd new file mode 100644 index 0000000..7761e40 --- /dev/null +++ b/addons/godot_ai/update_reload_runner.gd @@ -0,0 +1,590 @@ +@tool +extends Node + +## EditorSetting key used to defer a self_update telemetry event across the +## disable -> enable boundary. The runner runs while the plugin is disabled, +## so it can't send WebSocket events directly; it writes the outcome here +## and the re-enabled plugin's `_enter_tree` flushes it. See +## `plugin.gd::_flush_pending_self_update_telemetry`. +const PENDING_SELF_UPDATE_TELEMETRY_KEY := "godot_ai/pending_self_update_event" + +## Self-update runner. Owns the install-and-reload sequence from +## `start(zip_path, temp_dir, detached_dock)` onward: extract files into +## `addons/godot_ai/` with rollback bookkeeping, scan the filesystem, +## re-enable the plugin, and clean up the detached dock. +## +## Single-phase install: writes the full `_new_file_paths + +## _existing_file_paths` set before issuing exactly one +## `EditorFileSystem.scan()`. Godot's scan-time reparse pass then sees one +## consistent v(N+1) snapshot, so new files and existing files can resolve +## each other's same-release API changes regardless of parse order. +## +## Not owned here: HTTP download (in `utils/update_manager.gd`), banner UI +## (in `mcp_dock.gd`), or server stop prep (called by +## `plugin.gd::install_downloaded_update` before this runner starts via +## `_lifecycle.prepare_for_update_reload()`). +## +## This node is deliberately tiny and not parented under the EditorPlugin: +## it survives `set_plugin_enabled(false)`, extracts the downloaded release, +## waits for Godot's filesystem scan, then enables the plugin again. The old +## dock is detached before this runner starts, kept alive while deferred +## Callables drain, and freed only after the new plugin instance is loaded. + +const PLUGIN_CFG_PATH := "res://addons/godot_ai/plugin.cfg" +const PRE_DISABLE_DRAIN_FRAMES := 8 +const POST_DISABLE_DRAIN_FRAMES := 2 +const POST_ENABLE_FREE_FRAMES := 8 +const INSTALL_BASE_PATH := "res://" +const ZIP_ADDON_PREFIX := "addons/godot_ai/" +const TEMP_FILE_SUFFIX := ".godot_ai_update_tmp" +const INSTALL_BACKUP_SUFFIX := ".update_backup" + +## Outcome of `_install_zip_paths`. `OK` means all listed files were replaced. +## `FAILED_CLEAN` means a write/rename failed mid-batch but every previously +## written file was rolled back to its vN content (or removed, if the file +## was new in vN+1). `FAILED_MIXED` means rollback itself failed: the addons +## tree contains a mix of vN and vN+1 files. The runner MUST NOT re-enable +## the plugin in the MIXED case — see issue #297 finding #9 for the data-loss +## scenario this guards against. +enum InstallStatus { OK, FAILED_CLEAN, FAILED_MIXED } + +var _zip_path := "" +## Old plugin.cfg version, read from disk in `start()` before the extract +## replaces it. Rides the pending self-update marker. +var _from_version := "" +var _temp_dir := "" +var _detached_dock = null +var _started := false +var _next_step := "" +var _frames_remaining := 0 +var _waiting_for_scan := false +var _scan_next_step := "" +## Watchdog for `_start_filesystem_scan`: if Godot's `filesystem_changed` +## signal never fires (slow disk, NFS, AV holding the just-extracted addon +## files open), the runner used to hang in `_waiting_for_scan = true` +## forever and the dock stayed disabled. After this timeout we disconnect +## the signal and proceed anyway — worst case the new files aren't visible +## on the first frame, but they get picked up on the next scan. See +## audit-v2 finding #9 (issue #353). Untyped to match the codebase's +## defensive pattern for state that survives `fs.scan()` during update. +const SCAN_WATCHDOG_SECS := 30.0 +var _scan_watchdog_timer = null +## Sticky flag set by `_on_scan_watchdog_timeout`. Subsequent +## `_start_filesystem_scan` calls in the same update bypass connect+scan +## so a delayed `filesystem_changed` emission from the timed-out scan +## can't fire on a freshly-armed listener for the next scan and falsely +## settle it before that scan actually completed. See PR #381 review for +## the cross-scan race this guards against. +var _scan_timed_out := false +## Keep Array fields untyped: this runner survives fs.scan() during update, +## and typed Variant storage is part of the hot-reload crash class. +var _new_file_paths = [] +var _existing_file_paths = [] +## Per-file install records accumulated during install so a later failure +## can roll back files already replaced earlier in the same update. +## Each entry is an untyped Dictionary with target_path / backup_path / +## had_original keys. Cleared by `_finalize_install_success` on full success +## and by `_rollback_paths_written` on failure. +var _paths_written = [] +## Set true if `_install_zip_file`'s inner restore-from-backup couldn't +## complete (backup gone, copy failed). The failed file is NOT recorded in +## `_paths_written` because the function bails at that point — without this +## flag, `_rollback_paths_written` would walk only the prior records, all +## restore cleanly, and report FAILED_CLEAN even though the current target +## is missing or stale on disk. Surfaces FAILED_MIXED so the runner refuses +## to re-enable the plugin against a half-installed tree. +var _restore_failed := false +## Test-only opt-out for the scan-watchdog `push_warning` lines. The +## watchdog unit tests in `test_update_reload_runner.gd` invoke +## `_on_scan_watchdog_timeout()` and the post-timeout +## `_start_filesystem_scan` bypass branch directly to pin their behavior +## — but those code paths' `push_warning` calls then appear as yellow +## console noise in every `test_run`, training reviewers to ignore the +## runner's real production warnings. Tests set this true; production +## leaves it false so genuine scan timeouts during a real self-update +## still surface loudly. See issue #413. +var _suppress_scan_warnings := false + + +func start(zip_path: String, temp_dir: String, detached_dock) -> void: + if _started: + return + _started = true + _zip_path = zip_path + _temp_dir = temp_dir + _detached_dock = detached_dock + ## Captured before the extract replaces plugin.cfg: the success marker + ## carries from/to versions so the re-enabled plugin can (a) report a + ## complete self_update telemetry event and (b) scope the post-update + ## client repin to entries whose ONLY drift is the old version pin. Pure + ## file IO on purpose — the runner must not call into plugin scripts + ## during the disable window. + _from_version = _read_plugin_cfg_version() + _wait_frames(PRE_DISABLE_DRAIN_FRAMES, "_disable_old_plugin") + + +## Read `version="X.Y.Z"` from the on-disk plugin.cfg. Returns "" when the +## file or key is missing — consumers treat an empty version as "unknown" +## and degrade (telemetry sends empty fields, the repin gate skips). +static func _read_plugin_cfg_version() -> String: + var file := FileAccess.open(PLUGIN_CFG_PATH, FileAccess.READ) + if file == null: + return "" + var text := file.get_as_text() + file.close() + var re := RegEx.new() + if re.compile("(?m)^version=\"([^\"]+)\"") != OK: + return "" + var found := re.search(text) + return found.get_string(1) if found != null else "" + + +func _process(_delta: float) -> void: + if _frames_remaining <= 0: + set_process(false) + return + + _frames_remaining -= 1 + if _frames_remaining <= 0: + var step := _next_step + _next_step = "" + set_process(false) + call(step) + + +func _wait_frames(frame_count: int, next_step: String) -> void: + _next_step = next_step + _frames_remaining = max(1, frame_count) + set_process(true) + + +func _disable_old_plugin() -> void: + ## Disable before writing or scanning new scripts. This avoids both the + ## Dict/Array field-storage hot-reload crash (#245) and cached handler + ## constructor shape mismatches (#247) for plugin-owned instances. + print("MCP | update runner disabling old plugin") + EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, false) + _wait_frames(POST_DISABLE_DRAIN_FRAMES, "_extract_and_scan") + + +func _extract_and_scan() -> void: + if not _read_update_manifest(): + EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true) + _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") + return + + var install_paths := [] + install_paths.append_array(_new_file_paths) + install_paths.append_array(_existing_file_paths) + + var status := _install_zip_paths(install_paths) + if status != InstallStatus.OK: + _handle_install_failure(status) + return + + _finalize_install_success() + _cleanup_update_temp() + ## One scan covers both dependency directions: plugin.gd's preloads of + ## new files resolve because those files are already present, and new + ## files' references to new members or static-ness changes on existing + ## load-surface scripts resolve because those existing files are also + ## already at v(N+1). The goal is a consistent snapshot before scan, not + ## a tree-atomic install; per-file writes still use `.tmp` + rename and + ## rollback on failure. + _start_filesystem_scan("_enable_new_plugin") + + +func _start_filesystem_scan(next_step: String = "_enable_new_plugin") -> void: + var fs := EditorInterface.get_resource_filesystem() + var deferred_step := next_step if not next_step.is_empty() else "_enable_new_plugin" + if fs == null: + call_deferred(deferred_step) + return + + ## Bypass: a previous scan in this update already watchdog'd, so the + ## editor's filesystem is unresponsive. Re-arming a `filesystem_changed` + ## listener now would race with a delayed emission from the timed-out + ## scan: that single emission would fire whichever listener is currently + ## connected to the shared signal, falsely settling this scan before it + ## actually completed. Skip the wait; Godot's normal background scan + ## catches up after the plugin re-enables. See PR #381 review. + if _scan_timed_out: + if not _suppress_scan_warnings: + push_warning( + "MCP | skipping filesystem_changed wait after previous timeout (next_step=%s)" + % deferred_step + ) + call_deferred(deferred_step) + return + + _waiting_for_scan = true + _scan_next_step = deferred_step + if not fs.filesystem_changed.is_connected(_on_filesystem_changed): + fs.filesystem_changed.connect(_on_filesystem_changed, CONNECT_ONE_SHOT) + _arm_scan_watchdog() + fs.scan() + + +func _arm_scan_watchdog() -> void: + if _scan_watchdog_timer == null: + _scan_watchdog_timer = Timer.new() + _scan_watchdog_timer.one_shot = true + _scan_watchdog_timer.timeout.connect(_on_scan_watchdog_timeout) + add_child(_scan_watchdog_timer) + _scan_watchdog_timer.start(SCAN_WATCHDOG_SECS) + + +func _stop_scan_watchdog() -> void: + if _scan_watchdog_timer != null: + _scan_watchdog_timer.stop() + + +func _on_scan_watchdog_timeout() -> void: + ## Signal didn't fire within SCAN_WATCHDOG_SECS — most likely the + ## filesystem scan is blocked behind a slow disk / NFS / AV scanner + ## still reading the just-extracted addon files. + ## Set the sticky `_scan_timed_out` flag so any subsequent + ## `_start_filesystem_scan` in this update skips its connect+scan + ## (otherwise a delayed emission from this scan would falsely settle + ## the next scan's listener — see PR #381 review). + ## Disconnect the current listener too, so this scan's listener can't + ## double-call `_finish_scan_wait` if the signal arrives quickly after + ## the timeout fires. `_finish_scan_wait` is idempotent on + ## `_waiting_for_scan == false`. + if not _waiting_for_scan: + return + if not _suppress_scan_warnings: + push_warning( + "MCP | filesystem_changed didn't fire within %ds; proceeding without scan confirmation" + % int(SCAN_WATCHDOG_SECS) + ) + _scan_timed_out = true + var fs := EditorInterface.get_resource_filesystem() + if fs != null and fs.filesystem_changed.is_connected(_on_filesystem_changed): + fs.filesystem_changed.disconnect(_on_filesystem_changed) + _finish_scan_wait() + + +func _read_update_manifest() -> bool: + var zip_path := ProjectSettings.globalize_path(_zip_path) + var install_base := ProjectSettings.globalize_path(INSTALL_BASE_PATH) + + var reader := ZIPReader.new() + if reader.open(zip_path) != OK: + print("MCP | update extract failed: could not open %s" % zip_path) + return false + + _new_file_paths.clear() + _existing_file_paths.clear() + var has_plugin_cfg := false + var has_plugin_script := false + var files := reader.get_files() + for file_path in files: + if not file_path.begins_with(ZIP_ADDON_PREFIX): + continue + var rel_path := file_path.trim_prefix(ZIP_ADDON_PREFIX) + ## Many zip builders (`zip -r` without `-D`, AssetLib uploads, hand- + ## built archives) emit zero-byte directory entries like + ## `addons/godot_ai/`. Skip those before the safety check; the + ## empty-segment guard in `_is_safe_zip_addon_file` would otherwise + ## flag the bare prefix as unsafe and abort the extract. Current + ## release.yml passes `-D` to strip them, but installed runners must + ## still tolerate older or manually built zips. + if rel_path.is_empty() or file_path.ends_with("/"): + continue + if not _is_safe_zip_addon_file(file_path): + print("MCP | update extract failed: unsafe zip path %s" % file_path) + reader.close() + return false + if rel_path == "plugin.cfg": + has_plugin_cfg = true + elif rel_path == "plugin.gd": + has_plugin_script = true + var target_path := install_base.path_join(file_path) + if FileAccess.file_exists(target_path): + _existing_file_paths.append(file_path) + else: + _new_file_paths.append(file_path) + reader.close() + if not has_plugin_cfg: + print("MCP | update extract failed: zip is missing plugin.cfg") + return false + if not has_plugin_script: + print("MCP | update extract failed: zip is missing plugin.gd") + return false + return true + + +func _handle_install_failure(status: int) -> void: + _record_pending_self_update({ + "status": "failed_mixed" if status == InstallStatus.FAILED_MIXED else "failed_clean", + }) + if status == InstallStatus.FAILED_MIXED: + ## Half-installed addon tree on disk: re-enabling the plugin would + ## load a mix of vN and vN+1 files. Print a load-bearing diagnostic + ## and bail without re-enabling — user must restore manually. See + ## issue #297 finding #9 for the data-loss scenario. + push_error( + "MCP | self-update failed mid-install AND rollback could not" + + " restore the previous addons/godot_ai/ contents. The plugin" + + " is left disabled. Inspect addons/godot_ai/ for" + + " *.update_backup / *.godot_ai_update_tmp files and restore" + + " manually before re-enabling the plugin." + ) + print( + "MCP | self-update aborted: addons/godot_ai/ is in a mixed state;" + + " plugin left disabled (manual intervention required)." + ) + _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") + return + ## FAILED_CLEAN: rollback restored every previously-written file. Safe + ## to re-enable the previous plugin version. + print("MCP | self-update rolled back; re-enabling previous plugin version") + EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true) + _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") + + +func _is_safe_zip_addon_file(file_path: String) -> bool: + if file_path.is_absolute_path() or file_path.contains("\\"): + return false + if not file_path.begins_with(ZIP_ADDON_PREFIX): + return false + var rel_path := file_path.trim_prefix(ZIP_ADDON_PREFIX) + if rel_path.is_empty() or rel_path.ends_with("/"): + return false + ## Reserved install-machinery suffixes (#713): an entry named like the + ## runner's own staging/backup files would collide with the temp file + ## `_install_zip_file` writes, or overwrite / later be deleted with the + ## rollback snapshots `_finalize_install_success` cleans up — corrupting + ## the very rollback set protecting this install. + if rel_path.ends_with(TEMP_FILE_SUFFIX) or rel_path.ends_with(INSTALL_BACKUP_SUFFIX): + return false + for segment in rel_path.split("/", true): + if segment.is_empty() or segment == "." or segment == "..": + return false + return true + + +func _install_zip_paths(paths: Array) -> int: + if paths.is_empty(): + return InstallStatus.OK + + var zip_path := ProjectSettings.globalize_path(_zip_path) + var reader := ZIPReader.new() + if reader.open(zip_path) != OK: + print("MCP | update extract failed: could not reopen %s" % zip_path) + ## Nothing else can be written, but earlier files from this update + ## may have landed on disk; roll those back too. + return _rollback_paths_written() + + var install_base := ProjectSettings.globalize_path(INSTALL_BASE_PATH) + for file_path in paths: + var record := _install_zip_file(reader, String(file_path), install_base) + if record.is_empty(): + reader.close() + return _rollback_paths_written() + _paths_written.append(record) + reader.close() + return InstallStatus.OK + + +func _install_zip_file( + reader: ZIPReader, file_path: String, install_base: String +) -> Dictionary: + var target_path := install_base.path_join(file_path) + var dir := target_path.get_base_dir() + if DirAccess.make_dir_recursive_absolute(dir) != OK: + print("MCP | update extract failed: could not create %s" % dir) + return {} + + var temp_path := target_path + TEMP_FILE_SUFFIX + DirAccess.remove_absolute(temp_path) + var content := reader.read_file(file_path) + var f := FileAccess.open(temp_path, FileAccess.WRITE) + if f == null: + print("MCP | update extract failed: could not write %s (error %d)" % [ + temp_path, + FileAccess.get_open_error(), + ]) + return {} + ## `store_buffer` reports a short write only via its return value — it + ## does NOT set the last-error state `get_error()` reads — and the write + ## is stdio-buffered, so disk-full surfaces at flush/close, not here. + ## Capture the return and re-verify the on-disk size after close() so a + ## disk-full "succeeds" write can't rename a truncated file over the + ## live target (#687). + var stored := f.store_buffer(content) + f.flush() + var write_error := f.get_error() + f.close() + ## get_length() on a read handle, not get_file_as_bytes().size() — the + ## latter re-reads the whole file into memory per extracted entry just + ## to learn its length. + var written_size := -1 + var verify := FileAccess.open(temp_path, FileAccess.READ) + if verify != null: + written_size = verify.get_length() + verify.close() + if not stored or write_error != OK or written_size != content.size(): + print("MCP | update extract failed: write validation failed (error %d) for %s (stored=%s size=%d expected=%d)" % [ + write_error, + temp_path, + stored, + written_size, + content.size(), + ]) + DirAccess.remove_absolute(temp_path) + return {} + + ## Back up the original via COPY (not rename) so the source of truth + ## stays in place if a later step fails. Rolled back via + ## `_rollback_paths_written` if a subsequent file in this batch — or a + ## later batch — can't be installed. + var had_original := FileAccess.file_exists(target_path) + var backup_path := target_path + INSTALL_BACKUP_SUFFIX + if had_original: + DirAccess.remove_absolute(backup_path) + if DirAccess.copy_absolute(target_path, backup_path) != OK: + DirAccess.remove_absolute(temp_path) + print("MCP | update extract failed: could not back up %s" % target_path) + return {} + + if DirAccess.rename_absolute(temp_path, target_path) != OK: + ## POSIX and APFS replace atomically. Some filesystems reject + ## rename-over-existing; keep a fallback so the update can still + ## proceed, but the common path never exposes a truncated target. + DirAccess.remove_absolute(target_path) + if DirAccess.rename_absolute(temp_path, target_path) != OK: + DirAccess.remove_absolute(temp_path) + ## Target was removed above; restore from the COPY backup so the + ## addons dir is left in its vN state before we surface failure. + ## Only delete the backup if the restore copy actually succeeded + ## — if it didn't, target_path is missing, and `_restore_failed` + ## tells `_rollback_paths_written` to surface FAILED_MIXED so the + ## runner refuses to re-enable the plugin. Leaving the backup on + ## disk also gives the user a manual recovery path. Without this + ## guard the failed file isn't tracked anywhere (we return `{}`, + ## not appended to `_paths_written`) and the caller would + ## erroneously see FAILED_CLEAN. + if had_original: + if ( + FileAccess.file_exists(backup_path) + and DirAccess.copy_absolute(backup_path, target_path) == OK + ): + DirAccess.remove_absolute(backup_path) + else: + _restore_failed = true + print("MCP | update extract failed: could not replace %s" % target_path) + return {} + return { + "target_path": target_path, + "backup_path": backup_path, + "had_original": had_original, + } + + +## Restore (or remove) every file already touched in this update. Safe to +## call after a partial install — entries are processed in reverse so a +## given target is restored before the next earlier write of the same path +## could resurrect a stale value. Returns FAILED_CLEAN if every entry was +## restored AND no in-flight `_install_zip_file` left a target stranded +## (`_restore_failed`); FAILED_MIXED otherwise. The caller MUST NOT +## re-enable the plugin in the MIXED case. +func _rollback_paths_written() -> int: + var any_failed := false + var i := _paths_written.size() - 1 + while i >= 0: + var record = _paths_written[i] + var target := String(record.get("target_path", "")) + var backup := String(record.get("backup_path", "")) + var had_original := bool(record.get("had_original", false)) + if had_original: + if not FileAccess.file_exists(backup): + print("MCP | update rollback failed: backup missing for %s" % target) + any_failed = true + else: + DirAccess.remove_absolute(target) + if DirAccess.copy_absolute(backup, target) != OK: + print("MCP | update rollback failed: could not restore %s" % target) + any_failed = true + else: + DirAccess.remove_absolute(backup) + else: + if FileAccess.file_exists(target): + if DirAccess.remove_absolute(target) != OK: + print( + "MCP | update rollback failed: could not delete %s" % target + ) + any_failed = true + i -= 1 + _paths_written.clear() + if any_failed or _restore_failed: + return InstallStatus.FAILED_MIXED + return InstallStatus.FAILED_CLEAN + + +## Discard accumulated backups after the combined install succeeds. Backups +## are best-effort: a failure here doesn't compromise the new install, just +## leaves stray *.update_backup files for the user to clean up. +func _finalize_install_success() -> void: + for record in _paths_written: + if record.get("had_original", false): + DirAccess.remove_absolute(String(record.get("backup_path", ""))) + _paths_written.clear() + ## plugin.cfg on disk is the NEW version by now — read it back rather + ## than trusting any in-memory value, so the marker describes what was + ## actually installed. + _record_pending_self_update({ + "status": "success", + "from_version": _from_version, + "to_version": _read_plugin_cfg_version(), + }) + + +## Persist a self_update event description so the re-enabled plugin can +## emit it once its WebSocket is connected. Survives the disable -> enable +## window where the runner cannot send anything itself. +func _record_pending_self_update(data: Dictionary) -> void: + var settings := EditorInterface.get_editor_settings() + if settings == null: + return + settings.set_setting(PENDING_SELF_UPDATE_TELEMETRY_KEY, JSON.stringify(data)) + + +func _cleanup_update_temp() -> void: + DirAccess.remove_absolute(ProjectSettings.globalize_path(_zip_path)) + DirAccess.remove_absolute(ProjectSettings.globalize_path(_temp_dir)) + + +func _on_filesystem_changed() -> void: + _finish_scan_wait() + + +func _finish_scan_wait() -> void: + if not _waiting_for_scan: + return + _waiting_for_scan = false + _stop_scan_watchdog() + var next_step := _scan_next_step + _scan_next_step = "" + set_process(false) + if next_step.is_empty(): + next_step = "_enable_new_plugin" + call_deferred(next_step) + + +func _enable_new_plugin() -> void: + print("MCP | update runner enabling new plugin") + EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true) + _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") + + +func _cleanup_and_finish() -> void: + _cleanup_detached_dock() + queue_free() + + +func _cleanup_detached_dock() -> void: + if _detached_dock != null and is_instance_valid(_detached_dock): + _detached_dock.queue_free() + _detached_dock = null diff --git a/addons/godot_ai/update_reload_runner.gd.uid b/addons/godot_ai/update_reload_runner.gd.uid new file mode 100644 index 0000000..9cc6615 --- /dev/null +++ b/addons/godot_ai/update_reload_runner.gd.uid @@ -0,0 +1 @@ +uid://cu6c75n3x2pik diff --git a/addons/godot_ai/utils/allow_hosts.gd b/addons/godot_ai/utils/allow_hosts.gd new file mode 100644 index 0000000..a34c964 --- /dev/null +++ b/addons/godot_ai/utils/allow_hosts.gd @@ -0,0 +1,170 @@ +@tool +class_name McpAllowHosts +extends RefCounted + +## Client-side helpers for the `--allow-host` LAN opt-in (#507, server core +## in #421). Pure static functions only — no EditorSettings, no sockets — +## so the settings-value → launch-args plumbing and the manual-command LAN +## URL builder are deterministically testable without a live editor. +## +## Accepted syntax mirrors the server's `parse_allow_hosts` +## (src/godot_ai/transport/origin_guard.py): each token is a bare IP +## (IPv4 or IPv6) or a CIDR, comma-separated. Host bits set on a CIDR are +## tolerated server-side (`strict=False`), so we only validate the IP part +## and the prefix length here — anything else fails loudly at server +## startup, which the dock-side validation exists to pre-empt. + + +## Canonicalize a comma-separated allow-host value: whitespace-stripped, +## deduplicated, sorted. Returns "" for a value with no usable tokens so +## callers can skip appending `--allow-host` entirely (keeps spawns +## compatible with pre-#421 servers — same contract as +## `ClientConfigurator.excluded_domains()`). +static func normalize(raw: String) -> String: + var parts := PackedStringArray() + for p in raw.split(","): + var t := p.strip_edges() + if not t.is_empty() and parts.find(t) == -1: + parts.append(t) + parts.sort() + return ",".join(parts) + + +## Whether a single token is a bare IP or a CIDR the server will accept. +static func token_is_valid(token: String) -> bool: + var t := token.strip_edges() + if t.is_empty(): + return false + var ip := t + var prefix := 0 + ## Track slash presence separately from the prefix value: reusing -1 as + ## the "no slash" sentinel let an explicit negative prefix like + ## "10.0.0.0/-1" validate (is_valid_int accepts "-1"). CodeRabbit review. + var has_prefix := false + var slash := t.find("/") + if slash != -1: + ip = t.substr(0, slash) + var prefix_text := t.substr(slash + 1) + ## Explicit signs are rejected by the server's parse_allow_hosts + ## (ipaddress refuses "10.0.0.0/+8"), but is_valid_int accepts + ## them — keep the mirror honest. + if prefix_text.is_empty() or prefix_text.begins_with("+") or prefix_text.begins_with("-"): + return false + if not prefix_text.is_valid_int(): + return false + prefix = int(prefix_text) + has_prefix = true + if not ip.is_valid_ip_address(): + return false + var max_prefix := 128 if ip.contains(":") else 32 + return not has_prefix or (prefix >= 0 and prefix <= max_prefix) + + +## Every token in `raw` that fails `token_is_valid` — the dock surfaces +## these inline so a typo is caught before it aborts the server spawn. +static func invalid_tokens(raw: String) -> PackedStringArray: + var bad := PackedStringArray() + for p in raw.split(","): + var t := p.strip_edges() + if t.is_empty(): + continue + if not token_is_valid(t) and bad.find(t) == -1: + bad.append(t) + return bad + + +## True when the allowlist names at least one non-loopback range — i.e. +## the server is actually reachable off this machine and the manual +## command should surface a LAN URL. +static func is_lan_allowlist_active(value: String) -> bool: + for p in value.split(","): + var t := p.strip_edges() + if t.is_empty(): + continue + var ip := t.substr(0, t.find("/")) if t.contains("/") else t + if not _is_loopback(ip): + return true + return false + + +## Choose the LAN address to show in the manual command from the +## machine's local addresses (caller passes `IP.get_local_addresses()` +## so this stays pure). Loopback and link-local addresses are dropped; +## the first private-range IPv4 wins, then any remaining IPv4, then +## anything left (IPv6). Returns `{"address": String, "ambiguous": bool}` +## — `ambiguous` flags multiple viable candidates so the note can tell +## the user to pick the interface on their trusted network. +static func pick_lan_address(addresses: PackedStringArray) -> Dictionary: + var candidates := PackedStringArray() + for a in addresses: + var addr := String(a).strip_edges() + if addr.is_empty() or _is_loopback(addr) or _is_link_local(addr): + continue + candidates.append(addr) + if candidates.is_empty(): + return {"address": "", "ambiguous": false} + var chosen := "" + for addr in candidates: + if _is_private_ipv4(addr): + chosen = addr + break + if chosen.is_empty(): + for addr in candidates: + if addr.contains("."): + chosen = addr + break + if chosen.is_empty(): + chosen = candidates[0] + return {"address": chosen, "ambiguous": candidates.size() > 1} + + +## Informational LAN-URL note appended to the manual command when the +## allowlist is active (#507). Never changes what gets WRITTEN to client +## configs — loopback stays the write target; this is copy-paste help for +## pointing a remote agent at the right address. +static func lan_url_note(allow_hosts_value: String, addresses: PackedStringArray, http_port: int) -> String: + if not is_lan_allowlist_active(allow_hosts_value): + return "" + var pick := pick_lan_address(addresses) + var addr := String(pick.get("address", "")) + if addr.is_empty(): + return ( + "LAN access is enabled (--allow-host %s), but no LAN address was detected on this machine." + % allow_hosts_value + ) + var host := "[%s]" % addr if addr.contains(":") else addr + var note := ( + "LAN access is enabled (--allow-host %s). Remote agents on the allowed network can use: http://%s:%d/mcp" + % [allow_hosts_value, host, http_port] + ) + if bool(pick.get("ambiguous", false)): + note += "\n(multiple network interfaces detected — pick the address on the network you allowed)" + return note + + +static func _is_loopback(addr: String) -> bool: + var a := addr.to_lower() + return a.begins_with("127.") or a == "::1" or a == "localhost" + + +static func _is_link_local(addr: String) -> bool: + var a := addr.to_lower() + if a.begins_with("169.254."): + return true + ## IPv6 link-local is fe80::/10 — the whole fe80-febf first hextet, not + ## just literal "fe80" (Copilot review on #507's PR: fea0::... etc. must + ## also be excluded from LAN-URL candidates). + if a.length() >= 4 and a.begins_with("fe") and a[2] in "89ab": + return true + return false + + +static func _is_private_ipv4(addr: String) -> bool: + if not addr.contains("."): + return false + if addr.begins_with("10.") or addr.begins_with("192.168."): + return true + if addr.begins_with("172."): + var second := int(addr.get_slice(".", 1)) + return second >= 16 and second <= 31 + return false diff --git a/addons/godot_ai/utils/allow_hosts.gd.uid b/addons/godot_ai/utils/allow_hosts.gd.uid new file mode 100644 index 0000000..02fecd1 --- /dev/null +++ b/addons/godot_ai/utils/allow_hosts.gd.uid @@ -0,0 +1 @@ +uid://b7qk3vw2nxr4d diff --git a/addons/godot_ai/utils/class_introspection.gd b/addons/godot_ai/utils/class_introspection.gd new file mode 100644 index 0000000..3076740 --- /dev/null +++ b/addons/godot_ai/utils/class_introspection.gd @@ -0,0 +1,259 @@ +@tool +extends RefCounted + +## Builds stable, JSON-safe metadata for any class registered in ClassDB. + +const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd") + +## Sections returned when the caller does not name any. Deliberately narrow: +## a bare `get_class` is almost always "what properties does X have", and the +## full five-section dump for a large class (e.g. Node, Control) costs an agent +## thousands of tokens it rarely wanted. Callers opt into the rest by name, or +## request the lot with the "all" keyword (see `_sections`). +const DEFAULT_SECTIONS: Array[String] = ["properties"] +## The full documentation-shaped section set (excludes the heavier, separately +## gated "inheritors"). Expanded from the "all" keyword. +const ALL_SECTIONS: Array[String] = ["properties", "methods", "signals", "enums", "constants"] +const KNOWN_SECTIONS: Array[String] = ["properties", "methods", "signals", "enums", "constants", "inheritors"] +## Tokens a caller may legitimately pass in `sections` — the known sections plus +## the "all" meta-keyword. Used for error suggestions so a typo like "al" can +## resolve to "all"; "all" is NOT a section (it expands in `_sections`), so it +## stays out of KNOWN_SECTIONS which gates validity. +const SUGGESTABLE_SECTION_TOKENS: Array[String] = [ + "properties", "methods", "signals", "enums", "constants", "inheritors", "all" +] +const MAX_DEFAULT_ITEMS := 100 + + +static func build(type_name: String, options: Dictionary = {}) -> Dictionary: + var sections := _sections(options.get("sections", DEFAULT_SECTIONS)) + var include_inherited := bool(options.get("include_inherited", false)) + var include_inheritors := bool(options.get("include_inheritors", false)) + var offset := max(0, int(options.get("offset", 0))) + var limit := int(options.get("limit", MAX_DEFAULT_ITEMS)) + if limit < 0: + limit = MAX_DEFAULT_ITEMS + var can_instantiate := ClassDB.can_instantiate(type_name) + + var data := { + "class_name": type_name, + "engine_version": Engine.get_version_info().get("string", ""), + "parent_class": str(ClassDB.get_parent_class(type_name)), + "inheritance_chain": _inheritance_chain(type_name), + "can_instantiate": can_instantiate, + "is_singleton": Engine.has_singleton(type_name), + "include_inherited": include_inherited, + "offset": offset, + "limit": limit, + } + if include_inheritors or sections.has("inheritors"): + _add_paged(data, "inheritor", "inheritors", _inheritors(type_name, false), offset, limit) + _add_paged( + data, + "concrete_inheritor", + "concrete_inheritors", + _inheritors(type_name, true), + offset, + limit + ) + if sections.has("properties"): + _add_paged(data, "property", "properties", _properties(type_name, include_inherited), offset, limit) + if sections.has("methods"): + _add_paged(data, "method", "methods", _methods(type_name, include_inherited), offset, limit) + if sections.has("signals"): + _add_paged(data, "signal", "signals", _signals(type_name, include_inherited), offset, limit) + if sections.has("enums"): + _add_paged(data, "enum", "enums", _enums(type_name, include_inherited), offset, limit) + if sections.has("constants"): + _add_paged( + data, + "constant", + "constants", + _unscoped_constants(type_name, include_inherited), + offset, + limit + ) + return data + + +static func validate_sections(raw_sections: Variant) -> Dictionary: + var sections := _sections(raw_sections) + var invalid: Array[String] = [] + for section in sections: + if not KNOWN_SECTIONS.has(section): + invalid.append(section) + return {"sections": sections, "invalid": invalid} + + +static func _inheritance_chain(type_name: String) -> Array[String]: + var chain: Array[String] = [] + var current := type_name + while not current.is_empty(): + chain.append(current) + current = str(ClassDB.get_parent_class(current)) + return chain + + +static func _sections(raw_sections: Variant) -> Array[String]: + var result: Array[String] = [] + var values: Array = [] + if raw_sections is String: + values = raw_sections.split(",", false) + elif raw_sections is Array: + values = raw_sections + else: + values = DEFAULT_SECTIONS + for raw_section in values: + var section := str(raw_section).strip_edges().to_lower() + if section == "all": + for expanded in ALL_SECTIONS: + if not result.has(expanded): + result.append(expanded) + continue + if not section.is_empty() and not result.has(section): + result.append(section) + if result.is_empty(): + result.assign(DEFAULT_SECTIONS) + return result + + +static func _add_paged( + data: Dictionary, + singular: String, + key: String, + items: Array, + offset: int, + limit: int +) -> void: + var end := items.size() if limit == 0 else min(items.size(), offset + limit) + var page: Array = [] + if offset < items.size(): + page = items.slice(offset, end) + data[key] = page + data["%s_count" % singular] = items.size() + data["%s_returned_count" % singular] = page.size() + + +static func _inheritors(type_name: String, concrete_only: bool) -> Array[String]: + var result: Array[String] = [] + for inheritor in ClassDB.get_inheriters_from_class(type_name): + var inheritor_name := str(inheritor) + if concrete_only and not ClassDB.can_instantiate(inheritor_name): + continue + result.append(inheritor_name) + result.sort() + return result + + +static func _properties(type_name: String, include_inherited: bool) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for raw_prop in ClassDB.class_get_property_list(type_name, not include_inherited): + var prop: Dictionary = raw_prop + var usage := int(prop.get("usage", 0)) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + var prop_name := str(prop.get("name", "")) + result.append({ + "name": prop_name, + "type": type_string(int(prop.get("type", TYPE_NIL))), + "class_name": str(prop.get("class_name", "")), + "hint": int(prop.get("hint", PROPERTY_HINT_NONE)), + "hint_string": str(prop.get("hint_string", "")), + "usage": usage, + "default": VariantSerializer.serialize( + ClassDB.class_get_property_default_value(type_name, prop_name) + ), + }) + result.sort_custom(func(a, b): return a.name < b.name) + return result + + +static func _methods(type_name: String, include_inherited: bool) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for raw_method in ClassDB.class_get_method_list(type_name, not include_inherited): + var method: Dictionary = raw_method + var args: Array[Dictionary] = [] + for raw_arg in method.get("args", []): + args.append(_argument_info(raw_arg)) + var defaults: Array = [] + for value in method.get("default_args", []): + defaults.append(VariantSerializer.serialize(value)) + result.append({ + "name": str(method.get("name", "")), + "arguments": args, + "default_arguments": defaults, + "return": _argument_info(method.get("return", {})), + "flags": int(method.get("flags", 0)), + }) + result.sort_custom(func(a, b): return a.name < b.name) + return result + + +static func _signals(type_name: String, include_inherited: bool) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for raw_signal in ClassDB.class_get_signal_list(type_name, not include_inherited): + var signal_info: Dictionary = raw_signal + var args: Array[Dictionary] = [] + for raw_arg in signal_info.get("args", []): + args.append(_argument_info(raw_arg)) + var defaults: Array = [] + for value in signal_info.get("default_args", []): + defaults.append(VariantSerializer.serialize(value)) + result.append({ + "name": str(signal_info.get("name", "")), + "arguments": args, + "default_arguments": defaults, + "flags": int(signal_info.get("flags", 0)), + }) + result.sort_custom(func(a, b): return a.name < b.name) + return result + + +static func _argument_info(raw_info: Variant) -> Dictionary: + var info: Dictionary = raw_info if raw_info is Dictionary else {} + return { + "name": str(info.get("name", "")), + "type": type_string(int(info.get("type", TYPE_NIL))), + "class_name": str(info.get("class_name", "")), + "hint": int(info.get("hint", PROPERTY_HINT_NONE)), + "hint_string": str(info.get("hint_string", "")), + "usage": int(info.get("usage", 0)), + } + + +static func _enums(type_name: String, include_inherited: bool) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var enum_names: Array[String] = [] + for enum_name in ClassDB.class_get_enum_list(type_name, not include_inherited): + enum_names.append(str(enum_name)) + enum_names.sort() + for enum_name in enum_names: + var values: Array[Dictionary] = [] + for constant_name in ClassDB.class_get_enum_constants(type_name, enum_name, not include_inherited): + values.append({ + "name": str(constant_name), + "value": ClassDB.class_get_integer_constant(type_name, constant_name), + }) + values.sort_custom(func(a, b): return a.name < b.name) + result.append({ + "name": enum_name, + "is_bitfield": ClassDB.is_class_enum_bitfield(type_name, enum_name, not include_inherited), + "values": values, + }) + return result + + +static func _unscoped_constants(type_name: String, include_inherited: bool) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for constant_name in ClassDB.class_get_integer_constant_list(type_name, not include_inherited): + var enum_name := str( + ClassDB.class_get_integer_constant_enum(type_name, constant_name, not include_inherited) + ) + if not enum_name.is_empty(): + continue + result.append({ + "name": str(constant_name), + "value": ClassDB.class_get_integer_constant(type_name, constant_name), + }) + result.sort_custom(func(a, b): return a.name < b.name) + return result diff --git a/addons/godot_ai/utils/class_introspection.gd.uid b/addons/godot_ai/utils/class_introspection.gd.uid new file mode 100644 index 0000000..3eda5ca --- /dev/null +++ b/addons/godot_ai/utils/class_introspection.gd.uid @@ -0,0 +1 @@ +uid://caedbsmsl6fk4 diff --git a/addons/godot_ai/utils/diagnostics_capture.gd b/addons/godot_ai/utils/diagnostics_capture.gd new file mode 100644 index 0000000..22441ad --- /dev/null +++ b/addons/godot_ai/utils/diagnostics_capture.gd @@ -0,0 +1,66 @@ +@tool +class_name McpDiagnosticsCapture +extends RefCounted + +## Small helper for scoped validation-log capture windows. Callers snapshot a +## private log cursor, perform a deliberate validation action, then only report +## new diagnostics whose original source location is the target file. + + +static func capture_this_file(log_buffer: McpEditorLogBuffer, target_path: String, action: Callable) -> Dictionary: + var cursor := 0 + if log_buffer != null: + cursor = log_buffer.appended_total() + + var action_result = action.call() + var diagnostics: Array[Dictionary] = [] + var truncated := false + + if log_buffer != null: + var captured: Dictionary = log_buffer.get_since(cursor) + truncated = captured.get("truncated", false) + diagnostics = _diagnostics_for_target(captured.get("entries", []), target_path) + + return { + "action": action_result if action_result is Dictionary else {}, + "diagnostics": diagnostics, + "diagnostics_detail": "log_capture" if not diagnostics.is_empty() else "none", + "diagnostics_scope": "this_file", + "diagnostics_status": "partial" if truncated else "checked", + } + + +static func _diagnostics_for_target(entries: Array, target_path: String) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + for raw_entry in entries: + if not raw_entry is Dictionary: + continue + var entry: Dictionary = raw_entry + if not _entry_matches_target(entry, target_path): + continue + out.append(_normalize_entry(entry, target_path)) + return out + + +static func _entry_matches_target(entry: Dictionary, target_path: String) -> bool: + var source := _source_location(entry) + return str(source.get("path", "")) == target_path + + +static func _normalize_entry(entry: Dictionary, target_path: String) -> Dictionary: + var normalized := entry.duplicate(true) + var source := _source_location(entry) + normalized["path"] = str(source.get("path", target_path)) + normalized["line"] = int(source.get("line", normalized.get("line", 0))) + normalized["function"] = str(source.get("function", normalized.get("function", ""))) + if normalized.has("details") and normalized.details is Dictionary: + normalized["details"] = normalized.details.duplicate(true) + return normalized + + +static func _source_location(entry: Dictionary) -> Dictionary: + if entry.get("details") is Dictionary: + var details: Dictionary = entry.details + if details.get("source") is Dictionary: + return details.source + return {} diff --git a/addons/godot_ai/utils/diagnostics_capture.gd.uid b/addons/godot_ai/utils/diagnostics_capture.gd.uid new file mode 100644 index 0000000..95973c9 --- /dev/null +++ b/addons/godot_ai/utils/diagnostics_capture.gd.uid @@ -0,0 +1 @@ +uid://b3npxxpuobbc2 diff --git a/addons/godot_ai/utils/editor_log_buffer.gd b/addons/godot_ai/utils/editor_log_buffer.gd new file mode 100644 index 0000000..0ea44e7 --- /dev/null +++ b/addons/godot_ai/utils/editor_log_buffer.gd @@ -0,0 +1,127 @@ +@tool +class_name McpEditorLogBuffer +extends McpStructuredLogRing + +## Ring buffer for editor-process script errors and warnings (parse errors, +## @tool runtime errors, EditorPlugin errors, push_error/push_warning) captured +## by editor_logger.gd's Logger subclass. +## +## Smaller cap than McpGameLogBuffer (500 vs 2000) — the editor only emits errors, +## not the full println firehose a game can produce. No run_id rotation: editor +## errors persist across project_run cycles (they're about *editing* state, not +## about the playing game). +## +## Mutex-protected because Logger virtuals can fire from any thread (e.g. +## async script-loader threads emitting parse errors), and the buffer is +## read on the main thread by EditorHandler.get_logs. Each public method +## wraps the base ring's lockless helpers in `_mutex.lock()/unlock()` — +## the base stays lockless so McpGameLogBuffer's hot path doesn't pay an +## unused mutex cost. +## +## Entry shape: {source: "editor", level: "info"|"warn"|"error", +## text, path, line, function} — `path/line/function` may be empty/zero +## when the source location wasn't recoverable (e.g. printerr from a +## thread without a script context). + +const MAX_LINES := 500 + +var _mutex := Mutex.new() +var _error_appended_total := 0 +var _warn_appended_total := 0 + + +func _init() -> void: + super._init(MAX_LINES) + + +func append(level: String, text: String, path: String = "", line: int = 0, function: String = "", details: Dictionary = {}) -> void: + var coerced_level := _coerce_level(level) + var entry := { + "source": "editor", + "level": coerced_level, + "text": text, + "path": path, + "line": line, + "function": function, + } + if not details.is_empty(): + entry["details"] = details.duplicate(true) + _mutex.lock() + _append_entry(entry) + if coerced_level == "error": + _error_appended_total += 1 + elif coerced_level == "warn": + _warn_appended_total += 1 + _mutex.unlock() + + +func get_range(offset: int, count: int) -> Array[Dictionary]: + _mutex.lock() + var out := _get_range_unlocked(offset, count) + _mutex.unlock() + return out + + +func get_recent(count: int) -> Array[Dictionary]: + ## Single-lock so the size we compute `start` from can't race against + ## a concurrent append between the size read and the slice copy. + _mutex.lock() + var size := _total_count_unlocked() + var start := maxi(0, size - count) + var out := _get_range_unlocked(start, size - start) + _mutex.unlock() + return out + + +func get_since(since_seq: int, limit: int = -1) -> Dictionary: + ## Single-lock so the cursor snapshot and slice copy can't race against a + ## Logger-thread append. + _mutex.lock() + var out := _get_since_unlocked(since_seq, limit) + _mutex.unlock() + return out + + +func total_count() -> int: + _mutex.lock() + var n := _total_count_unlocked() + _mutex.unlock() + return n + + +func dropped_count() -> int: + _mutex.lock() + var n := _dropped_count_unlocked() + _mutex.unlock() + return n + + +func appended_total() -> int: + _mutex.lock() + var n := _appended_total_unlocked() + _mutex.unlock() + return n + + +func error_appended_total() -> int: + _mutex.lock() + var n := _error_appended_total + _mutex.unlock() + return n + + +func warn_appended_total() -> int: + _mutex.lock() + var n := _warn_appended_total + _mutex.unlock() + return n + + +func clear() -> int: + _mutex.lock() + var n := _total_count_unlocked() + _clear_storage() + _error_appended_total = 0 + _warn_appended_total = 0 + _mutex.unlock() + return n diff --git a/addons/godot_ai/utils/editor_log_buffer.gd.uid b/addons/godot_ai/utils/editor_log_buffer.gd.uid new file mode 100644 index 0000000..8c8d823 --- /dev/null +++ b/addons/godot_ai/utils/editor_log_buffer.gd.uid @@ -0,0 +1 @@ +uid://b6ynms0856hhq diff --git a/addons/godot_ai/utils/error_codes.gd b/addons/godot_ai/utils/error_codes.gd new file mode 100644 index 0000000..5dc3466 --- /dev/null +++ b/addons/godot_ai/utils/error_codes.gd @@ -0,0 +1,163 @@ +@tool +class_name McpErrorCodes +extends RefCounted + +## Error code constants shared across handlers. Mirrors protocol/errors.py. +## +## This `class_name` shipped in v2.3.2 and earlier and must stay reachable +## through self-update. v2.4.1 dropped it and triggered a "Could not resolve +## script" cascade for every user upgrading from any earlier version; v2.4.2 +## restored it as a hot-fix. The cascade fires because Godot keeps stale +## registry entries during the disable -> extract -> enable window when a +## previously-registered class_name disappears, and that failure mode is +## independent of the runner's install ordering. See CLAUDE.md's +## never-delete-published-class_name policy for the shape-aware shim path +## that retirement (if ever needed) must follow. +## +## All consumers use the preload-alias pattern +## (`const ErrorCodes := preload(...)`) introduced in #412. The alias is +## stylistic; both `McpErrorCodes.X` and `ErrorCodes.X` resolve through the +## same Script object cache, so the alias is not a parse-safety boundary +## under the single-phase runner. + +const INVALID_PARAMS := "INVALID_PARAMS" +const EDITED_SCENE_MISMATCH := "EDITED_SCENE_MISMATCH" +const EDITOR_NOT_READY := "EDITOR_NOT_READY" +const UNKNOWN_COMMAND := "UNKNOWN_COMMAND" +const INTERNAL_ERROR := "INTERNAL_ERROR" +const DEFERRED_TIMEOUT := "DEFERRED_TIMEOUT" +## Python-originated attach bridge codes. GDScript has no emit path, but the +## public registry intentionally mirrors protocol/errors.py. +const TRANSPORT_OUTCOME_UNKNOWN := "TRANSPORT_OUTCOME_UNKNOWN" +const NEW_CLIENT_SESSION_REQUIRED := "NEW_CLIENT_SESSION_REQUIRED" +const ATTACH_LOCK_TIMEOUT := "ATTACH_LOCK_TIMEOUT" +const ATTACH_LOCK_ERROR := "ATTACH_LOCK_ERROR" +const ATTACH_RUNTIME_DIR_ERROR := "ATTACH_RUNTIME_DIR_ERROR" +const PORT_OCCUPIED := "PORT_OCCUPIED" +const BACKEND_START_FAILED := "BACKEND_START_FAILED" +const BACKEND_START_TIMEOUT := "BACKEND_START_TIMEOUT" +# game_eval failure codes (#490) — keep in sync with protocol/errors.py +const EVAL_COMPILE_ERROR := "EVAL_COMPILE_ERROR" +const EVAL_RUNTIME_ERROR := "EVAL_RUNTIME_ERROR" +## #518/#859: the play session is up (EditorInterface.is_playing_scene() is true, +## so editor_handler's EDITOR_NOT_READY "game is not running" gate already +## passed) but the game cannot service evals: the helper did not register within +## EVAL_READY_WAIT_SEC, its main-loop beacon is stale, or its debugger session +## closed mid-eval. Carved out of INTERNAL_ERROR so these fast, +## caller-actionable failures do not burn the opaque 10s eval backstop. +const EVAL_GAME_NOT_READY := "EVAL_GAME_NOT_READY" +## #518: the eval genuinely never finished inside the timeout ladder — the +## game-side 8s deadline aborted a hung await, or the editor-side 10s backstop +## fired because the game never replied at all (CPU-bound loop, frozen / +## backgrounded idle loop). Carved out of INTERNAL_ERROR — the last big +## still-unlabeled bucket from #487/#488 — so "your eval code never finished" +## stops reading as an internal fault in telemetry and agent-facing errors. +const EVAL_HUNG := "EVAL_HUNG" +## #518: the eval completed but its serialized result is too large for the +## debugger + WebSocket pipeline. Without this the reply is dropped silently +## (the debugger TCP peer discards messages over ~8 MiB) and the request rides +## to the 10s backstop as a phantom "hang". Failing fast game-side with the +## real byte count makes the failure actionable (return a smaller slice). +const EVAL_RESULT_TOO_LARGE := "EVAL_RESULT_TOO_LARGE" +## #777: a game-side request (currently editor_screenshot source="game") +## reached a live, registered game helper but no reply came back before the +## editor-side timer fired. Every editor gate already passed +## (is_playing_scene, helper hello) so this is a TOP-LEVEL code, not an +## EDITOR_NOT_READY sub-code: the game process itself failed to respond — +## backgrounded with a frozen main loop and nothing rendered to fall back +## on, main thread blocked, or the helper died mid-run. Carved out of +## INTERNAL_ERROR (the largest opaque timeout bucket fleet-wide) so the +## residual timeout is attributable and actionable. +const GAME_HELPER_TIMEOUT := "GAME_HELPER_TIMEOUT" +## audit-v2 #21 (issue #365): finer-grained codes carved out of the 471 +## INVALID_PARAMS sites so agents can distinguish recoverable input +## errors from structural ones. INVALID_PARAMS stays for genuinely +## catch-all input errors that don't fit any of the buckets below. +## +## - NODE_NOT_FOUND: scene-tree/autoload node lookup failed (path didn't +## resolve to a Node). +## - RESOURCE_NOT_FOUND: a `res://` path lookup failed (file/.tres/ +## .gdshader/.tscn etc. doesn't exist or couldn't load). Distinct from +## NODE_NOT_FOUND because the recovery path differs — agents need to +## know whether to fix a node path vs. create/import a resource. +## - PROPERTY_NOT_ON_CLASS: property/signal/method/uniform/slot lookup +## failed on a known instance (path resolved, but the requested +## member doesn't exist on that class). +## - VALUE_OUT_OF_RANGE: numeric/index bound violation OR enum value +## not in the allowed set. +## - WRONG_TYPE: input was a value (or a loaded resource) of the wrong +## type — the param was provided, but `typeof` or `is X` failed. +## - MISSING_REQUIRED_PARAM: required input field was absent or empty. +const NODE_NOT_FOUND := "NODE_NOT_FOUND" +const RESOURCE_NOT_FOUND := "RESOURCE_NOT_FOUND" +const PROPERTY_NOT_ON_CLASS := "PROPERTY_NOT_ON_CLASS" +const VALUE_OUT_OF_RANGE := "VALUE_OUT_OF_RANGE" +const WRONG_TYPE := "WRONG_TYPE" +const MISSING_REQUIRED_PARAM := "MISSING_REQUIRED_PARAM" +const CUSTOM_TOOL_NOT_UNDOABLE := "CUSTOM_TOOL_NOT_UNDOABLE" +const CUSTOM_TOOL_DISABLED := "CUSTOM_TOOL_DISABLED" + +## #651 stage 1: EDITOR_NOT_READY sub-codes. These travel in +## `error.data.sub_code`, NEVER as the top-level `error.code` — existing +## callers and dashboards key on EDITOR_NOT_READY, so the top-level code is +## frozen. Each sub-code names the concrete editor state at rejection time, +## limited to states EditorInterface/EditorFileSystem can report +## deterministically. States we cannot observe (script compilation, +## resource reload, modal dialogs) intentionally get NO sub-code: a bare +## EDITOR_NOT_READY stays the honest fallback rather than a guessed label. +## Keep in sync with protocol/errors.py::EditorNotReadySubCode — enforced +## by tests/unit/test_editor_not_ready_hint_contract.py. +const SUB_EDITOR_IMPORTING := "EDITOR_IMPORTING" +const SUB_EDITOR_PLAYING := "EDITOR_PLAYING" +const SUB_EDITOR_NO_SCENE := "EDITOR_NO_SCENE" +const SUB_EDITOR_GAME_NOT_RUNNING := "EDITOR_GAME_NOT_RUNNING" +const SUB_EDITOR_VIEWPORT_UNAVAILABLE := "EDITOR_VIEWPORT_UNAVAILABLE" +const SUB_EDITOR_VIEWPORT_NOT_3D := "EDITOR_VIEWPORT_NOT_3D" +const SUB_EDITOR_VIEWPORT_EMPTY := "EDITOR_VIEWPORT_EMPTY" +const SUB_EDITOR_UNAVAILABLE := "EDITOR_UNAVAILABLE" +## Emitted only by the exclusive-run transport servicing path: a command +## arrived while a synchronous test run holds the main thread, and was +## rejected (not buffered) so it can't replay stale after its server-side +## future expires. See connection.gd::service_transport_during_exclusive_run. +const SUB_EDITOR_TEST_RUNNING := "EDITOR_TEST_RUNNING" + +## Terminal code for a test run that hit its between-test abort ceiling +## before finishing. error.data carries the partial summary; full partial +## results stay retrievable via get_test_results. +const TEST_RUN_TIMEOUT := "TEST_RUN_TIMEOUT" + + +## Build a standard error response dictionary. +static func make(code: String, message: String) -> Dictionary: + return {"status": "error", "error": {"code": code, "message": message}} + + +## Build an EDITOR_NOT_READY error carrying the #651 stage-1 attribution +## payload: `data.sub_code` + `retryable` + `hint`. Mirrors the shape +## scene_path.gd::require_edited_scene established (editor_state/retryable/ +## hint). `hint` may be empty when `message` already IS the recovery hint — +## the server's GodotCommandError string-appends every data key, so +## duplicating the message into data would double the agent-visible text. +static func make_not_ready( + sub_code: String, message: String, retryable: bool, hint: String = "" +) -> Dictionary: + var err := make(EDITOR_NOT_READY, message) + var data := {"sub_code": sub_code, "retryable": retryable} + if not hint.is_empty(): + data["hint"] = hint + err["error"]["data"] = data + return err + + +## Return a NEW error dict with the original code and a prefixed message. +## Prefer this over mutating `err["error"]["message"]` in place — callers +## that want to add context ("Property '%s': …") shouldn't need to know +## the internal shape of the dict returned by `make`. Empty `prefix` +## returns `err` unchanged so callers don't need their own guard. +static func prefix_message(err: Dictionary, prefix: String) -> Dictionary: + if prefix.is_empty(): + return err + var inner: Dictionary = err.get("error", {}) + var code: String = inner.get("code", INTERNAL_ERROR) + var message: String = inner.get("message", "") + return make(code, "%s: %s" % [prefix, message]) diff --git a/addons/godot_ai/utils/error_codes.gd.uid b/addons/godot_ai/utils/error_codes.gd.uid new file mode 100644 index 0000000..fcc1e82 --- /dev/null +++ b/addons/godot_ai/utils/error_codes.gd.uid @@ -0,0 +1 @@ +uid://d2klnglf5p861 diff --git a/addons/godot_ai/utils/fuzzy_suggestions.gd b/addons/godot_ai/utils/fuzzy_suggestions.gd new file mode 100644 index 0000000..de7cc19 --- /dev/null +++ b/addons/godot_ai/utils/fuzzy_suggestions.gd @@ -0,0 +1,39 @@ +@tool +extends RefCounted + +## Shared fuzzy ranking for typo suggestions. + + +static func rank( + needle: String, + candidates: Array, + limit: int = 5, + threshold: float = 0.4, + substring_bonus: float = 0.5, + prefix_bonus: float = 1.0 +) -> Array[String]: + if needle.is_empty() or candidates.is_empty(): + return [] + var needle_lower := needle.to_lower() + var scored: Array = [] + for raw_candidate in candidates: + var candidate := str(raw_candidate) + var candidate_lower := candidate.to_lower() + var score := needle.similarity(candidate) + if prefix_bonus != 0.0 and candidate_lower.begins_with(needle_lower): + score += prefix_bonus + elif substring_bonus != 0.0 and ( + candidate_lower.contains(needle_lower) or needle_lower.contains(candidate_lower) + ): + score += substring_bonus + if score >= threshold: + scored.append([score, candidate]) + scored.sort_custom(func(a, b): + if a[0] == b[0]: + return a[1] < b[1] + return a[0] > b[0] + ) + var result: Array[String] = [] + for index in range(min(limit, scored.size())): + result.append(scored[index][1]) + return result diff --git a/addons/godot_ai/utils/fuzzy_suggestions.gd.uid b/addons/godot_ai/utils/fuzzy_suggestions.gd.uid new file mode 100644 index 0000000..c663d01 --- /dev/null +++ b/addons/godot_ai/utils/fuzzy_suggestions.gd.uid @@ -0,0 +1 @@ +uid://bxwaws6w0xw60 diff --git a/addons/godot_ai/utils/game_log_buffer.gd b/addons/godot_ai/utils/game_log_buffer.gd new file mode 100644 index 0000000..2f1b3b0 --- /dev/null +++ b/addons/godot_ai/utils/game_log_buffer.gd @@ -0,0 +1,105 @@ +@tool +class_name McpGameLogBuffer +extends McpStructuredLogRing + +## Ring buffer for game-process log lines (print, push_warning, push_error) +## ferried back from the playing game over the EngineDebugger channel. +## +## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id` +## rotates at play-start, giving agents a stable cursor for "lines from +## this run" even when the game never reaches the mcp:hello boot beacon. +## +## Single-threaded — game_helper.gd drains its logger from `_process` and +## calls `append` from the main thread, so this subclass can use the base +## ring's lockless reads/writes directly. + +const MAX_LINES := 2000 + +var _run_id := "" +var _run_seq := 0 +var _error_warn_total := 0 +var _error_total := 0 + + +func _init() -> void: + super._init(MAX_LINES) + + +func append(level: String, text: String, details: Dictionary = {}) -> void: + var coerced_level := _coerce_level(level) + var entry := { + "source": "game", + "level": coerced_level, + "text": text, + "run_id": _run_id, + } + if not details.is_empty(): + entry["details"] = details.duplicate(true) + _append_entry(entry) + if coerced_level in ["warn", "error"]: + _error_warn_total += 1 + if coerced_level == "error": + _error_total += 1 + + +## Rotate the run identifier without dropping buffered entries. Called at +## play-start so even no-hello parse failures get a fresh current-run identity. +## Historical lines stay tagged with their original run_id and can still be +## queried explicitly. +func clear_for_new_run() -> String: + _run_id = _generate_run_id() + _error_warn_total = 0 + _error_total = 0 + return _run_id + + +func run_id() -> String: + return _run_id + + +func error_warn_total() -> int: + return _error_warn_total + + +func error_total() -> int: + return _error_total + + +## Warn-level lines for the current run: the combined error+warn tally minus +## the error-only tally. Feeds the `game_warn` watermark component so a run +## that only emitted push_warning is no longer reported as clean. +func warn_total() -> int: + return _error_warn_total - _error_total + + +func get_run_range(run_id: String, offset: int, count: int) -> Array[Dictionary]: + return get_run_page(run_id, offset, count).entries + + +func get_run_page(run_id: String, offset: int, count: int) -> Dictionary: + var entries := _entries_for_run(run_id) + var start := mini(maxi(0, offset), entries.size()) + var stop := mini(entries.size(), start + maxi(0, count)) + var out: Array[Dictionary] = [] + for i in range(start, stop): + out.append(entries[i]) + return { + "entries": out, + "total_count": entries.size(), + } + + +func _entries_for_run(run_id: String) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + for entry in get_range(0, total_count()): + if str(entry.get("run_id", "")) == run_id: + out.append(entry) + return out + + +func _generate_run_id() -> String: + ## Opaque to agents — they only check equality. Time-based is plenty + ## unique within a single editor session; the local sequence protects + ## fast back-to-back test runs within the same millisecond. + _run_seq += 1 + return "r%d-%d" % [Time.get_ticks_msec(), _run_seq] diff --git a/addons/godot_ai/utils/game_log_buffer.gd.uid b/addons/godot_ai/utils/game_log_buffer.gd.uid new file mode 100644 index 0000000..6c7ce7d --- /dev/null +++ b/addons/godot_ai/utils/game_log_buffer.gd.uid @@ -0,0 +1 @@ +uid://biojw0xl64haw diff --git a/addons/godot_ai/utils/json_values.gd b/addons/godot_ai/utils/json_values.gd new file mode 100644 index 0000000..d745d02 --- /dev/null +++ b/addons/godot_ai/utils/json_values.gd @@ -0,0 +1,92 @@ +@tool +class_name McpJsonValues +extends RefCounted + +## Canonical JSON→Variant parsers for the wire shapes agents send. +## +## One parser family instead of five drifted per-handler copies (#714) — +## the canonical color set is the maintainer decision recorded on that +## issue. parse_color accepts: Color passthrough; "#rrggbb"/"#rrggbbaa" +## hex or named-color strings (two-sentinel Color.from_string +## validation); {r,g,b[,a]} dicts; [r,g,b[,a]] arrays. parse_vector2/3 +## accept the Vector passthrough, {x,y[,z]} dicts, and [x,y[,z]] arrays. +## +## Strict WITHIN each shape (the #123/#126 contract): wrong dict keys, +## wrong array lengths, or non-numeric components return null instead of +## guessing zeros — callers turn null into their own typed error. + +const COLOR_KEYS: Array[String] = ["r", "g", "b"] +const VECTOR2_KEYS: Array[String] = ["x", "y"] +const VECTOR3_KEYS: Array[String] = ["x", "y", "z"] + + +static func parse_color(value: Variant) -> Variant: + if value is Color: + return value + if value is String: + ## Color.from_string returns the fallback on parse failure — call + ## twice with distinct sentinels; agreement means a real parse. + var a := Color.from_string(value, Color(0, 0, 0, 0)) + var b := Color.from_string(value, Color(1, 1, 1, 1)) + if a != b: + return null + return a + if value is Dictionary: + var d: Dictionary = value + if not d.has_all(COLOR_KEYS): + return null + var alpha: Variant = d.get("a", 1.0) + if not (_is_number(d.r) and _is_number(d.g) and _is_number(d.b) and _is_number(alpha)): + return null + return Color(float(d.r), float(d.g), float(d.b), float(alpha)) + if value is Array: + var arr: Array = value + if arr.size() != 3 and arr.size() != 4: + return null + for item in arr: + if not _is_number(item): + return null + var a4 := float(arr[3]) if arr.size() == 4 else 1.0 + return Color(float(arr[0]), float(arr[1]), float(arr[2]), a4) + return null + + +static func parse_vector2(value: Variant) -> Variant: + if value is Vector2: + return value + if value is Dictionary: + var d: Dictionary = value + if not d.has_all(VECTOR2_KEYS) or not (_is_number(d.x) and _is_number(d.y)): + return null + return Vector2(float(d.x), float(d.y)) + if value is Array: + var arr: Array = value + if arr.size() != 2 or not (_is_number(arr[0]) and _is_number(arr[1])): + return null + return Vector2(float(arr[0]), float(arr[1])) + return null + + +static func parse_vector3(value: Variant) -> Variant: + if value is Vector3: + return value + if value is Dictionary: + var d: Dictionary = value + if not d.has_all(VECTOR3_KEYS): + return null + if not (_is_number(d.x) and _is_number(d.y) and _is_number(d.z)): + return null + return Vector3(float(d.x), float(d.y), float(d.z)) + if value is Array: + var arr: Array = value + if arr.size() != 3: + return null + for item in arr: + if not _is_number(item): + return null + return Vector3(float(arr[0]), float(arr[1]), float(arr[2])) + return null + + +static func _is_number(v: Variant) -> bool: + return v is int or v is float diff --git a/addons/godot_ai/utils/json_values.gd.uid b/addons/godot_ai/utils/json_values.gd.uid new file mode 100644 index 0000000..b3eeba9 --- /dev/null +++ b/addons/godot_ai/utils/json_values.gd.uid @@ -0,0 +1 @@ +uid://4wbf83hwckms diff --git a/addons/godot_ai/utils/log_backtrace.gd b/addons/godot_ai/utils/log_backtrace.gd new file mode 100644 index 0000000..3fdf838 --- /dev/null +++ b/addons/godot_ai/utils/log_backtrace.gd @@ -0,0 +1,113 @@ +@tool +class_name McpLogBacktrace +extends RefCounted + +## Helpers for interpreting Godot's `_log_error` virtual arguments. +## (Named `McpLogBacktrace`, not `ScriptBacktrace`: Godot ships a built-in +## `ScriptBacktrace` class — the type of `script_backtraces[i]` entries +## — so class_name'ing ours the same would collide. Verified against +## the engine's `--doctool` output in 4.6.) +## +## Both `editor_logger.gd` and `game_logger.gd` need to: +## - Map `error_type` (0=ERROR, 1=WARNING, 2=SCRIPT, 3=SHADER) to a +## two-bucket "error" / "warn" string so callers can filter without +## consulting the enum. +## - Fall back to `code` when `rationale` is empty — single-arg +## `push_error("msg")` leaves rationale empty and stuffs the user's +## string into `code`; without the fallback the user message is +## silently lost. The two-arg form `push_error(code, rationale)` +## populates both and rationale wins. +## - Remap the source location to the first frame of `script_backtraces[0]` +## when present. `push_error` / `push_warning` always report +## `file=core/variant/variant_utility.cpp`; the actual user GDScript +## caller is in the backtrace. +## +## Centralising the rules keeps the next push_error semantics shift +## (already happened once between 4.5 and 4.6, see PR #78) a one-place +## fix instead of a two-place hunt. + + +## Coalesce the per-virtual-arg shape Godot hands `_log_error` into a +## flat record. Always walks `script_backtraces` for the first non-empty +## frame; loggers that need to filter by source path call this first and +## then check the resolved `path` field. +## +## Returns: `{level, message, path, line, function, details}` +## - `level`: "error" or "warn" (warn iff `error_type == 1`). +## - `message`: `rationale` when non-empty, else `code`. +## - `path` / `line` / `function`: first backtrace frame when one is +## available; otherwise the original `file` / `line` / `function`. +## - `details`: original `_log_error` fields plus the first non-empty +## backtrace as frames, mirroring the debugger Errors tab context. +const ERROR_TYPE_NAMES := { + 0: "error", + 1: "warning", + 2: "script", + 3: "shader", +} + + +static func resolve_error( + function: String, + file: String, + line: int, + code: String, + rationale: String, + error_type: int, + script_backtraces: Array, +) -> Dictionary: + var src_file := file + var src_line := line + var src_function := function + var frames: Array[Dictionary] = [] + ## First non-empty frame wins, not just `script_backtraces[0]` — + ## chained errors can leave the leading entry empty with the actual + ## user frame in `script_backtraces[1]`. + for bt in script_backtraces: + if bt != null and bt.get_frame_count() > 0: + frames = _frames_from_backtrace(bt) + src_file = str(frames[0].get("path", "")) + src_line = int(frames[0].get("line", 0)) + src_function = str(frames[0].get("function", "")) + break + var message := rationale if not rationale.is_empty() else code + return { + "level": "warn" if error_type == 1 else "error", + "message": message, + "path": src_file, + "line": src_line, + "function": src_function, + "details": { + "message": message, + "code": code, + "rationale": rationale, + "error_type": error_type, + "error_type_name": _error_type_name(error_type), + "source": { + "path": file, + "line": line, + "function": function, + }, + "resolved": { + "path": src_file, + "line": src_line, + "function": src_function, + }, + "frames": frames, + }, + } + + +static func _frames_from_backtrace(bt) -> Array[Dictionary]: + var frames: Array[Dictionary] = [] + for i in bt.get_frame_count(): + frames.append({ + "path": bt.get_frame_file(i), + "line": bt.get_frame_line(i), + "function": bt.get_frame_function(i), + }) + return frames + + +static func _error_type_name(error_type: int) -> String: + return str(ERROR_TYPE_NAMES.get(error_type, "unknown")) diff --git a/addons/godot_ai/utils/log_backtrace.gd.uid b/addons/godot_ai/utils/log_backtrace.gd.uid new file mode 100644 index 0000000..4845ad8 --- /dev/null +++ b/addons/godot_ai/utils/log_backtrace.gd.uid @@ -0,0 +1 @@ +uid://b8t9kznr2pqxa diff --git a/addons/godot_ai/utils/log_buffer.gd b/addons/godot_ai/utils/log_buffer.gd new file mode 100644 index 0000000..34bd2b5 --- /dev/null +++ b/addons/godot_ai/utils/log_buffer.gd @@ -0,0 +1,67 @@ +@tool +class_name McpLogBuffer +extends RefCounted + +## Ring buffer for MCP log lines. Also prints to Godot console. + +const MAX_LINES := 500 + +## When false, `log()` still records into the ring buffer but does not echo the +## line to the Godot console. The test runner flips this off for the duration +## of a run so negative-path suites (which intentionally drive a 500-line ring +## fill and malformed-result error logging) don't bury an all-green run in +## console noise. Ring *contents* — what tests assert on via `get_recent()` / +## `total_logged()` — are unaffected. Engine-level C++ errors raised by +## negative-path tests are not routed through here and still surface. +static var console_echo := true + +var _lines: Array[String] = [] +## Monotonic count of every line ever passed to `log()` since the last +## `clear()`. Distinct from `_lines.size()`, which is bounded at MAX_LINES. +## Consumers that need to detect "new lines arrived" (e.g. `LogViewer.tick`) +## must track this rather than the bounded size — once the ring fills, the +## size stays at MAX_LINES on every subsequent append, so a size-based +## cursor would freeze and the consumer would stop seeing new entries. +var _total_logged: int = 0 +var enabled := true + + +## `echo=false` records the line into the ring (so the dock's log panel shows +## it) without printing to the Godot console. Used for high-frequency +## machine-driven lines like readiness flips, which spammed the console of +## every install (#626) — filesystem scans toggle readiness on each import. +func log(msg: String, echo: bool = true) -> void: + var line := "MCP | %s" % msg + if enabled and console_echo and echo: + print(line) + _lines.append(line) + if _lines.size() > MAX_LINES: + _lines = _lines.slice(-MAX_LINES) + _total_logged += 1 + + +func get_recent(count: int = 50) -> Array[String]: + var start := maxi(0, _lines.size() - count) + var result: Array[String] = [] + result.assign(_lines.slice(start)) + return result + + +func clear() -> void: + _lines.clear() + ## Reset the monotonic counter so a viewer's `seq < _last_seq` shrink + ## detection still recognizes the clear. Callers that want a cumulative + ## ever-produced count across clears can wrap their own counter. + _total_logged = 0 + + +func total_count() -> int: + return _lines.size() + + +## Monotonic sequence — number of lines ever appended via `log()` since +## the last `clear()`. Strictly increases per append, even once the ring +## has filled and `total_count()` is pinned at MAX_LINES. See `_total_logged` +## for rationale. +func total_logged() -> int: + return _total_logged diff --git a/addons/godot_ai/utils/log_buffer.gd.uid b/addons/godot_ai/utils/log_buffer.gd.uid new file mode 100644 index 0000000..58341ca --- /dev/null +++ b/addons/godot_ai/utils/log_buffer.gd.uid @@ -0,0 +1 @@ +uid://ddkslse7511e6 diff --git a/addons/godot_ai/utils/mcp_adoption_label.gd b/addons/godot_ai/utils/mcp_adoption_label.gd new file mode 100644 index 0000000..3114037 --- /dev/null +++ b/addons/godot_ai/utils/mcp_adoption_label.gd @@ -0,0 +1,23 @@ +@tool +class_name McpAdoptionLabel +extends RefCounted + +## Outcome flag for `McpServerLifecycleManager.adopt_compatible_server`. +## Distinguishes a same-version managed adoption (we own the PID, can +## restart it) from an external compatible adoption (some other plugin +## instance / dev server owns the process; we just rendezvoused with it). +## +## Was a free-form string in PR 5; promoted to constants here because +## the seam now spans `server_lifecycle.gd`, `plugin.gd`'s log helper, +## the dock's restart-button gating, and the test suite. Stable strings +## keep log scrapes and characterization fixtures unaffected. + +## We have a PID we spawned (or re-acquired by reading the managed +## record + verifying liveness). `force_restart_server` and +## `prepare_for_update_reload` may target this PID. +const MANAGED := "managed" + +## A compatible godot-ai server is on the port but we don't own its +## PID — likely another plugin instance's spawn, or a developer-run +## `godot-ai --reload` server. We reuse it but won't kill it on stop. +const EXTERNAL := "external" diff --git a/addons/godot_ai/utils/mcp_adoption_label.gd.uid b/addons/godot_ai/utils/mcp_adoption_label.gd.uid new file mode 100644 index 0000000..aee58fe --- /dev/null +++ b/addons/godot_ai/utils/mcp_adoption_label.gd.uid @@ -0,0 +1 @@ +uid://klhsu1cuhcue diff --git a/addons/godot_ai/utils/mcp_client_refresh_state.gd b/addons/godot_ai/utils/mcp_client_refresh_state.gd new file mode 100644 index 0000000..fbc0b84 --- /dev/null +++ b/addons/godot_ai/utils/mcp_client_refresh_state.gd @@ -0,0 +1,107 @@ +@tool +class_name McpClientRefreshState +extends RefCounted + +## State machine for the dock's client-status refresh sweep. Single +## source of truth — supersedes the seven booleans + deadline previously +## scattered across `mcp_dock.gd` (`_client_status_refresh_in_flight`, +## `_client_status_refresh_pending`, `_client_status_refresh_pending_force`, +## `_client_status_refresh_timed_out`, `_client_status_refresh_started_msec`, +## `_client_status_refresh_deferred_until_filesystem_ready`, +## `_client_status_refresh_deferred_force`, +## `_client_status_refresh_deferred_initial`, +## `_client_status_refresh_shutdown_requested`). +## +## The ints are stable for tests; reordering is a breaking change. + +## No worker running, no pending request. Default state. +const IDLE := 0 +## A refresh request landed but the editor filesystem is busy +## (`EditorInterface.get_resource_filesystem().is_scanning()` is true); +## the dock parks the request and retries on the next `_process` after +## the scan settles. Held alongside two flags (force / initial) for +## what kind of refresh to retry; those live next to the state, not +## inside it, because they're requests not state. +const DEFERRED_FOR_FILESYSTEM := 1 +## Worker thread is alive and probing client status off-main. The +## dock paints "(checking...)" in the clients summary and accepts +## additional requests as `pending`. +const RUNNING := 2 +## Worker has been alive past CLIENT_STATUS_REFRESH_TIMEOUT_MSEC. The +## dock paints "(client probe still running)" and a forced refresh is +## allowed to abandon the worker into the orphan list and start a new +## sweep. The state stays RUNNING after a forced abandon-and-restart. +const RUNNING_TIMED_OUT := 3 +## `_exit_tree` / `_install_update` is draining workers. New refresh +## requests are rejected outright. Set once and not cleared (the dock +## instance is being torn down). +const SHUTTING_DOWN := 4 + +const _NAMES := { + IDLE: "idle", + DEFERRED_FOR_FILESYSTEM: "deferred_for_filesystem", + RUNNING: "running", + RUNNING_TIMED_OUT: "running_timed_out", + SHUTTING_DOWN: "shutting_down", +} + + +static func name_of(state: int) -> String: + return _NAMES.get(state, "unknown(%d)" % state) + + +## True when a worker thread should be alive in this state. Combined +## state — RUNNING or RUNNING_TIMED_OUT both have a worker running, but +## the timed-out flavor allows a force-refresh to abandon it. +static func has_worker_alive(state: int) -> bool: + return state == RUNNING or state == RUNNING_TIMED_OUT + + +## True while the status worker is still within its healthy budget. Once a +## refresh has timed out, the dock keeps the warning badge but must let users +## retry Configure / Configure all instead of stranding the controls behind an +## orphaned, uninterruptible worker. +static func should_disable_client_actions(state: int) -> bool: + return state == RUNNING + + +## True when the dock should reject new refresh spawns. Used by the +## dock's two refresh-spawn guards (the deferred refresh entrypoint and +## the status-refresh scheduler). +static func is_blocked_for_spawn(state: int) -> bool: + return state == SHUTTING_DOWN + + +## True when the summary label should show the in-flight badge. +static func should_show_checking_badge(state: int) -> bool: + return state == RUNNING or state == RUNNING_TIMED_OUT + + +## Transition table. Same shape as McpServerState — illegal transitions +## return false; callers `push_warning` and no-op. +static func can_transition(from: int, to: int) -> bool: + if from == to: + return true + ## Shutdown is sticky. + if from == SHUTTING_DOWN: + return false + ## Anything → SHUTTING_DOWN is legal (drain on _exit_tree / install). + if to == SHUTTING_DOWN: + return true + match from: + IDLE: + return to == RUNNING or to == DEFERRED_FOR_FILESYSTEM + DEFERRED_FOR_FILESYSTEM: + ## When the filesystem scan settles we either spawn a worker + ## (RUNNING) or roll back to IDLE if no rows need probing. + return to == RUNNING or to == IDLE + RUNNING: + ## Worker finishes -> IDLE. Worker outlives budget -> + ## RUNNING_TIMED_OUT. Forced respawn after orphan abandon + ## stays in RUNNING (covered by from == to above). + return to == IDLE or to == RUNNING_TIMED_OUT + RUNNING_TIMED_OUT: + ## Late-arriving worker result drops back to IDLE; forced + ## abandon-and-respawn drops back to RUNNING. + return to == IDLE or to == RUNNING + return false diff --git a/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid b/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid new file mode 100644 index 0000000..9e4129c --- /dev/null +++ b/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid @@ -0,0 +1 @@ +uid://dv4tukg6eioww diff --git a/addons/godot_ai/utils/mcp_server_state.gd b/addons/godot_ai/utils/mcp_server_state.gd new file mode 100644 index 0000000..24597f4 --- /dev/null +++ b/addons/godot_ai/utils/mcp_server_state.gd @@ -0,0 +1,182 @@ +@tool +class_name McpServerState +extends RefCounted + +## State machine for the plugin's server-spawn / adopt / version-verify +## lifecycle. Single source of truth — supersedes the boolean-flag thicket +## (`_server_started_this_session`, `_awaiting_server_version`, +## `_server_version_deadline_ms`, `_connection_blocked`, +## `_can_recover_incompatible`, `_refresh_retried`, +## `_adoption_watch_deadline_ms`) and the older terminal-only +## McpSpawnState string union. +## +## The integer values matter — they're what `get_server_status()` +## surfaces, what the dock pattern-matches on, and what the test suites +## assert against. Reordering the enum is a breaking change. +## +## The transitions are documented in `can_transition()`. The lifecycle +## manager calls `set_state()` which: +## 1. Validates the transition (logs a warning + no-ops on illegal). +## 2. Preserves first-writer-wins among terminal diagnoses so a late +## CRASHED from the watch loop can't clobber an earlier +## PORT_EXCLUDED from the proactive Windows reservation check. + +## Fresh plugin instance, `_start_server` has not run yet. Default state. +const UNINITIALIZED := 0 +## Process spawned via OS.create_process; watch loop is observing the +## SPAWN_GRACE_MS window. Transitions directly to READY (handshake_ack +## verifies a compatible version), CRASHED (process died early), or +## INCOMPATIBLE (handshake reported a mismatch). +const SPAWNING := 1 +## (slot 2 reserved — keep wire-compat for clients pattern-matching +## numeric `editor_state.state` values; do not reuse.) +## Server is healthy and version-verified. Happy path. Includes both +## "spawned fresh" and "adopted compatible existing server" flavors — +## adoption flavor is recorded separately via `McpAdoptionLabel`. +const READY := 3 +## Live server on the HTTP port returned a version that doesn't match +## what this plugin expects, OR returned no `handshake_ack` inside the +## timeout. Connection is blocked; recovery requires a kill+respawn +## click via `recover_incompatible_server`. +const INCOMPATIBLE := 4 +## Spawned process exited inside the SPAWN_GRACE_MS window. Python +## traceback went to Godot's output log. Terminal — reload the plugin +## or restart the editor to retry. +const CRASHED := 5 +## No server command resolved: no `.venv` Python, no `uvx` on PATH, no +## system `godot-ai`. Terminal — install guidance shown in dock. +const NO_COMMAND := 6 +## Windows reserved the HTTP port via Hyper-V / WSL2 / Docker exclusion +## range. Caught proactively before bind. Terminal — port picker shown. +const PORT_EXCLUDED := 7 +## HTTP port held by a process we didn't spawn (no matching managed +## record). Plugin armed an adoption-confirmation watcher; if the foreign +## occupant turns out to be a compatible godot-ai server, +## `handle_server_version_verified` transitions to READY. If the +## adoption deadline expires without a connection, the watcher self- +## disarms but the state stays at FOREIGN_PORT — the dock keeps showing +## "port held by another process" until the user reloads. The version- +## check seam (separate from the adoption deadline) is what fires +## INCOMPATIBLE on a positive-but-mismatched handshake. +const FOREIGN_PORT := 8 +## Static re-entrancy guard fired (`_server_started_this_session` was +## already true). The plugin is being re-enabled within the same editor +## session; the previous instance still owns the spawn. Terminal — does +## NOT block READY paths, just records that this enable cycle no-op'd. +const GUARDED := 9 +## stop_server / prepare_for_update_reload in progress. Transitional — +## next state is STOPPED. +const STOPPING := 10 +## stop_server completed; `_server_pid` reset to -1, port may or may +## not be free. From here a fresh `start_server` call moves back through +## SPAWNING / READY. +const STOPPED := 11 + +const _NAMES := { + UNINITIALIZED: "uninitialized", + SPAWNING: "spawning", + READY: "ready", + INCOMPATIBLE: "incompatible", + CRASHED: "crashed", + NO_COMMAND: "no_command", + PORT_EXCLUDED: "port_excluded", + FOREIGN_PORT: "foreign_port", + GUARDED: "guarded", + STOPPING: "stopping", + STOPPED: "stopped", +} + + +## Human-readable label. Used in startup-trace logs and transition +## warnings. Falls back to `unknown()` for unrecognised values so +## a future enum addition won't crash the formatter. +static func name_of(state: int) -> String: + return _NAMES.get(state, "unknown(%d)" % state) + + +## True for any state the dock should render as a non-OK diagnostic +## panel. Used as the "should we hide the spawn-failure panel?" gate. +static func is_terminal_diagnosis(state: int) -> bool: + return ( + state == CRASHED + or state == NO_COMMAND + or state == PORT_EXCLUDED + or state == INCOMPATIBLE + or state == FOREIGN_PORT + ) + + +## True when the dock should consider the server unsuitable for client +## health checks (incompatible tool surface). Currently just INCOMPATIBLE +## — FOREIGN_PORT is transitional and may resolve to READY if the +## foreign occupant turns out to speak our handshake. +static func blocks_client_health(state: int) -> bool: + return state == INCOMPATIBLE + + +## Transition validation table. Returns true when `from -> to` is a +## legal transition the lifecycle manager should accept. Illegal +## transitions are silently no-op'd at the call site (with a +## `push_warning` log) — this preserves the first-writer-wins contract +## that prevents a late CRASHED from the watch loop overwriting an +## earlier PORT_EXCLUDED diagnosis. +static func can_transition(from: int, to: int) -> bool: + if from == to: + return true + ## Stop is always legal — teardown / install reload short-circuits + ## any in-flight state. + if to == STOPPING: + return true + if to == STOPPED and from == STOPPING: + return true + ## STOPPED can also be reached directly when `_server_pid <= 0` and + ## stop_server early-returns; treat it as legal from any state to + ## keep the teardown path forgiving. + if to == STOPPED: + return true + ## STOPPED -> any (re-arm via restart paths). + if from == STOPPED: + return true + ## GUARDED is sticky for the rest of this enable cycle; only stop is + ## legal out of it. Already covered by the stop checks above. + if from == GUARDED: + return false + ## Terminal diagnoses freeze further forward transitions. Recovery + ## goes through STOPPING (covered above), so any other target is + ## rejected — this is the first-writer-wins contract. + if ( + from == CRASHED + or from == NO_COMMAND + or from == PORT_EXCLUDED + or from == INCOMPATIBLE + ): + return false + ## UNINITIALIZED is the boot state — any target except STOPPING is + ## reachable directly (start_server's early branches set + ## terminal states without going through SPAWNING). + if from == UNINITIALIZED: + return true + ## In-flight forward transitions. + match from: + SPAWNING: + return ( + to == READY + or to == CRASHED + or to == FOREIGN_PORT + or to == INCOMPATIBLE + ) + FOREIGN_PORT: + return to == READY or to == INCOMPATIBLE + READY: + ## Late incompatibility detection (e.g. version verifier + ## re-arms after a foreign-port reconnect that turns out + ## to be incompatible after all). + return to == INCOMPATIBLE or to == CRASHED + STOPPING: + ## Recovery rollback: kill-then-respawn paths that fail to + ## free the port re-latch INCOMPATIBLE (so the dock keeps + ## the diagnostic UI) or fall back to UNINITIALIZED (clean + ## baseline for a follow-up `_set_incompatible_server`). + ## STOPPING -> STOPPED is handled by the early checks above. + return to == INCOMPATIBLE or to == UNINITIALIZED + return false diff --git a/addons/godot_ai/utils/mcp_server_state.gd.uid b/addons/godot_ai/utils/mcp_server_state.gd.uid new file mode 100644 index 0000000..dc5bef9 --- /dev/null +++ b/addons/godot_ai/utils/mcp_server_state.gd.uid @@ -0,0 +1 @@ +uid://d3ial4erjonlq diff --git a/addons/godot_ai/utils/mcp_startup_path.gd b/addons/godot_ai/utils/mcp_startup_path.gd new file mode 100644 index 0000000..130b8e1 --- /dev/null +++ b/addons/godot_ai/utils/mcp_startup_path.gd @@ -0,0 +1,34 @@ +@tool +class_name McpStartupPath +extends RefCounted + +## Branch-tag enum for `McpServerLifecycleManager.start_server`. Records +## which arm of the spawn / adopt / drift / recover decision tree the +## current `_enter_tree` walked. Surfaced via the startup trace log so +## a Windows port-reservation issue or a stale-record kill can be +## reconstructed from the editor output. +## +## Single-file constants, not an int enum, because the values land in +## startup-trace text and the strings are stable across releases (the +## CLAUDE.md "tool surface" entry references them by name). + +const UNSET := "" +## Re-entrancy guard fired; this enable cycle did not spawn or adopt. +const GUARDED := "guarded" +## Adopted a compatible existing server (managed or external). +const ADOPTED := "adopted" +## Spawned a fresh server process. +const SPAWNED := "spawned" +## OS.create_process returned -1 or proactive Windows reservation +## detected. Either way the spawn never produced a live process. +const CRASHED := "crashed" +## Windows port-exclusion check fired — port is blocked at the OS layer. +const RESERVED := "reserved" +## Server-command discovery returned an empty list — no .venv, no uvx, +## no system godot-ai. +const NO_COMMAND := "no_command" +## Drift-recovery kill fell through; we set INCOMPATIBLE and stayed. +const INCOMPATIBLE := "incompatible" +## Port was free at start; this is the prelude to SPAWNED but kept as +## a distinct path so adopt-vs-spawn is unambiguous in the trace. +const FREE := "free" diff --git a/addons/godot_ai/utils/mcp_startup_path.gd.uid b/addons/godot_ai/utils/mcp_startup_path.gd.uid new file mode 100644 index 0000000..fd01066 --- /dev/null +++ b/addons/godot_ai/utils/mcp_startup_path.gd.uid @@ -0,0 +1 @@ +uid://cikdvq2x4vs4x diff --git a/addons/godot_ai/utils/path_validator.gd b/addons/godot_ai/utils/path_validator.gd new file mode 100644 index 0000000..c181578 --- /dev/null +++ b/addons/godot_ai/utils/path_validator.gd @@ -0,0 +1,198 @@ +@tool +class_name McpPathValidator +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Validates `res://`-rooted paths against directory-traversal escape. +## +## Issue #347 (audit-v2 #3): handlers were accepting `res://../etc/passwd.gd` +## because the only check was `path.begins_with("res://")`. LLM-driven path +## generation (prompt injection, agent typos, untrusted issue/PR text in +## context) can produce traversal payloads for the write tools that produce +## arbitrary disk content (`script_create`, `filesystem_write_text`, +## `patch_script`) and for the matching reads (info disclosure surface). +## +## Two entry points: +## * `validate_resource_path` — for paths that name a `res://` disk file the +## plugin will read or (with `for_write`) write. This is the strict one. +## * `validate_loadable_path` — for paths handed to `ResourceLoader`, which +## also accepts `uid://` (an opaque resource-DB id that cannot express +## traversal) and `user://` (the per-project user data sandbox). Load +## handlers must use this so `uid://` references copied out of `.tscn` +## ExtResource / `.uid` sidecars and `user://` runtime assets keep loading. +## +## Error wrapping: callers should use `path_error` / `loadable_error`, which +## return a ready `ErrorCodes.make(VALUE_OUT_OF_RANGE, …)` dict (or null). A +## bad path is a value-domain error, and funneling every site through one +## wrapper keeps the error code consistent across all handlers. +## +## Known limitation: containment is lexical (`globalize_path` + `simplify_path` +## prefix match). It does NOT resolve symlinks — GDScript exposes no realpath. +## A symlink *inside* the project that points outside it can therefore defeat +## the under-root check. This matches the engine's own `res://` resolution and +## is accepted; the loopback trust boundary is the primary control. + + +# Cached project / user roots. `globalize_path` is stable across the editor's +# lifetime — caching avoids redundant resolution on every call. Matters most +# for `reimport`, which loops the validator over each path in a batch. +# Lazy-init on first call so static-load timing can't see a half-initialised +# ProjectSettings. +static var _cached_res_root: String = "" +static var _cached_user_root: String = "" + + +static func _res_root() -> String: + if _cached_res_root.is_empty(): + _cached_res_root = ProjectSettings.globalize_path("res://").simplify_path() + return _cached_res_root + + +static func _user_root() -> String: + if _cached_user_root.is_empty(): + _cached_user_root = ProjectSettings.globalize_path("user://").simplify_path() + return _cached_user_root + + +## True when `path` carries a codepoint that must never reach a filesystem API. +## +## Issue #889: the previous guard built its sentinel with `String.chr(0)`, and +## Godot emits `Unicode parsing error … Unexpected NUL character` while +## *constructing* that string. Every validated call — including reads that then +## failed with "File not found" — printed an alarming engine error that had +## nothing to do with the caller's path. Reading codepoints instead never +## materialises a NUL String, so the noise is gone. +## +## Both codepoints are rejected, for different reasons: +## * `0x0000` — a real embedded NUL can truncate a C string, so the path that +## is validated is not the path that gets opened. Current Godot cannot +## retain one in a String, making this a no-op today; it stays as +## defense-in-depth for any build or input path that can. +## * `0xFFFD` — what current Godot actually substitutes for a NUL or any other +## undecodable input. Once the engine has replaced the byte there is no way +## left to tell an attempted NUL from malformed UTF-8 from a literal +## replacement character, so the only safe reading at a security boundary is +## to refuse all three. A legitimate filename containing U+FFFD is rejected +## too; that false positive is deliberate and vanishingly rare. +static func _contains_invalid_path_codepoint(path: String) -> bool: + for i in path.length(): + var codepoint := path.unicode_at(i) + if codepoint == 0x0000 or codepoint == 0xFFFD: + return true + return false + + +## Returns "" when the path is a safe `res://`-rooted reference inside the +## project root. Returns a human-readable error message otherwise. +## Prefer `path_error` over calling this directly — it wraps the message in the +## canonical error code. +## +## Pass `for_write = true` for any handler that creates/overwrites the file +## (write_file, create_script, patch_script, ResourceSaver-backed saves, +## scene saves). Write callers additionally refuse the project manifest and +## startup override, plus the `.godot/` metadata dir. Reads default to +## `for_write = false`, which permits inspecting those files. +static func validate_resource_path(path: String, for_write: bool = false) -> String: + if path.is_empty(): + return "Missing required param: path" + if _contains_invalid_path_codepoint(path): + return "Path must not contain null bytes or invalid Unicode" + if not path.begins_with("res://"): + return "Path must start with res://" + var confine_err := _confine_under(path, _res_root(), "res://") + if not confine_err.is_empty(): + return confine_err + if for_write: + return _reject_sensitive_write(path) + return "" + + +## Returns "" when `path` is safe to hand to `ResourceLoader.load` / `.exists`. +## Accepts, in addition to confined `res://` paths: +## * `uid://` — an opaque 64-bit resource id; it cannot express a path +## and the engine only ever resolves it to a resource already in the +## project, so there is nothing to confine. +## * `user://…` — the per-project user data dir, confined under its root the +## same way `res://` is (so `user://../…` can't escape the sandbox). +static func validate_loadable_path(path: String) -> String: + if path.is_empty(): + return "Missing required param: path" + if _contains_invalid_path_codepoint(path): + return "Path must not contain null bytes or invalid Unicode" + if path.begins_with("uid://"): + return "" + if path.begins_with("user://"): + return _confine_under(path, _user_root(), "user://") + if path.begins_with("res://"): + return _confine_under(path, _res_root(), "res://") + return "Path must start with res://, uid://, or user://" + + +## Shared traversal + under-root containment. `root` must already be simplified. +static func _confine_under(path: String, root: String, label: String) -> String: + if ".." in path: + return "Path must not contain '..' (path traversal not allowed)" + var globalized := ProjectSettings.globalize_path(path).simplify_path() + # Append a separator so `/proj_evil/...` can't pretend to be inside `/proj` + # via prefix match. `globalized == root` covers the bare `res://` / `user://`. + if globalized != root and not globalized.begins_with(root + "/"): + return "Path must resolve under %s root" % label + return "" + + +## Refuse writes that would clobber project-critical files. The path is already +## confirmed `res://`-rooted and traversal-free by the caller. +## +## Comparisons are case-folded: macOS (APFS) and Windows (NTFS) are +## case-insensitive by default, so `res://Project.godot` resolves to the real +## `project.godot` and must be refused too. +## +## `.import` sidecars are deliberately NOT blocked — editing an asset's import +## options then re-importing is a legitimate, recoverable workflow (the file is +## source-controlled). The blocked set is the startup-execution surface only: +## the manifest, its `override.cfg` shadow, and the `.godot/` cache dir. +static func _reject_sensitive_write(path: String) -> String: + var file_lower := path.get_file().to_lower() + if file_lower == "project.godot": + return "Refusing to write res://project.godot (project manifest)" + if file_lower == "override.cfg": + return "Refusing to write res://override.cfg (startup config override)" + # Reject the `.godot/` editor-metadata dir at any depth. Split drops empty + # segments so a trailing slash can't hide a segment from the check. + var segments := path.trim_prefix("res://").split("/", false) + for segment in segments: + if segment.to_lower() == ".godot": + return "Refusing to write under res://.godot/ (editor metadata)" + # Reject the currently-loaded plugin's own script tree. Overwriting a + # loaded .gd here (plus the immediate reimport triggered by update_file()) + # can SIGABRT the editor mid-call, or silently corrupt the installed + # plugin so the next enable fails to resolve scripts. + if segments.size() >= 2 and segments[0].to_lower() == "addons" and segments[1].to_lower() == "godot_ai": + return "Refusing to write under res://addons/godot_ai/ (overwriting a loaded plugin script can crash the editor)" + return "" + + +## Validate a write/read `res://` path and return a ready error dict, or null +## when the path is fine. The single wrapper every handler should use so the +## error code (VALUE_OUT_OF_RANGE — a bad path is a value-domain error) stays +## consistent. `param_name` is prefixed onto the message for context. +static func path_error(path: String, param_name: String = "path", for_write: bool = false) -> Variant: + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name) + var err := validate_resource_path(path, for_write) + if err.is_empty(): + return null + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err]) + + +## Same as `path_error` but for paths handed to `ResourceLoader` (allows +## `uid://` / `user://`). Returns a ready error dict or null. An empty path is +## reported as MISSING_REQUIRED_PARAM rather than a value error. +static func loadable_error(path: String, param_name: String = "path") -> Variant: + if path.is_empty(): + return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name) + var err := validate_loadable_path(path) + if err.is_empty(): + return null + return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err]) diff --git a/addons/godot_ai/utils/path_validator.gd.uid b/addons/godot_ai/utils/path_validator.gd.uid new file mode 100644 index 0000000..8ed4acf --- /dev/null +++ b/addons/godot_ai/utils/path_validator.gd.uid @@ -0,0 +1 @@ +uid://blxntmd65ljyu diff --git a/addons/godot_ai/utils/port_resolver.gd b/addons/godot_ai/utils/port_resolver.gd new file mode 100644 index 0000000..789cfc6 --- /dev/null +++ b/addons/godot_ai/utils/port_resolver.gd @@ -0,0 +1,370 @@ +@tool +class_name McpPortResolver +extends RefCounted + +## Pure-static port discovery / OS-specific scrapers. No instance state, +## no editor dependencies. plugin.gd has thin instance shims that wrap +## these and increment the cold-start trace counters. + +## Canonical pid-file path. plugin.gd::SERVER_PID_FILE re-exports this so +## external readers and tests can use either name. +const SERVER_PID_FILE := "user://godot_ai_server.pid" +const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") + + +static func can_bind_local_port(port: int) -> bool: + var server := TCPServer.new() + var err := server.listen(port, "127.0.0.1") + if err == OK: + server.stop() + return true + return false + + +## True when `port` is bound on 127.0.0.1. Probes via TCPServer first, +## falls back to OS scraping. Callers that want per-scraper trace +## counters should call `is_port_in_use_via_scrape` with a trace hook +## after their own `can_bind_local_port` probe. +static func is_port_in_use(port: int) -> bool: + if can_bind_local_port(port): + ## On POSIX, an IPv6 wildcard listener can coexist with a + ## successful 127.0.0.1 bind probe. Confirm with lsof so startup + ## sees the same listener set that shutdown/recovery would see. + if OS.get_name() != "Windows": + return is_port_in_use_via_scrape(port) + return false + return is_port_in_use_via_scrape(port) + + +## `trace` mirrors `find_all_pids_on_port`'s hook: one call per OS +## invocation with the counter name of the scraper that actually ran, so +## a wrapping caller's startup trace sees a genuine PowerShell fallback +## as `powershell`, not as a silent extra second under `netstat`. +static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -> bool: + var output: Array = [] + if OS.get_name() == "Windows": + _trace(trace, "netstat") + var exit_code := OS.execute("netstat", ["-ano"], output, true) + if exit_code == 0 and output.size() > 0: + var stdout := str(output[0]) + if parse_windows_netstat_listening(stdout, port): + return true + ## A healthy dump with no listener row IS the answer — don't + ## pay the ~1.2s powershell.exe spawn to confirm "not in use" + ## (see find_all_pids_on_port for the cost rationale). + if windows_netstat_dump_parseable(stdout): + return false + ## Fallback: netstat can be absent or unparseable on + ## stripped/locale-odd Windows installs. + _trace(trace, "powershell") + return not find_listener_pids_windows(port).is_empty() + _trace(trace, "lsof") + var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) + return exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty() + + +## Return the PID currently listening on the given TCP port, or 0 if +## the port is free. Thin convenience wrapper around `find_all_pids_on_port` +## — the per-OS scraping logic lives in one place. +static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int: + var pids := find_all_pids_on_port(port, trace) + return pids[0] if not pids.is_empty() else 0 + + +## Returns every PID bound LISTEN on `port`. Used by the kill paths so +## both the uvicorn reloader parent AND its worker child are caught when +## both bind the same port. +## +## `trace` is an optional Callable that fires once per OS invocation with +## a counter name (`"netstat"` / `"powershell"` / `"lsof"`) so the plugin +## can keep its cold-start trace accurate. The Windows path may fall +## through netstat → PowerShell, and a wrapping caller can't see which +## scraper actually ran without the hook. +static func find_all_pids_on_port(port: int, trace: Callable = Callable()) -> Array[int]: + if OS.get_name() == "Windows": + var output: Array = [] + _trace(trace, "netstat") + var exit_code := OS.execute("netstat", ["-ano"], output, true) + if exit_code == 0 and not output.is_empty(): + var stdout := str(output[0]) + var netstat_pids := parse_windows_netstat_pids(stdout, port) + if not netstat_pids.is_empty(): + return netstat_pids + ## An empty per-port parse from a healthy dump IS the answer + ## ("no listener"). Confirming it through the PowerShell probe + ## costs a powershell.exe spawn (~1.2s measured) against ~30ms + ## for the netstat scrape — two such confirmations dominated a + ## ~7s Windows startup walk. Only fall through when the dump + ## itself is unusable (netstat absent, or so format-odd that + ## zero TCP rows parse). + if windows_netstat_dump_parseable(stdout): + var no_listeners: Array[int] = [] + return no_listeners + _trace(trace, "powershell") + return find_listener_pids_windows(port) + var output: Array = [] + _trace(trace, "lsof") + var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) + if exit_code != 0 or output.is_empty(): + var empty: Array[int] = [] + return empty + return parse_lsof_pids(str(output[0])) + + +static func _trace(trace: Callable, counter: String) -> void: + if trace.is_valid(): + trace.call(counter) + + +static func find_listener_pids_windows(port: int) -> Array[int]: + var script := ( + "Get-NetTCPConnection -LocalPort %d -State Listen " + + "-ErrorAction SilentlyContinue | " + + "Select-Object -ExpandProperty OwningProcess" + ) % port + var output: Array = [] + var exit_code := execute_windows_powershell(script, output) + return windows_listener_pids_from_execute_result(exit_code, output) + + +static func execute_windows_powershell(script: String, output: Array) -> int: + var args := ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script] + for exe in windows_powershell_candidates(): + output.clear() + var exit_code := OS.execute(exe, args, output, true) + if exit_code == 0: + return exit_code + return -1 + + +static func windows_powershell_candidates() -> Array[String]: + var candidates: Array[String] = [] + var system_root := OS.get_environment("SystemRoot") + if system_root.is_empty(): + system_root = "C:/Windows" + system_root = system_root.replace("\\", "/").trim_suffix("/") + candidates.append(system_root + "/System32/WindowsPowerShell/v1.0/powershell.exe") + candidates.append("powershell.exe") + candidates.append("pwsh.exe") + return candidates + + +static func windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]: + var empty: Array[int] = [] + if exit_code == 0 and not output.is_empty(): + return parse_pid_lines(str(output[0])) + return empty + + +static func windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool: + return not windows_listener_pids_from_execute_result(exit_code, output).is_empty() + + +## Pure parser for `lsof -ti` output — newline-separated decimal PIDs. +## Empty lines and non-numeric tokens are dropped. Duplicates pass +## through (uvicorn reloader + worker can produce the same PID twice +## across runs but typically two distinct PIDs). +static func parse_lsof_pids(raw: String) -> Array[int]: + var pids: Array[int] = [] + for line in raw.strip_edges().split("\n", false): + var stripped := line.strip_edges() + if stripped.is_valid_int(): + pids.append(int(stripped)) + return pids + + +static func parse_pid_lines(raw: String) -> Array[int]: + var pids: Array[int] = [] + for line in raw.strip_edges().split("\n", false): + var stripped := line.strip_edges() + if stripped.is_valid_int(): + var pid := int(stripped) + if pid > 0 and not pids.has(pid): + pids.append(pid) + return pids + + +## Parse a Windows `netstat -ano` dump and return PIDs of rows whose +## local address ends with `:port` AND state is `LISTENING`. Substring +## matching the whole dump is wrong: a remote address containing +## `:port` would false-positive against an unrelated ESTABLISHED row. +static func parse_windows_netstat_pid(stdout: String, port: int) -> int: + var pids := parse_windows_netstat_pids(stdout, port) + return pids[0] if not pids.is_empty() else 0 + + +static func parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]: + var pids: Array[int] = [] + var port_suffix := ":%d" % port + for line in stdout.split("\n"): + var s := line.strip_edges() + if s.is_empty(): + continue + var fields := split_on_whitespace(s) + if fields.size() < 5: # proto, local, remote, state, pid + continue + ## Locale-independent listener signal (mirrors script/_dev_env.py): + ## the state column is localized ("LISTENING"/"ABHÖREN"/"ÉCOUTE"...), + ## but a listener's FOREIGN address is always the wildcard ":0". + if not fields[2].ends_with(":0"): + continue + if not fields[1].ends_with(port_suffix): + continue + var pid_str := fields[fields.size() - 1] + if pid_str.is_valid_int(): + var pid := int(pid_str) + if pid > 0 and not pids.has(pid): + pids.append(pid) + return pids + + +static func parse_windows_netstat_listening(stdout: String, port: int) -> bool: + return parse_windows_netstat_pid(stdout, port) > 0 + + +## True when `stdout` looks like a healthy `netstat -ano` dump: at least +## one row parses as a TCP connection (proto column literally "TCP", an +## address containing ":", an integer PID in the last column). Locale- +## independent — protocol names are never localized, unlike the state +## column. Gates whether an empty per-port parse can be trusted as "no +## listener": a live Windows host always carries TCP rows (svchost/RPC +## listen on 135 at minimum), so a dump with zero parseable rows means +## netstat itself is absent/broken and the PowerShell fallback must run. +static func windows_netstat_dump_parseable(stdout: String) -> bool: + for line in stdout.split("\n"): + var fields := split_on_whitespace(line.strip_edges()) + if fields.size() < 5: + continue + if fields[0].to_upper() != "TCP": + continue + if fields[1].find(":") < 0: + continue + if fields[fields.size() - 1].is_valid_int(): + return true + return false + + +## `String.split(" ", false)` only splits on single spaces; netstat +## columns are separated by runs of spaces / tabs. Collapse manually. +static func split_on_whitespace(s: String) -> PackedStringArray: + var out: PackedStringArray = [] + var cur := "" + for i in s.length(): + var c := s.substr(i, 1) + if c == " " or c == "\t": + if not cur.is_empty(): + out.append(cur) + cur = "" + else: + cur += c + if not cur.is_empty(): + out.append(cur) + return out + + +static func read_pid_file() -> int: + if not FileAccess.file_exists(SERVER_PID_FILE): + return 0 + var f := FileAccess.open(SERVER_PID_FILE, FileAccess.READ) + if f == null: + return 0 + var content := f.get_as_text().strip_edges() + f.close() + if content.is_empty() or not content.is_valid_int(): + return 0 + var pid := int(content) + return pid if pid > 0 else 0 + + +static func clear_pid_file() -> void: + if FileAccess.file_exists(SERVER_PID_FILE): + DirAccess.remove_absolute(ProjectSettings.globalize_path(SERVER_PID_FILE)) + + +## `kill -0` returns 0 for both running and zombie processes; Godot +## never `waitpid`s on `OS.create_process` children, so a fast-failing +## uvx launcher lingers as a zombie forever and `kill -0` would block +## the spawn-failure branch in check_server_health from firing. Use +## `ps -o stat=` instead. State codes: R/S/D/I/T (live), Z (zombie). #172. +static func pid_alive(pid: int) -> bool: + if pid <= 0: + return false + if OS.get_name() == "Windows": + var output: Array = [] + var exit_code := OS.execute("tasklist", ["/FI", "PID eq %d" % pid, "/NH", "/FO", "CSV"], output, true) + if exit_code != 0 or output.is_empty(): + return false + for line in output: + if str(line).find("\"%d\"" % pid) >= 0: + return true + return false + var output: Array = [] + var exit_code := OS.execute("ps", ["-p", str(pid), "-o", "stat="], output, true) + if exit_code != 0 or output.is_empty(): + return false + var stat := str(output[0]).strip_edges() + return not stat.is_empty() and not stat.begins_with("Z") + + +## Poll until the given port is no longer bound, or the timeout elapses. +## Used after `OS.kill` so we don't race the port-in-use check on rebind. +## NOTE: plugin.gd::_wait_for_port_free (and _is_port_in_use) is a +## deliberate line-for-line fork of this pair kept for _ProofPlugin +## isolation — keep the two in sync when editing either. +static func wait_for_port_free(port: int, timeout_s: float) -> void: + var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0) + while is_port_in_use(port): + if Time.get_ticks_msec() >= deadline: + push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s]) + return + OS.delay_msec(100) + + +## Choose a non-Windows-reserved WS port. Returns `configured` when free; +## otherwise the first non-excluded port within `span` of it. Optional +## `log_buffer` is a duck-typed sink (`log(String)`) that gets the +## remap notice so users see why the port shifted. +static func resolve_ws_port(configured: int, max_port: int, log_buffer = null) -> int: + var resolved := WindowsPortReservation.suggest_non_excluded_port( + configured, + 2048, + max_port + ) + if resolved != configured: + var message := "WebSocket port %d is reserved by Windows; using %d" % [configured, resolved] + print("MCP | %s" % message) + if log_buffer != null: + log_buffer.log(message) + return resolved + + +## Trust the cached ws_port from the managed record only when the record +## is current ownership proof — i.e. record version matches the installed +## plugin. Otherwise a stale record from an older install (e.g. a 9500 +## value pre-Windows-reservation collision) would mislead the +## compatibility check into killing an unrelated external process. #259. +static func resolved_ws_port_for_existing_server( + record_ws_port: int, + record_version: String, + current_version: String, + fresh_resolved: int +) -> int: + if record_ws_port <= 0: + return fresh_resolved + if current_version.is_empty() or record_version != current_version: + return fresh_resolved + return record_ws_port + + +static func resolve_ws_port_from_output( + configured_port: int, + netsh_output: String, + max_port: int, + span: int = 2048 +) -> int: + return WindowsPortReservation.suggest_non_excluded_port_from_output( + netsh_output, + configured_port, + span, + max_port + ) diff --git a/addons/godot_ai/utils/port_resolver.gd.uid b/addons/godot_ai/utils/port_resolver.gd.uid new file mode 100644 index 0000000..54a3d73 --- /dev/null +++ b/addons/godot_ai/utils/port_resolver.gd.uid @@ -0,0 +1 @@ +uid://pk0212qfh61x diff --git a/addons/godot_ai/utils/resource_io.gd b/addons/godot_ai/utils/resource_io.gd new file mode 100644 index 0000000..cfac90f --- /dev/null +++ b/addons/godot_ai/utils/resource_io.gd @@ -0,0 +1,277 @@ +@tool +class_name McpResourceIO +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Shared helpers for "save a Resource to .tres" and the mutually-exclusive +## path-vs-resource_path param validation that every resource-authoring +## handler needs. Extracted to remove 4-way duplication across +## resource_handler, environment_handler, texture_handler, and curve_handler. +## Also home to the shared write-a-text-file path + deferred import-settle +## completion used by both script_handler.create_script and +## filesystem_handler.write_file (#714). + +# Bounded settle window for `ResourceLoader.exists(path)` after a fresh text +# write registers with the filesystem, so an agent calling +# create_script/write_file -> attach_script back-to-back doesn't race the +# editor's import pipeline (#261, extended to write_file by #714). Polled once +# per frame, with an elapsed-time cap below the dispatcher's deferred timeouts +# for both commands. If import is still not visible at the cap, we still +# return committed data instead of letting the already-written file surface +# as DEFERRED_TIMEOUT. +const IMPORT_SETTLE_MAX_FRAMES := 300 +const IMPORT_SETTLE_MAX_MSEC := 3500 + + +## Validate that exactly one of {path, resource_path} is provided. +## +## When `require_property` is true (default), also requires a non-empty +## `property` param when `path` is given — this matches the semantics of +## "assign a resource to node.property" (resource_create, texture tools, +## curve_set_points). Pass false for tools where the path itself IS the +## target (environment_create assigning to WorldEnvironment.environment). +## +## Returns null on success or an error dict on failure. +static func validate_home(params: Dictionary, require_property: bool = true) -> Variant: + var node_path: String = params.get("path", "") + var property: String = params.get("property", "") + var resource_path: String = params.get("resource_path", "") + var has_node_target := not node_path.is_empty() + var has_file_target := not resource_path.is_empty() + + if has_node_target and has_file_target: + var both_msg := "Provide either path+property or resource_path, not both" if require_property else "Provide either path or resource_path, not both" + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, both_msg) + if not has_node_target and not has_file_target: + var none_msg := "Must provide either path+property (assign inline) or resource_path (save .tres)" if require_property else "Must provide either path or resource_path" + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, none_msg) + if require_property and has_node_target and property.is_empty(): + return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Missing required param: property (required when path is given)") + return null + + +## Save `res` to `resource_path` as a .tres/.res file. +## +## Handles: res:// prefix validation, overwrite check, parent-directory +## creation, ResourceSaver.save error reporting, and the post-save +## EditorFileSystem.update_file() so the dock picks up the change. +## +## `label` is the human-readable resource-kind for error messages (e.g. +## "Environment", "Gradient texture", "Curve"). `extra_fields` is merged +## into the success response alongside the standard fields +## (`resource_path`, `overwritten`, `undoable: false`, `reason`). Passing +## a `reason` key in `extra_fields` overrides the default — useful for +## tools that edit existing files rather than creating fresh ones. +## +## `pause_target` should be the handler's `McpConnection`. When supplied, +## `pause_processing` is flipped on around `ResourceSaver.save()` so the +## dispatcher's WebSocket pump can't re-enter while Godot pumps +## `Main::iteration()` for the resource-save's progress UI / script-class +## update task. Without this guard a queued command landing during the +## save can trigger another `save_to_disk` that tries to add the same +## `update_scripts_classes` editor task — "Task already exists" → null +## deref → SIGSEGV. Same family of bug as godotengine/godot#118545 and +## the same mitigation as `SceneHandler`'s `save_scene*` wraps. See +## issue #288. +## +## Returns either an error dict or a {"data": {...}} success dict — ready +## for the handler to return directly. +static func save_to_disk( + res: Resource, + resource_path: String, + overwrite: bool, + label: String, + extra_fields: Dictionary = {}, + pause_target: McpConnection = null, +) -> Dictionary: + var path_err = McpPathValidator.path_error(resource_path, "resource_path", true) + if path_err != null: + return path_err + + var existed_before := FileAccess.file_exists(resource_path) + if existed_before and not overwrite: + return ErrorCodes.make( + ErrorCodes.INVALID_PARAMS, + "%s already exists at %s (pass overwrite=true to replace)" % [label, resource_path] + ) + # Captured BEFORE the overwrite below so a resave of an already-uid'd file + # (overwrite=true) can restore its own uid instead of losing it — see + # ensure_uid's doc comment. + var prior_uid := ResourceLoader.get_resource_uid(resource_path) if existed_before else ResourceUID.INVALID_ID + + var dir_path := resource_path.get_base_dir() + var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path) + if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to create directory %s: %s" % [dir_path, error_string(mkdir_err)] + ) + + if pause_target != null: + pause_target.pause_processing = true + var save_err := ResourceSaver.save(res, resource_path) + if pause_target != null: + pause_target.pause_processing = false + if save_err != OK: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Failed to save %s to %s: %s" % [label, resource_path, error_string(save_err)] + ) + var uid_err := ensure_uid(resource_path, prior_uid) + if uid_err != OK: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "%s saved to %s but failed to write its uid: %s" % [label, resource_path, error_string(uid_err)] + ) + + var efs := EditorInterface.get_resource_filesystem() + if efs != null: + efs.update_file(resource_path) + + var data := { + "resource_path": resource_path, + "overwritten": existed_before, + "undoable": false, + "reason": "File creation is persistent; delete the file manually to revert", + } + attach_cleanup_hint(data, existed_before, [resource_path]) + # merge with overwrite=true so callers (e.g. curve_set_points editing an + # existing .tres) can supply a domain-specific `reason`. + data.merge(extra_fields, true) + return {"data": data} + + +## Save `res` to `resource_path` with the same `pause_processing` re-entrancy +## guard as `save_to_disk` (see its doc for the #288 SIGSEGV background), for +## call sites that need to pick their own error handling / overwrite policy +## instead of `save_to_disk`'s full validate+mkdir+overwrite-guard bundle +## (undo/redo callables reloading-mutating-resaving an existing resource, +## `apply_to_node`'s inline-then-save branch). Returns the raw +## `ResourceSaver.save` error code. +static func guarded_save(res: Resource, resource_path: String, pause_target: McpConnection) -> int: + var prior_uid := ResourceLoader.get_resource_uid(resource_path) if FileAccess.file_exists(resource_path) else ResourceUID.INVALID_ID + if pause_target != null: + pause_target.pause_processing = true + var save_err := ResourceSaver.save(res, resource_path) + if pause_target != null: + pause_target.pause_processing = false + if save_err != OK: + return save_err + return ensure_uid(resource_path, prior_uid) + + +## Make `resource_path` carry a stable uid after a successful +## `ResourceSaver.save()`, matching what Godot's own "New Scene"/"New +## Resource" editor flows always embed. A bare `ResourceSaver.save()` call +## does neither on its own: a brand-new file gets no `uid=` at all, and +## resaving a file that already had one silently drops it (#737). Call this +## immediately after every successful save. +## +## `prior_uid` is whatever `ResourceLoader.get_resource_uid(resource_path)` +## returned BEFORE this save overwrote the file (pass `ResourceUID.INVALID_ID` +## for a brand-new path). Reusing the prior id — instead of always minting a +## fresh one — keeps any `uid://...` references elsewhere in the project +## resolving to the same file. +## +## Returns the `Error` from `ResourceSaver.set_uid()` so callers can surface a +## uid-write failure instead of silently reporting success on a file that +## didn't end up with the uid it was supposed to get. +static func ensure_uid(resource_path: String, prior_uid: int) -> Error: + var id := prior_uid + if id == ResourceUID.INVALID_ID: + id = ResourceUID.create_id() + return ResourceSaver.set_uid(resource_path, id) + + +## Attach a `cleanup.rm` hint listing `paths` to `data` — only when the call +## just created a new file (`existed_before == false`). On overwrite the field +## is omitted because the caller already had the file on disk, and handing +## them a cleanup list would invite dropping user content instead of just +## scratch artifacts. Used by write-and-return handlers (create_script, +## filesystem_write_text, resource_create/save_to_disk) so callers running +## transient smoke tests can rm artifacts without tracking paths. See #82. +static func attach_cleanup_hint(data: Dictionary, existed_before: bool, paths: Array) -> void: + if existed_before: + return + data["cleanup"] = {"rm": paths} + + +## Shared write-a-text-file path (#714): parent-directory mkdir, write + +## flush with an explicit error check so a truncated write (disk full, +## permission flip mid-write) surfaces as an error instead of plain success. +## Deliberately does NOT call `EditorFileSystem.update_file()` — callers +## register the file themselves after assembling their response fields, so +## the registration comment (the dsarno/godot#6 scan-stacking rationale) +## stays next to the call. Returns null on success or an error dict ready +## to return from the handler. +static func write_text_to_disk(path: String, content: String) -> Variant: + var dir_path := path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + var err := DirAccess.make_dir_recursive_absolute(dir_path) + if err != OK: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path) + + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file for writing: %s" % path) + + file.store_string(content) + file.flush() + var write_err := file.get_error() + file.close() + if write_err != OK: + return ErrorCodes.make( + ErrorCodes.INTERNAL_ERROR, + "Write failed for %s (%s); file may be truncated" % [path, error_string(write_err)] + ) + return null + + +# `static` is load-bearing: the deferred completion captures no `self`, so the +# coroutine survives even if the calling handler RefCounted is freed mid-await. +# Under concurrent create storms with editor_reload_plugin fired during the +# burst, an instance-method coroutine is otherwise GC'd between `await` and +# resume, producing "Resumed function ... after await, but class instance is +# gone" errors and dropping the response. Keep this function static and +# parameterise everything it needs explicitly — do not reference instance +# state. Shared by create_script and write_file's fresh-`.gd` path (#714). +static func finish_text_write_deferred( + connection: McpConnection, + request_id: String, + path: String, + data: Dictionary, +) -> void: + if not is_instance_valid(connection): + return + var tree := connection.get_tree() + if tree == null: + return + var deadline_ms := Time.get_ticks_msec() + IMPORT_SETTLE_MAX_MSEC + # Let _dispatch() return DEFERRED_RESPONSE and register the request before + # this coroutine can send a committed result. ResourceLoader.exists(path) + # may already be true on fast imports; without this handoff the connection + # treats the response as late/unregistered and drops it, then the dispatcher + # times out a file that was already written (#324). The deadline starts + # before this await so a slow handoff frame is counted against the bounded + # settle window. + await tree.process_frame + var frames := 0 + while ( + frames < IMPORT_SETTLE_MAX_FRAMES + and Time.get_ticks_msec() < deadline_ms + and not ResourceLoader.exists(path) + ): + await tree.process_frame + frames += 1 + # If the plugin tears down (_exit_tree frees the connection) during the + # await, is_instance_valid() goes false and we drop the response silently — + # the server's request timeout will surface the failure to the caller. + if not is_instance_valid(connection): + return + var payload := data.duplicate() + var settled := ResourceLoader.exists(path) + payload["import_settled"] = settled + payload["import_settle"] = "settled" if settled else "timeout" + payload["import_pending"] = not settled + connection.send_deferred_response(request_id, {"data": payload}) diff --git a/addons/godot_ai/utils/resource_io.gd.uid b/addons/godot_ai/utils/resource_io.gd.uid new file mode 100644 index 0000000..94da4c9 --- /dev/null +++ b/addons/godot_ai/utils/resource_io.gd.uid @@ -0,0 +1 @@ +uid://de2rwdoa4wabf diff --git a/addons/godot_ai/utils/scene_path.gd b/addons/godot_ai/utils/scene_path.gd new file mode 100644 index 0000000..782aea1 --- /dev/null +++ b/addons/godot_ai/utils/scene_path.gd @@ -0,0 +1,155 @@ +@tool +class_name McpScenePath +extends RefCounted + +const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") + +## Utility for converting between Godot internal node paths and clean +## scene-relative paths like /Main/Camera3D. + + +## Return a clean path relative to the scene root (e.g. /Main/Camera3D). +## Returns "" when `node` is not the scene root or a descendant of it — +## without the ancestry guard, get_path_to() returns an empty NodePath that +## concatenates into a plausible-looking but invalid "/Main/". +static func from_node(node: Node, scene_root: Node) -> String: + if scene_root == null or node == null: + return "" + if node == scene_root: + return "/" + scene_root.name + if not scene_root.is_ancestor_of(node): + return "" + var relative := scene_root.get_path_to(node) + return "/" + scene_root.name + "/" + str(relative) + + +## Resolve a clean scene path like "/Main/Camera3D" to the actual node. +## +## Accepts forms relative to the edited scene root: +## "/Main" — explicit root prefix (canonical) +## "/Main/Camera3D" — descendant path +## "Camera3D" — bare relative to scene_root +## "World/Ground" — nested bare relative to scene_root +## +## Also accepts SceneTree-style "/root/[/...]" as an alias for +## the edited scene root. Agents reach for /root/Foo right after creating a +## scene because that's where scenes live at runtime; we honor it so the call +## doesn't fail with a confusing "not found" error. The alias only kicks in +## when the segment after /root matches the scene root's name — paths like +## "/root/@EditorNode@.../Main/..." (returned by Node.get_path() in the editor) +## fall through to the absolute-path fallback unchanged. +static func resolve(scene_path: String, scene_root: Node) -> Node: + if scene_root == null: + return null + + ## Bare "/" alias: the most natural first guess for "the scene root". + ## There is exactly one edited-scene root, so the alias is unambiguous; + ## without it, "/" falls through to get_node_or_null("/") → null and + ## every call costs the agent a NODE_NOT_FOUND round trip (issue #624). + if scene_path == "/": + return scene_root + + ## /root/[/...] alias: strip the /root prefix and recurse. + ## Match the scene root by name explicitly so we don't capture editor- + ## internal paths that legitimately live under /root. + var alias_prefix := "/root/" + scene_root.name + if scene_path == alias_prefix or scene_path.begins_with(alias_prefix + "/"): + return resolve(scene_path.substr(5), scene_root) # keep leading slash + + var root_prefix := "/" + scene_root.name + if scene_path == root_prefix: + return scene_root + if scene_path.begins_with(root_prefix + "/"): + var relative := scene_path.substr(root_prefix.length() + 1) + return scene_root.get_node_or_null(relative) + + # Try as-is (relative path, or absolute SceneTree path). + return scene_root.get_node_or_null(scene_path) + + +## Return the edited scene root, or an error dict if the editor has no open +## scene or the open scene doesn't match `expected_scene_file`. +## +## `expected_scene_file` is the caller's `scene_file` parameter — an empty +## string means "target whatever is currently edited" (current behaviour, +## no guard). A non-empty value must match `scene_file_path` on the current +## edited scene root exactly, or we return EDITED_SCENE_MISMATCH so the +## caller can re-open the right scene. +## +## Shape on success: {"node": }. Shape on error matches +## `ErrorCodes.make()` so callers can propagate the result directly. +static func require_edited_scene(expected_scene_file: String) -> Dictionary: + var root := EditorInterface.get_edited_scene_root() + if root == null: + # Mirrors the structured payload that the Python-side require_writable + # gate attaches for `playing` / `importing`. Together these cover the + # three recoverable editor *states* (playing / importing / no_scene) + # — the EDITOR_NOT_READY paths an AI caller can act on. Other + # EDITOR_NOT_READY callsites describing internal-state failures + # ("EditorFileSystem not available" etc.) carry sub_code + retryable + # via ErrorCodes.make_not_ready (#651 stage 1) but intentionally + # omit the hint — there's no useful caller action to name. + var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No scene open") + err["error"]["data"] = { + "sub_code": ErrorCodes.SUB_EDITOR_NO_SCENE, + "editor_state": "no_scene", + "retryable": false, + "hint": ( + "No scene is open. Call scene_open with a scene path " + + "(e.g. \"res://main.tscn\") before issuing scene-mutating tools." + ), + } + return err + if not expected_scene_file.is_empty() and root.scene_file_path != expected_scene_file: + var actual := root.scene_file_path if not root.scene_file_path.is_empty() else "" + return ErrorCodes.make( + ErrorCodes.EDITED_SCENE_MISMATCH, + ( + "Expected edited scene \"%s\" but \"%s\" is active. " + + "Call scene_open(\"%s\") first, or omit scene_file to target the active scene." + ) % [expected_scene_file, actual, expected_scene_file], + ) + return {"node": root} + + +## Format a "parent not found" error that names the path convention. +## Agents routinely try /root/Foo or absolute SceneTree paths; the bare +## "Parent not found: X" gave them no hint that paths are scene-relative. +## Wording is generic ("Paths are relative...") so the helper works for any +## param name (parent_path, new_parent, …). +static func format_parent_error(path: String, scene_root: Node) -> String: + if scene_root == null: + return "Parent not found: %s. No edited scene is open." % path + var root_name := str(scene_root.name) + return "Parent not found: %s. Paths are relative to the edited scene root (e.g. \"/%s\" or \"\"), not the SceneTree. Scene root is \"/%s\"." % [path, root_name, root_name] + + +## Format a "node not found" error that names the path convention and, when +## possible, suggests a corrected path. Agents routinely pass /root/Foo +## (runtime SceneTree) or unprefixed names; the bare "Node not found: X" +## gives no hint that paths are edited-scene-relative. +## +## Suggestion logic (highest-confidence first): +## 1. /root/[/...] where is not the scene root → suggest //[/...] +## 2. path doesn't start with "/" → suggest "//" +## 3. otherwise no concrete "did you mean", just the convention reminder. +static func format_node_error(path: String, scene_root: Node) -> String: + if scene_root == null: + return "Node not found: %s. No edited scene is open." % path + var root_name := str(scene_root.name) + var suggestion := "" + + if path.begins_with("/root/"): + var after_root := path.substr(6) # "/root/" is 6 chars + # Only suggest if the segment after /root/ isn't already the scene root + # (resolve() handles /root//... as an alias, so a failure + # with that prefix means a deeper segment is wrong — no clean rewrite). + var first_seg := after_root.split("/")[0] + if first_seg != root_name and not first_seg.is_empty(): + suggestion = "/" + root_name + "/" + after_root + elif not path.begins_with("/") and not path.is_empty(): + suggestion = "/" + root_name + "/" + path + + if suggestion.is_empty(): + return "Node not found: %s. Paths are relative to the edited scene root (e.g. \"/%s/Child\"), not runtime /root/... paths. Scene root is \"/%s\"." % [path, root_name, root_name] + return "Node not found: %s. Did you mean \"%s\"? Paths are relative to the edited scene root, not runtime /root/... paths. Scene root is \"/%s\"." % [path, suggestion, root_name] diff --git a/addons/godot_ai/utils/scene_path.gd.uid b/addons/godot_ai/utils/scene_path.gd.uid new file mode 100644 index 0000000..85795a9 --- /dev/null +++ b/addons/godot_ai/utils/scene_path.gd.uid @@ -0,0 +1 @@ +uid://c1irdrss0amex diff --git a/addons/godot_ai/utils/screenshot_encode.gd b/addons/godot_ai/utils/screenshot_encode.gd new file mode 100644 index 0000000..e3b0210 --- /dev/null +++ b/addons/godot_ai/utils/screenshot_encode.gd @@ -0,0 +1,38 @@ +@tool +class_name McpScreenshotEncode +extends RefCounted + +## Shared downscale + PNG + base64 block for the screenshot paths (#716). +## +## Two call sites straddle the editor/game process boundary — the editor's +## take_screenshot (editor_handler) and the game-process autoload +## (runtime/game_helper) — and were maintained as manually synchronized +## copies. Pure static, no editor APIs, so it loads safely in the game +## process too. + + +## Downscale `image` in place so its longest edge is at most +## `max_resolution` (0 = no cap), then PNG-encode. Returns +## {base64, width, height, original_width, original_height}. +static func downscale_and_encode(image: Image, max_resolution: int) -> Dictionary: + var original_width := image.get_width() + var original_height := image.get_height() + + if max_resolution > 0: + var longest := maxi(original_width, original_height) + if longest > max_resolution: + var scale := float(max_resolution) / float(longest) + ## Clamp to 1px min: extreme aspect ratios at very small + ## max_resolution could otherwise compute a zero dimension and + ## crash image.resize(). + var new_w := maxi(1, int(original_width * scale)) + var new_h := maxi(1, int(original_height * scale)) + image.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS) + + return { + "base64": Marshalls.raw_to_base64(image.save_png_to_buffer()), + "width": image.get_width(), + "height": image.get_height(), + "original_width": original_width, + "original_height": original_height, + } diff --git a/addons/godot_ai/utils/screenshot_encode.gd.uid b/addons/godot_ai/utils/screenshot_encode.gd.uid new file mode 100644 index 0000000..0bcdac2 --- /dev/null +++ b/addons/godot_ai/utils/screenshot_encode.gd.uid @@ -0,0 +1 @@ +uid://cl0xhoxwsbmow diff --git a/addons/godot_ai/utils/server_lifecycle.gd b/addons/godot_ai/utils/server_lifecycle.gd new file mode 100644 index 0000000..2abc497 --- /dev/null +++ b/addons/godot_ai/utils/server_lifecycle.gd @@ -0,0 +1,2197 @@ +@tool +class_name McpServerLifecycleManager +extends RefCounted + +## Server spawn / stop / respawn / adopt / recover orchestration plus the +## update-reload handoff. Owns the server-state machine +## (`McpServerState`), version-check seam (`McpServerVersionCheck`), +## adoption metadata, and connection-blocked / dev-mismatch flags. +## +## State previously lived on plugin.gd; PR 6 (#297) moved it here so +## PR 7 (UpdateManager extraction) can absorb the same encapsulation +## pattern. The plugin still owns the physical editor surfaces +## (Connection, Dock, Timer, EditorSettings I/O) and exposes them via +## `_host.()` shims; the test fixtures override those shims to +## drive the manager without touching the editor. +## +## `_host` is untyped to honor the self-update field-storage policy +## plugin.gd calls out near `_connection`. +var _host + +const UvCacheCleanup := preload("res://addons/godot_ai/utils/uv_cache_cleanup.gd") +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") +const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") +const McpServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") +const McpStartupPathScript := preload("res://addons/godot_ai/utils/mcp_startup_path.gd") +const McpAdoptionLabelScript := preload("res://addons/godot_ai/utils/mcp_adoption_label.gd") +const McpServerVersionCheckScript := preload("res://addons/godot_ai/utils/server_version_check.gd") + +# ---- State (owned here, was on plugin.gd through PR 5) --------------- + +## Single source of truth for the server-spawn/adopt/version lifecycle. +## See `McpServerState` for the transition table. +var _server_state: int = McpServerStateScript.UNINITIALIZED + +## OS-level state populated only when WE spawned the process. +var _server_pid: int = -1 +## keep_server_on_exit (#800): whether the RUNNING server was launched with +## the keep-alive env opt-outs (no owner pid, NO_IDLE_EXIT staged). Editor +## teardown routes on this, never on the live setting — a server spawned +## without the opt-outs must die with the editor even if the user enabled +## the setting mid-session, or the owner-PID watchdog reaps it seconds +## later and the preserved record goes stale (the #774 scenario). Set at +## spawn, recovered from the managed-server record on adoption. +var _server_keep_alive := false +var _server_spawn_ms: int = 0 +var _server_exit_ms: int = 0 +## Elapsed-since-spawn when the pid-file first appeared. The warm watch window +## starts here, not at process creation: a cold uvx download may consume most +## of the cold-start budget before Python starts and publishes the file. +var _server_pid_published_elapsed_ms: int = 0 +## Elapsed-since-spawn at the first watch tick that saw the spawn PID dead, or +## 0 when it is alive / has been healed onto the real PID. Only meaningful +## while a Windows trampoline handoff is being waited out (#797): it preserves +## the true exit time so a diagnosis raised after the wait still reports when +## the process actually died. Reset per spawn alongside `_server_spawn_ms`. +var _spawn_dead_since_ms: int = 0 + +## Version metadata. `expected_version` is what the plugin shipped with; +## `actual_version` is what the live server reported via handshake_ack. +var _server_expected_version: String = "" +var _server_actual_version: String = "" +var _server_actual_name: String = "" + +## Diagnostic + recovery flags surfaced to the dock via `get_status()`. +var _server_status_message: String = "" +## #647: when a post-crash probe pins the failure on a specific port held +## by a foreign process, this names that port (HTTP or WS) so the dock's +## status line and port-picker gating don't blame the wrong one. Zero when +## no conflict was diagnosed. +var _conflict_port: int = 0 +var _can_recover_incompatible: bool = false +var _connection_blocked: bool = false + +## One-shot guard for the stale-uvx-index recovery (#172). Reset at the +## top of `start_server` so each fresh spawn attempt gets its own +## refresh budget. +var _refresh_retried: bool = false + +## One-shot guard for the spawn-lost-port-race re-adoption (see +## `_diagnose_spawn_fast_exit`). #805: the budget is per RECOVERY, not per +## walk — a walk the re-adopt arm itself triggered must NOT refresh it +## (`_readopt_walk_pending` skips the top-of-walk reset), or a flapping +## godot-ai occupant sustains spawn → fast-exit → re-walk forever. The +## budget refreshes on the paths that prove recovery: a fresh +## user/plugin-initiated walk, a successful `adopt_compatible_server`, +## or a spawn that survives to publish its pid-file. +var _readopt_after_spawn_exit_retried: bool = false + +## #805: set by the re-adopt arm just before it re-runs `start_server`, +## consumed by the top of `_start_server_impl` to skip that walk's +## `_readopt_after_spawn_exit_retried` reset. Never true outside that +## one triggered walk. +var _readopt_walk_pending: bool = false + +## Rounds of weak-proof (`status_name`-tier) recovery available against a +## brand-verified stale-VERSION godot-ai occupant. Zero by default: the +## startup walk may only kill via strong ownership proof. Armed by an +## explicit user action that already authorizes replacing the old backend — +## the post-self-update startup (`authorize_stale_recovery` from plugin.gd, +## consented by the Update click) and the dock's recovery click. This is +## what lets the walk finish an update against an attach-bridge-owned old +## server: bridges pinned to the previous version respawn it the moment it +## is killed, and each respawn is an external server with no strong proof, +## so without this budget every lost port race dead-ends in INCOMPATIBLE +## and the user is left toggling the plugin by hand. Never spent on a +## same-version or foreign occupant; each spent round re-proves brand at +## kill time. Reset on the proven recoveries — adoption and the verified +## compatible handshake — so leftover rounds can't authorize a kill in a +## later, unrelated episode. Pid-file publication deliberately does NOT +## reset it: the server writes that file before binding either port, so a +## bridge respawn can still steal the bind afterwards and the fast-exit +## needs its remaining rounds (#890 review P1). +var _stale_recovery_budget: int = 0 + +## Single owner for kill/drain/respawn recovery. Startup probing and the +## WebSocket version verdict observe the same backend from different async +## paths; a retry budget bounds how often they may act, but does not stop both +## paths acting at once. Keep one recovery transaction in flight and coalesce +## stale handshake verdicts into it so an older continuation cannot kill the +## fresh server that another continuation just spawned. +var _recovery_in_flight: bool = false +## Generation-scoped capability for the startup walk spawned by +## `_recover_incompatible_server_impl`. The outer recovery keeps +## `_recovery_in_flight` armed so handshakes and unrelated recovery calls still +## coalesce, while this token lets only that exact owned walk spend another +## bounded stale-recovery round if an attach bridge reclaims the port after the +## kill. A later walk created by invalidation gets a different generation and +## cannot inherit the authority. +var _recovery_owned_startup_generation: int = -1 + +## Bounded deadline for the foreign-port adoption-confirmation watcher. +## Zero when disarmed. +var _adoption_watch_deadline_ms: int = 0 + +## Branch-tag from the most recent `start_server` walk. See +## `McpStartupPath`. Drives the startup-trace log. +var _startup_path: String = McpStartupPathScript.UNSET + +## Version-check seam. Lazily constructed on `arm_version_check` so +## tests that exercise the manager without a connection don't have to +## stub it out. +var _version_check + +## #678: when true, the blocking primitives on the startup path (port +## scrapes, per-PID brand shells, the HTTP status probe, kill + port-drain +## waits) run on a WorkerThreadPool thread while the main thread keeps +## pumping frames — the editor stays responsive during plugin init/reload +## on a contended port; the dock panel just arrives a beat later. The +## plugin enables this in production. Default false: unit tests (and any +## legacy caller) keep the historical fully-synchronous behavior, where +## the startup coroutines never actually suspend and call-then-assert +## still works. +var defer_blocking_work: bool = false + +## Cancellation for in-flight async startup work: bumped by `stop_server` +## (and therefore by `_exit_tree` and update-reload prep), checked after +## every await so a suspended `start_server` can't resurrect state — or +## spawn a server — after teardown started. +var _async_generation: int = 0 + +## Re-entrancy guard: with startup a coroutine, a second `start_server` +## call (respawn watch, dock button) can land mid-flight. +var _start_in_flight: bool = false + + +func _init(host) -> void: + _host = host + + +## The worker thread of the walk's current `_run_blocking` call, while it +## runs. `_invalidate_async_startup` JOINS it (bounded by the blocking +## op's own timeout) so no worker can still be executing a plugin method +## when `_exit_tree` frees the plugin — a mid-call free is use-after-free +## on the worker, which wedged the editor on macOS during rapid reload +## churn (main CI, post-#682). Null when no blocking work is in flight. +var _active_blocking_thread: Thread = null + + +## Run `work` off the main thread and suspend until it completes (#678). +## Falls back to inline execution when `defer_blocking_work` is off, or +## when no SceneTree is available to pump frames against. +## +## Uses a dedicated Thread (the dock's #238/#239 worker pattern) rather +## than WorkerThreadPool: `wait_to_finish()` hands the return value back +## without a shared mutable container, and this plugin has already seen +## WorkerThreadPool tasks SIGABRT under concurrency (see the notes in +## script_handler.gd / filesystem_handler.gd). `wait_to_finish` after +## `is_alive()` goes false joins an already-dead thread, so it never +## blocks the main thread. +## +## Returns null (without joining) when `_invalidate_async_startup` took +## ownership of the thread mid-flight — the walk is stale at that point +## and must bail. Callers therefore assign the result to an untyped +## local and bail on `_async_stale(...) or result == null` BEFORE any +## typed use — a typed assignment (or a bool()/int() constructor, both +## of which have no Nil form) trips on the null first. The null check is +## not redundant with the generation check: a caller that loses the slot +## without a generation bump — an invariant violation, but exactly what +## a concurrent fire-and-forget `_run_blocking` user produces — must +## still unwind instead of crashing on the Nil. +func _run_blocking(work: Callable) -> Variant: + if not defer_blocking_work: + return work.call() + var tree := Engine.get_main_loop() + if not (tree is SceneTree): + return work.call() + var thread := Thread.new() + if thread.start(work) != OK: + return work.call() + _active_blocking_thread = thread + while thread.is_alive(): + await (tree as SceneTree).process_frame + if _active_blocking_thread != thread: + ## Teardown/invalidation already joined this thread; the + ## result belongs to a cancelled walk. All resumes and joins + ## happen on the main thread, so this check cannot race. + return null + if _active_blocking_thread != thread: + return null + _active_blocking_thread = null + return thread.wait_to_finish() + + +func _async_stale(generation: int) -> bool: + return generation != _async_generation + + +## Cancel any in-flight async startup walk AND release the re-entrancy +## guard so the very next `start_server()` call walks fresh (#682 review). +## Every one-shot kill-and-restart path must call this before its +## follow-up start: without the generation bump the suspended walk +## resumes against post-kill reality (stale live-status snapshots), and +## without releasing the guard the follow-up start is silently swallowed. +## The cancelled walk unwinds via its post-await staleness checks and +## must NOT clear the guard itself — a newer walk may already own it +## (see the generation check in `start_server`). +## +## Also JOINS the walk's in-flight worker thread (bounded by that op's +## own timeout: lsof/netstat scrape, ≤800ms status probe, or kill + +## port-drain wait). `stop_server` runs this from `_exit_tree`, so once +## it returns no worker thread can still be executing a method of the +## plugin that is about to be freed — the macOS reload-churn wedge. +func _invalidate_async_startup() -> void: + _async_generation += 1 + _start_in_flight = false + var thread := _active_blocking_thread + _active_blocking_thread = null + if thread != null: + thread.wait_to_finish() + + +# ---- Public state accessors -------------------------------------------- + +func get_state() -> int: + return _server_state + + +func get_status_dict() -> Dictionary: + return { + "state": _server_state, + "exit_ms": _server_exit_ms, + "actual_name": _server_actual_name, + "actual_version": _server_actual_version, + "expected_version": _server_expected_version, + "message": _server_status_message, + "can_recover_incompatible": _can_recover_incompatible, + "connection_blocked": _connection_blocked, + "conflict_port": _conflict_port, + "keep_alive": _server_keep_alive, + "stale_recovery_budget": _stale_recovery_budget, + } + + +## Arm the bounded stale-occupant recovery (see `_stale_recovery_budget`). +## Called before the startup walk when a completed self-update is detected +## (plugin.gd) and by the dock recovery click — both are user actions that +## already authorize replacing the previous-version backend. `max()` so a +## click landing on an already-armed post-update episode never shrinks the +## remaining rounds. +func authorize_stale_recovery(rounds: int = 2) -> void: + _stale_recovery_budget = maxi(_stale_recovery_budget, rounds) + + +## True when a spent round of stale-occupant recovery may target the live +## occupant: budget remains and the occupant is a VERIFIED godot-ai whose +## version differs from ours. An empty version (foreign or unverified +## occupant) never qualifies — those stay on the strong-proof-only path. +func _stale_recovery_available(live_version: String, expected_version: String) -> bool: + if _stale_recovery_budget <= 0: + return false + if live_version.is_empty(): + return false + return live_version != expected_version + + +func get_server_pid() -> int: + return _server_pid + + +func get_startup_path() -> String: + return _startup_path + + +func get_adoption_watch_deadline_ms() -> int: + return _adoption_watch_deadline_ms + + +func is_awaiting_server_version() -> bool: + return _version_check != null and _version_check.is_active() + + +func is_connection_blocked() -> bool: + return _connection_blocked + + +# ---- State-machine entry points --------------------------------------- + +## Validated transition. Returns true on success; false (and logs a +## warning) when the transition is illegal under `McpServerState`'s +## table. Callers that need first-writer-wins among terminal diagnoses +## use `set_terminal_diagnosis` instead — that helper silently no-ops +## without warning when the diagnosis would be a regression. +func transition_state(target: int) -> bool: + if _server_state == target: + return true + if not McpServerStateScript.can_transition(_server_state, target): + push_warning( + "MCP | rejected illegal state transition %s -> %s" + % [ + McpServerStateScript.name_of(_server_state), + McpServerStateScript.name_of(target), + ] + ) + return false + _server_state = target + return true + + +## First-writer-wins mutator for terminal diagnoses (CRASHED, +## NO_COMMAND, PORT_EXCLUDED, INCOMPATIBLE, FOREIGN_PORT). Used during +## spawn to make sure a late watch-loop CRASHED doesn't clobber an +## earlier proactive PORT_EXCLUDED. Silent no-op when the current state +## is already a terminal diagnosis — the existing diagnosis is kept. +func set_terminal_diagnosis(target: int) -> bool: + if not McpServerStateScript.is_terminal_diagnosis(target): + push_warning( + "MCP | set_terminal_diagnosis called with non-terminal %s" + % McpServerStateScript.name_of(target) + ) + return false + if McpServerStateScript.is_terminal_diagnosis(_server_state): + return false + _server_state = target + return true + + +# ---- Adoption confirmation watcher ------------------------------------- + +## Arm the FOREIGN_PORT adoption-confirmation watcher. SPAWN_GRACE_MS +## ahead of `now`; `tick_adoption_watch` self-disarms after this expires +## so per-frame cost drops back to zero on a permanent foreign occupant. +func arm_adoption_watch() -> void: + _adoption_watch_deadline_ms = ( + Time.get_ticks_msec() + int(_host.SPAWN_GRACE_MS) + ) + + +func tick_adoption_watch(now_msec: int) -> void: + if _adoption_watch_deadline_ms > 0 and now_msec >= _adoption_watch_deadline_ms: + _adoption_watch_deadline_ms = 0 + + +# ---- Server version-check seam ---------------------------------------- + +func arm_version_check(connection, expected_version: String) -> void: + if _version_check == null: + _version_check = McpServerVersionCheckScript.new(self) + var expected := _resolve_expected_version(expected_version) + _server_expected_version = expected + _version_check.arm(connection, expected) + + +func disarm_version_check() -> void: + if _version_check != null: + _version_check.disarm() + + +func get_version_check(): + return _version_check + + +## Resolves a possibly-empty expected version to the plugin's shipping +## version. Manager methods that are called via test fixtures may +## receive an empty string when the test never seeded +## `_server_expected_version`, so this is the one place that fallback +## lives. +func _resolve_expected_version(supplied: String) -> String: + if not supplied.is_empty(): + return supplied + return _expected_server_version() + + +func _expected_server_version() -> String: + return ClientConfigurator.get_plugin_version() + + +## Called by McpServerVersionCheck when handshake_ack carries a version +## string. Decides compatible vs incompatible and transitions the state. +func handle_server_version_verified(expected_version: String, version: String) -> void: + _server_actual_name = "godot-ai" + _server_actual_version = version + var expected := _resolve_expected_version(expected_version) + _server_expected_version = expected + var compatibility := _server_version_compatibility(version, expected) + if compatibility.get("compatible", false): + _can_recover_incompatible = false + ## A verified compatible handshake ends any stale-recovery episode — + ## leftover authorized rounds must not survive into a later walk. + _stale_recovery_budget = 0 + ## Foreign-port and post-spawn handshakes both clear to READY + ## on a successful handshake. Late re-arms from READY also land + ## here and self-confirm. + transition_state(McpServerStateScript.READY) + _host._update_process_enabled() + return + ## A stale handshake is expected while the startup/recovery owner is already + ## replacing that exact old backend. Do not let the connection-side verdict + ## invalidate the startup walk and launch a second kill transaction. The + ## active owner re-probes the listener before acting and will either replace + ## it or leave the normal incompatible diagnosis on failure. + if _recovery_in_flight and version != expected: + print("MCP | stale godot-ai v%s handshake coalesced into active recovery" % version) + return + var live := {"version": version, "status_code": 200, "name": "godot-ai"} + ## Connection propagation + version-check disarm + process re-evaluation + ## all live inside _set_incompatible_server now (#691) so the startup-walk + ## recovery-failure and force-restart-failure paths get them too. + _set_incompatible_server(live, expected, ClientConfigurator.http_port()) + ## Post-update the WebSocket usually reaches the OLD backend before the + ## startup walk finishes its probes — this verdict then cancels the walk + ## (the `_invalidate_async_startup` in `_set_incompatible_server`'s + ## fire-and-forget arm) and used to strand the update at INCOMPATIBLE + ## even though the Update click already authorized replacing that + ## backend. With authorized rounds left and a VERIFIED stale version, + ## spend one and fire the same recovery flow the dock's Restart click + ## runs — via the plugin wrapper so the connection unblocks on success, + ## with `user_initiated=false` so the trigger can only spend, never + ## re-arm. The INCOMPATIBLE latch it gates on is synchronous: the + ## `_set_incompatible_server` head above runs before its first await. + if _stale_recovery_available(version, expected) and _host.has_method("recover_incompatible_server"): + _stale_recovery_budget -= 1 + print( + "MCP | stale godot-ai v%s answered the WS handshake — replacing it (update/recovery authorized, %d retry round(s) left)" + % [version, _stale_recovery_budget] + ) + ## Fire-and-forget coroutine: this verdict lands on the main thread + ## from `_process`, and the recovery owns the flow from here + ## (mirroring the recovery click). + _host.recover_incompatible_server(false, version) + + +func handle_server_version_unverified(expected_version: String) -> void: + var expected := _resolve_expected_version(expected_version) + _server_expected_version = expected + var live := {"version": "", "status_code": 0, "error": "missing_handshake_ack"} + _set_incompatible_server(live, expected, ClientConfigurator.http_port()) + + +# ---- Compatibility / version helpers (pure) --------------------------- + +## Plugin and server speak a single, version-coupled protocol — new commands +## and response fields are added together. Treating dev-mode mismatches as +## "compatible" silently adopts a stale server whose code may differ from the +## live source tree (e.g. another worktree on a different branch holding +## port 8000). Strict match in all modes routes mismatches through +## `recover_strong_port_occupant`, which kills the branded port-holder and +## lets `start_server` spawn fresh against the current source. +static func _server_version_compatibility( + actual_version: String, + expected_version: String +) -> Dictionary: + if actual_version.is_empty(): + return {"compatible": false, "reason": "unknown"} + if actual_version == expected_version: + return {"compatible": true, "reason": "exact"} + return {"compatible": false, "reason": "version_mismatch"} + + +static func _server_status_compatibility( + actual_version: String, + expected_version: String, + actual_ws_port: int, + expected_ws_port: int, +) -> Dictionary: + var version_result := _server_version_compatibility(actual_version, expected_version) + if not bool(version_result.get("compatible", false)): + return version_result + if actual_ws_port != expected_ws_port: + return {"compatible": false, "reason": "ws_port_mismatch"} + return version_result + + +static func _managed_record_has_version_drift(record_version: String, current_version: String) -> bool: + return not record_version.is_empty() and record_version != current_version + + +# ---- Incompatible-server bookkeeping ---------------------------------- + +func _set_incompatible_server( + live: Dictionary, + expected_version: String, + port: int, + caller_owns_worker_slot := false +) -> void: + ## Latches the incompatible diagnosis into manager state and asks + ## the dock to re-sweep client rows so they don't show stale green. + ## Threads the caller's `live` snapshot through the recovery proof + ## helper so we don't double-probe the port (~500ms each). + ## + ## Coroutine (#712): the recovery-proof evaluation (port scrapes + + ## per-PID brand shells) and the free-port bind probes run via + ## `_run_blocking` — these fire in exactly the contended/crashed + ## scenarios #678 de-blocked, so they must not stall the main thread + ## either. Everything user-visible (status message, connection block, + ## version-check disarm) is latched synchronously before the first + ## await; only the recovery verdict and the suggested-port diagnostic + ## arrive with the worker. + ## + ## `_run_blocking` tracks a single active worker, so the tail below + ## needs exclusive ownership of that slot. The startup walk awaits + ## this call with `caller_owns_worker_slot=true` — it already owns the + ## slot and serializes the tail behind its own blocking ops. Sync + ## callers (the handshake verdicts via `handle_server_version_*`, the + ## force-restart failure arm) fire-and-forget the tail and leave the + ## flag false, so the head takes ownership for them: a handshake + ## verdict lands from `_process` while a startup walk can still be + ## suspended in `_run_blocking`, and starting the tail's worker then + ## would steal the slot — the walk's op is orphaned from the + ## `_invalidate_async_startup` join guarantee and its resume gets a + ## null without a generation bump (the Nil-into-Dictionary crash on + ## the incompatible-occupant walk). Cancelling the walk first mirrors + ## the recovery click (#712): the diagnosis in hand supersedes + ## whatever the walk was still probing for. + if not caller_owns_worker_slot: + _invalidate_async_startup() + transition_state(McpServerStateScript.INCOMPATIBLE) + _connection_blocked = true + _server_expected_version = expected_version + _server_actual_name = str(live.get("name", "")) + _server_actual_version = _live_version_for_message(live) + _server_status_message = _incompatible_server_message( + live, expected_version, port, int(_host._resolved_ws_port) + ) + ## Conservative default until the off-thread proof lands: the dock + ## paints "not recoverable" rather than offering a kill we have not + ## yet proven ownership for. + _can_recover_incompatible = false + _host._refresh_dock_client_statuses() + ## Propagate the verdict to the live connection (#691). Pre-#678 the + ## startup walk finished synchronously before `_connection` existed, so + ## plugin.gd captured the INCOMPATIBLE verdict when constructing it. + ## Post-#678 the walk suspends at its first `_run_blocking` and the + ## plugin snapshots the pre-walk defaults (`connect_blocked=false`) — so + ## a verdict landing later (startup-walk recovery failure, handshake + ## mismatch, force-restart failure) must reach the connection here, or + ## it keeps dialing the WS port forever. Also disarm the version check: + ## the diagnosis already landed, so leaving the check armed keeps + ## per-frame `_process` on for the plugin's whole lifetime. + if _host._connection != null: + _host._connection.connect_blocked = true + _host._connection.connect_block_reason = _server_status_message + _host._connection.disconnect_from_server() + disarm_version_check() + _host._update_process_enabled() + + ## Off-thread recovery proof (#712), mirroring recover_strong_port_occupant: + ## the EditorSettings record is read on the main thread up front and + ## injected as record_override — EditorSettings is main-thread-only. + var async_gen := _async_generation + var record: Dictionary = _host._read_managed_server_record() + var proof_result: Variant = await _run_blocking(func() -> Variant: + if not is_instance_valid(_host): + return {"proof": "", "pids": []} + return _host._evaluate_recovery_port_occupant_proof(port, live, record) + ) + if _async_stale(async_gen) or proof_result == null: + return + var proof: Dictionary = proof_result + var proof_name := str(proof.get("proof", "")) + _can_recover_incompatible = not proof_name.is_empty() + print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)")) + if not _can_recover_incompatible: + ## Non-recoverable: a foreign / unprovable occupant holds the port and + ## we have no ownership proof, so we must NOT kill it — surface a + ## concrete free port the user can switch to instead (the same hint + ## the dock crash body renders). Logging it to the editor output also + ## lets `ci-stale-server-smoke --mode foreign` assert this upstream + ## classification from CI. Reservation-aware on Windows; the bind + ## probes behind suggest_free_port also run off-thread (#712). + var suggested_result: Variant = await _run_blocking(func() -> Variant: + return ClientConfigurator.suggest_free_port(port + 1) + ) + if _async_stale(async_gen) or suggested_result == null: + return + print("MCP | port %d occupant not recoverable (no ownership proof); suggested free port %d (set godot_ai/http_port)" % [port, int(suggested_result)]) + ## Second sweep so the dock's recovery affordance reflects the verdict + ## that just landed. + _host._refresh_dock_client_statuses() + + +static func _incompatible_server_message( + live: Dictionary, + expected_version: String, + port: int, + expected_ws_port: int +) -> String: + var version := _live_version_for_message(live) + var actual_ws_port := _live_ws_port_for_message(live) + ## `package_path` is a v2.4.4+ field — older servers omit it. Suffix + ## the message with "(loaded from )" when present so the user + ## can tell *which* `src/godot_ai/` is serving the port without + ## walking the process tree. See #416. + var package_path := _live_package_path_for_message(live) + var path_suffix := " (loaded from %s)" % package_path if not package_path.is_empty() else "" + ## After a plugin update, the usual occupant is a backend kept alive by + ## AI-client attach bridges still pinned to the previous version (their + ## leases outrank us — #669/#839, we must not kill it). Name that repair + ## first; "stop the old server" alone reads as a dead end when the server + ## respawns the moment the user kills it. + var repair := ( + "If AI-client attach bridges are keeping it alive, run Configure all to " + + "repin them, then restart those client apps — the old server exits on " + + "its own. Otherwise stop it manually or change both HTTP and WS ports." + ) + if not version.is_empty(): + if actual_ws_port > 0 and actual_ws_port != expected_ws_port: + return ( + "Port %d is occupied by godot-ai server v%s using WS port %d%s; " + + "plugin expects v%s with WS port %d. %s" + ) % [port, version, actual_ws_port, path_suffix, expected_version, expected_ws_port, repair] + return ( + "Port %d is occupied by godot-ai server v%s%s; plugin expects v%s. %s" + ) % [port, version, path_suffix, expected_version, repair] + var status_code := int(live.get("status_code", 0)) + if status_code > 0: + return ( + "Port %d is occupied by an unverified server (status endpoint returned HTTP %d); " + + "plugin expects godot-ai v%s. Stop the other server or change both HTTP and WS ports." + ) % [port, status_code, expected_version] + return ( + "Port %d is occupied by another process; plugin expects godot-ai v%s. " + + "Stop the other process or change both HTTP and WS ports." + ) % [port, expected_version] + + +static func _live_status_identifies_godot_ai(live: Dictionary) -> bool: + return str(live.get("name", "")) == "godot-ai" + + +static func _live_version_for_message(live: Dictionary) -> String: + if live.has("name") and str(live.get("name", "")) != "godot-ai": + return "" + return str(live.get("version", "")) + + +static func _live_ws_port_for_message(live: Dictionary) -> int: + if live.has("name") and str(live.get("name", "")) != "godot-ai": + return 0 + return int(live.get("ws_port", 0)) + + +static func _live_package_path_for_message(live: Dictionary) -> String: + ## Only trust the path when the live snapshot confirms a godot-ai + ## server — a probe of some unrelated HTTP service could in theory + ## return a `package_path` JSON field, and we don't want to mislabel + ## that as "godot-ai loaded from …" in the incompatible banner. + if live.has("name") and str(live.get("name", "")) != "godot-ai": + return "" + return str(live.get("package_path", "")) + + +# ---- start_server / spawn watch / respawn ----------------------------- + + +## Sets GODOT_AI_DISABLE_TELEMETRY in the process environment for the +## upcoming OS.create_process call if: (a) neither GODOT_AI_DISABLE_TELEMETRY +## nor DISABLE_TELEMETRY is already set to a *truthy* value (a falsey "0" does +## NOT count — it must not suppress a dock UI opt-out), and (b) the effective +## McpSettings.telemetry_enabled() is false. Returns true if the var was +## injected so the caller can unset it after spawning. +func _inject_telemetry_env() -> bool: + ## If telemetry is already disabled by a *truthy* env var, leave the env as + ## the user/CI set it — the post-spawn cleanup unsets what we inject, so + ## injecting here would strip their own var from the editor process. A + ## *falsey* value (e.g. DISABLE_TELEMETRY=0) must NOT count as "handled": + ## fall through so a dock UI opt-out still reaches the spawned server. The + ## truthy test mirrors McpSettings.telemetry_enabled() and the Python server. + if McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY"): + return false + if not McpSettings.telemetry_enabled(): + OS.set_environment("GODOT_AI_DISABLE_TELEMETRY", "true") + return true + return false + + +## Set GODOT_AI_OWNER_PID to this editor's PID for the next OS.create_process, +## so the spawned server can self-reap if this editor crashes. Returns true if +## set (caller must unset right after spawning — keep it out of the persistent +## editor env). No-op on Windows, where the server's reaper is disabled. +func _set_owner_pid_env() -> bool: + if OS.get_name() == "Windows": + return false + ## keep_server_on_exit (#800): a server meant to outlive editors must not + ## self-reap when this editor dies — don't hand it an owner pid at all. + if ClientConfigurator.keep_server_on_exit(): + return false + OS.set_environment("GODOT_AI_OWNER_PID", str(OS.get_process_id())) + return true + + +## Mark the next OS.create_process as plugin-spawned so the server arms its +## session-idle self-terminate backstop (#498): with zero editor sessions for +## a grace window, it exits on its own. Unlike the owner-PID reaper this is +## pure session-count on the server side, so it is set on EVERY platform — +## including Windows, where owner-PID is skipped; this marker is what finally +## gives Windows orphan coverage (#497). Same env-channel rationale and same +## tight scoping as _set_owner_pid_env: callers unset it right after spawning +## so a later manually-started dev server can never inherit it and idle-kill +## itself. +func _set_plugin_spawned_env() -> void: + OS.set_environment("GODOT_AI_PLUGIN_SPAWNED", "1") + + +## keep_server_on_exit (#800): opt the spawned server out of the +## session-idle self-terminate backstop (#498) via its existing +## GODOT_AI_NO_IDLE_EXIT escape hatch — a keep-alive server sits at zero +## sessions between editor runs by design, which is exactly what the +## backstop reaps. Returns true if set (same tight scoping as +## _set_owner_pid_env: callers unset right after spawning, and only when +## WE set it, so a user's own NO_IDLE_EXIT env is never stripped). +func _set_keep_alive_env() -> bool: + if not ClientConfigurator.keep_server_on_exit(): + return false + OS.set_environment("GODOT_AI_NO_IDLE_EXIT", "1") + return true + + +## Generate a fresh per-launch WS handshake auth token (#690) and stage it +## in the env for the next OS.create_process, same channel and same tight +## scoping as _set_owner_pid_env (callers unset right after spawning — the +## secret must not linger in the editor env). The caller hands the returned +## token to the host on successful spawn so the connection echoes it in the +## handshake and the managed-server record persists it across reloads. +func _set_ws_token_env() -> String: + var token := Crypto.new().generate_random_bytes(32).hex_encode() + OS.set_environment("GODOT_AI_WS_TOKEN", token) + return token + + +## Branch table (recorded version is the "is this ours?" signal — uvx +## launcher PIDs go stale; #135/#137): +## port free -> spawn fresh, record PID +## port in use, record matches + live ok -> adopt port owner (heals PID) +## port in use, record drifts -> kill owner + respawn +## port in use, no verified live match -> block adoption + warn +## +## #678: this is a coroutine in production (`defer_blocking_work`) — the +## port scrapes, status probes, and kill-drain waits run off the main +## thread and the state machine resumes between frames, so the editor +## stays responsive when the port is contended. With the flag off (unit +## tests) nothing suspends and the call completes synchronously. +func start_server() -> void: + if _start_in_flight: + return + _start_in_flight = true + var gen := _async_generation + await _start_server_impl(gen) + ## Only release the guard if this walk is still the current one — a + ## cancelled (stale) walk unwinding here must not clobber the guard a + ## newer walk armed after `_invalidate_async_startup`. + if gen == _async_generation: + _start_in_flight = false + ## Walk-completion continuation lives HERE — on the RefCounted + ## manager, kept alive by its own suspended state — never on the + ## plugin: resuming a coroutine of a freed Node errors out, and + ## reload churn frees plugin instances while walks are suspended. + if is_instance_valid(_host) and _host.has_method("_finish_startup_trace_after_walk"): + _host._finish_startup_trace_after_walk() + + +func _start_server_impl(async_gen: int) -> void: + if _host._server_started_this_session: + ## Static flag persists across disable/enable cycles in one editor + ## session — re-entrant spawn guard for plugin-reload-during-update. + _startup_path = McpStartupPathScript.GUARDED + transition_state(McpServerStateScript.GUARDED) + return + + _refresh_retried = false + if _readopt_walk_pending: + ## #805: this walk was triggered by the fast-exit re-adopt arm. + ## Keep the spent budget: if this walk ends up spawning and that + ## spawn fast-exits against a live godot-ai again, the occupant is + ## flapping and the diagnosis must latch terminal instead of + ## re-walking forever. Recovery paths (adoption, healthy spawn) + ## refresh the budget explicitly. + _readopt_walk_pending = false + else: + _readopt_after_spawn_exit_retried = false + _conflict_port = 0 + + var port := ClientConfigurator.http_port() + var ws_port := ClientConfigurator.ws_port() + var current_version := _expected_server_version() + _server_expected_version = current_version + + ## The worker closures re-check the host: the plugin can be freed while + ## a bounded shell probe is still running, and the generation check only + ## protects state after resume, not calls inside the task (#682 review). + var port_in_use_result: Variant = await _run_blocking(func() -> Variant: + return is_instance_valid(_host) and _host._is_port_in_use(port) + ) + if _async_stale(async_gen) or port_in_use_result == null: + return + var port_in_use := bool(port_in_use_result) + if not port_in_use: + ## #745: after an editor crash (or under multi-editor churn) the + ## managed server keeps running, yet the bind probe can still say + ## "free" (Windows lets a SO_REUSEADDR bind succeed over a live + ## listener; the scrape fallback can fail transiently). The HTTP + ## status probe is the authoritative tie-breaker and runs + ## UNCONDITIONALLY: the pid-file evidence gate that used to guard it + ## goes stale exactly when it's needed most — same-named test + ## projects share one app_userdata dir, so another editor's walk can + ## clear or overwrite the pid-file, and blind-spawning here produced + ## the reproduced duplicate-spawn + 4003 token loop. A live godot-ai + ## answer forces the adopt/recover branch below; an unresponsive + ## port falls through to the normal spawn path at the cost of one + ## fast connection-refused probe (off-thread in production). + var evidence_result: Variant = await _run_blocking(func() -> Variant: + if not is_instance_valid(_host): + return {} + return _host._probe_live_server_status_for_port(port) + ) + if _async_stale(async_gen) or evidence_result == null: + return + var evidence: Dictionary = evidence_result + if _live_status_identifies_godot_ai(evidence): + port_in_use = true + if port_in_use: + var record: Dictionary = _host._read_managed_server_record() + var record_version := str(record.get("version", "")) + var record_ws_port := int(record.get("ws_port", 0)) + _host._set_resolved_ws_port(PortResolver.resolved_ws_port_for_existing_server( + record_ws_port, + record_version, + current_version, + int(_host._resolve_ws_port()) + )) + ws_port = int(_host._resolved_ws_port) + ## Untyped first: a cancelled walk gets null back (see _run_blocking) + ## and must reach the staleness check before any typed cast. + var live_result: Variant = await _run_blocking(func() -> Variant: + if not is_instance_valid(_host): + return {} + return _host._probe_live_server_status_for_port(port) + ) + if _async_stale(async_gen) or live_result == null: + return + var live: Dictionary = live_result + var live_version := str(_host._verified_status_version(live)) + var live_ws_port := int(_host._verified_status_ws_port(live)) + var compatibility: Dictionary = _server_status_compatibility( + live_version, + current_version, + live_ws_port, + ws_port, + ) + if compatibility.get("compatible", false): + _server_actual_name = "godot-ai" + _server_actual_version = live_version + _can_recover_incompatible = false + ## A matching version is compatibility evidence, not ownership + ## evidence (#759/#764). A stale EditorSettings record can name a + ## dead PID while an unrelated compatible server owns the port. + ## Retain managed ownership only when the recorded PID is itself + ## the live, branded listener. + var adoption_proof_result: Variant = await _run_blocking(func() -> Variant: + if not is_instance_valid(_host): + return {"proof": "", "pids": []} + return _host._evaluate_strong_port_occupant_proof(port, live, record) + ) + if _async_stale(async_gen) or adoption_proof_result == null: + return + var adoption_proof: Dictionary = adoption_proof_result + var proof_pids: Array[int] = [] + proof_pids.assign(adoption_proof.get("pids", [])) + var owner := int(proof_pids[0]) if not proof_pids.is_empty() else 0 + var record_owns_listener := str(adoption_proof.get("proof", "")) == "managed_record" + var owner_label := adopt_compatible_server( + record_version, + current_version, + owner, + record_owns_listener + ) + _host._server_started_this_session = true + _startup_path = McpStartupPathScript.ADOPTED + transition_state(McpServerStateScript.READY) + print(_compatible_adoption_log_message( + owner_label, + int(_server_pid), + owner, + str(_server_actual_version), + live_ws_port, + current_version + )) + return + if bool(_managed_record_has_version_drift(record_version, current_version)): + print("MCP | managed server v%s does not match plugin v%s, restarting" + % [record_version, current_version]) + ## One transaction owns strong-proof recovery and the bounded stale-version + ## fallback. The WS handshake path can observe the same old server while + ## these awaits are suspended; `_recovery_in_flight` makes that verdict + ## coalesce here instead of starting a competing kill/respawn. + var recovered: bool = await _recover_startup_port_occupant( + port, live, live_version, current_version, async_gen + ) + if _async_stale(async_gen): + return + if not recovered: + _host._server_started_this_session = true + var post_recovery_result: Variant = await _run_blocking(func() -> Variant: + if not is_instance_valid(_host): + return {} + return _host._probe_live_server_status_for_port(port) + ) + if _async_stale(async_gen) or post_recovery_result == null: + return + var post_recovery_live: Dictionary = post_recovery_result + ## Awaited with caller_owns_worker_slot=true (#712): the + ## diagnosis tail runs its own _run_blocking proof, and the walk + ## stays the single owner of the active-worker slot by + ## serializing that tail behind this await instead of letting it + ## re-take the slot. The status message is latched before the + ## tail's first await, so the push_warning below reads the final + ## text either way. + await _set_incompatible_server(post_recovery_live, current_version, port, true) + if _async_stale(async_gen): + return + _startup_path = McpStartupPathScript.INCOMPATIBLE + push_warning(str(_server_status_message)) + return + else: + _startup_path = McpStartupPathScript.FREE + + _host._set_resolved_ws_port(_host._resolve_ws_port()) + ws_port = _host._resolved_ws_port + + _host._startup_trace_count("server_command_discovery") + ## CLI-finder discovery shells out (which/where, login shell) on cache + ## misses — the same #238/#239 family the dock already runs off-thread. + var server_cmd_result: Variant = await _run_blocking(func() -> Variant: + return ClientConfigurator.get_server_command() + ) + if _async_stale(async_gen) or server_cmd_result == null: + return + var server_cmd: Array = server_cmd_result + if server_cmd.is_empty(): + set_terminal_diagnosis(McpServerStateScript.NO_COMMAND) + _startup_path = McpStartupPathScript.NO_COMMAND + push_warning("MCP | could not find server command") + return + + var cmd: String = server_cmd[0] + var args: Array[String] = [] + args.assign(server_cmd.slice(1)) + args.append_array(_host._build_server_flags(port, ws_port)) + + ## Wipe any stale pid-file so a failed launch can't leave last + ## session's PID for `_find_managed_pid` to read. + _host._clear_pid_file() + + ## Proactive Windows port-reservation check (#146) — bind would + ## fail silently with WinError 10013 inside a Hyper-V / WSL2 / + ## Docker exclusion range; netstat shows nothing. + if WindowsPortReservation.is_port_excluded(port): + _host._server_started_this_session = true + set_terminal_diagnosis(McpServerStateScript.PORT_EXCLUDED) + _startup_path = McpStartupPathScript.RESERVED + push_warning("MCP | port %d is reserved by Windows (Hyper-V / WSL2 / Docker)" % port) + return + + ## ---- Spawn-time env-mutation window (#691) ------------------------- + ## From here to the post-spawn unsets below, the editor's process-global + ## environment is mutated around OS.create_process (which has no + ## per-child env parameter). Two invariants keep this safe: + ## 1. The window is SYNCHRONOUS main-thread code — no `await` between + ## the first setenv and the last unsetenv — and worker dispatch also + ## only happens on the main thread, so no new worker can start inside + ## the window. + ## 2. Already-running workers never call OS.get_environment: every env + ## read reachable from a worker (path templates, config_home_override, + ## CLI finder, mode_override/startup-trace) routes through + ## McpPathTemplate.env_lookup, which serves worker threads from a + ## main-thread-warmed snapshot. A concurrent glibc getenv during + ## setenv can return a freed pointer — process-fatal. + ## Residual (accepted): a worker's own OS.execute child (CLI status + ## probe) launched while this window is open inherits the temp vars — + ## rare, and tame next to the crash class above. + var injected_telemetry_env := _inject_telemetry_env() + + ## PYTHONPATH handling for dev checkouts: when the editor is launched + ## against a worktree whose `src/godot_ai/__version__` differs from the + ## root repo's editable install, the dev-venv python's `sitecustomize` + ## adds the *root repo's* `src/` to `sys.path`. The spawned server then + ## reports the root repo's version, the plugin's compatibility check + ## flags it as incompatible, and the user gets a Restart-Server loop + ## with no exit. `start_dev_server` already prepends the worktree's + ## `src/` for its --reload spawn; mirror that here for the auto-spawn + ## path so the same worktree-vs-root version skew is impossible. Gated + ## on `is_dev_checkout()` so production user installs (no nearby `src/`) + ## are untouched. See #418. + var worktree_src := "" + var prev_pythonpath := "" + var pythonpath_set := false + if ClientConfigurator.is_dev_checkout(): + worktree_src = ClientConfigurator.find_worktree_src_dir( + ProjectSettings.globalize_path("res://") + ) + if not worktree_src.is_empty(): + prev_pythonpath = OS.get_environment("PYTHONPATH") + var sep := ";" if OS.get_name() == "Windows" else ":" + var new_pp := ( + worktree_src + if prev_pythonpath.is_empty() + else worktree_src + sep + prev_pythonpath + ) + OS.set_environment("PYTHONPATH", new_pp) + pythonpath_set = true + + ## Tell the spawned server which editor owns it so it can self-reap if we + ## die without a clean stop_server (crash / hard-kill). Passed via env, not + ## a CLI flag, so an older server (staggered user-mode upgrade) silently + ## ignores an unknown var instead of failing argparse. Scoped tightly around + ## create_process and unset right after (like PYTHONPATH below): the child + ## inherits it, but it must NOT linger in the editor env, or a later + ## non-reload `godot-ai` subprocess (dev server, future spawn) would inherit + ## it and wrongly arm a reaper keyed to this editor. + ## Skipped on Windows: the server's reaper is POSIX-only for now (Windows + ## process-liveness/self-shutdown isn't live-validated yet). The server + ## gates on this too. + var owner_env_set := _set_owner_pid_env() + _set_plugin_spawned_env() + var keep_alive_env_set := _set_keep_alive_env() + var ws_token := _set_ws_token_env() + + _server_pid = OS.create_process(cmd, args) + var spawned_pid := int(_server_pid) + + if owner_env_set: + OS.unset_environment("GODOT_AI_OWNER_PID") + OS.unset_environment("GODOT_AI_PLUGIN_SPAWNED") + if keep_alive_env_set: + OS.unset_environment("GODOT_AI_NO_IDLE_EXIT") + OS.unset_environment("GODOT_AI_WS_TOKEN") + + ## Restore PYTHONPATH immediately — the spawned child has already + ## copied the env, so the editor's own process state returns to + ## baseline. Leaving it set would leak to any later OS.create_process + ## from unrelated paths. + if pythonpath_set: + if prev_pythonpath.is_empty(): + OS.unset_environment("PYTHONPATH") + else: + OS.set_environment("PYTHONPATH", prev_pythonpath) + + if injected_telemetry_env: + OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") + + if spawned_pid > 0: + _server_spawn_ms = Time.get_ticks_msec() + _server_exit_ms = 0 + _server_pid_published_elapsed_ms = 0 + _spawn_dead_since_ms = 0 + _server_keep_alive = keep_alive_env_set + _host._server_started_this_session = true + transition_state(McpServerStateScript.SPAWNING) + ## The child copied the env, so this token is what the server will + ## verify handshakes against — adopt it BEFORE writing the record + ## (the record write persists _ws_auth_token). + _host._set_ws_auth_token(ws_token) + ## Record the launcher PID so same-session + ## prepare_for_update_reload has something to kill. The next + ## editor start's adopt branch heals it to the real port owner. + _host._write_managed_server_record(spawned_pid, current_version, _server_keep_alive) + _startup_path = McpStartupPathScript.SPAWNED + ## Log "PYTHONPATH prefix=" rather than "PYTHONPATH=" so the line + ## isn't misleading when an existing PYTHONPATH was present — + ## we prepended `worktree_src`, not replaced. Keeps the log + ## compact (worktree_src is the actionable piece; the full + ## prev_pythonpath can be 5+ entries long on dev machines). + var suffix := " (PYTHONPATH prefix=%s)" % worktree_src if not worktree_src.is_empty() else "" + print("MCP | started server (PID %d, v%s): %s %s%s" % [spawned_pid, current_version, cmd, " ".join(args), suffix]) + _host._start_server_watch() + else: + _server_status_message = "" + set_terminal_diagnosis(McpServerStateScript.CRASHED) + _startup_path = McpStartupPathScript.CRASHED + push_warning("MCP | failed to start server") + + +## Is the watched spawn PID's death still explainable as a launcher handoff +## rather than a server exit? (#797) +## +## Observed on Windows 11 with a uv-created venv: one boot in four logged +## "server exited after 5146ms" while the real server kept running and was +## then adopted. The watched PID had died on a healthy boot, and because the +## server had not yet written its pid-file there was nothing to heal onto, so +## the watch crossed SPAWN_GRACE_MS and reported an exit — rescued only by the +## crash-survivor adoption path. +## +## A uv venv's `python.exe` is a shim rather than the interpreter, and the real +## server does run under a *different* PID than the one `OS.create_process` +## hands back. But the original report's suspected mechanism — that the shim +## exits once its child is up — is **disproven**, not merely unconfirmed. A +## 12-boot run on Windows 11 with a uv venv found the spawned trampoline alive +## on every boot, with the child owning both the pid-file and the listener; a +## CI runner showed the same. The shim is a live parent for the process's whole +## life, so it is not what kills the watched PID. +## +## Two consequences worth keeping straight. First, this gate is keyed to the +## observable condition — watched PID dead, no pid-file yet — not to any theory +## of why it died, so it stays correct whatever the cause. Second, and less +## comfortable: in that same 12-boot run the false "server exited" line never +## appeared AND the watched PID never died, so the guard never fired. Those +## clean boots are evidence the symptom did not reproduce, NOT evidence this +## guard fixes it. The true cause of the original 1-in-4 report is still +## unknown; if it resurfaces, start from that rather than from the trampoline. +## +## `real_pid <= 0` means no pid-file exists yet, and that reliably means "this +## server has not published one" rather than "stale leftover": `start_server` +## wipes the pid-file immediately before every spawn. So an absent pid-file +## plus a dead spawn PID inside the window is the handoff signature. +## +## Deliberately gated to Windows. POSIX uv venvs exec rather than trampoline, +## so a dead spawn PID there really is a dead server, and delaying its +## diagnosis would only slow down honest crash reporting on the platforms +## where this cannot happen. `os_name` is a parameter rather than an +## `OS.get_name()` call so the Windows path is exercisable from any host. +static func is_spawn_handoff_pending( + os_name: String, real_pid: int, elapsed_ms: int, window_ms: int +) -> bool: + if os_name != "Windows": + return false + if real_pid > 0: + return false + return elapsed_ms < window_ms + + +## First-write-wins stamp for the elapsed time at which the spawn PID was first +## observed dead (#797). +## +## A diagnosis raised after waiting out a handoff must still report when the +## process actually exited, not when the wait gave up — the point of #797 is an +## honest log line. Returns the existing stamp once one is set, so later ticks +## in the same wait cannot overwrite it; `<= 0` means "not yet stamped", +## matching how the field is cleared per spawn. +static func first_death_stamp(current_stamp_ms: int, elapsed_ms: int) -> int: + return current_stamp_ms if current_stamp_ms > 0 else elapsed_ms + + +## One-line forensic snapshot taken the moment a spawn is judged to have +## fast-exited (#797). +## +## #797 reported `server exited after 5146ms` on a healthy Windows boot, once +## in four. It is still unexplained: a 12-boot run on the reported +## configuration reproduced neither the symptom nor its suspected mechanism — +## the uv trampoline was alive on every boot, with the child owning the +## pid-file and the listener, so the shim's exit is ruled out as the cause. +## What killed that watched PID is unknown, and the log line at the time +## carried no evidence to answer it with. +## +## So capture the state at the moment of judgement rather than asking the next +## person to reproduce a 1-in-4 bug under observation. Everything here is read +## through seams the surrounding diagnosis already uses, on a path that only +## runs when a spawn is being declared dead, so it costs nothing in the +## healthy case. +## Deliberately does NOT scrape the port for listener PIDs. This runs from the +## 1 Hz watch loop, on a live frame, so a `_find_all_pids_on_port` subprocess +## here would stall the editor for a diagnostic. Deferring it via +## `_run_blocking` was the alternative and is worse: that helper is +## `await`-based, so it would turn this, `_diagnose_spawn_fast_exit` and +## `check_server_health` into coroutines — making the watch callback resume +## across arbitrary frames while its branches set terminal state and trigger +## re-adoption walks. That is the teardown-ordering hazard +## `_invalidate_async_startup` exists to contain, and it is not worth taking +## on for a log line. +## +## Little is lost: the probe on the very next line already establishes whether +## a godot-ai server answers on the port, and `_diagnose_spawn_port_conflict` +## names a foreign occupant when there is one. If you are tempted to add the +## PID list back, put it behind that existing conflict path rather than here. +func _log_spawn_exit_forensics() -> void: + var spawn_pid := int(_server_pid) + var pid_file_pid := int(_host._read_pid_file_for_proof()) + ## Computed here rather than accepted as a parameter. The caller's + ## `elapsed` IS `_spawn_dead_since_ms` — #837 passes the true death time so + ## the user-facing "server exited after Nms" line stays honest — so taking + ## it would make these two fields report the same number, collapsing the + ## exact distinction they exist to record. + var diagnosed_at_ms := 0 + if int(_server_spawn_ms) > 0: + diagnosed_at_ms = Time.get_ticks_msec() - int(_server_spawn_ms) + _host._log_buffer.log(format_spawn_exit_forensics({ + "os": OS.get_name(), + "launch_mode": ClientConfigurator.get_server_launch_mode(), + "elapsed_ms": diagnosed_at_ms, + ## Differs from elapsed_ms when a Windows handoff window was waited out + ## (#824/#837): the true death time versus when we gave up on it. + "first_dead_ms": int(_spawn_dead_since_ms), + "spawn_pid": spawn_pid, + ## Re-read rather than trusted from the watch tick: if the spawn PID is + ## alive HERE, the death that triggered this was transient, which is a + ## different bug from a process that really exited. + "spawn_alive": spawn_pid > 0 and bool(_host._pid_alive_for_proof(spawn_pid)), + "pid_file_pid": pid_file_pid, + "pid_file_alive": pid_file_pid > 0 and bool(_host._pid_alive_for_proof(pid_file_pid)), + })) + + +## Render the forensic snapshot. Pure so the format is testable without a live +## editor, and kept to one line so it survives log truncation in a bug report. +static func format_spawn_exit_forensics(facts: Dictionary) -> String: + var spawn_pid := int(facts.get("spawn_pid", 0)) + var pid_file_pid := int(facts.get("pid_file_pid", 0)) + ## The single most diagnostic bit, stated rather than left to be inferred: + ## a live pid-file process while the watched one is gone is the launcher + ## handoff shape; both gone is a real crash. + var shape := "unknown" + var spawn_alive := bool(facts.get("spawn_alive", false)) + var file_alive := bool(facts.get("pid_file_alive", false)) + if spawn_alive: + shape = "watched_pid_still_alive" + elif file_alive and pid_file_pid != spawn_pid: + shape = "handoff_child_alive" + elif not file_alive and pid_file_pid <= 0: + shape = "no_pid_file_published" + else: + shape = "all_dead" + return ( + "#797 spawn-exit forensics: shape=%s os=%s launch=%s elapsed=%dms " + + "first_dead=%dms spawn_pid=%d(alive=%s) pid_file_pid=%d(alive=%s)" + ) % [ + shape, + str(facts.get("os", "")), + str(facts.get("launch_mode", "")), + int(facts.get("elapsed_ms", 0)), + int(facts.get("first_dead_ms", 0)), + spawn_pid, + str(spawn_alive), + pid_file_pid, + str(file_alive), + ] + + +## Watch-loop callback (1 Hz, cold-start budget until pid-file publication, +## then the warm budget from the publication tick). +## `--pid-file` is the source of truth on Windows / uvx where the +## launcher PID dies quickly after spawning the real interpreter. +func check_server_health() -> void: + if int(_server_pid) <= 0: + _host._stop_server_watch() + return + var elapsed := Time.get_ticks_msec() - int(_server_spawn_ms) + var real_pid := PortResolver.read_pid_file() + if real_pid > 0 and _server_pid_published_elapsed_ms == 0: + _server_pid_published_elapsed_ms = elapsed + var spawn_pid := int(_server_pid) + if real_pid > 0 and real_pid != spawn_pid and PortResolver.pid_alive(real_pid): + _spawn_dead_since_ms = 0 + _server_pid = real_pid + ## The spawn record initially contains the launcher PID so same-session + ## teardown can kill it. Heal it as soon as the server publishes its + ## authoritative PID; future adoption requires the recorded PID to be + ## the actual live listener (#759). + _host._write_managed_server_record(real_pid, _expected_server_version(), _server_keep_alive) + ## #805: the spawn survived to publish its pid-file — proven + ## recovery, so the fast-exit re-adopt budget refreshes. + ## + ## The stale-recovery budget is deliberately NOT reset here (#890 + ## review P1): the Python server writes its pid-file during import, + ## BEFORE uvicorn binds HTTP or the WebSocket listener starts + ## (src/godot_ai/__init__.py) — publication proves the child reached + ## that line, not that it owns either port. A bridge respawn can + ## still steal the bind after the pid-file lands, and zeroing here + ## left that fast-exit with no recovery round. The budget resets on + ## the genuinely proven recoveries: adoption and the verified + ## compatible handshake. + _readopt_after_spawn_exit_retried = false + elif not PortResolver.pid_alive(spawn_pid): + _spawn_dead_since_ms = first_death_stamp(_spawn_dead_since_ms, elapsed) + if is_spawn_handoff_pending( + OS.get_name(), real_pid, elapsed, int(_host.SPAWN_HANDOFF_MS) + ): + return + if elapsed >= int(_host.SPAWN_GRACE_MS) and not McpServerStateScript.is_terminal_diagnosis(_server_state): + _diagnose_spawn_fast_exit(_spawn_dead_since_ms) + return + var watch_elapsed := watch_window_elapsed_ms( + elapsed, real_pid, _server_pid_published_elapsed_ms + ) + if watch_elapsed >= watch_budget_ms( + real_pid, int(_host.SERVER_WATCH_MS), int(_host.SERVER_COLD_START_WATCH_MS) + ): + ## #896: past the budget we stop watching either way, but a spawn that + ## never published a pid-file did not "survive startup" — it just never + ## proved anything. Say so, or the only remaining signal is a silent + ## reconnect loop. + if real_pid <= 0: + print( + "MCP | server spawn never published a pid-file within %ds; " + % int(_host.SERVER_COLD_START_WATCH_MS / 1000) + + "no longer watching it. If the editor stays disconnected, " + + "check the Godot output log for the server's own errors." + ) + _host._stop_server_watch() + + +## How long to keep watching a spawn, given whether it has published its +## pid-file yet. +## +## Pure so tests can drive the decision without a live spawn. `pid_from_file` +## is the authoritative "the server got far enough to run" signal: the Python +## server writes it during import, before uvicorn binds. Until it appears, a +## live launcher PID proves only that `uvx` has not exited — on a cold start +## that is usually a download still in progress, not a started server. +static func watch_budget_ms(pid_from_file: int, warm_ms: int, cold_ms: int) -> int: + return warm_ms if pid_from_file > 0 else cold_ms + + +## Elapsed time in the currently-active watch window. Before startup proof, +## the window begins at process creation. Once the pid-file appears, begin a +## fresh warm watch window so a 45-second download still gets 30 seconds of +## early-exit coverage after Python starts. +static func watch_window_elapsed_ms( + spawn_elapsed_ms: int, pid_from_file: int, pid_published_elapsed_ms: int +) -> int: + if pid_from_file <= 0 or pid_published_elapsed_ms <= 0: + return spawn_elapsed_ms + return maxi(0, spawn_elapsed_ms - pid_published_elapsed_ms) + + +## The spawned server died inside the SPAWN_GRACE_MS window. Decide what +## that means, in order: +## 1. A live godot-ai server answers on the HTTP port -> our spawn lost +## a port race the bind probe never saw (#745 bind-trap: the walk +## thought the port was free, the duplicate exited unable to bind, +## and the token it staged in the record is now stale). Re-run the +## startup walk so the adopt/recover branch handles the survivor — +## latching CRASHED here left the connection redialing forever with +## a token the surviving server rejects (close code 4003). One +## re-adopt per recovery via `_readopt_after_spawn_exit_retried` +## (#805): the triggered walk preserves the spent budget, so a +## flapping occupant (alive at each fast-exit probe, gone by each +## walk's probes — sustained multi-editor churn) latches a specific +## CRASHED diagnosis on the second round instead of re-walking +## forever. Exception: a verified stale-VERSION occupant with +## `_stale_recovery_budget` rounds left keeps re-walking (each walk +## spends one round in its recovery arm), so a bridge-respawn race +## after a self-update converges instead of latching. +## 2. #647: foreign process on the HTTP or WS port -> FOREIGN_PORT with +## an actionable message (we can't read the child's "port already in +## use" stderr). Checked before the --refresh retry: respawning +## against an occupied port can only fail the same way. +## 3. #172: stale uvx index -> one `--refresh` respawn. +## 4. Otherwise -> CRASHED, pointing at the Godot output log. +func _diagnose_spawn_fast_exit(elapsed: int) -> void: + _log_spawn_exit_forensics() + var live: Dictionary = _host._probe_live_server_status_for_port( + ClientConfigurator.http_port() + ) + if _live_status_identifies_godot_ai(live): + ## Stale-version occupant with authorized recovery rounds left: allow + ## another re-adopt walk even after the #805 one-shot is spent. This is + ## the attach-bridge respawn race — a bridge pinned to the previous + ## version respawns its cached backend the instant the old server is + ## killed, and can beat our (possibly cold) spawn to the bind. Each + ## extra walk spends one `_stale_recovery_budget` round in its recovery + ## arm, so this stays bounded; the #805 flapping diagnosis below still + ## latches for same-version/unverified occupants and once the budget is + ## exhausted. + var stale_retry := _stale_recovery_available( + str(_host._verified_status_version(live)), _expected_server_version() + ) + if not _readopt_after_spawn_exit_retried or stale_retry: + if _readopt_after_spawn_exit_retried: + ## Second and later rounds ride the stale budget — spend HERE, + ## not only in the walk's recovery arm: an occupant that flaps + ## out of existence before each walk's probe (the #805 shape) + ## would otherwise re-walk without ever spending, and the + ## terminal latch below would never be reached. + _stale_recovery_budget -= 1 + _readopt_after_spawn_exit_retried = true + _readopt_walk_pending = true + _host._log_buffer.log( + "server exited after %dms but a live godot-ai server answers on port %d — re-running adoption" + % [elapsed, ClientConfigurator.http_port()] + ) + _host._stop_server_watch() + _server_pid = -1 + ## Clear the spawn guard so the re-walk isn't GUARDED away. The + ## walk's adopt arm re-sets it and fixes the stale token/record + ## (external adoption drops both; managed adoption re-records). + _host._server_started_this_session = false + ## Fire-and-forget (mirrors force_restart_server): the walk is a + ## coroutine in production; its continuation lives on the manager. + start_server() + return + ## #805: the re-adopt budget is spent and a live godot-ai still + ## answers while our spawns keep dying — a flapping occupant + ## (another editor's server starting/stopping under it). Re-walking + ## or respawning can only repeat the cycle; latch a terminal + ## diagnosis that names the actual conflict. Reload Plugin (a fresh + ## walk) refreshes the budget for a deliberate retry. + _server_exit_ms = elapsed + _server_status_message = ( + "The spawned server keeps exiting while another godot-ai server " + + "answers on port %d, and re-adoption was already attempted. " + + "Another editor may be repeatedly starting/stopping a server on " + + "this port. Stop the other process or pick a different port, " + + "then click Reload Plugin." + ) % ClientConfigurator.http_port() + set_terminal_diagnosis(McpServerStateScript.CRASHED) + disarm_version_check() + _host._update_process_enabled() + _host._log_buffer.log(str(_server_status_message)) + push_warning("MCP | %s" % _server_status_message) + _host._stop_server_watch() + return + var conflict := _diagnose_spawn_port_conflict(live) + if not conflict.is_empty(): + _server_exit_ms = elapsed + _server_status_message = str(conflict.get("message", "")) + _conflict_port = int(conflict.get("port", 0)) + set_terminal_diagnosis(McpServerStateScript.FOREIGN_PORT) + disarm_version_check() + _host._update_process_enabled() + _host._log_buffer.log(str(_server_status_message)) + push_warning("MCP | %s" % _server_status_message) + _host._stop_server_watch() + return + if bool(_host._should_retry_with_refresh()): + _refresh_retried = true + respawn_with_refresh() + return + _server_exit_ms = elapsed + ## Generic crash: clear any stale per-state message so the dock's + ## CRASHED body falls back to its launch-mode copy instead of text + ## from an earlier diagnosis. + _server_status_message = "" + set_terminal_diagnosis(McpServerStateScript.CRASHED) + disarm_version_check() + _host._update_process_enabled() + _host._log_buffer.log("server exited after %dms — see Godot output log" % int(_server_exit_ms)) + _host._stop_server_watch() + + +## #647: post-crash port-conflict probe. Returns `{}` when no foreign +## conflict is detected (fall through to the CRASHED / retry path), or +## `{"message": String, "port": int}` when the HTTP or WS port is held by +## a process we can't identify as godot-ai. An occupant that *does* +## identify as godot-ai is deliberately not diagnosed here — that's the +## stale-server / adoption territory handled by `_diagnose_spawn_fast_exit`'s +## re-adopt arm (or the next `start_server` walk), not a foreign conflict. +## `pre_probed_live`: an HTTP status snapshot the caller already has on +## hand; non-empty skips the internal ~500ms probe (the probe helper never +## returns a bare `{}`, so the sentinel is unambiguous). +func _diagnose_spawn_port_conflict(pre_probed_live: Dictionary = {}) -> Dictionary: + var http_port := ClientConfigurator.http_port() + if bool(_host._is_port_in_use(http_port)): + var live: Dictionary = ( + pre_probed_live + if not pre_probed_live.is_empty() + else _host._probe_live_server_status_for_port(http_port) + ) + if _live_status_identifies_godot_ai(live): + return {} + return { + "message": ( + "Port %d is in use by another application. Stop it or change " + + "the port in Editor Settings (godot_ai/http_port)." + ) % http_port, + "port": http_port, + } + var ws_port := int(_host._resolved_ws_port) + if ws_port > 0 and bool(_host._is_port_in_use(ws_port)): + return { + "message": ( + "WebSocket port %d is in use by another application. Stop it " + + "or change the port in Editor Settings (godot_ai/ws_port)." + ) % ws_port, + "port": ws_port, + } + return {} + + +## Retry the spawn with uvx `--refresh` prepended (PyPI index can lag a +## fresh publish ~10 min — #172). One-shot per session via _refresh_retried. +func respawn_with_refresh() -> void: + _host._startup_trace_count("server_command_discovery") + var server_cmd := ClientConfigurator.get_server_command(true) + if server_cmd.is_empty(): + return + var cmd: String = server_cmd[0] + var args: Array[String] = [] + args.assign(server_cmd.slice(1)) + args.append_array(_host._build_server_flags(ClientConfigurator.http_port(), int(_host._resolved_ws_port))) + _host._clear_pid_file() + _host._log_buffer.log("retrying with --refresh (PyPI index may be stale)") + var injected_telemetry_env := _inject_telemetry_env() + ## Set owner PID for THIS spawn too (don't rely on it lingering from + ## start_server) — and unset right after, same scoping as start_server. + var owner_env_set := _set_owner_pid_env() + _set_plugin_spawned_env() + var keep_alive_env_set := _set_keep_alive_env() + var ws_token := _set_ws_token_env() + _server_pid = OS.create_process(cmd, args) + if owner_env_set: + OS.unset_environment("GODOT_AI_OWNER_PID") + OS.unset_environment("GODOT_AI_PLUGIN_SPAWNED") + if keep_alive_env_set: + OS.unset_environment("GODOT_AI_NO_IDLE_EXIT") + OS.unset_environment("GODOT_AI_WS_TOKEN") + if injected_telemetry_env: + OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") + var spawn_pid := int(_server_pid) + if spawn_pid > 0: + _server_spawn_ms = Time.get_ticks_msec() + _server_exit_ms = 0 + _server_pid_published_elapsed_ms = 0 + _spawn_dead_since_ms = 0 + _server_keep_alive = keep_alive_env_set + var current_version := _expected_server_version() + _host._set_ws_auth_token(ws_token) + _host._write_managed_server_record(spawn_pid, current_version, _server_keep_alive) + print("MCP | retried server (PID %d, v%s): %s %s" % [spawn_pid, current_version, cmd, " ".join(args)]) + else: + ## OS.create_process returned -1 on the retry — surface CRASHED + ## rather than loop. `_refresh_retried` is already true. + _server_status_message = "" + set_terminal_diagnosis(McpServerStateScript.CRASHED) + disarm_version_check() + _host._update_process_enabled() + _host._log_buffer.log("refresh retry failed to spawn — see Godot output log") + _host._stop_server_watch() + + +func adopt_compatible_server( + record_version: String, + current_version: String, + owner: int, + record_owns_listener: bool = false +) -> String: + _server_actual_name = "godot-ai" + _can_recover_incompatible = false + ## #805: adoption (managed or external) is a proven recovery — the + ## session now has a live compatible server. Refresh the fast-exit + ## re-adopt budget so a later, unrelated port race can heal again. + _readopt_after_spawn_exit_retried = false + ## The stale-recovery episode (if any) is over: leftover authorized + ## rounds must not carry into a later, unrelated walk where they could + ## weak-proof-kill a server the user deliberately started. + _stale_recovery_budget = 0 + if record_version == current_version and owner > 0 and record_owns_listener: + ## Managed adoption keeps the record's token (loaded into + ## _ws_auth_token at plugin startup) — the running server was + ## spawned with it and still verifies against it (#690). Version + ## equality alone is deliberately insufficient: the record must also + ## identify the live branded listener (#759/#764). + _server_pid = owner + ## Recover the keep-alive launch flag from the record the spawning + ## session persisted — a keep-alive survivor adopted here must + ## detach again on THIS session's exit, and only the record knows + ## how the process was actually launched. + _server_keep_alive = bool(_host._read_managed_server_record().get("keep_alive", false)) + _host._write_managed_server_record(owner, current_version, _server_keep_alive) + return McpAdoptionLabelScript.MANAGED + _server_pid = -1 + _server_keep_alive = false + ## External server: we didn't spawn it and don't know its token (it + ## most likely has none — dev servers aren't launched with one). Drop + ## ours so the handshake omits the field instead of sending a stale + ## token the server would reject. + _host._set_ws_auth_token("") + _host._clear_managed_server_record() + _host._clear_pid_file() + return McpAdoptionLabelScript.EXTERNAL + + +static func _compatible_adoption_log_message( + owner_label: String, + owned_pid: int, + observed_owner_pid: int, + live_version: String, + live_ws_port: int, + current_version: String +) -> String: + if owner_label == McpAdoptionLabelScript.MANAGED: + return "MCP | adopted managed server (PID %d, live v%s, WS %d, plugin v%s)" % [ + owned_pid, + live_version, + live_ws_port, + current_version + ] + return "MCP | adopted external server owner_pid=%d (live v%s, WS %d, plugin v%s)" % [ + observed_owner_pid, + live_version, + live_ws_port, + current_version + ] + + +## Single-owner recovery transaction for the contended startup walk. Strong +## ownership proof gets first refusal; only a user-authorized, verified version +## drift may fall back to weak proof. Keeping the flag across both awaits is +## what lets the WS handshake path coalesce instead of cancelling this walk. +func _recover_startup_port_occupant( + port: int, + live: Dictionary, + live_version: String, + current_version: String, + async_gen: int, +) -> bool: + var owns_recovery := not _recovery_in_flight + var is_owned_startup := ( + _recovery_in_flight and async_gen == _recovery_owned_startup_generation + ) + if not owns_recovery and not is_owned_startup: + return false + if owns_recovery: + _recovery_in_flight = true + ## Forward `live` so the strong proof helper reuses our snapshot. The kill + ## invalidates it; every later consumer re-probes before using live status. + var recovered: bool = await recover_strong_port_occupant(port, 3.0, live) + if not _async_stale(async_gen) and not recovered and _stale_recovery_available( + live_version, current_version + ): + _stale_recovery_budget -= 1 + print( + "MCP | stale godot-ai v%s holds port %d — replacing it (update/recovery authorized, %d retry round(s) left)" + % [live_version, port, _stale_recovery_budget] + ) + recovered = await _recover_stale_port_occupant_impl(port, 3.0) + ## A nested recovery-owned startup must leave the outer transaction armed; + ## only the path that acquired ownership here may release it. + if owns_recovery: + _recovery_in_flight = false + return recovered + + +## `pre_kill_live` is forwarded into the proof helper so it doesn't +## re-probe a port the caller already probed. The kill invalidates the +## snapshot — callers MUST re-probe before consuming live-status data +## after this returns. +## +## #678: coroutine in production — the proof evaluation (port scrapes + +## per-PID brand shells) and the kill + port-drain wait run off the main +## thread. The EditorSettings record is read on the main thread up front +## and injected into the proof helper; record/pid-file clears stay on the +## main thread after the awaits. +func recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool: + var async_gen := _async_generation + var record: Dictionary = _host._read_managed_server_record() + var proof_result: Variant = await _run_blocking(func() -> Variant: + if not is_instance_valid(_host): + return {"proof": "", "pids": []} + return _host._evaluate_strong_port_occupant_proof(port, pre_kill_live, record) + ) + if _async_stale(async_gen) or proof_result == null: + return false + var proof: Dictionary = proof_result + var targets: Array[int] = [] + targets.assign(proof.get("pids", [])) + if targets.is_empty(): + return false + + print("MCP | strong proof: %s" % str(proof.get("proof", ""))) + var freed_result: Variant = await _run_blocking(func() -> Variant: + if not is_instance_valid(_host): + return false + ## verify_brand=true: the proof above ran in a separate _run_blocking + ## task with main-thread frames in between — re-check each target at + ## kill time so a PID recycled inside that gap isn't killed (#686). + var killed: Array = _host._kill_processes_and_windows_spawn_children(targets, true) + if not killed.is_empty(): + print("MCP | killed pids %s on port %d" % [str(killed), port]) + _host._wait_for_port_free(port, wait_s) + return not bool(_host._is_port_in_use(port)) + ) + if _async_stale(async_gen) or freed_result == null: + return false + if not bool(freed_result): + return false + + _host._clear_managed_server_record() + _host._clear_pid_file() + return true + + +## Weak-proof sibling of `recover_strong_port_occupant`, reserved for the +## bounded stale-version episode (`_stale_recovery_budget` — callers gate on +## `_stale_recovery_available` and spend a round BEFORE calling). Uses the +## recovery-tier proof (`status_name` allowed) because the target is an +## external previous-version server with no managed record or pid-file to +## prove strong ownership with — the same authority the dock's recovery +## click exercises, here spent under an equally explicit user action (the +## Update click / a recovery click's retry). The kill still brand-verifies +## every PID at kill time (#686), so a recycled or unrelated PID on the +## port is never killed. No pre-probed `live` parameter on purpose: a prior +## failed strong recovery may have partially changed the port's state, so +## the worker re-probes — and re-verifies STALENESS on that fresh snapshot +## (#890 CodeRabbit): the caller's version gate ran on an earlier probe, +## and a same-version server that took the port in the interval must abort +## this recovery, not be killed by it. +func recover_stale_port_occupant(port: int, wait_s: float) -> bool: + if _recovery_in_flight: + return false + _recovery_in_flight = true + var recovered := await _recover_stale_port_occupant_impl(port, wait_s) + _recovery_in_flight = false + return recovered + + +func _recover_stale_port_occupant_impl(port: int, wait_s: float) -> bool: + var async_gen := _async_generation + var record: Dictionary = _host._read_managed_server_record() + ## Resolved on the main thread: the worker must not touch EditorSettings. + var expected_version := _expected_server_version() + var proof_result: Variant = await _run_blocking(func() -> Variant: + if not is_instance_valid(_host): + return {"proof": "", "pids": []} + var live: Dictionary = _host._probe_live_server_status_for_port(port) + var live_version := str(_host._verified_status_version(live)) + if live_version.is_empty() or live_version == expected_version: + return {"proof": "", "pids": []} + return _host._evaluate_recovery_port_occupant_proof(port, live, record) + ) + if _async_stale(async_gen) or proof_result == null: + return false + var proof: Dictionary = proof_result + var targets: Array[int] = [] + targets.assign(proof.get("pids", [])) + if targets.is_empty(): + return false + + print("MCP | stale-occupant proof: %s" % str(proof.get("proof", ""))) + var freed_result: Variant = await _run_blocking(func() -> Variant: + if not is_instance_valid(_host): + return false + ## Warm the current-version uv env in parallel with the kill + port + ## drain below (#890 review P1): on the release that first ships this + ## code the OLD updater ran the update, so no click-time pre-warm + ## happened and the respawn would resolve cold — exactly the race + ## this episode exists to win. Detached fire-and-forget via the host + ## seam (stubbed in tests); worker-safe (CliFinder is mutex-guarded, + ## no EditorSettings access). + _host._prewarm_server_package(expected_version) + var killed: Array = _host._kill_processes_and_windows_spawn_children(targets, true) + if not killed.is_empty(): + print("MCP | killed pids %s on port %d" % [str(killed), port]) + _host._wait_for_port_free(port, wait_s) + return not bool(_host._is_port_in_use(port)) + ) + if _async_stale(async_gen) or freed_result == null: + return false + if not bool(freed_result): + return false + + _host._clear_managed_server_record() + _host._clear_pid_file() + return true + + +## Editor-exit teardown chooser (#800): detach only when the RUNNING +## server was launched keep-alive (_server_keep_alive, set at spawn / +## recovered on adoption) — never on the live setting, which may have +## been toggled after spawn. Flag clear → stop_server kills as always, +## so enabling the setting mid-session takes effect on the next server +## start instead of leaving a record that points at a soon-reaped PID. +func teardown_for_editor_exit() -> void: + if _server_keep_alive: + detach_server() + return + ## #824: a backend we spawned may be keeping one or more MCP clients alive + ## through their `godot-ai attach` bridges. Killing it because *this* editor + ## is closing takes the server out from under them: an in-flight call can + ## become TRANSPORT_OUTCOME_UNKNOWN, and every bridge has to establish a new + ## backend before the next editor can reconnect. A live lease means the + ## backend has consumers beyond this editor, so hand it over instead. + var leased := active_lease_count_at_exit() + if leased > 0: + ## Give up kill authority along with the process: dropping the managed + ## record means the next editor adopts it through the external branch + ## rather than as a managed server it may kill. The server's own + ## pid-file is deliberately left in place — it is the backend's + ## publication, not our claim on it, and adoption reads it. + ## + ## The Python side remains the reaper of record: a plugin-spawned + ## backend keeps its idle backstop armed (only keep_server_on_exit + ## disarms it) and that backstop is lease-aware, so this defers the + ## stop to "no editors AND no leases AND grace elapsed" rather than + ## leaking the process. + _host._clear_managed_server_record() + detach_server( + "detaching server: %d attach lease(s) still held, leaving it to the " + % leased + + "server's own idle reaper" + ) + return + stop_server() + + +## Active attach-bridge leases on the backend this editor manages, or 0 when +## there is nothing to consult (#824). +## +## Returns 0 — preserving the historical kill-on-exit behavior — for every +## uncertain case: no managed PID, a probe that fails or times out, a server +## that does not identify as godot-ai, or one too old to publish the field. +## That direction is deliberate. A false 0 costs what today already costs +## (the backend is stopped and bridges reconnect); a false positive would +## leave a process running on a guess. +## +## Bounded by the status probe's own timeout (SERVER_STATUS_PROBE_TIMEOUT_MS), +## which is what keeps editor exit from hanging on a wedged HTTP server. +func active_lease_count_at_exit() -> int: + var pid := int(_server_pid) + if pid <= 0: + return 0 + ## Only a process we can still prove is our godot-ai server earns the + ## benefit of the doubt. The lease count comes from whoever answers on the + ## port, which is not by itself proof that it IS the process we are about + ## to stop — another editor's backend, or an attach-owned one, could hold + ## the port after ours died. Requiring the same alive+branded proof + ## `stop_server` uses before its kill closes that gap: without it, a + ## stranger's leases could talk this editor out of stopping its own server. + ## + ## Failing this check is harmless either way. A dead PID has nothing to + ## kill, and a recycled-but-unbranded PID is rejected by stop_server's own + ## gate (#686) — both land on the historical path. + if not _host._pid_alive_for_proof(pid): + return 0 + if not _host._pid_cmdline_is_godot_ai_for_proof(pid): + return 0 + return active_lease_count( + _host._probe_live_server_status_for_port(ClientConfigurator.http_port()) + ) + + +## Read the advisory lease count out of a `/godot-ai/status` payload. +## +## Gated on the payload identifying as godot-ai, so an unrelated process +## answering on the port cannot talk this editor out of a clean stop. A +## missing field means an older backend that predates #824; it reads as 0, +## which keeps that pairing on today's behavior. +static func active_lease_count(live: Dictionary) -> int: + if not _live_status_identifies_godot_ai(live): + return 0 + var raw: Variant = live.get("active_lease_count") + if raw == null: + return 0 + return maxi(0, int(raw)) + + +## keep_server_on_exit (#800): editor teardown that leaves the server +## running. Mirrors stop_server's bookkeeping — cancel in-flight async +## startup, stop the watch, settle on STOPPED — but kills nothing and +## PRESERVES the managed-server record + pid-file, so the next editor +## session's start_server walk adopts the survivor through the existing +## record-matches branch (#758/#774). Explicit stops (dock Restart, +## update reload) still route through stop_server and kill as before. +## `log_reason` names why the server is being left alive; the default is the +## keep_server_on_exit wording this function was written for. #824 reuses the +## same bookkeeping for the active-lease handover, and a shared log line would +## have reported the wrong cause for it. +func detach_server( + log_reason: String = "keep_server_on_exit: leaving server running" +) -> void: + _invalidate_async_startup() + _host._stop_server_watch() + var detached_pid := int(_server_pid) + _server_pid = -1 + _server_pid_published_elapsed_ms = 0 + transition_state(McpServerStateScript.STOPPED) + if detached_pid > 0: + print("MCP | %s (PID %d)" % [log_reason, detached_pid]) + + +func stop_server() -> void: + ## Cancel any in-flight async startup (#678): a suspended start_server + ## resuming after teardown must not resurrect state or spawn a server. + _invalidate_async_startup() + _host._stop_server_watch() + if int(_server_pid) <= 0: + transition_state(McpServerStateScript.STOPPED) + return + transition_state(McpServerStateScript.STOPPING) + ## Kill the tracked PID AND the real Python PID — they differ for the + ## uvx tier (the launcher exits before its child) and on Windows + ## `OS.kill` is `TerminateProcess` which doesn't walk the child tree. + var port := ClientConfigurator.http_port() + var killed: Array = [] + var candidates: Array[int] = [] + ## Re-verify the tracked PID at kill time (#686): nothing clears + ## `_server_pid` when the server dies mid-session (`check_server_health` + ## stops watching after SERVER_WATCH_MS), so hours later the kernel may + ## have recycled this PID to an unrelated process. Every other candidate + ## in this function is brand-gated; the tracked seed must be too. A false + ## negative is fail-safe: the port stays held and the record is preserved, + ## so the next start_server's drift branch retries the kill. + var tracked_pid := int(_server_pid) + if ( + tracked_pid > 0 + and _host._pid_alive_for_proof(tracked_pid) + and _host._pid_cmdline_is_godot_ai_for_proof(tracked_pid) + ): + candidates.append(tracked_pid) + var real_pid := int(_host._find_managed_pid(port)) + ## Add the real Python PID only if it isn't already tracked and proves out + ## as ours — re-appending an already-present PID just produces a duplicate + ## kill candidate. + if real_pid > 0 and not candidates.has(real_pid) and _host._pid_cmdline_is_godot_ai_for_proof(real_pid): + candidates.append(real_pid) + var listener_pids: Array = _host._find_all_pids_on_port(port) + for pid in listener_pids: + var listener_pid := int(pid) + if candidates.has(listener_pid): + continue + if _host._pid_cmdline_is_godot_ai_for_proof(listener_pid): + candidates.append(listener_pid) + killed = _host._kill_processes_and_windows_spawn_children(candidates) + if not killed.is_empty(): + print("MCP | stopped server (PID %s)" % str(killed)) + _server_pid = -1 + _server_keep_alive = false + _server_pid_published_elapsed_ms = 0 + _host._wait_for_port_free(port, 2.0) + ## Preserve record/pid-file when port is still held — the drift + ## branch on the next start_server retries the kill (#159 follow-up). + _host._finalize_stop_if_port_free(port) + transition_state(McpServerStateScript.STOPPED) + + ## Server's `_pydantic_core.pyd` hard-link is now released — sweep + ## stale uvx builds before they trip the next attach launcher. + UvCacheCleanup.purge_stale_builds() + + +## Kill the server, reset the re-entrancy guard so the re-enabled plugin +## spawns fresh (#132). User-mode only kills via strong proof. +func prepare_for_update_reload() -> void: + stop_server() + _host._server_started_this_session = false + if ClientConfigurator.is_dev_checkout(): + return + + var port := ClientConfigurator.http_port() + if not bool(_host._is_port_in_use(port)): + return + + var proof: Dictionary = _host._evaluate_strong_port_occupant_proof(port) + var targets: Array[int] = [] + targets.assign(proof.get("pids", [])) + if targets.is_empty(): + return + + _host._kill_processes_and_windows_spawn_children(targets) + _host._wait_for_port_free(port, 3.0) + if not bool(_host._is_port_in_use(port)): + _host._clear_managed_server_record() + _host._clear_pid_file() + + +# ---- Recovery click ---------------------------------------------------- + +## Returns true when a pure-state probe says recovery is allowed: +## current state is INCOMPATIBLE, the port is still held, and the +## incompatible diagnosis latched an ownership proof. Pure-state in the +## sense that nothing is killed — that's `recover_incompatible_server`. +## +## Consults the `_can_recover_incompatible` verdict that +## `_set_incompatible_server` computed off-thread instead of re-running +## the proof's port scrapes + per-PID brand shells on the main thread +## (#712): the dock polls this on refresh, and +## `recover_incompatible_server` re-proves at kill time anyway, so a +## stale latch can never kill an unproven occupant — worst case is a +## recovery click that comes back false. The port liveness re-check is +## a single local bind probe, cheap enough to stay synchronous. +func can_recover_incompatible_server() -> bool: + if _server_state != McpServerStateScript.INCOMPATIBLE: + return false + if not _can_recover_incompatible: + return false + return bool(_host._is_port_in_use(ClientConfigurator.http_port())) + + +func recover_incompatible_server(stale_version: String = "") -> bool: + if _recovery_in_flight: + return false + _recovery_in_flight = true + var recovered := await _recover_incompatible_server_impl(stale_version) + _recovery_in_flight = false + return recovered + + +func _recover_incompatible_server_impl(stale_version: String = "") -> bool: + if _server_state != McpServerStateScript.INCOMPATIBLE: + return false + + var port := ClientConfigurator.http_port() + ## Cancel any suspended contended-port walk BEFORE the off-thread proof + ## (#712): `_run_blocking` tracks a single active worker for the + ## teardown join, so starting ours while another walk's worker is alive + ## would orphan that thread from the join guarantee. This also releases + ## the guard so the respawn at the bottom isn't silently swallowed + ## (#682 review). The user's recovery click owns the flow from here. + _invalidate_async_startup() + var async_gen := _async_generation + ## EditorSettings record read on the main thread, injected so the + ## worker never touches EditorSettings (#712, mirroring + ## recover_strong_port_occupant). + var record: Dictionary = _host._read_managed_server_record() + var proof_result: Variant = await _run_blocking(func() -> Variant: + if not is_instance_valid(_host): + return {"proof": "", "pids": []} + var live: Dictionary = {} + if not stale_version.is_empty(): + ## Automatic recovery was authorized by a handshake from this exact + ## old version. Re-probe at proof time and refuse if that occupant has + ## already been replaced; a delayed old handshake must never target the + ## freshly-started current server via its new pid-file proof. + live = _host._probe_live_server_status_for_port(port) + var current_live_version := str(_host._verified_status_version(live)) + if current_live_version != stale_version: + return {"proof": "", "pids": []} + return _host._evaluate_recovery_port_occupant_proof(port, live, record) + ) + if _async_stale(async_gen) or proof_result == null: + return false + var proof: Dictionary = proof_result + var targets: Array[int] = [] + targets.assign(proof.get("pids", [])) + if targets.is_empty(): + return false + print("MCP | proof: %s" % str(proof.get("proof", ""))) + + ## Move into STOPPING so the post-kill respawn passes the + ## first-writer-wins guards. + transition_state(McpServerStateScript.STOPPING) + ## Main-thread read for the worker below (EditorSettings-backed). + var recovery_expected_version := _expected_server_version() + var freed_result: Variant = await _run_blocking(func() -> Variant: + if not is_instance_valid(_host): + return false + ## Warm the current-version uv env in parallel with the kill + drain + ## so the respawn below wins the bind race even when no click-time + ## pre-warm ran (#890 review P1 — the transition release's update runs + ## the OLD updater). Same rationale and seam as + ## recover_stale_port_occupant. + _host._prewarm_server_package(recovery_expected_version) + ## verify_brand=true: the proof above ran in a separate + ## _run_blocking task with main-thread frames in between — re-check + ## each target at kill time so a PID recycled inside that gap isn't + ## killed (#686, mirroring recover_strong_port_occupant). + var killed: Array = _host._kill_processes_and_windows_spawn_children(targets, true) + if not killed.is_empty(): + print("MCP | killed pids %s on port %d" % [str(killed), port]) + _host._wait_for_port_free(port, 5.0) + return not bool(_host._is_port_in_use(port)) + ) + if _async_stale(async_gen) or freed_result == null: + return false + if not bool(freed_result): + ## Kill failed; re-latch INCOMPATIBLE so the dock keeps the + ## diagnostic UI. + transition_state(McpServerStateScript.INCOMPATIBLE) + return false + + UvCacheCleanup.purge_stale_builds() + _host._clear_managed_server_record() + _host._clear_pid_file() + transition_state(McpServerStateScript.STOPPED) + _connection_blocked = false + _server_status_message = "" + _conflict_port = 0 + _server_actual_version = "" + _server_actual_name = "" + _can_recover_incompatible = false + _host._server_started_this_session = false + _server_pid = -1 + _server_pid_published_elapsed_ms = 0 + ## Await the respawn walk: the plugin gates its connection unblock on + ## the post-walk state (SPAWNING/READY), so returning true while the + ## walk is still suspended would leave the connection blocked forever + ## after a successful recovery click (#682 review). + ## NOTE: arming the stale-recovery budget for the click lives in the + ## plugin wrapper (`plugin.gd::recover_incompatible_server`), NOT here — + ## the automatic handshake-mismatch trigger reuses this manager flow and + ## must only SPEND budget; re-arming inside it would unbound the + ## kill/respawn loop against a persistent bridge respawner. + ## Keep the outer transaction armed while the respawn walk runs so stale WS + ## handshakes and unrelated recovery calls still coalesce. Grant only this + ## owned startup walk permission to spend a remaining recovery round if the + ## old attach bridge reclaims the port between the kill and the respawn. + var owned_startup_gen := _async_generation + _recovery_owned_startup_generation = owned_startup_gen + await start_server() + if _recovery_owned_startup_generation == owned_startup_gen: + _recovery_owned_startup_generation = -1 + return true + + +## Restart authorisation — a live PID means we spawned/adopted, a +## non-empty managed record is the cross-session proof used by the +## drift branch. +func can_restart_managed_server() -> bool: + if _server_pid > 0: + return true + var record: Dictionary = _host._read_managed_server_record() + return not str(record.get("version", "")).is_empty() + + +func has_managed_server() -> bool: + return _server_pid > 0 + + +## Reset state for a force-restart. Drops the managed record, clears +## the pid-file, and resets the spawn guard so the follow-up +## `start_server()` walks the spawn arm. +func reset_for_force_restart() -> void: + ## The user's explicit restart takes over: cancel any suspended + ## contended-port walk and release the re-entrancy guard so the + ## follow-up start isn't silently swallowed (#682 review). + _invalidate_async_startup() + _host._clear_managed_server_record() + _host._clear_pid_file() + _host._server_started_this_session = false + _server_pid = -1 + transition_state(McpServerStateScript.UNINITIALIZED) + + +## Ownership-checked kill of the port occupant + respawn. Driven from +## the dock's "Restart Server" button when the plugin adopted a foreign +## server whose version drifted from the plugin. +func force_restart_server() -> void: + if not can_restart_managed_server(): + push_warning("MCP | refusing to kill server on port %d without managed-server ownership proof" + % ClientConfigurator.http_port()) + return + var port := ClientConfigurator.http_port() + ## Kill every LISTENER on the port, not just the first one. A dev + ## server run via `uvicorn --reload` owns port 8000 through both a + ## reloader parent AND a worker child — killing only one (or zero, + ## if the single-pid parse fell over on multi-line lsof output) leaves + ## the other holding the port past `_wait_for_port_free`'s window. + ## + ## Brand-gate each raw listener PID (#686): `can_restart_managed_server()` + ## only proves we once managed *a* server, not that the port's current + ## occupants are ours — an adopted server that exited on its own can be + ## replaced on the port by an unrelated dev tool before the user clicks + ## Restart. Unbranded PIDs fall through to `_set_incompatible_server` + ## below instead of being killed. + transition_state(McpServerStateScript.STOPPING) + var restart_targets: Array[int] = [] + for pid in _host._find_all_pids_on_port(port): + var listener_pid := int(pid) + if _host._pid_cmdline_is_godot_ai_for_proof(listener_pid): + restart_targets.append(listener_pid) + _host._kill_processes_and_windows_spawn_children(restart_targets) + _host._wait_for_port_free(port, 5.0) + if _host._is_port_in_use(port): + ## Kill failed; clean baseline for the follow-up + ## `_set_incompatible_server`. + transition_state(McpServerStateScript.UNINITIALIZED) + _set_incompatible_server( + _host._probe_live_server_status_for_port(port), + _expected_server_version(), + port + ) + return + ## Same rationale as `stop_server`: the server child python just + ## released its `pydantic_core` mapping, so this is the only window in + ## which the hard-linked copies under `builds-v0\.tmp*` are deletable. + ## Sweep before respawning so the next uvx attach build doesn't + ## inherit the same cleanup-failure path that triggered the restart. + UvCacheCleanup.purge_stale_builds() + reset_for_force_restart() + start_server() diff --git a/addons/godot_ai/utils/server_lifecycle.gd.uid b/addons/godot_ai/utils/server_lifecycle.gd.uid new file mode 100644 index 0000000..8e62667 --- /dev/null +++ b/addons/godot_ai/utils/server_lifecycle.gd.uid @@ -0,0 +1 @@ +uid://bwfx8b0w2mgf6 diff --git a/addons/godot_ai/utils/server_version_check.gd b/addons/godot_ai/utils/server_version_check.gd new file mode 100644 index 0000000..4bb1371 --- /dev/null +++ b/addons/godot_ai/utils/server_version_check.gd @@ -0,0 +1,126 @@ +@tool +class_name McpServerVersionCheck +extends RefCounted + +## Standalone polling seam for the post-connection server-version +## handshake gate. Extracted from `plugin.gd` so the lifecycle manager +## stays focused on spawn/adopt/stop and the version-verify dance has +## its own home. +## +## The seam itself does NOT transition `McpServerState` on arm/disarm — +## the version check runs concurrently with whatever spawn-state the +## caller had latched (typically FOREIGN_PORT during adoption +## confirmation, or no-op directly to READY for a fresh spawn). Result +## transitions land on the manager via `handle_server_version_verified` +## (READY / INCOMPATIBLE) or `handle_server_version_unverified` +## (INCOMPATIBLE on deadline expiry); arm() leaves the state alone so a +## FOREIGN_PORT diagnosis isn't accidentally cleared before the +## handshake actually arrives. +## +## Owns the deadline timer (`_deadline_ms`) and requires the manager to +## feed it `tick(now_msec)` from the plugin's `_process` while +## `is_active()` is true. +## +## Decoupled from the connection's signal surface: `tick()` polls +## `_connection.is_connected` and `_connection.server_version` directly. +## A same-release signal addition plus a new consumer is shape-coupled work +## for old two-phase runners; they can parse the consumer while the +## McpConnection Script object still reflects v(N). We still null-check +## `_connection` because `disarm()` releases it. + +## How long to wait after the WebSocket opens before declaring the +## handshake_ack overdue. This is the sole owner of the 5s budget +## — kept at this layer so the version-check seam is self-contained. +const TIMEOUT_MS := 5 * 1000 + +## Untyped on purpose for the same self-update field-storage reason +## plugin.gd's fields are untyped. `_connection` is the live +## `McpConnection`; `_manager` is `McpServerLifecycleManager`. +## `_connection` is null between disarm() and the next arm() — the +## seam can spend most of the plugin's life dormant and we don't want +## to pin a Node that may be queue_freed in `_exit_tree`. `_manager` is +## set once at construction and held for the seam's lifetime (the +## manager owns this instance, so the cycle is short). +var _connection +var _manager +var _active: bool = false +var _deadline_ms: int = 0 +var _expected_version: String = "" + + +func _init(manager) -> void: + _manager = manager + + +## Arm the version-check. Marks the seam active, (re)attaches the +## connection it should poll, and starts watching for +## `_connection.server_version`. Does NOT transition manager state — +## the version check runs concurrently with whatever spawn-state was +## latched (e.g. FOREIGN_PORT during adoption confirmation, READY for +## a fresh spawn). Result transitions land on the manager via +## `handle_server_version_verified` / `_unverified` once the handshake +## (or its deadline) lands. +## +## The deadline starts the moment the connection actually opens, not at +## arm-time, because uvx cold-starts can take ~30s to bind the +## WebSocket and we don't want to count that against the handshake. +func arm(connection, expected_version: String) -> void: + _active = true + _deadline_ms = 0 + _expected_version = expected_version + _connection = connection + + +## Disarm without firing a verdict. Used when the manager moves on +## (e.g. recovery click → STOPPING). Releases the connection / +## manager references so the seam doesn't pin them past the active +## window — the plugin can spend most of its life with the version +## check disarmed, and `_connection` is a Node that may be queue_free'd +## by `_exit_tree`. Caller has already transitioned state, so we don't +## touch the manager. +func disarm() -> void: + _active = false + _deadline_ms = 0 + _connection = null + + +## True while the version-check needs `_process` ticks. Plugin uses +## this to gate `set_process(true)`. +func is_active() -> bool: + return _active + + +## Per-frame tick from the plugin's `_process`. No-op when disarmed. +## Returns true when the check finished this tick (verified or +## unverified) so the plugin can re-evaluate `set_process` enable. +func tick(now_msec: int) -> bool: + if not _active: + return false + if _connection == null: + return false + if not bool(_connection.is_connected): + return false + if _deadline_ms == 0: + _deadline_ms = now_msec + TIMEOUT_MS + var server_version := str(_connection.server_version) + if not server_version.is_empty(): + _complete_with_version(server_version) + return true + if now_msec >= _deadline_ms: + _complete_unverified() + return true + return false + + +func _complete_with_version(version: String) -> void: + _active = false + _deadline_ms = 0 + if _manager != null: + _manager.handle_server_version_verified(_expected_version, version) + + +func _complete_unverified() -> void: + _active = false + _deadline_ms = 0 + if _manager != null: + _manager.handle_server_version_unverified(_expected_version) diff --git a/addons/godot_ai/utils/server_version_check.gd.uid b/addons/godot_ai/utils/server_version_check.gd.uid new file mode 100644 index 0000000..7baacfd --- /dev/null +++ b/addons/godot_ai/utils/server_version_check.gd.uid @@ -0,0 +1 @@ +uid://ciqldbuaq8i8u diff --git a/addons/godot_ai/utils/settings.gd b/addons/godot_ai/utils/settings.gd new file mode 100644 index 0000000..9ce2c68 --- /dev/null +++ b/addons/godot_ai/utils/settings.gd @@ -0,0 +1,135 @@ +@tool +class_name McpSettings +extends RefCounted + +## Shared EditorSettings key constants for the godot_ai/* namespace. +## +## Centralised here so lightweight files (e.g. telemetry.gd) can reference +## settings keys without pulling in the full client_configurator.gd dep tree. +## All keys must keep their raw string values stable across releases because +## they are persisted in the user's editor_settings-4.tres. + +const SETTING_HTTP_PORT := "godot_ai/http_port" +## Comma-separated list of tool domains excluded from the server at spawn time. +const SETTING_EXCLUDED_DOMAINS := "godot_ai/excluded_domains" +const SETTING_TELEMETRY_ENABLED := "godot_ai/telemetry_enabled" +## Comma-separated CIDRs / bare IPs passed to the server as `--allow-host` +## at spawn time (#507, server core #421). Empty means loopback-only. +const SETTING_ALLOW_HOSTS := "godot_ai/allow_remote_hosts" +## Whether MCP log lines echo to the Godot console (dock "Log" toggle). +## The dock's ring-buffer log panel keeps recording regardless. +const SETTING_MCP_LOGGING := "godot_ai/mcp_logging" +## Which scope CLI-configured clients register the server in. Claude Code's +## `--scope user` writes the global block of ~/.claude.json, which that client +## loads in EVERY workspace — so the server gets spawned in unrelated, +## non-Godot projects. `project` writes /.mcp.json instead, so the +## entry travels with the project. Defaults to `user` to preserve the +## historical behaviour for existing installs. +const SETTING_CLIENT_SCOPE := "godot_ai/mcp_client_scope" + +## Scopes accepted for SETTING_CLIENT_SCOPE. These are the values the Claude +## Code CLI's `--scope` flag takes; an unrecognised setting falls back to +## DEFAULT_CLIENT_SCOPE rather than passing junk to the CLI. +const CLIENT_SCOPES := ["user", "project", "local"] +const DEFAULT_CLIENT_SCOPE := "user" + + +## Returns true if the string value is truthy +## ("1", "true", "yes", "on", case-insensitive, whitespace-trimmed). +static func truthy(value: String) -> bool: + return value.strip_edges().to_lower() in ["1", "true", "yes", "on"] + + +## Returns true if the named environment variable is set to a truthy value. +static func env_truthy(var_name: String) -> bool: + return truthy(OS.get_environment(var_name)) + + +## #691: EditorInterface / EditorSettings are not thread-safe, and the dock +## runs Configure / Remove on a worker Thread (mcp_dock.gd +## `_run_client_action_worker` -> McpClientConfigurator -> McpCliStrategy +## `_format_args` -> client_scope()). Same contract as +## McpClientConfigurator._editor_setting_lookup: the main thread does a live +## read and refreshes the snapshot; worker threads read the snapshot only, so +## a never-warmed value reads as null (unset) rather than touching +## EditorInterface off-thread. Warmed on the main thread by +## McpClientConfigurator.warm_env_snapshot() before each worker dispatch. +## +## Kept here rather than reusing the configurator's `_editor_setting_lookup` +## so utils/settings.gd stays free of the client_configurator.gd dep tree — +## see this file's header. +static var _client_scope_snapshot: Variant = null +static var _client_scope_mutex := Mutex.new() + + +static func _client_scope_setting() -> Variant: + if OS.get_thread_caller_id() == OS.get_main_thread_id(): + var live: Variant = null + if Engine.is_editor_hint(): + var es := EditorInterface.get_editor_settings() + if es != null and es.has_setting(SETTING_CLIENT_SCOPE): + live = es.get_setting(SETTING_CLIENT_SCOPE) + _client_scope_mutex.lock() + _client_scope_snapshot = live + _client_scope_mutex.unlock() + return live + _client_scope_mutex.lock() + var cached: Variant = _client_scope_snapshot + _client_scope_mutex.unlock() + return cached + + +## Main-thread pre-warm of the scope snapshot so the dock's Configure/Remove +## worker resolves `{scope}` from a value captured while EditorSettings access +## was safe. Idempotent; called from warm_env_snapshot(). +static func warm_client_scope() -> void: + _client_scope_setting() + + +## Returns the scope CLI clients should register the MCP server in, as +## substituted into `{scope}` in a descriptor's cli_register_template / +## cli_unregister_template. Safe to call from either thread (see +## _client_scope_setting). Unrecognised values fall back to +## DEFAULT_CLIENT_SCOPE so a hand-edited editor_settings-4.tres can never make +## the plugin shell out with a bad flag. +static func client_scope() -> String: + var value: Variant = _client_scope_setting() + ## String(null) is a hard error (#850) — an unset/never-warmed key must + ## short-circuit before the cast. + if value == null: + return DEFAULT_CLIENT_SCOPE + var raw := String(value).strip_edges().to_lower() + if raw in CLIENT_SCOPES: + return raw + return DEFAULT_CLIENT_SCOPE + + +## Returns true if telemetry should be active, checking in priority order: +## 1. GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY env vars +## 2. The godot_ai/telemetry_enabled EditorSetting written by the dock UI +## Defaults to true when neither source has set a preference. +static func telemetry_enabled() -> bool: + if env_truthy("GODOT_AI_DISABLE_TELEMETRY") or env_truthy("DISABLE_TELEMETRY"): + return false + var es := EditorInterface.get_editor_settings() + if es != null and es.has_setting(SETTING_TELEMETRY_ENABLED): + return bool(es.get_setting(SETTING_TELEMETRY_ENABLED)) + return true + + +## Returns whether MCP log lines should echo to the Godot console. Read at +## plugin startup (to apply the persisted choice to the log buffer and +## dispatcher) and by the dock's LogViewer toggle for its initial state. +## Defaults to true when the user has never touched the toggle. +static func mcp_logging_enabled() -> bool: + var es := EditorInterface.get_editor_settings() + if es != null and es.has_setting(SETTING_MCP_LOGGING): + return bool(es.get_setting(SETTING_MCP_LOGGING)) + return true + + +## Persist the dock "Log" toggle so the choice survives editor restarts (#626). +static func set_mcp_logging_enabled(enabled: bool) -> void: + var es := EditorInterface.get_editor_settings() + if es != null: + es.set_setting(SETTING_MCP_LOGGING, enabled) diff --git a/addons/godot_ai/utils/settings.gd.uid b/addons/godot_ai/utils/settings.gd.uid new file mode 100644 index 0000000..b8b1547 --- /dev/null +++ b/addons/godot_ai/utils/settings.gd.uid @@ -0,0 +1 @@ +uid://pefrtofs7ijw diff --git a/addons/godot_ai/utils/structured_log_ring.gd b/addons/godot_ai/utils/structured_log_ring.gd new file mode 100644 index 0000000..3e00b4e --- /dev/null +++ b/addons/godot_ai/utils/structured_log_ring.gd @@ -0,0 +1,156 @@ +@tool +class_name McpStructuredLogRing +extends RefCounted + +## Head-indexed circular buffer of structured log entries shared by +## game_log_buffer and editor_log_buffer. +## +## Once `_max_lines` (set in subclass `_init`) is reached, new appends +## overwrite the oldest slot at `_head`, keeping append O(1) on overflow +## — the previous slice() approach reallocated the full retained array +## on every drop, which a chatty game would pay for thousands of times +## per second. +## +## Lockless. Subclasses needing thread-safety (editor_log_buffer is +## written from any thread a Godot Logger virtual can fire on) wrap each +## public method with their own Mutex around the `_*_unlocked` helpers. +## Keeping the base lockless means the hot game-side path (single thread, +## called from _process) doesn't pay an unused mutex cost. +## +## Entry shape is owned by subclasses — `_append_entry` takes a +## ready-built Dictionary so each buffer can carry the fields it needs +## (game: `source/level/text`; editor: adds `path/line/function`). + +const VALID_LEVELS := ["info", "warn", "error"] + +var _max_lines: int +var _storage: Array[Dictionary] = [] +## Next write position within `_storage`. While filling (before first +## wrap) equals `_storage.size()`; once full, points at the oldest entry +## (the one about to be overwritten). +var _head := 0 +var _dropped_count := 0 +## Monotonic number of entries appended since this ring was created. Unlike +## `_storage.size()` and `_dropped_count`, this intentionally survives clear() +## so callers can use it as a stable "next entry to read" cursor. +var _appended_total := 0 + + +func _init(max_lines: int) -> void: + _max_lines = max_lines + + +## Append `entry` to the ring, evicting the oldest slot when full. +## Subclasses build the dict with their per-source shape and pass it in. +func _append_entry(entry: Dictionary) -> void: + if _storage.size() < _max_lines: + _storage.append(entry) + _head = _storage.size() % _max_lines + else: + ## Full — overwrite oldest in place, advance head, count the drop. + _storage[_head] = entry + _head = (_head + 1) % _max_lines + _dropped_count += 1 + _appended_total += 1 + + +## Lockless slice. Subclasses with a mutex wrap their `get_range` / +## `get_recent` overrides around this; the lockless base implementations +## of those public methods just delegate here. +func _get_range_unlocked(offset: int, count: int) -> Array[Dictionary]: + var size := _storage.size() + var start := maxi(0, offset) + var stop := mini(size, start + count) + var out: Array[Dictionary] = [] + for i in range(start, stop): + out.append(_storage[_logical_to_physical(i)]) + return out + + +func get_range(offset: int, count: int) -> Array[Dictionary]: + return _get_range_unlocked(offset, count) + + +func get_recent(count: int) -> Array[Dictionary]: + var size := _storage.size() + var start := maxi(0, size - count) + return _get_range_unlocked(start, size - start) + + +## Lockless cursor read. The cursor is the next sequence to read: calling +## get_since(appended_total()) after a snapshot returns only later appends. +func _get_since_unlocked(since_seq: int, limit: int = -1) -> Dictionary: + var size := _storage.size() + var oldest_seq := _appended_total - size + var start_seq := mini(maxi(since_seq, oldest_seq), _appended_total) + var start := start_seq - oldest_seq + var available := maxi(0, size - start) + var count := available + if limit >= 0: + count = mini(available, limit) + var entries := _get_range_unlocked(start, count) + var next_cursor := start_seq + entries.size() + return { + "cursor": since_seq, + "oldest_cursor": oldest_seq, + "next_cursor": next_cursor, + "appended_total": _appended_total, + "truncated": since_seq < oldest_seq, + "has_more": next_cursor < _appended_total, + "entries": entries, + } + + +func get_since(since_seq: int, limit: int = -1) -> Dictionary: + return _get_since_unlocked(since_seq, limit) + + +## Lockless accessors. Subclasses with a mutex use these under their lock +## so the field reads stay encapsulated in the base instead of leaking +## `_storage` / `_dropped_count` reach-through into the subclass. +func _total_count_unlocked() -> int: + return _storage.size() + + +func _dropped_count_unlocked() -> int: + return _dropped_count + + +func _appended_total_unlocked() -> int: + return _appended_total + + +func total_count() -> int: + return _total_count_unlocked() + + +func dropped_count() -> int: + return _dropped_count_unlocked() + + +func appended_total() -> int: + return _appended_total_unlocked() + + +## Translate a logical index (0 = oldest retained) to a physical +## `_storage` slot. Before the first wrap, storage-order is logical- +## order. After wrapping, the oldest entry lives at `_head`. +func _logical_to_physical(logical: int) -> int: + if _storage.size() < _max_lines: + return logical + return (_head + logical) % _max_lines + + +## Reset the ring to empty. Subclasses with a mutex wrap this with their +## lock; subclasses that surface `clear` to callers (McpEditorLogBuffer) +## return the prior size from their wrapper. +func _clear_storage() -> void: + _storage.clear() + _head = 0 + _dropped_count = 0 + + +## Coerce unknown levels to "info" so a misbehaving sender can't poison +## downstream filters with arbitrary strings. +static func _coerce_level(level: String) -> String: + return level if level in VALID_LEVELS else "info" diff --git a/addons/godot_ai/utils/structured_log_ring.gd.uid b/addons/godot_ai/utils/structured_log_ring.gd.uid new file mode 100644 index 0000000..57012ba --- /dev/null +++ b/addons/godot_ai/utils/structured_log_ring.gd.uid @@ -0,0 +1 @@ +uid://c4yh3jqfn6dwe diff --git a/addons/godot_ai/utils/surfaced_error_tracker.gd b/addons/godot_ai/utils/surfaced_error_tracker.gd new file mode 100644 index 0000000..5bd3022 --- /dev/null +++ b/addons/godot_ai/utils/surfaced_error_tracker.gd @@ -0,0 +1,642 @@ +@tool +class_name McpSurfacedErrorTracker +extends RefCounted + +## Central source for "errors the agent should know exist". +## +## Editor log cursors only cover McpEditorLogBuffer. Runtime errors from the +## game subprocess can land solely in the Debugger Errors tab, so this tracker +## promotes visible Debugger-tab rows into a monotonic sequence before the +## dispatcher stamps a watermark on each response envelope. + +const MAX_PROMOTED_DEBUGGER_ENTRIES := 500 +const MAX_PROMOTED_DEBUGGER_KEYS := 5000 +const DEBUGGER_REFRESH_MIN_INTERVAL_MS := 250 +const DEBUGGER_SCAN_AFTER_STOP_MS := 5000 +## #641: delays for the self-scheduled forced scans armed on run stop (and on +## game-helper hello, via McpDebuggerPlugin). Two ticks: an early one for rows +## the remote debugger delivers right around the event, and a late one past +## Godot's per-frame Errors-tab insertion throttle for error floods. +const DEFERRED_SCAN_DELAYS_SEC: Array[float] = [1.0, 5.0] +## #635: cap on accounted per-key row-time signatures. The live Errors tab is +## itself bounded, so this only guards a pathological flood of same-keyed rows +## with distinct time texts; past the cap the set resets to the current scan. +const MAX_ACCOUNTED_ROW_TIMES_PER_KEY := 512 + +var _editor_log_buffer +var _game_log_buffer +var _debugger_errors_root: Node +var _debugger_search_root_cache: Node +var _promoted_debugger_keys: Dictionary = {} +## #635: per-key set of Errors-tab row time texts already promoted, so a row +## observed after a clear+repopulate that no scan saw as empty still counts as +## new (see the re-promotion comment in refresh_debugger_errors). +var _promoted_debugger_row_times: Dictionary = {} +var _promoted_debugger_key_order: Array[String] = [] +var _promoted_debugger_entries: Array[Dictionary] = [] +var _debugger_promoted_total := 0 +var _run_seq := 0 +var _oldest_retained_debugger_sequence := 1 +var _last_debugger_refresh_msec := -DEBUGGER_REFRESH_MIN_INTERVAL_MS +var _debugger_scan_active := false +var _debugger_scan_until_msec := 0 +var _deferred_scans_scheduled_total := 0 + + +func _init(editor_log_buffer = null, game_log_buffer = null, debugger_errors_root: Node = null) -> void: + _editor_log_buffer = editor_log_buffer + _game_log_buffer = game_log_buffer + _debugger_errors_root = debugger_errors_root + + +func note_game_run_started(sticky_scan: bool = true) -> void: + _run_seq += 1 + _debugger_scan_active = sticky_scan + _debugger_scan_until_msec = 0 + if not sticky_scan: + _debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS + refresh_debugger_errors(true) + + +func note_game_run_stopped() -> void: + _debugger_scan_active = false + _debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS + schedule_deferred_scans() + + +## #641: promotion into the watermark used to depend on a tool call arriving +## while the scan gate was open (run active, or within DEBUGGER_SCAN_AFTER_STOP_MS +## of stop). Boot parse errors that landed in the Errors tab with no tool call +## in that window were never promoted, so the agent never got the +## new_errors_since_last_call hint. These editor-side timers force a scan +## regardless of tool-call cadence; the next stamped response then carries the +## already-promoted count even after the gate closes. Scans are content-keyed +## and idempotent, so a timer firing after an unrelated new run is harmless. +func schedule_deferred_scans(delays: Array = DEFERRED_SCAN_DELAYS_SEC) -> void: + var tree := Engine.get_main_loop() as SceneTree + if tree == null: + return + for delay in delays: + var timer := tree.create_timer(maxf(0.05, float(delay))) + timer.timeout.connect(_on_deferred_scan_timeout) + _deferred_scans_scheduled_total += 1 + + +func deferred_scans_scheduled_total() -> int: + return _deferred_scans_scheduled_total + + +func _on_deferred_scan_timeout() -> void: + refresh_debugger_errors(true) + + +func refresh_debugger_errors(force: bool = true) -> void: + var now := Time.get_ticks_msec() + if not force and not _should_scan_debugger_for_cached_watermark(now): + return + _last_debugger_refresh_msec = now + var current_by_key: Dictionary = {} + for entry in _raw_debugger_error_entries(): + if str(entry.get("level", "")) != "error": + continue + var key := _log_entry_key(entry) + var info: Dictionary = current_by_key.get(key, {"count": 0, "entry": entry, "times": {}}) + info["count"] = int(info.get("count", 0)) + 1 + var time_text := _row_time_text(entry) + if not time_text.is_empty(): + (info["times"] as Dictionary)[time_text] = true + current_by_key[key] = info + for key in _promoted_debugger_keys.keys(): + if not current_by_key.has(key): + _promoted_debugger_keys[key] = 0 + for key in current_by_key.keys(): + var info: Dictionary = current_by_key[key] + var current := int(info.get("count", 0)) + var stored := int(_promoted_debugger_keys.get(key, 0)) + ## #635: a count increase alone misses rows observed after a run + ## boundary. Godot clears the Errors tab at run start; when the new run + ## re-fires an error identical to one promoted before the clear, and no + ## scan happened to observe the tab empty in between, the per-key count + ## never dips — so the row kept its pre-run sequence and run-scoping + ## (editor_entries_since against the run-start cursor) misclassified an + ## in-run error as retained_recent. Each Errors-tab row carries its own + ## time text; an unaccounted (key, time) signature is a row we have not + ## promoted yet, so it earns a fresh sequence even at an equal or lower + ## count. Boundary condition: rows with an empty time text, or a + ## repopulated row whose time text is byte-identical to a pre-clear row, + ## fall back to count-only dedup and can still be missed. + var unseen_times := _unaccounted_row_times(key, info.get("times", {})) + var delta := current - stored + if delta <= 0 and not unseen_times.is_empty(): + delta = mini(unseen_times.size(), current) + if delta <= 0: + if current != stored: + _promoted_debugger_keys[key] = current + continue + if not _promoted_debugger_keys.has(key): + _promoted_debugger_key_order.append(key) + _promoted_debugger_keys[key] = current + _account_row_times(key, info.get("times", {})) + _debugger_promoted_total += delta + var source_entry: Dictionary = info.get("entry", {}) + var promoted := source_entry.duplicate(true) + promoted["_debugger_key"] = key + promoted["_debugger_occurrences"] = current + promoted["_debugger_sequence"] = _debugger_promoted_total + _remove_promoted_debugger_entry(key) + _promoted_debugger_entries.append(promoted) + _trim_promoted_debugger_entries() + _trim_promoted_debugger_key_counts() + + +## #645: promote an error record that has no Errors-tab row to scrape — e.g. a +## boot-time parse error that parked the game in a remote-debugger break before +## any surface got a record. The entry joins the same promoted sequence as +## scraped Debugger rows, so run-scoping (editor_entries_since), the retained +## fallback, and the response watermark all see it with no extra plumbing. +## Re-recording the same key later (the same script still broken on the next +## run) re-promotes it with a fresh sequence, mirroring how re-appearing +## Errors-tab rows behave; scan reconciliation zeroes the key's count once the +## break ends since the row never exists in the live tab. +func record_synthetic_error(entry: Dictionary) -> void: + var key := _log_entry_key(entry) + var occurrences := int(_promoted_debugger_keys.get(key, 0)) + 1 + if not _promoted_debugger_keys.has(key): + _promoted_debugger_key_order.append(key) + _promoted_debugger_keys[key] = occurrences + _debugger_promoted_total += 1 + var promoted := entry.duplicate(true) + promoted["_debugger_key"] = key + promoted["_debugger_occurrences"] = occurrences + promoted["_debugger_sequence"] = _debugger_promoted_total + promoted["_debugger_synthetic"] = true + _remove_promoted_debugger_entry(key) + _promoted_debugger_entries.append(promoted) + _trim_promoted_debugger_entries() + _trim_promoted_debugger_key_counts() + + +## Monotonicity contract (#767): run_seq and the session-scoped components +## (editor_ring, debugger_promoted, editor_ring_warn) must NEVER decrease +## within an editor session, and the per-run components (game_error_warn, +## game_warn) must never decrease within a run — they may reset only when +## run_seq increments in the same stamp (the run boundary that rotates the +## game buffer's counters). Released servers diff consecutive stamps +## (websocket.py::_sync_error_watermark_for_session) and treat any other +## decrease as a counter reset, counting the FULL current value as new — one +## dip makes every old server out there over-report errors. Any future +## hold/classification feature must therefore DEFER an increment until its +## entry is released, never subtract an already-stamped one: a stamp like +## `raw_total - currently_held_entries` is exactly the regression this +## guards against. Producer-side coverage: +## test_editor.gd::test_surfaced_error_tracker_watermark_components_never_decrease. +func watermark(force_debugger_scan: bool = false) -> Dictionary: + refresh_debugger_errors(force_debugger_scan) + return { + "run_seq": _run_seq, + "editor_ring": _error_appended_total(), + "debugger_promoted": _debugger_promoted_total, + ## Historically misnamed: carries game-process ERROR counts only. + "game_error_warn": _game_error_total(), + ## Warn-level components, parallel to the error counts above. The server + ## diffs these into `new_warnings_since_last_call` so a warning-only run + ## surfaces instead of reading as clean. Debugger Errors-tab warning rows + ## are not promoted here yet (buffers cover push_warning from the game and + ## editor parse/@tool warnings) — tracked as a follow-up. + "editor_ring_warn": _warn_appended_total(), + "game_warn": _game_warn_total(), + } + + +static func stamp_watermark(response: Dictionary, tracker) -> void: + if tracker == null: + return + if not tracker.has_method("watermark"): + return + response["error_watermark"] = tracker.watermark() + + +func debugger_promoted_total(force_debugger_scan: bool = true) -> int: + refresh_debugger_errors(force_debugger_scan) + return _debugger_promoted_total + + +func collect_editor_log_entries() -> Array[Dictionary]: + refresh_debugger_errors(true) + var entries: Array[Dictionary] = [] + var seen_keys: Dictionary = {} + if _editor_log_buffer != null: + for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()): + seen_keys[_log_entry_key(entry)] = true + entries.append(entry) + for entry in read_debugger_error_entries(): + var key := _log_entry_key(entry) + if seen_keys.has(key): + continue + seen_keys[key] = true + entries.append(entry) + ## #645: synthesized break records have no live Errors-tab row to scrape — + ## merge them from the promoted list so logs_read(source="editor") shows + ## the record that run/game responses point at. + for entry in _promoted_debugger_entries: + if not bool(entry.get("_debugger_synthetic", false)): + continue + var key := _log_entry_key(entry) + if seen_keys.has(key): + continue + seen_keys[key] = true + entries.append(_strip_promotion_bookkeeping(entry)) + return entries + + +static func _strip_promotion_bookkeeping(entry: Dictionary) -> Dictionary: + var clean := entry.duplicate(true) + for key in ["_debugger_key", "_debugger_occurrences", "_debugger_sequence", "_debugger_synthetic"]: + clean.erase(key) + return clean + + +func editor_entries_since(editor_cursor: int, debugger_cursor: int, force_debugger_scan: bool = true) -> Dictionary: + refresh_debugger_errors(force_debugger_scan) + var entries: Array[Dictionary] = [] + var seen_keys: Dictionary = {} + var truncated := false + if _editor_log_buffer != null: + var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, editor_cursor), -1) + truncated = bool(captured.get("truncated", false)) + for entry in captured.get("entries", []): + seen_keys[_log_entry_key(entry)] = true + entries.append(entry) + if debugger_cursor < _oldest_retained_debugger_sequence - 1: + truncated = true + for entry in _promoted_debugger_entries: + if int(entry.get("_debugger_sequence", 0)) <= debugger_cursor: + continue + var key := _log_entry_key(entry) + if seen_keys.has(key): + continue + seen_keys[key] = true + entries.append(entry) + return { + "entries": entries, + "truncated": truncated, + } + + +func retained_recent_editor_entries() -> Array[Dictionary]: + ## There is no shared timestamp across the editor logger ring and Godot's + ## Debugger Errors tree. Preserve the pre-PR fallback contract: newest + ## buffered editor entries first, then debugger-only rows that were not in + ## the ring, so stale Debugger rows cannot outrank newer ring entries. + var entries: Array[Dictionary] = [] + var seen_keys: Dictionary = {} + if _editor_log_buffer != null: + entries = _editor_log_buffer.get_recent(_editor_log_buffer.total_count()) + entries.reverse() + for entry in entries: + seen_keys[_log_entry_key(entry)] = true + for entry in collect_editor_log_entries(): + var key := _log_entry_key(entry) + if seen_keys.has(key): + continue + seen_keys[key] = true + entries.append(entry) + return entries + + +func read_debugger_error_entries() -> Array[Dictionary]: + var entries: Array[Dictionary] = [] + var seen_keys: Dictionary = {} + for entry in _raw_debugger_error_entries(): + var key := _log_entry_key(entry) + if seen_keys.has(key): + continue + seen_keys[key] = true + entries.append(entry) + return entries + + +func locate_debugger_error_trees() -> Array[Tree]: + var trees: Array[Tree] = [] + var root: Node = _debugger_errors_root + ## #641: a deferred-scan timer can outlive an injected root (tests, + ## teardown). A freed root must not fall through to the live editor UI — + ## that would promote unrelated real errors into a tracker scoped to the + ## dead root — so treat it as "nothing to scan". + if root != null and not is_instance_valid(root): + return trees + if root == null: + root = _debugger_search_root() + if root == null: + return trees + _collect_debugger_error_trees(root, trees) + return trees + + +func clear_debugger_error_trees() -> int: + var cleared := 0 + for tree in locate_debugger_error_trees(): + cleared += entries_from_debugger_error_tree(tree).size() + if not _press_debugger_clear_button(tree): + ## Synthetic roots in tests do not have Godot's Clear button. + tree.clear() + return cleared + + +func _debugger_search_root() -> Node: + if is_instance_valid(_debugger_search_root_cache): + return _debugger_search_root_cache + _debugger_search_root_cache = null + var base := EditorInterface.get_base_control() + if base == null: + return null + _debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode") + if _debugger_search_root_cache == null: + return base + return _debugger_search_root_cache + + +static func _find_first_of_class(node: Node, klass: String) -> Node: + if node.get_class() == klass: + return node + for child in node.get_children(): + var found := _find_first_of_class(child, klass) + if found != null: + return found + return null + + +static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void: + if node is Tree and _tree_has_debugger_errors(node as Tree): + out.append(node as Tree) + for child in node.get_children(): + if child is Node: + _collect_debugger_error_trees(child as Node, out) + + +static func _tree_has_debugger_errors(tree: Tree) -> bool: + var root := tree.get_root() + if root == null: + return false + var item := root.get_first_child() + while item != null: + if _is_debugger_error_item(item): + return true + item = item.get_next() + return false + + +static func _press_debugger_clear_button(tree: Tree) -> bool: + var parent := tree.get_parent() + if parent == null: + return false + var stack: Array[Node] = [parent] + while not stack.is_empty(): + var node: Node = stack.pop_back() + if node is BaseButton: + for conn in node.get_signal_connection_list("pressed"): + if str(conn.get("callable", "")).contains("_clear_errors_list"): + node.emit_signal("pressed") + return true + for child in node.get_children(): + stack.push_back(child) + return false + + +static func entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]: + var entries: Array[Dictionary] = [] + var root := tree.get_root() + if root == null: + return entries + var item := root.get_first_child() + while item != null: + if _is_debugger_error_item(item): + entries.append(_entry_from_debugger_error_item(item)) + item = item.get_next() + return entries + + +static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary: + var title := item.get_text(1) + var loc := _location_from_metadata(item.get_metadata(0)) + var function := _function_from_title(title) + return { + "source": "editor", + "level": "warn" if item.has_meta("_is_warning") else "error", + "text": title, + "path": str(loc.get("path", "")), + "line": int(loc.get("line", 0)), + "function": function, + "details": _details_from_debugger_error_item(item, loc, function), + } + + +static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary: + var children: Array[Dictionary] = [] + var child := item.get_first_child() + while child != null: + var child_loc := _location_from_metadata(child.get_metadata(0)) + children.append({ + "label": child.get_text(0), + "text": child.get_text(1), + "path": str(child_loc.get("path", "")), + "line": int(child_loc.get("line", 0)), + }) + child = child.get_next() + return { + "debugger_tab": "Errors", + "time": item.get_text(0), + "message": item.get_text(1), + "error_type_name": "warning" if item.has_meta("_is_warning") else "error", + "source": { + "path": str(loc.get("path", "")), + "line": int(loc.get("line", 0)), + "function": function, + }, + "resolved": { + "path": str(loc.get("path", "")), + "line": int(loc.get("line", 0)), + "function": function, + }, + "children": children, + "frames": _frames_from_error_children(children), + } + + +static func _is_debugger_error_item(item: TreeItem) -> bool: + return item.has_meta("_is_warning") or item.has_meta("_is_error") + + +static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]: + var start := -1 + for i in children.size(): + if str(children[i].label).contains("Stack Trace"): + start = i + break + if start < 0: + for i in children.size(): + if str(children[i].label).is_empty() and not str(children[i].path).is_empty(): + start = maxi(i - 1, 0) + break + if start < 0: + return [] + var frames: Array[Dictionary] = [] + for i in range(start, children.size()): + if str(children[i].path).is_empty(): + continue + frames.append({ + "path": children[i].path, + "line": children[i].line, + "function": _function_from_frame_text(children[i].text), + }) + return frames + + +static func _location_from_metadata(meta: Variant) -> Dictionary: + if meta is Array and meta.size() >= 2: + return {"path": str(meta[0]), "line": int(meta[1])} + return {"path": "", "line": 0} + + +static func _function_from_title(title: String) -> String: + var colon := title.find(": ") + if colon <= 0: + return "" + return title.substr(0, colon) + + +static func _function_from_frame_text(text: String) -> String: + var marker := text.find(" @ ") + if marker < 0: + return "" + var fn := text.substr(marker + 3).strip_edges() + if fn.ends_with("()"): + fn = fn.substr(0, fn.length() - 2) + return fn + + +## Shared one-line rendering of a compact editor-error entry for messages and +## hints ("text (path:line)"). Single home so the debugger plugin, project +## handler, and editor handler can't drift apart. +static func format_editor_error_summary(entry: Dictionary) -> String: + var text := str(entry.get("text", "editor error")) + var path := str(entry.get("path", "")) + var line := int(entry.get("line", 0)) + if not path.is_empty() and line > 0: + return "%s (%s:%d)" % [text, path, line] + if not path.is_empty(): + return "%s (%s)" % [text, path] + return text + + +static func _log_entry_key(entry: Dictionary) -> String: + return "%s|%s|%s|%s" % [ + str(entry.get("level", "")), + str(entry.get("text", "")), + str(entry.get("path", "")), + str(entry.get("line", 0)), + ] + + +func _error_appended_total() -> int: + if _editor_log_buffer == null: + return 0 + if _editor_log_buffer.has_method("error_appended_total"): + return int(_editor_log_buffer.call("error_appended_total")) + return 0 + + +func _game_error_total() -> int: + if _game_log_buffer == null: + return 0 + if _game_log_buffer.has_method("error_total"): + return int(_game_log_buffer.call("error_total")) + return 0 + + +func _warn_appended_total() -> int: + if _editor_log_buffer == null: + return 0 + if _editor_log_buffer.has_method("warn_appended_total"): + return int(_editor_log_buffer.call("warn_appended_total")) + return 0 + + +func _game_warn_total() -> int: + if _game_log_buffer == null: + return 0 + if _game_log_buffer.has_method("warn_total"): + return int(_game_log_buffer.call("warn_total")) + return 0 + + +func _should_scan_debugger_for_cached_watermark(now_msec: int) -> bool: + if not _debugger_scan_active and now_msec > _debugger_scan_until_msec: + return false + return now_msec - _last_debugger_refresh_msec >= DEBUGGER_REFRESH_MIN_INTERVAL_MS + + +func _trim_promoted_debugger_entries() -> void: + while _promoted_debugger_entries.size() > MAX_PROMOTED_DEBUGGER_ENTRIES: + _promoted_debugger_entries.pop_front() + if _promoted_debugger_entries.is_empty(): + _oldest_retained_debugger_sequence = _debugger_promoted_total + 1 + else: + _oldest_retained_debugger_sequence = int(_promoted_debugger_entries[0].get("_debugger_sequence", 1)) + + +func _trim_promoted_debugger_key_counts() -> void: + while _promoted_debugger_key_order.size() > MAX_PROMOTED_DEBUGGER_KEYS: + var key := _promoted_debugger_key_order.pop_front() + _promoted_debugger_keys.erase(key) + _promoted_debugger_row_times.erase(key) + + +## #635: per-row time text from a scraped Errors-tab entry (column 0 of the +## row, carried in details.time). Empty when the entry has no details — e.g. +## synthetic records — which keeps those on count-only dedup. +static func _row_time_text(entry: Dictionary) -> String: + var details: Variant = entry.get("details", {}) + if details is Dictionary: + return str((details as Dictionary).get("time", "")) + return "" + + +func _unaccounted_row_times(key: String, times: Dictionary) -> Array: + var accounted: Dictionary = _promoted_debugger_row_times.get(key, {}) + var unseen := [] + for time_text in times.keys(): + if not accounted.has(time_text): + unseen.append(time_text) + return unseen + + +func _account_row_times(key: String, times: Dictionary) -> void: + if times.is_empty(): + return + var accounted: Dictionary = _promoted_debugger_row_times.get(key, {}) + for time_text in times.keys(): + accounted[time_text] = true + ## Enforce the bound AFTER merging: a pre-merge `>` check let the set + ## reach the cap and keep growing (and a batch of new times could jump + ## past it). Past the cap, reset to just this scan's times — the live + ## Errors tab is itself bounded, so this only fires under a pathological + ## same-key flood, where "recent scan only" is an acceptable memory of + ## what was promoted (worst case: a re-observed ancient row re-promotes). + if accounted.size() > MAX_ACCOUNTED_ROW_TIMES_PER_KEY: + accounted = times.duplicate() + _promoted_debugger_row_times[key] = accounted + + +func _remove_promoted_debugger_entry(key: String) -> void: + for i in range(_promoted_debugger_entries.size() - 1, -1, -1): + if str(_promoted_debugger_entries[i].get("_debugger_key", "")) == key: + _promoted_debugger_entries.remove_at(i) + return + + +func _raw_debugger_error_entries() -> Array[Dictionary]: + var entries: Array[Dictionary] = [] + for tree in locate_debugger_error_trees(): + entries.append_array(entries_from_debugger_error_tree(tree)) + return entries diff --git a/addons/godot_ai/utils/surfaced_error_tracker.gd.uid b/addons/godot_ai/utils/surfaced_error_tracker.gd.uid new file mode 100644 index 0000000..433a191 --- /dev/null +++ b/addons/godot_ai/utils/surfaced_error_tracker.gd.uid @@ -0,0 +1 @@ +uid://o0ulahkt83re diff --git a/addons/godot_ai/utils/update_manager.gd b/addons/godot_ai/utils/update_manager.gd new file mode 100644 index 0000000..65de4ee --- /dev/null +++ b/addons/godot_ai/utils/update_manager.gd @@ -0,0 +1,843 @@ +@tool +class_name McpUpdateManager +extends Node + +## Self-update manager for pre-runner work. Owns release checks, HTTP ZIP +## download, the install-in-flight gate, and install state signals back to +## the dock. Once `_install_zip()` calls +## `plugin.gd::install_downloaded_update(...)`, ownership transfers to +## `update_reload_runner.gd`, which owns extract, scan, plugin re-enable, +## and detached-dock cleanup. +## +## The dock owns banner rendering and forwards button clicks. The split +## exists because the dock script is one of the files overwritten on disk +## during install — keeping pipeline state on a separate Node lets the dock +## tear down cleanly without losing the in-flight gate that other dock spawn +## paths consult. +## +## `class_name McpUpdateManager` is retained because it shipped in a +## published release. If this class is ever retired, follow CLAUDE.md's +## never-delete-published-class_name shim policy instead of deleting the +## declaration. +## +## `_plugin` and `_dock` are deliberately untyped: the same self-update +## window that overwrites this script also overwrites the dock and plugin +## scripts, and a static-typed reference into a script being hot-reloaded +## crashes inside `GDScriptFunction::call`. `server_lifecycle.gd` follows +## the same convention. + +const RELEASES_URL := ( + "https://api.github.com/repos/hi-godot/godot-ai/releases/latest" +) +const RELEASES_PAGE := "https://github.com/hi-godot/godot-ai/releases/latest" +const UPDATE_TEMP_DIR := "user://godot_ai_update/" +const UPDATE_TEMP_ZIP := "user://godot_ai_update/update.zip" +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") + +## RSA-4096 public key for release-signature verification (#687). The paired +## private key exists only in the GitHub Actions secret RELEASE_SIGNING_KEY_PEM +## (plus the maintainer's offline backup) — deliberately outside the repo +## token's scope, because the threat model is release-asset substitution by a +## leaked token or compromised workflow, and a token that can rewrite assets +## still cannot read secrets. Rotation requires shipping a new plugin release +## embedding the new key (and bumping SIGNING_REQUIRED_FROM_VERSION past the +## last release signed with the old one). +const RELEASE_SIGNING_PUBLIC_KEY_PEM := """-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr4OmbONFTONGFcXSUQ2p +e54YaUhWDA75wxeDWhOc476vsdo53YnXEFT7EPr2hUKqeNxv++LqKOkFuAsxSNZy +wBe6P1tmQA4Og6Ezv4CGnZdEj1uhlDJFK9ShQ29oWfC6bf/84625SvvBxZos2Br9 +yPKl7h5wzqDoeUSpv+f0ynTiC0i/HAUo/NQBlkgGwkomK2Fr3pP1VDxxq2xvgHSk +lU6Qcomr9WjJxI+HkDN5tRPPn0pDrg6YFx2J18OfD8KIa/kMGxuXOcHlPyRYpjyu +qTtg2oL0NyUIG+1TmJ3DcN4GlKC55eOrkfJ04vudS5pxdnUIFRmkGBXZLdaetoPc +ixtlD4w6gi8KIH1CTG+/TtHP1KVdOogCWDcjRCAmMJPFZe6eEKXmGQUZDb9wfnbx +h++XiVe5tq83BTLWmaFTy+fZbNo12uhNCNS1LJ42/yj+S1xvo0yMbkkNr1hIYk0P +584XnBQeBSVJDf3667NZXaxnWv94K9zbb+1OvOvPwhbOdgi2Ymcw5QEOQIavtg86 +XLLcWzG+SJsycz1imikjv6sStWh8WHneKSTMq6A7V6PBj7oJyEJp10696BDw287k +YlH+9VGqowPEMXpWX57wOBKiWb4K1kw1LfxjT8W1e/pcX9pJqiv0DkjTXUxo9CDG +1X1+ZXBBR3MkGuFAOCjy0x8CAwEAAQ== +-----END PUBLIC KEY----- +""" + +## Every release at or above this version ships a signed sidecar +## (release.yml hard-fails without the signing secret). At or above it, a +## missing `.sha256.sig` asset is treated as tampering — an attacker who can +## rewrite release assets could otherwise just strip the signature to skip +## verification. Below it (releases published before signing existed), the +## legacy checksum-only path still installs. +const SIGNING_REQUIRED_FROM_VERSION := "2.9.3" + +## Host -> required path prefix for self-update downloads (ZIP and checksum +## sidecar). The URLs are taken verbatim from the GitHub Releases API's +## `browser_download_url`, so before fetching we pin them to https on a +## GitHub-owned host AND to this repo's release-asset path (#599) — a +## tampered or unexpected API response can't point the in-editor updater at +## an arbitrary origin, nor at a release asset of a *different* repo on a +## trusted host. +## +## In practice `browser_download_url` is always the +## `https://github.com/hi-godot/godot-ai/releases/download//` +## shape; HTTPRequest then follows the github.com -> *.githubusercontent.com +## redirect internally (this guard validates the entry point, not each hop). +## The CDN hosts are kept as defense-in-depth should the API ever hand back +## a direct CDN URL — their object keys carry the repo *id*, not the repo +## name, so the tightest checkable prefix there is the release-asset key +## namespace. +const _TRUSTED_DOWNLOAD_PATH_PREFIXES := { + "github.com": "/hi-godot/godot-ai/releases/download/", + "www.github.com": "/hi-godot/godot-ai/releases/download/", + "api.github.com": "/repos/hi-godot/godot-ai/releases/assets/", + "objects.githubusercontent.com": "/github-production-release-asset-", + "release-assets.githubusercontent.com": "/github-production-release-asset-", +} + +## Emitted after `check_for_updates()` resolves a newer remote version. +## Payload mirrors the Dictionary returned by `parse_releases_response`: +## {has_update, version, forced, label_text, download_url} +signal update_check_completed(result: Dictionary) + +## Emitted at every UI-relevant step of the install pipeline. Payload +## keys are all optional and apply on top of the current banner state: +## label_text: String ## banner label override +## button_text: String ## update button text override +## button_disabled: bool ## update button disabled state +## banner_visible: bool ## banner visibility override +## outcome: String ## "success" -> dock paints green +signal install_state_changed(state: Dictionary) + +var _plugin +var _dock + +var _http_request: HTTPRequest +var _download_request: HTTPRequest +var _verify_request: HTTPRequest +var _signature_request: HTTPRequest +var _latest_download_url: String = "" +## URL of the `godot-ai-plugin.zip.sha256` sidecar asset. Used to verify the +## downloaded archive's integrity before extract (#523). Verification is +## mandatory (#599): when a release ships no sidecar this stays empty and +## `_verify_then_install` refuses the install. +var _latest_checksum_url: String = "" +## URL of the `godot-ai-plugin.zip.sha256.sig` signature asset (#687). Empty +## on releases published before signing existed; `_verify_then_install` +## refuses an empty URL once the remote version is inside the signing era +## (see SIGNING_REQUIRED_FROM_VERSION). +var _latest_signature_url: String = "" +## Remote version from the last update check — drives the +## signature-required compat gate in `_verify_then_install`. +var _latest_remote_version: String = "" +## Sidecar bytes + parsed digest held between the checksum download and the +## signature verdict, so the signature is checked against exactly the bytes +## the digest was parsed from. +var _pending_sidecar_body := PackedByteArray() +var _pending_expected_digest: String = "" + +## Set for the duration of `_install_zip` — extract-overwrite of plugin +## scripts on disk would crash any worker mid-`GDScriptFunction::call` +## (confirmed via SIGABRT in the dock's refresh worker). Dock spawn paths +## consult this via `is_install_in_flight()`; in-flight workers are +## drained before any disk write. +var _install_in_flight: bool = false + + +# ---- Setup ------------------------------------------------------------- + +func setup(plugin, dock) -> void: + _plugin = plugin + _dock = dock + + +# ---- Public API --------------------------------------------------------- + +## Kick off the GitHub Releases API check. No-ops in dev checkouts — +## `addons/godot_ai/` is a symlink into canonical `plugin/` source there, +## and an extract would clobber tracked files (#116). `is_dev_checkout()` +## honours the mode override (EditorSetting `godot_ai/mode_override` > +## `GODOT_AI_MODE` env), so +## testers can force `user` to exercise the AssetLib flow from a dev tree; +## `_install_zip` still gates on the physical symlink check so a forced- +## user mode can never clobber source. +func check_for_updates() -> void: + if ClientConfigurator.is_dev_checkout(): + return + if _http_request == null: + _http_request = HTTPRequest.new() + _http_request.request_completed.connect(_on_update_check_completed) + add_child(_http_request) + _http_request.request(RELEASES_URL, ["Accept: application/vnd.github+json"]) + + +## Cancel any in-flight check so a follow-up check_for_updates() can't +## hit ERR_BUSY on the shared HTTPRequest. No current dock caller — the +## mode-override dropdown that used it was removed in #408; kept as +## published API of the update flow. +func cancel_check() -> void: + if _http_request != null: + _http_request.cancel_request() + + +## Reset the cached download/checksum URLs so a fresh check paints over +## a clean banner. No current production caller — the mode-override +## dropdown that used it was removed in #408; kept for tests and any +## future re-check path. +func clear_pending_download() -> void: + _latest_download_url = "" + _latest_checksum_url = "" + _latest_signature_url = "" + _latest_remote_version = "" + _pending_sidecar_body = PackedByteArray() + _pending_expected_digest = "" + + +## True when the running Godot is within the supported self-update floor. +## Godot < 4.5 must not be offered a one-click update to a release whose +## always-loaded scripts depend on 4.5 APIs/classes. +## Guards `major` too so a future Godot 5.x (minor 0) isn't misclassified. +func _can_self_update() -> bool: + var v := Engine.get_version_info() + return _version_can_self_update(int(v.get("major", 0)), int(v.get("minor", 0))) + + +## Pure version predicate, split out so it's testable without faking the +## running engine. In-editor self-update needs Godot >= 4.5. +static func _version_can_self_update(major: int, minor: int) -> bool: + return major > 4 or (major == 4 and minor >= 5) + + +## Banner guidance for engines below the support floor. Shown up-front at +## check time so those users do not install an incompatible latest release. +static func _manual_update_label(version: String) -> String: + var release_noun := "release" + var suffix := "" + if not version.is_empty(): + release_noun = "version" + suffix = " (latest: v%s)" % version + return ( + "This is the last Godot AI %s for this Godot%s. " % [release_noun, suffix] + + "Upgrade to Godot 4.5+ to keep receiving updates." + ) + + +## PID of the detached package pre-warm started alongside the zip download, +## or <= 0 when none is running (no uvx tier, unusable version pin, or it has +## already been waited out). See `_wait_for_prewarm_before_install`. +var _prewarm_pid := -1 +## When the install first found the pre-warm still running, so the wait below +## is bounded from the first check rather than re-armed on every poll. +var _prewarm_wait_started_ms := 0 +## Ceiling on how long install will wait for the pre-warm. Matches the budget +## the Configure-time pre-warm allows for the same download. +const PREWARM_WAIT_BUDGET_MS := 180 * 1000 +## Poll interval while waiting. Deliberately not sub-second: `pid_alive` shells +## out to `tasklist` on Windows via a blocking `OS.execute` on the main thread, +## so a tight poll would make the editor sluggish for the whole wait. +const PREWARM_POLL_SECONDS := 2.0 + + +## Driven by the dock's Update button. On Godot < 4.5 (see _can_self_update) +## the in-editor install is disabled so users cannot install an incompatible +## latest release. With no resolved download URL, falls back to opening the +## release page. Otherwise kicks off the download -> extract -> reload pipeline. +func start_install() -> void: + if not _can_self_update(): + install_state_changed.emit({ + "button_text": "Upgrade Godot", + "button_disabled": true, + "label_text": _manual_update_label(""), + "banner_visible": true, + }) + return + + if _latest_download_url.is_empty(): + OS.shell_open(RELEASES_PAGE) + return + + ## Pin the resolved asset URL to https on a GitHub host AND to this + ## repo's release-asset path before fetching (#523, #599). Fall back to + ## the release page (a user-driven browser download) rather than pulling + ## an executable plugin payload from an unexpected origin. + if not _is_trusted_download_url(_latest_download_url): + push_error( + "MCP | refusing self-update download from untrusted URL: %s" + % _latest_download_url + ) + OS.shell_open(RELEASES_PAGE) + install_state_changed.emit({ + "button_text": "Update via download page", + "button_disabled": false, + }) + return + + install_state_changed.emit({ + "button_text": "Downloading...", + "button_disabled": true, + }) + + ## Warm the uv cache for the NEW server version while the plugin zip + ## downloads (see prewarm_server_package): the post-update walk must win + ## a port bind race against attach bridges respawning the cached OLD + ## version, and a cold uvx resolve loses that race every time. uvx is + ## almost always cached in the CliFinder by now (the dock's setup probe); + ## a cold lookup is the same bounded main-thread cost as + ## `_reprobe_uv_if_negative`, on the same rare click-driven path. + _prewarm_pid = ClientConfigurator.prewarm_server_package(_latest_remote_version) + _prewarm_wait_started_ms = 0 + + if _download_request != null: + _download_request.queue_free() + _download_request = HTTPRequest.new() + var global_zip := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP) + var global_dir := ProjectSettings.globalize_path(UPDATE_TEMP_DIR) + DirAccess.make_dir_recursive_absolute(global_dir) + _download_request.download_file = global_zip + _download_request.max_redirects = 10 + _download_request.request_completed.connect(_on_download_completed) + add_child(_download_request) + var err := _download_request.request(_latest_download_url) + if err != OK: + ## `request_completed` never fires when `request()` itself errors, + ## so cleanup (queue_free + null + drop the staged zip) has to land + ## inline — otherwise the HTTPRequest stays parented under the + ## manager until the next click. + _download_request.queue_free() + _download_request = null + DirAccess.remove_absolute(global_zip) + install_state_changed.emit({ + "button_text": "Request failed", + "button_disabled": false, + }) + +## Consulted by the dock's spawn paths (focus-in refresh, manual button, +## deferred initial refresh) — true while plugin scripts are being +## overwritten. A worker mid-`GDScriptFunction::call` into a half- +## overwritten script SIGABRTs the editor. +func is_install_in_flight() -> bool: + return _install_in_flight + + +# ---- Releases-API parse (pure, testable) ------------------------------- + +## Parses the GitHub Releases API JSON response. Returns: +## has_update: bool ## true if remote tag > local version +## version: String ## remote tag minus leading "v" +## forced: bool ## mode_override() == "user" (banner-only hint) +## label_text: String ## "Update available: vX.Y.Z" + " (forced)" +## download_url: String ## matching `godot-ai-plugin.zip` asset URL +## checksum_url: String ## `godot-ai-plugin.zip.sha256` asset URL ("" if absent) +## signature_url: String ## `godot-ai-plugin.zip.sha256.sig` asset URL ("" if absent) +## +## Static so tests drive it without instancing the manager. +static func parse_releases_response( + result: int, response_code: int, body: PackedByteArray +) -> Dictionary: + var out := { + "has_update": false, + "version": "", + "forced": false, + "label_text": "", + "download_url": "", + "checksum_url": "", + "signature_url": "", + } + if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: + return out + var parsed = JSON.parse_string(body.get_string_from_utf8()) + if parsed == null or not (parsed is Dictionary): + return out + var json: Dictionary = parsed + var tag: String = String(json.get("tag_name", "")) + if tag.is_empty(): + return out + var remote_version := tag.trim_prefix("v") + var local_version := ClientConfigurator.get_plugin_version() + if not _is_newer(remote_version, local_version): + return out + + var url := "" + var checksum_url := "" + var signature_url := "" + var assets: Array = json.get("assets", []) + for asset in assets: + var asset_dict: Dictionary = asset + var asset_name := String(asset_dict.get("name", "")) + if asset_name == "godot-ai-plugin.zip": + url = String(asset_dict.get("browser_download_url", "")) + elif asset_name == "godot-ai-plugin.zip.sha256": + checksum_url = String(asset_dict.get("browser_download_url", "")) + elif asset_name == "godot-ai-plugin.zip.sha256.sig": + signature_url = String(asset_dict.get("browser_download_url", "")) + + var forced := ClientConfigurator.mode_override() == "user" + var label_text := "Update available: v%s" % remote_version + if forced: + ## Forced-user mode (EditorSetting or env) is the only way the banner + ## lights up in a dev tree; suffix so the operator notices. + label_text += " (forced)" + + out["has_update"] = true + out["version"] = remote_version + out["forced"] = forced + out["label_text"] = label_text + out["download_url"] = url + out["checksum_url"] = checksum_url + out["signature_url"] = signature_url + return out + + +## True only for an `https://` URL whose host is a key of +## `_TRUSTED_DOWNLOAD_PATH_PREFIXES` AND whose path starts with that host's +## required prefix — trusted host alone is not enough; the URL must be a +## hi-godot/godot-ai release asset (#599). Parses the authority by hand +## (GDScript has no URL parser): strips userinfo via the LAST `@` so a spoof +## like `https://github.com@evil.com/...` resolves to `evil.com` (rejected), +## and strips any `:port`. The path is compared case-sensitively (GitHub +## release paths are case-sensitive). Static so the guard is unit-testable +## without instancing the manager. +static func _is_trusted_download_url(url: String) -> bool: + const SCHEME := "https://" + if not url.begins_with(SCHEME): + return false + if url.find("\\") >= 0: + return false + var rest := url.substr(SCHEME.length()) + var authority := rest + var path := "" + var slash := rest.find("/") + if slash >= 0: + authority = rest.substr(0, slash) + path = rest.substr(slash) + ## Host is everything after the LAST '@' (userinfo precedes it). + var at := authority.rfind("@") + if at >= 0: + authority = authority.substr(at + 1) + var colon := authority.find(":") + if colon >= 0: + authority = authority.substr(0, colon) + var host := authority.to_lower() + if not _TRUSTED_DOWNLOAD_PATH_PREFIXES.has(host): + return false + ## Scope the checks below to the path proper (#713): direct CDN asset + ## URLs carry signed query params (X-Amz-Credential=...%2F...) whose + ## legitimate %2F tokens made every CDN prefix unreachable when the + ## needle scan covered the query string. Routing is decided by the + ## path, so the query is safe to ignore. + var qmark := path.find("?") + if qmark >= 0: + path = path.substr(0, qmark) + ## Reject dot-segments (and their percent-encoded forms) anywhere in the + ## path: "/hi-godot/godot-ai/releases/download/../../evil/..." passes a + ## raw string-prefix test but normalizes server-side to a different repo, + ## defeating the scoping (#599 review). Also reject percent-encoded + ## slashes, which some servers decode before routing. + var lower_path := path.to_lower() + for needle in ["/../", "/..", "%2e", "%2f", "%5c"]: + if lower_path.contains(needle): + return false + return path.begins_with(String(_TRUSTED_DOWNLOAD_PATH_PREFIXES[host])) + + +static func _is_newer(remote: String, local: String) -> bool: + var r := remote.split(".") + var l := local.split(".") + for i in range(max(r.size(), l.size())): + var rv := int(r[i]) if i < r.size() else 0 + var lv := int(l[i]) if i < l.size() else 0 + if rv > lv: + return true + if rv < lv: + return false + return false + + +# ---- HTTPRequest callbacks (instance-side) ----------------------------- + +func _on_update_check_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray +) -> void: + var parsed := parse_releases_response(result, response_code, body) + if not bool(parsed.get("has_update", false)): + return + if not _can_self_update(): + install_state_changed.emit({ + "button_text": "Upgrade Godot", + "button_disabled": true, + "label_text": _manual_update_label(String(parsed.get("version", ""))), + "banner_visible": true, + }) + return + _latest_download_url = String(parsed.get("download_url", "")) + _latest_checksum_url = String(parsed.get("checksum_url", "")) + _latest_signature_url = String(parsed.get("signature_url", "")) + _latest_remote_version = String(parsed.get("version", "")) + update_check_completed.emit(parsed) + + +func _on_download_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + _body: PackedByteArray +) -> void: + if _download_request != null: + _download_request.queue_free() + _download_request = null + + if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: + print("MCP | update download failed: result=%d code=%d" % [result, response_code]) + ## Failure parity with _fail_verification (#713): HTTPRequest's + ## download_file mode leaves whatever partial/error bytes it wrote + ## staged at UPDATE_TEMP_ZIP — drop them so no later step can ever + ## pick up a half-downloaded archive. + DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)) + install_state_changed.emit({ + "button_text": "Download failed (%d)" % response_code, + "button_disabled": false, + }) + return + + # Deferred so the HTTPRequest callback returns before the next step starts. + _verify_then_install.call_deferred() + + +# ---- Integrity verification (#523, #599, #687) -------------------------- + +## Gate the extract on (1) an RSA signature over the checksum sidecar and +## (2) a SHA-256 match of the archive against that sidecar. TLS + host +## pinning constrain where the bytes came from; the digest verifies the +## bytes themselves (in-transit corruption, single-object substitution); +## the signature verifies the digest's *provenance*. Both `download_url` +## and `checksum_url` come from the same GitHub Releases API response over +## the same channel, so anyone able to modify the release's assets (leaked +## repo token, compromised release workflow) can regenerate the sidecar to +## match a tampered zip — but cannot forge the `.sha256.sig` signature, +## whose private key lives only in an Actions secret outside the repo +## token's scope (#687). +## +## Verification is MANDATORY (#599): no `.sha256` sidecar — mistake or +## tamper — refuses to install. The signature is mandatory for every +## release at or above SIGNING_REQUIRED_FROM_VERSION: a missing signature +## there is a strip-attack signal, not a compat case, and hard-fails. Only +## releases predating signing take the legacy checksum-only path. +func _verify_then_install() -> void: + _pending_sidecar_body = PackedByteArray() + _pending_expected_digest = "" + + if _latest_checksum_url.is_empty(): + _fail_verification( + "release published no godot-ai-plugin.zip.sha256 sidecar; " + + "refusing unverified install (#599)" + ) + return + + ## A present-but-untrusted checksum URL is a tamper signal, not a + ## backward-compat case — refuse rather than silently skip. Trusted + ## means a GitHub host AND this repo's release-asset path (#599). + if not _is_trusted_download_url(_latest_checksum_url): + _fail_verification("checksum URL is not a trusted hi-godot/godot-ai release asset") + return + + if _latest_signature_url.is_empty(): + if _signature_required(_latest_remote_version): + _fail_verification( + "release v%s ships no godot-ai-plugin.zip.sha256.sig signature. " + % _latest_remote_version + + "Every release from v%s on is signed" % SIGNING_REQUIRED_FROM_VERSION + + " — a missing signature means a stripped or tampered release (#687)" + ) + return + print( + "MCP | self-update: release v%s predates signing; " % _latest_remote_version + + "using legacy checksum-only verification (#687)" + ) + elif not _is_trusted_download_url(_latest_signature_url): + _fail_verification("signature URL is not a trusted hi-godot/godot-ai release asset") + return + + install_state_changed.emit({"button_text": "Verifying..."}) + if _verify_request != null: + _verify_request.queue_free() + _verify_request = HTTPRequest.new() + _verify_request.max_redirects = 10 + _verify_request.request_completed.connect(_on_checksum_completed) + add_child(_verify_request) + var err := _verify_request.request(_latest_checksum_url) + if err != OK: + _verify_request.queue_free() + _verify_request = null + _fail_verification("could not request checksum (error %d)" % err) + + +func _on_checksum_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray +) -> void: + if _verify_request != null: + _verify_request.queue_free() + _verify_request = null + + if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: + _fail_verification("checksum download failed (result=%d code=%d)" % [result, response_code]) + return + + var expected := _parse_sha256_digest(body.get_string_from_utf8()) + if expected.is_empty(): + _fail_verification("malformed checksum file") + return + + ## Signature verification (when armed) runs over the exact sidecar bytes + ## the digest was parsed from — hold both until the signature verdict. + if not _latest_signature_url.is_empty(): + _pending_sidecar_body = body + _pending_expected_digest = expected + _fetch_signature() + return + + ## Legacy pre-signing release: `_verify_then_install` already gated this + ## on the remote version predating SIGNING_REQUIRED_FROM_VERSION. + _finish_digest_check_and_install(expected) + + +## Download the `.sha256.sig` release asset; `_on_signature_completed` +## verifies it over the held sidecar bytes before the digest is trusted. +func _fetch_signature() -> void: + if _signature_request != null: + _signature_request.queue_free() + _signature_request = HTTPRequest.new() + _signature_request.max_redirects = 10 + _signature_request.request_completed.connect(_on_signature_completed) + add_child(_signature_request) + var err := _signature_request.request(_latest_signature_url) + if err != OK: + _signature_request.queue_free() + _signature_request = null + _fail_verification("could not request signature (error %d)" % err) + + +func _on_signature_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray +) -> void: + if _signature_request != null: + _signature_request.queue_free() + _signature_request = null + + if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: + _fail_verification( + "signature download failed (result=%d code=%d)" % [result, response_code] + ) + return + + if not _verify_sidecar_signature(RELEASE_SIGNING_PUBLIC_KEY_PEM, _pending_sidecar_body, body): + _fail_verification( + "release signature does not verify against the embedded public key — " + + "the checksum sidecar was not produced by the release pipeline (#687)" + ) + return + + print("MCP | self-update release signature verified (rsa-4096/sha256)") + _finish_digest_check_and_install(_pending_expected_digest) + + +## Final gate shared by the signed and legacy paths: the staged archive's +## SHA-256 must match the (now-trusted) sidecar digest before extract. +func _finish_digest_check_and_install(expected: String) -> void: + _pending_sidecar_body = PackedByteArray() + _pending_expected_digest = "" + + var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP) + var actual := FileAccess.get_sha256(zip_path).to_lower() + if actual.is_empty(): + _fail_verification("could not hash the downloaded archive") + return + if actual != expected: + _fail_verification( + "checksum mismatch (expected %s…, got %s…)" + % [expected.substr(0, 12), actual.substr(0, 12)] + ) + return + + print("MCP | self-update checksum verified (sha256 %s)" % actual) + install_state_changed.emit({"button_text": "Installing..."}) + _install_zip.call_deferred() + + +## True when `remote_version` falls inside the signing era — every release +## at or above SIGNING_REQUIRED_FROM_VERSION ships a signed sidecar, so a +## missing signature there must hard-fail rather than fall back to the +## legacy checksum-only path. An empty/unknown version fails closed. Static +## so it's unit-testable. +static func _signature_required(remote_version: String) -> bool: + if remote_version.strip_edges().is_empty(): + return true + return not _is_newer(SIGNING_REQUIRED_FROM_VERSION, remote_version) + + +## PKCS#1 v1.5 RSA verification of `signature` over SHA-256(`sidecar`) — +## the exact output of release.yml's `openssl dgst -sha256 -sign`. Takes +## the PEM as a parameter (rather than reading the const) so tests can +## exercise both verdicts with a generated throwaway keypair. Static so +## it's unit-testable without instancing the manager. +static func _verify_sidecar_signature( + public_key_pem: String, sidecar: PackedByteArray, signature: PackedByteArray +) -> bool: + if sidecar.is_empty() or signature.is_empty(): + return false + var key := CryptoKey.new() + if key.load_from_string(public_key_pem, true) != OK: + return false + var ctx := HashingContext.new() + if ctx.start(HashingContext.HASH_SHA256) != OK: + return false + ctx.update(sidecar) + var digest := ctx.finish() + var crypto := Crypto.new() + return crypto.verify(HashingContext.HASH_SHA256, digest, signature, key) + + +## Surface an integrity-check failure and drop the staged zip so the bad +## bytes can never reach the extract path. Keeps the button enabled for retry. +func _fail_verification(reason: String) -> void: + _pending_sidecar_body = PackedByteArray() + _pending_expected_digest = "" + push_error( + "MCP | self-update integrity check failed: %s. The download was not installed." + % reason + ) + print("MCP | self-update aborted (integrity): %s" % reason) + DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)) + install_state_changed.emit({ + "button_text": "Verification failed — retry", + "button_disabled": false, + }) + + +## Extract the hex digest from a `sha256sum`-style file (" ") or a +## bare digest line. Returns lowercase 64-char hex, or "" if the content isn't +## a valid SHA-256 digest. Static so it's unit-testable. See #523. +static func _parse_sha256_digest(text: String) -> String: + var trimmed := text.strip_edges() + if trimmed.is_empty(): + return "" + ## First whitespace-delimited token; `sha256sum` separates digest and + ## filename with two spaces, but some tools use tabs. + var normalized := trimmed.replace("\t", " ").replace("\n", " ").replace("\r", " ") + var tokens := normalized.split(" ", false) + if tokens.is_empty(): + return "" + var digest := String(tokens[0]).strip_edges().to_lower() + if digest.length() != 64: + return "" + for i in digest.length(): + var c := digest[i] + if not ((c >= "0" and c <= "9") or (c >= "a" and c <= "f")): + return "" + return digest + + +# ---- Install orchestration --------------------------------------------- + +## True when install may proceed now. False means "come back later" — this +## call has already scheduled the retry. +## +## Deliberately placed AFTER the symlink check in `_install_zip` so a dev +## checkout (which never installs) is never delayed by it. +func _wait_for_prewarm_before_install() -> bool: + if _prewarm_pid <= 0 or not PortResolver.pid_alive(_prewarm_pid): + _prewarm_pid = -1 + return true + if _prewarm_wait_started_ms == 0: + _prewarm_wait_started_ms = Time.get_ticks_msec() + if Time.get_ticks_msec() - _prewarm_wait_started_ms >= PREWARM_WAIT_BUDGET_MS: + ## Proceed rather than strand the update: a pre-warm this slow means the + ## post-update spawn will be cold, which is now survivable — the startup + ## watch stays alive until the pid-file appears (#896). + print( + "MCP | self-update: package pre-warm still running after %ds; " + % int(PREWARM_WAIT_BUDGET_MS / 1000) + + "installing anyway, the new server may start cold" + ) + _prewarm_pid = -1 + return true + install_state_changed.emit({"button_text": "Preparing packages...", "button_disabled": true}) + if is_inside_tree(): + get_tree().create_timer(PREWARM_POLL_SECONDS).timeout.connect( + _install_zip, CONNECT_ONE_SHOT + ) + return false + ## Detached from the tree (dock teardown mid-update): no timer to re-arm on, + ## so let the install proceed rather than silently dropping it. + _prewarm_pid = -1 + return true + + +func _install_zip() -> void: + ## Symlinked addons dir means an extract would clobber canonical + ## `plugin/` source through the link. Symlink detection is independent + ## of the mode override: even forced-user aborts here. See #116. + if ClientConfigurator.addons_dir_is_symlink(): + install_state_changed.emit({ + "button_text": "Dev checkout — update via git", + "button_disabled": true, + "banner_visible": false, + }) + return + + ## #896: do not tear the old server down until the NEW version's uv + ## environment is actually built. `install_downloaded_update` below kills + ## the server and hands off to the reload runner; the new plugin instance + ## then spawns a server that, on a cold cache, is still downloading ~67 + ## packages. The pre-warm was started alongside the zip download precisely + ## to cover this, but nothing waited for it — so on a slow link the update + ## proceeded while the environment was still being fetched, which is the + ## window where a failed spawn goes undiagnosed. + ## + ## Non-blocking: re-arms itself on a timer rather than stalling the main + ## thread, and gives up after PREWARM_WAIT_BUDGET_MS so a wedged pre-warm + ## cannot strand the update. Fully inert when no pre-warm is running. + if not _wait_for_prewarm_before_install(): + return + + ## Drain in-flight workers + block new ones BEFORE any disk write. + ## Without this, focus-in landing in the extract -> reload window spawns + ## a worker that walks into a partially-overwritten script and + ## SIGABRTs in `GDScriptFunction::call`. + _install_in_flight = true + _drain_dock_workers() + + var has_runner: bool = ( + _plugin != null + and _plugin.has_method("install_downloaded_update") + ) + if has_runner: + install_state_changed.emit({"button_text": "Reloading..."}) + ## Runner takes over: plugin tears down, runner extracts + scans + + ## re-enables. `install_downloaded_update` calls + ## `prepare_for_update_reload()` internally (kills the server, + ## resets the spawn guard) - see plugin.gd::install_downloaded_update. + _plugin.install_downloaded_update(UPDATE_TEMP_ZIP, UPDATE_TEMP_DIR, _dock) + return + + DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)) + DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_DIR)) + _install_in_flight = false + install_state_changed.emit({ + "button_text": "Reload runner missing", + "button_disabled": false, + }) + + +func _reload_after_update() -> void: + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) + EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) + + +func _drain_dock_workers() -> void: + if _dock != null and _dock.has_method("prepare_for_self_update_drain"): + _dock.prepare_for_self_update_drain() diff --git a/addons/godot_ai/utils/update_manager.gd.uid b/addons/godot_ai/utils/update_manager.gd.uid new file mode 100644 index 0000000..4089a72 --- /dev/null +++ b/addons/godot_ai/utils/update_manager.gd.uid @@ -0,0 +1 @@ +uid://cegiyw3fjcwev diff --git a/addons/godot_ai/utils/update_mixed_state.gd b/addons/godot_ai/utils/update_mixed_state.gd new file mode 100644 index 0000000..96d0024 --- /dev/null +++ b/addons/godot_ai/utils/update_mixed_state.gd @@ -0,0 +1,140 @@ +@tool +extends RefCounted + +## Scanner that detects whether `addons/godot_ai/` is in a half-installed +## state left behind by a self-update whose rollback couldn't restore the +## previous addon contents (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`). +## +## Without this surface the user sees "plugin won't start" with no actionable +## context, re-runs the update, and compounds the mismatch (issue #354 / +## audit-v2 #10). The dock paints a banner from `diagnose()` and +## `editor_handler.gd::get_editor_state` includes the same Dictionary so an +## MCP agent can see and report the state. + +const ADDON_DIR := "res://addons/godot_ai/" +## Producer is `update_reload_runner.gd::INSTALL_BACKUP_SUFFIX`. Inlined as a +## literal because old two-phase runners can parse this diagnostic script +## against stale runner Script-object content during their mixed-snapshot +## scan. `test_update_backup_suffix_stays_in_sync` guards against drift. +const BACKUP_SUFFIX := ".update_backup" +## Cap so a runaway addons tree (someone parented the wrong dir, an old +## crashed install left thousands of artifacts) can't blow the +## `editor_state` payload size or freeze the editor on first paint. +const MAX_BACKUP_RESULTS := 200 +## TTL for the `diagnose()` cache. `editor_state` is one of the highest- +## traffic MCP tools (agents poll it constantly) and a recursive +## `DirAccess` walk on every call would put I/O on the 4ms `_process()` +## budget. Mixed-state is rare and persistent across editor restarts, so +## a few seconds of staleness is acceptable; the dock's Re-scan button +## bypasses the cache via `force=true` for immediate feedback. +const CACHE_TTL_MSEC := 5000 + +static var _cache_value: Dictionary = {} +static var _cache_timestamp_msec: int = -1 + + +## Walk `dir` recursively and return every `res://`-relative path that ends +## in `.update_backup`, sorted ascending. Truncates at `MAX_BACKUP_RESULTS` +## — the truncation flag is exposed via `diagnose()`. +## +## Walk order is deterministic: entries within each directory are sorted +## alphabetically, subdirs pushed reverse-sorted so DFS pops them in +## ascending order. Without this two scans of the same mixed tree could +## return different 200-file slices when truncation kicks in (Godot's +## `list_dir` order isn't guaranteed stable across filesystems). +static func find_backups(dir: String = ADDON_DIR) -> Array: + var results: Array = [] + var stack: Array = [dir] + while not stack.is_empty(): + if results.size() >= MAX_BACKUP_RESULTS: + break + var current: String = stack.pop_back() + var d := DirAccess.open(current) + ## Missing dir, permission error, or unreadable junction — skip + ## silently. A missing addons dir is the bare-clone case; mid-walk + ## errors stay quiet so a single permission glitch can't block the + ## diagnostic the rest of the scan would have produced. + if d == null: + continue + var entries: Array = [] + d.list_dir_begin() + while true: + var entry := d.get_next() + if entry.is_empty(): + break + if entry == "." or entry == "..": + continue + entries.append({"name": entry, "is_dir": d.current_is_dir()}) + d.list_dir_end() + entries.sort_custom(func(a, b): return a["name"] < b["name"]) + ## Push subdirs reverse-sorted so the next outer iteration pops + ## them in ascending order — see method docstring for why this + ## determinism matters for the truncated case. + for i in range(entries.size() - 1, -1, -1): + var entry: Dictionary = entries[i] + if entry["is_dir"]: + stack.append(current.path_join(entry["name"])) + for entry in entries: + if entry["is_dir"]: + continue + if not String(entry["name"]).ends_with(BACKUP_SUFFIX): + continue + results.append(current.path_join(entry["name"])) + if results.size() >= MAX_BACKUP_RESULTS: + break + results.sort() + return results + + +## Build the structured diagnostic Dictionary surfaced via `editor_state` +## and the dock banner. Empty when the addons tree is clean — callers +## gate banner visibility / response field on `is_empty()`. +## +## Cached for `CACHE_TTL_MSEC` when scanning the default `ADDON_DIR` so +## per-`editor_state` polls don't re-walk the addons tree every frame. +## Tests passing a custom `dir` always see a fresh scan (cache only +## tracks the production path). `force=true` bypasses the cache — used +## by the dock's Re-scan button so a manual fix is reflected immediately. +static func diagnose(dir: String = ADDON_DIR, force: bool = false) -> Dictionary: + var use_cache := dir == ADDON_DIR and not force + if use_cache and _cache_timestamp_msec >= 0: + if Time.get_ticks_msec() - _cache_timestamp_msec < CACHE_TTL_MSEC: + return _cache_value.duplicate(true) + + var backups := find_backups(dir) + var result: Dictionary = {} + if not backups.is_empty(): + ## Most commonly produced by `_rollback_paths_written` returning + ## FAILED_MIXED, but `_finalize_install_success` removes backups on + ## a best-effort basis so a successful install can also leave them + ## behind if the cleanup `remove_absolute` hit a permission error. + ## The recovery action — delete the *.update_backup files — is the + ## same in both cases, so the message acknowledges both + ## possibilities rather than asserting the alarming one. + result = { + "addon_dir": dir, + "backup_files": backups, + "backup_count": backups.size(), + "truncated": backups.size() >= MAX_BACKUP_RESULTS, + "message": ( + "Found .update_backup files in addons/godot_ai/. This usually" + + " means a self-update rollback couldn't restore the previous" + + " addon contents (FAILED_MIXED) — the plugin may load a mix" + + " of old and new files. Restore the addon from your VCS or a" + + " fresh release ZIP, then delete the listed *.update_backup" + + " files. If the plugin runs without issues these are likely" + + " stale from a successful install and safe to delete." + ), + } + if use_cache: + _cache_value = result.duplicate(true) + _cache_timestamp_msec = Time.get_ticks_msec() + return result + + +## Reset the `diagnose()` cache. Tests that flip the addons-tree state +## between calls use this to avoid TTL-bound flakiness; the dock's +## Re-scan button uses `force=true` instead. +static func clear_cache() -> void: + _cache_value = {} + _cache_timestamp_msec = -1 diff --git a/addons/godot_ai/utils/update_mixed_state.gd.uid b/addons/godot_ai/utils/update_mixed_state.gd.uid new file mode 100644 index 0000000..6d608d9 --- /dev/null +++ b/addons/godot_ai/utils/update_mixed_state.gd.uid @@ -0,0 +1 @@ +uid://dd5rti52vgs71 diff --git a/addons/godot_ai/utils/uv_cache_cleanup.gd b/addons/godot_ai/utils/uv_cache_cleanup.gd new file mode 100644 index 0000000..86edbe1 --- /dev/null +++ b/addons/godot_ai/utils/uv_cache_cleanup.gd @@ -0,0 +1,161 @@ +@tool +class_name McpUvCacheCleanup +extends RefCounted + +## Sweeps stale `.tmp*` build venvs out of `%LOCALAPPDATA%\uv\cache\builds-v0`. +## +## Background +## ---------- +## When an MCP client's attach launcher invokes +## `uvx --from godot-ai==VERSION godot-ai attach ...`, uv builds an ephemeral venv under +## `builds-v0\.tmpXXXXXX\`. To save disk it hard-links shared C extensions +## (notably `pydantic_core/_pydantic_core.cp313-win_amd64.pyd`) from +## `archive-v0\\Lib\site-packages\...` into the build venv. +## +## If the godot-ai server's own Python child has that same `.pyd` mapped via +## `LoadLibrary` (it does — godot-ai imports pydantic), the file is locked +## under BOTH paths because hard links share the inode and Windows tracks +## handles per-file, not per-path. uv's post-install cleanup of the build +## venv then dies with: +## +## Failed to install: pywin32-311-cp313-cp313-win_amd64.whl (pywin32==311) +## Caused by: failed to remove directory `...\.tmpXXXXXX\Lib\site-packages\pywin32-311.data` +## 다른 프로세스가 파일을 사용 중이기 때문에 ... (os error 32) +## +## (the `pywin32` mention is incidental — the actual lock is on the earlier +## hard-linked `_pydantic_core.pyd`; pywin32 is just the last install step +## in the wheel-resolution order that triggers the cleanup pass). +## +## What this does +## -------------- +## After the plugin stops/restarts the managed server — i.e. the moment when +## the archive-v0 `.pyd` mappings drop and the hard-linked builds-v0 copy +## becomes deletable — sweep `builds-v0\` for `.tmp*` orphans: +## +## 1. Rename each `.tmpXXX` to `_dead_.tmpXXX`. Rename succeeds even when +## AV scanners hold the file open without `FILE_SHARE_DELETE` (Defender +## and Softcamp SDS both do this), so this step always advances. +## 2. Recursively remove the renamed dir, swallowing per-file +## access-denied. Anything still genuinely locked is left for the next +## sweep — uv won't reuse the renamed name, so no future build collides. +## +## No-op on non-Windows (uv's hard-link strategy only causes this lock +## pattern on NTFS) and when the cache directory doesn't exist. + +const DEAD_PREFIX := "_dead_" +const TMP_PREFIX := ".tmp" + + +## Live entrypoint. Resolves `%LOCALAPPDATA%\uv\cache\builds-v0` and runs +## the sweep. Returns the same counts the testable `purge_directory` returns, +## or all zeros on non-Windows / missing cache. +static func purge_stale_builds() -> Dictionary: + if OS.get_name() != "Windows": + return _empty_result() + var local_appdata := OS.get_environment("LOCALAPPDATA") + if local_appdata.is_empty(): + return _empty_result() + var builds_root := local_appdata.replace("\\", "/").path_join("uv/cache/builds-v0") + return purge_directory(builds_root) + + +## Pure-ish entrypoint that takes a directory path. Returns +## `{ "scanned": int, "renamed": int, "deleted": int, "remaining": int }`. +## - `scanned`: how many `.tmp*` subdirs we saw on entry. +## - `renamed`: how many we successfully renamed to `_dead_*`. +## - `deleted`: how many we then fully removed. +## - `remaining`: how many `_dead_*` dirs are still on disk after the sweep +## (left for the next call to retry). +## +## Errors are swallowed — the caller is on a server-stop hot path and +## must not raise. +static func purge_directory(builds_root: String) -> Dictionary: + var result := _empty_result() + if not DirAccess.dir_exists_absolute(builds_root): + return result + var dir := DirAccess.open(builds_root) + if dir == null: + return result + dir.include_hidden = true + + ## Pass 1: collect names. Iterating + renaming in the same walk would + ## confuse DirAccess's internal cursor on NTFS. + var tmp_names: Array[String] = [] + var dead_names: Array[String] = [] + dir.list_dir_begin() + var entry := dir.get_next() + while entry != "": + if dir.current_is_dir() and not (entry == "." or entry == ".."): + if entry.begins_with(TMP_PREFIX): + tmp_names.append(entry) + elif entry.begins_with(DEAD_PREFIX): + dead_names.append(entry) + entry = dir.get_next() + dir.list_dir_end() + result.scanned = tmp_names.size() + + ## Pass 2: rename `.tmp*` → `_dead_.tmp*`. Rename works even on + ## AV-locked files (Defender opens without FILE_SHARE_DELETE, but rename + ## doesn't need delete share). Any rename failure is non-fatal. + for name in tmp_names: + var src := builds_root.path_join(name) + var dst := builds_root.path_join(DEAD_PREFIX + name) + if dir.rename(src, dst) == OK: + result.renamed += 1 + dead_names.append(DEAD_PREFIX + name) + + ## Pass 3: best-effort recursive delete of every `_dead_*`, including + ## ones left over from earlier sweeps that couldn't be cleaned then. + for name in dead_names: + var path := builds_root.path_join(name) + if _remove_recursive(path): + result.deleted += 1 + + ## Final pass: count `_dead_*` survivors so the caller (and tests) can + ## see how many genuinely-locked dirs we couldn't reach. + var dir2 := DirAccess.open(builds_root) + if dir2 != null: + dir2.include_hidden = true + dir2.list_dir_begin() + var e := dir2.get_next() + while e != "": + if dir2.current_is_dir() and e.begins_with(DEAD_PREFIX): + result.remaining += 1 + e = dir2.get_next() + dir2.list_dir_end() + + return result + + +## Recursive `rm -rf` that swallows access-denied per-file. Returns true +## only when the target directory itself was removed. +static func _remove_recursive(path: String) -> bool: + var dir := DirAccess.open(path) + if dir == null: + ## Already gone, or unreadable — try a direct remove just in case + ## (an empty dir handle-leak path) and report based on existence. + DirAccess.remove_absolute(path) + return not DirAccess.dir_exists_absolute(path) + dir.include_hidden = true + dir.list_dir_begin() + var entry := dir.get_next() + while entry != "": + if entry == "." or entry == "..": + entry = dir.get_next() + continue + var child := path.path_join(entry) + if dir.current_is_dir(): + _remove_recursive(child) + else: + DirAccess.remove_absolute(child) + entry = dir.get_next() + dir.list_dir_end() + ## Remove the (hopefully now empty) dir itself. If a hard-linked .pyd is + ## still mapped by a surviving process, this fails silently and the + ## caller sees `remaining > 0` so it can retry on the next sweep. + DirAccess.remove_absolute(path) + return not DirAccess.dir_exists_absolute(path) + + +static func _empty_result() -> Dictionary: + return { "scanned": 0, "renamed": 0, "deleted": 0, "remaining": 0 } diff --git a/addons/godot_ai/utils/uv_cache_cleanup.gd.uid b/addons/godot_ai/utils/uv_cache_cleanup.gd.uid new file mode 100644 index 0000000..321659d --- /dev/null +++ b/addons/godot_ai/utils/uv_cache_cleanup.gd.uid @@ -0,0 +1 @@ +uid://d33ukg65qf7q0 diff --git a/addons/godot_ai/utils/variant_serializer.gd b/addons/godot_ai/utils/variant_serializer.gd new file mode 100644 index 0000000..21d8c6e --- /dev/null +++ b/addons/godot_ai/utils/variant_serializer.gd @@ -0,0 +1,102 @@ +@tool +extends RefCounted + +## Converts Godot Variants into values that can be encoded as JSON. + + +## Non-finite floats (NaN/INF) have no JSON representation: JSON.stringify +## emits them as the bare tokens `inf`/`nan`, which are invalid JSON — the +## server drops the whole frame and the pending request times out (#688). +## Serialize them as null instead (the same choice web JSON.stringify makes), +## applied uniformly across the supported 4.5+ floor — no version gate, so +## wire output is identical on every supported engine. +static func _safe_float(f: float) -> Variant: + return f if is_finite(f) else null + + +static func serialize(value: Variant) -> Variant: + if value == null: + return null + match typeof(value): + TYPE_BOOL, TYPE_INT, TYPE_STRING: + return value + TYPE_FLOAT: + return _safe_float(value) + TYPE_STRING_NAME: + return str(value) + # Integer vector types are listed separately from their float twins so + # int components stay ints on the wire (no float coercion via + # _safe_float's typed parameter). + TYPE_VECTOR2I: + return {"x": value.x, "y": value.y} + TYPE_VECTOR2: + return {"x": _safe_float(value.x), "y": _safe_float(value.y)} + TYPE_VECTOR3I: + return {"x": value.x, "y": value.y, "z": value.z} + TYPE_VECTOR3: + return {"x": _safe_float(value.x), "y": _safe_float(value.y), "z": _safe_float(value.z)} + TYPE_VECTOR4I: + return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} + TYPE_VECTOR4, TYPE_QUATERNION: + return { + "x": _safe_float(value.x), + "y": _safe_float(value.y), + "z": _safe_float(value.z), + "w": _safe_float(value.w), + } + TYPE_COLOR: + return { + "r": _safe_float(value.r), + "g": _safe_float(value.g), + "b": _safe_float(value.b), + "a": _safe_float(value.a), + } + TYPE_RECT2, TYPE_RECT2I, TYPE_AABB: + return { + "position": serialize(value.position), + "size": serialize(value.size), + } + TYPE_PLANE: + return {"normal": serialize(value.normal), "d": _safe_float(value.d)} + TYPE_BASIS: + return { + "x": serialize(value.x), + "y": serialize(value.y), + "z": serialize(value.z), + } + TYPE_TRANSFORM2D: + return { + "x": serialize(value.x), + "y": serialize(value.y), + "origin": serialize(value.origin), + } + TYPE_TRANSFORM3D: + return { + "basis": serialize(value.basis), + "origin": serialize(value.origin), + } + TYPE_PROJECTION: + return { + "x": serialize(value.x), + "y": serialize(value.y), + "z": serialize(value.z), + "w": serialize(value.w), + } + TYPE_NODE_PATH: + return str(value) + TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_VECTOR4_ARRAY, TYPE_PACKED_COLOR_ARRAY: + var arr: Array = [] + for item in value: + arr.append(serialize(item)) + return arr + TYPE_DICTIONARY: + var out := {} + for key in value: + out[str(key)] = serialize(value[key]) + return out + TYPE_OBJECT: + if value is Resource and value.resource_path: + return value.resource_path + return str(value) + _: + return str(value) diff --git a/addons/godot_ai/utils/variant_serializer.gd.uid b/addons/godot_ai/utils/variant_serializer.gd.uid new file mode 100644 index 0000000..b8e0a28 --- /dev/null +++ b/addons/godot_ai/utils/variant_serializer.gd.uid @@ -0,0 +1 @@ +uid://cte37mtbd61n3 diff --git a/addons/godot_ai/utils/windows_port_reservation.gd b/addons/godot_ai/utils/windows_port_reservation.gd new file mode 100644 index 0000000..23b4f43 --- /dev/null +++ b/addons/godot_ai/utils/windows_port_reservation.gd @@ -0,0 +1,146 @@ +@tool +class_name McpWindowsPortReservation +extends RefCounted + +## Detects whether Windows has reserved a TCP port range that covers the +## plugin's server port. Hyper-V, WSL2, Docker Desktop, and Windows +## Sandbox all grab port ranges at boot via the winnat service. When a +## user's chosen port sits inside a reserved range, bind(2) fails with +## WinError 10013 ("forbidden by its access permissions") rather than +## 10048 ("address in use") — `netstat` shows nothing because no process +## owns the port, making the failure invisible. See issue #146. + +const NETSH_ARGS := ["interface", "ipv4", "show", "excludedportrange", "protocol=tcp"] + +## Session-lifetime cache. winnat establishes its excluded-port ranges at +## boot, so the table is effectively static for an editor session — while +## a `netsh` spawn costs ~250ms (measured), which the old 2s TTL re-paid +## on every startup walk (and could even re-pay *within* one walk when +## server-command discovery ran long between the two netsh consumers). +## Staleness risk is bounded: a mid-session winnat change (Docker/WSL2 +## start) at worst yields the same failure mode as the pre-#146 code for +## the remainder of the session, and only if a spawn happens after it. +static var _netsh_cache_text := "" +static var _netsh_cache_valid := false +static var _netsh_query_count := 0 + + +## Returns true if `port` falls inside a currently-reserved range on this +## Windows host. No-op on non-Windows (returns false). +static func is_port_excluded(port: int) -> bool: + if OS.get_name() != "Windows": + return false + var cached := _get_cached_excluded_output() + if bool(cached.get("hit", false)): + return parse_excluded(str(cached.get("text", "")), port) + var output: Array = [] + var exit_code := _execute_netsh_excluded_ranges(output) + if exit_code != 0 or output.is_empty(): + return false + var text := str(output[0]) + _store_excluded_output(text) + return parse_excluded(text, port) + + +static func _store_excluded_output(text: String) -> void: + _netsh_cache_text = text + _netsh_cache_valid = true + + +static func _get_cached_excluded_output() -> Dictionary: + if not _netsh_cache_valid: + return {"hit": false, "text": ""} + return {"hit": true, "text": _netsh_cache_text} + + +static func _clear_cache_for_tests() -> void: + _netsh_cache_text = "" + _netsh_cache_valid = false + + +static func netsh_query_count() -> int: + return _netsh_query_count + + +static func _execute_netsh_excluded_ranges(output: Array) -> int: + _netsh_query_count += 1 + return OS.execute("netsh", NETSH_ARGS, output, true) + + +## Parse the `netsh` excluded-port-range output and return true if `port` +## sits inside any reserved range. Exposed for testing; the live check +## uses `is_port_excluded`. Expected input format: +## +## Protocol tcp Port Exclusion Ranges +## +## Start Port End Port +## ---------- -------- +## 80 80 +## 5040 5040 +## 8000 8099 +## +## * - Administered port exclusions. +static func parse_excluded(text: String, port: int) -> bool: + return _ranges_contain(parse_excluded_ranges(text), port) + + +## Parse the `netsh` excluded-port-range output once into inclusive ranges. +static func parse_excluded_ranges(text: String) -> Array[Vector2i]: + var ranges: Array[Vector2i] = [] + for line in text.split("\n"): + var trimmed := line.strip_edges() + if trimmed.is_empty() or trimmed.begins_with("-") or trimmed.begins_with("*"): + continue + var parts: PackedStringArray = trimmed.split(" ", false) + if parts.size() < 2: + continue + if not parts[0].is_valid_int() or not parts[1].is_valid_int(): + continue + var start_p := int(parts[0]) + var end_p := int(parts[1]) + ranges.append(Vector2i(start_p, end_p)) + return ranges + + +static func _ranges_contain(ranges: Array[Vector2i], port: int) -> bool: + for r in ranges: + if port >= r.x and port <= r.y: + return true + return false + + +## Return the first port in `start`..`start+span-1` that is not excluded by +## Windows' port reservation table. Runs `netsh` once, unlike probing every +## candidate with `is_port_excluded`, which keeps fallback port selection cheap +## when Hyper-V / WSL2 / Docker reserve many adjacent ranges. +static func suggest_non_excluded_port(start: int, span: int = 2048, max_port: int = 65535) -> int: + if OS.get_name() != "Windows": + return start + var cached := _get_cached_excluded_output() + if bool(cached.get("hit", false)): + return suggest_non_excluded_port_from_output(str(cached.get("text", "")), start, span, max_port) + var output: Array = [] + var exit_code := _execute_netsh_excluded_ranges(output) + if exit_code != 0 or output.is_empty(): + return start + var text := str(output[0]) + _store_excluded_output(text) + return suggest_non_excluded_port_from_output(text, start, span, max_port) + + +## Pure parser-backed helper for tests and for `suggest_non_excluded_port`. +static func suggest_non_excluded_port_from_output(text: String, start: int, span: int = 2048, max_port: int = 65535) -> int: + var ranges := parse_excluded_ranges(text) + var limit := mini(start + span - 1, max_port) + var p := start + while p <= limit: + var advanced := false + for r in ranges: + if p >= r.x and p <= r.y: + p = r.y + 1 + advanced = true + break + if not advanced: + return p + return start + diff --git a/addons/godot_ai/utils/windows_port_reservation.gd.uid b/addons/godot_ai/utils/windows_port_reservation.gd.uid new file mode 100644 index 0000000..ffc5746 --- /dev/null +++ b/addons/godot_ai/utils/windows_port_reservation.gd.uid @@ -0,0 +1 @@ +uid://bt7mxpjcdrobq diff --git a/addons/godot_ai/vision_routing.gd b/addons/godot_ai/vision_routing.gd new file mode 100644 index 0000000..2f42cef --- /dev/null +++ b/addons/godot_ai/vision_routing.gd @@ -0,0 +1,1043 @@ +@tool +extends RefCounted + +## Vision Routing - route screenshot-tool images through a curated vision API. +## +## Models without image support (e.g. DeepSeek) cannot read the image blocks the +## screenshot tool returns. When routing is enabled, every single-image capture is sent to a +## vision model on a worker thread and the resulting text description is +## returned to the AI instead: +## +## - Editor (non-game) screenshots are captured normally by +## editor_handler.gd, then described on a worker thread; the reply is +## deferred until the description is ready. +## - Game screenshots are intercepted in mcp_debugger_plugin.gd the same way. +## - On success the response keeps a valid but tiny 2x2 placeholder image and +## carries the description as text metadata (`vision_description` plus a +## `note`, which the server forwards to the model). +## - On failure (missing key, network, API error) the original image payload +## passes through unchanged, so the screenshot tool never breaks. +## +## Providers are curated: label, API dialect, endpoint shape, environment +## variable and encrypted key slot are fixed in the PROVIDERS table. The model +## id is NOT - it is required per provider and entered by the user (stored in +## Editor Settings next to the key), because third-party model ids retire and +## only the account holder gets notified. Switching providers switches to that +## provider's entered model, so the software never guesses a model name: +## - Groq (free tier) - OpenAI chat-completions +## - Google Gemini (free tier, AI Studio key) - generateContent REST +## - xAI Grok (paid) - OpenAI chat-completions +## Both dialects are handled in this file. +## +## Settings live in Editor Settings (`vision_routing/enabled`, +## `vision_routing/provider`, plus one encrypted key slot and one model-id +## slot per provider). Keys are stored encrypted (AES-256-CBC, key derived +## from this machine) rather than in plain text, and each provider's +## environment variable (GROQ_API_KEY / GOOGLE_API_KEY / XAI_API_KEY) takes +## priority over the stored key. +## +## UI: a "Vision Routing" section inside the Clients & Tools Settings tab. + +## Curated providers: label, dialect, endpoint shape, env var and key/model +## setting slots are fixed here. The model id itself is user-entered per +## provider (see `_resolved_model`), so provider rows carry a `model_setting` +## slot and a `model_placeholder` suggestion instead of a baked-in id - +## third-party model ids retire, and only the account holder gets notified +## when one does. +const PROVIDERS := { + "groq": { + "label": "Groq", + "dialect": "openai", + "model_setting": "vision_routing/groq_model", + "model_placeholder": "qwen/qwen3.6-27b", + "host": "api.groq.com", + "port": 443, + "path": "/openai/v1/chat/completions", + "env": "GROQ_API_KEY", + "setting": "vision_routing/api_key_enc", + "placeholder": "gsk_...", + "key_label": "Groq API key (free tier: console.groq.com)", + "reasoning_effort": "none", + }, + "google": { + "label": "Google Gemini", + "dialect": "gemini", + "host": "generativelanguage.googleapis.com", + "port": 443, + "path": "/v1beta/models/{model}:generateContent", + "model_setting": "vision_routing/google_model", + "model_placeholder": "gemini-flash-latest", + "env": "GOOGLE_API_KEY", + "setting": "vision_routing/google_api_key_enc", + "placeholder": "AIza...", + "key_label": "Google AI Studio API key (free tier: aistudio.google.com)", + }, + "grok": { + "label": "xAI Grok", + "dialect": "openai", + "model_setting": "vision_routing/grok_model", + "model_placeholder": "grok-4.5", + "host": "api.x.ai", + "port": 443, + "path": "/v1/chat/completions", + "env": "XAI_API_KEY", + "setting": "vision_routing/grok_api_key_enc", + "placeholder": "xai-...", + "key_label": "xAI API key (api.x.ai)", + }, +} +const PROVIDER_ORDER := ["groq", "google", "grok"] + +const SETTING_ENABLED := "vision_routing/enabled" +const SETTING_PROVIDER := "vision_routing/provider" +const SETTING_API_KEY_ENC := "vision_routing/api_key_enc" +const TAB_NAME := "Vision Routing" + +const MAX_IMAGE_EDGE := 1024 +const CONNECT_TIMEOUT_MS := 4000 +const REQUEST_TIMEOUT_MS := 8000 +## Total budget for the whole provider exchange (connect + request + body). +## Kept under the server's 15s editor_screenshot window so the fallback +## pass-through always lands before the server gives up on slow providers. +const TOTAL_ROUTE_BUDGET_MS := 10000 +## Response body cap: the deadline bounds time but not bytes, and the socket +## talks to third-party hosts - a faulty or hostile provider must not be able +## to stream an unbounded body inside the window. +const MAX_RESPONSE_BODY_BYTES := 1048576 +## Output-token budget shared by both dialects (OpenAI `max_tokens`, +## Gemini `generationConfig.maxOutputTokens`) so routed descriptions +## stay bounded. +const MAX_OUTPUT_TOKENS := 512 + +const _ENC_PREFIX := "v1" +const _SALT := "vision_routing::v1::godot-ai" +const _PLACEHOLDER_PNG := "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAALSURBVBhXY2BABwAAEgABp3qZbgAAAABJRU5ErkJggg==" + +## Plugin log buffer (McpLogBuffer), set by plugin.gd; null-safe. +var log_buffer: Object = null + +var _active := true +var _route_done: Callable +var _pending: Dictionary = {} # request_id -> {payload, data, connection, provider_id, provider, params} +var _threads: Dictionary = {} # request_id -> Thread ("_test" = ping thread) +var _ui_loading := false + +# UI references, kept in sync by _sync_ui_states(). +var _tab_provider: OptionButton = null +var _tab_enable: CheckButton = null +var _tab_key_label: Label = null +var _tab_key_hint: Label = null +var _tab_key_edit: LineEdit = null +var _tab_model_label: Label = null +var _tab_model_hint: Label = null +var _tab_model_edit: LineEdit = null +var _tab_status: Label = null +var _tab_test_button: Button = null + + +func _init() -> void: + _route_done = Callable(self, "_on_route_complete") + + +## Plugin teardown: stop workers and join in-flight threads so Godot never +## destroys a Thread mid-execution during a plugin reload. +func shutdown() -> void: + _active = false + ## Workers poll _active between HTTP polls, so this returns within one + ## poll interval (~50ms) unless a request is mid-flight in the OS; worst + ## case is a connect/request timeout. + for rid in _threads: + var thread: Thread = _threads[rid] + if thread != null and thread.is_started(): + thread.wait_to_finish() + _threads.clear() + _pending.clear() + _tab_provider = null + _tab_enable = null + _tab_key_label = null + _tab_key_hint = null + _tab_key_edit = null + _tab_status = null + _tab_test_button = null + _tab_model_label = null + _tab_model_hint = null + _tab_model_edit = null + + +# --- routing ------------------------------------------------------------------ + +## Entry point called from editor_handler.take_screenshot when routing is +## enabled. Runs the real capture (via `original`), then routes the image +## through the selected provider's vision API on a worker thread. Returns the deferred-response sentinel +## when the worker owns the reply, otherwise the capture result unchanged. +func route_editor_screenshot(params: Dictionary, original: Callable, connection: Object) -> Dictionary: + var rid := str(params.get("_request_id", "")) + if rid.is_empty(): + ## No deferred channel (e.g. batch_execute / dispatch_direct) - keep + ## the original synchronous result untouched. + return original.call(params) + var result := original.call(params) + if not (result is Dictionary) or not result.has("data"): + ## Deferred capture (source="game"): the frame arrives later through + ## route_game_payload. Stash the caller params (e.g. user_prompt) by + ## request id so the routed description keeps the agent's context. + if result is Dictionary and result.get("_deferred", false): + _pending[rid] = {"params": params} + return result + var data: Variant = result["data"] + if not (data is Dictionary) or not data.has("image_base64"): + return result + if _start_route(rid, str(params.get("source", "viewport")), result, data, connection, params): + ## Keep the deferred ledger inside the server's 15s editor_screenshot + ## window (the worker itself is capped at TOTAL_ROUTE_BUDGET_MS), so + ## a hung provider surfaces as a clean plugin timeout, not a + ## server-side abort. + return {"_deferred": true, "_deferred_timeout_ms": 13000} + return result + + +## Entry point called from McpDebuggerPlugin._on_screenshot_response before +## the frame is sent. Returns true when a worker owns the reply (the caller +## must NOT send the payload itself); false means pass through unchanged. +func route_game_payload(connection: Object, rid: String, payload: Dictionary) -> bool: + var data: Variant = payload.get("data") + if not (data is Dictionary) or not data.has("image_base64"): + ## Not a routeable frame - drop any params stashed by + ## route_editor_screenshot for this request id. + _pending.erase(rid) + return false + ## The editor handler stashed the caller params (user_prompt etc.) by + ## request id when it deferred the capture; hand them to the prompt + ## builder. Absent (older helper, direct call) = generic prompt. + var params: Dictionary = _pending.get(rid, {}).get("params", {}) + return _start_route(rid, "game", payload, data, connection, params) + + +## Returns true when a worker owns the reply; false means the caller should +## pass the original payload through unchanged. +func _start_route(rid: String, source: String, payload: Dictionary, data: Dictionary, connection: Object, params: Dictionary) -> bool: + var provider_id := _active_provider_id() + var base_provider: Dictionary = PROVIDERS.get(provider_id, PROVIDERS["groq"]) + ## Worker-thread snapshot: the entered model id is resolved into the + ## provider dict here, once, so the thread sees a stable copy and + ## routed_via can report the exact model that was pinged. + var provider := _provider_with_model(provider_id) + var api_key := _resolved_api_key(provider_id) + if api_key.is_empty(): + _log("vision routing: no API key for %s (set %s or paste one in the Vision Routing section) - screenshot %s passed through" % [provider_id, base_provider.get("env", ""), rid]) + _pending.erase(rid) + return false + if str(provider.get("model", "")).is_empty(): + ## Empty model behaves exactly like empty key: routing declines, the + ## image passes through, and the log line says what is missing. + _log("vision routing: no model id set for %s - screenshot %s passed through (set a model id in the Vision Routing section)" % [provider_id, rid]) + _pending.erase(rid) + return false + _pending[rid] = {"payload": payload, "data": data, "connection": connection, "provider_id": provider_id, "provider": provider, "params": params} + var prompt := _build_prompt(params) + var thread := Thread.new() + var start_err := thread.start(_route_worker.bind(provider, str(data.get("image_base64", "")), prompt, api_key, rid)) + if start_err != OK: + _pending.erase(rid) + _log("vision routing: could not start worker for %s: %s - screenshot %s passed through" % [rid, error_string(start_err), source]) + return false + _threads[rid] = thread + _log("vision routing: routing %s screenshot %s (%d b64 chars) via %s (%s)" % [source, rid, str(data.get("image_base64", "")).length(), base_provider.get("label", provider_id), provider.get("model", "")]) + return true + + +func _on_route_complete(rid: String, result: Variant) -> void: + _join_thread(rid) + if not _active: + _pending.erase(rid) + return + var entry: Dictionary = _pending.get(rid, {}) + _pending.erase(rid) + if entry.is_empty(): + return + var connection: Object = entry.get("connection") + if connection == null or not is_instance_valid(connection): + return + var provider_id := str(entry.get("provider_id", "groq")) + ## Provider snapshot taken at route start (with the entered model id); + ## falls back to a fresh resolve if absent (direct test callers). + var provider: Dictionary = entry.get("provider", {}) + if provider.is_empty(): + provider = _provider_with_model(provider_id) + ## Workers return {"desc": ..., "error": ...}; plain strings are accepted + ## for backwards compatibility (tests / older callers). + var description_str := "" + var error_str := "" + if result is Dictionary: + description_str = str(result.get("desc", "")) + error_str = str(result.get("error", "")) + elif result != null: + description_str = str(result) + if description_str.is_empty(): + if error_str.is_empty(): + error_str = "unknown error" + _log("vision routing: %s failed for %s (%s) - returning original image with failure note" % [provider_id, rid, error_str]) + ## Append a short templated reason to the note so text-only agents + ## (who otherwise just receive a useless image block) learn the + ## feature is down and why. + var failed_data: Dictionary = entry.get("data", {}).duplicate() + var failure_note := "Vision routing unavailable (%s): %s" % [provider_id, error_str] + var existing_note := str(failed_data.get("note", "")) + failed_data["note"] = (existing_note + " | " if not existing_note.is_empty() else "") + failure_note + _pass_through(connection, rid, {"data": failed_data}) + return + var data: Dictionary = entry.get("data", {}).duplicate() + var routed_via := "%s:%s" % [provider_id, provider.get("model", "")] + ## The server forwards a fixed whitelist of metadata keys into the text + ## result; `note` is the free-form one, so a self-attributing description + ## rides there (plus an explicit `vision_description` key) so text-only + ## models can read it. The label keeps provider text from reading as if + ## the plugin said it. The image is replaced by a valid 2x2 placeholder + ## so the payload stays well-formed. + data["vision_description"] = description_str + data["routed_via"] = routed_via + var original_note := str(data.get("note", "")) + var labeled := "Vision description (%s): %s" % [routed_via, description_str] + data["note"] = (original_note + " | " if not original_note.is_empty() else "") + labeled + data["image_base64"] = _PLACEHOLDER_PNG + data["format"] = "png" + _log("vision routing: description ready for %s (%d chars)" % [rid, description_str.length()]) + _pass_through(connection, rid, {"data": data}) + + +func _pass_through(connection: Object, rid: String, payload: Dictionary) -> void: + if connection != null and is_instance_valid(connection): + connection.send_deferred_response(rid, payload) + + +func _build_prompt(params: Dictionary) -> String: + var lines := PackedStringArray([ + "You are the vision module of a text-only AI agent driving the Godot editor through MCP.", + "Describe this screenshot so the agent can act without seeing it. Report:", + "- What is shown: Godot editor viewport, game window, 2D/3D scene, UI panel, dialog, or other.", + "- Objects/nodes: what they are, position, color, size, and any labels or text (quote text exactly).", + "- UI text: menus, buttons, error dialogs, console output, warnings, line numbers.", + "- State: selected node outlines, gizmos, play/stop status, panels that are open.", + "- Problems: errors, red highlights, missing textures, black screens, glitches, stretching.", + "Be concise (under 200 words), factual, and use exact quotes instead of paraphrase. Do not give advice.", + ]) + var user_prompt := str(params.get("user_prompt", "")) + if not user_prompt.is_empty(): + lines.append("Context from the agent that requested this screenshot: %s" % user_prompt) + return "\n".join(lines) + + +# --- routing worker (thread) --------------------------------------------------- + +func _route_worker(provider: Dictionary, image_b64: String, prompt: String, api_key: String, rid: String) -> void: + var result := _describe_blocking(provider, image_b64, prompt, api_key) + _route_done.call_deferred(rid, result) + + +## Returns {"desc": String, "error": String}; "desc" is empty on failure and +## "error" carries the reason. Runs on a worker thread, so it never writes +## shared state - everything it needs is passed in and returned. `provider` +## is the route-start snapshot (includes the entered model id). +func _describe_blocking(provider: Dictionary, image_b64: String, prompt: String, api_key: String) -> Dictionary: + var b64 := _downscale_image_if_needed(image_b64) + var body := _build_request_body(provider, prompt, b64) + var headers := _build_headers(provider, api_key) + var response := _http_post_json(str(provider.get("host", "")), int(provider.get("port", 443)), _resolve_path(provider), headers, body) + return _parse_description(provider, response) + + +## Provider paths may carry a {model} placeholder (Gemini's endpoint embeds +## the model id); substitute the entered model id verbatim. +func _resolve_path(provider: Dictionary) -> String: + return str(provider.get("path", "")).replace("{model}", str(provider.get("model", ""))) + + +func _build_request_body(provider: Dictionary, prompt: String, image_b64: String) -> String: + var model := str(provider.get("model", "")) + if str(provider.get("dialect", "")) == "gemini": + return JSON.stringify({ + "contents": [{ + "role": "user", + "parts": [ + {"text": prompt}, + {"inline_data": {"mime_type": "image/png", "data": image_b64}}, + ], + }], + "generationConfig": {"maxOutputTokens": MAX_OUTPUT_TOKENS}, + }) + var payload := { + "model": model, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64," + image_b64}}, + ], + }], + "max_tokens": MAX_OUTPUT_TOKENS, + "temperature": 0.2, + } + if provider.has("reasoning_effort"): + payload["reasoning_effort"] = provider["reasoning_effort"] + return JSON.stringify(payload) + + +func _build_headers(provider: Dictionary, api_key: String) -> PackedStringArray: + if str(provider.get("dialect", "")) == "gemini": + return PackedStringArray([ + "Content-Type: application/json", + "x-goog-api-key: %s" % api_key, + ]) + return PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % api_key, + ]) + + +func _parse_description(provider: Dictionary, response: Dictionary) -> Dictionary: + var code: int = int(response.get("code", 0)) + var label := str(provider.get("label", str(provider.get("model", "?")))) + if code == 0: + return {"desc": "", "error": str(response.get("error", "HTTP request failed"))} + if code != 200: + return {"desc": "", "error": "%s HTTP %d: %s" % [label, code, _body_snippet(str(response.get("text", "")))]} + var parsed: Variant = JSON.parse_string(str(response.get("text", ""))) + if not (parsed is Dictionary): + return {"desc": "", "error": "%s response was not JSON: %s" % [label, _body_snippet(str(response.get("text", "")))]} + if str(provider.get("dialect", "")) == "gemini": + return _parse_gemini(parsed, label, response) + return _parse_openai(parsed, label, response) + + +func _parse_openai(parsed: Dictionary, label: String, response: Dictionary) -> Dictionary: + var choices: Variant = parsed.get("choices") + if not (choices is Array) or choices.is_empty(): + return {"desc": "", "error": "%s response had no choices: %s" % [label, _body_snippet(str(response.get("text", "")))]} + if not (choices[0] is Dictionary): + return {"desc": "", "error": "%s response choice was not an object: %s" % [label, _body_snippet(str(response.get("text", "")))]} + var message: Variant = choices[0].get("message", {}) + if not (message is Dictionary): + return {"desc": "", "error": "%s response message was not an object: %s" % [label, _body_snippet(str(response.get("text", "")))]} + var content: Variant = message.get("content", "") + if content == null: + return {"desc": "", "error": "%s response content was null: %s" % [label, _body_snippet(str(response.get("text", "")))]} + return {"desc": _strip_think(str(content)), "error": ""} + + +func _parse_gemini(parsed: Dictionary, label: String, response: Dictionary) -> Dictionary: + var candidates: Variant = parsed.get("candidates") + if not (candidates is Array) or candidates.is_empty(): + var reason := "" + var feedback: Variant = parsed.get("promptFeedback") + if feedback is Dictionary: + reason = str(feedback.get("blockReason", "")) + var reason_part := "" + if not reason.is_empty(): + reason_part = " (blocked: %s)" % reason + return {"desc": "", "error": "%s response had no candidates%s: %s" % [label, reason_part, _body_snippet(str(response.get("text", "")))]} + if not (candidates[0] is Dictionary): + return {"desc": "", "error": "%s response candidate was not an object: %s" % [label, _body_snippet(str(response.get("text", "")))]} + var content: Variant = candidates[0].get("content", {}) + if not (content is Dictionary): + return {"desc": "", "error": "%s response content was missing: %s" % [label, _body_snippet(str(response.get("text", "")))]} + var parts: Variant = content.get("parts") + if not (parts is Array) or parts.is_empty(): + return {"desc": "", "error": "%s response had no parts: %s" % [label, _body_snippet(str(response.get("text", "")))]} + var texts := PackedStringArray() + for part in parts: + if part is Dictionary: + var part_text := str(part.get("text", "")) + if not part_text.is_empty(): + texts.append(part_text) + if texts.is_empty(): + return {"desc": "", "error": "%s response parts had no text: %s" % [label, _body_snippet(str(response.get("text", "")))]} + return {"desc": _strip_think("\n".join(texts)), "error": ""} + + +func _strip_think(text: String) -> String: + var out := text.strip_edges() + ## Reasoning models may wrap their answer in ... blocks. + var think_end := out.rfind("") + if think_end != -1: + out = out.substr(think_end + "".length()).strip_edges() + return out + + +## Minimal vision call used by the "Test connection" button. Sends the same +## image-bearing body shape as real routing (with the 2x2 placeholder PNG), +## so one click validates key + model existence + image capability - a +## text-only or retired model id fails loudly here instead of at screenshot +## time. `provider` is the route-style snapshot (includes the model id). +func _ping_blocking(provider: Dictionary, api_key: String) -> Dictionary: + var body := _build_request_body(provider, "Reply with exactly: OK", _PLACEHOLDER_PNG) + var response := _http_post_json(str(provider.get("host", "")), int(provider.get("port", 443)), _resolve_path(provider), _build_headers(provider, api_key), body) + var code: int = int(response.get("code", 0)) + if code == 200: + return {"ok": true, "error": ""} + if code == 0: + return {"ok": false, "error": str(response.get("error", "request failed"))} + return {"ok": false, "error": "%s HTTP %d: %s" % [provider.get("label", "provider"), code, _body_snippet(str(response.get("text", "")))]} + + +func _http_post_json(host: String, port: int, path: String, headers: PackedStringArray, body: String) -> Dictionary: + if host.is_empty() or body.is_empty(): + return {"code": 0, "error": "invalid request (empty host or body)"} + var http := HTTPClient.new() + var connect_err := http.connect_to_host(host, port, TLSOptions.client()) + if connect_err != OK: + http.close() + return {"code": 0, "error": "connect_to_host failed: %s" % error_string(connect_err)} + ## One total deadline for the whole exchange (see TOTAL_ROUTE_BUDGET_MS), + ## so connect + request + body can never exceed it. + var budget_deadline := Time.get_ticks_msec() + TOTAL_ROUTE_BUDGET_MS + var deadline := mini(Time.get_ticks_msec() + CONNECT_TIMEOUT_MS, budget_deadline) + var first_poll := true + var connected := false + var last_status := -1 + while Time.get_ticks_msec() < deadline: + if not _active: + http.close() + return {"code": 0, "error": "aborted (plugin teardown)"} + http.poll() + var status := http.get_status() + last_status = status + if status == HTTPClient.STATUS_CONNECTED: + connected = true + break + if status == HTTPClient.STATUS_DISCONNECTED and not first_poll: + break + first_poll = false + OS.delay_msec(50) + if not connected: + http.close() + return {"code": 0, "error": "could not connect (status %d)" % last_status} + if http.request(HTTPClient.METHOD_POST, path, headers, body) != OK: + http.close() + return {"code": 0, "error": "request() failed"} + deadline = mini(Time.get_ticks_msec() + REQUEST_TIMEOUT_MS, budget_deadline) + var timed_out := false + var status_at_timeout := -1 + while http.get_status() == HTTPClient.STATUS_REQUESTING: + if not _active: + http.close() + return {"code": 0, "error": "aborted (plugin teardown)"} + http.poll() + if Time.get_ticks_msec() > deadline: + timed_out = true + status_at_timeout = http.get_status() + break + OS.delay_msec(50) + if timed_out: + http.close() + return {"code": 0, "error": "request timed out (status %d)" % status_at_timeout} + if not http.has_response(): + var st := http.get_status() + http.close() + return {"code": 0, "error": "no HTTP response (status %d)" % st} + var code := http.get_response_code() + var chunks := PackedByteArray() + var body_deadline := mini(Time.get_ticks_msec() + REQUEST_TIMEOUT_MS, budget_deadline) + while http.get_status() == HTTPClient.STATUS_BODY: + if not _active: + http.close() + return {"code": 0, "error": "aborted (plugin teardown)"} + chunks.append_array(http.read_response_body_chunk()) + if chunks.size() > MAX_RESPONSE_BODY_BYTES: + http.close() + return {"code": 0, "error": "response body exceeded %d bytes" % MAX_RESPONSE_BODY_BYTES} + http.poll() + if Time.get_ticks_msec() > body_deadline: + break + OS.delay_msec(10) + http.close() + return {"code": code, "text": chunks.get_string_from_utf8()} + + +func _body_snippet(text: String) -> String: + if text.is_empty(): + return "(empty body)" + if text.length() > 300: + return text.substr(0, 300) + "..." + return text + + +func _join_thread(rid: String) -> void: + ## Join the worker before dropping the Thread reference, otherwise Godot + ## warns "Thread object destroyed without completion". + var thread: Thread = _threads.get(rid) + if thread != null and thread.is_started(): + thread.wait_to_finish() + _threads.erase(rid) + + +func _downscale_image_if_needed(image_b64: String) -> String: + if image_b64.is_empty(): + return image_b64 + var raw := Marshalls.base64_to_raw(image_b64) + if raw.is_empty(): + return image_b64 + var image := Image.new() + if image.load_png_from_buffer(raw) != OK: + return image_b64 + var width := image.get_width() + var height := image.get_height() + if width <= MAX_IMAGE_EDGE and height <= MAX_IMAGE_EDGE: + return image_b64 + if width >= height: + height = maxi(1, int(round(height * MAX_IMAGE_EDGE / float(width)))) + width = MAX_IMAGE_EDGE + else: + width = maxi(1, int(round(width * MAX_IMAGE_EDGE / float(height)))) + height = MAX_IMAGE_EDGE + image.resize(width, height, Image.INTERPOLATE_BILINEAR) + var out := image.save_png_to_buffer() + if out.is_empty(): + return image_b64 + return Marshalls.raw_to_base64(out) + + +func _ping_worker(provider: Dictionary, api_key: String, status_label: Label, key_source: String) -> void: + var result := _ping_blocking(provider, api_key) + Callable(self, "_on_ping_done").call_deferred(result, provider, status_label, key_source) + + +func _on_ping_done(result: Dictionary, provider: Dictionary, status_label: Label, key_source: String) -> void: + _join_thread("_test") + if _tab_test_button != null and is_instance_valid(_tab_test_button): + _tab_test_button.disabled = false + if status_label != null and is_instance_valid(status_label): + var label := str(provider.get("label", "provider")) + if result.get("ok", false): + status_label.text = "OK - %s responded (%s)." % [label, key_source] + else: + status_label.text = "FAILED - %s (%s)." % [result.get("error", "unknown error"), key_source] + _log("vision routing: ping result: %s" % status_label.text) + + +# --- settings / key storage --------------------------------------------------- + +func _settings() -> EditorSettings: + return EditorInterface.get_editor_settings() + + +func is_routing_enabled() -> bool: + var es := _settings() + if es == null or not es.has_setting(SETTING_ENABLED): + return false + var value = es.get_setting(SETTING_ENABLED) + return value != null and value + + +func _active_provider_id() -> String: + var es := _settings() + if es == null: + return "groq" + var stored := "" + if es.has_setting(SETTING_PROVIDER): + stored = str(es.get_setting(SETTING_PROVIDER)) + if stored.is_empty() or not PROVIDERS.has(stored): + return "groq" + return stored + + +func _provider_setting(provider_id: String) -> String: + return str(PROVIDERS.get(provider_id, PROVIDERS["groq"]).get("setting", SETTING_API_KEY_ENC)) + + +func _resolved_api_key(provider_id: String) -> String: + ## Environment variable takes priority over the stored (encrypted) key. + var provider: Dictionary = PROVIDERS.get(provider_id, PROVIDERS["groq"]) + var env_key := OS.get_environment(str(provider.get("env", ""))) + if not env_key.is_empty(): + return env_key + var es := _settings() + if es == null: + return "" + var setting := _provider_setting(provider_id) + var blob := "" + if es.has_setting(setting): + blob = str(es.get_setting(setting)) + if blob.is_empty(): + return "" + return _decrypt(blob) + + +func _decrypted_key(provider_id: String) -> String: + var es := _settings() + if es == null: + return "" + var setting := _provider_setting(provider_id) + if not es.has_setting(setting): + return "" + return _decrypt(str(es.get_setting(setting))) + + +func set_api_key(provider_id: String, plain: String) -> void: + var es := _settings() + if es == null: + return + if plain.is_empty(): + es.set_setting(_provider_setting(provider_id), "") + return + var blob := _encrypt(plain) + if blob.is_empty(): + _log("vision routing: cannot store %s key - this machine reports no unique id; set %s instead" % [provider_id, PROVIDERS.get(provider_id, PROVIDERS["groq"]).get("env", "")]) + return + es.set_setting(_provider_setting(provider_id), blob) + + +## The model id is stored per provider next to the key (a plain Editor +## Setting - it is not a secret). Empty clears the slot. +func set_model_id(provider_id: String, model: String) -> void: + var es := _settings() + if es == null: + return + es.set_setting(_model_setting(provider_id), model.strip_edges()) + + +func _model_setting(provider_id: String) -> String: + return str(PROVIDERS.get(provider_id, PROVIDERS["groq"]).get("model_setting", "")) + + +func _resolved_model(provider_id: String) -> String: + var es := _settings() + if es == null: + return "" + var setting := _model_setting(provider_id) + if not es.has_setting(setting): + return "" + return str(es.get_setting(setting)).strip_edges() + + +## Route-start snapshot: the curated provider table plus the user's entered +## model id. Used by the worker thread and by _on_route_complete for the +## routed_via label, so it always reports the model that was actually pinged. +func _provider_with_model(provider_id: String) -> Dictionary: + var provider: Dictionary = PROVIDERS.get(provider_id, PROVIDERS["groq"]).duplicate() + provider["model"] = _resolved_model(provider_id) + return provider + + +## Machine-derived key: not a password, but enough that a casually-opened +## editor_settings-4.tres does not reveal the key in plain text. Separate +## domain tags give AES and HMAC independent keys so neither reuses the +## other's bytes. +func _derive_key(tag: String) -> PackedByteArray: + var parts := PackedStringArray([ + OS.get_unique_id(), + OS.get_environment("USERNAME"), + OS.get_environment("USERPROFILE"), + OS.get_environment("USER"), + OS.get_environment("HOME"), + OS.get_name(), + _SALT, + tag, + ]) + var ctx := HashingContext.new() + ctx.start(HashingContext.HASH_SHA256) + ctx.update(("|".join(parts)).to_utf8_buffer()) + return ctx.finish() + + +func _encrypt(plain: String) -> String: + if OS.get_unique_id().is_empty(): + ## Without a machine id the key would be near-constant across users + ## on the same machine - refuse, and let callers fall back to the + ## provider's environment variable. + return "" + var aes_key := _derive_key("aes") + var mac_key := _derive_key("mac") + var iv := Crypto.new().generate_random_bytes(16) + var raw := plain.to_utf8_buffer() + ## PKCS7 padding. + var pad := 16 - (raw.size() % 16) + var padded := raw.duplicate() + for i in pad: + padded.append(pad) + var aes := AESContext.new() + aes.start(AESContext.MODE_CBC_ENCRYPT, aes_key, iv) + var cipher := aes.update(padded) + aes.finish() + var hmac := HMACContext.new() + hmac.start(HashingContext.HASH_SHA256, mac_key) + hmac.update(iv) + hmac.update(cipher) + var mac := hmac.finish() + return "%s:%s:%s:%s" % [_ENC_PREFIX, Marshalls.raw_to_base64(iv), Marshalls.raw_to_base64(mac), Marshalls.raw_to_base64(cipher)] + + +func _decrypt(blob: String) -> String: + var parts := blob.split(":") + if parts.size() != 4 or parts[0] != _ENC_PREFIX: + return "" + var iv := Marshalls.base64_to_raw(parts[1]) + var mac := Marshalls.base64_to_raw(parts[2]) + var cipher := Marshalls.base64_to_raw(parts[3]) + if iv.size() != 16 or cipher.is_empty() or cipher.size() % 16 != 0: + return "" + var aes_key := _derive_key("aes") + var mac_key := _derive_key("mac") + var hmac := HMACContext.new() + hmac.start(HashingContext.HASH_SHA256, mac_key) + hmac.update(iv) + hmac.update(cipher) + var expected := hmac.finish() + if expected != mac: + return "" + var aes := AESContext.new() + aes.start(AESContext.MODE_CBC_DECRYPT, aes_key, iv) + var padded := aes.update(cipher) + aes.finish() + if padded.is_empty(): + return "" + var pad := int(padded[padded.size() - 1]) + if pad < 1 or pad > 16 or pad > padded.size(): + return "" + var raw := padded.slice(0, padded.size() - pad) + return raw.get_string_from_utf8() + + +# --- UI: tab in Clients & Tools ------------------------------------------------- + +## Builds the "Vision Routing" section inside the Clients & Tools Settings +## tab. Called by mcp_dock._build_settings_tab; `refresh_ui()` re-syncs the +## controls from Editor Settings each time the window opens. +func build_section(parent: VBoxContainer) -> void: + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 8) + parent.add_child(box) + + var header := Label.new() + header.text = "Vision Routing" + header.add_theme_font_size_override("font_size", 18) + box.add_child(header) + + var provider_row := HBoxContainer.new() + var provider_label := Label.new() + provider_label.text = "Provider" + provider_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + provider_row.add_child(provider_label) + _tab_provider = OptionButton.new() + _tab_provider.tooltip_text = "Which vision provider routes the screenshots. The model id is required per provider and is yours to maintain." + var active_provider := _active_provider_id() + for provider_index in PROVIDER_ORDER.size(): + var provider_item: Dictionary = PROVIDERS[PROVIDER_ORDER[provider_index]] + _tab_provider.add_item(str(provider_item.get("label", PROVIDER_ORDER[provider_index])), provider_index) + if PROVIDER_ORDER[provider_index] == active_provider: + _tab_provider.select(provider_index) + _tab_provider.item_selected.connect(_on_provider_changed) + provider_row.add_child(_tab_provider) + box.add_child(provider_row) + + var enable_row := HBoxContainer.new() + var enable_label := Label.new() + enable_label.text = "Enable routing" + enable_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + enable_row.add_child(enable_label) + _tab_enable = CheckButton.new() + _tab_enable.button_pressed = is_routing_enabled() + _tab_enable.toggled.connect(_on_enable_toggled) + enable_row.add_child(_tab_enable) + box.add_child(enable_row) + + var description := Label.new() + description.text = ( + "When enabled, every single-image screenshot the AI model takes through " + + "the godot-ai screenshot tool is sent to the selected provider's vision model (see " + + "the Provider dropdown) for a text description. The description is " + + "returned to the AI instead of the raw image, so models without image " + + "support (e.g. DeepSeek) can still \"see\" the editor and game. When " + + "the connected model analyzes images itself, switch this off here so " + + "screenshots pass through unchanged." + ) + description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + description.size_flags_horizontal = Control.SIZE_EXPAND_FILL + box.add_child(description) + + _tab_key_label = Label.new() + box.add_child(_tab_key_label) + + var key_row := HBoxContainer.new() + _tab_key_edit = LineEdit.new() + _tab_key_edit.secret = true + _tab_key_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + ## Persist on commit (Enter / focus loss) instead of per keystroke, so + ## pasting a key does not re-encrypt and rewrite Editor Settings dozens + ## of times. + _tab_key_edit.text_submitted.connect(_on_key_committed) + _tab_key_edit.focus_exited.connect(func() -> void: _on_key_committed(_tab_key_edit.text)) + key_row.add_child(_tab_key_edit) + var show_button := CheckButton.new() + show_button.tooltip_text = "Show / hide key" + show_button.toggled.connect(func(show: bool) -> void: _tab_key_edit.secret = not show) + key_row.add_child(show_button) + box.add_child(key_row) + + _tab_key_hint = Label.new() + _tab_key_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _tab_key_hint.add_theme_color_override("font_color", Color(0.55, 0.55, 0.55)) + box.add_child(_tab_key_hint) + + _tab_model_label = Label.new() + box.add_child(_tab_model_label) + + var model_row := HBoxContainer.new() + _tab_model_edit = LineEdit.new() + _tab_model_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + ## Persist on commit (Enter / focus loss), same pattern as the key field. + _tab_model_edit.text_submitted.connect(_on_model_committed) + _tab_model_edit.focus_exited.connect(func() -> void: _on_model_committed(_tab_model_edit.text)) + model_row.add_child(_tab_model_edit) + box.add_child(model_row) + + _tab_model_hint = Label.new() + _tab_model_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _tab_model_hint.add_theme_color_override("font_color", Color(0.55, 0.55, 0.55)) + box.add_child(_tab_model_hint) + + var test_row := HBoxContainer.new() + var test_button := Button.new() + test_button.text = "Test connection" + test_button.pressed.connect(_on_test_connection) + _tab_test_button = test_button + test_row.add_child(test_button) + _tab_status = Label.new() + _tab_status.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _tab_status.add_theme_color_override("font_color", Color(0.55, 0.55, 0.55)) + test_row.add_child(_tab_status) + box.add_child(test_row) + + refresh_ui() + + +## Re-syncs every control from Editor Settings. Called after the section is +## built and again by mcp_dock each time the Clients & Tools window opens, +## so a change made in another editor instance (or a hand-edit of +## editor_settings-4.tres) is reflected. +func refresh_ui() -> void: + _sync_ui_states() + _sync_provider_ui() + _ui_loading = true + if _tab_key_edit != null and is_instance_valid(_tab_key_edit): + _tab_key_edit.text = _decrypted_key(_active_provider_id()) + if _tab_model_edit != null and is_instance_valid(_tab_model_edit): + _tab_model_edit.text = _resolved_model(_active_provider_id()) + _ui_loading = false + + +func _on_key_committed(_new_text: String) -> void: + if _ui_loading: + return + set_api_key(_active_provider_id(), _tab_key_edit.text) + + +func _on_model_committed(_new_text: String) -> void: + if _ui_loading: + return + set_model_id(_active_provider_id(), _tab_model_edit.text) + + +func _on_provider_changed(index: int) -> void: + if index < 0 or index >= PROVIDER_ORDER.size(): + return + var provider_id: String = PROVIDER_ORDER[index] + var es := _settings() + if es != null: + es.set_setting(SETTING_PROVIDER, provider_id) + _ui_loading = true + if _tab_key_edit != null and is_instance_valid(_tab_key_edit): + _tab_key_edit.text = _decrypted_key(provider_id) + if _tab_model_edit != null and is_instance_valid(_tab_model_edit): + _tab_model_edit.text = _resolved_model(provider_id) + _ui_loading = false + _sync_provider_ui() + _log("vision routing: provider changed to %s (%s)" % [provider_id, PROVIDERS[provider_id].get("label", provider_id)]) + + +func _sync_provider_ui() -> void: + var provider_id := _active_provider_id() + var provider: Dictionary = PROVIDERS.get(provider_id, PROVIDERS["groq"]) + if _tab_provider != null and is_instance_valid(_tab_provider): + var provider_index := PROVIDER_ORDER.find(provider_id) + if provider_index != -1 and _tab_provider.selected != provider_index: + _tab_provider.select(provider_index) + if _tab_key_label != null and is_instance_valid(_tab_key_label): + _tab_key_label.text = str(provider.get("key_label", "API key")) + if _tab_key_hint != null and is_instance_valid(_tab_key_hint): + var hint := ( + "Stored encrypted (AES-256, key derived from this machine) in Editor Settings " + + "- not plain text, but local obfuscation only. You can also set the " + + "%s environment variable; it takes priority over this field." + ) % provider.get("env", "") + if OS.get_unique_id().is_empty(): + hint += " This machine reports no unique id, so keys cannot be stored locally - use the %s environment variable." % provider.get("env", "") + _tab_key_hint.text = hint + if _tab_key_edit != null and is_instance_valid(_tab_key_edit): + _tab_key_edit.placeholder_text = str(provider.get("placeholder", "")) + if _tab_model_label != null and is_instance_valid(_tab_model_label): + _tab_model_label.text = "Model id (required) - %s" % provider.get("label", provider_id) + if _tab_model_edit != null and is_instance_valid(_tab_model_edit): + _tab_model_edit.placeholder_text = str(provider.get("model_placeholder", "")) + _tab_model_edit.tooltip_text = "The vision model this provider should ping. Yours to maintain: when a model id retires, replace it here." + if _tab_model_hint != null and is_instance_valid(_tab_model_hint): + _tab_model_hint.text = ( + "Required per provider; empty behaves like an empty key (routing " + + "declines and screenshots pass through). Suggested id (as of " + + "2026-08 - check your provider console, ids retire): %s." + ) % provider.get("model_placeholder", "") + + +func _on_test_connection() -> void: + if _tab_status == null: + return + var provider_id := _active_provider_id() + var provider := _provider_with_model(provider_id) + var env_name := str(PROVIDERS.get(provider_id, PROVIDERS["groq"]).get("env", "")) + if str(provider.get("model", "")).is_empty(): + _tab_status.text = "No model id set for %s - add one below." % provider.get("label", provider_id) + return + var api_key := _resolved_api_key(provider_id) + if api_key.is_empty(): + _tab_status.text = "No key set - add one below or set %s." % env_name + return + var running: Thread = _threads.get("_test") + if running != null and running.is_started() and running.is_alive(): + return + var previous_status := _tab_status.text + _tab_status.text = "Testing..." + if _tab_test_button != null and is_instance_valid(_tab_test_button): + _tab_test_button.disabled = true + var key_source := "stored key" + if not OS.get_environment(env_name).is_empty(): + key_source = "via %s" % env_name + var thread := Thread.new() + _threads["_test"] = thread + var start_err := thread.start(_ping_worker.bind(provider, api_key, _tab_status, key_source)) + if start_err != OK: + ## A failed thread start must not leave the button disabled and the + ## status stuck on "Testing..." forever. + _threads.erase("_test") + _tab_status.text = previous_status + if _tab_test_button != null and is_instance_valid(_tab_test_button): + _tab_test_button.disabled = false + _log("vision routing: could not start ping thread: %s" % error_string(start_err)) + + +func _on_enable_toggled(enabled: bool) -> void: + var es := _settings() + if es != null: + es.set_setting(SETTING_ENABLED, enabled) + _sync_ui_states() + + +func _sync_ui_states() -> void: + var enabled := is_routing_enabled() + if _tab_enable != null and is_instance_valid(_tab_enable) and _tab_enable.button_pressed != enabled: + _tab_enable.set_pressed_no_signal(enabled) + + +# --- logging -------------------------------------------------------------------- + +func _log(message: String) -> void: + if log_buffer != null and is_instance_valid(log_buffer) and log_buffer.has_method("log"): + log_buffer.log(message, false) diff --git a/addons/godot_ai/vision_routing.gd.uid b/addons/godot_ai/vision_routing.gd.uid new file mode 100644 index 0000000..c4c5bc2 --- /dev/null +++ b/addons/godot_ai/vision_routing.gd.uid @@ -0,0 +1 @@ +uid://dp444q4ocpx45 diff --git a/build-theme.ps1 b/build-theme.ps1 new file mode 100644 index 0000000..aaac755 --- /dev/null +++ b/build-theme.ps1 @@ -0,0 +1,142 @@ +param( + [string]$ThemeId = "space-pixel", + [string]$ThemeSource = "", + [string]$OutputPath = "", + [string]$GodotExecutable = "", + [string]$ProgressPath = "", + [switch]$PreserveActiveTheme, + [switch]$InstallToDefaultUserPath, + [switch]$SkipValidation +) + +$ErrorActionPreference = "Stop" +$projectRoot = (Resolve-Path $PSScriptRoot).Path +. (Join-Path $projectRoot "tools\Resolve-GodotExecutable.ps1") +$GodotExecutable = Resolve-ThemeGodotExecutable -GodotExecutable $GodotExecutable + +if (-not [string]::IsNullOrWhiteSpace($ProgressPath)) { + if (-not [System.IO.Path]::IsPathRooted($ProgressPath)) { + $ProgressPath = Join-Path $projectRoot $ProgressPath + } + $ProgressPath = [System.IO.Path]::GetFullPath($ProgressPath) +} + +function Write-ThemeBuildProgress { + param( + [string]$Stage, + [string]$Message, + [int]$Percent, + [string]$Status = "running" + ) + if ([string]::IsNullOrWhiteSpace($ProgressPath)) { + return + } + $progressDirectory = [System.IO.Path]::GetDirectoryName($ProgressPath) + New-Item -ItemType Directory -Force -Path $progressDirectory | Out-Null + $payload = [ordered]@{ + ThemeId = $ThemeId + Stage = $Stage + Message = $Message + Percent = [Math]::Max(0, [Math]::Min(100, $Percent)) + Status = $Status + OutputPath = $OutputPath + UpdatedAtUtc = [DateTime]::UtcNow.ToString("o") + } + $json = $payload | ConvertTo-Json -Compress + [System.IO.File]::WriteAllText($ProgressPath, $json, [System.Text.UTF8Encoding]::new($false)) +} + +$activeStatePath = Join-Path $projectRoot "build\active-theme.json" +$activeStateExisted = Test-Path -LiteralPath $activeStatePath +$preservedActiveState = if ($PreserveActiveTheme -and $activeStateExisted) { + [System.IO.File]::ReadAllBytes($activeStatePath) +} else { + $null +} + +try { +Write-ThemeBuildProgress -Stage "select" -Message "Selecting theme source" -Percent 5 + +& (Join-Path $PSScriptRoot "tools\Select-Theme.ps1") -ThemeId $ThemeId -ThemeSource $ThemeSource +$activeState = Get-Content -LiteralPath $activeStatePath -Raw -Encoding UTF8 | ConvertFrom-Json +$packageRoot = [System.IO.Path]::GetFullPath([string]$activeState.Package) + +$buildDirectory = Join-Path $PSScriptRoot "build" +New-Item -ItemType Directory -Force -Path $buildDirectory | Out-Null +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $buildDirectory ($ThemeId + ".pck") +} elseif (-not [System.IO.Path]::IsPathRooted($OutputPath)) { + $OutputPath = Join-Path $PSScriptRoot $OutputPath +} +$OutputPath = [System.IO.Path]::GetFullPath($OutputPath) +Write-ThemeBuildProgress -Stage "prepare" -Message "Preparing isolated build workspace" -Percent 12 + +$workspaceRoot = [System.IO.Path]::GetFullPath((Join-Path $projectRoot ("build\theme-workspaces\" + $ThemeId + "-build"))) +$allowedWorkspaceRoot = [System.IO.Path]::GetFullPath((Join-Path $projectRoot "build\theme-workspaces")) +try { + & (Join-Path $projectRoot "tools\Prepare-ThemeWorkspace.ps1") -PackageRoot $packageRoot -WorkspaceRoot $workspaceRoot + + Write-ThemeBuildProgress -Stage "import" -Message "Importing theme resources in Godot" -Percent 25 + & $GodotExecutable --headless --path $workspaceRoot --import + if ($LASTEXITCODE -ne 0) { + throw "Godot failed to import the selected theme." + } + if (-not $SkipValidation) { + Write-ThemeBuildProgress -Stage "validate" -Message "Validating manifest, scenes, and bindings" -Percent 45 + & $GodotExecutable --headless --path $workspaceRoot --script res://tools/validate_theme.gd -- --contract res://contracts/screen_bindings.v2.json + if ($LASTEXITCODE -ne 0) { + throw "The selected theme does not satisfy the full-scene binding contract." + } + } + + Write-ThemeBuildProgress -Stage "export" -Message "Exporting production resources to PCK" -Percent 62 + & $GodotExecutable --headless --path $workspaceRoot --export-pack "Theme DLC" $OutputPath + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $OutputPath)) { + throw "Godot failed to build the theme DLC package." + } + + Write-ThemeBuildProgress -Stage "verify" -Message "Verifying packaged interactive pet" -Percent 84 + & $GodotExecutable --headless --path $projectRoot --script res://tools/verify_interactive_pet.gd -- --pack $OutputPath --optional + if ($LASTEXITCODE -ne 0) { + throw "Packaged interactive pet verification failed for theme '$ThemeId'." + } +} finally { + if ( + (Test-Path -LiteralPath $workspaceRoot) -and + $workspaceRoot.StartsWith($allowedWorkspaceRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) + ) { + Remove-Item -LiteralPath $workspaceRoot -Recurse -Force + } + if ((Test-Path -LiteralPath $allowedWorkspaceRoot) -and @(Get-ChildItem -LiteralPath $allowedWorkspaceRoot -Force).Count -eq 0) { + Remove-Item -LiteralPath $allowedWorkspaceRoot -Force + } +} + +Write-ThemeBuildProgress -Stage "finalize" -Message "Finalizing local theme package" -Percent 93 +$activeOutputPath = Join-Path $buildDirectory "active_theme.pck" +if (-not $PreserveActiveTheme -and $OutputPath -ne $activeOutputPath) { + Copy-Item -LiteralPath $OutputPath -Destination $activeOutputPath -Force +} + +if ($InstallToDefaultUserPath) { + Write-ThemeBuildProgress -Stage "install" -Message "Installing PCK into the Exchanger user directory" -Percent 97 + $installDirectory = Join-Path $env:APPDATA "Godot\app_userdata\Exchanger\theme_dlc" + New-Item -ItemType Directory -Force -Path $installDirectory | Out-Null + Copy-Item -LiteralPath $OutputPath -Destination (Join-Path $installDirectory "active_theme.pck") -Force + Write-Host "Theme '$ThemeId' installed into the Exchanger user directory." +} else { + Write-Host "Theme '$ThemeId' built: $OutputPath" +} +Write-ThemeBuildProgress -Stage "complete" -Message "Theme build completed" -Percent 100 -Status "complete" +} catch { + Write-ThemeBuildProgress -Stage "error" -Message $_.Exception.Message -Percent 100 -Status "error" + throw +} finally { + if ($PreserveActiveTheme) { + if ($activeStateExisted) { + [System.IO.File]::WriteAllBytes($activeStatePath, $preservedActiveState) + } elseif (Test-Path -LiteralPath $activeStatePath) { + Remove-Item -LiteralPath $activeStatePath -Force + } + } +} diff --git a/contracts/screen_bindings.v2.json b/contracts/screen_bindings.v2.json new file mode 100644 index 0000000..657e795 --- /dev/null +++ b/contracts/screen_bindings.v2.json @@ -0,0 +1,3929 @@ +{ + "Format": "ExchangerThemeBindingContract", + "Version": 2, + "ScreenMetadataKey": "exchanger_screen_binding_id", + "ElementMetadataKey": "exchanger_binding_id", + "Source": "Immutable Exchanger ThemeView contract: host binds existing theme-owned nodes only", + "Screens": [ + { + "BindingId": "screen.home", + "Key": "home", + "RootName": "ThemeScreen_Home", + "Path": "res://exchanger_theme_dlc/scenes/screen_home.tscn", + "Bindings": [ + { + "Id": "home.scroll.content.footer.margin.content.brand", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Footer/Margin/Content/Brand" + }, + { + "Id": "home.scroll.content.speech_text", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SpeechText" + }, + { + "Id": "home.scroll.content.price_panel.margin.content.price", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PricePanel/Margin/Content/Price" + }, + { + "Id": "home.scroll.content.payment_panel.margin.content.buttons.cash_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentPanel/Margin/Content/Buttons/CashButton" + }, + { + "Id": "home.scroll.content.payment_panel.margin.content.buttons.card_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentPanel/Margin/Content/Buttons/CardButton" + }, + { + "Id": "home.scroll.content.footer.margin.content.support", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Footer/Margin/Content/Support" + }, + { + "Id": "home.scroll.content.stock_status", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/StockStatus" + }, + { + "Id": "home.scroll.content.footer.margin.content.connection_status", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus" + }, + { + "Id": "home.scroll.content.stock_status.state", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/StockStatus/Margin/Content/State" + }, + { + "Id": "home.scroll.content.footer.margin.content.connection_status.label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus/Panel/Margin/Label" + }, + { + "Id": "home.scroll.content.advertisement_panel.layer.content", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content" + }, + { + "Id": "home.scroll.content.advertisement_panel.layer.content.poster", + "Types": [ + "TextureRect" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content/Poster" + }, + { + "Id": "home.scroll.content.advertisement_panel.layer.content.video_player", + "Types": [ + "VideoStreamPlayer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content/VideoPlayer" + }, + { + "Id": "home.scroll.content.advertisement_panel.layer.content.fallback_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content/FallbackLabel" + } + ] + }, + { + "BindingId": "screen.cash_payment", + "Key": "cash_payment", + "RootName": "ThemeScreen_CashPayment", + "Path": "res://exchanger_theme_dlc/scenes/screen_cash_payment.tscn", + "Bindings": [ + { + "Id": "cash_payment.content.top_bar.back_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/TopBar/BackButton" + }, + { + "Id": "cash_payment.content.summary.margin.values.balance", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Summary/Margin/Values/Balance" + }, + { + "Id": "cash_payment.content.summary.margin.values.tokens", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Summary/Margin/Values/Tokens" + }, + { + "Id": "cash_payment.content.summary.margin.values.bonus", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Summary/Margin/Values/Bonus" + }, + { + "Id": "cash_payment.content.dispense_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DispenseButton" + }, + { + "Id": "cash_payment.content.debug_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel" + }, + { + "Id": "cash_payment.content.debug_panel.add10_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/Add10Button" + }, + { + "Id": "cash_payment.content.debug_panel.add50_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/Add50Button" + }, + { + "Id": "cash_payment.content.debug_panel.add100_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/Add100Button" + } + ] + }, + { + "BindingId": "screen.card_amount", + "Key": "card_amount", + "RootName": "ThemeScreen_CardAmount", + "Path": "res://exchanger_theme_dlc/scenes/screen_card_amount.tscn", + "Bindings": [ + { + "Id": "card_amount.content.top_bar.back_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/TopBar/BackButton" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_13", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot13" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_12", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot12" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_11", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot11" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_10", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot10" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_09", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot09" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_08", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot08" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_07", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot07" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_06", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot06" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_05", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot05" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_04", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot04" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_03", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot03" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_02", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot02" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_01", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot01" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_00", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot00" + }, + { + "Id": "card_amount.content.confirm_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ConfirmButton" + }, + { + "Id": "card_amount.content.reward_panel.content.value", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/RewardPanel/Content/Value" + }, + { + "Id": "card_amount.content.selection_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/SelectionLabel" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.other_amount_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/OtherAmountButton" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid", + "Types": [ + "GridContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_14", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot14" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_15", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot15" + } + ] + }, + { + "BindingId": "screen.card_custom_amount", + "Key": "card_custom_amount", + "RootName": "ThemeScreen_CardCustomAmount", + "Path": "res://exchanger_theme_dlc/scenes/screen_card_custom_amount.tscn", + "Bindings": [ + { + "Id": "card_custom_amount.content.top_bar.back_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/TopBar/BackButton" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit7", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit7" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit6", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit6" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit5", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit5" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit4", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit4" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit3", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit3" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit2", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit2" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit1", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit1" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit0", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit0" + }, + { + "Id": "card_custom_amount.content.confirm_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ConfirmButton" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.backspace_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/BackspaceButton" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.clear_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/ClearButton" + }, + { + "Id": "card_custom_amount.content.hint", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Hint" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.input_value", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/InputValue" + }, + { + "Id": "card_custom_amount.content.amount_panel.margin.values.tokens", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Values/Tokens" + }, + { + "Id": "card_custom_amount.content.amount_panel.margin.values.amount", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Values/Amount" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit8", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit8" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit9", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit9" + } + ] + }, + { + "BindingId": "screen.card_terminal", + "Key": "card_terminal", + "RootName": "ThemeScreen_CardTerminal", + "Path": "res://exchanger_theme_dlc/scenes/screen_card_terminal.tscn", + "Bindings": [ + { + "Id": "card_terminal.content.top_bar.back_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/TopBar/BackButton" + }, + { + "Id": "card_terminal.content.cancel_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/CancelButton" + }, + { + "Id": "card_terminal.content.amount", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Amount" + }, + { + "Id": "card_terminal.content.tokens", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Tokens" + }, + { + "Id": "card_terminal.content.countdown", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Countdown" + }, + { + "Id": "card_terminal.content.debug_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel" + }, + { + "Id": "card_terminal.content.debug_panel.approve_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/ApproveButton" + }, + { + "Id": "card_terminal.content.debug_panel.decline_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/DeclineButton" + }, + { + "Id": "card_terminal.content.debug_panel.dispense_failure_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/DispenseFailureButton" + }, + { + "Id": "card_terminal.content.debug_panel.partial_dispense_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/PartialDispenseButton" + }, + { + "Id": "card_terminal.content.debug_panel.connection_loss_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/ConnectionLossButton" + } + ] + }, + { + "BindingId": "screen.success", + "Key": "success", + "RootName": "ThemeScreen_Success", + "Path": "res://exchanger_theme_dlc/scenes/screen_success.tscn", + "Bindings": [ + { + "Id": "success.content.result_panel.margin.values.title", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ResultPanel/Margin/Values/Title" + }, + { + "Id": "success.content.result_panel.margin.values.body.message", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ResultPanel/Margin/Values/Body/Message" + }, + { + "Id": "success.content.countdown", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Countdown" + }, + { + "Id": "success.content.home_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/HomeButton" + } + ] + }, + { + "BindingId": "screen.error", + "Key": "error", + "RootName": "ThemeScreen_Error", + "Path": "res://exchanger_theme_dlc/scenes/screen_error.tscn", + "Bindings": [ + { + "Id": "error.content.error_panel.margin.values.title", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ErrorPanel/Margin/Values/Title" + }, + { + "Id": "error.content.error_panel.margin.values.body.message", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ErrorPanel/Margin/Values/Body/Message" + }, + { + "Id": "error.content.countdown", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Countdown" + }, + { + "Id": "error.content.home_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/HomeButton" + } + ] + }, + { + "BindingId": "screen.service_access", + "Key": "service_access", + "RootName": "ThemeScreen_ServiceAccess", + "Path": "res://exchanger_theme_dlc/scenes/screen_service_access.tscn", + "Bindings": [ + { + "Id": "service_access.content.header", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Header" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit7", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit7" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit6", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit6" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit5", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit5" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit4", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit4" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit3", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit3" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit2", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit2" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit1", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit1" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit8", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit8" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit0", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit0" + }, + { + "Id": "service_access.content.access_panel.fields.enter_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/EnterButton" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.erase", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Erase" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.clear", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Clear" + }, + { + "Id": "service_access.content.access_panel.fields.status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Status" + }, + { + "Id": "service_access.content.access_panel.fields.pin_display", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/PinDisplay" + }, + { + "Id": "service_access.content.access_panel.fields.pin_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/PinLabel" + }, + { + "Id": "service_access.content.description", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Description" + }, + { + "Id": "service_access.content.cancel_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/CancelButton" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit9", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit9" + } + ] + }, + { + "BindingId": "screen.settings", + "Key": "settings", + "RootName": "ThemeScreen_Settings", + "Path": "res://exchanger_theme_dlc/scenes/screen_settings.tscn", + "Bindings": [ + { + "Id": "settings.scroll.content.section_tabs.general_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/GeneralButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row/Threshold" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.empty_state", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/EmptyState" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row/Bonus" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Row/Amount" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.empty_state", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/EmptyState" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Row/Amount" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row/Bonus" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows", + "Types": [ + "VBoxContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.limits_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/LimitsError" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.limits.custom_step", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits/CustomStep" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.limits.max_amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits/MaxAmount" + }, + { + "Id": "settings.scroll.content.payment_methods_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.payment_methods_panel.margin.content.card_enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content/CardEnabled" + }, + { + "Id": "settings.scroll.content.payment_methods_panel.margin.content.cash_enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content/CashEnabled" + }, + { + "Id": "settings.scroll.content.preview_panel.margin.content.values", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PreviewPanel/Margin/Content/Values" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.add_rule_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/AddRuleButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows", + "Types": [ + "VBoxContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows" + }, + { + "Id": "settings.scroll.content.price_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PricePanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.price_panel.margin.content.price", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PricePanel/Margin/Content/Price" + }, + { + "Id": "settings.scroll.content.test_panel.margin.content.status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TestPanel/Margin/Content/Status" + }, + { + "Id": "settings.scroll.content.test_panel.margin.content.test_sound_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TestPanel/Margin/Content/TestSoundButton" + }, + { + "Id": "settings.scroll.content.advertisement_panel.margin.content.volume_row.value", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content/VolumeRow/Value" + }, + { + "Id": "settings.scroll.content.advertisement_panel.margin.content.volume_row.slider", + "Types": [ + "HSlider" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content/VolumeRow/Slider" + }, + { + "Id": "settings.scroll.content.master_panel.margin.content.mute", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/MasterPanel/Margin/Content/Mute" + }, + { + "Id": "settings.scroll.content.master_panel.margin.content.volume_row.value", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/MasterPanel/Margin/Content/VolumeRow/Value" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.add_preset_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/AddPresetButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.presets_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetsError" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.grid.idle", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid/Idle" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.grid.cash_payment", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid/CashPayment" + }, + { + "Id": "settings.scroll.content.advertisement_poster_panel.margin.content.poster_path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content/PosterPath" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel.margin.content.fallback_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content/FallbackError" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel.margin.content.fallback_text", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content/FallbackText" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel.margin.content.muted", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content/Muted" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel.margin.content.enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content/Enabled" + }, + { + "Id": "settings.scroll.content.security_panel.margin.content.status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SecurityPanel/Margin/Content/Status" + }, + { + "Id": "settings.scroll.content.security_panel.margin.content.reset_pin_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SecurityPanel/Margin/Content/ResetPinButton" + }, + { + "Id": "settings.scroll.content.security_panel.margin.content.enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SecurityPanel/Margin/Content/Enabled" + }, + { + "Id": "settings.scroll.content.branding_preview_panel.margin.content.support_phone", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content/SupportPhone" + }, + { + "Id": "settings.scroll.content.master_panel.margin.content.volume_row.slider", + "Types": [ + "HSlider" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/MasterPanel/Margin/Content/VolumeRow/Slider" + }, + { + "Id": "settings.scroll.content.branding_preview_panel.margin.content.short_text", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content/ShortText" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.support_phone_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/SupportPhoneError" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.short_text_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/ShortTextError" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.application_name_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/ApplicationNameError" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.support_phone", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/SupportPhone" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.short_text", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/ShortText" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.application_name", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/ApplicationName" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.grid.success", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid/Success" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.grid.cashless_payment", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid/CashlessPayment" + }, + { + "Id": "settings.scroll.content.branding_preview_panel.margin.content.application_name", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content/ApplicationName" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.buttons.confirm_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Buttons/ConfirmButton" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.buttons.cancel_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Buttons/CancelButton" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Amount" + }, + { + "Id": "settings.scroll.content.appearance_effects_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceEffectsPanel" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel" + }, + { + "Id": "settings.scroll.content.advertisement_preview_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPreviewPanel" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel" + }, + { + "Id": "settings.scroll.content.advertisement_poster_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPosterPanel" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel" + }, + { + "Id": "settings.scroll.content.test_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TestPanel" + }, + { + "Id": "settings.scroll.content.advertisement_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel" + }, + { + "Id": "settings.scroll.content.master_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/MasterPanel" + }, + { + "Id": "settings.scroll.content.appearance_notice_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceNoticePanel" + }, + { + "Id": "settings.scroll.content.security_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SecurityPanel" + }, + { + "Id": "settings.scroll.content.branding_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel" + }, + { + "Id": "settings.scroll.content.section_tabs.diagnostics_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/DiagnosticsButton" + }, + { + "Id": "settings.scroll.content.section_tabs.equipment_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/EquipmentButton" + }, + { + "Id": "settings.scroll.content.section_tabs.service_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/ServiceButton" + }, + { + "Id": "settings.scroll.content.section_tabs.payment_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/PaymentButton" + }, + { + "Id": "settings.scroll.content.section_tabs.pricing_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/PricingButton" + }, + { + "Id": "settings.scroll.content.section_tabs.appearance_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/AppearanceButton" + }, + { + "Id": "settings.scroll.content.section_tabs.advertisement_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/AdvertisementButton" + }, + { + "Id": "settings.scroll.content.section_tabs.audio_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/AudioButton" + }, + { + "Id": "settings.scroll.content.branding_preview_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPreviewPanel" + }, + { + "Id": "settings.scroll.content.advertisement_poster_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.price_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PricePanel" + }, + { + "Id": "settings.scroll.content.preview_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PreviewPanel" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.description", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Description" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.title", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Title" + }, + { + "Id": "settings.service_dialog_overlay", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Status" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper2.manual_add_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2/ManualAddButton" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper2.recount_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2/RecountButton" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper2_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2Label" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper1.manual_add_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1/ManualAddButton" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper1.recount_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1/RecountButton" + }, + { + "Id": "settings.scroll.content.bonus_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper1_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1Label" + }, + { + "Id": "settings.scroll.content.service_inventory_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel" + }, + { + "Id": "settings.scroll.content.diagnostics_test_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsTestPanel" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel" + }, + { + "Id": "settings.scroll.content.diagnostics_status_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsStatusPanel" + }, + { + "Id": "settings.scroll.content.equipment_window_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel" + }, + { + "Id": "settings.scroll.content.timeouts_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel" + }, + { + "Id": "settings.scroll.content.card_options_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel" + }, + { + "Id": "settings.scroll.content.payment_methods_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentMethodsPanel" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.count", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Count" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows", + "Types": [ + "VBoxContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.add_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/AddButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Error" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.header.hide_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/Header/HideButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_32", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_32" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_31", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_31" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_30", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_30" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_29", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_29" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_28", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_28" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_27", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_27" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_26", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_26" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_25", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_25" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.primary_actions.language_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/LanguageButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_24", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_24" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_22", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_22" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_21", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_21" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_20", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_20" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_19", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_19" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_18", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_18" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_17", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_17" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_16", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_16" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_15", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_15" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_14", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_14" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_23", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_23" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_13", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_13" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.primary_actions.shift_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/ShiftButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.primary_actions.backspace_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/BackspaceButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.empty_state", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/EmptyState" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.backspace_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/BackspaceButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.clear_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/ClearButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.header.hide_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Header/HideButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit9", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit9" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit8", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit8" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit7", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit7" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.primary_actions.symbols_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/SymbolsButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit6", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit6" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit4", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit4" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit3", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit3" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit2", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit2" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit1", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit1" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit0", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit0" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.secondary_actions.space_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/SpaceButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.secondary_actions.clear_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/ClearButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.secondary_actions.caret_right_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/CaretRightButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.secondary_actions.caret_left_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/CaretLeftButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit5", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit5" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Row/RemoveButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_12", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_12" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_10", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_10" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel.margin.content.minimum_level", + "Types": [ + "OptionButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content/MinimumLevel" + }, + { + "Id": "settings.scroll.content.diagnostics_status_panel.margin.content.recent_errors", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content/RecentErrors" + }, + { + "Id": "settings.scroll.content.diagnostics_status_panel.margin.content.runtime_summary", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content/RuntimeSummary" + }, + { + "Id": "settings.scroll.content.equipment_window_panel.margin.content.target_fps", + "Types": [ + "OptionButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content/TargetFps" + }, + { + "Id": "settings.scroll.content.equipment_window_panel.margin.content.debug_window", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content/DebugWindow" + }, + { + "Id": "settings.scroll.content.equipment_window_panel.margin.content.borderless", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content/Borderless" + }, + { + "Id": "settings.scroll.content.equipment_window_panel.margin.content.fullscreen", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content/Fullscreen" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.connection_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/ConnectionError" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.reconnect_seconds", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/ReconnectSeconds" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel.margin.content.max_file_size", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content/MaxFileSize" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.baud_rate", + "Types": [ + "OptionButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/BaudRate" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.port_name", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/PortName" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.use_mock", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/UseMock" + }, + { + "Id": "settings.scroll.content.appearance_notice_panel.margin.content.current_theme", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin/Content/CurrentTheme" + }, + { + "Id": "settings.scroll.content.appearance_effects_panel.margin.content.reduced_effects", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceEffectsPanel/Margin/Content/ReducedEffects" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel.margin.content.package_status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content/PackageStatus" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel.margin.content.path_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content/PathError" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel.margin.content.external_path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content/ExternalPath" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel.margin.content.external_enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content/ExternalEnabled" + }, + { + "Id": "settings.scroll.content.advertisement_preview_panel.margin.content.summary", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPreviewPanel/Margin/Content/Summary" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.port_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/PortError" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_11", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_11" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel.margin.content.retention_days", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content/RetentionDays" + }, + { + "Id": "settings.scroll.content.diagnostics_test_panel.margin.content.test_dispense_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content/TestDispenseButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_09", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_09" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_08", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_08" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_07", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_07" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_06", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_06" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_05", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_05" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_04", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_04" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_03", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_03" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_02", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_02" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_01", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_01" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel.margin.content.logging_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content/LoggingError" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_00", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_00" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.recount_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/RecountButton" + }, + { + "Id": "settings.scroll.content", + "Types": [ + "Node" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content" + }, + { + "Id": "settings.scroll.content.section_tabs", + "Types": [ + "Node" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs" + }, + { + "Id": "settings.scroll.content.header.margin.content.title", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Header/Margin/Content/Title" + }, + { + "Id": "settings.scroll.content.header", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Header" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard" + }, + { + "Id": "settings.scroll", + "Types": [ + "ScrollContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll" + }, + { + "Id": "settings.scroll.content.diagnostics_test_panel.margin.content.test_dispense_status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content/TestDispenseStatus" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.manual_add_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/ManualAddButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Error" + } + ] + } + ] +} diff --git a/docs/APPLY_THEME.md b/docs/APPLY_THEME.md new file mode 100644 index 0000000..a1450e5 --- /dev/null +++ b/docs/APPLY_THEME.md @@ -0,0 +1,162 @@ +# Как применить тему в Exchanger + +Эта инструкция описывает полный пользовательский путь: от выбора и редактирования темы до +установки нового PCK в Exchanger. + +Важно различать действия: + +- `Редактор → Выбрать тему` и `activate-theme.ps1` выбирают тему только для builder-preview; +- `Редактор → Экспортировать тему` позволяет выбрать любую тему по ID и создаёт указанный + пользователем `.pck`, но не устанавливает его и не переключает preview; +- `Редактор → Применить тему к Exchanger` проверяет, собирает и устанавливает активную тему; +- `build-theme.ps1 -InstallToDefaultUserPath` выполняет ту же установку из PowerShell. + +## 1. Открыть проект + +Требуется Godot 4.7.x. Откройте проект `ExchangerThemeDlcBuilder` в Godot Editor, а PowerShell — +в корне проекта: + +```powershell +Set-Location 'E:\Danil\Projects\Godot\ExchangerThemeDlcBuilder' +``` + +Скрипты автоматически ищут Godot среди запущенных процессов, команд `godot`/`godot4` в `PATH` и +переменных `GODOT4_EXECUTABLE`/`GODOT_EXECUTABLE`. + +## 2. Выбрать тему для preview + +В Godot Editor выберите `Редактор → Выбрать тему`, укажите нужную тему и подтвердите выбор. + +Эквивалентная команда для `space-pixel`: + +```powershell +.\activate-theme.ps1 -ThemeId space-pixel +``` + +Активация записывает выбранный package в `build/active-theme.json`. Она не собирает PCK и не +изменяет установленную тему Exchanger. + +## 3. Редактировать production-файлы + +Редактируйте файлы непосредственно в: + +```text +themes//package/ +``` + +Основные каталоги и файлы: + +- `scenes/` — девять экранов приложения; +- `components/` — общие компоненты темы; +- `assets/` — изображения, шрифты и звук; +- `visuals/` — дополнительные визуальные ресурсы; +- `ui_theme.tres` — стили элементов интерфейса; +- `background_decoration.tscn` — отдельная фоновая декорация. + +`source/` используется для оригиналов и references, но в production PCK не экспортируется. + +Все зависимости внутри `package/` должны оставаться переносимыми. Используйте относительные +ссылки, например `../ui_theme.tres` или `../assets/button.png`. Ссылки вида +`res://themes//package/...` могут открываться в builder, но не разрешатся в изолированном +workspace сборки, где package монтируется как `res://exchanger_theme_dlc/`. + +## 4. Проверить preview + +Запустите проект из Godot либо выполните: + +```powershell +.\preview-theme.ps1 -ThemeId space-pixel +``` + +Preview показывает реальные production-сцены без изменения их визуального дерева. Проверьте все +девять вкладок: `HOME`, `CASH`, `SUM`, `KEYPAD`, `CARD`, `SUCCESS`, `ERROR`, `SERVICE`, `SETTINGS`. + +Сначала включите `BINDINGS: ON`, чтобы проверить размеченные функциональные элементы, затем +выключите overlay и проверьте внешний вид в портретном окне `720×1280`. В SETTINGS дополнительно +проверьте состояния `KEYBOARD: OFF/TEXT/NUM` и `INVENTORY: OFF/PANEL/DIALOG`. + +Сохранённые изменения `.tscn`, `.tres`, JSON, изображений, шрифтов и звука обычно появляются через +live reload. Если этого не произошло, убедитесь, что активирована правильная тема, сохраните файл +на диск и перезапустите preview. + +## 5. Выполнить автоматические проверки + +```powershell +.\tools\Test-PackagedBindingContract.ps1 +.\test-theme.ps1 -ThemeId space-pixel +``` + +Первая команда проверяет byte-identical копии binding-контракта во всех темах. Вторая создаёт +изолированный workspace, импортирует ресурсы, проверяет manifest v2, девять сцен, обязательные +биндинги и запрещённые production-узлы, а затем выполняет headless preview smoke. + +Не переходите к установке, пока обе команды не завершатся успешно. + +## 6. Закрыть Exchanger + +Полностью закройте запущенное приложение перед заменой PCK. Live reload действует только в +builder-preview; Exchanger загружает внешний пакет при запуске. + +## 7. Собрать и установить тему + +Предпочтительный интерактивный путь: закройте Exchanger и выберите в Godot Editor +`Редактор → Применить тему к Exchanger`. Подтверждение показывает ID активной темы и конечный +путь. Сборка идёт в фоне; по завершении аддон сообщает об успехе либо показывает последние строки +ошибки PowerShell. Во время работы видны текущий этап и процент выполнения. + +Эквивалентная команда: + +```powershell +.\build-theme.ps1 -ThemeId space-pixel -InstallToDefaultUserPath +``` + +Команда валидирует тему, собирает `build/space-pixel.pck`, обновляет совместимую копию +`build/active_theme.pck` и устанавливает пакет по пути: + +```text +%APPDATA%\Godot\app_userdata\Exchanger\theme_dlc\active_theme.pck +``` + +Команда без `-InstallToDefaultUserPath` только создаёт PCK в `build/`; в Exchanger такая сборка +автоматически не попадёт. + +## 8. Перезапустить и проверить Exchanger + +Запустите Exchanger заново и проверьте все затронутые экраны и интерактивные элементы. В журнале +должны быть указаны ожидаемые `ThemeId` и manifest версии 2. + +Если приложение показывает встроенную fallback-тему: + +1. убедитесь, что Exchanger был полностью перезапущен; +2. проверьте наличие установленного `active_theme.pck` по пути выше; +3. повторите validator и сборку без `-SkipValidation`; +4. проверьте журнал ThemeManager на ошибку manifest, контракта, binding-id или отсутствующего + ресурса. + +## Короткий ежедневный цикл + +После редактирования темы выполните: + +```powershell +.\activate-theme.ps1 -ThemeId space-pixel +.\tools\Test-PackagedBindingContract.ps1 +.\test-theme.ps1 -ThemeId space-pixel +``` + +Затем закройте Exchanger, установите новую сборку и запустите приложение заново: + +```powershell +.\build-theme.ps1 -ThemeId space-pixel -InstallToDefaultUserPath +``` + +## Выполнение через VS Code + +Откройте палитру команд `Ctrl+Shift+P`, выберите `Tasks: Run Task` и последовательно запустите: + +1. `Theme: активировать`; +2. `Theme: проверить копии контракта`; +3. `Theme: проверить validator и preview smoke`; +4. после закрытия Exchanger — `Theme: собрать и установить PCK (заменяет установленную тему)`. + +Задача `Theme: полный локальный QA` выполняет проверки и обычную сборку, но не устанавливает PCK +в пользовательский каталог Exchanger. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..adccbb5 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,92 @@ +# Архитектура и границы ответственности + +## Общая модель + +Theme Builder создаёт полные доверенные тематические сцены. Exchanger загружает PCK, проверяет +manifest и binding-контракт, создаёт тематическую сцену экрана и подключает свою C#-логику к +узлам по metadata. Фактическая иерархия, визуальные эффекты и GDScript-логика темы свободны, пока +соблюдены обязательные ID и типы. + +```text +themes//package ───────────────> preview gallery + binding overlay + │ + └── temporary build workspace/exchanger_theme_dlc + ├── validator + └── export ──> build/.pck ──install──> Exchanger userdata +``` + +## Дерево проекта + +```text +themes/ +├── example/ +│ ├── source/ исходные материалы будущей темы +│ └── package/ полный безопасный шаблон v2 +├── space-pixel/ текущая оформленная тема для preview +└── / + ├── source/ оригиналы, лицензии, references + └── package/ + ├── manifest.json + ├── contracts/screen_bindings.v2.json + ├── scenes/ девять production-сцен + ├── assets/ runtime-копии изображений/шрифтов/аудио + ├── components/ вложенные сцены и runtime-компоненты темы + ├── scripts/ GDScript доверенной темы + ├── visuals/ необязательные визуальные фрагменты + ├── ui_theme.tres + └── background_decoration.tscn +contracts/ авторитетная копия builder-контракта +tools/preview/ галерея, live reload и overlay +addons/exchanger_theme_manager/ EditorPlugin выбора/создания тем +build/ PCK, active-theme.json и временные build-workspaces +``` + +`themes_src/` и `preview_scenes/` — архив ранней partial-theme модели. Они не являются источником +новой разработки и не входят в PCK. Для новых задач используйте только `themes/` и +`tools/preview/`. + +Каталоги тем не содержат `.gdignore` и всегда индексируются Godot, поэтому все исходники +`themes//package/` доступны в FileSystem. `Exchanger Theme Manager` использует те же видимые +каталоги для списка тем, а preview инстанцирует сцены выбранного package напрямую. Внутренние +зависимости package записываются относительными путями, поэтому каждая открытая сцена использует +ресурсы своей темы. Сгенерированные `.import`/`.uid` не копируются в новую тему. При сборке +обычные `.import` создаются заново, но sidecar-файлы шрифтов `.ttf`, `.otf`, `.woff` и `.woff2` +переносятся с заменой package-пути на runtime-путь: так сохраняются заданные автором antialiasing, +hinting, subpixel positioning и oversampling. Затем package временно копируется под namespace +`res://exchanger_theme_dlc/`; export preset включает только этот namespace и его зависимости. + +Каждая production-сцена является полным визуальным корнем: на её root назначен package +`ui_theme.tres`, а фон и decoration-сцена находятся внутри её дерева перед +функциональным содержимым. Root помечен `exchanger_self_contained_visuals = true`. Preview не +назначает сцене Theme, не добавляет фон/декорации и не монтирует демонстрационные дочерние узлы — +только инстанцирует девять сцен во вкладки. `UiThemePath` остаётся описанием package Theme, а +legacy `BackgroundTexturePath` и `BackgroundDecorationPath` у +самодостаточной темы пусты, чтобы Exchanger не нарисовал тот же chrome второй раз. + +## Что принадлежит Theme Builder + +- композиция и стили девяти экранов; +- фон, анимации, шейдеры, частицы и другие визуальные эффекты; +- собственный GDScript и theme-owned runtime-компоненты; +- package-ресурсы и manifest; +- preview, binding overlay, validator и build scripts; +- локальные исходные материалы и документация темы. + +## Что принадлежит Exchanger + +- навигация и состояние пользовательского потока; +- оплата, выдача, COM-протокол и оборудование; +- сохранение и проверка настроек, PIN, таймеры и аудио-сервисы; +- управление данными готовых theme-owned Home-узлов: остаток жетонов, подключение и промо; +- загрузчик PCK, fallback-тема и авторитет поддерживаемой версии host-контракта. + +Соседний Exchanger требуется только для обновления/проверки host-контракта и финального +интеграционного QA. Обычная визуальная разработка темы автономна. + +## Доверенный PCK + +В export preset перечислены только ресурсы временной package-копии и их зависимости. Production- +сцены не ограничены пассивными узлами: GDScript, `Timer`, `AudioStreamPlayer*`, `HTTPRequest`, +шейдеры и прочие стандартные возможности Godot разрешены. `source/`, references, docs, preview и +builder tooling не экспортируются. Host продолжает сверять SHA-256 package-копии контракта и при +ошибке структуры экранов или биндингов возвращается к встроенной fallback-теме. diff --git a/docs/BINDING_CONTRACT.md b/docs/BINDING_CONTRACT.md new file mode 100644 index 0000000..6259ca2 --- /dev/null +++ b/docs/BINDING_CONTRACT.md @@ -0,0 +1,367 @@ +# Manifest v2 и binding-контракт + +## Зачем нужны биндинги + +Тема управляет внешним видом и может выполнять собственный доверенный GDScript. Exchanger находит +нужную кнопку, Label или поле по `exchanger_binding_id`, проверяет класс узла и подключает +app-owned обработчик. Поэтому автор темы может свободно менять иерархию, имена и внутреннюю логику, +но не ID и требуемый тип обязательных узлов. + +## Manifest v2 + +`themes//package/manifest.json` содержит `Format: ExchangerThemeDlc`, `FormatVersion: 2` и +ровно девять `ScreenScenes`: + +| Screen binding | Файл | Имя корня | +| --- | --- | --- | +| `screen.home` | `screen_home.tscn` | `ThemeScreen_Home` | +| `screen.cash_payment` | `screen_cash_payment.tscn` | `ThemeScreen_CashPayment` | +| `screen.card_amount` | `screen_card_amount.tscn` | `ThemeScreen_CardAmount` | +| `screen.card_custom_amount` | `screen_card_custom_amount.tscn` | `ThemeScreen_CardCustomAmount` | +| `screen.card_terminal` | `screen_card_terminal.tscn` | `ThemeScreen_CardTerminal` | +| `screen.success` | `screen_success.tscn` | `ThemeScreen_Success` | +| `screen.error` | `screen_error.tscn` | `ThemeScreen_Error` | +| `screen.service_access` | `screen_service_access.tscn` | `ThemeScreen_ServiceAccess` | +| `screen.settings` | `screen_settings.tscn` | `ThemeScreen_Settings` | + +Каждый файл находится в `res://exchanger_theme_dlc/scenes/`. Корень — `Control` с metadata +`exchanger_screen_binding_id = screen.`. + +Manifest может дополнительно объявить интерактивного питомца: + +```json +"InteractivePetScene": "res://exchanger_theme_dlc/components/interactive_pet.tscn" +``` + +Поле необязательно. Если оно задано, путь обязан находиться внутри +`res://exchanger_theme_dlc/`, указывать на существующую `.tscn`, а корень сцены должен наследовать +`Node2D` и реализовывать нейтральный pet API из [SCRIPTING.md](SCRIPTING.md). Путь к самой сцене +задаётся manifest, а её изображения подключаются обычными resource-ссылками из любого каталога +внутри `package/`: имя папки с текстурами не является частью контракта. + +## Навигация питомца + +Если manifest объявляет `InteractivePetScene`, текущий Builder требует невидимую навигацию версии +1 во всех восьми production-сценах, кроме `screen.settings`. Host находит её только по metadata и +не анализирует имена узлов или геометрию визуальных `Control`: + +- единственный `Node2D`-корень: `exchanger_pet_navigation_root = true`, + `exchanger_pet_navigation_version = 1`, `exchanger_pet_default_surface_id` и + `exchanger_pet_roaming_bounds: Rect2`; +- горизонтальная поверхность — любой вложенный `Node2D` с `exchanger_pet_surface = true`, + уникальным `exchanger_pet_surface_id`, числовыми `exchanger_pet_surface_from_x`, + `exchanger_pet_surface_to_x`, `exchanger_pet_surface_y` и необязательным + `exchanger_pet_surface_sit_points: PackedFloat32Array`; +- вертикальный переход — любой вложенный `Node2D` с `exchanger_pet_climb_edge = true`, уникальным + `exchanger_pet_climb_edge_id`, ссылками `exchanger_pet_climb_from_surface_id` / + `exchanger_pet_climb_to_surface_id`, координатами `exchanger_pet_climb_x`, +`exchanger_pet_climb_from_y`, `exchanger_pet_climb_to_y` и стороной + `exchanger_pet_climb_side = left|right`. + +Climb edge является двунаправленной связью: `from`/`to` фиксируют две стыкуемые поверхности и +соответствующие им Y, но host может пройти её вверх или вниз и выбрать прямую анимацию по +фактическому направлению движения. + +Все координаты задаются в локальной системе соответствующего экрана и обозначают реальные +экранные точки контакта, +а не позицию верхнего левого угла сцены питомца. Host получает смещение лап через optional +`get_pet_ground_offset()` и размещает root по формуле +`contact_point - ground_offset * runtime_scale`. Поверхностями могут быть пол, верхние грани +кнопок и окон; climb edges явно связывают их в граф. Навигационные `Node2D` ничего не рисуют и не +меняют визуальное дерево/геометрию `Control`. Host сохраняет ограниченный fallback для старых тем +без v1-маркеров, но новая тема с питомцем без полного набора восьми графов не проходит Builder +Validator. + +Builder дополнительно требует `exchanger_self_contained_visuals = true`: это не binding для +бизнес-логики, а признак того, что root сам применяет package Theme и содержит фон/decoration. +Preview не монтирует оформление и не изменяет дерево production-сцены. + +## Каталог 481 элемента + +`contracts/screen_bindings.v2.json` — авторитетная копия builder. Для каждого экрана она хранит +ID, допустимый Godot-класс, обязательность и fallback path host. Каждый package содержит точную +байтовую копию в `package/contracts/screen_bindings.v2.json`; host сверяет SHA-256 до загрузки сцен. + +Текущий immutable-контракт: + +| Экран | Биндингов в JSON | +| --- | ---: | +| `screen.home` | 14 | +| `screen.cash_payment` | 9 | +| `screen.card_amount` | 22 | +| `screen.card_custom_amount` | 18 | +| `screen.card_terminal` | 11 | +| `screen.success` | 4 | +| `screen.error` | 4 | +| `screen.service_access` | 19 | +| `screen.settings` | 380 | +| **Всего** | **481** | + +Все 481 записи имеют `Required: true`. SHA-256 канонического файла: +`DDDC89118BEEA78A820561464001656136ED42AC1AD5710FB109D25ADAF44143`. + +На нужном узле задаётся metadata: + +```text +exchanger_binding_id = home.scroll.content.payment_panel.margin.content.buttons.cash_button +``` + +ID уникален во всём пакете. Допускается наследник указанного класса. Все визуальные узлы принадлежат +теме: Exchanger не добавляет, не удаляет, не переносит и не стилизует `Control` внутри ThemeView. +Host только подключает сигналы и обновляет данные, видимость и доступность существующих узлов. + +### Supplemental runtime bindings + +После фиксации immutable JSON приложению понадобились дополнительные готовые theme-owned узлы. +Чтобы не менять байтовый контракт выпущенных тем, их ID проверяются Builder Validator отдельно. +Для новой темы обязательны ещё 24 element-binding; два дополнительных compatible binding ниже +могут отсутствовать в уже выпущенной теме: + +| Экран | Binding ID | Требуемый тип | +| --- | --- | --- | +| Home | `home.scroll.content.advertisement_panel` | `Control` | +| Home | `home.scroll.content.stock_status.meter.empty` | `Control` | +| Home | `home.scroll.content.stock_status.meter.low` | `Control` | +| Home | `home.scroll.content.stock_status.meter.enough` | `Control` | +| Home | `home.scroll.content.stock_status.meter.much` | `Control` | +| Home | `home.scroll.content.stock_status.approximate_count` | `Label` | +| CardAmount | `card_amount.content.reward_panel.content.bonus` | `Label` | +| CashPayment | `cash_payment.content.top_bar.home_navigation_button` | `Button` (`KeypadButton`; в `space-pixel` — `button_square`) | +| CardAmount | `card_amount.content.top_bar.home_navigation_button` | `Button` (`KeypadButton`; в `space-pixel` — `button_square`) | +| CardCustomAmount | `card_custom_amount.content.top_bar.home_navigation_button` | `Button` (`KeypadButton`; в `space-pixel` — `button_square`) | +| CardTerminal | `card_terminal.content.top_bar.home_navigation_button` | `Button` (`KeypadButton`; в `space-pixel` — `button_square`) | +| Success | `success.content.home_navigation_button` | `Button` (`KeypadButton`; в `space-pixel` — `button_square`) | +| Error | `error.content.home_navigation_button` | `Button` (`KeypadButton`; в `space-pixel` — `button_square`) | +| ServiceAccess | `service_access.content.home_navigation_button` | `Button` (`KeypadButton`; в `space-pixel` — `button_square`) | +| Settings | `settings.footer` | `PanelContainer` | +| Settings | `settings.footer.save_and_exit_button` | `Button` | +| Settings | `settings.scroll.content.footer_clearance` | `Control` | +| Settings | `settings.scroll.content.service_inventory_panel.margin.content.description` | `Label` | +| Settings | `settings.scroll.content.service_inventory_panel.margin.content.inventory_enabled` | `CheckButton` | +| Settings | `settings.scroll.content.service_inventory_panel.margin.content.purchase_limit_enabled` | `CheckButton` | +| Settings | `settings.scroll.content.menu_visibility_panel` | `PanelContainer` | +| Settings | `settings.scroll.content.menu_visibility_panel.margin.content.show_stock_status` | `CheckButton` | +| Settings | `settings.scroll.content.menu_visibility_panel.margin.content.show_right_character` | `CheckButton` | +| Settings | `settings.scroll.content.menu_visibility_panel.margin.content.show_promotion_block` | `CheckButton` | + +Полностью обновлённая тема содержит **509 element-binding**: 481 из JSON, 24 обязательных и четыре +optional supplemental. Metadata девяти корней `exchanger_screen_binding_id` считается отдельно. + +`tools/Sync-HostBindingContract.ps1` намеренно исключает эти 28 ID из генерируемого +immutable JSON, даже если host обращается к их каноническому `FallbackPath` через буквальный +`GetNode`. Иначе обычная синхронизация ошибочно превращала бы supplemental-узел в новый элемент +зафиксированного каталога и ломала обратную совместимость выпущенных тем. Полный список выше +одновременно защищён `Test-PackagedBindingContract.ps1`. + +Дополнительные compatible binding: + +| Экран | Binding ID | Требуемый тип | +| --- | --- | --- | +| CashPayment | `cash_payment.content.banner_text` | `Label` | +| Settings | `settings.scroll.content.animated_banner_texts_panel` | `Control` | +| Settings | `settings.scroll.content.advertisement_poster_panel.margin.content.select_button` | `Button` | +| Settings | `settings.scroll.content.menu_visibility_panel.margin.content.show_interactive_pet` | `CheckButton` | + +`cash_payment.content.banner_text` — подпись нижней анимированной плашки с НЛО на экране +наличной оплаты. `settings.scroll.content.animated_banner_texts_panel` — готовая панель четырёх +полей настройки текстов нижних баннеров: наличные, выбор суммы картой, ручной ввод суммы и шаблон +таймера терминала. В последнем поле `{seconds}` заменяется текущим числом секунд. +`select_button` открывает системный выбор файла изображения для промо-блока, а +`show_interactive_pet` — готовый переключатель с подписью «ИНТЕРАКТИВНЫЙ ПИТОМЕЦ»: +он включает или отключает питомца активной темы. Если manifest не объявляет +`InteractivePetScene`, host делает этот переключатель недоступным, не меняя сохранённое +предпочтение владельца. Все четыре ID получают +встроенный fallback в ранее выпущенных PCK. + +`home_navigation_button` — готовая компактная theme-owned кнопка с изображением дома. Её основа +— `KeypadButton` (для `space-pixel` — `button_square`). На +CashPayment, CardAmount, CardCustomAmount и CardTerminal она стоит рядом с `BackButton`; на +Success, Error и ServiceAccess, где верхней кнопки «Назад» нет, занимает её обычное левое место. +Host только подключает безопасный возврат на Home. До завершения подтверждённой выдачи на Success +кнопка остаётся видимой, но недоступной. Старый PCK без ID получает встроенный fallback по +каноническому пути. + +Один дополнительный биндинг необязателен: + +| Экран | Binding ID | Тип для Builder Validator | +| --- | --- | --- | +| Home | `home.decoration.custom_text_block` | `Control` | +| Home | `home.decoration.right_character` | `TextureRect` | + +Если Home содержит пользовательскую речевую плашку и персонажа рядом с ней, назначьте +`home.decoration.custom_text_block` готовой группе из фона плашки и персонажа. Host одновременно +переключает эту группу и Label `home.scroll.content.speech_text`; он не создаёт и не перестраивает +визуальные узлы. Для совместимости со старыми темами можно оставить +`home.decoration.right_character` на самом персонаже: тогда host скроет персонажа и текст, но +старая тема не обязана скрывать отдельный фон речевой плашки. Если персонажа или плашки нет, не +создавайте пустые узлы только ради optional ID. + +### Что не является element-binding + +Не добавляйте вымышленные `exchanger_binding_id` для фоновой графики, частиц, анимационных +контроллеров или питомца. Для них используются другие точки интеграции: + +- production-сцена определяется через `ScreenScenes` и `exchanger_screen_binding_id` корня; +- интерактивный питомец подключается полем manifest `InteractivePetScene` и публичным pet API; +- составные действия питомца используют методы и сигналы composite API, а не binding metadata; +- поверхности, sit points и climb edges используют navigation v1 metadata; +- полностью автономные декоративные узлы не требуют связи с host. + +Практический процесс создания составного действия описан в +[COMPOSITE_PET_ANIMATIONS.md](COMPOSITE_PET_ANIMATIONS.md). + +Устаревшее описание приложения `subtitle` не входит в контракт и не должно создаваться темой. +Вместо него предусмотрена самостоятельная покупательская фраза: Label +`home.scroll.content.speech_text` расположен внутри авторской речевой плашки Home, а SETTINGS +содержит поле `...branding_panel...short_text`, Label ошибки `...short_text_error` и строку +предпросмотра `...branding_preview_panel...short_text`. Поле ограничено 48 символами; ни один из +этих ID не содержит `subtitle`. Переключатель SETTINGS по историческому ID +`...show_right_character` отображается пользователю как «ПОКАЗЫВАТЬ ПОЛЬЗОВАТЕЛЬСКИЙ ТЕКСТ» и +управляет всей optional-группой, а не только персонажем. + +Home содержит готовые theme-owned Label состояния жетонов и подключения, а промо-блок — +`TextureRect`, `VideoStreamPlayer` и fallback-`Label`. `VideoStreamPlayer` не содержит +скрипта и управляется C#-логикой Exchanger через биндинг. + +Home также использует supplemental runtime bindings, которые валидатор проверяет отдельно от +481 элемента контракта: готовый root промо-панели, четыре визуальных состояния +полоски `home.scroll.content.stock_status.meter.empty/low/enough/much` и Label +`home.scroll.content.stock_status.approximate_count`. Тема заранее располагает состояние слева, +полоску по центру и приблизительный остаток справа; host меняет только текст и видимость. Полный +список supplemental ID приведён выше. + +На экране наличной оплаты итог разделён на три независимых обязательных `Label`: + +- `cash_payment.content.summary.margin.values.balance` — внесённая сумма в рублях, + например `1000 РУБЛЕЙ`; +- `cash_payment.content.summary.margin.values.tokens` — базовое количество жетонов без бонуса, + например `100 ЖЕТОНОВ`; +- `cash_payment.content.summary.margin.values.bonus` — бонус отдельным текстом, например + `+ 50 ЖЕТОНОВ\nВ ПОДАРОК`; если бонуса нет, host выводит `БЕЗ БОНУСА`. + +Не объединяйте рубли и жетоны в одном из этих узлов и не скрывайте `tokens` или `bonus`: Exchanger +заполняет все три значения независимо, а тема полностью определяет их положение и оформление. +Заголовок этой панели — «К ВЫДАЧЕ». + +Экран выбора карточной суммы использует ту же композицию базовых и бонусных жетонов: + +- `card_amount.content.reward_panel.content.value` — обязательный `Label` immutable-контракта по + прежнему пути `SafeMargin/Content/RewardPanel/Content/Value`; host выводит в нём только базовое + количество жетонов; +- `card_amount.content.reward_panel.content.bonus` — supplemental runtime `Label` по пути + `SafeMargin/Content/RewardPanel/Content/Bonus`; host выводит `+ N ЖЕТОНОВ\nВ ПОДАРОК` или + `БЕЗ БОНУСА`. + +`Value` располагается слева, `Bonus` — справа. Supplemental ID намеренно не входит в immutable +JSON-каталог из 481 элемента. Это сохраняет совместимость старых тем: если отдельного `Bonus` нет, +host использует legacy fallback и выводит совмещённый итог в существующем `Value`. Новые темы, +которые проходят текущий Builder Validator, обязаны содержать оба готовых theme-owned `Label`. + +Экран ручного ввода карточной суммы также разделяет платёжные данные: + +- `card_custom_amount.content.keypad_panel.content.input_value` — введённая сумма в рублях без + единиц измерения, например `1000`; это Label внутри поля под заголовком «ВВЕДИТЕ СУММУ»; +- `card_custom_amount.content.amount_panel.margin.values.amount` — базовое количество жетонов; +- `card_custom_amount.content.amount_panel.margin.values.tokens` — бонусные жетоны, например + `+ 50 ЖЕТОНОВ\nВ ПОДАРОК`; при отсутствии бонуса host выводит `БЕЗ БОНУСА`. + +Названия двух последних ID сохранены для совместимости существующих тем. Визуально оба верхних +Label относятся только к жетонам, располагаются слева и справа как на CashPayment; рубли выводятся +исключительно через `input_value`. + +Динамические коллекции представлены фиксированными слотами: + +- 16 кнопок `card_amount...preset_grid.slot_00..15`; +- 16 строк бонусов `settings...bonus_panel...rows.slot_00..15`; +- 16 строк готовых сумм `settings...preset_rows.slot_00..15`; +- 12 строк промо-роликов `settings...advertisement_playlist...rows.slot_00..11`. + +Ещё 57 биндингов относятся к двум theme-owned экранным клавиатурам SETTINGS: + +- текстовая панель с 33 фиксированными буквенными слотами в строках `12 + 11 + 10`; +- переключатели языка, регистра и спецсимволов, пробел, очистка, удаление и перемещение каретки; +- цифровая панель с десятью цифрами, очисткой и удалением; +- отдельная кнопка `СКРЫТЬ` в правом верхнем углу каждой панели. + +Обе панели существуют в production-сцене заранее и имеют `visible = false`. Exchanger может +показывать одну панель, менять текст/видимость отдельных клавиш и подключать `pressed`, но не +добавляет клавиши и не меняет их расположение. Все кнопки обязаны иметь `focus_mode = None`, +чтобы нажатие экранной клавиши не забирало фокус у редактируемого поля. + +Ещё 14 биндингов образуют theme-owned интерфейс учёта жетонов в SETTINGS: + +- кнопка вкладки `settings.scroll.content.section_tabs.service_button`; +- скрытая панель `settings.scroll.content.service_inventory_panel` с остатком, статусом и + действиями автоматического пересчёта/ручного добавления; +- два Label `...hopper1_label` и `...hopper2_label`, в которых host показывает точный остаток + соответствующего хоппера рядом с его названием; +- скрытый overlay `settings.service_dialog_overlay` с динамическими заголовком и описанием, + редактируемым `SpinBox amount`, отменой и подтверждением. + +Обе темы обязаны заранее содержать эту панель и диалог. Exchanger может менять значения, +`Visible`/`Disabled` и подключать сигналы, но не создаёт диалог, поля или кнопки и не изменяет +их геометрию. Значение `amount` не имеет текстового suffix, чтобы не обрезать многозначные числа. + +Помимо этих 14 каталогизированных ID, Builder требует в сервисной панели три +supplemental runtime-binding. `settings.scroll.content.service_inventory_panel.margin.content.description` +— это `Label` по каноническому пути +`SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Description`; host обновляет +его описание в зависимости от режима учёта. +`settings.scroll.content.service_inventory_panel.margin.content.inventory_enabled` — это +`CheckButton` по каноническому пути +`SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/InventoryAccountingEnabled`, +расположенный сразу после `Description`, с подписью «УЧИТЫВАТЬ ОСТАТОК ЖЕТОНОВ» и включённым +состоянием по умолчанию. Сразу после него расположен включённый по умолчанию `CheckButton` +`settings.scroll.content.service_inventory_panel.margin.content.purchase_limit_enabled` по каноническому +пути `SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/PurchaseLimitEnabled` с +подписью «НЕ ПРОДАВАТЬ БОЛЬШЕ ОСТАТКА». Host подключает к готовым переключателям сохранение +режима учёта и ограничения продаж доступным остатком; создавать эти узлы в runtime нельзя. +Supplemental ID не входят в `contracts/screen_bindings.v2.json`, поэтому +число 481 и байтовый SHA-256 каталога не изменяются. + +Каждая строка целиком создана дизайнером и имеет binding root для переключения `Visible`, а её +поля, кнопка удаления и Label ошибки размечены отдельными индексированными ID. Неиспользуемые +слоты скрыты и поэтому не занимают место в Container. Для пустых коллекций предусмотрены +theme-owned `empty_state` Label. + +## Как читать overlay + +В preview включите `BINDINGS: ON`. Оранжевая рамка показывает геометрию размеченного узла, тёмная +подпись — точный ID, который увидит Exchanger. Подпись корня начинается с `screen.`. Наложение +подписей допустимо на плотных экранах и не влияет на PCK; для оценки композиции выключите overlay. + +На вкладке SETTINGS кнопка `KEYBOARD: OFF/TEXT/NUM` последовательно показывает скрытое, +текстовое и цифровое состояния. Это только диагностическое переключение `Visible` уже +существующих theme-owned панелей; preview не добавляет клавиатуру в production-сцену. +Кнопка `INVENTORY: OFF/PANEL/DIALOG` аналогично показывает готовую сервисную панель и диалог, +скрывая на время просмотра прочие секции SETTINGS и не меняя их дерево или геометрию. + +Если рамка отсутствует, у узла нет metadata. Если одна рамка неожиданно покрывает большую область, +binding назначен контейнеру, а не ожидаемому дочернему контролу — сверьте `Types` и `FallbackPath` +в JSON. + +## Синхронизация без рассинхронизации + +Обычная разработка темы не требует соседнего Exchanger: + +```powershell +.\tools\Test-PackagedBindingContract.ps1 +``` + +При согласованном изменении C# host используйте точную папку Exchanger: + +```powershell +.\tools\Sync-HostBindingContract.ps1 -HostProject ..\exchanger +.\tools\Import-HostSceneTemplates.ps1 -HostProject ..\exchanger -ThemeIds example +.\tools\Sync-HostBindingContract.ps1 -HostProject ..\exchanger -Check +.\tools\Test-PackagedBindingContract.ps1 +``` + +`Sync-HostBindingContract.ps1` генерирует каталог из вызовов `GetNode(SafeMargin/...)` host и +копирует его во все packages. `Import-HostSceneTemplates.ps1` перезаписывает `package/scenes` у +указанных тем: не запускайте его для оформленной темы без резервной копии/осознанного плана +переноса дизайна. После миграции валидируйте `example` и каждую тему, затем обновите закреплённый +SHA-256 и тесты в Exchanger в рамках отдельной host-задачи. + +Нельзя вручную менять или переформатировать package-копию JSON: байтовый хеш изменится, даже если +смысл данных сохранится. diff --git a/docs/COMPOSITE_PET_ANIMATIONS.md b/docs/COMPOSITE_PET_ANIMATIONS.md new file mode 100644 index 0000000..67ff76f --- /dev/null +++ b/docs/COMPOSITE_PET_ANIMATIONS.md @@ -0,0 +1,347 @@ +# Композитные анимации питомца: гайд для дизайнера + +Композитное действие — это не один клип, а сценарий из нескольких анимаций, визуальных +преобразований и перемещений. Сценарий хранится в теме, поэтому каждый питомец может вести себя +по-своему, а Exchanger только предоставляет безопасные операции для экранной позиции, границ и +опор. + +Рабочий пример находится в сцене +[`interactive_pet.tscn`](../themes/space-pixel/package/components/interactive_pet.tscn) и скрипте +[`interactive_pet.gd`](../themes/space-pixel/package/scripts/interactive_pet.gd) темы +`space-pixel`. Нейтральный API полностью описан в [SCRIPTING.md](SCRIPTING.md), а разметка опор — +в [BINDING_CONTRACT.md](BINDING_CONTRACT.md). + +## Что делает дизайнер, а что делает приложение + +Тема определяет: + +- набор и имена кадровых анимаций; +- порядок клипов и число повторов; +- поворот персонажа и изменение масштаба его визуальной части; +- момент отключения обычной навигации, контактов с опорами, границ и пользовательского ввода; +- перемещение экранной точки контакта и выбор момента посадки; +- восстановление визуального состояния после завершения или отмены. + +Exchanger определяет: + +- уникальный `run_id` каждого запуска; +- фактическую экранную точку контакта питомца; +- границы текущего экрана и доступные опоры; +- применение политик движения; +- отмену устаревшего запуска при смене экрана, drag, выгрузке темы или конфликтующем действии. + +Композитное действие не получает доступ к внутренним узлам Exchanger. Оно работает только со +своей сценой и переданным объектом `runtime`. + +## 1. Подготовьте отдельные клипы + +Сначала каждый этап должен корректно проигрываться отдельно во вкладке `PET`. Для действия +инопланетянина `space-pixel` используются: + +| Этап | Клип | Режим | +| --- | --- | --- | +| Поворот из левой ориентации | `turn_left` | один цикл | +| Поворот из правой ориентации | `turn_right` | один цикл | +| Приближение | `come_closer` | один цикл | +| Основное действие | `action` | повторяемый | +| Падение | `falling` | повторяемый | +| Приземление | `landing` | один цикл | +| Ожидание | `idle` | зацикленный | + +Имена клипов принадлежат теме и могут отличаться. Приложение видит публичное действие +`interact`, а тему интересует, из каких собственных клипов оно состоит. + +Те же прямые анимации `landing` и `idle` используются host и вне composite-сценария: после +обычного `falling` при отпускании питомца или смене экрана host проигрывает одноразовый `landing`, +затем включает `idle`. Запрос `celebrate`, поступивший во время такого приземления, выполняется +после завершения `landing`. + +Перед сборкой сценария проверьте: + +- все ячейки sprite-sheet имеют одинаковый размер; +- точка контакта лап находится в одном месте каждого кадра; +- прозрачные поля не меняют видимый масштаб персонажа между кадрами; +- one-shot клипы не зациклены, а повторяемые клипы могут корректно начинаться заново; +- `get_pet_animation_frame_count()` и `get_pet_animation_frames_per_second()` возвращают реальные + значения: по ним вычисляется длительность цикла; +- все текстуры импортированы с nearest-фильтрацией, если тема использует pixel art. + +## 2. Отделите экранную позицию от визуального масштаба + +Рекомендуемая структура сцены: + +```text +InteractivePet (Node2D, позиционируется приложением) +└── VisualRoot (Node2D, pivot в точке контакта с опорой) + └── Sprite (AnimatedSprite2D или другой визуальный узел) +``` + +Root `InteractivePet` не масштабируется композитным сценарием: его положение связано с экранной +точкой контакта. Меняйте `VisualRoot.scale`, чтобы приближение не сдвигало лапы с опоры. В +`space-pixel` `VisualRoot.position` совпадает с `ground_offset`, то есть масштабирование происходит +вокруг точки под ногами персонажа. + +После успешного завершения и любой отмены возвращайте `VisualRoot.scale = Vector2.ONE`. Если тема +вводит дополнительные эффекты, таким же образом восстановите rotation, modulate, material и +временную видимость дочерних узлов. + +## 3. Подключите питомца к manifest + +В `themes//package/manifest.json` укажите production-сцену: + +```json +"InteractivePetScene": "res://exchanger_theme_dlc/components/interactive_pet.tscn" +``` + +Путь должен быть каноническим и находиться внутри `res://exchanger_theme_dlc/`. Сама сцена, скрипт, +sprite-sheet, звук и другие зависимости должны лежать внутри `themes//package/`. + +## 4. Реализуйте базовый animation API + +Композитный контракт расширяет обычного питомца, поэтому сначала должны работать базовые методы и +сигналы: + +```gdscript +signal pet_animation_started(animation_name: StringName) +signal pet_animation_completed(animation_name: StringName) + +func play_pet_animation(animation_name: StringName) -> bool: + return false + +func pause_pet_animation() -> void: + pass + +func stop_pet_animation() -> void: + pass + +func get_pet_animation_names() -> Array[StringName]: + return [] + +func get_pet_animation_frame_count(animation_name: StringName) -> int: + return 0 + +func has_pet_animation(animation_name: StringName) -> bool: + return false + +func get_current_pet_animation() -> StringName: + return &"" + +func get_current_pet_frame() -> int: + return 0 +``` + +Практически всегда также нужны +`get_pet_animation_frames_per_second()` и `get_pet_ground_offset()`. Имена анимаций, которые +host запрашивает, совпадают с именами, возвращаемыми `get_pet_animation_names()`. Полный список +и допустимые возвращаемые значения приведены в +[SCRIPTING.md](SCRIPTING.md#нейтральный-контракт-интерактивного-питомца). + +## 5. Добавьте композитный API целиком + +Нельзя реализовать только часть интерфейса. Валидатор требует сразу два сигнала и три метода: + +```gdscript +signal pet_composite_action_started(action_name: StringName, run_id: int) +signal pet_composite_action_finished( + action_name: StringName, + run_id: int, + completed: bool +) + +func has_pet_composite_action(action_name: StringName) -> bool: + return action_name == &"interact" + +func start_pet_composite_action( + action_name: StringName, + runtime: Object, + parameters: Dictionary +) -> int: + # Проверка, принятие запуска и запуск корутины темы. + return 0 + +func cancel_pet_composite_action(run_id: int, reason: StringName) -> void: + # Остановка только того запуска, которому принадлежит run_id. + pass +``` + +При принятии запуска `start_pet_composite_action()` обязан вернуть ровно +`int(runtime.call(&"GetRunId"))`; при отказе — `0`. После принятия испустите `started`, сохраните +`run_id` и runtime, затем запустите корутину через `call_deferred()`. Так метод старта не блокирует +Exchanger. + +Минимальное состояние одного активного запуска: + +```gdscript +var _composite_run_id := 0 +var _composite_action := &"" +var _composite_runtime: Object + +func _is_composite_active(run_id: int, runtime: Object) -> bool: + return ( + run_id == _composite_run_id + and runtime == _composite_runtime + and is_instance_valid(runtime) + and bool(runtime.call(&"IsActive")) + ) +``` + +Проверяйте `_is_composite_active()` сразу после каждого `await`. Старая корутина не должна менять +кадр, scale или экранную позицию после отзыва runtime. + +## 6. Составьте раскадровку действия + +До написания кода удобно заполнить таблицу сценария. Для `space-pixel/interact` она выглядит так: + +| Шаг | Что происходит | Motion policy | +| --- | --- | --- | +| 1 | Обычная навигация и ввод приостанавливаются | `suspended / ignore / allow / false` | +| 2 | По предыдущей ориентации проигрывается `turn_left` или `turn_right` | без изменения | +| 3 | `come_closer`, одновременно `VisualRoot` растёт от ×1 до ×2 | без изменения | +| 4 | `action` повторяется `action_repetitions` раз | без изменения | +| 5 | Выбирается самая высокая доступная опора и вычисляется длина падения | без изменения | +| 6 | `falling` идёт вниз, персонаж один раз проходит через низ и появляется сверху | `theme_driven / after_vertical_wrap / wrap_vertical_once / false` | +| 7 | После wrap разрешается посадка только на выбранную опору | та же политика | +| 8 | Scale возвращается к ×1, проигрываются `landing`, затем `idle` | завершение запуска | + +Поля policy записываются в порядке `navigation / support_contacts / screen_bounds / +input_enabled`. Вызов выглядит так: + +Для синхронного приближения доведите `VisualRoot.scale` до конечного значения уже к началу +последнего кадра `come_closer`. Последний кадр должен быть показан хотя бы один render-frame в +полном масштабе, и только после этого можно запускать `action`. Если интерполировать scale до +самого конца общей длительности клипа и сразу переключить анимацию, финальный масштаб формально +будет вычислен, но пользователь не увидит его до начала следующего клипа. + +```gdscript +var accepted := bool(runtime.call(&"SetMotionPolicy", { + "navigation": "suspended", + "support_contacts": "ignore", + "screen_bounds": "allow", + "input_enabled": false, +})) +``` + +Для падения `space-pixel` дополнительно передаёт `landing_surface_id` выбранной опоры и +`wrap_margin`. Все допустимые значения перечислены в +[SCRIPTING.md](SCRIPTING.md#составные-действия-питомца). + +## 7. Управляйте движением через anchor + +`runtime.GetAnchor()` возвращает экранную точку контакта, а не позицию root сцены. Для движения +используйте `MoveAnchor(delta)`, а не меняйте `position` питомца напрямую. Основные операции: + +| Вызов | Назначение | +| --- | --- | +| `GetViewportRect()` | получить границы экрана | +| `GetAnchor()` | получить текущую точку контакта | +| `GetHighestSupport()` | выбрать верхнюю доступную опору | +| `SetMotionPolicy(policy)` | временно изменить навигацию, контакты, границы и ввод | +| `MoveAnchor(delta)` | переместить точку контакта с применением policy | +| `SetAnchor(anchor)` | установить точку явно, например выровнять X после wrap | +| `LandOnSupport(surface_id)` | завершить движение точной посадкой на существующую опору | + +Каждый изменяющий вызов возвращает результат. Проверяйте `accepted`; у `MoveAnchor()` также читайте +`wrapped`, `landed`, `surface_id` и фактический `anchor`. Не считайте, что запрошенное перемещение +обязательно было применено без изменений. + +Для равномерного перемещения вызывайте `MoveAnchor()` после каждого `process_frame` и умножайте +скорость на `delta`. Количество повторов падения можно вычислить так: + +```text +полная длина = путь от текущего anchor до низа + путь от верха до выбранной опоры +число циклов = ceil(полная длина / расстояние_за_один_цикл) +время = число циклов × кадры_клипа / fps +скорость = полная длина / время +``` + +`falling_distance_per_cycle` — художественный параметр темы: он задаёт, насколько быстро один +визуальный цикл должен продвигать персонажа по экрану. + +Для этапа приближения эталонный питомец предоставляет ещё три параметра темы: + +- `approach_visual_scale` — итоговый масштаб (`2.0` для инопланетянина); +- `approach_scale_completion_ratio` — доля длительности `come_closer`, за которую масштаб должен + полностью достигнуть целевого значения; значение `0.75` оставляет заключительную четверть клипа + уже в полном масштабе; +- `approach_hold_seconds` — короткая выдержка полного масштаба после завершения `come_closer` и до + первого кадра `action`; по умолчанию `0.15` секунды. + +Не доводите масштаб до цели только на последнем кадре: формально размер успеет измениться, но +переход визуально сольётся с началом `action`. Последние кадры приближения должны явно проиграться +при окончательном масштабе, а следующий клип — начаться только после выдержки. + +## 8. Учитывайте параметры запуска + +Для `interact` сейчас используются два параметра: + +- `action_repetitions` — число повторов основного действия; ограничьте его разумным диапазоном, + например `1..16`; +- `reduced_effects` — режим уменьшенных эффектов. В `space-pixel` он пропускает приближение и + проход через границу, но сохраняет `action`, `landing` и возврат в `idle`. + +Не требуйте наличия ключа: используйте `parameters.get("имя", значение_по_умолчанию)`. Неизвестные +поля игнорируйте, чтобы новые версии приложения не ломали старую тему. + +## 9. Корректно завершайте и отменяйте действие + +Успешное завершение: + +1. Убедитесь, что run всё ещё активен. +2. Верните временные визуальные изменения. +3. Перейдите в стабильный клип, обычно `idle`. +4. Очистите сохранённые `run_id` и runtime. +5. Испустите `pet_composite_action_finished(action_name, run_id, true)`. + +При ошибке собственной последовательности испустите `finished(..., false)`. При вызове +`cancel_pet_composite_action()` немедленно очистите состояние и визуальные изменения; повторно +обращаться к runtime после отмены нельзя. Эталонная реализация намеренно не испускает второй +`finished`, если отмена уже инициирована host: владельцем окончательного lifecycle остаётся один +активный запуск. + +## 10. Разметьте опоры экранов + +Если manifest содержит `InteractivePetScene`, текущий Builder требует navigation v1 во всех восьми +production-сценах, кроме `screen.settings`. На каждом экране должны быть: + +- ровно один `Node2D` с `exchanger_pet_navigation_root = true`; +- положительные `exchanger_pet_roaming_bounds` и существующий `default_surface_id`; +- хотя бы одна горизонтальная поверхность; +- при необходимости — sit points и двунаправленные climb edges. + +Это metadata навигации, а не `exchanger_binding_id`. Подробный список ключей и правила координат +приведены в [BINDING_CONTRACT.md](BINDING_CONTRACT.md#навигация-питомца). + +## 11. Проверьте результат + +1. Активируйте тему: `./activate-theme.ps1 -ThemeId `. +2. Запустите preview и откройте вкладку `PET`. +3. Проверьте каждый клип отдельно, включая первый и последний кадр. +4. Запустите «СОСТАВНОЕ ДЕЙСТВИЕ: INTERACT» несколько раз подряд. +5. Отмените действие через `Esc`, переход в галерею и повторный запуск. +6. Проверьте обычный и `reduced_effects` сценарии в Exchanger. +7. Выполните: + +```powershell +./tools/Test-PackagedBindingContract.ps1 +./test-theme.ps1 -ThemeId +./build-theme.ps1 -ThemeId +``` + +Готовое действие не дёргает root при смене клипов, не оставляет увеличенный `VisualRoot`, не +реагирует на ввод во время запрещённого этапа, не цепляется за промежуточные опоры и после +приземления стабильно возвращается в `idle`. + +## Частые ошибки + +- **Валидатор сообщает о partial composite API.** Добавлена только часть из двух сигналов и трёх + методов — реализуйте весь набор или удалите его целиком. +- **Персонаж смещается при увеличении.** Pivot `VisualRoot` расположен не в точке контакта лап. +- **Старая анимация продолжает работать после перехода.** После `await` не проверены `run_id` и + `runtime.IsActive()`. +- **Питомец цепляется за окна во время падения.** Слишком рано включены `solid`-контакты; для + сценария с проходом через экран используйте `after_vertical_wrap`. +- **Питомец исчезает и не возвращается.** Не проверены `wrapped`/`landed`, неверен + `landing_surface_id` или поверхность лежит вне roaming bounds. +- **Действие имеет неверную скорость.** Число кадров или FPS в API не совпадает с фактическим + `SpriteFrames`. +- **В PCK нет скрипта или текстуры.** Зависимость лежит в `source/`, а не внутри `package/`. diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md new file mode 100644 index 0000000..748955a --- /dev/null +++ b/docs/PROJECT_CONTEXT.md @@ -0,0 +1,724 @@ +# Контекст проекта и handoff + +Дата актуализации: 14 сентября 2026 года. + +## Текущее состояние + +- Builder работает на Godot 4.7.1, главное preview — `tools/preview/preview.tscn`, размер + `720×1280`. +- Реализован полный формат `ExchangerThemeDlc` v2: 9 production-сцен, 481 обязательный immutable + element-binding из JSON и 24 обязательных supplemental runtime-binding, проверяемых Builder + отдельно. Четыре optional ID (тексты баннеров, выбор промо-изображения и переключатель + питомца) сохраняют обратную совместимость; полностью обновлённая тема содержит 509 + element-binding; + metadata корней экранов и навигации питомца считаются отдельно. +- `themes/example` — валидная тема-шаблон; `themes/space-pixel` — текущая оформленная тема. +- В SETTINGS `space-pixel` кнопка добавления промо-ролика использует явный кегль `17 px`. + Благодаря этому её минимальная ширина равна `365 px`, панель списка промо не расширяет общий + `VBoxContainer`, а все видимые панели раздела «ПРОМО» занимают стандартную ширину `664 px` в + viewport `720×1280`. Актуальные `build/space-pixel.pck` и `build/active_theme.pck` совпадают: + `1700400` bytes, SHA-256 + `8082656B3F8DBAA74D6FC55659FDE99D636BF4A626BCECA99F93BA8C9123ECF7`. +- `space-pixel` содержит самостоятельный компонент интерактивного питомца + `package/components/interactive_pet.tscn`: 22 анимации, 414 кадров, Nearest-фильтрация и публичный + GDScript-API с прямыми именами анимаций/runtime-профилем. Обычное перемещение исполняет host по + отдельному theme-owned navigation v1 активного экрана; составной `interact` остаётся в теме и + управляет экранной anchor-точкой только через scoped runtime host. +- Optional composite contract позволяет теме задавать последовательность клипов, scale и временные + политики навигации, контактов с опорами, границ и ввода. `space-pixel` выполняет условный поворот, + `come_closer` ×2, повторы `action`, вычисляемый `falling` через низ/верх экрана, `landing` на самой + высокой доступной опоре и `idle`. Любая отмена инвалидирует run-scoped bridge; старые темы + сохраняют одиночный semantic fallback. +- Для автора темы добавлен отдельный дизайнерский гайд `docs/COMPOSITE_PET_ANIMATIONS.md`: от + подготовки клипов и pivot `VisualRoot` до раскадровки policy, работы с anchor, отмены и QA. +- Non-sitting состояния питомца используют нулевую локальную поправку дочернего + `AnimatedSprite2D`. Только `sitting` имеет поправку `(0,24)`, которая опускает его на `20,28` + экранного пикселя при scale `0.845`; root, hit box и `GROUND_OFFSET` не меняются. +- Все восемь non-settings сцен `space-pixel` содержат невидимые metadata-графы питомца: всего 54 + поверхности верхних граней пола/кнопок/окон и 49 climb edges. Settings намеренно остаётся без + графа. Визуальные `Control` не менялись; координаты являются реальными contact points в + `720×1280`. +- Exchanger вызывает прямую анимацию `dancing` темы `space-pixel` + при начале клиентской выдачи и явно проигрывает четыре полных цикла после посадки на Success. +- Обычное падение питомца использует последовательность прямых анимаций + `falling → landing → idle`: это относится и к отпусканию после drag, и к падению со старой позиции + при смене экрана. `landing` является необязательным для старых тем: при отсутствии анимации host сразу + включает `idle`. Запрос `celebrate` во время приземления ждёт завершения `landing`, после чего + запускаются четыре праздничных цикла. +- 7 сентября обновлённый набор исходников питомца синхронизирован в package: добавлены девять + состояний (`come_closer`, `idle`, `landing`, `turn_left/right`, `action` и три + `climbing_action*`), + обновлён `dancing`, а `home`/`cross` включены как runtime-пиктограммы. Клип + `walking_finish_right` переименован в `walking_right_stop`, `walking_finish` — в + `walking_left_stop`, а `walking_start` — в `walking_left_start`; имена файлов синхронизированы в + архиве, source и package. Актуальные `space-pixel.pck` и Builder `active_theme.pck` совпадают: + `1658512` bytes, SHA-256 + `76518563591159EC4B8087911D2BB29924BC29C12AAA4FF1544D97ED01648E91`. +- Preview показывает реальные production-сцены, переключает все экраны, отображает binding overlay + первой кнопкой слева и поддерживает live reload с сохранением текущей вкладки. Он не назначает + сценам Theme/фон/decoration и не добавляет демонстрационные дочерние узлы. +- Кнопка `PET` основной галереи открывает отдельный `tools/preview/interactive_pet_preview.tscn`: + viewer динамически загружает `InteractivePetScene` активной темы, строит кнопки из её API и + автоматически вписывает персонажа в stage с масштабом до `4×`. Отдельная кнопка запускает + составной `interact` через тестовый runtime с viewport и верхней опорой. Preview tooling не + входит в PCK. +- Все 18 production-сцен `example` и `space-pixel` самодостаточны: root применяет package Theme, + содержит `ThemeBackground`/`BackgroundDecoration` и metadata + `exchanger_self_contained_visuals = true`. +- Home, CardAmount и Settings содержат готовые theme-owned runtime-узлы: 16 кнопок сумм, + 16 строк бонусов, 16 строк готовых сумм и 12 строк промо. Host не должен изменять визуальное + дерево ThemeView. +- Итоговые панели CashPayment, CardAmount и CardCustomAmount имеют общий заголовок «К ВЫДАЧЕ» и + раздельные theme-owned Label базовых жетонов слева и бонуса справа. В CardAmount исходный + immutable-binding `...reward_panel.content.value` сохранён для базы, а отдельный Bonus использует + supplemental runtime-binding `card_amount.content.reward_panel.content.bonus`; старые темы без + него поддерживаются host legacy fallback. +- В SETTINGS обеих тем заранее находятся скрытые theme-owned текстовая и цифровая клавиатуры: + 33 буквенных слота, переключатели языка/символов/регистра, действия редактирования и цифры 0–9. +- SETTINGS обеих тем содержит готовый раздел «Сервис» для учёта жетонов и скрытый диалог + пересчёта/ручного добавления. Их 14 binding-id входят в неизменяемый контракт, включая + отдельные Label точного остатка hopper 1/2. Три обязательных supplemental binding не входят + в JSON-каталог 481 элемента: Label `...service_inventory_panel.margin.content.description` + позволяет host менять описание по режиму учёта, а `CheckButton` + `...service_inventory_panel.margin.content.inventory_enabled` включает учёт остатка по умолчанию, а + `CheckButton` `...service_inventory_panel.margin.content.purchase_limit_enabled` по умолчанию запрещает + продажу сверх учтённого остатка; + host только обновляет готовые theme-owned узлы и подписывает сигналы. +- Полный supplemental-срез состоит из 24 обязательных ID: 6 Home, 7 кнопок навигации «Главная», + 1 CardAmount и 10 Settings. Кнопки навигации добавлены на все экраны, кроме Home и Settings: + на экранах с Back они находятся справа от неё, а на Success/Error/ServiceAccess занимают её + стандартную позицию. Возврат отменяет незавершённую оплату; на Success кнопка недоступна во + время выдачи. Основа кнопки — `KeypadButton`: в `space-pixel` это готовый + `assets/ui/button_square.png` с наложенной `interactive_pet/home.png`, а `example` рисует + встроенную пиктограмму дома без дополнительного ассета. + Optional `home.decoration.custom_text_block` объединяет готовые речевую плашку и персонажа + справа, когда они есть в дизайне; `home.decoration.right_character` сохранён как legacy + fallback для старых тем. Канонический перечень типов и ID находится в + `docs/BINDING_CONTRACT.md`. +- Встроенный EditorPlugin `Exchanger Theme Manager` добавляет в меню `Редактор` команды + `Выбрать тему`, `Создать тему`, `Экспортировать тему` и `Применить тему к Exchanger`. + Экспорт сначала предлагает любую валидную тему по ID, затем открывает стандартный save-dialog и + асинхронно собирает её в выбранный PCK без переключения preview и установки. Выбор, создание, + экспорт и применение показывают этапы и процент в общем окне прогресса; применение устанавливает + активную тему в стандартный пользовательский каталог. Все каталоги тем постоянно видны в Godot + FileSystem и не содержат `.gdignore`. +- Сборка создаёт `build/.pck` и `build/active_theme.pck`; установка в Exchanger выполняется + только явным `-InstallToDefaultUserPath`. +- Постоянного `exchanger_theme_dlc/` в корне больше нет. Preview читает активный package напрямую + по `build/active-theme.json`; каноническая папка создаётся только во временном изолированном + workspace на время validator/export и затем удаляется. + +## Последняя подтверждённая проверка + +Актуальные подтверждённые проверки: + +- 11 сентября 2026 года после проверки исправленного PCK в Exchanger только сидящий питомец + оставался выше нужной позиции. `sitting` получил отдельный visual offset `(0,24)`, который + опускает его на `20,28` экранного пикселя при scale `0.845`; non-sitting offset остаётся + нулевым. `GROUND_OFFSET=(64,96)`, `VisualRoot`, навигационные точки, Exchanger host и + binding-контракт не менялись. Contract check, validator, pet verifier, preview smoke и build + прошли; живой PET-preview `720×1280` подтвердил current offset `(0,24)` и отсутствие clipping. + `build/space-pixel.pck` и `build/active_theme.pck` совпадают: `1700352` bytes, + SHA-256 `931A2EAA118722B95E49EA097C8CE41CE9E655F2CF7248CCA050B28A9AF50357`; +- 11 сентября 2026 года устранено завышенное положение питомца на поверхностях: в + `interactive_pet.gd` non-sitting visual offset изменён с `(0,-24)` на `(0,0)`. При runtime + scale `0.845` прежняя поправка поднимала видимый спрайт на `20,28` экранного пикселя, хотя host + сохранял корректный anchor. `GROUND_OFFSET=(64,96)`, `VisualRoot`, навигационные точки и + binding-контракт не менялись. Contract check, validator, pet verifier, preview smoke и build + прошли; `build/space-pixel.pck` и `build/active_theme.pck` совпадают: `1700256` bytes, + SHA-256 `C1872763DA443190FA3CA2D281E1599B8C4F772CF290A19F3E4E580CAFE2336F`; +- 10 сентября 2026 года composite-действие `interact` в `space-pixel` синхронизировано по + финальной фазе приближения: `VisualRoot` достигает масштаба ×2 за первые `75%` клипа + `come_closer`, сохраняет его на заключительной четверти клипа и ещё `0.15` секунды после + последнего кадра, и только затем запускает `action`. Точечный runtime-тест PET-preview + зафиксировал scale `2.0` уже на кадре 27, на финальном кадре 35 и в начале `action`; +- 10 сентября 2026 года устранена линейная фильтрация содержимого `space-pixel` в Exchanger при + физическом разрешении `1080×1920`: корни всех девяти self-contained production-сцен теперь + явно задают `texture_filter = 1` (`Nearest`). Раньше nearest был задан фону и отдельным + изображениям, а Builder дополнительно маскировал проблему глобальной настройкой проекта; + текст и прочие наследующие элементы в host могли получать линейный фильтр. Binding-каталог и + host-контракт не изменялись. Валидатор теперь требует Nearest на каждом production-root, если + manifest задаёт `UseNearestTextureFilter: true`. Все девять вкладок с binding overlay проверены + в живом preview `720×1280`; contract check, validator/preview smoke и build прошли. Итоговые + `build/space-pixel.pck` и `build/active_theme.pck` после финальной composite-правки имеют размер + `1698608` bytes и SHA-256 + `4F65DA0758C4234BA752F112C50830010D0914E1EC5FCD76FD70D913614B45A3`; +- 9 сентября 2026 года экранные заголовки `space-pixel` уменьшены: общий `ScreenTitle` использует + `36 px` вместо `42 px`, а заголовок ServiceAccess — `30 px` вместо `34 px`. Блок техподдержки + в собственном footer Home и общем `components/footer.tscn` сдвинут на `32 px` влево + (`x = 341 px`) без изменения размеров, binding-id и runtime-форматирования телефона. Все девять + вкладок просмотрены в живом preview `720×1280`: заголовки и номер не обрезаются, блок поддержки + не пересекается с логотипом. Contract check, validator/preview smoke и build прошли; + `build/space-pixel.pck` и `build/active_theme.pck` совпадают: `1697968` bytes, SHA-256 + `628A7D5BDFFB03DF1DEAF835069C243FCC1FFF8D6AA39850B937A336EB0F6417`; + +- 9 сентября 2026 года добавлены четыре compatible optional supplemental binding: `cash_payment.content.banner_text` + (`Label`) для подписи нижней анимированной плашки CashPayment и + `settings.scroll.content.animated_banner_texts_panel` (`Control`) для готовой панели настройки + всех четырёх нижних баннеров. Immutable JSON-каталог сохраняет 481 binding и прежний SHA-256; + полностью обновлённая тема содержит 509 element-binding, но старый PCK валиден и получает + отсутствующие optional-узлы из встроенной темы. Добавлены также + `settings.scroll.content.advertisement_poster_panel.margin.content.select_button` (`Button`) для + системного выбора изображения промо и + `settings.scroll.content.menu_visibility_panel.margin.content.show_interactive_pet` + (`CheckButton`) для отключения theme-owned интерактивного питомца. + `Sync-HostBindingContract -Check`, packaged contract для example/space-pixel и `test-theme` + обеих тем прошли. + +- 9 сентября 2026 года добавлена supplemental-навигация `home_navigation_button` для + CashPayment, CardAmount, CardCustomAmount, CardTerminal, Success, Error и ServiceAccess. + Immutable JSON-каталог не менялся: по-прежнему 481 binding и SHA-256 + `DDDC89118BEEA78A820561464001656136ED42AC1AD5710FB109D25ADAF44143`; с supplemental-слоем + на момент этой поставки каждая новая тема содержала 505 обязательных binding. В `space-pixel` расширены верхние + поверхности Back и добавлены три поверхности/перехода навигации, поэтому граф теперь имеет + 54 поверхности и 49 climb edges. `Sync-HostBindingContract -Check`, + `Test-PackagedBindingContract.ps1`, `test-theme example`, `test-theme space-pixel` и + build/install прошли. Установленный PCK Exchanger: `1664336` bytes, SHA-256 + `E2CCD76C0374CE3B6285371DF4113511C39C22BCB131D4D3A10F5B391AFBA0A1`. + +- 8 сентября 2026 года настройка Home «ПОКАЗЫВАТЬ ПОЛЬЗОВАТЕЛЬСКИЙ ТЕКСТ» получила optional + binding `home.decoration.custom_text_block`: в `space-pixel` это готовая группа речевой плашки + и правого персонажа, поэтому host может скрыть её целиком вместе с обязательным Label текста, + не перестраивая ThemeView. Старый optional `home.decoration.right_character` сохранён для + выпущенных тем. Визуальные координаты и ассеты не изменялись; скрипт покачивания использует + новый вложенный путь персонажа. Contract copies (481 immutable binding) не менялись; + `Test-PackagedBindingContract.ps1`, `test-theme example`, `test-theme space-pixel`, packaged + pet verifier (22 анимации, 414 кадров, 51 поверхность, 46 переходов) и build/install прошли. + Установленный PCK Exchanger: `1658288` bytes, SHA-256 + `4DD492DA3B181B86B291D7930F400B1F8B660658EC1AECBC519266C4EFD6B642`; + +- 8 сентября 2026 года документация и валидируемый semantic action list синхронизированы с + обычным host-flow `drop → landing → idle`. `landing` добавлен в generic Theme Validator и + PCK/source pet verifier: если тема возвращает для него непустое имя, оно обязано входить в + `get_pet_animation_names()`, при этом старые темы с пустым mapping остаются допустимыми. + Документы фиксируют одинаковое поведение после drag и смены экрана, а также очередь + `celebrate` до завершения landing. `space-pixel` уже содержал корректные ресурсы и mapping: + `landing` — one-shot на 11 кадров при 24 fps, `idle` — зацикленный на 19 кадров при 24 fps; + production-сцены, sprite-sheet, биндинги и PCK не изменялись. Contract check, `test-theme` + для `example` и `space-pixel`, а также verifier существующего PCK прошли; verifier подтвердил + 22 анимации, 414 кадров, неподвижный root, 51 поверхность и 46 climb edges. Builder PCK-копии + остались байт-в-байт одинаковыми: `1658512` bytes, SHA-256 + `76518563591159EC4B8087911D2BB29924BC29C12AAA4FF1544D97ED01648E91`; + +- 7 сентября 2026 года синхронизация с текущим Exchanger исправлена без изменения immutable + контракта: `Sync-HostBindingContract.ps1` явно исключает все 17 supplemental runtime-binding из + генерации JSON, даже когда host обращается к их каноническому пути через буквальный `GetNode`. + `Test-PackagedBindingContract.ps1` защищает от попадания в каталог полный набор 17 ID, а не + только прежние четыре. `Sync-HostBindingContract -Check` и проверка package-копий проходят: + 9 экранов, 481 binding, SHA-256 + `DDDC89118BEEA78A820561464001656136ED42AC1AD5710FB109D25ADAF44143`; production-сцены и PCK + этой правкой не изменялись. Свежий PCK отдельно прошёл в Exchanger полный pet-smoke и + исправленный payment-format smoke на mock-оборудовании без сохранения пользовательских Settings. + +- 7 сентября 2026 года три клипа ходьбы `space-pixel` переименованы сквозным образом: + `walking_finish_right → walking_right_stop`, `walking_finish → walking_left_stop` и + `walking_start → walking_left_start`. Обновлены sprite-sheet в `themes_src`, theme source и + package, `SpriteFrames`, export-поля, списки кадров/FPS/one-shot, facing-наборы, preview enum, + документация и semantic mapping `walk_start_left`/`walk_finish_left`/`walk_finish_right`. + `walking_start_right` завершает симметричную пару стартовых клипов. Старые четыре имени и файла + отсутствуют. Принудительный + Godot import, contract check, Theme Validator, pet verifier (`22` анимации, `414` кадров, + `51` поверхность, `46` climb edges), preview smoke, build и PCK smoke (`9` экранов, + `23` scripted instances) прошли. `build/space-pixel.pck` и `build/active_theme.pck` совпадают: + `1658512` bytes, SHA-256 + `76518563591159EC4B8087911D2BB29924BC29C12AAA4FF1544D97ED01648E91`. Пользовательская копия + Exchanger не перезаписывалась; + +- 7 сентября 2026 года реализован optional composite contract интерактивного питомца между + `space-pixel` и Exchanger. Theme-owned `interact` выполняет условный поворот, `come_closer` со + scale `VisualRoot` до ×2, заданное число `action`, вычисляет полный путь `falling` через нижнюю и + верхнюю границы, игнорирует опоры до wrap, приземляется на самую высокую доступную поверхность, + проигрывает `landing` и возвращается в `idle`. Exchanger предоставляет только scoped runtime + для anchor/viewport/поверхностей и политик, автоматически отзывает его при смене экрана/темы, + drag и конфликтующих действиях; прежний semantic fallback сохранён. PET-preview получил отдельную + кнопку и реальный запуск Godot завершил сценарий в `idle` без ошибок. Contract-синхронизация + подтвердила 2 темы/481 binding и SHA-256 каталога + `DDDC89118BEEA78A820561464001656136ED42AC1AD5710FB109D25ADAF44143`; `test-theme`, validator, + preview smoke, PCK smoke (`9` экранов, `23` scripted instances), Release-сборка без предупреждений, + NUnit `488/488` (18 аппаратных пропущены) и полный Exchanger `InteractivePetBehaviorSmoke` с + явным PCK/mock прошли. Итоговые `build/space-pixel.pck` и Builder `build/active_theme.pck`: + `1658436` bytes, SHA-256 + `37E86765C7F8DE542F799AF8523241A6EDE3F4DC4C481E28CCAC76D52C807AC6`. Пользовательский каталог + Exchanger не перезаписывался; + +- 7 сентября 2026 года верхняя плашка `CardAmount` темы `space-pixel` приведена к точной + визуальной копии плашки `CashPayment/Summary`: обе используют цельный atlas-region + `panel_medium.png`, `PanelContainer` размером `649×125` в позиции `(36,148)` и одинаковые + трансформации заголовка, базовых жетонов и бонуса. Runtime-инспекция Godot 4.7.1 подтвердила + совпадение global rect всех четырёх видимых элементов; binding-id и канонические пути `Value` и + `Bonus` сохранены. Contract check, validator, interactive-pet verifier, preview smoke, сборка и + PCK smoke (`9` экранов, `23` scripted instances) прошли. `build/space-pixel.pck` и + `build/active_theme.pck` совпадают: `1435772` bytes, SHA-256 + `F505882620719F154319A9CD93208F42ECB59FCC615410827A44DADED8F61404`; + +- 7 сентября 2026 года итоговые панели CashPayment, CardAmount и CardCustomAmount обеих тем + синхронизированы по композиции. Caption CashPayment изменён с «БАЛАНС ЖЕТОНОВ» на «К ВЫДАЧЕ». + CardAmount сохраняет обязательный `Label` Value с ID + `card_amount.content.reward_panel.content.value` по пути + `SafeMargin/Content/RewardPanel/Content/Value` для базовых жетонов слева и содержит отдельный + theme-owned `Label` Bonus справа с supplemental ID + `card_amount.content.reward_panel.content.bonus` по пути + `SafeMargin/Content/RewardPanel/Content/Bonus`; его default — `БЕЗ БОНУСА`, а runtime-формат — + `+ N ЖЕТОНОВ\nВ ПОДАРОК` или `БЕЗ БОНУСА`. CardCustomAmount сохранил контрактные Label Amount и + Tokens на прежних путях, но теперь также размещает базу слева и бонус справа; sample бонуса имеет + явный перенос перед «В ПОДАРОК». Validator проверяет точные типы/пути, порядок, размещение, + default-тексты и CashPayment caption, а contract-test подтверждает сохранность Value и отсутствие + нового supplemental ID в JSON. Visual QA всех трёх экранов обеих production-тем в отдельной + Builder-сессии Godot 4.7.1 при `720×1280` подтвердил полную читаемость, отсутствие визуального + столкновения, clipping и перекрытия соседних панелей/footer. Contract check, `test-theme`, + validator/preview smoke, сборка с `-PreserveActiveTheme` и отдельный PCK smoke прошли; PCK содержат + 9 экранов и 9/23 scripted instances. JSON-контракт остался байт-в-байт прежним: 481 binding, + SHA-256 `DDDC89118BEEA78A820561464001656136ED42AC1AD5710FB109D25ADAF44143`. Новые PCK: + `example.pck` — `464876` bytes, SHA-256 + `02B8505FB7F0EF4F4E9C2905D97E8F8751A1DDE86DDF460AFC958CCE6AF51994`; + `space-pixel.pck` — `1436988` bytes, SHA-256 + `4D9AA00FE2096794331B348916D1766B47A45D9EDFEE60BEAB0E424ED4761A67`. + `active-theme.json`, Builder `active_theme.pck` и пользовательский `active_theme.pck` не + изменились; тема не устанавливалась; + +- 7 сентября 2026 года в SETTINGS → «Сервис» обеих тем Label `Description` + получил supplemental binding-id + `settings.scroll.content.service_inventory_panel.margin.content.description`, чтобы host мог менять + описание по режиму учёта. Сразу после него добавлен theme-owned `CheckButton` + «УЧИТЫВАТЬ ОСТАТОК ЖЕТОНОВ» с supplemental binding-id + `settings.scroll.content.service_inventory_panel.margin.content.inventory_enabled` и + включённым состоянием по умолчанию. Сразу после него добавлен второй theme-owned + `CheckButton` «НЕ ПРОДАВАТЬ БОЛЬШЕ ОСТАТКА» с supplemental binding-id + `settings.scroll.content.service_inventory_panel.margin.content.purchase_limit_enabled`, также + включённый по умолчанию. Он позволяет host ограничивать продажу доступным учтённым остатком. + Минимальная высота `ServiceInventoryPanel` увеличена только на высоту новой строки и штатный + отступ VBox: с `696` до `772` px. Validator проверяет ID, типы, точные пути, порядок обоих + переключателей, тексты и начальные состояния; contract-test отдельно гарантирует, что все три + supplemental ID не попали в immutable JSON. Visual QA обеих production-сцен в отдельной + Builder-сессии Godot 4.7.1 при `720×1280` подтвердил полную видимость обоих переключателей, + hopper-полей и статуса, отсутствие clipping/перекрытий и корректное отделение footer: у + `example` панель имеет фактическую высоту `773` px и зазор до footer `20` px, у `space-pixel` — + `817` px и `47` px соответственно. Contract check, `test-theme`, validator/preview smoke, + сборка с `-PreserveActiveTheme` и PCK smoke обеих тем прошли; PCK содержат 9 + экранов и 9/23 scripted instances. JSON-контракт остался байт-в-байт прежним: + 481 binding, SHA-256 + `DDDC89118BEEA78A820561464001656136ED42AC1AD5710FB109D25ADAF44143`. Новые PCK: + `example.pck` — `464268` bytes, SHA-256 + `AC03334354D285E36A25BFB81D60880F829700F083AF5D728F10AB15C764B63D`; + `space-pixel.pck` — `1436492` bytes, SHA-256 + `166660916913E4766FC91437155BB8B78A405150B3A599CF401CD78A83DFB727`. + `active-theme.json`, Builder `active_theme.pck` и пользовательский `active_theme.pck` не + изменились; тема не устанавливалась; + +- 7 сентября 2026 года `Exchanger Theme Manager` обновлён до `1.3.3`: результат + `JSON.parse_string()` явно объявлен как `Variant`, а результат `Array.pop_back()` приведён к + `Node`. В `theme_manager_plugin.gd` больше нет небезопасного вывода типа из `Variant`; + +- 7 сентября 2026 года в SETTINGS → «Общие» обеих тем визуально удалены строка названия + приложения и панель предпросмотра, а подпись поля покупательской фразы изменена на + «СВОЙ ТЕКСТ • ДО 48 ЗНАКОВ». Все существующие `application_name` и + `branding_preview_panel` binding-узлы сохранены с прежними ID/типами и скрыты; лимит поля + `short_text` остался равен 48. Пояснение раздела больше не упоминает название или предпросмотр. + Visual QA обеих production-сцен в отдельной Builder-сессии Godot 4.7.1 при `720×1280` + подтвердил отсутствие пустых строк, обрезания и перекрытий, а также сохранность фиксированного + footer. Contract check, validator/preview smoke, `test-theme` и build обеих тем с сохранением + active state прошли; PCK smoke подтвердил 9 экранов и 9/23 scripted instances. Контракт остался + байт-в-байт прежним: 481 binding, SHA-256 + `DDDC89118BEEA78A820561464001656136ED42AC1AD5710FB109D25ADAF44143`, поэтому обязательное + правило обновления `docs/BINDING_CONTRACT.md` не применяется. Новые PCK: + `example.pck` — `463324` bytes, SHA-256 + `25E90059652EC26BD1779F7D6761F1CD1248A458F8C93F8E242596D3988FDE9F`; + `space-pixel.pck` — `1435548` bytes, SHA-256 + `E5941A4E7437BA996945E85551714F8B6E11F77A64808C68B29A8B88F66629AB`. + `active_theme.pck` в Builder и пользовательском каталоге Exchanger не изменялись и не + устанавливались; + +- 4 сентября 2026 года runtime scale интерактивного питомца `space-pixel` увеличен ровно на 30% + (`0.65 → 0.845`), а сниженная высота оставлена только для `sitting`: все остальные анимации + получили локальную поправку `(0,-24)`. Contract check, test/validator с переходом + `sitting → non-sitting → sitting`, preview smoke, build, PCK verifier и host + `InteractivePetBehaviorSmoke` с явным PCK прошли. PCK имеет размер + `1435452` bytes и SHA-256 + `390C9E3F83B026304F0E75E5A6AAD4458684BBCDFC847FFB542B6CFB1AC278E9`. Visual QA preview + `720×1280` подтвердил crisp Nearest, отсутствие clipping и пересечения с заголовком для обеих + высот. Пользовательский `active_theme.pck` не устанавливался. +- 3 сентября 2026 года `Exchanger Theme Manager` обновлён до `1.3.1`: JSON-протокол прогресса + между Windows PowerShell 5.1 и Godot использует только ASCII-сообщения и стабильные stage-id, + а все видимые русские названия этапов формируются внутри GDScript. Это исключает mojibake вида + `РџС...` независимо от системной кодовой страницы. Контрольная сборка `example` через + `powershell.exe` завершилась с `Stage=complete`, `Percent=100`, сохранила активной тему + `space-pixel`; package binding contract синхронизирован для обеих тем, SHA-256 + `DDDC89118BEEA78A820561464001656136ED42AC1AD5710FB109D25ADAF44143`. После editor filesystem + scan Godot 4.7.1 новых parser/runtime errors не зарегистрировал; + +- 3 сентября 2026 года `Exchanger Theme Manager` обновлён до `1.3.0`: диалоги выбора, создания, + экспорта и применения получили сведения о теме и этапах, операции показывают общий progress bar, + а экспорт позволяет выбрать любой валидный ThemeId независимо от активного preview. Скрипт + `build-theme.ps1` принимает `-ProgressPath` и публикует JSON-состояние стадий сборки; режим + `-PreserveActiveTheme` не даёт редакторскому экспорту менять активное состояние и его PCK-копию. + Контрольный экспорт неактивной `example` завершился `complete/100%` и сохранил хэши active state + и `active_theme.pck`; PCK smoke подтвердил 9 экранов/9 scripted instances. Повторные contract, + validator, pet verifier, preview smoke и сборка `space-pixel` прошли; её PCK сохранил размер + `1431068` bytes и SHA-256 + `5756191317B0318F1833657573B75A5240E96E3B4B063A7E6012C7165B9A2B7D`. Headless Godot 4.7.1 + подтвердил загрузку плагина и регистрацию команд меню без parser/runtime errors; + +- 3 сентября 2026 года `Exchanger Theme Manager` обновлён до `1.2.0`: добавлена команда + `Редактор → Экспортировать тему` со стандартным `FileDialog`, выбором произвольного `.pck` и + фоновой общей build-задачей. Экспорт не устанавливает тему; команда применения сохранена + отдельно. Аддон загружен в Godot 4.7.1 без parser/runtime errors. Экспорт `space-pixel` через + произвольный `-OutputPath`, validator, pet verifier, общий PCK smoke, preview smoke и contract + check прошли; тестовый PCK содержит 9 экранов/23 scripted instances, имеет размер `1431068` + bytes и SHA-256 `5756191317B0318F1833657573B75A5240E96E3B4B063A7E6012C7165B9A2B7D`; + +- 3 сентября 2026 года `space-pixel` получил theme-owned navigation v1 для интерактивного + питомца во всех восьми non-settings сценах. Невидимые metadata-графы содержат 51 + горизонтальную contact surface и 46 связанных climb edges; Settings намеренно исключён. + Визуальные `Control` и 285 кадров не менялись. Component API сопоставляет semantic + actions с существующими анимациями (`drag = hanging`, `drop = falling`, совместимый + `climb_finish = sitting`) и сообщает scale `0.65`, walk/climb speeds `88/72 px/s`, hit size + `128×128`, ground offset `(64,96)`. Generic validator/verifier + требуют граф у каждой non-settings сцены темы с питомцем и проверяют уникальность IDs, + конечность/границы координат, sit points, ссылки и стыковку climb edges с поверхностями. + Contract SHA-256 `DDDC89118BEEA78A820561464001656136ED42AC1AD5710FB109D25ADAF44143` + не изменился. `test-theme space-pixel`, validator, preview smoke, export и pet verifier прошли: + `13` анимаций, `285` кадров, `51` поверхность, `46` climb edges. Текущие + `build/space-pixel.pck`, `build/active_theme.pck` и установленная Exchanger-копия совпадают: + `1431068` bytes, SHA-256 + `5756191317B0318F1833657573B75A5240E96E3B4B063A7E6012C7165B9A2B7D`. Расширенный host + runtime-smoke с mock-оборудованием подтвердил touch/LMB drag, подъём на Home/CashPayment, + активацию индивидуального графа каждой сцены, сохранение позиции и `falling`-падение при + переходе, скрытие в Settings и возврат с падением. После добавления `celebrate` smoke также + подтвердил очередь до посадки, ровно четыре запуска `dancing` и возврат в `Sitting`; + Exchanger unit-тесты: 419/419; + +- 3 сентября 2026 года feature-level контракт интерактивного питомца нейтрализован: optional + manifest field `InteractivePetScene` указывает на произвольную theme-owned `Node2D`-сцену внутри + package; canonical space-pixel компонент/скрипт и builder tooling переименованы в + `interactive_pet.*`. Viewer больше не инстанцирует space-pixel статически: он загружает + компонент активной темы, строит кнопки из `get_pet_animation_names()` и не обращается к папкам + текстур. Generic verifier используется в `test-theme.ps1` и `build-theme.ps1`, проверяет API, + сигналы, анимации и неподвижность root как в package, так и в PCK. Существующие каталоги + `source/alien_animation` и `package/assets/animations/alien_pet` сохранены без переименования как + внутренняя структура конкретной темы. Legacy `components/alien_pet.tscn` распознаётся только + discovery fallback для старых packages. Contract check, `test-theme example` (pet SKIP), + `test-theme space-pixel`, живой viewer `720×1280`, validator, preview smoke, export и общий PCK + smoke прошли; viewer показал 13 динамических кнопок и 285 кадров без parser/runtime errors. + `build/space-pixel.pck` и `build/active_theme.pck` совпадают: `1388444` bytes, SHA-256 + `BECBD72EDA68B67C5744B4BE642756186876A6D9754E6DF3B025FC78FD0D3C16`; + +- 2 сентября 2026 года `Exchanger Theme Manager` обновлён до `1.1.0`: команда + `Редактор → Применить тему к Exchanger` запускает validator/export/install активной темы в + фоновом `Thread`, показывает компактные состояния выполнения/ошибки и не блокирует UI редактора. + Аддон успешно загружен Godot 4.7.1 в editor mode. Для `space-pixel` прошли byte-identical + contract check, validator, preview smoke и PCK smoke (`9` сцен, `23` scripted instances). + `build/space-pixel.pck`, `build/active_theme.pck` и установленная пользовательская копия имеют + размер `1388536` bytes и SHA-256 + `D2D4BCF640374B76D8EFF48919EA7CD9A46AC7059DA42A73A630BE73B39C12D4`; + +- 2 сентября 2026 года исходные sprite-sheet из `alien_animation` перенесены в theme-specific + `themes/space-pixel/source/alien_animation` и `package/assets/animations/alien_pet`. Компонент, + позднее переименованный в `interactive_pet.tscn`, реализует 13 состояний без движения корня. + Отдельный viewer всех состояний проверен живым запуском при `720×1280`; он использует тот же + production-компонент и стартует с `sitting`. Contract check, validator, preview smoke, общий + PCK smoke и отдельный pet-PCK smoke прошли. Viewer также проверен переходами `PET` → viewer и + `Esc` → галерея; parser-ошибка вывода типа `initial_name` устранена явным `StringName`. + Package-копия binding-контракта синхронизирована с авторитетным JSON. Собранные + `space-pixel.pck` и `active_theme.pck` совпадают: `1389432` bytes, SHA-256 + `1392F51A12014EB1653F976B252FA55DCC5EFDE53B18563858DEF878CA2ADF3C`; установка в Exchanger + не выполнялась. + +- 1 сентября 2026 года обе Home-сцены получили theme-owned Label + `home.scroll.content.stock_status.approximate_count`: состояние расположено слева, полоска + по центру, приблизительный остаток справа. Builder проверяет этот Label и четыре состояния + полоски как supplemental runtime bindings, не меняя JSON-контракт 477; +- template/example дополнительно приведён к актуальной двуххопперной сервисной панели контракта; + contract check, validator и preview smoke прошли для example и space-pixel; + +- package-копии `example` и `space-pixel` совпали с авторитетным контрактом SHA-256 + `7ABC98D7770782D0EB658E6EC8CD8FA4F504B0F61862C84FD5E3580341FE73F2`; +- каталог содержит 473 биндинга, включая 57 keyboard bindings, 4 `short_text` bindings, 12 + сервисных bindings и + отдельное поле введённой суммы CardCustomAmount; +- обе темы прошли Godot import, validator и headless preview smoke после переноса визуального + chrome внутрь всех production-сцен; +- SETTINGS preview получил диагностическое состояние `KEYBOARD: OFF/TEXT/NUM`, которое меняет + только `Visible` двух уже существующих панелей и позволяет проверить их binding overlay; +- валидатор подтвердил уникальность всех 473 ID, допустимые типы и наличие всех 57 keyboard + bindings в `example` и `space-pixel`; +- Godot EditorFileSystem индексирует `themes/example` и `themes/space-pixel` без `.gdignore` и + без duplicate UID; package-сцены открываются напрямую из `themes//package/scenes/`; +- `space-pixel` успешно собран в PCK; export включил вложенную + `components/on_screen_keyboards.tscn` и прошёл предварительный validator; +- validator требует пустой `suffix` у всех 16 полей количества дополнительных жетонов, чтобы + тема не могла снова отнять у многозначного числа место припиской; +- служебные ряды текстовой клавиатуры `example` и `space-pixel` выровнены по единой высоте + `76 px` и зазорам `12 px`; в `space-pixel` отдельный стиль обрезает прозрачные края + `button_long.png`, стрелки имеют touch-target `88×76 px`, а длинные действия не сжимают текст; +- кнопки скрытия обеих экранных клавиатур сохраняют тип `Button`, прежние binding ID и логику, + но используют крупную центрированную стрелку вниз вместо надписи «СКРЫТЬ»; touch-target каждой + кнопки равен `64×64 px`. `space-pixel` переиспользует `button_down.png`, а шаблон `example` — + собственный кодовый SVG `assets/ui/keyboard_hide.svg`; +- описание приложения `subtitle` остаётся удалённым. Отдельная настройка «Короткий текст» использует + новые ID `home.scroll.content.speech_text` и три `settings...short_text` binding: HOME bubble + переносит и центрирует до 48 символов, SETTINGS содержит поле, ошибку и предпросмотр; +- живой preview через `godot-ai` загрузил 9 вкладок, переключил каждую из них, включил/выключил + binding overlay и не записал ошибок в game/editor logs; +- визуально подтверждены прямое открытие `example`/`space-pixel` scene-файлов, все девять + `space-pixel` вкладок без overlay, портретные `720×1280` и отсутствие внешнего preview chrome; +- через `godot-ai` подтверждены готовые Home stock/promo visuals и восемь видимых CardAmount + preset-кнопок; скрытые слоты 08–15 не создают пустых ячеек; +- в шаблоне `example` экран ручного ввода сохраняет полноразмерную панель `652×768 px` и клавиши + `152×152 px`; в `space-pixel` этот экран приведён к отдельному JPG-референсу: панель занимает + `646×466 px`, цифровые клавиши квадратные `46×46 px`, длинные действия находятся в нижнем ряду; +- на экране сервисного входа обеих тем PIN-клавиатура центрирована, а все двенадцать клавиш + имеют одинаковый квадратный размер `112×112 px`; действия и footer не перекрываются; +- `space-pixel` использует пользовательские `tumbler_on.png` и `tumbler_off.png` для всех + `CheckButton`; ON/OFF проверены в живом SETTINGS при исходном размере `128×64 px`, а все + 12 строк имеют одинаковые внутренние отступы слева и справа по `24 px`; +- все 57 `SpinBox` темы `space-pixel` используют пользовательские стрелки вверх/вниз; runtime-копии + `32×32 px` сохраняют пиксельный вид, отделены от поля промежутком `10 px` и проверены действием + цены `10 → 11 → 10`; у 16 бонусных полей удалён суффикс «жет.», чтобы многозначное количество + помещалось в поле целиком; +- все `LineEdit` (normal/focus/read-only), внутренние текстовые поля `SpinBox` и `OptionButton` + используют растягиваемый фон из видимой области `290×64 px` исходного `summa_bar.png`; +- обе полосы `HSlider` в SETTINGS → «ЗВУК» темы `space-pixel` увеличены по высоте с `14` до + `36 px`, видимый круглый grabber отключён, а заполнение `100%` доходит до правого края; + живой Exchanger подтвердил отсутствие обрезания и наложений в обоих блоках громкости; +- установленный `%APPDATA%/Godot/app_userdata/Exchanger/theme_dlc/active_theme.pck` сверён со + сборкой по SHA-256; после полного перезапуска Exchanger экран «ОПЛАТА» показал новые тумблеры + ON/OFF и квадратные стрелки SpinBox, а runtime-журнал подтвердил активацию `space-pixel`; +- прямой preview и обе команды test/build подтверждены при физически отсутствующем корневом + `exchanger_theme_dlc/`; временные workspaces после завершения команд пусты; +- после пересохранения открытых `ui_theme.tres` и `screen_home.tscn` Godot Editor заменил + переносимые ссылки на builder-only `res://themes/space-pixel/package/...`; относительные пути + восстановлены, после чего изолированный validator снова загрузил все ресурсы без ошибок; +- `build/example.pck` пересобран из актуального package после обновления Home/Settings и контракта: + размер `437356` bytes, SHA-256 + `38C23A7593082462E58F7DE2354013A9E548E2C45D58ACEDD8FC09CA82E38E95`; validator, preview smoke + и PCK smoke прошли, последний инстанцировал 9 экранов и подтвердил 9 scripted node instances; +- `Test-PackagedBindingContract.ps1`, `test-theme example`, `test-theme space-pixel` и + `build-theme space-pixel` прошли после удаления короткого описания. Импорт, полный validator, + headless preview smoke и export PCK завершились без ошибок; +- Theme DLC не является самостоятельным приложением и намеренно не содержит main scene, поэтому + запуск `--main-pack` не применяется для QA. Готовность пакета подтверждают validator/export и + последующая интеграционная проверка в Exchanger; +- Exchanger переведён на full-trust theme runtime: host больше не отклоняет GDScript, runtime-узлы, + размер PCK или прежний decoration budget; 263 unit-теста прошли, 17 hardware-тестов пропущены + из-за отсутствия оборудования. +- Home темы `space-pixel` приведён к референсу + `themes_src/space_pixel/references/photo_2026-08-20_14-09-56.jpg` в исходном `720×1280`: + восстановлены белые bubble-плашки, исходный масштаб и позиции НЛО, панели на `y=338/490`, + билборд, логотип и блок поддержки; обязательные пути и binding-id сохранены. +- Контрольный Godot-render снизил среднюю абсолютную RGB-разницу Home с референсом с `75,81` до + `6,84`; полученная на том этапе сборка имела размер `684216` bytes и SHA-256 + `CD1AB4B914997A369874F7F1E01FCE44B71EEF0BD2297E544D3C3701D809B13B`. +- CashPayment, CardCustomAmount, CardAmount и CardTerminal темы `space-pixel` приведены к + `photo_2026-08-20_14-10-27/36/40/43.jpg` прямым редактированием production-сцен. Сохранены все + обязательные bindings, скрытые CardAmount-слоты 08–15 и исходные bill/coin/card-анимации. + Повторная строгая сверка CardCustomAmount/CardAmount заменила приблизительный panel chrome на + трёхчастные исходные текстуры и локальные StyleBox с раздельной геометрией фона и текста. + Средняя RGB-разница внутри двух основных панелей снизилась с `13,28` до `7,64` и с `15,31` + до `10,88` соответственно; финальные кадры вручную сверены по геометрии, заголовкам, кнопкам, + CTA, bubble и footer. CashPayment сохраняет MAD `13,66`, CardTerminal — MAD `11,58`. +- Runtime CashPayment в Exchanger исправлен под уже существующий тройной binding-контракт: + `balance` получает только рубли, `tokens` — базовое количество жетонов, `bonus` — бонус в + формате `+ N ЖЕТОНОВ\nВ ПОДАРОК` либо `БЕЗ БОНУСА`. Поэтому host больше не стирает + pixel-perfect значения production-сцены и не объединяет рубли с жетонами в одной строке. + Форматирование покрыто отдельными тестами для нулевого баланса и двух бонусных порогов; + на этом этапе полный набор Exchanger прошёл `272` unit-теста, `17` hardware-тестов штатно пропущены. + Production Windows export успешно запустился, загрузил установленную `space-pixel` и завершился + без theme/binding/fallback-ошибок. PCK осталась байт-в-байт прежней: `718536` bytes, + SHA-256 `AEB8DD1199F71F1114D523CF9928FA6743383746A9DE689755131E8CE9F16BAA`. +- CardCustomAmount получил отдельный обязательный binding + `card_custom_amount.content.keypad_panel.content.input_value`: введённая сумма обновляется внутри + `summa_bar`, а верхние два Label показывают только базовые и бонусные жетоны через общий + `PricingPolicy`. Контракт содержит 461 ID и имеет SHA-256 + `EF5C75B467C87107DF070C3885693D69BF57434259EF3F54DB82E5F074172753`. + После расширения контракта прошли `276` unit-тестов, `17` hardware-тестов штатно пропущены; + production export с mock-hardware загрузил внешнюю тему без theme/binding/fallback-ошибок. + Новая установленная PCK: `721192` bytes, SHA-256 + `30A8806709BCA315E1F455FED1F438C5CD377B9ACEC35A249BABDFF12F1888C4`. +- Отдельная настройка «Короткий текст» добавила обязательный HOME Label + `home.scroll.content.speech_text` и три SETTINGS binding с сегментом `short_text`; deprecated + `subtitle` ID не возвращены. Поле ограничено 48 символами, а Label речевой плашки переносит, + центрирует и обрезает текст в пределах авторской композиции. После включения строгого Nearest + кегль HOME bubble увеличен с `12` до `16 px`, межстрочный интервал равен `-2 px`, а область + Label сужена до реальной белой части спрайта (`x=408..590`, `y=62..135`), поэтому короткая + фраза читается крупно и переносится внутри облака. +- После изменения `Test-PackagedBindingContract.ps1`, `test-theme example`, + `test-theme space-pixel`, `build-theme space-pixel` и штатный `verify_theme_pck.gd` прошли. + PCK smoke смонтировал пакет, инстанцировал 9 экранов и нашёл 3 scripted node instances. +- Footer из референса Home с логотипом `Robotic Retailers` и двухстрочным номером техподдержки + присутствует на восьми production-экранах. Семь экранов используют общий + `themes/space-pixel/package/components/footer.tscn`, Home сохраняет собственные обязательные + footer bindings, SETTINGS намеренно остаётся без footer. В `CardCustomAmount` подтверждение + поднято на `12 px`, поэтому его область не пересекается с footer. +- Актуальные `build/space-pixel.pck`, `build/active_theme.pck` и установленный + `%APPDATA%/Godot/app_userdata/Exchanger/theme_dlc/active_theme.pck` байт-в-байт совпадают: + размер `721192` bytes, SHA-256 + `30A8806709BCA315E1F455FED1F438C5CD377B9ACEC35A249BABDFF12F1888C4`. + 27 августа 2026 года после установки выполнен полный перезапуск Exchanger в `720×1280`: + runtime-журнал подтвердил активацию `space-pixel`, theme/resource/fallback-ошибок не было, + актуальные контрольные кадр и видео сохранены как + `build/exchanger-speech-bubble-runtime.png` и `build/exchanger-speech-bubble-runtime.avi`. + Корни всех девяти сцен наследуют `texture_filter = 1`, а production TTF импортируется без + antialiasing, hinting и subpixel positioning, с oversampling `1.0`. Contract, + validator/preview smoke и PCK smoke прошли; последний инстанцировал 9 экранов и нашёл + 10 scripted node instances. + +## Зафиксированные решения + +- Внешняя тема владеет визуальным деревом экранов; Exchanger владеет логикой и безопасностью. +- Интерактивный питомец является optional theme-owned компонентом из `InteractivePetScene`. + Публичный контракт использует только нейтральные pet-методы/сигналы; расположение и имена + текстурных каталогов не входят в контракт и определяются ссылками самой сцены. +- Навигация питомца также theme-owned: обязательные для темы с питомцем v1 `Node2D`-маркеры во + всех non-settings сценах объявляют bounds, горизонтальные поверхности, точки сидения и + связанные вертикальные края только через metadata. Settings намеренно исключён. + Host не вычисляет маршруты из имён/геометрии `Control`; component API сопоставляет semantic + actions с анимациями и сообщает scale `0.845`, скорости `88/72 px/s`, hit size `128×128` и + ground offset `(64,96)`. `GROUND_OFFSET=(64,96)` остаётся host-точкой контакта для всех + состояний; non-sitting анимации используют нулевую поправку дочернего визуала, а `sitting` — + `(0,24)`. Root, navigation и hit box при переключении состояний не меняются. +- Exchanger не добавляет, не удаляет, не переносит и не стилизует `Control` внутри ThemeView; + коллекции реализованы заранее созданными theme-owned слотами. +- Экранные клавиатуры являются теми же theme-owned слотами: host меняет только значения, + `Visible`/`Disabled` и подключает сигналы; создание или перестройка клавиш в runtime запрещены. +- Сервисная панель и её диалог также являются theme-owned слотами. Preview-переключатель + `INVENTORY: OFF/PANEL/DIALOG` меняет только видимость готовых узлов для диагностики. +- 28 августа 2026 года сервисный интерфейс добавлен в `example` и `space-pixel`: 12 новых + binding-слотов довели контракт до 473 элементов (373 на экране Settings), SHA-256 контракта + `7ABC98D7770782D0EB658E6EC8CD8FA4F504B0F61862C84FD5E3580341FE73F2`. + Contract check, validator и preview smoke прошли для обеих тем. Итоговые + `build/space-pixel.pck` и `build/active_theme.pck` имеют размер `730888` bytes и одинаковый + SHA-256 `AD665F4FFC840091E670407D4ADC82463AA269A91D54E181E3639BC8AB8BEF68`. +- 1 сентября 2026 года `space-pixel` использует проектный `button_cancel.png` для всех действий + «ОТМЕНА»/«УДАЛИТЬ». Пролёты НЛО главного экрана пересобраны по `interface.mp4`: большая + композиция проходит экран на `0–4` секундах, малая — на `9,75–13` секундах 14-секундного + цикла; координаты хранятся в системе `1080×1920` и один раз масштабируются production-сценой + до `720×1280`. Оба набора используют `AnimatedSprite2D` с двумя кадрами: большой — `1 fps`, + малый — `1,5 fps`. Старые одиночные пролёты и двойное уменьшение масштаба удалены. Godot preview + визуально проверен на `t=1/2/3/10,5/12/12,5`; contract check, validator и preview smoke прошли. + `build/space-pixel.pck`, `build/active_theme.pck` и установленная пользовательская копия + совпадают: `741032` bytes, SHA-256 + `E97F95F544BC257CB309789FD67585F93FFD1569F5662FE78ACCE7E7D0193D8A`. +- 2 сентября 2026 года фиксированный 14-секундный `AnimationPlayer` пролётов заменён на + `scripts/ufo_flight_controller.gd`. Большой и малый `AnimatedSprite2D` независимо проходят + экран, после случайной паузы разворачиваются через `flip_h` и летят обратно; высота каждого + нового прохода случайна в диапазоне `260–1660` координат исходной сцены и привязана к шагу + `3 px`. Покадровые циклы из двух кадров продолжают работать параллельно с Tween-движением. + Runtime-проверка подтвердила смену направления/высоты и работающий `flip_h`; contract check, + validator, preview smoke и PCK smoke прошли. PCK smoke инстанцировал 9 экранов и 18 scripted + node instances. Собранная, активная и установленная PCK совпадают: `741708` bytes, SHA-256 + `5034C3DB1DA44565ED45F682757C03E44D238ABDB1A0B2947F371A98C80C10BB`. В запущенном Exchanger + скрипт загружен из `res://exchanger_theme_dlc/` во всех девяти decoration-инстансах; Home + runtime подтвердил смену направления `-1 → 1`, высоты `1632 → 885` и активную смену кадров. +- Два референсных НЛО вокруг ценового блока Home получили отдельный theme-owned скрипт + `scripts/home_ufo_bobbing.gd`: левый движется с амплитудой `4 px` и периодом `3,2 с`, правый — + `5 px` и `3,8 с`, в противофазе. Каждый кадр смещения округлён до целого пикселя; базовые + позиции `AlienLeft y=130` и `AlienRight y=83` не изменены. Contract check, validator, preview + smoke и PCK smoke прошли; PCK smoke подтвердил 9 экранов и 19 scripted node instances. + Exchanger runtime загрузил `res://exchanger_theme_dlc/scripts/home_ufo_bobbing.gd` и подтвердил + изменение обеих Y-позиций при Nearest-фильтрации. PCK-копии совпадают: `743376` bytes, + SHA-256 `1646AA10C8DD5EA9AE70FA21040507889FC1719FF26E122464E105E9D2012AFE`. +- Двухкадровый нижний `Banner` экрана Cash больше не меняет видимый размер при переключении. + Исходные PNG `1024×256` сохранены; `scripts/stable_banner_animation.gd` компенсирует различие + их цветных bounds (`263×167` против `257×163`) масштабом и смещением второго кадра. В builder + runtime и в установленной PCK внутри Exchanger оба кадра получили одинаковый экранный Rect2 + `(34.996, 1000.8, 175.421, 111.389)`, autoplay `1 fps` продолжает работать. Contract check, + validator, preview smoke и PCK smoke прошли; PCK smoke подтвердил 9 экранов и 20 scripted node + instances. Собранная, активная и установленная PCK совпадают: `744728` bytes, SHA-256 + `FAD047504C95D14CFB8E999B7F5E4EA800BD3EE5AB4E95F3FA7F2836A4E3E8D5`. +- На экране CardAmount отдельные `ReferenceAlien` и `SelectionBubble` заменены тем же цельным + двухкадровым НЛО-баннером. Обязательный Label + `card_amount.content.selection_label` сохранён поверх белой части баннера, поэтому Exchanger + продолжает подставлять runtime-подсказку без изменения host-контракта. Нормализация кадров + выполняется `scripts/stable_banner_animation.gd`; preview, validator, contract check и PCK smoke + прошли, PCK smoke подтвердил 9 экранов и 21 scripted node instance. Установленный Exchanger + загрузил Banner и скрипт из `res://exchanger_theme_dlc/`. Собранная, активная и установленная + PCK совпадают: `744984` bytes, SHA-256 + `29A3386FB186415A5A2B30F6B9DFFF42BEBC532A8A43FA8809274F3F927B3784`. +- На CardCustomAmount пары `PromoAlien + PromoBubble`, а на CardTerminal + `ReferenceUfo + StatusBubble` заменены цельными двухкадровыми НЛО-баннерами. Обязательные + `card_custom_amount.content.hint` и `card_terminal.content.countdown` сохранены поверх белой + части. Оба экрана визуально проверены отдельно в `720×1280`; contract check, validator, preview + smoke и PCK smoke прошли, PCK smoke подтвердил 9 экранов и 23 scripted node instances. + PCK-копии совпадают: `745864` bytes, SHA-256 + `44E36EBBA126BADC34FD65FFEF78F8E4C7A280D4F56704EBBDCC0F2C63C479B1`. +- 2 сентября 2026 года host-контракт синхронизирован до 479 биндингов: добавлены + `card_terminal.content.top_bar.back_button`, наличный и безналичный таймауты Settings вместо + прежнего общего поля. `example` и `space-pixel` содержат готовые theme-owned элементы; + contract check, validator и preview smoke обеих тем прошли. Новый `space-pixel.pck` установлен + в Exchanger: 747192 bytes, SHA-256 + `9C42625A26B7620662392FD06F681529157EC129E5BB85BF4990254896A877A1`. + Headless runtime Exchanger с mock-оборудованием загрузил тему без binding-ошибок. +- 2 сентября 2026 года сервисные подписи `Hopper1Label` и `Hopper2Label` получили отдельные + binding-id точного остатка. Контракт синхронизирован до 481 биндинга, SHA-256 — + `DDDC89118BEEA78A820561464001656136ED42AC1AD5710FB109D25ADAF44143`. `example` и + `space-pixel` прошли contract check, validator и preview smoke. Панель `space-pixel` визуально + проверена в preview при `720×1280`: обе подписи целиком помещаются в существующие theme-owned + Label и не перекрывают кнопки. Собранные `space-pixel.pck` и `active_theme.pck` совпадают: + `748120` bytes, SHA-256 `B83FBB273588DA450C5A206BFC69C13517048DFD9D2861A2BEB2190BE4239E32`. + Host runtime-smoke с явным путём к PCK и mock-оборудованием прошёл без binding-ошибок; пакет в + пользовательский каталог не устанавливался. +- Preview и Exchanger инстанцируют те же готовые `ScreenScenes`; optional manifest-пути общего + фона/decoration пусты, чтобы host не дублировал встроенные слои сцены. +- Production-тема является доверенной и может исполнять GDScript и содержать собственные + runtime-узлы; manifest и 481 app-binding остаются обязательными. +- Host ищет элементы по metadata, а не NodePath, и сверяет SHA-256 каталога контракта. +- Основной фон статический. Покадровые анимации и НЛО — отдельные спрайты, не живой фон. +- Внутренние зависимости package задаются относительными путями, чтобы исходники всех тем + безопасно индексировались и открывались одновременно. +- Русская терминология интерфейса: «промо» и «промо-ролик». +- Автономная память проекта использует `project: exchanger-theme-dlc-builder`. +- 10 сентября 2026 года причина отсутствия последних визуальных изменений в Exchanger оказалась + не в новой сборке, а в устаревшей установленной копии PCK от 9 сентября. После повторной сборки + и установки `build/active_theme.pck` и пользовательский + `%APPDATA%/Godot/app_userdata/Exchanger/theme_dlc/active_theme.pck` после финальной + composite-правки совпадают: `1698608` bytes, SHA-256 + `4F65DA0758C4234BA752F112C50830010D0914E1EC5FCD76FD70D913614B45A3`. + Exchanger монтирует PCK при запуске, поэтому для применения заменённого файла требуется полностью + закрыть и снова запустить приложение. + +## Известные ограничения + +- Preview не запускает настоящую оплату, выдачу, COM-контроллер и сохранение настроек. Он показывает + начальные значения готовых theme-owned узлов; их реакцию на runtime-данные проверяют в Exchanger. +- Обновление binding-контракта требует доступного исходника совместимой версии Exchanger и + синхронного изменения host hash/tests. +- `themes_src/` и `preview_scenes/` сохранены как архив ранней модели и могут путать поиск; новые + изменения туда не вносятся. +- Фактический TTF идентифицирует себя как `VCR OSD Mono [RUS by Daymarius]`, версия от + 17 августа 2018 года, designer `MrManet`; поля copyright/license/license URL пусты. Оригинальный + автор VCR OSD Mono публично разрешил коммерческое использование, но отдельное разрешение автора + кириллической модификации из первичного источника не найдено. Для внутреннего использования файл + сохранён ради pixel-perfect метрик; перед публичной поставкой нужно получить письменное + подтверждение Daymarius либо заменить шрифт. +- Финальное визуальное совпадение всегда требует ручного просмотра всех экранов; headless smoke + не оценивает композицию. + +## Последнее изменение: переключатель интерактивного питомца + +17 сентября 2026 года готовый `ShowInteractivePet` в Settings обеих production-тем +(`example`, `space-pixel`) получил краткую подпись «ИНТЕРАКТИВНЫЙ ПИТОМЕЦ». Его supplemental +binding ID и тип не менялись. Exchanger делает переключатель недоступным, если активный manifest +не содержит `InteractivePetScene`; сохранённое предпочтение при этом не стирается и применяется +при следующей теме с питомцем. Контракт `BINDING_CONTRACT.md` уточнён; PCK требуется пересобрать +перед установкой в приложение. + +## Ближайшие разумные задачи + +1. Перед публичной поставкой получить письменное разрешение на кириллическую модификацию шрифта + либо заменить её на лицензированную альтернативу; для внутренней установки это не блокирует QA. +2. При изменениях host-контракта выполнить документированную миграцию и повторить runtime QA. +3. Для каждой новой темы начать с `example`, добавить её собственный `source/README.md` и + документировать отличия/лицензии. +4. После визуальных изменений сравнивать preview и реальный Exchanger на одинаковом `720×1280`. + +## Старт для нового ИИ-агента + +1. Полностью прочитать корневой `AGENTS.md`. +2. Выполнить agentmemory recall с project id `exchanger-theme-dlc-builder` по текущей задаче. +3. Проверить `git status`, если папка уже подключена к Git; на момент создания handoff локальная + папка могла ещё не быть инициализирована как отдельный репозиторий. +4. Прочитать профильный документ и `contracts/screen_bindings.v2.json` до изменения сцен. +5. Активировать конкретную тему и работать только в её `source/`/`package/`. +6. Завершить contract check, validator, preview smoke, визуальный QA и сохранение memory. + +Ziva-Godot запрещён. Для Editor используется только `godot-ai`. diff --git a/docs/QA_AND_TROUBLESHOOTING.md b/docs/QA_AND_TROUBLESHOOTING.md new file mode 100644 index 0000000..48f6da1 --- /dev/null +++ b/docs/QA_AND_TROUBLESHOOTING.md @@ -0,0 +1,89 @@ +# QA и решение проблем + +## Автоматический checklist + +```powershell +.\tools\Test-PackagedBindingContract.ps1 +.\test-theme.ps1 -ThemeId example +.\test-theme.ps1 -ThemeId space-pixel +.\build-theme.ps1 -ThemeId space-pixel +godot --headless --path . --script res://tools/verify_interactive_pet.gd -- --theme space-pixel +``` + +Проверяется формат manifest, девять сцен, root names/metadata, 481 immutable и 24 обязательных supplemental +обязательных ID, типы и уникальность, самодостаточные Theme/background/decoration, импорт GDScript +и остальных ресурсов, запуск preview и export PCK. +Если manifest содержит `InteractivePetScene`, `test-theme.ps1` и `build-theme.ps1` также запускают +generic pet verifier. Он проверяет scene/API/сигналы/анимации и неподвижность корня, но не ищет +текстуры по имени папки. + +## Визуальный checklist 720×1280 + +1. Убедитесь, что в меню `Редактор` доступны `Выбрать тему`, `Создать тему`, + `Экспортировать тему` и `Применить тему к Exchanger`, а selector показывает `example` и + `space-pixel`. Экспорт должен позволять выбрать каждый ID независимо от активной темы, затем + предложить путь `.pck`; применение — показать стандартный путь Exchanger. +2. Для выбора, создания, экспорта и применения проверьте окно прогресса: название этапа, процент, + итоговый путь и доступную после завершения кнопку закрытия. +3. Откройте все девять вкладок: HOME, CASH, SUM, KEYPAD, CARD, SUCCESS, ERROR, SERVICE, SETTINGS. +4. С `BINDINGS: ON` убедитесь, что каждый функциональный элемент имеет рамку и осмысленный ID. +5. С `BINDINGS: OFF` проверьте отступы, центрирование изображений, контраст текста, отсутствие + обрезания и перекрытий. +6. Проверьте длинные суммы, сообщения, русские подписи и все внутренние разделы SETTINGS. +7. В SETTINGS переключите `KEYBOARD: OFF → TEXT → NUM`: обе панели должны быть снизу, + не выходить за 720×1280, иметь кнопку скрытия справа сверху и удобные touch-клавиши. +8. С `BINDINGS: ON` проверьте все 57 keyboard ID; при `OFF` клавиатуры не должны занимать место. +9. Переключите `INVENTORY: OFF → PANEL → DIALOG`: проверьте остаток, обе кнопки, статус, + редактируемое количество и подтверждение; с `BINDINGS: ON` должны быть видны все 14 ID. +10. Убедитесь, что фон неподвижен, а движение ограничено отдельными спрайтами. +11. Сохраните `.tscn` или `.tres` при открытой вкладке и подтвердите live reload без сброса вкладки. +12. Просмотрите game/editor logs через `godot-ai`: новые parser/runtime/resource errors недопустимы. +13. Откройте хотя бы одну `.tscn` прямо из `themes//package/scenes/` и сравните с той же вкладкой: + кроме внешней навигации/overlay галерея не должна менять её вид или дерево. + +## Частые ошибки + +### Godot 4.7 не найден + +Запустите Godot Editor, добавьте `godot`/`godot4` в PATH, задайте в текущем терминале +`$env:GODOT4_EXECUTABLE = 'D:\path\to\Godot.exe'` или передайте `-GodotExecutable`. Не указывайте +исполняемый файл MCP-сервера `godot-ai`: это не Godot Editor. + +### Preview показывает не ту тему + +Повторите `activate-theme.ps1 -ThemeId ` и проверьте `build/active-theme.json`: поле +`Package` должно указывать на нужный `themes//package/`. Preview читает его напрямую. + +### Изменение package не появилось + +Preview должен быть запущен после активации. Сохраните файл на диск, подождите около секунды и +проверьте вывод `[Theme Preview]`. Для нового типа ресурса, которого нет в watched extensions, +перезапустите preview или дополните tooling отдельной задачей. + +### Missing/duplicate/wrong type binding + +Найдите ID в `contracts/screen_bindings.v2.json`, проверьте metadata узла и его Godot-класс. +Иерархия/имя дочернего узла не важны; ID, класс и уникальность обязательны. + +### Packaged binding catalog differs + +Не копируйте JSON вручную с изменением кодировки. Если host не менялся, восстановите точную копию +`contracts/screen_bindings.v2.json` в package. Если host изменился, выполните согласованную миграцию +из `BINDING_CONTRACT.md` и обновите Exchanger. + +### Скрипт работает в preview, но не после сборки + +Убедитесь, что `.gd` находится внутри `themes//package/` и прикреплён к экспортируемой сцене +либо загружается из неё. Проверьте относительные пути, parser errors при `test-theme.ps1` и журнал +Exchanger после полного перезапуска. `_init()` может вызываться при предварительной валидации +PackedScene; runtime-инициализацию, которой требуется SceneTree, размещайте в `_ready()`. + +### PCK собрался, но Exchanger использует fallback + +Закройте и перезапустите Exchanger, проверьте путь установки, manifest v2, hash контракта и журнал +ThemeManager. Затем выполните интеграционный checklist из `THEME_INTEGRATION.md`. + +## Критерий готовности + +Автоматические команды успешны, все девять экранов визуально проверены в портретном окне, журнал +чист, PCK содержит только production-ресурсы, изменения и ограничения отражены в docs/agentmemory. diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md new file mode 100644 index 0000000..93606ae --- /dev/null +++ b/docs/QUICK_START.md @@ -0,0 +1,53 @@ +# Быстрый старт из VS Code + +## Требования + +- Windows 10/11; +- Godot 4.7.x с поддержкой .NET (проект не компилирует C#, но использует ту же версию, что host); +- PowerShell 7 или Windows PowerShell 5.1; +- VS Code и рекомендованное расширение `geequlim.godot-tools`. + +Скрипты находят Godot в таком порядке: параметр `-GodotExecutable`, переменная процесса +`GODOT4_EXECUTABLE`/`GODOT_EXECUTABLE`, запущенный Godot Editor, команда `godot` или `godot4` в +`PATH`. Машинно-зависимый путь в `.vscode/settings.json` не хранится. + +## Первое открытие + +1. Откройте именно папку `ExchangerThemeDlcBuilder` в VS Code. +2. Для ИИ-агента первым сообщением попросите прочитать `AGENTS.md` и + `docs/PROJECT_CONTEXT.md`. Агент сам обязан восстановить память проекта. +3. Разрешите установку рекомендованного расширения. +4. Откройте проект в Godot Editor. Аддон `Exchanger Theme Manager` включён по умолчанию. +5. В меню `Редактор` нажмите `Выбрать тему`, выберите `space-pixel` и подтвердите выбор. + +Команда запишет выбранный package в `build/active-theme.json` и откроет главную preview-сцену +прямо из него. `Создать тему` в том же меню создаёт готовую копию `example`, проверяет ID и +сразу выбирает новую тему. После проверки закройте Exchanger и выберите +`Редактор → Применить тему к Exchanger`: аддон соберёт активную тему в фоне и установит PCK. +Если нужен только переносимый файл без установки, выберите `Редактор → Экспортировать тему` и +сначала укажите нужный ID темы (он не обязан быть активным в preview), затем задайте имя `.pck` в +стандартном диалоге сохранения. Все длительные команды показывают этап и процент выполнения. + +## Happy path новой темы + +Предпочтительный интерактивный путь: `Редактор → Создать тему`. Для скриптовой автоматизации: + +```powershell +Copy-Item -LiteralPath .\themes\example -Destination .\themes\my-theme -Recurse +# Отредактируйте ThemeId/DisplayName в themes/my-theme/package/manifest.json +.\activate-theme.ps1 -ThemeId my-theme +.\preview-theme.ps1 -ThemeId my-theme +.\test-theme.ps1 -ThemeId my-theme +.\build-theme.ps1 -ThemeId my-theme +``` + +Каталог `themes/my-theme/package/` сразу виден в Godot FileSystem — редактируйте сцены и ресурсы +там напрямую. Каждая созданная из `example` сцена уже содержит Theme, фон и decoration и при +прямом открытии выглядит так же, как в preview. Preview читает эти же файлы и автоматически +перезагрузит текущую вкладку после их сохранения. + +## Следующее чтение + +- перед изменением сцены — [BINDING_CONTRACT.md](BINDING_CONTRACT.md); +- перед сборкой и установкой — [WORKFLOW.md](WORKFLOW.md); +- при ошибке — [QA_AND_TROUBLESHOOTING.md](QA_AND_TROUBLESHOOTING.md). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2d37592 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,34 @@ +# Документация Exchanger Theme DLC Builder + +Документы разделены по назначению, чтобы человек или новый ИИ-агент мог работать без доступа к +контексту основного проекта. + +## Начать работу + +- [QUICK_START.md](QUICK_START.md) — установка, VS Code, первая активация и happy path. +- [APPLY_THEME.md](APPLY_THEME.md) — пошаговое применение изменённой темы в Exchanger: preview, + проверки, сборка, установка и перезапуск. +- [PROJECT_CONTEXT.md](PROJECT_CONTEXT.md) — текущее состояние, подтверждённые ограничения и + ближайшие задачи; обязательное чтение для нового ИИ-агента. +- [CONTRIBUTING.md](../CONTRIBUTING.md) — правила изменения и checklist перед передачей результата. + +## Понять систему + +- [ARCHITECTURE.md](ARCHITECTURE.md) — границы Theme Builder/Exchanger и дерево каталогов. +- [BINDING_CONTRACT.md](BINDING_CONTRACT.md) — manifest v2, девять сцен, 481 immutable и 24 + supplemental runtime-биндингов, metadata и безопасная синхронизация с host. +- [WORKFLOW.md](WORKFLOW.md) — полный цикл activate → edit → live reload → save → validate → build + → install. + +## Создавать и проверять темы + +- [THEME_AUTHORING.md](THEME_AUTHORING.md) — практические правила визуальной разработки. +- [SCRIPTING.md](SCRIPTING.md) — GDScript, сигналы и произвольная runtime-логика доверенной темы. +- [COMPOSITE_PET_ANIMATIONS.md](COMPOSITE_PET_ANIMATIONS.md) — пошаговая сборка составного + действия питомца для дизайнера. +- [THEME_INTEGRATION.md](THEME_INTEGRATION.md) — установка PCK и runtime-проверка в Exchanger. +- [QA_AND_TROUBLESHOOTING.md](QA_AND_TROUBLESHOOTING.md) — visual QA, команды и типовые ошибки. +- [SPACE_PIXEL.md](SPACE_PIXEL.md) — решения, инвентаризация и ограничения темы `space-pixel`. + +Корневой [README](../README.md) — короткая стартовая страница. Инструкции ИИ находятся только в +[AGENTS.md](../AGENTS.md). diff --git a/docs/SCRIPTING.md b/docs/SCRIPTING.md new file mode 100644 index 0000000..f3f20a7 --- /dev/null +++ b/docs/SCRIPTING.md @@ -0,0 +1,280 @@ +# Скрипты и runtime-логика темы + +Темы Exchanger считаются доверенными. Любая production-сцена или вложенный компонент внутри +`themes//package/` может содержать GDScript и стандартные runtime-узлы Godot. + +## Где хранить скрипты + +Рекомендуемая структура: + +```text +themes//package/ +├── scripts/ GDScript темы +├── components/ переиспользуемые сцены со скриптами +├── scenes/ девять обязательных экранов +└── background_decoration.tscn +``` + +Прикрепляйте скрипт к узлу обычным действием Godot `Attach Script`. Путь должен находиться внутри +`package/`, например: + +```text +res://themes/space-pixel/package/scripts/alien_controller.gd +``` + +В текстовых `.tscn` зависимости храните относительным путём, например +`../scripts/alien_controller.gd` из `scenes/` или `scripts/alien_controller.gd` из корня package. +Во время сборки package переносится в `res://exchanger_theme_dlc/`, поэтому жёсткая builder-ссылка +`res://themes//package/...` не является переносимой. Build-workspace автоматически заменяет +известный package-префикс на runtime namespace, если Godot Editor всё же пересохранил такой путь, +но относительные ссылки остаются предпочтительными и понятнее при переносе темы между проектами. + +## Что разрешено + +- `_ready()`, `_process()` и `_physics_process()`; +- собственные сигналы и подключения сигналов Godot; +- `Tween`, `AnimationPlayer`, `AnimatedSprite2D` и method tracks; +- `Timer`, аудиоплееры, видео, частицы, шейдеры и процедурная графика; +- загрузка ресурсов темы, ввод и остальные API Godot, доступные приложению; +- сетевые и файловые операции стандартными API Godot. + +Builder больше не накладывает лимит на количество узлов, кадров, `AnimationPlayer`, FPS или +`speed_scale`. Exchanger также не применяет decoration budget. + +## Жизненный цикл + +Preview инстанцирует те же production-сцены, которые затем загружает Exchanger. Поэтому скрипт +работает в обоих окружениях без отдельной preview-копии. Изменения `.gd`, `.gdshader`, сцен, +ресурсов, изображений, шрифтов, аудио и OGV отслеживаются live reload preview. + +При проверке контракта PackedScene предварительно инстанцируется, чтобы найти binding metadata. +Из-за этого код `_init()` может выполниться ещё до реального показа экрана. Логику, которой нужен +SceneTree, autoload или готовые дочерние узлы, размещайте в `_ready()`. Освобождайте сигналы и +внешние ссылки в `_exit_tree()`, если скрипт подключается к объектам вне своей сцены. + +Экран создаётся заново при переходе к нему, если так работает текущий host-flow. Не храните +важное постоянное состояние только в полях узла темы: для него нужен app-owned сервис либо явно +выбранное внешнее хранилище. + +## Взаимодействие с Exchanger + +Exchanger продолжает подключать оплату, навигацию, настройки и оборудование к 481 обязательному +узлам через `exchanger_binding_id`. Скрипты темы могут параллельно подключаться к сигналам своих +узлов и менять своё дерево. + +Прямые пути вроде `/root/ThemeManager` или внутренние пути C#-контроллеров технически доступны, +но не являются стабильным контрактом темы. При такой интеграции фиксируйте требуемую версию +Exchanger в документации темы. + +## Пример + +В шаблоне находится скрипт: + +```text +themes/example/package/scripts/decoration_controller.gd +``` + +Он прикреплён к `background_decoration.tscn`, экспортирует параметры и показывает простую +анимацию через `_process()`. Свойство `animation_enabled` выключено по умолчанию; добавьте в +decoration дочерний спрайт и включите его, чтобы увидеть движение. + +В `space-pixel` скрипт `scripts/ufo_flight_controller.gd` управляет независимыми Tween-пролётами +двух `AnimatedSprite2D`. Он не вмешивается в их покадровое воспроизведение: после каждого прохода +спрайт разворачивается, выбирает новую случайную высоту и возвращается с противоположной стороны. +Отдельный `scripts/home_ufo_bobbing.gd` прикреплён к `visuals/home_top.tscn` и добавляет двум +референсным `TextureRect` лёгкое независимое вертикальное покачивание с округлением позиции до +целого пикселя. +Скрипт `scripts/stable_banner_animation.gd` прикреплён к двухкадровым `Banner` экранов Cash, +CardAmount, CardCustomAmount и CardTerminal и компенсирует различие видимых границ PNG через +frame-dependent scale/position, не заменяя и не отключая исходные кадры. + +### Нейтральный контракт интерактивного питомца + +Каждая тема может предоставить собственного питомца через необязательное поле manifest +`InteractivePetScene`. Рекомендуемый runtime-путь — +`res://exchanger_theme_dlc/components/interactive_pet.tscn`, однако сам manifest является +источником истины. Корень сцены наследует `Node2D`; анимационный компонент сам не меняет позицию +root, потому что перемещением управляет host. Он предоставляет методы `play_pet_animation()`, `pause_pet_animation()`, +`stop_pet_animation()`, `get_pet_animation_names()`, `get_pet_animation_frame_count()`, +`has_pet_animation()`, `get_current_pet_animation()` и `get_current_pet_frame()`. Сигналы +`pet_animation_started` и `pet_animation_completed` позволяют приложению строить интерактивное +поведение поверх визуального компонента. + +Viewer дополнительно использует необязательные методы `is_pet_animation_looping()`, +`get_pet_animation_frames_per_second()` и `get_pet_preview_size()`. Без них базовые ручное +переключение, пауза и повтор работают, но viewer не обязан знать длительность цикла или исходный +размер сцены. Список кнопок всегда строится из `get_pet_animation_names()` и не фиксирует имена +или количество состояний. + +Тема также может предоставить пару read-only методов `get_pet_animation_visual_offset(animation_name)` +и `get_pet_visual_offset()`. Если присутствует один из них, verifier требует оба. Первый сообщает +локальную поправку каждой анимации, второй — текущую; viewer использует объединённую область всех +объявленных поправок, чтобы вписать состояния разной высоты в stage без clipping. + +Для runtime-поведения компонент может дополнительно предоставить методы: + +```gdscript +get_pet_action_animation(action_name: StringName) -> StringName +get_pet_runtime_scale() -> float +get_pet_walk_speed() -> float +get_pet_climb_speed() -> float +get_pet_hit_size() -> Vector2 +get_pet_ground_offset() -> Vector2 +``` + +Все они необязательны для обратной совместимости. `get_pet_action_animation()` сопоставляет +нейтральные действия host с фактическими именами клипов конкретной темы. Для полного поведения +используются действия `idle`, `sit`, `interact`, `celebrate`, `walk_left`, `walk_right`, +`walk_start_left`, `walk_start_right`, `walk_finish_left`, `walk_finish_right`, `climb_start`, +`climb_up`, `climb_down`, `climb_finish`, `drag`, `drop` и `landing`. +`hanging` активно только во время удержания питомца. `falling` проигрывается во время падения +после отпускания и при падении со сохранённой позиции после смены экрана. После контакта с +опорой host один раз проигрывает прямую анимацию `landing`, затем переходит в `idle`; старые темы, +у которых `landing` отсутствует в списке анимаций, сохраняют fallback в `idle`. Запрос `dancing`, +поступивший во время `landing`, ставится в очередь до завершения приземления, после чего host явно +перезапускает праздничную анимацию четыре раза. Масштаб и обе скорости задаются положительными конечными числами; +`hit_size` — локальная область захвата пальцем/ЛКМ, а `ground_offset` — локальная точка контакта +лап с поверхностью. Host размещает root в `contact_point - ground_offset * runtime_scale`. + +После завершённой ходьбы актуальный Exchanger выбирает состояние простоя вероятностно: с +вероятностью `35%` запускает `idle`, а с вероятностью `65%` — `sitting`. `idle` удобно +использовать для активного ожидания стоя, а `sitting` — для спокойной посадки персонажа. +`sitting` остаётся необязательной: если её нет в списке анимаций, host использует `idle`, поэтому +ранее собранные темы продолжают работать без изменений. + +### Составные действия питомца + +Если вы собираете последовательность со стороны дизайна, начните с отдельного пошагового гайда +[COMPOSITE_PET_ANIMATIONS.md](COMPOSITE_PET_ANIMATIONS.md). Ниже приведён точный технический +контракт между темой и Exchanger. + +Уникальную последовательность конкретного вида питомца можно полностью оставить в теме. Для +этого компонент реализует весь необязательный контракт целиком: + +```gdscript +signal pet_composite_action_started(action_name: StringName, run_id: int) +signal pet_composite_action_finished(action_name: StringName, run_id: int, completed: bool) + +has_pet_composite_action(action_name: StringName) -> bool +start_pet_composite_action(action_name: StringName, runtime: Object, parameters: Dictionary) -> int +cancel_pet_composite_action(run_id: int, reason: StringName) -> void +``` + +Частичный набор методов или сигналов является ошибкой валидатора. `start_*` возвращает ровно +`runtime.GetRunId()` при принятии запуска и `0` при отказе. Тема испускает `started` после +принятия, а `finished(..., true)` — только после полного завершения. При отмене она прекращает +корутину, сбрасывает локальные визуальные преобразования и больше не вызывает переданный runtime. +Host также инвалидирует runtime при смене экрана, выгрузке темы, drag или конфликтующем действии, +поэтому после каждого `await` нужно проверять `runtime.IsActive()`. + +Переданный scoped runtime предоставляет строго ограниченный интерфейс ко внешнему движению: + +```text +GetRunId() -> int +IsActive() -> bool +GetViewportRect() -> Rect2 +GetAnchor() -> Vector2 +GetHighestSupport() -> Dictionary +SetMotionPolicy(policy: Dictionary) -> bool +SetAnchor(anchor: Vector2) -> Dictionary +MoveAnchor(delta: Vector2) -> Dictionary +LandOnSupport(surface_id: String) -> Dictionary +``` + +`anchor` — экранная точка контакта питомца, а не позиция его root. `GetHighestSupport()` возвращает +`accepted`, `valid`, `surface_id` и `anchor`; верхней считается доступная поверхность с наименьшим +Y, а при равенстве выбирается ближайшая по X. Результат `MoveAnchor()` содержит `accepted`, +фактический `anchor`, `wrapped`, `landed` и `surface_id`. `LandOnSupport()` выполняет явную +финальную привязку к существующей доступной поверхности. + +Поддерживаемые поля `SetMotionPolicy()`: + +- `navigation`: `suspended` запрещает обычную app-owned навигацию, `theme_driven` разрешает теме + перемещать anchor; +- `support_contacts`: `ignore`, `solid` или `after_vertical_wrap`; +- `screen_bounds`: `clamp`, `allow` или `wrap_vertical_once`; +- `input_enabled`: разрешает или запрещает drag/tap во время составного действия; +- `landing_surface_id`: целевая опора для контактов после wrap; +- `wrap_margin`: внешний вертикальный запас в пикселях. + +Это политика host-навигации, а не доступ к внутренним узлам приложения. Тема определяет порядок, +число повторов, визуальный scale и момент смены политики; приложение сохраняет экранную позицию, +проверяет границы/опоры и автоматически отзывает полномочия старого запуска. Неизвестные или старые +темы без этого API продолжают использовать одиночную semantic-анимацию `interact`. + +Маршрут принадлежит не персонажу, а конкретному экрану темы. Поэтому каждая production-сцена, +кроме Settings, объявляет собственный невидимый граф поверхностей и вертикальных переходов через +v1 metadata, описанные в +[BINDING_CONTRACT.md](BINDING_CONTRACT.md). Горизонтальные отрезки могут совпадать с полом, +верхними гранями кнопок и окон; отдельные sit points разрешают питомцу останавливаться и сидеть. +Climb edges связывают поверхности и задают, у какого левого/правого края разрешён подъём. Host не +вычисляет эти места по NodePath и не меняет визуальные `Control` темы. При смене экрана он +сохраняет текущую экранную позицию, включает граф новой сцены и проигрывает `falling` до её default +surface, затем `landing` и `idle`; Settings скрывает питомца, не сбрасывая сохранённую позицию. + +Текстуры, SpriteFrames и внутренняя иерархия не входят в публичный контракт. Сцена может ссылаться +на изображения из любой подпапки собственного `package/`; переименовывать каталог в `alien_pet` +не требуется. Важно лишь, чтобы все зависимости оставались внутри package и использовали +переносимые относительные либо канонические runtime-ссылки. + +Реализация `space-pixel` находится в `components/interactive_pet.tscn`; её скрипт +`scripts/interactive_pet.gd` собирает 22 sprite-sheet и предоставляет следующий собственный набор +анимаций: + +```text +action, climbing_action, climbing_action_start, climbing_action_stop, +climbing_down, climbing_start, climbing_up, come_closer, dancing, falling, hanging, idle, +landing, sitting, turn_left, turn_right, +walking_left, walking_left_start, walking_left_stop, walking_right, +walking_start_right, walking_right_stop +``` + +Через `get_pet_action_animation()` тема сопоставляет эти клипы нейтральным действиям host: `idle` +и `sitting` для ожидания, `action` для обычного действия, `dancing` для выдачи, парные +walking-анимации для ходьбы, +`climbing_start/up/down` для подъёма, `hanging` при удержании/перетаскивании, `falling` при +падении и `landing` для одноразового перехода к `idle` после обычной посадки. Актуальный host +после подъёма или спуска +сразу продолжает маршрут и не запрашивает `climb_finish`. Runtime-профиль рассчитан на сцены +`720×1280`: scale `0.845`, walk speed `88 px/s`, climb speed `72 px/s`, hit size `128×128`, +ground offset `(64,96)`. Для `sitting` визуальная поправка равна `(0,24)`, для каждой другой +анимации — `(0,0)`; root и `GROUND_OFFSET` при переключении не меняются. Поправка `sitting` +опускает только это состояние на `20,28` экранного пикселя при scale `0.845`. Прежняя поправка +`(0,-24)` у non-sitting состояний остаётся удалённой. + +`come_closer`, `landing`, `turn_left`, `turn_right`, `climbing_action_start` и +`climbing_action_stop` являются one-shot-переходами. `idle`, `action` и `climbing_action` +зациклены. `space-pixel` предоставляет составной `interact`: учитывает последнее направление, +проигрывает `turn_left`/`turn_right`, при `come_closer` увеличивает `VisualRoot` до ×2, повторяет +`action`, вычисляет число циклов `falling` по полной длине пути, проходит через нижнюю и верхнюю +границы без контакта с промежуточными опорами, садится на самую высокую доступную поверхность, +проигрывает `landing` и возвращается в `idle`. При `reduced_effects=true` приближение и проход +через границу пропускаются, но действие, посадка и возврат в idle сохраняются. + +Стартовые и завершающие состояния проигрываются один раз; остальные состояния зациклены. Экспорт +`return_to_sitting_after_one_shot` при необходимости автоматически возвращает питомца к `sitting`. +Все состояния можно проверить в отдельной сцене `tools/preview/interactive_pet_preview.tscn` или открыть +кнопкой `PET` в верхней панели основной галереи. Просмотрщик вписывает объединённую область всех +объявленных per-animation visual offsets в Stage; это только tooling и не меняет production-компонент. +Кнопка «СОСТАВНОЕ ДЕЙСТВИЕ: INTERACT» передаёт компоненту тестовый runtime с viewport и верхней +опорой, поэтому полный theme-owned сценарий можно проверить без запуска Exchanger. + +## Проверка и сборка + +```powershell +.\tools\Test-PackagedBindingContract.ps1 +.\test-theme.ps1 -ThemeId +.\build-theme.ps1 -ThemeId +``` + +`test-theme.ps1` импортирует скрипты в изолированном Godot-проекте и обнаруживает parser/resource +errors. Для проверки `_ready()`, сигналов и runtime-поведения дополнительно запустите preview, а +после сборки — Exchanger с установленным PCK. + +Готовый PCK можно отдельно смонтировать, инстанцировать все девять экранов и проверить наличие +прикреплённых скриптов: + +```powershell +godot --headless --path . --script res://tools/verify_theme_pck.gd -- ` + --pack (Resolve-Path .\build\example.pck) --require-scripts +``` diff --git a/docs/SPACE_PIXEL.md b/docs/SPACE_PIXEL.md new file mode 100644 index 0000000..d872c78 --- /dev/null +++ b/docs/SPACE_PIXEL.md @@ -0,0 +1,441 @@ +# Тема `space-pixel` + +## Реализованный вид + +- Статический pixel-art фон `background.png` размером 1080×1920. +- Кириллический pixel-font `VCR OSD Mono Cyr` для Label и Button. +- Зелёная текстура `button_big.png` как масштабируемый StyleBoxTexture для normal, hover, + pressed и disabled-состояний. +- Безопасная тёмно-синяя default-панель для существующих экранов Exchanger и отдельная + `WhitePanel`-вариация с настоящей белой заливкой, зелёной шапкой и тонкой рамкой из исходника. +- Семантические вариации текста, кнопок и панелей, включая `ScreenTitle`, `PanelTitle`, + `PanelText`, `AdvertisementPanel`, `FooterPanel`, `StatusPanel` и `WhitePanel`, для точного + воспроизведения контрастов референса без глобальной перекраски всех Control. +- Экранные заголовки используют общий кегль `36 px`; более компактный заголовок сервисного входа + — `30 px`. Блок техподдержки в Home и общем footer начинается с `x = 341 px`, поэтому обе строки + номера остаются левее правой границы при том же масштабе и не пересекаются с логотипом. +- Decoration-сцена с theme-owned скриптом `scripts/ufo_flight_controller.gd`: два кадра звёзд + и два `AnimatedSprite2D` с независимыми двусторонними пролётами. После выхода за край каждый + НЛО выдерживает случайную паузу, разворачивается через `flip_h` и возвращается на новой + случайной высоте в диапазоне `260–1660` исходной системы `1080×1920`. Высота привязана к шагу + `3 px`, чтобы после масштабирования production-экраном до `720×1280` коэффициентом `2/3` + оставаться целочисленной. Оба спрайта независимо переключают `frame_01.png`/`frame_02.png`; + скорость большого набора — `1 fps`, малого — `1,5 fps`. +- Два НЛО ценового блока Home в `visuals/home_top.tscn` сохраняют референсные базовые позиции, + но независимо покачиваются вверх-вниз через `scripts/home_ufo_bobbing.gd`: амплитуда слева + `4 px`, справа `5 px`, периоды `3,2` и `3,8` секунды. Смещение округляется до целого пикселя, + поэтому Nearest-фильтрация остаётся чёткой. +- Двухкадровый нижний баннер экранов Cash, CardAmount, CardCustomAmount и CardTerminal сохраняет + оба исходных кадра, но + `scripts/stable_banner_animation.gd` компенсирует различие их внутренней геометрии. Для + `frame_02` корректируются масштаб и опорная точка, поэтому видимая цветная область обоих + кадров занимает один `Rect2(34.996, 1000.8, 175.421, 111.389)` в координатах экрана. +- Красная текстура `button_cancel.png` используется через variation `CancelButton` для действий + «ОТМЕНА» и через `DangerButton` для всех кнопок «УДАЛИТЬ». +- Nearest-фильтрация в preview и decoration-сцене. Manifest `space-pixel` задаёт + `UseNearestTextureFilter: true`, поэтому Exchanger включает nearest только для этой DLC. +- Корни всех девяти production-сцен также явно задают `texture_filter = 1`. Это необходимо для + self-contained режима: Exchanger не переопределяет визуальные параметры такой сцены, а весь + текст и вложенные изображения наследуют Nearest непосредственно от theme-owned root даже при + физическом разрешении `1080×1920` и коэффициенте масштабирования `1,5`. + Интерполяционное размытие при этом устраняется; из-за дробного коэффициента отдельные исходные + пиксели неизбежно занимают разное число физических пикселей. + +## Сравнение с визуальными референсами + +Полностью просмотрены восемь JPG и 64 последовательных кадра `interface.mp4` с интервалом +0,5 секунды. Фото и видео находятся только в локальном, игнорируемом Git каталоге +`themes/space-pixel/source/references/` и никогда не экспортируются в PCK. + +Подтверждённые паттерны интерфейса: + +- фон всегда остаётся одним статическим космическим изображением; +- экранный заголовок белый, крупный и расположен сверху; кнопка `НАЗАД` — слева от него; +- информационные поверхности белые, с зелёной шапкой и тонкой зелёной окантовкой; +- между зелёной шапкой и содержимым сохраняется видимый белый интервал 16–20 px; в текущих + preview-сценах ему соответствует `separation = 28` с учётом внутренних полей текстуры панели; +- заголовки панелей и основной текст — тёмно-синие/почти чёрные; +- основное действие — одна широкая зелёная CTA-кнопка, вспомогательные действия компактнее; +- пинпад ручного ввода занимает основную рабочую область экрана и состоит из двенадцати + одинаковых квадратных клавиш `152×152 px` в сетке `3×4` с зазором `12 px`; +- PIN-клавиатура сервисного входа использует тот же квадратный визуальный стиль: двенадцать + клавиш `112×112 px` в центрированной сетке `3×4` с зазором `12 px`; +- иерархия читается сверху вниз: заголовок → баланс/состав выдачи → инструкция/выбор → действие; +- нижняя зона бренда и технической поддержки постоянна и отделена от основного действия; +- пришелец, НЛО и speech bubble занимают свободные зоны, но не закрывают функциональный контент; +- покадровые bill/coin/card/result-анимации принадлежат конкретным состояниям экрана, а не фону. +- экранные изображения центрируются по внутренней белой области после полей `WhitePanel`, поэтому + их координата не обязана совпадать с геометрическим центром внешней рамки панели. + +Home воспроизводит `themes_src/space_pixel/references/photo_2026-08-20_14-09-56.jpg` в его +исходном размере `720×1280`. Bubble-плашки, оба НЛО, панели наличия/оплаты, кнопки, билборд, +логотип и поддержка выставлены по измеренным пиксельным координатам; исходные PNG масштабируются +nearest-фильтром без подмены на приблизительные `StyleBoxFlat`. Скрипты `home_reference_label.gd` +и `home_stock_presenter.gd` сохраняют референсный формат цены/телефона и переключают индикатор +`МНОГО`/`МАЛО`, не меняя app-owned binding-id. + +Четыре транзакционных экрана также собраны непосредственно по JPG `720×1280`, без внешнего +preview chrome и без подмены их дерева галереей: + +- `screen_cash_payment.tscn` соответствует `photo_2026-08-20_14-10-27.jpg`: восстановлены + две балансные карточки, панель инструкции с исходной bill/coin-анимацией, широкая кнопка выдачи, + нижний НЛО, речевая плашка и footer; +- `screen_card_custom_amount.tscn` соответствует `photo_2026-08-20_14-10-36.jpg`: верхняя карточка + выдачи и панель ручного ввода имеют измеренные границы, клавиши `46×46 px`, а действия + `ОТМЕНА`/`0`/`OK` находятся в том же нижнем ряду, что и на фото; +- `screen_card_amount.tscn` соответствует `photo_2026-08-20_14-10-40.jpg`: показаны восемь + референсных сумм в двух колонках, отдельная кнопка «ДРУГАЯ СУММА», CTA подтверждения и нижняя + композиция с НЛО, bubble и footer; обязательные слоты 08–15 сохранены скрытыми; +- `screen_card_terminal.tscn` соответствует `photo_2026-08-20_14-10-43.jpg`: карточная анимация + остаётся внутри production-сцены, под ней расположена кнопка отмены и исходная нижняя композиция. + +Обязательные binding ID и типы узлов во всех четырёх сценах сохранены. Контрольные прямые рендеры +сделаны из самих production `.tscn`, а не из вручную собранного preview-макета. + +Последовательность видео подтверждает длительный home-loop с НЛО, cash-инструкцию с чередованием +кадров купюры/монеты, возврат на home, выбор карточной суммы, card-terminal loop и финальную +анимацию неуспешной оплаты. + +`tools/preview/preview.tscn` является переключаемой галереей в 720×1280. Девять production-сцен в +`themes/space-pixel/package/scenes/` показывают все текущие `ScreenId`: Home, cash-screen, выбор карточной суммы, +ручной ввод, терминал, успешную и неуспешную оплату, сервисный вход и настройки. SETTINGS содержит +восемь внутренних разделов и демонстрирует LineEdit, SpinBox, OptionButton, CheckButton, HSlider +и полосы прокрутки. Bill/coin/card/success/failed-анимации, кнопки, белые карточки, безопасные зоны +декора и footer принадлежат самим production-сценам. Галерея ничего не добавляет в их дерево и +только переключает сцены; live reload сохраняет основную вкладку и раздел SETTINGS. + +Отдельная builder-сцена `tools/preview/interactive_pet_preview.tscn` открывается кнопкой `PET` +основной галереи и динамически загружает `InteractivePetScene` активной темы. Для `space-pixel` +это `components/interactive_pet.tscn`, показанный в масштабе до `4×`. Все 22 анимации получают +кнопки из публичного списка компонента; также работают предыдущая/следующая, пауза, повтор, +счётчик кадров и автопоказ полного цикла каждого состояния. `Esc` и кнопка «← ГАЛЕРЕЯ» возвращают +к девяти production-экранам. Сцена и её `InteractivePetPreview.gd` являются только builder tooling +и в PCK не экспортируются. Кнопка `PET` недоступна для активной темы без питомца. + +Runtime-профиль питомца для сцен `720×1280`: scale `0.845`, ходьба `88 px/s`, подъём `72 px/s`, +touch/ЛКМ hit size `128×128`, точка лап `(64,96)`. Host вызывает реальные имена анимаций +напрямую: `idle`, `sitting`, `action`, `dancing`, все парные walking-анимации и +`climbing_start/up/down`; +в composite-действии `interact` увеличение до ×2 завершается за первые `75%` клипа +`come_closer`; заключительная четверть показывается в полном масштабе, затем выдерживается ещё +`0.15` секунды до запуска первого цикла `action`. +при начале выдачи host проигрывает четыре полных цикла `dancing`. `hanging` проигрывается при +удержании/перетаскивании, `falling` — при падении после отпускания и при смене экрана, а +одноразовый `landing` — после контакта с опорой; затем host включает зацикленный `idle`. Запрос +`celebrate` во время `landing` ждёт окончания приземления и только потом запускает четыре цикла +`dancing`. После завершённой ходьбы host выбирает `idle` с вероятностью `35%` или `sitting` с +вероятностью `65%`. +После подъёма или спуска host сразу продолжает маршрут без отдельной hanging-анимации. +Кадры и исходные каталоги текстур не менялись. + +Non-sitting состояния используют нулевую локальную поправку дочернего спрайта. `sitting` +использует отдельную поправку `(0,24)`, которая опускает только сидящего питомца на `20,28` +экранного пикселя при runtime scale `0.845`. Root, navigation, hit box и общая точка лап +`(64,96)` при переключении состояний не меняются. + +Невидимый `PetNavigation` присутствует во всех восьми production-сценах, кроме Settings, и в +сумме объявляет 51 реальную горизонтальную поверхность и 46 связанных climb edges. Каждый экран +имеет собственные поверхности пола, кнопок и окон; при переключении host сохраняет старую +позицию, переключает граф и опускает питомца на default-floor нового экрана. Settings намеренно +не содержит графа и скрывает питомца. + +В `screen_home.tscn` bounds равен `(32,48,656,1192)`, объявлены восемь поверхностей и десять +climb edges. Default surface — пол +`x=40..680, y=1240`. Питомец может останавливаться на верхних гранях billboard +`x=144..576, y=827`, окна способов оплаты `x=40..680, y=490`, окна остатка +`x=40..680, y=337`, кнопок наличной/безналичной оплаты (`y=550`), ценового окна и речевой +плашки (`y=60/61`). Парные переходы у `x=40` и `x=680` дают подъём по левому/правому краю между +полом, платёжным и верхним окном; дополнительные edges связывают billboard, обе кнопки и верхние +плашки. Узлы маршрута — только `Node2D` с metadata, поэтому они ничего не рисуют и не меняют +геометрию production `Control`. + +## Инвентаризация исходников + +| Группа | Материалы | +| --- | --- | +| Фон | `background.png`, два полноэкранных кадра звёзд | +| Кнопки | big, long, square, back, cancel и стрелки SpinBox вверх/вниз | +| Контейнеры | пять окон, три bubble-панели, billboard | +| Индикаторы | many, less, summa bar и тумблеры ON/OFF | +| Декорации | alien1–alien3, logo | +| Покадровая графика | banner (2), bill (4), coin (3), card (2), payment failed (3), payment success (5), UFO small (2), UFO large (2) | +| Интерактивный питомец | 22 sprite-sheet по сетке `128×128 px`, 414 непустых кадров | +| Шрифт | `VCR OSD Mono Cyr.ttf` | + +Всего в production package используется 75 PNG. `Цвета.txt` пуст, поэтому палитра определена по изображениям: +`#00183D` для fallback-фона, тёмно-синие поверхности/текст, зелёные контуры/кнопки, +фиолетовый focus-акцент и почти белый экранный текст. + +## Разделение runtime и исходников + +- `themes/space-pixel/source/` — сохранённый исходный материал пользователя и references. +- `themes/space-pixel/package/assets/` — копии с ASCII-путями для стабильной сборки PCK. +- `package/scenes/` — девять полноценных сцен с 481 immutable, 24 обязательными и двумя optional supplemental + metadata binding; optional правый персонаж Home также размечен собственным runtime ID. +- `package/components/on_screen_keyboards.tscn` — две оформленные пиксельные экранные + клавиатуры SETTINGS, скрытые по умолчанию и полностью принадлежащие теме. +- `package/components/interactive_pet.tscn` — визуальный компонент интерактивного питомца со всеми 22 + анимациями из `source/alien_animation`; runtime-API находится в + `package/scripts/interactive_pet.gd`. Имена `source/alien_animation` и + `package/assets/animations/alien_pet` сохранены только как theme-specific структура исходных + материалов; generic pet contract не требует таких имён от других тем. +- `package/ui_theme.tres`, decoration, visuals и `manifest.json` — исходник темы DLC v2. +- `tools/preview/` — tooling вне PCK; preview читает `package/` напрямую, без корневого staging. + +В PCK входят активные зависимости темы. Остальные runtime-копии сохранены в builder для +следующего расширения контракта. + +Обновление исходников от 7 сентября добавило `come_closer`, `idle`, `landing`, +`turn_left`, `turn_right`, `action`, `climbing_action_start`, `climbing_action_stop` и +`climbing_action`, +обновило `dancing` и добавило runtime-пиктограммы питомца `home`/`cross`. Собранный PCK содержит +все эти ресурсы; одноимённые дубликаты climbing-action листов сохранены только в `source/`. + +## Runtime-supported и preview-only ресурсы + +В runtime через DLC v2 поддерживаются: + +- базовые стили Godot Control и семантические variations из `ui_theme.tres`; +- один неподвижный фон, встроенный в каждую экранную сцену; +- один инстанс decoration-сцены с тремя покадровыми `AnimatedSprite2D` и theme-owned + GDScript/Tween-контроллером двусторонних пролётов в каждом экране; +- переиспользуемый компонент питомца `components/interactive_pet.tscn`: приложение получает путь + из `InteractivePetScene` manifest, semantic mapping и runtime-профиль; Home предоставляет + theme-owned граф поверхностей/подъёмов, а host исполняет перемещение и touch/ЛКМ drag; +- девять полноценных визуальных сцен, включая ServiceAccess и Settings. + +В блоке запаса Home слева остаётся состояние `МНОГО/ДОСТАТОЧНО/МАЛО`, по центру находится +theme-owned полоска, а справа Label `home.scroll.content.stock_status.approximate_count` +показывает приблизительный остаток: например `нет жетонов`, `<100`, `~100` или `>100`. + +Manifest сохраняет `UiThemePath` как описание package Theme, но его legacy-пути общего фона +и decoration пусты: production-сцены уже содержат эти слои, поэтому preview и Exchanger не должны +монтировать их повторно. + +Manifest `ExchangerThemeDlc` версии 2 использует `ScreenScenes` и временно сохраняет обратно +совместимый словарь `VisualSlots`. +Анимации купюры, монеты, карты и результатов, НЛО главного экрана и рамка промо-блока входят в +`active_theme.pck` и отображаются только в своих контейнерах. Интерактивные элементы и рабочий +текст остаются в Exchanger. + +## Семантическая интеграция с Exchanger + +Все `theme_type_variation`, размеры и отступы назначены внутри production-сцен, включая +фиксированные строки SETTINGS. Host не стилизует ThemeView. Поддерживаемый набор: + +- `ScreenTitle`: `CashPaymentScreen/.../TopBar/Title`, `CardAmountScreen/.../Content/Title`, + `CardCustomAmountScreen/.../TopBar/Title`, `CardTerminalScreen/.../Content/Title`; +- `BackButton`: одноимённые TopBar-кнопки CashPayment, CardAmount и CardCustomAmount; +- `WhitePanel`: Home `PricePanel`, `PaymentPanel` и `TokenStockStatus`; Cash `Summary`, + `BanknotePanel`, `CoinPanel`; CardAmount `AmountPanel`; CardCustom `AmountPanel`; + CardTerminal `InstructionPanel`, `PaymentPanel`; Success `ResultPanel`; Error `ErrorPanel`; +- `PanelTitle`: Home `PricePanel/.../Caption`, `PaymentPanel/.../Title`, TokenStockStatus `Title`; + Cash `Summary/.../Caption`; CardCustom `AmountPanel/.../Caption`; CardTerminal + `PaymentPanel/.../Caption`; Success/Error заголовки внутри result-панелей; +- `PanelText`: значения и описания внутри перечисленных WhitePanel, включая Balance/Tokens/Bonus, + Amount, Instruction, Message и stock State; +- `PrimaryButton`: Cash `DispenseButton`, CardCustom `ConfirmButton`, Success/Error `HomeButton`; +- `CompactButton`: Home `CashButton`/`CardButton`, CardAmount `OtherAmountButton` и динамически + создаваемые preset-кнопки, keypad CardCustom; +- `CancelButton`: CardCustom `ClearButton`, CardTerminal и ServiceAccess `CancelButton`, а также + кнопка отмены theme-owned сервисного диалога SETTINGS; +- `SupportText`: Home `Footer/.../Support` и новый общий footer остальных экранов. +- `ScreenDescription`: пояснения SERVICE и SETTINGS; +- `SectionTabButton`: пять переключателей разделов SETTINGS владельца; +- `SecondaryButton` и `DangerButton`: вторичные и удаляющие действия; +- `FieldLabel`, `StatusText`, `WarningText`, `ErrorText`: формы и состояния служебных экранов. +- `AdvertisementPanel`: промо-блок Home; `FooterPanel`: общий preview-footer; + `StatusPanel`: Home `StockPanel` и компактная панель подключения. + +Кроме variations тема задаёт базовые стили `LineEdit`, `SpinBox`, `OptionButton`, +`CheckButton`, `HSlider`, `VScrollBar` и `HScrollBar`; все такие Control уже находятся +в тематических сценах. + +Runtime-композиция синхронизирована с preview: host использует ту же сетку 720×1280, общий footer, +BackButton и объединённые панели Cash и Card. Кнопки preset/keypad и все фиксированные слоты +оформлены самой темой. SETTINGS намеренно сохраняет только пять вкладок владельца; технические панели +не возвращаются ради старого preview. + +Footer с логотипом `Robotic Retailers` и двухстрочным номером техподдержки занимает нижние +`152 px` и показывается на восьми production-экранах. Home содержит собственную идентичную +композицию с обязательными binding ID, остальные семь экранов подключают +`package/components/footer.tscn`. На SETTINGS footer намеренно отсутствует. + +## Проверки + +- 11 сентября 2026 года по итогам проверки в Exchanger только `sitting` опущен ещё на `24` + локальных пикселя: его visual offset теперь `(0,24)`, то есть `+20,28` экранного пикселя при + scale `0.845`. Все non-sitting анимации сохраняют исправленную нулевую поправку; verifier + проверяет переход `(0,24) → (0,0) → (0,24)` без движения root. Contract check, validator, + pet verifier, preview smoke и build прошли. Живой PET-preview `720×1280` подтвердил + current offset `(0,24)` и отсутствие clipping. `build/space-pixel.pck` и + `build/active_theme.pck` совпадают: `1700352` bytes, SHA-256 + `931A2EAA118722B95E49EA097C8CE41CE9E655F2CF7248CCA050B28A9AF50357`. +- 11 сентября 2026 года удалено ошибочное локальное смещение `(0,-24)` у всех non-sitting + анимаций питомца. Теперь переход `sitting → non-sitting → sitting` сохраняет нулевую поправку, + а положение root и точка лап `(64,96)` не меняются; это опускает прежние поднятые состояния + ровно на `20,28` экранного пикселя при scale `0.845`. Contract check, validator, pet verifier, + preview smoke и build прошли. `build/space-pixel.pck` и `build/active_theme.pck` совпадают: + `1700256` bytes, SHA-256 + `C1872763DA443190FA3CA2D281E1599B8C4F772CF290A19F3E4E580CAFE2336F`. +- 7 сентября 2026 года `CardAmount/RewardPanel` повторяет `CashPayment/Summary` по atlas-region, + размеру `649×125`, позиции `(36,148)` и rect заголовка/базового значения/бонуса. Обязательный + `Value` и supplemental `Bonus` сохранили прежние binding-id и канонические пути. Runtime QA + `720×1280`, contract check, validator/preview smoke, build и PCK smoke прошли; PCK — `1435772` bytes, + SHA-256 `F505882620719F154319A9CD93208F42ECB59FCC615410827A44DADED8F61404`. +- 4 сентября 2026 года scale питомца увеличен ровно на 30% (`0.65 → 0.845`), а сниженная высота + оставлена только для `sitting`; verifier подтвердил переход поправок `(0,0) → (0,-24) → (0,0)` + без смещения root. Contract check, validator, preview smoke, build, PCK verifier и host + `InteractivePetBehaviorSmoke` с явным PCK прошли. + Preview `720×1280` подтвердил отсутствие clipping, пересечения с заголовком и сохранение + Nearest для sitting/non-sitting; PCK — `1435452` bytes, SHA-256 + `390C9E3F83B026304F0E75E5A6AAD4458684BBCDFC847FFB542B6CFB1AC278E9`. +- Godot 4.7.1 импортирует все runtime-файлы без ошибок; pixel-font распознан автоматически. +- В `assets/fonts/vcr_osd_mono_cyr.ttf.import` отключены antialiasing, hinting и subpixel + positioning, oversampling зафиксирован в `1.0`. Все девять корней экранов имеют + `texture_filter = 1`, поэтому Labels, Buttons, LineEdit, SpinBox и прочий текст наследуют + Nearest без покомпонентных настроек. +- У всех текстовых variations отключены тени и обводка кнопок: при масштабировании preview до + 720×1280 символы не смешиваются с полупрозрачной копией. Светлый текст использует чистый белый, + а текст на белых и зелёных поверхностях — контрастный тёмно-синий цвет. +- Все девять вкладок production preview и восемь разделов SETTINGS запускаются без ошибок и визуально + проверены в окне 720×1280. +- В `CardCustomAmount` темы `space-pixel` проверена референсная панель `646×466 px`; все цифровые + клавиши имеют одинаковый квадратный размер `46×46 px`, длинные действия выровнены с нижним + рядом, а содержимое и footer не перекрываются. +- В `ServiceAccess` все двенадцать PIN-клавиш имеют одинаковый размер `112×112 px`; кнопки + `ВОЙТИ`, `ОТМЕНА` и footer остаются видимыми и не перекрываются. +- Нижние служебные ряды текстовой клавиатуры используют отдельный `KeyboardActionButton` без + прозрачных боковых полей исходной текстуры: все кнопки имеют высоту `76 px`, зазоры `12 px`, + стрелки — touch-target `88×76 px`, а `СТЕРЕТЬ`, `ОЧИСТИТЬ` и `ПРОБЕЛ` получили увеличенную + ширину под полные подписи. +- Кнопки скрытия текстовой и цифровой клавиатур остаются обычными `Button` с прежними binding ID, + но вместо надписи используют контрастную стрелку вниз из `button_down.png`. Обе кнопки имеют + квадратную область нажатия `64×64 px`, крупную центрированную пиктограмму и подсказку + «Скрыть клавиатуру». +- Описание приложения `subtitle` остаётся удалённым. Новая отдельная настройка «Короткий текст» + управляет фразой в белой речевой плашке возле НЛО: HOME Label ограничен геометрией bubble, + переносит до 48 символов, центрирует строки и обрезает выход за границы. Для читаемости при + Nearest его кегль увеличен с `12` до `16 px`; Label занимает внутреннюю белую область + `182×73 px`, использует `line_spacing = -2` и переносит текущую фразу на две строки. В SETTINGS + добавлены поле, ошибка и предпросмотр `short_text`; панели увеличены ровно под новый ряд без + пустых мест. +- `CheckButton` использует тёмно-синий текст, крупную зелёную touch-строку и исходные + `tumbler_on.png`/`tumbler_off.png` размером `128×64 px`; в живом SETTINGS проверены оба состояния + без обрезания и растяжения. У всех 12 строк отступ от левого края до текста и от правого края + до тумблера одинаковый — `24 px`. +- Все 57 `SpinBox` используют исходные `button_up.png`/`button_down.png`; их runtime-копии + уменьшены ближайшим соседом с `64×64` до `32×32 px`, поэтому обе квадратные кнопки целиком + помещаются справа от поля. Между полем и кнопками задан единый промежуток `10 px`, а variation + `SpinBoxInnerLineEdit` использует шрифт `18 px`. У 16 полей количества дополнительных жетонов + нет текстового суффикса: вся доступная ширина отведена многозначному числу. В живом SETTINGS + проверено изменение цены `10 → 11 → 10` и отображение бонусных строк. +- Фон всех текстовых полей `LineEdit` (включая focus/read-only), текстовых частей `SpinBox` + и полей выбора `OptionButton` использует видимую область `290×64 px` исходного + `summa_bar.png` через растягиваемый `StyleBoxTexture`. В живом SETTINGS проверены обычные + текстовые и числовые поля без искажения краёв и обрезания текста. +- Обе полосы `HSlider` в разделе SETTINGS → «ЗВУК» имеют высоту `36 px` вместо прежних + `14 px` (примерно в 2,6 раза выше). Видимый круглый grabber отключён; при значении `100%` + заполнение доходит до правого края. В живом Exchanger проверено, что полосы не обрезаются + и не нарушают компоновку соседних элементов. +- Decoration `space-pixel` использует корневой scripted `Node2D` и три `AnimatedSprite2D`. + Звёзды и оба НЛО имеют по два кадра; НЛО воспроизводят собственные покадровые циклы + одновременно с независимыми Tween-траекториями. + Это существенно ниже всех лимитов host. +- Live reload проверен реальным изменением `ScreenTitle/font_sizes/font_size`: новое значение + появилось в запущенной галерее без перезапуска, а активная вкладка сохранилась. +- Фон остаётся статическим; движение ограничено отдельными Sprite2D/AnimatedSprite2D. +- `build-theme.ps1` собирает `build/active_theme.pck`. +- Компонент `interactive_pet.tscn` проверен отдельно в builder и из смонтированного PCK: доступны все + 22 имени, 414 кадров, корректные loop/one-shot флаги и неизменная позиция корня. Sprite использует + Nearest-фильтрацию. Отдельная preview-сцена проверена в живом Godot при `720×1280`: стартовое + состояние `sitting`, все 22 кнопки доступны в прокручиваемом списке. + Итоговые `space-pixel.pck` и `active_theme.pck` совпадают: `1325176` bytes, + SHA-256 `7DB9F2108D478E7CDB77E3A85ACA33C8DD2EC27A68FA2CF61A28BF78B4331595`. +- Exchanger загружает пакет и активирует `space-pixel` вместо fallback-темы. +- После настройки Nearest фактический Exchanger повторно запущен в `720×1280`; контрольный кадр + `build/exchanger-speech-bubble-runtime.png` подтверждает жёсткие пиксельные края глифов и + читаемый перенос короткого текста. Собранная, активная и установленная PCK совпадают: + `721192` bytes, SHA-256 + `30A8806709BCA315E1F455FED1F438C5CD377B9ACEC35A249BABDFF12F1888C4`. +- После этих изменений `Test-PackagedBindingContract.ps1`, `test-theme example`, + `test-theme space-pixel` и `build-theme space-pixel` завершились успешно. +- Pixel-perfect Home QA выполнен прямым рендером production-сцены в `720×1280`: крупные белые + компоненты совпали с координатами JPG, а средняя абсолютная RGB-разница контрольного кадра + уменьшилась с `75,81` до `6,84` (разница включает JPEG-компрессию и rasterization текста). +- Транзакционные референсы проверены тем же прямым рендером `720×1280`: средняя абсолютная + RGB-разница CashPayment уменьшена с `66,66` до `13,66`, CardTerminal — с `59,14` до `11,58`; + после повторной строгой сверки средняя RGB-разница внутри двух основных панелей + CardCustomAmount уменьшена с `13,28` до `7,64`, CardAmount — с `15,31` до `10,88`. + Вручную подтверждены заголовки, текстурные рамки, квадратные клавиши, CTA, исходные анимации + и общий footer на финальных кадрах. Значение выдачи CardAmount совпадает с reference bounding + box полностью; «ДРУГАЯ СУММА» и CTA отличаются не более чем на один пиксель по краю текста. +- CashPayment использует три раздельных обязательных Label из контракта: `Balance` показывает + только рубли, `Tokens` — базовые жетоны, `Bonus` — дополнительные жетоны двумя строками. + Их исходные значения и расположение соответствуют `photo_2026-08-20_14-10-27.jpg`, а Exchanger + обновляет только текст, не меняя геометрию сцены. +- В CardCustomAmount Label `InputValue` внутри `summa_bar` теперь имеет собственный обязательный + binding и показывает фактически набранную сумму. Верхняя панель «К ВЫДАЧЕ» получает только + базовые и бонусные жетоны; host больше не объединяет рубли и жетоны и не оставляет статическое + значение `1000` в поле ввода. +- Актуальные `build/space-pixel.pck` и `build/active_theme.pck` с единым footer, Nearest-шрифтом + и отдельным `InputValue` binding имеют размер `721192` bytes и SHA-256 + `30A8806709BCA315E1F455FED1F438C5CD377B9ACEC35A249BABDFF12F1888C4`. + Штатный PCK smoke смонтировал пакет, инстанцировал девять экранов и подтвердил 10 scripted node + instances. Та же сборка установлена в стандартный каталог Exchanger и подтверждена совпадением + SHA-256. После полного запуска Exchanger в `720×1280` журнал подтвердил активацию `space-pixel` + без theme/resource/fallback-ошибок; актуальный Home-кадр сохранён в + `build/exchanger-speech-bubble-runtime.png`. +- 1 сентября 2026 года красный `button_cancel.png` подтверждён во всех variation + `CancelButton`/`DangerButton`. Пролёты `UfoLarge` и `UfoSmall` повторно измерены по + `interface.mp4`: контрольные позиции проверены на `t=1/2/3/10,5/12/12,5`, лишние старые + одиночные НЛО удалены, двойное уменьшение масштаба устранено. Оба набора остаются покадровыми: + `frame_01.png` и `frame_02.png` подключены к `AnimatedSprite2D` и воспроизводятся параллельно + уже выверенному движению. После + contract check, validator и preview smoke актуальные `build/space-pixel.pck` и + `build/active_theme.pck` совпадают: `741032` bytes, SHA-256 + `E97F95F544BC257CB309789FD67585F93FFD1569F5662FE78ACCE7E7D0193D8A`. + Эта же PCK установлена в `%APPDATA%/Godot/app_userdata/Exchanger/theme_dlc/active_theme.pck`; + runtime-журнал Exchanger подтвердил активацию внешней темы `space-pixel` без fallback и + theme/resource-ошибок, а контрольный кадр `720×1280` — корректную отрисовку Home. +- 2 сентября 2026 года runtime-проверка нового двустороннего контроллера подтвердила смену + направления `1 → -1`, `flip_h`, новую случайную высоту и продолжающуюся смену кадров. + Contract check, validator, preview smoke и PCK smoke прошли; PCK smoke инстанцировал девять + экранов и 18 scripted node instances. `build/space-pixel.pck`, `build/active_theme.pck` и + установленная пользовательская копия совпадают: `741708` bytes, SHA-256 + `5034C3DB1DA44565ED45F682757C03E44D238ABDB1A0B2947F371A98C80C10BB`. Exchanger загрузил + package-скрипт на всех девяти экранах; Home runtime подтвердил обратный пролёт, новую высоту + и продолжающуюся покадровую анимацию без ошибок журнала. +- Два статичных НЛО возле цены дополнены независимым мягким вертикальным покачиванием. Builder + runtime зафиксировал для левого позиции `132 → 133 → 127`, для правого `81 → 88 → 85` при + исходных `y=130/83`; исходная композиция и Nearest-фильтрация сохранены. Contract check, + validator, preview smoke и PCK smoke прошли; PCK smoke подтвердил 9 экранов и 19 scripted node + instances. Собранная, активная и установленная PCK совпадают: `743376` bytes, SHA-256 + `1646AA10C8DD5EA9AE70FA21040507889FC1719FF26E122464E105E9D2012AFE`. Живой Exchanger загрузил + `home_ufo_bobbing.gd` из package и подтвердил движение обоих НЛО без ошибок журнала. +- Для нижнего двухкадрового баннера Cash устранён скачок размера НЛО: исходные цветные области + `263×167` и `257×163 px` нормализуются runtime-трансформацией второго кадра. Принудительная + проверка `frame=0/1` подтвердила полностью одинаковые экранные bounds обоих кадров; autoplay + продолжает переключать изображения с исходной скоростью `1 fps`. +- 7 сентября три клипа ходьбы и их sprite-sheet получили актуальные имена: + `walking_right_stop`, `walking_left_stop` и `walking_left_start`. Semantic actions приложения + остались прежними и теперь сопоставлены новым клипам; `walking_start_right` завершает симметричную + пару стартовых состояний. + Contract check, validator, pet verifier, preview smoke, build и PCK smoke прошли. Итоговые + `build/space-pixel.pck` и `build/active_theme.pck` совпадают: `1658512` bytes, SHA-256 + `76518563591159EC4B8087911D2BB29924BC29C12AAA4FF1544D97ED01648E91`. +- 14 сентября у кнопки «ВЫБРАТЬ И ДОБАВИТЬ ПРОМО-РОЛИК» в SETTINGS задан явный кегль `17 px`. + До исправления inherited-кегль создавал минимальную ширину кнопки `636 px` и растягивал все + видимые панели раздела «ПРОМО» до `742 px`; после исправления минимум кнопки равен `365 px`, + поэтому панели укладываются в стандартные `664 px` между боковыми полями экрана `720×1280`. + Contract check, validator/preview smoke и build прошли. Итоговые `build/space-pixel.pck` и + `build/active_theme.pck` совпадают: `1700400` bytes, SHA-256 + `8082656B3F8DBAA74D6FC55659FDE99D636BF4A626BCECA99F93BA8C9123ECF7`. +- Production-ресурсы используют относительные ссылки внутри `package/`. Если Godot Editor + пересохранил их как `res://themes/space-pixel/package/...`, перед validator/build нужно вернуть + относительные пути, иначе изолированный workspace не найдёт ассеты. + +TTF идентифицирует себя как `VCR OSD Mono [RUS by Daymarius]`, но не содержит copyright/license +metadata. Автор оригинального VCR OSD Mono публично разрешил коммерческое использование; для +кириллической модификации первичное разрешение не найдено. Текущий файл допустим для внутренней +темы, но перед публичным распространением требуется письменное подтверждение Daymarius либо замена. diff --git a/docs/THEME_AUTHORING.md b/docs/THEME_AUTHORING.md new file mode 100644 index 0000000..dabf160 --- /dev/null +++ b/docs/THEME_AUTHORING.md @@ -0,0 +1,323 @@ +# Создание полносценовой темы Exchanger + +Для первого запуска начните с [быстрого старта](QUICK_START.md). Каноническое описание manifest и +metadata находится в [BINDING_CONTRACT.md](BINDING_CONTRACT.md), а последовательность команд — в +[WORKFLOW.md](WORKFLOW.md). + +## Модель темы + +Manifest версии 2 передаёт приложению полный визуальный корень каждого экрана. Exchanger оставляет +у себя бизнес-логику, навигацию, оплату, COM-контроллер и сохранение настроек, а тематическая сцена +определяет расположение, графику, анимацию и Godot `Control`-узлы. Приложение находит нужные узлы +по metadata binding-id и подключает к ним свою логику. + +Production-тема считается доверенной. На корень, дочерние и вложенные сцены можно назначать +GDScript, подключать сигналы и использовать обычные runtime-узлы Godot: `AnimationPlayer`, +`AnimatedSprite2D`, `Timer`, `AudioStreamPlayer*`, `HTTPRequest`, частицы и шейдеры. Подробный +порядок и особенности жизненного цикла описаны в [SCRIPTING.md](SCRIPTING.md). + +Оплата, выдача, COM-протокол и постоянные настройки остаются app-owned подсистемами Exchanger. +Скрипт темы может управлять своим деревом и взаимодействовать с доступным Godot runtime, но прямые +обращения к внутренним NodePath host не входят в стабильный binding-контракт. + +Визуальное дерево самодостаточно: root `Control` применяет `../ui_theme.tres`, содержит +полноэкранный `ThemeBackground` и инстанс `BackgroundDecoration` перед рабочим UI, а +также metadata `exchanger_self_contained_visuals = true`. Поэтому открытая `.tscn`, вкладка preview +и экран Exchanger используют одну и ту же композицию. В manifest `UiThemePath` сохраняется как +описание package Theme, но `BackgroundTexturePath` и `BackgroundDecorationPath` пусты — иначе +внешний background-layer host продублирует содержимое сцены. + +## Структура builder + +```text +themes/ +├── example/ # копируемая валидная тема-шаблон +│ ├── source/ # исходники и локальные references +│ └── package/ # единственное содержимое будущего PCK +└── space-pixel/ + ├── source/ # сохранённые исходные материалы пользователя + └── package/ + ├── manifest.json + ├── ui_theme.tres + ├── background_decoration.tscn + ├── assets/ + ├── components/ + ├── visuals/ + └── scenes/ +contracts/screen_bindings.v2.json # точный machine-readable каталог биндингов +tools/preview/ # preview overlay; никогда не входит в PCK +addons/exchanger_theme_manager/ # EditorPlugin выбора и создания тем +build/ # PCK, выбор темы и скрытые временные workspaces +``` + +Редактируйте `themes//package/`: это единственный источник preview и будущего PCK. +`source/references/` не экспортируется и игнорируется Git. +Старые `themes_src/` и `preview_scenes/` временно сохранены как архив исходных материалов и прежних +визуальных макетов; новые темы создаются только внутри `themes/`. + +## Создание новой темы + +1. В Godot Editor выберите `Редактор → Создать тему`. +2. Введите ID из строчных латинских букв, цифр и дефисов и понятное название. +3. Аддон скопирует `themes/example`, обновит `ThemeId`/`DisplayName`, активирует тему и откроет + preview. + +Все темы сразу видны в Godot FileSystem: `.gdignore` внутри `themes/` не используется. Аддон +удаляет legacy-файл из корня каждой темы при открытии проекта и не копирует его при создании темы. +Для неинтерактивной автоматизации новая тема создаётся копированием `example`, после чего +активируется: + +```powershell +.\activate-theme.ps1 -ThemeId +``` + +4. Откройте `res://themes//package/` в Godot и редактируйте исходники темы напрямую: + работающий preview читает эти же файлы и автоматически перезагружается после сохранения. +5. Запустите проект (`F6`/`F5`). Все девять production-сцен видны во вкладках, а overlay биндингов + включён по умолчанию. +6. Соберите PCK командой из раздела «Сборка». + +## Имена сцен + +Имена и пути фиксированы: + +| Screen binding | Файл | Имя корня | +| --- | --- | --- | +| `screen.home` | `screen_home.tscn` | `ThemeScreen_Home` | +| `screen.cash_payment` | `screen_cash_payment.tscn` | `ThemeScreen_CashPayment` | +| `screen.card_amount` | `screen_card_amount.tscn` | `ThemeScreen_CardAmount` | +| `screen.card_custom_amount` | `screen_card_custom_amount.tscn` | `ThemeScreen_CardCustomAmount` | +| `screen.card_terminal` | `screen_card_terminal.tscn` | `ThemeScreen_CardTerminal` | +| `screen.success` | `screen_success.tscn` | `ThemeScreen_Success` | +| `screen.error` | `screen_error.tscn` | `ThemeScreen_Error` | +| `screen.service_access` | `screen_service_access.tscn` | `ThemeScreen_ServiceAccess` | +| `screen.settings` | `screen_settings.tscn` | `ThemeScreen_Settings` | + +В PCK каждый файл обязан находиться в `res://exchanger_theme_dlc/scenes/`. + +Сцена `screen_card_custom_amount.tscn` в шаблоне уже содержит эталонную раскладку пинпада: +широкую рабочую панель, центрированную сетку `3×4`, зазоры `12 px` и двенадцать квадратных +клавиш `152×152 px`. При оформлении новой темы можно менять их графику и стили, сохраняя +binding-id, типы узлов и удобную touch-геометрию. + +Клавиатура PIN-кода в `screen_service_access.tscn` использует ту же вариацию `KeypadButton`: +центрированную сетку `3×4`, зазоры `12 px` и двенадцать квадратных клавиш `112×112 px`. +Меньший размер оставляет место для статуса, действий `ВОЙТИ`/`ОТМЕНА` и общего footer. + +## Metadata биндингов + +Корень имеет metadata: + +```text +exchanger_screen_binding_id = screen. +``` + +Каждый интерактивный или динамически обновляемый элемент имеет: + +```text +exchanger_binding_id = <точный id из contracts/screen_bindings.v2.json> +``` + +Идентификатор не зависит от фактической иерархии тематической сцены. Узлы можно перемещать и +переименовывать, но binding-id, требуемый тип и уникальность изменять нельзя. Для `Button` нужен +узел класса `Button` или наследник, для `Label` — `Label`, для `Control` — любой наследник +`Control`. Каталог содержит 481 обязательный immutable element-binding и исходный fallback path +каждого. Текущий Builder дополнительно требует 24 supplemental runtime-binding; ещё два +совместимых optional ID для текстов баннеров не препятствуют проверке старой темы. Полностью +обновлённая тема содержит 509 element-binding. Их полный список приведён в +[BINDING_CONTRACT.md](BINDING_CONTRACT.md#supplemental-runtime-bindings). +Его байт-в-байт копия обязана находиться в каждой теме по пути +`package/contracts/screen_bindings.v2.json` и экспортируется как +`res://exchanger_theme_dlc/contracts/screen_bindings.v2.json`. Перед загрузкой сцен Exchanger +сверяет SHA-256 этого файла с закреплённым в приложении хешем контракта. Поэтому каталог нельзя +переформатировать, сокращать или генерировать независимо от host: нужна точная байтовая копия. + +Ни один binding-контейнер не является пустым местом для runtime UI. Home содержит готовые +theme-owned состояния запаса/подключения и узлы промо. CardAmount и Settings содержат полный набор +фиксированных индексированных слотов. Дизайнер оформляет каждый слот в сцене; Exchanger только +заполняет существующие поля и скрывает неиспользуемые root-слоты. + +Итоговые панели CashPayment, CardAmount и CardCustomAmount используют общий визуальный паттерн с +заголовком «К ВЫДАЧЕ»: базовые жетоны находятся слева, бонус — справа. В CardAmount сохраняйте +обязательный `Label` `Value` с binding-id `card_amount.content.reward_panel.content.value` по пути +`SafeMargin/Content/RewardPanel/Content/Value` и добавляйте сразу после него `Label` `Bonus` с +supplemental binding-id `card_amount.content.reward_panel.content.bonus` по пути +`SafeMargin/Content/RewardPanel/Content/Bonus`. `Value` содержит только базовые жетоны, `Bonus` — +`+ N ЖЕТОНОВ\nВ ПОДАРОК` либо `БЕЗ БОНУСА`. Supplemental ID не добавляется в JSON-каталог 481 +элемента; его отсутствие в старой теме обрабатывает host fallback, но в новых темах он обязателен +для Builder Validator. + +В CardCustomAmount существующие `Amount` и `Tokens` сохраняют контрактные ID и пути, но также +размещаются соответственно слева и справа. Двухстрочный bonus sample должен содержать явный перенос +перед «В ПОДАРОК»; динамический текст меняет host. Не объединяйте эти значения и не создавайте +runtime-узлы из host-кода. + +Отдельный Label `home.scroll.content.speech_text` выводит короткую фразу покупателю в речевой +плашке Home. В SETTINGS ей соответствуют `short_text`, `short_text_error` и строка предпросмотра; +поле ограничено 48 символами. Это самостоятельная настройка, не описание приложения и не +устаревший `subtitle`. Label Home должен переносить текст, обрезать выход за границы и сохранять +горизонтальное/вертикальное центрирование внутри авторской плашки. + +В актуальном интерфейсе SETTINGS → «Общие» владельцу автомата показывается поле с подписью +«СВОЙ ТЕКСТ • ДО 48 ЗНАКОВ» и контакты техподдержки. Узлы `application_name` и весь +`branding_preview_panel` сохраняются с прежними binding-id и типами только для совместимости, +но по умолчанию скрыты и не занимают место в композиции. Не удаляйте и не переименовывайте эти +узлы при оформлении темы. + +Не добавляйте в preview демонстрационные замены этих узлов: начальные значения и видимость +настраиваются прямо в production-сцене. В `example` и `space-pixel` первые восемь сумм видимы, +резервные слоты 08–15 скрыты; бонусы и промо-строки начинают с видимого `empty_state`. + +Экран SETTINGS также инстанцирует собственный +`package/components/on_screen_keyboards.tscn`. В нём заранее находятся две скрытые панели: +текстовая (33 буквенных слота 12/11/10 и служебные действия) и цифровая (0–9, очистка, +удаление). Менять композицию и оформление можно, но нельзя удалять binding metadata, менять тип +клавиш или включать фокус у `Button`. Кнопка скрытия каждой панели располагается справа в её +верхней строке. Для проверки используйте `KEYBOARD: OFF/TEXT/NUM` на вкладке SETTINGS preview. + +В той же SETTINGS-сцене заранее создаются скрытые `ServiceInventoryPanel` и +`ServiceDialogOverlay`. Панель показывает учтённый остаток и две операции, а диалог содержит +редактируемое количество и подтверждение. Сохраняйте 14 точных ID из контракта, тип `SpinBox` +для `amount`, пустой suffix и начальное `visible = false` у панели и overlay. На самом +`Description` обязателен supplemental binding-id +`...service_inventory_panel.margin.content.description`, чтобы host мог менять описание +по режиму учёта. Сразу после `Description` панель также содержит supplemental +`CheckButton` `InventoryAccountingEnabled` с binding-id +`...service_inventory_panel.margin.content.inventory_enabled`, подписью +«УЧИТЫВАТЬ ОСТАТОК ЖЕТОНОВ» и включённым состоянием по умолчанию. Сразу после него обязателен +второй `CheckButton` `PurchaseLimitEnabled` с binding-id +`...service_inventory_panel.margin.content.purchase_limit_enabled`, подписью «НЕ ПРОДАВАТЬ БОЛЬШЕ ОСТАТКА» и +включённым состоянием по умолчанию. Минимальная высота сервисной панели — `772 px`; тема может увеличить +фактическую высоту из-за своих minimum sizes. Все три supplemental ID не входят в JSON-каталог 481 +элемента, но обязательны для совместимого runtime и проверяются Builder +Validator. Для визуальной проверки используйте `INVENTORY: OFF/PANEL/DIALOG`. + +## Preview и подписи + +`tools/preview/BindingOverlay.gd` обходит реально загруженную production-сцену и показывает рамку +и binding-id каждого размеченного узла. Кнопка `BINDINGS: ON/OFF` находится первой слева в общей +верхней панели экранов и управляет overlay. Overlay и preview-скрипты находятся вне package, +поэтому не экспортируются в production PCK. + +Галерея не меняет root, anchors, Theme или дочерние узлы production-сцены и не добавляет +демонстрационные подписи в Home-host. Единственное наложение — отдельный sibling `BindingOverlay`. + +Preview следит за package активной темы, путь к которой записан в `build/active-theme.json`. +После изменения `.tscn`, `.tres`, JSON, изображений, шрифтов или звука галерея перезагружается +с сохранением выбранной вкладки. + +## Интерактивный питомец темы + +Питомец необязателен и полностью принадлежит конкретной теме. Создайте любую `Node2D`-сцену +внутри `package/`, реализуйте публичные методы и сигналы из [SCRIPTING.md](SCRIPTING.md), затем +укажите её канонический runtime-путь в `InteractivePetScene` manifest. Рекомендуемое имя сцены — +`components/interactive_pet.tscn`, но графику можно хранить в любой собственной подпапке package +и подключать обычными относительными resource-ссылками. Builder не требует `alien_pet`, +`alien_animation` или другого персонажно-зависимого имени каталога. + +Кнопка `PET` доступна только когда active manifest объявляет загружаемую сцену. Viewer сам +инстанцирует компонент и создаёт кнопки по `get_pet_animation_names()`, поэтому число и имена +состояний выбирает автор темы. Старый путь `components/alien_pet.tscn` распознаётся только как +fallback миграции ранее собранных тем и не должен использоваться в новых packages. + +Для составного действия начните с дизайнерской раскадровки и пошагового руководства +[COMPOSITE_PET_ANIMATIONS.md](COMPOSITE_PET_ANIMATIONS.md). Composite API не добавляет новых +`exchanger_binding_id`: питомец подключается через manifest и методы сцены, а его маршруты — через +navigation v1 metadata экранов. + +Чтобы питомец ходил по элементам всех экранов, добавьте под root каждой production-сцены, кроме +`screen.settings`, невидимый `Node2D` с `exchanger_pet_navigation_root = true` и v1 metadata из +[BINDING_CONTRACT.md](BINDING_CONTRACT.md). Отдельными `Node2D` разметьте горизонтальные верхние +грани пола, кнопок и окон, допустимые точки сидения и вертикальные climb edges между ними. +Координаты являются реальными scene-local точками контакта; имена marker-узлов произвольны и host +их не читает. При смене экрана питомец падает со старой позиции на default surface нового графа, +затем использует все его поверхности. В Settings питомец скрыт. Не меняйте ради навигации +размеры/позиции визуальных `Control` и не добавляйте видимые линии: маркеры сами ничего не рисуют. + +Для корректной посадки персонажа реализуйте optional `get_pet_ground_offset()` и runtime-профиль +из [SCRIPTING.md](SCRIPTING.md). Имена, передаваемые host, совпадают с реальными именами +анимаций темы. `get_pet_hit_size()` определяет зону захвата пальцем и зажатой ЛКМ; `hanging` +выбирает анимацию на время удержания и перетаскивания, `falling` — на время падения после +отпускания или перехода между экранами, +`landing` — одноразовую анимацию после контакта с опорой, а `idle` — устойчивый цикл ожидания +после её завершения. Если тема не предоставляет `landing`, host сразу переходит из `falling` в +`idle`, сохраняя совместимость со старыми темами. `celebrate` запускает праздничную анимацию при +начале выдачи жетонов; запрос, пришедший во время `landing`, ждёт окончания приземления. Host +проигрывает четыре полных цикла `celebrate`; для точного времени тема должна возвращать корректную +скорость через `get_pet_animation_frames_per_second()`. + +После завершённой ходьбы Exchanger выбирает состояние простоя случайно: с вероятностью `35%` +запускает `idle`, а с вероятностью `65%` — `sitting`. Если `sitting` отсутствует, host использует +`idle`, поэтому старые темы не требуют обновления. + +## Manifest v2 + +`ScreenScenes` должен содержать ровно девять канонических пар из таблицы выше. Старый словарь +`VisualSlots` допускается для обратной совместимости `space-pixel`, но полноэкранный host использует +`ScreenScenes`. Внутренние English-идентификаторы `Advertisement*` остаются стабильными; видимый +русский текст использует только «промо» и «промо-ролик». + +## Проверка и синхронизация контракта + +Для обычной автономной разработки темы сначала проверяйте копии без обращения к host: + +```powershell +.\tools\Test-PackagedBindingContract.ps1 +.\test-theme.ps1 -ThemeId +``` + +Каталог можно пересобрать из C# host: + +```powershell +.\tools\Sync-HostBindingContract.ps1 -HostProject ..\exchanger +.\tools\Sync-HostBindingContract.ps1 -HostProject ..\exchanger -Check +``` + +Эталонные структуры сцен можно повторно импортировать из fallback-сцен host и автоматически +разметить: + +```powershell +.\tools\Import-HostSceneTemplates.ps1 -HostProject ..\exchanger -ThemeIds example,space-pixel +``` + +Команда импорта перезаписывает `package/scenes`, поэтому индивидуальную композицию темы сначала +сохраните отдельно. Валидатор запускается автоматически при сборке и проверяет manifest, имена, +root metadata, наличие и типы всех биндингов и уникальность id. Скрипты и runtime-узлы темы +разрешены и проверяются обычным импортом Godot. + +## Сборка + +```powershell +.\build-theme.ps1 -ThemeId space-pixel +.\build-theme.ps1 -ThemeId my-theme -InstallToDefaultUserPath +.\build-theme.ps1 -ThemeSource D:\Themes\my-theme -OutputPath build\my-theme.pck +``` + +Сборка выбирает тему, копирует только `package/` во временный изолированный проект под +канонический runtime-путь `res://exchanger_theme_dlc/`, валидирует, создаёт +`build/.pck` и удаляет workspace. Совместимая копия также записывается в +`build/active_theme.pck`. + +Для шрифтов сборка сохраняет настройки соседнего `.import` и переносит их на runtime-путь. +Для строгого pixel-font вида выставьте в Godot `Antialiasing: None`, `Hinting: None`, +`Subpixel Positioning: Disabled`, `Oversampling: 1.0`, а на корне production-сцены — +`Texture Filter: Nearest`. Это действует на весь текст, наследующий фильтр корня. +Если manifest содержит `UseNearestTextureFilter: true`, валидатор требует `Nearest` на корне +каждой из девяти production-сцен: одной фильтрации `ThemeBackground` недостаточно. При переходе +с логического `720×1280` на физическое `1080×1920` коэффициент равен `1,5`; Nearest убирает +смешивание соседних пикселей, но не может сделать дробное масштабирование равномерным — строгие +одинаковые pixel-блоки возможны только при целочисленном коэффициенте. + +## Критерии готовности + +- manifest версии 2 содержит девять `ScreenScenes`; +- имена файлов, корней и screen metadata строго канонические; +- 481 immutable и 24 обязательных supplemental element-binding присутствуют по одному разу и + имеют допустимые типы; +- GDScript и все его зависимости импортируются без parser/runtime-ошибок; +- каждая сцена сама содержит package Theme, `ThemeBackground`, `BackgroundDecoration` и root-флаг + `exchanger_self_contained_visuals = true`; +- визуальные эффекты и скриптовая логика корректно работают в preview и Exchanger; +- overlay показывает все биндинги на всех девяти вкладках; +- валидатор, preview smoke и export PCK проходят без ошибок; +- PCK проверен в Exchanger, включая переходы, ввод, настройки, оплату и выдачу. diff --git a/docs/THEME_INTEGRATION.md b/docs/THEME_INTEGRATION.md new file mode 100644 index 0000000..134ca7b --- /dev/null +++ b/docs/THEME_INTEGRATION.md @@ -0,0 +1,73 @@ +# Интеграция полносценовой темы в Exchanger + +Этот документ описывает границу с основным приложением. Для локального цикла разработки используйте +[WORKFLOW.md](WORKFLOW.md), а для точной схемы контракта — +[BINDING_CONTRACT.md](BINDING_CONTRACT.md). + +## Граница ответственности + +Exchanger монтирует один внешний PCK, проверяет manifest v2 и создаёт визуальный корень выбранного +экрана из `ScreenScenes`. C#-контроллер экрана остаётся в приложении. Он разрешает нужные тематические +узлы по `exchanger_binding_id`, подписывает кнопки и обновляет текст/состояния. Он не добавляет, +не удаляет, не переносит и не стилизует визуальные узлы ThemeView. Тема считается доверенной: +прикреплённый GDScript и её runtime-узлы исполняются как часть инстанцированной сцены. +App-owned C#-контроллеры оплаты, оборудования и настроек остаются вне ThemeView, но тема может +содержать собственные таймеры, аудио, сетевые запросы и другую вспомогательную логику. + +PackedScene уже содержит root Theme, `ThemeBackground` и `BackgroundDecoration`; это тот же файл, +который открывается напрямую и показывается в builder preview. `UiThemePath` описывает Theme +самодостаточных сцен. `BackgroundTexturePath` и `BackgroundDecorationPath` у +самодостаточных тем пусты, поэтому общий background-layer Exchanger не дублирует визуальные слои +экранной сцены. + +При любой ошибке PCK приложение использует единственную встроенную fallback-тему. + +## Установка + +```powershell +.\build-theme.ps1 -ThemeId space-pixel -InstallToDefaultUserPath +``` + +Пакет устанавливается в: + +```text +%APPDATA%\Godot\app_userdata\Exchanger\theme_dlc\active_theme.pck +``` + +Перед заменой PCK закройте Exchanger. После установки выполните полный перезапуск. + +## Контракт + +Источником истины для автономной работы builder является `contracts/screen_bindings.v2.json`. +Он генерируется и при согласованной миграции синхронизируется с: + +- `src/core/ThemeManager/ThemeSceneContract.cs`; +- `src/core/ThemeManager/ThemeBindingResolver.cs`; +- вызовами `GetNode(SafeMargin/...)` в девяти C#-контроллерах экранов. + +Путь, имя root, screen metadata, element metadata и тип каждого обязательного узла должны совпасть. +Фактическая иерархия узлов внутри сцены темы свободна: приложение ищет их по metadata, а не NodePath. + +Каждая тема включает точную байтовую копию каталога по каноническому runtime-пути +`res://exchanger_theme_dlc/contracts/screen_bindings.v2.json`. Host до создания ThemeView вычисляет +SHA-256 встроенного файла и сравнивает его с закреплённым хешем поддерживаемой версии контракта. +Несовпадение означает несовместимый или изменённый пакет и приводит к безопасной fallback-теме. + +## Проверка интеграции + +1. В журнале активирован ожидаемый `ThemeId` и manifest версии 2. +2. Все девять экранов созданы из соответствующих `ScreenScenes`. +3. Кнопки навигации и оплаты реагируют на касание. +4. Динамические Label, формы, PIN-клавиатуры и настройки обновляются через биндинги. +5. Home находит готовые stock/connection/promo nodes без изменения дерева ThemeView. +6. Технические панели остаются скрытыми/управляются host-логикой так же, как во fallback-сцене. +7. В production PCK нет preview overlay, builder tooling, `source/` и references; GDScript и + runtime-ресурсы самой темы присутствуют и запускаются без ошибок. +8. При повреждённой сцене, неверном типе или отсутствующем binding-id активируется безопасный fallback. + +## Совместимость + +Manifest v1 и `VisualSlots` относятся к прежней частичной модели. `space-pixel` пока сохраняет +старые English `Advertisement*` identifiers и `VisualSlots` для миграции, но новым темам следует +использовать v2 `ScreenScenes`. В пользовательском русском интерфейсе используются «промо» и +«промо-ролик». diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md new file mode 100644 index 0000000..7d042b7 --- /dev/null +++ b/docs/WORKFLOW.md @@ -0,0 +1,103 @@ +# Жизненный цикл темы + +## 1. Создание + +В Godot Editor выберите `Редактор → Создать тему`. Укажите стабильный lowercase ID с +латинскими буквами, цифрами и дефисами и человекочитаемое название. Аддон скопирует `example`, +обновит manifest и сразу активирует новую тему. + +Для автоматизации можно скопировать `themes/example` в `themes/`. Используйте ID с +латинскими буквами, цифрами и дефисами. Измените только `ThemeId` и человекочитаемый +`DisplayName` в manifest; канонические scene keys/paths не меняйте. + +## 2. Активация + +В Godot Editor выберите `Редактор → Выбрать тему`. После подтверждения аддон остановит запущенный +preview при необходимости, запишет выбранный package в `build/active-theme.json`, откроет preview +и восстановит запуск. + +Эквивалентная PowerShell-команда: + +```powershell +.\activate-theme.ps1 -ThemeId space-pixel +``` + +Команда только проверяет manifest и пишет `build/active-theme.json`. Корневая копия темы не +создаётся: активный package остаётся единственным источником. + +## 3. Редактирование и live reload + +Источник — видимый в Godot FileSystem каталог `themes//package/`. Запущенный preview +загружает его сцены напрямую, следит за изменёнными `.tscn`, `.tres`, JSON, изображениями, +шрифтами и звуком и сохраняет выбранную вкладку при перезагрузке. + +Открывайте production-сцены прямо из `res://themes//package/scenes/`. Их ссылки относительные: +каждая тема использует собственные `assets/`, `components/` и `visuals`, даже когда рядом +индексируются остальные темы. Сцена уже содержит собственные `Theme`, статический фон и +decoration, поэтому её вид в редакторе является production-видом, а не упрощённым каркасом. +Если Godot Editor пересохранил ссылку с полным builder-префиксом, временный test/build workspace +автоматически заменит его на `res://exchanger_theme_dlc/`; исходный package при этом не меняется. + +## 4. Preview + +```powershell +.\preview-theme.ps1 -ThemeId space-pixel +``` + +Верхняя панель переключает девять экранов. `BINDINGS: ON/OFF` включает подписи metadata. Overlay +нужен для проверки контракта, а финальный визуальный QA делается с выключенными подписями. Галерея +не меняет инстанцированные сцены и не добавляет им оформление или preview-only содержимое. + +## 5. Проверка + +```powershell +.\tools\Test-PackagedBindingContract.ps1 +.\test-theme.ps1 -ThemeId space-pixel +``` + +Первая команда без Godot сверяет byte-identical копии контракта во всех темах. Вторая выбирает +тему, валидирует её в изолированном временном workspace и запускает headless smoke preview, +который читает исходный package напрямую. + +## 6. Сборка + +В Godot Editor выберите `Редактор → Экспортировать тему`, выберите любую валидную тему по ID, +затем укажите каталог и имя `.pck` в стандартном диалоге сохранения. Аддон проверит и соберёт +именно выбранную тему в фоне, не переключая preview и не заменяя установленную тему Exchanger. +Окно прогресса показывает импорт, проверку контракта, экспорт, проверку пакета и завершение. + +Эквивалентная команда с путём по умолчанию: + +```powershell +.\build-theme.ps1 -ThemeId space-pixel +``` + +Результаты: `build/space-pixel.pck` и совместимая копия `build/active_theme.pck`. Для сохранения +обязательных runtime-путей сборка кратковременно создаёт +`build/theme-workspaces/-build/exchanger_theme_dlc/`, экспортирует PCK и удаляет workspace. +`build/` скрыт от Godot через собственный `.gdignore` и игнорируется системой контроля версий. +Выбор другого пути в Editor передаётся скрипту как `-OutputPath -PreserveActiveTheme`: поэтому +редакторский экспорт не меняет `build/active-theme.json` и `build/active_theme.pck`. Прямой запуск +скрипта без `-PreserveActiveTheme` сохраняет прежнее поведение и обновляет совместимую копию. + +## 7. Установка + +Закройте Exchanger, затем в Godot Editor выберите +`Редактор → Применить тему к Exchanger`. В компактном окне проверьте ID активной темы и путь, +нажмите `Собрать и применить`. Аддон выполняет validator, export и установку в фоновом потоке; +редактор остаётся доступен, а отдельное окно показывает текущий этап, процент и результат. + +Эквивалентная команда для автоматизации: + +```powershell +.\build-theme.ps1 -ThemeId space-pixel -InstallToDefaultUserPath +``` + +Команда заменяет `%APPDATA%\Godot\app_userdata\Exchanger\theme_dlc\active_theme.pck`. После этого +нужен полный перезапуск Exchanger и интеграционный checklist из `THEME_INTEGRATION.md`. + +## 8. Изменение host-контракта + +Это отдельная миграция, а не часть обычного оформления темы. Сначала изменяется Exchanger и его +тесты, затем builder синхронизируется штатной командой. Последовательность описана в +[BINDING_CONTRACT.md](BINDING_CONTRACT.md). Никогда не правьте 481 ID вручную в обход host. diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..eac44a2 --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,74 @@ +[preset.0] + +name="Theme DLC" +platform="Windows Desktop" +dedicated_server=false +custom_features="" +export_filter="resources" +export_files=PackedStringArray() +include_filter="exchanger_theme_dlc/**" +exclude_filter="" +export_path="build/active_theme.pck" +patches=PackedStringArray() +patch_delta_encoding=false +patch_delta_compression_level_zstd=19 +patch_delta_min_reduction=0.1 +patch_delta_include_filters="*" +patch_delta_exclude_filters="" +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=false +texture_format/s3tc_bptc=true +texture_format/etc2_astc=true +shader_baker/enabled=false +binary_format/architecture="x86_64" +codesign/enable=false +codesign/timestamp=true +codesign/timestamp_server_url="" +codesign/digest_algorithm=1 +codesign/description="" +codesign/custom_options=PackedStringArray() +application/modify_resources=true +application/icon="" +application/console_wrapper_icon="" +application/icon_interpolation=4 +application/file_version="" +application/product_version="" +application/company_name="" +application/product_name="" +application/file_description="" +application/copyright="" +application/trademarks="" +application/export_angle=0 +application/export_d3d12=0 +application/d3d12_agility_sdk_multiarch=true +ssh_remote_deploy/enabled=false +ssh_remote_deploy/host="user@host_ip" +ssh_remote_deploy/port="22" +ssh_remote_deploy/extra_args_ssh="" +ssh_remote_deploy/extra_args_scp="" +ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}' +$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}' +$trigger = New-ScheduledTaskTrigger -Once -At 00:00 +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries +$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings +Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true +Start-ScheduledTask -TaskName godot_remote_debug +while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 } +Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue" +ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue +Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue +Remove-Item -Recurse -Force '{temp_dir}'" +dotnet/include_scripts_content=false +dotnet/include_debug_symbols=true +dotnet/embed_build_outputs=false diff --git a/godot-ai-LICENSE.txt b/godot-ai-LICENSE.txt new file mode 100644 index 0000000..7806d22 --- /dev/null +++ b/godot-ai-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Godot AI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/preview-theme.ps1 b/preview-theme.ps1 new file mode 100644 index 0000000..9b10552 --- /dev/null +++ b/preview-theme.ps1 @@ -0,0 +1,16 @@ +param( + [string]$ThemeId = "space-pixel", + [string]$ThemeSource = "", + [string]$GodotExecutable = "" +) + +$ErrorActionPreference = "Stop" +$projectRoot = (Resolve-Path $PSScriptRoot).Path +. (Join-Path $projectRoot "tools\Resolve-GodotExecutable.ps1") +$GodotExecutable = Resolve-ThemeGodotExecutable -GodotExecutable $GodotExecutable + +& (Join-Path $projectRoot "tools\Select-Theme.ps1") -ThemeId $ThemeId -ThemeSource $ThemeSource +& $GodotExecutable --path $projectRoot +if ($LASTEXITCODE -ne 0) { + throw "Preview for theme '$ThemeId' failed." +} diff --git a/preview.tscn b/preview.tscn new file mode 100644 index 0000000..53a1221 --- /dev/null +++ b/preview.tscn @@ -0,0 +1,131 @@ +[gd_scene format=3 uid="uid://d252sroyl85e6"] + +[ext_resource type="Texture2D" path="res://themes/space-pixel/package/assets/background/background.png" id="1_background"] +[ext_resource type="Theme" path="res://themes/space-pixel/package/ui_theme.tres" id="2_theme"] +[ext_resource type="PackedScene" path="res://themes/space-pixel/package/background_decoration.tscn" id="3_decoration"] +[ext_resource type="PackedScene" uid="uid://cs8me8jggvo48" path="res://preview_scenes/HomePreview.tscn" id="4_home"] +[ext_resource type="PackedScene" uid="uid://cr4uwvtqa6nlw" path="res://preview_scenes/CashPreview.tscn" id="5_cash"] +[ext_resource type="PackedScene" uid="uid://c4bbashsu0wad" path="res://preview_scenes/CardAmountPreview.tscn" id="6_sum"] +[ext_resource type="PackedScene" uid="uid://dmbn0n0i8vyij" path="res://preview_scenes/CardCustomPreview.tscn" id="7_keypad"] +[ext_resource type="PackedScene" uid="uid://dadn5uck0dn57" path="res://preview_scenes/CardTerminalPreview.tscn" id="8_card"] +[ext_resource type="PackedScene" uid="uid://cyepwpigxpvau" path="res://preview_scenes/SuccessPreview.tscn" id="9_success"] +[ext_resource type="PackedScene" uid="uid://bvynyjtfxum6i" path="res://preview_scenes/ErrorPreview.tscn" id="10_error"] +[ext_resource type="Script" uid="uid://b0fuom2r8d6vj" path="res://preview_scenes/PreviewLiveReload.gd" id="11_live_reload"] +[ext_resource type="PackedScene" path="res://preview_scenes/ServiceAccessPreview.tscn" id="12_service"] +[ext_resource type="PackedScene" path="res://preview_scenes/SettingsPreview.tscn" id="13_settings"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_tabs"] + +[sub_resource type="StyleBoxFlat" id="StyleBox_tab_unselected"] +content_margin_left = 10.0 +content_margin_top = 4.0 +content_margin_right = 10.0 +content_margin_bottom = 4.0 +bg_color = Color(0.005, 0.03, 0.09, 0.9) + +[sub_resource type="StyleBoxFlat" id="StyleBox_tab_hovered"] +content_margin_left = 10.0 +content_margin_top = 4.0 +content_margin_right = 10.0 +content_margin_bottom = 4.0 +bg_color = Color(0.08, 0.28, 0.2, 0.96) + +[sub_resource type="StyleBoxFlat" id="StyleBox_tab_selected"] +content_margin_left = 10.0 +content_margin_top = 4.0 +content_margin_right = 10.0 +content_margin_bottom = 4.0 +bg_color = Color(0.45, 0.86, 0.31, 0.98) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.72, 1, 0.58, 1) + +[node name="SpacePixelPreviewGallery" type="Control" unique_id=2025692250] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("2_theme") +script = ExtResource("11_live_reload") + +[node name="Background" type="TextureRect" parent="." unique_id=242445040] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("1_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="Decoration" parent="." unique_id=1738874259 instance=ExtResource("3_decoration")] +scale = Vector2(0.666667, 0.666667) + +[node name="Screens" type="TabContainer" parent="." unique_id=2074523310] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_colors/font_selected_color = Color(0.02, 0.08, 0.18, 1) +theme_override_colors/font_unselected_color = Color(0.92, 0.98, 1, 1) +theme_override_font_sizes/font_size = 16 +theme_override_styles/panel = SubResource("StyleBoxEmpty_tabs") +theme_override_styles/tab_unselected = SubResource("StyleBox_tab_unselected") +theme_override_styles/tab_hovered = SubResource("StyleBox_tab_hovered") +theme_override_styles/tab_selected = SubResource("StyleBox_tab_selected") +tab_alignment = 1 +current_tab = 0 +use_hidden_tabs_for_min_size = true + +[node name="HOME" parent="Screens" unique_id=1309203819 instance=ExtResource("4_home")] +layout_mode = 2 +metadata/_tab_index = 0 + +[node name="CASH" parent="Screens" unique_id=1335897324 instance=ExtResource("5_cash")] +visible = false +layout_mode = 2 +metadata/_tab_index = 1 + +[node name="SUM" parent="Screens" unique_id=467515431 instance=ExtResource("6_sum")] +visible = false +layout_mode = 2 +metadata/_tab_index = 2 + +[node name="KEYPAD" parent="Screens" unique_id=674122645 instance=ExtResource("7_keypad")] +visible = false +layout_mode = 2 +metadata/_tab_index = 3 + +[node name="CARD" parent="Screens" unique_id=883192782 instance=ExtResource("8_card")] +visible = false +layout_mode = 2 +metadata/_tab_index = 4 + +[node name="SUCCESS" parent="Screens" unique_id=1292278757 instance=ExtResource("9_success")] +visible = false +layout_mode = 2 +metadata/_tab_index = 5 + +[node name="ERROR" parent="Screens" unique_id=2137224203 instance=ExtResource("10_error")] +visible = false +layout_mode = 2 +metadata/_tab_index = 6 + +[node name="SERVICE" parent="Screens" unique_id=2084691589 instance=ExtResource("12_service")] +visible = false +layout_mode = 2 +metadata/_tab_index = 7 + +[node name="SETTINGS" parent="Screens" unique_id=943449370 instance=ExtResource("13_settings")] +visible = false +layout_mode = 2 +metadata/_tab_index = 8 diff --git a/preview_scenes/.gdignore b/preview_scenes/.gdignore new file mode 100644 index 0000000..dacb7b3 --- /dev/null +++ b/preview_scenes/.gdignore @@ -0,0 +1 @@ +# Archived preview layouts are not part of the active Godot project. diff --git a/preview_scenes/CardAmountPreview.tscn b/preview_scenes/CardAmountPreview.tscn new file mode 100644 index 0000000..2709696 --- /dev/null +++ b/preview_scenes/CardAmountPreview.tscn @@ -0,0 +1,177 @@ +[gd_scene format=3 uid="uid://c4bbashsu0wad"] + +[ext_resource type="PackedScene" path="res://preview_scenes/PreviewFooter.tscn" id="1_footer"] +[ext_resource type="Texture2D" uid="uid://sntindr0yqtc" path="res://exchanger_theme_dlc/assets/ui/back_button.png" id="2_back"] + +[node name="SUM" type="Control" unique_id=21701794] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="BackIcon" type="TextureRect" parent="." unique_id=727102079] +texture_filter = 1 +layout_mode = 0 +offset_left = 24.0 +offset_top = 16.0 +offset_right = 76.0 +offset_bottom = 68.0 +texture = ExtResource("2_back") +expand_mode = 1 +stretch_mode = 5 + +[node name="BackText" type="Label" parent="." unique_id=1863709339] +layout_mode = 0 +offset_left = 20.0 +offset_top = 66.0 +offset_right = 84.0 +offset_bottom = 92.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 13 +text = "НАЗАД" +horizontal_alignment = 1 + +[node name="Title" type="Label" parent="." unique_id=959122858] +layout_mode = 0 +offset_left = 92.0 +offset_top = 20.0 +offset_right = 696.0 +offset_bottom = 80.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА КАРТОЙ" +horizontal_alignment = 1 + +[node name="RewardPanel" type="PanelContainer" parent="." unique_id=749756036] +layout_mode = 0 +offset_left = 34.0 +offset_top = 110.0 +offset_right = 686.0 +offset_bottom = 250.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="RewardPanel" unique_id=703241980] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="RewardPanel/Content" unique_id=1676855582] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "К ВЫДАЧЕ" +vertical_alignment = 1 + +[node name="Value" type="Label" parent="RewardPanel/Content" unique_id=459557488] +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 27 +text = "100 ЖЕТОНОВ + 50 В ПОДАРОК" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="AmountPanel" type="PanelContainer" parent="." unique_id=1806004633] +layout_mode = 0 +offset_left = 34.0 +offset_top = 274.0 +offset_right = 686.0 +offset_bottom = 828.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="AmountPanel" unique_id=205229911] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="AmountPanel/Content" unique_id=1947589315] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ОПЛАТА" +vertical_alignment = 1 + +[node name="Grid" type="GridContainer" parent="AmountPanel/Content" unique_id=1392864743] +custom_minimum_size = Vector2(0, 350) +layout_mode = 2 +theme_override_constants/h_separation = 20 +theme_override_constants/v_separation = 14 +columns = 2 + +[node name="Amount10" type="Button" parent="AmountPanel/Content/Grid" unique_id=505006584] +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "10 РУБ." + +[node name="Amount50" type="Button" parent="AmountPanel/Content/Grid" unique_id=606799949] +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "50 РУБ." + +[node name="Amount100" type="Button" parent="AmountPanel/Content/Grid" unique_id=987842818] +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "100 РУБ." + +[node name="Amount150" type="Button" parent="AmountPanel/Content/Grid" unique_id=218471006] +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "150 РУБ." + +[node name="Amount200" type="Button" parent="AmountPanel/Content/Grid" unique_id=273401422] +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "200 РУБ." + +[node name="Amount300" type="Button" parent="AmountPanel/Content/Grid" unique_id=605529934] +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "300 РУБ." + +[node name="Amount400" type="Button" parent="AmountPanel/Content/Grid" unique_id=2050233395] +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "400 РУБ." + +[node name="Amount500" type="Button" parent="AmountPanel/Content/Grid" unique_id=1413881908] +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "500 РУБ." + +[node name="Other" type="Button" parent="AmountPanel/Content" unique_id=558538431] +custom_minimum_size = Vector2(340, 68) +layout_mode = 2 +size_flags_horizontal = 4 +theme_type_variation = &"CompactButton" +text = "ДРУГАЯ СУММА" + +[node name="Confirm" type="Button" parent="." unique_id=229954390] +layout_mode = 0 +offset_left = 34.0 +offset_top = 858.0 +offset_right = 686.0 +offset_bottom = 952.0 +theme_type_variation = &"PrimaryButton" +text = "ПОДТВЕРДИТЬ" + +[node name="Hint" type="Label" parent="." unique_id=1898482115] +layout_mode = 0 +offset_left = 60.0 +offset_top = 974.0 +offset_right = 660.0 +offset_bottom = 1038.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 18 +text = "ВЫБЕРИТЕ СУММУ ДЛЯ ПЕРЕХОДА К ТЕРМИНАЛУ" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Footer" parent="." unique_id=756615138 instance=ExtResource("1_footer")] +layout_mode = 1 diff --git a/preview_scenes/CardCustomPreview.tscn b/preview_scenes/CardCustomPreview.tscn new file mode 100644 index 0000000..5408d2d --- /dev/null +++ b/preview_scenes/CardCustomPreview.tscn @@ -0,0 +1,182 @@ +[gd_scene format=3 uid="uid://dmbn0n0i8vyij"] + +[ext_resource type="PackedScene" path="res://preview_scenes/PreviewFooter.tscn" id="1_footer"] +[ext_resource type="Texture2D" uid="uid://sntindr0yqtc" path="res://exchanger_theme_dlc/assets/ui/back_button.png" id="2_back"] + +[node name="KEYPAD" type="Control" unique_id=1473255093] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="BackIcon" type="TextureRect" parent="." unique_id=2063912065] +texture_filter = 1 +layout_mode = 0 +offset_left = 24.0 +offset_top = 16.0 +offset_right = 76.0 +offset_bottom = 68.0 +texture = ExtResource("2_back") +expand_mode = 1 +stretch_mode = 5 + +[node name="BackText" type="Label" parent="." unique_id=973004298] +layout_mode = 0 +offset_left = 20.0 +offset_top = 66.0 +offset_right = 84.0 +offset_bottom = 92.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 13 +text = "НАЗАД" +horizontal_alignment = 1 + +[node name="Title" type="Label" parent="." unique_id=650414558] +layout_mode = 0 +offset_left = 92.0 +offset_top = 20.0 +offset_right = 696.0 +offset_bottom = 80.0 +theme_type_variation = &"ScreenTitle" +text = "ДРУГАЯ СУММА" +horizontal_alignment = 1 + +[node name="AmountPanel" type="PanelContainer" parent="." unique_id=833520481] +layout_mode = 0 +offset_left = 70.0 +offset_top = 112.0 +offset_right = 650.0 +offset_bottom = 288.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="AmountPanel" unique_id=1256168150] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="AmountPanel/Content" unique_id=2076137117] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "СУММА ОПЛАТЫ" +vertical_alignment = 1 + +[node name="Amount" type="Label" parent="AmountPanel/Content" unique_id=794675741] +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 36 +text = "1000 ₽ • 100 ЖЕТОНОВ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="KeypadPanel" type="PanelContainer" parent="." unique_id=223784690] +layout_mode = 0 +offset_left = 104.0 +offset_top = 320.0 +offset_right = 616.0 +offset_bottom = 872.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="KeypadPanel" unique_id=1346008923] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="KeypadPanel/Content" unique_id=979359435] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ВВЕДИТЕ СУММУ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Grid" type="GridContainer" parent="KeypadPanel/Content" unique_id=19340587] +layout_mode = 2 +theme_override_constants/h_separation = 12 +theme_override_constants/v_separation = 12 +columns = 3 + +[node name="Key1" type="Button" parent="KeypadPanel/Content/Grid" unique_id=292513137] +custom_minimum_size = Vector2(145, 82) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "1" + +[node name="Key2" type="Button" parent="KeypadPanel/Content/Grid" unique_id=2059796320] +custom_minimum_size = Vector2(145, 82) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "2" + +[node name="Key3" type="Button" parent="KeypadPanel/Content/Grid" unique_id=1606281080] +custom_minimum_size = Vector2(145, 82) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "3" + +[node name="Key4" type="Button" parent="KeypadPanel/Content/Grid" unique_id=1575091527] +custom_minimum_size = Vector2(145, 82) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "4" + +[node name="Key5" type="Button" parent="KeypadPanel/Content/Grid" unique_id=1264618676] +custom_minimum_size = Vector2(145, 82) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "5" + +[node name="Key6" type="Button" parent="KeypadPanel/Content/Grid" unique_id=1221752006] +custom_minimum_size = Vector2(145, 82) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "6" + +[node name="Key7" type="Button" parent="KeypadPanel/Content/Grid" unique_id=1146303995] +custom_minimum_size = Vector2(145, 82) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "7" + +[node name="Key8" type="Button" parent="KeypadPanel/Content/Grid" unique_id=1022590765] +custom_minimum_size = Vector2(145, 82) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "8" + +[node name="Key9" type="Button" parent="KeypadPanel/Content/Grid" unique_id=1628652970] +custom_minimum_size = Vector2(145, 82) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "9" + +[node name="Clear" type="Button" parent="KeypadPanel/Content/Grid" unique_id=1400291329] +custom_minimum_size = Vector2(145, 82) +layout_mode = 2 +theme_type_variation = &"CompactButton" +theme_override_font_sizes/font_size = 17 +text = "СБРОС" + +[node name="Key0" type="Button" parent="KeypadPanel/Content/Grid" unique_id=2048436846] +custom_minimum_size = Vector2(145, 82) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "0" + +[node name="Backspace" type="Button" parent="KeypadPanel/Content/Grid" unique_id=980931690] +custom_minimum_size = Vector2(145, 82) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "⌫" + +[node name="Confirm" type="Button" parent="." unique_id=892179263] +layout_mode = 0 +offset_left = 70.0 +offset_top = 902.0 +offset_right = 650.0 +offset_bottom = 996.0 +theme_type_variation = &"PrimaryButton" +text = "ПОДТВЕРДИТЬ" + +[node name="Footer" parent="." unique_id=191299046 instance=ExtResource("1_footer")] +layout_mode = 1 diff --git a/preview_scenes/CardTerminalPreview.tscn b/preview_scenes/CardTerminalPreview.tscn new file mode 100644 index 0000000..5a848ff --- /dev/null +++ b/preview_scenes/CardTerminalPreview.tscn @@ -0,0 +1,127 @@ +[gd_scene format=3 uid="uid://dadn5uck0dn57"] + +[ext_resource type="PackedScene" path="res://preview_scenes/PreviewFooter.tscn" id="1_footer"] +[ext_resource type="Texture2D" uid="uid://sntindr0yqtc" path="res://exchanger_theme_dlc/assets/ui/back_button.png" id="2_back"] +[ext_resource type="Texture2D" uid="uid://b3usa242mfern" path="res://exchanger_theme_dlc/assets/animations/card/frame_01.png" id="3_card_1"] +[ext_resource type="Texture2D" uid="uid://de6pb3jrexihf" path="res://exchanger_theme_dlc/assets/animations/card/frame_02.png" id="4_card_2"] + +[sub_resource type="SpriteFrames" id="SpriteFrames_card"] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": ExtResource("3_card_1") +}, { +"duration": 1.0, +"texture": ExtResource("4_card_2") +}], +"loop": 1, +"name": &"default", +"speed": 2.0 +}] + +[node name="CARD" type="Control" unique_id=2023585296] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="BackIcon" type="TextureRect" parent="." unique_id=838274810] +texture_filter = 1 +layout_mode = 0 +offset_left = 24.0 +offset_top = 16.0 +offset_right = 76.0 +offset_bottom = 68.0 +texture = ExtResource("2_back") +expand_mode = 1 +stretch_mode = 5 + +[node name="BackText" type="Label" parent="." unique_id=1051493463] +layout_mode = 0 +offset_left = 20.0 +offset_top = 66.0 +offset_right = 84.0 +offset_bottom = 92.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 13 +text = "НАЗАД" +horizontal_alignment = 1 + +[node name="Title" type="Label" parent="." unique_id=861663064] +layout_mode = 0 +offset_left = 92.0 +offset_top = 20.0 +offset_right = 696.0 +offset_bottom = 80.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА КАРТОЙ" +horizontal_alignment = 1 + +[node name="PaymentPanel" type="PanelContainer" parent="." unique_id=648967317] +layout_mode = 0 +offset_left = 112.0 +offset_top = 204.0 +offset_right = 608.0 +offset_bottom = 826.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="PaymentPanel" unique_id=2112945038] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="PaymentPanel/Content" unique_id=1931358880] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ОПЛАТА" +vertical_alignment = 1 + +[node name="Body" type="Control" parent="PaymentPanel/Content" unique_id=1680174075] +custom_minimum_size = Vector2(0, 520) +layout_mode = 2 + +[node name="CardAnimation" type="AnimatedSprite2D" parent="PaymentPanel/Content/Body" unique_id=1670614075] +texture_filter = 1 +position = Vector2(226, 224) +scale = Vector2(0.72, 0.72) +sprite_frames = SubResource("SpriteFrames_card") +autoplay = "default" + +[node name="Instruction" type="Label" parent="PaymentPanel/Content/Body" unique_id=805003299] +layout_mode = 0 +offset_left = 26.0 +offset_top = 404.0 +offset_right = 426.0 +offset_bottom = 494.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 21 +text = "ПРИЛОЖИТЕ КАРТУ +К ТЕРМИНАЛУ ОПЛАТЫ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Amount" type="Label" parent="." unique_id=1532983865] +layout_mode = 0 +offset_left = 110.0 +offset_top = 856.0 +offset_right = 610.0 +offset_bottom = 924.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 24 +text = "К ОПЛАТЕ: 1000 ₽ • 100 ЖЕТОНОВ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Cancel" type="Button" parent="." unique_id=1065301015] +layout_mode = 0 +offset_left = 210.0 +offset_top = 946.0 +offset_right = 510.0 +offset_bottom = 1018.0 +theme_type_variation = &"CompactButton" +text = "ОТМЕНА" + +[node name="Footer" parent="." unique_id=1866459506 instance=ExtResource("1_footer")] +layout_mode = 1 diff --git a/preview_scenes/CashPreview.tscn b/preview_scenes/CashPreview.tscn new file mode 100644 index 0000000..8149124 --- /dev/null +++ b/preview_scenes/CashPreview.tscn @@ -0,0 +1,222 @@ +[gd_scene format=3 uid="uid://cr4uwvtqa6nlw"] + +[ext_resource type="PackedScene" path="res://preview_scenes/PreviewFooter.tscn" id="1_footer"] +[ext_resource type="Texture2D" uid="uid://sntindr0yqtc" path="res://exchanger_theme_dlc/assets/ui/back_button.png" id="2_back"] +[ext_resource type="Texture2D" uid="uid://co8f6snofsikl" path="res://exchanger_theme_dlc/assets/animations/bill/frame_01.png" id="3_bill_1"] +[ext_resource type="Texture2D" uid="uid://dfobk36rg1j0q" path="res://exchanger_theme_dlc/assets/animations/bill/frame_02.png" id="4_bill_2"] +[ext_resource type="Texture2D" uid="uid://bcpdaecm5egr8" path="res://exchanger_theme_dlc/assets/animations/bill/frame_03.png" id="5_bill_3"] +[ext_resource type="Texture2D" uid="uid://cl2qmi0ieqhd" path="res://exchanger_theme_dlc/assets/animations/bill/frame_04.png" id="6_bill_4"] +[ext_resource type="Texture2D" uid="uid://sv5r7i74bs5" path="res://exchanger_theme_dlc/assets/animations/coin/frame_01.png" id="7_coin_1"] +[ext_resource type="Texture2D" uid="uid://d2iaks2d6flqb" path="res://exchanger_theme_dlc/assets/animations/coin/frame_02.png" id="8_coin_2"] +[ext_resource type="Texture2D" uid="uid://ddo0fm0ophvci" path="res://exchanger_theme_dlc/assets/animations/coin/frame_03.png" id="9_coin_3"] +[ext_resource type="Texture2D" uid="uid://wxf81owt4q13" path="res://exchanger_theme_dlc/assets/decorations/alien3.png" id="10_alien"] +[ext_resource type="Texture2D" uid="uid://bdyjsoj5lwxu0" path="res://exchanger_theme_dlc/assets/ui/bubble_text.png" id="11_bubble"] + +[sub_resource type="SpriteFrames" id="SpriteFrames_bill"] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": ExtResource("3_bill_1") +}, { +"duration": 1.0, +"texture": ExtResource("4_bill_2") +}, { +"duration": 1.0, +"texture": ExtResource("5_bill_3") +}, { +"duration": 1.0, +"texture": ExtResource("6_bill_4") +}], +"loop": 1, +"name": &"default", +"speed": 4.0 +}] + +[sub_resource type="SpriteFrames" id="SpriteFrames_coin"] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": ExtResource("7_coin_1") +}, { +"duration": 1.0, +"texture": ExtResource("8_coin_2") +}, { +"duration": 1.0, +"texture": ExtResource("9_coin_3") +}], +"loop": 1, +"name": &"default", +"speed": 3.0 +}] + +[node name="CASH" type="Control" unique_id=617693511] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="BackIcon" type="TextureRect" parent="." unique_id=46384435] +texture_filter = 1 +layout_mode = 0 +offset_left = 24.0 +offset_top = 16.0 +offset_right = 76.0 +offset_bottom = 68.0 +texture = ExtResource("2_back") +expand_mode = 1 +stretch_mode = 5 + +[node name="BackText" type="Label" parent="." unique_id=2102036995] +layout_mode = 0 +offset_left = 20.0 +offset_top = 66.0 +offset_right = 84.0 +offset_bottom = 92.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 13 +text = "НАЗАД" +horizontal_alignment = 1 + +[node name="Title" type="Label" parent="." unique_id=382438172] +layout_mode = 0 +offset_left = 92.0 +offset_top = 20.0 +offset_right = 696.0 +offset_bottom = 80.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА НАЛИЧНЫМИ" +horizontal_alignment = 1 + +[node name="BalancePanel" type="PanelContainer" parent="." unique_id=744516633] +layout_mode = 0 +offset_left = 34.0 +offset_top = 108.0 +offset_right = 686.0 +offset_bottom = 244.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="BalancePanel" unique_id=677837882] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="BalancePanel/Content" unique_id=1604443831] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ТЕКУЩИЙ БАЛАНС" +vertical_alignment = 1 + +[node name="Value" type="Label" parent="BalancePanel/Content" unique_id=530140376] +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 30 +text = "100 РУБЛЕЙ • 10 ЖЕТОНОВ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="InstructionPanel" type="PanelContainer" parent="." unique_id=1702548195] +layout_mode = 0 +offset_left = 34.0 +offset_top = 264.0 +offset_right = 686.0 +offset_bottom = 750.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="InstructionPanel" unique_id=1466233221] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="InstructionPanel/Content" unique_id=249944596] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "КАК ВНЕСТИ" +vertical_alignment = 1 + +[node name="Body" type="Control" parent="InstructionPanel/Content" unique_id=1172503487] +custom_minimum_size = Vector2(0, 380) +layout_mode = 2 + +[node name="Bill" type="AnimatedSprite2D" parent="InstructionPanel/Content/Body" unique_id=1473978289] +texture_filter = 1 +position = Vector2(108, 106) +scale = Vector2(0.52, 0.52) +sprite_frames = SubResource("SpriteFrames_bill") +autoplay = "default" + +[node name="BillText" type="Label" parent="InstructionPanel/Content/Body" unique_id=1142826781] +layout_mode = 0 +offset_left = 206.0 +offset_top = 58.0 +offset_right = 566.0 +offset_bottom = 154.0 +theme_type_variation = &"PanelText" +text = "ВСТАВЬТЕ КУПЮРЫ +В КУПЮРОПРИЁМНИК" +vertical_alignment = 1 + +[node name="Coin" type="AnimatedSprite2D" parent="InstructionPanel/Content/Body" unique_id=839503345] +texture_filter = 1 +position = Vector2(108, 274) +scale = Vector2(0.52, 0.52) +sprite_frames = SubResource("SpriteFrames_coin") +autoplay = "default" + +[node name="CoinText" type="Label" parent="InstructionPanel/Content/Body" unique_id=1769314122] +layout_mode = 0 +offset_left = 206.0 +offset_top = 224.0 +offset_right = 566.0 +offset_bottom = 326.0 +theme_type_variation = &"PanelText" +text = "ЛИБО МОНЕТЫ +В МОНЕТОПРИЕМНИК" +vertical_alignment = 1 + +[node name="Action" type="Button" parent="." unique_id=1957243040] +layout_mode = 0 +offset_left = 34.0 +offset_top = 774.0 +offset_right = 686.0 +offset_bottom = 866.0 +theme_type_variation = &"PrimaryButton" +text = "ВЫДАТЬ" + +[node name="Alien" type="TextureRect" parent="." unique_id=775495492] +texture_filter = 1 +layout_mode = 0 +offset_left = 34.0 +offset_top = 892.0 +offset_right = 174.0 +offset_bottom = 1032.0 +texture = ExtResource("10_alien") +expand_mode = 1 +stretch_mode = 5 + +[node name="Bubble" type="TextureRect" parent="." unique_id=2111671620] +texture_filter = 1 +layout_mode = 0 +offset_left = 164.0 +offset_top = 914.0 +offset_right = 676.0 +offset_bottom = 1042.0 +texture = ExtResource("11_bubble") +expand_mode = 1 +stretch_mode = 5 + +[node name="BubbleText" type="Label" parent="." unique_id=1764793564] +layout_mode = 0 +offset_left = 270.0 +offset_top = 938.0 +offset_right = 570.0 +offset_bottom = 1018.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 14 +text = "АВТОМАТ НЕ ВЫДАЁТ СДАЧУ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Footer" parent="." unique_id=1630848867 instance=ExtResource("1_footer")] +layout_mode = 1 diff --git a/preview_scenes/ErrorPreview.tscn b/preview_scenes/ErrorPreview.tscn new file mode 100644 index 0000000..c4debc3 --- /dev/null +++ b/preview_scenes/ErrorPreview.tscn @@ -0,0 +1,98 @@ +[gd_scene format=3 uid="uid://bvynyjtfxum6i"] + +[ext_resource type="PackedScene" path="res://preview_scenes/PreviewFooter.tscn" id="1_footer"] +[ext_resource type="Texture2D" uid="uid://dwvk6pkpvh63t" path="res://exchanger_theme_dlc/assets/animations/payment_failed/frame_01.png" id="2_failed_1"] +[ext_resource type="Texture2D" uid="uid://bmv4rutkbnudh" path="res://exchanger_theme_dlc/assets/animations/payment_failed/frame_02.png" id="3_failed_2"] +[ext_resource type="Texture2D" uid="uid://b5v7kqjrfhqjw" path="res://exchanger_theme_dlc/assets/animations/payment_failed/frame_03.png" id="4_failed_3"] + +[sub_resource type="SpriteFrames" id="SpriteFrames_failed"] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": ExtResource("2_failed_1") +}, { +"duration": 1.0, +"texture": ExtResource("3_failed_2") +}, { +"duration": 1.0, +"texture": ExtResource("4_failed_3") +}], +"loop": 1, +"name": &"default", +"speed": 3.0 +}] + +[node name="ERROR" type="Control" unique_id=111555268] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Title" type="Label" parent="." unique_id=152037192] +layout_mode = 0 +offset_left = 40.0 +offset_top = 44.0 +offset_right = 680.0 +offset_bottom = 112.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА НЕ ПРОШЛА" +horizontal_alignment = 1 + +[node name="ErrorPanel" type="PanelContainer" parent="." unique_id=2060585109] +layout_mode = 0 +offset_left = 96.0 +offset_top = 176.0 +offset_right = 624.0 +offset_bottom = 826.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="ErrorPanel" unique_id=35202737] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="ErrorPanel/Content" unique_id=910778326] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ОШИБКА ОПЛАТЫ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Body" type="Control" parent="ErrorPanel/Content" unique_id=159572421] +custom_minimum_size = Vector2(0, 538) +layout_mode = 2 + +[node name="Animation" type="AnimatedSprite2D" parent="ErrorPanel/Content/Body" unique_id=13659618] +texture_filter = 1 +position = Vector2(242, 220) +scale = Vector2(0.72, 0.72) +sprite_frames = SubResource("SpriteFrames_failed") +autoplay = "default" + +[node name="Message" type="Label" parent="ErrorPanel/Content/Body" unique_id=685857508] +layout_mode = 0 +offset_left = 30.0 +offset_top = 410.0 +offset_right = 454.0 +offset_bottom = 506.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 22 +text = "ПОПРОБУЙТЕ ЕЩЁ РАЗ +ИЛИ ВЫБЕРИТЕ ДРУГОЙ СПОСОБ" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Home" type="Button" parent="." unique_id=1613589566] +layout_mode = 0 +offset_left = 84.0 +offset_top = 862.0 +offset_right = 636.0 +offset_bottom = 956.0 +theme_type_variation = &"PrimaryButton" +text = "НА ГЛАВНЫЙ ЭКРАН" + +[node name="Footer" parent="." unique_id=214379731 instance=ExtResource("1_footer")] +layout_mode = 1 diff --git a/preview_scenes/HomePreview.tscn b/preview_scenes/HomePreview.tscn new file mode 100644 index 0000000..95f20ff --- /dev/null +++ b/preview_scenes/HomePreview.tscn @@ -0,0 +1,191 @@ +[gd_scene format=3 uid="uid://cs8me8jggvo48"] + +[ext_resource type="PackedScene" path="res://preview_scenes/PreviewFooter.tscn" id="1_footer"] +[ext_resource type="Texture2D" uid="uid://btsthd6xtskwu" path="res://exchanger_theme_dlc/assets/decorations/alien1.png" id="2_alien_left"] +[ext_resource type="Texture2D" uid="uid://niifdcrv3ap" path="res://exchanger_theme_dlc/assets/decorations/alien2.png" id="3_alien_right"] +[ext_resource type="Texture2D" uid="uid://bdyjsoj5lwxu0" path="res://exchanger_theme_dlc/assets/ui/bubble_text.png" id="4_bubble"] +[ext_resource type="Texture2D" uid="uid://c0kbwcs8mic3f" path="res://exchanger_theme_dlc/assets/ui/billboard_ads.png" id="5_billboard"] + +[node name="HOME" type="Control" unique_id=388200253] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="AlienLeft" type="TextureRect" parent="." unique_id=415598416] +texture_filter = 1 +layout_mode = 0 +offset_left = 18.0 +offset_top = 22.0 +offset_right = 172.0 +offset_bottom = 176.0 +mouse_filter = 2 +texture = ExtResource("2_alien_left") +expand_mode = 1 +stretch_mode = 5 + +[node name="PricePanel" type="PanelContainer" parent="." unique_id=952610858] +layout_mode = 0 +offset_left = 148.0 +offset_top = 24.0 +offset_right = 398.0 +offset_bottom = 184.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="PricePanel" unique_id=1295849823] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Title" type="Label" parent="PricePanel/Content" unique_id=123962868] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ЦЕНА ЖЕТОНА" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Price" type="Label" parent="PricePanel/Content" unique_id=245845008] +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 32 +text = "10 РУБ." +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="AlienRight" type="TextureRect" parent="." unique_id=552282646] +texture_filter = 1 +layout_mode = 0 +offset_left = 532.0 +offset_top = 26.0 +offset_right = 696.0 +offset_bottom = 190.0 +mouse_filter = 2 +texture = ExtResource("3_alien_right") +expand_mode = 1 +stretch_mode = 5 + +[node name="Speech" type="TextureRect" parent="." unique_id=1409022347] +texture_filter = 1 +layout_mode = 0 +offset_left = 394.0 +offset_top = 30.0 +offset_right = 574.0 +offset_bottom = 122.0 +mouse_filter = 2 +texture = ExtResource("4_bubble") +expand_mode = 1 +stretch_mode = 5 + +[node name="SpeechText" type="Label" parent="." unique_id=545656950] +layout_mode = 0 +offset_left = 412.0 +offset_top = 48.0 +offset_right = 548.0 +offset_bottom = 102.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 12 +text = "ПОЛУЧИ +СВОИ ЖЕТОНЫ!" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="StockPanel" type="PanelContainer" parent="." unique_id=1108839080] +layout_mode = 0 +offset_left = 30.0 +offset_top = 206.0 +offset_right = 690.0 +offset_bottom = 340.0 +theme_type_variation = &"StatusPanel" + +[node name="Content" type="VBoxContainer" parent="StockPanel" unique_id=676038112] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Title" type="Label" parent="StockPanel/Content" unique_id=2120524316] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ДОСТУПНО К ВЫДАЧЕ" +vertical_alignment = 1 + +[node name="State" type="Label" parent="StockPanel/Content" unique_id=419001717] +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 29 +text = "МНОГО ЖЕТОНОВ :)" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="PaymentPanel" type="PanelContainer" parent="." unique_id=2009320934] +layout_mode = 0 +offset_left = 30.0 +offset_top = 360.0 +offset_right = 690.0 +offset_bottom = 548.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="PaymentPanel" unique_id=773738540] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Title" type="Label" parent="PaymentPanel/Content" unique_id=706439829] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ВЫБЕРИТЕ СПОСОБ ОПЛАТЫ" +vertical_alignment = 1 + +[node name="Buttons" type="HBoxContainer" parent="PaymentPanel/Content" unique_id=1766802133] +layout_mode = 2 +theme_override_constants/separation = 20 +alignment = 1 + +[node name="Cash" type="Button" parent="PaymentPanel/Content/Buttons" unique_id=1036074934] +custom_minimum_size = Vector2(280, 70) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "НАЛИЧНЫЕ" + +[node name="Card" type="Button" parent="PaymentPanel/Content/Buttons" unique_id=1970038171] +custom_minimum_size = Vector2(280, 70) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "БЕЗНАЛИЧНЫЕ" + +[node name="AdvertisementFrame" type="PanelContainer" parent="."] +layout_mode = 0 +offset_left = 62.0 +offset_top = 590.0 +offset_right = 658.0 +offset_bottom = 938.0 +mouse_filter = 2 +theme_type_variation = &"AdvertisementPanel" + +[node name="Billboard" type="TextureRect" parent="." unique_id=943947521] +texture_filter = 1 +layout_mode = 0 +offset_left = 72.0 +offset_top = 602.0 +offset_right = 648.0 +offset_bottom = 926.0 +mouse_filter = 2 +texture = ExtResource("5_billboard") +expand_mode = 1 +stretch_mode = 5 + +[node name="AdLabel" type="Label" parent="." unique_id=353224380] +layout_mode = 0 +offset_left = 174.0 +offset_top = 730.0 +offset_right = 546.0 +offset_bottom = 792.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 20 +text = "ПРОМО-БЛОК" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Footer" parent="." unique_id=279899163 instance=ExtResource("1_footer")] +layout_mode = 1 diff --git a/preview_scenes/PreviewFooter.tscn b/preview_scenes/PreviewFooter.tscn new file mode 100644 index 0000000..e97cfdc --- /dev/null +++ b/preview_scenes/PreviewFooter.tscn @@ -0,0 +1,45 @@ +[gd_scene format=3] + +[node name="PreviewFooter" type="Control"] +layout_mode = 3 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -82.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 2 + +[node name="Background" type="PanelContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_type_variation = &"FooterPanel" + +[node name="Brand" type="Label" parent="."] +layout_mode = 0 +offset_left = 28.0 +offset_top = 8.0 +offset_right = 296.0 +offset_bottom = 74.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 20 +text = "РАЗМЕН\nЖЕТОНОВ" +vertical_alignment = 1 + +[node name="Support" type="Label" parent="."] +layout_mode = 0 +offset_left = 310.0 +offset_top = 8.0 +offset_right = 692.0 +offset_bottom = 74.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 18 +text = "ТЕХПОДДЕРЖКА:\nТелефон поддержки уточняется" +horizontal_alignment = 2 +vertical_alignment = 1 diff --git a/preview_scenes/PreviewLiveReload.gd b/preview_scenes/PreviewLiveReload.gd new file mode 100644 index 0000000..5b48ee9 --- /dev/null +++ b/preview_scenes/PreviewLiveReload.gd @@ -0,0 +1,157 @@ +extends Control + +const POLL_INTERVAL_SECONDS := 0.25 +const RELOAD_DEBOUNCE_SECONDS := 0.45 +const SELECTED_TAB_META := &"theme_preview_selected_tab" +const SELECTED_SETTINGS_TAB_META := &"theme_preview_selected_settings_tab" +const WATCHED_DIRECTORIES := [ + "res://exchanger_theme_dlc", + "res://preview_scenes", +] +const WATCHED_FILES := [ + "res://preview.tscn", +] +const WATCHED_EXTENSIONS := { + "jpeg": true, + "jpg": true, + "json": true, + "gd": true, + "mp3": true, + "ogg": true, + "otf": true, + "png": true, + "svg": true, + "tres": true, + "tscn": true, + "ttf": true, + "wav": true, + "webp": true, +} +const HASHED_EXTENSIONS := { + "json": true, + "tres": true, + "tscn": true, +} + +var _poll_elapsed := 0.0 +var _reload_countdown := -1.0 +var _snapshot: Dictionary = {} +var _pending_changes: Dictionary = {} + + +func _ready() -> void: + _restore_selected_tab() + _snapshot = _capture_snapshot() + print("[Theme Preview] Live reload is active.") + + +func _process(delta: float) -> void: + if _reload_countdown >= 0.0: + _reload_countdown -= delta + if _reload_countdown <= 0.0: + _reload_preview() + return + + _poll_elapsed += delta + if _poll_elapsed < POLL_INTERVAL_SECONDS: + return + + _poll_elapsed = 0.0 + var next_snapshot := _capture_snapshot() + var changed_paths := _find_changed_paths(_snapshot, next_snapshot) + _snapshot = next_snapshot + if changed_paths.is_empty(): + return + + for path in changed_paths: + _pending_changes[path] = true + _reload_countdown = RELOAD_DEBOUNCE_SECONDS + + +func _restore_selected_tab() -> void: + var screens := get_node_or_null("Screens") as TabContainer + if screens == null or screens.get_tab_count() == 0: + return + + var selected_tab := int(get_tree().root.get_meta(SELECTED_TAB_META, screens.current_tab)) + screens.current_tab = clampi(selected_tab, 0, screens.get_tab_count() - 1) + + var settings_tabs := get_node_or_null("Screens/SETTINGS/Sections") as TabContainer + if settings_tabs != null and settings_tabs.get_tab_count() > 0: + var selected_settings_tab := int( + get_tree().root.get_meta(SELECTED_SETTINGS_TAB_META, settings_tabs.current_tab) + ) + settings_tabs.current_tab = clampi( + selected_settings_tab, + 0, + settings_tabs.get_tab_count() - 1 + ) + + +func _capture_snapshot() -> Dictionary: + var result: Dictionary = {} + for directory_path in WATCHED_DIRECTORIES: + _scan_directory(directory_path, result) + for file_path in WATCHED_FILES: + if FileAccess.file_exists(file_path): + result[file_path] = _file_fingerprint(file_path) + return result + + +func _scan_directory(directory_path: String, result: Dictionary) -> void: + if not DirAccess.dir_exists_absolute(directory_path): + return + + for file_name in DirAccess.get_files_at(directory_path): + var file_path := directory_path.path_join(file_name) + var extension := file_name.get_extension().to_lower() + if WATCHED_EXTENSIONS.has(extension): + result[file_path] = _file_fingerprint(file_path) + + for child_directory in DirAccess.get_directories_at(directory_path): + _scan_directory(directory_path.path_join(child_directory), result) + + +func _file_fingerprint(file_path: String) -> String: + var modified_time := FileAccess.get_modified_time(file_path) + var file := FileAccess.open(file_path, FileAccess.READ) + var file_size := file.get_length() if file != null else -1 + var extension := file_path.get_extension().to_lower() + if HASHED_EXTENSIONS.has(extension): + return "%s:%s:%s" % [modified_time, file_size, FileAccess.get_md5(file_path)] + return "%s:%s" % [modified_time, file_size] + + +func _find_changed_paths(previous: Dictionary, current: Dictionary) -> PackedStringArray: + var changed := PackedStringArray() + for path in current: + if not previous.has(path) or previous[path] != current[path]: + changed.append(path) + for path in previous: + if not current.has(path): + changed.append(path) + return changed + + +func _reload_preview() -> void: + var screens := get_node_or_null("Screens") as TabContainer + if screens != null: + get_tree().root.set_meta(SELECTED_TAB_META, screens.current_tab) + + var settings_tabs := get_node_or_null("Screens/SETTINGS/Sections") as TabContainer + if settings_tabs != null: + get_tree().root.set_meta(SELECTED_SETTINGS_TAB_META, settings_tabs.current_tab) + + var changed_paths := PackedStringArray(_pending_changes.keys()) + changed_paths.sort() + _pending_changes.clear() + _reload_countdown = -1.0 + + for path in changed_paths: + if ResourceLoader.exists(path): + ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_REPLACE_DEEP) + + print("[Theme Preview] Reloading after changes: %s" % ", ".join(changed_paths)) + var reload_error := get_tree().reload_current_scene() + if reload_error != OK: + push_error("[Theme Preview] Scene reload failed with error %s." % reload_error) diff --git a/preview_scenes/ServiceAccessPreview.tscn b/preview_scenes/ServiceAccessPreview.tscn new file mode 100644 index 0000000..efb6168 --- /dev/null +++ b/preview_scenes/ServiceAccessPreview.tscn @@ -0,0 +1,172 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="PackedScene" path="res://preview_scenes/PreviewFooter.tscn" id="1_footer"] + +[node name="SERVICE" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Title" type="Label" parent="."] +layout_mode = 0 +offset_left = 40.0 +offset_top = 18.0 +offset_right = 680.0 +offset_bottom = 72.0 +theme_type_variation = &"ScreenTitle" +theme_override_font_sizes/font_size = 34 +text = "ВХОД В НАСТРОЙКИ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Description" type="Label" parent="."] +layout_mode = 0 +offset_left = 52.0 +offset_top = 78.0 +offset_right = 668.0 +offset_bottom = 140.0 +theme_type_variation = &"ScreenDescription" +theme_override_font_sizes/font_size = 16 +text = "ВВЕДИТЕ PIN-КОД, ЧТОБЫ ОТКРЫТЬ НАСТРОЙКИ" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="AccessPanel" type="PanelContainer" parent="."] +layout_mode = 0 +offset_left = 60.0 +offset_top = 152.0 +offset_right = 660.0 +offset_bottom = 916.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="AccessPanel"] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="AccessPanel/Content"] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "PIN-КОД" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="PinDisplay" type="Label" parent="AccessPanel/Content"] +custom_minimum_size = Vector2(0, 62) +layout_mode = 2 +theme_type_variation = &"StatusText" +theme_override_font_sizes/font_size = 34 +text = "—" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Status" type="Label" parent="AccessPanel/Content"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 17 +text = "ПОСЛЕ ПЯТИ ОШИБОК ВХОД БЛОКИРУЕТСЯ НА 30 СЕКУНД" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Keypad" type="GridContainer" parent="AccessPanel/Content"] +layout_mode = 2 +theme_override_constants/h_separation = 12 +theme_override_constants/v_separation = 12 +columns = 3 + +[node name="Digit1" type="Button" parent="AccessPanel/Content/Keypad"] +custom_minimum_size = Vector2(170, 76) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "1" + +[node name="Digit2" type="Button" parent="AccessPanel/Content/Keypad"] +custom_minimum_size = Vector2(170, 76) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "2" + +[node name="Digit3" type="Button" parent="AccessPanel/Content/Keypad"] +custom_minimum_size = Vector2(170, 76) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "3" + +[node name="Digit4" type="Button" parent="AccessPanel/Content/Keypad"] +custom_minimum_size = Vector2(170, 76) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "4" + +[node name="Digit5" type="Button" parent="AccessPanel/Content/Keypad"] +custom_minimum_size = Vector2(170, 76) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "5" + +[node name="Digit6" type="Button" parent="AccessPanel/Content/Keypad"] +custom_minimum_size = Vector2(170, 76) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "6" + +[node name="Digit7" type="Button" parent="AccessPanel/Content/Keypad"] +custom_minimum_size = Vector2(170, 76) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "7" + +[node name="Digit8" type="Button" parent="AccessPanel/Content/Keypad"] +custom_minimum_size = Vector2(170, 76) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "8" + +[node name="Digit9" type="Button" parent="AccessPanel/Content/Keypad"] +custom_minimum_size = Vector2(170, 76) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "9" + +[node name="Clear" type="Button" parent="AccessPanel/Content/Keypad"] +custom_minimum_size = Vector2(170, 76) +layout_mode = 2 +theme_type_variation = &"CompactButton" +theme_override_font_sizes/font_size = 16 +text = "СБРОС" + +[node name="Digit0" type="Button" parent="AccessPanel/Content/Keypad"] +custom_minimum_size = Vector2(170, 76) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "0" + +[node name="Erase" type="Button" parent="AccessPanel/Content/Keypad"] +custom_minimum_size = Vector2(170, 76) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "⌫" + +[node name="Enter" type="Button" parent="AccessPanel/Content"] +custom_minimum_size = Vector2(0, 90) +layout_mode = 2 +theme_type_variation = &"PrimaryButton" +theme_override_font_sizes/font_size = 32 +text = "ВОЙТИ" + +[node name="Cancel" type="Button" parent="."] +layout_mode = 0 +offset_left = 110.0 +offset_top = 940.0 +offset_right = 610.0 +offset_bottom = 1026.0 +theme_type_variation = &"SecondaryButton" +text = "ОТМЕНА" + +[node name="Footer" parent="." instance=ExtResource("1_footer")] diff --git a/preview_scenes/SettingsPreview.gd b/preview_scenes/SettingsPreview.gd new file mode 100644 index 0000000..f560e53 --- /dev/null +++ b/preview_scenes/SettingsPreview.gd @@ -0,0 +1,317 @@ +extends Control + +const PANEL_TITLE_SIZE := 46.0 +const FIELD_HEIGHT := 62.0 +const ACTION_HEIGHT := 66.0 + +@onready var _page: VBoxContainer = $PageScroll/PageMargin/Page +@onready var _buttons: Array[Button] = [ + $SectionTabs/General, + $SectionTabs/Audio, + $SectionTabs/Advertisement, + $SectionTabs/Pricing, + $SectionTabs/Payment, +] + + +func _ready() -> void: + for index in _buttons.size(): + _buttons[index].pressed.connect(_show_section.bind(index)) + _show_section(0) + + +func _show_section(index: int) -> void: + for child in _page.get_children(): + child.free() + for button_index in _buttons.size(): + _buttons[button_index].button_pressed = button_index == index + match index: + 0: _build_general(_page) + 1: _build_audio(_page) + 2: _build_advertisement(_page) + 3: _build_pricing(_page) + 4: _build_payment(_page) + + +func _add_panel(page: VBoxContainer, title: String, minimum_height: float = 0.0) -> VBoxContainer: + var panel := PanelContainer.new() + panel.theme_type_variation = &"WhitePanel" + panel.custom_minimum_size = Vector2(0, minimum_height) + page.add_child(panel) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 25) + margin.add_theme_constant_override("margin_top", 23) + margin.add_theme_constant_override("margin_right", 25) + margin.add_theme_constant_override("margin_bottom", 23) + panel.add_child(margin) + + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 9) + margin.add_child(content) + + var header := Label.new() + header.custom_minimum_size = Vector2(0, PANEL_TITLE_SIZE) + header.theme_type_variation = &"PanelTitle" + header.add_theme_font_size_override("font_size", 23) + header.text = title + header.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + content.add_child(header) + + var body := VBoxContainer.new() + body.add_theme_constant_override("separation", 9) + content.add_child(body) + return body + + +func _panel_label(text: String, font_size: int = 18, centered: bool = false) -> Label: + var label := Label.new() + label.theme_type_variation = &"PanelText" + label.text = text + if text.length() > 42 or text.contains("\n"): + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.add_theme_font_size_override("font_size", font_size) + if centered: + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + return label + + +func _semantic_label(text: String, variation: StringName, centered: bool = false) -> Label: + var label := Label.new() + label.theme_type_variation = variation + label.text = text + if text.length() > 42 or text.contains("\n"): + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + if centered: + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + return label + + +func _line_edit(placeholder: String, value: String = "") -> LineEdit: + var field := LineEdit.new() + field.custom_minimum_size = Vector2(0, FIELD_HEIGHT) + field.placeholder_text = placeholder + field.text = value + return field + + +func _check(text: String, pressed: bool = false, disabled: bool = false) -> CheckButton: + var field := CheckButton.new() + field.custom_minimum_size = Vector2(0, ACTION_HEIGHT) + field.text = text + field.button_pressed = pressed + field.disabled = disabled + return field + + +func _action(text: String) -> Button: + var button := Button.new() + button.custom_minimum_size = Vector2(0, ACTION_HEIGHT) + button.theme_type_variation = &"CompactButton" + button.text = text + return button + + +func _semantic_action(text: String, variation: StringName) -> Button: + var button := Button.new() + button.custom_minimum_size = Vector2(0, ACTION_HEIGHT) + button.theme_type_variation = variation + button.text = text + return button + + +func _spin(minimum: float, maximum: float, value: float, suffix: String = "") -> SpinBox: + var field := SpinBox.new() + field.custom_minimum_size = Vector2(190, FIELD_HEIGHT) + field.min_value = minimum + field.max_value = maximum + field.value = value + field.suffix = suffix + return field + + +func _option(items: PackedStringArray, selected: int = 0) -> OptionButton: + var field := OptionButton.new() + field.custom_minimum_size = Vector2(0, FIELD_HEIGHT) + for item in items: + field.add_item(item) + field.select(clampi(selected, 0, maxi(0, field.item_count - 1))) + return field + + +func _row() -> HBoxContainer: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 14) + return row + + +func _expand(control: Control) -> Control: + control.size_flags_horizontal = Control.SIZE_EXPAND_FILL + return control + + +func _build_general(page: VBoxContainer) -> void: + var identity := _add_panel(page, "ТЕКСТ НА ГЛАВНОМ ЭКРАНЕ", 593) + identity.add_child(_semantic_label("НАЗВАНИЕ • ДО 48 ЗНАКОВ", &"FieldLabel")) + identity.add_child(_line_edit("РАЗМЕН ЖЕТОНОВ", "РАЗМЕН ЖЕТОНОВ")) + identity.add_child(_semantic_label("КОРОТКОЕ ОПИСАНИЕ • ДО 80 ЗНАКОВ", &"FieldLabel")) + identity.add_child(_line_edit("Автомат выдачи жетонов", "Автомат выдачи жетонов")) + identity.add_child(_semantic_label("ТЕЛЕФОН ПОДДЕРЖКИ • ДО 80 ЗНАКОВ", &"FieldLabel")) + identity.add_child(_line_edit("Телефон поддержки уточняется", "Телефон поддержки уточняется")) + + var preview := _add_panel(page, "КАК ЭТО УВИДИТ ПОКУПАТЕЛЬ", 273) + preview.add_child(_panel_label("РАЗМЕН ЖЕТОНОВ", 28, true)) + preview.add_child(_panel_label("Автомат выдачи жетонов", 20, true)) + preview.add_child(_panel_label("ТЕХПОДДЕРЖКА • Телефон поддержки уточняется", 17, true)) + + +func _build_audio(page: VBoxContainer) -> void: + var master := _add_panel(page, "ОБЩАЯ ГРОМКОСТЬ", 330) + master.add_child(_panel_label("ВЛИЯЕТ НА ВСЕ ЗВУКИ ПРИЛОЖЕНИЯ")) + var master_row := _row() + var master_slider := HSlider.new() + master_slider.custom_minimum_size = Vector2(0, 60) + master_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + master_slider.max_value = 100 + master_slider.value = 80 + master_row.add_child(master_slider) + master_row.add_child(_panel_label("80%", 22)) + master.add_child(master_row) + master.add_child(_check("ОТКЛЮЧИТЬ ВСЕ ЗВУКИ")) + + var advertisement := _add_panel(page, "ГРОМКОСТЬ ПРОМО-РОЛИКА", 250) + advertisement.add_child(_panel_label("ОТДЕЛЬНАЯ ГРОМКОСТЬ ПРОМО-РОЛИКОВ")) + var ad_slider := HSlider.new() + ad_slider.custom_minimum_size = Vector2(0, 60) + ad_slider.max_value = 100 + ad_slider.value = 45 + advertisement.add_child(ad_slider) + + var test := _add_panel(page, "ПРОВЕРКА", 250) + test.add_child(_action("ПРОВЕРИТЬ ЗВУК")) + test.add_child(_semantic_label("ИЗМЕНЕНИЯ СОХРАНЯЮТСЯ АВТОМАТИЧЕСКИ", &"StatusText", true)) + + +func _build_advertisement(page: VBoxContainer) -> void: + var settings := _add_panel(page, "ПРОМО НА ГЛАВНОМ ЭКРАНЕ", 390) + settings.add_child(_check("ПОКАЗЫВАТЬ ПРОМО", true)) + settings.add_child(_check("ПРОМО-РОЛИК БЕЗ ЗВУКА", true)) + settings.add_child(_semantic_label("ТЕКСТ ВМЕСТО ПРОМО • ДО 120 СИМВОЛОВ", &"FieldLabel")) + settings.add_child(_line_edit("ПРОМО-БЛОК", "ПОЛУЧИ СВОИ ЖЕТОНЫ!")) + + var media := _add_panel(page, "ПОСТЕР И ПЛЕЙЛИСТ", 390) + media.add_child(_semantic_label("ПУТЬ К ПОСТЕРУ PNG / JPG / WEBP", &"FieldLabel")) + media.add_child(_line_edit("user://promo/poster.png")) + media.add_child(_semantic_label("ЛОКАЛЬНЫЙ ПРОМО-РОЛИК OGV", &"FieldLabel")) + var video_row := _row() + video_row.add_child(_expand(_line_edit("D:/media/promo.ogv"))) + var remove := _action("УДАЛИТЬ") + remove.custom_minimum_size.x = 150 + remove.theme_type_variation = &"DangerButton" + video_row.add_child(remove) + media.add_child(video_row) + media.add_child(_action("+ ДОБАВИТЬ ПРОМО-РОЛИК")) + + var summary := _add_panel(page, "ПРЕДВАРИТЕЛЬНЫЙ ИТОГ", 190) + summary.add_child(_panel_label("ПРОМО ВКЛЮЧЕНО • ПОСТЕР ЗАДАН • ПРОМО-РОЛИКОВ: 1", 17, true)) + + +func _build_appearance(page: VBoxContainer) -> void: + var theme_panel := _add_panel(page, "ВНЕШНЯЯ ТЕМА", 390) + theme_panel.add_child(_check("ИСПОЛЬЗОВАТЬ ВНЕШНИЙ DLC", true)) + theme_panel.add_child(_semantic_label("ПУТЬ К ПАКЕТУ .PCK", &"FieldLabel")) + theme_panel.add_child(_line_edit("user://theme_dlc/active_theme.pck", "user://theme_dlc/active_theme.pck")) + theme_panel.add_child(_semantic_label("ПАКЕТ НАЙДЕН. РЕСУРСЫ ПРОВЕРЯТСЯ ПРИ ЗАПУСКЕ.", &"StatusText")) + + var effects := _add_panel(page, "ЭФФЕКТЫ", 250) + effects.add_child(_panel_label("ОБЛЕГЧЁННЫЙ РЕЖИМ ОТКЛЮЧАЕТ НЕОБЯЗАТЕЛЬНЫЕ СПРАЙТЫ", 17)) + effects.add_child(_check("ОБЛЕГЧЁННЫЙ РЕЖИМ")) + + var status := _add_panel(page, "ПРИМЕНЕНИЕ ИЗМЕНЕНИЙ", 240) + status.add_child(_panel_label("ЭФФЕКТЫ ПРИМЕНЯЮТСЯ СРАЗУ.", 17)) + status.add_child(_panel_label("DLC И ПУТЬ — ПОСЛЕ ПЕРЕЗАПУСКА.", 17)) + status.add_child(_panel_label("СЕЙЧАС АКТИВНА: SPACE PIXEL", 18)) + + +func _build_pricing(page: VBoxContainer) -> void: + var price := _add_panel(page, "СТОИМОСТЬ ОДНОГО ЖЕТОНА", 260) + price.add_child(_panel_label("ЦЕНА ДОЛЖНА БЫТЬ ПОЛОЖИТЕЛЬНОЙ")) + price.add_child(_spin(1, 10000, 10, " ₽")) + + var bonus := _add_panel(page, "ДОПОЛНИТЕЛЬНЫЕ ЖЕТОНЫ", 380) + bonus.add_child(_panel_label("ПОРОГ СУММЫ → БОНУС")) + var bonus_row := _row() + bonus_row.add_child(_expand(_spin(10, 100000, 500, " ₽"))) + bonus_row.add_child(_panel_label("→", 24, true)) + bonus_row.add_child(_expand(_spin(0, 10000, 50, " ЖЕТ."))) + bonus.add_child(bonus_row) + bonus.add_child(_action("+ ДОБАВИТЬ ПРАВИЛО")) + + var preview := _add_panel(page, "ПРЕДВАРИТЕЛЬНЫЙ РАСЧЁТ", 230) + preview.add_child(_panel_label("100 ₽ → 10 + 0 = 10 ЖЕТ.\n500 ₽ → 50 + 50 = 100 ЖЕТ.", 19, true)) + + +func _build_payment(page: VBoxContainer) -> void: + var methods := _add_panel(page, "СПОСОБЫ ОПЛАТЫ", 280) + methods.add_child(_check("НАЛИЧНАЯ ОПЛАТА", true)) + methods.add_child(_check("ОПЛАТА КАРТОЙ", true)) + + var card := _add_panel(page, "КАРТОЧНЫЕ СУММЫ", 400) + card.add_child(_panel_label("МАКСИМАЛЬНАЯ СУММА")) + card.add_child(_spin(10, 100000, 5000, " ₽")) + card.add_child(_panel_label("ШАГ РУЧНОГО ВВОДА")) + card.add_child(_spin(1, 10000, 10, " ₽")) + card.add_child(_action("+ ДОБАВИТЬ СУММУ")) + + var timeouts := _add_panel(page, "ТАЙМАУТЫ", 330) + for item in [["БЕЗДЕЙСТВИЕ", 120], ["ОЖИДАНИЕ ОПЛАТЫ", 90], ["ПОКАЗ РЕЗУЛЬТАТА", 8]]: + var timeout_row := _row() + timeout_row.add_child(_expand(_panel_label(item[0], 17))) + timeout_row.add_child(_spin(3, 600, item[1], " С")) + timeouts.add_child(timeout_row) + + +func _build_equipment(page: VBoxContainer) -> void: + var connection := _add_panel(page, "ПОДКЛЮЧЕНИЕ К КОНТРОЛЛЕРУ", 500) + connection.add_child(_check("ИСПОЛЬЗОВАТЬ ИМИТАЦИЮ ОБОРУДОВАНИЯ")) + connection.add_child(_panel_label("COM-ПОРТ")) + connection.add_child(_option(PackedStringArray(["COM1", "COM3", "COM7"]), 1)) + connection.add_child(_panel_label("СКОРОСТЬ ПОРТА")) + connection.add_child(_option(PackedStringArray(["9600", "57600", "115200"]), 2)) + connection.add_child(_panel_label("ПОВТОР ПОДКЛЮЧЕНИЯ")) + connection.add_child(_spin(1, 60, 5, " С")) + + var window := _add_panel(page, "ОКНО И ПРОИЗВОДИТЕЛЬНОСТЬ", 390) + window.add_child(_check("ПОЛНОЭКРАННЫЙ РЕЖИМ", true)) + window.add_child(_check("ОКНО БЕЗ РАМКИ", true)) + window.add_child(_check("DEBUG-СБОРКА В ОКНЕ • НЕДОСТУПНО", false, true)) + window.add_child(_option(PackedStringArray(["30 FPS", "60 FPS", "120 FPS"]), 1)) + + +func _build_diagnostics(page: VBoxContainer) -> void: + var status := _add_panel(page, "СОСТОЯНИЕ ПРИЛОЖЕНИЯ", 290) + status.add_child(_panel_label("ВЕРСИЯ: 0.1.0 • КОНТРОЛЛЕР: ГОТОВ", 18)) + status.add_child(_panel_label("ПОСЛЕДНИЕ БЕЗОПАСНЫЕ ПРЕДУПРЕЖДЕНИЯ")) + status.add_child(_panel_label("НЕТ ПРЕДУПРЕЖДЕНИЙ.", 17)) + + var logging := _add_panel(page, "ЖУРНАЛИРОВАНИЕ", 390) + logging.add_child(_panel_label("МИНИМАЛЬНЫЙ УРОВЕНЬ")) + logging.add_child(_option(PackedStringArray(["DEBUG", "INFO", "WARNING", "ERROR"]), 1)) + var limits := _row() + limits.add_child(_expand(_spin(1, 100, 10, " МБ"))) + limits.add_child(_expand(_spin(1, 365, 30, " ДН."))) + logging.add_child(limits) + + var test := _add_panel(page, "ТЕСТОВАЯ ВЫДАЧА", 280) + test.add_child(_panel_label("ВЫДАЁТ ОДИН ЖЕТОН БЕЗ ОПЛАТЫ.", 17)) + test.add_child(_action("ПОДТВЕРДИТЬ ВЫДАЧУ 1 ЖЕТОНА")) + test.add_child(_semantic_label("НАЖМИТЕ КНОПКУ ЕЩЁ РАЗ ДЛЯ ПОДТВЕРЖДЕНИЯ.", &"WarningText", true)) + + var contract := _add_panel(page, "СОСТОЯНИЯ UI-КОНТРАКТА", 420) + contract.add_child(_semantic_label("ПОЛЕ: ОБЯЗАТЕЛЬНОЕ ЗНАЧЕНИЕ", &"FieldLabel")) + contract.add_child(_semantic_label("ГОТОВО: ИЗМЕНЕНИЯ СОХРАНЕНЫ.", &"StatusText")) + contract.add_child(_semantic_label("ПРЕДУПРЕЖДЕНИЕ: НУЖЕН ПЕРЕЗАПУСК.", &"WarningText")) + contract.add_child(_semantic_label("ОШИБКА: ИСПРАВЬТЕ ЗНАЧЕНИЕ.", &"ErrorText")) + contract.add_child(_semantic_action("ВТОРИЧНОЕ ДЕЙСТВИЕ", &"SecondaryButton")) + contract.add_child(_semantic_action("ОПАСНОЕ ДЕЙСТВИЕ", &"DangerButton")) + contract.add_child(_semantic_action("ВКЛАДКА РАЗДЕЛА", &"SectionTabButton")) diff --git a/preview_scenes/SettingsPreview.tscn b/preview_scenes/SettingsPreview.tscn new file mode 100644 index 0000000..f583853 --- /dev/null +++ b/preview_scenes/SettingsPreview.tscn @@ -0,0 +1,100 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="PackedScene" path="res://preview_scenes/PreviewFooter.tscn" id="1_footer"] +[ext_resource type="Script" path="res://preview_scenes/SettingsPreview.gd" id="2_script"] + +[node name="SETTINGS" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("2_script") + +[node name="Header" type="PanelContainer" parent="."] +layout_mode = 0 +offset_left = 28.0 +offset_top = 28.0 +offset_right = 692.0 +offset_bottom = 142.0 + +[node name="Title" type="Label" parent="Header"] +layout_mode = 2 +theme_type_variation = &"ScreenTitle" +theme_override_font_sizes/font_size = 31 +text = "НАСТРОЙКИ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="SectionTabs" type="GridContainer" parent="."] +layout_mode = 0 +offset_left = 60.0 +offset_top = 162.0 +offset_right = 660.0 +offset_bottom = 319.0 +theme_override_constants/h_separation = 12 +theme_override_constants/v_separation = 12 +columns = 3 + +[node name="General" type="Button" parent="SectionTabs"] +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"SectionTabButton" +text = "ОБЩИЕ" +toggle_mode = true + +[node name="Audio" type="Button" parent="SectionTabs"] +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"SectionTabButton" +text = "ЗВУК" +toggle_mode = true + +[node name="Advertisement" type="Button" parent="SectionTabs"] +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"SectionTabButton" +text = "ПРОМО" +toggle_mode = true + +[node name="Pricing" type="Button" parent="SectionTabs"] +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"SectionTabButton" +text = "ТАРИФЫ" +toggle_mode = true + +[node name="Payment" type="Button" parent="SectionTabs"] +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"SectionTabButton" +text = "ОПЛАТА" +toggle_mode = true + +[node name="PageScroll" type="ScrollContainer" parent="."] +layout_mode = 0 +offset_left = 28.0 +offset_top = 340.0 +offset_right = 692.0 +offset_bottom = 1170.0 +horizontal_scroll_mode = 0 + +[node name="PageMargin" type="MarginContainer" parent="PageScroll"] +custom_minimum_size = Vector2(646, 0) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/margin_right = 10 + +[node name="Page" type="VBoxContainer" parent="PageScroll/PageMargin"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 19 + +[node name="Footer" parent="." instance=ExtResource("1_footer")] +layout_mode = 1 diff --git a/preview_scenes/SuccessPreview.tscn b/preview_scenes/SuccessPreview.tscn new file mode 100644 index 0000000..d489cef --- /dev/null +++ b/preview_scenes/SuccessPreview.tscn @@ -0,0 +1,105 @@ +[gd_scene format=3 uid="uid://cyepwpigxpvau"] + +[ext_resource type="PackedScene" path="res://preview_scenes/PreviewFooter.tscn" id="1_footer"] +[ext_resource type="Texture2D" uid="uid://bxc8ku68xb4r8" path="res://exchanger_theme_dlc/assets/animations/payment_success/frame_01.png" id="2_success_1"] +[ext_resource type="Texture2D" uid="uid://clubcslpx4qdr" path="res://exchanger_theme_dlc/assets/animations/payment_success/frame_02.png" id="3_success_2"] +[ext_resource type="Texture2D" uid="uid://ckqpek4skkm77" path="res://exchanger_theme_dlc/assets/animations/payment_success/frame_03.png" id="4_success_3"] +[ext_resource type="Texture2D" uid="uid://c33lc27tqncru" path="res://exchanger_theme_dlc/assets/animations/payment_success/frame_04.png" id="5_success_4"] +[ext_resource type="Texture2D" uid="uid://w8adsbfld640" path="res://exchanger_theme_dlc/assets/animations/payment_success/frame_05.png" id="6_success_5"] + +[sub_resource type="SpriteFrames" id="SpriteFrames_success"] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": ExtResource("2_success_1") +}, { +"duration": 1.0, +"texture": ExtResource("3_success_2") +}, { +"duration": 1.0, +"texture": ExtResource("4_success_3") +}, { +"duration": 1.0, +"texture": ExtResource("5_success_4") +}, { +"duration": 1.0, +"texture": ExtResource("6_success_5") +}], +"loop": 1, +"name": &"default", +"speed": 5.0 +}] + +[node name="SUCCESS" type="Control" unique_id=542554992] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Title" type="Label" parent="." unique_id=1877645034] +layout_mode = 0 +offset_left = 40.0 +offset_top = 44.0 +offset_right = 680.0 +offset_bottom = 112.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА ПРОШЛА" +horizontal_alignment = 1 + +[node name="ResultPanel" type="PanelContainer" parent="." unique_id=1033809395] +layout_mode = 0 +offset_left = 96.0 +offset_top = 176.0 +offset_right = 624.0 +offset_bottom = 826.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="ResultPanel" unique_id=1107502705] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="ResultPanel/Content" unique_id=3989466] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ВЫДАЧА ЖЕТОНОВ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Body" type="Control" parent="ResultPanel/Content" unique_id=652635619] +custom_minimum_size = Vector2(0, 538) +layout_mode = 2 + +[node name="Animation" type="AnimatedSprite2D" parent="ResultPanel/Content/Body" unique_id=118426354] +texture_filter = 1 +position = Vector2(242, 220) +scale = Vector2(0.72, 0.72) +sprite_frames = SubResource("SpriteFrames_success") +autoplay = "default" + +[node name="Message" type="Label" parent="ResultPanel/Content/Body" unique_id=11368542] +layout_mode = 0 +offset_left = 28.0 +offset_top = 418.0 +offset_right = 456.0 +offset_bottom = 504.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 23 +text = "ЗАБЕРИТЕ 100 ЖЕТОНОВ +СПАСИБО ЗА ПОКУПКУ!" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Home" type="Button" parent="." unique_id=1638722420] +layout_mode = 0 +offset_left = 84.0 +offset_top = 862.0 +offset_right = 636.0 +offset_bottom = 956.0 +theme_type_variation = &"PrimaryButton" +text = "НА ГЛАВНЫЙ ЭКРАН" + +[node name="Footer" parent="." unique_id=1175664387 instance=ExtResource("1_footer")] +layout_mode = 1 diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..794db94 --- /dev/null +++ b/project.godot @@ -0,0 +1,47 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Exchanger Theme DLC Builder" +run/main_scene="res://tools/preview/preview.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") + +[autoload] + +_mcp_game_helper="*res://addons/godot_ai/runtime/game_helper.gd" + +[display] + +window/size/viewport_width=720 +window/size/viewport_height=1280 +window/size/window_width_override=720 +window/size/window_height_override=1280 +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" + +[dotnet] + +project/assembly_name="Exchanger Theme DLC Builder" + +[editor] + +export/convert_text_resources_to_binary=false + +[editor_plugins] + +enabled=PackedStringArray("res://addons/exchanger_theme_manager/plugin.cfg", "res://addons/godot_ai/plugin.cfg") + +[rendering] + +textures/canvas_textures/default_texture_filter=0 +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +textures/vram_compression/import_etc2_astc=true diff --git a/test-theme.ps1 b/test-theme.ps1 new file mode 100644 index 0000000..3240571 --- /dev/null +++ b/test-theme.ps1 @@ -0,0 +1,58 @@ +param( + [string]$ThemeId = "space-pixel", + [string]$ThemeSource = "", + [string]$GodotExecutable = "", + [switch]$SkipPreviewSmoke +) + +$ErrorActionPreference = "Stop" +$projectRoot = (Resolve-Path $PSScriptRoot).Path +. (Join-Path $projectRoot "tools\Resolve-GodotExecutable.ps1") +$GodotExecutable = Resolve-ThemeGodotExecutable -GodotExecutable $GodotExecutable + +& (Join-Path $projectRoot "tools\Select-Theme.ps1") -ThemeId $ThemeId -ThemeSource $ThemeSource +$activeState = Get-Content -LiteralPath (Join-Path $projectRoot "build\active-theme.json") -Raw -Encoding UTF8 | ConvertFrom-Json +$packageRoot = [System.IO.Path]::GetFullPath([string]$activeState.Package) +$workspaceRoot = [System.IO.Path]::GetFullPath((Join-Path $projectRoot ("build\theme-workspaces\" + $ThemeId + "-test"))) +$allowedWorkspaceRoot = [System.IO.Path]::GetFullPath((Join-Path $projectRoot "build\theme-workspaces")) + +try { + & (Join-Path $projectRoot "tools\Prepare-ThemeWorkspace.ps1") -PackageRoot $packageRoot -WorkspaceRoot $workspaceRoot + + & $GodotExecutable --headless --path $workspaceRoot --import + if ($LASTEXITCODE -ne 0) { + throw "Godot could not import resources for theme '$ThemeId'." + } + + & $GodotExecutable --headless --path $workspaceRoot --script res://tools/validate_theme.gd -- --contract res://contracts/screen_bindings.v2.json + if ($LASTEXITCODE -ne 0) { + throw "Theme '$ThemeId' does not satisfy manifest v2 or the binding contract." + } + + & $GodotExecutable --headless --path $projectRoot --script res://tools/verify_interactive_pet.gd -- --theme $ThemeId --optional + if ($LASTEXITCODE -ne 0) { + throw "Interactive pet verification failed for theme '$ThemeId'." + } + + if (-not $SkipPreviewSmoke) { + if (-not $packageRoot.StartsWith($projectRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Preview smoke requires a theme package inside the builder project." + } + & $GodotExecutable --headless --path $projectRoot --quit-after 3 + if ($LASTEXITCODE -ne 0) { + throw "Headless preview smoke for theme '$ThemeId' failed." + } + } +} finally { + if ( + (Test-Path -LiteralPath $workspaceRoot) -and + $workspaceRoot.StartsWith($allowedWorkspaceRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) + ) { + Remove-Item -LiteralPath $workspaceRoot -Recurse -Force + } + if ((Test-Path -LiteralPath $allowedWorkspaceRoot) -and @(Get-ChildItem -LiteralPath $allowedWorkspaceRoot -Force).Count -eq 0) { + Remove-Item -LiteralPath $allowedWorkspaceRoot -Force + } +} + +Write-Host "Theme '$ThemeId': validation and preview smoke passed." diff --git a/themes/example/README.md b/themes/example/README.md new file mode 100644 index 0000000..f4f8545 --- /dev/null +++ b/themes/example/README.md @@ -0,0 +1,27 @@ +# Шаблон темы + +Создавайте новую тему через `Редактор → Создать тему`: EditorPlugin скопирует этот каталог, +обновит `ThemeId`/`DisplayName` и активирует результат. Для автоматизации допустимо скопировать +`example` в `themes/` вручную и изменить те же поля manifest. Не переименовывайте файлы экранов, +корневые узлы и значения `exchanger_binding_id`: они являются контрактом приложения. + +Тема `example` сама является валидным минимальным пакетом manifest v2: в ней находятся все +девять production-сцен, 481 immutable и 17 supplemental обязательных биндингов, включая готовые +экранные клавиатуры SETTINGS. Её можно активировать, проверить и +собрать до начала оформления новой темы. + +Если теме нужен интерактивный питомец, добавьте его production-сцену в `package/components/`, +скрипты и ресурсы — внутрь `package/`, затем задайте `InteractivePetScene` в manifest. Базовый и +необязательный составной runtime-контракты описаны в +[`docs/SCRIPTING.md`](../../docs/SCRIPTING.md), а дизайнерский workflow — в +[`docs/COMPOSITE_PET_ANIMATIONS.md`](../../docs/COMPOSITE_PET_ANIMATIONS.md); памятка для автора лежит в +[`source/interactive_pet/README.md`](source/interactive_pet/README.md). + +Оригиналы, рабочие файлы графического редактора и локальные референсы храните в `source/`. +В PCK экспортируется только содержимое `package/`. Скрипт сборки временно копирует его в +изолированный проект под канонический runtime-путь `res://exchanger_theme_dlc/` и удаляет +workspace после export. + +Каталог темы всегда виден в Godot FileSystem. `.gdignore` не добавляйте: package использует +относительные ссылки, поэтому сцены можно открывать и редактировать непосредственно в +`res://themes//package/scenes/`. diff --git a/themes/example/package/assets/background/background.png b/themes/example/package/assets/background/background.png new file mode 100644 index 0000000..e2e9893 Binary files /dev/null and b/themes/example/package/assets/background/background.png differ diff --git a/themes/example/package/assets/background/background.png.import b/themes/example/package/assets/background/background.png.import new file mode 100644 index 0000000..2341830 --- /dev/null +++ b/themes/example/package/assets/background/background.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bdfx6iprx4kbw" +path="res://.godot/imported/background.png-b340f250985a724d15828ecf8cb4c3ce.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/example/package/assets/background/background.png" +dest_files=["res://.godot/imported/background.png-b340f250985a724d15828ecf8cb4c3ce.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/example/package/assets/ui/keyboard_hide.svg b/themes/example/package/assets/ui/keyboard_hide.svg new file mode 100644 index 0000000..425408e --- /dev/null +++ b/themes/example/package/assets/ui/keyboard_hide.svg @@ -0,0 +1,3 @@ + + + diff --git a/themes/example/package/assets/ui/keyboard_hide.svg.import b/themes/example/package/assets/ui/keyboard_hide.svg.import new file mode 100644 index 0000000..ed7f296 --- /dev/null +++ b/themes/example/package/assets/ui/keyboard_hide.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dmuxbbgwk3j3h" +path="res://.godot/imported/keyboard_hide.svg-b284de8037d6cef1dded45b062e71f95.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/example/package/assets/ui/keyboard_hide.svg" +dest_files=["res://.godot/imported/keyboard_hide.svg-b284de8037d6cef1dded45b062e71f95.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/themes/example/package/background_decoration.tscn b/themes/example/package/background_decoration.tscn new file mode 100644 index 0000000..83dce1e --- /dev/null +++ b/themes/example/package/background_decoration.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="scripts/decoration_controller.gd" id="1_script"] + +[node name="BackgroundDecoration" type="Node2D"] +script = ExtResource("1_script") diff --git a/themes/example/package/components/footer.tscn b/themes/example/package/components/footer.tscn new file mode 100644 index 0000000..e97cfdc --- /dev/null +++ b/themes/example/package/components/footer.tscn @@ -0,0 +1,45 @@ +[gd_scene format=3] + +[node name="PreviewFooter" type="Control"] +layout_mode = 3 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -82.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 2 + +[node name="Background" type="PanelContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_type_variation = &"FooterPanel" + +[node name="Brand" type="Label" parent="."] +layout_mode = 0 +offset_left = 28.0 +offset_top = 8.0 +offset_right = 296.0 +offset_bottom = 74.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 20 +text = "РАЗМЕН\nЖЕТОНОВ" +vertical_alignment = 1 + +[node name="Support" type="Label" parent="."] +layout_mode = 0 +offset_left = 310.0 +offset_top = 8.0 +offset_right = 692.0 +offset_bottom = 74.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 18 +text = "ТЕХПОДДЕРЖКА:\nТелефон поддержки уточняется" +horizontal_alignment = 2 +vertical_alignment = 1 diff --git a/themes/example/package/components/on_screen_keyboards.tscn b/themes/example/package/components/on_screen_keyboards.tscn new file mode 100644 index 0000000..fb08458 --- /dev/null +++ b/themes/example/package/components/on_screen_keyboards.tscn @@ -0,0 +1,550 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Texture2D" path="../assets/ui/keyboard_hide.svg" id="1_hide_icon"] + +[node name="KeyboardOverlay" type="Control"] +z_index = 30 +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="TextKeyboard" type="PanelContainer" parent="."] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard" +visible = false +layout_mode = 1 +anchors_preset = 12 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_top = -512.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 0 + +[node name="Content" type="VBoxContainer" parent="TextKeyboard"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Header" type="HBoxContainer" parent="TextKeyboard/Content"] +custom_minimum_size = Vector2(0, 58) +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Title" type="Label" parent="TextKeyboard/Content/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 20 +text = "ЭКРАННАЯ КЛАВИАТУРА" +vertical_alignment = 1 + +[node name="HideButton" type="Button" parent="TextKeyboard/Content/Header"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.header.hide_button" +custom_minimum_size = Vector2(64, 64) +layout_mode = 2 +focus_mode = 0 +tooltip_text = "Скрыть клавиатуру" +icon = ExtResource("1_hide_icon") +icon_max_width = 40 +expand_icon = true + +[node name="LetterRows" type="VBoxContainer" parent="TextKeyboard/Content"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Row0" type="HBoxContainer" parent="TextKeyboard/Content/LetterRows"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="Key00" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_00" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "й" + +[node name="Key01" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_01" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ц" + +[node name="Key02" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_02" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "у" + +[node name="Key03" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_03" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "к" + +[node name="Key04" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_04" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "е" + +[node name="Key05" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_05" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "н" + +[node name="Key06" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_06" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "г" + +[node name="Key07" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_07" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ш" + +[node name="Key08" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_08" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "щ" + +[node name="Key09" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_09" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "з" + +[node name="Key10" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_10" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "х" + +[node name="Key11" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_11" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ъ" + +[node name="Row1" type="HBoxContainer" parent="TextKeyboard/Content/LetterRows"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="Key12" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_12" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ф" + +[node name="Key13" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_13" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ы" + +[node name="Key14" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_14" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "в" + +[node name="Key15" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_15" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "а" + +[node name="Key16" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_16" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "п" + +[node name="Key17" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_17" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "р" + +[node name="Key18" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_18" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "о" + +[node name="Key19" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_19" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "л" + +[node name="Key20" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_20" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "д" + +[node name="Key21" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_21" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ж" + +[node name="Key22" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_22" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "э" + +[node name="Row2" type="HBoxContainer" parent="TextKeyboard/Content/LetterRows"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="Key23" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_23" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "я" + +[node name="Key24" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_24" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ч" + +[node name="Key25" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_25" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "с" + +[node name="Key26" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_26" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "м" + +[node name="Key27" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_27" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "и" + +[node name="Key28" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_28" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "т" + +[node name="Key29" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_29" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ь" + +[node name="Key30" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_30" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "б" + +[node name="Key31" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_31" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ю" + +[node name="Key32" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_32" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ё" + +[node name="PrimaryActions" type="HBoxContainer" parent="TextKeyboard/Content"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="ShiftButton" type="Button" parent="TextKeyboard/Content/PrimaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.primary_actions.shift_button" +custom_minimum_size = Vector2(128, 76) +layout_mode = 2 +focus_mode = 0 +text = "SHIFT" +theme_type_variation = &"KeyboardActionButton" + +[node name="LanguageButton" type="Button" parent="TextKeyboard/Content/PrimaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.primary_actions.language_button" +custom_minimum_size = Vector2(96, 76) +layout_mode = 2 +focus_mode = 0 +text = "RU" +theme_type_variation = &"KeyboardActionButton" + +[node name="SymbolsButton" type="Button" parent="TextKeyboard/Content/PrimaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.primary_actions.symbols_button" +custom_minimum_size = Vector2(116, 76) +layout_mode = 2 +focus_mode = 0 +text = "?123" +theme_type_variation = &"KeyboardActionButton" + +[node name="BackspaceButton" type="Button" parent="TextKeyboard/Content/PrimaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.primary_actions.backspace_button" +custom_minimum_size = Vector2(236, 76) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "СТЕРЕТЬ" +theme_type_variation = &"KeyboardActionButton" + +[node name="SecondaryActions" type="HBoxContainer" parent="TextKeyboard/Content"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="ClearButton" type="Button" parent="TextKeyboard/Content/SecondaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.secondary_actions.clear_button" +custom_minimum_size = Vector2(164, 76) +layout_mode = 2 +focus_mode = 0 +text = "ОЧИСТИТЬ" +theme_type_variation = &"KeyboardActionButton" + +[node name="CaretLeftButton" type="Button" parent="TextKeyboard/Content/SecondaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.secondary_actions.caret_left_button" +custom_minimum_size = Vector2(88, 76) +layout_mode = 2 +focus_mode = 0 +text = "<" +theme_type_variation = &"KeyboardActionButton" + +[node name="SpaceButton" type="Button" parent="TextKeyboard/Content/SecondaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.secondary_actions.space_button" +custom_minimum_size = Vector2(220, 76) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ПРОБЕЛ" +theme_type_variation = &"KeyboardActionButton" + +[node name="CaretRightButton" type="Button" parent="TextKeyboard/Content/SecondaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.secondary_actions.caret_right_button" +custom_minimum_size = Vector2(88, 76) +layout_mode = 2 +focus_mode = 0 +text = ">" +theme_type_variation = &"KeyboardActionButton" + +[node name="NumericKeyboard" type="PanelContainer" parent="."] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard" +visible = false +layout_mode = 1 +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -280.0 +offset_top = -520.0 +offset_right = 280.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 0 + +[node name="Content" type="VBoxContainer" parent="NumericKeyboard"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Header" type="HBoxContainer" parent="NumericKeyboard/Content"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Title" type="Label" parent="NumericKeyboard/Content/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 20 +text = "ЦИФРОВАЯ КЛАВИАТУРА" +vertical_alignment = 1 + +[node name="HideButton" type="Button" parent="NumericKeyboard/Content/Header"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.header.hide_button" +custom_minimum_size = Vector2(64, 64) +layout_mode = 2 +focus_mode = 0 +tooltip_text = "Скрыть клавиатуру" +icon = ExtResource("1_hide_icon") +icon_max_width = 40 +expand_icon = true + +[node name="Grid" type="GridContainer" parent="NumericKeyboard/Content"] +layout_mode = 2 +theme_override_constants/h_separation = 10 +theme_override_constants/v_separation = 10 +columns = 3 + +[node name="Digit1" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit1" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "1" + +[node name="Digit2" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit2" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "2" + +[node name="Digit3" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit3" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "3" + +[node name="Digit4" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit4" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "4" + +[node name="Digit5" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit5" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "5" + +[node name="Digit6" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit6" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "6" + +[node name="Digit7" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit7" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "7" + +[node name="Digit8" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit8" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "8" + +[node name="Digit9" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit9" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "9" + +[node name="ClearButton" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.clear_button" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ОЧИСТИТЬ" + +[node name="Digit0" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit0" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "0" + +[node name="BackspaceButton" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.backspace_button" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "СТЕРЕТЬ" diff --git a/themes/example/package/contracts/screen_bindings.v2.json b/themes/example/package/contracts/screen_bindings.v2.json new file mode 100644 index 0000000..657e795 --- /dev/null +++ b/themes/example/package/contracts/screen_bindings.v2.json @@ -0,0 +1,3929 @@ +{ + "Format": "ExchangerThemeBindingContract", + "Version": 2, + "ScreenMetadataKey": "exchanger_screen_binding_id", + "ElementMetadataKey": "exchanger_binding_id", + "Source": "Immutable Exchanger ThemeView contract: host binds existing theme-owned nodes only", + "Screens": [ + { + "BindingId": "screen.home", + "Key": "home", + "RootName": "ThemeScreen_Home", + "Path": "res://exchanger_theme_dlc/scenes/screen_home.tscn", + "Bindings": [ + { + "Id": "home.scroll.content.footer.margin.content.brand", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Footer/Margin/Content/Brand" + }, + { + "Id": "home.scroll.content.speech_text", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SpeechText" + }, + { + "Id": "home.scroll.content.price_panel.margin.content.price", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PricePanel/Margin/Content/Price" + }, + { + "Id": "home.scroll.content.payment_panel.margin.content.buttons.cash_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentPanel/Margin/Content/Buttons/CashButton" + }, + { + "Id": "home.scroll.content.payment_panel.margin.content.buttons.card_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentPanel/Margin/Content/Buttons/CardButton" + }, + { + "Id": "home.scroll.content.footer.margin.content.support", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Footer/Margin/Content/Support" + }, + { + "Id": "home.scroll.content.stock_status", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/StockStatus" + }, + { + "Id": "home.scroll.content.footer.margin.content.connection_status", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus" + }, + { + "Id": "home.scroll.content.stock_status.state", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/StockStatus/Margin/Content/State" + }, + { + "Id": "home.scroll.content.footer.margin.content.connection_status.label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus/Panel/Margin/Label" + }, + { + "Id": "home.scroll.content.advertisement_panel.layer.content", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content" + }, + { + "Id": "home.scroll.content.advertisement_panel.layer.content.poster", + "Types": [ + "TextureRect" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content/Poster" + }, + { + "Id": "home.scroll.content.advertisement_panel.layer.content.video_player", + "Types": [ + "VideoStreamPlayer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content/VideoPlayer" + }, + { + "Id": "home.scroll.content.advertisement_panel.layer.content.fallback_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content/FallbackLabel" + } + ] + }, + { + "BindingId": "screen.cash_payment", + "Key": "cash_payment", + "RootName": "ThemeScreen_CashPayment", + "Path": "res://exchanger_theme_dlc/scenes/screen_cash_payment.tscn", + "Bindings": [ + { + "Id": "cash_payment.content.top_bar.back_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/TopBar/BackButton" + }, + { + "Id": "cash_payment.content.summary.margin.values.balance", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Summary/Margin/Values/Balance" + }, + { + "Id": "cash_payment.content.summary.margin.values.tokens", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Summary/Margin/Values/Tokens" + }, + { + "Id": "cash_payment.content.summary.margin.values.bonus", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Summary/Margin/Values/Bonus" + }, + { + "Id": "cash_payment.content.dispense_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DispenseButton" + }, + { + "Id": "cash_payment.content.debug_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel" + }, + { + "Id": "cash_payment.content.debug_panel.add10_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/Add10Button" + }, + { + "Id": "cash_payment.content.debug_panel.add50_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/Add50Button" + }, + { + "Id": "cash_payment.content.debug_panel.add100_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/Add100Button" + } + ] + }, + { + "BindingId": "screen.card_amount", + "Key": "card_amount", + "RootName": "ThemeScreen_CardAmount", + "Path": "res://exchanger_theme_dlc/scenes/screen_card_amount.tscn", + "Bindings": [ + { + "Id": "card_amount.content.top_bar.back_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/TopBar/BackButton" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_13", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot13" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_12", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot12" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_11", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot11" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_10", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot10" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_09", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot09" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_08", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot08" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_07", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot07" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_06", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot06" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_05", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot05" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_04", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot04" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_03", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot03" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_02", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot02" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_01", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot01" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_00", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot00" + }, + { + "Id": "card_amount.content.confirm_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ConfirmButton" + }, + { + "Id": "card_amount.content.reward_panel.content.value", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/RewardPanel/Content/Value" + }, + { + "Id": "card_amount.content.selection_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/SelectionLabel" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.other_amount_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/OtherAmountButton" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid", + "Types": [ + "GridContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_14", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot14" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_15", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot15" + } + ] + }, + { + "BindingId": "screen.card_custom_amount", + "Key": "card_custom_amount", + "RootName": "ThemeScreen_CardCustomAmount", + "Path": "res://exchanger_theme_dlc/scenes/screen_card_custom_amount.tscn", + "Bindings": [ + { + "Id": "card_custom_amount.content.top_bar.back_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/TopBar/BackButton" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit7", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit7" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit6", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit6" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit5", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit5" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit4", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit4" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit3", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit3" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit2", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit2" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit1", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit1" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit0", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit0" + }, + { + "Id": "card_custom_amount.content.confirm_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ConfirmButton" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.backspace_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/BackspaceButton" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.clear_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/ClearButton" + }, + { + "Id": "card_custom_amount.content.hint", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Hint" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.input_value", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/InputValue" + }, + { + "Id": "card_custom_amount.content.amount_panel.margin.values.tokens", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Values/Tokens" + }, + { + "Id": "card_custom_amount.content.amount_panel.margin.values.amount", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Values/Amount" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit8", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit8" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit9", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit9" + } + ] + }, + { + "BindingId": "screen.card_terminal", + "Key": "card_terminal", + "RootName": "ThemeScreen_CardTerminal", + "Path": "res://exchanger_theme_dlc/scenes/screen_card_terminal.tscn", + "Bindings": [ + { + "Id": "card_terminal.content.top_bar.back_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/TopBar/BackButton" + }, + { + "Id": "card_terminal.content.cancel_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/CancelButton" + }, + { + "Id": "card_terminal.content.amount", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Amount" + }, + { + "Id": "card_terminal.content.tokens", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Tokens" + }, + { + "Id": "card_terminal.content.countdown", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Countdown" + }, + { + "Id": "card_terminal.content.debug_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel" + }, + { + "Id": "card_terminal.content.debug_panel.approve_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/ApproveButton" + }, + { + "Id": "card_terminal.content.debug_panel.decline_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/DeclineButton" + }, + { + "Id": "card_terminal.content.debug_panel.dispense_failure_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/DispenseFailureButton" + }, + { + "Id": "card_terminal.content.debug_panel.partial_dispense_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/PartialDispenseButton" + }, + { + "Id": "card_terminal.content.debug_panel.connection_loss_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/ConnectionLossButton" + } + ] + }, + { + "BindingId": "screen.success", + "Key": "success", + "RootName": "ThemeScreen_Success", + "Path": "res://exchanger_theme_dlc/scenes/screen_success.tscn", + "Bindings": [ + { + "Id": "success.content.result_panel.margin.values.title", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ResultPanel/Margin/Values/Title" + }, + { + "Id": "success.content.result_panel.margin.values.body.message", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ResultPanel/Margin/Values/Body/Message" + }, + { + "Id": "success.content.countdown", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Countdown" + }, + { + "Id": "success.content.home_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/HomeButton" + } + ] + }, + { + "BindingId": "screen.error", + "Key": "error", + "RootName": "ThemeScreen_Error", + "Path": "res://exchanger_theme_dlc/scenes/screen_error.tscn", + "Bindings": [ + { + "Id": "error.content.error_panel.margin.values.title", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ErrorPanel/Margin/Values/Title" + }, + { + "Id": "error.content.error_panel.margin.values.body.message", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ErrorPanel/Margin/Values/Body/Message" + }, + { + "Id": "error.content.countdown", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Countdown" + }, + { + "Id": "error.content.home_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/HomeButton" + } + ] + }, + { + "BindingId": "screen.service_access", + "Key": "service_access", + "RootName": "ThemeScreen_ServiceAccess", + "Path": "res://exchanger_theme_dlc/scenes/screen_service_access.tscn", + "Bindings": [ + { + "Id": "service_access.content.header", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Header" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit7", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit7" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit6", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit6" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit5", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit5" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit4", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit4" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit3", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit3" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit2", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit2" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit1", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit1" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit8", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit8" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit0", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit0" + }, + { + "Id": "service_access.content.access_panel.fields.enter_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/EnterButton" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.erase", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Erase" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.clear", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Clear" + }, + { + "Id": "service_access.content.access_panel.fields.status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Status" + }, + { + "Id": "service_access.content.access_panel.fields.pin_display", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/PinDisplay" + }, + { + "Id": "service_access.content.access_panel.fields.pin_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/PinLabel" + }, + { + "Id": "service_access.content.description", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Description" + }, + { + "Id": "service_access.content.cancel_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/CancelButton" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit9", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit9" + } + ] + }, + { + "BindingId": "screen.settings", + "Key": "settings", + "RootName": "ThemeScreen_Settings", + "Path": "res://exchanger_theme_dlc/scenes/screen_settings.tscn", + "Bindings": [ + { + "Id": "settings.scroll.content.section_tabs.general_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/GeneralButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row/Threshold" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.empty_state", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/EmptyState" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row/Bonus" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Row/Amount" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.empty_state", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/EmptyState" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Row/Amount" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row/Bonus" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows", + "Types": [ + "VBoxContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.limits_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/LimitsError" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.limits.custom_step", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits/CustomStep" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.limits.max_amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits/MaxAmount" + }, + { + "Id": "settings.scroll.content.payment_methods_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.payment_methods_panel.margin.content.card_enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content/CardEnabled" + }, + { + "Id": "settings.scroll.content.payment_methods_panel.margin.content.cash_enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content/CashEnabled" + }, + { + "Id": "settings.scroll.content.preview_panel.margin.content.values", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PreviewPanel/Margin/Content/Values" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.add_rule_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/AddRuleButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows", + "Types": [ + "VBoxContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows" + }, + { + "Id": "settings.scroll.content.price_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PricePanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.price_panel.margin.content.price", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PricePanel/Margin/Content/Price" + }, + { + "Id": "settings.scroll.content.test_panel.margin.content.status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TestPanel/Margin/Content/Status" + }, + { + "Id": "settings.scroll.content.test_panel.margin.content.test_sound_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TestPanel/Margin/Content/TestSoundButton" + }, + { + "Id": "settings.scroll.content.advertisement_panel.margin.content.volume_row.value", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content/VolumeRow/Value" + }, + { + "Id": "settings.scroll.content.advertisement_panel.margin.content.volume_row.slider", + "Types": [ + "HSlider" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content/VolumeRow/Slider" + }, + { + "Id": "settings.scroll.content.master_panel.margin.content.mute", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/MasterPanel/Margin/Content/Mute" + }, + { + "Id": "settings.scroll.content.master_panel.margin.content.volume_row.value", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/MasterPanel/Margin/Content/VolumeRow/Value" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.add_preset_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/AddPresetButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.presets_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetsError" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.grid.idle", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid/Idle" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.grid.cash_payment", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid/CashPayment" + }, + { + "Id": "settings.scroll.content.advertisement_poster_panel.margin.content.poster_path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content/PosterPath" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel.margin.content.fallback_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content/FallbackError" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel.margin.content.fallback_text", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content/FallbackText" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel.margin.content.muted", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content/Muted" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel.margin.content.enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content/Enabled" + }, + { + "Id": "settings.scroll.content.security_panel.margin.content.status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SecurityPanel/Margin/Content/Status" + }, + { + "Id": "settings.scroll.content.security_panel.margin.content.reset_pin_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SecurityPanel/Margin/Content/ResetPinButton" + }, + { + "Id": "settings.scroll.content.security_panel.margin.content.enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SecurityPanel/Margin/Content/Enabled" + }, + { + "Id": "settings.scroll.content.branding_preview_panel.margin.content.support_phone", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content/SupportPhone" + }, + { + "Id": "settings.scroll.content.master_panel.margin.content.volume_row.slider", + "Types": [ + "HSlider" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/MasterPanel/Margin/Content/VolumeRow/Slider" + }, + { + "Id": "settings.scroll.content.branding_preview_panel.margin.content.short_text", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content/ShortText" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.support_phone_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/SupportPhoneError" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.short_text_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/ShortTextError" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.application_name_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/ApplicationNameError" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.support_phone", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/SupportPhone" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.short_text", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/ShortText" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.application_name", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/ApplicationName" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.grid.success", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid/Success" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.grid.cashless_payment", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid/CashlessPayment" + }, + { + "Id": "settings.scroll.content.branding_preview_panel.margin.content.application_name", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content/ApplicationName" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.buttons.confirm_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Buttons/ConfirmButton" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.buttons.cancel_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Buttons/CancelButton" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Amount" + }, + { + "Id": "settings.scroll.content.appearance_effects_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceEffectsPanel" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel" + }, + { + "Id": "settings.scroll.content.advertisement_preview_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPreviewPanel" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel" + }, + { + "Id": "settings.scroll.content.advertisement_poster_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPosterPanel" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel" + }, + { + "Id": "settings.scroll.content.test_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TestPanel" + }, + { + "Id": "settings.scroll.content.advertisement_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel" + }, + { + "Id": "settings.scroll.content.master_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/MasterPanel" + }, + { + "Id": "settings.scroll.content.appearance_notice_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceNoticePanel" + }, + { + "Id": "settings.scroll.content.security_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SecurityPanel" + }, + { + "Id": "settings.scroll.content.branding_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel" + }, + { + "Id": "settings.scroll.content.section_tabs.diagnostics_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/DiagnosticsButton" + }, + { + "Id": "settings.scroll.content.section_tabs.equipment_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/EquipmentButton" + }, + { + "Id": "settings.scroll.content.section_tabs.service_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/ServiceButton" + }, + { + "Id": "settings.scroll.content.section_tabs.payment_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/PaymentButton" + }, + { + "Id": "settings.scroll.content.section_tabs.pricing_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/PricingButton" + }, + { + "Id": "settings.scroll.content.section_tabs.appearance_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/AppearanceButton" + }, + { + "Id": "settings.scroll.content.section_tabs.advertisement_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/AdvertisementButton" + }, + { + "Id": "settings.scroll.content.section_tabs.audio_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/AudioButton" + }, + { + "Id": "settings.scroll.content.branding_preview_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPreviewPanel" + }, + { + "Id": "settings.scroll.content.advertisement_poster_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.price_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PricePanel" + }, + { + "Id": "settings.scroll.content.preview_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PreviewPanel" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.description", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Description" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.title", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Title" + }, + { + "Id": "settings.service_dialog_overlay", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Status" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper2.manual_add_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2/ManualAddButton" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper2.recount_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2/RecountButton" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper2_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2Label" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper1.manual_add_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1/ManualAddButton" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper1.recount_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1/RecountButton" + }, + { + "Id": "settings.scroll.content.bonus_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper1_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1Label" + }, + { + "Id": "settings.scroll.content.service_inventory_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel" + }, + { + "Id": "settings.scroll.content.diagnostics_test_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsTestPanel" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel" + }, + { + "Id": "settings.scroll.content.diagnostics_status_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsStatusPanel" + }, + { + "Id": "settings.scroll.content.equipment_window_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel" + }, + { + "Id": "settings.scroll.content.timeouts_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel" + }, + { + "Id": "settings.scroll.content.card_options_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel" + }, + { + "Id": "settings.scroll.content.payment_methods_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentMethodsPanel" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.count", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Count" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows", + "Types": [ + "VBoxContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.add_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/AddButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Error" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.header.hide_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/Header/HideButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_32", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_32" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_31", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_31" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_30", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_30" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_29", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_29" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_28", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_28" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_27", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_27" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_26", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_26" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_25", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_25" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.primary_actions.language_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/LanguageButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_24", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_24" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_22", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_22" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_21", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_21" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_20", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_20" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_19", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_19" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_18", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_18" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_17", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_17" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_16", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_16" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_15", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_15" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_14", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_14" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_23", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_23" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_13", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_13" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.primary_actions.shift_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/ShiftButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.primary_actions.backspace_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/BackspaceButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.empty_state", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/EmptyState" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.backspace_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/BackspaceButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.clear_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/ClearButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.header.hide_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Header/HideButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit9", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit9" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit8", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit8" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit7", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit7" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.primary_actions.symbols_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/SymbolsButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit6", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit6" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit4", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit4" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit3", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit3" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit2", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit2" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit1", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit1" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit0", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit0" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.secondary_actions.space_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/SpaceButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.secondary_actions.clear_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/ClearButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.secondary_actions.caret_right_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/CaretRightButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.secondary_actions.caret_left_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/CaretLeftButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit5", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit5" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Row/RemoveButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_12", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_12" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_10", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_10" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel.margin.content.minimum_level", + "Types": [ + "OptionButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content/MinimumLevel" + }, + { + "Id": "settings.scroll.content.diagnostics_status_panel.margin.content.recent_errors", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content/RecentErrors" + }, + { + "Id": "settings.scroll.content.diagnostics_status_panel.margin.content.runtime_summary", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content/RuntimeSummary" + }, + { + "Id": "settings.scroll.content.equipment_window_panel.margin.content.target_fps", + "Types": [ + "OptionButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content/TargetFps" + }, + { + "Id": "settings.scroll.content.equipment_window_panel.margin.content.debug_window", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content/DebugWindow" + }, + { + "Id": "settings.scroll.content.equipment_window_panel.margin.content.borderless", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content/Borderless" + }, + { + "Id": "settings.scroll.content.equipment_window_panel.margin.content.fullscreen", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content/Fullscreen" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.connection_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/ConnectionError" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.reconnect_seconds", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/ReconnectSeconds" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel.margin.content.max_file_size", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content/MaxFileSize" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.baud_rate", + "Types": [ + "OptionButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/BaudRate" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.port_name", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/PortName" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.use_mock", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/UseMock" + }, + { + "Id": "settings.scroll.content.appearance_notice_panel.margin.content.current_theme", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin/Content/CurrentTheme" + }, + { + "Id": "settings.scroll.content.appearance_effects_panel.margin.content.reduced_effects", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceEffectsPanel/Margin/Content/ReducedEffects" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel.margin.content.package_status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content/PackageStatus" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel.margin.content.path_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content/PathError" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel.margin.content.external_path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content/ExternalPath" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel.margin.content.external_enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content/ExternalEnabled" + }, + { + "Id": "settings.scroll.content.advertisement_preview_panel.margin.content.summary", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPreviewPanel/Margin/Content/Summary" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.port_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/PortError" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_11", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_11" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel.margin.content.retention_days", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content/RetentionDays" + }, + { + "Id": "settings.scroll.content.diagnostics_test_panel.margin.content.test_dispense_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content/TestDispenseButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_09", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_09" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_08", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_08" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_07", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_07" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_06", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_06" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_05", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_05" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_04", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_04" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_03", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_03" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_02", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_02" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_01", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_01" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel.margin.content.logging_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content/LoggingError" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_00", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_00" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.recount_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/RecountButton" + }, + { + "Id": "settings.scroll.content", + "Types": [ + "Node" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content" + }, + { + "Id": "settings.scroll.content.section_tabs", + "Types": [ + "Node" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs" + }, + { + "Id": "settings.scroll.content.header.margin.content.title", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Header/Margin/Content/Title" + }, + { + "Id": "settings.scroll.content.header", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Header" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard" + }, + { + "Id": "settings.scroll", + "Types": [ + "ScrollContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll" + }, + { + "Id": "settings.scroll.content.diagnostics_test_panel.margin.content.test_dispense_status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content/TestDispenseStatus" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.manual_add_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/ManualAddButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Error" + } + ] + } + ] +} diff --git a/themes/example/package/manifest.json b/themes/example/package/manifest.json new file mode 100644 index 0000000..154a1c3 --- /dev/null +++ b/themes/example/package/manifest.json @@ -0,0 +1,23 @@ +{ + "Format": "ExchangerThemeDlc", + "FormatVersion": 2, + "ThemeId": "example", + "DisplayName": "Пример темы", + "UiThemePath": "res://exchanger_theme_dlc/ui_theme.tres", + "BackgroundTexturePath": "", + "BackgroundDecorationPath": "", + "BackgroundDecorationScale": 1.0, + "ScreenScenes": { + "screen.home": "res://exchanger_theme_dlc/scenes/screen_home.tscn", + "screen.cash_payment": "res://exchanger_theme_dlc/scenes/screen_cash_payment.tscn", + "screen.card_amount": "res://exchanger_theme_dlc/scenes/screen_card_amount.tscn", + "screen.card_custom_amount": "res://exchanger_theme_dlc/scenes/screen_card_custom_amount.tscn", + "screen.card_terminal": "res://exchanger_theme_dlc/scenes/screen_card_terminal.tscn", + "screen.success": "res://exchanger_theme_dlc/scenes/screen_success.tscn", + "screen.error": "res://exchanger_theme_dlc/scenes/screen_error.tscn", + "screen.service_access": "res://exchanger_theme_dlc/scenes/screen_service_access.tscn", + "screen.settings": "res://exchanger_theme_dlc/scenes/screen_settings.tscn" + }, + "FallbackColor": "#101722", + "UseNearestTextureFilter": false +} diff --git a/themes/example/package/scenes/screen_card_amount.tscn b/themes/example/package/scenes/screen_card_amount.tscn new file mode 100644 index 0000000..a4a4780 --- /dev/null +++ b/themes/example/package/scenes/screen_card_amount.tscn @@ -0,0 +1,350 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_ui_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="1_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="1_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="2_footer"] + +[node name="ThemeScreen_CardAmount" type="Control"] +metadata/exchanger_screen_binding_id = "screen.card_amount" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_ui_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("1_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("1_decoration")] + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="TopBar" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 92.0 + +[node name="BackButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "card_amount.content.top_bar.back_button" +layout_mode = 0 +offset_left = 24.0 +offset_top = 16.0 +offset_right = 76.0 +offset_bottom = 68.0 +theme_type_variation = &"BackButton" + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "card_amount.content.top_bar.home_navigation_button" +layout_mode = 0 +offset_left = 88.0 +offset_top = 16.0 +offset_right = 140.0 +offset_bottom = 68.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" + +[node name="House" type="Polygon2D" parent="SafeMargin/Content/TopBar/HomeNavigationButton"] +polygon = PackedVector2Array(8, 27, 26, 27, 17, 13) +color = Color(1, 1, 1, 1) + +[node name="HouseBody" type="Polygon2D" parent="SafeMargin/Content/TopBar/HomeNavigationButton"] +polygon = PackedVector2Array(11, 26, 23, 26, 23, 40, 18, 40, 18, 32, 16, 32, 16, 40, 11, 40) +color = Color(1, 1, 1, 1) + +[node name="BackText" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 20.0 +offset_top = 66.0 +offset_right = 84.0 +offset_bottom = 92.0 +mouse_filter = 2 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 13 +text = "НАЗАД" +horizontal_alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 148.0 +offset_top = 20.0 +offset_right = 696.0 +offset_bottom = 80.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА КАРТОЙ" +horizontal_alignment = 1 + +[node name="RewardPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 34.0 +offset_top = 110.0 +offset_right = 686.0 +offset_bottom = 250.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="Control" parent="SafeMargin/Content/RewardPanel"] +layout_mode = 2 + +[node name="Header" type="Label" parent="SafeMargin/Content/RewardPanel/Content"] +custom_minimum_size = Vector2(0, 44) +layout_mode = 0 +offset_right = 652.0 +offset_bottom = 44.0 +theme_type_variation = &"PanelTitle" +text = "К ВЫДАЧЕ" +vertical_alignment = 1 + +[node name="Value" type="Label" parent="SafeMargin/Content/RewardPanel/Content"] +metadata/exchanger_binding_id = "card_amount.content.reward_panel.content.value" +layout_mode = 0 +offset_left = 20.0 +offset_top = 52.0 +offset_right = 310.0 +offset_bottom = 116.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 26 +text = "ВЫБЕРИТЕ СУММУ" +vertical_alignment = 1 + +[node name="Bonus" type="Label" parent="SafeMargin/Content/RewardPanel/Content"] +metadata/exchanger_binding_id = "card_amount.content.reward_panel.content.bonus" +layout_mode = 0 +offset_left = 326.0 +offset_top = 52.0 +offset_right = 632.0 +offset_bottom = 116.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 24 +text = "БЕЗ БОНУСА" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="AmountPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 34.0 +offset_top = 274.0 +offset_right = 686.0 +offset_bottom = 828.0 +theme_type_variation = &"WhitePanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Content/AmountPanel"] +layout_mode = 2 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Content/AmountPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="SafeMargin/Content/AmountPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ОПЛАТА" +vertical_alignment = 1 + +[node name="PresetGrid" type="GridContainer" parent="SafeMargin/Content/AmountPanel/Margin/Content"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid" +custom_minimum_size = Vector2(0, 350) +layout_mode = 2 +theme_override_constants/h_separation = 20 +theme_override_constants/v_separation = 14 +columns = 2 + +[node name="PresetSlot00" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_00" +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "10 РУБ." + +[node name="PresetSlot01" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_01" +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "50 РУБ." + +[node name="PresetSlot02" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_02" +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "100 РУБ." + +[node name="PresetSlot03" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_03" +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "150 РУБ." + +[node name="PresetSlot04" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_04" +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "200 РУБ." + +[node name="PresetSlot05" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_05" +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "300 РУБ." + +[node name="PresetSlot06" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_06" +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "400 РУБ." + +[node name="PresetSlot07" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_07" +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "500 РУБ." + +[node name="PresetSlot08" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_08" +visible = false +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot09" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_09" +visible = false +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot10" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_10" +visible = false +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot11" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_11" +visible = false +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot12" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_12" +visible = false +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot13" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_13" +visible = false +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot14" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_14" +visible = false +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot15" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_15" +visible = false +custom_minimum_size = Vector2(292, 68) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + + +[node name="OtherAmountButton" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.other_amount_button" +custom_minimum_size = Vector2(340, 68) +layout_mode = 2 +size_flags_horizontal = 4 +theme_type_variation = &"CompactButton" +text = "ДРУГАЯ СУММА" + +[node name="ConfirmButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_amount.content.confirm_button" +layout_mode = 0 +offset_left = 34.0 +offset_top = 858.0 +offset_right = 686.0 +offset_bottom = 952.0 +theme_type_variation = &"PrimaryButton" +text = "ПОДТВЕРДИТЬ" +disabled = true + +[node name="SelectionLabel" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_amount.content.selection_label" +layout_mode = 0 +offset_left = 60.0 +offset_top = 974.0 +offset_right = 660.0 +offset_bottom = 1038.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 18 +text = "ВЫБЕРИТЕ СУММУ ДЛЯ ПЕРЕХОДА К ТЕРМИНАЛУ" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("2_footer")] +layout_mode = 1 diff --git a/themes/example/package/scenes/screen_card_custom_amount.tscn b/themes/example/package/scenes/screen_card_custom_amount.tscn new file mode 100644 index 0000000..6d73d03 --- /dev/null +++ b/themes/example/package/scenes/screen_card_custom_amount.tscn @@ -0,0 +1,286 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_ui_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="1_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="1_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="2_footer"] + +[node name="ThemeScreen_CardCustomAmount" type="Control"] +metadata/exchanger_screen_binding_id = "screen.card_custom_amount" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_ui_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("1_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("1_decoration")] + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="TopBar" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 92.0 + +[node name="BackButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "card_custom_amount.content.top_bar.back_button" +layout_mode = 0 +offset_left = 24.0 +offset_top = 16.0 +offset_right = 76.0 +offset_bottom = 68.0 +theme_type_variation = &"BackButton" + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "card_custom_amount.content.top_bar.home_navigation_button" +layout_mode = 0 +offset_left = 88.0 +offset_top = 16.0 +offset_right = 140.0 +offset_bottom = 68.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" + +[node name="House" type="Polygon2D" parent="SafeMargin/Content/TopBar/HomeNavigationButton"] +polygon = PackedVector2Array(8, 27, 26, 27, 17, 13) +color = Color(1, 1, 1, 1) + +[node name="HouseBody" type="Polygon2D" parent="SafeMargin/Content/TopBar/HomeNavigationButton"] +polygon = PackedVector2Array(11, 26, 23, 26, 23, 40, 18, 40, 18, 32, 16, 32, 16, 40, 11, 40) +color = Color(1, 1, 1, 1) + +[node name="BackText" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 20.0 +offset_top = 66.0 +offset_right = 84.0 +offset_bottom = 92.0 +mouse_filter = 2 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 13 +text = "НАЗАД" +horizontal_alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 148.0 +offset_top = 20.0 +offset_right = 696.0 +offset_bottom = 80.0 +theme_type_variation = &"ScreenTitle" +text = "ДРУГАЯ СУММА" +horizontal_alignment = 1 + +[node name="AmountPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 70.0 +offset_top = 112.0 +offset_right = 650.0 +offset_bottom = 288.0 +theme_type_variation = &"WhitePanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Content/AmountPanel"] +layout_mode = 2 + +[node name="Values" type="Control" parent="SafeMargin/Content/AmountPanel/Margin"] +layout_mode = 2 + +[node name="Header" type="Label" parent="SafeMargin/Content/AmountPanel/Margin/Values"] +custom_minimum_size = Vector2(0, 44) +layout_mode = 0 +offset_right = 580.0 +offset_bottom = 44.0 +theme_type_variation = &"PanelTitle" +text = "К ВЫДАЧЕ" +vertical_alignment = 1 + +[node name="Amount" type="Label" parent="SafeMargin/Content/AmountPanel/Margin/Values"] +metadata/exchanger_binding_id = "card_custom_amount.content.amount_panel.margin.values.amount" +layout_mode = 0 +offset_left = 16.0 +offset_top = 52.0 +offset_right = 270.0 +offset_bottom = 124.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 26 +text = "0 ЖЕТОНОВ" +vertical_alignment = 1 + +[node name="Tokens" type="Label" parent="SafeMargin/Content/AmountPanel/Margin/Values"] +metadata/exchanger_binding_id = "card_custom_amount.content.amount_panel.margin.values.tokens" +layout_mode = 0 +offset_left = 286.0 +offset_top = 52.0 +offset_right = 560.0 +offset_bottom = 124.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 24 +text = "+ 50 ЖЕТОНОВ\nВ ПОДАРОК" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="KeypadPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 34.0 +offset_top = 306.0 +offset_right = 686.0 +offset_bottom = 1074.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Content/KeypadPanel"] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="SafeMargin/Content/KeypadPanel/Content"] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ВВЕДИТЕ СУММУ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="InputValue" type="Label" parent="SafeMargin/Content/KeypadPanel/Content"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.input_value" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 32 +text = "0" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="KeypadCenter" type="CenterContainer" parent="SafeMargin/Content/KeypadPanel/Content"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Keypad" type="GridContainer" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter"] +layout_mode = 2 +theme_override_constants/h_separation = 12 +theme_override_constants/v_separation = 12 +columns = 3 + +[node name="Digit1" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit1" +custom_minimum_size = Vector2(152, 152) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "1" +[node name="Digit2" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit2" +custom_minimum_size = Vector2(152, 152) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "2" +[node name="Digit3" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit3" +custom_minimum_size = Vector2(152, 152) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "3" +[node name="Digit4" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit4" +custom_minimum_size = Vector2(152, 152) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "4" +[node name="Digit5" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit5" +custom_minimum_size = Vector2(152, 152) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "5" +[node name="Digit6" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit6" +custom_minimum_size = Vector2(152, 152) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "6" +[node name="Digit7" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit7" +custom_minimum_size = Vector2(152, 152) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "7" +[node name="Digit8" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit8" +custom_minimum_size = Vector2(152, 152) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "8" +[node name="Digit9" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit9" +custom_minimum_size = Vector2(152, 152) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "9" + +[node name="ClearButton" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.clear_button" +custom_minimum_size = Vector2(152, 152) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +theme_override_font_sizes/font_size = 17 +text = "СТЕРЕТЬ" + +[node name="Digit0" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit0" +custom_minimum_size = Vector2(152, 152) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "0" + +[node name="BackspaceButton" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.backspace_button" +visible = false +custom_minimum_size = Vector2(152, 152) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "⌫" + +[node name="ConfirmButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_custom_amount.content.confirm_button" +layout_mode = 0 +offset_left = 70.0 +offset_top = 1092.0 +offset_right = 650.0 +offset_bottom = 1178.0 +theme_type_variation = &"PrimaryButton" +text = "ПОДТВЕРДИТЬ" + +[node name="Hint" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_custom_amount.content.hint" +visible = false + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("2_footer")] +layout_mode = 1 diff --git a/themes/example/package/scenes/screen_card_terminal.tscn b/themes/example/package/scenes/screen_card_terminal.tscn new file mode 100644 index 0000000..2afd3e7 --- /dev/null +++ b/themes/example/package/scenes/screen_card_terminal.tscn @@ -0,0 +1,201 @@ +[gd_scene load_steps=7 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_ui_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="1_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="1_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="3_footer"] + +[node name="ThemeScreen_CardTerminal" type="Control"] +metadata/exchanger_screen_binding_id = "screen.card_terminal" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_ui_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("1_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("1_decoration")] + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="TopBar" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 92.0 + +[node name="BackButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "card_terminal.content.top_bar.back_button" +layout_mode = 0 +offset_left = 24.0 +offset_top = 16.0 +offset_right = 76.0 +offset_bottom = 68.0 +theme_type_variation = &"BackButton" + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "card_terminal.content.top_bar.home_navigation_button" +layout_mode = 0 +offset_left = 88.0 +offset_top = 16.0 +offset_right = 140.0 +offset_bottom = 68.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" + +[node name="House" type="Polygon2D" parent="SafeMargin/Content/TopBar/HomeNavigationButton"] +polygon = PackedVector2Array(8, 27, 26, 27, 17, 13) +color = Color(1, 1, 1, 1) + +[node name="HouseBody" type="Polygon2D" parent="SafeMargin/Content/TopBar/HomeNavigationButton"] +polygon = PackedVector2Array(11, 26, 23, 26, 23, 40, 18, 40, 18, 32, 16, 32, 16, 40, 11, 40) +color = Color(1, 1, 1, 1) + +[node name="BackText" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 20.0 +offset_top = 66.0 +offset_right = 84.0 +offset_bottom = 92.0 +mouse_filter = 2 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 13 +text = "НАЗАД" +horizontal_alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 148.0 +offset_top = 20.0 +offset_right = 696.0 +offset_bottom = 80.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА КАРТОЙ" +horizontal_alignment = 1 + +[node name="PaymentPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 112.0 +offset_top = 204.0 +offset_right = 608.0 +offset_bottom = 826.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Content/PaymentPanel"] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="SafeMargin/Content/PaymentPanel/Content"] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ОПЛАТА" +vertical_alignment = 1 + +[node name="Body" type="Control" parent="SafeMargin/Content/PaymentPanel/Content"] +custom_minimum_size = Vector2(0, 520) +layout_mode = 2 +mouse_filter = 2 + +[node name="Instruction" type="Label" parent="SafeMargin/Content/PaymentPanel/Content/Body"] +layout_mode = 0 +offset_left = 26.0 +offset_top = 404.0 +offset_right = 426.0 +offset_bottom = 494.0 +mouse_filter = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 21 +text = "ПРИЛОЖИТЕ КАРТУ\nК ТЕРМИНАЛУ ОПЛАТЫ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Amount" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_terminal.content.amount" +layout_mode = 0 +offset_left = 110.0 +offset_top = 856.0 +offset_right = 610.0 +offset_bottom = 924.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 24 +text = "К ОПЛАТЕ: 0 ₽ • 0 ЖЕТОНОВ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Tokens" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_terminal.content.tokens" +visible = false + +[node name="CancelButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_terminal.content.cancel_button" +layout_mode = 0 +offset_left = 210.0 +offset_top = 946.0 +offset_right = 510.0 +offset_bottom = 1018.0 +theme_type_variation = &"CompactButton" +text = "ОТМЕНА" + +[node name="Countdown" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_terminal.content.countdown" +layout_mode = 0 +offset_left = 110.0 +offset_top = 1032.0 +offset_right = 610.0 +offset_bottom = 1082.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 16 +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="DebugPanel" type="VBoxContainer" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_terminal.content.debug_panel" +visible = false + +[node name="ApproveButton" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "card_terminal.content.debug_panel.approve_button" +text = "Одобрить" +[node name="DeclineButton" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "card_terminal.content.debug_panel.decline_button" +text = "Отклонить" +[node name="DispenseFailureButton" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "card_terminal.content.debug_panel.dispense_failure_button" +text = "Ошибка выдачи" +[node name="PartialDispenseButton" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "card_terminal.content.debug_panel.partial_dispense_button" +text = "Частичная выдача" +[node name="ConnectionLossButton" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "card_terminal.content.debug_panel.connection_loss_button" +text = "Потеря связи" + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("3_footer")] +layout_mode = 1 diff --git a/themes/example/package/scenes/screen_cash_payment.tscn b/themes/example/package/scenes/screen_cash_payment.tscn new file mode 100644 index 0000000..1021aa6 --- /dev/null +++ b/themes/example/package/scenes/screen_cash_payment.tscn @@ -0,0 +1,291 @@ +[gd_scene load_steps=7 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_ui_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="1_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="1_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="3_footer"] + +[node name="ThemeScreen_CashPayment" type="Control"] +metadata/exchanger_screen_binding_id = "screen.cash_payment" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_ui_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("1_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("1_decoration")] + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="TopBar" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 92.0 + +[node name="BackButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "cash_payment.content.top_bar.back_button" +layout_mode = 0 +offset_left = 24.0 +offset_top = 16.0 +offset_right = 76.0 +offset_bottom = 68.0 +theme_type_variation = &"BackButton" + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "cash_payment.content.top_bar.home_navigation_button" +layout_mode = 0 +offset_left = 88.0 +offset_top = 16.0 +offset_right = 140.0 +offset_bottom = 68.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" + +[node name="House" type="Polygon2D" parent="SafeMargin/Content/TopBar/HomeNavigationButton"] +polygon = PackedVector2Array(8, 27, 26, 27, 17, 13) +color = Color(1, 1, 1, 1) + +[node name="HouseBody" type="Polygon2D" parent="SafeMargin/Content/TopBar/HomeNavigationButton"] +polygon = PackedVector2Array(11, 26, 23, 26, 23, 40, 18, 40, 18, 32, 16, 32, 16, 40, 11, 40) +color = Color(1, 1, 1, 1) + +[node name="BackText" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 20.0 +offset_top = 66.0 +offset_right = 84.0 +offset_bottom = 92.0 +mouse_filter = 2 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 13 +text = "НАЗАД" +horizontal_alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 148.0 +offset_top = 20.0 +offset_right = 696.0 +offset_bottom = 80.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА НАЛИЧНЫМИ" +horizontal_alignment = 1 + +[node name="CurrentBalancePanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 34.0 +offset_top = 264.0 +offset_right = 360.0 +offset_bottom = 374.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Content/CurrentBalancePanel"] +layout_mode = 2 + +[node name="Caption" type="Label" parent="SafeMargin/Content/CurrentBalancePanel/Content"] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ТЕКУЩИЙ БАЛАНС" +vertical_alignment = 1 + +[node name="Summary" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 34.0 +offset_top = 108.0 +offset_right = 686.0 +offset_bottom = 244.0 +theme_type_variation = &"WhitePanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Content/Summary"] +layout_mode = 2 + +[node name="Values" type="Control" parent="SafeMargin/Content/Summary/Margin"] +layout_mode = 2 + +[node name="Caption" type="Label" parent="SafeMargin/Content/Summary/Margin/Values"] +layout_mode = 0 +offset_right = 652.0 +offset_bottom = 44.0 +theme_type_variation = &"PanelTitle" +text = "К ВЫДАЧЕ" +vertical_alignment = 1 + +[node name="Balance" type="Label" parent="SafeMargin/Content/Summary/Margin/Values"] +metadata/exchanger_binding_id = "cash_payment.content.summary.margin.values.balance" +layout_mode = 0 +offset_left = 16.0 +offset_top = 206.0 +offset_right = 310.0 +offset_bottom = 254.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "0 РУБЛЕЙ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Tokens" type="Label" parent="SafeMargin/Content/Summary/Margin/Values"] +metadata/exchanger_binding_id = "cash_payment.content.summary.margin.values.tokens" +layout_mode = 0 +offset_left = 20.0 +offset_top = 52.0 +offset_right = 310.0 +offset_bottom = 116.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 26 +text = "0 ЖЕТОНОВ" +vertical_alignment = 1 + +[node name="Bonus" type="Label" parent="SafeMargin/Content/Summary/Margin/Values"] +metadata/exchanger_binding_id = "cash_payment.content.summary.margin.values.bonus" +layout_mode = 0 +offset_left = 326.0 +offset_top = 52.0 +offset_right = 632.0 +offset_bottom = 116.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 24 +text = "БЕЗ БОНУСА" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="InstructionPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 34.0 +offset_top = 394.0 +offset_right = 686.0 +offset_bottom = 880.0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Content/InstructionPanel"] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Header" type="Label" parent="SafeMargin/Content/InstructionPanel/Content"] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "КАК ВНЕСТИ" +vertical_alignment = 1 + +[node name="Body" type="Control" parent="SafeMargin/Content/InstructionPanel/Content"] +custom_minimum_size = Vector2(0, 380) +layout_mode = 2 +mouse_filter = 2 + +[node name="BillText" type="Label" parent="SafeMargin/Content/InstructionPanel/Content/Body"] +layout_mode = 0 +offset_left = 206.0 +offset_top = 58.0 +offset_right = 566.0 +offset_bottom = 154.0 +mouse_filter = 2 +theme_type_variation = &"PanelText" +text = "ВСТАВЬТЕ КУПЮРЫ\nВ КУПЮРОПРИЁМНИК" +vertical_alignment = 1 + +[node name="CoinText" type="Label" parent="SafeMargin/Content/InstructionPanel/Content/Body"] +layout_mode = 0 +offset_left = 206.0 +offset_top = 224.0 +offset_right = 566.0 +offset_bottom = 326.0 +mouse_filter = 2 +theme_type_variation = &"PanelText" +text = "ИЛИ МОНЕТЫ\nВ МОНЕТОПРИЁМНИК" +vertical_alignment = 1 + +[node name="DispenseButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "cash_payment.content.dispense_button" +layout_mode = 0 +offset_left = 34.0 +offset_top = 900.0 +offset_right = 686.0 +offset_bottom = 980.0 +theme_type_variation = &"PrimaryButton" +text = "ВЫДАТЬ" + +[node name="Tip" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 34.0 +offset_top = 994.0 +offset_right = 676.0 +offset_bottom = 1100.0 +mouse_filter = 2 + +[node name="TipText" type="Label" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 270.0 +offset_top = 1010.0 +offset_right = 570.0 +offset_bottom = 1084.0 +mouse_filter = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 14 +text = "АВТОМАТ НЕ ВЫДАЁТ СДАЧУ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="BannerText" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "cash_payment.content.banner_text" +layout_mode = 0 +offset_left = 225.0 +offset_top = 1022.0 +offset_right = 660.0 +offset_bottom = 1092.0 +mouse_filter = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 18 +text = "АВТОМАТ НЕ ВЫДАЁТ СДАЧУ" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="DebugPanel" type="HBoxContainer" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "cash_payment.content.debug_panel" +visible = false + +[node name="Add10Button" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "cash_payment.content.debug_panel.add10_button" +text = "+10 ₽" + +[node name="Add50Button" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "cash_payment.content.debug_panel.add50_button" +text = "+50 ₽" + +[node name="Add100Button" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "cash_payment.content.debug_panel.add100_button" +text = "+100 ₽" + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("3_footer")] +layout_mode = 1 diff --git a/themes/example/package/scenes/screen_error.tscn b/themes/example/package/scenes/screen_error.tscn new file mode 100644 index 0000000..2e50452 --- /dev/null +++ b/themes/example/package/scenes/screen_error.tscn @@ -0,0 +1,137 @@ +[gd_scene load_steps=7 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_ui_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="1_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="1_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="3_footer"] + +[node name="ThemeScreen_Error" type="Control"] +metadata/exchanger_screen_binding_id = "screen.error" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_ui_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("1_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("1_decoration")] + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "error.content.home_navigation_button" +layout_mode = 0 +offset_left = 24.0 +offset_top = 16.0 +offset_right = 76.0 +offset_bottom = 68.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" + +[node name="House" type="Polygon2D" parent="SafeMargin/Content/HomeNavigationButton"] +polygon = PackedVector2Array(8, 27, 26, 27, 17, 13) +color = Color(1, 1, 1, 1) + +[node name="HouseBody" type="Polygon2D" parent="SafeMargin/Content/HomeNavigationButton"] +polygon = PackedVector2Array(11, 26, 23, 26, 23, 40, 18, 40, 18, 32, 16, 32, 16, 40, 11, 40) +color = Color(1, 1, 1, 1) + +[node name="ScreenTitle" type="Label" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 40.0 +offset_top = 44.0 +offset_right = 680.0 +offset_bottom = 112.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА НЕ ПРОШЛА" +horizontal_alignment = 1 + +[node name="ErrorPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 96.0 +offset_top = 176.0 +offset_right = 624.0 +offset_bottom = 826.0 +theme_type_variation = &"WhitePanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Content/ErrorPanel"] +layout_mode = 2 + +[node name="Values" type="VBoxContainer" parent="SafeMargin/Content/ErrorPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Title" type="Label" parent="SafeMargin/Content/ErrorPanel/Margin/Values"] +metadata/exchanger_binding_id = "error.content.error_panel.margin.values.title" +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ОШИБКА ОПЛАТЫ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Body" type="Control" parent="SafeMargin/Content/ErrorPanel/Margin/Values"] +custom_minimum_size = Vector2(0, 538) +layout_mode = 2 +mouse_filter = 2 + +[node name="Message" type="Label" parent="SafeMargin/Content/ErrorPanel/Margin/Values/Body"] +metadata/exchanger_binding_id = "error.content.error_panel.margin.values.body.message" +layout_mode = 0 +offset_left = 30.0 +offset_top = 410.0 +offset_right = 454.0 +offset_bottom = 506.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 22 +text = "ПОПРОБУЙТЕ ЕЩЁ РАЗ\nИЛИ ВЫБЕРИТЕ ДРУГОЙ СПОСОБ" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Countdown" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "error.content.countdown" +visible = false +theme_type_variation = &"ErrorText" + +[node name="HomeButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "error.content.home_button" +layout_mode = 0 +offset_left = 84.0 +offset_top = 862.0 +offset_right = 636.0 +offset_bottom = 956.0 +theme_type_variation = &"PrimaryButton" +text = "НА ГЛАВНЫЙ ЭКРАН" + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("3_footer")] +layout_mode = 1 diff --git a/themes/example/package/scenes/screen_home.tscn b/themes/example/package/scenes/screen_home.tscn new file mode 100644 index 0000000..f217dae --- /dev/null +++ b/themes/example/package/scenes/screen_home.tscn @@ -0,0 +1,415 @@ +[gd_scene load_steps=9 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_ui_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="1_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="1_decoration"] + +[node name="ThemeScreen_Home" type="Control"] +metadata/exchanger_screen_binding_id = "screen.home" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_ui_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("1_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("1_decoration")] + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Scroll" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin/Scroll"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="ThemeTop" type="Control" parent="SafeMargin/Scroll/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 200.0 +mouse_filter = 2 + +[node name="PricePanel" type="PanelContainer" parent="SafeMargin/Scroll/Content"] +layout_mode = 0 +offset_left = 148.0 +offset_top = 24.0 +offset_right = 398.0 +offset_bottom = 184.0 +theme_type_variation = &"WhitePanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/PricePanel"] +layout_mode = 2 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/PricePanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Caption" type="Label" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content"] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ЦЕНА ЖЕТОНА" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Price" type="Label" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content"] +metadata/exchanger_binding_id = "home.scroll.content.price_panel.margin.content.price" +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 32 +text = "10 ₽" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="SpeechText" type="Label" parent="SafeMargin/Scroll/Content"] +metadata/exchanger_binding_id = "home.scroll.content.speech_text" +layout_mode = 0 +offset_left = 380.0 +offset_top = 42.0 +offset_right = 580.0 +offset_bottom = 110.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 12 +text = "ТЕКСТ КЛИЕНТА" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 +clip_text = true +max_lines_visible = 3 + +[node name="StockStatus" parent="SafeMargin/Scroll/Content" type="Control"] +metadata/exchanger_binding_id = "home.scroll.content.stock_status" +layout_mode = 0 +offset_left = 30.0 +offset_top = 206.0 +offset_right = 690.0 +offset_bottom = 340.0 + +[node name="StockPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content/StockStatus"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_type_variation = &"StatusPanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel"] +layout_mode = 2 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 28 +alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ДОСТУПНО К ВЫДАЧЕ" +vertical_alignment = 1 + +[node name="StatusRow" type="CenterContainer" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow"] +layout_mode = 2 +theme_override_constants/separation = 18 + +[node name="State" type="Label" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row"] +metadata/exchanger_binding_id = "home.scroll.content.stock_status.state" +custom_minimum_size = Vector2(160, 48) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 25 +text = "МНОГО" +horizontal_alignment = 2 +vertical_alignment = 1 + +[node name="Meter" type="Control" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row"] +custom_minimum_size = Vector2(200, 32) +layout_mode = 2 +mouse_filter = 2 + +[node name="Background" type="ColorRect" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row/Meter"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.13, 0.17, 0.24, 1) + +[node name="Empty" type="ColorRect" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row/Meter"] +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 0.08 +anchor_bottom = 1.0 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.78, 0.18, 0.2, 1) +metadata/exchanger_binding_id = "home.scroll.content.stock_status.meter.empty" + +[node name="Low" type="ColorRect" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row/Meter"] +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 0.35 +anchor_bottom = 1.0 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.95, 0.62, 0.15, 1) +metadata/exchanger_binding_id = "home.scroll.content.stock_status.meter.low" + +[node name="Enough" type="ColorRect" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row/Meter"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 0.68 +anchor_bottom = 1.0 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.42, 0.86, 0.31, 1) +metadata/exchanger_binding_id = "home.scroll.content.stock_status.meter.enough" + +[node name="Much" type="ColorRect" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row/Meter"] +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.2, 0.78, 0.3, 1) +metadata/exchanger_binding_id = "home.scroll.content.stock_status.meter.much" + +[node name="ApproximateCount" type="Label" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row"] +metadata/exchanger_binding_id = "home.scroll.content.stock_status.approximate_count" +custom_minimum_size = Vector2(120, 48) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 25 +text = "<100" +horizontal_alignment = 0 +vertical_alignment = 1 + + +[node name="PaymentPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content"] +layout_mode = 0 +offset_left = 30.0 +offset_top = 360.0 +offset_right = 690.0 +offset_bottom = 548.0 +theme_type_variation = &"WhitePanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/PaymentPanel"] +layout_mode = 2 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/PaymentPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/PaymentPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ВЫБЕРИТЕ СПОСОБ ОПЛАТЫ" +vertical_alignment = 1 + +[node name="Buttons" type="HBoxContainer" parent="SafeMargin/Scroll/Content/PaymentPanel/Margin/Content"] +layout_mode = 2 +theme_override_constants/separation = 20 +alignment = 1 + +[node name="CashButton" type="Button" parent="SafeMargin/Scroll/Content/PaymentPanel/Margin/Content/Buttons"] +metadata/exchanger_binding_id = "home.scroll.content.payment_panel.margin.content.buttons.cash_button" +custom_minimum_size = Vector2(280, 70) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "НАЛИЧНЫЕ" + +[node name="CardButton" type="Button" parent="SafeMargin/Scroll/Content/PaymentPanel/Margin/Content/Buttons"] +metadata/exchanger_binding_id = "home.scroll.content.payment_panel.margin.content.buttons.card_button" +custom_minimum_size = Vector2(280, 70) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "БЕЗНАЛИЧНЫЕ" + +[node name="AdvertisementPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content"] +metadata/exchanger_binding_id = "home.scroll.content.advertisement_panel" +layout_mode = 0 +offset_left = 62.0 +offset_top = 590.0 +offset_right = 658.0 +offset_bottom = 1120.0 +mouse_filter = 2 +theme_type_variation = &"AdvertisementPanel" + +[node name="Layer" type="Control" parent="SafeMargin/Scroll/Content/AdvertisementPanel"] +layout_mode = 2 +mouse_filter = 2 + +[node name="ThemeFrame" type="Control" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Layer"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Content" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Layer" type="Control"] +metadata/exchanger_binding_id = "home.scroll.content.advertisement_panel.layer.content" +layout_mode = 0 +offset_left = 112.0 +offset_top = 185.0 +offset_right = 484.0 +offset_bottom = 345.0 + +[node name="Poster" type="TextureRect" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content"] +metadata/exchanger_binding_id = "home.scroll.content.advertisement_panel.layer.content.poster" +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +expand_mode = 1 +stretch_mode = 6 + +[node name="VideoPlayer" type="VideoStreamPlayer" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content"] +metadata/exchanger_binding_id = "home.scroll.content.advertisement_panel.layer.content.video_player" +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +expand = true +bus = &"Advertisement" + +[node name="FallbackLabel" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content"] +metadata/exchanger_binding_id = "home.scroll.content.advertisement_panel.layer.content.fallback_label" +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 20 +text = "ПРОМО-БЛОК" +horizontal_alignment = 1 +vertical_alignment = 1 + + +[node name="Footer" type="PanelContainer" parent="SafeMargin/Scroll/Content"] +layout_mode = 0 +offset_top = 1198.0 +offset_right = 720.0 +offset_bottom = 1280.0 +theme_type_variation = &"FooterPanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/Footer"] +layout_mode = 2 + +[node name="Content" type="Control" parent="SafeMargin/Scroll/Content/Footer/Margin"] +layout_mode = 2 + +[node name="Brand" type="Label" parent="SafeMargin/Scroll/Content/Footer/Margin/Content"] +metadata/exchanger_binding_id = "home.scroll.content.footer.margin.content.brand" +layout_mode = 0 +offset_left = 28.0 +offset_top = 8.0 +offset_right = 296.0 +offset_bottom = 74.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 20 +text = "РАЗМЕН\nЖЕТОНОВ" +vertical_alignment = 1 + +[node name="Support" type="Label" parent="SafeMargin/Scroll/Content/Footer/Margin/Content"] +metadata/exchanger_binding_id = "home.scroll.content.footer.margin.content.support" +layout_mode = 0 +offset_left = 310.0 +offset_top = 8.0 +offset_right = 692.0 +offset_bottom = 74.0 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 18 +text = "ТЕХПОДДЕРЖКА:\nТелефон поддержки уточняется" +horizontal_alignment = 2 +vertical_alignment = 1 + +[node name="ConnectionStatus" parent="SafeMargin/Scroll/Content/Footer/Margin/Content" type="Control"] +metadata/exchanger_binding_id = "home.scroll.content.footer.margin.content.connection_status" +layout_mode = 0 +offset_left = 110.0 +offset_top = -68.0 +offset_right = 610.0 +offset_bottom = -8.0 +visible = false + +[node name="Panel" type="PanelContainer" parent="SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_type_variation = &"StatusPanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus/Panel"] +layout_mode = 2 +theme_override_constants/margin_left = 18 +theme_override_constants/margin_top = 10 +theme_override_constants/margin_right = 18 +theme_override_constants/margin_bottom = 10 + +[node name="Label" type="Label" parent="SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus/Panel/Margin"] +metadata/exchanger_binding_id = "home.scroll.content.footer.margin.content.connection_status.label" +layout_mode = 2 +theme_type_variation = &"StatusText" +theme_override_font_sizes/font_size = 18 +text = "СЕРВИС: НЕТ СВЯЗИ С КОНТРОЛЛЕРОМ" +horizontal_alignment = 1 +vertical_alignment = 1 diff --git a/themes/example/package/scenes/screen_service_access.tscn b/themes/example/package/scenes/screen_service_access.tscn new file mode 100644 index 0000000..5c5ad24 --- /dev/null +++ b/themes/example/package/scenes/screen_service_access.tscn @@ -0,0 +1,252 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_ui_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="1_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="1_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="2_footer"] + +[node name="ThemeScreen_ServiceAccess" type="Control"] +metadata/exchanger_screen_binding_id = "screen.service_access" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_ui_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("1_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("1_decoration")] + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "service_access.content.home_navigation_button" +layout_mode = 0 +offset_left = 24.0 +offset_top = 16.0 +offset_right = 76.0 +offset_bottom = 68.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" + +[node name="House" type="Polygon2D" parent="SafeMargin/Content/HomeNavigationButton"] +polygon = PackedVector2Array(8, 27, 26, 27, 17, 13) +color = Color(1, 1, 1, 1) + +[node name="HouseBody" type="Polygon2D" parent="SafeMargin/Content/HomeNavigationButton"] +polygon = PackedVector2Array(11, 26, 23, 26, 23, 40, 18, 40, 18, 32, 16, 32, 16, 40, 11, 40) +color = Color(1, 1, 1, 1) + +[node name="Header" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "service_access.content.header" +layout_mode = 0 +offset_left = 40.0 +offset_top = 18.0 +offset_right = 680.0 +offset_bottom = 72.0 +theme_type_variation = &"ScreenTitle" +theme_override_font_sizes/font_size = 34 +text = "ВХОД В НАСТРОЙКИ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "service_access.content.description" +layout_mode = 0 +offset_left = 52.0 +offset_top = 78.0 +offset_right = 668.0 +offset_bottom = 140.0 +theme_type_variation = &"ScreenDescription" +theme_override_font_sizes/font_size = 16 +text = "ВВЕДИТЕ PIN-КОД, ЧТОБЫ ОТКРЫТЬ НАСТРОЙКИ" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="AccessPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 60.0 +offset_top = 152.0 +offset_right = 660.0 +offset_bottom = 916.0 +theme_type_variation = &"WhitePanel" + +[node name="Fields" type="VBoxContainer" parent="SafeMargin/Content/AccessPanel"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="PinLabel" type="Label" parent="SafeMargin/Content/AccessPanel/Fields"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.pin_label" +custom_minimum_size = Vector2(0, 32) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "PIN-КОД" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="PinDisplay" type="Label" parent="SafeMargin/Content/AccessPanel/Fields"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.pin_display" +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_type_variation = &"StatusText" +theme_override_font_sizes/font_size = 34 +text = "—" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Status" type="Label" parent="SafeMargin/Content/AccessPanel/Fields"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.status" +custom_minimum_size = Vector2(0, 38) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 17 +text = "ВВЕДИТЕ PIN-КОД" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="KeypadCenter" type="CenterContainer" parent="SafeMargin/Content/AccessPanel/Fields"] +custom_minimum_size = Vector2(0, 484) +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Keypad" type="GridContainer" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter"] +layout_mode = 2 +theme_override_constants/h_separation = 12 +theme_override_constants/v_separation = 12 +columns = 3 + +[node name="Digit1" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit1" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "1" + +[node name="Digit2" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit2" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "2" + +[node name="Digit3" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit3" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "3" + +[node name="Digit4" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit4" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "4" + +[node name="Digit5" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit5" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "5" + +[node name="Digit6" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit6" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "6" + +[node name="Digit7" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit7" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "7" + +[node name="Digit8" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit8" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "8" + +[node name="Digit9" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit9" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "9" + +[node name="Clear" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.clear" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +theme_override_font_sizes/font_size = 16 +text = "СБРОС" + +[node name="Digit0" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit0" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "0" + +[node name="Erase" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.erase" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "⌫" + +[node name="EnterButton" type="Button" parent="SafeMargin/Content/AccessPanel/Fields"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.enter_button" +custom_minimum_size = Vector2(0, 76) +layout_mode = 2 +theme_type_variation = &"PrimaryButton" +theme_override_font_sizes/font_size = 30 +text = "ВОЙТИ" + +[node name="CancelButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "service_access.content.cancel_button" +layout_mode = 0 +offset_left = 110.0 +offset_top = 940.0 +offset_right = 610.0 +offset_bottom = 1026.0 +theme_type_variation = &"SecondaryButton" +text = "ОТМЕНА" + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("2_footer")] +layout_mode = 1 diff --git a/themes/example/package/scenes/screen_settings.tscn b/themes/example/package/scenes/screen_settings.tscn new file mode 100644 index 0000000..f21e223 --- /dev/null +++ b/themes/example/package/scenes/screen_settings.tscn @@ -0,0 +1,3987 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_ui_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="1_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="1_decoration"] +[ext_resource type="PackedScene" path="../components/on_screen_keyboards.tscn" id="4_keyboards"] + +[node name="ThemeScreen_Settings" type="Control" unique_id=1004620256] +metadata/exchanger_screen_binding_id = "screen.settings" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_ui_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("1_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("1_decoration")] + +[node name="SafeMargin" type="MarginContainer" parent="." unique_id=1771276379] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 28.0 +offset_top = 28.0 +offset_right = -28.0 +offset_bottom = -28.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Scroll" type="ScrollContainer" parent="SafeMargin" unique_id=1730433592] +metadata/exchanger_binding_id = "settings.scroll" +custom_minimum_size = Vector2(0, 1100) +layout_mode = 2 +size_flags_vertical = 0 +horizontal_scroll_mode = 0 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll" unique_id=1670934776] +metadata/exchanger_binding_id = "settings.scroll.content" +custom_minimum_size = Vector2(0, 1173) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 19 + +[node name="Header" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=154820359] +metadata/exchanger_binding_id = "settings.scroll.content.header" +custom_minimum_size = Vector2(0, 107) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/Header" unique_id=2033082988] +layout_mode = 2 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 19 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 19 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/Header/Margin" unique_id=1892810197] +layout_mode = 2 +theme_override_constants/separation = 8 +alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/Header/Margin/Content" unique_id=1212756635] +metadata/exchanger_binding_id = "settings.scroll.content.header.margin.content.title" +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 31 +text = "НАСТРОЙКИ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="SectionTabs" type="GridContainer" parent="SafeMargin/Scroll/Content" unique_id=1099846765] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs" +custom_minimum_size = Vector2(0, 157) +layout_mode = 2 +theme_override_constants/h_separation = 12 +theme_override_constants/v_separation = 12 +columns = 3 + +[node name="GeneralButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=390094413] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.general_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ОБЩИЕ" + +[node name="AudioButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=1209434198] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.audio_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ЗВУК" + +[node name="AdvertisementButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=931340907] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.advertisement_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ПРОМО" + +[node name="AppearanceButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=1803958748] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.appearance_button" +visible = false +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ТЕМА" + +[node name="PricingButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=2043412965] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.pricing_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ТАРИФЫ" + +[node name="PaymentButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=750238068] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.payment_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ОПЛАТА" + +[node name="ServiceButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs"] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.service_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "СЕРВИС" + +[node name="EquipmentButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=436932014] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.equipment_button" +visible = false +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ОБОРУД." + +[node name="DiagnosticsButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=1314343470] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.diagnostics_button" +visible = false +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ДИАГНОСТ." + +[node name="BrandingPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=2078006035] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel" +custom_minimum_size = Vector2(0, 439) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/BrandingPanel" unique_id=1084754695] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin" unique_id=288780598] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=1441920640] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ТЕКСТ НА ГЛАВНОМ ЭКРАНЕ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=314471939] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Здесь можно изменить свой текст для покупателей и контакты техподдержки." +autowrap_mode = 2 + +[node name="ApplicationNameLabel" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=1972340592] +visible = false +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "НАЗВАНИЕ • ДО 48 ЗНАКОВ" + +[node name="ApplicationName" type="LineEdit" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=923442286] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel.margin.content.application_name" +visible = false +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +placeholder_text = "РАЗМЕН ЖЕТОНОВ" +max_length = 48 + +[node name="ApplicationNameError" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=172647480] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel.margin.content.application_name_error" +visible = false +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="ShortTextLabel" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "СВОЙ ТЕКСТ • ДО 48 ЗНАКОВ" + +[node name="ShortText" type="LineEdit" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel.margin.content.short_text" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +placeholder_text = "ТЕКСТ КЛИЕНТА" +max_length = 48 + +[node name="ShortTextError" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel.margin.content.short_text_error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="SupportPhoneLabel" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=1058802895] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "ТЕЛЕФОН ПОДДЕРЖКИ • ДО 80 ЗНАКОВ" + +[node name="SupportPhone" type="LineEdit" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=1224448315] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel.margin.content.support_phone" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +placeholder_text = "+7 (000) 000-00-00" +max_length = 80 + +[node name="SupportPhoneError" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=939950598] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel.margin.content.support_phone_error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="AnimatedBannerTextsPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.animated_banner_texts_panel" +custom_minimum_size = Vector2(0, 560) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ТЕКСТЫ НА БАННЕРАХ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Тексты отображаются на нижних анимированных плашках с НЛО. В шаблоне таймера используйте {seconds} для числа секунд." +autowrap_mode = 2 + +[node name="CashPaymentLabel" type="Label" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "НАЛИЧНЫЕ • ТЕКСТ НА БАННЕРЕ" + +[node name="CashPayment" type="LineEdit" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +max_length = 120 + +[node name="CardAmountLabel" type="Label" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "КАРТА • ВЫБОР СУММЫ" + +[node name="CardAmount" type="LineEdit" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +max_length = 120 + +[node name="CardCustomAmountLabel" type="Label" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "КАРТА • ВВОД СУММЫ" + +[node name="CardCustomAmount" type="LineEdit" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +max_length = 120 + +[node name="CardTerminalCountdownLabel" type="Label" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "КАРТА • ТАЙМЕР • {seconds} = СЕКУНДЫ" + +[node name="CardTerminalCountdown" type="LineEdit" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +max_length = 120 + +[node name="BrandingPreviewPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=378115165] +metadata/exchanger_binding_id = "settings.scroll.content.branding_preview_panel" +visible = false +custom_minimum_size = Vector2(0, 273) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/BrandingPreviewPanel" unique_id=253672003] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin" unique_id=1113826782] +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content" unique_id=160381550] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "КАК ЭТО УВИДИТ ПОКУПАТЕЛЬ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="ApplicationName" type="Label" parent="SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content" unique_id=808413130] +metadata/exchanger_binding_id = "settings.scroll.content.branding_preview_panel.margin.content.application_name" +visible = false +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "РАЗМЕН ЖЕТОНОВ" +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="ShortText" type="Label" parent="SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.branding_preview_panel.margin.content.short_text" +visible = false +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ТЕКСТ КЛИЕНТА" +horizontal_alignment = 1 +autowrap_mode = 2 +clip_text = true +max_lines_visible = 3 + +[node name="SupportPhone" type="Label" parent="SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content" unique_id=85872323] +metadata/exchanger_binding_id = "settings.scroll.content.branding_preview_panel.margin.content.support_phone" +visible = false +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ТЕХПОДДЕРЖКА • Телефон поддержки уточняется" +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="SecurityPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1879849850] +metadata/exchanger_binding_id = "settings.scroll.content.security_panel" +custom_minimum_size = Vector2(0, 313) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/SecurityPanel" unique_id=962456073] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/SecurityPanel/Margin" unique_id=607567481] +layout_mode = 2 +theme_override_constants/separation = 13 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/SecurityPanel/Margin/Content" unique_id=830468842] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ЗАЩИТА НАСТРОЕК" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/SecurityPanel/Margin/Content" unique_id=1393678109] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Включите защиту, если хотите открывать настройки только по PIN-коду. Приложение предложит придумать его при следующем входе." +autowrap_mode = 2 + +[node name="Enabled" type="CheckButton" parent="SafeMargin/Scroll/Content/SecurityPanel/Margin/Content" unique_id=558474112] +metadata/exchanger_binding_id = "settings.scroll.content.security_panel.margin.content.enabled" +custom_minimum_size = Vector2(0, 55) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ЗАЩИТИТЬ НАСТРОЙКИ PIN-КОДОМ" + +[node name="ResetPinButton" type="Button" parent="SafeMargin/Scroll/Content/SecurityPanel/Margin/Content" unique_id=1063988680] +metadata/exchanger_binding_id = "settings.scroll.content.security_panel.margin.content.reset_pin_button" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ПРИДУМАТЬ НОВЫЙ PIN" + +[node name="Status" type="Label" parent="SafeMargin/Scroll/Content/SecurityPanel/Margin/Content" unique_id=419034981] +metadata/exchanger_binding_id = "settings.scroll.content.security_panel.margin.content.status" +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Защита выключена." +autowrap_mode = 2 + +[node name="MasterPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1198367056] +metadata/exchanger_binding_id = "settings.scroll.content.master_panel" +custom_minimum_size = Vector2(0, 273) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/MasterPanel" unique_id=1657463854] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/MasterPanel/Margin" unique_id=1222038005] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/MasterPanel/Margin/Content" unique_id=2077123349] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ОБЩАЯ ГРОМКОСТЬ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/MasterPanel/Margin/Content" unique_id=1560394233] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Громкость всех звуков автомата." +autowrap_mode = 2 + +[node name="VolumeRow" type="HBoxContainer" parent="SafeMargin/Scroll/Content/MasterPanel/Margin/Content" unique_id=150712937] +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="Slider" type="HSlider" parent="SafeMargin/Scroll/Content/MasterPanel/Margin/Content/VolumeRow" unique_id=2060895330] +metadata/exchanger_binding_id = "settings.scroll.content.master_panel.margin.content.volume_row.slider" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +size_flags_horizontal = 3 +value = 100.0 + +[node name="Value" type="Label" parent="SafeMargin/Scroll/Content/MasterPanel/Margin/Content/VolumeRow" unique_id=2057703712] +metadata/exchanger_binding_id = "settings.scroll.content.master_panel.margin.content.volume_row.value" +custom_minimum_size = Vector2(87, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "100%" +horizontal_alignment = 2 +vertical_alignment = 1 + +[node name="Mute" type="CheckButton" parent="SafeMargin/Scroll/Content/MasterPanel/Margin/Content" unique_id=1848500350] +metadata/exchanger_binding_id = "settings.scroll.content.master_panel.margin.content.mute" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +text = "ОТКЛЮЧИТЬ ВСЕ ЗВУКИ" + +[node name="AdvertisementPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1140295799] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_panel" +custom_minimum_size = Vector2(0, 220) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AdvertisementPanel" unique_id=746826493] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Margin" unique_id=652964597] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content" unique_id=912048069] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ГРОМКОСТЬ ПРОМО-РОЛИКА" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content" unique_id=1736411184] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Громкость звука в промо-роликах." +autowrap_mode = 2 + +[node name="VolumeRow" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content" unique_id=405566535] +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="Slider" type="HSlider" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content/VolumeRow" unique_id=530125674] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_panel.margin.content.volume_row.slider" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +size_flags_horizontal = 3 +value = 100.0 + +[node name="Value" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content/VolumeRow" unique_id=2101989997] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_panel.margin.content.volume_row.value" +custom_minimum_size = Vector2(87, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "100%" +horizontal_alignment = 2 +vertical_alignment = 1 + +[node name="TestPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1383634412] +metadata/exchanger_binding_id = "settings.scroll.content.test_panel" +custom_minimum_size = Vector2(0, 207) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/TestPanel" unique_id=130409172] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/TestPanel/Margin" unique_id=1257657298] +layout_mode = 2 +theme_override_constants/separation = 15 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/TestPanel/Margin/Content" unique_id=1433324321] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ПРОВЕРКА" +vertical_alignment = 1 + +[node name="TestSoundButton" type="Button" parent="SafeMargin/Scroll/Content/TestPanel/Margin/Content" unique_id=1609783017] +metadata/exchanger_binding_id = "settings.scroll.content.test_panel.margin.content.test_sound_button" +custom_minimum_size = Vector2(0, 75) +layout_mode = 2 +text = "ПРОВЕРИТЬ ЗВУК" + +[node name="Status" type="Label" parent="SafeMargin/Scroll/Content/TestPanel/Margin/Content" unique_id=2061523529] +metadata/exchanger_binding_id = "settings.scroll.content.test_panel.margin.content.status" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Настройки сохраняются автоматически." +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="AdvertisementSettingsPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=21455015] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_settings_panel" +custom_minimum_size = Vector2(0, 440) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel" unique_id=1405250504] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin" unique_id=1013830763] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=1786577231] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ПРОМО НА ГЛАВНОМ ЭКРАНЕ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=336744550] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Во время оплаты и выдачи жетонов промо скрывается." +autowrap_mode = 2 + +[node name="Enabled" type="CheckButton" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=1616831303] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_settings_panel.margin.content.enabled" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +text = "ПОКАЗЫВАТЬ ПРОМО" + +[node name="Muted" type="CheckButton" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=672717699] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_settings_panel.margin.content.muted" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +text = "ПРОМО-РОЛИК БЕЗ ЗВУКА" + +[node name="FallbackLabel" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=1641720039] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "ТЕКСТ ВМЕСТО ПРОМО • ДО 120 ЗНАКОВ" + +[node name="FallbackText" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=228850090] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_settings_panel.margin.content.fallback_text" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +placeholder_text = "ПРОМО-БЛОК" +max_length = 120 + +[node name="FallbackError" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=1143025908] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_settings_panel.margin.content.fallback_error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="AdvertisementPosterPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=455807788] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_poster_panel" +custom_minimum_size = Vector2(0, 370) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel" unique_id=1508974411] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin" unique_id=245064043] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content" unique_id=816209396] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "КАРТИНКА ВМЕСТО ПРОМО-РОЛИКА" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content" unique_id=1218265445] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Укажите путь к картинке на этом компьютере. Подойдут PNG, JPG и WEBP." +autowrap_mode = 2 + +[node name="PosterPath" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content" unique_id=732541286] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_poster_panel.margin.content.poster_path" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +placeholder_text = "Например: D:/Промо/постер.png" +max_length = 512 + +[node name="SelectButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_poster_panel.margin.content.select_button" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ВЫБРАТЬ КАРТИНКУ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content" unique_id=2129938351] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_poster_panel.margin.content.error" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="AdvertisementPlaylistPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1828835854] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel" +custom_minimum_size = Vector2(0, 287) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel" unique_id=1696350498] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin" unique_id=50006063] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content" unique_id=483709700] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ПРОМО-РОЛИКИ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content" unique_id=1727337488] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Добавьте до 12 промо-роликов в нужном порядке. Поддерживаются файлы OGV." +autowrap_mode = 2 + +[node name="Rows" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content" unique_id=1297952818] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="EmptyState" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.empty_state" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_type_variation = &"PanelText" +text = "Промо-ролики пока не добавлены. Будет показана картинка или текст вместо промо." +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Slot00" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot01" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot02" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot03" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot04" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot05" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot06" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot07" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot08" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot09" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot10" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot11" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + + +[node name="AddButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content" unique_id=1496917419] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.add_button" +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +text = "ВЫБРАТЬ И ДОБАВИТЬ ПРОМО-РОЛИК" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content" unique_id=1649782226] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="AdvertisementPreviewPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=193189850] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_preview_panel" +visible = false +custom_minimum_size = Vector2(0, 167) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AdvertisementPreviewPanel" unique_id=923292165] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 20 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPreviewPanel/Margin" unique_id=617595689] +layout_mode = 2 +theme_override_constants/separation = 11 +alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPreviewPanel/Margin/Content" unique_id=572843131] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ПРЕДВАРИТЕЛЬНЫЙ ИТОГ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Summary" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPreviewPanel/Margin/Content" unique_id=1780700717] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_preview_panel.margin.content.summary" +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "Промо включено • картинка не выбрана • промо-роликов: 0 +Текст вместо промо: ПРОМО-БЛОК" +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="AppearanceThemePanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1626819618] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_theme_panel" +visible = false +custom_minimum_size = Vector2(0, 460) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AppearanceThemePanel" unique_id=1429095905] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin" unique_id=1782922611] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=1789600456] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ВНЕШНЯЯ ТЕМА" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=1804153132] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Приложение всегда имеет одну встроенную тему. Единственный внешний DLC полностью заменит её после перезапуска, если пакет пройдёт проверку." +autowrap_mode = 2 + +[node name="ExternalEnabled" type="CheckButton" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=1434776624] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_theme_panel.margin.content.external_enabled" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +text = "ИСПОЛЬЗОВАТЬ ВНЕШНИЙ DLC" + +[node name="PathLabel" type="Label" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=1039205902] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "ПУТЬ К ПАКЕТУ .PCK" + +[node name="ExternalPath" type="LineEdit" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=435170720] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_theme_panel.margin.content.external_path" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +placeholder_text = "user://theme_dlc/active_theme.pck" +max_length = 512 + +[node name="PathError" type="Label" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=538466093] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_theme_panel.margin.content.path_error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="PackageStatus" type="Label" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=1243517659] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_theme_panel.margin.content.package_status" +custom_minimum_size = Vector2(0, 52) +layout_mode = 2 +theme_override_font_sizes/font_size = 14 +text = "Пакет будет проверен при запуске." +autowrap_mode = 2 + +[node name="AppearanceEffectsPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=2118148985] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_effects_panel" +visible = false +custom_minimum_size = Vector2(0, 260) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AppearanceEffectsPanel" unique_id=1463853577] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AppearanceEffectsPanel/Margin" unique_id=460821403] +layout_mode = 2 +theme_override_constants/separation = 13 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AppearanceEffectsPanel/Margin/Content" unique_id=1219490666] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ЭФФЕКТЫ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AppearanceEffectsPanel/Margin/Content" unique_id=1195218204] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Облегчённый режим отключает необязательные анимированные спрайты, но сохраняет статический фон и весь интерфейс." +autowrap_mode = 2 + +[node name="ReducedEffects" type="CheckButton" parent="SafeMargin/Scroll/Content/AppearanceEffectsPanel/Margin/Content" unique_id=336415120] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_effects_panel.margin.content.reduced_effects" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +text = "ОБЛЕГЧЁННЫЙ РЕЖИМ" + +[node name="AppearanceNoticePanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=391044970] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_notice_panel" +visible = false +custom_minimum_size = Vector2(0, 220) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AppearanceNoticePanel" unique_id=716854481] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 20 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin" unique_id=400003096] +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin/Content" unique_id=1138899540] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +text = "ПРИМЕНЕНИЕ ИЗМЕНЕНИЙ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Immediate" type="Label" parent="SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin/Content" unique_id=263463990] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Облегчённый режим применяется сразу." +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="Restart" type="Label" parent="SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin/Content" unique_id=1044968640] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.78, 0.25, 1) +theme_override_font_sizes/font_size = 15 +text = "Включение DLC и путь к нему применятся только после перезапуска приложения." +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="CurrentTheme" type="Label" parent="SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin/Content" unique_id=869113996] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_notice_panel.margin.content.current_theme" +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Сейчас активна встроенная тема." +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="PricePanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1175190762] +metadata/exchanger_binding_id = "settings.scroll.content.price_panel" +visible = false +custom_minimum_size = Vector2(0, 247) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/PricePanel" unique_id=1252255186] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/PricePanel/Margin" unique_id=37103992] +layout_mode = 2 +theme_override_constants/separation = 13 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content" unique_id=1223521084] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "СТОИМОСТЬ ОДНОГО ЖЕТОНА" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content" unique_id=442890264] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "По этой цене автомат рассчитывает, сколько жетонов получит покупатель." +autowrap_mode = 2 + +[node name="Price" type="SpinBox" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content" unique_id=906569844] +metadata/exchanger_binding_id = "settings.scroll.content.price_panel.margin.content.price" +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +max_value = 100000.0 +value = 10.0 +update_on_text_changed = true +suffix = " ₽" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content" unique_id=1786171896] +metadata/exchanger_binding_id = "settings.scroll.content.price_panel.margin.content.error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="BonusPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1271515153] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel" +visible = false +custom_minimum_size = Vector2(0, 347) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/BonusPanel" unique_id=1639657177] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin" unique_id=452757233] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content" unique_id=176636309] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ДОПОЛНИТЕЛЬНЫЕ ЖЕТОНЫ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content" unique_id=2054989766] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Например: при оплате от 500 ₽ подарить 5 жетонов. Если подходят несколько правил, сработает самое выгодное." +autowrap_mode = 2 + +[node name="ColumnHeaders" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content" unique_id=1779316545] +custom_minimum_size = Vector2(0, 32) +layout_mode = 2 +theme_override_constants/separation = 11 + +[node name="Threshold" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/ColumnHeaders" unique_id=1516500981] +custom_minimum_size = Vector2(200, 0) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 13 +text = "СУММА ОПЛАТЫ ОТ" + +[node name="Bonus" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/ColumnHeaders" unique_id=730440012] +custom_minimum_size = Vector2(160, 0) +layout_mode = 2 +theme_override_font_sizes/font_size = 13 +text = "ЖЕТОНОВ В ПОДАРОК" + +[node name="Spacer" type="Control" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/ColumnHeaders" unique_id=293311810] +custom_minimum_size = Vector2(103, 0) +layout_mode = 2 + +[node name="Rows" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content" unique_id=1591524546] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows" +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="EmptyState" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.empty_state" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_type_variation = &"PanelText" +text = "Бонусы пока не настроены." +vertical_alignment = 1 + +[node name="Slot00" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_00" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot01" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_01" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot02" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_02" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot03" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_03" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot04" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_04" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot05" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_05" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot06" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_06" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot07" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_07" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot08" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_08" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot09" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_09" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot10" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_10" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot11" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_11" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot12" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_12" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot13" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_13" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot14" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_14" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot15" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_15" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + + +[node name="AddRuleButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content" unique_id=1425772859] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.add_rule_button" +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +text = "+ ДОБАВИТЬ БОНУС" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content" unique_id=397393203] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="PreviewPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1550161471] +metadata/exchanger_binding_id = "settings.scroll.content.preview_panel" +visible = false +custom_minimum_size = Vector2(0, 247) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/PreviewPanel" unique_id=1017412349] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/PreviewPanel/Margin" unique_id=1259875287] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/PreviewPanel/Margin/Content" unique_id=1565228186] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ПРИМЕР РАСЧЁТА" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/PreviewPanel/Margin/Content" unique_id=1558111681] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Столько жетонов получит покупатель при разных суммах оплаты." +autowrap_mode = 2 + +[node name="Values" type="Label" parent="SafeMargin/Scroll/Content/PreviewPanel/Margin/Content" unique_id=1890400642] +metadata/exchanger_binding_id = "settings.scroll.content.preview_panel.margin.content.values" +custom_minimum_size = Vector2(0, 140) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "10 ₽ → 1 + 0 = 1 жет." +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="PaymentMethodsPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1014677861] +metadata/exchanger_binding_id = "settings.scroll.content.payment_methods_panel" +visible = false +custom_minimum_size = Vector2(0, 260) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel" unique_id=1681526059] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin" unique_id=1821598693] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content" unique_id=1268252060] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "СПОСОБЫ ОПЛАТЫ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content" unique_id=71570643] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Оставьте хотя бы один доступный способ оплаты." +autowrap_mode = 2 + +[node name="CashEnabled" type="CheckButton" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content" unique_id=1651424955] +metadata/exchanger_binding_id = "settings.scroll.content.payment_methods_panel.margin.content.cash_enabled" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "НАЛИЧНАЯ ОПЛАТА" + +[node name="CardEnabled" type="CheckButton" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content" unique_id=60921604] +metadata/exchanger_binding_id = "settings.scroll.content.payment_methods_panel.margin.content.card_enabled" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "ОПЛАТА КАРТОЙ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content" unique_id=614267083] +metadata/exchanger_binding_id = "settings.scroll.content.payment_methods_panel.margin.content.error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="CardOptionsPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1947991433] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel" +visible = false +custom_minimum_size = Vector2(0, 393) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel" unique_id=1880854225] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin" unique_id=1861346620] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=1252395405] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "СУММЫ ДЛЯ ОПЛАТЫ КАРТОЙ" +vertical_alignment = 1 + +[node name="Limits" type="GridContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=796782793] +layout_mode = 2 +theme_override_constants/h_separation = 13 +theme_override_constants/v_separation = 9 +columns = 2 + +[node name="MaxLabel" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits" unique_id=1157864144] +custom_minimum_size = Vector2(240, 63) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "МАКСИМАЛЬНАЯ СУММА" +vertical_alignment = 1 + +[node name="MaxAmount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits" unique_id=945870327] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.limits.max_amount" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +value = 5000.0 +update_on_text_changed = true +suffix = " ₽" + +[node name="StepLabel" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits" unique_id=117453909] +custom_minimum_size = Vector2(240, 63) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "ШАГ ВЫБОРА СУММЫ" +vertical_alignment = 1 + +[node name="CustomStep" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits" unique_id=2056772758] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.limits.custom_step" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +value = 10.0 +update_on_text_changed = true +suffix = " ₽" + +[node name="LimitsError" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=1784116878] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.limits_error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="PresetsTitle" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=3400869] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "ГОТОВЫЕ СУММЫ" + +[node name="PresetRows" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=783857380] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows" +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="EmptyState" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.empty_state" +visible = false +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_type_variation = &"PanelText" +text = "Готовые суммы пока не добавлены. Покупатель сможет ввести сумму сам." +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Slot00" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 10.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot01" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 50.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot02" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 100.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot03" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 150.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot04" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 200.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot05" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 300.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot06" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 400.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot07" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 500.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot08" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot09" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot10" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot11" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot12" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot13" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot14" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot15" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + + +[node name="AddPresetButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=1011694991] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.add_preset_button" +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +text = "+ ДОБАВИТЬ СУММУ" + +[node name="PresetsError" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=1336764151] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.presets_error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="TimeoutsPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=225381422] +metadata/exchanger_binding_id = "settings.scroll.content.timeouts_panel" +visible = false +custom_minimum_size = Vector2(0, 380) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/TimeoutsPanel" unique_id=1678816813] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin" unique_id=2108046571] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content" unique_id=423508686] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ВРЕМЯ ОЖИДАНИЯ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content" unique_id=844982832] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Настройте, сколько времени автомат ждёт действий покупателя." +autowrap_mode = 2 + +[node name="Grid" type="GridContainer" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content" unique_id=987524088] +layout_mode = 2 +theme_override_constants/h_separation = 13 +theme_override_constants/v_separation = 9 +columns = 2 + +[node name="IdleLabel" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid" unique_id=1311503414] +custom_minimum_size = Vector2(273, 63) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "ВОЗВРАТ НА ГЛАВНЫЙ (15–600 С)" +vertical_alignment = 1 + +[node name="Idle" type="SpinBox" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid" unique_id=396087782] +metadata/exchanger_binding_id = "settings.scroll.content.timeouts_panel.margin.content.grid.idle" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 600.0 +value = 45.0 +update_on_text_changed = true +suffix = " с" + +[node name="CashPaymentLabel" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid" unique_id=254111291] +custom_minimum_size = Vector2(273, 63) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "ОПЛАТА НАЛИЧНЫМИ (15–600 С)" +vertical_alignment = 1 + +[node name="CashPayment" type="SpinBox" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid" unique_id=800168430] +metadata/exchanger_binding_id = "settings.scroll.content.timeouts_panel.margin.content.grid.cash_payment" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 600.0 +value = 45.0 +update_on_text_changed = true +suffix = " с" + +[node name="CashlessPaymentLabel" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid"] +custom_minimum_size = Vector2(273, 63) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "БЕЗНАЛИЧНАЯ ОПЛАТА (30–600 С)" +vertical_alignment = 1 + +[node name="CashlessPayment" type="SpinBox" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid"] +metadata/exchanger_binding_id = "settings.scroll.content.timeouts_panel.margin.content.grid.cashless_payment" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 600.0 +value = 120.0 +update_on_text_changed = true +suffix = " с" + +[node name="SuccessLabel" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid" unique_id=1606719904] +custom_minimum_size = Vector2(273, 63) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "ПОКАЗЫВАТЬ РЕЗУЛЬТАТ (3–60 С)" +vertical_alignment = 1 + +[node name="Success" type="SpinBox" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid" unique_id=1946483701] +metadata/exchanger_binding_id = "settings.scroll.content.timeouts_panel.margin.content.grid.success" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 60.0 +value = 10.0 +update_on_text_changed = true +suffix = " с" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content" unique_id=53635746] +metadata/exchanger_binding_id = "settings.scroll.content.timeouts_panel.margin.content.error" +custom_minimum_size = Vector2(0, 53) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="EquipmentConnectionPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=12227059] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel" +visible = false +custom_minimum_size = Vector2(0, 607) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel" unique_id=1058768579] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin" unique_id=1950970524] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=608936661] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ПОДКЛЮЧЕНИЕ К КОНТРОЛЛЕРУ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=735739671] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Только для инженера. Изменения применяются после перезапуска и никогда не выполняют reconnect во время открытой операции." +autowrap_mode = 2 + +[node name="UseMock" type="CheckButton" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=1144904040] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel.margin.content.use_mock" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +text = "ИСПОЛЬЗОВАТЬ ИМИТАЦИЮ ОБОРУДОВАНИЯ" + +[node name="PortLabel" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=1595492564] +layout_mode = 2 +text = "COM-ПОРТ" + +[node name="PortName" type="LineEdit" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=1760832797] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel.margin.content.port_name" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +placeholder_text = "COM3" +max_length = 16 + +[node name="PortError" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=993828178] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel.margin.content.port_error" +custom_minimum_size = Vector2(0, 37) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="BaudLabel" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=1392931961] +layout_mode = 2 +text = "СКОРОСТЬ ПОРТА" + +[node name="BaudRate" type="OptionButton" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=810177550] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel.margin.content.baud_rate" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 + +[node name="ReconnectLabel" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=1591920916] +layout_mode = 2 +text = "ПОВТОР ПОДКЛЮЧЕНИЯ (1–60 С)" + +[node name="ReconnectSeconds" type="SpinBox" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=175154894] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel.margin.content.reconnect_seconds" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +min_value = 1.0 +max_value = 60.0 +value = 5.0 +update_on_text_changed = true +suffix = " с" + +[node name="ConnectionError" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=242906874] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel.margin.content.connection_error" +custom_minimum_size = Vector2(0, 40) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="EquipmentWindowPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1569238897] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_window_panel" +visible = false +custom_minimum_size = Vector2(0, 433) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel" unique_id=1468236776] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin" unique_id=660658614] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=1729915087] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ОКНО И ПРОИЗВОДИТЕЛЬНОСТЬ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=1381667443] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Инженерные restart-only параметры. Размер debug-окна и низкоуровневые параметры протокола намеренно не редактируются здесь." +autowrap_mode = 2 + +[node name="Fullscreen" type="CheckButton" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=859230815] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_window_panel.margin.content.fullscreen" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +text = "ПОЛНОЭКРАННЫЙ РЕЖИМ" + +[node name="Borderless" type="CheckButton" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=445076357] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_window_panel.margin.content.borderless" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +text = "ОКНО БЕЗ РАМКИ" + +[node name="DebugWindow" type="CheckButton" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=1119698600] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_window_panel.margin.content.debug_window" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +text = "DEBUG-СБОРКА ЗАПУСКАЕТСЯ В ОКНЕ" + +[node name="FpsLabel" type="Label" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=67242259] +layout_mode = 2 +text = "ЦЕЛЕВОЙ FPS" + +[node name="TargetFps" type="OptionButton" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=48472713] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_window_panel.margin.content.target_fps" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 + +[node name="EquipmentNotice" type="Label" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=1717442777] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.72, 0.2, 1) +theme_override_font_sizes/font_size = 15 +text = "AES-ключ, режим шифрования, команды выдачи, framing и параметры датчика не показываются и не сохраняются через этот экран." +autowrap_mode = 2 + +[node name="DiagnosticsStatusPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=63855322] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_status_panel" +visible = false +custom_minimum_size = Vector2(0, 400) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/DiagnosticsStatusPanel" unique_id=363101112] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin" unique_id=1447109247] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content" unique_id=1149194245] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "СОСТОЯНИЕ ПРИЛОЖЕНИЯ" +vertical_alignment = 1 + +[node name="RuntimeSummary" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content" unique_id=1086182449] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_status_panel.margin.content.runtime_summary" +custom_minimum_size = Vector2(0, 120) +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Версия и состояние загружаются при открытии." +autowrap_mode = 2 + +[node name="RecentErrorsTitle" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content" unique_id=1121096725] +layout_mode = 2 +text = "ПОСЛЕДНИЕ БЕЗОПАСНЫЕ ПРЕДУПРЕЖДЕНИЯ" + +[node name="RecentErrors" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content" unique_id=601475052] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_status_panel.margin.content.recent_errors" +custom_minimum_size = Vector2(0, 147) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Нет предупреждений." +autowrap_mode = 2 + +[node name="DiagnosticsLoggingPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=246114907] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_logging_panel" +visible = false +custom_minimum_size = Vector2(0, 467) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel" unique_id=618088528] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin" unique_id=1603972519] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=728718139] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ЖУРНАЛИРОВАНИЕ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=1279998568] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Изменения применяются сразу. Общий объём архивов автоматически ограничен десятью максимальными файлами. Сырые кадры протокола недоступны." +autowrap_mode = 2 + +[node name="LevelLabel" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=1684427359] +layout_mode = 2 +text = "МИНИМАЛЬНЫЙ УРОВЕНЬ" + +[node name="MinimumLevel" type="OptionButton" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=1516855564] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_logging_panel.margin.content.minimum_level" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 + +[node name="SizeLabel" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=1036811316] +layout_mode = 2 +text = "МАКСИМАЛЬНЫЙ РАЗМЕР ФАЙЛА (1–100 МБ)" + +[node name="MaxFileSize" type="SpinBox" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=379652985] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_logging_panel.margin.content.max_file_size" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +min_value = 1.0 +value = 5.0 +update_on_text_changed = true +suffix = " МБ" + +[node name="RetentionLabel" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=1113473421] +layout_mode = 2 +text = "СРОК ХРАНЕНИЯ (1–365 ДНЕЙ)" + +[node name="RetentionDays" type="SpinBox" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=1996036056] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_logging_panel.margin.content.retention_days" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +min_value = 1.0 +max_value = 365.0 +value = 14.0 +update_on_text_changed = true +suffix = " дн." + +[node name="LoggingError" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=722071223] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_logging_panel.margin.content.logging_error" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="ServiceInventoryPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel" +visible = false +custom_minimum_size = Vector2(0, 772) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "УЧЁТ ЖЕТОНОВ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.description" +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Здесь можно пересчитать жетоны в хоппере или добавить уже пересчитанные вручную." +autowrap_mode = 2 + +[node name="InventoryAccountingEnabled" type="CheckButton" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.inventory_enabled" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +button_pressed = true +text = "УЧИТЫВАТЬ ОСТАТОК ЖЕТОНОВ" + +[node name="PurchaseLimitEnabled" type="CheckButton" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.purchase_limit_enabled" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +button_pressed = true +text = "НЕ ПРОДАВАТЬ БОЛЬШЕ ОСТАТКА" + +[node name="Count" type="Label" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.count" +custom_minimum_size = Vector2(0, 68) +layout_mode = 2 +theme_override_font_sizes/font_size = 25 +text = "УЧТЁННЫЙ ОСТАТОК: 0 ЖЕТОНОВ" +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Hopper1Label" type="Label" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.hopper1_label" +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "ХОППЕР 1 — ОСТАТОК: 0 ЖЕТОНОВ" + +[node name="Hopper1" type="HBoxContainer" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="RecountButton" type="Button" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.hopper1.recount_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +text = "ПЕРЕСЧИТАТЬ" + +[node name="ManualAddButton" type="Button" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.hopper1.manual_add_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +text = "ДОБАВИТЬ" + +[node name="Hopper2Label" type="Label" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.hopper2_label" +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "ХОППЕР 2 — ОСТАТОК: 0 ЖЕТОНОВ" + +[node name="Hopper2" type="HBoxContainer" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="RecountButton" type="Button" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.hopper2.recount_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +text = "ПЕРЕСЧИТАТЬ" + +[node name="ManualAddButton" type="Button" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.hopper2.manual_add_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +text = "ДОБАВИТЬ" + +[node name="LegacyRecountButton" type="Button" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +visible = false +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.recount_button" +layout_mode = 2 + +[node name="LegacyManualAddButton" type="Button" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +visible = false +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.manual_add_button" +layout_mode = 2 + +[node name="Status" type="Label" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.status" +custom_minimum_size = Vector2(0, 50) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Остаток ещё не заполнен." +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="MenuVisibilityPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.menu_visibility_panel" +visible = false +custom_minimum_size = Vector2(0, 360) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "СКРЫТИЕ ЭЛЕМЕНТОВ МЕНЮ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Выберите, какие необязательные элементы показывать на главном экране." +autowrap_mode = 2 + +[node name="ShowStockStatus" type="CheckButton" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.menu_visibility_panel.margin.content.show_stock_status" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +button_pressed = true +text = "ПОКАЗЫВАТЬ ОСТАТОК ЖЕТОНОВ" + +[node name="ShowRightCharacter" type="CheckButton" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.menu_visibility_panel.margin.content.show_right_character" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +button_pressed = true +text = "ПОКАЗЫВАТЬ ПОЛЬЗОВАТЕЛЬСКИЙ ТЕКСТ" + +[node name="ShowPromotionBlock" type="CheckButton" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.menu_visibility_panel.margin.content.show_promotion_block" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +button_pressed = true +text = "ПОКАЗЫВАТЬ ПРОМО-БЛОК" + +[node name="ShowInteractivePet" type="CheckButton" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.menu_visibility_panel.margin.content.show_interactive_pet" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +button_pressed = true +text = "ИНТЕРАКТИВНЫЙ ПИТОМЕЦ" + +[node name="DiagnosticsTestPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=2121165678] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_test_panel" +visible = false +custom_minimum_size = Vector2(0, 287) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/DiagnosticsTestPanel" unique_id=1592965841] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin" unique_id=1036673328] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content" unique_id=1717665009] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ТЕСТОВАЯ ВЫДАЧА" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content" unique_id=537707851] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Выдаёт один жетон без оплаты. Требует готового контроллера и повторного подтверждения кнопкой." +autowrap_mode = 2 + +[node name="TestDispenseButton" type="Button" parent="SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content" unique_id=263785982] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_test_panel.margin.content.test_dispense_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +text = "ТЕСТ: ВЫДАТЬ 1 ЖЕТОН" + +[node name="TestDispenseStatus" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content" unique_id=2115719528] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_test_panel.margin.content.test_dispense_status" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Тест не запущен." +autowrap_mode = 2 + +[node name="FooterClearance" type="Control" parent="SafeMargin/Scroll/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.footer_clearance" +custom_minimum_size = Vector2(0, 124) +layout_mode = 2 +mouse_filter = 2 + +[node name="ServiceDialogOverlay" type="ColorRect" parent="SafeMargin"] +metadata/exchanger_binding_id = "settings.service_dialog_overlay" +visible = false +z_index = 20 +layout_mode = 2 +mouse_filter = 0 +color = Color(0, 0, 0, 0.72) + +[node name="Dialog" type="PanelContainer" parent="SafeMargin/ServiceDialogOverlay"] +custom_minimum_size = Vector2(600, 460) +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -300.0 +offset_top = -230.0 +offset_right = 300.0 +offset_bottom = 230.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/ServiceDialogOverlay/Dialog"] +layout_mode = 2 +theme_override_constants/margin_left = 28 +theme_override_constants/margin_top = 28 +theme_override_constants/margin_right = 28 +theme_override_constants/margin_bottom = 28 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin"] +layout_mode = 2 +theme_override_constants/separation = 20 + +[node name="Title" type="Label" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content"] +metadata/exchanger_binding_id = "settings.service_dialog_overlay.dialog.margin.content.title" +custom_minimum_size = Vector2(0, 58) +layout_mode = 2 +theme_override_font_sizes/font_size = 27 +text = "ДОБАВИТЬ ЖЕТОНЫ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content"] +metadata/exchanger_binding_id = "settings.service_dialog_overlay.dialog.margin.content.description" +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "Введите количество жетонов. Перед добавлением приложение попросит подтвердить число." +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Amount" type="SpinBox" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content"] +metadata/exchanger_binding_id = "settings.service_dialog_overlay.dialog.margin.content.amount" +custom_minimum_size = Vector2(0, 72) +layout_mode = 2 +max_value = 999999.0 +update_on_text_changed = true + +[node name="Buttons" type="HBoxContainer" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content"] +custom_minimum_size = Vector2(0, 76) +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="CancelButton" type="Button" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Buttons"] +metadata/exchanger_binding_id = "settings.service_dialog_overlay.dialog.margin.content.buttons.cancel_button" +custom_minimum_size = Vector2(0, 76) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 17 +text = "ОТМЕНА" + +[node name="ConfirmButton" type="Button" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Buttons"] +metadata/exchanger_binding_id = "settings.service_dialog_overlay.dialog.margin.content.buttons.confirm_button" +custom_minimum_size = Vector2(0, 76) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 17 +text = "ПОДТВЕРДИТЬ" + +[node name="Footer" type="PanelContainer" parent="."] +metadata/exchanger_binding_id = "settings.footer" +z_index = 5 +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 28.0 +offset_top = -132.0 +offset_right = -28.0 +offset_bottom = -28.0 +grow_horizontal = 2 +grow_vertical = 0 +theme_type_variation = &"FooterPanel" + +[node name="SaveAndExitButton" type="Button" parent="Footer"] +metadata/exchanger_binding_id = "settings.footer.save_and_exit_button" +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 14.0 +offset_top = 10.0 +offset_right = -14.0 +offset_bottom = -10.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_type_variation = &"PrimaryButton" +theme_override_font_sizes/font_size = 25 +text = "СОХРАНИТЬ И ВЫЙТИ" + +[node name="OnScreenKeyboardOverlay" parent="." instance=ExtResource("4_keyboards")] diff --git a/themes/example/package/scenes/screen_success.tscn b/themes/example/package/scenes/screen_success.tscn new file mode 100644 index 0000000..e8a0d43 --- /dev/null +++ b/themes/example/package/scenes/screen_success.tscn @@ -0,0 +1,135 @@ +[gd_scene load_steps=7 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_ui_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="1_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="1_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="3_footer"] + +[node name="ThemeScreen_Success" type="Control"] +metadata/exchanger_screen_binding_id = "screen.success" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_ui_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("1_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("1_decoration")] + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "success.content.home_navigation_button" +layout_mode = 0 +offset_left = 24.0 +offset_top = 16.0 +offset_right = 76.0 +offset_bottom = 68.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" + +[node name="House" type="Polygon2D" parent="SafeMargin/Content/HomeNavigationButton"] +polygon = PackedVector2Array(8, 27, 26, 27, 17, 13) +color = Color(1, 1, 1, 1) + +[node name="HouseBody" type="Polygon2D" parent="SafeMargin/Content/HomeNavigationButton"] +polygon = PackedVector2Array(11, 26, 23, 26, 23, 40, 18, 40, 18, 32, 16, 32, 16, 40, 11, 40) +color = Color(1, 1, 1, 1) + +[node name="ScreenTitle" type="Label" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 40.0 +offset_top = 44.0 +offset_right = 680.0 +offset_bottom = 112.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА ПРОШЛА" +horizontal_alignment = 1 + +[node name="ResultPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 96.0 +offset_top = 176.0 +offset_right = 624.0 +offset_bottom = 826.0 +theme_type_variation = &"WhitePanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Content/ResultPanel"] +layout_mode = 2 + +[node name="Values" type="VBoxContainer" parent="SafeMargin/Content/ResultPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Title" type="Label" parent="SafeMargin/Content/ResultPanel/Margin/Values"] +metadata/exchanger_binding_id = "success.content.result_panel.margin.values.title" +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ВЫДАЧА ЖЕТОНОВ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Body" type="Control" parent="SafeMargin/Content/ResultPanel/Margin/Values"] +custom_minimum_size = Vector2(0, 538) +layout_mode = 2 +mouse_filter = 2 + +[node name="Message" type="Label" parent="SafeMargin/Content/ResultPanel/Margin/Values/Body"] +metadata/exchanger_binding_id = "success.content.result_panel.margin.values.body.message" +layout_mode = 0 +offset_left = 28.0 +offset_top = 418.0 +offset_right = 456.0 +offset_bottom = 504.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 23 +text = "ЗАБЕРИТЕ ЖЕТОНЫ\nСПАСИБО ЗА ПОКУПКУ!" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Countdown" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "success.content.countdown" +visible = false + +[node name="HomeButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "success.content.home_button" +layout_mode = 0 +offset_left = 84.0 +offset_top = 862.0 +offset_right = 636.0 +offset_bottom = 956.0 +theme_type_variation = &"PrimaryButton" +text = "НА ГЛАВНЫЙ ЭКРАН" + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("3_footer")] +layout_mode = 1 diff --git a/themes/example/package/scripts/decoration_controller.gd b/themes/example/package/scripts/decoration_controller.gd new file mode 100644 index 0000000..d79be42 --- /dev/null +++ b/themes/example/package/scripts/decoration_controller.gd @@ -0,0 +1,20 @@ +extends Node2D + +## Пример runtime-скрипта доверенной темы. Он прикреплён к общей decoration-сцене, +## но выключен по умолчанию, чтобы шаблон оставался визуально нейтральным. +@export var animation_enabled := false +@export_range(0.0, 200.0, 0.5) var vertical_amplitude := 12.0 +@export_range(0.01, 10.0, 0.01) var cycles_per_second := 0.25 + +var _origin := Vector2.ZERO +var _phase := 0.0 + + +func _ready() -> void: + _origin = position + set_process(animation_enabled) + + +func _process(delta: float) -> void: + _phase = fmod(_phase + delta * cycles_per_second, 1.0) + position = _origin + Vector2(0.0, sin(_phase * TAU) * vertical_amplitude) diff --git a/themes/example/package/scripts/decoration_controller.gd.uid b/themes/example/package/scripts/decoration_controller.gd.uid new file mode 100644 index 0000000..b992b70 --- /dev/null +++ b/themes/example/package/scripts/decoration_controller.gd.uid @@ -0,0 +1 @@ +uid://c3351q5vojmpt diff --git a/themes/example/package/ui_theme.tres b/themes/example/package/ui_theme.tres new file mode 100644 index 0000000..81aa911 --- /dev/null +++ b/themes/example/package/ui_theme.tres @@ -0,0 +1,59 @@ +[gd_resource type="Theme" load_steps=3 format=3] + +[sub_resource type="StyleBoxFlat" id="Panel"] +content_margin_left = 24.0 +content_margin_top = 24.0 +content_margin_right = 24.0 +content_margin_bottom = 24.0 +bg_color = Color(0.96, 0.97, 0.99, 1) +corner_radius_top_left = 18 +corner_radius_top_right = 18 +corner_radius_bottom_right = 18 +corner_radius_bottom_left = 18 + +[sub_resource type="StyleBoxFlat" id="Button"] +content_margin_left = 20.0 +content_margin_top = 16.0 +content_margin_right = 20.0 +content_margin_bottom = 16.0 +bg_color = Color(0.2, 0.64, 0.36, 1) +corner_radius_top_left = 14 +corner_radius_top_right = 14 +corner_radius_bottom_right = 14 +corner_radius_bottom_left = 14 + +[resource] +default_font_size = 24 +AdvertisementPanel/base_type = &"PanelContainer" +BackButton/base_type = &"Button" +Label/colors/font_color = Color(0.06, 0.1, 0.16, 1) +Button/colors/font_color = Color(1, 1, 1, 1) +Button/styles/normal = SubResource("Button") +Button/styles/hover = SubResource("Button") +Button/styles/pressed = SubResource("Button") +PanelContainer/styles/panel = SubResource("Panel") +CompactButton/base_type = &"Button" +KeyboardActionButton/base_type = &"Button" +KeyboardActionButton/font_sizes/font_size = 22 +KeypadButton/base_type = &"Button" +DangerButton/base_type = &"Button" +ErrorText/base_type = &"Label" +ErrorText/colors/font_color = Color(0.75, 0.12, 0.16, 1) +FieldLabel/base_type = &"Label" +FooterPanel/base_type = &"PanelContainer" +PanelText/base_type = &"Label" +PanelTitle/base_type = &"Label" +PrimaryButton/base_type = &"Button" +ScreenDescription/base_type = &"Label" +ScreenDescription/colors/font_color = Color(1, 1, 1, 1) +ScreenTitle/base_type = &"Label" +ScreenTitle/colors/font_color = Color(1, 1, 1, 1) +SecondaryButton/base_type = &"Button" +SectionTabButton/base_type = &"Button" +StatusPanel/base_type = &"PanelContainer" +StatusText/base_type = &"Label" +SupportText/base_type = &"Label" +SupportText/colors/font_color = Color(1, 1, 1, 1) +WarningText/base_type = &"Label" +WarningText/colors/font_color = Color(0.82, 0.5, 0.08, 1) +WhitePanel/base_type = &"PanelContainer" diff --git a/themes/example/source/README.md b/themes/example/source/README.md new file mode 100644 index 0000000..714250a --- /dev/null +++ b/themes/example/source/README.md @@ -0,0 +1,4 @@ +# Исходные материалы + +Храните здесь изображения в исходном качестве, исходники анимаций, шрифты и их лицензии. +Локальные фото и видео для визуального сравнения помещайте в `references/`. diff --git a/themes/example/source/interactive_pet/README.md b/themes/example/source/interactive_pet/README.md new file mode 100644 index 0000000..11325ae --- /dev/null +++ b/themes/example/source/interactive_pet/README.md @@ -0,0 +1,24 @@ +# Шаблон логики интерактивного питомца + +Production-компонент питомца создаётся в `package/components/interactive_pet.tscn`, а его +GDScript и ресурсы остаются внутри `package/`. Поле `InteractivePetScene` в manifest должно +ссылаться на эту сцену через канонический namespace `res://exchanger_theme_dlc/`. + +Пошаговая раскадровка, структура `VisualRoot`, политики движения и checklist дизайнера описаны в +[`docs/COMPOSITE_PET_ANIMATIONS.md`](../../../../docs/COMPOSITE_PET_ANIMATIONS.md). + +Сначала реализуйте обязательный animation API из `docs/SCRIPTING.md`. Если уникальное действие +состоит из нескольких анимаций и перемещений, реализуйте весь optional composite API целиком: +`has_pet_composite_action`, `start_pet_composite_action`, `cancel_pet_composite_action` и два +сигнала `pet_composite_action_started` / `pet_composite_action_finished`. Частичная реализация +считается ошибкой валидатора. + +Сценарий работает как корутина темы. Host передаёт scoped runtime: через него тема временно +приостанавливает обычную навигацию, разрешает выход за границы, игнорирует опоры, перемещает +экранную опорную точку и затем явно возвращает её на объявленную поверхность. Перед каждым +`await` проверяйте `runtime.IsActive()` и run id. При отмене немедленно прекращайте корутину, +возвращайте локальный scale/визуальные состояния и не управляйте старым runtime. + +После реализации активируйте тему и откройте `PET` в preview. Обычные кнопки проверяют каждый +клип отдельно, а «СОСТАВНОЕ ДЕЙСТВИЕ: INTERACT» — полный сценарий с проходом через границу и +посадкой на верхнюю тестовую опору. diff --git a/themes/space-pixel/package/assets/animations/alien_pet/action.png b/themes/space-pixel/package/assets/animations/alien_pet/action.png new file mode 100644 index 0000000..2c25f30 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/action.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/action.png.import b/themes/space-pixel/package/assets/animations/alien_pet/action.png.import new file mode 100644 index 0000000..6aa6bec --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/action.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://evysfqsy6g8w" +path="res://.godot/imported/action.png-04241c8360c1fc8dad2f0e02a075a6ae.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/action.png" +dest_files=["res://.godot/imported/action.png-04241c8360c1fc8dad2f0e02a075a6ae.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/climbing_action.png b/themes/space-pixel/package/assets/animations/alien_pet/climbing_action.png new file mode 100644 index 0000000..41a3425 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/climbing_action.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/climbing_action.png.import b/themes/space-pixel/package/assets/animations/alien_pet/climbing_action.png.import new file mode 100644 index 0000000..1346c9c --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/climbing_action.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://kanikcd4bu5v" +path="res://.godot/imported/climbing_action.png-2d21024c7efd5aad7f3d68823237cd2c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/climbing_action.png" +dest_files=["res://.godot/imported/climbing_action.png-2d21024c7efd5aad7f3d68823237cd2c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/climbing_action_start.png b/themes/space-pixel/package/assets/animations/alien_pet/climbing_action_start.png new file mode 100644 index 0000000..de73a1e Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/climbing_action_start.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/climbing_action_start.png.import b/themes/space-pixel/package/assets/animations/alien_pet/climbing_action_start.png.import new file mode 100644 index 0000000..8e4b3fb --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/climbing_action_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d337pf8yibt1l" +path="res://.godot/imported/climbing_action_start.png-7621e08628cbf0bc7c4abfac23565650.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/climbing_action_start.png" +dest_files=["res://.godot/imported/climbing_action_start.png-7621e08628cbf0bc7c4abfac23565650.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/climbing_action_stop.png b/themes/space-pixel/package/assets/animations/alien_pet/climbing_action_stop.png new file mode 100644 index 0000000..da6d719 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/climbing_action_stop.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/climbing_action_stop.png.import b/themes/space-pixel/package/assets/animations/alien_pet/climbing_action_stop.png.import new file mode 100644 index 0000000..8525d0f --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/climbing_action_stop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6rcpc0l736sq" +path="res://.godot/imported/climbing_action_stop.png-72a1e16849c110fc74b0628394a70392.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/climbing_action_stop.png" +dest_files=["res://.godot/imported/climbing_action_stop.png-72a1e16849c110fc74b0628394a70392.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/climbing_down.png b/themes/space-pixel/package/assets/animations/alien_pet/climbing_down.png new file mode 100644 index 0000000..f3e4090 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/climbing_down.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/climbing_down.png.import b/themes/space-pixel/package/assets/animations/alien_pet/climbing_down.png.import new file mode 100644 index 0000000..b02b2cd --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/climbing_down.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bpyxt5fswdyqg" +path="res://.godot/imported/climbing_down.png-6429479bec6fd2f1217fe1eff0914fee.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/climbing_down.png" +dest_files=["res://.godot/imported/climbing_down.png-6429479bec6fd2f1217fe1eff0914fee.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/climbing_start.png b/themes/space-pixel/package/assets/animations/alien_pet/climbing_start.png new file mode 100644 index 0000000..374294b Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/climbing_start.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/climbing_start.png.import b/themes/space-pixel/package/assets/animations/alien_pet/climbing_start.png.import new file mode 100644 index 0000000..7a8873e --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/climbing_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cm7gpna4ns3be" +path="res://.godot/imported/climbing_start.png-976d4aa9fa2234fa4a3771e191985bb2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/climbing_start.png" +dest_files=["res://.godot/imported/climbing_start.png-976d4aa9fa2234fa4a3771e191985bb2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/climbing_up.png b/themes/space-pixel/package/assets/animations/alien_pet/climbing_up.png new file mode 100644 index 0000000..f5f41bf Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/climbing_up.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/climbing_up.png.import b/themes/space-pixel/package/assets/animations/alien_pet/climbing_up.png.import new file mode 100644 index 0000000..98c8b9e --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/climbing_up.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cnbee5ael5tsg" +path="res://.godot/imported/climbing_up.png-d9e59f7bdcdd80d45b445a1e99e2ab48.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/climbing_up.png" +dest_files=["res://.godot/imported/climbing_up.png-d9e59f7bdcdd80d45b445a1e99e2ab48.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/come_closer.png b/themes/space-pixel/package/assets/animations/alien_pet/come_closer.png new file mode 100644 index 0000000..f3127ee Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/come_closer.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/come_closer.png.import b/themes/space-pixel/package/assets/animations/alien_pet/come_closer.png.import new file mode 100644 index 0000000..5e07493 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/come_closer.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bsmel1k8ohsub" +path="res://.godot/imported/come_closer.png-d28ccffd8455d606c3a161a9fd1e2e32.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/come_closer.png" +dest_files=["res://.godot/imported/come_closer.png-d28ccffd8455d606c3a161a9fd1e2e32.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/dancing.png b/themes/space-pixel/package/assets/animations/alien_pet/dancing.png new file mode 100644 index 0000000..cc2238a Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/dancing.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/dancing.png.import b/themes/space-pixel/package/assets/animations/alien_pet/dancing.png.import new file mode 100644 index 0000000..13e37a9 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/dancing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dokquou3nkitt" +path="res://.godot/imported/dancing.png-ed0b68ebc77941dab2c3af8b2e791e18.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/dancing.png" +dest_files=["res://.godot/imported/dancing.png-ed0b68ebc77941dab2c3af8b2e791e18.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/falling.png b/themes/space-pixel/package/assets/animations/alien_pet/falling.png new file mode 100644 index 0000000..39548de Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/falling.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/falling.png.import b/themes/space-pixel/package/assets/animations/alien_pet/falling.png.import new file mode 100644 index 0000000..05388c9 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/falling.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bfs0gdewfedmh" +path="res://.godot/imported/falling.png-30ad2b866ad2eaafecedbfbf5883ff42.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/falling.png" +dest_files=["res://.godot/imported/falling.png-30ad2b866ad2eaafecedbfbf5883ff42.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/hanging.png b/themes/space-pixel/package/assets/animations/alien_pet/hanging.png new file mode 100644 index 0000000..caf0453 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/hanging.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/hanging.png.import b/themes/space-pixel/package/assets/animations/alien_pet/hanging.png.import new file mode 100644 index 0000000..d8b91df --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/hanging.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://7d45p4xo6waq" +path="res://.godot/imported/hanging.png-b2789e9f89ccc9fedce7d4e0b20a9a23.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/hanging.png" +dest_files=["res://.godot/imported/hanging.png-b2789e9f89ccc9fedce7d4e0b20a9a23.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/idle.png b/themes/space-pixel/package/assets/animations/alien_pet/idle.png new file mode 100644 index 0000000..99a2960 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/idle.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/idle.png.import b/themes/space-pixel/package/assets/animations/alien_pet/idle.png.import new file mode 100644 index 0000000..7f730ac --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/idle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bog3bxikpv4vv" +path="res://.godot/imported/idle.png-51971cb3a4f4e87229ff2d2465a67822.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/idle.png" +dest_files=["res://.godot/imported/idle.png-51971cb3a4f4e87229ff2d2465a67822.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/landing.png b/themes/space-pixel/package/assets/animations/alien_pet/landing.png new file mode 100644 index 0000000..4b6d00b Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/landing.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/landing.png.import b/themes/space-pixel/package/assets/animations/alien_pet/landing.png.import new file mode 100644 index 0000000..2a0b7b4 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/landing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfx08kqh1l2dk" +path="res://.godot/imported/landing.png-0edb35c9f8848c057af69e57c243a937.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/landing.png" +dest_files=["res://.godot/imported/landing.png-0edb35c9f8848c057af69e57c243a937.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/sitting.png b/themes/space-pixel/package/assets/animations/alien_pet/sitting.png new file mode 100644 index 0000000..4198e55 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/sitting.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/sitting.png.import b/themes/space-pixel/package/assets/animations/alien_pet/sitting.png.import new file mode 100644 index 0000000..99b6101 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/sitting.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://p2k1nr7u0n8l" +path="res://.godot/imported/sitting.png-07c31166245c2b8086e533eca84c95a1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/sitting.png" +dest_files=["res://.godot/imported/sitting.png-07c31166245c2b8086e533eca84c95a1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/turn_left.png b/themes/space-pixel/package/assets/animations/alien_pet/turn_left.png new file mode 100644 index 0000000..f0fc32a Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/turn_left.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/turn_left.png.import b/themes/space-pixel/package/assets/animations/alien_pet/turn_left.png.import new file mode 100644 index 0000000..0435043 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/turn_left.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cge3gefu8fxqs" +path="res://.godot/imported/turn_left.png-1adf482ebce739c67b3e3edd4ea2e476.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/turn_left.png" +dest_files=["res://.godot/imported/turn_left.png-1adf482ebce739c67b3e3edd4ea2e476.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/turn_right.png b/themes/space-pixel/package/assets/animations/alien_pet/turn_right.png new file mode 100644 index 0000000..69903a4 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/turn_right.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/turn_right.png.import b/themes/space-pixel/package/assets/animations/alien_pet/turn_right.png.import new file mode 100644 index 0000000..fce7a8e --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/turn_right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://kvdyxfre1n8c" +path="res://.godot/imported/turn_right.png-30823cbb62aabbf645cc0ae3492c64c2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/turn_right.png" +dest_files=["res://.godot/imported/turn_right.png-30823cbb62aabbf645cc0ae3492c64c2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/walking_left.png b/themes/space-pixel/package/assets/animations/alien_pet/walking_left.png new file mode 100644 index 0000000..e5e0783 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/walking_left.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/walking_left.png.import b/themes/space-pixel/package/assets/animations/alien_pet/walking_left.png.import new file mode 100644 index 0000000..55187b7 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/walking_left.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rxjilm82m058" +path="res://.godot/imported/walking_left.png-0ebadbaa93abca199a085deb2c39d0a0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/walking_left.png" +dest_files=["res://.godot/imported/walking_left.png-0ebadbaa93abca199a085deb2c39d0a0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/walking_left_start.png b/themes/space-pixel/package/assets/animations/alien_pet/walking_left_start.png new file mode 100644 index 0000000..bcf299a Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/walking_left_start.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/walking_left_start.png.import b/themes/space-pixel/package/assets/animations/alien_pet/walking_left_start.png.import new file mode 100644 index 0000000..d033bff --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/walking_left_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dp6txiyte76en" +path="res://.godot/imported/walking_left_start.png-d8c331bb451cea8d08980ad35bcb95ec.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/walking_left_start.png" +dest_files=["res://.godot/imported/walking_left_start.png-d8c331bb451cea8d08980ad35bcb95ec.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/walking_left_stop.png b/themes/space-pixel/package/assets/animations/alien_pet/walking_left_stop.png new file mode 100644 index 0000000..e54ac95 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/walking_left_stop.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/walking_left_stop.png.import b/themes/space-pixel/package/assets/animations/alien_pet/walking_left_stop.png.import new file mode 100644 index 0000000..9f27b5c --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/walking_left_stop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cavh0687pqu4o" +path="res://.godot/imported/walking_left_stop.png-671095a9b35020da460ee54a3fe2c39a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/walking_left_stop.png" +dest_files=["res://.godot/imported/walking_left_stop.png-671095a9b35020da460ee54a3fe2c39a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/walking_right.png b/themes/space-pixel/package/assets/animations/alien_pet/walking_right.png new file mode 100644 index 0000000..33c9070 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/walking_right.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/walking_right.png.import b/themes/space-pixel/package/assets/animations/alien_pet/walking_right.png.import new file mode 100644 index 0000000..89136d4 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/walking_right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dj5bhh36bcenk" +path="res://.godot/imported/walking_right.png-a14f21589b9c01ec828d442be708daf7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/walking_right.png" +dest_files=["res://.godot/imported/walking_right.png-a14f21589b9c01ec828d442be708daf7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/walking_right_stop.png b/themes/space-pixel/package/assets/animations/alien_pet/walking_right_stop.png new file mode 100644 index 0000000..dd12060 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/walking_right_stop.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/walking_right_stop.png.import b/themes/space-pixel/package/assets/animations/alien_pet/walking_right_stop.png.import new file mode 100644 index 0000000..76cbb3a --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/walking_right_stop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c41lc5pb7bl64" +path="res://.godot/imported/walking_right_stop.png-3ed5a089e219400420dd047727e207b4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/walking_right_stop.png" +dest_files=["res://.godot/imported/walking_right_stop.png-3ed5a089e219400420dd047727e207b4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/alien_pet/walking_start_right.png b/themes/space-pixel/package/assets/animations/alien_pet/walking_start_right.png new file mode 100644 index 0000000..f8e561f Binary files /dev/null and b/themes/space-pixel/package/assets/animations/alien_pet/walking_start_right.png differ diff --git a/themes/space-pixel/package/assets/animations/alien_pet/walking_start_right.png.import b/themes/space-pixel/package/assets/animations/alien_pet/walking_start_right.png.import new file mode 100644 index 0000000..ee89887 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/alien_pet/walking_start_right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dx04s533dp3m3" +path="res://.godot/imported/walking_start_right.png-13cc2de803eda5208e5e86e2b77b873f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/alien_pet/walking_start_right.png" +dest_files=["res://.godot/imported/walking_start_right.png-13cc2de803eda5208e5e86e2b77b873f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/banner/frame_01.png b/themes/space-pixel/package/assets/animations/banner/frame_01.png new file mode 100644 index 0000000..c30b61d Binary files /dev/null and b/themes/space-pixel/package/assets/animations/banner/frame_01.png differ diff --git a/themes/space-pixel/package/assets/animations/banner/frame_01.png.import b/themes/space-pixel/package/assets/animations/banner/frame_01.png.import new file mode 100644 index 0000000..39debd5 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/banner/frame_01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d2bopkbsieo4l" +path="res://.godot/imported/frame_01.png-35b87883f48ffed45a9ecb707fc4525e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/banner/frame_01.png" +dest_files=["res://.godot/imported/frame_01.png-35b87883f48ffed45a9ecb707fc4525e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/banner/frame_02.png b/themes/space-pixel/package/assets/animations/banner/frame_02.png new file mode 100644 index 0000000..8480f12 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/banner/frame_02.png differ diff --git a/themes/space-pixel/package/assets/animations/banner/frame_02.png.import b/themes/space-pixel/package/assets/animations/banner/frame_02.png.import new file mode 100644 index 0000000..7ce0abb --- /dev/null +++ b/themes/space-pixel/package/assets/animations/banner/frame_02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bcwhb232b0l5n" +path="res://.godot/imported/frame_02.png-fe369c653aa1750dce0cf4b928f030d6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/banner/frame_02.png" +dest_files=["res://.godot/imported/frame_02.png-fe369c653aa1750dce0cf4b928f030d6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/bill/frame_01.png b/themes/space-pixel/package/assets/animations/bill/frame_01.png new file mode 100644 index 0000000..b44f366 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/bill/frame_01.png differ diff --git a/themes/space-pixel/package/assets/animations/bill/frame_01.png.import b/themes/space-pixel/package/assets/animations/bill/frame_01.png.import new file mode 100644 index 0000000..10d3b53 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/bill/frame_01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://co8f6snofsikl" +path="res://.godot/imported/frame_01.png-08c44a6d0d1d083060c3665f284a81d9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/bill/frame_01.png" +dest_files=["res://.godot/imported/frame_01.png-08c44a6d0d1d083060c3665f284a81d9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/bill/frame_02.png b/themes/space-pixel/package/assets/animations/bill/frame_02.png new file mode 100644 index 0000000..3a496b8 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/bill/frame_02.png differ diff --git a/themes/space-pixel/package/assets/animations/bill/frame_02.png.import b/themes/space-pixel/package/assets/animations/bill/frame_02.png.import new file mode 100644 index 0000000..9f1c992 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/bill/frame_02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dfobk36rg1j0q" +path="res://.godot/imported/frame_02.png-2170a2bb7e47b223b799a6e835977958.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/bill/frame_02.png" +dest_files=["res://.godot/imported/frame_02.png-2170a2bb7e47b223b799a6e835977958.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/bill/frame_03.png b/themes/space-pixel/package/assets/animations/bill/frame_03.png new file mode 100644 index 0000000..e7ff5bf Binary files /dev/null and b/themes/space-pixel/package/assets/animations/bill/frame_03.png differ diff --git a/themes/space-pixel/package/assets/animations/bill/frame_03.png.import b/themes/space-pixel/package/assets/animations/bill/frame_03.png.import new file mode 100644 index 0000000..10fccf4 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/bill/frame_03.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bcpdaecm5egr8" +path="res://.godot/imported/frame_03.png-36e956ba86627d0d1b2c919659917683.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/bill/frame_03.png" +dest_files=["res://.godot/imported/frame_03.png-36e956ba86627d0d1b2c919659917683.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/bill/frame_04.png b/themes/space-pixel/package/assets/animations/bill/frame_04.png new file mode 100644 index 0000000..80596bd Binary files /dev/null and b/themes/space-pixel/package/assets/animations/bill/frame_04.png differ diff --git a/themes/space-pixel/package/assets/animations/bill/frame_04.png.import b/themes/space-pixel/package/assets/animations/bill/frame_04.png.import new file mode 100644 index 0000000..fdc797d --- /dev/null +++ b/themes/space-pixel/package/assets/animations/bill/frame_04.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cl2qmi0ieqhd" +path="res://.godot/imported/frame_04.png-4373b7333ec49772f201d56d2f3cb70f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/bill/frame_04.png" +dest_files=["res://.godot/imported/frame_04.png-4373b7333ec49772f201d56d2f3cb70f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/card/frame_01.png b/themes/space-pixel/package/assets/animations/card/frame_01.png new file mode 100644 index 0000000..e90e0a9 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/card/frame_01.png differ diff --git a/themes/space-pixel/package/assets/animations/card/frame_01.png.import b/themes/space-pixel/package/assets/animations/card/frame_01.png.import new file mode 100644 index 0000000..8a9d431 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/card/frame_01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b3usa242mfern" +path="res://.godot/imported/frame_01.png-3a846a690265f4d848c8bba775933e4a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/card/frame_01.png" +dest_files=["res://.godot/imported/frame_01.png-3a846a690265f4d848c8bba775933e4a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/card/frame_02.png b/themes/space-pixel/package/assets/animations/card/frame_02.png new file mode 100644 index 0000000..d3e1684 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/card/frame_02.png differ diff --git a/themes/space-pixel/package/assets/animations/card/frame_02.png.import b/themes/space-pixel/package/assets/animations/card/frame_02.png.import new file mode 100644 index 0000000..0bb7eba --- /dev/null +++ b/themes/space-pixel/package/assets/animations/card/frame_02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://de6pb3jrexihf" +path="res://.godot/imported/frame_02.png-29064a6575daa73bdd7a2da2630e8b8d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/card/frame_02.png" +dest_files=["res://.godot/imported/frame_02.png-29064a6575daa73bdd7a2da2630e8b8d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/coin/frame_01.png b/themes/space-pixel/package/assets/animations/coin/frame_01.png new file mode 100644 index 0000000..0ae6101 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/coin/frame_01.png differ diff --git a/themes/space-pixel/package/assets/animations/coin/frame_01.png.import b/themes/space-pixel/package/assets/animations/coin/frame_01.png.import new file mode 100644 index 0000000..17db3af --- /dev/null +++ b/themes/space-pixel/package/assets/animations/coin/frame_01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://sv5r7i74bs5" +path="res://.godot/imported/frame_01.png-379d6603d972f7b9b71935265b230b1b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/coin/frame_01.png" +dest_files=["res://.godot/imported/frame_01.png-379d6603d972f7b9b71935265b230b1b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/coin/frame_02.png b/themes/space-pixel/package/assets/animations/coin/frame_02.png new file mode 100644 index 0000000..7f98ade Binary files /dev/null and b/themes/space-pixel/package/assets/animations/coin/frame_02.png differ diff --git a/themes/space-pixel/package/assets/animations/coin/frame_02.png.import b/themes/space-pixel/package/assets/animations/coin/frame_02.png.import new file mode 100644 index 0000000..add7760 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/coin/frame_02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d2iaks2d6flqb" +path="res://.godot/imported/frame_02.png-adfbf9d38bed5007368c42d152b48450.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/coin/frame_02.png" +dest_files=["res://.godot/imported/frame_02.png-adfbf9d38bed5007368c42d152b48450.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/coin/frame_03.png b/themes/space-pixel/package/assets/animations/coin/frame_03.png new file mode 100644 index 0000000..01055c9 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/coin/frame_03.png differ diff --git a/themes/space-pixel/package/assets/animations/coin/frame_03.png.import b/themes/space-pixel/package/assets/animations/coin/frame_03.png.import new file mode 100644 index 0000000..f05ad0f --- /dev/null +++ b/themes/space-pixel/package/assets/animations/coin/frame_03.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ddo0fm0ophvci" +path="res://.godot/imported/frame_03.png-93dd021feeaaf12b75bcf72874606579.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/coin/frame_03.png" +dest_files=["res://.godot/imported/frame_03.png-93dd021feeaaf12b75bcf72874606579.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/payment_failed/frame_01.png b/themes/space-pixel/package/assets/animations/payment_failed/frame_01.png new file mode 100644 index 0000000..22396e8 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/payment_failed/frame_01.png differ diff --git a/themes/space-pixel/package/assets/animations/payment_failed/frame_01.png.import b/themes/space-pixel/package/assets/animations/payment_failed/frame_01.png.import new file mode 100644 index 0000000..7688b47 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/payment_failed/frame_01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dwvk6pkpvh63t" +path="res://.godot/imported/frame_01.png-113ab5ef9ec4986bd669d5bd168f05c4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/payment_failed/frame_01.png" +dest_files=["res://.godot/imported/frame_01.png-113ab5ef9ec4986bd669d5bd168f05c4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/payment_failed/frame_02.png b/themes/space-pixel/package/assets/animations/payment_failed/frame_02.png new file mode 100644 index 0000000..74f8b4a Binary files /dev/null and b/themes/space-pixel/package/assets/animations/payment_failed/frame_02.png differ diff --git a/themes/space-pixel/package/assets/animations/payment_failed/frame_02.png.import b/themes/space-pixel/package/assets/animations/payment_failed/frame_02.png.import new file mode 100644 index 0000000..b986c55 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/payment_failed/frame_02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bmv4rutkbnudh" +path="res://.godot/imported/frame_02.png-8bd91fa73de8348b1ee1bcb6127c0eca.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/payment_failed/frame_02.png" +dest_files=["res://.godot/imported/frame_02.png-8bd91fa73de8348b1ee1bcb6127c0eca.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/payment_failed/frame_03.png b/themes/space-pixel/package/assets/animations/payment_failed/frame_03.png new file mode 100644 index 0000000..8e46e2a Binary files /dev/null and b/themes/space-pixel/package/assets/animations/payment_failed/frame_03.png differ diff --git a/themes/space-pixel/package/assets/animations/payment_failed/frame_03.png.import b/themes/space-pixel/package/assets/animations/payment_failed/frame_03.png.import new file mode 100644 index 0000000..0b9ac3d --- /dev/null +++ b/themes/space-pixel/package/assets/animations/payment_failed/frame_03.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b5v7kqjrfhqjw" +path="res://.godot/imported/frame_03.png-20ef1b78c45b7e49842910a7567db868.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/payment_failed/frame_03.png" +dest_files=["res://.godot/imported/frame_03.png-20ef1b78c45b7e49842910a7567db868.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/payment_success/frame_01.png b/themes/space-pixel/package/assets/animations/payment_success/frame_01.png new file mode 100644 index 0000000..4af13f0 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/payment_success/frame_01.png differ diff --git a/themes/space-pixel/package/assets/animations/payment_success/frame_01.png.import b/themes/space-pixel/package/assets/animations/payment_success/frame_01.png.import new file mode 100644 index 0000000..aaab07b --- /dev/null +++ b/themes/space-pixel/package/assets/animations/payment_success/frame_01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bxc8ku68xb4r8" +path="res://.godot/imported/frame_01.png-ae08a1c0ae2227bf8e41fe40da8323ba.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/payment_success/frame_01.png" +dest_files=["res://.godot/imported/frame_01.png-ae08a1c0ae2227bf8e41fe40da8323ba.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/payment_success/frame_02.png b/themes/space-pixel/package/assets/animations/payment_success/frame_02.png new file mode 100644 index 0000000..bccef40 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/payment_success/frame_02.png differ diff --git a/themes/space-pixel/package/assets/animations/payment_success/frame_02.png.import b/themes/space-pixel/package/assets/animations/payment_success/frame_02.png.import new file mode 100644 index 0000000..eeaa91f --- /dev/null +++ b/themes/space-pixel/package/assets/animations/payment_success/frame_02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://clubcslpx4qdr" +path="res://.godot/imported/frame_02.png-ca8f6510596857490c41533865641cb1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/payment_success/frame_02.png" +dest_files=["res://.godot/imported/frame_02.png-ca8f6510596857490c41533865641cb1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/payment_success/frame_03.png b/themes/space-pixel/package/assets/animations/payment_success/frame_03.png new file mode 100644 index 0000000..a10d308 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/payment_success/frame_03.png differ diff --git a/themes/space-pixel/package/assets/animations/payment_success/frame_03.png.import b/themes/space-pixel/package/assets/animations/payment_success/frame_03.png.import new file mode 100644 index 0000000..4721ea7 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/payment_success/frame_03.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ckqpek4skkm77" +path="res://.godot/imported/frame_03.png-f78af69e80df291fd5c77c17d0eb15fc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/payment_success/frame_03.png" +dest_files=["res://.godot/imported/frame_03.png-f78af69e80df291fd5c77c17d0eb15fc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/payment_success/frame_04.png b/themes/space-pixel/package/assets/animations/payment_success/frame_04.png new file mode 100644 index 0000000..67c9322 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/payment_success/frame_04.png differ diff --git a/themes/space-pixel/package/assets/animations/payment_success/frame_04.png.import b/themes/space-pixel/package/assets/animations/payment_success/frame_04.png.import new file mode 100644 index 0000000..cc36dd8 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/payment_success/frame_04.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c33lc27tqncru" +path="res://.godot/imported/frame_04.png-578f4bd9660877a9174806f484296bf4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/payment_success/frame_04.png" +dest_files=["res://.godot/imported/frame_04.png-578f4bd9660877a9174806f484296bf4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/payment_success/frame_05.png b/themes/space-pixel/package/assets/animations/payment_success/frame_05.png new file mode 100644 index 0000000..a10d308 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/payment_success/frame_05.png differ diff --git a/themes/space-pixel/package/assets/animations/payment_success/frame_05.png.import b/themes/space-pixel/package/assets/animations/payment_success/frame_05.png.import new file mode 100644 index 0000000..b158d73 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/payment_success/frame_05.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://w8adsbfld640" +path="res://.godot/imported/frame_05.png-aaed4c6a2f9744586cc494dedb266d6a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/payment_success/frame_05.png" +dest_files=["res://.godot/imported/frame_05.png-aaed4c6a2f9744586cc494dedb266d6a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/ufo_large/frame_01.png b/themes/space-pixel/package/assets/animations/ufo_large/frame_01.png new file mode 100644 index 0000000..67ecfce Binary files /dev/null and b/themes/space-pixel/package/assets/animations/ufo_large/frame_01.png differ diff --git a/themes/space-pixel/package/assets/animations/ufo_large/frame_01.png.import b/themes/space-pixel/package/assets/animations/ufo_large/frame_01.png.import new file mode 100644 index 0000000..8c5bc53 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/ufo_large/frame_01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b8gbiaq5xg1tb" +path="res://.godot/imported/frame_01.png-9b5ee0c477286ca89296304054c5dba5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/ufo_large/frame_01.png" +dest_files=["res://.godot/imported/frame_01.png-9b5ee0c477286ca89296304054c5dba5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/ufo_large/frame_02.png b/themes/space-pixel/package/assets/animations/ufo_large/frame_02.png new file mode 100644 index 0000000..a705cb0 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/ufo_large/frame_02.png differ diff --git a/themes/space-pixel/package/assets/animations/ufo_large/frame_02.png.import b/themes/space-pixel/package/assets/animations/ufo_large/frame_02.png.import new file mode 100644 index 0000000..acadfae --- /dev/null +++ b/themes/space-pixel/package/assets/animations/ufo_large/frame_02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dgahoicv0q28h" +path="res://.godot/imported/frame_02.png-a4918cdd2fc973980576805a5f988f6e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/ufo_large/frame_02.png" +dest_files=["res://.godot/imported/frame_02.png-a4918cdd2fc973980576805a5f988f6e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/ufo_small/frame_01.png b/themes/space-pixel/package/assets/animations/ufo_small/frame_01.png new file mode 100644 index 0000000..cf240d2 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/ufo_small/frame_01.png differ diff --git a/themes/space-pixel/package/assets/animations/ufo_small/frame_01.png.import b/themes/space-pixel/package/assets/animations/ufo_small/frame_01.png.import new file mode 100644 index 0000000..30b51d2 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/ufo_small/frame_01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dy6p2hnvks8rp" +path="res://.godot/imported/frame_01.png-33c91e7b947de2992f5cd9947012bb67.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/ufo_small/frame_01.png" +dest_files=["res://.godot/imported/frame_01.png-33c91e7b947de2992f5cd9947012bb67.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/animations/ufo_small/frame_02.png b/themes/space-pixel/package/assets/animations/ufo_small/frame_02.png new file mode 100644 index 0000000..5839c12 Binary files /dev/null and b/themes/space-pixel/package/assets/animations/ufo_small/frame_02.png differ diff --git a/themes/space-pixel/package/assets/animations/ufo_small/frame_02.png.import b/themes/space-pixel/package/assets/animations/ufo_small/frame_02.png.import new file mode 100644 index 0000000..2d17787 --- /dev/null +++ b/themes/space-pixel/package/assets/animations/ufo_small/frame_02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b2tisflc3r8q8" +path="res://.godot/imported/frame_02.png-bc96fdbfa255b226c621722b3f38f15d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/animations/ufo_small/frame_02.png" +dest_files=["res://.godot/imported/frame_02.png-bc96fdbfa255b226c621722b3f38f15d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/background/background.png b/themes/space-pixel/package/assets/background/background.png new file mode 100644 index 0000000..e2e9893 Binary files /dev/null and b/themes/space-pixel/package/assets/background/background.png differ diff --git a/themes/space-pixel/package/assets/background/background.png.import b/themes/space-pixel/package/assets/background/background.png.import new file mode 100644 index 0000000..6cb78f8 --- /dev/null +++ b/themes/space-pixel/package/assets/background/background.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d1vaivunuaov7" +path="res://.godot/imported/background.png-dcccd77928a97bb3ddbc4a0551d8b8f0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/background/background.png" +dest_files=["res://.godot/imported/background.png-dcccd77928a97bb3ddbc4a0551d8b8f0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/decorations/alien1.png b/themes/space-pixel/package/assets/decorations/alien1.png new file mode 100644 index 0000000..e4d0481 Binary files /dev/null and b/themes/space-pixel/package/assets/decorations/alien1.png differ diff --git a/themes/space-pixel/package/assets/decorations/alien1.png.import b/themes/space-pixel/package/assets/decorations/alien1.png.import new file mode 100644 index 0000000..1a83298 --- /dev/null +++ b/themes/space-pixel/package/assets/decorations/alien1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://btsthd6xtskwu" +path="res://.godot/imported/alien1.png-ad004163259cc21dc65eef11e5ac9e0e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/decorations/alien1.png" +dest_files=["res://.godot/imported/alien1.png-ad004163259cc21dc65eef11e5ac9e0e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/decorations/alien2.png b/themes/space-pixel/package/assets/decorations/alien2.png new file mode 100644 index 0000000..9aeccb4 Binary files /dev/null and b/themes/space-pixel/package/assets/decorations/alien2.png differ diff --git a/themes/space-pixel/package/assets/decorations/alien2.png.import b/themes/space-pixel/package/assets/decorations/alien2.png.import new file mode 100644 index 0000000..2624214 --- /dev/null +++ b/themes/space-pixel/package/assets/decorations/alien2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://niifdcrv3ap" +path="res://.godot/imported/alien2.png-ee324a47ba2b9548d4d570d9b7e35737.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/decorations/alien2.png" +dest_files=["res://.godot/imported/alien2.png-ee324a47ba2b9548d4d570d9b7e35737.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/decorations/alien3.png b/themes/space-pixel/package/assets/decorations/alien3.png new file mode 100644 index 0000000..5a3fcd9 Binary files /dev/null and b/themes/space-pixel/package/assets/decorations/alien3.png differ diff --git a/themes/space-pixel/package/assets/decorations/alien3.png.import b/themes/space-pixel/package/assets/decorations/alien3.png.import new file mode 100644 index 0000000..862ef8b --- /dev/null +++ b/themes/space-pixel/package/assets/decorations/alien3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://wxf81owt4q13" +path="res://.godot/imported/alien3.png-56c4f7b1baa050fd21293be24fca74cd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/decorations/alien3.png" +dest_files=["res://.godot/imported/alien3.png-56c4f7b1baa050fd21293be24fca74cd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/decorations/stars_1.png b/themes/space-pixel/package/assets/decorations/stars_1.png new file mode 100644 index 0000000..9a40982 Binary files /dev/null and b/themes/space-pixel/package/assets/decorations/stars_1.png differ diff --git a/themes/space-pixel/package/assets/decorations/stars_1.png.import b/themes/space-pixel/package/assets/decorations/stars_1.png.import new file mode 100644 index 0000000..1e9109c --- /dev/null +++ b/themes/space-pixel/package/assets/decorations/stars_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bgnlcaoieg0aw" +path="res://.godot/imported/stars_1.png-dff04f3d5b4757b15b3506b6c32ac062.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/decorations/stars_1.png" +dest_files=["res://.godot/imported/stars_1.png-dff04f3d5b4757b15b3506b6c32ac062.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/decorations/stars_2.png b/themes/space-pixel/package/assets/decorations/stars_2.png new file mode 100644 index 0000000..ab93590 Binary files /dev/null and b/themes/space-pixel/package/assets/decorations/stars_2.png differ diff --git a/themes/space-pixel/package/assets/decorations/stars_2.png.import b/themes/space-pixel/package/assets/decorations/stars_2.png.import new file mode 100644 index 0000000..0f9e84b --- /dev/null +++ b/themes/space-pixel/package/assets/decorations/stars_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bfidlof0k3fe6" +path="res://.godot/imported/stars_2.png-29dde6d0c85d2c3dc39a7ca83c6cf6d9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/decorations/stars_2.png" +dest_files=["res://.godot/imported/stars_2.png-29dde6d0c85d2c3dc39a7ca83c6cf6d9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/fonts/vcr_osd_mono_cyr.ttf b/themes/space-pixel/package/assets/fonts/vcr_osd_mono_cyr.ttf new file mode 100644 index 0000000..b621e80 Binary files /dev/null and b/themes/space-pixel/package/assets/fonts/vcr_osd_mono_cyr.ttf differ diff --git a/themes/space-pixel/package/assets/fonts/vcr_osd_mono_cyr.ttf.import b/themes/space-pixel/package/assets/fonts/vcr_osd_mono_cyr.ttf.import new file mode 100644 index 0000000..19cfb40 --- /dev/null +++ b/themes/space-pixel/package/assets/fonts/vcr_osd_mono_cyr.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://culveonyhkpru" +path="res://.godot/imported/vcr_osd_mono_cyr.ttf-f40c966ed9da973cca78f97a50ed43bc.fontdata" + +[deps] + +source_file="res://themes/space-pixel/package/assets/fonts/vcr_osd_mono_cyr.ttf" +dest_files=["res://.godot/imported/vcr_osd_mono_cyr.ttf-f40c966ed9da973cca78f97a50ed43bc.fontdata"] + +[params] + +Rendering=null +antialiasing=0 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=0 +subpixel_positioning=0 +keep_rounding_remainders=true +oversampling=1.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/themes/space-pixel/package/assets/ui/back_button.png b/themes/space-pixel/package/assets/ui/back_button.png new file mode 100644 index 0000000..efcc11d Binary files /dev/null and b/themes/space-pixel/package/assets/ui/back_button.png differ diff --git a/themes/space-pixel/package/assets/ui/back_button.png.import b/themes/space-pixel/package/assets/ui/back_button.png.import new file mode 100644 index 0000000..b231469 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/back_button.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://sntindr0yqtc" +path="res://.godot/imported/back_button.png-36441c43385864cb39abc422b7c43873.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/back_button.png" +dest_files=["res://.godot/imported/back_button.png-36441c43385864cb39abc422b7c43873.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/billboard_ads.png b/themes/space-pixel/package/assets/ui/billboard_ads.png new file mode 100644 index 0000000..07e41fe Binary files /dev/null and b/themes/space-pixel/package/assets/ui/billboard_ads.png differ diff --git a/themes/space-pixel/package/assets/ui/billboard_ads.png.import b/themes/space-pixel/package/assets/ui/billboard_ads.png.import new file mode 100644 index 0000000..808037f --- /dev/null +++ b/themes/space-pixel/package/assets/ui/billboard_ads.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c0kbwcs8mic3f" +path="res://.godot/imported/billboard_ads.png-09f5dae37ed28603ff7e9bc1d29a6c0d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/billboard_ads.png" +dest_files=["res://.godot/imported/billboard_ads.png-09f5dae37ed28603ff7e9bc1d29a6c0d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/bubble_large.png b/themes/space-pixel/package/assets/ui/bubble_large.png new file mode 100644 index 0000000..d964d46 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/bubble_large.png differ diff --git a/themes/space-pixel/package/assets/ui/bubble_large.png.import b/themes/space-pixel/package/assets/ui/bubble_large.png.import new file mode 100644 index 0000000..505a848 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/bubble_large.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://4uvc8mo74hy1" +path="res://.godot/imported/bubble_large.png-9ee2621676a6266d6c87e3e367def13f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/bubble_large.png" +dest_files=["res://.godot/imported/bubble_large.png-9ee2621676a6266d6c87e3e367def13f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/bubble_small.png b/themes/space-pixel/package/assets/ui/bubble_small.png new file mode 100644 index 0000000..403939f Binary files /dev/null and b/themes/space-pixel/package/assets/ui/bubble_small.png differ diff --git a/themes/space-pixel/package/assets/ui/bubble_small.png.import b/themes/space-pixel/package/assets/ui/bubble_small.png.import new file mode 100644 index 0000000..e587453 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/bubble_small.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bq36jb502ayh0" +path="res://.godot/imported/bubble_small.png-af06222515af2f67d59b7298476ac60e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/bubble_small.png" +dest_files=["res://.godot/imported/bubble_small.png-af06222515af2f67d59b7298476ac60e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/bubble_text.png b/themes/space-pixel/package/assets/ui/bubble_text.png new file mode 100644 index 0000000..80c9736 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/bubble_text.png differ diff --git a/themes/space-pixel/package/assets/ui/bubble_text.png.import b/themes/space-pixel/package/assets/ui/bubble_text.png.import new file mode 100644 index 0000000..fa4dd20 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/bubble_text.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bdyjsoj5lwxu0" +path="res://.godot/imported/bubble_text.png-fda9c2b8d9828c7774deeabfe4508cf8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/bubble_text.png" +dest_files=["res://.godot/imported/bubble_text.png-fda9c2b8d9828c7774deeabfe4508cf8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/button_big.png b/themes/space-pixel/package/assets/ui/button_big.png new file mode 100644 index 0000000..ad18865 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/button_big.png differ diff --git a/themes/space-pixel/package/assets/ui/button_big.png.import b/themes/space-pixel/package/assets/ui/button_big.png.import new file mode 100644 index 0000000..23a4819 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/button_big.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bxw1d5ftc3jpp" +path="res://.godot/imported/button_big.png-53daf2f20d0531d38da6750da26e9d06.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/button_big.png" +dest_files=["res://.godot/imported/button_big.png-53daf2f20d0531d38da6750da26e9d06.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/button_cancel.png b/themes/space-pixel/package/assets/ui/button_cancel.png new file mode 100644 index 0000000..a796a16 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/button_cancel.png differ diff --git a/themes/space-pixel/package/assets/ui/button_cancel.png.import b/themes/space-pixel/package/assets/ui/button_cancel.png.import new file mode 100644 index 0000000..a66de7d --- /dev/null +++ b/themes/space-pixel/package/assets/ui/button_cancel.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://str14omahot0" +path="res://.godot/imported/button_cancel.png-c68d2786ef503dfa2f7fd44eab8d59e5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/button_cancel.png" +dest_files=["res://.godot/imported/button_cancel.png-c68d2786ef503dfa2f7fd44eab8d59e5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/button_down.png b/themes/space-pixel/package/assets/ui/button_down.png new file mode 100644 index 0000000..08540d6 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/button_down.png differ diff --git a/themes/space-pixel/package/assets/ui/button_down.png.import b/themes/space-pixel/package/assets/ui/button_down.png.import new file mode 100644 index 0000000..77bce84 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/button_down.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bfietocvvkfrd" +path="res://.godot/imported/button_down.png-53668c4007013bf485b2906da49f90eb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/button_down.png" +dest_files=["res://.godot/imported/button_down.png-53668c4007013bf485b2906da49f90eb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/button_long.png b/themes/space-pixel/package/assets/ui/button_long.png new file mode 100644 index 0000000..af9beb9 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/button_long.png differ diff --git a/themes/space-pixel/package/assets/ui/button_long.png.import b/themes/space-pixel/package/assets/ui/button_long.png.import new file mode 100644 index 0000000..b38e42b --- /dev/null +++ b/themes/space-pixel/package/assets/ui/button_long.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b4gr7gwf5jyy6" +path="res://.godot/imported/button_long.png-2b809ee0fc232253c98949854d46aa93.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/button_long.png" +dest_files=["res://.godot/imported/button_long.png-2b809ee0fc232253c98949854d46aa93.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/button_square.png b/themes/space-pixel/package/assets/ui/button_square.png new file mode 100644 index 0000000..f58b254 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/button_square.png differ diff --git a/themes/space-pixel/package/assets/ui/button_square.png.import b/themes/space-pixel/package/assets/ui/button_square.png.import new file mode 100644 index 0000000..77fa3af --- /dev/null +++ b/themes/space-pixel/package/assets/ui/button_square.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://jcpf3lum8v3x" +path="res://.godot/imported/button_square.png-3a6939c8a48dd51fd0720212ad6a1a42.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/button_square.png" +dest_files=["res://.godot/imported/button_square.png-3a6939c8a48dd51fd0720212ad6a1a42.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/button_up.png b/themes/space-pixel/package/assets/ui/button_up.png new file mode 100644 index 0000000..cc43e24 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/button_up.png differ diff --git a/themes/space-pixel/package/assets/ui/button_up.png.import b/themes/space-pixel/package/assets/ui/button_up.png.import new file mode 100644 index 0000000..2bb86fa --- /dev/null +++ b/themes/space-pixel/package/assets/ui/button_up.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://kir5pffovdmv" +path="res://.godot/imported/button_up.png-5e5ac029c05fd03b799846885d258b66.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/button_up.png" +dest_files=["res://.godot/imported/button_up.png-5e5ac029c05fd03b799846885d258b66.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/interactive_pet/cross.png b/themes/space-pixel/package/assets/ui/interactive_pet/cross.png new file mode 100644 index 0000000..2019082 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/interactive_pet/cross.png differ diff --git a/themes/space-pixel/package/assets/ui/interactive_pet/cross.png.import b/themes/space-pixel/package/assets/ui/interactive_pet/cross.png.import new file mode 100644 index 0000000..eaab1a1 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/interactive_pet/cross.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bexy0dnrx5qma" +path="res://.godot/imported/cross.png-c790743fdac7e45c2752bce5f19cf7bc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/interactive_pet/cross.png" +dest_files=["res://.godot/imported/cross.png-c790743fdac7e45c2752bce5f19cf7bc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/interactive_pet/home.png b/themes/space-pixel/package/assets/ui/interactive_pet/home.png new file mode 100644 index 0000000..93d46ee Binary files /dev/null and b/themes/space-pixel/package/assets/ui/interactive_pet/home.png differ diff --git a/themes/space-pixel/package/assets/ui/interactive_pet/home.png.import b/themes/space-pixel/package/assets/ui/interactive_pet/home.png.import new file mode 100644 index 0000000..ed8eb67 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/interactive_pet/home.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://n3incnbhl716" +path="res://.godot/imported/home.png-e47cdb385cb1ae8c69a9969ae0384667.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/interactive_pet/home.png" +dest_files=["res://.godot/imported/home.png-e47cdb385cb1ae8c69a9969ae0384667.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/logo.png b/themes/space-pixel/package/assets/ui/logo.png new file mode 100644 index 0000000..8754482 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/logo.png differ diff --git a/themes/space-pixel/package/assets/ui/logo.png.import b/themes/space-pixel/package/assets/ui/logo.png.import new file mode 100644 index 0000000..d0216ae --- /dev/null +++ b/themes/space-pixel/package/assets/ui/logo.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://218aa00e5vns" +path="res://.godot/imported/logo.png-e09caff8a916489027ad714199e73712.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/logo.png" +dest_files=["res://.godot/imported/logo.png-e09caff8a916489027ad714199e73712.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/panel_card.png b/themes/space-pixel/package/assets/ui/panel_card.png new file mode 100644 index 0000000..7d585fb Binary files /dev/null and b/themes/space-pixel/package/assets/ui/panel_card.png differ diff --git a/themes/space-pixel/package/assets/ui/panel_card.png.import b/themes/space-pixel/package/assets/ui/panel_card.png.import new file mode 100644 index 0000000..d22af90 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/panel_card.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://50n4k7kjdpyo" +path="res://.godot/imported/panel_card.png-515ae32d4b979e65038f4e96c1da1d28.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/panel_card.png" +dest_files=["res://.godot/imported/panel_card.png-515ae32d4b979e65038f4e96c1da1d28.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/panel_dispense.png b/themes/space-pixel/package/assets/ui/panel_dispense.png new file mode 100644 index 0000000..581e811 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/panel_dispense.png differ diff --git a/themes/space-pixel/package/assets/ui/panel_dispense.png.import b/themes/space-pixel/package/assets/ui/panel_dispense.png.import new file mode 100644 index 0000000..a91e5d8 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/panel_dispense.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://lxwb8ho7j3iq" +path="res://.godot/imported/panel_dispense.png-1cc4e964e973ca47516b816af34e902b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/panel_dispense.png" +dest_files=["res://.godot/imported/panel_dispense.png-1cc4e964e973ca47516b816af34e902b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/panel_large.png b/themes/space-pixel/package/assets/ui/panel_large.png new file mode 100644 index 0000000..ddb45b7 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/panel_large.png differ diff --git a/themes/space-pixel/package/assets/ui/panel_large.png.import b/themes/space-pixel/package/assets/ui/panel_large.png.import new file mode 100644 index 0000000..768f31f --- /dev/null +++ b/themes/space-pixel/package/assets/ui/panel_large.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://byp3lesg5hfuf" +path="res://.godot/imported/panel_large.png-c6e7712a06d6693937e602817024a159.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/panel_large.png" +dest_files=["res://.godot/imported/panel_large.png-c6e7712a06d6693937e602817024a159.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/panel_medium.png b/themes/space-pixel/package/assets/ui/panel_medium.png new file mode 100644 index 0000000..3630f6c Binary files /dev/null and b/themes/space-pixel/package/assets/ui/panel_medium.png differ diff --git a/themes/space-pixel/package/assets/ui/panel_medium.png.import b/themes/space-pixel/package/assets/ui/panel_medium.png.import new file mode 100644 index 0000000..b6b8de8 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/panel_medium.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cp2gv8jrxb1is" +path="res://.godot/imported/panel_medium.png-fdc5afc39b829fc567243d4fe7eac33b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/panel_medium.png" +dest_files=["res://.godot/imported/panel_medium.png-fdc5afc39b829fc567243d4fe7eac33b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/panel_wide.png b/themes/space-pixel/package/assets/ui/panel_wide.png new file mode 100644 index 0000000..a815c0a Binary files /dev/null and b/themes/space-pixel/package/assets/ui/panel_wide.png differ diff --git a/themes/space-pixel/package/assets/ui/panel_wide.png.import b/themes/space-pixel/package/assets/ui/panel_wide.png.import new file mode 100644 index 0000000..a5bed92 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/panel_wide.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://coavqhyjqqkda" +path="res://.godot/imported/panel_wide.png-fe7fae5b1174af72b6209a3448d9b6a6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/panel_wide.png" +dest_files=["res://.godot/imported/panel_wide.png-fe7fae5b1174af72b6209a3448d9b6a6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/stock_low.png b/themes/space-pixel/package/assets/ui/stock_low.png new file mode 100644 index 0000000..944ef72 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/stock_low.png differ diff --git a/themes/space-pixel/package/assets/ui/stock_low.png.import b/themes/space-pixel/package/assets/ui/stock_low.png.import new file mode 100644 index 0000000..93a589d --- /dev/null +++ b/themes/space-pixel/package/assets/ui/stock_low.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c7wodqqe8yuij" +path="res://.godot/imported/stock_low.png-253692d85d64f0ca766c4ad0e836fd16.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/stock_low.png" +dest_files=["res://.godot/imported/stock_low.png-253692d85d64f0ca766c4ad0e836fd16.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/stock_many.png b/themes/space-pixel/package/assets/ui/stock_many.png new file mode 100644 index 0000000..83449b5 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/stock_many.png differ diff --git a/themes/space-pixel/package/assets/ui/stock_many.png.import b/themes/space-pixel/package/assets/ui/stock_many.png.import new file mode 100644 index 0000000..67e937d --- /dev/null +++ b/themes/space-pixel/package/assets/ui/stock_many.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dt26n4n470hnm" +path="res://.godot/imported/stock_many.png-8569544e0130d32887047ba567d2d763.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/stock_many.png" +dest_files=["res://.godot/imported/stock_many.png-8569544e0130d32887047ba567d2d763.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/sum_bar.png b/themes/space-pixel/package/assets/ui/sum_bar.png new file mode 100644 index 0000000..47f6499 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/sum_bar.png differ diff --git a/themes/space-pixel/package/assets/ui/sum_bar.png.import b/themes/space-pixel/package/assets/ui/sum_bar.png.import new file mode 100644 index 0000000..b38de82 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/sum_bar.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dbnw5b84pre6y" +path="res://.godot/imported/sum_bar.png-75cf739076199e2a0d0d8b6e3a1dab10.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/sum_bar.png" +dest_files=["res://.godot/imported/sum_bar.png-75cf739076199e2a0d0d8b6e3a1dab10.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/tumbler_off.png b/themes/space-pixel/package/assets/ui/tumbler_off.png new file mode 100644 index 0000000..4a6bfec Binary files /dev/null and b/themes/space-pixel/package/assets/ui/tumbler_off.png differ diff --git a/themes/space-pixel/package/assets/ui/tumbler_off.png.import b/themes/space-pixel/package/assets/ui/tumbler_off.png.import new file mode 100644 index 0000000..1c6d33b --- /dev/null +++ b/themes/space-pixel/package/assets/ui/tumbler_off.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dioycex1x3g62" +path="res://.godot/imported/tumbler_off.png-9474b30016336019c7b6f5c626006ed3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/tumbler_off.png" +dest_files=["res://.godot/imported/tumbler_off.png-9474b30016336019c7b6f5c626006ed3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/assets/ui/tumbler_on.png b/themes/space-pixel/package/assets/ui/tumbler_on.png new file mode 100644 index 0000000..c915073 Binary files /dev/null and b/themes/space-pixel/package/assets/ui/tumbler_on.png differ diff --git a/themes/space-pixel/package/assets/ui/tumbler_on.png.import b/themes/space-pixel/package/assets/ui/tumbler_on.png.import new file mode 100644 index 0000000..c2fd4c3 --- /dev/null +++ b/themes/space-pixel/package/assets/ui/tumbler_on.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://od3ytfnm6es0" +path="res://.godot/imported/tumbler_on.png-ab60be199e26ddc4861dd29e39a32727.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/package/assets/ui/tumbler_on.png" +dest_files=["res://.godot/imported/tumbler_on.png-ab60be199e26ddc4861dd29e39a32727.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/package/background_decoration.tscn b/themes/space-pixel/package/background_decoration.tscn new file mode 100644 index 0000000..e7ee36b --- /dev/null +++ b/themes/space-pixel/package/background_decoration.tscn @@ -0,0 +1,58 @@ +[gd_scene load_steps=11 format=3] + +[ext_resource type="Texture2D" path="assets/decorations/stars_1.png" id="1_stars_1"] +[ext_resource type="Texture2D" path="assets/decorations/stars_2.png" id="2_stars_2"] +[ext_resource type="Texture2D" path="assets/animations/ufo_small/frame_01.png" id="3_ufo_small"] +[ext_resource type="Texture2D" path="assets/animations/ufo_large/frame_01.png" id="4_ufo_large"] +[ext_resource type="Texture2D" path="assets/animations/ufo_small/frame_02.png" id="5_ufo_small_2"] +[ext_resource type="Texture2D" path="assets/animations/ufo_large/frame_02.png" id="6_ufo_large_2"] +[ext_resource type="Script" path="scripts/ufo_flight_controller.gd" id="7_flight_controller"] + +[sub_resource type="SpriteFrames" id="SpriteFrames_stars"] +animations = [{ +"frames": [{"duration": 1.0, "texture": ExtResource("1_stars_1")}, {"duration": 1.0, "texture": ExtResource("2_stars_2")}], +"loop": true, +"name": &"twinkle", +"speed": 1.0 +}] + +[sub_resource type="SpriteFrames" id="SpriteFrames_ufo_large"] +animations = [{ +"frames": [{"duration": 1.0, "texture": ExtResource("4_ufo_large")}, {"duration": 1.0, "texture": ExtResource("6_ufo_large_2")}], +"loop": true, +"name": &"flight", +"speed": 1.0 +}] + +[sub_resource type="SpriteFrames" id="SpriteFrames_ufo_small"] +animations = [{ +"frames": [{"duration": 1.0, "texture": ExtResource("3_ufo_small")}, {"duration": 1.0, "texture": ExtResource("5_ufo_small_2")}], +"loop": true, +"name": &"flight", +"speed": 1.5 +}] + +[node name="SpacePixelDecoration" type="Node2D"] +script = ExtResource("7_flight_controller") + +[node name="Stars" type="AnimatedSprite2D" parent="."] +texture_filter = 1 +modulate = Color(1, 1, 1, 0.72) +position = Vector2(540, 960) +sprite_frames = SubResource("SpriteFrames_stars") +animation = &"twinkle" +autoplay = "twinkle" + +[node name="UfoLarge" type="AnimatedSprite2D" parent="."] +texture_filter = 1 +position = Vector2(-380, 1350) +sprite_frames = SubResource("SpriteFrames_ufo_large") +animation = &"flight" +autoplay = "flight" + +[node name="UfoSmall" type="AnimatedSprite2D" parent="."] +texture_filter = 1 +position = Vector2(-160, 1550) +sprite_frames = SubResource("SpriteFrames_ufo_small") +animation = &"flight" +autoplay = "flight" diff --git a/themes/space-pixel/package/components/footer.tscn b/themes/space-pixel/package/components/footer.tscn new file mode 100644 index 0000000..360f913 --- /dev/null +++ b/themes/space-pixel/package/components/footer.tscn @@ -0,0 +1,52 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Texture2D" path="../assets/ui/logo.png" id="1_logo"] +[ext_resource type="Script" path="../scripts/home_reference_label.gd" id="2_reference_label"] + +[node name="PreviewFooter" type="Control"] +layout_mode = 3 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -152.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 2 + +[node name="Logo" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 0 +offset_left = -18.0 +offset_top = 24.0 +offset_right = 323.0 +offset_bottom = 109.0 +mouse_filter = 2 +texture = ExtResource("1_logo") +expand_mode = 1 +stretch_mode = 5 + +[node name="Brand" type="Label" parent="."] +visible = false +layout_mode = 0 +offset_right = 1.0 +offset_bottom = 1.0 +theme_type_variation = &"SupportText" +text = "РАЗМЕН\nЖЕТОНОВ" + +[node name="Support" type="Label" parent="."] +layout_mode = 0 +offset_left = 341.0 +offset_top = 19.0 +offset_right = 677.0 +offset_bottom = 105.0 +theme_type_variation = &"SupportText" +theme_override_constants/line_spacing = 10 +theme_override_font_sizes/font_size = 24 +text = "ТЕХ ПОДДЕРЖКА:\n+7-XXX-XXX-XX-XX" +vertical_alignment = 1 +mouse_filter = 2 +pivot_offset = Vector2(0, 43) +scale = Vector2(1.35, 1) +script = ExtResource("2_reference_label") +format_mode = "support" diff --git a/themes/space-pixel/package/components/interactive_pet.tscn b/themes/space-pixel/package/components/interactive_pet.tscn new file mode 100644 index 0000000..432b7c2 --- /dev/null +++ b/themes/space-pixel/package/components/interactive_pet.tscn @@ -0,0 +1,3485 @@ +[gd_scene format=3 uid="uid://coexu8odbbour"] + +[ext_resource type="Script" uid="uid://k2f6x5chdh01" path="res://themes/space-pixel/package/scripts/interactive_pet.gd" id="1_pet_script"] +[ext_resource type="Texture2D" uid="uid://bpyxt5fswdyqg" path="res://themes/space-pixel/package/assets/animations/alien_pet/climbing_down.png" id="2_climbing_down"] +[ext_resource type="Texture2D" uid="uid://cm7gpna4ns3be" path="res://themes/space-pixel/package/assets/animations/alien_pet/climbing_start.png" id="3_climbing_start"] +[ext_resource type="Texture2D" uid="uid://cnbee5ael5tsg" path="res://themes/space-pixel/package/assets/animations/alien_pet/climbing_up.png" id="4_climbing_up"] +[ext_resource type="Texture2D" uid="uid://dokquou3nkitt" path="res://themes/space-pixel/package/assets/animations/alien_pet/dancing.png" id="5_dancing"] +[ext_resource type="Texture2D" uid="uid://bfs0gdewfedmh" path="res://themes/space-pixel/package/assets/animations/alien_pet/falling.png" id="6_floating"] +[ext_resource type="Texture2D" uid="uid://7d45p4xo6waq" path="res://themes/space-pixel/package/assets/animations/alien_pet/hanging.png" id="7_hanging"] +[ext_resource type="Texture2D" uid="uid://p2k1nr7u0n8l" path="res://themes/space-pixel/package/assets/animations/alien_pet/sitting.png" id="8_sitting"] +[ext_resource type="Texture2D" uid="uid://cavh0687pqu4o" path="res://themes/space-pixel/package/assets/animations/alien_pet/walking_left_stop.png" id="9_walking_left_stop"] +[ext_resource type="Texture2D" uid="uid://c41lc5pb7bl64" path="res://themes/space-pixel/package/assets/animations/alien_pet/walking_right_stop.png" id="10_walking_right_stop"] +[ext_resource type="Texture2D" uid="uid://rxjilm82m058" path="res://themes/space-pixel/package/assets/animations/alien_pet/walking_left.png" id="11_walking_left"] +[ext_resource type="Texture2D" uid="uid://dj5bhh36bcenk" path="res://themes/space-pixel/package/assets/animations/alien_pet/walking_right.png" id="12_walking_right"] +[ext_resource type="Texture2D" uid="uid://dp6txiyte76en" path="res://themes/space-pixel/package/assets/animations/alien_pet/walking_left_start.png" id="13_walking_left_start"] +[ext_resource type="Texture2D" uid="uid://dx04s533dp3m3" path="res://themes/space-pixel/package/assets/animations/alien_pet/walking_start_right.png" id="14_walking_start_right"] +[ext_resource type="Texture2D" uid="uid://bsmel1k8ohsub" path="res://themes/space-pixel/package/assets/animations/alien_pet/come_closer.png" id="15_come_closer"] +[ext_resource type="Texture2D" uid="uid://bog3bxikpv4vv" path="res://themes/space-pixel/package/assets/animations/alien_pet/idle.png" id="16_idle"] +[ext_resource type="Texture2D" uid="uid://cfx08kqh1l2dk" path="res://themes/space-pixel/package/assets/animations/alien_pet/landing.png" id="17_landing"] +[ext_resource type="Texture2D" uid="uid://cge3gefu8fxqs" path="res://themes/space-pixel/package/assets/animations/alien_pet/turn_left.png" id="18_turn_left"] +[ext_resource type="Texture2D" uid="uid://kvdyxfre1n8c" path="res://themes/space-pixel/package/assets/animations/alien_pet/turn_right.png" id="19_turn_right"] +[ext_resource type="Texture2D" uid="uid://evysfqsy6g8w" path="res://themes/space-pixel/package/assets/animations/alien_pet/action.png" id="20_action"] +[ext_resource type="Texture2D" uid="uid://d337pf8yibt1l" path="res://themes/space-pixel/package/assets/animations/alien_pet/climbing_action_start.png" id="21_climbing_action_start"] +[ext_resource type="Texture2D" uid="uid://c6rcpc0l736sq" path="res://themes/space-pixel/package/assets/animations/alien_pet/climbing_action_stop.png" id="22_climbing_action_stop"] +[ext_resource type="Texture2D" uid="uid://kanikcd4bu5v" path="res://themes/space-pixel/package/assets/animations/alien_pet/climbing_action.png" id="23_climbing_action"] + +[sub_resource type="AtlasTexture" id="AtlasTexture_p1714"] +atlas = ExtResource("20_action") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_8c6ti"] +atlas = ExtResource("20_action") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_tniwl"] +atlas = ExtResource("20_action") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_6yq1d"] +atlas = ExtResource("20_action") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5ecvf"] +atlas = ExtResource("20_action") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_7oebb"] +atlas = ExtResource("20_action") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_f6nk0"] +atlas = ExtResource("20_action") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5kqdc"] +atlas = ExtResource("20_action") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_cy34w"] +atlas = ExtResource("20_action") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_uw8w4"] +atlas = ExtResource("20_action") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_0jeb0"] +atlas = ExtResource("20_action") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_vrvgl"] +atlas = ExtResource("23_climbing_action") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_qfw80"] +atlas = ExtResource("23_climbing_action") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_8ayha"] +atlas = ExtResource("23_climbing_action") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_dlx3p"] +atlas = ExtResource("23_climbing_action") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ehvel"] +atlas = ExtResource("23_climbing_action") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_001ov"] +atlas = ExtResource("23_climbing_action") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_s8q76"] +atlas = ExtResource("23_climbing_action") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_a5udf"] +atlas = ExtResource("23_climbing_action") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_bw5x5"] +atlas = ExtResource("23_climbing_action") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_qyxep"] +atlas = ExtResource("23_climbing_action") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_xkdno"] +atlas = ExtResource("21_climbing_action_start") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_kfm5u"] +atlas = ExtResource("21_climbing_action_start") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_1a3qf"] +atlas = ExtResource("21_climbing_action_start") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_w7kiu"] +atlas = ExtResource("21_climbing_action_start") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_qggv0"] +atlas = ExtResource("21_climbing_action_start") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_mwp8a"] +atlas = ExtResource("21_climbing_action_start") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_jvwdv"] +atlas = ExtResource("21_climbing_action_start") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_6fthk"] +atlas = ExtResource("21_climbing_action_start") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_v21ty"] +atlas = ExtResource("21_climbing_action_start") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_x52ca"] +atlas = ExtResource("21_climbing_action_start") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_whajc"] +atlas = ExtResource("21_climbing_action_start") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_oviss"] +atlas = ExtResource("22_climbing_action_stop") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_aoqd0"] +atlas = ExtResource("22_climbing_action_stop") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_f786a"] +atlas = ExtResource("22_climbing_action_stop") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_mil3v"] +atlas = ExtResource("22_climbing_action_stop") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_o1tmv"] +atlas = ExtResource("22_climbing_action_stop") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_14y8m"] +atlas = ExtResource("22_climbing_action_stop") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_oy35m"] +atlas = ExtResource("22_climbing_action_stop") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5ntlk"] +atlas = ExtResource("22_climbing_action_stop") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_6ftmd"] +atlas = ExtResource("22_climbing_action_stop") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5ptsy"] +atlas = ExtResource("22_climbing_action_stop") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_4o0kk"] +atlas = ExtResource("22_climbing_action_stop") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_18ift"] +atlas = ExtResource("2_climbing_down") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_33wbu"] +atlas = ExtResource("2_climbing_down") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_qi250"] +atlas = ExtResource("2_climbing_down") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_6tcir"] +atlas = ExtResource("2_climbing_down") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_kub5e"] +atlas = ExtResource("2_climbing_down") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_mqvp5"] +atlas = ExtResource("2_climbing_down") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_sa2fm"] +atlas = ExtResource("2_climbing_down") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_rrh6t"] +atlas = ExtResource("2_climbing_down") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_olvb2"] +atlas = ExtResource("2_climbing_down") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ykoa0"] +atlas = ExtResource("2_climbing_down") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_pb5wh"] +atlas = ExtResource("2_climbing_down") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_rf71h"] +atlas = ExtResource("2_climbing_down") +region = Rect2(384, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_3s374"] +atlas = ExtResource("2_climbing_down") +region = Rect2(512, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_byluo"] +atlas = ExtResource("2_climbing_down") +region = Rect2(640, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_yakrs"] +atlas = ExtResource("2_climbing_down") +region = Rect2(768, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_8o4sm"] +atlas = ExtResource("2_climbing_down") +region = Rect2(896, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_vc2fe"] +atlas = ExtResource("3_climbing_start") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_28rgb"] +atlas = ExtResource("3_climbing_start") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_6hd38"] +atlas = ExtResource("3_climbing_start") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_x0os1"] +atlas = ExtResource("3_climbing_start") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_lvcc8"] +atlas = ExtResource("3_climbing_start") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_nckjj"] +atlas = ExtResource("3_climbing_start") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ujele"] +atlas = ExtResource("3_climbing_start") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_466jh"] +atlas = ExtResource("3_climbing_start") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_oyoy4"] +atlas = ExtResource("3_climbing_start") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_qsh7i"] +atlas = ExtResource("3_climbing_start") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_3n7x0"] +atlas = ExtResource("3_climbing_start") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_oe4uu"] +atlas = ExtResource("3_climbing_start") +region = Rect2(384, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_18noe"] +atlas = ExtResource("3_climbing_start") +region = Rect2(512, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_d7lsy"] +atlas = ExtResource("3_climbing_start") +region = Rect2(640, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_gt28j"] +atlas = ExtResource("3_climbing_start") +region = Rect2(768, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_p8bvr"] +atlas = ExtResource("3_climbing_start") +region = Rect2(896, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_i52qu"] +atlas = ExtResource("3_climbing_start") +region = Rect2(0, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ovyu0"] +atlas = ExtResource("3_climbing_start") +region = Rect2(128, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_27udb"] +atlas = ExtResource("3_climbing_start") +region = Rect2(256, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_hiyls"] +atlas = ExtResource("4_climbing_up") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_un2so"] +atlas = ExtResource("4_climbing_up") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_22wwf"] +atlas = ExtResource("4_climbing_up") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_p7j38"] +atlas = ExtResource("4_climbing_up") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ie5ux"] +atlas = ExtResource("4_climbing_up") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_hd3lm"] +atlas = ExtResource("4_climbing_up") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_y4kbk"] +atlas = ExtResource("4_climbing_up") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_soxka"] +atlas = ExtResource("4_climbing_up") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_7p6ng"] +atlas = ExtResource("4_climbing_up") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5nnyo"] +atlas = ExtResource("4_climbing_up") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_02vj1"] +atlas = ExtResource("4_climbing_up") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_naj0j"] +atlas = ExtResource("4_climbing_up") +region = Rect2(384, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_te1xw"] +atlas = ExtResource("4_climbing_up") +region = Rect2(512, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_a50f7"] +atlas = ExtResource("4_climbing_up") +region = Rect2(640, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_rlp38"] +atlas = ExtResource("4_climbing_up") +region = Rect2(768, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_4lpf7"] +atlas = ExtResource("4_climbing_up") +region = Rect2(896, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_0o1st"] +atlas = ExtResource("15_come_closer") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_qdk1a"] +atlas = ExtResource("15_come_closer") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_hx5pd"] +atlas = ExtResource("15_come_closer") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ehxu0"] +atlas = ExtResource("15_come_closer") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_c3yjf"] +atlas = ExtResource("15_come_closer") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_inikp"] +atlas = ExtResource("15_come_closer") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_e51pw"] +atlas = ExtResource("15_come_closer") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_kpa4g"] +atlas = ExtResource("15_come_closer") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_v8l2m"] +atlas = ExtResource("15_come_closer") +region = Rect2(1024, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_tnnld"] +atlas = ExtResource("15_come_closer") +region = Rect2(1152, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_jl5ov"] +atlas = ExtResource("15_come_closer") +region = Rect2(1280, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ubsyx"] +atlas = ExtResource("15_come_closer") +region = Rect2(1408, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_f6bcv"] +atlas = ExtResource("15_come_closer") +region = Rect2(1536, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_tnllf"] +atlas = ExtResource("15_come_closer") +region = Rect2(1664, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5yrpk"] +atlas = ExtResource("15_come_closer") +region = Rect2(1792, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_7fugc"] +atlas = ExtResource("15_come_closer") +region = Rect2(1920, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_p7ss2"] +atlas = ExtResource("15_come_closer") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_8wcq3"] +atlas = ExtResource("15_come_closer") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_j1wry"] +atlas = ExtResource("15_come_closer") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5w2ha"] +atlas = ExtResource("15_come_closer") +region = Rect2(384, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_v1w5o"] +atlas = ExtResource("15_come_closer") +region = Rect2(512, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_1lqm2"] +atlas = ExtResource("15_come_closer") +region = Rect2(640, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_umq30"] +atlas = ExtResource("15_come_closer") +region = Rect2(768, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_2s456"] +atlas = ExtResource("15_come_closer") +region = Rect2(896, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ktafw"] +atlas = ExtResource("15_come_closer") +region = Rect2(1024, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_763bp"] +atlas = ExtResource("15_come_closer") +region = Rect2(1152, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_g52vn"] +atlas = ExtResource("15_come_closer") +region = Rect2(1280, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_b1dj3"] +atlas = ExtResource("15_come_closer") +region = Rect2(1408, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_thfkn"] +atlas = ExtResource("15_come_closer") +region = Rect2(1536, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ky0pi"] +atlas = ExtResource("15_come_closer") +region = Rect2(1664, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_xrwqd"] +atlas = ExtResource("15_come_closer") +region = Rect2(1792, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_woq8o"] +atlas = ExtResource("15_come_closer") +region = Rect2(1920, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_4h4ig"] +atlas = ExtResource("15_come_closer") +region = Rect2(0, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_u8rur"] +atlas = ExtResource("15_come_closer") +region = Rect2(128, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_rn3if"] +atlas = ExtResource("15_come_closer") +region = Rect2(256, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_60ehj"] +atlas = ExtResource("15_come_closer") +region = Rect2(384, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ns2qv"] +atlas = ExtResource("5_dancing") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_swv85"] +atlas = ExtResource("5_dancing") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_bjivs"] +atlas = ExtResource("5_dancing") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_fpn50"] +atlas = ExtResource("5_dancing") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_7l7kh"] +atlas = ExtResource("5_dancing") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_glkt7"] +atlas = ExtResource("5_dancing") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_4fpdy"] +atlas = ExtResource("5_dancing") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_oardh"] +atlas = ExtResource("5_dancing") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_pwc7q"] +atlas = ExtResource("5_dancing") +region = Rect2(1024, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_h7hki"] +atlas = ExtResource("5_dancing") +region = Rect2(1152, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_0iqe6"] +atlas = ExtResource("5_dancing") +region = Rect2(1280, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_6286g"] +atlas = ExtResource("5_dancing") +region = Rect2(1408, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_6sdg4"] +atlas = ExtResource("5_dancing") +region = Rect2(1536, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_0p85c"] +atlas = ExtResource("5_dancing") +region = Rect2(1664, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_c66jl"] +atlas = ExtResource("5_dancing") +region = Rect2(1792, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_a3eqw"] +atlas = ExtResource("5_dancing") +region = Rect2(1920, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_wyrwo"] +atlas = ExtResource("5_dancing") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_7eett"] +atlas = ExtResource("5_dancing") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_rf7u6"] +atlas = ExtResource("5_dancing") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_u4b1f"] +atlas = ExtResource("5_dancing") +region = Rect2(384, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_x8lri"] +atlas = ExtResource("5_dancing") +region = Rect2(512, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_3u3mj"] +atlas = ExtResource("5_dancing") +region = Rect2(640, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_8qj56"] +atlas = ExtResource("5_dancing") +region = Rect2(768, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_4fc3q"] +atlas = ExtResource("5_dancing") +region = Rect2(896, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_n1q13"] +atlas = ExtResource("5_dancing") +region = Rect2(1024, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_oaqsy"] +atlas = ExtResource("5_dancing") +region = Rect2(1152, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_qy0eh"] +atlas = ExtResource("5_dancing") +region = Rect2(1280, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_e6o2g"] +atlas = ExtResource("5_dancing") +region = Rect2(1408, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_lrmk1"] +atlas = ExtResource("5_dancing") +region = Rect2(1536, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_r7cpx"] +atlas = ExtResource("5_dancing") +region = Rect2(1664, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_cxn1k"] +atlas = ExtResource("5_dancing") +region = Rect2(1792, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_s75oe"] +atlas = ExtResource("5_dancing") +region = Rect2(1920, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_b5mik"] +atlas = ExtResource("5_dancing") +region = Rect2(0, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_m86pd"] +atlas = ExtResource("5_dancing") +region = Rect2(128, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_25acx"] +atlas = ExtResource("5_dancing") +region = Rect2(256, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_4utlm"] +atlas = ExtResource("5_dancing") +region = Rect2(384, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_j1bsy"] +atlas = ExtResource("5_dancing") +region = Rect2(512, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_tmgc5"] +atlas = ExtResource("5_dancing") +region = Rect2(640, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_b8xqb"] +atlas = ExtResource("5_dancing") +region = Rect2(768, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_2fm21"] +atlas = ExtResource("5_dancing") +region = Rect2(896, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_po2pu"] +atlas = ExtResource("5_dancing") +region = Rect2(1024, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_xwcwy"] +atlas = ExtResource("5_dancing") +region = Rect2(1152, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_j0o54"] +atlas = ExtResource("5_dancing") +region = Rect2(1280, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_7jsyk"] +atlas = ExtResource("5_dancing") +region = Rect2(1408, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_17ms8"] +atlas = ExtResource("5_dancing") +region = Rect2(1536, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_p4gxb"] +atlas = ExtResource("5_dancing") +region = Rect2(1664, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_xyqv4"] +atlas = ExtResource("5_dancing") +region = Rect2(1792, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_l8u0n"] +atlas = ExtResource("5_dancing") +region = Rect2(1920, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_l6vva"] +atlas = ExtResource("5_dancing") +region = Rect2(0, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_fa5h5"] +atlas = ExtResource("5_dancing") +region = Rect2(128, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_4lu5k"] +atlas = ExtResource("5_dancing") +region = Rect2(256, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_t6fhg"] +atlas = ExtResource("5_dancing") +region = Rect2(384, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_y0d05"] +atlas = ExtResource("5_dancing") +region = Rect2(512, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ebbdg"] +atlas = ExtResource("5_dancing") +region = Rect2(640, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_c70pw"] +atlas = ExtResource("5_dancing") +region = Rect2(768, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_r6ljm"] +atlas = ExtResource("5_dancing") +region = Rect2(896, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_nwct2"] +atlas = ExtResource("5_dancing") +region = Rect2(1024, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_vuqb5"] +atlas = ExtResource("5_dancing") +region = Rect2(1152, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_pneku"] +atlas = ExtResource("5_dancing") +region = Rect2(1280, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_rqbwa"] +atlas = ExtResource("5_dancing") +region = Rect2(1408, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_jk530"] +atlas = ExtResource("5_dancing") +region = Rect2(1536, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_vxhif"] +atlas = ExtResource("5_dancing") +region = Rect2(1664, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_usv3h"] +atlas = ExtResource("5_dancing") +region = Rect2(1792, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_k6yk0"] +atlas = ExtResource("5_dancing") +region = Rect2(1920, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_262k3"] +atlas = ExtResource("5_dancing") +region = Rect2(0, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_on21h"] +atlas = ExtResource("5_dancing") +region = Rect2(128, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_1g4it"] +atlas = ExtResource("5_dancing") +region = Rect2(256, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_v5kkw"] +atlas = ExtResource("5_dancing") +region = Rect2(384, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_n0p8r"] +atlas = ExtResource("5_dancing") +region = Rect2(512, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_2ukdk"] +atlas = ExtResource("5_dancing") +region = Rect2(640, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_iplt6"] +atlas = ExtResource("5_dancing") +region = Rect2(768, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_avbn2"] +atlas = ExtResource("5_dancing") +region = Rect2(896, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_04biu"] +atlas = ExtResource("5_dancing") +region = Rect2(1024, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_lnh25"] +atlas = ExtResource("5_dancing") +region = Rect2(1152, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_8tjbq"] +atlas = ExtResource("5_dancing") +region = Rect2(1280, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_skw87"] +atlas = ExtResource("5_dancing") +region = Rect2(1408, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_fknqf"] +atlas = ExtResource("5_dancing") +region = Rect2(1536, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ecke3"] +atlas = ExtResource("5_dancing") +region = Rect2(1664, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_28226"] +atlas = ExtResource("5_dancing") +region = Rect2(1792, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_gjtls"] +atlas = ExtResource("5_dancing") +region = Rect2(1920, 512, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_akjwq"] +atlas = ExtResource("5_dancing") +region = Rect2(0, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_333qr"] +atlas = ExtResource("5_dancing") +region = Rect2(128, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_nojie"] +atlas = ExtResource("5_dancing") +region = Rect2(256, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_bi0ch"] +atlas = ExtResource("5_dancing") +region = Rect2(384, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_eyi1n"] +atlas = ExtResource("5_dancing") +region = Rect2(512, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_llipu"] +atlas = ExtResource("5_dancing") +region = Rect2(640, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_m2exb"] +atlas = ExtResource("5_dancing") +region = Rect2(768, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_wuyje"] +atlas = ExtResource("5_dancing") +region = Rect2(896, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_cepc8"] +atlas = ExtResource("5_dancing") +region = Rect2(1024, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_p6ste"] +atlas = ExtResource("5_dancing") +region = Rect2(1152, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_8r0nc"] +atlas = ExtResource("5_dancing") +region = Rect2(1280, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_pknhn"] +atlas = ExtResource("5_dancing") +region = Rect2(1408, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_3d7vy"] +atlas = ExtResource("5_dancing") +region = Rect2(1536, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_bx4f8"] +atlas = ExtResource("5_dancing") +region = Rect2(1664, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ki123"] +atlas = ExtResource("5_dancing") +region = Rect2(1792, 640, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_3krfi"] +atlas = ExtResource("6_floating") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_uen7d"] +atlas = ExtResource("6_floating") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_gecc2"] +atlas = ExtResource("6_floating") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_8jtcy"] +atlas = ExtResource("6_floating") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_855mo"] +atlas = ExtResource("6_floating") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_pko00"] +atlas = ExtResource("6_floating") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_7105n"] +atlas = ExtResource("6_floating") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_d5d30"] +atlas = ExtResource("6_floating") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_042jw"] +atlas = ExtResource("6_floating") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_20uf8"] +atlas = ExtResource("6_floating") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_hsx1o"] +atlas = ExtResource("6_floating") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_duejx"] +atlas = ExtResource("6_floating") +region = Rect2(384, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ockil"] +atlas = ExtResource("6_floating") +region = Rect2(512, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_45e2l"] +atlas = ExtResource("6_floating") +region = Rect2(640, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_jg8l4"] +atlas = ExtResource("6_floating") +region = Rect2(768, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_1g6a7"] +atlas = ExtResource("6_floating") +region = Rect2(896, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_fb0hx"] +atlas = ExtResource("6_floating") +region = Rect2(0, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_m8kgo"] +atlas = ExtResource("6_floating") +region = Rect2(128, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ib48p"] +atlas = ExtResource("6_floating") +region = Rect2(256, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_2ajdj"] +atlas = ExtResource("6_floating") +region = Rect2(384, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_qj42n"] +atlas = ExtResource("6_floating") +region = Rect2(512, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ws2jq"] +atlas = ExtResource("6_floating") +region = Rect2(640, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_gj2w1"] +atlas = ExtResource("6_floating") +region = Rect2(768, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_h1d6d"] +atlas = ExtResource("6_floating") +region = Rect2(896, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_p7v4m"] +atlas = ExtResource("6_floating") +region = Rect2(0, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_u2m8g"] +atlas = ExtResource("6_floating") +region = Rect2(128, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_y7yed"] +atlas = ExtResource("6_floating") +region = Rect2(256, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_co712"] +atlas = ExtResource("6_floating") +region = Rect2(384, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_1ecmv"] +atlas = ExtResource("6_floating") +region = Rect2(512, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_voohc"] +atlas = ExtResource("6_floating") +region = Rect2(640, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_o7uc4"] +atlas = ExtResource("7_hanging") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_84vms"] +atlas = ExtResource("7_hanging") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_vmdhf"] +atlas = ExtResource("7_hanging") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_c28ku"] +atlas = ExtResource("7_hanging") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_srjwt"] +atlas = ExtResource("7_hanging") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_b6vew"] +atlas = ExtResource("7_hanging") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_nafm8"] +atlas = ExtResource("7_hanging") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_63s0s"] +atlas = ExtResource("7_hanging") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_gcggp"] +atlas = ExtResource("7_hanging") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_bxhwq"] +atlas = ExtResource("7_hanging") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_cwngy"] +atlas = ExtResource("7_hanging") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_lldte"] +atlas = ExtResource("7_hanging") +region = Rect2(384, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_tv88g"] +atlas = ExtResource("7_hanging") +region = Rect2(512, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_aapo5"] +atlas = ExtResource("7_hanging") +region = Rect2(640, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_wnmq1"] +atlas = ExtResource("7_hanging") +region = Rect2(768, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_jsfm6"] +atlas = ExtResource("7_hanging") +region = Rect2(896, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_2ymtc"] +atlas = ExtResource("7_hanging") +region = Rect2(0, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_xq0vy"] +atlas = ExtResource("7_hanging") +region = Rect2(128, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_v0nwu"] +atlas = ExtResource("7_hanging") +region = Rect2(256, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_mwioe"] +atlas = ExtResource("7_hanging") +region = Rect2(384, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_6phgx"] +atlas = ExtResource("7_hanging") +region = Rect2(512, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_m1oul"] +atlas = ExtResource("7_hanging") +region = Rect2(640, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_4g55x"] +atlas = ExtResource("7_hanging") +region = Rect2(768, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_xad33"] +atlas = ExtResource("16_idle") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_6fy5m"] +atlas = ExtResource("16_idle") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_8xvdq"] +atlas = ExtResource("16_idle") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_1y4fx"] +atlas = ExtResource("16_idle") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_wwjx6"] +atlas = ExtResource("16_idle") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_og64f"] +atlas = ExtResource("16_idle") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_86y48"] +atlas = ExtResource("16_idle") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_dfscs"] +atlas = ExtResource("16_idle") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_1ywcw"] +atlas = ExtResource("16_idle") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_f5u3j"] +atlas = ExtResource("16_idle") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_4ywtq"] +atlas = ExtResource("16_idle") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_htj7m"] +atlas = ExtResource("16_idle") +region = Rect2(384, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_moxrq"] +atlas = ExtResource("16_idle") +region = Rect2(512, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_nt6jq"] +atlas = ExtResource("16_idle") +region = Rect2(640, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_1ag4u"] +atlas = ExtResource("16_idle") +region = Rect2(768, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ul6bl"] +atlas = ExtResource("16_idle") +region = Rect2(896, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_e3k0j"] +atlas = ExtResource("16_idle") +region = Rect2(0, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_aioe3"] +atlas = ExtResource("16_idle") +region = Rect2(128, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_1yddp"] +atlas = ExtResource("16_idle") +region = Rect2(256, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_2i2r6"] +atlas = ExtResource("17_landing") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_nnyxq"] +atlas = ExtResource("17_landing") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_m8bci"] +atlas = ExtResource("17_landing") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_jcnuq"] +atlas = ExtResource("17_landing") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_6std6"] +atlas = ExtResource("17_landing") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_40rep"] +atlas = ExtResource("17_landing") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5cnie"] +atlas = ExtResource("17_landing") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_rkcs3"] +atlas = ExtResource("17_landing") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_hn42w"] +atlas = ExtResource("17_landing") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_xexfq"] +atlas = ExtResource("17_landing") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_sdeiv"] +atlas = ExtResource("17_landing") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_kwi4v"] +atlas = ExtResource("8_sitting") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_gjqrp"] +atlas = ExtResource("8_sitting") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_kyccr"] +atlas = ExtResource("8_sitting") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ea0le"] +atlas = ExtResource("8_sitting") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_88j6p"] +atlas = ExtResource("8_sitting") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_f7sxy"] +atlas = ExtResource("8_sitting") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ctqcc"] +atlas = ExtResource("8_sitting") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_3flrw"] +atlas = ExtResource("8_sitting") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_w3a3j"] +atlas = ExtResource("8_sitting") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_uwjv3"] +atlas = ExtResource("8_sitting") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_mdlp0"] +atlas = ExtResource("8_sitting") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ig2oa"] +atlas = ExtResource("8_sitting") +region = Rect2(384, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5tp2c"] +atlas = ExtResource("8_sitting") +region = Rect2(512, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_tx62n"] +atlas = ExtResource("8_sitting") +region = Rect2(640, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_7gj5l"] +atlas = ExtResource("8_sitting") +region = Rect2(768, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_s0wbm"] +atlas = ExtResource("8_sitting") +region = Rect2(896, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_dcxye"] +atlas = ExtResource("8_sitting") +region = Rect2(0, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_wlc13"] +atlas = ExtResource("8_sitting") +region = Rect2(128, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_jw2qi"] +atlas = ExtResource("8_sitting") +region = Rect2(256, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5hj7k"] +atlas = ExtResource("8_sitting") +region = Rect2(384, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_m5fe5"] +atlas = ExtResource("8_sitting") +region = Rect2(512, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_fq4oa"] +atlas = ExtResource("8_sitting") +region = Rect2(640, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_306se"] +atlas = ExtResource("8_sitting") +region = Rect2(768, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_7us7t"] +atlas = ExtResource("8_sitting") +region = Rect2(896, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_wetbv"] +atlas = ExtResource("8_sitting") +region = Rect2(0, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_cgsks"] +atlas = ExtResource("8_sitting") +region = Rect2(128, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5ekgr"] +atlas = ExtResource("8_sitting") +region = Rect2(256, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_yjnot"] +atlas = ExtResource("8_sitting") +region = Rect2(384, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ronbm"] +atlas = ExtResource("8_sitting") +region = Rect2(512, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_6a0oe"] +atlas = ExtResource("8_sitting") +region = Rect2(640, 384, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_xghdm"] +atlas = ExtResource("18_turn_left") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_or51f"] +atlas = ExtResource("18_turn_left") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_1nvyc"] +atlas = ExtResource("18_turn_left") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5rhas"] +atlas = ExtResource("18_turn_left") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ine4j"] +atlas = ExtResource("18_turn_left") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_3a302"] +atlas = ExtResource("18_turn_left") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_bvsvp"] +atlas = ExtResource("18_turn_left") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_jug24"] +atlas = ExtResource("18_turn_left") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_216y7"] +atlas = ExtResource("18_turn_left") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_vtsh3"] +atlas = ExtResource("18_turn_left") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_64bfp"] +atlas = ExtResource("19_turn_right") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_3bvqx"] +atlas = ExtResource("19_turn_right") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_osh2x"] +atlas = ExtResource("19_turn_right") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_csbmi"] +atlas = ExtResource("19_turn_right") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_akhul"] +atlas = ExtResource("19_turn_right") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_7wqe0"] +atlas = ExtResource("19_turn_right") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_ek3uu"] +atlas = ExtResource("19_turn_right") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_rek4b"] +atlas = ExtResource("19_turn_right") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_aq50j"] +atlas = ExtResource("19_turn_right") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_o1ies"] +atlas = ExtResource("19_turn_right") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_k0g41"] +atlas = ExtResource("11_walking_left") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_r31cj"] +atlas = ExtResource("11_walking_left") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_2kaan"] +atlas = ExtResource("11_walking_left") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5avkc"] +atlas = ExtResource("11_walking_left") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_wllvd"] +atlas = ExtResource("11_walking_left") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_tqep8"] +atlas = ExtResource("11_walking_left") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_dg2ou"] +atlas = ExtResource("11_walking_left") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_f05ab"] +atlas = ExtResource("11_walking_left") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_uwmem"] +atlas = ExtResource("11_walking_left") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_lrbxu"] +atlas = ExtResource("11_walking_left") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_nd34c"] +atlas = ExtResource("11_walking_left") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_vamp1"] +atlas = ExtResource("11_walking_left") +region = Rect2(384, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_e6n1f"] +atlas = ExtResource("11_walking_left") +region = Rect2(512, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_dtipg"] +atlas = ExtResource("11_walking_left") +region = Rect2(640, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_7bj0h"] +atlas = ExtResource("11_walking_left") +region = Rect2(768, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_7176c"] +atlas = ExtResource("11_walking_left") +region = Rect2(896, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_e0vd5"] +atlas = ExtResource("11_walking_left") +region = Rect2(0, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_pvcky"] +atlas = ExtResource("11_walking_left") +region = Rect2(128, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_lyf8l"] +atlas = ExtResource("11_walking_left") +region = Rect2(256, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_y32pd"] +atlas = ExtResource("13_walking_left_start") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_hucjn"] +atlas = ExtResource("13_walking_left_start") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_xvjk6"] +atlas = ExtResource("13_walking_left_start") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_bwxhd"] +atlas = ExtResource("13_walking_left_start") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_k16y0"] +atlas = ExtResource("13_walking_left_start") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_b1xwo"] +atlas = ExtResource("9_walking_left_stop") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_esgdu"] +atlas = ExtResource("9_walking_left_stop") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_txknb"] +atlas = ExtResource("9_walking_left_stop") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_fhhix"] +atlas = ExtResource("9_walking_left_stop") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_w6u4e"] +atlas = ExtResource("12_walking_right") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_sonbe"] +atlas = ExtResource("12_walking_right") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_lblpb"] +atlas = ExtResource("12_walking_right") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_67ehv"] +atlas = ExtResource("12_walking_right") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_qvvbc"] +atlas = ExtResource("12_walking_right") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_xs7a8"] +atlas = ExtResource("12_walking_right") +region = Rect2(640, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_yxefm"] +atlas = ExtResource("12_walking_right") +region = Rect2(768, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_nx458"] +atlas = ExtResource("12_walking_right") +region = Rect2(896, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_8uslj"] +atlas = ExtResource("12_walking_right") +region = Rect2(0, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_2vis6"] +atlas = ExtResource("12_walking_right") +region = Rect2(128, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_hhhxs"] +atlas = ExtResource("12_walking_right") +region = Rect2(256, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_fkwr5"] +atlas = ExtResource("12_walking_right") +region = Rect2(384, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_x4uvy"] +atlas = ExtResource("12_walking_right") +region = Rect2(512, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_5dgh0"] +atlas = ExtResource("12_walking_right") +region = Rect2(640, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_md7h6"] +atlas = ExtResource("12_walking_right") +region = Rect2(768, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_d3hvn"] +atlas = ExtResource("12_walking_right") +region = Rect2(896, 128, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_831f1"] +atlas = ExtResource("12_walking_right") +region = Rect2(0, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_cm1ae"] +atlas = ExtResource("12_walking_right") +region = Rect2(128, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_v0246"] +atlas = ExtResource("12_walking_right") +region = Rect2(256, 256, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_p66ib"] +atlas = ExtResource("10_walking_right_stop") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_mxf2g"] +atlas = ExtResource("10_walking_right_stop") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_sm1lg"] +atlas = ExtResource("10_walking_right_stop") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_rfw88"] +atlas = ExtResource("10_walking_right_stop") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_oeeat"] +atlas = ExtResource("14_walking_start_right") +region = Rect2(0, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_gjbtp"] +atlas = ExtResource("14_walking_start_right") +region = Rect2(128, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_qlii4"] +atlas = ExtResource("14_walking_start_right") +region = Rect2(256, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_c1x2e"] +atlas = ExtResource("14_walking_start_right") +region = Rect2(384, 0, 128, 128) +filter_clip = true + +[sub_resource type="AtlasTexture" id="AtlasTexture_i8h7t"] +atlas = ExtResource("14_walking_start_right") +region = Rect2(512, 0, 128, 128) +filter_clip = true + +[sub_resource type="SpriteFrames" id="SpriteFrames_nfixd"] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_p1714") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_8c6ti") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_tniwl") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6yq1d") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5ecvf") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7oebb") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_f6nk0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5kqdc") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_cy34w") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_uw8w4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_0jeb0") +}], +"loop": 1, +"name": &"action", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_vrvgl") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_qfw80") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_8ayha") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_dlx3p") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ehvel") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_001ov") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_s8q76") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_a5udf") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_bw5x5") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_qyxep") +}], +"loop": 1, +"name": &"climbing_action", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_xkdno") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_kfm5u") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_1a3qf") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_w7kiu") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_qggv0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_mwp8a") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_jvwdv") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6fthk") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_v21ty") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_x52ca") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_whajc") +}], +"loop": 0, +"name": &"climbing_action_start", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_oviss") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_aoqd0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_f786a") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_mil3v") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_o1tmv") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_14y8m") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_oy35m") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5ntlk") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6ftmd") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5ptsy") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_4o0kk") +}], +"loop": 0, +"name": &"climbing_action_stop", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_18ift") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_33wbu") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_qi250") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6tcir") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_kub5e") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_mqvp5") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_sa2fm") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_rrh6t") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_olvb2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ykoa0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_pb5wh") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_rf71h") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_3s374") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_byluo") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_yakrs") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_8o4sm") +}], +"loop": 1, +"name": &"climbing_down", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_vc2fe") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_28rgb") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6hd38") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_x0os1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_lvcc8") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_nckjj") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ujele") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_466jh") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_oyoy4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_qsh7i") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_3n7x0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_oe4uu") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_18noe") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_d7lsy") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_gt28j") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_p8bvr") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_i52qu") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ovyu0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_27udb") +}], +"loop": 0, +"name": &"climbing_start", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_hiyls") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_un2so") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_22wwf") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_p7j38") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ie5ux") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_hd3lm") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_y4kbk") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_soxka") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7p6ng") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5nnyo") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_02vj1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_naj0j") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_te1xw") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_a50f7") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_rlp38") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_4lpf7") +}], +"loop": 1, +"name": &"climbing_up", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_0o1st") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_qdk1a") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_hx5pd") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ehxu0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_c3yjf") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_inikp") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_e51pw") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_kpa4g") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_v8l2m") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_tnnld") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_jl5ov") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ubsyx") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_f6bcv") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_tnllf") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5yrpk") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7fugc") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_p7ss2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_8wcq3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_j1wry") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5w2ha") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_v1w5o") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_1lqm2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_umq30") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_2s456") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ktafw") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_763bp") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_g52vn") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_b1dj3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_thfkn") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ky0pi") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_xrwqd") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_woq8o") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_4h4ig") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_u8rur") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_rn3if") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_60ehj") +}], +"loop": 0, +"name": &"come_closer", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_ns2qv") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_swv85") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_bjivs") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_fpn50") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7l7kh") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_glkt7") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_4fpdy") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_oardh") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_pwc7q") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_h7hki") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_0iqe6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6286g") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6sdg4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_0p85c") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_c66jl") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_a3eqw") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_wyrwo") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7eett") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_rf7u6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_u4b1f") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_x8lri") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_3u3mj") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_8qj56") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_4fc3q") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_n1q13") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_oaqsy") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_qy0eh") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_e6o2g") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_lrmk1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_r7cpx") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_cxn1k") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_s75oe") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_b5mik") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_m86pd") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_25acx") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_4utlm") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_j1bsy") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_tmgc5") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_b8xqb") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_2fm21") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_po2pu") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_xwcwy") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_j0o54") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7jsyk") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_17ms8") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_p4gxb") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_xyqv4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_l8u0n") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_l6vva") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_fa5h5") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_4lu5k") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_t6fhg") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_y0d05") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ebbdg") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_c70pw") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_r6ljm") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_nwct2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_vuqb5") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_pneku") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_rqbwa") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_jk530") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_vxhif") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_usv3h") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_k6yk0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_262k3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_on21h") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_1g4it") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_v5kkw") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_n0p8r") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_2ukdk") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_iplt6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_avbn2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_04biu") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_lnh25") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_8tjbq") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_skw87") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_fknqf") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ecke3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_28226") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_gjtls") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_akjwq") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_333qr") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_nojie") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_bi0ch") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_eyi1n") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_llipu") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_m2exb") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_wuyje") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_cepc8") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_p6ste") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_8r0nc") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_pknhn") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_3d7vy") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_bx4f8") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ki123") +}], +"loop": 1, +"name": &"dancing", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_3krfi") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_uen7d") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_gecc2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_8jtcy") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_855mo") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_pko00") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7105n") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_d5d30") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_042jw") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_20uf8") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_hsx1o") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_duejx") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ockil") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_45e2l") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_jg8l4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_1g6a7") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_fb0hx") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_m8kgo") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ib48p") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_2ajdj") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_qj42n") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ws2jq") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_gj2w1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_h1d6d") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_p7v4m") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_u2m8g") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_y7yed") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_co712") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_1ecmv") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_voohc") +}], +"loop": 1, +"name": &"falling", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_o7uc4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_84vms") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_vmdhf") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_c28ku") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_srjwt") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_b6vew") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_nafm8") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_63s0s") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_gcggp") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_bxhwq") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_cwngy") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_lldte") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_tv88g") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_aapo5") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_wnmq1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_jsfm6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_2ymtc") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_xq0vy") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_v0nwu") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_mwioe") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6phgx") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_m1oul") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_4g55x") +}], +"loop": 1, +"name": &"hanging", +"speed": 12.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_xad33") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6fy5m") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_8xvdq") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_1y4fx") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_wwjx6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_og64f") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_86y48") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_dfscs") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_1ywcw") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_f5u3j") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_4ywtq") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_htj7m") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_moxrq") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_nt6jq") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_1ag4u") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ul6bl") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_e3k0j") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_aioe3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_1yddp") +}], +"loop": 1, +"name": &"idle", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_2i2r6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_nnyxq") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_m8bci") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_jcnuq") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6std6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_40rep") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5cnie") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_rkcs3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_hn42w") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_xexfq") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_sdeiv") +}], +"loop": 0, +"name": &"landing", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_kwi4v") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_gjqrp") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_kyccr") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ea0le") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_88j6p") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_f7sxy") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ctqcc") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_3flrw") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_w3a3j") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_uwjv3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_mdlp0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ig2oa") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5tp2c") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_tx62n") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7gj5l") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_s0wbm") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_dcxye") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_wlc13") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_jw2qi") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5hj7k") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_m5fe5") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_fq4oa") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_306se") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7us7t") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_wetbv") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_cgsks") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5ekgr") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_yjnot") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ronbm") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6a0oe") +}], +"loop": 1, +"name": &"sitting", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_xghdm") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_or51f") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_1nvyc") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5rhas") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ine4j") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_3a302") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_bvsvp") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_jug24") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_216y7") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_vtsh3") +}], +"loop": 0, +"name": &"turn_left", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_64bfp") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_3bvqx") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_osh2x") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_csbmi") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_akhul") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7wqe0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ek3uu") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_rek4b") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_aq50j") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_o1ies") +}], +"loop": 0, +"name": &"turn_right", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_k0g41") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_r31cj") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_2kaan") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5avkc") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_wllvd") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_tqep8") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_dg2ou") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_f05ab") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_uwmem") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_lrbxu") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_nd34c") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_vamp1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_e6n1f") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_dtipg") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7bj0h") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7176c") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_e0vd5") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_pvcky") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_lyf8l") +}], +"loop": 1, +"name": &"walking_left", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_y32pd") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_hucjn") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_xvjk6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_bwxhd") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_k16y0") +}], +"loop": 0, +"name": &"walking_left_start", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_b1xwo") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_esgdu") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_txknb") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_fhhix") +}], +"loop": 0, +"name": &"walking_left_stop", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_w6u4e") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_sonbe") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_lblpb") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_67ehv") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_qvvbc") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_xs7a8") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_yxefm") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_nx458") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_8uslj") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_2vis6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_hhhxs") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_fkwr5") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_x4uvy") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5dgh0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_md7h6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_d3hvn") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_831f1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_cm1ae") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_v0246") +}], +"loop": 1, +"name": &"walking_right", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_p66ib") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_mxf2g") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_sm1lg") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_rfw88") +}], +"loop": 0, +"name": &"walking_right_stop", +"speed": 24.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_oeeat") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_gjbtp") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_qlii4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_c1x2e") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_i8h7t") +}], +"loop": 0, +"name": &"walking_start_right", +"speed": 24.0 +}] + +[node name="InteractivePet" type="Node2D" unique_id=1955588965] +script = ExtResource("1_pet_script") +climbing_down_texture = ExtResource("2_climbing_down") +climbing_start_texture = ExtResource("3_climbing_start") +climbing_up_texture = ExtResource("4_climbing_up") +dancing_texture = ExtResource("5_dancing") +come_closer_texture = ExtResource("15_come_closer") +falling_texture = ExtResource("6_floating") +hanging_texture = ExtResource("7_hanging") +sitting_texture = ExtResource("8_sitting") +idle_texture = ExtResource("16_idle") +landing_texture = ExtResource("17_landing") +turn_left_texture = ExtResource("18_turn_left") +turn_right_texture = ExtResource("19_turn_right") +walking_left_stop_texture = ExtResource("9_walking_left_stop") +walking_right_stop_texture = ExtResource("10_walking_right_stop") +walking_left_texture = ExtResource("11_walking_left") +walking_right_texture = ExtResource("12_walking_right") +walking_left_start_texture = ExtResource("13_walking_left_start") +walking_start_right_texture = ExtResource("14_walking_start_right") +action_texture = ExtResource("20_action") +climbing_action_start_texture = ExtResource("21_climbing_action_start") +climbing_action_stop_texture = ExtResource("22_climbing_action_stop") +climbing_action_texture = ExtResource("23_climbing_action") + +[node name="VisualRoot" type="Node2D" parent="." unique_id=181212115] +position = Vector2(64, 96) + +[node name="Sprite" type="AnimatedSprite2D" parent="VisualRoot" unique_id=999923383] +texture_filter = 1 +position = Vector2(0, -56) +sprite_frames = SubResource("SpriteFrames_nfixd") +animation = &"action" +frame_progress = 0.5807543 diff --git a/themes/space-pixel/package/components/on_screen_keyboards.tscn b/themes/space-pixel/package/components/on_screen_keyboards.tscn new file mode 100644 index 0000000..cb63dce --- /dev/null +++ b/themes/space-pixel/package/components/on_screen_keyboards.tscn @@ -0,0 +1,595 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Texture2D" path="../assets/ui/button_down.png" id="1_hide_icon"] + +[node name="KeyboardOverlay" type="Control"] +z_index = 30 +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="TextKeyboard" type="PanelContainer" parent="."] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard" +visible = false +layout_mode = 1 +anchors_preset = 12 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_top = -512.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="TextKeyboard"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Header" type="HBoxContainer" parent="TextKeyboard/Content"] +custom_minimum_size = Vector2(0, 58) +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Title" type="Label" parent="TextKeyboard/Content/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 20 +text = "ЭКРАННАЯ КЛАВИАТУРА" +vertical_alignment = 1 + +[node name="HideButton" type="Button" parent="TextKeyboard/Content/Header"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.header.hide_button" +custom_minimum_size = Vector2(64, 64) +layout_mode = 2 +focus_mode = 0 +tooltip_text = "Скрыть клавиатуру" +icon = ExtResource("1_hide_icon") +flat = true + +[node name="LetterRows" type="VBoxContainer" parent="TextKeyboard/Content"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Row0" type="HBoxContainer" parent="TextKeyboard/Content/LetterRows"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="Key00" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_00" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "й" +theme_type_variation = &"KeypadButton" + +[node name="Key01" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_01" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ц" +theme_type_variation = &"KeypadButton" + +[node name="Key02" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_02" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "у" +theme_type_variation = &"KeypadButton" + +[node name="Key03" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_03" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "к" +theme_type_variation = &"KeypadButton" + +[node name="Key04" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_04" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "е" +theme_type_variation = &"KeypadButton" + +[node name="Key05" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_05" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "н" +theme_type_variation = &"KeypadButton" + +[node name="Key06" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_06" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "г" +theme_type_variation = &"KeypadButton" + +[node name="Key07" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_07" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ш" +theme_type_variation = &"KeypadButton" + +[node name="Key08" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_08" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "щ" +theme_type_variation = &"KeypadButton" + +[node name="Key09" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_09" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "з" +theme_type_variation = &"KeypadButton" + +[node name="Key10" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_10" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "х" +theme_type_variation = &"KeypadButton" + +[node name="Key11" type="Button" parent="TextKeyboard/Content/LetterRows/Row0"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_11" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ъ" +theme_type_variation = &"KeypadButton" + +[node name="Row1" type="HBoxContainer" parent="TextKeyboard/Content/LetterRows"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="Key12" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_12" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ф" +theme_type_variation = &"KeypadButton" + +[node name="Key13" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_13" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ы" +theme_type_variation = &"KeypadButton" + +[node name="Key14" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_14" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "в" +theme_type_variation = &"KeypadButton" + +[node name="Key15" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_15" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "а" +theme_type_variation = &"KeypadButton" + +[node name="Key16" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_16" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "п" +theme_type_variation = &"KeypadButton" + +[node name="Key17" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_17" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "р" +theme_type_variation = &"KeypadButton" + +[node name="Key18" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_18" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "о" +theme_type_variation = &"KeypadButton" + +[node name="Key19" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_19" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "л" +theme_type_variation = &"KeypadButton" + +[node name="Key20" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_20" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "д" +theme_type_variation = &"KeypadButton" + +[node name="Key21" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_21" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ж" +theme_type_variation = &"KeypadButton" + +[node name="Key22" type="Button" parent="TextKeyboard/Content/LetterRows/Row1"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_22" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "э" +theme_type_variation = &"KeypadButton" + +[node name="Row2" type="HBoxContainer" parent="TextKeyboard/Content/LetterRows"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="Key23" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_23" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "я" +theme_type_variation = &"KeypadButton" + +[node name="Key24" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_24" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ч" +theme_type_variation = &"KeypadButton" + +[node name="Key25" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_25" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "с" +theme_type_variation = &"KeypadButton" + +[node name="Key26" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_26" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "м" +theme_type_variation = &"KeypadButton" + +[node name="Key27" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_27" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "и" +theme_type_variation = &"KeypadButton" + +[node name="Key28" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_28" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "т" +theme_type_variation = &"KeypadButton" + +[node name="Key29" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_29" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ь" +theme_type_variation = &"KeypadButton" + +[node name="Key30" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_30" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "б" +theme_type_variation = &"KeypadButton" + +[node name="Key31" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_31" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ю" +theme_type_variation = &"KeypadButton" + +[node name="Key32" type="Button" parent="TextKeyboard/Content/LetterRows/Row2"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_32" +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ё" +theme_type_variation = &"KeypadButton" + +[node name="PrimaryActions" type="HBoxContainer" parent="TextKeyboard/Content"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="ShiftButton" type="Button" parent="TextKeyboard/Content/PrimaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.primary_actions.shift_button" +custom_minimum_size = Vector2(128, 76) +layout_mode = 2 +focus_mode = 0 +text = "SHIFT" +theme_type_variation = &"KeyboardActionButton" + +[node name="LanguageButton" type="Button" parent="TextKeyboard/Content/PrimaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.primary_actions.language_button" +custom_minimum_size = Vector2(96, 76) +layout_mode = 2 +focus_mode = 0 +text = "RU" +theme_type_variation = &"KeyboardActionButton" + +[node name="SymbolsButton" type="Button" parent="TextKeyboard/Content/PrimaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.primary_actions.symbols_button" +custom_minimum_size = Vector2(116, 76) +layout_mode = 2 +focus_mode = 0 +text = "?123" +theme_type_variation = &"KeyboardActionButton" + +[node name="BackspaceButton" type="Button" parent="TextKeyboard/Content/PrimaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.primary_actions.backspace_button" +custom_minimum_size = Vector2(236, 76) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "СТЕРЕТЬ" +theme_type_variation = &"KeyboardActionButton" + +[node name="SecondaryActions" type="HBoxContainer" parent="TextKeyboard/Content"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="ClearButton" type="Button" parent="TextKeyboard/Content/SecondaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.secondary_actions.clear_button" +custom_minimum_size = Vector2(164, 76) +layout_mode = 2 +focus_mode = 0 +text = "ОЧИСТИТЬ" +theme_type_variation = &"KeyboardActionButton" + +[node name="CaretLeftButton" type="Button" parent="TextKeyboard/Content/SecondaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.secondary_actions.caret_left_button" +custom_minimum_size = Vector2(88, 76) +layout_mode = 2 +focus_mode = 0 +text = "<" +theme_type_variation = &"KeyboardActionButton" + +[node name="SpaceButton" type="Button" parent="TextKeyboard/Content/SecondaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.secondary_actions.space_button" +custom_minimum_size = Vector2(220, 76) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ПРОБЕЛ" +theme_type_variation = &"KeyboardActionButton" + +[node name="CaretRightButton" type="Button" parent="TextKeyboard/Content/SecondaryActions"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.text_keyboard.content.secondary_actions.caret_right_button" +custom_minimum_size = Vector2(88, 76) +layout_mode = 2 +focus_mode = 0 +text = ">" +theme_type_variation = &"KeyboardActionButton" + +[node name="NumericKeyboard" type="PanelContainer" parent="."] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard" +visible = false +layout_mode = 1 +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -280.0 +offset_top = -520.0 +offset_right = 280.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 0 +theme_type_variation = &"WhitePanel" + +[node name="Content" type="VBoxContainer" parent="NumericKeyboard"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Header" type="HBoxContainer" parent="NumericKeyboard/Content"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Title" type="Label" parent="NumericKeyboard/Content/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 20 +text = "ЦИФРОВАЯ КЛАВИАТУРА" +vertical_alignment = 1 + +[node name="HideButton" type="Button" parent="NumericKeyboard/Content/Header"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.header.hide_button" +custom_minimum_size = Vector2(64, 64) +layout_mode = 2 +focus_mode = 0 +tooltip_text = "Скрыть клавиатуру" +icon = ExtResource("1_hide_icon") +flat = true + +[node name="Grid" type="GridContainer" parent="NumericKeyboard/Content"] +layout_mode = 2 +theme_override_constants/h_separation = 10 +theme_override_constants/v_separation = 10 +columns = 3 + +[node name="Digit1" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit1" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "1" +theme_type_variation = &"KeypadButton" + +[node name="Digit2" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit2" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "2" +theme_type_variation = &"KeypadButton" + +[node name="Digit3" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit3" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "3" +theme_type_variation = &"KeypadButton" + +[node name="Digit4" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit4" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "4" +theme_type_variation = &"KeypadButton" + +[node name="Digit5" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit5" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "5" +theme_type_variation = &"KeypadButton" + +[node name="Digit6" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit6" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "6" +theme_type_variation = &"KeypadButton" + +[node name="Digit7" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit7" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "7" +theme_type_variation = &"KeypadButton" + +[node name="Digit8" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit8" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "8" +theme_type_variation = &"KeypadButton" + +[node name="Digit9" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit9" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "9" +theme_type_variation = &"KeypadButton" + +[node name="ClearButton" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.clear_button" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "ОЧИСТИТЬ" +theme_type_variation = &"KeypadButton" + +[node name="Digit0" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.digit0" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "0" +theme_type_variation = &"KeypadButton" + +[node name="BackspaceButton" type="Button" parent="NumericKeyboard/Content/Grid"] +metadata/exchanger_binding_id = "settings.keyboard_overlay.numeric_keyboard.content.grid.backspace_button" +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 0 +text = "СТЕРЕТЬ" +theme_type_variation = &"KeypadButton" diff --git a/themes/space-pixel/package/contracts/screen_bindings.v2.json b/themes/space-pixel/package/contracts/screen_bindings.v2.json new file mode 100644 index 0000000..657e795 --- /dev/null +++ b/themes/space-pixel/package/contracts/screen_bindings.v2.json @@ -0,0 +1,3929 @@ +{ + "Format": "ExchangerThemeBindingContract", + "Version": 2, + "ScreenMetadataKey": "exchanger_screen_binding_id", + "ElementMetadataKey": "exchanger_binding_id", + "Source": "Immutable Exchanger ThemeView contract: host binds existing theme-owned nodes only", + "Screens": [ + { + "BindingId": "screen.home", + "Key": "home", + "RootName": "ThemeScreen_Home", + "Path": "res://exchanger_theme_dlc/scenes/screen_home.tscn", + "Bindings": [ + { + "Id": "home.scroll.content.footer.margin.content.brand", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Footer/Margin/Content/Brand" + }, + { + "Id": "home.scroll.content.speech_text", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SpeechText" + }, + { + "Id": "home.scroll.content.price_panel.margin.content.price", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PricePanel/Margin/Content/Price" + }, + { + "Id": "home.scroll.content.payment_panel.margin.content.buttons.cash_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentPanel/Margin/Content/Buttons/CashButton" + }, + { + "Id": "home.scroll.content.payment_panel.margin.content.buttons.card_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentPanel/Margin/Content/Buttons/CardButton" + }, + { + "Id": "home.scroll.content.footer.margin.content.support", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Footer/Margin/Content/Support" + }, + { + "Id": "home.scroll.content.stock_status", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/StockStatus" + }, + { + "Id": "home.scroll.content.footer.margin.content.connection_status", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus" + }, + { + "Id": "home.scroll.content.stock_status.state", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/StockStatus/Margin/Content/State" + }, + { + "Id": "home.scroll.content.footer.margin.content.connection_status.label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus/Panel/Margin/Label" + }, + { + "Id": "home.scroll.content.advertisement_panel.layer.content", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content" + }, + { + "Id": "home.scroll.content.advertisement_panel.layer.content.poster", + "Types": [ + "TextureRect" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content/Poster" + }, + { + "Id": "home.scroll.content.advertisement_panel.layer.content.video_player", + "Types": [ + "VideoStreamPlayer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content/VideoPlayer" + }, + { + "Id": "home.scroll.content.advertisement_panel.layer.content.fallback_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content/FallbackLabel" + } + ] + }, + { + "BindingId": "screen.cash_payment", + "Key": "cash_payment", + "RootName": "ThemeScreen_CashPayment", + "Path": "res://exchanger_theme_dlc/scenes/screen_cash_payment.tscn", + "Bindings": [ + { + "Id": "cash_payment.content.top_bar.back_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/TopBar/BackButton" + }, + { + "Id": "cash_payment.content.summary.margin.values.balance", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Summary/Margin/Values/Balance" + }, + { + "Id": "cash_payment.content.summary.margin.values.tokens", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Summary/Margin/Values/Tokens" + }, + { + "Id": "cash_payment.content.summary.margin.values.bonus", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Summary/Margin/Values/Bonus" + }, + { + "Id": "cash_payment.content.dispense_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DispenseButton" + }, + { + "Id": "cash_payment.content.debug_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel" + }, + { + "Id": "cash_payment.content.debug_panel.add10_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/Add10Button" + }, + { + "Id": "cash_payment.content.debug_panel.add50_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/Add50Button" + }, + { + "Id": "cash_payment.content.debug_panel.add100_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/Add100Button" + } + ] + }, + { + "BindingId": "screen.card_amount", + "Key": "card_amount", + "RootName": "ThemeScreen_CardAmount", + "Path": "res://exchanger_theme_dlc/scenes/screen_card_amount.tscn", + "Bindings": [ + { + "Id": "card_amount.content.top_bar.back_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/TopBar/BackButton" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_13", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot13" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_12", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot12" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_11", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot11" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_10", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot10" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_09", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot09" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_08", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot08" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_07", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot07" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_06", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot06" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_05", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot05" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_04", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot04" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_03", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot03" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_02", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot02" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_01", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot01" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_00", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot00" + }, + { + "Id": "card_amount.content.confirm_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ConfirmButton" + }, + { + "Id": "card_amount.content.reward_panel.content.value", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/RewardPanel/Content/Value" + }, + { + "Id": "card_amount.content.selection_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/SelectionLabel" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.other_amount_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/OtherAmountButton" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid", + "Types": [ + "GridContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_14", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot14" + }, + { + "Id": "card_amount.content.amount_panel.margin.content.preset_grid.slot_15", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot15" + } + ] + }, + { + "BindingId": "screen.card_custom_amount", + "Key": "card_custom_amount", + "RootName": "ThemeScreen_CardCustomAmount", + "Path": "res://exchanger_theme_dlc/scenes/screen_card_custom_amount.tscn", + "Bindings": [ + { + "Id": "card_custom_amount.content.top_bar.back_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/TopBar/BackButton" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit7", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit7" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit6", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit6" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit5", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit5" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit4", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit4" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit3", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit3" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit2", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit2" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit1", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit1" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit0", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit0" + }, + { + "Id": "card_custom_amount.content.confirm_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ConfirmButton" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.backspace_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/BackspaceButton" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.clear_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/ClearButton" + }, + { + "Id": "card_custom_amount.content.hint", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Hint" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.input_value", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/InputValue" + }, + { + "Id": "card_custom_amount.content.amount_panel.margin.values.tokens", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Values/Tokens" + }, + { + "Id": "card_custom_amount.content.amount_panel.margin.values.amount", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AmountPanel/Margin/Values/Amount" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit8", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit8" + }, + { + "Id": "card_custom_amount.content.keypad_panel.content.keypad.digit9", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit9" + } + ] + }, + { + "BindingId": "screen.card_terminal", + "Key": "card_terminal", + "RootName": "ThemeScreen_CardTerminal", + "Path": "res://exchanger_theme_dlc/scenes/screen_card_terminal.tscn", + "Bindings": [ + { + "Id": "card_terminal.content.top_bar.back_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/TopBar/BackButton" + }, + { + "Id": "card_terminal.content.cancel_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/CancelButton" + }, + { + "Id": "card_terminal.content.amount", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Amount" + }, + { + "Id": "card_terminal.content.tokens", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Tokens" + }, + { + "Id": "card_terminal.content.countdown", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Countdown" + }, + { + "Id": "card_terminal.content.debug_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel" + }, + { + "Id": "card_terminal.content.debug_panel.approve_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/ApproveButton" + }, + { + "Id": "card_terminal.content.debug_panel.decline_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/DeclineButton" + }, + { + "Id": "card_terminal.content.debug_panel.dispense_failure_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/DispenseFailureButton" + }, + { + "Id": "card_terminal.content.debug_panel.partial_dispense_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/PartialDispenseButton" + }, + { + "Id": "card_terminal.content.debug_panel.connection_loss_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/DebugPanel/ConnectionLossButton" + } + ] + }, + { + "BindingId": "screen.success", + "Key": "success", + "RootName": "ThemeScreen_Success", + "Path": "res://exchanger_theme_dlc/scenes/screen_success.tscn", + "Bindings": [ + { + "Id": "success.content.result_panel.margin.values.title", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ResultPanel/Margin/Values/Title" + }, + { + "Id": "success.content.result_panel.margin.values.body.message", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ResultPanel/Margin/Values/Body/Message" + }, + { + "Id": "success.content.countdown", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Countdown" + }, + { + "Id": "success.content.home_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/HomeButton" + } + ] + }, + { + "BindingId": "screen.error", + "Key": "error", + "RootName": "ThemeScreen_Error", + "Path": "res://exchanger_theme_dlc/scenes/screen_error.tscn", + "Bindings": [ + { + "Id": "error.content.error_panel.margin.values.title", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ErrorPanel/Margin/Values/Title" + }, + { + "Id": "error.content.error_panel.margin.values.body.message", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/ErrorPanel/Margin/Values/Body/Message" + }, + { + "Id": "error.content.countdown", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Countdown" + }, + { + "Id": "error.content.home_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/HomeButton" + } + ] + }, + { + "BindingId": "screen.service_access", + "Key": "service_access", + "RootName": "ThemeScreen_ServiceAccess", + "Path": "res://exchanger_theme_dlc/scenes/screen_service_access.tscn", + "Bindings": [ + { + "Id": "service_access.content.header", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Header" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit7", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit7" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit6", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit6" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit5", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit5" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit4", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit4" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit3", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit3" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit2", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit2" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit1", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit1" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit8", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit8" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit0", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit0" + }, + { + "Id": "service_access.content.access_panel.fields.enter_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/EnterButton" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.erase", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Erase" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.clear", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Clear" + }, + { + "Id": "service_access.content.access_panel.fields.status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Status" + }, + { + "Id": "service_access.content.access_panel.fields.pin_display", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/PinDisplay" + }, + { + "Id": "service_access.content.access_panel.fields.pin_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/PinLabel" + }, + { + "Id": "service_access.content.description", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/Description" + }, + { + "Id": "service_access.content.cancel_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/CancelButton" + }, + { + "Id": "service_access.content.access_panel.fields.keypad.digit9", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit9" + } + ] + }, + { + "BindingId": "screen.settings", + "Key": "settings", + "RootName": "ThemeScreen_Settings", + "Path": "res://exchanger_theme_dlc/scenes/screen_settings.tscn", + "Bindings": [ + { + "Id": "settings.scroll.content.section_tabs.general_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/GeneralButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_13", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_12", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_11", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row/Threshold" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.empty_state", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/EmptyState" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row/Bonus" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Row/Amount" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_10", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_04", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_02", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_03", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_09", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_08", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row/Bonus" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_06", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_01", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Error" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Row/Path" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.empty_state", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/EmptyState" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Error" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Row/Amount" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Row/Amount" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row/RemoveButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.bonus", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row/Bonus" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.preset_rows", + "Types": [ + "VBoxContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.limits_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/LimitsError" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.limits.custom_step", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits/CustomStep" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.limits.max_amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits/MaxAmount" + }, + { + "Id": "settings.scroll.content.payment_methods_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.payment_methods_panel.margin.content.card_enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content/CardEnabled" + }, + { + "Id": "settings.scroll.content.payment_methods_panel.margin.content.cash_enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content/CashEnabled" + }, + { + "Id": "settings.scroll.content.preview_panel.margin.content.values", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PreviewPanel/Margin/Content/Values" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.add_rule_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/AddRuleButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows", + "Types": [ + "VBoxContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows" + }, + { + "Id": "settings.scroll.content.price_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PricePanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.price_panel.margin.content.price", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PricePanel/Margin/Content/Price" + }, + { + "Id": "settings.scroll.content.test_panel.margin.content.status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TestPanel/Margin/Content/Status" + }, + { + "Id": "settings.scroll.content.test_panel.margin.content.test_sound_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TestPanel/Margin/Content/TestSoundButton" + }, + { + "Id": "settings.scroll.content.advertisement_panel.margin.content.volume_row.value", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content/VolumeRow/Value" + }, + { + "Id": "settings.scroll.content.advertisement_panel.margin.content.volume_row.slider", + "Types": [ + "HSlider" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content/VolumeRow/Slider" + }, + { + "Id": "settings.scroll.content.master_panel.margin.content.mute", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/MasterPanel/Margin/Content/Mute" + }, + { + "Id": "settings.scroll.content.master_panel.margin.content.volume_row.value", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/MasterPanel/Margin/Content/VolumeRow/Value" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.add_preset_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/AddPresetButton" + }, + { + "Id": "settings.scroll.content.card_options_panel.margin.content.presets_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetsError" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.grid.idle", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid/Idle" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.grid.cash_payment", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid/CashPayment" + }, + { + "Id": "settings.scroll.content.advertisement_poster_panel.margin.content.poster_path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content/PosterPath" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel.margin.content.fallback_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content/FallbackError" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel.margin.content.fallback_text", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content/FallbackText" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel.margin.content.muted", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content/Muted" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel.margin.content.enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content/Enabled" + }, + { + "Id": "settings.scroll.content.security_panel.margin.content.status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SecurityPanel/Margin/Content/Status" + }, + { + "Id": "settings.scroll.content.security_panel.margin.content.reset_pin_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SecurityPanel/Margin/Content/ResetPinButton" + }, + { + "Id": "settings.scroll.content.security_panel.margin.content.enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SecurityPanel/Margin/Content/Enabled" + }, + { + "Id": "settings.scroll.content.branding_preview_panel.margin.content.support_phone", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content/SupportPhone" + }, + { + "Id": "settings.scroll.content.master_panel.margin.content.volume_row.slider", + "Types": [ + "HSlider" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/MasterPanel/Margin/Content/VolumeRow/Slider" + }, + { + "Id": "settings.scroll.content.branding_preview_panel.margin.content.short_text", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content/ShortText" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.support_phone_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/SupportPhoneError" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.short_text_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/ShortTextError" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.application_name_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/ApplicationNameError" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.support_phone", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/SupportPhone" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.short_text", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/ShortText" + }, + { + "Id": "settings.scroll.content.branding_panel.margin.content.application_name", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content/ApplicationName" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.grid.success", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid/Success" + }, + { + "Id": "settings.scroll.content.timeouts_panel.margin.content.grid.cashless_payment", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid/CashlessPayment" + }, + { + "Id": "settings.scroll.content.branding_preview_panel.margin.content.application_name", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content/ApplicationName" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.buttons.confirm_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Buttons/ConfirmButton" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.buttons.cancel_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Buttons/CancelButton" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.amount", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Amount" + }, + { + "Id": "settings.scroll.content.appearance_effects_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceEffectsPanel" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel" + }, + { + "Id": "settings.scroll.content.advertisement_preview_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPreviewPanel" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel" + }, + { + "Id": "settings.scroll.content.advertisement_poster_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPosterPanel" + }, + { + "Id": "settings.scroll.content.advertisement_settings_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementSettingsPanel" + }, + { + "Id": "settings.scroll.content.test_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TestPanel" + }, + { + "Id": "settings.scroll.content.advertisement_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPanel" + }, + { + "Id": "settings.scroll.content.master_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/MasterPanel" + }, + { + "Id": "settings.scroll.content.appearance_notice_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceNoticePanel" + }, + { + "Id": "settings.scroll.content.security_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SecurityPanel" + }, + { + "Id": "settings.scroll.content.branding_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPanel" + }, + { + "Id": "settings.scroll.content.section_tabs.diagnostics_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/DiagnosticsButton" + }, + { + "Id": "settings.scroll.content.section_tabs.equipment_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/EquipmentButton" + }, + { + "Id": "settings.scroll.content.section_tabs.service_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/ServiceButton" + }, + { + "Id": "settings.scroll.content.section_tabs.payment_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/PaymentButton" + }, + { + "Id": "settings.scroll.content.section_tabs.pricing_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/PricingButton" + }, + { + "Id": "settings.scroll.content.section_tabs.appearance_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/AppearanceButton" + }, + { + "Id": "settings.scroll.content.section_tabs.advertisement_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/AdvertisementButton" + }, + { + "Id": "settings.scroll.content.section_tabs.audio_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs/AudioButton" + }, + { + "Id": "settings.scroll.content.branding_preview_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BrandingPreviewPanel" + }, + { + "Id": "settings.scroll.content.advertisement_poster_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content/Error" + }, + { + "Id": "settings.scroll.content.price_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PricePanel" + }, + { + "Id": "settings.scroll.content.preview_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PreviewPanel" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.description", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Description" + }, + { + "Id": "settings.service_dialog_overlay.dialog.margin.content.title", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Title" + }, + { + "Id": "settings.service_dialog_overlay", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/ServiceDialogOverlay" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Status" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper2.manual_add_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2/ManualAddButton" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper2.recount_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2/RecountButton" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper2_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2Label" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper1.manual_add_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1/ManualAddButton" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper1.recount_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1/RecountButton" + }, + { + "Id": "settings.scroll.content.bonus_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.hopper1_label", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1Label" + }, + { + "Id": "settings.scroll.content.service_inventory_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel" + }, + { + "Id": "settings.scroll.content.diagnostics_test_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsTestPanel" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel" + }, + { + "Id": "settings.scroll.content.diagnostics_status_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsStatusPanel" + }, + { + "Id": "settings.scroll.content.equipment_window_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel" + }, + { + "Id": "settings.scroll.content.timeouts_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/TimeoutsPanel" + }, + { + "Id": "settings.scroll.content.card_options_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/CardOptionsPanel" + }, + { + "Id": "settings.scroll.content.payment_methods_panel", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/PaymentMethodsPanel" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.count", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Count" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows", + "Types": [ + "VBoxContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.add_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/AddButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Error" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.header.hide_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/Header/HideButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_32", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_32" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_31", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_31" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_30", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_30" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_29", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_29" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_28", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_28" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_27", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_27" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_26", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_26" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_25", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_25" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.primary_actions.language_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/LanguageButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_24", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_24" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_22", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_22" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_21", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_21" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_20", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_20" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_19", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_19" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_18", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_18" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_17", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_17" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_16", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_16" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_15", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_15" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_14", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_14" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row2.key_23", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row2/Key_23" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_13", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_13" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.primary_actions.shift_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/ShiftButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.primary_actions.backspace_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/BackspaceButton" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.threshold", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row/Threshold" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.slot_00", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00" + }, + { + "Id": "settings.scroll.content.bonus_panel.margin.content.rows.empty_state", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/EmptyState" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.backspace_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/BackspaceButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.clear_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/ClearButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.header.hide_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Header/HideButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit9", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit9" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit8", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit8" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit7", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit7" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.primary_actions.symbols_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/SymbolsButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit6", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit6" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit4", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit4" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit3", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit3" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit2", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit2" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit1", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit1" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit0", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit0" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.secondary_actions.space_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/SpaceButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.secondary_actions.clear_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/ClearButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.secondary_actions.caret_right_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/CaretRightButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.secondary_actions.caret_left_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/CaretLeftButton" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard.content.grid.digit5", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit5" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.remove_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Row/RemoveButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row1.key_12", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row1/Key_12" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_10", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_10" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel.margin.content.minimum_level", + "Types": [ + "OptionButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content/MinimumLevel" + }, + { + "Id": "settings.scroll.content.diagnostics_status_panel.margin.content.recent_errors", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content/RecentErrors" + }, + { + "Id": "settings.scroll.content.diagnostics_status_panel.margin.content.runtime_summary", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content/RuntimeSummary" + }, + { + "Id": "settings.scroll.content.equipment_window_panel.margin.content.target_fps", + "Types": [ + "OptionButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content/TargetFps" + }, + { + "Id": "settings.scroll.content.equipment_window_panel.margin.content.debug_window", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content/DebugWindow" + }, + { + "Id": "settings.scroll.content.equipment_window_panel.margin.content.borderless", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content/Borderless" + }, + { + "Id": "settings.scroll.content.equipment_window_panel.margin.content.fullscreen", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content/Fullscreen" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.connection_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/ConnectionError" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.reconnect_seconds", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/ReconnectSeconds" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel.margin.content.max_file_size", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content/MaxFileSize" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.baud_rate", + "Types": [ + "OptionButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/BaudRate" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.port_name", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/PortName" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.use_mock", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/UseMock" + }, + { + "Id": "settings.scroll.content.appearance_notice_panel.margin.content.current_theme", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin/Content/CurrentTheme" + }, + { + "Id": "settings.scroll.content.appearance_effects_panel.margin.content.reduced_effects", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceEffectsPanel/Margin/Content/ReducedEffects" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel.margin.content.package_status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content/PackageStatus" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel.margin.content.path_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content/PathError" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel.margin.content.external_path", + "Types": [ + "LineEdit" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content/ExternalPath" + }, + { + "Id": "settings.scroll.content.appearance_theme_panel.margin.content.external_enabled", + "Types": [ + "CheckButton" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content/ExternalEnabled" + }, + { + "Id": "settings.scroll.content.advertisement_preview_panel.margin.content.summary", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPreviewPanel/Margin/Content/Summary" + }, + { + "Id": "settings.scroll.content.equipment_connection_panel.margin.content.port_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content/PortError" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_11", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_11" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel.margin.content.retention_days", + "Types": [ + "SpinBox" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content/RetentionDays" + }, + { + "Id": "settings.scroll.content.diagnostics_test_panel.margin.content.test_dispense_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content/TestDispenseButton" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_09", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_09" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_08", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_08" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_07", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_07" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_06", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_06" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_05", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_05" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_04", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_04" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_03", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_03" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_02", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_02" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_01", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_01" + }, + { + "Id": "settings.scroll.content.diagnostics_logging_panel.margin.content.logging_error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content/LoggingError" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard.content.letter_rows.row0.key_00", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row0/Key_00" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.recount_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/RecountButton" + }, + { + "Id": "settings.scroll.content", + "Types": [ + "Node" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content" + }, + { + "Id": "settings.scroll.content.section_tabs", + "Types": [ + "Node" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/SectionTabs" + }, + { + "Id": "settings.scroll.content.header.margin.content.title", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Header/Margin/Content/Title" + }, + { + "Id": "settings.scroll.content.header", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/Header" + }, + { + "Id": "settings.keyboard_overlay.numeric_keyboard", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/NumericKeyboard" + }, + { + "Id": "settings.keyboard_overlay.text_keyboard", + "Types": [ + "Control" + ], + "Required": true, + "FallbackPath": "SafeMargin/KeyboardOverlay/TextKeyboard" + }, + { + "Id": "settings.scroll", + "Types": [ + "ScrollContainer" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll" + }, + { + "Id": "settings.scroll.content.diagnostics_test_panel.margin.content.test_dispense_status", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content/TestDispenseStatus" + }, + { + "Id": "settings.scroll.content.service_inventory_panel.margin.content.manual_add_button", + "Types": [ + "Button" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/ManualAddButton" + }, + { + "Id": "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.error", + "Types": [ + "Label" + ], + "Required": true, + "FallbackPath": "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Error" + } + ] + } + ] +} diff --git a/themes/space-pixel/package/manifest.json b/themes/space-pixel/package/manifest.json new file mode 100644 index 0000000..45d083f --- /dev/null +++ b/themes/space-pixel/package/manifest.json @@ -0,0 +1,33 @@ +{ + "Format": "ExchangerThemeDlc", + "FormatVersion": 2, + "ThemeId": "space-pixel", + "DisplayName": "Космическая pixel-тема", + "UiThemePath": "res://exchanger_theme_dlc/ui_theme.tres", + "BackgroundTexturePath": "", + "BackgroundDecorationPath": "", + "BackgroundDecorationScale": 0.666667, + "InteractivePetScene": "res://exchanger_theme_dlc/components/interactive_pet.tscn", + "ScreenScenes": { + "screen.home": "res://exchanger_theme_dlc/scenes/screen_home.tscn", + "screen.cash_payment": "res://exchanger_theme_dlc/scenes/screen_cash_payment.tscn", + "screen.card_amount": "res://exchanger_theme_dlc/scenes/screen_card_amount.tscn", + "screen.card_custom_amount": "res://exchanger_theme_dlc/scenes/screen_card_custom_amount.tscn", + "screen.card_terminal": "res://exchanger_theme_dlc/scenes/screen_card_terminal.tscn", + "screen.success": "res://exchanger_theme_dlc/scenes/screen_success.tscn", + "screen.error": "res://exchanger_theme_dlc/scenes/screen_error.tscn", + "screen.service_access": "res://exchanger_theme_dlc/scenes/screen_service_access.tscn", + "screen.settings": "res://exchanger_theme_dlc/scenes/screen_settings.tscn" + }, + "VisualSlots": { + "home.top": "res://exchanger_theme_dlc/visuals/home_top.tscn", + "home.advertisement": "res://exchanger_theme_dlc/visuals/home_advertisement.tscn", + "cash.instructions": "res://exchanger_theme_dlc/visuals/cash_instructions.tscn", + "cash.tip": "res://exchanger_theme_dlc/visuals/cash_tip.tscn", + "card.terminal": "res://exchanger_theme_dlc/visuals/card_terminal.tscn", + "result.success": "res://exchanger_theme_dlc/visuals/result_success.tscn", + "result.error": "res://exchanger_theme_dlc/visuals/result_error.tscn" + }, + "FallbackColor": "#00183D", + "UseNearestTextureFilter": true +} diff --git a/themes/space-pixel/package/scenes/screen_card_amount.tscn b/themes/space-pixel/package/scenes/screen_card_amount.tscn new file mode 100644 index 0000000..54e9b9d --- /dev/null +++ b/themes/space-pixel/package/scenes/screen_card_amount.tscn @@ -0,0 +1,873 @@ +[gd_scene load_steps=29 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_screen_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="2_screen_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="3_screen_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="4_footer"] +[ext_resource type="Texture2D" path="../assets/ui/button_long.png" id="5_button_long"] +[ext_resource type="Texture2D" path="../assets/animations/banner/frame_01.png" id="6_banner_1"] +[ext_resource type="Texture2D" path="../assets/animations/banner/frame_02.png" id="7_banner_2"] +[ext_resource type="Texture2D" path="../assets/ui/panel_medium.png" id="8_panel_medium"] +[ext_resource type="Texture2D" path="../assets/ui/button_big.png" id="9_button_big"] +[ext_resource type="Script" path="../scripts/stable_banner_animation.gd" id="10_banner_script"] +[ext_resource type="Texture2D" path="../assets/ui/interactive_pet/home.png" id="11_home_icon"] + +[sub_resource type="SpriteFrames" id="SpriteFrames_card_amount_banner"] +animations = [{"frames": [{"duration": 1.0, "texture": ExtResource("6_banner_1")}, {"duration": 1.0, "texture": ExtResource("7_banner_2")}], "loop": true, "name": &"default", "speed": 1.0}] + +[sub_resource type="StyleBoxTexture" id="StyleBox_preset_normal"] +content_margin_left = 16.0 +content_margin_top = 4.0 +content_margin_right = 16.0 +content_margin_bottom = 8.0 +texture = ExtResource("5_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) + +[sub_resource type="StyleBoxTexture" id="StyleBox_preset_hover"] +content_margin_left = 16.0 +content_margin_top = 4.0 +content_margin_right = 16.0 +content_margin_bottom = 8.0 +texture = ExtResource("5_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) +modulate_color = Color(1.08, 1.08, 1.08, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_preset_pressed"] +content_margin_left = 16.0 +content_margin_top = 6.0 +content_margin_right = 16.0 +content_margin_bottom = 6.0 +texture = ExtResource("5_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) +modulate_color = Color(0.78, 0.84, 0.8, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_preset_disabled"] +content_margin_left = 16.0 +content_margin_top = 4.0 +content_margin_right = 16.0 +content_margin_bottom = 8.0 +texture = ExtResource("5_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) +modulate_color = Color(0.42, 0.48, 0.54, 0.72) + +[sub_resource type="AtlasTexture" id="AtlasTexture_panel_header"] +atlas = ExtResource("8_panel_medium") +region = Rect2(4, 34, 504, 68) + +[sub_resource type="AtlasTexture" id="AtlasTexture_panel_body"] +atlas = ExtResource("8_panel_medium") +region = Rect2(5, 102, 502, 98) + +[sub_resource type="AtlasTexture" id="AtlasTexture_panel_bottom"] +atlas = ExtResource("8_panel_medium") +region = Rect2(5, 200, 502, 20) + +[sub_resource type="AtlasTexture" id="AtlasTexture_reward_panel"] +atlas = ExtResource("8_panel_medium") +region = Rect2(4, 34, 504, 186) + +[sub_resource type="StyleBoxTexture" id="StyleBox_confirm_normal"] +content_margin_left = 40.0 +content_margin_top = 11.0 +content_margin_right = 30.0 +content_margin_bottom = 18.0 +texture = ExtResource("9_button_big") +texture_margin_left = 28.0 +texture_margin_top = 24.0 +texture_margin_right = 28.0 +texture_margin_bottom = 24.0 + +[sub_resource type="StyleBoxTexture" id="StyleBox_confirm_hover"] +content_margin_left = 40.0 +content_margin_top = 11.0 +content_margin_right = 30.0 +content_margin_bottom = 18.0 +texture = ExtResource("9_button_big") +texture_margin_left = 28.0 +texture_margin_top = 24.0 +texture_margin_right = 28.0 +texture_margin_bottom = 24.0 +modulate_color = Color(1.12, 1.12, 1.12, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_confirm_pressed"] +content_margin_left = 40.0 +content_margin_top = 13.0 +content_margin_right = 30.0 +content_margin_bottom = 16.0 +texture = ExtResource("9_button_big") +texture_margin_left = 28.0 +texture_margin_top = 24.0 +texture_margin_right = 28.0 +texture_margin_bottom = 24.0 +modulate_color = Color(0.72, 0.8, 0.76, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_confirm_disabled"] +content_margin_left = 40.0 +content_margin_top = 11.0 +content_margin_right = 30.0 +content_margin_bottom = 18.0 +texture = ExtResource("9_button_big") +texture_margin_left = 28.0 +texture_margin_top = 24.0 +texture_margin_right = 28.0 +texture_margin_bottom = 24.0 +modulate_color = Color(0.42, 0.48, 0.54, 0.72) + +[sub_resource type="StyleBoxTexture" id="StyleBox_other_normal"] +content_margin_left = 17.0 +content_margin_top = 6.0 +content_margin_right = 16.0 +content_margin_bottom = 6.0 +texture = ExtResource("5_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) + +[sub_resource type="StyleBoxTexture" id="StyleBox_other_hover"] +content_margin_left = 17.0 +content_margin_top = 6.0 +content_margin_right = 16.0 +content_margin_bottom = 6.0 +texture = ExtResource("5_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) +modulate_color = Color(1.08, 1.08, 1.08, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_other_pressed"] +content_margin_left = 17.0 +content_margin_top = 8.0 +content_margin_right = 16.0 +content_margin_bottom = 4.0 +texture = ExtResource("5_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) +modulate_color = Color(0.78, 0.84, 0.8, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_other_disabled"] +content_margin_left = 17.0 +content_margin_top = 6.0 +content_margin_right = 16.0 +content_margin_bottom = 6.0 +texture = ExtResource("5_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) +modulate_color = Color(0.42, 0.48, 0.54, 0.72) + +[node name="ThemeScreen_CardAmount" type="Control"] +texture_filter = 1 +metadata/exchanger_screen_binding_id = "screen.card_amount" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_screen_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("2_screen_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("3_screen_decoration")] +scale = Vector2(0.666667, 0.666667) + +[node name="PetNavigation" type="Node2D" parent="."] +metadata/exchanger_pet_navigation_root = true +metadata/exchanger_pet_navigation_version = 1 +metadata/exchanger_pet_default_surface_id = &"floor" +metadata/exchanger_pet_roaming_bounds = Rect2(32, 32, 656, 1208) + +[node name="Floor" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"floor" +metadata/exchanger_pet_surface_from_x = 40.0 +metadata/exchanger_pet_surface_to_x = 680.0 +metadata/exchanger_pet_surface_y = 1240.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(96, 360, 624) + +[node name="BackButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"back_button" +metadata/exchanger_pet_surface_from_x = 49.0 +metadata/exchanger_pet_surface_to_x = 161.0 +metadata/exchanger_pet_surface_y = 43.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(72.5, 135) + +[node name="RewardWindow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"reward_window" +metadata/exchanger_pet_surface_from_x = 38.0 +metadata/exchanger_pet_surface_to_x = 684.0 +metadata/exchanger_pet_surface_y = 160.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="AmountWindow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"amount_window" +metadata/exchanger_pet_surface_from_x = 38.0 +metadata/exchanger_pet_surface_to_x = 684.0 +metadata/exchanger_pet_surface_y = 350.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="PresetRow1" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"preset_row_1" +metadata/exchanger_pet_surface_from_x = 76.0 +metadata/exchanger_pet_surface_to_x = 605.0 +metadata/exchanger_pet_surface_y = 451.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(192, 488) + +[node name="PresetRow2" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"preset_row_2" +metadata/exchanger_pet_surface_from_x = 76.0 +metadata/exchanger_pet_surface_to_x = 605.0 +metadata/exchanger_pet_surface_y = 511.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(192, 488) + +[node name="PresetRow3" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"preset_row_3" +metadata/exchanger_pet_surface_from_x = 76.0 +metadata/exchanger_pet_surface_to_x = 605.0 +metadata/exchanger_pet_surface_y = 571.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(192, 488) + +[node name="PresetRow4" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"preset_row_4" +metadata/exchanger_pet_surface_from_x = 76.0 +metadata/exchanger_pet_surface_to_x = 605.0 +metadata/exchanger_pet_surface_y = 631.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(192, 488) + +[node name="OtherAmountButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"other_amount_button" +metadata/exchanger_pet_surface_from_x = 224.0 +metadata/exchanger_pet_surface_to_x = 451.0 +metadata/exchanger_pet_surface_y = 704.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(337.5) + +[node name="ConfirmButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"confirm_button" +metadata/exchanger_pet_surface_from_x = 34.0 +metadata/exchanger_pet_surface_to_x = 571.5 +metadata/exchanger_pet_surface_y = 873.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(303) + +[node name="FloorToBack" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_back" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"back_button" +metadata/exchanger_pet_climb_x = 49.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 43.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToReward" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_reward" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"reward_window" +metadata/exchanger_pet_climb_x = 40.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 160.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToAmount" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_amount" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"amount_window" +metadata/exchanger_pet_climb_x = 40.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 350.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToPreset1" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_preset_1" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"preset_row_1" +metadata/exchanger_pet_climb_x = 76.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 451.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToPreset2" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_preset_2" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"preset_row_2" +metadata/exchanger_pet_climb_x = 76.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 511.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToPreset3" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_preset_3" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"preset_row_3" +metadata/exchanger_pet_climb_x = 76.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 571.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToPreset4" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_preset_4" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"preset_row_4" +metadata/exchanger_pet_climb_x = 76.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 631.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToOther" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_other" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"other_amount_button" +metadata/exchanger_pet_climb_x = 224.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 704.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToConfirm" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_confirm" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"confirm_button" +metadata/exchanger_pet_climb_x = 40.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 873.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="TopBar" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 122.0 + +[node name="BackButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "card_amount.content.top_bar.back_button" +layout_mode = 0 +offset_left = 49.0 +offset_top = 43.0 +offset_right = 96.0 +offset_bottom = 90.0 +theme_type_variation = &"BackButton" + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "card_amount.content.top_bar.home_navigation_button" +layout_mode = 0 +offset_left = 109.0 +offset_top = 42.0 +offset_right = 161.0 +offset_bottom = 94.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" +icon = ExtResource("11_home_icon") +expand_icon = true +icon_max_width = 30 + +[node name="BackText" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 37.0 +offset_top = 94.0 +offset_right = 108.0 +offset_bottom = 118.0 +mouse_filter = 2 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 14 +text = "НАЗАД" +horizontal_alignment = 1 +pivot_offset = Vector2(35.5, 12) +scale = Vector2(1.45, 1) + +[node name="Title" type="Label" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 200.0 +offset_top = 44.0 +offset_right = 728.0 +offset_bottom = 108.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА КАРТОЙ" +horizontal_alignment = 1 +pivot_offset = Vector2(264, 32) +scale = Vector2(1.35, 1) + +[node name="RewardPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 36.0 +offset_top = 148.0 +offset_right = 685.0 +offset_bottom = 273.0 +theme_type_variation = &"ReferencePanel" + +[node name="Content" type="Control" parent="SafeMargin/Content/RewardPanel"] +layout_mode = 2 + +[node name="PanelTexture" type="TextureRect" parent="SafeMargin/Content/RewardPanel/Content"] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -22.0 +offset_top = -10.0 +offset_right = 22.0 +offset_bottom = 18.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = SubResource("AtlasTexture_reward_panel") +expand_mode = 1 +stretch_mode = 0 + +[node name="Header" type="Label" parent="SafeMargin/Content/RewardPanel/Content"] +layout_mode = 0 +offset_left = 2.0 +offset_top = -9.0 +offset_right = 370.0 +offset_bottom = 34.0 +theme_type_variation = &"PanelTitle" +text = "К ВЫДАЧЕ" +vertical_alignment = 1 +pivot_offset = Vector2(0, 21) +scale = Vector2(1.21, 1) + +[node name="Value" type="Label" parent="SafeMargin/Content/RewardPanel/Content"] +metadata/exchanger_binding_id = "card_amount.content.reward_panel.content.value" +layout_mode = 0 +offset_left = 2.0 +offset_top = 37.0 +offset_right = 300.0 +offset_bottom = 108.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 27 +text = "ВЫБЕРИТЕ СУММУ" +vertical_alignment = 1 +pivot_offset = Vector2(0, 33.5) +scale = Vector2(1.25, 1) + +[node name="Bonus" type="Label" parent="SafeMargin/Content/RewardPanel/Content"] +metadata/exchanger_binding_id = "card_amount.content.reward_panel.content.bonus" +layout_mode = 0 +offset_left = 295.0 +offset_top = 35.0 +offset_right = 595.0 +offset_bottom = 110.0 +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(0, 0.0627451, 0.552941, 1) +theme_override_font_sizes/font_size = 25 +text = "БЕЗ БОНУСА" +horizontal_alignment = 1 +vertical_alignment = 1 +pivot_offset = Vector2(150, 35.5) +scale = Vector2(1.35, 1) + +[node name="AmountPanel" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 38.0 +offset_top = 350.0 +offset_right = 684.0 +offset_bottom = 814.0 + +[node name="Chrome" type="PanelContainer" parent="SafeMargin/Content/AmountPanel"] +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_type_variation = &"ReferencePanel" + +[node name="BackgroundHeader" type="TextureRect" parent="SafeMargin/Content/AmountPanel"] +texture_filter = 1 +layout_mode = 0 +offset_left = -3.0 +offset_right = 645.0 +offset_bottom = 46.0 +mouse_filter = 2 +texture = SubResource("AtlasTexture_panel_header") +expand_mode = 1 +stretch_mode = 0 + +[node name="BackgroundBody" type="TextureRect" parent="SafeMargin/Content/AmountPanel"] +texture_filter = 1 +layout_mode = 0 +offset_left = -4.0 +offset_top = 46.0 +offset_right = 648.0 +offset_bottom = 456.0 +mouse_filter = 2 +texture = SubResource("AtlasTexture_panel_body") +expand_mode = 1 +stretch_mode = 0 + +[node name="BackgroundBottom" type="TextureRect" parent="SafeMargin/Content/AmountPanel"] +texture_filter = 1 +layout_mode = 0 +offset_left = -4.0 +offset_top = 456.0 +offset_right = 648.0 +offset_bottom = 464.0 +mouse_filter = 2 +texture = SubResource("AtlasTexture_panel_bottom") +expand_mode = 1 +stretch_mode = 0 + +[node name="BottomBorder" type="ColorRect" parent="SafeMargin/Content/AmountPanel"] +visible = false +layout_mode = 0 +offset_left = 3.0 +offset_top = 462.0 +offset_right = 640.0 +offset_bottom = 464.0 +mouse_filter = 2 +color = Color(0.52549, 0.823529, 0.478431, 1) + +[node name="WhiteBody" type="ColorRect" parent="SafeMargin/Content/AmountPanel"] +visible = false +layout_mode = 0 +offset_left = 4.0 +offset_top = 44.0 +offset_right = 642.0 +offset_bottom = 463.0 +mouse_filter = 2 +color = Color(1, 1, 1, 1) + +[node name="Divider" type="ColorRect" parent="SafeMargin/Content/AmountPanel"] +visible = false +layout_mode = 0 +offset_left = 4.0 +offset_top = 44.0 +offset_right = 642.0 +offset_bottom = 47.0 +mouse_filter = 2 +color = Color(0.82, 0.77, 0.89, 1) + +[node name="Header" type="Label" parent="SafeMargin/Content/AmountPanel"] +layout_mode = 0 +offset_left = 22.0 +offset_top = 0.0 +offset_right = 620.0 +offset_bottom = 44.0 +theme_type_variation = &"PanelTitle" +theme_override_font_sizes/font_size = 30 +text = "ОПЛАТА" +vertical_alignment = 1 +pivot_offset = Vector2(0, 22) +scale = Vector2(1.125, 1) + +[node name="Margin" type="Control" parent="SafeMargin/Content/AmountPanel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin/Content/AmountPanel/Margin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="PresetGrid" type="GridContainer" parent="SafeMargin/Content/AmountPanel/Margin/Content"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid" +layout_mode = 0 +offset_left = 38.0 +offset_top = 101.0 +offset_right = 604.0 +offset_bottom = 325.0 +pivot_offset = Vector2(0, 112.5) +scale = Vector2(1.16, 1) +theme_override_constants/h_separation = 22 +theme_override_constants/v_separation = 15 +columns = 2 + +[node name="PresetSlot00" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_00" +custom_minimum_size = Vector2(233, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +theme_override_styles/disabled = SubResource("StyleBox_preset_disabled") +theme_override_styles/hover = SubResource("StyleBox_preset_hover") +theme_override_styles/normal = SubResource("StyleBox_preset_normal") +theme_override_styles/pressed = SubResource("StyleBox_preset_pressed") +text = "10 РУБ." +theme_override_font_sizes/font_size = 28 + +[node name="PresetSlot01" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_01" +custom_minimum_size = Vector2(233, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +theme_override_styles/disabled = SubResource("StyleBox_preset_disabled") +theme_override_styles/hover = SubResource("StyleBox_preset_hover") +theme_override_styles/normal = SubResource("StyleBox_preset_normal") +theme_override_styles/pressed = SubResource("StyleBox_preset_pressed") +text = "50 РУБ." +theme_override_font_sizes/font_size = 28 + +[node name="PresetSlot02" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_02" +custom_minimum_size = Vector2(233, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +theme_override_styles/disabled = SubResource("StyleBox_preset_disabled") +theme_override_styles/hover = SubResource("StyleBox_preset_hover") +theme_override_styles/normal = SubResource("StyleBox_preset_normal") +theme_override_styles/pressed = SubResource("StyleBox_preset_pressed") +text = "100 РУБ." +theme_override_font_sizes/font_size = 28 + +[node name="PresetSlot03" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_03" +custom_minimum_size = Vector2(233, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +theme_override_styles/disabled = SubResource("StyleBox_preset_disabled") +theme_override_styles/hover = SubResource("StyleBox_preset_hover") +theme_override_styles/normal = SubResource("StyleBox_preset_normal") +theme_override_styles/pressed = SubResource("StyleBox_preset_pressed") +text = "150 РУБ." +theme_override_font_sizes/font_size = 28 + +[node name="PresetSlot04" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_04" +custom_minimum_size = Vector2(233, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +theme_override_styles/disabled = SubResource("StyleBox_preset_disabled") +theme_override_styles/hover = SubResource("StyleBox_preset_hover") +theme_override_styles/normal = SubResource("StyleBox_preset_normal") +theme_override_styles/pressed = SubResource("StyleBox_preset_pressed") +text = "200 РУБ." +theme_override_font_sizes/font_size = 28 + +[node name="PresetSlot05" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_05" +custom_minimum_size = Vector2(233, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +theme_override_styles/disabled = SubResource("StyleBox_preset_disabled") +theme_override_styles/hover = SubResource("StyleBox_preset_hover") +theme_override_styles/normal = SubResource("StyleBox_preset_normal") +theme_override_styles/pressed = SubResource("StyleBox_preset_pressed") +text = "300 РУБ." +theme_override_font_sizes/font_size = 28 + +[node name="PresetSlot06" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_06" +custom_minimum_size = Vector2(233, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +theme_override_styles/disabled = SubResource("StyleBox_preset_disabled") +theme_override_styles/hover = SubResource("StyleBox_preset_hover") +theme_override_styles/normal = SubResource("StyleBox_preset_normal") +theme_override_styles/pressed = SubResource("StyleBox_preset_pressed") +text = "400 РУБ." +theme_override_font_sizes/font_size = 28 + +[node name="PresetSlot07" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_07" +custom_minimum_size = Vector2(233, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +theme_override_styles/disabled = SubResource("StyleBox_preset_disabled") +theme_override_styles/hover = SubResource("StyleBox_preset_hover") +theme_override_styles/normal = SubResource("StyleBox_preset_normal") +theme_override_styles/pressed = SubResource("StyleBox_preset_pressed") +text = "500 РУБ." +theme_override_font_sizes/font_size = 28 + +[node name="PresetSlot08" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_08" +visible = false +custom_minimum_size = Vector2(200, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot09" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_09" +visible = false +custom_minimum_size = Vector2(200, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot10" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_10" +visible = false +custom_minimum_size = Vector2(200, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot11" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_11" +visible = false +custom_minimum_size = Vector2(200, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot12" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_12" +visible = false +custom_minimum_size = Vector2(200, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot13" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_13" +visible = false +custom_minimum_size = Vector2(200, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot14" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_14" +visible = false +custom_minimum_size = Vector2(200, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="PresetSlot15" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.preset_grid.slot_15" +visible = false +custom_minimum_size = Vector2(200, 45) +layout_mode = 2 +theme_type_variation = &"CompactButton" +text = "0 РУБ." + +[node name="OtherAmountButton" type="Button" parent="SafeMargin/Content/AmountPanel/Margin/Content"] +metadata/exchanger_binding_id = "card_amount.content.amount_panel.margin.content.other_amount_button" +layout_mode = 0 +offset_left = 186.0 +offset_top = 354.0 +offset_right = 409.5 +offset_bottom = 399.0 +pivot_offset = Vector2(0, 22.5) +scale = Vector2(1.208, 1) +theme_type_variation = &"CompactButton" +theme_override_styles/disabled = SubResource("StyleBox_other_disabled") +theme_override_styles/hover = SubResource("StyleBox_other_hover") +theme_override_styles/normal = SubResource("StyleBox_other_normal") +theme_override_styles/pressed = SubResource("StyleBox_other_pressed") +text = "ДРУГАЯ СУММА" +theme_override_font_sizes/font_size = 27 + +[node name="ConfirmButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_amount.content.confirm_button" +layout_mode = 0 +offset_left = 34.0 +offset_top = 873.0 +offset_right = 571.5 +offset_bottom = 949.0 +pivot_offset = Vector2(0, 38) +scale = Vector2(1.2, 1) +theme_type_variation = &"PrimaryButton" +theme_override_font_sizes/font_size = 45 +theme_override_styles/disabled = SubResource("StyleBox_confirm_disabled") +theme_override_styles/hover = SubResource("StyleBox_confirm_hover") +theme_override_styles/normal = SubResource("StyleBox_confirm_normal") +theme_override_styles/pressed = SubResource("StyleBox_confirm_pressed") +text = "ПОДТВЕРДИТЬ" + +[node name="BottomTip" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 1128.0 +mouse_filter = 2 + +[node name="Banner" type="AnimatedSprite2D" parent="SafeMargin/Content/BottomTip"] +texture_filter = 1 +position = Vector2(376.5, 1067.5) +scale = Vector2(0.667, 0.667) +sprite_frames = SubResource("SpriteFrames_card_amount_banner") +autoplay = "default" +script = ExtResource("10_banner_script") + +[node name="SelectionLabel" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_amount.content.selection_label" +layout_mode = 0 +offset_left = 225.0 +offset_top = 1022.0 +offset_right = 660.0 +offset_bottom = 1092.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 18 +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("4_footer")] +layout_mode = 1 diff --git a/themes/space-pixel/package/scenes/screen_card_custom_amount.tscn b/themes/space-pixel/package/scenes/screen_card_custom_amount.tscn new file mode 100644 index 0000000..40c8fe0 --- /dev/null +++ b/themes/space-pixel/package/scenes/screen_card_custom_amount.tscn @@ -0,0 +1,725 @@ +[gd_scene load_steps=20 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_screen_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="2_screen_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="3_screen_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="2_footer"] +[ext_resource type="Texture2D" path="../assets/ui/sum_bar.png" id="5_sum_bar"] +[ext_resource type="Texture2D" path="../assets/animations/banner/frame_01.png" id="6_banner_1"] +[ext_resource type="Texture2D" path="../assets/animations/banner/frame_02.png" id="7_banner_2"] +[ext_resource type="Texture2D" path="../assets/ui/panel_medium.png" id="8_panel_medium"] +[ext_resource type="Texture2D" path="../assets/ui/button_square.png" id="9_button_square"] +[ext_resource type="Script" path="../scripts/stable_banner_animation.gd" id="10_banner_script"] +[ext_resource type="Texture2D" path="../assets/ui/interactive_pet/home.png" id="11_home_icon"] + +[sub_resource type="SpriteFrames" id="SpriteFrames_card_custom_amount_banner"] +animations = [{"frames": [{"duration": 1.0, "texture": ExtResource("6_banner_1")}, {"duration": 1.0, "texture": ExtResource("7_banner_2")}], "loop": true, "name": &"default", "speed": 1.0}] + +[sub_resource type="AtlasTexture" id="AtlasTexture_panel_header"] +atlas = ExtResource("8_panel_medium") +region = Rect2(4, 34, 504, 68) + +[sub_resource type="AtlasTexture" id="AtlasTexture_panel_body"] +atlas = ExtResource("8_panel_medium") +region = Rect2(5, 102, 502, 98) + +[sub_resource type="AtlasTexture" id="AtlasTexture_panel_bottom"] +atlas = ExtResource("8_panel_medium") +region = Rect2(5, 200, 502, 20) + +[sub_resource type="StyleBoxTexture" id="StyleBox_ref_keypad_normal"] +content_margin_left = 8.0 +content_margin_top = 6.0 +content_margin_right = 8.0 +content_margin_bottom = 10.0 +texture = ExtResource("9_button_square") +texture_margin_left = 12.0 +texture_margin_top = 12.0 +texture_margin_right = 12.0 +texture_margin_bottom = 12.0 + +[sub_resource type="StyleBoxTexture" id="StyleBox_ref_keypad_hover"] +content_margin_left = 8.0 +content_margin_top = 6.0 +content_margin_right = 8.0 +content_margin_bottom = 10.0 +texture = ExtResource("9_button_square") +texture_margin_left = 12.0 +texture_margin_top = 12.0 +texture_margin_right = 12.0 +texture_margin_bottom = 12.0 +modulate_color = Color(1.12, 1.12, 1.12, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_ref_keypad_pressed"] +content_margin_left = 8.0 +content_margin_top = 8.0 +content_margin_right = 8.0 +content_margin_bottom = 8.0 +texture = ExtResource("9_button_square") +texture_margin_left = 12.0 +texture_margin_top = 12.0 +texture_margin_right = 12.0 +texture_margin_bottom = 12.0 +modulate_color = Color(0.72, 0.8, 0.76, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_ref_keypad_disabled"] +content_margin_left = 8.0 +content_margin_top = 6.0 +content_margin_right = 8.0 +content_margin_bottom = 10.0 +texture = ExtResource("9_button_square") +texture_margin_left = 12.0 +texture_margin_top = 12.0 +texture_margin_right = 12.0 +texture_margin_bottom = 12.0 +modulate_color = Color(0.36, 0.42, 0.5, 0.7) + +[node name="ThemeScreen_CardCustomAmount" type="Control"] +texture_filter = 1 +metadata/exchanger_screen_binding_id = "screen.card_custom_amount" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_screen_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("2_screen_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("3_screen_decoration")] +scale = Vector2(0.666667, 0.666667) + +[node name="PetNavigation" type="Node2D" parent="."] +metadata/exchanger_pet_navigation_root = true +metadata/exchanger_pet_navigation_version = 1 +metadata/exchanger_pet_default_surface_id = &"floor" +metadata/exchanger_pet_roaming_bounds = Rect2(32, 32, 656, 1208) + +[node name="Floor" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"floor" +metadata/exchanger_pet_surface_from_x = 40.0 +metadata/exchanger_pet_surface_to_x = 680.0 +metadata/exchanger_pet_surface_y = 1240.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(96, 360, 624) + +[node name="BackButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"back_button" +metadata/exchanger_pet_surface_from_x = 48.0 +metadata/exchanger_pet_surface_to_x = 161.0 +metadata/exchanger_pet_surface_y = 42.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(72, 135) + +[node name="AmountWindow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"amount_window" +metadata/exchanger_pet_surface_from_x = 38.0 +metadata/exchanger_pet_surface_to_x = 684.0 +metadata/exchanger_pet_surface_y = 184.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="KeypadWindow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"keypad_window" +metadata/exchanger_pet_surface_from_x = 38.0 +metadata/exchanger_pet_surface_to_x = 684.0 +metadata/exchanger_pet_surface_y = 378.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="KeypadRow1" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"keypad_row_1" +metadata/exchanger_pet_surface_from_x = 277.0 +metadata/exchanger_pet_surface_to_x = 443.0 +metadata/exchanger_pet_surface_y = 568.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(300, 360, 420) + +[node name="KeypadRow2" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"keypad_row_2" +metadata/exchanger_pet_surface_from_x = 277.0 +metadata/exchanger_pet_surface_to_x = 443.0 +metadata/exchanger_pet_surface_y = 626.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(300, 360, 420) + +[node name="KeypadRow3" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"keypad_row_3" +metadata/exchanger_pet_surface_from_x = 277.0 +metadata/exchanger_pet_surface_to_x = 443.0 +metadata/exchanger_pet_surface_y = 685.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(300, 360, 420) + +[node name="KeypadActionRow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"keypad_action_row" +metadata/exchanger_pet_surface_from_x = 147.0 +metadata/exchanger_pet_surface_to_x = 550.0 +metadata/exchanger_pet_surface_y = 741.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(223, 360, 473) + +[node name="FloorToBack" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_back" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"back_button" +metadata/exchanger_pet_climb_x = 48.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 42.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToAmount" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_amount" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"amount_window" +metadata/exchanger_pet_climb_x = 40.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 184.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToKeypad" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_keypad" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"keypad_window" +metadata/exchanger_pet_climb_x = 40.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 378.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToRow1" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_row_1" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"keypad_row_1" +metadata/exchanger_pet_climb_x = 277.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 568.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToRow2" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_row_2" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"keypad_row_2" +metadata/exchanger_pet_climb_x = 277.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 626.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToRow3" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_row_3" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"keypad_row_3" +metadata/exchanger_pet_climb_x = 277.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 685.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToActions" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_actions" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"keypad_action_row" +metadata/exchanger_pet_climb_x = 147.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 741.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="TopBar" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 92.0 + +[node name="BackButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "card_custom_amount.content.top_bar.back_button" +layout_mode = 0 +offset_left = 48.0 +offset_top = 42.0 +offset_right = 96.0 +offset_bottom = 90.0 +theme_type_variation = &"BackButton" + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "card_custom_amount.content.top_bar.home_navigation_button" +layout_mode = 0 +offset_left = 109.0 +offset_top = 42.0 +offset_right = 161.0 +offset_bottom = 94.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" +icon = ExtResource("11_home_icon") +expand_icon = true +icon_max_width = 30 + +[node name="BackText" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 36.0 +offset_top = 94.0 +offset_right = 108.0 +offset_bottom = 118.0 +mouse_filter = 2 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 14 +text = "НАЗАД" +horizontal_alignment = 1 +pivot_offset = Vector2(36, 12) +scale = Vector2(1.45, 1) + +[node name="Title" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 200.0 +offset_top = 44.0 +offset_right = 728.0 +offset_bottom = 108.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА КАРТОЙ" +horizontal_alignment = 1 +pivot_offset = Vector2(264, 32) +scale = Vector2(1.35, 1) + +[node name="AmountPanel" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 38.0 +offset_top = 184.0 +offset_right = 684.0 +offset_bottom = 359.0 + +[node name="BackgroundHeader" type="TextureRect" parent="SafeMargin/Content/AmountPanel"] +texture_filter = 1 +layout_mode = 0 +offset_left = -2.0 +offset_right = 646.0 +offset_bottom = 46.0 +mouse_filter = 2 +texture = SubResource("AtlasTexture_panel_header") +expand_mode = 1 +stretch_mode = 0 + +[node name="BackgroundBody" type="TextureRect" parent="SafeMargin/Content/AmountPanel"] +texture_filter = 1 +layout_mode = 0 +offset_left = -4.0 +offset_top = 46.0 +offset_right = 648.0 +offset_bottom = 148.0 +mouse_filter = 2 +texture = SubResource("AtlasTexture_panel_body") +expand_mode = 1 +stretch_mode = 0 + +[node name="BackgroundBottom" type="TextureRect" parent="SafeMargin/Content/AmountPanel"] +texture_filter = 1 +layout_mode = 0 +offset_left = -4.0 +offset_top = 148.0 +offset_right = 648.0 +offset_bottom = 175.0 +mouse_filter = 2 +texture = SubResource("AtlasTexture_panel_bottom") +expand_mode = 1 +stretch_mode = 0 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Content/AmountPanel"] +layout_mode = 0 +offset_left = 22.0 +offset_right = 624.0 +offset_bottom = 175.0 + +[node name="Values" type="Control" parent="SafeMargin/Content/AmountPanel/Margin"] +layout_mode = 2 + +[node name="Header" type="Label" parent="SafeMargin/Content/AmountPanel/Margin/Values"] +custom_minimum_size = Vector2(0, 44) +layout_mode = 0 +offset_right = 602.0 +offset_bottom = 44.0 +theme_type_variation = &"PanelTitle" +text = "К ВЫДАЧЕ" +vertical_alignment = 1 +pivot_offset = Vector2(0, 22) +scale = Vector2(1.25, 1) + +[node name="Amount" type="Label" parent="SafeMargin/Content/AmountPanel/Margin/Values"] +metadata/exchanger_binding_id = "card_custom_amount.content.amount_panel.margin.values.amount" +layout_mode = 0 +offset_left = 4.0 +offset_top = 57.0 +offset_right = 294.0 +offset_bottom = 137.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 27 +text = "100 ЖЕТОНОВ" +vertical_alignment = 1 +pivot_offset = Vector2(0, 40) +scale = Vector2(1.2, 1) + +[node name="Tokens" type="Label" parent="SafeMargin/Content/AmountPanel/Margin/Values"] +metadata/exchanger_binding_id = "card_custom_amount.content.amount_panel.margin.values.tokens" +layout_mode = 0 +offset_left = 296.0 +offset_top = 55.0 +offset_right = 602.0 +offset_bottom = 139.0 +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(0, 0.0627451, 0.552941, 1) +theme_override_font_sizes/font_size = 25 +text = "+ 50 ЖЕТОНОВ\nВ ПОДАРОК" +horizontal_alignment = 1 +vertical_alignment = 1 +pivot_offset = Vector2(153, 42) +scale = Vector2(1.3, 1) + +[node name="KeypadPanel" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 38.0 +offset_top = 378.0 +offset_right = 684.0 +offset_bottom = 844.0 + +[node name="BackgroundHeader" type="TextureRect" parent="SafeMargin/Content/KeypadPanel"] +texture_filter = 1 +layout_mode = 0 +offset_left = -2.0 +offset_top = -1.0 +offset_right = 646.0 +offset_bottom = 45.0 +mouse_filter = 2 +texture = SubResource("AtlasTexture_panel_header") +expand_mode = 1 +stretch_mode = 0 + +[node name="BackgroundBody" type="TextureRect" parent="SafeMargin/Content/KeypadPanel"] +texture_filter = 1 +layout_mode = 0 +offset_left = -4.0 +offset_top = 45.0 +offset_right = 648.0 +offset_bottom = 442.0 +mouse_filter = 2 +texture = SubResource("AtlasTexture_panel_body") +expand_mode = 1 +stretch_mode = 0 + +[node name="BackgroundBottom" type="TextureRect" parent="SafeMargin/Content/KeypadPanel"] +texture_filter = 1 +layout_mode = 0 +offset_left = -4.0 +offset_top = 442.0 +offset_right = 648.0 +offset_bottom = 466.0 +mouse_filter = 2 +texture = SubResource("AtlasTexture_panel_bottom") +expand_mode = 1 +stretch_mode = 0 + +[node name="Content" type="Control" parent="SafeMargin/Content/KeypadPanel"] +layout_mode = 0 +offset_left = 22.0 +offset_right = 624.0 +offset_bottom = 466.0 + +[node name="Header" type="Label" parent="SafeMargin/Content/KeypadPanel/Content"] +custom_minimum_size = Vector2(0, 44) +layout_mode = 0 +offset_right = 602.0 +offset_bottom = 44.0 +theme_type_variation = &"PanelTitle" +text = "ОПЛАТА" +vertical_alignment = 1 +pivot_offset = Vector2(0, 22) +scale = Vector2(1.17, 1) + +[node name="Prompt" type="Label" parent="SafeMargin/Content/KeypadPanel/Content"] +layout_mode = 0 +offset_top = 62.0 +offset_right = 602.0 +offset_bottom = 106.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "ВВЕДИТЕ СУММУ" +horizontal_alignment = 1 +vertical_alignment = 1 +pivot_offset = Vector2(301, 22) +scale = Vector2(1.17, 1) + +[node name="InputBar" type="TextureRect" parent="SafeMargin/Content/KeypadPanel/Content"] +texture_filter = 1 +layout_mode = 0 +offset_left = 131.0 +offset_top = 120.0 +offset_right = 470.0 +offset_bottom = 164.0 +mouse_filter = 2 +texture = ExtResource("5_sum_bar") +expand_mode = 1 +stretch_mode = 5 + +[node name="InputValue" type="Label" parent="SafeMargin/Content/KeypadPanel/Content"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.input_value" +layout_mode = 0 +offset_top = 118.0 +offset_left = 205.0 +offset_right = 397.0 +offset_bottom = 164.0 +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(0, 0.0627451, 0.552941, 1) +theme_override_font_sizes/font_size = 28 +text = "1000" +horizontal_alignment = 1 +vertical_alignment = 1 +pivot_offset = Vector2(96, 23) +scale = Vector2(1.15, 1) + +[node name="Keypad" type="Control" parent="SafeMargin/Content/KeypadPanel/Content"] +layout_mode = 0 +offset_right = 602.0 +offset_bottom = 444.0 + +[node name="Digit1" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit1" +layout_mode = 0 +offset_left = 217.0 +offset_top = 190.0 +offset_right = 263.0 +offset_bottom = 236.0 +theme_type_variation = &"KeypadButton" +theme_override_styles/disabled = SubResource("StyleBox_ref_keypad_disabled") +theme_override_styles/hover = SubResource("StyleBox_ref_keypad_hover") +theme_override_styles/normal = SubResource("StyleBox_ref_keypad_normal") +theme_override_styles/pressed = SubResource("StyleBox_ref_keypad_pressed") +theme_override_font_sizes/font_size = 27 +text = "1" +[node name="Digit2" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit2" +layout_mode = 0 +offset_left = 277.0 +offset_top = 190.0 +offset_right = 323.0 +offset_bottom = 236.0 +theme_type_variation = &"KeypadButton" +theme_override_styles/disabled = SubResource("StyleBox_ref_keypad_disabled") +theme_override_styles/hover = SubResource("StyleBox_ref_keypad_hover") +theme_override_styles/normal = SubResource("StyleBox_ref_keypad_normal") +theme_override_styles/pressed = SubResource("StyleBox_ref_keypad_pressed") +theme_override_font_sizes/font_size = 27 +text = "2" +[node name="Digit3" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit3" +layout_mode = 0 +offset_left = 337.0 +offset_top = 190.0 +offset_right = 383.0 +offset_bottom = 236.0 +theme_type_variation = &"KeypadButton" +theme_override_styles/disabled = SubResource("StyleBox_ref_keypad_disabled") +theme_override_styles/hover = SubResource("StyleBox_ref_keypad_hover") +theme_override_styles/normal = SubResource("StyleBox_ref_keypad_normal") +theme_override_styles/pressed = SubResource("StyleBox_ref_keypad_pressed") +theme_override_font_sizes/font_size = 27 +text = "3" +[node name="Digit4" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit4" +layout_mode = 0 +offset_left = 217.0 +offset_top = 248.0 +offset_right = 263.0 +offset_bottom = 294.0 +theme_type_variation = &"KeypadButton" +theme_override_styles/disabled = SubResource("StyleBox_ref_keypad_disabled") +theme_override_styles/hover = SubResource("StyleBox_ref_keypad_hover") +theme_override_styles/normal = SubResource("StyleBox_ref_keypad_normal") +theme_override_styles/pressed = SubResource("StyleBox_ref_keypad_pressed") +theme_override_font_sizes/font_size = 27 +text = "4" +[node name="Digit5" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit5" +layout_mode = 0 +offset_left = 277.0 +offset_top = 248.0 +offset_right = 323.0 +offset_bottom = 294.0 +theme_type_variation = &"KeypadButton" +theme_override_styles/disabled = SubResource("StyleBox_ref_keypad_disabled") +theme_override_styles/hover = SubResource("StyleBox_ref_keypad_hover") +theme_override_styles/normal = SubResource("StyleBox_ref_keypad_normal") +theme_override_styles/pressed = SubResource("StyleBox_ref_keypad_pressed") +theme_override_font_sizes/font_size = 27 +text = "5" +[node name="Digit6" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit6" +layout_mode = 0 +offset_left = 337.0 +offset_top = 248.0 +offset_right = 383.0 +offset_bottom = 294.0 +theme_type_variation = &"KeypadButton" +theme_override_styles/disabled = SubResource("StyleBox_ref_keypad_disabled") +theme_override_styles/hover = SubResource("StyleBox_ref_keypad_hover") +theme_override_styles/normal = SubResource("StyleBox_ref_keypad_normal") +theme_override_styles/pressed = SubResource("StyleBox_ref_keypad_pressed") +theme_override_font_sizes/font_size = 27 +text = "6" +[node name="Digit7" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit7" +layout_mode = 0 +offset_left = 217.0 +offset_top = 307.0 +offset_right = 263.0 +offset_bottom = 353.0 +theme_type_variation = &"KeypadButton" +theme_override_styles/disabled = SubResource("StyleBox_ref_keypad_disabled") +theme_override_styles/hover = SubResource("StyleBox_ref_keypad_hover") +theme_override_styles/normal = SubResource("StyleBox_ref_keypad_normal") +theme_override_styles/pressed = SubResource("StyleBox_ref_keypad_pressed") +theme_override_font_sizes/font_size = 27 +text = "7" +[node name="Digit8" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit8" +layout_mode = 0 +offset_left = 277.0 +offset_top = 307.0 +offset_right = 323.0 +offset_bottom = 353.0 +theme_type_variation = &"KeypadButton" +theme_override_styles/disabled = SubResource("StyleBox_ref_keypad_disabled") +theme_override_styles/hover = SubResource("StyleBox_ref_keypad_hover") +theme_override_styles/normal = SubResource("StyleBox_ref_keypad_normal") +theme_override_styles/pressed = SubResource("StyleBox_ref_keypad_pressed") +theme_override_font_sizes/font_size = 27 +text = "8" +[node name="Digit9" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit9" +layout_mode = 0 +offset_left = 337.0 +offset_top = 307.0 +offset_right = 383.0 +offset_bottom = 353.0 +theme_type_variation = &"KeypadButton" +theme_override_styles/disabled = SubResource("StyleBox_ref_keypad_disabled") +theme_override_styles/hover = SubResource("StyleBox_ref_keypad_hover") +theme_override_styles/normal = SubResource("StyleBox_ref_keypad_normal") +theme_override_styles/pressed = SubResource("StyleBox_ref_keypad_pressed") +theme_override_font_sizes/font_size = 27 +text = "9" + +[node name="ClearButton" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.clear_button" +layout_mode = 0 +offset_left = 87.0 +offset_top = 363.0 +offset_right = 239.6 +offset_bottom = 408.0 +pivot_offset = Vector2(0, 22.5) +scale = Vector2(1.16, 1) +theme_type_variation = &"CancelButton" +theme_override_font_sizes/font_size = 28 +text = "СТЕРЕТЬ" + +[node name="Digit0" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.digit0" +layout_mode = 0 +offset_left = 277.0 +offset_top = 364.0 +offset_right = 323.0 +offset_bottom = 410.0 +theme_type_variation = &"KeypadButton" +theme_override_styles/disabled = SubResource("StyleBox_ref_keypad_disabled") +theme_override_styles/hover = SubResource("StyleBox_ref_keypad_hover") +theme_override_styles/normal = SubResource("StyleBox_ref_keypad_normal") +theme_override_styles/pressed = SubResource("StyleBox_ref_keypad_pressed") +theme_override_font_sizes/font_size = 27 +text = "0" + +[node name="BackspaceButton" type="Button" parent="SafeMargin/Content/KeypadPanel/Content/Keypad"] +metadata/exchanger_binding_id = "card_custom_amount.content.keypad_panel.content.keypad.backspace_button" +visible = false +layout_mode = 0 +offset_left = 337.0 +offset_top = 365.0 +offset_right = 383.0 +offset_bottom = 411.0 +theme_type_variation = &"KeypadButton" +theme_override_styles/disabled = SubResource("StyleBox_ref_keypad_disabled") +theme_override_styles/hover = SubResource("StyleBox_ref_keypad_hover") +theme_override_styles/normal = SubResource("StyleBox_ref_keypad_normal") +theme_override_styles/pressed = SubResource("StyleBox_ref_keypad_pressed") +theme_override_font_sizes/font_size = 27 +text = "⌫" + +[node name="ConfirmButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_custom_amount.content.confirm_button" +layout_mode = 0 +offset_left = 397.0 +offset_top = 741.0 +offset_right = 549.6 +offset_bottom = 786.0 +pivot_offset = Vector2(0, 22.5) +scale = Vector2(1.16, 1) +theme_type_variation = &"KeyboardActionButton" +theme_override_font_sizes/font_size = 28 +text = "OK" + +[node name="BottomTip" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 1128.0 +mouse_filter = 2 + +[node name="Banner" type="AnimatedSprite2D" parent="SafeMargin/Content/BottomTip"] +texture_filter = 1 +position = Vector2(376.5, 1067.5) +scale = Vector2(0.667, 0.667) +sprite_frames = SubResource("SpriteFrames_card_custom_amount_banner") +autoplay = "default" +script = ExtResource("10_banner_script") + +[node name="Hint" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_custom_amount.content.hint" +visible = false +layout_mode = 0 +offset_left = 225.0 +offset_top = 1022.0 +offset_right = 660.0 +offset_bottom = 1092.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 18 +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("2_footer")] +layout_mode = 1 diff --git a/themes/space-pixel/package/scenes/screen_card_terminal.tscn b/themes/space-pixel/package/scenes/screen_card_terminal.tscn new file mode 100644 index 0000000..19a9ff0 --- /dev/null +++ b/themes/space-pixel/package/scenes/screen_card_terminal.tscn @@ -0,0 +1,311 @@ +[gd_scene load_steps=13 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_screen_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="2_screen_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="3_screen_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="3_footer"] +[ext_resource type="PackedScene" path="../visuals/card_terminal.tscn" id="4_terminal_visual"] +[ext_resource type="Texture2D" path="../assets/ui/panel_card.png" id="5_panel_card"] +[ext_resource type="Texture2D" path="../assets/animations/banner/frame_01.png" id="6_banner_1"] +[ext_resource type="Texture2D" path="../assets/animations/banner/frame_02.png" id="7_banner_2"] +[ext_resource type="Script" path="../scripts/stable_banner_animation.gd" id="8_banner_script"] +[ext_resource type="Texture2D" path="../assets/ui/interactive_pet/home.png" id="9_home_icon"] + +[sub_resource type="SpriteFrames" id="SpriteFrames_card_terminal_banner"] +animations = [{"frames": [{"duration": 1.0, "texture": ExtResource("6_banner_1")}, {"duration": 1.0, "texture": ExtResource("7_banner_2")}], "loop": true, "name": &"default", "speed": 1.0}] + +[sub_resource type="StyleBoxTexture" id="StyleBox_terminal_panel"] +content_margin_left = 42.0 +content_margin_top = 109.0 +content_margin_right = 42.0 +content_margin_bottom = 109.0 +texture = ExtResource("5_panel_card") + +[node name="ThemeScreen_CardTerminal" type="Control"] +texture_filter = 1 +metadata/exchanger_screen_binding_id = "screen.card_terminal" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_screen_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("2_screen_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("3_screen_decoration")] +scale = Vector2(0.666667, 0.666667) + +[node name="PetNavigation" type="Node2D" parent="."] +metadata/exchanger_pet_navigation_root = true +metadata/exchanger_pet_navigation_version = 1 +metadata/exchanger_pet_default_surface_id = &"floor" +metadata/exchanger_pet_roaming_bounds = Rect2(32, 32, 656, 1208) + +[node name="Floor" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"floor" +metadata/exchanger_pet_surface_from_x = 40.0 +metadata/exchanger_pet_surface_to_x = 680.0 +metadata/exchanger_pet_surface_y = 1240.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(96, 360, 624) + +[node name="BackButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"back_button" +metadata/exchanger_pet_surface_from_x = 49.0 +metadata/exchanger_pet_surface_to_x = 161.0 +metadata/exchanger_pet_surface_y = 43.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(73, 135) + +[node name="PaymentWindow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"payment_window" +metadata/exchanger_pet_surface_from_x = 32.0 +metadata/exchanger_pet_surface_to_x = 688.0 +metadata/exchanger_pet_surface_y = 243.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="CancelButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"cancel_button" +metadata/exchanger_pet_surface_from_x = 162.0 +metadata/exchanger_pet_surface_to_x = 436.0 +metadata/exchanger_pet_surface_y = 840.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(299) + +[node name="FloorToBack" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_back" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"back_button" +metadata/exchanger_pet_climb_x = 49.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 43.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToPayment" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_payment" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"payment_window" +metadata/exchanger_pet_climb_x = 40.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 243.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToCancel" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_cancel" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"cancel_button" +metadata/exchanger_pet_climb_x = 162.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 840.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="TopBar" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 120.0 + +[node name="BackButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "card_terminal.content.top_bar.back_button" +layout_mode = 0 +offset_left = 49.0 +offset_top = 43.0 +offset_right = 97.0 +offset_bottom = 91.0 +theme_type_variation = &"BackButton" + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "card_terminal.content.top_bar.home_navigation_button" +layout_mode = 0 +offset_left = 109.0 +offset_top = 42.0 +offset_right = 161.0 +offset_bottom = 94.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" +icon = ExtResource("9_home_icon") +expand_icon = true +icon_max_width = 30 + +[node name="BackText" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 38.0 +offset_top = 95.0 +offset_right = 103.0 +offset_bottom = 123.0 +mouse_filter = 2 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 16 +text = "НАЗАД" +horizontal_alignment = 1 +pivot_offset = Vector2(32.5, 14) +scale = Vector2(1.2, 1) + +[node name="Title" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 200.0 +offset_top = 43.0 +offset_right = 720.0 +offset_bottom = 103.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА КАРТОЙ" +horizontal_alignment = 1 +pivot_offset = Vector2(260, 30) +scale = Vector2(1.385, 1) + +[node name="PaymentPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 19.0 +offset_top = 243.0 +offset_right = 702.0 +offset_bottom = 923.0 +theme_type_variation = &"ReferencePanel" +theme_override_styles/panel = SubResource("StyleBox_terminal_panel") + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Content/PaymentPanel"] +layout_mode = 2 +theme_override_constants/separation = 0 + +[node name="Header" type="Label" parent="SafeMargin/Content/PaymentPanel/Content"] +custom_minimum_size = Vector2(0, 43) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +theme_override_font_sizes/font_size = 34 +text = "ОПЛАТА" +vertical_alignment = 1 + +[node name="Body" type="Control" parent="SafeMargin/Content/PaymentPanel/Content"] +custom_minimum_size = Vector2(0, 419) +layout_mode = 2 +mouse_filter = 2 + +[node name="SpacePixelVisual" parent="SafeMargin/Content/PaymentPanel/Content/Body" instance=ExtResource("4_terminal_visual")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Instruction" type="Label" parent="SafeMargin/Content/PaymentPanel/Content/Body"] +layout_mode = 0 +offset_left = 99.0 +offset_top = 297.0 +offset_right = 499.0 +offset_bottom = 387.0 +mouse_filter = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 21 +text = "ПРИЛОЖИТЕ КАРТУ\nК ТЕРМИНАЛУ ОПЛАТЫ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Amount" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_terminal.content.amount" +layout_mode = 0 +offset_left = 211.0 +offset_top = 991.0 +offset_right = 680.0 +offset_bottom = 1042.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 20 +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Tokens" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_terminal.content.tokens" +visible = false + +[node name="CancelButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_terminal.content.cancel_button" +layout_mode = 0 +offset_left = 210.0 +offset_top = 840.0 +offset_right = 484.0 +offset_bottom = 886.0 +theme_type_variation = &"CancelButton" +text = "ОТМЕНА" +pivot_offset = Vector2(137, 23) +scale = Vector2(1.35, 1) + +[node name="BottomTip" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 1128.0 +mouse_filter = 2 + +[node name="Banner" type="AnimatedSprite2D" parent="SafeMargin/Content/BottomTip"] +texture_filter = 1 +position = Vector2(376.5, 1067.5) +scale = Vector2(0.667, 0.667) +sprite_frames = SubResource("SpriteFrames_card_terminal_banner") +autoplay = "default" +script = ExtResource("8_banner_script") + +[node name="Countdown" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_terminal.content.countdown" +layout_mode = 0 +offset_left = 211.0 +offset_top = 1042.0 +offset_right = 680.0 +offset_bottom = 1088.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 16 +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="DebugPanel" type="VBoxContainer" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "card_terminal.content.debug_panel" +visible = false + +[node name="ApproveButton" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "card_terminal.content.debug_panel.approve_button" +text = "Одобрить" +[node name="DeclineButton" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "card_terminal.content.debug_panel.decline_button" +text = "Отклонить" +[node name="DispenseFailureButton" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "card_terminal.content.debug_panel.dispense_failure_button" +text = "Ошибка выдачи" +[node name="PartialDispenseButton" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "card_terminal.content.debug_panel.partial_dispense_button" +text = "Частичная выдача" +[node name="ConnectionLossButton" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "card_terminal.content.debug_panel.connection_loss_button" +text = "Потеря связи" + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("3_footer")] +layout_mode = 1 diff --git a/themes/space-pixel/package/scenes/screen_cash_payment.tscn b/themes/space-pixel/package/scenes/screen_cash_payment.tscn new file mode 100644 index 0000000..232b845 --- /dev/null +++ b/themes/space-pixel/package/scenes/screen_cash_payment.tscn @@ -0,0 +1,586 @@ +[gd_scene load_steps=16 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_screen_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="2_screen_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="3_screen_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="3_footer"] +[ext_resource type="PackedScene" path="../visuals/cash_instructions.tscn" id="4_instructions"] +[ext_resource type="PackedScene" path="../visuals/cash_tip.tscn" id="5_tip"] +[ext_resource type="Texture2D" path="../assets/ui/panel_medium.png" id="6_panel"] +[ext_resource type="Texture2D" path="../assets/animations/banner/frame_01.png" id="7_banner_1"] +[ext_resource type="Texture2D" path="../assets/animations/banner/frame_02.png" id="8_banner_2"] +[ext_resource type="Script" path="../scripts/stable_banner_animation.gd" id="9_banner_script"] +[ext_resource type="Texture2D" path="../assets/ui/interactive_pet/home.png" id="10_home_icon"] + +[sub_resource type="AtlasTexture" id="AtlasTexture_cash_panel"] +atlas = ExtResource("6_panel") +region = Rect2(4, 34, 504, 186) + +[sub_resource type="AtlasTexture" id="AtlasTexture_cash_panel_top"] +atlas = ExtResource("6_panel") +region = Rect2(4, 34, 504, 66) + +[sub_resource type="AtlasTexture" id="AtlasTexture_cash_panel_body"] +atlas = ExtResource("6_panel") +region = Rect2(4, 100, 504, 120) + +[sub_resource type="SpriteFrames" id="SpriteFrames_cash_banner"] +animations = [{"frames": [{"duration": 1.0, "texture": ExtResource("7_banner_1")}, {"duration": 1.0, "texture": ExtResource("8_banner_2")}], "loop": true, "name": &"default", "speed": 1.0}] + +[node name="ThemeScreen_CashPayment" type="Control"] +texture_filter = 1 +metadata/exchanger_screen_binding_id = "screen.cash_payment" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_screen_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("2_screen_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("3_screen_decoration")] +scale = Vector2(0.666667, 0.666667) + +[node name="PetNavigation" type="Node2D" parent="."] +metadata/exchanger_pet_navigation_root = true +metadata/exchanger_pet_navigation_version = 1 +metadata/exchanger_pet_default_surface_id = &"floor" +metadata/exchanger_pet_roaming_bounds = Rect2(32, 32, 656, 1208) + +[node name="Floor" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"floor" +metadata/exchanger_pet_surface_from_x = 40.0 +metadata/exchanger_pet_surface_to_x = 680.0 +metadata/exchanger_pet_surface_y = 1240.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(96, 360, 624) + +[node name="BackButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"back_button" +metadata/exchanger_pet_surface_from_x = 49.0 +metadata/exchanger_pet_surface_to_x = 161.0 +metadata/exchanger_pet_surface_y = 42.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(75, 135) + +[node name="SummaryWindow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"summary_window" +metadata/exchanger_pet_surface_from_x = 36.0 +metadata/exchanger_pet_surface_to_x = 685.0 +metadata/exchanger_pet_surface_y = 148.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="BalanceWindow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"balance_window" +metadata/exchanger_pet_surface_from_x = 36.0 +metadata/exchanger_pet_surface_to_x = 372.0 +metadata/exchanger_pet_surface_y = 280.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(204) + +[node name="TipWindow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"tip_window" +metadata/exchanger_pet_surface_from_x = 381.0 +metadata/exchanger_pet_surface_to_x = 684.0 +metadata/exchanger_pet_surface_y = 281.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(532) + +[node name="InstructionWindow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"instruction_window" +metadata/exchanger_pet_surface_from_x = 35.0 +metadata/exchanger_pet_surface_to_x = 685.0 +metadata/exchanger_pet_surface_y = 451.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="DispenseButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"dispense_button" +metadata/exchanger_pet_surface_from_x = 36.0 +metadata/exchanger_pet_surface_to_x = 684.0 +metadata/exchanger_pet_surface_y = 883.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="FloorToBack" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_back" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"back_button" +metadata/exchanger_pet_climb_x = 49.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 42.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToSummary" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_summary" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"summary_window" +metadata/exchanger_pet_climb_x = 40.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 148.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToBalance" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_balance" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"balance_window" +metadata/exchanger_pet_climb_x = 40.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 280.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToTip" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_tip" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"tip_window" +metadata/exchanger_pet_climb_x = 680.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 281.0 +metadata/exchanger_pet_climb_side = &"right" + +[node name="FloorToInstruction" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_instruction" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"instruction_window" +metadata/exchanger_pet_climb_x = 40.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 451.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToDispense" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_dispense" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"dispense_button" +metadata/exchanger_pet_climb_x = 680.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 883.0 +metadata/exchanger_pet_climb_side = &"right" + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="TopBar" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 92.0 + +[node name="BackButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "cash_payment.content.top_bar.back_button" +layout_mode = 0 +offset_left = 49.0 +offset_top = 42.0 +offset_right = 101.0 +offset_bottom = 94.0 +theme_type_variation = &"BackButton" + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content/TopBar"] +metadata/exchanger_binding_id = "cash_payment.content.top_bar.home_navigation_button" +layout_mode = 0 +offset_left = 109.0 +offset_top = 42.0 +offset_right = 161.0 +offset_bottom = 94.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" +icon = ExtResource("10_home_icon") +expand_icon = true +icon_max_width = 30 + +[node name="BackText" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 39.0 +offset_top = 90.0 +offset_right = 103.0 +offset_bottom = 116.0 +mouse_filter = 2 +theme_type_variation = &"SupportText" +theme_override_font_sizes/font_size = 16 +text = "НАЗАД" +horizontal_alignment = 1 +vertical_alignment = 1 +pivot_offset = Vector2(32, 13) +scale = Vector2(1.28, 1) + +[node name="Title" type="Label" parent="SafeMargin/Content/TopBar"] +layout_mode = 0 +offset_left = 170.0 +offset_top = 38.5 +offset_right = 680.0 +offset_bottom = 90.5 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА НАЛИЧНЫМИ" +vertical_alignment = 1 +pivot_offset = Vector2(0, 26) +scale = Vector2(1.355, 1.1) + +[node name="CurrentBalancePanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 36.0 +offset_top = 280.0 +offset_right = 372.0 +offset_bottom = 406.0 +theme_type_variation = &"ReferencePanel" + +[node name="Content" type="Control" parent="SafeMargin/Content/CurrentBalancePanel"] +layout_mode = 2 + +[node name="PanelTexture" type="TextureRect" parent="SafeMargin/Content/CurrentBalancePanel/Content"] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -22.0 +offset_top = -10.0 +offset_right = 22.0 +offset_bottom = 18.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = SubResource("AtlasTexture_cash_panel") +expand_mode = 1 +stretch_mode = 0 + +[node name="Title" type="Label" parent="SafeMargin/Content/CurrentBalancePanel/Content"] +layout_mode = 0 +offset_top = -8.0 +offset_right = 307.0 +offset_bottom = 34.0 +theme_type_variation = &"PanelTitle" +text = "ТЕКУЩИЙ БАЛАНС" +vertical_alignment = 1 +pivot_offset = Vector2(0, 21) +scale = Vector2(1.23, 1) + +[node name="Summary" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 36.0 +offset_top = 148.0 +offset_right = 685.0 +offset_bottom = 273.0 +theme_type_variation = &"ReferencePanel" + +[node name="Margin" type="Control" parent="SafeMargin/Content/Summary"] +layout_mode = 2 + +[node name="Values" type="Control" parent="SafeMargin/Content/Summary/Margin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="PanelTexture" type="TextureRect" parent="SafeMargin/Content/Summary/Margin/Values"] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -22.0 +offset_top = -10.0 +offset_right = 22.0 +offset_bottom = 18.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = SubResource("AtlasTexture_cash_panel") +expand_mode = 1 +stretch_mode = 0 + +[node name="Caption" type="Label" parent="SafeMargin/Content/Summary/Margin/Values"] +layout_mode = 0 +offset_left = 2.0 +offset_top = -9.0 +offset_right = 370.0 +offset_bottom = 34.0 +theme_type_variation = &"PanelTitle" +text = "К ВЫДАЧЕ" +vertical_alignment = 1 +pivot_offset = Vector2(0, 21) +scale = Vector2(1.21, 1) + +[node name="Balance" type="Label" parent="SafeMargin/Content/Summary/Margin/Values"] +metadata/exchanger_binding_id = "cash_payment.content.summary.margin.values.balance" +layout_mode = 0 +offset_left = 10.0 +offset_top = 165.0 +offset_right = 310.0 +offset_bottom = 240.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 27 +text = "1000 РУБЛЕЙ" +vertical_alignment = 1 +pivot_offset = Vector2(0, 37) +scale = Vector2(1.22, 1) + +[node name="Tokens" type="Label" parent="SafeMargin/Content/Summary/Margin/Values"] +metadata/exchanger_binding_id = "cash_payment.content.summary.margin.values.tokens" +layout_mode = 0 +offset_left = 2.0 +offset_top = 37.0 +offset_right = 300.0 +offset_bottom = 108.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 27 +text = "100 ЖЕТОНОВ" +vertical_alignment = 1 +pivot_offset = Vector2(0, 33.5) +scale = Vector2(1.25, 1) + +[node name="Bonus" type="Label" parent="SafeMargin/Content/Summary/Margin/Values"] +metadata/exchanger_binding_id = "cash_payment.content.summary.margin.values.bonus" +layout_mode = 0 +offset_left = 295.0 +offset_top = 35.0 +offset_right = 595.0 +offset_bottom = 110.0 +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(0, 0.0627451, 0.552941, 1) +theme_override_font_sizes/font_size = 25 +text = "+ 50 ЖЕТОНОВ\nВ ПОДАРОК" +horizontal_alignment = 1 +vertical_alignment = 1 +pivot_offset = Vector2(150, 35.5) +scale = Vector2(1.35, 1) + +[node name="InstructionPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 35.0 +offset_top = 451.0 +offset_right = 685.0 +offset_bottom = 816.0 +theme_type_variation = &"ReferencePanel" + +[node name="Content" type="Control" parent="SafeMargin/Content/InstructionPanel"] +layout_mode = 2 + +[node name="PanelTop" type="TextureRect" parent="SafeMargin/Content/InstructionPanel/Content"] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_left = -22.0 +offset_top = -10.0 +offset_right = 22.0 +offset_bottom = 34.0 +grow_horizontal = 2 +mouse_filter = 2 +texture = SubResource("AtlasTexture_cash_panel_top") +expand_mode = 1 +stretch_mode = 0 + +[node name="PanelBody" type="NinePatchRect" parent="SafeMargin/Content/InstructionPanel/Content"] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -22.0 +offset_top = 34.0 +offset_right = 22.0 +offset_bottom = 18.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = SubResource("AtlasTexture_cash_panel_body") +patch_margin_left = 15 +patch_margin_right = 15 +patch_margin_bottom = 12 + +[node name="Header" type="Label" parent="SafeMargin/Content/InstructionPanel/Content"] +layout_mode = 0 +offset_top = -9.0 +offset_left = 4.0 +offset_right = 322.0 +offset_bottom = 33.0 +theme_type_variation = &"PanelTitle" +text = "КАК ВНЕСТИ" +vertical_alignment = 1 +pivot_offset = Vector2(0, 21) +scale = Vector2(1.25, 1) + +[node name="Body" type="Control" parent="SafeMargin/Content/InstructionPanel/Content"] +layout_mode = 0 +offset_left = -21.0 +offset_top = 33.0 +offset_right = 627.0 +offset_bottom = 355.0 +mouse_filter = 2 + +[node name="SpacePixelVisual" parent="SafeMargin/Content/InstructionPanel/Content/Body" instance=ExtResource("4_instructions")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="BillText" type="Label" parent="SafeMargin/Content/InstructionPanel/Content/Body"] +layout_mode = 0 +offset_left = 294.0 +offset_top = 44.0 +offset_right = 551.0 +offset_bottom = 118.0 +mouse_filter = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 21 +text = "ВСТАВЬТЕ КУПЮРЫ\nВ КУПЮРОПРИЁМНИК" +vertical_alignment = 1 +pivot_offset = Vector2(0, 37) +scale = Vector2(1.08, 1) + +[node name="CoinText" type="Label" parent="SafeMargin/Content/InstructionPanel/Content/Body"] +layout_mode = 0 +offset_left = 294.0 +offset_top = 183.0 +offset_right = 551.0 +offset_bottom = 265.0 +mouse_filter = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 21 +text = "ИЛИ МОНЕТЫ\nВ МОНЕТОПРИЁМНИК" +vertical_alignment = 1 +pivot_offset = Vector2(0, 41) +scale = Vector2(1.08, 1) + +[node name="DispenseButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "cash_payment.content.dispense_button" +layout_mode = 0 +offset_left = 36.0 +offset_top = 883.0 +offset_right = 684.0 +offset_bottom = 959.0 +theme_type_variation = &"PrimaryButton" +text = "" + +[node name="Caption" type="Label" parent="SafeMargin/Content/DispenseButton"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -6.0 +offset_bottom = -6.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(0, 0.0627451, 0.552941, 1) +theme_override_font_sizes/font_size = 40 +text = "ВЫДАТЬ" +horizontal_alignment = 1 +vertical_alignment = 1 +pivot_offset = Vector2(324, 38) +scale = Vector2(1.37, 1) + +[node name="Tip" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 381.0 +offset_top = 281.0 +offset_right = 684.0 +offset_bottom = 406.0 +mouse_filter = 2 + +[node name="SpacePixelVisual" parent="SafeMargin/Content/Tip" instance=ExtResource("5_tip")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="TipText" type="Label" parent="SafeMargin/Content"] +visible = false +layout_mode = 0 +offset_left = 381.0 +offset_top = 281.0 +offset_right = 557.0 +offset_bottom = 365.0 +mouse_filter = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 14 +text = "АВТОМАТ НЕ ВЫДАЁТ СДАЧУ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="BottomTip" type="Control" parent="SafeMargin/Content"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 1128.0 +mouse_filter = 2 + +[node name="BannerText" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "cash_payment.content.banner_text" +layout_mode = 0 +offset_left = 225.0 +offset_top = 1022.0 +offset_right = 660.0 +offset_bottom = 1092.0 +mouse_filter = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 18 +text = "АВТОМАТ НЕ ВЫДАЁТ СДАЧУ" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Banner" type="AnimatedSprite2D" parent="SafeMargin/Content/BottomTip"] +texture_filter = 1 +position = Vector2(376.5, 1067.5) +scale = Vector2(0.667, 0.667) +sprite_frames = SubResource("SpriteFrames_cash_banner") +autoplay = "default" +script = ExtResource("9_banner_script") + +[node name="DebugPanel" type="HBoxContainer" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "cash_payment.content.debug_panel" +visible = false + +[node name="Add10Button" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "cash_payment.content.debug_panel.add10_button" +text = "+10 ₽" + +[node name="Add50Button" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "cash_payment.content.debug_panel.add50_button" +text = "+50 ₽" + +[node name="Add100Button" type="Button" parent="SafeMargin/Content/DebugPanel"] +metadata/exchanger_binding_id = "cash_payment.content.debug_panel.add100_button" +text = "+100 ₽" + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("3_footer")] +layout_mode = 1 diff --git a/themes/space-pixel/package/scenes/screen_error.tscn b/themes/space-pixel/package/scenes/screen_error.tscn new file mode 100644 index 0000000..52a223a --- /dev/null +++ b/themes/space-pixel/package/scenes/screen_error.tscn @@ -0,0 +1,212 @@ +[gd_scene load_steps=7 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_screen_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="2_screen_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="3_screen_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="3_footer"] +[ext_resource type="PackedScene" path="../visuals/result_error.tscn" id="4_result_visual"] +[ext_resource type="Texture2D" path="../assets/ui/interactive_pet/home.png" id="5_home_icon"] + +[node name="ThemeScreen_Error" type="Control"] +texture_filter = 1 +metadata/exchanger_screen_binding_id = "screen.error" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_screen_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("2_screen_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("3_screen_decoration")] +scale = Vector2(0.666667, 0.666667) + +[node name="PetNavigation" type="Node2D" parent="."] +metadata/exchanger_pet_navigation_root = true +metadata/exchanger_pet_navigation_version = 1 +metadata/exchanger_pet_default_surface_id = &"floor" +metadata/exchanger_pet_roaming_bounds = Rect2(32, 32, 656, 1208) + +[node name="Floor" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"floor" +metadata/exchanger_pet_surface_from_x = 40.0 +metadata/exchanger_pet_surface_to_x = 680.0 +metadata/exchanger_pet_surface_y = 1240.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(96, 360, 624) + +[node name="ErrorWindow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"error_window" +metadata/exchanger_pet_surface_from_x = 96.0 +metadata/exchanger_pet_surface_to_x = 624.0 +metadata/exchanger_pet_surface_y = 176.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="HomeButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"home_button" +metadata/exchanger_pet_surface_from_x = 84.0 +metadata/exchanger_pet_surface_to_x = 636.0 +metadata/exchanger_pet_surface_y = 862.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="HomeNavigationButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"home_navigation_button" +metadata/exchanger_pet_surface_from_x = 49.0 +metadata/exchanger_pet_surface_to_x = 101.0 +metadata/exchanger_pet_surface_y = 42.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(75) + +[node name="FloorToHomeNavigation" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_home_navigation" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"home_navigation_button" +metadata/exchanger_pet_climb_x = 49.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 42.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToError" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_error" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"error_window" +metadata/exchanger_pet_climb_x = 96.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 176.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToHome" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_home" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"home_button" +metadata/exchanger_pet_climb_x = 84.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 862.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "error.content.home_navigation_button" +layout_mode = 0 +offset_left = 49.0 +offset_top = 42.0 +offset_right = 101.0 +offset_bottom = 94.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" +icon = ExtResource("5_home_icon") +expand_icon = true +icon_max_width = 30 + +[node name="ScreenTitle" type="Label" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 40.0 +offset_top = 44.0 +offset_right = 680.0 +offset_bottom = 112.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА НЕ ПРОШЛА" +horizontal_alignment = 1 + +[node name="ErrorPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 96.0 +offset_top = 176.0 +offset_right = 624.0 +offset_bottom = 826.0 +theme_type_variation = &"WhitePanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Content/ErrorPanel"] +layout_mode = 2 + +[node name="Values" type="VBoxContainer" parent="SafeMargin/Content/ErrorPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Title" type="Label" parent="SafeMargin/Content/ErrorPanel/Margin/Values"] +metadata/exchanger_binding_id = "error.content.error_panel.margin.values.title" +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ОШИБКА ОПЛАТЫ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Body" type="Control" parent="SafeMargin/Content/ErrorPanel/Margin/Values"] +custom_minimum_size = Vector2(0, 538) +layout_mode = 2 +mouse_filter = 2 + +[node name="SpacePixelVisual" parent="SafeMargin/Content/ErrorPanel/Margin/Values/Body" instance=ExtResource("4_result_visual")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Message" type="Label" parent="SafeMargin/Content/ErrorPanel/Margin/Values/Body"] +metadata/exchanger_binding_id = "error.content.error_panel.margin.values.body.message" +layout_mode = 0 +offset_left = 30.0 +offset_top = 410.0 +offset_right = 454.0 +offset_bottom = 506.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 22 +text = "ПОПРОБУЙТЕ ЕЩЁ РАЗ\nИЛИ ВЫБЕРИТЕ ДРУГОЙ СПОСОБ" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Countdown" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "error.content.countdown" +visible = false +theme_type_variation = &"ErrorText" + +[node name="HomeButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "error.content.home_button" +layout_mode = 0 +offset_left = 84.0 +offset_top = 862.0 +offset_right = 636.0 +offset_bottom = 956.0 +theme_type_variation = &"PrimaryButton" +text = "НА ГЛАВНЫЙ ЭКРАН" + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("3_footer")] +layout_mode = 1 diff --git a/themes/space-pixel/package/scenes/screen_home.tscn b/themes/space-pixel/package/scenes/screen_home.tscn new file mode 100644 index 0000000..c60d6fa --- /dev/null +++ b/themes/space-pixel/package/scenes/screen_home.tscn @@ -0,0 +1,902 @@ +[gd_scene format=3 uid="uid://bq5v17i2jq3ca"] + +[ext_resource type="Theme" uid="uid://dfq25gg23x0xh" path="res://themes/space-pixel/package/ui_theme.tres" id="1_screen_theme"] +[ext_resource type="Texture2D" uid="uid://d1vaivunuaov7" path="res://themes/space-pixel/package/assets/background/background.png" id="2_screen_background"] +[ext_resource type="PackedScene" path="res://themes/space-pixel/package/background_decoration.tscn" id="3_screen_decoration"] +[ext_resource type="PackedScene" path="res://themes/space-pixel/package/visuals/home_top.tscn" id="6_theme_top"] +[ext_resource type="PackedScene" path="res://themes/space-pixel/package/visuals/home_advertisement.tscn" id="7_theme_promo"] +[ext_resource type="Texture2D" uid="uid://4uvc8mo74hy1" path="res://themes/space-pixel/package/assets/ui/bubble_large.png" id="8_price_bubble"] +[ext_resource type="Texture2D" uid="uid://coavqhyjqqkda" path="res://themes/space-pixel/package/assets/ui/panel_wide.png" id="9_panel_wide"] +[ext_resource type="Texture2D" uid="uid://c7wodqqe8yuij" path="res://themes/space-pixel/package/assets/ui/stock_low.png" id="10_stock_low"] +[ext_resource type="Texture2D" uid="uid://dt26n4n470hnm" path="res://themes/space-pixel/package/assets/ui/stock_many.png" id="10_stock_many"] +[ext_resource type="Texture2D" uid="uid://218aa00e5vns" path="res://themes/space-pixel/package/assets/ui/logo.png" id="11_logo"] +[ext_resource type="Texture2D" uid="uid://b4gr7gwf5jyy6" path="res://themes/space-pixel/package/assets/ui/button_long.png" id="12_button_long"] +[ext_resource type="Script" uid="uid://dqgrwkru1akc1" path="res://themes/space-pixel/package/scripts/home_reference_label.gd" id="13_reference_label"] + +[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_home_button_normal"] +content_margin_left = 18.0 +content_margin_top = 8.0 +content_margin_right = 18.0 +content_margin_bottom = 8.0 +texture = ExtResource("12_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) + +[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_home_button_pressed"] +content_margin_left = 18.0 +content_margin_top = 10.0 +content_margin_right = 18.0 +content_margin_bottom = 6.0 +texture = ExtResource("12_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) +modulate_color = Color(0.78, 0.84, 0.8, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_home_button_hover"] +content_margin_left = 18.0 +content_margin_top = 8.0 +content_margin_right = 18.0 +content_margin_bottom = 8.0 +texture = ExtResource("12_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) +modulate_color = Color(1.08, 1.08, 1.08, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_home_button_disabled"] +content_margin_left = 18.0 +content_margin_top = 8.0 +content_margin_right = 18.0 +content_margin_bottom = 8.0 +texture = ExtResource("12_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) +modulate_color = Color(0.42, 0.48, 0.54, 0.72) + +[node name="ThemeScreen_Home" type="Control" unique_id=1804336361] +texture_filter = 1 +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_screen_theme") +metadata/exchanger_screen_binding_id = "screen.home" +metadata/exchanger_self_contained_visuals = true + +[node name="ThemeBackground" type="TextureRect" parent="." unique_id=1992534830] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("2_screen_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." unique_id=646343961 instance=ExtResource("3_screen_decoration")] +scale = Vector2(0.666667, 0.666667) + +[node name="PetNavigation" type="Node2D" parent="." unique_id=616650133] +metadata/exchanger_pet_navigation_root = true +metadata/exchanger_pet_navigation_version = 1 +metadata/exchanger_pet_default_surface_id = &"floor" +metadata/exchanger_pet_roaming_bounds = Rect2(32, 48, 656, 1192) + +[node name="Floor" type="Node2D" parent="PetNavigation" unique_id=449547698] +position = Vector2(40, 1240) +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"floor" +metadata/exchanger_pet_surface_from_x = 40.0 +metadata/exchanger_pet_surface_to_x = 680.0 +metadata/exchanger_pet_surface_y = 1240.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(96, 360, 624) + +[node name="AdvertisementWindow" type="Node2D" parent="PetNavigation" unique_id=1891603462] +position = Vector2(144, 827) +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"advertisement_window" +metadata/exchanger_pet_surface_visibility_binding_id = "home.scroll.content.advertisement_panel" +metadata/exchanger_pet_surface_from_x = 144.0 +metadata/exchanger_pet_surface_to_x = 576.0 +metadata/exchanger_pet_surface_y = 827.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="PaymentWindow" type="Node2D" parent="PetNavigation" unique_id=836670345] +position = Vector2(40, 490) +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"payment_window" +metadata/exchanger_pet_surface_from_x = 40.0 +metadata/exchanger_pet_surface_to_x = 680.0 +metadata/exchanger_pet_surface_y = 490.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="StockWindow" type="Node2D" parent="PetNavigation" unique_id=826893497] +position = Vector2(40, 337) +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"stock_window" +metadata/exchanger_pet_surface_visibility_binding_id = "home.scroll.content.stock_status" +metadata/exchanger_pet_surface_from_x = 40.0 +metadata/exchanger_pet_surface_to_x = 680.0 +metadata/exchanger_pet_surface_y = 337.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="CashButton" type="Node2D" parent="PetNavigation" unique_id=761084871] +position = Vector2(76, 550) +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"cash_button" +metadata/exchanger_pet_surface_from_x = 76.0 +metadata/exchanger_pet_surface_to_x = 346.0 +metadata/exchanger_pet_surface_y = 550.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(211) + +[node name="CardButton" type="Node2D" parent="PetNavigation" unique_id=1171221989] +position = Vector2(375, 550) +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"card_button" +metadata/exchanger_pet_surface_from_x = 375.0 +metadata/exchanger_pet_surface_to_x = 645.0 +metadata/exchanger_pet_surface_y = 550.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(510) + +[node name="PriceWindow" type="Node2D" parent="PetNavigation" unique_id=600648955] +position = Vector2(153, 60) +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"price_window" +metadata/exchanger_pet_surface_from_x = 153.0 +metadata/exchanger_pet_surface_to_x = 397.0 +metadata/exchanger_pet_surface_y = 60.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(275) + +[node name="SpeechWindow" type="Node2D" parent="PetNavigation" unique_id=1263112927] +position = Vector2(406, 61) +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"speech_window" +metadata/exchanger_pet_surface_from_x = 406.0 +metadata/exchanger_pet_surface_to_x = 578.0 +metadata/exchanger_pet_surface_y = 61.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(492) + +[node name="LeftFloorToPayment" type="Node2D" parent="PetNavigation" unique_id=668670166] +position = Vector2(40, 1240) +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"left_floor_to_payment" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"payment_window" +metadata/exchanger_pet_climb_x = 40.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 490.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="RightFloorToPayment" type="Node2D" parent="PetNavigation" unique_id=1777127755] +position = Vector2(680, 1240) +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"right_floor_to_payment" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"payment_window" +metadata/exchanger_pet_climb_x = 680.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 490.0 +metadata/exchanger_pet_climb_side = &"right" + +[node name="LeftPaymentToStock" type="Node2D" parent="PetNavigation" unique_id=324222546] +position = Vector2(40, 490) +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"left_payment_to_stock" +metadata/exchanger_pet_climb_from_surface_id = &"payment_window" +metadata/exchanger_pet_climb_to_surface_id = &"stock_window" +metadata/exchanger_pet_climb_x = 40.0 +metadata/exchanger_pet_climb_from_y = 490.0 +metadata/exchanger_pet_climb_to_y = 337.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="RightPaymentToStock" type="Node2D" parent="PetNavigation" unique_id=294741352] +position = Vector2(680, 490) +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"right_payment_to_stock" +metadata/exchanger_pet_climb_from_surface_id = &"payment_window" +metadata/exchanger_pet_climb_to_surface_id = &"stock_window" +metadata/exchanger_pet_climb_x = 680.0 +metadata/exchanger_pet_climb_from_y = 490.0 +metadata/exchanger_pet_climb_to_y = 337.0 +metadata/exchanger_pet_climb_side = &"right" + +[node name="AdvertisementToFloor" type="Node2D" parent="PetNavigation" unique_id=867083212] +position = Vector2(576, 827) +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"advertisement_to_floor" +metadata/exchanger_pet_climb_from_surface_id = &"advertisement_window" +metadata/exchanger_pet_climb_to_surface_id = &"floor" +metadata/exchanger_pet_climb_x = 576.0 +metadata/exchanger_pet_climb_from_y = 827.0 +metadata/exchanger_pet_climb_to_y = 1240.0 +metadata/exchanger_pet_climb_side = &"right" + +[node name="AdvertisementToPayment" type="Node2D" parent="PetNavigation" unique_id=1856161019] +position = Vector2(576, 827) +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"advertisement_to_payment" +metadata/exchanger_pet_climb_from_surface_id = &"advertisement_window" +metadata/exchanger_pet_climb_to_surface_id = &"payment_window" +metadata/exchanger_pet_climb_x = 576.0 +metadata/exchanger_pet_climb_from_y = 827.0 +metadata/exchanger_pet_climb_to_y = 490.0 +metadata/exchanger_pet_climb_side = &"right" + +[node name="CashButtonToPayment" type="Node2D" parent="PetNavigation" unique_id=1999912169] +position = Vector2(76, 550) +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"cash_button_to_payment" +metadata/exchanger_pet_climb_from_surface_id = &"cash_button" +metadata/exchanger_pet_climb_to_surface_id = &"payment_window" +metadata/exchanger_pet_climb_x = 76.0 +metadata/exchanger_pet_climb_from_y = 550.0 +metadata/exchanger_pet_climb_to_y = 490.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="CardButtonToPayment" type="Node2D" parent="PetNavigation" unique_id=738124591] +position = Vector2(645, 550) +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"card_button_to_payment" +metadata/exchanger_pet_climb_from_surface_id = &"card_button" +metadata/exchanger_pet_climb_to_surface_id = &"payment_window" +metadata/exchanger_pet_climb_x = 645.0 +metadata/exchanger_pet_climb_from_y = 550.0 +metadata/exchanger_pet_climb_to_y = 490.0 +metadata/exchanger_pet_climb_side = &"right" + +[node name="PriceToStock" type="Node2D" parent="PetNavigation" unique_id=1593757411] +position = Vector2(397, 337) +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"price_to_stock" +metadata/exchanger_pet_climb_from_surface_id = &"stock_window" +metadata/exchanger_pet_climb_to_surface_id = &"price_window" +metadata/exchanger_pet_climb_x = 397.0 +metadata/exchanger_pet_climb_from_y = 337.0 +metadata/exchanger_pet_climb_to_y = 60.0 +metadata/exchanger_pet_climb_side = &"right" + +[node name="SpeechToStock" type="Node2D" parent="PetNavigation" unique_id=291349586] +position = Vector2(578, 337) +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"speech_to_stock" +metadata/exchanger_pet_climb_from_surface_id = &"stock_window" +metadata/exchanger_pet_climb_to_surface_id = &"speech_window" +metadata/exchanger_pet_climb_x = 578.0 +metadata/exchanger_pet_climb_from_y = 337.0 +metadata/exchanger_pet_climb_to_y = 61.0 +metadata/exchanger_pet_climb_side = &"right" + +[node name="SafeMargin" type="Control" parent="." unique_id=690477283] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Scroll" type="Control" parent="SafeMargin" unique_id=1061057886] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin/Scroll" unique_id=1159952639] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="ThemeTop" type="Control" parent="SafeMargin/Scroll/Content" unique_id=796161356] +anchors_preset = 0 +offset_right = 720.0 +offset_bottom = 300.0 +mouse_filter = 2 + +[node name="SpacePixelVisual" parent="SafeMargin/Scroll/Content/ThemeTop" unique_id=637818246 instance=ExtResource("6_theme_top")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="PricePanel" type="Control" parent="SafeMargin/Scroll/Content" unique_id=928304235] +anchors_preset = 0 +offset_right = 720.0 +offset_bottom = 280.0 +mouse_filter = 2 + +[node name="Bubble" type="TextureRect" parent="SafeMargin/Scroll/Content/PricePanel" unique_id=1751255244] +texture_filter = 1 +layout_mode = 0 +offset_left = 99.0 +offset_top = 58.0 +offset_right = 440.0 +offset_bottom = 228.0 +mouse_filter = 2 +texture = ExtResource("8_price_bubble") +expand_mode = 1 + +[node name="Margin" type="Control" parent="SafeMargin/Scroll/Content/PricePanel" unique_id=1737799399] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Content" type="Control" parent="SafeMargin/Scroll/Content/PricePanel/Margin" unique_id=1995513691] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Caption" type="Label" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content" unique_id=1162785127] +layout_mode = 0 +offset_left = 180.0 +offset_top = 67.0 +offset_right = 375.0 +offset_bottom = 126.0 +scale = Vector2(1.08, 0.891) +pivot_offset = Vector2(97.5, 29.5) +theme_type_variation = &"PanelTitle" +theme_override_colors/font_color = Color(0, 0.0627451, 0.552941, 1) +theme_override_font_sizes/font_size = 60 +text = "ЦЕНА" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="TokenCaption" type="Label" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content" unique_id=854323000] +layout_mode = 0 +offset_left = 178.0 +offset_top = 116.0 +offset_right = 373.0 +offset_bottom = 159.0 +scale = Vector2(1, 0.85) +pivot_offset = Vector2(97.5, 21.5) +theme_type_variation = &"PanelTitle" +theme_override_colors/font_color = Color(0, 0.0627451, 0.552941, 1) +theme_override_font_sizes/font_size = 40 +text = "ЖЕТОНА" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Price" type="Label" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content" unique_id=501765176] +layout_mode = 0 +offset_left = 181.0 +offset_top = 163.0 +offset_right = 379.0 +offset_bottom = 214.0 +scale = Vector2(1.183, 1) +pivot_offset = Vector2(99, 25.5) +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(0, 0, 0, 1) +theme_override_font_sizes/font_size = 36 +text = "10 руб." +horizontal_alignment = 1 +vertical_alignment = 1 +script = ExtResource("13_reference_label") +metadata/exchanger_binding_id = "home.scroll.content.price_panel.margin.content.price" + +[node name="SpeechText" type="Label" parent="SafeMargin/Scroll/Content" unique_id=329585995] +layout_mode = 0 +offset_left = 408.0 +offset_top = 62.0 +offset_right = 590.0 +offset_bottom = 135.0 +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(0, 0, 0, 1) +theme_override_constants/line_spacing = -2 +theme_override_font_sizes/font_size = 16 +text = "ТЕКСТ КЛИЕНТА" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 +clip_text = true +max_lines_visible = 3 +metadata/exchanger_binding_id = "home.scroll.content.speech_text" + +[node name="StockStatus" type="Control" parent="SafeMargin/Scroll/Content" unique_id=479011076] +anchors_preset = 0 +offset_left = 19.0 +offset_top = 315.0 +offset_right = 702.0 +offset_bottom = 486.0 +metadata/exchanger_binding_id = "home.scroll.content.stock_status" + +[node name="StockPanel" type="Control" parent="SafeMargin/Scroll/Content/StockStatus" unique_id=2048238646] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Background" type="TextureRect" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel" unique_id=1713879349] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -4.0 +offset_right = -1.0 +offset_bottom = 2.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("9_panel_wide") +expand_mode = 1 + +[node name="Margin" type="Control" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel" unique_id=1964468884] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Content" type="Control" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin" unique_id=920573597] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content" unique_id=893970727] +layout_mode = 0 +offset_left = 43.0 +offset_top = 21.0 +offset_right = 651.0 +offset_bottom = 64.0 +scale = Vector2(1.047, 0.88) +pivot_offset = Vector2(0, 21.5) +theme_type_variation = &"PanelTitle" +theme_override_colors/font_color = Color(0, 0.0627451, 0.552941, 1) +theme_override_font_sizes/font_size = 32 +text = "ДОСТУПНО К ВЫДАЧЕ" +vertical_alignment = 1 + +[node name="StatusRow" type="CenterContainer" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content" unique_id=883118960] +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_top = 81.0 +offset_bottom = 129.0 +grow_horizontal = 2 +mouse_filter = 2 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow" unique_id=2091302252] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="State" type="Label" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row" unique_id=966054930] +custom_minimum_size = Vector2(134, 48) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(0, 0, 0, 1) +theme_override_font_sizes/font_size = 30 +text = "МНОГО" +horizontal_alignment = 2 +vertical_alignment = 1 +metadata/exchanger_binding_id = "home.scroll.content.stock_status.state" + +[node name="Meter" type="Control" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row" unique_id=700812114] +custom_minimum_size = Vector2(172, 43) +layout_mode = 2 +mouse_filter = 2 + +[node name="Empty" type="TextureRect" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row/Meter" unique_id=45362449] +visible = false +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("10_stock_low") +expand_mode = 1 +stretch_mode = 5 +metadata/exchanger_binding_id = "home.scroll.content.stock_status.meter.empty" + +[node name="Low" type="TextureRect" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row/Meter" unique_id=1953397082] +visible = false +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("10_stock_low") +expand_mode = 1 +stretch_mode = 5 +metadata/exchanger_binding_id = "home.scroll.content.stock_status.meter.low" + +[node name="Enough" type="TextureRect" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row/Meter" unique_id=1501487737] +visible = false +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("10_stock_many") +expand_mode = 1 +stretch_mode = 5 +metadata/exchanger_binding_id = "home.scroll.content.stock_status.meter.enough" + +[node name="Much" type="TextureRect" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row/Meter" unique_id=1697165866] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("10_stock_many") +expand_mode = 1 +stretch_mode = 5 +metadata/exchanger_binding_id = "home.scroll.content.stock_status.meter.much" + +[node name="ApproximateCount" type="Label" parent="SafeMargin/Scroll/Content/StockStatus/StockPanel/Margin/Content/StatusRow/Row" unique_id=1920944711] +custom_minimum_size = Vector2(150, 48) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(0, 0, 0, 1) +theme_override_font_sizes/font_size = 27 +text = "<100" +vertical_alignment = 1 +clip_text = true +metadata/exchanger_binding_id = "home.scroll.content.stock_status.approximate_count" + +[node name="PaymentPanel" type="Control" parent="SafeMargin/Scroll/Content" unique_id=1345480221] +anchors_preset = 0 +offset_left = 19.0 +offset_top = 467.0 +offset_right = 702.0 +offset_bottom = 638.0 +mouse_filter = 2 + +[node name="Background" type="TextureRect" parent="SafeMargin/Scroll/Content/PaymentPanel" unique_id=1295094144] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -2.0 +offset_right = -1.0 +offset_bottom = -2.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("9_panel_wide") +expand_mode = 1 +stretch_mode = 5 + +[node name="Margin" type="Control" parent="SafeMargin/Scroll/Content/PaymentPanel" unique_id=991417353] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Content" type="Control" parent="SafeMargin/Scroll/Content/PaymentPanel/Margin" unique_id=1724603325] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/PaymentPanel/Margin/Content" unique_id=587555161] +layout_mode = 0 +offset_left = 42.0 +offset_top = 22.0 +offset_right = 650.0 +offset_bottom = 66.0 +scale = Vector2(1.05, 0.88) +pivot_offset = Vector2(0, 22) +theme_type_variation = &"PanelTitle" +theme_override_colors/font_color = Color(0, 0.0627451, 0.552941, 1) +theme_override_font_sizes/font_size = 32 +text = "ВЫБЕРИТЕ СПОСОБ ОПЛАТЫ" +vertical_alignment = 1 + +[node name="Buttons" type="Control" parent="SafeMargin/Scroll/Content/PaymentPanel/Margin/Content" unique_id=2136563870] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="CashButton" type="Button" parent="SafeMargin/Scroll/Content/PaymentPanel/Margin/Content/Buttons" unique_id=391197948] +layout_mode = 0 +offset_left = 57.0 +offset_top = 82.0 +offset_right = 327.0 +offset_bottom = 127.0 +theme_override_styles/normal = SubResource("StyleBoxTexture_home_button_normal") +theme_override_styles/pressed = SubResource("StyleBoxTexture_home_button_pressed") +theme_override_styles/hover = SubResource("StyleBoxTexture_home_button_hover") +theme_override_styles/disabled = SubResource("StyleBoxTexture_home_button_disabled") +theme_override_styles/focus = SubResource("StyleBoxTexture_home_button_normal") +metadata/exchanger_binding_id = "home.scroll.content.payment_panel.margin.content.buttons.cash_button" + +[node name="Caption" type="Label" parent="SafeMargin/Scroll/Content/PaymentPanel/Margin/Content/Buttons/CashButton" unique_id=1403095620] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 1.0 +offset_top = -2.0 +offset_right = 1.0 +offset_bottom = -2.0 +grow_horizontal = 2 +grow_vertical = 2 +scale = Vector2(1.02, 0.83) +pivot_offset = Vector2(135, 22.5) +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(0, 0.0627451, 0.552941, 1) +theme_override_font_sizes/font_size = 32 +text = "НАЛИЧНЫЕ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="CardButton" type="Button" parent="SafeMargin/Scroll/Content/PaymentPanel/Margin/Content/Buttons" unique_id=1364251695] +layout_mode = 0 +offset_left = 356.0 +offset_top = 82.0 +offset_right = 626.0 +offset_bottom = 127.0 +theme_override_styles/normal = SubResource("StyleBoxTexture_home_button_normal") +theme_override_styles/pressed = SubResource("StyleBoxTexture_home_button_pressed") +theme_override_styles/hover = SubResource("StyleBoxTexture_home_button_hover") +theme_override_styles/disabled = SubResource("StyleBoxTexture_home_button_disabled") +theme_override_styles/focus = SubResource("StyleBoxTexture_home_button_normal") +metadata/exchanger_binding_id = "home.scroll.content.payment_panel.margin.content.buttons.card_button" + +[node name="Caption" type="Label" parent="SafeMargin/Scroll/Content/PaymentPanel/Margin/Content/Buttons/CardButton" unique_id=1993220316] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 1.0 +offset_top = -2.0 +offset_right = 1.0 +offset_bottom = -2.0 +grow_horizontal = 2 +grow_vertical = 2 +scale = Vector2(1.02, 0.83) +pivot_offset = Vector2(135, 22.5) +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(0, 0.0627451, 0.552941, 1) +theme_override_font_sizes/font_size = 32 +text = "БЕЗНАЛИЧНЫЕ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="AdvertisementPanel" type="Control" parent="SafeMargin/Scroll/Content" unique_id=1981193011] +anchors_preset = 0 +offset_top = 744.0 +offset_right = 720.0 +offset_bottom = 1086.0 +mouse_filter = 2 +metadata/exchanger_binding_id = "home.scroll.content.advertisement_panel" + +[node name="Layer" type="Control" parent="SafeMargin/Scroll/Content/AdvertisementPanel" unique_id=1438117421] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="ThemeFrame" type="Control" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Layer" unique_id=1074573756] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="SpacePixelVisual" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Layer/ThemeFrame" unique_id=696829693 instance=ExtResource("7_theme_promo")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Layer" unique_id=333120086] +anchors_preset = 0 +offset_left = 154.0 +offset_top = 92.0 +offset_right = 567.0 +offset_bottom = 306.0 +metadata/exchanger_binding_id = "home.scroll.content.advertisement_panel.layer.content" + +[node name="Poster" type="TextureRect" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content" unique_id=123745090] +visible = false +layout_mode = 1 +anchors_preset = -1 +anchor_top = 0.0050000004 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -0.07000005 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +expand_mode = 1 +stretch_mode = 6 +metadata/exchanger_binding_id = "home.scroll.content.advertisement_panel.layer.content.poster" + +[node name="VideoPlayer" type="VideoStreamPlayer" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content" unique_id=1918075027] +layout_mode = 1 +anchors_preset = -1 +anchor_top = 0.0050000004 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -0.07000005 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +expand = true +metadata/exchanger_binding_id = "home.scroll.content.advertisement_panel.layer.content.video_player" + +[node name="FallbackLabel" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content" unique_id=746293611] +layout_mode = 1 +anchors_preset = -1 +anchor_left = -0.0050000004 +anchor_top = 0.010000001 +anchor_right = 1.0070001 +anchor_bottom = 1.029 +offset_left = 50.428005 +offset_top = -0.09000015 +offset_right = -31.273163 +offset_bottom = -0.06100464 +grow_horizontal = 2 +grow_vertical = 2 +scale = Vector2(1.247, 1) +pivot_offset = Vector2(204, 104.5) +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 20 +text = "(ВИДЕОРЕКЛАМА)" +horizontal_alignment = 1 +vertical_alignment = 1 +metadata/exchanger_binding_id = "home.scroll.content.advertisement_panel.layer.content.fallback_label" + +[node name="Footer" type="Control" parent="SafeMargin/Scroll/Content" unique_id=1745688783] +anchors_preset = 0 +offset_top = 1128.0 +offset_right = 720.0 +offset_bottom = 1280.0 + +[node name="Margin" type="Control" parent="SafeMargin/Scroll/Content/Footer" unique_id=2069983039] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin/Scroll/Content/Footer/Margin" unique_id=781378825] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Logo" type="TextureRect" parent="SafeMargin/Scroll/Content/Footer/Margin/Content" unique_id=1609648852] +texture_filter = 1 +layout_mode = 0 +offset_left = -18.0 +offset_top = 24.0 +offset_right = 323.0 +offset_bottom = 109.0 +mouse_filter = 2 +texture = ExtResource("11_logo") +expand_mode = 1 +stretch_mode = 5 + +[node name="Brand" type="Label" parent="SafeMargin/Scroll/Content/Footer/Margin/Content" unique_id=334741666] +visible = false +layout_mode = 0 +offset_right = 1.0 +offset_bottom = 1.0 +theme_type_variation = &"SupportText" +text = "РАЗМЕН +ЖЕТОНОВ" +metadata/exchanger_binding_id = "home.scroll.content.footer.margin.content.brand" + +[node name="Support" type="Label" parent="SafeMargin/Scroll/Content/Footer/Margin/Content" unique_id=1638210773] +layout_mode = 0 +offset_left = 341.0 +offset_top = 19.0 +offset_right = 677.0 +offset_bottom = 105.0 +scale = Vector2(1.35, 1) +pivot_offset = Vector2(0, 43) +theme_type_variation = &"SupportText" +theme_override_constants/line_spacing = 10 +theme_override_font_sizes/font_size = 24 +text = "ТЕХ ПОДДЕРЖКА: ++7-XXX-XXX-XX-XX" +vertical_alignment = 1 +script = ExtResource("13_reference_label") +format_mode = "support" +metadata/exchanger_binding_id = "home.scroll.content.footer.margin.content.support" + +[node name="ConnectionStatus" type="Control" parent="SafeMargin/Scroll/Content/Footer/Margin/Content" unique_id=1740402169] +visible = false +anchors_preset = 0 +offset_left = 110.0 +offset_top = -68.0 +offset_right = 610.0 +offset_bottom = -8.0 +metadata/exchanger_binding_id = "home.scroll.content.footer.margin.content.connection_status" + +[node name="Panel" type="PanelContainer" parent="SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus" unique_id=1920745621] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_type_variation = &"StatusPanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus/Panel" unique_id=1995352139] +layout_mode = 2 +theme_override_constants/margin_left = 18 +theme_override_constants/margin_top = 10 +theme_override_constants/margin_right = 18 +theme_override_constants/margin_bottom = 10 + +[node name="Label" type="Label" parent="SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus/Panel/Margin" unique_id=1995483906] +layout_mode = 2 +theme_type_variation = &"StatusText" +theme_override_font_sizes/font_size = 18 +text = "СЕРВИС: НЕТ СВЯЗИ С КОНТРОЛЛЕРОМ" +horizontal_alignment = 1 +vertical_alignment = 1 +metadata/exchanger_binding_id = "home.scroll.content.footer.margin.content.connection_status.label" diff --git a/themes/space-pixel/package/scenes/screen_service_access.tscn b/themes/space-pixel/package/scenes/screen_service_access.tscn new file mode 100644 index 0000000..16465fb --- /dev/null +++ b/themes/space-pixel/package/scenes/screen_service_access.tscn @@ -0,0 +1,408 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_screen_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="2_screen_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="3_screen_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="2_footer"] +[ext_resource type="Texture2D" path="../assets/ui/interactive_pet/home.png" id="4_home_icon"] + +[node name="ThemeScreen_ServiceAccess" type="Control"] +texture_filter = 1 +metadata/exchanger_screen_binding_id = "screen.service_access" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_screen_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("2_screen_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("3_screen_decoration")] +scale = Vector2(0.666667, 0.666667) + +[node name="PetNavigation" type="Node2D" parent="."] +metadata/exchanger_pet_navigation_root = true +metadata/exchanger_pet_navigation_version = 1 +metadata/exchanger_pet_default_surface_id = &"floor" +metadata/exchanger_pet_roaming_bounds = Rect2(32, 32, 656, 1208) + +[node name="Floor" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"floor" +metadata/exchanger_pet_surface_from_x = 40.0 +metadata/exchanger_pet_surface_to_x = 680.0 +metadata/exchanger_pet_surface_y = 1240.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(96, 360, 624) + +[node name="AccessWindow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"access_window" +metadata/exchanger_pet_surface_from_x = 60.0 +metadata/exchanger_pet_surface_to_x = 660.0 +metadata/exchanger_pet_surface_y = 152.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="KeypadRow1" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"keypad_row_1" +metadata/exchanger_pet_surface_from_x = 180.0 +metadata/exchanger_pet_surface_to_x = 540.0 +metadata/exchanger_pet_surface_y = 318.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(236, 360, 484) + +[node name="KeypadRow2" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"keypad_row_2" +metadata/exchanger_pet_surface_from_x = 180.0 +metadata/exchanger_pet_surface_to_x = 540.0 +metadata/exchanger_pet_surface_y = 442.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(236, 360, 484) + +[node name="KeypadRow3" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"keypad_row_3" +metadata/exchanger_pet_surface_from_x = 180.0 +metadata/exchanger_pet_surface_to_x = 540.0 +metadata/exchanger_pet_surface_y = 566.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(236, 360, 484) + +[node name="KeypadRow4" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"keypad_row_4" +metadata/exchanger_pet_surface_from_x = 180.0 +metadata/exchanger_pet_surface_to_x = 540.0 +metadata/exchanger_pet_surface_y = 690.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(236, 360, 484) + +[node name="EnterButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"enter_button" +metadata/exchanger_pet_surface_from_x = 82.0 +metadata/exchanger_pet_surface_to_x = 638.0 +metadata/exchanger_pet_surface_y = 822.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="CancelButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"cancel_button" +metadata/exchanger_pet_surface_from_x = 110.0 +metadata/exchanger_pet_surface_to_x = 610.0 +metadata/exchanger_pet_surface_y = 940.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="HomeNavigationButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"home_navigation_button" +metadata/exchanger_pet_surface_from_x = 49.0 +metadata/exchanger_pet_surface_to_x = 101.0 +metadata/exchanger_pet_surface_y = 42.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(75) + +[node name="FloorToHomeNavigation" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_home_navigation" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"home_navigation_button" +metadata/exchanger_pet_climb_x = 49.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 42.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToAccess" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_access" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"access_window" +metadata/exchanger_pet_climb_x = 60.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 152.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToRow1" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_row_1" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"keypad_row_1" +metadata/exchanger_pet_climb_x = 180.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 318.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToRow2" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_row_2" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"keypad_row_2" +metadata/exchanger_pet_climb_x = 180.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 442.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToRow3" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_row_3" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"keypad_row_3" +metadata/exchanger_pet_climb_x = 180.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 566.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToRow4" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_row_4" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"keypad_row_4" +metadata/exchanger_pet_climb_x = 180.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 690.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToEnter" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_enter" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"enter_button" +metadata/exchanger_pet_climb_x = 82.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 822.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToCancel" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_cancel" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"cancel_button" +metadata/exchanger_pet_climb_x = 110.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 940.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "service_access.content.home_navigation_button" +layout_mode = 0 +offset_left = 49.0 +offset_top = 42.0 +offset_right = 101.0 +offset_bottom = 94.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" +icon = ExtResource("4_home_icon") +expand_icon = true +icon_max_width = 30 + +[node name="Header" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "service_access.content.header" +layout_mode = 0 +offset_left = 40.0 +offset_top = 18.0 +offset_right = 680.0 +offset_bottom = 72.0 +theme_type_variation = &"ScreenTitle" +theme_override_font_sizes/font_size = 30 +text = "ВХОД В НАСТРОЙКИ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "service_access.content.description" +layout_mode = 0 +offset_left = 52.0 +offset_top = 78.0 +offset_right = 668.0 +offset_bottom = 140.0 +theme_type_variation = &"ScreenDescription" +theme_override_font_sizes/font_size = 16 +text = "ВВЕДИТЕ PIN-КОД, ЧТОБЫ ОТКРЫТЬ НАСТРОЙКИ" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="AccessPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 60.0 +offset_top = 152.0 +offset_right = 660.0 +offset_bottom = 916.0 +theme_type_variation = &"WhitePanel" + +[node name="Fields" type="VBoxContainer" parent="SafeMargin/Content/AccessPanel"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="PinLabel" type="Label" parent="SafeMargin/Content/AccessPanel/Fields"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.pin_label" +custom_minimum_size = Vector2(0, 32) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "PIN-КОД" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="PinDisplay" type="Label" parent="SafeMargin/Content/AccessPanel/Fields"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.pin_display" +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_type_variation = &"StatusText" +theme_override_font_sizes/font_size = 34 +text = "—" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Status" type="Label" parent="SafeMargin/Content/AccessPanel/Fields"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.status" +custom_minimum_size = Vector2(0, 38) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 17 +text = "ВВЕДИТЕ PIN-КОД" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="KeypadCenter" type="CenterContainer" parent="SafeMargin/Content/AccessPanel/Fields"] +custom_minimum_size = Vector2(0, 484) +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Keypad" type="GridContainer" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter"] +layout_mode = 2 +theme_override_constants/h_separation = 12 +theme_override_constants/v_separation = 12 +columns = 3 + +[node name="Digit1" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit1" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "1" + +[node name="Digit2" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit2" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "2" + +[node name="Digit3" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit3" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "3" + +[node name="Digit4" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit4" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "4" + +[node name="Digit5" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit5" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "5" + +[node name="Digit6" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit6" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "6" + +[node name="Digit7" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit7" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "7" + +[node name="Digit8" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit8" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "8" + +[node name="Digit9" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit9" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "9" + +[node name="Clear" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.clear" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +theme_override_font_sizes/font_size = 16 +text = "СБРОС" + +[node name="Digit0" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.digit0" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "0" + +[node name="Erase" type="Button" parent="SafeMargin/Content/AccessPanel/Fields/KeypadCenter/Keypad"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.keypad.erase" +custom_minimum_size = Vector2(112, 112) +layout_mode = 2 +theme_type_variation = &"KeypadButton" +text = "⌫" + +[node name="EnterButton" type="Button" parent="SafeMargin/Content/AccessPanel/Fields"] +metadata/exchanger_binding_id = "service_access.content.access_panel.fields.enter_button" +custom_minimum_size = Vector2(0, 76) +layout_mode = 2 +theme_type_variation = &"PrimaryButton" +theme_override_font_sizes/font_size = 30 +text = "ВОЙТИ" + +[node name="CancelButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "service_access.content.cancel_button" +layout_mode = 0 +offset_left = 110.0 +offset_top = 940.0 +offset_right = 610.0 +offset_bottom = 1026.0 +theme_type_variation = &"CancelButton" +text = "ОТМЕНА" + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("2_footer")] +layout_mode = 1 diff --git a/themes/space-pixel/package/scenes/screen_settings.tscn b/themes/space-pixel/package/scenes/screen_settings.tscn new file mode 100644 index 0000000..74a3919 --- /dev/null +++ b/themes/space-pixel/package/scenes/screen_settings.tscn @@ -0,0 +1,3992 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_screen_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="2_screen_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="3_screen_decoration"] +[ext_resource type="PackedScene" path="../components/on_screen_keyboards.tscn" id="4_keyboards"] + +[node name="ThemeScreen_Settings" type="Control" unique_id=1004620256] +texture_filter = 1 +metadata/exchanger_screen_binding_id = "screen.settings" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_screen_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("2_screen_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("3_screen_decoration")] +scale = Vector2(0.666667, 0.666667) + +[node name="SafeMargin" type="MarginContainer" parent="." unique_id=1771276379] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 28.0 +offset_top = 28.0 +offset_right = -28.0 +offset_bottom = -28.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Scroll" type="ScrollContainer" parent="SafeMargin" unique_id=1730433592] +metadata/exchanger_binding_id = "settings.scroll" +custom_minimum_size = Vector2(0, 1100) +layout_mode = 2 +size_flags_vertical = 0 +horizontal_scroll_mode = 0 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll" unique_id=1670934776] +metadata/exchanger_binding_id = "settings.scroll.content" +custom_minimum_size = Vector2(0, 1173) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 19 + +[node name="Header" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=154820359] +metadata/exchanger_binding_id = "settings.scroll.content.header" +custom_minimum_size = Vector2(0, 107) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/Header" unique_id=2033082988] +layout_mode = 2 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 19 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 19 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/Header/Margin" unique_id=1892810197] +layout_mode = 2 +theme_override_constants/separation = 8 +alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/Header/Margin/Content" unique_id=1212756635] +metadata/exchanger_binding_id = "settings.scroll.content.header.margin.content.title" +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 31 +text = "НАСТРОЙКИ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="SectionTabs" type="GridContainer" parent="SafeMargin/Scroll/Content" unique_id=1099846765] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs" +custom_minimum_size = Vector2(0, 157) +layout_mode = 2 +theme_override_constants/h_separation = 12 +theme_override_constants/v_separation = 12 +columns = 3 + +[node name="GeneralButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=390094413] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.general_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ОБЩИЕ" + +[node name="AudioButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=1209434198] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.audio_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ЗВУК" + +[node name="AdvertisementButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=931340907] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.advertisement_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ПРОМО" + +[node name="AppearanceButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=1803958748] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.appearance_button" +visible = false +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ТЕМА" + +[node name="PricingButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=2043412965] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.pricing_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ТАРИФЫ" + +[node name="PaymentButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=750238068] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.payment_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ОПЛАТА" + +[node name="ServiceButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs"] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.service_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "СЕРВИС" + +[node name="EquipmentButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=436932014] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.equipment_button" +visible = false +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ОБОРУД." + +[node name="DiagnosticsButton" type="Button" parent="SafeMargin/Scroll/Content/SectionTabs" unique_id=1314343470] +metadata/exchanger_binding_id = "settings.scroll.content.section_tabs.diagnostics_button" +visible = false +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +text = "ДИАГНОСТ." + +[node name="BrandingPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=2078006035] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel" +custom_minimum_size = Vector2(0, 439) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/BrandingPanel" unique_id=1084754695] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin" unique_id=288780598] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=1441920640] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ТЕКСТ НА ГЛАВНОМ ЭКРАНЕ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=314471939] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Здесь можно изменить свой текст для покупателей и контакты техподдержки." +autowrap_mode = 2 + +[node name="ApplicationNameLabel" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=1972340592] +visible = false +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "НАЗВАНИЕ • ДО 48 ЗНАКОВ" + +[node name="ApplicationName" type="LineEdit" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=923442286] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel.margin.content.application_name" +visible = false +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +placeholder_text = "РАЗМЕН ЖЕТОНОВ" +max_length = 48 + +[node name="ApplicationNameError" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=172647480] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel.margin.content.application_name_error" +visible = false +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="ShortTextLabel" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "СВОЙ ТЕКСТ • ДО 48 ЗНАКОВ" + +[node name="ShortText" type="LineEdit" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel.margin.content.short_text" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +placeholder_text = "ТЕКСТ КЛИЕНТА" +max_length = 48 + +[node name="ShortTextError" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel.margin.content.short_text_error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="SupportPhoneLabel" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=1058802895] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "ТЕЛЕФОН ПОДДЕРЖКИ • ДО 80 ЗНАКОВ" + +[node name="SupportPhone" type="LineEdit" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=1224448315] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel.margin.content.support_phone" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +placeholder_text = "+7 (000) 000-00-00" +max_length = 80 + +[node name="SupportPhoneError" type="Label" parent="SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" unique_id=939950598] +metadata/exchanger_binding_id = "settings.scroll.content.branding_panel.margin.content.support_phone_error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="AnimatedBannerTextsPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.animated_banner_texts_panel" +custom_minimum_size = Vector2(0, 560) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ТЕКСТЫ НА БАННЕРАХ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Тексты отображаются на нижних анимированных плашках с НЛО. В шаблоне таймера используйте {seconds} для числа секунд." +autowrap_mode = 2 + +[node name="CashPaymentLabel" type="Label" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "НАЛИЧНЫЕ • ТЕКСТ НА БАННЕРЕ" + +[node name="CashPayment" type="LineEdit" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +max_length = 120 + +[node name="CardAmountLabel" type="Label" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "КАРТА • ВЫБОР СУММЫ" + +[node name="CardAmount" type="LineEdit" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +max_length = 120 + +[node name="CardCustomAmountLabel" type="Label" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "КАРТА • ВВОД СУММЫ" + +[node name="CardCustomAmount" type="LineEdit" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +max_length = 120 + +[node name="CardTerminalCountdownLabel" type="Label" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "КАРТА • ТАЙМЕР • {seconds} = СЕКУНДЫ" + +[node name="CardTerminalCountdown" type="LineEdit" parent="SafeMargin/Scroll/Content/AnimatedBannerTextsPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +max_length = 120 + +[node name="BrandingPreviewPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=378115165] +metadata/exchanger_binding_id = "settings.scroll.content.branding_preview_panel" +visible = false +custom_minimum_size = Vector2(0, 273) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/BrandingPreviewPanel" unique_id=253672003] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin" unique_id=1113826782] +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content" unique_id=160381550] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "КАК ЭТО УВИДИТ ПОКУПАТЕЛЬ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="ApplicationName" type="Label" parent="SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content" unique_id=808413130] +metadata/exchanger_binding_id = "settings.scroll.content.branding_preview_panel.margin.content.application_name" +visible = false +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "РАЗМЕН ЖЕТОНОВ" +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="ShortText" type="Label" parent="SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.branding_preview_panel.margin.content.short_text" +visible = false +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ТЕКСТ КЛИЕНТА" +horizontal_alignment = 1 +autowrap_mode = 2 +clip_text = true +max_lines_visible = 3 + +[node name="SupportPhone" type="Label" parent="SafeMargin/Scroll/Content/BrandingPreviewPanel/Margin/Content" unique_id=85872323] +metadata/exchanger_binding_id = "settings.scroll.content.branding_preview_panel.margin.content.support_phone" +visible = false +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ТЕХПОДДЕРЖКА • Телефон поддержки уточняется" +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="SecurityPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1879849850] +metadata/exchanger_binding_id = "settings.scroll.content.security_panel" +custom_minimum_size = Vector2(0, 313) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/SecurityPanel" unique_id=962456073] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/SecurityPanel/Margin" unique_id=607567481] +layout_mode = 2 +theme_override_constants/separation = 13 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/SecurityPanel/Margin/Content" unique_id=830468842] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ЗАЩИТА НАСТРОЕК" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/SecurityPanel/Margin/Content" unique_id=1393678109] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Включите защиту, если хотите открывать настройки только по PIN-коду. Приложение предложит придумать его при следующем входе." +autowrap_mode = 2 + +[node name="Enabled" type="CheckButton" parent="SafeMargin/Scroll/Content/SecurityPanel/Margin/Content" unique_id=558474112] +metadata/exchanger_binding_id = "settings.scroll.content.security_panel.margin.content.enabled" +custom_minimum_size = Vector2(0, 55) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ЗАЩИТИТЬ НАСТРОЙКИ PIN-КОДОМ" + +[node name="ResetPinButton" type="Button" parent="SafeMargin/Scroll/Content/SecurityPanel/Margin/Content" unique_id=1063988680] +metadata/exchanger_binding_id = "settings.scroll.content.security_panel.margin.content.reset_pin_button" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ПРИДУМАТЬ НОВЫЙ PIN" + +[node name="Status" type="Label" parent="SafeMargin/Scroll/Content/SecurityPanel/Margin/Content" unique_id=419034981] +metadata/exchanger_binding_id = "settings.scroll.content.security_panel.margin.content.status" +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Защита выключена." +autowrap_mode = 2 + +[node name="MasterPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1198367056] +metadata/exchanger_binding_id = "settings.scroll.content.master_panel" +custom_minimum_size = Vector2(0, 273) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/MasterPanel" unique_id=1657463854] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/MasterPanel/Margin" unique_id=1222038005] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/MasterPanel/Margin/Content" unique_id=2077123349] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ОБЩАЯ ГРОМКОСТЬ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/MasterPanel/Margin/Content" unique_id=1560394233] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Громкость всех звуков автомата." +autowrap_mode = 2 + +[node name="VolumeRow" type="HBoxContainer" parent="SafeMargin/Scroll/Content/MasterPanel/Margin/Content" unique_id=150712937] +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="Slider" type="HSlider" parent="SafeMargin/Scroll/Content/MasterPanel/Margin/Content/VolumeRow" unique_id=2060895330] +metadata/exchanger_binding_id = "settings.scroll.content.master_panel.margin.content.volume_row.slider" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +size_flags_horizontal = 3 +value = 100.0 + +[node name="Value" type="Label" parent="SafeMargin/Scroll/Content/MasterPanel/Margin/Content/VolumeRow" unique_id=2057703712] +metadata/exchanger_binding_id = "settings.scroll.content.master_panel.margin.content.volume_row.value" +custom_minimum_size = Vector2(87, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "100%" +horizontal_alignment = 2 +vertical_alignment = 1 + +[node name="Mute" type="CheckButton" parent="SafeMargin/Scroll/Content/MasterPanel/Margin/Content" unique_id=1848500350] +metadata/exchanger_binding_id = "settings.scroll.content.master_panel.margin.content.mute" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +text = "ОТКЛЮЧИТЬ ВСЕ ЗВУКИ" + +[node name="AdvertisementPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1140295799] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_panel" +custom_minimum_size = Vector2(0, 220) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AdvertisementPanel" unique_id=746826493] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Margin" unique_id=652964597] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content" unique_id=912048069] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ГРОМКОСТЬ ПРОМО-РОЛИКА" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content" unique_id=1736411184] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Громкость звука в промо-роликах." +autowrap_mode = 2 + +[node name="VolumeRow" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content" unique_id=405566535] +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="Slider" type="HSlider" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content/VolumeRow" unique_id=530125674] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_panel.margin.content.volume_row.slider" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +size_flags_horizontal = 3 +value = 100.0 + +[node name="Value" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPanel/Margin/Content/VolumeRow" unique_id=2101989997] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_panel.margin.content.volume_row.value" +custom_minimum_size = Vector2(87, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "100%" +horizontal_alignment = 2 +vertical_alignment = 1 + +[node name="TestPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1383634412] +metadata/exchanger_binding_id = "settings.scroll.content.test_panel" +custom_minimum_size = Vector2(0, 207) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/TestPanel" unique_id=130409172] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/TestPanel/Margin" unique_id=1257657298] +layout_mode = 2 +theme_override_constants/separation = 15 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/TestPanel/Margin/Content" unique_id=1433324321] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ПРОВЕРКА" +vertical_alignment = 1 + +[node name="TestSoundButton" type="Button" parent="SafeMargin/Scroll/Content/TestPanel/Margin/Content" unique_id=1609783017] +metadata/exchanger_binding_id = "settings.scroll.content.test_panel.margin.content.test_sound_button" +custom_minimum_size = Vector2(0, 75) +layout_mode = 2 +text = "ПРОВЕРИТЬ ЗВУК" + +[node name="Status" type="Label" parent="SafeMargin/Scroll/Content/TestPanel/Margin/Content" unique_id=2061523529] +metadata/exchanger_binding_id = "settings.scroll.content.test_panel.margin.content.status" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Настройки сохраняются автоматически." +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="AdvertisementSettingsPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=21455015] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_settings_panel" +custom_minimum_size = Vector2(0, 440) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel" unique_id=1405250504] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin" unique_id=1013830763] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=1786577231] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ПРОМО НА ГЛАВНОМ ЭКРАНЕ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=336744550] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Во время оплаты и выдачи жетонов промо скрывается." +autowrap_mode = 2 + +[node name="Enabled" type="CheckButton" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=1616831303] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_settings_panel.margin.content.enabled" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +text = "ПОКАЗЫВАТЬ ПРОМО" + +[node name="Muted" type="CheckButton" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=672717699] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_settings_panel.margin.content.muted" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +text = "ПРОМО-РОЛИК БЕЗ ЗВУКА" + +[node name="FallbackLabel" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=1641720039] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "ТЕКСТ ВМЕСТО ПРОМО • ДО 120 ЗНАКОВ" + +[node name="FallbackText" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=228850090] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_settings_panel.margin.content.fallback_text" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +placeholder_text = "ПРОМО-БЛОК" +max_length = 120 + +[node name="FallbackError" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementSettingsPanel/Margin/Content" unique_id=1143025908] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_settings_panel.margin.content.fallback_error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="AdvertisementPosterPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=455807788] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_poster_panel" +custom_minimum_size = Vector2(0, 370) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel" unique_id=1508974411] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin" unique_id=245064043] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content" unique_id=816209396] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "КАРТИНКА ВМЕСТО ПРОМО-РОЛИКА" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content" unique_id=1218265445] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Укажите путь к картинке на этом компьютере. Подойдут PNG, JPG и WEBP." +autowrap_mode = 2 + +[node name="PosterPath" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content" unique_id=732541286] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_poster_panel.margin.content.poster_path" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +placeholder_text = "Например: D:/Промо/постер.png" +max_length = 512 + +[node name="SelectButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_poster_panel.margin.content.select_button" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ВЫБРАТЬ КАРТИНКУ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPosterPanel/Margin/Content" unique_id=2129938351] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_poster_panel.margin.content.error" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="AdvertisementPlaylistPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1828835854] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel" +custom_minimum_size = Vector2(0, 287) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel" unique_id=1696350498] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin" unique_id=50006063] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content" unique_id=483709700] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ПРОМО-РОЛИКИ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content" unique_id=1727337488] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Добавьте до 12 промо-роликов в нужном порядке. Поддерживаются файлы OGV." +autowrap_mode = 2 + +[node name="Rows" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content" unique_id=1297952818] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="EmptyState" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.empty_state" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(1, 1, 1, 1) +text = "Промо-ролики пока не добавлены. Будет показана картинка или текст вместо промо." +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Slot00" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot00"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_00.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot01" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot01"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_01.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot02" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot02"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_02.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot03" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot03"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_03.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot04" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot04"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_04.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot05" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot05"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_05.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot06" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot06"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_06.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot07" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot07"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_07.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot08" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot08"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_08.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot09" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot09"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_09.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot10" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot10"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_10.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot11" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Path" type="LineEdit" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.path" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 23 +placeholder_text = "user://advertisement/video.ogv" +max_length = 260 + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot11"] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_11.error" +custom_minimum_size = Vector2(0, 28) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + + +[node name="AddButton" type="Button" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content" unique_id=1496917419] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.add_button" +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ВЫБРАТЬ И ДОБАВИТЬ ПРОМО-РОЛИК" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content" unique_id=1649782226] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_playlist_panel.margin.content.error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="AdvertisementPreviewPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=193189850] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_preview_panel" +visible = false +custom_minimum_size = Vector2(0, 167) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AdvertisementPreviewPanel" unique_id=923292165] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 20 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AdvertisementPreviewPanel/Margin" unique_id=617595689] +layout_mode = 2 +theme_override_constants/separation = 11 +alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPreviewPanel/Margin/Content" unique_id=572843131] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "ПРЕДВАРИТЕЛЬНЫЙ ИТОГ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Summary" type="Label" parent="SafeMargin/Scroll/Content/AdvertisementPreviewPanel/Margin/Content" unique_id=1780700717] +metadata/exchanger_binding_id = "settings.scroll.content.advertisement_preview_panel.margin.content.summary" +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "Промо включено • картинка не выбрана • промо-роликов: 0 +Текст вместо промо: ПРОМО-БЛОК" +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="AppearanceThemePanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1626819618] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_theme_panel" +visible = false +custom_minimum_size = Vector2(0, 460) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AppearanceThemePanel" unique_id=1429095905] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin" unique_id=1782922611] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=1789600456] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ВНЕШНЯЯ ТЕМА" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=1804153132] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Приложение всегда имеет одну встроенную тему. Единственный внешний DLC полностью заменит её после перезапуска, если пакет пройдёт проверку." +autowrap_mode = 2 + +[node name="ExternalEnabled" type="CheckButton" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=1434776624] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_theme_panel.margin.content.external_enabled" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +text = "ИСПОЛЬЗОВАТЬ ВНЕШНИЙ DLC" + +[node name="PathLabel" type="Label" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=1039205902] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "ПУТЬ К ПАКЕТУ .PCK" + +[node name="ExternalPath" type="LineEdit" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=435170720] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_theme_panel.margin.content.external_path" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +placeholder_text = "user://theme_dlc/active_theme.pck" +max_length = 512 + +[node name="PathError" type="Label" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=538466093] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_theme_panel.margin.content.path_error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 13 +autowrap_mode = 2 + +[node name="PackageStatus" type="Label" parent="SafeMargin/Scroll/Content/AppearanceThemePanel/Margin/Content" unique_id=1243517659] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_theme_panel.margin.content.package_status" +custom_minimum_size = Vector2(0, 52) +layout_mode = 2 +theme_override_font_sizes/font_size = 14 +text = "Пакет будет проверен при запуске." +autowrap_mode = 2 + +[node name="AppearanceEffectsPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=2118148985] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_effects_panel" +visible = false +custom_minimum_size = Vector2(0, 260) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AppearanceEffectsPanel" unique_id=1463853577] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AppearanceEffectsPanel/Margin" unique_id=460821403] +layout_mode = 2 +theme_override_constants/separation = 13 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AppearanceEffectsPanel/Margin/Content" unique_id=1219490666] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ЭФФЕКТЫ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/AppearanceEffectsPanel/Margin/Content" unique_id=1195218204] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Облегчённый режим отключает необязательные анимированные спрайты, но сохраняет статический фон и весь интерфейс." +autowrap_mode = 2 + +[node name="ReducedEffects" type="CheckButton" parent="SafeMargin/Scroll/Content/AppearanceEffectsPanel/Margin/Content" unique_id=336415120] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_effects_panel.margin.content.reduced_effects" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +text = "ОБЛЕГЧЁННЫЙ РЕЖИМ" + +[node name="AppearanceNoticePanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=391044970] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_notice_panel" +visible = false +custom_minimum_size = Vector2(0, 220) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/AppearanceNoticePanel" unique_id=716854481] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 20 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin" unique_id=400003096] +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin/Content" unique_id=1138899540] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 19 +text = "ПРИМЕНЕНИЕ ИЗМЕНЕНИЙ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Immediate" type="Label" parent="SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin/Content" unique_id=263463990] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Облегчённый режим применяется сразу." +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="Restart" type="Label" parent="SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin/Content" unique_id=1044968640] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.78, 0.25, 1) +theme_override_font_sizes/font_size = 15 +text = "Включение DLC и путь к нему применятся только после перезапуска приложения." +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="CurrentTheme" type="Label" parent="SafeMargin/Scroll/Content/AppearanceNoticePanel/Margin/Content" unique_id=869113996] +metadata/exchanger_binding_id = "settings.scroll.content.appearance_notice_panel.margin.content.current_theme" +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Сейчас активна встроенная тема." +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="PricePanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1175190762] +metadata/exchanger_binding_id = "settings.scroll.content.price_panel" +visible = false +custom_minimum_size = Vector2(0, 247) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/PricePanel" unique_id=1252255186] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/PricePanel/Margin" unique_id=37103992] +layout_mode = 2 +theme_override_constants/separation = 13 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content" unique_id=1223521084] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "СТОИМОСТЬ ОДНОГО ЖЕТОНА" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content" unique_id=442890264] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "По этой цене автомат рассчитывает, сколько жетонов получит покупатель." +autowrap_mode = 2 + +[node name="Price" type="SpinBox" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content" unique_id=906569844] +metadata/exchanger_binding_id = "settings.scroll.content.price_panel.margin.content.price" +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +max_value = 100000.0 +value = 10.0 +update_on_text_changed = true +suffix = " ₽" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/PricePanel/Margin/Content" unique_id=1786171896] +metadata/exchanger_binding_id = "settings.scroll.content.price_panel.margin.content.error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="BonusPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1271515153] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel" +visible = false +custom_minimum_size = Vector2(0, 347) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/BonusPanel" unique_id=1639657177] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin" unique_id=452757233] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content" unique_id=176636309] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ДОПОЛНИТЕЛЬНЫЕ ЖЕТОНЫ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content" unique_id=2054989766] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Например: при оплате от 500 ₽ подарить 5 жетонов. Если подходят несколько правил, сработает самое выгодное." +autowrap_mode = 2 + +[node name="ColumnHeaders" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content" unique_id=1779316545] +custom_minimum_size = Vector2(0, 32) +layout_mode = 2 +theme_override_constants/separation = 11 + +[node name="Threshold" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/ColumnHeaders" unique_id=1516500981] +custom_minimum_size = Vector2(200, 0) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 13 +text = "СУММА ОПЛАТЫ ОТ" + +[node name="Bonus" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/ColumnHeaders" unique_id=730440012] +custom_minimum_size = Vector2(160, 0) +layout_mode = 2 +theme_override_font_sizes/font_size = 13 +text = "ЖЕТОНОВ В ПОДАРОК" + +[node name="Spacer" type="Control" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/ColumnHeaders" unique_id=293311810] +custom_minimum_size = Vector2(103, 0) +layout_mode = 2 + +[node name="Rows" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content" unique_id=1591524546] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows" +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="EmptyState" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.empty_state" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_type_variation = &"PanelText" +text = "Бонусы пока не настроены." +vertical_alignment = 1 + +[node name="Slot00" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_00" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot00"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_00.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot01" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_01" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot01"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_01.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot02" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_02" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot02"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_02.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot03" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_03" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot03"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_03.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot04" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_04" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot04"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_04.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot05" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_05" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot05"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_05.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot06" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_06" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot06"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_06.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot07" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_07" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot07"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_07.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot08" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_08" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot08"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_08.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot09" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_09" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot09"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_09.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot10" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_10" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot10"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_10.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot11" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_11" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot11"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_11.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot12" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_12" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot12"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_12.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot13" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_13" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot13"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_13.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot14" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_14" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot14"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_14.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot15" type="VBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_15" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Threshold" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.threshold" +custom_minimum_size = Vector2(200, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="Arrow" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row"] +custom_minimum_size = Vector2(32, 67) +layout_mode = 2 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 28 +text = "→" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Bonus" type="SpinBox" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.bonus" +custom_minimum_size = Vector2(160, 67) +layout_mode = 2 +max_value = 100000.0 +step = 1.0 +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.remove_button" +custom_minimum_size = Vector2(103, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot15"] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_15.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + + +[node name="AddRuleButton" type="Button" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content" unique_id=1425772859] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.add_rule_button" +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +text = "+ ДОБАВИТЬ БОНУС" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/BonusPanel/Margin/Content" unique_id=397393203] +metadata/exchanger_binding_id = "settings.scroll.content.bonus_panel.margin.content.error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="PreviewPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1550161471] +metadata/exchanger_binding_id = "settings.scroll.content.preview_panel" +visible = false +custom_minimum_size = Vector2(0, 247) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/PreviewPanel" unique_id=1017412349] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/PreviewPanel/Margin" unique_id=1259875287] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/PreviewPanel/Margin/Content" unique_id=1565228186] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ПРИМЕР РАСЧЁТА" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/PreviewPanel/Margin/Content" unique_id=1558111681] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Столько жетонов получит покупатель при разных суммах оплаты." +autowrap_mode = 2 + +[node name="Values" type="Label" parent="SafeMargin/Scroll/Content/PreviewPanel/Margin/Content" unique_id=1890400642] +metadata/exchanger_binding_id = "settings.scroll.content.preview_panel.margin.content.values" +custom_minimum_size = Vector2(0, 140) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "10 ₽ → 1 + 0 = 1 жет." +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="PaymentMethodsPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1014677861] +metadata/exchanger_binding_id = "settings.scroll.content.payment_methods_panel" +visible = false +custom_minimum_size = Vector2(0, 260) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel" unique_id=1681526059] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin" unique_id=1821598693] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content" unique_id=1268252060] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "СПОСОБЫ ОПЛАТЫ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content" unique_id=71570643] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Оставьте хотя бы один доступный способ оплаты." +autowrap_mode = 2 + +[node name="CashEnabled" type="CheckButton" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content" unique_id=1651424955] +metadata/exchanger_binding_id = "settings.scroll.content.payment_methods_panel.margin.content.cash_enabled" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "НАЛИЧНАЯ ОПЛАТА" + +[node name="CardEnabled" type="CheckButton" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content" unique_id=60921604] +metadata/exchanger_binding_id = "settings.scroll.content.payment_methods_panel.margin.content.card_enabled" +custom_minimum_size = Vector2(0, 61) +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "ОПЛАТА КАРТОЙ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/PaymentMethodsPanel/Margin/Content" unique_id=614267083] +metadata/exchanger_binding_id = "settings.scroll.content.payment_methods_panel.margin.content.error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="CardOptionsPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1947991433] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel" +visible = false +custom_minimum_size = Vector2(0, 393) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel" unique_id=1880854225] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin" unique_id=1861346620] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=1252395405] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "СУММЫ ДЛЯ ОПЛАТЫ КАРТОЙ" +vertical_alignment = 1 + +[node name="Limits" type="GridContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=796782793] +layout_mode = 2 +theme_override_constants/h_separation = 13 +theme_override_constants/v_separation = 9 +columns = 2 + +[node name="MaxLabel" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits" unique_id=1157864144] +custom_minimum_size = Vector2(240, 63) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "МАКСИМАЛЬНАЯ СУММА" +vertical_alignment = 1 + +[node name="MaxAmount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits" unique_id=945870327] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.limits.max_amount" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +value = 5000.0 +update_on_text_changed = true +suffix = " ₽" + +[node name="StepLabel" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits" unique_id=117453909] +custom_minimum_size = Vector2(240, 63) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "ШАГ ВЫБОРА СУММЫ" +vertical_alignment = 1 + +[node name="CustomStep" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/Limits" unique_id=2056772758] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.limits.custom_step" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +value = 10.0 +update_on_text_changed = true +suffix = " ₽" + +[node name="LimitsError" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=1784116878] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.limits_error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="PresetsTitle" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=3400869] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "ГОТОВЫЕ СУММЫ" + +[node name="PresetRows" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=783857380] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows" +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="EmptyState" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.empty_state" +visible = false +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_type_variation = &"PanelText" +text = "Готовые суммы пока не добавлены. Покупатель сможет ввести сумму сам." +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Slot00" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 10.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot00"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_00.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot01" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 50.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot01"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_01.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot02" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 100.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot02"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_02.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot03" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 150.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot03"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_03.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot04" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 200.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot04"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_04.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot05" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 300.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot05"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_05.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot06" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 400.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot06"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_06.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot07" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07" +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 500.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot07"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_07.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot08" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot08"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_08.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot09" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot09"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_09.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot10" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot10"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_10.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot11" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot11"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_11.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot12" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot12"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_12.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot13" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot13"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_13.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot14" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot14"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_14.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + +[node name="Slot15" type="VBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15" +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="Row" type="HBoxContainer" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15"] +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="Amount" type="SpinBox" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.amount" +custom_minimum_size = Vector2(0, 67) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 1000000.0 +step = 1.0 +value = 0.0 +suffix = " ₽" +update_on_text_changed = true + +[node name="RemoveButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15/Row"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.remove_button" +custom_minimum_size = Vector2(127, 67) +layout_mode = 2 +theme_type_variation = &"DangerButton" +text = "УДАЛИТЬ" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot15"] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_15.error" +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 +theme_type_variation = &"ErrorText" +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 20 +autowrap_mode = 2 + + +[node name="AddPresetButton" type="Button" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=1011694991] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.add_preset_button" +custom_minimum_size = Vector2(0, 70) +layout_mode = 2 +text = "+ ДОБАВИТЬ СУММУ" + +[node name="PresetsError" type="Label" parent="SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content" unique_id=1336764151] +metadata/exchanger_binding_id = "settings.scroll.content.card_options_panel.margin.content.presets_error" +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="TimeoutsPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=225381422] +metadata/exchanger_binding_id = "settings.scroll.content.timeouts_panel" +visible = false +custom_minimum_size = Vector2(0, 380) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/TimeoutsPanel" unique_id=1678816813] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin" unique_id=2108046571] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content" unique_id=423508686] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ВРЕМЯ ОЖИДАНИЯ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content" unique_id=844982832] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Настройте, сколько времени автомат ждёт действий покупателя." +autowrap_mode = 2 + +[node name="Grid" type="GridContainer" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content" unique_id=987524088] +layout_mode = 2 +theme_override_constants/h_separation = 13 +theme_override_constants/v_separation = 9 +columns = 2 + +[node name="IdleLabel" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid" unique_id=1311503414] +custom_minimum_size = Vector2(273, 63) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "ВОЗВРАТ НА ГЛАВНЫЙ (15–600 С)" +vertical_alignment = 1 + +[node name="Idle" type="SpinBox" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid" unique_id=396087782] +metadata/exchanger_binding_id = "settings.scroll.content.timeouts_panel.margin.content.grid.idle" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 600.0 +value = 45.0 +update_on_text_changed = true +suffix = " с" + +[node name="CashPaymentLabel" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid" unique_id=254111291] +custom_minimum_size = Vector2(273, 63) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "ОПЛАТА НАЛИЧНЫМИ (15–600 С)" +vertical_alignment = 1 + +[node name="CashPayment" type="SpinBox" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid" unique_id=800168430] +metadata/exchanger_binding_id = "settings.scroll.content.timeouts_panel.margin.content.grid.cash_payment" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 600.0 +value = 45.0 +update_on_text_changed = true +suffix = " с" + +[node name="CashlessPaymentLabel" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid"] +custom_minimum_size = Vector2(273, 63) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "БЕЗНАЛИЧНАЯ ОПЛАТА (30–600 С)" +vertical_alignment = 1 + +[node name="CashlessPayment" type="SpinBox" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid"] +metadata/exchanger_binding_id = "settings.scroll.content.timeouts_panel.margin.content.grid.cashless_payment" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 600.0 +value = 120.0 +update_on_text_changed = true +suffix = " с" + +[node name="SuccessLabel" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid" unique_id=1606719904] +custom_minimum_size = Vector2(273, 63) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "ПОКАЗЫВАТЬ РЕЗУЛЬТАТ (3–60 С)" +vertical_alignment = 1 + +[node name="Success" type="SpinBox" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content/Grid" unique_id=1946483701] +metadata/exchanger_binding_id = "settings.scroll.content.timeouts_panel.margin.content.grid.success" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +size_flags_horizontal = 3 +max_value = 60.0 +value = 10.0 +update_on_text_changed = true +suffix = " с" + +[node name="Error" type="Label" parent="SafeMargin/Scroll/Content/TimeoutsPanel/Margin/Content" unique_id=53635746] +metadata/exchanger_binding_id = "settings.scroll.content.timeouts_panel.margin.content.error" +custom_minimum_size = Vector2(0, 53) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="EquipmentConnectionPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=12227059] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel" +visible = false +custom_minimum_size = Vector2(0, 607) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel" unique_id=1058768579] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin" unique_id=1950970524] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=608936661] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ПОДКЛЮЧЕНИЕ К КОНТРОЛЛЕРУ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=735739671] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Только для инженера. Изменения применяются после перезапуска и никогда не выполняют reconnect во время открытой операции." +autowrap_mode = 2 + +[node name="UseMock" type="CheckButton" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=1144904040] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel.margin.content.use_mock" +custom_minimum_size = Vector2(0, 63) +layout_mode = 2 +text = "ИСПОЛЬЗОВАТЬ ИМИТАЦИЮ ОБОРУДОВАНИЯ" + +[node name="PortLabel" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=1595492564] +layout_mode = 2 +text = "COM-ПОРТ" + +[node name="PortName" type="LineEdit" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=1760832797] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel.margin.content.port_name" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +placeholder_text = "COM3" +max_length = 16 + +[node name="PortError" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=993828178] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel.margin.content.port_error" +custom_minimum_size = Vector2(0, 37) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="BaudLabel" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=1392931961] +layout_mode = 2 +text = "СКОРОСТЬ ПОРТА" + +[node name="BaudRate" type="OptionButton" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=810177550] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel.margin.content.baud_rate" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 + +[node name="ReconnectLabel" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=1591920916] +layout_mode = 2 +text = "ПОВТОР ПОДКЛЮЧЕНИЯ (1–60 С)" + +[node name="ReconnectSeconds" type="SpinBox" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=175154894] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel.margin.content.reconnect_seconds" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +min_value = 1.0 +max_value = 60.0 +value = 5.0 +update_on_text_changed = true +suffix = " с" + +[node name="ConnectionError" type="Label" parent="SafeMargin/Scroll/Content/EquipmentConnectionPanel/Margin/Content" unique_id=242906874] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_connection_panel.margin.content.connection_error" +custom_minimum_size = Vector2(0, 40) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="EquipmentWindowPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=1569238897] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_window_panel" +visible = false +custom_minimum_size = Vector2(0, 433) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel" unique_id=1468236776] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin" unique_id=660658614] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=1729915087] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ОКНО И ПРОИЗВОДИТЕЛЬНОСТЬ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=1381667443] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Инженерные restart-only параметры. Размер debug-окна и низкоуровневые параметры протокола намеренно не редактируются здесь." +autowrap_mode = 2 + +[node name="Fullscreen" type="CheckButton" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=859230815] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_window_panel.margin.content.fullscreen" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +text = "ПОЛНОЭКРАННЫЙ РЕЖИМ" + +[node name="Borderless" type="CheckButton" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=445076357] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_window_panel.margin.content.borderless" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +text = "ОКНО БЕЗ РАМКИ" + +[node name="DebugWindow" type="CheckButton" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=1119698600] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_window_panel.margin.content.debug_window" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +text = "DEBUG-СБОРКА ЗАПУСКАЕТСЯ В ОКНЕ" + +[node name="FpsLabel" type="Label" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=67242259] +layout_mode = 2 +text = "ЦЕЛЕВОЙ FPS" + +[node name="TargetFps" type="OptionButton" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=48472713] +metadata/exchanger_binding_id = "settings.scroll.content.equipment_window_panel.margin.content.target_fps" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 + +[node name="EquipmentNotice" type="Label" parent="SafeMargin/Scroll/Content/EquipmentWindowPanel/Margin/Content" unique_id=1717442777] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.72, 0.2, 1) +theme_override_font_sizes/font_size = 15 +text = "AES-ключ, режим шифрования, команды выдачи, framing и параметры датчика не показываются и не сохраняются через этот экран." +autowrap_mode = 2 + +[node name="DiagnosticsStatusPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=63855322] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_status_panel" +visible = false +custom_minimum_size = Vector2(0, 400) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/DiagnosticsStatusPanel" unique_id=363101112] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin" unique_id=1447109247] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content" unique_id=1149194245] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "СОСТОЯНИЕ ПРИЛОЖЕНИЯ" +vertical_alignment = 1 + +[node name="RuntimeSummary" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content" unique_id=1086182449] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_status_panel.margin.content.runtime_summary" +custom_minimum_size = Vector2(0, 120) +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Версия и состояние загружаются при открытии." +autowrap_mode = 2 + +[node name="RecentErrorsTitle" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content" unique_id=1121096725] +layout_mode = 2 +text = "ПОСЛЕДНИЕ БЕЗОПАСНЫЕ ПРЕДУПРЕЖДЕНИЯ" + +[node name="RecentErrors" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsStatusPanel/Margin/Content" unique_id=601475052] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_status_panel.margin.content.recent_errors" +custom_minimum_size = Vector2(0, 147) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Нет предупреждений." +autowrap_mode = 2 + +[node name="DiagnosticsLoggingPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=246114907] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_logging_panel" +visible = false +custom_minimum_size = Vector2(0, 467) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel" unique_id=618088528] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin" unique_id=1603972519] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=728718139] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ЖУРНАЛИРОВАНИЕ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=1279998568] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Изменения применяются сразу. Общий объём архивов автоматически ограничен десятью максимальными файлами. Сырые кадры протокола недоступны." +autowrap_mode = 2 + +[node name="LevelLabel" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=1684427359] +layout_mode = 2 +text = "МИНИМАЛЬНЫЙ УРОВЕНЬ" + +[node name="MinimumLevel" type="OptionButton" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=1516855564] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_logging_panel.margin.content.minimum_level" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 + +[node name="SizeLabel" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=1036811316] +layout_mode = 2 +text = "МАКСИМАЛЬНЫЙ РАЗМЕР ФАЙЛА (1–100 МБ)" + +[node name="MaxFileSize" type="SpinBox" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=379652985] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_logging_panel.margin.content.max_file_size" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +min_value = 1.0 +value = 5.0 +update_on_text_changed = true +suffix = " МБ" + +[node name="RetentionLabel" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=1113473421] +layout_mode = 2 +text = "СРОК ХРАНЕНИЯ (1–365 ДНЕЙ)" + +[node name="RetentionDays" type="SpinBox" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=1996036056] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_logging_panel.margin.content.retention_days" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +min_value = 1.0 +max_value = 365.0 +value = 14.0 +update_on_text_changed = true +suffix = " дн." + +[node name="LoggingError" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsLoggingPanel/Margin/Content" unique_id=722071223] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_logging_panel.margin.content.logging_error" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.4, 0.35, 1) +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 + +[node name="ServiceInventoryPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel" +visible = false +custom_minimum_size = Vector2(0, 772) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "УЧЁТ ЖЕТОНОВ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.description" +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Здесь можно пересчитать жетоны в хоппере или добавить уже пересчитанные вручную." +autowrap_mode = 2 + +[node name="InventoryAccountingEnabled" type="CheckButton" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.inventory_enabled" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +button_pressed = true +text = "УЧИТЫВАТЬ ОСТАТОК ЖЕТОНОВ" + +[node name="PurchaseLimitEnabled" type="CheckButton" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.purchase_limit_enabled" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +button_pressed = true +text = "НЕ ПРОДАВАТЬ БОЛЬШЕ ОСТАТКА" + +[node name="Count" type="Label" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.count" +custom_minimum_size = Vector2(0, 68) +layout_mode = 2 +theme_override_font_sizes/font_size = 25 +text = "УЧТЁННЫЙ ОСТАТОК: 0 ЖЕТОНОВ" +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Hopper1Label" type="Label" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.hopper1_label" +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "ХОППЕР 1 — ОСТАТОК: 0 ЖЕТОНОВ" + +[node name="Hopper1" type="HBoxContainer" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="RecountButton" type="Button" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.hopper1.recount_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +text = "ПЕРЕСЧИТАТЬ" + +[node name="ManualAddButton" type="Button" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper1"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.hopper1.manual_add_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +text = "ДОБАВИТЬ" + +[node name="Hopper2Label" type="Label" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.hopper2_label" +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "ХОППЕР 2 — ОСТАТОК: 0 ЖЕТОНОВ" + +[node name="Hopper2" type="HBoxContainer" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="RecountButton" type="Button" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.hopper2.recount_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +text = "ПЕРЕСЧИТАТЬ" + +[node name="ManualAddButton" type="Button" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Hopper2"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.hopper2.manual_add_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +size_flags_horizontal = 3 +text = "ДОБАВИТЬ" + +[node name="LegacyRecountButton" type="Button" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +visible = false +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.recount_button" +layout_mode = 2 + +[node name="LegacyManualAddButton" type="Button" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +visible = false +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.manual_add_button" +layout_mode = 2 + +[node name="Status" type="Label" parent="SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.service_inventory_panel.margin.content.status" +custom_minimum_size = Vector2(0, 50) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Остаток ещё не заполнен." +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="MenuVisibilityPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.menu_visibility_panel" +visible = false +custom_minimum_size = Vector2(0, 360) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin/Content"] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "СКРЫТИЕ ЭЛЕМЕНТОВ МЕНЮ" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Выберите, какие необязательные элементы показывать на главном экране." +autowrap_mode = 2 + +[node name="ShowStockStatus" type="CheckButton" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.menu_visibility_panel.margin.content.show_stock_status" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +button_pressed = true +text = "ПОКАЗЫВАТЬ ОСТАТОК ЖЕТОНОВ" + +[node name="ShowRightCharacter" type="CheckButton" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.menu_visibility_panel.margin.content.show_right_character" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +button_pressed = true +text = "ПОКАЗЫВАТЬ ПОЛЬЗОВАТЕЛЬСКИЙ ТЕКСТ" + +[node name="ShowPromotionBlock" type="CheckButton" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.menu_visibility_panel.margin.content.show_promotion_block" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +button_pressed = true +text = "ПОКАЗЫВАТЬ ПРОМО-БЛОК" + +[node name="ShowInteractivePet" type="CheckButton" parent="SafeMargin/Scroll/Content/MenuVisibilityPanel/Margin/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.menu_visibility_panel.margin.content.show_interactive_pet" +custom_minimum_size = Vector2(0, 60) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +button_pressed = true +text = "ИНТЕРАКТИВНЫЙ ПИТОМЕЦ" + +[node name="DiagnosticsTestPanel" type="PanelContainer" parent="SafeMargin/Scroll/Content" unique_id=2121165678] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_test_panel" +visible = false +custom_minimum_size = Vector2(0, 287) +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Scroll/Content/DiagnosticsTestPanel" unique_id=1592965841] +layout_mode = 2 +theme_override_constants/margin_left = 25 +theme_override_constants/margin_top = 23 +theme_override_constants/margin_right = 25 +theme_override_constants/margin_bottom = 23 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin" unique_id=1036673328] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Title" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content" unique_id=1717665009] +custom_minimum_size = Vector2(0, 46) +layout_mode = 2 +theme_override_font_sizes/font_size = 23 +text = "ТЕСТОВАЯ ВЫДАЧА" +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content" unique_id=537707851] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Выдаёт один жетон без оплаты. Требует готового контроллера и повторного подтверждения кнопкой." +autowrap_mode = 2 + +[node name="TestDispenseButton" type="Button" parent="SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content" unique_id=263785982] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_test_panel.margin.content.test_dispense_button" +custom_minimum_size = Vector2(0, 73) +layout_mode = 2 +text = "ТЕСТ: ВЫДАТЬ 1 ЖЕТОН" + +[node name="TestDispenseStatus" type="Label" parent="SafeMargin/Scroll/Content/DiagnosticsTestPanel/Margin/Content" unique_id=2115719528] +metadata/exchanger_binding_id = "settings.scroll.content.diagnostics_test_panel.margin.content.test_dispense_status" +custom_minimum_size = Vector2(0, 47) +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "Тест не запущен." +autowrap_mode = 2 + +[node name="FooterClearance" type="Control" parent="SafeMargin/Scroll/Content"] +metadata/exchanger_binding_id = "settings.scroll.content.footer_clearance" +custom_minimum_size = Vector2(0, 124) +layout_mode = 2 +mouse_filter = 2 + +[node name="ServiceDialogOverlay" type="ColorRect" parent="SafeMargin"] +metadata/exchanger_binding_id = "settings.service_dialog_overlay" +visible = false +z_index = 20 +layout_mode = 2 +mouse_filter = 0 +color = Color(0, 0, 0, 0.72) + +[node name="Dialog" type="PanelContainer" parent="SafeMargin/ServiceDialogOverlay"] +custom_minimum_size = Vector2(600, 460) +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -300.0 +offset_top = -230.0 +offset_right = 300.0 +offset_bottom = 230.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Margin" type="MarginContainer" parent="SafeMargin/ServiceDialogOverlay/Dialog"] +layout_mode = 2 +theme_override_constants/margin_left = 28 +theme_override_constants/margin_top = 28 +theme_override_constants/margin_right = 28 +theme_override_constants/margin_bottom = 28 + +[node name="Content" type="VBoxContainer" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin"] +layout_mode = 2 +theme_override_constants/separation = 20 + +[node name="Title" type="Label" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content"] +metadata/exchanger_binding_id = "settings.service_dialog_overlay.dialog.margin.content.title" +custom_minimum_size = Vector2(0, 58) +layout_mode = 2 +theme_override_font_sizes/font_size = 27 +text = "ДОБАВИТЬ ЖЕТОНЫ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Description" type="Label" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content"] +metadata/exchanger_binding_id = "settings.service_dialog_overlay.dialog.margin.content.description" +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 17 +text = "Введите количество жетонов. Перед добавлением приложение попросит подтвердить число." +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="Amount" type="SpinBox" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content"] +metadata/exchanger_binding_id = "settings.service_dialog_overlay.dialog.margin.content.amount" +custom_minimum_size = Vector2(0, 72) +layout_mode = 2 +max_value = 999999.0 +update_on_text_changed = true + +[node name="Buttons" type="HBoxContainer" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content"] +custom_minimum_size = Vector2(0, 76) +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="CancelButton" type="Button" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Buttons"] +metadata/exchanger_binding_id = "settings.service_dialog_overlay.dialog.margin.content.buttons.cancel_button" +custom_minimum_size = Vector2(0, 76) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"CancelButton" +theme_override_font_sizes/font_size = 17 +text = "ОТМЕНА" + +[node name="ConfirmButton" type="Button" parent="SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Buttons"] +metadata/exchanger_binding_id = "settings.service_dialog_overlay.dialog.margin.content.buttons.confirm_button" +custom_minimum_size = Vector2(0, 76) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 17 +text = "ПОДТВЕРДИТЬ" + +[node name="Footer" type="PanelContainer" parent="."] +metadata/exchanger_binding_id = "settings.footer" +z_index = 5 +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 28.0 +offset_top = -132.0 +offset_right = -28.0 +offset_bottom = -28.0 +grow_horizontal = 2 +grow_vertical = 0 +theme_type_variation = &"FooterPanel" + +[node name="SaveAndExitButton" type="Button" parent="Footer"] +metadata/exchanger_binding_id = "settings.footer.save_and_exit_button" +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 14.0 +offset_top = 10.0 +offset_right = -14.0 +offset_bottom = -10.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_type_variation = &"PrimaryButton" +theme_override_font_sizes/font_size = 25 +text = "СОХРАНИТЬ И ВЫЙТИ" + +[node name="OnScreenKeyboardOverlay" parent="." instance=ExtResource("4_keyboards")] diff --git a/themes/space-pixel/package/scenes/screen_success.tscn b/themes/space-pixel/package/scenes/screen_success.tscn new file mode 100644 index 0000000..3c533e0 --- /dev/null +++ b/themes/space-pixel/package/scenes/screen_success.tscn @@ -0,0 +1,210 @@ +[gd_scene load_steps=7 format=3] + +[ext_resource type="Theme" path="../ui_theme.tres" id="1_screen_theme"] +[ext_resource type="Texture2D" path="../assets/background/background.png" id="2_screen_background"] +[ext_resource type="PackedScene" path="../background_decoration.tscn" id="3_screen_decoration"] +[ext_resource type="PackedScene" path="../components/footer.tscn" id="3_footer"] +[ext_resource type="PackedScene" path="../visuals/result_success.tscn" id="4_result_visual"] +[ext_resource type="Texture2D" path="../assets/ui/interactive_pet/home.png" id="5_home_icon"] + +[node name="ThemeScreen_Success" type="Control"] +texture_filter = 1 +metadata/exchanger_screen_binding_id = "screen.success" +metadata/exchanger_self_contained_visuals = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("1_screen_theme") + +[node name="ThemeBackground" type="TextureRect" parent="."] +texture_filter = 1 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +texture = ExtResource("2_screen_background") +expand_mode = 1 +stretch_mode = 6 + +[node name="BackgroundDecoration" parent="." instance=ExtResource("3_screen_decoration")] +scale = Vector2(0.666667, 0.666667) + +[node name="PetNavigation" type="Node2D" parent="."] +metadata/exchanger_pet_navigation_root = true +metadata/exchanger_pet_navigation_version = 1 +metadata/exchanger_pet_default_surface_id = &"floor" +metadata/exchanger_pet_roaming_bounds = Rect2(32, 32, 656, 1208) + +[node name="Floor" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"floor" +metadata/exchanger_pet_surface_from_x = 40.0 +metadata/exchanger_pet_surface_to_x = 680.0 +metadata/exchanger_pet_surface_y = 1240.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(96, 360, 624) + +[node name="ResultWindow" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"result_window" +metadata/exchanger_pet_surface_from_x = 96.0 +metadata/exchanger_pet_surface_to_x = 624.0 +metadata/exchanger_pet_surface_y = 176.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="HomeButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"home_button" +metadata/exchanger_pet_surface_from_x = 84.0 +metadata/exchanger_pet_surface_to_x = 636.0 +metadata/exchanger_pet_surface_y = 862.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(360) + +[node name="HomeNavigationButton" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_surface = true +metadata/exchanger_pet_surface_id = &"home_navigation_button" +metadata/exchanger_pet_surface_from_x = 49.0 +metadata/exchanger_pet_surface_to_x = 101.0 +metadata/exchanger_pet_surface_y = 42.0 +metadata/exchanger_pet_surface_sit_points = PackedFloat32Array(75) + +[node name="FloorToHomeNavigation" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_home_navigation" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"home_navigation_button" +metadata/exchanger_pet_climb_x = 49.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 42.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToResult" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_result" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"result_window" +metadata/exchanger_pet_climb_x = 96.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 176.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="FloorToHome" type="Node2D" parent="PetNavigation"] +metadata/exchanger_pet_climb_edge = true +metadata/exchanger_pet_climb_edge_id = &"floor_to_home" +metadata/exchanger_pet_climb_from_surface_id = &"floor" +metadata/exchanger_pet_climb_to_surface_id = &"home_button" +metadata/exchanger_pet_climb_x = 84.0 +metadata/exchanger_pet_climb_from_y = 1240.0 +metadata/exchanger_pet_climb_to_y = 862.0 +metadata/exchanger_pet_climb_side = &"left" + +[node name="SafeMargin" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Control" parent="SafeMargin"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="HomeNavigationButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "success.content.home_navigation_button" +layout_mode = 0 +offset_left = 49.0 +offset_top = 42.0 +offset_right = 101.0 +offset_bottom = 94.0 +tooltip_text = "ГЛАВНАЯ" +theme_type_variation = &"KeypadButton" +icon = ExtResource("5_home_icon") +expand_icon = true +icon_max_width = 30 + +[node name="ScreenTitle" type="Label" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 40.0 +offset_top = 44.0 +offset_right = 680.0 +offset_bottom = 112.0 +theme_type_variation = &"ScreenTitle" +text = "ОПЛАТА ПРОШЛА" +horizontal_alignment = 1 + +[node name="ResultPanel" type="PanelContainer" parent="SafeMargin/Content"] +layout_mode = 0 +offset_left = 96.0 +offset_top = 176.0 +offset_right = 624.0 +offset_bottom = 826.0 +theme_type_variation = &"WhitePanel" + +[node name="Margin" type="MarginContainer" parent="SafeMargin/Content/ResultPanel"] +layout_mode = 2 + +[node name="Values" type="VBoxContainer" parent="SafeMargin/Content/ResultPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 28 + +[node name="Title" type="Label" parent="SafeMargin/Content/ResultPanel/Margin/Values"] +metadata/exchanger_binding_id = "success.content.result_panel.margin.values.title" +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +theme_type_variation = &"PanelTitle" +text = "ВЫДАЧА ЖЕТОНОВ" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Body" type="Control" parent="SafeMargin/Content/ResultPanel/Margin/Values"] +custom_minimum_size = Vector2(0, 538) +layout_mode = 2 +mouse_filter = 2 + +[node name="SpacePixelVisual" parent="SafeMargin/Content/ResultPanel/Margin/Values/Body" instance=ExtResource("4_result_visual")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Message" type="Label" parent="SafeMargin/Content/ResultPanel/Margin/Values/Body"] +metadata/exchanger_binding_id = "success.content.result_panel.margin.values.body.message" +layout_mode = 0 +offset_left = 28.0 +offset_top = 418.0 +offset_right = 456.0 +offset_bottom = 504.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 23 +text = "ЗАБЕРИТЕ ЖЕТОНЫ\nСПАСИБО ЗА ПОКУПКУ!" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Countdown" type="Label" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "success.content.countdown" +visible = false + +[node name="HomeButton" type="Button" parent="SafeMargin/Content"] +metadata/exchanger_binding_id = "success.content.home_button" +layout_mode = 0 +offset_left = 84.0 +offset_top = 862.0 +offset_right = 636.0 +offset_bottom = 956.0 +theme_type_variation = &"PrimaryButton" +text = "НА ГЛАВНЫЙ ЭКРАН" + +[node name="Footer" parent="SafeMargin/Content" instance=ExtResource("3_footer")] +layout_mode = 1 diff --git a/themes/space-pixel/package/scripts/home_reference_label.gd b/themes/space-pixel/package/scripts/home_reference_label.gd new file mode 100644 index 0000000..771f7df --- /dev/null +++ b/themes/space-pixel/package/scripts/home_reference_label.gd @@ -0,0 +1,31 @@ +@tool +extends Label + +@export_enum("price", "support") var format_mode := "price" + + +func _process(_delta: float) -> void: + var formatted := _format_text(text) + if formatted != text: + text = formatted + + +func _format_text(value: String) -> String: + if format_mode == "support": + var lines := value.split("\n") + if lines.size() < 2: + return value + var heading := lines[0].replace(" ", "").trim_suffix(":").to_upper() + if heading != "ТЕХПОДДЕРЖКА": + return value + return "ТЕХ ПОДДЕРЖКА:\n" + "\n".join(lines.slice(1)) + + var upper := value.strip_edges().to_upper() + var amount := value.strip_edges() + for suffix in [" РУБ.", " ₽", " Р"]: + if upper.ends_with(suffix): + amount = amount.left(amount.length() - suffix.length()).strip_edges() + break + if amount.is_valid_int(): + return "%s руб." % amount + return value diff --git a/themes/space-pixel/package/scripts/home_reference_label.gd.uid b/themes/space-pixel/package/scripts/home_reference_label.gd.uid new file mode 100644 index 0000000..736d709 --- /dev/null +++ b/themes/space-pixel/package/scripts/home_reference_label.gd.uid @@ -0,0 +1 @@ +uid://dqgrwkru1akc1 diff --git a/themes/space-pixel/package/scripts/home_stock_presenter.gd b/themes/space-pixel/package/scripts/home_stock_presenter.gd new file mode 100644 index 0000000..a46a027 --- /dev/null +++ b/themes/space-pixel/package/scripts/home_stock_presenter.gd @@ -0,0 +1,30 @@ +@tool +extends Control + +const STOCK_MANY := preload("../assets/ui/stock_many.png") +const STOCK_LOW := preload("../assets/ui/stock_low.png") + +@onready var _state: Label = $StockPanel/Margin/Content/State +@onready var _meter: TextureRect = $StockPanel/Margin/Content/Meter + + +func _process(_delta: float) -> void: + if not is_instance_valid(_state) or not is_instance_valid(_meter): + return + + var raw := _state.text.strip_edges().to_upper() + if raw.begins_with("МНОГО"): + _apply_state("МНОГО", STOCK_MANY, true) + elif raw.begins_with("МАЛО"): + _apply_state("МАЛО", STOCK_LOW, true) + elif raw.contains("НЕТ ДАННЫХ"): + _apply_state("НЕТ ДАННЫХ", null, false) + + +func _apply_state(caption: String, texture: Texture2D, show_meter: bool) -> void: + if _state.text != caption: + _state.text = caption + if _meter.texture != texture: + _meter.texture = texture + if _meter.visible != show_meter: + _meter.visible = show_meter diff --git a/themes/space-pixel/package/scripts/home_stock_presenter.gd.uid b/themes/space-pixel/package/scripts/home_stock_presenter.gd.uid new file mode 100644 index 0000000..de292b5 --- /dev/null +++ b/themes/space-pixel/package/scripts/home_stock_presenter.gd.uid @@ -0,0 +1 @@ +uid://bjig3xvfbd8f3 diff --git a/themes/space-pixel/package/scripts/home_ufo_bobbing.gd b/themes/space-pixel/package/scripts/home_ufo_bobbing.gd new file mode 100644 index 0000000..a1db412 --- /dev/null +++ b/themes/space-pixel/package/scripts/home_ufo_bobbing.gd @@ -0,0 +1,37 @@ +extends Control + +@export_range(0.0, 20.0, 1.0) var left_amplitude := 4.0 +@export_range(0.0, 20.0, 1.0) var right_amplitude := 5.0 +@export_range(0.5, 10.0, 0.1) var left_period := 3.2 +@export_range(0.5, 10.0, 0.1) var right_period := 3.8 + +@onready var _left: TextureRect = $AlienLeft +@onready var _right: TextureRect = $CustomTextBlock/AlienRight + +var _left_base_position := Vector2.ZERO +var _right_base_position := Vector2.ZERO +var _elapsed := 0.0 + + +func _ready() -> void: + _left_base_position = _left.position + _right_base_position = _right.position + + +func _process(delta: float) -> void: + _elapsed = fmod(_elapsed + delta, maxf(left_period, right_period) * 10.0) + _apply_bob(_left, _left_base_position, left_amplitude, left_period, 0.0) + _apply_bob(_right, _right_base_position, right_amplitude, right_period, PI) + + +func _apply_bob( + sprite: TextureRect, + base_position: Vector2, + amplitude: float, + period: float, + phase: float +) -> void: + # Округление сохраняет чёткие пиксельные края при Nearest-фильтрации. + var offset_y := roundf(sin(_elapsed * TAU / period + phase) * amplitude) + sprite.position = base_position + Vector2(0.0, offset_y) + sprite.set_meta("bob_offset_y", offset_y) diff --git a/themes/space-pixel/package/scripts/home_ufo_bobbing.gd.uid b/themes/space-pixel/package/scripts/home_ufo_bobbing.gd.uid new file mode 100644 index 0000000..6679867 --- /dev/null +++ b/themes/space-pixel/package/scripts/home_ufo_bobbing.gd.uid @@ -0,0 +1 @@ +uid://nccpj07e6yaa diff --git a/themes/space-pixel/package/scripts/interactive_pet.gd b/themes/space-pixel/package/scripts/interactive_pet.gd new file mode 100644 index 0000000..b9c3c0d --- /dev/null +++ b/themes/space-pixel/package/scripts/interactive_pet.gd @@ -0,0 +1,743 @@ +@tool +extends Node2D + +signal pet_animation_started(animation_name: StringName) +signal pet_animation_completed(animation_name: StringName) +signal pet_composite_action_started(action_name: StringName, run_id: int) +signal pet_composite_action_finished(action_name: StringName, run_id: int, completed: bool) + +const FRAME_SIZE := Vector2i(128, 128) +const ANIMATION_NAMES := [ + &"climbing_down", + &"climbing_start", + &"climbing_up", + &"dancing", + &"come_closer", + &"falling", + &"hanging", + &"sitting", + &"idle", + &"landing", + &"turn_left", + &"turn_right", + &"walking_left_stop", + &"walking_right_stop", + &"walking_left", + &"walking_right", + &"walking_left_start", + &"walking_start_right", + &"action", + &"climbing_action_start", + &"climbing_action_stop", + &"climbing_action", +] +const ANIMATION_FRAME_COUNTS := { + &"climbing_down": 16, + &"climbing_start": 19, + &"climbing_up": 16, + &"dancing": 95, + &"come_closer": 36, + &"falling": 30, + &"hanging": 23, + &"sitting": 30, + &"idle": 19, + &"landing": 11, + &"turn_left": 10, + &"turn_right": 10, + &"walking_left_stop": 4, + &"walking_right_stop": 4, + &"walking_left": 19, + &"walking_right": 19, + &"walking_left_start": 5, + &"walking_start_right": 5, + &"action": 11, + &"climbing_action_start": 11, + &"climbing_action_stop": 11, + &"climbing_action": 10, +} +const ANIMATION_SPEEDS := { + &"climbing_down": 24.0, + &"climbing_start": 24.0, + &"climbing_up": 24.0, + &"dancing": 24.0, + &"come_closer": 24.0, + &"falling": 24.0, + &"hanging": 12.0, + &"sitting": 24.0, + &"idle": 24.0, + &"landing": 24.0, + &"turn_left": 24.0, + &"turn_right": 24.0, + &"walking_left_stop": 24.0, + &"walking_right_stop": 24.0, + &"walking_left": 24.0, + &"walking_right": 24.0, + &"walking_left_start": 24.0, + &"walking_start_right": 24.0, + &"action": 24.0, + &"climbing_action_start": 24.0, + &"climbing_action_stop": 24.0, + &"climbing_action": 24.0, +} +const ONE_SHOT_ANIMATIONS := { + &"climbing_start": true, + &"come_closer": true, + &"landing": true, + &"turn_left": true, + &"turn_right": true, + &"walking_left_stop": true, + &"walking_right_stop": true, + &"walking_left_start": true, + &"walking_start_right": true, + &"climbing_action_start": true, + &"climbing_action_stop": true, +} +const ACTION_ANIMATIONS := { + &"idle": &"idle", + &"sit": &"sitting", + &"interact": &"action", + &"celebrate": &"dancing", + &"walk_left": &"walking_left", + &"walk_right": &"walking_right", + &"walk_start_left": &"walking_left_start", + &"walk_start_right": &"walking_start_right", + &"walk_finish_left": &"walking_left_stop", + &"walk_finish_right": &"walking_right_stop", + &"climb_start": &"climbing_start", + &"climb_up": &"climbing_up", + &"climb_down": &"climbing_down", + &"climb_finish": &"idle", + &"drag": &"hanging", + &"drop": &"falling", + &"come_closer": &"come_closer", + &"landing": &"landing", + &"turn_left": &"turn_left", + &"turn_right": &"turn_right", + &"action": &"action", + &"climbing_action_start": &"climbing_action_start", + &"climbing_action": &"climbing_action", + &"climbing_action_stop": &"climbing_action_stop", +} +const RUNTIME_SCALE := 0.845 +const WALK_SPEED := 88.0 +const CLIMB_SPEED := 72.0 +const HIT_SIZE := Vector2(128.0, 128.0) +const GROUND_OFFSET := Vector2(64.0, 96.0) +const SITTING_VISUAL_OFFSET := Vector2(0.0, 24.0) +const NON_SITTING_VISUAL_OFFSET := Vector2.ZERO +const COMPOSITE_INTERACT := &"interact" +const FACING_FRONT := &"front" +const FACING_LEFT := &"left" +const FACING_RIGHT := &"right" +const LEFT_FACING_ANIMATIONS := { + &"walking_left": true, + &"walking_left_start": true, + &"walking_left_stop": true, +} +const RIGHT_FACING_ANIMATIONS := { + &"walking_right": true, + &"walking_start_right": true, + &"walking_right_stop": true, +} +const FRONT_FACING_ANIMATIONS := { + &"idle": true, + &"sitting": true, + &"turn_left": true, + &"turn_right": true, + &"come_closer": true, + &"action": true, + &"falling": true, + &"landing": true, + &"dancing": true, +} + +@export_category("Sprite Sheets") +@export var climbing_down_texture: Texture2D +@export var climbing_start_texture: Texture2D +@export var climbing_up_texture: Texture2D +@export var dancing_texture: Texture2D +@export var come_closer_texture: Texture2D +@export var falling_texture: Texture2D +@export var hanging_texture: Texture2D +@export var sitting_texture: Texture2D +@export var idle_texture: Texture2D +@export var landing_texture: Texture2D +@export var turn_left_texture: Texture2D +@export var turn_right_texture: Texture2D +@export var walking_left_stop_texture: Texture2D +@export var walking_right_stop_texture: Texture2D +@export var walking_left_texture: Texture2D +@export var walking_right_texture: Texture2D +@export var walking_left_start_texture: Texture2D +@export var walking_start_right_texture: Texture2D +@export var action_texture: Texture2D +@export var climbing_action_start_texture: Texture2D +@export var climbing_action_stop_texture: Texture2D +@export var climbing_action_texture: Texture2D + +@export_category("Playback") +@export_enum( + "climbing_down", + "climbing_start", + "climbing_up", + "dancing", + "come_closer", + "falling", + "hanging", + "sitting", + "idle", + "landing", + "turn_left", + "turn_right", + "walking_left_stop", + "walking_right_stop", + "walking_left", + "walking_right", + "walking_left_start", + "walking_start_right", + "action", + "climbing_action_start", + "climbing_action_stop", + "climbing_action" +) var initial_animation := "idle" +@export var autoplay_on_ready := true +@export_range(0.1, 4.0, 0.05) var playback_speed_scale := 1.0 +@export var return_to_sitting_after_one_shot := false + +@export_category("Composite Action") +@export_range(1.0, 4.0, 0.05) var approach_visual_scale := 2.0 +@export_range(0.1, 1.0, 0.05) var approach_scale_completion_ratio := 0.75 +@export_range(0.0, 1.0, 0.01) var approach_hold_seconds := 0.15 +@export_range(32.0, 1024.0, 1.0) var falling_distance_per_cycle := 320.0 +@export_range(0.0, 512.0, 1.0) var vertical_wrap_margin := 192.0 + +@onready var _visual_root: Node2D = $VisualRoot +@onready var _sprite: AnimatedSprite2D = $VisualRoot/Sprite + +var _sprite_base_position := Vector2.ZERO +var _facing: StringName = FACING_FRONT +var _composite_run_id := 0 +var _composite_action: StringName = &"" +var _composite_runtime: Object + + +func _ready() -> void: + _sprite_base_position = _sprite.position + _build_sprite_frames() + if not _sprite.animation_finished.is_connected(_on_animation_finished): + _sprite.animation_finished.connect(_on_animation_finished) + if autoplay_on_ready: + play_pet_animation(StringName(initial_animation)) + + +func get_pet_animation_names() -> Array[StringName]: + var result: Array[StringName] = [] + for animation_name in ANIMATION_NAMES: + result.append(animation_name) + return result + + +func get_pet_action_animation(action_name: StringName) -> StringName: + return StringName(ACTION_ANIMATIONS.get(action_name, &"")) + + +func get_pet_animation_frame_count(animation_name: StringName) -> int: + return int(ANIMATION_FRAME_COUNTS.get(animation_name, 0)) + + +func has_pet_animation(animation_name: StringName) -> bool: + return ( + is_instance_valid(_sprite) + and _sprite.sprite_frames != null + and _sprite.sprite_frames.has_animation(animation_name) + ) + + +func play_pet_animation(animation_name: StringName, restart := true) -> bool: + if not has_pet_animation(animation_name): + push_warning("Unknown interactive pet animation: %s" % animation_name) + return false + _apply_animation_visual_offset(animation_name) + if restart: + _sprite.set_frame_and_progress(0, 0.0) + _sprite.speed_scale = playback_speed_scale + _sprite.play(animation_name) + _remember_animation_facing(animation_name) + pet_animation_started.emit(animation_name) + return true + + +func pause_pet_animation() -> void: + if is_instance_valid(_sprite): + _sprite.pause() + + +func stop_pet_animation() -> void: + if is_instance_valid(_sprite): + _sprite.stop() + + +func get_current_pet_animation() -> StringName: + if not is_instance_valid(_sprite): + return &"" + return _sprite.animation + + +func get_current_pet_frame() -> int: + if not is_instance_valid(_sprite): + return -1 + return _sprite.frame + + +func is_pet_animation_looping(animation_name: StringName) -> bool: + return ( + is_instance_valid(_sprite) + and _sprite.sprite_frames != null + and _sprite.sprite_frames.has_animation(animation_name) + and _sprite.sprite_frames.get_animation_loop(animation_name) + ) + + +func get_pet_animation_frames_per_second(animation_name: StringName) -> float: + if not has_pet_animation(animation_name): + return 0.0 + return _sprite.sprite_frames.get_animation_speed(animation_name) * playback_speed_scale + + +func get_pet_preview_size() -> Vector2: + return Vector2(FRAME_SIZE) + + +func has_pet_composite_action(action_name: StringName) -> bool: + return action_name == COMPOSITE_INTERACT + + +func start_pet_composite_action( + action_name: StringName, + runtime: Object, + parameters: Dictionary +) -> int: + if not has_pet_composite_action(action_name) or not _is_valid_runtime(runtime): + return 0 + if _composite_run_id != 0: + cancel_pet_composite_action(_composite_run_id, &"replaced") + + var run_id := int(runtime.call(&"GetRunId")) + if run_id <= 0: + return 0 + _composite_run_id = run_id + _composite_action = action_name + _composite_runtime = runtime + pet_composite_action_started.emit(action_name, run_id) + call_deferred(&"_run_interact_composite", run_id, runtime, parameters.duplicate(true)) + return run_id + + +func cancel_pet_composite_action(run_id: int, _reason: StringName) -> void: + if run_id != _composite_run_id: + return + _finish_composite_action(run_id, false, false) + + +func get_pet_runtime_scale() -> float: + return RUNTIME_SCALE + + +func get_pet_walk_speed() -> float: + return WALK_SPEED + + +func get_pet_climb_speed() -> float: + return CLIMB_SPEED + + +func get_pet_hit_size() -> Vector2: + return HIT_SIZE + + +func get_pet_ground_offset() -> Vector2: + return GROUND_OFFSET + + +func get_pet_animation_visual_offset(animation_name: StringName) -> Vector2: + if not ANIMATION_NAMES.has(animation_name): + return Vector2.ZERO + if animation_name == &"sitting": + return SITTING_VISUAL_OFFSET + return NON_SITTING_VISUAL_OFFSET + + +func get_pet_visual_offset() -> Vector2: + if not is_instance_valid(_sprite): + return Vector2.ZERO + return _sprite.position - _sprite_base_position + + +func _apply_animation_visual_offset(animation_name: StringName) -> void: + _sprite.position = _sprite_base_position + get_pet_animation_visual_offset(animation_name) + + +func _run_interact_composite(run_id: int, runtime: Object, parameters: Dictionary) -> void: + var suspended_policy := { + "navigation": "suspended", + "support_contacts": "ignore", + "screen_bounds": "allow", + "input_enabled": false, + } + if not bool(runtime.call(&"SetMotionPolicy", suspended_policy)): + _finish_composite_action(run_id, false) + return + + var turn_animation := _turn_animation_for_facing() + if turn_animation != &"" and not await _play_animation_cycles( + turn_animation, + 1, + run_id, + runtime + ): + return + + var reduced_effects := bool(parameters.get("reduced_effects", false)) + if not reduced_effects and not await _play_animation_with_visual_scale( + &"come_closer", + 1.0, + approach_visual_scale, + run_id, + runtime + ): + return + + var action_repetitions := clampi(int(parameters.get("action_repetitions", 3)), 1, 16) + if not await _play_animation_cycles(&"action", action_repetitions, run_id, runtime): + return + + if not reduced_effects and not await _play_wrapped_fall(run_id, runtime): + return + if not await _play_animation_cycles(&"landing", 1, run_id, runtime): + return + if not _is_composite_active(run_id, runtime): + return + play_pet_animation(&"idle") + _finish_composite_action(run_id, true) + + +func _play_wrapped_fall(run_id: int, runtime: Object) -> bool: + var support_value: Variant = runtime.call(&"GetHighestSupport") + if not support_value is Dictionary: + _finish_composite_action(run_id, false) + return false + var support := support_value as Dictionary + if not bool(support.get("accepted", false)) or not bool(support.get("valid", false)): + _finish_composite_action(run_id, false) + return false + + var viewport := runtime.call(&"GetViewportRect") as Rect2 + var start_anchor := runtime.call(&"GetAnchor") as Vector2 + var target_anchor := support.get("anchor", Vector2.ZERO) as Vector2 + var target_surface_id := String(support.get("surface_id", "")) + if viewport.size.x <= 0.0 or viewport.size.y <= 0.0 or target_surface_id.is_empty(): + _finish_composite_action(run_id, false) + return false + + var first_leg := maxf(0.0, viewport.end.y + vertical_wrap_margin - start_anchor.y) + var second_leg := maxf(0.0, target_anchor.y - (viewport.position.y - vertical_wrap_margin)) + var total_distance := maxf(1.0, first_leg + second_leg) + var calculated_cycles := maxi(1, ceili(total_distance / falling_distance_per_cycle)) + var cycle_seconds := _animation_cycle_seconds(&"falling") + if cycle_seconds <= 0.0: + _finish_composite_action(run_id, false) + return false + var total_seconds := float(calculated_cycles) * cycle_seconds + var fall_speed := total_distance / total_seconds + var falling_policy := { + "navigation": "theme_driven", + "support_contacts": "after_vertical_wrap", + "screen_bounds": "wrap_vertical_once", + "input_enabled": false, + "landing_surface_id": target_surface_id, + "wrap_margin": vertical_wrap_margin, + } + if not bool(runtime.call(&"SetMotionPolicy", falling_policy)): + _finish_composite_action(run_id, false) + return false + if not play_pet_animation(&"falling"): + _finish_composite_action(run_id, false) + return false + + var elapsed := 0.0 + var wrapped := false + var landed := false + while elapsed < total_seconds and not landed: + await get_tree().process_frame + if not _is_composite_active(run_id, runtime): + return false + var delta := minf(get_process_delta_time(), total_seconds - elapsed) + elapsed += delta + var move_value: Variant = runtime.call(&"MoveAnchor", Vector2.DOWN * fall_speed * delta) + if not move_value is Dictionary: + _finish_composite_action(run_id, false) + return false + var move_result := move_value as Dictionary + if not bool(move_result.get("accepted", false)): + _finish_composite_action(run_id, false) + return false + if bool(move_result.get("wrapped", false)): + wrapped = true + var wrapped_anchor := move_result.get("anchor", Vector2.ZERO) as Vector2 + wrapped_anchor.x = target_anchor.x + runtime.call(&"SetAnchor", wrapped_anchor) + _visual_root.scale = Vector2.ONE + landed = bool(move_result.get("landed", false)) + + if not wrapped: + _finish_composite_action(run_id, false) + return false + if not landed: + var landing_value: Variant = runtime.call(&"LandOnSupport", target_surface_id) + if not landing_value is Dictionary or not bool( + (landing_value as Dictionary).get("landed", false) + ): + _finish_composite_action(run_id, false) + return false + return true + + +func _play_animation_with_visual_scale( + animation_name: StringName, + from_scale: float, + to_scale: float, + run_id: int, + runtime: Object +) -> bool: + if not play_pet_animation(animation_name): + _finish_composite_action(run_id, false) + return false + var duration := _animation_cycle_seconds(animation_name) + if duration <= 0.0: + _finish_composite_action(run_id, false) + return false + var frames_per_second := get_pet_animation_frames_per_second(animation_name) + var final_frame_duration := 1.0 / frames_per_second + var scale_duration := clampf( + duration * approach_scale_completion_ratio, + final_frame_duration, + duration + ) + var elapsed := 0.0 + _visual_root.scale = Vector2.ONE * from_scale + while elapsed < duration: + await get_tree().process_frame + if not _is_composite_active(run_id, runtime): + return false + elapsed = minf(duration, elapsed + get_process_delta_time()) + # Finish the zoom before the clip ends. Its closing frames therefore + # establish the final size instead of changing size at the action cut. + var weight := minf(1.0, elapsed / scale_duration) + _visual_root.scale = Vector2.ONE * lerpf(from_scale, to_scale, weight) + _visual_root.scale = Vector2.ONE * to_scale + var hold_elapsed := 0.0 + while hold_elapsed < approach_hold_seconds: + await get_tree().process_frame + if not _is_composite_active(run_id, runtime): + return false + hold_elapsed += get_process_delta_time() + return true + + +func _play_animation_cycles( + animation_name: StringName, + cycles: int, + run_id: int, + runtime: Object +) -> bool: + var cycle_seconds := _animation_cycle_seconds(animation_name) + if cycle_seconds <= 0.0: + _finish_composite_action(run_id, false) + return false + for _cycle in maxi(1, cycles): + if not play_pet_animation(animation_name): + _finish_composite_action(run_id, false) + return false + var elapsed := 0.0 + while elapsed < cycle_seconds: + await get_tree().process_frame + if not _is_composite_active(run_id, runtime): + return false + elapsed += get_process_delta_time() + return true + + +func _animation_cycle_seconds(animation_name: StringName) -> float: + var frame_count := get_pet_animation_frame_count(animation_name) + var frames_per_second := get_pet_animation_frames_per_second(animation_name) + if frame_count <= 0 or frames_per_second <= 0.0: + return 0.0 + return float(frame_count) / frames_per_second + + +func _turn_animation_for_facing() -> StringName: + match _facing: + FACING_LEFT: + return &"turn_left" + FACING_RIGHT: + return &"turn_right" + return &"" + + +func _remember_animation_facing(animation_name: StringName) -> void: + if LEFT_FACING_ANIMATIONS.has(animation_name): + _facing = FACING_LEFT + elif RIGHT_FACING_ANIMATIONS.has(animation_name): + _facing = FACING_RIGHT + elif FRONT_FACING_ANIMATIONS.has(animation_name): + _facing = FACING_FRONT + + +func _is_valid_runtime(runtime: Object) -> bool: + if runtime == null: + return false + for method_name in [ + &"GetRunId", + &"IsActive", + &"GetViewportRect", + &"GetAnchor", + &"GetHighestSupport", + &"SetMotionPolicy", + &"SetAnchor", + &"MoveAnchor", + &"LandOnSupport", + ]: + if not runtime.has_method(method_name): + return false + return true + + +func _is_composite_active(run_id: int, runtime: Object) -> bool: + return ( + run_id == _composite_run_id + and runtime == _composite_runtime + and is_instance_valid(runtime) + and bool(runtime.call(&"IsActive")) + ) + + +func _finish_composite_action(run_id: int, completed: bool, emit_finished := true) -> void: + if run_id != _composite_run_id: + return + var action_name := _composite_action + _composite_run_id = 0 + _composite_action = &"" + _composite_runtime = null + if is_instance_valid(_visual_root): + _visual_root.scale = Vector2.ONE + if emit_finished: + pet_composite_action_finished.emit(action_name, run_id, completed) + + +func _build_sprite_frames() -> void: + var generated_frames := SpriteFrames.new() + generated_frames.remove_animation(&"default") + + for animation_name in ANIMATION_NAMES: + var texture := _texture_for(animation_name) + if texture == null: + continue + var columns := floori(float(texture.get_width()) / float(FRAME_SIZE.x)) + var frame_count := int(ANIMATION_FRAME_COUNTS[animation_name]) + generated_frames.add_animation(animation_name) + generated_frames.set_animation_speed( + animation_name, + float(ANIMATION_SPEEDS[animation_name]) + ) + generated_frames.set_animation_loop( + animation_name, + not ONE_SHOT_ANIMATIONS.has(animation_name) + ) + + for frame_index in frame_count: + var atlas_frame := AtlasTexture.new() + atlas_frame.atlas = texture + atlas_frame.region = Rect2( + float(frame_index % columns) * FRAME_SIZE.x, + float(floori(float(frame_index) / float(columns))) * FRAME_SIZE.y, + FRAME_SIZE.x, + FRAME_SIZE.y + ) + atlas_frame.filter_clip = true + generated_frames.add_frame(animation_name, atlas_frame) + + _sprite.sprite_frames = generated_frames + + +func _texture_for(animation_name: StringName) -> Texture2D: + match animation_name: + &"climbing_down": + return climbing_down_texture + &"climbing_start": + return climbing_start_texture + &"climbing_up": + return climbing_up_texture + &"dancing": + return dancing_texture + &"come_closer": + return come_closer_texture + &"falling": + return falling_texture + &"hanging": + return hanging_texture + &"sitting": + return sitting_texture + &"idle": + return idle_texture + &"landing": + return landing_texture + &"turn_left": + return turn_left_texture + &"turn_right": + return turn_right_texture + &"walking_left_stop": + return walking_left_stop_texture + &"walking_right_stop": + return walking_right_stop_texture + &"walking_left": + return walking_left_texture + &"walking_right": + return walking_right_texture + &"walking_left_start": + return walking_left_start_texture + &"walking_start_right": + return walking_start_right_texture + &"action": + return action_texture + &"climbing_action_start": + return climbing_action_start_texture + &"climbing_action_stop": + return climbing_action_stop_texture + &"climbing_action": + return climbing_action_texture + return null + + +func _on_animation_finished() -> void: + var completed_animation := _sprite.animation + pet_animation_completed.emit(completed_animation) + if return_to_sitting_after_one_shot and ONE_SHOT_ANIMATIONS.has(completed_animation): + play_pet_animation(&"sitting") + + +func _get_configuration_warnings() -> PackedStringArray: + var warnings := PackedStringArray() + for animation_name in ANIMATION_NAMES: + var texture := _texture_for(animation_name) + if texture == null: + warnings.append("Missing sprite sheet for %s." % animation_name) + continue + if texture.get_width() % FRAME_SIZE.x != 0 or texture.get_height() % FRAME_SIZE.y != 0: + warnings.append("Sprite sheet %s is not aligned to 128x128 cells." % animation_name) + continue + var available_frames := floori(float(texture.get_width()) / float(FRAME_SIZE.x)) * floori( + float(texture.get_height()) / float(FRAME_SIZE.y) + ) + if int(ANIMATION_FRAME_COUNTS[animation_name]) > available_frames: + warnings.append("Sprite sheet %s does not contain all declared frames." % animation_name) + return warnings diff --git a/themes/space-pixel/package/scripts/interactive_pet.gd.uid b/themes/space-pixel/package/scripts/interactive_pet.gd.uid new file mode 100644 index 0000000..1a5a99b --- /dev/null +++ b/themes/space-pixel/package/scripts/interactive_pet.gd.uid @@ -0,0 +1 @@ +uid://k2f6x5chdh01 diff --git a/themes/space-pixel/package/scripts/stable_banner_animation.gd b/themes/space-pixel/package/scripts/stable_banner_animation.gd new file mode 100644 index 0000000..42325a9 --- /dev/null +++ b/themes/space-pixel/package/scripts/stable_banner_animation.gd @@ -0,0 +1,30 @@ +extends AnimatedSprite2D + +# Непрозрачная цветная область исходных кадров: +# frame_01: Rect2(0, 28, 263, 167) +# frame_02: Rect2(0, 39, 257, 163) +# Второй кадр масштабируется и сдвигается относительно центра 1024x256 так, +# чтобы обе области занимали одинаковые экранные границы. +@export var second_frame_scale_factor := Vector2(263.0 / 257.0, 167.0 / 163.0) +@export var second_frame_local_offset := Vector2(11.953307, -8.815951) + +var _base_position := Vector2.ZERO +var _base_scale := Vector2.ONE + + +func _ready() -> void: + _base_position = position + _base_scale = scale + frame_changed.connect(_apply_frame_compensation) + _apply_frame_compensation() + + +func _apply_frame_compensation() -> void: + if frame == 1: + scale = _base_scale * second_frame_scale_factor + position = _base_position + second_frame_local_offset * _base_scale + else: + scale = _base_scale + position = _base_position + + set_meta("normalized_frame", frame) diff --git a/themes/space-pixel/package/scripts/stable_banner_animation.gd.uid b/themes/space-pixel/package/scripts/stable_banner_animation.gd.uid new file mode 100644 index 0000000..4943907 --- /dev/null +++ b/themes/space-pixel/package/scripts/stable_banner_animation.gd.uid @@ -0,0 +1 @@ +uid://bdt0nqmx75ng8 diff --git a/themes/space-pixel/package/scripts/ufo_flight_controller.gd b/themes/space-pixel/package/scripts/ufo_flight_controller.gd new file mode 100644 index 0000000..a0e6e36 --- /dev/null +++ b/themes/space-pixel/package/scripts/ufo_flight_controller.gd @@ -0,0 +1,67 @@ +extends Node2D + +@export_range(0.0, 1920.0, 1.0) var min_flight_height := 260.0 +@export_range(0.0, 1920.0, 1.0) var max_flight_height := 1660.0 +@export_range(0.0, 10.0, 0.1) var min_edge_pause := 1.0 +@export_range(0.0, 10.0, 0.1) var max_edge_pause := 3.0 +@export_range(1.0, 1000.0, 1.0) var large_speed := 470.0 +@export_range(1.0, 1000.0, 1.0) var small_speed := 437.0 +@export_range(0.0, 30.0, 0.1) var small_initial_delay := 5.5 + +@onready var _large: AnimatedSprite2D = $UfoLarge +@onready var _small: AnimatedSprite2D = $UfoSmall + +var _random := RandomNumberGenerator.new() + + +func _ready() -> void: + _random.randomize() + _fly_forever(_large, -380.0, 1500.0, large_speed, 0.0) + _fly_forever(_small, -160.0, 1260.0, small_speed, small_initial_delay) + + +func _fly_forever( + sprite: AnimatedSprite2D, + left_edge: float, + right_edge: float, + speed: float, + initial_delay: float +) -> void: + var direction := 1 + _place_for_next_pass(sprite, left_edge, direction) + + if initial_delay > 0.0: + await get_tree().create_timer(initial_delay).timeout + + while is_inside_tree() and is_instance_valid(sprite): + var target_x := right_edge if direction > 0 else left_edge + var duration := absf(target_x - sprite.position.x) / speed + var flight := create_tween().set_trans(Tween.TRANS_LINEAR) + flight.tween_property(sprite, "position:x", target_x, duration) + await flight.finished + + if not is_inside_tree() or not is_instance_valid(sprite): + return + + await get_tree().create_timer( + _random.randf_range(min_edge_pause, max_edge_pause) + ).timeout + + direction *= -1 + _place_for_next_pass(sprite, target_x, direction) + + +func _place_for_next_pass( + sprite: AnimatedSprite2D, + edge_x: float, + direction: int +) -> void: + # Шаг 3 px сохраняет целочисленную высоту после масштаба production-сцен 2/3. + var random_height := snappedf( + _random.randf_range(min_flight_height, max_flight_height), + 3.0 + ) + sprite.position = Vector2(edge_x, random_height) + sprite.flip_h = direction < 0 + sprite.set_meta("flight_direction", direction) + sprite.set_meta("flight_height", random_height) diff --git a/themes/space-pixel/package/scripts/ufo_flight_controller.gd.uid b/themes/space-pixel/package/scripts/ufo_flight_controller.gd.uid new file mode 100644 index 0000000..8520abd --- /dev/null +++ b/themes/space-pixel/package/scripts/ufo_flight_controller.gd.uid @@ -0,0 +1 @@ +uid://cifbft5lk7prl diff --git a/themes/space-pixel/package/ui_theme.tres b/themes/space-pixel/package/ui_theme.tres new file mode 100644 index 0000000..c06b1b2 --- /dev/null +++ b/themes/space-pixel/package/ui_theme.tres @@ -0,0 +1,755 @@ +[gd_resource type="Theme" format=3 uid="uid://dfq25gg23x0xh"] + +[ext_resource type="FontFile" uid="uid://culveonyhkpru" path="res://themes/space-pixel/package/assets/fonts/vcr_osd_mono_cyr.ttf" id="1_font"] +[ext_resource type="Texture2D" uid="uid://bxw1d5ftc3jpp" path="res://themes/space-pixel/package/assets/ui/button_big.png" id="2_button_big"] +[ext_resource type="Texture2D" uid="uid://b4gr7gwf5jyy6" path="res://themes/space-pixel/package/assets/ui/button_long.png" id="3_button_long"] +[ext_resource type="Texture2D" uid="uid://sntindr0yqtc" path="res://themes/space-pixel/package/assets/ui/back_button.png" id="4_back_button"] +[ext_resource type="Texture2D" uid="uid://cp2gv8jrxb1is" path="res://themes/space-pixel/package/assets/ui/panel_medium.png" id="5_panel"] +[ext_resource type="Texture2D" uid="uid://od3ytfnm6es0" path="res://themes/space-pixel/package/assets/ui/tumbler_on.png" id="6_check_on"] +[ext_resource type="Texture2D" uid="uid://dioycex1x3g62" path="res://themes/space-pixel/package/assets/ui/tumbler_off.png" id="7_check_off"] +[ext_resource type="Texture2D" uid="uid://jcpf3lum8v3x" path="res://themes/space-pixel/package/assets/ui/button_square.png" id="8_button_square"] +[ext_resource type="Texture2D" uid="uid://kir5pffovdmv" path="res://themes/space-pixel/package/assets/ui/button_up.png" id="9_button_up"] +[ext_resource type="Texture2D" uid="uid://bfietocvvkfrd" path="res://themes/space-pixel/package/assets/ui/button_down.png" id="10_button_down"] +[ext_resource type="Texture2D" uid="uid://dbnw5b84pre6y" path="res://themes/space-pixel/package/assets/ui/sum_bar.png" id="11_sum_bar"] +[ext_resource type="Texture2D" uid="uid://str14omahot0" path="res://themes/space-pixel/package/assets/ui/button_cancel.png" id="12_button_cancel"] + +[sub_resource type="StyleBoxFlat" id="StyleBox_advertisement"] +content_margin_left = 18.0 +content_margin_top = 18.0 +content_margin_right = 18.0 +content_margin_bottom = 18.0 +bg_color = Color(0.005, 0.03, 0.09, 0.72) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.42, 0.86, 0.31, 0.84) +corner_radius_top_left = 18 +corner_radius_top_right = 18 +corner_radius_bottom_right = 18 +corner_radius_bottom_left = 18 + +[sub_resource type="StyleBoxFlat" id="StyleBox_focus"] +bg_color = Color(0, 0, 0, 0) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +border_color = Color(0.88, 0.48, 1, 0.95) +corner_radius_top_left = 14 +corner_radius_top_right = 14 +corner_radius_bottom_right = 14 +corner_radius_bottom_left = 14 +expand_margin_left = 3.0 +expand_margin_top = 3.0 +expand_margin_right = 3.0 +expand_margin_bottom = 3.0 + +[sub_resource type="StyleBoxTexture" id="StyleBox_back"] +content_margin_left = 8.0 +content_margin_top = 8.0 +content_margin_right = 8.0 +content_margin_bottom = 8.0 +texture = ExtResource("4_back_button") +texture_margin_left = 8.0 +texture_margin_top = 8.0 +texture_margin_right = 8.0 +texture_margin_bottom = 8.0 + +[sub_resource type="StyleBoxTexture" id="StyleBox_button_disabled"] +content_margin_left = 30.0 +content_margin_top = 18.0 +content_margin_right = 30.0 +content_margin_bottom = 18.0 +texture = ExtResource("2_button_big") +texture_margin_left = 28.0 +texture_margin_top = 24.0 +texture_margin_right = 28.0 +texture_margin_bottom = 24.0 +modulate_color = Color(0.36, 0.42, 0.5, 0.7) + +[sub_resource type="StyleBoxTexture" id="StyleBox_button_hover"] +content_margin_left = 30.0 +content_margin_top = 18.0 +content_margin_right = 30.0 +content_margin_bottom = 18.0 +texture = ExtResource("2_button_big") +texture_margin_left = 28.0 +texture_margin_top = 24.0 +texture_margin_right = 28.0 +texture_margin_bottom = 24.0 +modulate_color = Color(1.12, 1.12, 1.12, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_button_normal"] +content_margin_left = 30.0 +content_margin_top = 18.0 +content_margin_right = 30.0 +content_margin_bottom = 18.0 +texture = ExtResource("2_button_big") +texture_margin_left = 28.0 +texture_margin_top = 24.0 +texture_margin_right = 28.0 +texture_margin_bottom = 24.0 + +[sub_resource type="StyleBoxTexture" id="StyleBox_button_pressed"] +content_margin_left = 30.0 +content_margin_top = 20.0 +content_margin_right = 30.0 +content_margin_bottom = 16.0 +texture = ExtResource("2_button_big") +texture_margin_left = 28.0 +texture_margin_top = 24.0 +texture_margin_right = 28.0 +texture_margin_bottom = 24.0 +modulate_color = Color(0.72, 0.8, 0.76, 1) + +[sub_resource type="AtlasTexture" id="AtlasTexture_button_cancel"] +atlas = ExtResource("12_button_cancel") +region = Rect2(82, 0, 349, 64) + +[sub_resource type="StyleBoxTexture" id="StyleBox_danger_disabled"] +content_margin_left = 20.0 +content_margin_top = 10.0 +content_margin_right = 20.0 +content_margin_bottom = 10.0 +texture = SubResource("AtlasTexture_button_cancel") +texture_margin_left = 24.0 +texture_margin_top = 20.0 +texture_margin_right = 24.0 +texture_margin_bottom = 20.0 +modulate_color = Color(0.52, 0.52, 0.52, 0.7) + +[sub_resource type="StyleBoxTexture" id="StyleBox_danger_hover"] +content_margin_left = 20.0 +content_margin_top = 10.0 +content_margin_right = 20.0 +content_margin_bottom = 10.0 +texture = SubResource("AtlasTexture_button_cancel") +texture_margin_left = 24.0 +texture_margin_top = 20.0 +texture_margin_right = 24.0 +texture_margin_bottom = 20.0 +modulate_color = Color(1.12, 1.12, 1.12, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_danger_normal"] +content_margin_left = 20.0 +content_margin_top = 10.0 +content_margin_right = 20.0 +content_margin_bottom = 10.0 +texture = SubResource("AtlasTexture_button_cancel") +texture_margin_left = 24.0 +texture_margin_top = 20.0 +texture_margin_right = 24.0 +texture_margin_bottom = 20.0 + +[sub_resource type="StyleBoxTexture" id="StyleBox_danger_pressed"] +content_margin_left = 20.0 +content_margin_top = 12.0 +content_margin_right = 20.0 +content_margin_bottom = 8.0 +texture = SubResource("AtlasTexture_button_cancel") +texture_margin_left = 24.0 +texture_margin_top = 20.0 +texture_margin_right = 24.0 +texture_margin_bottom = 20.0 +modulate_color = Color(0.78, 0.78, 0.78, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBox_check_disabled"] +content_margin_left = 24.0 +content_margin_top = 12.0 +content_margin_right = 24.0 +content_margin_bottom = 12.0 +bg_color = Color(0.46, 0.58, 0.45, 0.72) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.65, 0.72, 0.66, 0.72) +corner_radius_top_left = 12 +corner_radius_top_right = 12 +corner_radius_bottom_right = 12 +corner_radius_bottom_left = 12 + +[sub_resource type="StyleBoxFlat" id="StyleBox_check_hover"] +content_margin_left = 24.0 +content_margin_top = 12.0 +content_margin_right = 24.0 +content_margin_bottom = 12.0 +bg_color = Color(0.38, 0.88, 0.29, 1) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +border_color = Color(0.84, 1, 0.72, 1) +corner_radius_top_left = 12 +corner_radius_top_right = 12 +corner_radius_bottom_right = 12 +corner_radius_bottom_left = 12 + +[sub_resource type="StyleBoxFlat" id="StyleBox_check_pressed"] +content_margin_left = 24.0 +content_margin_top = 14.0 +content_margin_right = 24.0 +content_margin_bottom = 10.0 +bg_color = Color(0.19, 0.63, 0.17, 1) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +border_color = Color(0.72, 1, 0.58, 1) +corner_radius_top_left = 12 +corner_radius_top_right = 12 +corner_radius_bottom_right = 12 +corner_radius_bottom_left = 12 + +[sub_resource type="StyleBoxFlat" id="StyleBox_check_normal"] +content_margin_left = 24.0 +content_margin_top = 12.0 +content_margin_right = 24.0 +content_margin_bottom = 12.0 +bg_color = Color(0.28, 0.8, 0.22, 1) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.72, 1, 0.58, 1) +corner_radius_top_left = 12 +corner_radius_top_right = 12 +corner_radius_bottom_right = 12 +corner_radius_bottom_left = 12 + +[sub_resource type="StyleBoxTexture" id="StyleBox_compact_normal"] +content_margin_left = 20.0 +content_margin_top = 10.0 +content_margin_right = 20.0 +content_margin_bottom = 10.0 +texture = ExtResource("3_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 + +[sub_resource type="StyleBoxTexture" id="StyleBox_compact_pressed"] +content_margin_left = 20.0 +content_margin_top = 12.0 +content_margin_right = 20.0 +content_margin_bottom = 8.0 +texture = ExtResource("3_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +modulate_color = Color(0.72, 0.8, 0.76, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBox_footer"] +content_margin_left = 20.0 +content_margin_top = 8.0 +content_margin_right = 20.0 +content_margin_bottom = 8.0 +bg_color = Color(0.005, 0.03, 0.09, 0.78) +border_width_top = 2 +border_color = Color(0.42, 0.86, 0.31, 0.9) + +[sub_resource type="StyleBoxFlat" id="StyleBox_scroll_grabber"] +bg_color = Color(0.25, 0.78, 0.2, 0.94) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.72, 1, 0.58, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBox_scroll_track"] +bg_color = Color(0.005, 0.03, 0.09, 0.72) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="Gradient" id="Gradient_slider_grabber_transparent"] +colors = PackedColorArray(0, 0, 0, 0, 0, 0, 0, 0) + +[sub_resource type="GradientTexture1D" id="GradientTexture_slider_grabber_transparent"] +gradient = SubResource("Gradient_slider_grabber_transparent") +width = 1 + +[sub_resource type="StyleBoxFlat" id="StyleBox_slider_fill"] +content_margin_top = 18.0 +content_margin_bottom = 18.0 +bg_color = Color(0.25, 0.78, 0.2, 1) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.72, 1, 0.58, 1) +corner_radius_top_left = 18 +corner_radius_top_right = 18 +corner_radius_bottom_right = 18 +corner_radius_bottom_left = 18 + +[sub_resource type="StyleBoxFlat" id="StyleBox_slider_track"] +content_margin_top = 18.0 +content_margin_bottom = 18.0 +bg_color = Color(0.025, 0.075, 0.16, 0.96) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.28, 0.73, 0.23, 1) +corner_radius_top_left = 18 +corner_radius_top_right = 18 +corner_radius_bottom_right = 18 +corner_radius_bottom_left = 18 + +[sub_resource type="StyleBoxTexture" id="StyleBox_keyboard_action_disabled"] +content_margin_left = 12.0 +content_margin_top = 10.0 +content_margin_right = 12.0 +content_margin_bottom = 10.0 +texture = ExtResource("3_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) +modulate_color = Color(0.36, 0.42, 0.5, 0.7) + +[sub_resource type="StyleBoxTexture" id="StyleBox_keyboard_action_hover"] +content_margin_left = 12.0 +content_margin_top = 10.0 +content_margin_right = 12.0 +content_margin_bottom = 10.0 +texture = ExtResource("3_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) +modulate_color = Color(1.12, 1.12, 1.12, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_keyboard_action_normal"] +content_margin_left = 12.0 +content_margin_top = 10.0 +content_margin_right = 12.0 +content_margin_bottom = 10.0 +texture = ExtResource("3_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) + +[sub_resource type="StyleBoxTexture" id="StyleBox_keyboard_action_pressed"] +content_margin_left = 12.0 +content_margin_top = 12.0 +content_margin_right = 12.0 +content_margin_bottom = 8.0 +texture = ExtResource("3_button_long") +texture_margin_left = 20.0 +texture_margin_top = 16.0 +texture_margin_right = 20.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(55, 0, 402, 64) +modulate_color = Color(0.72, 0.8, 0.76, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_keypad_disabled"] +content_margin_left = 8.0 +content_margin_top = 8.0 +content_margin_right = 8.0 +content_margin_bottom = 8.0 +texture = ExtResource("8_button_square") +texture_margin_left = 12.0 +texture_margin_top = 12.0 +texture_margin_right = 12.0 +texture_margin_bottom = 12.0 +modulate_color = Color(0.36, 0.42, 0.5, 0.7) + +[sub_resource type="StyleBoxTexture" id="StyleBox_keypad_hover"] +content_margin_left = 8.0 +content_margin_top = 8.0 +content_margin_right = 8.0 +content_margin_bottom = 8.0 +texture = ExtResource("8_button_square") +texture_margin_left = 12.0 +texture_margin_top = 12.0 +texture_margin_right = 12.0 +texture_margin_bottom = 12.0 +modulate_color = Color(1.12, 1.12, 1.12, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_keypad_normal"] +content_margin_left = 8.0 +content_margin_top = 8.0 +content_margin_right = 8.0 +content_margin_bottom = 8.0 +texture = ExtResource("8_button_square") +texture_margin_left = 12.0 +texture_margin_top = 12.0 +texture_margin_right = 12.0 +texture_margin_bottom = 12.0 + +[sub_resource type="StyleBoxTexture" id="StyleBox_keypad_pressed"] +content_margin_left = 8.0 +content_margin_top = 10.0 +content_margin_right = 8.0 +content_margin_bottom = 6.0 +texture = ExtResource("8_button_square") +texture_margin_left = 12.0 +texture_margin_top = 12.0 +texture_margin_right = 12.0 +texture_margin_bottom = 12.0 +modulate_color = Color(0.72, 0.8, 0.76, 1) + +[sub_resource type="StyleBoxTexture" id="StyleBox_input_focus"] +content_margin_left = 18.0 +content_margin_top = 12.0 +content_margin_right = 18.0 +content_margin_bottom = 12.0 +texture = ExtResource("11_sum_bar") +texture_margin_left = 16.0 +texture_margin_top = 16.0 +texture_margin_right = 16.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(111, 0, 290, 64) + +[sub_resource type="StyleBoxTexture" id="StyleBox_input_normal"] +content_margin_left = 18.0 +content_margin_top = 12.0 +content_margin_right = 18.0 +content_margin_bottom = 12.0 +texture = ExtResource("11_sum_bar") +texture_margin_left = 16.0 +texture_margin_top = 16.0 +texture_margin_right = 16.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(111, 0, 290, 64) + +[sub_resource type="StyleBoxTexture" id="StyleBox_input_read_only"] +content_margin_left = 18.0 +content_margin_top = 12.0 +content_margin_right = 18.0 +content_margin_bottom = 12.0 +texture = ExtResource("11_sum_bar") +texture_margin_left = 16.0 +texture_margin_top = 16.0 +texture_margin_right = 16.0 +texture_margin_bottom = 16.0 +region_rect = Rect2(111, 0, 290, 64) +modulate_color = Color(0.72, 0.78, 0.75, 0.86) + +[sub_resource type="StyleBoxFlat" id="StyleBox_panel_safe"] +content_margin_left = 28.0 +content_margin_top = 24.0 +content_margin_right = 28.0 +content_margin_bottom = 24.0 +bg_color = Color(0.008, 0.047, 0.145, 0.92) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +border_color = Color(0.31, 0.75, 0.24, 0.96) +corner_radius_top_left = 16 +corner_radius_top_right = 16 +corner_radius_bottom_right = 16 +corner_radius_bottom_left = 16 +shadow_color = Color(0.015, 0, 0.12, 0.62) +shadow_size = 10 + +[sub_resource type="StyleBoxTexture" id="StyleBox_panel_reference_44"] +content_margin_left = 22.0 +content_margin_top = 10.0 +content_margin_right = 22.0 +content_margin_bottom = 18.0 +texture = ExtResource("5_panel") +texture_margin_left = 22.0 +texture_margin_top = 44.0 +texture_margin_right = 22.0 +texture_margin_bottom = 18.0 +region_rect = Rect2(4, 34, 504, 186) + +[sub_resource type="StyleBoxEmpty" id="StyleBox_tab_panel"] + +[sub_resource type="StyleBoxFlat" id="StyleBox_status"] +content_margin_left = 22.0 +content_margin_top = 10.0 +content_margin_right = 22.0 +content_margin_bottom = 18.0 +bg_color = Color(0.96, 0.98, 1, 1) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +border_color = Color(0.42, 0.86, 0.31, 1) +corner_radius_top_left = 18 +corner_radius_top_right = 18 +corner_radius_bottom_right = 18 +corner_radius_bottom_left = 18 + +[sub_resource type="StyleBoxFlat" id="StyleBox_tab_selected"] +content_margin_left = 10.0 +content_margin_top = 5.0 +content_margin_right = 10.0 +content_margin_bottom = 5.0 +bg_color = Color(0.45, 0.86, 0.31, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.72, 1, 0.58, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBox_tab_unselected"] +content_margin_left = 10.0 +content_margin_top = 5.0 +content_margin_right = 10.0 +content_margin_bottom = 5.0 +bg_color = Color(0.005, 0.03, 0.09, 0.94) + +[sub_resource type="StyleBoxTexture" id="StyleBox_panel_reference"] +content_margin_left = 22.0 +content_margin_top = 10.0 +content_margin_right = 22.0 +content_margin_bottom = 18.0 +texture = ExtResource("5_panel") +texture_margin_left = 22.0 +texture_margin_top = 66.0 +texture_margin_right = 22.0 +texture_margin_bottom = 18.0 +region_rect = Rect2(4, 34, 504, 186) + +[resource] +default_font = ExtResource("1_font") +default_font_size = 28 +AdvertisementPanel/base_type = &"PanelContainer" +AdvertisementPanel/styles/panel = SubResource("StyleBox_advertisement") +BackButton/base_type = &"Button" +BackButton/colors/font_color = Color(0.96, 0.98, 1, 1) +BackButton/font_sizes/font_size = 14 +BackButton/styles/focus = SubResource("StyleBox_focus") +BackButton/styles/hover = SubResource("StyleBox_back") +BackButton/styles/normal = SubResource("StyleBox_back") +BackButton/styles/pressed = SubResource("StyleBox_back") +Button/colors/font_color = Color(0.005, 0.03, 0.08, 1) +Button/colors/font_disabled_color = Color(0.72, 0.78, 0.82, 0.72) +Button/colors/font_hover_color = Color(0, 0.015, 0.05, 1) +Button/colors/font_outline_color = Color(0, 0, 0, 0) +Button/colors/font_pressed_color = Color(0.94, 1, 0.9, 1) +Button/constants/outline_size = 0 +Button/font_sizes/font_size = 32 +Button/styles/disabled = SubResource("StyleBox_button_disabled") +Button/styles/focus = SubResource("StyleBox_focus") +Button/styles/hover = SubResource("StyleBox_button_hover") +Button/styles/normal = SubResource("StyleBox_button_normal") +Button/styles/pressed = SubResource("StyleBox_button_pressed") +CancelButton/base_type = &"Button" +CancelButton/colors/font_color = Color(1, 0.96, 0.92, 1) +CancelButton/colors/font_hover_color = Color(1, 1, 1, 1) +CancelButton/colors/font_pressed_color = Color(1, 0.95, 0.9, 1) +CancelButton/font_sizes/font_size = 18 +CancelButton/styles/disabled = SubResource("StyleBox_danger_disabled") +CancelButton/styles/focus = SubResource("StyleBox_focus") +CancelButton/styles/hover = SubResource("StyleBox_danger_hover") +CancelButton/styles/normal = SubResource("StyleBox_danger_normal") +CancelButton/styles/pressed = SubResource("StyleBox_danger_pressed") +CheckButton/colors/font_color = Color(0.005, 0.03, 0.08, 1) +CheckButton/colors/font_disabled_color = Color(0.015, 0.08, 0.14, 0.62) +CheckButton/colors/font_hover_color = Color(0, 0.015, 0.05, 1) +CheckButton/colors/font_hover_pressed_color = Color(0, 0.015, 0.05, 1) +CheckButton/colors/font_pressed_color = Color(0, 0.015, 0.05, 1) +CheckButton/constants/check_v_offset = 0 +CheckButton/constants/h_separation = 0 +CheckButton/constants/icon_max_width = 128 +CheckButton/font_sizes/font_size = 18 +CheckButton/icons/checked = ExtResource("6_check_on") +CheckButton/icons/checked_disabled = ExtResource("6_check_on") +CheckButton/icons/unchecked = ExtResource("7_check_off") +CheckButton/icons/unchecked_disabled = ExtResource("7_check_off") +CheckButton/styles/disabled = SubResource("StyleBox_check_disabled") +CheckButton/styles/focus = SubResource("StyleBox_focus") +CheckButton/styles/hover = SubResource("StyleBox_check_hover") +CheckButton/styles/hover_pressed = SubResource("StyleBox_check_pressed") +CheckButton/styles/normal = SubResource("StyleBox_check_normal") +CheckButton/styles/pressed = SubResource("StyleBox_check_pressed") +CompactButton/base_type = &"Button" +CompactButton/font_sizes/font_size = 24 +CompactButton/styles/disabled = SubResource("StyleBox_button_disabled") +CompactButton/styles/focus = SubResource("StyleBox_focus") +CompactButton/styles/hover = SubResource("StyleBox_compact_normal") +CompactButton/styles/normal = SubResource("StyleBox_compact_normal") +CompactButton/styles/pressed = SubResource("StyleBox_compact_pressed") +DangerButton/base_type = &"Button" +DangerButton/colors/font_color = Color(1, 0.96, 0.92, 1) +DangerButton/colors/font_hover_color = Color(1, 1, 1, 1) +DangerButton/colors/font_pressed_color = Color(1, 0.95, 0.9, 1) +DangerButton/font_sizes/font_size = 18 +DangerButton/styles/disabled = SubResource("StyleBox_danger_disabled") +DangerButton/styles/focus = SubResource("StyleBox_focus") +DangerButton/styles/hover = SubResource("StyleBox_danger_hover") +DangerButton/styles/normal = SubResource("StyleBox_danger_normal") +DangerButton/styles/pressed = SubResource("StyleBox_danger_pressed") +ErrorText/base_type = &"Label" +ErrorText/colors/font_color = Color(0.56, 0.015, 0.045, 1) +ErrorText/colors/font_shadow_color = Color(0, 0, 0, 0) +ErrorText/font_sizes/font_size = 18 +FieldLabel/base_type = &"Label" +FieldLabel/colors/font_color = Color(0.01, 0.055, 0.3, 1) +FieldLabel/colors/font_shadow_color = Color(0, 0, 0, 0) +FieldLabel/font_sizes/font_size = 18 +FooterPanel/base_type = &"PanelContainer" +FooterPanel/styles/panel = SubResource("StyleBox_footer") +HScrollBar/styles/grabber = SubResource("StyleBox_scroll_grabber") +HScrollBar/styles/grabber_highlight = SubResource("StyleBox_scroll_grabber") +HScrollBar/styles/grabber_pressed = SubResource("StyleBox_scroll_grabber") +HScrollBar/styles/scroll = SubResource("StyleBox_scroll_track") +HScrollBar/styles/scroll_focus = SubResource("StyleBox_scroll_track") +HSlider/constants/center_grabber = 1 +HSlider/icons/grabber = SubResource("GradientTexture_slider_grabber_transparent") +HSlider/icons/grabber_disabled = SubResource("GradientTexture_slider_grabber_transparent") +HSlider/icons/grabber_highlight = SubResource("GradientTexture_slider_grabber_transparent") +HSlider/styles/grabber_area = SubResource("StyleBox_slider_fill") +HSlider/styles/grabber_area_highlight = SubResource("StyleBox_slider_fill") +HSlider/styles/slider = SubResource("StyleBox_slider_track") +KeyboardActionButton/base_type = &"Button" +KeyboardActionButton/font_sizes/font_size = 22 +KeyboardActionButton/styles/disabled = SubResource("StyleBox_keyboard_action_disabled") +KeyboardActionButton/styles/focus = SubResource("StyleBox_focus") +KeyboardActionButton/styles/hover = SubResource("StyleBox_keyboard_action_hover") +KeyboardActionButton/styles/normal = SubResource("StyleBox_keyboard_action_normal") +KeyboardActionButton/styles/pressed = SubResource("StyleBox_keyboard_action_pressed") +KeypadButton/base_type = &"Button" +KeypadButton/font_sizes/font_size = 24 +KeypadButton/styles/disabled = SubResource("StyleBox_keypad_disabled") +KeypadButton/styles/focus = SubResource("StyleBox_focus") +KeypadButton/styles/hover = SubResource("StyleBox_keypad_hover") +KeypadButton/styles/normal = SubResource("StyleBox_keypad_normal") +KeypadButton/styles/pressed = SubResource("StyleBox_keypad_pressed") +Label/colors/font_color = Color(1, 1, 1, 1) +Label/colors/font_shadow_color = Color(0, 0, 0, 0) +Label/constants/shadow_offset_x = 0 +Label/constants/shadow_offset_y = 0 +Label/font_sizes/font_size = 28 +LineEdit/colors/caret_color = Color(0.035, 0.13, 0.47, 1) +LineEdit/colors/font_color = Color(0.005, 0.012, 0.05, 1) +LineEdit/colors/font_placeholder_color = Color(0.22, 0.28, 0.38, 0.76) +LineEdit/colors/font_uneditable_color = Color(0.22, 0.28, 0.38, 0.8) +LineEdit/colors/selection_color = Color(0.25, 0.78, 0.2, 0.42) +LineEdit/font_sizes/font_size = 24 +LineEdit/styles/focus = SubResource("StyleBox_input_focus") +LineEdit/styles/normal = SubResource("StyleBox_input_normal") +LineEdit/styles/read_only = SubResource("StyleBox_input_read_only") +OptionButton/colors/font_color = Color(0.005, 0.03, 0.08, 1) +OptionButton/colors/font_hover_color = Color(0, 0.015, 0.05, 1) +OptionButton/colors/font_pressed_color = Color(0.94, 1, 0.9, 1) +OptionButton/font_sizes/font_size = 22 +OptionButton/styles/focus = SubResource("StyleBox_focus") +OptionButton/styles/hover = SubResource("StyleBox_input_normal") +OptionButton/styles/normal = SubResource("StyleBox_input_normal") +OptionButton/styles/pressed = SubResource("StyleBox_input_focus") +PanelContainer/styles/panel = SubResource("StyleBox_panel_safe") +PanelText/base_type = &"Label" +PanelText/colors/font_color = Color(0.005, 0.012, 0.05, 1) +PanelText/colors/font_shadow_color = Color(0, 0, 0, 0) +PanelText/font_sizes/font_size = 25 +PanelTitle/base_type = &"Label" +PanelTitle/colors/font_color = Color(0.01, 0.055, 0.3, 1) +PanelTitle/colors/font_shadow_color = Color(0, 0, 0, 0) +PanelTitle/font_sizes/font_size = 27 +PrimaryButton/base_type = &"Button" +PrimaryButton/font_sizes/font_size = 40 +PrimaryButton/styles/disabled = SubResource("StyleBox_button_disabled") +PrimaryButton/styles/focus = SubResource("StyleBox_focus") +PrimaryButton/styles/hover = SubResource("StyleBox_button_hover") +PrimaryButton/styles/normal = SubResource("StyleBox_button_normal") +PrimaryButton/styles/pressed = SubResource("StyleBox_button_pressed") +ReferencePanel/base_type = &"PanelContainer" +ReferencePanel/styles/panel = SubResource("StyleBox_panel_reference_44") +ScreenDescription/base_type = &"Label" +ScreenDescription/colors/font_color = Color(1, 1, 1, 1) +ScreenDescription/colors/font_shadow_color = Color(0, 0, 0, 0) +ScreenDescription/constants/shadow_offset_x = 0 +ScreenDescription/constants/shadow_offset_y = 0 +ScreenDescription/font_sizes/font_size = 18 +ScreenTitle/base_type = &"Label" +ScreenTitle/colors/font_color = Color(1, 1, 1, 1) +ScreenTitle/colors/font_shadow_color = Color(0, 0, 0, 0) +ScreenTitle/constants/shadow_offset_x = 0 +ScreenTitle/constants/shadow_offset_y = 0 +ScreenTitle/font_sizes/font_size = 36 +SecondaryButton/base_type = &"Button" +SecondaryButton/font_sizes/font_size = 22 +SecondaryButton/styles/disabled = SubResource("StyleBox_button_disabled") +SecondaryButton/styles/focus = SubResource("StyleBox_focus") +SecondaryButton/styles/hover = SubResource("StyleBox_compact_normal") +SecondaryButton/styles/normal = SubResource("StyleBox_compact_normal") +SecondaryButton/styles/pressed = SubResource("StyleBox_compact_pressed") +SectionTabButton/base_type = &"Button" +SectionTabButton/font_sizes/font_size = 18 +SectionTabButton/styles/disabled = SubResource("StyleBox_button_disabled") +SectionTabButton/styles/focus = SubResource("StyleBox_focus") +SectionTabButton/styles/hover = SubResource("StyleBox_compact_normal") +SectionTabButton/styles/normal = SubResource("StyleBox_compact_normal") +SectionTabButton/styles/pressed = SubResource("StyleBox_compact_pressed") +SpinBox/colors/down_disabled_icon_modulate = Color(1, 1, 1, 0.55) +SpinBox/colors/down_hover_icon_modulate = Color(1, 1, 1, 1) +SpinBox/colors/down_icon_modulate = Color(1, 1, 1, 1) +SpinBox/colors/down_pressed_icon_modulate = Color(1, 1, 1, 1) +SpinBox/colors/up_disabled_icon_modulate = Color(1, 1, 1, 0.55) +SpinBox/colors/up_hover_icon_modulate = Color(1, 1, 1, 1) +SpinBox/colors/up_icon_modulate = Color(1, 1, 1, 1) +SpinBox/colors/up_pressed_icon_modulate = Color(1, 1, 1, 1) +SpinBox/constants/buttons_vertical_separation = 0 +SpinBox/constants/buttons_width = 32 +SpinBox/constants/field_and_buttons_separation = 10 +SpinBox/constants/set_min_buttons_width_from_icons = 1 +SpinBox/icons/down = ExtResource("10_button_down") +SpinBox/icons/down_disabled = ExtResource("10_button_down") +SpinBox/icons/down_hover = ExtResource("10_button_down") +SpinBox/icons/down_pressed = ExtResource("10_button_down") +SpinBox/icons/up = ExtResource("9_button_up") +SpinBox/icons/up_disabled = ExtResource("9_button_up") +SpinBox/icons/up_hover = ExtResource("9_button_up") +SpinBox/icons/up_pressed = ExtResource("9_button_up") +SpinBox/styles/down_background = SubResource("StyleBox_tab_panel") +SpinBox/styles/down_background_disabled = SubResource("StyleBox_tab_panel") +SpinBox/styles/down_background_hovered = SubResource("StyleBox_tab_panel") +SpinBox/styles/down_background_pressed = SubResource("StyleBox_tab_panel") +SpinBox/styles/field_and_buttons_separator = SubResource("StyleBox_tab_panel") +SpinBox/styles/up_background = SubResource("StyleBox_tab_panel") +SpinBox/styles/up_background_disabled = SubResource("StyleBox_tab_panel") +SpinBox/styles/up_background_hovered = SubResource("StyleBox_tab_panel") +SpinBox/styles/up_background_pressed = SubResource("StyleBox_tab_panel") +SpinBox/styles/up_down_buttons_separator = SubResource("StyleBox_tab_panel") +SpinBoxInnerLineEdit/base_type = &"LineEdit" +SpinBoxInnerLineEdit/font_sizes/font_size = 18 +StatusPanel/base_type = &"PanelContainer" +StatusPanel/styles/panel = SubResource("StyleBox_status") +StatusText/base_type = &"Label" +StatusText/colors/font_color = Color(0.005, 0.012, 0.05, 1) +StatusText/colors/font_shadow_color = Color(0, 0, 0, 0) +StatusText/font_sizes/font_size = 18 +SupportText/base_type = &"Label" +SupportText/colors/font_color = Color(1, 1, 1, 1) +SupportText/colors/font_shadow_color = Color(0, 0, 0, 0) +SupportText/constants/shadow_offset_x = 0 +SupportText/constants/shadow_offset_y = 0 +SupportText/font_sizes/font_size = 18 +TabContainer/colors/font_hovered_color = Color(0.96, 1, 0.9, 1) +TabContainer/colors/font_selected_color = Color(0.02, 0.08, 0.18, 1) +TabContainer/colors/font_unselected_color = Color(0.92, 0.98, 1, 1) +TabContainer/font_sizes/font_size = 13 +TabContainer/styles/panel = SubResource("StyleBox_tab_panel") +TabContainer/styles/tab_hovered = SubResource("StyleBox_tab_selected") +TabContainer/styles/tab_selected = SubResource("StyleBox_tab_selected") +TabContainer/styles/tab_unselected = SubResource("StyleBox_tab_unselected") +VScrollBar/styles/grabber = SubResource("StyleBox_scroll_grabber") +VScrollBar/styles/grabber_highlight = SubResource("StyleBox_scroll_grabber") +VScrollBar/styles/grabber_pressed = SubResource("StyleBox_scroll_grabber") +VScrollBar/styles/scroll = SubResource("StyleBox_scroll_track") +VScrollBar/styles/scroll_focus = SubResource("StyleBox_scroll_track") +WarningText/base_type = &"Label" +WarningText/colors/font_color = Color(0.52, 0.3, 0.02, 1) +WarningText/colors/font_shadow_color = Color(0, 0, 0, 0) +WarningText/font_sizes/font_size = 18 +WhitePanel/base_type = &"PanelContainer" +WhitePanel/styles/panel = SubResource("StyleBox_panel_reference") diff --git a/themes/space-pixel/package/visuals/card_terminal.tscn b/themes/space-pixel/package/visuals/card_terminal.tscn new file mode 100644 index 0000000..f0a3712 --- /dev/null +++ b/themes/space-pixel/package/visuals/card_terminal.tscn @@ -0,0 +1,18 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Texture2D" path="../assets/animations/card/frame_01.png" id="1_card"] +[ext_resource type="Texture2D" path="../assets/animations/card/frame_02.png" id="2_card"] + +[sub_resource type="SpriteFrames" id="SpriteFrames_card"] +animations = [{"frames": [{"duration": 1.0, "texture": ExtResource("1_card")}, {"duration": 1.0, "texture": ExtResource("2_card")}], "loop": true, "name": &"default", "speed": 1.0}] + +[node name="CardTerminal" type="Control"] +custom_minimum_size = Vector2(0, 0) +mouse_filter = 2 + +[node name="Animation" type="AnimatedSprite2D" parent="."] +texture_filter = 1 +position = Vector2(286, 183) +scale = Vector2(0.68, 0.68) +sprite_frames = SubResource("SpriteFrames_card") +autoplay = "default" diff --git a/themes/space-pixel/package/visuals/cash_instructions.tscn b/themes/space-pixel/package/visuals/cash_instructions.tscn new file mode 100644 index 0000000..af000d4 --- /dev/null +++ b/themes/space-pixel/package/visuals/cash_instructions.tscn @@ -0,0 +1,33 @@ +[gd_scene load_steps=9 format=3] + +[ext_resource type="Texture2D" path="../assets/animations/bill/frame_01.png" id="1_bill_1"] +[ext_resource type="Texture2D" path="../assets/animations/bill/frame_02.png" id="2_bill_2"] +[ext_resource type="Texture2D" path="../assets/animations/bill/frame_03.png" id="3_bill_3"] +[ext_resource type="Texture2D" path="../assets/animations/bill/frame_04.png" id="4_bill_4"] +[ext_resource type="Texture2D" path="../assets/animations/coin/frame_01.png" id="5_coin_1"] +[ext_resource type="Texture2D" path="../assets/animations/coin/frame_02.png" id="6_coin_2"] +[ext_resource type="Texture2D" path="../assets/animations/coin/frame_03.png" id="7_coin_3"] + +[sub_resource type="SpriteFrames" id="SpriteFrames_bill"] +animations = [{"frames": [{"duration": 1.0, "texture": ExtResource("1_bill_1")}, {"duration": 1.0, "texture": ExtResource("2_bill_2")}, {"duration": 1.0, "texture": ExtResource("3_bill_3")}, {"duration": 1.0, "texture": ExtResource("4_bill_4")}], "loop": true, "name": &"default", "speed": 4.0}] + +[sub_resource type="SpriteFrames" id="SpriteFrames_coin"] +animations = [{"frames": [{"duration": 1.0, "texture": ExtResource("5_coin_1")}, {"duration": 1.0, "texture": ExtResource("6_coin_2")}, {"duration": 1.0, "texture": ExtResource("7_coin_3")}], "loop": true, "name": &"default", "speed": 3.0}] + +[node name="CashInstructions" type="Control"] +custom_minimum_size = Vector2(648, 322) +mouse_filter = 2 + +[node name="Bill" type="AnimatedSprite2D" parent="."] +texture_filter = 1 +position = Vector2(188, 93) +scale = Vector2(0.667, 0.667) +sprite_frames = SubResource("SpriteFrames_bill") +autoplay = "default" + +[node name="Coin" type="AnimatedSprite2D" parent="."] +texture_filter = 1 +position = Vector2(184, 241) +scale = Vector2(0.667, 0.667) +sprite_frames = SubResource("SpriteFrames_coin") +autoplay = "default" diff --git a/themes/space-pixel/package/visuals/cash_tip.tscn b/themes/space-pixel/package/visuals/cash_tip.tscn new file mode 100644 index 0000000..610de3c --- /dev/null +++ b/themes/space-pixel/package/visuals/cash_tip.tscn @@ -0,0 +1,57 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Texture2D" path="../assets/decorations/alien3.png" id="1_alien"] +[ext_resource type="Texture2D" path="../assets/ui/bubble_text.png" id="2_bubble"] + +[node name="CashTip" type="Control"] +custom_minimum_size = Vector2(303, 125) +mouse_filter = 2 + +[node name="Bubble" type="TextureRect" parent="."] +texture_filter = 1 +offset_left = -75.0 +offset_top = -1.0 +offset_right = 266.0 +offset_bottom = 84.0 +mouse_filter = 2 +texture = ExtResource("2_bubble") +expand_mode = 1 +stretch_mode = 0 + +[node name="Header" type="Label" parent="."] +layout_mode = 0 +offset_left = 16.0 +offset_top = 12.0 +offset_right = 177.0 +offset_bottom = 35.0 +theme_type_variation = &"PanelText" +theme_override_font_sizes/font_size = 11 +text = "ОЙ, СОВСЕМ ЗАБЫЛ:" +vertical_alignment = 1 +pivot_offset = Vector2(0, 11.5) +scale = Vector2(1.31, 1) + +[node name="Message" type="Label" parent="."] +layout_mode = 0 +offset_left = 15.0 +offset_top = 37.0 +offset_right = 180.0 +offset_bottom = 75.0 +theme_type_variation = &"PanelText" +theme_override_colors/font_color = Color(0, 0.0627451, 0.552941, 1) +theme_override_font_sizes/font_size = 11 +text = "АВТОМАТ ВЕДЬ\nСДАЧИ НЕ ДАЁТ!" +vertical_alignment = 1 +pivot_offset = Vector2(0, 18) +scale = Vector2(1.2, 1) + +[node name="Alien" type="TextureRect" parent="."] +texture_filter = 1 +offset_left = 146.0 +offset_top = -17.0 +offset_right = 317.0 +offset_bottom = 154.0 +mouse_filter = 2 +texture = ExtResource("1_alien") +expand_mode = 1 +stretch_mode = 0 diff --git a/themes/space-pixel/package/visuals/home_advertisement.tscn b/themes/space-pixel/package/visuals/home_advertisement.tscn new file mode 100644 index 0000000..ae8cde7 --- /dev/null +++ b/themes/space-pixel/package/visuals/home_advertisement.tscn @@ -0,0 +1,18 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Texture2D" path="../assets/ui/billboard_ads.png" id="1_billboard"] + +[node name="HomeAdvertisement" type="Control"] +custom_minimum_size = Vector2(720, 342) +mouse_filter = 2 + +[node name="Billboard" type="TextureRect" parent="."] +texture_filter = 1 +offset_left = 18.0 +offset_top = 12.0 +offset_right = 701.0 +offset_bottom = 352.0 +mouse_filter = 2 +texture = ExtResource("1_billboard") +expand_mode = 1 +stretch_mode = 0 diff --git a/themes/space-pixel/package/visuals/home_top.tscn b/themes/space-pixel/package/visuals/home_top.tscn new file mode 100644 index 0000000..11577b7 --- /dev/null +++ b/themes/space-pixel/package/visuals/home_top.tscn @@ -0,0 +1,49 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Texture2D" path="../assets/decorations/alien1.png" id="1_left"] +[ext_resource type="Texture2D" path="../assets/decorations/alien2.png" id="2_right"] +[ext_resource type="Texture2D" path="../assets/ui/bubble_text.png" id="3_bubble"] +[ext_resource type="Script" path="../scripts/home_ufo_bobbing.gd" id="4_bobbing"] + +[node name="HomeTop" type="Control"] +custom_minimum_size = Vector2(720, 300) +mouse_filter = 2 +script = ExtResource("4_bobbing") + +[node name="AlienLeft" type="TextureRect" parent="."] +texture_filter = 1 +offset_left = 17.0 +offset_top = 130.0 +offset_right = 187.0 +offset_bottom = 300.0 +mouse_filter = 2 +texture = ExtResource("1_left") +expand_mode = 1 +stretch_mode = 5 + +[node name="CustomTextBlock" type="Control" parent="."] +metadata/exchanger_binding_id = "home.decoration.custom_text_block" +mouse_filter = 2 + +[node name="AlienRight" type="TextureRect" parent="CustomTextBlock"] +metadata/exchanger_binding_id = "home.decoration.right_character" +texture_filter = 1 +offset_left = 536.0 +offset_top = 83.0 +offset_right = 706.0 +offset_bottom = 253.0 +mouse_filter = 2 +texture = ExtResource("2_right") +expand_mode = 1 +stretch_mode = 5 + +[node name="Speech" type="TextureRect" parent="CustomTextBlock"] +texture_filter = 1 +offset_left = 331.0 +offset_top = 59.0 +offset_right = 667.0 +offset_bottom = 141.0 +mouse_filter = 2 +texture = ExtResource("3_bubble") +expand_mode = 1 +stretch_mode = 0 diff --git a/themes/space-pixel/package/visuals/result_error.tscn b/themes/space-pixel/package/visuals/result_error.tscn new file mode 100644 index 0000000..adc472f --- /dev/null +++ b/themes/space-pixel/package/visuals/result_error.tscn @@ -0,0 +1,19 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Texture2D" path="../assets/animations/payment_failed/frame_01.png" id="1"] +[ext_resource type="Texture2D" path="../assets/animations/payment_failed/frame_02.png" id="2"] +[ext_resource type="Texture2D" path="../assets/animations/payment_failed/frame_03.png" id="3"] + +[sub_resource type="SpriteFrames" id="Frames"] +animations = [{"frames": [{"duration": 1.0, "texture": ExtResource("1")}, {"duration": 1.0, "texture": ExtResource("2")}, {"duration": 1.0, "texture": ExtResource("3")}], "loop": true, "name": &"default", "speed": 3.0}] + +[node name="ResultError" type="Control"] +custom_minimum_size = Vector2(484, 538) +mouse_filter = 2 + +[node name="Animation" type="AnimatedSprite2D" parent="."] +texture_filter = 1 +position = Vector2(242, 220) +scale = Vector2(0.72, 0.72) +sprite_frames = SubResource("Frames") +autoplay = "default" diff --git a/themes/space-pixel/package/visuals/result_success.tscn b/themes/space-pixel/package/visuals/result_success.tscn new file mode 100644 index 0000000..1593258 --- /dev/null +++ b/themes/space-pixel/package/visuals/result_success.tscn @@ -0,0 +1,21 @@ +[gd_scene load_steps=7 format=3] + +[ext_resource type="Texture2D" path="../assets/animations/payment_success/frame_01.png" id="1"] +[ext_resource type="Texture2D" path="../assets/animations/payment_success/frame_02.png" id="2"] +[ext_resource type="Texture2D" path="../assets/animations/payment_success/frame_03.png" id="3"] +[ext_resource type="Texture2D" path="../assets/animations/payment_success/frame_04.png" id="4"] +[ext_resource type="Texture2D" path="../assets/animations/payment_success/frame_05.png" id="5"] + +[sub_resource type="SpriteFrames" id="Frames"] +animations = [{"frames": [{"duration": 1.0, "texture": ExtResource("1")}, {"duration": 1.0, "texture": ExtResource("2")}, {"duration": 1.0, "texture": ExtResource("3")}, {"duration": 1.0, "texture": ExtResource("4")}, {"duration": 1.0, "texture": ExtResource("5")}], "loop": true, "name": &"default", "speed": 5.0}] + +[node name="ResultSuccess" type="Control"] +custom_minimum_size = Vector2(484, 538) +mouse_filter = 2 + +[node name="Animation" type="AnimatedSprite2D" parent="."] +texture_filter = 1 +position = Vector2(242, 220) +scale = Vector2(0.72, 0.72) +sprite_frames = SubResource("Frames") +autoplay = "default" diff --git a/themes/space-pixel/source/Bubble_addtext.png b/themes/space-pixel/source/Bubble_addtext.png new file mode 100644 index 0000000..80c9736 Binary files /dev/null and b/themes/space-pixel/source/Bubble_addtext.png differ diff --git a/themes/space-pixel/source/Bubble_addtext.png.import b/themes/space-pixel/source/Bubble_addtext.png.import new file mode 100644 index 0000000..bdfbe82 --- /dev/null +++ b/themes/space-pixel/source/Bubble_addtext.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dokdtevosptcq" +path="res://.godot/imported/Bubble_addtext.png-77f0a7c9adaede2207610ff34af41ff5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Bubble_addtext.png" +dest_files=["res://.godot/imported/Bubble_addtext.png-77f0a7c9adaede2207610ff34af41ff5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/README.md b/themes/space-pixel/source/README.md new file mode 100644 index 0000000..48ed177 --- /dev/null +++ b/themes/space-pixel/source/README.md @@ -0,0 +1,53 @@ +# Исходники темы `space-pixel` + +Этот каталог содержит оригинальные материалы темы и не включается в DLC напрямую. +Исходные файлы не удаляются и не перезаписываются при подготовке пакета. + +Фактическая структура поставки сохранена: UI-файлы лежат в корне, последовательности кадров — +в русскоязычных подпапках, шрифт — в `Шрифты/`. Производные runtime-копии получили ASCII-имена +и лежат отдельно в соседнем `package/assets/`. + +В `references/` локально скопированы восемь макетов интерфейса, рендер корпуса разменника и +видео полного пользовательского потока. Этот каталог нужен только для визуального сравнения, +игнорируется Git и не экспортируется в DLC. + +Обязательные правила Exchanger: + +- основной фон остаётся статическим; +- анимация реализуется отдельными спрайтами в пассивной decoration-сцене; +- шейдеры, частицы и видео для фона не используются; +- тема должна корректно работать при отключённой decoration-сцене (`ReducedEffects=true`); +- итоговые runtime-ресурсы помещаются в соседний `package/`, напрямую отображаются в preview и + собираются в пакет `ExchangerThemeDlc` версии 2 с каноническими runtime-путями. + +Исходная UI-поставка включает `tumbler_on.png`, `tumbler_off.png`, `button_up.png`, +`button_down.png`, новые пиктограммы питомца `home.png`/`cross.png` и исходные дубликаты +последовательности мытья `climbing_action_start.png`/`climbing_action.png`/ +`climbing_action_stop.png`, а также один шрифт +`VCR OSD Mono Cyr.ttf`. Дополнительный каталог +`alien_animation/` содержит 22 sprite-sheet интерактивного питомца; это сохранённое имя папки +исходной поставки конкретной темы, а не требование builder. Production-сцена +`package/components/interactive_pet.tscn` ссылается на свои runtime-копии напрямую и использует +414 кадров по сетке `128×128 px`. Другие темы могут хранить текстуры питомца в папке с любым +именем — переименование под `alien_pet` не требуется. Исходные стрелки `64×64 px` +преобразуются в runtime-копии `32×32 px` ближайшим соседом, чтобы целиком помещаться в две +половины `SpinBox`. Файл `Цвета.txt` имеет нулевой +размер, поэтому палитра темы определена по самим изображениям. Большинство файлов `.import` и +`Thumbs.db` считаются метаданными исходной поставки и не копируются в runtime-каталог. Для +production-шрифта отдельно поддерживается `package/assets/fonts/vcr_osd_mono_cyr.ttf.import`: +он отключает antialiasing, hinting и subpixel positioning и фиксирует oversampling `1.0`. + +## Происхождение шрифта + +Встроенные metadata исходного TTF: family `VCR OSD Mono [RUS by Daymarius]`, version +`1.00 August 17, 2018`, designer `MrManet`. Поля copyright, license и license URL пусты. + +- автор оригинального VCR OSD Mono подтвердил бесплатное коммерческое использование в комментарии + на `https://www.dafont.com/font-comment.php?file=vcr_osd_mono`; +- точная кириллическая версия описана сторонним каталогом + `https://ffont.ru/en/font/vcr-osd-mono-rus-by-daymarius`, но эта страница не заменяет разрешение + автора модификации. + +Для внутреннего проекта файл сохраняется ради совпадения с референсами. Перед публичным +распространением PCK нужно получить письменное разрешение Daymarius либо заменить TTF на шрифт с +явной лицензией и заново выполнить pixel-perfect QA всех экранов. diff --git a/themes/space-pixel/source/Window_k_vidache.png b/themes/space-pixel/source/Window_k_vidache.png new file mode 100644 index 0000000..581e811 Binary files /dev/null and b/themes/space-pixel/source/Window_k_vidache.png differ diff --git a/themes/space-pixel/source/Window_k_vidache.png.import b/themes/space-pixel/source/Window_k_vidache.png.import new file mode 100644 index 0000000..8f038ce --- /dev/null +++ b/themes/space-pixel/source/Window_k_vidache.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bm7uaxf8dnwwg" +path="res://.godot/imported/Window_k_vidache.png-d8dab70fb2e2b8a7d21eca79a8e6fd02.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Window_k_vidache.png" +dest_files=["res://.godot/imported/Window_k_vidache.png-d8dab70fb2e2b8a7d21eca79a8e6fd02.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Window_paycard.png b/themes/space-pixel/source/Window_paycard.png new file mode 100644 index 0000000..7d585fb Binary files /dev/null and b/themes/space-pixel/source/Window_paycard.png differ diff --git a/themes/space-pixel/source/Window_paycard.png.import b/themes/space-pixel/source/Window_paycard.png.import new file mode 100644 index 0000000..e143815 --- /dev/null +++ b/themes/space-pixel/source/Window_paycard.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://hvm0048q8gro" +path="res://.godot/imported/Window_paycard.png-1c46b5dff26a49966bfd3902cddb3771.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Window_paycard.png" +dest_files=["res://.godot/imported/Window_paycard.png-1c46b5dff26a49966bfd3902cddb3771.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Window_paynal_balance.png b/themes/space-pixel/source/Window_paynal_balance.png new file mode 100644 index 0000000..a815c0a Binary files /dev/null and b/themes/space-pixel/source/Window_paynal_balance.png differ diff --git a/themes/space-pixel/source/Window_paynal_balance.png.import b/themes/space-pixel/source/Window_paynal_balance.png.import new file mode 100644 index 0000000..7ab35c6 --- /dev/null +++ b/themes/space-pixel/source/Window_paynal_balance.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bh50c21qkfas1" +path="res://.godot/imported/Window_paynal_balance.png-7c30643370bfd2445a42401521c7c768.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Window_paynal_balance.png" +dest_files=["res://.godot/imported/Window_paynal_balance.png-7c30643370bfd2445a42401521c7c768.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Window_paynal_balance_money.png b/themes/space-pixel/source/Window_paynal_balance_money.png new file mode 100644 index 0000000..3630f6c Binary files /dev/null and b/themes/space-pixel/source/Window_paynal_balance_money.png differ diff --git a/themes/space-pixel/source/Window_paynal_balance_money.png.import b/themes/space-pixel/source/Window_paynal_balance_money.png.import new file mode 100644 index 0000000..46426bd --- /dev/null +++ b/themes/space-pixel/source/Window_paynal_balance_money.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://evbkhli3wy0e" +path="res://.godot/imported/Window_paynal_balance_money.png-a9ed45398ea128d6ba67603c1dd65719.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Window_paynal_balance_money.png" +dest_files=["res://.godot/imported/Window_paynal_balance_money.png-a9ed45398ea128d6ba67603c1dd65719.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Window_paynal_instruction.png b/themes/space-pixel/source/Window_paynal_instruction.png new file mode 100644 index 0000000..ddb45b7 Binary files /dev/null and b/themes/space-pixel/source/Window_paynal_instruction.png differ diff --git a/themes/space-pixel/source/Window_paynal_instruction.png.import b/themes/space-pixel/source/Window_paynal_instruction.png.import new file mode 100644 index 0000000..1685e59 --- /dev/null +++ b/themes/space-pixel/source/Window_paynal_instruction.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://es3itce43866" +path="res://.godot/imported/Window_paynal_instruction.png-f8c79d6e7d0d457ac0e3841dcb551aa4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Window_paynal_instruction.png" +dest_files=["res://.godot/imported/Window_paynal_instruction.png-f8c79d6e7d0d457ac0e3841dcb551aa4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien1.png b/themes/space-pixel/source/alien1.png new file mode 100644 index 0000000..e4d0481 Binary files /dev/null and b/themes/space-pixel/source/alien1.png differ diff --git a/themes/space-pixel/source/alien1.png.import b/themes/space-pixel/source/alien1.png.import new file mode 100644 index 0000000..ebb63f9 --- /dev/null +++ b/themes/space-pixel/source/alien1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://btm80sapoawud" +path="res://.godot/imported/alien1.png-8fad6ca9040d6686523e1c03f8f0abd0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien1.png" +dest_files=["res://.godot/imported/alien1.png-8fad6ca9040d6686523e1c03f8f0abd0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien2.png b/themes/space-pixel/source/alien2.png new file mode 100644 index 0000000..9aeccb4 Binary files /dev/null and b/themes/space-pixel/source/alien2.png differ diff --git a/themes/space-pixel/source/alien2.png.import b/themes/space-pixel/source/alien2.png.import new file mode 100644 index 0000000..ee10c43 --- /dev/null +++ b/themes/space-pixel/source/alien2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://jqoca8yy31mv" +path="res://.godot/imported/alien2.png-8fa88584ec7eed67e858932dc9e775ce.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien2.png" +dest_files=["res://.godot/imported/alien2.png-8fa88584ec7eed67e858932dc9e775ce.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien3.png b/themes/space-pixel/source/alien3.png new file mode 100644 index 0000000..5a3fcd9 Binary files /dev/null and b/themes/space-pixel/source/alien3.png differ diff --git a/themes/space-pixel/source/alien3.png.import b/themes/space-pixel/source/alien3.png.import new file mode 100644 index 0000000..b3b4dc8 --- /dev/null +++ b/themes/space-pixel/source/alien3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cc3amnqlqpmax" +path="res://.godot/imported/alien3.png-f183ae7559864347bd1f2e2d578cc37d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien3.png" +dest_files=["res://.godot/imported/alien3.png-f183ae7559864347bd1f2e2d578cc37d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/action.png b/themes/space-pixel/source/alien_animation/action.png new file mode 100644 index 0000000..657fa0a Binary files /dev/null and b/themes/space-pixel/source/alien_animation/action.png differ diff --git a/themes/space-pixel/source/alien_animation/action.png.import b/themes/space-pixel/source/alien_animation/action.png.import new file mode 100644 index 0000000..64ce224 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/action.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ry4vsuf7ac1w" +path="res://.godot/imported/action.png-4d93b8891fe3a257b37588d3c0166c34.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/action.png" +dest_files=["res://.godot/imported/action.png-4d93b8891fe3a257b37588d3c0166c34.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/climbing_action.png b/themes/space-pixel/source/alien_animation/climbing_action.png new file mode 100644 index 0000000..41a3425 Binary files /dev/null and b/themes/space-pixel/source/alien_animation/climbing_action.png differ diff --git a/themes/space-pixel/source/alien_animation/climbing_action.png.import b/themes/space-pixel/source/alien_animation/climbing_action.png.import new file mode 100644 index 0000000..5482524 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/climbing_action.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bvt6mvcv6rnph" +path="res://.godot/imported/climbing_action.png-09646ad2d300a74811efac54185f133b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/climbing_action.png" +dest_files=["res://.godot/imported/climbing_action.png-09646ad2d300a74811efac54185f133b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/climbing_action_start.png b/themes/space-pixel/source/alien_animation/climbing_action_start.png new file mode 100644 index 0000000..de73a1e Binary files /dev/null and b/themes/space-pixel/source/alien_animation/climbing_action_start.png differ diff --git a/themes/space-pixel/source/alien_animation/climbing_action_start.png.import b/themes/space-pixel/source/alien_animation/climbing_action_start.png.import new file mode 100644 index 0000000..adae81f --- /dev/null +++ b/themes/space-pixel/source/alien_animation/climbing_action_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d1sspdi4ir8rc" +path="res://.godot/imported/climbing_action_start.png-3b595123148dd874df8e3c2391030f0e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/climbing_action_start.png" +dest_files=["res://.godot/imported/climbing_action_start.png-3b595123148dd874df8e3c2391030f0e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/climbing_action_stop.png b/themes/space-pixel/source/alien_animation/climbing_action_stop.png new file mode 100644 index 0000000..da6d719 Binary files /dev/null and b/themes/space-pixel/source/alien_animation/climbing_action_stop.png differ diff --git a/themes/space-pixel/source/alien_animation/climbing_action_stop.png.import b/themes/space-pixel/source/alien_animation/climbing_action_stop.png.import new file mode 100644 index 0000000..187b6e9 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/climbing_action_stop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://nm0y2hd18npx" +path="res://.godot/imported/climbing_action_stop.png-62e84f7aee7838daacd6b5af28f9adcc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/climbing_action_stop.png" +dest_files=["res://.godot/imported/climbing_action_stop.png-62e84f7aee7838daacd6b5af28f9adcc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/climbing_down.png b/themes/space-pixel/source/alien_animation/climbing_down.png new file mode 100644 index 0000000..f3e4090 Binary files /dev/null and b/themes/space-pixel/source/alien_animation/climbing_down.png differ diff --git a/themes/space-pixel/source/alien_animation/climbing_down.png.import b/themes/space-pixel/source/alien_animation/climbing_down.png.import new file mode 100644 index 0000000..7ef456c --- /dev/null +++ b/themes/space-pixel/source/alien_animation/climbing_down.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ck2fk02dtnbjq" +path="res://.godot/imported/climbing_down.png-9cb2c2fecde5153c359ab52a85221dab.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/climbing_down.png" +dest_files=["res://.godot/imported/climbing_down.png-9cb2c2fecde5153c359ab52a85221dab.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/climbing_start.png b/themes/space-pixel/source/alien_animation/climbing_start.png new file mode 100644 index 0000000..649a01a Binary files /dev/null and b/themes/space-pixel/source/alien_animation/climbing_start.png differ diff --git a/themes/space-pixel/source/alien_animation/climbing_start.png.import b/themes/space-pixel/source/alien_animation/climbing_start.png.import new file mode 100644 index 0000000..a31ab1a --- /dev/null +++ b/themes/space-pixel/source/alien_animation/climbing_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://diyfb1ls1e5eo" +path="res://.godot/imported/climbing_start.png-a4ae48c7c0659a5edae55cfa41a56a77.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/climbing_start.png" +dest_files=["res://.godot/imported/climbing_start.png-a4ae48c7c0659a5edae55cfa41a56a77.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/climbing_up.png b/themes/space-pixel/source/alien_animation/climbing_up.png new file mode 100644 index 0000000..3cfb53e Binary files /dev/null and b/themes/space-pixel/source/alien_animation/climbing_up.png differ diff --git a/themes/space-pixel/source/alien_animation/climbing_up.png.import b/themes/space-pixel/source/alien_animation/climbing_up.png.import new file mode 100644 index 0000000..ae3d175 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/climbing_up.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d1p7hg6oxb3b4" +path="res://.godot/imported/climbing_up.png-746e9c7765ab6eda7a269398ad7c1cfe.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/climbing_up.png" +dest_files=["res://.godot/imported/climbing_up.png-746e9c7765ab6eda7a269398ad7c1cfe.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/come_closer.png b/themes/space-pixel/source/alien_animation/come_closer.png new file mode 100644 index 0000000..f3127ee Binary files /dev/null and b/themes/space-pixel/source/alien_animation/come_closer.png differ diff --git a/themes/space-pixel/source/alien_animation/come_closer.png.import b/themes/space-pixel/source/alien_animation/come_closer.png.import new file mode 100644 index 0000000..3a77919 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/come_closer.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://v8pbjw5d2kqw" +path="res://.godot/imported/come_closer.png-f62687926e3886371acc12f5ec13c0a7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/come_closer.png" +dest_files=["res://.godot/imported/come_closer.png-f62687926e3886371acc12f5ec13c0a7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/dancing.png b/themes/space-pixel/source/alien_animation/dancing.png new file mode 100644 index 0000000..cc2238a Binary files /dev/null and b/themes/space-pixel/source/alien_animation/dancing.png differ diff --git a/themes/space-pixel/source/alien_animation/dancing.png.import b/themes/space-pixel/source/alien_animation/dancing.png.import new file mode 100644 index 0000000..80c3cd6 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/dancing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bto71px8u3op1" +path="res://.godot/imported/dancing.png-ddc1c50d7509c7782d6ef753a4d78a4e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/dancing.png" +dest_files=["res://.godot/imported/dancing.png-ddc1c50d7509c7782d6ef753a4d78a4e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/falling.png b/themes/space-pixel/source/alien_animation/falling.png new file mode 100644 index 0000000..d1c3b9d Binary files /dev/null and b/themes/space-pixel/source/alien_animation/falling.png differ diff --git a/themes/space-pixel/source/alien_animation/falling.png.import b/themes/space-pixel/source/alien_animation/falling.png.import new file mode 100644 index 0000000..401d544 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/falling.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b6yq2xhticm4s" +path="res://.godot/imported/falling.png-5b7eeb7b018f375ae275fa5bebcc57a7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/falling.png" +dest_files=["res://.godot/imported/falling.png-5b7eeb7b018f375ae275fa5bebcc57a7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/hanging.png b/themes/space-pixel/source/alien_animation/hanging.png new file mode 100644 index 0000000..a80ea47 Binary files /dev/null and b/themes/space-pixel/source/alien_animation/hanging.png differ diff --git a/themes/space-pixel/source/alien_animation/hanging.png.import b/themes/space-pixel/source/alien_animation/hanging.png.import new file mode 100644 index 0000000..f77bef3 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/hanging.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bq6cpfidlssqn" +path="res://.godot/imported/hanging.png-91e9a50fa8aed8cec258ead3af3287a8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/hanging.png" +dest_files=["res://.godot/imported/hanging.png-91e9a50fa8aed8cec258ead3af3287a8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/idle.png b/themes/space-pixel/source/alien_animation/idle.png new file mode 100644 index 0000000..b117916 Binary files /dev/null and b/themes/space-pixel/source/alien_animation/idle.png differ diff --git a/themes/space-pixel/source/alien_animation/idle.png.import b/themes/space-pixel/source/alien_animation/idle.png.import new file mode 100644 index 0000000..8b76fcc --- /dev/null +++ b/themes/space-pixel/source/alien_animation/idle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bk8pqbw5lb5km" +path="res://.godot/imported/idle.png-b9460d5a8d30e3e0cd6e6ffdd2054228.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/idle.png" +dest_files=["res://.godot/imported/idle.png-b9460d5a8d30e3e0cd6e6ffdd2054228.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/landing.png b/themes/space-pixel/source/alien_animation/landing.png new file mode 100644 index 0000000..4b6d00b Binary files /dev/null and b/themes/space-pixel/source/alien_animation/landing.png differ diff --git a/themes/space-pixel/source/alien_animation/landing.png.import b/themes/space-pixel/source/alien_animation/landing.png.import new file mode 100644 index 0000000..5f21f46 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/landing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://0f7c5pgmu3jv" +path="res://.godot/imported/landing.png-7a7e3dfc8e22791f14b262e3a235ca02.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/landing.png" +dest_files=["res://.godot/imported/landing.png-7a7e3dfc8e22791f14b262e3a235ca02.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/sitting.png b/themes/space-pixel/source/alien_animation/sitting.png new file mode 100644 index 0000000..777c472 Binary files /dev/null and b/themes/space-pixel/source/alien_animation/sitting.png differ diff --git a/themes/space-pixel/source/alien_animation/sitting.png.import b/themes/space-pixel/source/alien_animation/sitting.png.import new file mode 100644 index 0000000..0f47a4f --- /dev/null +++ b/themes/space-pixel/source/alien_animation/sitting.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bop1xqnlvul2s" +path="res://.godot/imported/sitting.png-553c8e224b9deb76442b17e85bb630f6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/sitting.png" +dest_files=["res://.godot/imported/sitting.png-553c8e224b9deb76442b17e85bb630f6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/turn_left.png b/themes/space-pixel/source/alien_animation/turn_left.png new file mode 100644 index 0000000..f0fc32a Binary files /dev/null and b/themes/space-pixel/source/alien_animation/turn_left.png differ diff --git a/themes/space-pixel/source/alien_animation/turn_left.png.import b/themes/space-pixel/source/alien_animation/turn_left.png.import new file mode 100644 index 0000000..49cabfe --- /dev/null +++ b/themes/space-pixel/source/alien_animation/turn_left.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://jpg234fx3cs5" +path="res://.godot/imported/turn_left.png-0655137ceaa84c962c0a8ffcc991834e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/turn_left.png" +dest_files=["res://.godot/imported/turn_left.png-0655137ceaa84c962c0a8ffcc991834e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/turn_right.png b/themes/space-pixel/source/alien_animation/turn_right.png new file mode 100644 index 0000000..69903a4 Binary files /dev/null and b/themes/space-pixel/source/alien_animation/turn_right.png differ diff --git a/themes/space-pixel/source/alien_animation/turn_right.png.import b/themes/space-pixel/source/alien_animation/turn_right.png.import new file mode 100644 index 0000000..811a7f7 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/turn_right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dcnjuicilmy7m" +path="res://.godot/imported/turn_right.png-9705b79633b7ac5c9bedda6eb18e3df7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/turn_right.png" +dest_files=["res://.godot/imported/turn_right.png-9705b79633b7ac5c9bedda6eb18e3df7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/walking_left.png b/themes/space-pixel/source/alien_animation/walking_left.png new file mode 100644 index 0000000..c0bb6d7 Binary files /dev/null and b/themes/space-pixel/source/alien_animation/walking_left.png differ diff --git a/themes/space-pixel/source/alien_animation/walking_left.png.import b/themes/space-pixel/source/alien_animation/walking_left.png.import new file mode 100644 index 0000000..fe3b5d5 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/walking_left.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b8v6ly51w7yvt" +path="res://.godot/imported/walking_left.png-bb5370246f3991ba00a8ed4f250abb76.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/walking_left.png" +dest_files=["res://.godot/imported/walking_left.png-bb5370246f3991ba00a8ed4f250abb76.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/walking_left_start.png b/themes/space-pixel/source/alien_animation/walking_left_start.png new file mode 100644 index 0000000..bcf299a Binary files /dev/null and b/themes/space-pixel/source/alien_animation/walking_left_start.png differ diff --git a/themes/space-pixel/source/alien_animation/walking_left_start.png.import b/themes/space-pixel/source/alien_animation/walking_left_start.png.import new file mode 100644 index 0000000..c2c017c --- /dev/null +++ b/themes/space-pixel/source/alien_animation/walking_left_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rvpwxiydmfgb" +path="res://.godot/imported/walking_left_start.png-202431d77dbe10cf10fb60da17efc868.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/walking_left_start.png" +dest_files=["res://.godot/imported/walking_left_start.png-202431d77dbe10cf10fb60da17efc868.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/walking_left_stop.png b/themes/space-pixel/source/alien_animation/walking_left_stop.png new file mode 100644 index 0000000..e54ac95 Binary files /dev/null and b/themes/space-pixel/source/alien_animation/walking_left_stop.png differ diff --git a/themes/space-pixel/source/alien_animation/walking_left_stop.png.import b/themes/space-pixel/source/alien_animation/walking_left_stop.png.import new file mode 100644 index 0000000..cf7b623 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/walking_left_stop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://drhuwqxauv6sh" +path="res://.godot/imported/walking_left_stop.png-982b08fc03bc3f81bcb6f2515cf59bdc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/walking_left_stop.png" +dest_files=["res://.godot/imported/walking_left_stop.png-982b08fc03bc3f81bcb6f2515cf59bdc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/walking_right.png b/themes/space-pixel/source/alien_animation/walking_right.png new file mode 100644 index 0000000..da883df Binary files /dev/null and b/themes/space-pixel/source/alien_animation/walking_right.png differ diff --git a/themes/space-pixel/source/alien_animation/walking_right.png.import b/themes/space-pixel/source/alien_animation/walking_right.png.import new file mode 100644 index 0000000..d8fa5d0 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/walking_right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://m0wvqcqx4tvm" +path="res://.godot/imported/walking_right.png-a2ff68cdcae8232bf9a800d9412f13b8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/walking_right.png" +dest_files=["res://.godot/imported/walking_right.png-a2ff68cdcae8232bf9a800d9412f13b8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/walking_right_stop.png b/themes/space-pixel/source/alien_animation/walking_right_stop.png new file mode 100644 index 0000000..dd12060 Binary files /dev/null and b/themes/space-pixel/source/alien_animation/walking_right_stop.png differ diff --git a/themes/space-pixel/source/alien_animation/walking_right_stop.png.import b/themes/space-pixel/source/alien_animation/walking_right_stop.png.import new file mode 100644 index 0000000..8eb7395 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/walking_right_stop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ctjvovb6pq8yd" +path="res://.godot/imported/walking_right_stop.png-eef5d4cd17d29d4d0b6072e0054d949b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/walking_right_stop.png" +dest_files=["res://.godot/imported/walking_right_stop.png-eef5d4cd17d29d4d0b6072e0054d949b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/alien_animation/walking_start_right.png b/themes/space-pixel/source/alien_animation/walking_start_right.png new file mode 100644 index 0000000..f8e561f Binary files /dev/null and b/themes/space-pixel/source/alien_animation/walking_start_right.png differ diff --git a/themes/space-pixel/source/alien_animation/walking_start_right.png.import b/themes/space-pixel/source/alien_animation/walking_start_right.png.import new file mode 100644 index 0000000..0ae57b3 --- /dev/null +++ b/themes/space-pixel/source/alien_animation/walking_start_right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c4ug8xkx4q8k5" +path="res://.godot/imported/walking_start_right.png-6ab75aa37a777795e4c508ef7bcc9655.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/alien_animation/walking_start_right.png" +dest_files=["res://.godot/imported/walking_start_right.png-6ab75aa37a777795e4c508ef7bcc9655.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/back_button.png b/themes/space-pixel/source/back_button.png new file mode 100644 index 0000000..efcc11d Binary files /dev/null and b/themes/space-pixel/source/back_button.png differ diff --git a/themes/space-pixel/source/back_button.png.import b/themes/space-pixel/source/back_button.png.import new file mode 100644 index 0000000..eb11e18 --- /dev/null +++ b/themes/space-pixel/source/back_button.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dmyjhei871wvy" +path="res://.godot/imported/back_button.png-158ab78332883ac5b6c51d113d83d3fd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/back_button.png" +dest_files=["res://.godot/imported/back_button.png-158ab78332883ac5b6c51d113d83d3fd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/background.png b/themes/space-pixel/source/background.png new file mode 100644 index 0000000..e2e9893 Binary files /dev/null and b/themes/space-pixel/source/background.png differ diff --git a/themes/space-pixel/source/background.png.import b/themes/space-pixel/source/background.png.import new file mode 100644 index 0000000..174d336 --- /dev/null +++ b/themes/space-pixel/source/background.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ssud2yq3katv" +path="res://.godot/imported/background.png-d5c0d6c0e41051aed35853cca901fa60.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/background.png" +dest_files=["res://.godot/imported/background.png-d5c0d6c0e41051aed35853cca901fa60.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/billboard_ads.png b/themes/space-pixel/source/billboard_ads.png new file mode 100644 index 0000000..07e41fe Binary files /dev/null and b/themes/space-pixel/source/billboard_ads.png differ diff --git a/themes/space-pixel/source/billboard_ads.png.import b/themes/space-pixel/source/billboard_ads.png.import new file mode 100644 index 0000000..4f640ca --- /dev/null +++ b/themes/space-pixel/source/billboard_ads.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://de8f4ll2u4fob" +path="res://.godot/imported/billboard_ads.png-cfcd440b1e96a6a7f1048cadfb42309e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/billboard_ads.png" +dest_files=["res://.godot/imported/billboard_ads.png-cfcd440b1e96a6a7f1048cadfb42309e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/bubble1.png b/themes/space-pixel/source/bubble1.png new file mode 100644 index 0000000..d964d46 Binary files /dev/null and b/themes/space-pixel/source/bubble1.png differ diff --git a/themes/space-pixel/source/bubble1.png.import b/themes/space-pixel/source/bubble1.png.import new file mode 100644 index 0000000..5327d04 --- /dev/null +++ b/themes/space-pixel/source/bubble1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://by5n2p44bg626" +path="res://.godot/imported/bubble1.png-90d246763ddee167a8dba488f338cbd7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/bubble1.png" +dest_files=["res://.godot/imported/bubble1.png-90d246763ddee167a8dba488f338cbd7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/bubble2.png b/themes/space-pixel/source/bubble2.png new file mode 100644 index 0000000..403939f Binary files /dev/null and b/themes/space-pixel/source/bubble2.png differ diff --git a/themes/space-pixel/source/bubble2.png.import b/themes/space-pixel/source/bubble2.png.import new file mode 100644 index 0000000..f4fdcae --- /dev/null +++ b/themes/space-pixel/source/bubble2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d31p4cftop1pi" +path="res://.godot/imported/bubble2.png-926af1f752ee0132b23db31bd0a272ee.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/bubble2.png" +dest_files=["res://.godot/imported/bubble2.png-926af1f752ee0132b23db31bd0a272ee.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/button_big.png b/themes/space-pixel/source/button_big.png new file mode 100644 index 0000000..ad18865 Binary files /dev/null and b/themes/space-pixel/source/button_big.png differ diff --git a/themes/space-pixel/source/button_big.png.import b/themes/space-pixel/source/button_big.png.import new file mode 100644 index 0000000..6f589ce --- /dev/null +++ b/themes/space-pixel/source/button_big.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dyn4nwn46au56" +path="res://.godot/imported/button_big.png-15c38f7b46e234f9b3e749933b567d04.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/button_big.png" +dest_files=["res://.godot/imported/button_big.png-15c38f7b46e234f9b3e749933b567d04.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/button_down.png b/themes/space-pixel/source/button_down.png new file mode 100644 index 0000000..13abcee Binary files /dev/null and b/themes/space-pixel/source/button_down.png differ diff --git a/themes/space-pixel/source/button_down.png.import b/themes/space-pixel/source/button_down.png.import new file mode 100644 index 0000000..73941dc --- /dev/null +++ b/themes/space-pixel/source/button_down.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://diere30hilyo5" +path="res://.godot/imported/button_down.png-83743db962537a2e399ff8cbf592858a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/button_down.png" +dest_files=["res://.godot/imported/button_down.png-83743db962537a2e399ff8cbf592858a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/button_long.png b/themes/space-pixel/source/button_long.png new file mode 100644 index 0000000..af9beb9 Binary files /dev/null and b/themes/space-pixel/source/button_long.png differ diff --git a/themes/space-pixel/source/button_long.png.import b/themes/space-pixel/source/button_long.png.import new file mode 100644 index 0000000..2709bfd --- /dev/null +++ b/themes/space-pixel/source/button_long.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cl50uip07f0gh" +path="res://.godot/imported/button_long.png-cf9cad104b33ea6f2301ee622ae7691c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/button_long.png" +dest_files=["res://.godot/imported/button_long.png-cf9cad104b33ea6f2301ee622ae7691c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/button_square.png b/themes/space-pixel/source/button_square.png new file mode 100644 index 0000000..f58b254 Binary files /dev/null and b/themes/space-pixel/source/button_square.png differ diff --git a/themes/space-pixel/source/button_square.png.import b/themes/space-pixel/source/button_square.png.import new file mode 100644 index 0000000..66c5233 --- /dev/null +++ b/themes/space-pixel/source/button_square.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://u8at8npwq6ey" +path="res://.godot/imported/button_square.png-f825a85c6878ab4178c0381e83c82249.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/button_square.png" +dest_files=["res://.godot/imported/button_square.png-f825a85c6878ab4178c0381e83c82249.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/button_up.png b/themes/space-pixel/source/button_up.png new file mode 100644 index 0000000..834e3ca Binary files /dev/null and b/themes/space-pixel/source/button_up.png differ diff --git a/themes/space-pixel/source/button_up.png.import b/themes/space-pixel/source/button_up.png.import new file mode 100644 index 0000000..c69dfb7 --- /dev/null +++ b/themes/space-pixel/source/button_up.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cdxc7yc8fcnh4" +path="res://.godot/imported/button_up.png-b36f4d3e87b96a4924a7e3c9e78d8649.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/button_up.png" +dest_files=["res://.godot/imported/button_up.png-b36f4d3e87b96a4924a7e3c9e78d8649.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/climbing_action.png b/themes/space-pixel/source/climbing_action.png new file mode 100644 index 0000000..41a3425 Binary files /dev/null and b/themes/space-pixel/source/climbing_action.png differ diff --git a/themes/space-pixel/source/climbing_action.png.import b/themes/space-pixel/source/climbing_action.png.import new file mode 100644 index 0000000..7ff9927 --- /dev/null +++ b/themes/space-pixel/source/climbing_action.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d0gqkp7h3c8kq" +path="res://.godot/imported/climbing_action.png-50e7a97ef2a70a5d5bfc67123bb5554f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/climbing_action.png" +dest_files=["res://.godot/imported/climbing_action.png-50e7a97ef2a70a5d5bfc67123bb5554f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/climbing_action_start.png b/themes/space-pixel/source/climbing_action_start.png new file mode 100644 index 0000000..de73a1e Binary files /dev/null and b/themes/space-pixel/source/climbing_action_start.png differ diff --git a/themes/space-pixel/source/climbing_action_start.png.import b/themes/space-pixel/source/climbing_action_start.png.import new file mode 100644 index 0000000..cb0f480 --- /dev/null +++ b/themes/space-pixel/source/climbing_action_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1jxeahllexjb" +path="res://.godot/imported/climbing_action_start.png-33a0b18314dff7990dfb56bb22a7370b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/climbing_action_start.png" +dest_files=["res://.godot/imported/climbing_action_start.png-33a0b18314dff7990dfb56bb22a7370b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/climbing_action_stop.png b/themes/space-pixel/source/climbing_action_stop.png new file mode 100644 index 0000000..da6d719 Binary files /dev/null and b/themes/space-pixel/source/climbing_action_stop.png differ diff --git a/themes/space-pixel/source/climbing_action_stop.png.import b/themes/space-pixel/source/climbing_action_stop.png.import new file mode 100644 index 0000000..e82e48a --- /dev/null +++ b/themes/space-pixel/source/climbing_action_stop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bsnnpp4g1t1vo" +path="res://.godot/imported/climbing_action_stop.png-aeb95d5e87ca122119c6e3273a5a8f61.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/climbing_action_stop.png" +dest_files=["res://.godot/imported/climbing_action_stop.png-aeb95d5e87ca122119c6e3273a5a8f61.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/cross.png b/themes/space-pixel/source/cross.png new file mode 100644 index 0000000..2019082 Binary files /dev/null and b/themes/space-pixel/source/cross.png differ diff --git a/themes/space-pixel/source/cross.png.import b/themes/space-pixel/source/cross.png.import new file mode 100644 index 0000000..5c375ae --- /dev/null +++ b/themes/space-pixel/source/cross.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b48crlpmb6ftg" +path="res://.godot/imported/cross.png-50efb0427744a06cd27a96cf2465d60a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/cross.png" +dest_files=["res://.godot/imported/cross.png-50efb0427744a06cd27a96cf2465d60a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/home.png b/themes/space-pixel/source/home.png new file mode 100644 index 0000000..93d46ee Binary files /dev/null and b/themes/space-pixel/source/home.png differ diff --git a/themes/space-pixel/source/home.png.import b/themes/space-pixel/source/home.png.import new file mode 100644 index 0000000..7cbcf21 --- /dev/null +++ b/themes/space-pixel/source/home.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cg4pv3nv2w61u" +path="res://.godot/imported/home.png-71e3bc795b00a9b2103bbdce3f1298f9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/home.png" +dest_files=["res://.godot/imported/home.png-71e3bc795b00a9b2103bbdce3f1298f9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/less_bar.png b/themes/space-pixel/source/less_bar.png new file mode 100644 index 0000000..944ef72 Binary files /dev/null and b/themes/space-pixel/source/less_bar.png differ diff --git a/themes/space-pixel/source/less_bar.png.import b/themes/space-pixel/source/less_bar.png.import new file mode 100644 index 0000000..f26d9cf --- /dev/null +++ b/themes/space-pixel/source/less_bar.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dapmhcayre32d" +path="res://.godot/imported/less_bar.png-fba91e3a0829dcda8ec5ea427aacd57b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/less_bar.png" +dest_files=["res://.godot/imported/less_bar.png-fba91e3a0829dcda8ec5ea427aacd57b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/logo.png b/themes/space-pixel/source/logo.png new file mode 100644 index 0000000..8754482 Binary files /dev/null and b/themes/space-pixel/source/logo.png differ diff --git a/themes/space-pixel/source/logo.png.import b/themes/space-pixel/source/logo.png.import new file mode 100644 index 0000000..e773fe4 --- /dev/null +++ b/themes/space-pixel/source/logo.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bbtykaq6b685e" +path="res://.godot/imported/logo.png-6bad1e68a6fc9c05658db89a267ae103.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/logo.png" +dest_files=["res://.godot/imported/logo.png-6bad1e68a6fc9c05658db89a267ae103.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/many_bar.png b/themes/space-pixel/source/many_bar.png new file mode 100644 index 0000000..83449b5 Binary files /dev/null and b/themes/space-pixel/source/many_bar.png differ diff --git a/themes/space-pixel/source/many_bar.png.import b/themes/space-pixel/source/many_bar.png.import new file mode 100644 index 0000000..b7c525d --- /dev/null +++ b/themes/space-pixel/source/many_bar.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dx7ck2ayq51p" +path="res://.godot/imported/many_bar.png-90261a18eeb8071d37c6387cbd89b7ce.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/many_bar.png" +dest_files=["res://.godot/imported/many_bar.png-90261a18eeb8071d37c6387cbd89b7ce.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/star_frame1.png b/themes/space-pixel/source/star_frame1.png new file mode 100644 index 0000000..9a40982 Binary files /dev/null and b/themes/space-pixel/source/star_frame1.png differ diff --git a/themes/space-pixel/source/star_frame1.png.import b/themes/space-pixel/source/star_frame1.png.import new file mode 100644 index 0000000..26714ca --- /dev/null +++ b/themes/space-pixel/source/star_frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://db3dtx0uev8wc" +path="res://.godot/imported/star_frame1.png-2306e77d71efca28c46e06fcc3adc5d0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/star_frame1.png" +dest_files=["res://.godot/imported/star_frame1.png-2306e77d71efca28c46e06fcc3adc5d0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/star_frame2.png b/themes/space-pixel/source/star_frame2.png new file mode 100644 index 0000000..ab93590 Binary files /dev/null and b/themes/space-pixel/source/star_frame2.png differ diff --git a/themes/space-pixel/source/star_frame2.png.import b/themes/space-pixel/source/star_frame2.png.import new file mode 100644 index 0000000..e351db1 --- /dev/null +++ b/themes/space-pixel/source/star_frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bxdssk5jept0c" +path="res://.godot/imported/star_frame2.png-f06ef6624863d8c099728fb7f9623aeb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/star_frame2.png" +dest_files=["res://.godot/imported/star_frame2.png-f06ef6624863d8c099728fb7f9623aeb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/summa_bar.png b/themes/space-pixel/source/summa_bar.png new file mode 100644 index 0000000..47f6499 Binary files /dev/null and b/themes/space-pixel/source/summa_bar.png differ diff --git a/themes/space-pixel/source/summa_bar.png.import b/themes/space-pixel/source/summa_bar.png.import new file mode 100644 index 0000000..f7a4e76 --- /dev/null +++ b/themes/space-pixel/source/summa_bar.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b63xwo4cxtylq" +path="res://.godot/imported/summa_bar.png-a6b117db03d9b4d85b599a474292865b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/summa_bar.png" +dest_files=["res://.godot/imported/summa_bar.png-a6b117db03d9b4d85b599a474292865b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/tumbler_off.png b/themes/space-pixel/source/tumbler_off.png new file mode 100644 index 0000000..4a6bfec Binary files /dev/null and b/themes/space-pixel/source/tumbler_off.png differ diff --git a/themes/space-pixel/source/tumbler_off.png.import b/themes/space-pixel/source/tumbler_off.png.import new file mode 100644 index 0000000..9936a79 --- /dev/null +++ b/themes/space-pixel/source/tumbler_off.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cq0bphy2h3ocx" +path="res://.godot/imported/tumbler_off.png-98e98513064138f20ec469c6ea575330.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/tumbler_off.png" +dest_files=["res://.godot/imported/tumbler_off.png-98e98513064138f20ec469c6ea575330.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/tumbler_on.png b/themes/space-pixel/source/tumbler_on.png new file mode 100644 index 0000000..c915073 Binary files /dev/null and b/themes/space-pixel/source/tumbler_on.png differ diff --git a/themes/space-pixel/source/tumbler_on.png.import b/themes/space-pixel/source/tumbler_on.png.import new file mode 100644 index 0000000..28b4a90 --- /dev/null +++ b/themes/space-pixel/source/tumbler_on.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dlc8akoajx6mp" +path="res://.godot/imported/tumbler_on.png-019725ba5b8f5757f72ac74072d4474b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/tumbler_on.png" +dest_files=["res://.godot/imported/tumbler_on.png-019725ba5b8f5757f72ac74072d4474b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_баннер/frame1.png b/themes/space-pixel/source/Анимация_баннер/frame1.png new file mode 100644 index 0000000..c30b61d Binary files /dev/null and b/themes/space-pixel/source/Анимация_баннер/frame1.png differ diff --git a/themes/space-pixel/source/Анимация_баннер/frame1.png.import b/themes/space-pixel/source/Анимация_баннер/frame1.png.import new file mode 100644 index 0000000..55be4c5 --- /dev/null +++ b/themes/space-pixel/source/Анимация_баннер/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b36jhy7q4evdp" +path="res://.godot/imported/frame1.png-cd0ba1e22331fe7e733cddc38e0566ab.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_баннер/frame1.png" +dest_files=["res://.godot/imported/frame1.png-cd0ba1e22331fe7e733cddc38e0566ab.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_баннер/frame2.png b/themes/space-pixel/source/Анимация_баннер/frame2.png new file mode 100644 index 0000000..8480f12 Binary files /dev/null and b/themes/space-pixel/source/Анимация_баннер/frame2.png differ diff --git a/themes/space-pixel/source/Анимация_баннер/frame2.png.import b/themes/space-pixel/source/Анимация_баннер/frame2.png.import new file mode 100644 index 0000000..6ce8a33 --- /dev/null +++ b/themes/space-pixel/source/Анимация_баннер/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d21ate8sh81xw" +path="res://.godot/imported/frame2.png-7f5e3dae6685045b353463c9033ab13d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_баннер/frame2.png" +dest_files=["res://.godot/imported/frame2.png-7f5e3dae6685045b353463c9033ab13d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_купюры/frame1.png b/themes/space-pixel/source/Анимация_купюры/frame1.png new file mode 100644 index 0000000..b44f366 Binary files /dev/null and b/themes/space-pixel/source/Анимация_купюры/frame1.png differ diff --git a/themes/space-pixel/source/Анимация_купюры/frame1.png.import b/themes/space-pixel/source/Анимация_купюры/frame1.png.import new file mode 100644 index 0000000..e8ddf90 --- /dev/null +++ b/themes/space-pixel/source/Анимация_купюры/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b788j2vanqnch" +path="res://.godot/imported/frame1.png-960910820fa5eb8002ba0ea4a86dd2ed.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_купюры/frame1.png" +dest_files=["res://.godot/imported/frame1.png-960910820fa5eb8002ba0ea4a86dd2ed.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_купюры/frame2.png b/themes/space-pixel/source/Анимация_купюры/frame2.png new file mode 100644 index 0000000..3a496b8 Binary files /dev/null and b/themes/space-pixel/source/Анимация_купюры/frame2.png differ diff --git a/themes/space-pixel/source/Анимация_купюры/frame2.png.import b/themes/space-pixel/source/Анимация_купюры/frame2.png.import new file mode 100644 index 0000000..69ed8d2 --- /dev/null +++ b/themes/space-pixel/source/Анимация_купюры/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://3jqm0guwxnrb" +path="res://.godot/imported/frame2.png-0d282f2e9b913b11e788f8e1f2fb3056.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_купюры/frame2.png" +dest_files=["res://.godot/imported/frame2.png-0d282f2e9b913b11e788f8e1f2fb3056.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_купюры/frame3.png b/themes/space-pixel/source/Анимация_купюры/frame3.png new file mode 100644 index 0000000..e7ff5bf Binary files /dev/null and b/themes/space-pixel/source/Анимация_купюры/frame3.png differ diff --git a/themes/space-pixel/source/Анимация_купюры/frame3.png.import b/themes/space-pixel/source/Анимация_купюры/frame3.png.import new file mode 100644 index 0000000..acd9a19 --- /dev/null +++ b/themes/space-pixel/source/Анимация_купюры/frame3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://o7aifsuay2vy" +path="res://.godot/imported/frame3.png-c601f00a2df62ad8cd3507e5078263a1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_купюры/frame3.png" +dest_files=["res://.godot/imported/frame3.png-c601f00a2df62ad8cd3507e5078263a1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_купюры/frame4.png b/themes/space-pixel/source/Анимация_купюры/frame4.png new file mode 100644 index 0000000..80596bd Binary files /dev/null and b/themes/space-pixel/source/Анимация_купюры/frame4.png differ diff --git a/themes/space-pixel/source/Анимация_купюры/frame4.png.import b/themes/space-pixel/source/Анимация_купюры/frame4.png.import new file mode 100644 index 0000000..a3e2283 --- /dev/null +++ b/themes/space-pixel/source/Анимация_купюры/frame4.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dttdobjlr1qc2" +path="res://.godot/imported/frame4.png-364c8fe41c61fd6415c2e229554ebfde.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_купюры/frame4.png" +dest_files=["res://.godot/imported/frame4.png-364c8fe41c61fd6415c2e229554ebfde.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_монеты/frame1.png b/themes/space-pixel/source/Анимация_монеты/frame1.png new file mode 100644 index 0000000..0ae6101 Binary files /dev/null and b/themes/space-pixel/source/Анимация_монеты/frame1.png differ diff --git a/themes/space-pixel/source/Анимация_монеты/frame1.png.import b/themes/space-pixel/source/Анимация_монеты/frame1.png.import new file mode 100644 index 0000000..42ba4d6 --- /dev/null +++ b/themes/space-pixel/source/Анимация_монеты/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://wvbp1nf3ojdp" +path="res://.godot/imported/frame1.png-5dee8948344fd4cb527c87e272dc7f45.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_монеты/frame1.png" +dest_files=["res://.godot/imported/frame1.png-5dee8948344fd4cb527c87e272dc7f45.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_монеты/frame2.png b/themes/space-pixel/source/Анимация_монеты/frame2.png new file mode 100644 index 0000000..7f98ade Binary files /dev/null and b/themes/space-pixel/source/Анимация_монеты/frame2.png differ diff --git a/themes/space-pixel/source/Анимация_монеты/frame2.png.import b/themes/space-pixel/source/Анимация_монеты/frame2.png.import new file mode 100644 index 0000000..bb08414 --- /dev/null +++ b/themes/space-pixel/source/Анимация_монеты/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c86tbkgsk45sm" +path="res://.godot/imported/frame2.png-23c17d281b5fc290f7b4d4df65cca4a7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_монеты/frame2.png" +dest_files=["res://.godot/imported/frame2.png-23c17d281b5fc290f7b4d4df65cca4a7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_монеты/frame3.png b/themes/space-pixel/source/Анимация_монеты/frame3.png new file mode 100644 index 0000000..01055c9 Binary files /dev/null and b/themes/space-pixel/source/Анимация_монеты/frame3.png differ diff --git a/themes/space-pixel/source/Анимация_монеты/frame3.png.import b/themes/space-pixel/source/Анимация_монеты/frame3.png.import new file mode 100644 index 0000000..d2d3fd3 --- /dev/null +++ b/themes/space-pixel/source/Анимация_монеты/frame3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqmdycpqehblu" +path="res://.godot/imported/frame3.png-f14c2108ef59fc01ce9308b29240de41.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_монеты/frame3.png" +dest_files=["res://.godot/imported/frame3.png-f14c2108ef59fc01ce9308b29240de41.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_оплата_картой/Frame1.png b/themes/space-pixel/source/Анимация_оплата_картой/Frame1.png new file mode 100644 index 0000000..e90e0a9 Binary files /dev/null and b/themes/space-pixel/source/Анимация_оплата_картой/Frame1.png differ diff --git a/themes/space-pixel/source/Анимация_оплата_картой/Frame1.png.import b/themes/space-pixel/source/Анимация_оплата_картой/Frame1.png.import new file mode 100644 index 0000000..1bfdf6f --- /dev/null +++ b/themes/space-pixel/source/Анимация_оплата_картой/Frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ta1j4qo6f3lx" +path="res://.godot/imported/Frame1.png-47e3352142cd7492897a014fd98bcca0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_оплата_картой/Frame1.png" +dest_files=["res://.godot/imported/Frame1.png-47e3352142cd7492897a014fd98bcca0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_оплата_картой/Frame2.png b/themes/space-pixel/source/Анимация_оплата_картой/Frame2.png new file mode 100644 index 0000000..d3e1684 Binary files /dev/null and b/themes/space-pixel/source/Анимация_оплата_картой/Frame2.png differ diff --git a/themes/space-pixel/source/Анимация_оплата_картой/Frame2.png.import b/themes/space-pixel/source/Анимация_оплата_картой/Frame2.png.import new file mode 100644 index 0000000..a67d9f3 --- /dev/null +++ b/themes/space-pixel/source/Анимация_оплата_картой/Frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://chtl0f5byjhv5" +path="res://.godot/imported/Frame2.png-b953c05683675fbb9fd582c51c0dd2a0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_оплата_картой/Frame2.png" +dest_files=["res://.godot/imported/Frame2.png-b953c05683675fbb9fd582c51c0dd2a0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_оплата_непрошла/frame1.png b/themes/space-pixel/source/Анимация_оплата_непрошла/frame1.png new file mode 100644 index 0000000..22396e8 Binary files /dev/null and b/themes/space-pixel/source/Анимация_оплата_непрошла/frame1.png differ diff --git a/themes/space-pixel/source/Анимация_оплата_непрошла/frame1.png.import b/themes/space-pixel/source/Анимация_оплата_непрошла/frame1.png.import new file mode 100644 index 0000000..f1ea24d --- /dev/null +++ b/themes/space-pixel/source/Анимация_оплата_непрошла/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://csk6lvfcycvqp" +path="res://.godot/imported/frame1.png-97ab7a2b966d927fb0eb9cca0fac7a9c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_оплата_непрошла/frame1.png" +dest_files=["res://.godot/imported/frame1.png-97ab7a2b966d927fb0eb9cca0fac7a9c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_оплата_непрошла/frame2.png b/themes/space-pixel/source/Анимация_оплата_непрошла/frame2.png new file mode 100644 index 0000000..74f8b4a Binary files /dev/null and b/themes/space-pixel/source/Анимация_оплата_непрошла/frame2.png differ diff --git a/themes/space-pixel/source/Анимация_оплата_непрошла/frame2.png.import b/themes/space-pixel/source/Анимация_оплата_непрошла/frame2.png.import new file mode 100644 index 0000000..c6e3e1b --- /dev/null +++ b/themes/space-pixel/source/Анимация_оплата_непрошла/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://grc0j553bn70" +path="res://.godot/imported/frame2.png-1bb933a1eb61d4ca842f47c39534cf95.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_оплата_непрошла/frame2.png" +dest_files=["res://.godot/imported/frame2.png-1bb933a1eb61d4ca842f47c39534cf95.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_оплата_непрошла/frame3.png b/themes/space-pixel/source/Анимация_оплата_непрошла/frame3.png new file mode 100644 index 0000000..8e46e2a Binary files /dev/null and b/themes/space-pixel/source/Анимация_оплата_непрошла/frame3.png differ diff --git a/themes/space-pixel/source/Анимация_оплата_непрошла/frame3.png.import b/themes/space-pixel/source/Анимация_оплата_непрошла/frame3.png.import new file mode 100644 index 0000000..79f89bd --- /dev/null +++ b/themes/space-pixel/source/Анимация_оплата_непрошла/frame3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ckr8wtiy7souh" +path="res://.godot/imported/frame3.png-ad15083429c7b341cd747d64be661916.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_оплата_непрошла/frame3.png" +dest_files=["res://.godot/imported/frame3.png-ad15083429c7b341cd747d64be661916.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_оплата_прошла/frame1.png b/themes/space-pixel/source/Анимация_оплата_прошла/frame1.png new file mode 100644 index 0000000..4af13f0 Binary files /dev/null and b/themes/space-pixel/source/Анимация_оплата_прошла/frame1.png differ diff --git a/themes/space-pixel/source/Анимация_оплата_прошла/frame1.png.import b/themes/space-pixel/source/Анимация_оплата_прошла/frame1.png.import new file mode 100644 index 0000000..f10cd42 --- /dev/null +++ b/themes/space-pixel/source/Анимация_оплата_прошла/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ddgqpufhdhhlu" +path="res://.godot/imported/frame1.png-c3eeee7fcb16a884b501e4d27ff320d9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_оплата_прошла/frame1.png" +dest_files=["res://.godot/imported/frame1.png-c3eeee7fcb16a884b501e4d27ff320d9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_оплата_прошла/frame2.png b/themes/space-pixel/source/Анимация_оплата_прошла/frame2.png new file mode 100644 index 0000000..bccef40 Binary files /dev/null and b/themes/space-pixel/source/Анимация_оплата_прошла/frame2.png differ diff --git a/themes/space-pixel/source/Анимация_оплата_прошла/frame2.png.import b/themes/space-pixel/source/Анимация_оплата_прошла/frame2.png.import new file mode 100644 index 0000000..f510c09 --- /dev/null +++ b/themes/space-pixel/source/Анимация_оплата_прошла/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cjw7mijxpbnf2" +path="res://.godot/imported/frame2.png-323f41de777fe28f230ce9770200207c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_оплата_прошла/frame2.png" +dest_files=["res://.godot/imported/frame2.png-323f41de777fe28f230ce9770200207c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_оплата_прошла/frame3.png b/themes/space-pixel/source/Анимация_оплата_прошла/frame3.png new file mode 100644 index 0000000..a10d308 Binary files /dev/null and b/themes/space-pixel/source/Анимация_оплата_прошла/frame3.png differ diff --git a/themes/space-pixel/source/Анимация_оплата_прошла/frame3.png.import b/themes/space-pixel/source/Анимация_оплата_прошла/frame3.png.import new file mode 100644 index 0000000..c4c1b29 --- /dev/null +++ b/themes/space-pixel/source/Анимация_оплата_прошла/frame3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ccc3fa3ssc673" +path="res://.godot/imported/frame3.png-57f5d1bb20a8f133357576828ccc9b68.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_оплата_прошла/frame3.png" +dest_files=["res://.godot/imported/frame3.png-57f5d1bb20a8f133357576828ccc9b68.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_оплата_прошла/frame4.png b/themes/space-pixel/source/Анимация_оплата_прошла/frame4.png new file mode 100644 index 0000000..67c9322 Binary files /dev/null and b/themes/space-pixel/source/Анимация_оплата_прошла/frame4.png differ diff --git a/themes/space-pixel/source/Анимация_оплата_прошла/frame4.png.import b/themes/space-pixel/source/Анимация_оплата_прошла/frame4.png.import new file mode 100644 index 0000000..2f1433f --- /dev/null +++ b/themes/space-pixel/source/Анимация_оплата_прошла/frame4.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dy6b7hk816nre" +path="res://.godot/imported/frame4.png-32404444404568a0b6aa2d0faa119f99.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_оплата_прошла/frame4.png" +dest_files=["res://.godot/imported/frame4.png-32404444404568a0b6aa2d0faa119f99.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Анимация_оплата_прошла/frame5.png b/themes/space-pixel/source/Анимация_оплата_прошла/frame5.png new file mode 100644 index 0000000..a10d308 Binary files /dev/null and b/themes/space-pixel/source/Анимация_оплата_прошла/frame5.png differ diff --git a/themes/space-pixel/source/Анимация_оплата_прошла/frame5.png.import b/themes/space-pixel/source/Анимация_оплата_прошла/frame5.png.import new file mode 100644 index 0000000..3ed4e54 --- /dev/null +++ b/themes/space-pixel/source/Анимация_оплата_прошла/frame5.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bg6lwbivk4aw6" +path="res://.godot/imported/frame5.png-4978b216d0c64513fd898c34dde889ed.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Анимация_оплата_прошла/frame5.png" +dest_files=["res://.godot/imported/frame5.png-4978b216d0c64513fd898c34dde889ed.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Пролетающие_нло/frame1.png b/themes/space-pixel/source/Пролетающие_нло/frame1.png new file mode 100644 index 0000000..cf240d2 Binary files /dev/null and b/themes/space-pixel/source/Пролетающие_нло/frame1.png differ diff --git a/themes/space-pixel/source/Пролетающие_нло/frame1.png.import b/themes/space-pixel/source/Пролетающие_нло/frame1.png.import new file mode 100644 index 0000000..27399c8 --- /dev/null +++ b/themes/space-pixel/source/Пролетающие_нло/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://nw5fju11ofud" +path="res://.godot/imported/frame1.png-0e8c133415b77fab6fdab4a7352847a6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Пролетающие_нло/frame1.png" +dest_files=["res://.godot/imported/frame1.png-0e8c133415b77fab6fdab4a7352847a6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Пролетающие_нло/frame2.png b/themes/space-pixel/source/Пролетающие_нло/frame2.png new file mode 100644 index 0000000..5839c12 Binary files /dev/null and b/themes/space-pixel/source/Пролетающие_нло/frame2.png differ diff --git a/themes/space-pixel/source/Пролетающие_нло/frame2.png.import b/themes/space-pixel/source/Пролетающие_нло/frame2.png.import new file mode 100644 index 0000000..19358dc --- /dev/null +++ b/themes/space-pixel/source/Пролетающие_нло/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dq2mc2nbafe0c" +path="res://.godot/imported/frame2.png-bce838d6b2b94ddcc3014ea5572cddeb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Пролетающие_нло/frame2.png" +dest_files=["res://.godot/imported/frame2.png-bce838d6b2b94ddcc3014ea5572cddeb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Пролетающие_нло_большие/frame1.png b/themes/space-pixel/source/Пролетающие_нло_большие/frame1.png new file mode 100644 index 0000000..67ecfce Binary files /dev/null and b/themes/space-pixel/source/Пролетающие_нло_большие/frame1.png differ diff --git a/themes/space-pixel/source/Пролетающие_нло_большие/frame1.png.import b/themes/space-pixel/source/Пролетающие_нло_большие/frame1.png.import new file mode 100644 index 0000000..75dc5f3 --- /dev/null +++ b/themes/space-pixel/source/Пролетающие_нло_большие/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://qqvp1sj62ee" +path="res://.godot/imported/frame1.png-66efdb5f8d4fcfa51a543bcf9a31a261.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Пролетающие_нло_большие/frame1.png" +dest_files=["res://.godot/imported/frame1.png-66efdb5f8d4fcfa51a543bcf9a31a261.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Пролетающие_нло_большие/frame2.png b/themes/space-pixel/source/Пролетающие_нло_большие/frame2.png new file mode 100644 index 0000000..a705cb0 Binary files /dev/null and b/themes/space-pixel/source/Пролетающие_нло_большие/frame2.png differ diff --git a/themes/space-pixel/source/Пролетающие_нло_большие/frame2.png.import b/themes/space-pixel/source/Пролетающие_нло_большие/frame2.png.import new file mode 100644 index 0000000..d8ab126 --- /dev/null +++ b/themes/space-pixel/source/Пролетающие_нло_большие/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ccyqdd24xl3rr" +path="res://.godot/imported/frame2.png-385ee4f8150e79a206c581a50ed67124.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes/space-pixel/source/Пролетающие_нло_большие/frame2.png" +dest_files=["res://.godot/imported/frame2.png-385ee4f8150e79a206c581a50ed67124.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes/space-pixel/source/Цвета.txt b/themes/space-pixel/source/Цвета.txt new file mode 100644 index 0000000..e69de29 diff --git a/themes/space-pixel/source/Шрифты/VCR OSD Mono Cyr.ttf b/themes/space-pixel/source/Шрифты/VCR OSD Mono Cyr.ttf new file mode 100644 index 0000000..b621e80 Binary files /dev/null and b/themes/space-pixel/source/Шрифты/VCR OSD Mono Cyr.ttf differ diff --git a/themes/space-pixel/source/Шрифты/VCR OSD Mono Cyr.ttf.import b/themes/space-pixel/source/Шрифты/VCR OSD Mono Cyr.ttf.import new file mode 100644 index 0000000..9e653f7 --- /dev/null +++ b/themes/space-pixel/source/Шрифты/VCR OSD Mono Cyr.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://eh4jot2utx21" +path="res://.godot/imported/VCR OSD Mono Cyr.ttf-5259a1fdead6a5e6056a0f059e4a5ace.fontdata" + +[deps] + +source_file="res://themes/space-pixel/source/Шрифты/VCR OSD Mono Cyr.ttf" +dest_files=["res://.godot/imported/VCR OSD Mono Cyr.ttf-5259a1fdead6a5e6056a0f059e4a5ace.fontdata"] + +[params] + +Rendering=null +antialiasing=0 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=0 +subpixel_positioning=0 +keep_rounding_remainders=true +oversampling=1.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/themes_src/space_pixel/Bubble_addtext.png b/themes_src/space_pixel/Bubble_addtext.png new file mode 100644 index 0000000..80c9736 Binary files /dev/null and b/themes_src/space_pixel/Bubble_addtext.png differ diff --git a/themes_src/space_pixel/Bubble_addtext.png.import b/themes_src/space_pixel/Bubble_addtext.png.import new file mode 100644 index 0000000..c481e8a --- /dev/null +++ b/themes_src/space_pixel/Bubble_addtext.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://milcjrclu3em" +path="res://.godot/imported/Bubble_addtext.png-5a114d7e5399906c30454f9c34f2b2b9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Bubble_addtext.png" +dest_files=["res://.godot/imported/Bubble_addtext.png-5a114d7e5399906c30454f9c34f2b2b9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/README.md b/themes_src/space_pixel/README.md new file mode 100644 index 0000000..404ec55 --- /dev/null +++ b/themes_src/space_pixel/README.md @@ -0,0 +1,29 @@ +# Архив исходников темы `space_pixel` + +> Этот каталог относится к ранней partial-theme модели и не является источником разработки. +> Актуальные материалы находятся в `themes/space_pixel/source/`, production-пакет — в +> `themes/space_pixel/package/`, формат — `ExchangerThemeDlc` v2. + +Этот каталог содержит оригинальные материалы темы и не включается в DLC напрямую. +Исходные файлы не удаляются и не перезаписываются при подготовке пакета. + +Фактическая структура поставки сохранена: UI-файлы лежат в корне, последовательности кадров — +в русскоязычных подпапках, шрифт — в `Шрифты/`. Производные копии получили ASCII-имена и лежат +отдельно в `res://exchanger_theme_dlc/assets/`. + +В `references/` локально скопированы восемь макетов интерфейса, рендер корпуса разменника и +видео полного пользовательского потока. Этот каталог нужен только для визуального сравнения, +игнорируется Git и не экспортируется в DLC. + +Обязательные правила Exchanger: + +- основной фон остаётся статическим; +- анимация реализуется отдельными спрайтами в пассивной decoration-сцене; +- шейдеры, частицы и видео для фона не используются; +- тема должна корректно работать при отключённой decoration-сцене (`ReducedEffects=true`); +- исторические runtime-ресурсы отсюда не собираются; актуальная тема использует + `themes/space_pixel/package/` и `ExchangerThemeDlc` версии 2. + +Поставка содержит 46 PNG и один шрифт `VCR OSD Mono Cyr.ttf`. Файл `Цвета.txt` имеет нулевой +размер, поэтому палитра темы определена по самим изображениям. Файлы `.import` и `Thumbs.db` +считаются метаданными исходной поставки и не копируются в runtime-каталог. diff --git a/themes_src/space_pixel/Window_k_vidache.png b/themes_src/space_pixel/Window_k_vidache.png new file mode 100644 index 0000000..581e811 Binary files /dev/null and b/themes_src/space_pixel/Window_k_vidache.png differ diff --git a/themes_src/space_pixel/Window_k_vidache.png.import b/themes_src/space_pixel/Window_k_vidache.png.import new file mode 100644 index 0000000..5de376c --- /dev/null +++ b/themes_src/space_pixel/Window_k_vidache.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtukabci80bup" +path="res://.godot/imported/Window_k_vidache.png-3d733cb64226e51be35c9ad526226b76.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Window_k_vidache.png" +dest_files=["res://.godot/imported/Window_k_vidache.png-3d733cb64226e51be35c9ad526226b76.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Window_paycard.png b/themes_src/space_pixel/Window_paycard.png new file mode 100644 index 0000000..7d585fb Binary files /dev/null and b/themes_src/space_pixel/Window_paycard.png differ diff --git a/themes_src/space_pixel/Window_paycard.png.import b/themes_src/space_pixel/Window_paycard.png.import new file mode 100644 index 0000000..77a4ec8 --- /dev/null +++ b/themes_src/space_pixel/Window_paycard.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c73onrqq5s3cr" +path="res://.godot/imported/Window_paycard.png-1c61288a644fb769406a139c75a8b316.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Window_paycard.png" +dest_files=["res://.godot/imported/Window_paycard.png-1c61288a644fb769406a139c75a8b316.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Window_paynal_balance.png b/themes_src/space_pixel/Window_paynal_balance.png new file mode 100644 index 0000000..a815c0a Binary files /dev/null and b/themes_src/space_pixel/Window_paynal_balance.png differ diff --git a/themes_src/space_pixel/Window_paynal_balance.png.import b/themes_src/space_pixel/Window_paynal_balance.png.import new file mode 100644 index 0000000..7e35172 --- /dev/null +++ b/themes_src/space_pixel/Window_paynal_balance.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b5avqy426og2y" +path="res://.godot/imported/Window_paynal_balance.png-16d0a7c640226ecc1858cd678a99d296.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Window_paynal_balance.png" +dest_files=["res://.godot/imported/Window_paynal_balance.png-16d0a7c640226ecc1858cd678a99d296.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Window_paynal_balance_money.png b/themes_src/space_pixel/Window_paynal_balance_money.png new file mode 100644 index 0000000..3630f6c Binary files /dev/null and b/themes_src/space_pixel/Window_paynal_balance_money.png differ diff --git a/themes_src/space_pixel/Window_paynal_balance_money.png.import b/themes_src/space_pixel/Window_paynal_balance_money.png.import new file mode 100644 index 0000000..b71e7ff --- /dev/null +++ b/themes_src/space_pixel/Window_paynal_balance_money.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://csvo4b5vj2fkd" +path="res://.godot/imported/Window_paynal_balance_money.png-215d0893f2642adb3185e505652041b1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Window_paynal_balance_money.png" +dest_files=["res://.godot/imported/Window_paynal_balance_money.png-215d0893f2642adb3185e505652041b1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Window_paynal_instruction.png b/themes_src/space_pixel/Window_paynal_instruction.png new file mode 100644 index 0000000..ddb45b7 Binary files /dev/null and b/themes_src/space_pixel/Window_paynal_instruction.png differ diff --git a/themes_src/space_pixel/Window_paynal_instruction.png.import b/themes_src/space_pixel/Window_paynal_instruction.png.import new file mode 100644 index 0000000..e97e679 --- /dev/null +++ b/themes_src/space_pixel/Window_paynal_instruction.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dp7fiugglek8p" +path="res://.godot/imported/Window_paynal_instruction.png-7bcaef50299038cf8d1f819ee1108f7d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Window_paynal_instruction.png" +dest_files=["res://.godot/imported/Window_paynal_instruction.png-7bcaef50299038cf8d1f819ee1108f7d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien1.png b/themes_src/space_pixel/alien1.png new file mode 100644 index 0000000..e4d0481 Binary files /dev/null and b/themes_src/space_pixel/alien1.png differ diff --git a/themes_src/space_pixel/alien1.png.import b/themes_src/space_pixel/alien1.png.import new file mode 100644 index 0000000..af480ca --- /dev/null +++ b/themes_src/space_pixel/alien1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://i5l2u8w4uttm" +path="res://.godot/imported/alien1.png-3778ad20411a5a82c9e69a5275a6d737.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien1.png" +dest_files=["res://.godot/imported/alien1.png-3778ad20411a5a82c9e69a5275a6d737.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien2.png b/themes_src/space_pixel/alien2.png new file mode 100644 index 0000000..9aeccb4 Binary files /dev/null and b/themes_src/space_pixel/alien2.png differ diff --git a/themes_src/space_pixel/alien2.png.import b/themes_src/space_pixel/alien2.png.import new file mode 100644 index 0000000..673c35d --- /dev/null +++ b/themes_src/space_pixel/alien2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://of5wkt4qqq2h" +path="res://.godot/imported/alien2.png-f25e4ea94c5864a4bd3af0df1ef20e93.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien2.png" +dest_files=["res://.godot/imported/alien2.png-f25e4ea94c5864a4bd3af0df1ef20e93.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien3.png b/themes_src/space_pixel/alien3.png new file mode 100644 index 0000000..5a3fcd9 Binary files /dev/null and b/themes_src/space_pixel/alien3.png differ diff --git a/themes_src/space_pixel/alien3.png.import b/themes_src/space_pixel/alien3.png.import new file mode 100644 index 0000000..af5e155 --- /dev/null +++ b/themes_src/space_pixel/alien3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ku57ocnar7rc" +path="res://.godot/imported/alien3.png-d843e0127bc1cc8e3bb908ecf4670a65.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien3.png" +dest_files=["res://.godot/imported/alien3.png-d843e0127bc1cc8e3bb908ecf4670a65.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/action.png b/themes_src/space_pixel/alien_animation/action.png new file mode 100644 index 0000000..657fa0a Binary files /dev/null and b/themes_src/space_pixel/alien_animation/action.png differ diff --git a/themes_src/space_pixel/alien_animation/action.png.import b/themes_src/space_pixel/alien_animation/action.png.import new file mode 100644 index 0000000..948ea2d --- /dev/null +++ b/themes_src/space_pixel/alien_animation/action.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ck7qygavosxu8" +path="res://.godot/imported/action.png-383781188e5e5bb370418eccc230938b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/action.png" +dest_files=["res://.godot/imported/action.png-383781188e5e5bb370418eccc230938b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/climbing_action.png b/themes_src/space_pixel/alien_animation/climbing_action.png new file mode 100644 index 0000000..41a3425 Binary files /dev/null and b/themes_src/space_pixel/alien_animation/climbing_action.png differ diff --git a/themes_src/space_pixel/alien_animation/climbing_action.png.import b/themes_src/space_pixel/alien_animation/climbing_action.png.import new file mode 100644 index 0000000..c7d0472 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/climbing_action.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bs3lqug7wa6yi" +path="res://.godot/imported/climbing_action.png-28aa9a28b1c8b89f389967d2f4df73af.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/climbing_action.png" +dest_files=["res://.godot/imported/climbing_action.png-28aa9a28b1c8b89f389967d2f4df73af.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/climbing_action_start.png b/themes_src/space_pixel/alien_animation/climbing_action_start.png new file mode 100644 index 0000000..de73a1e Binary files /dev/null and b/themes_src/space_pixel/alien_animation/climbing_action_start.png differ diff --git a/themes_src/space_pixel/alien_animation/climbing_action_start.png.import b/themes_src/space_pixel/alien_animation/climbing_action_start.png.import new file mode 100644 index 0000000..4e8ba2f --- /dev/null +++ b/themes_src/space_pixel/alien_animation/climbing_action_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b60d7wqn5w8cc" +path="res://.godot/imported/climbing_action_start.png-2068d863091a5850656cf80675b447db.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/climbing_action_start.png" +dest_files=["res://.godot/imported/climbing_action_start.png-2068d863091a5850656cf80675b447db.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/climbing_action_stop.png b/themes_src/space_pixel/alien_animation/climbing_action_stop.png new file mode 100644 index 0000000..da6d719 Binary files /dev/null and b/themes_src/space_pixel/alien_animation/climbing_action_stop.png differ diff --git a/themes_src/space_pixel/alien_animation/climbing_action_stop.png.import b/themes_src/space_pixel/alien_animation/climbing_action_stop.png.import new file mode 100644 index 0000000..509514a --- /dev/null +++ b/themes_src/space_pixel/alien_animation/climbing_action_stop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://eee0w4tg4i3t" +path="res://.godot/imported/climbing_action_stop.png-146dc6325d16edd2015a213abc8104d1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/climbing_action_stop.png" +dest_files=["res://.godot/imported/climbing_action_stop.png-146dc6325d16edd2015a213abc8104d1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/climbing_down.png b/themes_src/space_pixel/alien_animation/climbing_down.png new file mode 100644 index 0000000..f3e4090 Binary files /dev/null and b/themes_src/space_pixel/alien_animation/climbing_down.png differ diff --git a/themes_src/space_pixel/alien_animation/climbing_down.png.import b/themes_src/space_pixel/alien_animation/climbing_down.png.import new file mode 100644 index 0000000..7d40b63 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/climbing_down.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c0jmolkqgkq0e" +path="res://.godot/imported/climbing_down.png-ca639a997df402309e6eff6e9db7405f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/climbing_down.png" +dest_files=["res://.godot/imported/climbing_down.png-ca639a997df402309e6eff6e9db7405f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/climbing_start.png b/themes_src/space_pixel/alien_animation/climbing_start.png new file mode 100644 index 0000000..649a01a Binary files /dev/null and b/themes_src/space_pixel/alien_animation/climbing_start.png differ diff --git a/themes_src/space_pixel/alien_animation/climbing_start.png.import b/themes_src/space_pixel/alien_animation/climbing_start.png.import new file mode 100644 index 0000000..9509009 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/climbing_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cvw4js4d8rsmj" +path="res://.godot/imported/climbing_start.png-5d7e7c1377c30d27758167009df3ab34.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/climbing_start.png" +dest_files=["res://.godot/imported/climbing_start.png-5d7e7c1377c30d27758167009df3ab34.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/climbing_up.png b/themes_src/space_pixel/alien_animation/climbing_up.png new file mode 100644 index 0000000..3cfb53e Binary files /dev/null and b/themes_src/space_pixel/alien_animation/climbing_up.png differ diff --git a/themes_src/space_pixel/alien_animation/climbing_up.png.import b/themes_src/space_pixel/alien_animation/climbing_up.png.import new file mode 100644 index 0000000..0c91363 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/climbing_up.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://l7gjp3w4wltd" +path="res://.godot/imported/climbing_up.png-60cb362f4fa4d6b11bfa427f21a41ea1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/climbing_up.png" +dest_files=["res://.godot/imported/climbing_up.png-60cb362f4fa4d6b11bfa427f21a41ea1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/come_closer.png b/themes_src/space_pixel/alien_animation/come_closer.png new file mode 100644 index 0000000..f3127ee Binary files /dev/null and b/themes_src/space_pixel/alien_animation/come_closer.png differ diff --git a/themes_src/space_pixel/alien_animation/come_closer.png.import b/themes_src/space_pixel/alien_animation/come_closer.png.import new file mode 100644 index 0000000..136230f --- /dev/null +++ b/themes_src/space_pixel/alien_animation/come_closer.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bpb2y5yrv2mem" +path="res://.godot/imported/come_closer.png-ae13499f3f972a66c8c99d6b0a5d12d0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/come_closer.png" +dest_files=["res://.godot/imported/come_closer.png-ae13499f3f972a66c8c99d6b0a5d12d0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/dancing.png b/themes_src/space_pixel/alien_animation/dancing.png new file mode 100644 index 0000000..cc2238a Binary files /dev/null and b/themes_src/space_pixel/alien_animation/dancing.png differ diff --git a/themes_src/space_pixel/alien_animation/dancing.png.import b/themes_src/space_pixel/alien_animation/dancing.png.import new file mode 100644 index 0000000..55d22c6 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/dancing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1iotxnt3c65h" +path="res://.godot/imported/dancing.png-3050319206519dd6015ca83514b803fd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/dancing.png" +dest_files=["res://.godot/imported/dancing.png-3050319206519dd6015ca83514b803fd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/falling.png b/themes_src/space_pixel/alien_animation/falling.png new file mode 100644 index 0000000..d1c3b9d Binary files /dev/null and b/themes_src/space_pixel/alien_animation/falling.png differ diff --git a/themes_src/space_pixel/alien_animation/falling.png.import b/themes_src/space_pixel/alien_animation/falling.png.import new file mode 100644 index 0000000..89719d6 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/falling.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d4bfjf205yfrl" +path="res://.godot/imported/falling.png-cd53565403e346203b2c70e1450a6e62.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/falling.png" +dest_files=["res://.godot/imported/falling.png-cd53565403e346203b2c70e1450a6e62.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/hanging.png b/themes_src/space_pixel/alien_animation/hanging.png new file mode 100644 index 0000000..a80ea47 Binary files /dev/null and b/themes_src/space_pixel/alien_animation/hanging.png differ diff --git a/themes_src/space_pixel/alien_animation/hanging.png.import b/themes_src/space_pixel/alien_animation/hanging.png.import new file mode 100644 index 0000000..f165c44 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/hanging.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cits6fnmep15l" +path="res://.godot/imported/hanging.png-7dfda3ec8542983e994b5d4381735623.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/hanging.png" +dest_files=["res://.godot/imported/hanging.png-7dfda3ec8542983e994b5d4381735623.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/idle.png b/themes_src/space_pixel/alien_animation/idle.png new file mode 100644 index 0000000..b117916 Binary files /dev/null and b/themes_src/space_pixel/alien_animation/idle.png differ diff --git a/themes_src/space_pixel/alien_animation/idle.png.import b/themes_src/space_pixel/alien_animation/idle.png.import new file mode 100644 index 0000000..9046f03 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/idle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d0qlcd7wlmxmb" +path="res://.godot/imported/idle.png-b7a1ac713c17f5170138bc5b35ea4d43.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/idle.png" +dest_files=["res://.godot/imported/idle.png-b7a1ac713c17f5170138bc5b35ea4d43.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/landing.png b/themes_src/space_pixel/alien_animation/landing.png new file mode 100644 index 0000000..4b6d00b Binary files /dev/null and b/themes_src/space_pixel/alien_animation/landing.png differ diff --git a/themes_src/space_pixel/alien_animation/landing.png.import b/themes_src/space_pixel/alien_animation/landing.png.import new file mode 100644 index 0000000..2231cd7 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/landing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cyliudhc26gpe" +path="res://.godot/imported/landing.png-d2abf5c630b4e044189d396a7c2dfd35.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/landing.png" +dest_files=["res://.godot/imported/landing.png-d2abf5c630b4e044189d396a7c2dfd35.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/sitting.png b/themes_src/space_pixel/alien_animation/sitting.png new file mode 100644 index 0000000..777c472 Binary files /dev/null and b/themes_src/space_pixel/alien_animation/sitting.png differ diff --git a/themes_src/space_pixel/alien_animation/sitting.png.import b/themes_src/space_pixel/alien_animation/sitting.png.import new file mode 100644 index 0000000..2cc604a --- /dev/null +++ b/themes_src/space_pixel/alien_animation/sitting.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dgxlhrm2bna7o" +path="res://.godot/imported/sitting.png-f41dd14eec9e28117220f62b2e9ef30a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/sitting.png" +dest_files=["res://.godot/imported/sitting.png-f41dd14eec9e28117220f62b2e9ef30a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/turn_left.png b/themes_src/space_pixel/alien_animation/turn_left.png new file mode 100644 index 0000000..f0fc32a Binary files /dev/null and b/themes_src/space_pixel/alien_animation/turn_left.png differ diff --git a/themes_src/space_pixel/alien_animation/turn_left.png.import b/themes_src/space_pixel/alien_animation/turn_left.png.import new file mode 100644 index 0000000..f447564 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/turn_left.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dnevt1plysr5j" +path="res://.godot/imported/turn_left.png-f757ea622ddce5b6fad591da1470d37d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/turn_left.png" +dest_files=["res://.godot/imported/turn_left.png-f757ea622ddce5b6fad591da1470d37d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/turn_right.png b/themes_src/space_pixel/alien_animation/turn_right.png new file mode 100644 index 0000000..69903a4 Binary files /dev/null and b/themes_src/space_pixel/alien_animation/turn_right.png differ diff --git a/themes_src/space_pixel/alien_animation/turn_right.png.import b/themes_src/space_pixel/alien_animation/turn_right.png.import new file mode 100644 index 0000000..7139669 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/turn_right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cywia4n6hfn78" +path="res://.godot/imported/turn_right.png-6039d6e5da42b19d3847c7bafd4dceee.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/turn_right.png" +dest_files=["res://.godot/imported/turn_right.png-6039d6e5da42b19d3847c7bafd4dceee.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/walking_left.png b/themes_src/space_pixel/alien_animation/walking_left.png new file mode 100644 index 0000000..c0bb6d7 Binary files /dev/null and b/themes_src/space_pixel/alien_animation/walking_left.png differ diff --git a/themes_src/space_pixel/alien_animation/walking_left.png.import b/themes_src/space_pixel/alien_animation/walking_left.png.import new file mode 100644 index 0000000..bb3a0b3 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/walking_left.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bmj4i2dogmoej" +path="res://.godot/imported/walking_left.png-f43165cacab2ba4a5ffe86b3dde903ca.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/walking_left.png" +dest_files=["res://.godot/imported/walking_left.png-f43165cacab2ba4a5ffe86b3dde903ca.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/walking_left_start.png b/themes_src/space_pixel/alien_animation/walking_left_start.png new file mode 100644 index 0000000..bcf299a Binary files /dev/null and b/themes_src/space_pixel/alien_animation/walking_left_start.png differ diff --git a/themes_src/space_pixel/alien_animation/walking_left_start.png.import b/themes_src/space_pixel/alien_animation/walking_left_start.png.import new file mode 100644 index 0000000..30bae5c --- /dev/null +++ b/themes_src/space_pixel/alien_animation/walking_left_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d05q2i3lt4fvf" +path="res://.godot/imported/walking_left_start.png-59c80fc89286d8a898587111709bbba9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/walking_left_start.png" +dest_files=["res://.godot/imported/walking_left_start.png-59c80fc89286d8a898587111709bbba9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/walking_left_stop.png b/themes_src/space_pixel/alien_animation/walking_left_stop.png new file mode 100644 index 0000000..e54ac95 Binary files /dev/null and b/themes_src/space_pixel/alien_animation/walking_left_stop.png differ diff --git a/themes_src/space_pixel/alien_animation/walking_left_stop.png.import b/themes_src/space_pixel/alien_animation/walking_left_stop.png.import new file mode 100644 index 0000000..2ea90a0 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/walking_left_stop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b8u6hev06bhm7" +path="res://.godot/imported/walking_left_stop.png-8e8f6fad3fe961601056786c431929ab.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/walking_left_stop.png" +dest_files=["res://.godot/imported/walking_left_stop.png-8e8f6fad3fe961601056786c431929ab.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/walking_right.png b/themes_src/space_pixel/alien_animation/walking_right.png new file mode 100644 index 0000000..da883df Binary files /dev/null and b/themes_src/space_pixel/alien_animation/walking_right.png differ diff --git a/themes_src/space_pixel/alien_animation/walking_right.png.import b/themes_src/space_pixel/alien_animation/walking_right.png.import new file mode 100644 index 0000000..13e30b8 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/walking_right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b71lqsvgm4dth" +path="res://.godot/imported/walking_right.png-5e34493648958ffc3c7b1587dfb10184.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/walking_right.png" +dest_files=["res://.godot/imported/walking_right.png-5e34493648958ffc3c7b1587dfb10184.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/walking_right_stop.png b/themes_src/space_pixel/alien_animation/walking_right_stop.png new file mode 100644 index 0000000..dd12060 Binary files /dev/null and b/themes_src/space_pixel/alien_animation/walking_right_stop.png differ diff --git a/themes_src/space_pixel/alien_animation/walking_right_stop.png.import b/themes_src/space_pixel/alien_animation/walking_right_stop.png.import new file mode 100644 index 0000000..bc10f6a --- /dev/null +++ b/themes_src/space_pixel/alien_animation/walking_right_stop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://btyr8t3dc1mou" +path="res://.godot/imported/walking_right_stop.png-1b80c885c024e1efe6cb747f4df1152a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/walking_right_stop.png" +dest_files=["res://.godot/imported/walking_right_stop.png-1b80c885c024e1efe6cb747f4df1152a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/alien_animation/walking_start_right.png b/themes_src/space_pixel/alien_animation/walking_start_right.png new file mode 100644 index 0000000..f8e561f Binary files /dev/null and b/themes_src/space_pixel/alien_animation/walking_start_right.png differ diff --git a/themes_src/space_pixel/alien_animation/walking_start_right.png.import b/themes_src/space_pixel/alien_animation/walking_start_right.png.import new file mode 100644 index 0000000..44d91f9 --- /dev/null +++ b/themes_src/space_pixel/alien_animation/walking_start_right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cq2cb0wu4u0m5" +path="res://.godot/imported/walking_start_right.png-b3d777bfdd98f33db931cbdede277396.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/alien_animation/walking_start_right.png" +dest_files=["res://.godot/imported/walking_start_right.png-b3d777bfdd98f33db931cbdede277396.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/back_button.png b/themes_src/space_pixel/back_button.png new file mode 100644 index 0000000..efcc11d Binary files /dev/null and b/themes_src/space_pixel/back_button.png differ diff --git a/themes_src/space_pixel/back_button.png.import b/themes_src/space_pixel/back_button.png.import new file mode 100644 index 0000000..57d0dca --- /dev/null +++ b/themes_src/space_pixel/back_button.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://da3vutcllwhbq" +path="res://.godot/imported/back_button.png-f6b6803304c81b31330977fee6d35dec.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/back_button.png" +dest_files=["res://.godot/imported/back_button.png-f6b6803304c81b31330977fee6d35dec.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/background.png b/themes_src/space_pixel/background.png new file mode 100644 index 0000000..e2e9893 Binary files /dev/null and b/themes_src/space_pixel/background.png differ diff --git a/themes_src/space_pixel/background.png.import b/themes_src/space_pixel/background.png.import new file mode 100644 index 0000000..f928dbd --- /dev/null +++ b/themes_src/space_pixel/background.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bm8vuidpyqgsw" +path="res://.godot/imported/background.png-b7f8c58f305eed5ebed5deba47799117.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/background.png" +dest_files=["res://.godot/imported/background.png-b7f8c58f305eed5ebed5deba47799117.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/billboard_ads.png b/themes_src/space_pixel/billboard_ads.png new file mode 100644 index 0000000..07e41fe Binary files /dev/null and b/themes_src/space_pixel/billboard_ads.png differ diff --git a/themes_src/space_pixel/billboard_ads.png.import b/themes_src/space_pixel/billboard_ads.png.import new file mode 100644 index 0000000..07d80fe --- /dev/null +++ b/themes_src/space_pixel/billboard_ads.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bkvugrm84rq8n" +path="res://.godot/imported/billboard_ads.png-7d84abd3cced7856e1014b499eb9325f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/billboard_ads.png" +dest_files=["res://.godot/imported/billboard_ads.png-7d84abd3cced7856e1014b499eb9325f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/bubble1.png b/themes_src/space_pixel/bubble1.png new file mode 100644 index 0000000..d964d46 Binary files /dev/null and b/themes_src/space_pixel/bubble1.png differ diff --git a/themes_src/space_pixel/bubble1.png.import b/themes_src/space_pixel/bubble1.png.import new file mode 100644 index 0000000..ad7876b --- /dev/null +++ b/themes_src/space_pixel/bubble1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtf887ix1qguo" +path="res://.godot/imported/bubble1.png-70752e7ca44f5273bb240d2668c03846.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/bubble1.png" +dest_files=["res://.godot/imported/bubble1.png-70752e7ca44f5273bb240d2668c03846.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/bubble2.png b/themes_src/space_pixel/bubble2.png new file mode 100644 index 0000000..403939f Binary files /dev/null and b/themes_src/space_pixel/bubble2.png differ diff --git a/themes_src/space_pixel/bubble2.png.import b/themes_src/space_pixel/bubble2.png.import new file mode 100644 index 0000000..e5ffd71 --- /dev/null +++ b/themes_src/space_pixel/bubble2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cd4lfvxf5abpl" +path="res://.godot/imported/bubble2.png-2a082f7f2ab06d54a4ce9c9a65c3daf1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/bubble2.png" +dest_files=["res://.godot/imported/bubble2.png-2a082f7f2ab06d54a4ce9c9a65c3daf1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/button_big.png b/themes_src/space_pixel/button_big.png new file mode 100644 index 0000000..ad18865 Binary files /dev/null and b/themes_src/space_pixel/button_big.png differ diff --git a/themes_src/space_pixel/button_big.png.import b/themes_src/space_pixel/button_big.png.import new file mode 100644 index 0000000..3b36acc --- /dev/null +++ b/themes_src/space_pixel/button_big.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://buufm0ws53m4m" +path="res://.godot/imported/button_big.png-55df0468019338d0b17955e374f44355.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/button_big.png" +dest_files=["res://.godot/imported/button_big.png-55df0468019338d0b17955e374f44355.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/button_cancel.png b/themes_src/space_pixel/button_cancel.png new file mode 100644 index 0000000..a796a16 Binary files /dev/null and b/themes_src/space_pixel/button_cancel.png differ diff --git a/themes_src/space_pixel/button_cancel.png.import b/themes_src/space_pixel/button_cancel.png.import new file mode 100644 index 0000000..40c4943 --- /dev/null +++ b/themes_src/space_pixel/button_cancel.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bobs4v1xnq4qb" +path="res://.godot/imported/button_cancel.png-59b3ff32a8eab5ec08dff365a23d3b1d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/button_cancel.png" +dest_files=["res://.godot/imported/button_cancel.png-59b3ff32a8eab5ec08dff365a23d3b1d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/button_down.png b/themes_src/space_pixel/button_down.png new file mode 100644 index 0000000..13abcee Binary files /dev/null and b/themes_src/space_pixel/button_down.png differ diff --git a/themes_src/space_pixel/button_down.png.import b/themes_src/space_pixel/button_down.png.import new file mode 100644 index 0000000..eea02ff --- /dev/null +++ b/themes_src/space_pixel/button_down.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqasvexdm5eab" +path="res://.godot/imported/button_down.png-b76944d177aec389a611669cd2063f16.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/button_down.png" +dest_files=["res://.godot/imported/button_down.png-b76944d177aec389a611669cd2063f16.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/button_long.png b/themes_src/space_pixel/button_long.png new file mode 100644 index 0000000..af9beb9 Binary files /dev/null and b/themes_src/space_pixel/button_long.png differ diff --git a/themes_src/space_pixel/button_long.png.import b/themes_src/space_pixel/button_long.png.import new file mode 100644 index 0000000..8cbe0d6 --- /dev/null +++ b/themes_src/space_pixel/button_long.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfc34lksi4kms" +path="res://.godot/imported/button_long.png-789fb95f468402464ecacdaec87ac4a1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/button_long.png" +dest_files=["res://.godot/imported/button_long.png-789fb95f468402464ecacdaec87ac4a1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/button_square.png b/themes_src/space_pixel/button_square.png new file mode 100644 index 0000000..f58b254 Binary files /dev/null and b/themes_src/space_pixel/button_square.png differ diff --git a/themes_src/space_pixel/button_square.png.import b/themes_src/space_pixel/button_square.png.import new file mode 100644 index 0000000..934520f --- /dev/null +++ b/themes_src/space_pixel/button_square.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cdav85yww0ubt" +path="res://.godot/imported/button_square.png-07a5cf5851bfb9d04cf267ee6d4a2d6b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/button_square.png" +dest_files=["res://.godot/imported/button_square.png-07a5cf5851bfb9d04cf267ee6d4a2d6b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/button_up.png b/themes_src/space_pixel/button_up.png new file mode 100644 index 0000000..834e3ca Binary files /dev/null and b/themes_src/space_pixel/button_up.png differ diff --git a/themes_src/space_pixel/button_up.png.import b/themes_src/space_pixel/button_up.png.import new file mode 100644 index 0000000..29853bc --- /dev/null +++ b/themes_src/space_pixel/button_up.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://32b5q06gs0tc" +path="res://.godot/imported/button_up.png-419fa5ee8fd9cd96ff49f0660dae70b8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/button_up.png" +dest_files=["res://.godot/imported/button_up.png-419fa5ee8fd9cd96ff49f0660dae70b8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/climbing_action.png b/themes_src/space_pixel/climbing_action.png new file mode 100644 index 0000000..41a3425 Binary files /dev/null and b/themes_src/space_pixel/climbing_action.png differ diff --git a/themes_src/space_pixel/climbing_action.png.import b/themes_src/space_pixel/climbing_action.png.import new file mode 100644 index 0000000..564dabf --- /dev/null +++ b/themes_src/space_pixel/climbing_action.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c53quh87f1ul3" +path="res://.godot/imported/climbing_action.png-a16b05ce72a16e1b25e52acaa732dc6b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/climbing_action.png" +dest_files=["res://.godot/imported/climbing_action.png-a16b05ce72a16e1b25e52acaa732dc6b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/climbing_action_start.png b/themes_src/space_pixel/climbing_action_start.png new file mode 100644 index 0000000..de73a1e Binary files /dev/null and b/themes_src/space_pixel/climbing_action_start.png differ diff --git a/themes_src/space_pixel/climbing_action_start.png.import b/themes_src/space_pixel/climbing_action_start.png.import new file mode 100644 index 0000000..2b28576 --- /dev/null +++ b/themes_src/space_pixel/climbing_action_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dsaoc2arwu208" +path="res://.godot/imported/climbing_action_start.png-ba451ffec227af2390bde1e903dd1e42.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/climbing_action_start.png" +dest_files=["res://.godot/imported/climbing_action_start.png-ba451ffec227af2390bde1e903dd1e42.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/climbing_action_stop.png b/themes_src/space_pixel/climbing_action_stop.png new file mode 100644 index 0000000..da6d719 Binary files /dev/null and b/themes_src/space_pixel/climbing_action_stop.png differ diff --git a/themes_src/space_pixel/climbing_action_stop.png.import b/themes_src/space_pixel/climbing_action_stop.png.import new file mode 100644 index 0000000..e982fb1 --- /dev/null +++ b/themes_src/space_pixel/climbing_action_stop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bxgstes0s72x7" +path="res://.godot/imported/climbing_action_stop.png-b9af007609a9965bdb7efdcc111ba766.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/climbing_action_stop.png" +dest_files=["res://.godot/imported/climbing_action_stop.png-b9af007609a9965bdb7efdcc111ba766.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/cross.png b/themes_src/space_pixel/cross.png new file mode 100644 index 0000000..2019082 Binary files /dev/null and b/themes_src/space_pixel/cross.png differ diff --git a/themes_src/space_pixel/cross.png.import b/themes_src/space_pixel/cross.png.import new file mode 100644 index 0000000..2110dd8 --- /dev/null +++ b/themes_src/space_pixel/cross.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bgf8h8d0wpiwm" +path="res://.godot/imported/cross.png-28395ef6349964f9a5ab1a4ee3b399c5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/cross.png" +dest_files=["res://.godot/imported/cross.png-28395ef6349964f9a5ab1a4ee3b399c5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/home.png b/themes_src/space_pixel/home.png new file mode 100644 index 0000000..93d46ee Binary files /dev/null and b/themes_src/space_pixel/home.png differ diff --git a/themes_src/space_pixel/home.png.import b/themes_src/space_pixel/home.png.import new file mode 100644 index 0000000..acbb32b --- /dev/null +++ b/themes_src/space_pixel/home.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dm2k5lc4xvfss" +path="res://.godot/imported/home.png-fa64bbf4173a5deae320dbcaa29748a3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/home.png" +dest_files=["res://.godot/imported/home.png-fa64bbf4173a5deae320dbcaa29748a3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/icon.svg b/themes_src/space_pixel/icon.svg new file mode 100644 index 0000000..b12bd15 --- /dev/null +++ b/themes_src/space_pixel/icon.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/themes_src/space_pixel/icon.svg.import b/themes_src/space_pixel/icon.svg.import new file mode 100644 index 0000000..9354f40 --- /dev/null +++ b/themes_src/space_pixel/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bj65urn8oyc3m" +path="res://.godot/imported/icon.svg-560fd98ec95ed8f6baddb0cdf6be14fe.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/icon.svg" +dest_files=["res://.godot/imported/icon.svg-560fd98ec95ed8f6baddb0cdf6be14fe.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/themes_src/space_pixel/less_bar.png b/themes_src/space_pixel/less_bar.png new file mode 100644 index 0000000..944ef72 Binary files /dev/null and b/themes_src/space_pixel/less_bar.png differ diff --git a/themes_src/space_pixel/less_bar.png.import b/themes_src/space_pixel/less_bar.png.import new file mode 100644 index 0000000..ef20991 --- /dev/null +++ b/themes_src/space_pixel/less_bar.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cs2kqrlldxcde" +path="res://.godot/imported/less_bar.png-fc4b3607d6f276e5cc9bf002bfde84eb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/less_bar.png" +dest_files=["res://.godot/imported/less_bar.png-fc4b3607d6f276e5cc9bf002bfde84eb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/logo.png b/themes_src/space_pixel/logo.png new file mode 100644 index 0000000..8754482 Binary files /dev/null and b/themes_src/space_pixel/logo.png differ diff --git a/themes_src/space_pixel/logo.png.import b/themes_src/space_pixel/logo.png.import new file mode 100644 index 0000000..7950f95 --- /dev/null +++ b/themes_src/space_pixel/logo.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cxgvhepd5jbkm" +path="res://.godot/imported/logo.png-45e4635b1339dbc818aec9dfc044f7fc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/logo.png" +dest_files=["res://.godot/imported/logo.png-45e4635b1339dbc818aec9dfc044f7fc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/many_bar.png b/themes_src/space_pixel/many_bar.png new file mode 100644 index 0000000..83449b5 Binary files /dev/null and b/themes_src/space_pixel/many_bar.png differ diff --git a/themes_src/space_pixel/many_bar.png.import b/themes_src/space_pixel/many_bar.png.import new file mode 100644 index 0000000..45bf301 --- /dev/null +++ b/themes_src/space_pixel/many_bar.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b4ja1o062mlkm" +path="res://.godot/imported/many_bar.png-580f380eede7390c198c96a3bbe490a5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/many_bar.png" +dest_files=["res://.godot/imported/many_bar.png-580f380eede7390c198c96a3bbe490a5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/star_frame1.png b/themes_src/space_pixel/star_frame1.png new file mode 100644 index 0000000..9a40982 Binary files /dev/null and b/themes_src/space_pixel/star_frame1.png differ diff --git a/themes_src/space_pixel/star_frame1.png.import b/themes_src/space_pixel/star_frame1.png.import new file mode 100644 index 0000000..a233eef --- /dev/null +++ b/themes_src/space_pixel/star_frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cmws6athq34hv" +path="res://.godot/imported/star_frame1.png-592924b368026bc1e9b4109693f02c04.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/star_frame1.png" +dest_files=["res://.godot/imported/star_frame1.png-592924b368026bc1e9b4109693f02c04.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/star_frame2.png b/themes_src/space_pixel/star_frame2.png new file mode 100644 index 0000000..ab93590 Binary files /dev/null and b/themes_src/space_pixel/star_frame2.png differ diff --git a/themes_src/space_pixel/star_frame2.png.import b/themes_src/space_pixel/star_frame2.png.import new file mode 100644 index 0000000..1783240 --- /dev/null +++ b/themes_src/space_pixel/star_frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://clbty5kjsg8xv" +path="res://.godot/imported/star_frame2.png-bcbd8efe3d0aa0960890c036a9f2a7b8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/star_frame2.png" +dest_files=["res://.godot/imported/star_frame2.png-bcbd8efe3d0aa0960890c036a9f2a7b8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/summa_bar.png b/themes_src/space_pixel/summa_bar.png new file mode 100644 index 0000000..47f6499 Binary files /dev/null and b/themes_src/space_pixel/summa_bar.png differ diff --git a/themes_src/space_pixel/summa_bar.png.import b/themes_src/space_pixel/summa_bar.png.import new file mode 100644 index 0000000..f6e63bd --- /dev/null +++ b/themes_src/space_pixel/summa_bar.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cck7wknwk3rf7" +path="res://.godot/imported/summa_bar.png-7710b3f11d6d3cd53f8f8ea4707e6613.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/summa_bar.png" +dest_files=["res://.godot/imported/summa_bar.png-7710b3f11d6d3cd53f8f8ea4707e6613.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/tumbler_off.png b/themes_src/space_pixel/tumbler_off.png new file mode 100644 index 0000000..4a6bfec Binary files /dev/null and b/themes_src/space_pixel/tumbler_off.png differ diff --git a/themes_src/space_pixel/tumbler_off.png.import b/themes_src/space_pixel/tumbler_off.png.import new file mode 100644 index 0000000..2765079 --- /dev/null +++ b/themes_src/space_pixel/tumbler_off.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://wj7myrlifbs1" +path="res://.godot/imported/tumbler_off.png-96a63cec1ef37c5e90a974995670a828.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/tumbler_off.png" +dest_files=["res://.godot/imported/tumbler_off.png-96a63cec1ef37c5e90a974995670a828.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/tumbler_on.png b/themes_src/space_pixel/tumbler_on.png new file mode 100644 index 0000000..c915073 Binary files /dev/null and b/themes_src/space_pixel/tumbler_on.png differ diff --git a/themes_src/space_pixel/tumbler_on.png.import b/themes_src/space_pixel/tumbler_on.png.import new file mode 100644 index 0000000..a580c70 --- /dev/null +++ b/themes_src/space_pixel/tumbler_on.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cf8mj6sqic26k" +path="res://.godot/imported/tumbler_on.png-ab3b980cd759105b5b5112e1cce17f34.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/tumbler_on.png" +dest_files=["res://.godot/imported/tumbler_on.png-ab3b980cd759105b5b5112e1cce17f34.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_баннер/frame1.png b/themes_src/space_pixel/Анимация_баннер/frame1.png new file mode 100644 index 0000000..c30b61d Binary files /dev/null and b/themes_src/space_pixel/Анимация_баннер/frame1.png differ diff --git a/themes_src/space_pixel/Анимация_баннер/frame1.png.import b/themes_src/space_pixel/Анимация_баннер/frame1.png.import new file mode 100644 index 0000000..e05fe33 --- /dev/null +++ b/themes_src/space_pixel/Анимация_баннер/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ckbtpvo2lyimh" +path="res://.godot/imported/frame1.png-b8f30ba7b9c8f85e19ebf6d35527eb21.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_баннер/frame1.png" +dest_files=["res://.godot/imported/frame1.png-b8f30ba7b9c8f85e19ebf6d35527eb21.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_баннер/frame2.png b/themes_src/space_pixel/Анимация_баннер/frame2.png new file mode 100644 index 0000000..8480f12 Binary files /dev/null and b/themes_src/space_pixel/Анимация_баннер/frame2.png differ diff --git a/themes_src/space_pixel/Анимация_баннер/frame2.png.import b/themes_src/space_pixel/Анимация_баннер/frame2.png.import new file mode 100644 index 0000000..6d68ac9 --- /dev/null +++ b/themes_src/space_pixel/Анимация_баннер/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bo18t2v8j45x1" +path="res://.godot/imported/frame2.png-03a509ec5549c5f6dfffa5a38d2d7bff.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_баннер/frame2.png" +dest_files=["res://.godot/imported/frame2.png-03a509ec5549c5f6dfffa5a38d2d7bff.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_купюры/frame1.png b/themes_src/space_pixel/Анимация_купюры/frame1.png new file mode 100644 index 0000000..b44f366 Binary files /dev/null and b/themes_src/space_pixel/Анимация_купюры/frame1.png differ diff --git a/themes_src/space_pixel/Анимация_купюры/frame1.png.import b/themes_src/space_pixel/Анимация_купюры/frame1.png.import new file mode 100644 index 0000000..cc542c5 --- /dev/null +++ b/themes_src/space_pixel/Анимация_купюры/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://x5elj45opd6j" +path="res://.godot/imported/frame1.png-9b5b99b6e97eaaf1346e9a92ee59a71c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_купюры/frame1.png" +dest_files=["res://.godot/imported/frame1.png-9b5b99b6e97eaaf1346e9a92ee59a71c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_купюры/frame2.png b/themes_src/space_pixel/Анимация_купюры/frame2.png new file mode 100644 index 0000000..3a496b8 Binary files /dev/null and b/themes_src/space_pixel/Анимация_купюры/frame2.png differ diff --git a/themes_src/space_pixel/Анимация_купюры/frame2.png.import b/themes_src/space_pixel/Анимация_купюры/frame2.png.import new file mode 100644 index 0000000..9a36ddb --- /dev/null +++ b/themes_src/space_pixel/Анимация_купюры/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dr13ek3gcn3pd" +path="res://.godot/imported/frame2.png-c919f216f488d938c953c27e780ad034.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_купюры/frame2.png" +dest_files=["res://.godot/imported/frame2.png-c919f216f488d938c953c27e780ad034.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_купюры/frame3.png b/themes_src/space_pixel/Анимация_купюры/frame3.png new file mode 100644 index 0000000..e7ff5bf Binary files /dev/null and b/themes_src/space_pixel/Анимация_купюры/frame3.png differ diff --git a/themes_src/space_pixel/Анимация_купюры/frame3.png.import b/themes_src/space_pixel/Анимация_купюры/frame3.png.import new file mode 100644 index 0000000..b7840e0 --- /dev/null +++ b/themes_src/space_pixel/Анимация_купюры/frame3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c8g3kcr5mliux" +path="res://.godot/imported/frame3.png-1ecb4fb43f32085cccd7728854243436.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_купюры/frame3.png" +dest_files=["res://.godot/imported/frame3.png-1ecb4fb43f32085cccd7728854243436.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_купюры/frame4.png b/themes_src/space_pixel/Анимация_купюры/frame4.png new file mode 100644 index 0000000..80596bd Binary files /dev/null and b/themes_src/space_pixel/Анимация_купюры/frame4.png differ diff --git a/themes_src/space_pixel/Анимация_купюры/frame4.png.import b/themes_src/space_pixel/Анимация_купюры/frame4.png.import new file mode 100644 index 0000000..7fea56d --- /dev/null +++ b/themes_src/space_pixel/Анимация_купюры/frame4.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dwr625ogrr8dw" +path="res://.godot/imported/frame4.png-8362c5c223fe9c5fb7689ac556667750.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_купюры/frame4.png" +dest_files=["res://.godot/imported/frame4.png-8362c5c223fe9c5fb7689ac556667750.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_монеты/frame1.png b/themes_src/space_pixel/Анимация_монеты/frame1.png new file mode 100644 index 0000000..0ae6101 Binary files /dev/null and b/themes_src/space_pixel/Анимация_монеты/frame1.png differ diff --git a/themes_src/space_pixel/Анимация_монеты/frame1.png.import b/themes_src/space_pixel/Анимация_монеты/frame1.png.import new file mode 100644 index 0000000..fd6d14d --- /dev/null +++ b/themes_src/space_pixel/Анимация_монеты/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b3ejdo34aoybp" +path="res://.godot/imported/frame1.png-b3f7a82060722581a3eb237e1d6726e5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_монеты/frame1.png" +dest_files=["res://.godot/imported/frame1.png-b3f7a82060722581a3eb237e1d6726e5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_монеты/frame2.png b/themes_src/space_pixel/Анимация_монеты/frame2.png new file mode 100644 index 0000000..7f98ade Binary files /dev/null and b/themes_src/space_pixel/Анимация_монеты/frame2.png differ diff --git a/themes_src/space_pixel/Анимация_монеты/frame2.png.import b/themes_src/space_pixel/Анимация_монеты/frame2.png.import new file mode 100644 index 0000000..180b334 --- /dev/null +++ b/themes_src/space_pixel/Анимация_монеты/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bbg7njgn5cha0" +path="res://.godot/imported/frame2.png-616817b8ba39553636d5e4c36305198a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_монеты/frame2.png" +dest_files=["res://.godot/imported/frame2.png-616817b8ba39553636d5e4c36305198a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_монеты/frame3.png b/themes_src/space_pixel/Анимация_монеты/frame3.png new file mode 100644 index 0000000..01055c9 Binary files /dev/null and b/themes_src/space_pixel/Анимация_монеты/frame3.png differ diff --git a/themes_src/space_pixel/Анимация_монеты/frame3.png.import b/themes_src/space_pixel/Анимация_монеты/frame3.png.import new file mode 100644 index 0000000..26bbba2 --- /dev/null +++ b/themes_src/space_pixel/Анимация_монеты/frame3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://jsgeegi20wkm" +path="res://.godot/imported/frame3.png-d11ea3bdf56aad42980956eb74c6d924.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_монеты/frame3.png" +dest_files=["res://.godot/imported/frame3.png-d11ea3bdf56aad42980956eb74c6d924.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_оплата_картой/Frame1.png b/themes_src/space_pixel/Анимация_оплата_картой/Frame1.png new file mode 100644 index 0000000..e90e0a9 Binary files /dev/null and b/themes_src/space_pixel/Анимация_оплата_картой/Frame1.png differ diff --git a/themes_src/space_pixel/Анимация_оплата_картой/Frame1.png.import b/themes_src/space_pixel/Анимация_оплата_картой/Frame1.png.import new file mode 100644 index 0000000..b8922dd --- /dev/null +++ b/themes_src/space_pixel/Анимация_оплата_картой/Frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d0gwq60ew3ucm" +path="res://.godot/imported/Frame1.png-99980b532e82d05e97f9b72bbd7ed901.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_оплата_картой/Frame1.png" +dest_files=["res://.godot/imported/Frame1.png-99980b532e82d05e97f9b72bbd7ed901.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_оплата_картой/Frame2.png b/themes_src/space_pixel/Анимация_оплата_картой/Frame2.png new file mode 100644 index 0000000..d3e1684 Binary files /dev/null and b/themes_src/space_pixel/Анимация_оплата_картой/Frame2.png differ diff --git a/themes_src/space_pixel/Анимация_оплата_картой/Frame2.png.import b/themes_src/space_pixel/Анимация_оплата_картой/Frame2.png.import new file mode 100644 index 0000000..440bf7b --- /dev/null +++ b/themes_src/space_pixel/Анимация_оплата_картой/Frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dxvevaqkeo6yd" +path="res://.godot/imported/Frame2.png-1971211e160df39e6ab8092e78125c8f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_оплата_картой/Frame2.png" +dest_files=["res://.godot/imported/Frame2.png-1971211e160df39e6ab8092e78125c8f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_оплата_непрошла/frame1.png b/themes_src/space_pixel/Анимация_оплата_непрошла/frame1.png new file mode 100644 index 0000000..22396e8 Binary files /dev/null and b/themes_src/space_pixel/Анимация_оплата_непрошла/frame1.png differ diff --git a/themes_src/space_pixel/Анимация_оплата_непрошла/frame1.png.import b/themes_src/space_pixel/Анимация_оплата_непрошла/frame1.png.import new file mode 100644 index 0000000..19208e9 --- /dev/null +++ b/themes_src/space_pixel/Анимация_оплата_непрошла/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cx1h1ia34nor5" +path="res://.godot/imported/frame1.png-4d13fb815ade35fc1f4ea39792ed8c7e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_оплата_непрошла/frame1.png" +dest_files=["res://.godot/imported/frame1.png-4d13fb815ade35fc1f4ea39792ed8c7e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_оплата_непрошла/frame2.png b/themes_src/space_pixel/Анимация_оплата_непрошла/frame2.png new file mode 100644 index 0000000..74f8b4a Binary files /dev/null and b/themes_src/space_pixel/Анимация_оплата_непрошла/frame2.png differ diff --git a/themes_src/space_pixel/Анимация_оплата_непрошла/frame2.png.import b/themes_src/space_pixel/Анимация_оплата_непрошла/frame2.png.import new file mode 100644 index 0000000..36e76e5 --- /dev/null +++ b/themes_src/space_pixel/Анимация_оплата_непрошла/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bwrt2m6xvxpkl" +path="res://.godot/imported/frame2.png-eb666079e1bcefc311605b27dee35558.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_оплата_непрошла/frame2.png" +dest_files=["res://.godot/imported/frame2.png-eb666079e1bcefc311605b27dee35558.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_оплата_непрошла/frame3.png b/themes_src/space_pixel/Анимация_оплата_непрошла/frame3.png new file mode 100644 index 0000000..8e46e2a Binary files /dev/null and b/themes_src/space_pixel/Анимация_оплата_непрошла/frame3.png differ diff --git a/themes_src/space_pixel/Анимация_оплата_непрошла/frame3.png.import b/themes_src/space_pixel/Анимация_оплата_непрошла/frame3.png.import new file mode 100644 index 0000000..84b928c --- /dev/null +++ b/themes_src/space_pixel/Анимация_оплата_непрошла/frame3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ci2nxj1be04l5" +path="res://.godot/imported/frame3.png-acbda80a71ccec1e98623dc6ac031e82.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_оплата_непрошла/frame3.png" +dest_files=["res://.godot/imported/frame3.png-acbda80a71ccec1e98623dc6ac031e82.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_оплата_прошла/frame1.png b/themes_src/space_pixel/Анимация_оплата_прошла/frame1.png new file mode 100644 index 0000000..4af13f0 Binary files /dev/null and b/themes_src/space_pixel/Анимация_оплата_прошла/frame1.png differ diff --git a/themes_src/space_pixel/Анимация_оплата_прошла/frame1.png.import b/themes_src/space_pixel/Анимация_оплата_прошла/frame1.png.import new file mode 100644 index 0000000..acfb271 --- /dev/null +++ b/themes_src/space_pixel/Анимация_оплата_прошла/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bipeanf6ptrny" +path="res://.godot/imported/frame1.png-d53e93458f4dc87a16396a0aa2c1fcde.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_оплата_прошла/frame1.png" +dest_files=["res://.godot/imported/frame1.png-d53e93458f4dc87a16396a0aa2c1fcde.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_оплата_прошла/frame2.png b/themes_src/space_pixel/Анимация_оплата_прошла/frame2.png new file mode 100644 index 0000000..bccef40 Binary files /dev/null and b/themes_src/space_pixel/Анимация_оплата_прошла/frame2.png differ diff --git a/themes_src/space_pixel/Анимация_оплата_прошла/frame2.png.import b/themes_src/space_pixel/Анимация_оплата_прошла/frame2.png.import new file mode 100644 index 0000000..314cfc8 --- /dev/null +++ b/themes_src/space_pixel/Анимация_оплата_прошла/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dlgk8egqw03ec" +path="res://.godot/imported/frame2.png-a2e13f4624b6a74023aedc2e5a5c2d9a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_оплата_прошла/frame2.png" +dest_files=["res://.godot/imported/frame2.png-a2e13f4624b6a74023aedc2e5a5c2d9a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_оплата_прошла/frame3.png b/themes_src/space_pixel/Анимация_оплата_прошла/frame3.png new file mode 100644 index 0000000..a10d308 Binary files /dev/null and b/themes_src/space_pixel/Анимация_оплата_прошла/frame3.png differ diff --git a/themes_src/space_pixel/Анимация_оплата_прошла/frame3.png.import b/themes_src/space_pixel/Анимация_оплата_прошла/frame3.png.import new file mode 100644 index 0000000..533b766 --- /dev/null +++ b/themes_src/space_pixel/Анимация_оплата_прошла/frame3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtm7qop8jrgtt" +path="res://.godot/imported/frame3.png-a0e0766737bb43e9716fcfd300b51a09.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_оплата_прошла/frame3.png" +dest_files=["res://.godot/imported/frame3.png-a0e0766737bb43e9716fcfd300b51a09.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_оплата_прошла/frame4.png b/themes_src/space_pixel/Анимация_оплата_прошла/frame4.png new file mode 100644 index 0000000..67c9322 Binary files /dev/null and b/themes_src/space_pixel/Анимация_оплата_прошла/frame4.png differ diff --git a/themes_src/space_pixel/Анимация_оплата_прошла/frame4.png.import b/themes_src/space_pixel/Анимация_оплата_прошла/frame4.png.import new file mode 100644 index 0000000..ed15bf9 --- /dev/null +++ b/themes_src/space_pixel/Анимация_оплата_прошла/frame4.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bmjdakupffklv" +path="res://.godot/imported/frame4.png-b330625e3a4ad98dae4b252b4c58e7b0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_оплата_прошла/frame4.png" +dest_files=["res://.godot/imported/frame4.png-b330625e3a4ad98dae4b252b4c58e7b0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Анимация_оплата_прошла/frame5.png b/themes_src/space_pixel/Анимация_оплата_прошла/frame5.png new file mode 100644 index 0000000..a10d308 Binary files /dev/null and b/themes_src/space_pixel/Анимация_оплата_прошла/frame5.png differ diff --git a/themes_src/space_pixel/Анимация_оплата_прошла/frame5.png.import b/themes_src/space_pixel/Анимация_оплата_прошла/frame5.png.import new file mode 100644 index 0000000..d034f81 --- /dev/null +++ b/themes_src/space_pixel/Анимация_оплата_прошла/frame5.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://qp7ia8ucah56" +path="res://.godot/imported/frame5.png-8eee4931dbffa4048ca31f0d1492b2d3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Анимация_оплата_прошла/frame5.png" +dest_files=["res://.godot/imported/frame5.png-8eee4931dbffa4048ca31f0d1492b2d3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Пролетающие_нло/frame1.png b/themes_src/space_pixel/Пролетающие_нло/frame1.png new file mode 100644 index 0000000..cf240d2 Binary files /dev/null and b/themes_src/space_pixel/Пролетающие_нло/frame1.png differ diff --git a/themes_src/space_pixel/Пролетающие_нло/frame1.png.import b/themes_src/space_pixel/Пролетающие_нло/frame1.png.import new file mode 100644 index 0000000..9e42d4c --- /dev/null +++ b/themes_src/space_pixel/Пролетающие_нло/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bymm2ddyjs42k" +path="res://.godot/imported/frame1.png-fa0ab81b81fe005da87c1db64c1f5a83.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Пролетающие_нло/frame1.png" +dest_files=["res://.godot/imported/frame1.png-fa0ab81b81fe005da87c1db64c1f5a83.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Пролетающие_нло/frame2.png b/themes_src/space_pixel/Пролетающие_нло/frame2.png new file mode 100644 index 0000000..5839c12 Binary files /dev/null and b/themes_src/space_pixel/Пролетающие_нло/frame2.png differ diff --git a/themes_src/space_pixel/Пролетающие_нло/frame2.png.import b/themes_src/space_pixel/Пролетающие_нло/frame2.png.import new file mode 100644 index 0000000..f3f9c1e --- /dev/null +++ b/themes_src/space_pixel/Пролетающие_нло/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c74gd56itfjr8" +path="res://.godot/imported/frame2.png-a41ff1d43384dcccf823cf8dfc99c57e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Пролетающие_нло/frame2.png" +dest_files=["res://.godot/imported/frame2.png-a41ff1d43384dcccf823cf8dfc99c57e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Пролетающие_нло_большие/frame1.png b/themes_src/space_pixel/Пролетающие_нло_большие/frame1.png new file mode 100644 index 0000000..67ecfce Binary files /dev/null and b/themes_src/space_pixel/Пролетающие_нло_большие/frame1.png differ diff --git a/themes_src/space_pixel/Пролетающие_нло_большие/frame1.png.import b/themes_src/space_pixel/Пролетающие_нло_большие/frame1.png.import new file mode 100644 index 0000000..49f9f68 --- /dev/null +++ b/themes_src/space_pixel/Пролетающие_нло_большие/frame1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://23y72b0v7wt3" +path="res://.godot/imported/frame1.png-e550743d5895cc24d704a6fa24119c85.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Пролетающие_нло_большие/frame1.png" +dest_files=["res://.godot/imported/frame1.png-e550743d5895cc24d704a6fa24119c85.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Пролетающие_нло_большие/frame2.png b/themes_src/space_pixel/Пролетающие_нло_большие/frame2.png new file mode 100644 index 0000000..a705cb0 Binary files /dev/null and b/themes_src/space_pixel/Пролетающие_нло_большие/frame2.png differ diff --git a/themes_src/space_pixel/Пролетающие_нло_большие/frame2.png.import b/themes_src/space_pixel/Пролетающие_нло_большие/frame2.png.import new file mode 100644 index 0000000..ad8348d --- /dev/null +++ b/themes_src/space_pixel/Пролетающие_нло_большие/frame2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cg3ppag3ftjda" +path="res://.godot/imported/frame2.png-abaaada45d890cf766394b757647dbd2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://themes_src/space_pixel/Пролетающие_нло_большие/frame2.png" +dest_files=["res://.godot/imported/frame2.png-abaaada45d890cf766394b757647dbd2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/themes_src/space_pixel/Цвета.txt b/themes_src/space_pixel/Цвета.txt new file mode 100644 index 0000000..e69de29 diff --git a/themes_src/space_pixel/Шрифты/VCR OSD Mono Cyr.ttf b/themes_src/space_pixel/Шрифты/VCR OSD Mono Cyr.ttf new file mode 100644 index 0000000..b621e80 Binary files /dev/null and b/themes_src/space_pixel/Шрифты/VCR OSD Mono Cyr.ttf differ diff --git a/themes_src/space_pixel/Шрифты/VCR OSD Mono Cyr.ttf.import b/themes_src/space_pixel/Шрифты/VCR OSD Mono Cyr.ttf.import new file mode 100644 index 0000000..ef180a6 --- /dev/null +++ b/themes_src/space_pixel/Шрифты/VCR OSD Mono Cyr.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://ddybog57gjow" +path="res://.godot/imported/VCR OSD Mono Cyr.ttf-09037bc35c87c5384f9fe427c383a182.fontdata" + +[deps] + +source_file="res://themes_src/space_pixel/Шрифты/VCR OSD Mono Cyr.ttf" +dest_files=["res://.godot/imported/VCR OSD Mono Cyr.ttf-09037bc35c87c5384f9fe427c383a182.fontdata"] + +[params] + +Rendering=null +antialiasing=0 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=0 +subpixel_positioning=0 +keep_rounding_remainders=true +oversampling=1.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/tools/Import-HostSceneTemplates.ps1 b/tools/Import-HostSceneTemplates.ps1 new file mode 100644 index 0000000..912b9ae --- /dev/null +++ b/tools/Import-HostSceneTemplates.ps1 @@ -0,0 +1,115 @@ +param( + [string]$HostProject = (Join-Path $PSScriptRoot "..\..\exchanger"), + [string[]]$ThemeIds = @("example", "space-pixel") +) + +$ErrorActionPreference = "Stop" +$builderRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$hostRoot = (Resolve-Path -LiteralPath $HostProject).Path +$contract = Get-Content -LiteralPath (Join-Path $builderRoot "contracts\screen_bindings.v2.json") -Raw | ConvertFrom-Json + +$sourceDirectories = [ordered]@{ + home = "HomeScreen" + cash_payment = "CashPaymentScreen" + card_amount = "CardAmountScreen" + card_custom_amount = "CardCustomAmountScreen" + card_terminal = "CardTerminalScreen" + success = "SuccessScreen" + error = "ErrorScreen" + service_access = "ServiceAccessScreen" + settings = "SettingsScreen" +} + +function Convert-NodeHeader([string]$Line) { + if ($Line -match 'instance=ExtResource\("(?:2_stock|3_advertisement|4_connection)"\)') { + $lineWithoutInstance = [regex]::Replace($Line, '\s+instance=ExtResource\("[^\"]+"\)', '') + if ($lineWithoutInstance -notmatch '\stype="') { + $lineWithoutInstance = $lineWithoutInstance.Insert($lineWithoutInstance.Length - 1, ' type="Control"') + } + return $lineWithoutInstance + } + return $Line +} + +foreach ($themeId in $ThemeIds) { + $themeRoot = Join-Path $builderRoot ("themes\" + $themeId) + $sceneDirectory = Join-Path $themeRoot "package\scenes" + New-Item -ItemType Directory -Force -Path $sceneDirectory | Out-Null + + foreach ($screen in $contract.Screens) { + $sourceDirectory = $sourceDirectories[$screen.Key] + $sourcePath = Join-Path $hostRoot ("src\scenes\{0}\{0}.tscn" -f $sourceDirectory) + $lines = Get-Content -LiteralPath $sourcePath + $bindingByFallbackPath = @{} + foreach ($binding in $screen.Bindings) { + $bindingByFallbackPath[$binding.FallbackPath] = $binding.Id + } + + $result = [System.Collections.Generic.List[string]]::new() + $rootSeen = $false + $skipNonVisualService = $false + foreach ($sourceLine in $lines) { + $line = $sourceLine + if ($line -match '^\[gd_scene .*\suid="[^"]+"') { + $line = [regex]::Replace($line, '\s+uid="[^"]+"', '') + } + if ($line -match '^\[node name="(?:AutoSaveTimer|TestTonePlayer)"') { + $skipNonVisualService = $true + continue + } + if ($skipNonVisualService) { + if ($line -notmatch '^\[') { + continue + } + $skipNonVisualService = $false + } + if ($line -match '^\[ext_resource type="Script"') { + continue + } + if ($line -match '^\[ext_resource type="PackedScene".*path="res://src/ui/KioskFooter/') { + $line = [regex]::Replace($line, 'path="[^"]+"', 'path="../components/footer.tscn"') + } elseif ($line -match '^\[ext_resource .*path="res://src/') { + continue + } + if ($line -match '^script = ExtResource\(' -or $line -match '^SlotId = ') { + continue + } + + if ($line -match '^\[node name="') { + $line = Convert-NodeHeader $line + $nameMatch = [regex]::Match($line, '^\[node name="(?[^"]+)"') + $parentMatch = [regex]::Match($line, ' parent="(?[^"]+)"') + $name = $nameMatch.Groups["name"].Value + if (-not $rootSeen) { + $line = $line.Replace(('name="' + $name + '"'), ('name="' + $screen.RootName + '"')) + $rootSeen = $true + $result.Add($line) + $result.Add(('metadata/exchanger_screen_binding_id = "' + $screen.BindingId + '"')) + continue + } + + $parent = $parentMatch.Groups["parent"].Value + $fullPath = if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq ".") { + $name + } else { + $parent + "/" + $name + } + if ($bindingByFallbackPath.ContainsKey($fullPath)) { + $result.Add($line) + $result.Add(('metadata/exchanger_binding_id = "' + $bindingByFallbackPath[$fullPath] + '"')) + continue + } + } + $result.Add($line) + } + + $outputPath = Join-Path $sceneDirectory ("screen_" + $screen.Key + ".tscn") + $text = ($result -join "`n") + "`n" + if ($text -match 'res://src/' -or $text -match '(?m)^script = ') { + throw "Sanitized scene still references host code: $outputPath" + } + Set-Content -LiteralPath $outputPath -Value $text -Encoding UTF8 -NoNewline + } +} + +Write-Host "Imported and bound host scene structures for: $($ThemeIds -join ', ')" diff --git a/tools/Prepare-ThemeWorkspace.ps1 b/tools/Prepare-ThemeWorkspace.ps1 new file mode 100644 index 0000000..21709fa --- /dev/null +++ b/tools/Prepare-ThemeWorkspace.ps1 @@ -0,0 +1,86 @@ +param( + [Parameter(Mandatory = $true)] + [string]$PackageRoot, + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot +) + +$ErrorActionPreference = "Stop" +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$packageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path +$workspaceRoot = [System.IO.Path]::GetFullPath($WorkspaceRoot) +$allowedRoot = [System.IO.Path]::GetFullPath((Join-Path $projectRoot "build\theme-workspaces")) +$packageResourcePrefix = "" +if ($packageRoot.StartsWith($projectRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + $packageProjectRelative = $packageRoot.Substring($projectRoot.Length + 1).Replace("\", "/").TrimEnd("/") + $packageResourcePrefix = "res://$packageProjectRelative/" +} +$runtimeResourcePrefix = "res://exchanger_theme_dlc/" +$portableTextExtensions = @(".gd", ".gdshader", ".json", ".tres", ".tscn") +$preservedImportSourceExtensions = @(".ttf", ".otf", ".woff", ".woff2") +$utf8WithoutBom = [System.Text.UTF8Encoding]::new($false) + +if ( + ($workspaceRoot -eq $allowedRoot) -or + (-not $workspaceRoot.StartsWith($allowedRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) +) { + throw "Refusing to prepare unexpected theme workspace: $workspaceRoot" +} + +if (Test-Path -LiteralPath $workspaceRoot) { + Remove-Item -LiteralPath $workspaceRoot -Recurse -Force +} + +$stagingRoot = Join-Path $workspaceRoot "exchanger_theme_dlc" +New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null +foreach ($file in Get-ChildItem -LiteralPath $packageRoot -Recurse -File) { + if ($file.Name -eq ".gdignore" -or $file.Extension -eq ".uid") { + continue + } + $isPreservedImport = $false + if ($file.Extension -eq ".import") { + $importSourceName = $file.Name.Substring(0, $file.Name.Length - $file.Extension.Length) + $importSourceExtension = [System.IO.Path]::GetExtension($importSourceName).ToLowerInvariant() + $isPreservedImport = $importSourceExtension -in $preservedImportSourceExtensions + if (-not $isPreservedImport) { + continue + } + } + $relative = $file.FullName.Substring($packageRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar).Length).TrimStart([System.IO.Path]::DirectorySeparatorChar) + $destination = Join-Path $stagingRoot $relative + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null + Copy-Item -LiteralPath $file.FullName -Destination $destination -Force + if (-not [string]::IsNullOrWhiteSpace($packageResourcePrefix) -and ($file.Extension -in $portableTextExtensions -or $isPreservedImport)) { + $content = [System.IO.File]::ReadAllText($destination) + $portableContent = $content.Replace($packageResourcePrefix, $runtimeResourcePrefix) + if ($portableContent -ne $content) { + [System.IO.File]::WriteAllText($destination, $portableContent, $utf8WithoutBom) + } + } +} + +New-Item -ItemType Directory -Force -Path (Join-Path $workspaceRoot "tools") | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $workspaceRoot "contracts") | Out-Null +Copy-Item -LiteralPath (Join-Path $projectRoot "tools\validate_theme.gd") -Destination (Join-Path $workspaceRoot "tools\validate_theme.gd") -Force +Copy-Item -LiteralPath (Join-Path $projectRoot "contracts\screen_bindings.v2.json") -Destination (Join-Path $workspaceRoot "contracts\screen_bindings.v2.json") -Force +Copy-Item -LiteralPath (Join-Path $projectRoot "export_presets.cfg") -Destination (Join-Path $workspaceRoot "export_presets.cfg") -Force + +$projectConfig = @' +; Generated isolated workspace for Exchanger theme validation/export. +config_version=5 + +[application] +config/name="Exchanger Theme Build Workspace" +config/features=PackedStringArray("4.7", "GL Compatibility") + +[editor] +export/convert_text_resources_to_binary=false + +[rendering] +textures/canvas_textures/default_texture_filter=0 +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +'@ +[System.IO.File]::WriteAllText((Join-Path $workspaceRoot "project.godot"), $projectConfig, $utf8WithoutBom) + +Write-Host "Prepared isolated theme workspace: $workspaceRoot" diff --git a/tools/Resolve-GodotExecutable.ps1 b/tools/Resolve-GodotExecutable.ps1 new file mode 100644 index 0000000..d50b74a --- /dev/null +++ b/tools/Resolve-GodotExecutable.ps1 @@ -0,0 +1,50 @@ +function Resolve-ThemeGodotExecutable { + param( + [string]$GodotExecutable = "" + ) + + $candidate = $GodotExecutable + if ([string]::IsNullOrWhiteSpace($candidate)) { + $candidate = [Environment]::GetEnvironmentVariable("GODOT4_EXECUTABLE", "Process") + } + if ([string]::IsNullOrWhiteSpace($candidate)) { + $candidate = [Environment]::GetEnvironmentVariable("GODOT_EXECUTABLE", "Process") + } + if ([string]::IsNullOrWhiteSpace($candidate)) { + $runningGodot = Get-Process | Where-Object { + $_.ProcessName -like "Godot*" ` + -and $_.ProcessName -notlike "godot-ai*" ` + -and $_.Path + } | Select-Object -First 1 + if ($runningGodot) { + $candidate = $runningGodot.Path + } + } + if ([string]::IsNullOrWhiteSpace($candidate)) { + foreach ($commandName in @("godot", "godot4")) { + $command = Get-Command $commandName -ErrorAction SilentlyContinue + if ($command) { + $candidate = $command.Source + break + } + } + } + if ([string]::IsNullOrWhiteSpace($candidate) -or -not (Test-Path -LiteralPath $candidate)) { + throw "Godot 4.7 was not found. Start Godot Editor, add godot/godot4 to PATH, set GODOT4_EXECUTABLE, or pass -GodotExecutable." + } + + $candidate = (Resolve-Path -LiteralPath $candidate).Path + $consoleCandidate = Join-Path ` + (Split-Path -Parent $candidate) ` + (([System.IO.Path]::GetFileNameWithoutExtension($candidate)) + "_console.exe") + if (Test-Path -LiteralPath $consoleCandidate) { + $candidate = $consoleCandidate + } + + $godotVersion = & $candidate --version 2>$null + if ($LASTEXITCODE -ne 0 -or $godotVersion -notmatch '^4\.7(\.|$)') { + throw "A real Godot 4.7 executable is required; the godot-ai MCP server is not Godot. Found: '$godotVersion'." + } + + return $candidate +} diff --git a/tools/Select-Theme.ps1 b/tools/Select-Theme.ps1 new file mode 100644 index 0000000..23c2fa5 --- /dev/null +++ b/tools/Select-Theme.ps1 @@ -0,0 +1,67 @@ +param( + [string]$ThemeId = "space-pixel", + [string]$ThemeSource = "" +) + +$ErrorActionPreference = "Stop" +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$themesRoot = Join-Path $projectRoot "themes" + +if (Test-Path -LiteralPath $themesRoot) { + foreach ($themeDirectory in Get-ChildItem -LiteralPath $themesRoot -Directory) { + $legacyIgnore = Join-Path $themeDirectory.FullName ".gdignore" + if (Test-Path -LiteralPath $legacyIgnore) { + Remove-Item -LiteralPath $legacyIgnore -Force + Write-Host "Removed legacy theme ignore: $legacyIgnore" + } + } +} + +if ([string]::IsNullOrWhiteSpace($ThemeSource)) { + $themeRoot = Join-Path $projectRoot ("themes\" + $ThemeId) +} else { + $themeRoot = (Resolve-Path -LiteralPath $ThemeSource).Path +} + +$packageRoot = if (Test-Path -LiteralPath (Join-Path $themeRoot "package\manifest.json")) { + Join-Path $themeRoot "package" +} elseif (Test-Path -LiteralPath (Join-Path $themeRoot "manifest.json")) { + $themeRoot +} else { + throw "Theme source must contain package\manifest.json or manifest.json: $themeRoot" +} +$packageRoot = [System.IO.Path]::GetFullPath($packageRoot) + +$manifest = Get-Content -LiteralPath (Join-Path $packageRoot "manifest.json") -Raw -Encoding UTF8 | ConvertFrom-Json +if ($manifest.Format -ne "ExchangerThemeDlc") { + throw "Unsupported manifest Format '$($manifest.Format)'." +} +if ($manifest.FormatVersion -ne 2) { + throw "Full-scene themes require manifest FormatVersion 2." +} +if (-not [string]::IsNullOrWhiteSpace($ThemeId) -and $manifest.ThemeId -ne $ThemeId) { + throw "ThemeId '$($manifest.ThemeId)' does not match selected id '$ThemeId'." +} + +$buildDirectory = Join-Path $projectRoot "build" +New-Item -ItemType Directory -Force -Path $buildDirectory | Out-Null +$utf8WithoutBom = [System.Text.UTF8Encoding]::new($false) +$buildIgnorePath = Join-Path $buildDirectory ".gdignore" +if (-not (Test-Path -LiteralPath $buildIgnorePath)) { + [System.IO.File]::WriteAllText( + $buildIgnorePath, + "# Generated builds and isolated export workspaces are not editor resources." + [Environment]::NewLine, + $utf8WithoutBom + ) +} +$state = [ordered]@{ + ThemeId = [string]$manifest.ThemeId + DisplayName = [string]$manifest.DisplayName + Source = [System.IO.Path]::GetFullPath($themeRoot) + Package = $packageRoot + ActivatedAtUtc = [DateTime]::UtcNow.ToString("o") +} +$stateJson = $state | ConvertTo-Json +[System.IO.File]::WriteAllText((Join-Path $buildDirectory "active-theme.json"), $stateJson + [Environment]::NewLine, $utf8WithoutBom) + +Write-Host "Theme '$($manifest.ThemeId)' selected from $packageRoot" diff --git a/tools/Sync-HostBindingContract.ps1 b/tools/Sync-HostBindingContract.ps1 new file mode 100644 index 0000000..83283a6 --- /dev/null +++ b/tools/Sync-HostBindingContract.ps1 @@ -0,0 +1,305 @@ +param( + [string]$HostProject = (Join-Path $PSScriptRoot "..\..\exchanger"), + [string]$OutputPath = (Join-Path $PSScriptRoot "..\contracts\screen_bindings.v2.json"), + [switch]$Check +) + +$ErrorActionPreference = "Stop" +$hostRoot = (Resolve-Path -LiteralPath $HostProject).Path +$screenDefinitions = @( + [ordered]@{ Directory = "HomeScreen"; Key = "home"; Pascal = "Home" }, + [ordered]@{ Directory = "CashPaymentScreen"; Key = "cash_payment"; Pascal = "CashPayment" }, + [ordered]@{ Directory = "CardAmountScreen"; Key = "card_amount"; Pascal = "CardAmount" }, + [ordered]@{ Directory = "CardCustomAmountScreen"; Key = "card_custom_amount"; Pascal = "CardCustomAmount" }, + [ordered]@{ Directory = "CardTerminalScreen"; Key = "card_terminal"; Pascal = "CardTerminal" }, + [ordered]@{ Directory = "SuccessScreen"; Key = "success"; Pascal = "Success" }, + [ordered]@{ Directory = "ErrorScreen"; Key = "error"; Pascal = "Error" }, + [ordered]@{ Directory = "ServiceAccessScreen"; Key = "service_access"; Pascal = "ServiceAccess" }, + [ordered]@{ Directory = "SettingsScreen"; Key = "settings"; Pascal = "Settings" } +) + +# Эти готовые узлы появились после фиксации immutable-каталога v2. Host использует +# их напрямую и Builder валидирует отдельно, поэтому буквальные GetNode-пути не +# должны незаметно расширять contracts/screen_bindings.v2.json. +$supplementalRuntimeBindingIds = @( + "home.scroll.content.advertisement_panel", + "home.scroll.content.stock_status.meter.empty", + "home.scroll.content.stock_status.meter.low", + "home.scroll.content.stock_status.meter.enough", + "home.scroll.content.stock_status.meter.much", + "home.scroll.content.stock_status.approximate_count", + "card_amount.content.reward_panel.content.bonus", + "cash_payment.content.banner_text", + "cash_payment.content.top_bar.home_navigation_button", + "card_amount.content.top_bar.home_navigation_button", + "card_custom_amount.content.top_bar.home_navigation_button", + "card_terminal.content.top_bar.home_navigation_button", + "success.content.home_navigation_button", + "error.content.home_navigation_button", + "service_access.content.home_navigation_button", + "settings.footer", + "settings.footer.save_and_exit_button", + "settings.scroll.content.footer_clearance", + "settings.scroll.content.service_inventory_panel.margin.content.description", + "settings.scroll.content.service_inventory_panel.margin.content.inventory_enabled", + "settings.scroll.content.service_inventory_panel.margin.content.purchase_limit_enabled", + "settings.scroll.content.menu_visibility_panel", + "settings.scroll.content.menu_visibility_panel.margin.content.show_stock_status", + "settings.scroll.content.menu_visibility_panel.margin.content.show_right_character", + "settings.scroll.content.menu_visibility_panel.margin.content.show_promotion_block", + "settings.scroll.content.menu_visibility_panel.margin.content.show_interactive_pet", + "settings.scroll.content.advertisement_poster_panel.margin.content.select_button", + "settings.scroll.content.animated_banner_texts_panel" +) + +function Convert-ToSnakeCase([string]$Value) { + return [regex]::Replace($Value, '(?<=[a-z0-9])([A-Z])', '_$1').ToLowerInvariant() +} + +function Convert-ToBindingId([string]$ScreenKey, [string]$FallbackPath) { + $relative = $FallbackPath.Substring("SafeMargin/".Length) + $segments = $relative.Split('/') | ForEach-Object { Convert-ToSnakeCase $_ } + return $ScreenKey + "." + ($segments -join ".") +} + +function Convert-ToGodotType([string]$CodeType) { + switch ($CodeType) { + "AdvertisementPlayerControl" { return "Control" } + "ConnectionStatusControl" { return "Control" } + "StockStatusControl" { return "Control" } + default { return $CodeType } + } +} + +function Add-Binding( + [System.Collections.Specialized.OrderedDictionary]$Bindings, + [string]$Id, + [string]$Type, + [string]$FallbackPath +) { + $Bindings[$Id] = [ordered]@{ + Id = $Id + Types = @($Type) + Required = $true + FallbackPath = $FallbackPath + } +} + +$screens = @() +foreach ($definition in $screenDefinitions) { + $sourcePath = Join-Path $hostRoot ("src\scenes\{0}\{0}.cs" -f $definition.Directory) + if (-not (Test-Path -LiteralPath $sourcePath)) { + throw "Host screen source is missing: $sourcePath" + } + $source = Get-Content -LiteralPath $sourcePath -Raw + $matches = [regex]::Matches( + $source, + 'GetNode(?:<(?[^>]+)>)?\("(?SafeMargin/[^"]+)"\)' + ) + $bindings = [ordered]@{} + foreach ($match in $matches) { + $fallbackPath = $match.Groups["path"].Value + $bindingId = Convert-ToBindingId $definition.Key $fallbackPath + $codeType = $match.Groups["type"].Value + if ([string]::IsNullOrWhiteSpace($codeType)) { + $codeType = "Node" + } + $bindings[$bindingId] = [ordered]@{ + Id = $bindingId + Types = @((Convert-ToGodotType $codeType)) + Required = $true + FallbackPath = $fallbackPath + } + } + + if ($definition.Key -eq "card_custom_amount" -or $definition.Key -eq "service_access") { + for ($digit = 0; $digit -le 9; $digit++) { + $fallbackPath = if ($definition.Key -eq "card_custom_amount") { + "SafeMargin/Content/KeypadPanel/Content/Keypad/Digit$digit" + } else { + "SafeMargin/Content/AccessPanel/Fields/Keypad/Digit$digit" + } + $bindingId = Convert-ToBindingId $definition.Key $fallbackPath + $bindings[$bindingId] = [ordered]@{ + Id = $bindingId + Types = @("Button") + Required = $true + FallbackPath = $fallbackPath + } + } + } + + if ($definition.Key -eq "home") { + Add-Binding $bindings "home.scroll.content.footer.margin.content.connection_status" "Control" "SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus" + Add-Binding $bindings "home.scroll.content.stock_status.state" "Label" "SafeMargin/Scroll/Content/StockStatus/Margin/Content/State" + Add-Binding $bindings "home.scroll.content.footer.margin.content.connection_status.label" "Label" "SafeMargin/Scroll/Content/Footer/Margin/Content/ConnectionStatus/Panel/Margin/Label" + Add-Binding $bindings "home.scroll.content.advertisement_panel.layer.content" "Control" "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content" + Add-Binding $bindings "home.scroll.content.advertisement_panel.layer.content.poster" "TextureRect" "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content/Poster" + Add-Binding $bindings "home.scroll.content.advertisement_panel.layer.content.video_player" "VideoStreamPlayer" "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content/VideoPlayer" + Add-Binding $bindings "home.scroll.content.advertisement_panel.layer.content.fallback_label" "Label" "SafeMargin/Scroll/Content/AdvertisementPanel/Layer/Content/FallbackLabel" + } + + if ($definition.Key -eq "card_amount") { + for ($index = 0; $index -lt 16; $index++) { + $suffix = $index.ToString("00") + Add-Binding $bindings "card_amount.content.amount_panel.margin.content.preset_grid.slot_$suffix" "Button" "SafeMargin/Content/AmountPanel/Margin/Content/PresetGrid/PresetSlot$suffix" + } + } + + if ($definition.Key -eq "settings") { + Add-Binding $bindings "settings.scroll.content.section_tabs.service_button" "Button" "SafeMargin/Scroll/Content/SectionTabs/ServiceButton" + Add-Binding $bindings "settings.scroll.content.service_inventory_panel" "Control" "SafeMargin/Scroll/Content/ServiceInventoryPanel" + Add-Binding $bindings "settings.scroll.content.service_inventory_panel.margin.content.count" "Label" "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Count" + Add-Binding $bindings "settings.scroll.content.service_inventory_panel.margin.content.recount_button" "Button" "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/RecountButton" + Add-Binding $bindings "settings.scroll.content.service_inventory_panel.margin.content.manual_add_button" "Button" "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/ManualAddButton" + Add-Binding $bindings "settings.scroll.content.service_inventory_panel.margin.content.status" "Label" "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Status" + Add-Binding $bindings "settings.service_dialog_overlay" "Control" "SafeMargin/ServiceDialogOverlay" + Add-Binding $bindings "settings.service_dialog_overlay.dialog.margin.content.title" "Label" "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Title" + Add-Binding $bindings "settings.service_dialog_overlay.dialog.margin.content.description" "Label" "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Description" + Add-Binding $bindings "settings.service_dialog_overlay.dialog.margin.content.amount" "SpinBox" "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Amount" + Add-Binding $bindings "settings.service_dialog_overlay.dialog.margin.content.buttons.cancel_button" "Button" "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Buttons/CancelButton" + Add-Binding $bindings "settings.service_dialog_overlay.dialog.margin.content.buttons.confirm_button" "Button" "SafeMargin/ServiceDialogOverlay/Dialog/Margin/Content/Buttons/ConfirmButton" + + for ($index = 0; $index -lt 33; $index++) { + $suffix = $index.ToString("00") + $row = if ($index -lt 12) { 0 } elseif ($index -lt 23) { 1 } else { 2 } + Add-Binding $bindings "settings.keyboard_overlay.text_keyboard.content.letter_rows.row$row.key_$suffix" "Button" "SafeMargin/KeyboardOverlay/TextKeyboard/Content/LetterRows/Row$row/Key_$suffix" + } + + foreach ($action in @( + [ordered]@{ Id = "settings.keyboard_overlay.text_keyboard.content.header.hide_button"; Path = "SafeMargin/KeyboardOverlay/TextKeyboard/Content/Header/HideButton" }, + [ordered]@{ Id = "settings.keyboard_overlay.text_keyboard.content.primary_actions.language_button"; Path = "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/LanguageButton" }, + [ordered]@{ Id = "settings.keyboard_overlay.text_keyboard.content.primary_actions.shift_button"; Path = "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/ShiftButton" }, + [ordered]@{ Id = "settings.keyboard_overlay.text_keyboard.content.primary_actions.symbols_button"; Path = "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/SymbolsButton" }, + [ordered]@{ Id = "settings.keyboard_overlay.text_keyboard.content.primary_actions.backspace_button"; Path = "SafeMargin/KeyboardOverlay/TextKeyboard/Content/PrimaryActions/BackspaceButton" }, + [ordered]@{ Id = "settings.keyboard_overlay.text_keyboard.content.secondary_actions.caret_left_button"; Path = "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/CaretLeftButton" }, + [ordered]@{ Id = "settings.keyboard_overlay.text_keyboard.content.secondary_actions.caret_right_button"; Path = "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/CaretRightButton" }, + [ordered]@{ Id = "settings.keyboard_overlay.text_keyboard.content.secondary_actions.clear_button"; Path = "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/ClearButton" }, + [ordered]@{ Id = "settings.keyboard_overlay.text_keyboard.content.secondary_actions.space_button"; Path = "SafeMargin/KeyboardOverlay/TextKeyboard/Content/SecondaryActions/SpaceButton" } + )) { + Add-Binding $bindings $action.Id "Button" $action.Path + } + + for ($digit = 0; $digit -le 9; $digit++) { + Add-Binding $bindings "settings.keyboard_overlay.numeric_keyboard.content.grid.digit$digit" "Button" "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/Digit$digit" + } + foreach ($action in @( + [ordered]@{ Id = "settings.keyboard_overlay.numeric_keyboard.content.header.hide_button"; Path = "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Header/HideButton" }, + [ordered]@{ Id = "settings.keyboard_overlay.numeric_keyboard.content.grid.clear_button"; Path = "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/ClearButton" }, + [ordered]@{ Id = "settings.keyboard_overlay.numeric_keyboard.content.grid.backspace_button"; Path = "SafeMargin/KeyboardOverlay/NumericKeyboard/Content/Grid/BackspaceButton" } + )) { + Add-Binding $bindings $action.Id "Button" $action.Path + } + + Add-Binding $bindings "settings.scroll.content.bonus_panel.margin.content.rows.empty_state" "Label" "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/EmptyState" + for ($index = 0; $index -lt 16; $index++) { + $suffix = $index.ToString("00") + $id = "settings.scroll.content.bonus_panel.margin.content.rows.slot_$suffix" + $path = "SafeMargin/Scroll/Content/BonusPanel/Margin/Content/Rows/Slot$suffix" + Add-Binding $bindings $id "Control" $path + Add-Binding $bindings "$id.threshold" "SpinBox" "$path/Row/Threshold" + Add-Binding $bindings "$id.bonus" "SpinBox" "$path/Row/Bonus" + Add-Binding $bindings "$id.remove_button" "Button" "$path/Row/RemoveButton" + Add-Binding $bindings "$id.error" "Label" "$path/Error" + } + + Add-Binding $bindings "settings.scroll.content.card_options_panel.margin.content.preset_rows.empty_state" "Label" "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/EmptyState" + for ($index = 0; $index -lt 16; $index++) { + $suffix = $index.ToString("00") + $id = "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_$suffix" + $path = "SafeMargin/Scroll/Content/CardOptionsPanel/Margin/Content/PresetRows/Slot$suffix" + Add-Binding $bindings $id "Control" $path + Add-Binding $bindings "$id.amount" "SpinBox" "$path/Row/Amount" + Add-Binding $bindings "$id.remove_button" "Button" "$path/Row/RemoveButton" + Add-Binding $bindings "$id.error" "Label" "$path/Error" + } + + Add-Binding $bindings "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.empty_state" "Label" "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/EmptyState" + for ($index = 0; $index -lt 12; $index++) { + $suffix = $index.ToString("00") + $id = "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_$suffix" + $path = "SafeMargin/Scroll/Content/AdvertisementPlaylistPanel/Margin/Content/Rows/Slot$suffix" + Add-Binding $bindings $id "Control" $path + Add-Binding $bindings "$id.path" "LineEdit" "$path/Row/Path" + Add-Binding $bindings "$id.remove_button" "Button" "$path/Row/RemoveButton" + Add-Binding $bindings "$id.error" "Label" "$path/Error" + } + } + + foreach ($supplementalBindingId in $supplementalRuntimeBindingIds) { + if ($bindings.Contains($supplementalBindingId)) { + $bindings.Remove($supplementalBindingId) + } + } + + $screens += [ordered]@{ + BindingId = "screen.$($definition.Key)" + Key = $definition.Key + RootName = "ThemeScreen_$($definition.Pascal)" + Path = "res://exchanger_theme_dlc/scenes/screen_$($definition.Key).tscn" + Bindings = @($bindings.Values | Sort-Object Id) + } +} + +$catalog = [ordered]@{ + Format = "ExchangerThemeBindingContract" + Version = 2 + ScreenMetadataKey = "exchanger_screen_binding_id" + ElementMetadataKey = "exchanger_binding_id" + Source = "Immutable Exchanger ThemeView contract: host binds existing theme-owned nodes only" + Screens = $screens +} +$json = $catalog | ConvertTo-Json -Depth 12 + +if ($Check) { + if (-not (Test-Path -LiteralPath $OutputPath)) { + throw "Binding catalog is missing: $OutputPath" + } + $current = Get-Content -LiteralPath $OutputPath -Raw | ConvertFrom-Json + $expectedScreens = @($catalog.Screens) + $currentScreens = @($current.Screens) + if ($current.Format -ne $catalog.Format -or $current.Version -ne $catalog.Version -or $currentScreens.Count -ne $expectedScreens.Count) { + throw "Binding catalog header or screen count is out of date." + } + foreach ($expectedScreen in $expectedScreens) { + $currentScreen = @($currentScreens | Where-Object { $_.BindingId -eq $expectedScreen.BindingId }) + if ($currentScreen.Count -ne 1 -or @($currentScreen[0].Bindings).Count -ne @($expectedScreen.Bindings).Count) { + throw "Binding catalog screen '$($expectedScreen.BindingId)' is out of date." + } + foreach ($expectedBinding in $expectedScreen.Bindings) { + $actual = @($currentScreen[0].Bindings | Where-Object { $_.Id -eq $expectedBinding.Id }) + if ($actual.Count -ne 1 -or $actual[0].FallbackPath -ne $expectedBinding.FallbackPath -or (@($actual[0].Types) -join ",") -ne (@($expectedBinding.Types) -join ",")) { + throw "Binding '$($expectedBinding.Id)' is missing or out of date." + } + } + } + $builderRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + $authorityHash = (Get-FileHash -LiteralPath $OutputPath -Algorithm SHA256).Hash + foreach ($packageDirectory in Get-ChildItem -LiteralPath (Join-Path $builderRoot "themes") -Directory) { + $packageCatalog = Join-Path $packageDirectory.FullName "package\contracts\screen_bindings.v2.json" + if (-not (Test-Path -LiteralPath $packageCatalog)) { + throw "Packaged binding catalog is missing or out of date: $packageCatalog" + } + $packageHash = (Get-FileHash -LiteralPath $packageCatalog -Algorithm SHA256).Hash + if ($packageHash -ne $authorityHash) { + throw "Packaged binding catalog is missing or out of date: $packageCatalog" + } + } + Write-Host "Binding catalog matches the host sources." + return +} + +$outputDirectory = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +Set-Content -LiteralPath $OutputPath -Value $json -Encoding UTF8 +$builderRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +foreach ($themeDirectory in Get-ChildItem -LiteralPath (Join-Path $builderRoot "themes") -Directory) { + $packageRoot = Join-Path $themeDirectory.FullName "package" + if (-not (Test-Path -LiteralPath $packageRoot)) { + continue + } + $packageContractDirectory = Join-Path $packageRoot "contracts" + New-Item -ItemType Directory -Force -Path $packageContractDirectory | Out-Null + Copy-Item -LiteralPath $OutputPath -Destination (Join-Path $packageContractDirectory "screen_bindings.v2.json") -Force +} +Write-Host "Binding catalog written: $OutputPath" diff --git a/tools/Test-PackagedBindingContract.ps1 b/tools/Test-PackagedBindingContract.ps1 new file mode 100644 index 0000000..2f408e1 --- /dev/null +++ b/tools/Test-PackagedBindingContract.ps1 @@ -0,0 +1,147 @@ +param( + [string]$ThemeId = "*" +) + +$ErrorActionPreference = "Stop" +$builderRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$authorityPath = Join-Path $builderRoot "contracts\screen_bindings.v2.json" +if (-not (Test-Path -LiteralPath $authorityPath)) { + throw "The authoritative binding contract is missing: $authorityPath" +} + +$contract = Get-Content -LiteralPath $authorityPath -Raw | ConvertFrom-Json +if ($contract.Format -ne "ExchangerThemeBindingContract" -or $contract.Version -ne 2) { + throw "contracts\screen_bindings.v2.json has an unsupported format or version." +} +$screenCount = @($contract.Screens).Count +$bindingCount = @($contract.Screens | ForEach-Object { $_.Bindings }).Count +if ($screenCount -ne 9 -or $bindingCount -ne 481) { + throw "Binding contract v2 must contain 9 screens and 481 elements; found $screenCount/$bindingCount." +} + +$allBindings = @($contract.Screens | ForEach-Object { $_.Bindings }) +$bindingIds = @($allBindings | ForEach-Object { $_.Id }) +if (@($bindingIds | Select-Object -Unique).Count -ne 481) { + throw "Binding contract v2 contains duplicate element IDs." +} + +$requiredServiceBindings = [ordered]@{ + "settings.scroll.content.section_tabs.service_button" = "Button" + "settings.scroll.content.service_inventory_panel" = "Control" + "settings.scroll.content.service_inventory_panel.margin.content.count" = "Label" + "settings.scroll.content.service_inventory_panel.margin.content.hopper1_label" = "Label" + "settings.scroll.content.service_inventory_panel.margin.content.hopper2_label" = "Label" + "settings.scroll.content.service_inventory_panel.margin.content.recount_button" = "Button" + "settings.scroll.content.service_inventory_panel.margin.content.manual_add_button" = "Button" + "settings.scroll.content.service_inventory_panel.margin.content.status" = "Label" + "settings.service_dialog_overlay" = "Control" + "settings.service_dialog_overlay.dialog.margin.content.title" = "Label" + "settings.service_dialog_overlay.dialog.margin.content.description" = "Label" + "settings.service_dialog_overlay.dialog.margin.content.amount" = "SpinBox" + "settings.service_dialog_overlay.dialog.margin.content.buttons.cancel_button" = "Button" + "settings.service_dialog_overlay.dialog.margin.content.buttons.confirm_button" = "Button" +} +foreach ($entry in $requiredServiceBindings.GetEnumerator()) { + $binding = @($allBindings | Where-Object { $_.Id -eq $entry.Key }) + if ($binding.Count -ne 1 -or @($binding[0].Types) -notcontains $entry.Value) { + throw "Binding contract v2 is missing service binding '$($entry.Key)' with type '$($entry.Value)'." + } +} +$supplementalRuntimeBindings = @( + "home.scroll.content.advertisement_panel", + "home.scroll.content.stock_status.meter.empty", + "home.scroll.content.stock_status.meter.low", + "home.scroll.content.stock_status.meter.enough", + "home.scroll.content.stock_status.meter.much", + "home.scroll.content.stock_status.approximate_count", + "card_amount.content.reward_panel.content.bonus", + "cash_payment.content.banner_text", + "cash_payment.content.top_bar.home_navigation_button", + "card_amount.content.top_bar.home_navigation_button", + "card_custom_amount.content.top_bar.home_navigation_button", + "card_terminal.content.top_bar.home_navigation_button", + "success.content.home_navigation_button", + "error.content.home_navigation_button", + "service_access.content.home_navigation_button", + "settings.footer", + "settings.footer.save_and_exit_button", + "settings.scroll.content.footer_clearance", + "settings.scroll.content.service_inventory_panel.margin.content.description", + "settings.scroll.content.service_inventory_panel.margin.content.inventory_enabled", + "settings.scroll.content.service_inventory_panel.margin.content.purchase_limit_enabled", + "settings.scroll.content.menu_visibility_panel", + "settings.scroll.content.menu_visibility_panel.margin.content.show_stock_status", + "settings.scroll.content.menu_visibility_panel.margin.content.show_right_character", + "settings.scroll.content.menu_visibility_panel.margin.content.show_promotion_block", + "settings.scroll.content.menu_visibility_panel.margin.content.show_interactive_pet", + "settings.scroll.content.advertisement_poster_panel.margin.content.select_button", + "settings.scroll.content.animated_banner_texts_panel" +) +foreach ($supplementalRuntimeBinding in $supplementalRuntimeBindings) { + if ($bindingIds -contains $supplementalRuntimeBinding) { + throw "Supplemental runtime binding '$supplementalRuntimeBinding' must not change the immutable 481-element JSON contract." + } +} +$cardAmountValueBinding = @($allBindings | Where-Object { + $_.Id -eq "card_amount.content.reward_panel.content.value" +}) +if ($cardAmountValueBinding.Count -ne 1 -or @($cardAmountValueBinding[0].Types) -notcontains "Label") { + throw "The immutable CardAmount Value binding must remain a Label at its original ID/path." +} + +$requiredShortTextBindings = @( + "home.scroll.content.speech_text", + "settings.scroll.content.branding_panel.margin.content.short_text", + "settings.scroll.content.branding_panel.margin.content.short_text_error", + "settings.scroll.content.branding_preview_panel.margin.content.short_text" +) +foreach ($bindingId in $requiredShortTextBindings) { + if ($bindingIds -notcontains $bindingId) { + throw "Binding contract v2 is missing required short-text binding '$bindingId'." + } +} +if (@($bindingIds | Where-Object { $_ -like "*subtitle*" }).Count -ne 0) { + throw "Binding contract v2 must not restore deprecated subtitle bindings." +} + +$expectedFixedCollections = @{ + "card_amount.content.amount_panel.margin.content.preset_grid.slot_" = 16 + "settings.scroll.content.bonus_panel.margin.content.rows.slot_" = 80 + "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_" = 64 + "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_" = 48 +} +foreach ($entry in $expectedFixedCollections.GetEnumerator()) { + $actual = @($bindingIds | Where-Object { $_.StartsWith($entry.Key, [System.StringComparison]::Ordinal) }).Count + if ($actual -ne $entry.Value) { + throw "Fixed collection '$($entry.Key)' must expose $($entry.Value) bindings; found $actual." + } +} + +$authorityHash = (Get-FileHash -LiteralPath $authorityPath -Algorithm SHA256).Hash +$themeDirectories = Get-ChildItem -LiteralPath (Join-Path $builderRoot "themes") -Directory | Where-Object { + $ThemeId -eq "*" -or $_.Name -eq $ThemeId +} +if (@($themeDirectories).Count -eq 0) { + throw "Theme '$ThemeId' was not found in themes/." +} + +foreach ($themeDirectory in $themeDirectories) { + $manifestPath = Join-Path $themeDirectory.FullName "package\manifest.json" + $packageContractPath = Join-Path $themeDirectory.FullName "package\contracts\screen_bindings.v2.json" + if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "Manifest for theme '$($themeDirectory.Name)' is missing: $manifestPath" + } + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if ($manifest.Format -ne "ExchangerThemeDlc" -or $manifest.FormatVersion -ne 2) { + throw "Theme '$($themeDirectory.Name)' does not use ExchangerThemeDlc v2." + } + if (-not (Test-Path -LiteralPath $packageContractPath)) { + throw "Binding contract copy for theme '$($themeDirectory.Name)' is missing." + } + $packageHash = (Get-FileHash -LiteralPath $packageContractPath -Algorithm SHA256).Hash + if ($packageHash -ne $authorityHash) { + throw "Binding contract copy for theme '$($themeDirectory.Name)' differs from contracts\screen_bindings.v2.json." + } +} + +Write-Host "Binding contract copies are synchronized: $(@($themeDirectories).Count) theme(s), SHA-256 $authorityHash." diff --git a/tools/preview/BindingOverlay.gd b/tools/preview/BindingOverlay.gd new file mode 100644 index 0000000..bbfd865 --- /dev/null +++ b/tools/preview/BindingOverlay.gd @@ -0,0 +1,76 @@ +extends Control + +const ELEMENT_META_KEY := &"exchanger_binding_id" +const SCREEN_META_KEY := &"exchanger_screen_binding_id" +const LABEL_FONT_SIZE := 10 +const LABEL_HEIGHT := 22.0 +const LABEL_PADDING := 5.0 + +var target: Node + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_process(true) + + +func _process(_delta: float) -> void: + queue_redraw() + + +func _draw() -> void: + if target == null or not is_instance_valid(target): + return + for node in _all_nodes(target): + if (not node.has_meta(ELEMENT_META_KEY) and not node.has_meta(SCREEN_META_KEY)) or not node is CanvasItem: + continue + var canvas_item := node as CanvasItem + if not canvas_item.is_visible_in_tree(): + continue + var binding_id := str( + node.get_meta(ELEMENT_META_KEY) + if node.has_meta(ELEMENT_META_KEY) + else node.get_meta(SCREEN_META_KEY) + ) + var bounds := _bounds_for(canvas_item) + draw_rect(bounds, Color(1.0, 0.72, 0.08, 0.92), false, 2.0) + var label_width := minf(size.x - 8.0, maxf(80.0, binding_id.length() * 6.0 + LABEL_PADDING * 2.0)) + var label_rect := Rect2(bounds.position, Vector2(label_width, LABEL_HEIGHT)) + label_rect.position.x = clampf(label_rect.position.x, 4.0, size.x - label_width - 4.0) + if label_rect.position.y < 28.0: + label_rect.position.y = bounds.end.y + draw_rect(label_rect, Color(0.02, 0.06, 0.14, 0.94), true) + draw_rect(label_rect, Color(1.0, 0.72, 0.08, 1.0), false, 1.0) + draw_string( + ThemeDB.fallback_font, + label_rect.position + Vector2(LABEL_PADDING, 16.0), + binding_id, + HORIZONTAL_ALIGNMENT_LEFT, + label_width - LABEL_PADDING * 2.0, + LABEL_FONT_SIZE, + Color.WHITE + ) + + +func _bounds_for(item: CanvasItem) -> Rect2: + var inverse := get_global_transform().affine_inverse() + if item is Control: + var control := item as Control + var top_left := inverse * control.get_global_transform_with_canvas() * Vector2.ZERO + var bottom_right := inverse * control.get_global_transform_with_canvas() * control.size + return Rect2(top_left, bottom_right - top_left).abs() + if item is Node2D: + var point := inverse * (item as Node2D).get_global_transform_with_canvas().origin + return Rect2(point - Vector2(8.0, 8.0), Vector2(16.0, 16.0)) + return Rect2(Vector2.ZERO, Vector2(16.0, 16.0)) + + +func _all_nodes(root: Node) -> Array[Node]: + var result: Array[Node] = [root] + var cursor := 0 + while cursor < result.size(): + var current := result[cursor] + for child in current.get_children(): + result.append(child) + cursor += 1 + return result diff --git a/tools/preview/BindingOverlay.gd.uid b/tools/preview/BindingOverlay.gd.uid new file mode 100644 index 0000000..de92456 --- /dev/null +++ b/tools/preview/BindingOverlay.gd.uid @@ -0,0 +1 @@ +uid://4qqpq7ifmrua diff --git a/tools/preview/InteractivePetPreview.gd b/tools/preview/InteractivePetPreview.gd new file mode 100644 index 0000000..249bafd --- /dev/null +++ b/tools/preview/InteractivePetPreview.gd @@ -0,0 +1,504 @@ +extends Control + +const ACTIVE_STATE_PATH := "res://build/active-theme.json" +const CANONICAL_PACKAGE_ROOT := "res://exchanger_theme_dlc" +const PET_MANIFEST_FIELD := "InteractivePetScene" +const PET_COMPONENT_RELATIVE_PATH := "components/interactive_pet.tscn" +const LEGACY_PET_COMPONENT_RELATIVE_PATH := "components/alien_pet.tscn" +const GALLERY_SCENE_PATH := "res://tools/preview/preview.tscn" +const REQUIRED_PET_METHODS := [ + &"get_pet_animation_names", + &"get_pet_animation_frame_count", + &"has_pet_animation", + &"play_pet_animation", + &"pause_pet_animation", + &"stop_pet_animation", + &"get_current_pet_animation", + &"get_current_pet_frame", +] +const COMPOSITE_METHODS := [ + &"has_pet_composite_action", + &"start_pet_composite_action", + &"cancel_pet_composite_action", +] +const COMPOSITE_SIGNALS := [ + &"pet_composite_action_started", + &"pet_composite_action_finished", +] +const COMPOSITE_PREVIEW_ACTION := &"interact" +const COMPOSITE_PREVIEW_VIEWPORT := Rect2(0.0, 0.0, 720.0, 720.0) +const COMPOSITE_PREVIEW_SUPPORT_ID := "preview_highest_support" +const COMPOSITE_PREVIEW_SUPPORT := Vector2(360.0, 128.0) +const COMPOSITE_PREVIEW_START := Vector2(360.0, 620.0) + +@onready var stage: Control = %Stage +@onready var animation_name_label: Label = %AnimationName +@onready var frame_label: Label = %FrameCounter +@onready var pause_button: Button = %PauseButton +@onready var auto_button: Button = %AutoButton +@onready var composite_button: Button = %CompositeButton +@onready var animation_buttons: GridContainer = %AnimationButtons +@onready var animation_button_template: Button = %AnimationButtonTemplate +@onready var auto_timer: Timer = %AutoTimer + +var pet: Node2D +var _animation_names: Array[StringName] = [] +var _current_index := 0 +var _paused := false +var _composite_runtime: PetCompositePreviewRuntime +var _composite_run_id := 0 +var _composite_display_scale := 1.0 +var _composite_display_origin := Vector2.ZERO + + +func _ready() -> void: + _connect_controls() + if not _instantiate_active_pet(): + return + _animation_names = _read_animation_names() + if _animation_names.is_empty(): + _show_error("Компонент питомца не содержит доступных анимаций.") + return + _build_animation_buttons() + _center_pet() + _configure_composite_preview() + var initial_name := StringName(str(pet.call(&"get_current_pet_animation"))) + _current_index = maxi(0, _animation_names.find(initial_name)) + _play_current_animation() + + +func _process(_delta: float) -> void: + if not is_instance_valid(pet): + set_process(false) + return + if _animation_names.is_empty(): + frame_label.text = "КАДР: —" + return + var animation_name := StringName(str(pet.call(&"get_current_pet_animation"))) + var frame_count := int(pet.call(&"get_pet_animation_frame_count", animation_name)) + var current_frame := int(pet.call(&"get_current_pet_frame")) + frame_label.text = "КАДР: %d / %d" % [current_frame + 1, frame_count] + + +func _unhandled_key_input(event: InputEvent) -> void: + if not event is InputEventKey or not event.pressed or event.echo: + return + match event.keycode: + KEY_LEFT: + _select_relative(-1) + KEY_RIGHT: + _select_relative(1) + KEY_SPACE: + _toggle_pause() + KEY_R: + _play_current_animation() + KEY_ESCAPE: + _return_to_gallery() + return + _: + return + var viewport := get_viewport() + if viewport != null: + viewport.set_input_as_handled() + + +func _connect_controls() -> void: + stage.resized.connect(_center_pet) + animation_buttons.resized.connect(_resize_animation_buttons) + %GalleryButton.pressed.connect(_return_to_gallery) + %PreviousButton.pressed.connect(_select_relative.bind(-1)) + %NextButton.pressed.connect(_select_relative.bind(1)) + pause_button.pressed.connect(_toggle_pause) + %ReplayButton.pressed.connect(_play_current_animation) + auto_button.toggled.connect(_on_auto_toggled) + composite_button.pressed.connect(_start_composite_preview) + auto_timer.timeout.connect(_select_relative.bind(1)) + + +func _instantiate_active_pet() -> bool: + var source_package := _active_package_path() + var component_path := _resolve_pet_component_path(source_package) + if component_path.is_empty(): + _show_error("В активной теме не задан компонент интерактивного питомца.") + return false + var packed_scene := load(component_path) as PackedScene + if packed_scene == null: + _show_error("Не удалось загрузить компонент питомца: %s" % component_path) + return false + var instance := packed_scene.instantiate() + if not instance is Node2D: + instance.queue_free() + _show_error("Корень компонента питомца должен наследовать Node2D.") + return false + pet = instance as Node2D + for method_name in REQUIRED_PET_METHODS: + if not pet.has_method(method_name): + pet.queue_free() + pet = null + _show_error("У компонента питомца отсутствует метод %s()." % method_name) + return false + if not pet.has_signal(&"pet_animation_completed"): + pet.queue_free() + pet = null + _show_error("У компонента питомца отсутствует сигнал pet_animation_completed.") + return false + stage.add_child(pet) + pet.connect(&"pet_animation_completed", _on_pet_animation_completed) + if pet.has_signal(&"pet_composite_action_finished"): + pet.connect(&"pet_composite_action_finished", _on_composite_action_finished) + return true + + +func _read_animation_names() -> Array[StringName]: + var result: Array[StringName] = [] + var raw_names = pet.call(&"get_pet_animation_names") + if not raw_names is Array: + return result + for raw_name in raw_names: + var animation_name := StringName(str(raw_name)) + if not animation_name.is_empty() and not result.has(animation_name): + result.append(animation_name) + return result + + +func _build_animation_buttons() -> void: + var group := ButtonGroup.new() + for animation_name in _animation_names: + var button := animation_button_template.duplicate() as Button + button.name = "Animation_%s" % _safe_node_name(animation_name) + button.visible = true + button.text = str(animation_name) + button.button_group = group + button.set_meta(&"animation_name", animation_name) + button.pressed.connect(_select_animation.bind(animation_name)) + animation_buttons.add_child(button) + animation_button_template.visible = false + _resize_animation_buttons() + + +func _resize_animation_buttons() -> void: + var separation := float(animation_buttons.get_theme_constant("h_separation")) + var column_width := floorf((animation_buttons.size.x - separation) * 0.5) + if column_width <= 0.0: + return + for child in animation_buttons.get_children(): + if child is Button: + child.custom_minimum_size.x = column_width + + +func _center_pet() -> void: + if not is_instance_valid(pet): + return + if _is_composite_preview_active(): + _layout_composite_preview() + return + var frame_size := Vector2(128.0, 128.0) + if pet.has_method(&"get_pet_preview_size"): + var reported_size = pet.call(&"get_pet_preview_size") + if reported_size is Vector2 and reported_size.x > 0.0 and reported_size.y > 0.0: + frame_size = reported_size + var min_visual_offset := Vector2.ZERO + var max_visual_offset := Vector2.ZERO + if pet.has_method(&"get_pet_animation_visual_offset"): + for animation_name in _animation_names: + var raw_offset = pet.call(&"get_pet_animation_visual_offset", animation_name) + if raw_offset is Vector2: + min_visual_offset.x = minf(min_visual_offset.x, raw_offset.x) + min_visual_offset.y = minf(min_visual_offset.y, raw_offset.y) + max_visual_offset.x = maxf(max_visual_offset.x, raw_offset.x) + max_visual_offset.y = maxf(max_visual_offset.y, raw_offset.y) + var preview_envelope_size := frame_size + max_visual_offset - min_visual_offset + var available_size := stage.size + if available_size.x <= 0.0 or available_size.y <= 0.0: + return + var preview_scale := minf(4.0, minf( + available_size.x / preview_envelope_size.x, + available_size.y / preview_envelope_size.y + )) + preview_scale = maxf(preview_scale, 0.01) + pet.scale = Vector2.ONE * preview_scale + pet.position = ( + (available_size - preview_envelope_size * preview_scale) * 0.5 + - min_visual_offset * preview_scale + ) + + +func _select_relative(offset: int) -> void: + if _animation_names.is_empty(): + return + _current_index = wrapi(_current_index + offset, 0, _animation_names.size()) + _play_current_animation() + + +func _select_animation(animation_name: StringName) -> void: + var index := _animation_names.find(animation_name) + if index < 0: + return + _current_index = index + _play_current_animation() + + +func _play_current_animation() -> void: + if _animation_names.is_empty() or not is_instance_valid(pet): + return + _cancel_composite_preview(&"manual_animation") + var animation_name := _animation_names[_current_index] + if not bool(pet.call(&"play_pet_animation", animation_name)): + _show_error("Компонент отклонил анимацию %s." % animation_name) + return + _paused = false + pause_button.text = "ПАУЗА" + _update_animation_info(animation_name) + _update_animation_buttons(animation_name) + _schedule_auto_advance(animation_name) + + +func _toggle_pause() -> void: + if _animation_names.is_empty() or not is_instance_valid(pet): + return + _paused = not _paused + if _paused: + pet.call(&"pause_pet_animation") + auto_timer.paused = true + pause_button.text = "ПРОДОЛЖИТЬ" + else: + pet.call(&"play_pet_animation", _animation_names[_current_index], false) + auto_timer.paused = false + pause_button.text = "ПАУЗА" + + +func _on_auto_toggled(enabled: bool) -> void: + auto_button.text = "АВТОПОКАЗ: ВКЛ" if enabled else "АВТОПОКАЗ: ВЫКЛ" + if enabled: + _play_current_animation() + else: + auto_timer.stop() + + +func _configure_composite_preview() -> void: + var supported := true + for method_name in COMPOSITE_METHODS: + supported = supported and pet.has_method(method_name) + for signal_name in COMPOSITE_SIGNALS: + supported = supported and pet.has_signal(signal_name) + supported = supported and bool( + pet.call(&"has_pet_composite_action", COMPOSITE_PREVIEW_ACTION) + ) if supported else false + composite_button.disabled = not supported + composite_button.tooltip_text = ( + "Запустить составное действие interact через тестовый runtime host." + if supported + else "Активная тема не предоставляет составное действие interact." + ) + + +func _start_composite_preview() -> void: + if composite_button.disabled or not is_instance_valid(pet): + return + _cancel_composite_preview(&"restarted") + auto_button.set_pressed_no_signal(false) + _on_auto_toggled(false) + _composite_run_id += 1 + _composite_runtime = PetCompositePreviewRuntime.new( + _composite_run_id, + COMPOSITE_PREVIEW_VIEWPORT, + COMPOSITE_PREVIEW_START, + COMPOSITE_PREVIEW_SUPPORT_ID, + COMPOSITE_PREVIEW_SUPPORT + ) + _composite_runtime.anchor_changed.connect(_apply_composite_anchor) + _layout_composite_preview() + _apply_composite_anchor(COMPOSITE_PREVIEW_START) + var returned_run_id := int(pet.call( + &"start_pet_composite_action", + COMPOSITE_PREVIEW_ACTION, + _composite_runtime, + {"action_repetitions": 3, "reduced_effects": false} + )) + if returned_run_id != _composite_run_id: + _cancel_composite_preview(&"start_rejected") + _show_error("Тема отклонила запуск составного действия interact.") + return + composite_button.disabled = true + animation_name_label.text = "СОСТАВНОЕ ДЕЙСТВИЕ: interact" + + +func _layout_composite_preview() -> void: + var available_size := stage.size + if available_size.x <= 0.0 or available_size.y <= 0.0: + return + _composite_display_scale = minf( + available_size.x / COMPOSITE_PREVIEW_VIEWPORT.size.x, + available_size.y / COMPOSITE_PREVIEW_VIEWPORT.size.y + ) + _composite_display_origin = ( + available_size - COMPOSITE_PREVIEW_VIEWPORT.size * _composite_display_scale + ) * 0.5 + if _composite_runtime != null: + _apply_composite_anchor(_composite_runtime.GetAnchor()) + + +func _apply_composite_anchor(anchor: Vector2) -> void: + if not is_instance_valid(pet): + return + var runtime_scale := 1.0 + var ground_offset := Vector2.ZERO + if pet.has_method(&"get_pet_runtime_scale"): + runtime_scale = float(pet.call(&"get_pet_runtime_scale")) + if pet.has_method(&"get_pet_ground_offset"): + var reported_offset: Variant = pet.call(&"get_pet_ground_offset") + if reported_offset is Vector2: + ground_offset = reported_offset + pet.scale = Vector2.ONE * runtime_scale * _composite_display_scale + pet.position = _composite_display_origin + ( + anchor - ground_offset * runtime_scale + ) * _composite_display_scale + + +func _on_composite_action_finished( + action_name: StringName, + run_id: int, + completed: bool +) -> void: + if run_id != _composite_run_id or action_name != COMPOSITE_PREVIEW_ACTION: + return + if _composite_runtime != null: + _composite_runtime.invalidate() + _composite_runtime = null + composite_button.disabled = false + animation_name_label.text = ( + "СОСТАВНОЕ ДЕЙСТВИЕ ЗАВЕРШЕНО" + if completed + else "СОСТАВНОЕ ДЕЙСТВИЕ ОТМЕНЕНО" + ) + + +func _cancel_composite_preview(reason: StringName) -> void: + if not _is_composite_preview_active(): + return + pet.call(&"cancel_pet_composite_action", _composite_run_id, reason) + _composite_runtime.invalidate() + _composite_runtime = null + composite_button.disabled = false + + +func _is_composite_preview_active() -> bool: + return _composite_runtime != null and _composite_runtime.IsActive() + + +func _on_pet_animation_completed(animation_name: StringName) -> void: + if ( + auto_button.button_pressed + and not _animation_names.is_empty() + and animation_name == _animation_names[_current_index] + ): + _select_relative(1) + + +func _schedule_auto_advance(animation_name: StringName) -> void: + auto_timer.stop() + if not auto_button.button_pressed or not is_instance_valid(pet): + return + var is_looping := false + if pet.has_method(&"is_pet_animation_looping"): + is_looping = bool(pet.call(&"is_pet_animation_looping", animation_name)) + if not is_looping: + return + var frames_per_second := 0.0 + if pet.has_method(&"get_pet_animation_frames_per_second"): + frames_per_second = float( + pet.call(&"get_pet_animation_frames_per_second", animation_name) + ) + var frame_count := int(pet.call(&"get_pet_animation_frame_count", animation_name)) + var duration := float(frame_count) / maxf(frames_per_second, 0.01) + auto_timer.start(maxf(duration, 1.0)) + + +func _update_animation_info(animation_name: StringName) -> void: + var frame_count := int(pet.call(&"get_pet_animation_frame_count", animation_name)) + var mode := "РЕЖИМ НЕ УКАЗАН" + if pet.has_method(&"is_pet_animation_looping"): + mode = "ЦИКЛ" if bool( + pet.call(&"is_pet_animation_looping", animation_name) + ) else "ОДИН РАЗ" + animation_name_label.text = "АНИМАЦИЯ: %s • %d КАДРОВ • %s" % [ + animation_name, + frame_count, + mode, + ] + + +func _update_animation_buttons(animation_name: StringName) -> void: + for child in animation_buttons.get_children(): + if child is Button and child.has_meta(&"animation_name"): + (child as Button).set_pressed_no_signal( + StringName(child.get_meta(&"animation_name")) == animation_name + ) + + +func _active_package_path() -> String: + var state := _load_json(ACTIVE_STATE_PATH) + return str(state.get("Package", "")).replace("\\", "/") + + +func _resolve_pet_component_path(package_path: String) -> String: + if package_path.is_empty(): + return "" + var package_res_root := ProjectSettings.localize_path(package_path).replace("\\", "/") + if not package_res_root.begins_with("res://"): + return "" + var manifest := _load_json(package_path.path_join("manifest.json")) + var declared_path := str(manifest.get(PET_MANIFEST_FIELD, "")) + var candidates := PackedStringArray() + if not declared_path.is_empty(): + var declared_relative := _package_relative_path(declared_path) + if not declared_relative.is_empty(): + candidates.append(declared_relative) + candidates.append(PET_COMPONENT_RELATIVE_PATH) + candidates.append(LEGACY_PET_COMPONENT_RELATIVE_PATH) + for relative_path in candidates: + var candidate := package_res_root.path_join(relative_path) + if ResourceLoader.exists(candidate): + return candidate + return "" + + +func _package_relative_path(resource_path: String) -> String: + var normalized := resource_path.replace("\\", "/") + if normalized.contains(".."): + return "" + if normalized.begins_with(CANONICAL_PACKAGE_ROOT + "/"): + return normalized.trim_prefix(CANONICAL_PACKAGE_ROOT + "/") + if normalized.begins_with("res://"): + return "" + return normalized.trim_prefix("/") + + +func _load_json(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var value = JSON.parse_string(FileAccess.get_file_as_string(path)) + return value if value is Dictionary else {} + + +func _safe_node_name(animation_name: StringName) -> String: + var result := str(animation_name) + for character in ["/", "\\", ":", "@", "\"", "%"]: + result = result.replace(character, "_") + return result + + +func _show_error(message: String) -> void: + push_error("[Interactive Pet Preview] %s" % message) + animation_name_label.text = "ОШИБКА: %s" % message + frame_label.text = "КАДР: —" + pause_button.disabled = true + auto_button.disabled = true + composite_button.disabled = true + set_process(false) + + +func _return_to_gallery() -> void: + _cancel_composite_preview(&"leave_preview") + set_process(false) + get_tree().change_scene_to_file(GALLERY_SCENE_PATH) diff --git a/tools/preview/InteractivePetPreview.gd.uid b/tools/preview/InteractivePetPreview.gd.uid new file mode 100644 index 0000000..dfd22f6 --- /dev/null +++ b/tools/preview/InteractivePetPreview.gd.uid @@ -0,0 +1 @@ +uid://dfnm2i8jtxl6g diff --git a/tools/preview/PetCompositePreviewRuntime.gd b/tools/preview/PetCompositePreviewRuntime.gd new file mode 100644 index 0000000..5050820 --- /dev/null +++ b/tools/preview/PetCompositePreviewRuntime.gd @@ -0,0 +1,132 @@ +class_name PetCompositePreviewRuntime +extends RefCounted + +signal anchor_changed(anchor: Vector2) + +var _run_id: int +var _viewport_rect: Rect2 +var _anchor: Vector2 +var _support_id: String +var _support_anchor: Vector2 +var _active := true +var _support_contacts := "solid" +var _screen_bounds := "clamp" +var _landing_surface_id := "" +var _wrap_margin := 0.0 +var _wrapped := false + + +func _init( + run_id: int, + viewport_rect: Rect2, + anchor: Vector2, + support_id: String, + support_anchor: Vector2 +) -> void: + _run_id = run_id + _viewport_rect = viewport_rect + _anchor = anchor + _support_id = support_id + _support_anchor = support_anchor + + +func GetRunId() -> int: + return _run_id + + +func IsActive() -> bool: + return _active + + +func GetViewportRect() -> Rect2: + return _viewport_rect + + +func GetAnchor() -> Vector2: + return _anchor + + +func GetHighestSupport() -> Dictionary: + return { + "accepted": _active, + "valid": _active, + "surface_id": _support_id, + "anchor": _support_anchor, + } + + +func SetMotionPolicy(policy: Dictionary) -> bool: + if not _active: + return false + _support_contacts = str(policy.get("support_contacts", _support_contacts)) + _screen_bounds = str(policy.get("screen_bounds", _screen_bounds)) + _landing_surface_id = str(policy.get("landing_surface_id", "")) + _wrap_margin = maxf(0.0, float(policy.get("wrap_margin", 0.0))) + if _screen_bounds == "wrap_vertical_once": + _wrapped = false + return true + + +func SetAnchor(anchor: Vector2) -> Dictionary: + if not _active: + return {"accepted": false} + _anchor = anchor + anchor_changed.emit(_anchor) + return {"accepted": true, "anchor": _anchor} + + +func MoveAnchor(delta: Vector2) -> Dictionary: + if not _active: + return {"accepted": false} + + var previous := _anchor + var next := previous + delta + var wrapped_now := false + if ( + _screen_bounds == "wrap_vertical_once" + and not _wrapped + and next.y > _viewport_rect.end.y + _wrap_margin + ): + var overflow := next.y - (_viewport_rect.end.y + _wrap_margin) + next.y = _viewport_rect.position.y - _wrap_margin + overflow + _wrapped = true + wrapped_now = true + + var landed := false + if ( + _support_contacts == "after_vertical_wrap" + and _wrapped + and _landing_surface_id == _support_id + and delta.y >= 0.0 + and previous.y <= _support_anchor.y + and next.y >= _support_anchor.y + ): + next = _support_anchor + landed = true + + _anchor = next + anchor_changed.emit(_anchor) + return { + "accepted": true, + "anchor": _anchor, + "wrapped": wrapped_now, + "landed": landed, + "surface_id": _support_id if landed else "", + } + + +func LandOnSupport(surface_id: String) -> Dictionary: + if not _active or surface_id != _support_id: + return {"accepted": _active, "landed": false} + _anchor = _support_anchor + anchor_changed.emit(_anchor) + return { + "accepted": true, + "landed": true, + "anchor": _anchor, + "surface_id": _support_id, + } + + +func invalidate() -> void: + _active = false diff --git a/tools/preview/PetCompositePreviewRuntime.gd.uid b/tools/preview/PetCompositePreviewRuntime.gd.uid new file mode 100644 index 0000000..e8c26b3 --- /dev/null +++ b/tools/preview/PetCompositePreviewRuntime.gd.uid @@ -0,0 +1 @@ +uid://6anwfiii67gj diff --git a/tools/preview/ThemeGallery.gd b/tools/preview/ThemeGallery.gd new file mode 100644 index 0000000..5089c9f --- /dev/null +++ b/tools/preview/ThemeGallery.gd @@ -0,0 +1,308 @@ +extends Control + +const ACTIVE_STATE_PATH := "res://build/active-theme.json" +const CANONICAL_PACKAGE_ROOT := "res://exchanger_theme_dlc" +const PET_PREVIEW_SCENE_PATH := "res://tools/preview/interactive_pet_preview.tscn" +const PET_MANIFEST_FIELD := "InteractivePetScene" +const PET_COMPONENT_RELATIVE_PATH := "components/interactive_pet.tscn" +const LEGACY_PET_COMPONENT_RELATIVE_PATH := "components/alien_pet.tscn" +const POLL_INTERVAL := 0.35 +const RELOAD_DELAY := 0.45 +const SCREEN_ORDER := [ + ["HOME", "screen.home"], + ["CASH", "screen.cash_payment"], + ["SUM", "screen.card_amount"], + ["KEYPAD", "screen.card_custom_amount"], + ["CARD", "screen.card_terminal"], + ["SUCCESS", "screen.success"], + ["ERROR", "screen.error"], + ["SERVICE", "screen.service_access"], + ["SETTINGS", "screen.settings"], +] +const WATCHED_EXTENSIONS := { + "gd": true, "gdshader": true, "json": true, "material": true, + "res": true, "scn": true, "tres": true, "tscn": true, + "png": true, "jpg": true, "jpeg": true, "webp": true, "svg": true, + "ttf": true, "otf": true, "wav": true, "ogg": true, "mp3": true, + "ogv": true, +} +const TAB_META := &"full_scene_theme_preview_tab" +const OVERLAY_META := &"full_scene_theme_preview_overlay" +const KEYBOARD_PREVIEW_META := &"full_scene_theme_preview_keyboard" +const SERVICE_PREVIEW_META := &"full_scene_theme_preview_service" +const TAB_INDEX_META := &"preview_tab_index" +const TEXT_KEYBOARD_BINDING := "settings.keyboard_overlay.text_keyboard" +const NUMERIC_KEYBOARD_BINDING := "settings.keyboard_overlay.numeric_keyboard" +const SERVICE_PANEL_BINDING := "settings.scroll.content.service_inventory_panel" +const SERVICE_DIALOG_BINDING := "settings.service_dialog_overlay" + +@onready var screens: TabContainer = $Screens +@onready var binding_overlay: Control = $BindingOverlay +@onready var navigation: HBoxContainer = $TopBar/Navigation +@onready var overlay_toggle: Button = $TopBar/Navigation/OverlayToggle +@onready var keyboard_preview_toggle: Button = $KeyboardPreviewToggle +@onready var service_preview_toggle: Button = $ServicePreviewToggle +@onready var pet_preview_button: Button = $TopBar/Navigation/PetPreview + +var _source_package := "" +var _source_snapshot: Dictionary = {} +var _poll_elapsed := 0.0 +var _reload_elapsed := -1.0 +var _service_visibility_snapshots: Dictionary = {} + + +func _ready() -> void: + _source_package = _active_package_path() + var pet_component_path := _resolve_pet_component_path(_source_package) + pet_preview_button.disabled = pet_component_path.is_empty() + pet_preview_button.tooltip_text = ( + "Открыть просмотрщик анимаций питомца" + if not pet_preview_button.disabled + else "В активной теме не задан InteractivePetScene" + ) + _load_active_theme() + screens.tab_changed.connect(_on_tab_changed) + overlay_toggle.toggled.connect(_on_overlay_toggled) + keyboard_preview_toggle.pressed.connect(_on_keyboard_preview_pressed) + service_preview_toggle.pressed.connect(_on_service_preview_pressed) + pet_preview_button.pressed.connect(_open_pet_preview) + for child in navigation.get_children(): + if child is Button and child.has_meta(TAB_INDEX_META): + (child as Button).pressed.connect( + _on_page_button_pressed.bind(int(child.get_meta(TAB_INDEX_META))) + ) + var overlay_enabled := bool(get_tree().root.get_meta(OVERLAY_META, true)) + overlay_toggle.set_pressed_no_signal(overlay_enabled) + _on_overlay_toggled(overlay_enabled) + var selected := int(get_tree().root.get_meta(TAB_META, 0)) + screens.current_tab = clampi(selected, 0, maxi(0, screens.get_tab_count() - 1)) + _on_tab_changed(screens.current_tab) + if not _source_package.is_empty(): + _source_snapshot = _capture_snapshot(_source_package) + print("[Theme Preview] Watching %s" % _source_package) + + +func _process(delta: float) -> void: + if _source_package.is_empty(): + return + if _reload_elapsed >= 0.0: + _reload_elapsed -= delta + if _reload_elapsed <= 0.0: + get_tree().root.set_meta(TAB_META, screens.current_tab) + get_tree().reload_current_scene() + return + _poll_elapsed += delta + if _poll_elapsed < POLL_INTERVAL: + return + _poll_elapsed = 0.0 + var next_source := _capture_snapshot(_source_package) + var source_changes := _changed_paths(_source_snapshot, next_source) + _source_snapshot = next_source + if not source_changes.is_empty(): + _reload_elapsed = RELOAD_DELAY + + +func _load_active_theme() -> void: + if _source_package.is_empty(): + push_error("[Theme Preview] No active package is selected.") + return + var package_res_root := ProjectSettings.localize_path(_source_package).replace("\\", "/") + if not package_res_root.begins_with("res://"): + push_error("[Theme Preview] Active package must be inside this project: %s" % _source_package) + return + var manifest := _load_json(_source_package.path_join("manifest.json")) + var scene_map: Dictionary = manifest.get("ScreenScenes", {}) + for descriptor in SCREEN_ORDER: + var binding_id: String = descriptor[1] + var canonical_path := str(scene_map.get(binding_id, "")) + var relative_path := canonical_path.trim_prefix(CANONICAL_PACKAGE_ROOT).trim_prefix("/") + var scene_path := package_res_root.path_join(relative_path) + if not ResourceLoader.exists(scene_path): + push_error("[Theme Preview] Missing scene for %s: %s" % [binding_id, scene_path]) + continue + var packed := load(scene_path) as PackedScene + if packed == null: + continue + var screen := packed.instantiate() + screens.add_child(screen) + + +func _on_tab_changed(tab: int) -> void: + _update_page_buttons(tab) + if tab < 0 or tab >= screens.get_tab_count(): + binding_overlay.target = null + keyboard_preview_toggle.visible = false + service_preview_toggle.visible = false + return + var screen := screens.get_child(tab) + binding_overlay.target = screen + keyboard_preview_toggle.visible = ( + str(screen.get_meta(&"exchanger_screen_binding_id", "")) == "screen.settings" + ) + service_preview_toggle.visible = keyboard_preview_toggle.visible + _apply_service_preview_mode(screen) + _apply_keyboard_preview_mode(screen) + + +func _on_page_button_pressed(tab: int) -> void: + if tab >= 0 and tab < screens.get_tab_count(): + screens.current_tab = tab + + +func _open_pet_preview() -> void: + get_tree().change_scene_to_file(PET_PREVIEW_SCENE_PATH) + + +func _update_page_buttons(selected_tab: int) -> void: + for child in navigation.get_children(): + if child is Button and child.has_meta(TAB_INDEX_META): + (child as Button).set_pressed_no_signal( + int(child.get_meta(TAB_INDEX_META)) == selected_tab + ) + + +func _on_overlay_toggled(enabled: bool) -> void: + binding_overlay.visible = enabled + overlay_toggle.text = "BINDINGS: ON" if enabled else "BINDINGS: OFF" + get_tree().root.set_meta(OVERLAY_META, enabled) + + +func _on_keyboard_preview_pressed() -> void: + var mode := (int(get_tree().root.get_meta(KEYBOARD_PREVIEW_META, 0)) + 1) % 3 + get_tree().root.set_meta(KEYBOARD_PREVIEW_META, mode) + if screens.current_tab >= 0 and screens.current_tab < screens.get_tab_count(): + _apply_keyboard_preview_mode(screens.get_child(screens.current_tab)) + + +func _apply_keyboard_preview_mode(screen: Node) -> void: + var mode := int(get_tree().root.get_meta(KEYBOARD_PREVIEW_META, 0)) + var text_keyboard := _find_binding(screen, TEXT_KEYBOARD_BINDING) as CanvasItem + var numeric_keyboard := _find_binding(screen, NUMERIC_KEYBOARD_BINDING) as CanvasItem + if text_keyboard != null: + text_keyboard.visible = mode == 1 + if numeric_keyboard != null: + numeric_keyboard.visible = mode == 2 + keyboard_preview_toggle.text = ["KEYBOARD: OFF", "KEYBOARD: TEXT", "KEYBOARD: NUM"][mode] + + +func _on_service_preview_pressed() -> void: + var mode := (int(get_tree().root.get_meta(SERVICE_PREVIEW_META, 0)) + 1) % 3 + get_tree().root.set_meta(SERVICE_PREVIEW_META, mode) + if screens.current_tab >= 0 and screens.current_tab < screens.get_tab_count(): + _apply_service_preview_mode(screens.get_child(screens.current_tab)) + + +func _apply_service_preview_mode(screen: Node) -> void: + var panel := _find_binding(screen, SERVICE_PANEL_BINDING) as CanvasItem + var dialog := _find_binding(screen, SERVICE_DIALOG_BINDING) as CanvasItem + if panel == null or dialog == null: + service_preview_toggle.text = "INVENTORY: OFF" + return + + var screen_key := screen.get_instance_id() + if not _service_visibility_snapshots.has(screen_key): + var snapshot: Dictionary = {} + for child in panel.get_parent().get_children(): + if child is PanelContainer and child.name != &"Header": + snapshot[child] = child.visible + _service_visibility_snapshots[screen_key] = snapshot + + var mode := int(get_tree().root.get_meta(SERVICE_PREVIEW_META, 0)) + var visibility_snapshot: Dictionary = _service_visibility_snapshots[screen_key] + for node in visibility_snapshot: + if is_instance_valid(node): + (node as CanvasItem).visible = bool(visibility_snapshot[node]) if mode == 0 else false + + panel.visible = mode > 0 + dialog.visible = mode == 2 + var scroll := _find_binding(screen, "settings.scroll") as ScrollContainer + if scroll != null and mode > 0: + scroll.scroll_vertical = 0 + service_preview_toggle.text = ["INVENTORY: OFF", "INVENTORY: PANEL", "INVENTORY: DIALOG"][mode] + + +func _find_binding(root: Node, binding_id: String) -> Node: + var pending: Array[Node] = [root] + var cursor := 0 + while cursor < pending.size(): + var current := pending[cursor] + cursor += 1 + if str(current.get_meta(&"exchanger_binding_id", "")) == binding_id: + return current + for child in current.get_children(): + pending.append(child) + return null + + +func _active_package_path() -> String: + var state := _load_json(ACTIVE_STATE_PATH) + var package_path := str(state.get("Package", "")) + return package_path.replace("\\", "/") + + +func _resolve_pet_component_path(package_path: String) -> String: + if package_path.is_empty(): + return "" + var package_res_root := ProjectSettings.localize_path(package_path).replace("\\", "/") + if not package_res_root.begins_with("res://"): + return "" + var manifest := _load_json(package_path.path_join("manifest.json")) + var declared_path := str(manifest.get(PET_MANIFEST_FIELD, "")) + var candidates := PackedStringArray() + if not declared_path.is_empty(): + var declared_relative := _package_relative_path(declared_path) + if not declared_relative.is_empty(): + candidates.append(declared_relative) + candidates.append(PET_COMPONENT_RELATIVE_PATH) + candidates.append(LEGACY_PET_COMPONENT_RELATIVE_PATH) + for relative_path in candidates: + var candidate := package_res_root.path_join(relative_path) + if ResourceLoader.exists(candidate): + return candidate + return "" + + +func _package_relative_path(resource_path: String) -> String: + var normalized := resource_path.replace("\\", "/") + if normalized.contains(".."): + return "" + if normalized.begins_with(CANONICAL_PACKAGE_ROOT + "/"): + return normalized.trim_prefix(CANONICAL_PACKAGE_ROOT + "/") + if normalized.begins_with("res://"): + return "" + return normalized.trim_prefix("/") + + +func _capture_snapshot(root_path: String) -> Dictionary: + var result: Dictionary = {} + _scan_directory(root_path, result) + return result + + +func _scan_directory(path: String, result: Dictionary) -> void: + if not DirAccess.dir_exists_absolute(path): + return + for file_name in DirAccess.get_files_at(path): + if WATCHED_EXTENSIONS.has(file_name.get_extension().to_lower()): + var file_path := path.path_join(file_name) + result[file_path] = "%s:%s" % [FileAccess.get_modified_time(file_path), FileAccess.get_md5(file_path)] + for directory_name in DirAccess.get_directories_at(path): + _scan_directory(path.path_join(directory_name), result) + + +func _changed_paths(previous: Dictionary, current: Dictionary) -> PackedStringArray: + var result := PackedStringArray() + for path in current: + if not previous.has(path) or previous[path] != current[path]: + result.append(path) + for path in previous: + if not current.has(path): + result.append(path) + return result + + +func _load_json(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var value = JSON.parse_string(FileAccess.get_file_as_string(path)) + return value if value is Dictionary else {} diff --git a/tools/preview/ThemeGallery.gd.uid b/tools/preview/ThemeGallery.gd.uid new file mode 100644 index 0000000..ce91fd7 --- /dev/null +++ b/tools/preview/ThemeGallery.gd.uid @@ -0,0 +1 @@ +uid://1jw85fv3vsjb diff --git a/tools/preview/interactive_pet_preview.tscn b/tools/preview/interactive_pet_preview.tscn new file mode 100644 index 0000000..5b9a59f --- /dev/null +++ b/tools/preview/interactive_pet_preview.tscn @@ -0,0 +1,284 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://tools/preview/InteractivePetPreview.gd" id="1_preview_script"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_stage"] +content_margin_left = 8.0 +content_margin_top = 8.0 +content_margin_right = 8.0 +content_margin_bottom = 8.0 +bg_color = Color(0.00392157, 0.027451, 0.0745098, 0.94) +border_width_left = 3 +border_width_top = 3 +border_width_right = 3 +border_width_bottom = 3 +border_color = Color(0.419608, 0.858824, 0.309804, 1) +corner_radius_top_left = 16 +corner_radius_top_right = 16 +corner_radius_bottom_right = 16 +corner_radius_bottom_left = 16 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_normal"] +content_margin_left = 8.0 +content_margin_top = 6.0 +content_margin_right = 8.0 +content_margin_bottom = 6.0 +bg_color = Color(0.0156863, 0.0862745, 0.184314, 0.96) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.419608, 0.858824, 0.309804, 0.7) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"] +content_margin_left = 8.0 +content_margin_top = 6.0 +content_margin_right = 8.0 +content_margin_bottom = 6.0 +bg_color = Color(0.027451, 0.184314, 0.317647, 1) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.580392, 1, 0.45098, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_pressed"] +content_margin_left = 8.0 +content_margin_top = 6.0 +content_margin_right = 8.0 +content_margin_bottom = 6.0 +bg_color = Color(0.419608, 0.858824, 0.309804, 1) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.776471, 1, 0.694118, 1) +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 + +[node name="InteractivePetPreview" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +texture_filter = 1 +script = ExtResource("1_preview_script") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0, 0.0313725, 0.101961, 1) + +[node name="SafeMargin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 24.0 +offset_top = 20.0 +offset_right = -24.0 +offset_bottom = -20.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="VBoxContainer" parent="SafeMargin"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Header" type="HBoxContainer" parent="SafeMargin/Content"] +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="GalleryButton" type="Button" parent="SafeMargin/Content/Header"] +unique_name_in_owner = true +custom_minimum_size = Vector2(140, 44) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 14 +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +text = "← ГАЛЕРЕЯ" + +[node name="Title" type="Label" parent="SafeMargin/Content/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_font_sizes/font_size = 28 +text = "АНИМАЦИИ ПИТОМЦА" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="StagePanel" type="PanelContainer" parent="SafeMargin/Content"] +custom_minimum_size = Vector2(0, 530) +layout_mode = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_stage") + +[node name="Stage" type="Control" parent="SafeMargin/Content/StagePanel"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 512) +layout_mode = 2 +mouse_filter = 2 + +[node name="Status" type="HBoxContainer" parent="SafeMargin/Content"] +custom_minimum_size = Vector2(0, 34) +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="AnimationName" type="Label" parent="SafeMargin/Content/Status"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_font_sizes/font_size = 15 +text = "АНИМАЦИЯ: —" +vertical_alignment = 1 + +[node name="FrameCounter" type="Label" parent="SafeMargin/Content/Status"] +unique_name_in_owner = true +custom_minimum_size = Vector2(130, 0) +layout_mode = 2 +theme_override_colors/font_color = Color(0.580392, 1, 0.45098, 1) +theme_override_font_sizes/font_size = 15 +text = "КАДР: —" +horizontal_alignment = 2 +vertical_alignment = 1 + +[node name="Transport" type="HBoxContainer" parent="SafeMargin/Content"] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="PreviousButton" type="Button" parent="SafeMargin/Content/Transport"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 15 +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +text = "НАЗАД" + +[node name="PauseButton" type="Button" parent="SafeMargin/Content/Transport"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 15 +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +text = "ПАУЗА" + +[node name="ReplayButton" type="Button" parent="SafeMargin/Content/Transport"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 15 +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +text = "ПОВТОР" + +[node name="NextButton" type="Button" parent="SafeMargin/Content/Transport"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 15 +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +text = "ДАЛЕЕ" + +[node name="AutoButton" type="Button" parent="SafeMargin/Content/Transport"] +unique_name_in_owner = true +custom_minimum_size = Vector2(164, 0) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 13 +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +theme_override_styles/hover_pressed = SubResource("StyleBoxFlat_button_pressed") +toggle_mode = true +text = "АВТОПОКАЗ: ВЫКЛ" + +[node name="CompositeButton" type="Button" parent="SafeMargin/Content"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 15 +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +text = "СОСТАВНОЕ ДЕЙСТВИЕ: INTERACT" + +[node name="AnimationScroll" type="ScrollContainer" parent="SafeMargin/Content"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="AnimationButtons" type="GridContainer" parent="SafeMargin/Content/AnimationScroll"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/h_separation = 8 +theme_override_constants/v_separation = 8 +columns = 2 + +[node name="AnimationButtonTemplate" type="Button" parent="SafeMargin/Content/AnimationScroll/AnimationButtons"] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 14 +theme_override_styles/normal = SubResource("StyleBoxFlat_button_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_button_pressed") +theme_override_styles/hover_pressed = SubResource("StyleBoxFlat_button_pressed") +toggle_mode = true +text = "animation" + +[node name="Help" type="Label" parent="SafeMargin/Content"] +custom_minimum_size = Vector2(0, 30) +layout_mode = 2 +theme_override_colors/font_color = Color(0.8, 0.878431, 1, 1) +theme_override_font_sizes/font_size = 13 +text = "←/→ — выбрать • ПРОБЕЛ — пауза • R — повтор • ESC — галерея" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="AutoTimer" type="Timer" parent="."] +unique_name_in_owner = true +one_shot = true diff --git a/tools/preview/preview.tscn b/tools/preview/preview.tscn new file mode 100644 index 0000000..66421ce --- /dev/null +++ b/tools/preview/preview.tscn @@ -0,0 +1,270 @@ +[gd_scene load_steps=8 format=3] + +[ext_resource type="Script" path="res://tools/preview/ThemeGallery.gd" id="1_gallery"] +[ext_resource type="Script" path="res://tools/preview/BindingOverlay.gd" id="2_overlay"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_top_bar"] +bg_color = Color(0.00392157, 0.027451, 0.0745098, 0.98) +border_width_bottom = 1 +border_color = Color(0.164706, 0.364706, 0.65098, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nav_normal"] +bg_color = Color(0.00392157, 0.027451, 0.0745098, 0.98) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nav_hover"] +bg_color = Color(0.0352941, 0.137255, 0.294118, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nav_pressed"] +bg_color = Color(0.458824, 0.823529, 0.364706, 1) + +[sub_resource type="ButtonGroup" id="ButtonGroup_pages"] + +[node name="ThemeGallery" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_gallery") + +[node name="Screens" type="TabContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = 28.0 +grow_horizontal = 2 +grow_vertical = 2 +tabs_visible = false +use_hidden_tabs_for_min_size = true + +[node name="BindingOverlay" type="Control" parent="."] +z_index = 100 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("2_overlay") + +[node name="TopBar" type="PanelContainer" parent="."] +z_index = 101 +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_bottom = 28.0 +grow_horizontal = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_top_bar") + +[node name="KeyboardPreviewToggle" type="Button" parent="."] +visible = false +z_index = 102 +layout_mode = 0 +offset_left = 4.0 +offset_top = 32.0 +offset_right = 174.0 +offset_bottom = 60.0 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +text = "KEYBOARD: OFF" + +[node name="ServicePreviewToggle" type="Button" parent="."] +visible = false +z_index = 102 +layout_mode = 0 +offset_left = 4.0 +offset_top = 64.0 +offset_right = 194.0 +offset_bottom = 92.0 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +text = "INVENTORY: OFF" + +[node name="Navigation" type="HBoxContainer" parent="TopBar"] +layout_mode = 2 +theme_override_constants/separation = 1 + +[node name="OverlayToggle" type="Button" parent="TopBar/Navigation"] +custom_minimum_size = Vector2(0, 27) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_stretch_ratio = 1.65 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +theme_override_styles/hover_pressed = SubResource("StyleBoxFlat_nav_pressed") +text = "BINDINGS: ON" +toggle_mode = true + +[node name="Home" type="Button" parent="TopBar/Navigation"] +metadata/preview_tab_index = 0 +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +theme_override_styles/hover_pressed = SubResource("StyleBoxFlat_nav_pressed") +toggle_mode = true +button_group = SubResource("ButtonGroup_pages") +text = "HOME" + +[node name="Cash" type="Button" parent="TopBar/Navigation"] +metadata/preview_tab_index = 1 +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +theme_override_styles/hover_pressed = SubResource("StyleBoxFlat_nav_pressed") +toggle_mode = true +button_group = SubResource("ButtonGroup_pages") +text = "CASH" + +[node name="Sum" type="Button" parent="TopBar/Navigation"] +metadata/preview_tab_index = 2 +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +theme_override_styles/hover_pressed = SubResource("StyleBoxFlat_nav_pressed") +toggle_mode = true +button_group = SubResource("ButtonGroup_pages") +text = "SUM" + +[node name="Keypad" type="Button" parent="TopBar/Navigation"] +metadata/preview_tab_index = 3 +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +theme_override_styles/hover_pressed = SubResource("StyleBoxFlat_nav_pressed") +toggle_mode = true +button_group = SubResource("ButtonGroup_pages") +text = "KEYPAD" + +[node name="Card" type="Button" parent="TopBar/Navigation"] +metadata/preview_tab_index = 4 +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +theme_override_styles/hover_pressed = SubResource("StyleBoxFlat_nav_pressed") +toggle_mode = true +button_group = SubResource("ButtonGroup_pages") +text = "CARD" + +[node name="Success" type="Button" parent="TopBar/Navigation"] +metadata/preview_tab_index = 5 +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +theme_override_styles/hover_pressed = SubResource("StyleBoxFlat_nav_pressed") +toggle_mode = true +button_group = SubResource("ButtonGroup_pages") +text = "SUCCESS" + +[node name="Error" type="Button" parent="TopBar/Navigation"] +metadata/preview_tab_index = 6 +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +theme_override_styles/hover_pressed = SubResource("StyleBoxFlat_nav_pressed") +toggle_mode = true +button_group = SubResource("ButtonGroup_pages") +text = "ERROR" + +[node name="Service" type="Button" parent="TopBar/Navigation"] +metadata/preview_tab_index = 7 +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +theme_override_styles/hover_pressed = SubResource("StyleBoxFlat_nav_pressed") +toggle_mode = true +button_group = SubResource("ButtonGroup_pages") +text = "SERVICE" + +[node name="Settings" type="Button" parent="TopBar/Navigation"] +metadata/preview_tab_index = 8 +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +theme_override_styles/hover_pressed = SubResource("StyleBoxFlat_nav_pressed") +toggle_mode = true +button_group = SubResource("ButtonGroup_pages") +text = "SETTINGS" + +[node name="PetPreview" type="Button" parent="TopBar/Navigation"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.92, 0.96, 1, 1) +theme_override_colors/font_hover_color = Color(1, 1, 1, 1) +theme_override_colors/font_pressed_color = Color(0.00392157, 0.0470588, 0.145098, 1) +theme_override_font_sizes/font_size = 11 +theme_override_styles/normal = SubResource("StyleBoxFlat_nav_normal") +theme_override_styles/hover = SubResource("StyleBoxFlat_nav_hover") +theme_override_styles/pressed = SubResource("StyleBoxFlat_nav_pressed") +text = "PET" diff --git a/tools/probe_pet_approach.gd.uid b/tools/probe_pet_approach.gd.uid new file mode 100644 index 0000000..18bd557 --- /dev/null +++ b/tools/probe_pet_approach.gd.uid @@ -0,0 +1 @@ +uid://bs0apv617wblm diff --git a/tools/qa_theme_manager_dialogs.gd.uid b/tools/qa_theme_manager_dialogs.gd.uid new file mode 100644 index 0000000..e854ff6 --- /dev/null +++ b/tools/qa_theme_manager_dialogs.gd.uid @@ -0,0 +1 @@ +uid://ceg323226xtfl diff --git a/tools/qa_theme_manager_plugin.gd.uid b/tools/qa_theme_manager_plugin.gd.uid new file mode 100644 index 0000000..0023725 --- /dev/null +++ b/tools/qa_theme_manager_plugin.gd.uid @@ -0,0 +1 @@ +uid://c62ukbn80gudy diff --git a/tools/validate_theme.gd b/tools/validate_theme.gd new file mode 100644 index 0000000..65c93a9 --- /dev/null +++ b/tools/validate_theme.gd @@ -0,0 +1,909 @@ +extends SceneTree + +const DEFAULT_CONTRACT := "res://contracts/screen_bindings.v2.json" +const MANIFEST_PATH := "res://exchanger_theme_dlc/manifest.json" +const PACKAGED_CONTRACT_PATH := "res://exchanger_theme_dlc/contracts/screen_bindings.v2.json" +const SELF_CONTAINED_VISUALS_META := &"exchanger_self_contained_visuals" +const CANONICAL_PACKAGE_ROOT := "res://exchanger_theme_dlc/" +const INTERACTIVE_PET_MANIFEST_FIELD := "InteractivePetScene" +const INTERACTIVE_PET_METHODS := [ + &"get_pet_animation_names", + &"get_pet_animation_frame_count", + &"has_pet_animation", + &"play_pet_animation", + &"pause_pet_animation", + &"stop_pet_animation", + &"get_current_pet_animation", + &"get_current_pet_frame", +] +const INTERACTIVE_PET_COMPOSITE_METHODS := [ + &"has_pet_composite_action", + &"start_pet_composite_action", + &"cancel_pet_composite_action", +] +const INTERACTIVE_PET_COMPOSITE_SIGNALS := [ + &"pet_composite_action_started", + &"pet_composite_action_finished", +] +const INTERACTIVE_PET_ACTIONS := [ + &"idle", + &"sit", + &"interact", + &"walk_left", + &"walk_right", + &"walk_start_left", + &"walk_start_right", + &"walk_finish_left", + &"walk_finish_right", + &"climb_start", + &"climb_up", + &"climb_down", + &"climb_finish", + &"drag", + &"drop", + &"landing", + &"celebrate", +] +const PET_NAVIGATION_ROOT_META := &"exchanger_pet_navigation_root" +const PET_SURFACE_META := &"exchanger_pet_surface" +const PET_SURFACE_VISIBILITY_BINDING_META := &"exchanger_pet_surface_visibility_binding_id" +const PET_CLIMB_EDGE_META := &"exchanger_pet_climb_edge" +const IMMUTABLE_BINDING_COUNT := 481 +const SUPPLEMENTAL_RUNTIME_BINDINGS := { + "screen.home": { + "home.scroll.content.advertisement_panel": ["Control"], + "home.scroll.content.stock_status.meter.empty": ["Control"], + "home.scroll.content.stock_status.meter.low": ["Control"], + "home.scroll.content.stock_status.meter.enough": ["Control"], + "home.scroll.content.stock_status.meter.much": ["Control"], + "home.scroll.content.stock_status.approximate_count": ["Label"], + }, + "screen.card_amount": { + "card_amount.content.reward_panel.content.bonus": ["Label"], + "card_amount.content.top_bar.home_navigation_button": ["Button"], + }, + "screen.cash_payment": { + "cash_payment.content.top_bar.home_navigation_button": ["Button"], + }, + "screen.card_custom_amount": { + "card_custom_amount.content.top_bar.home_navigation_button": ["Button"], + }, + "screen.card_terminal": { + "card_terminal.content.top_bar.home_navigation_button": ["Button"], + }, + "screen.success": { + "success.content.home_navigation_button": ["Button"], + }, + "screen.error": { + "error.content.home_navigation_button": ["Button"], + }, + "screen.service_access": { + "service_access.content.home_navigation_button": ["Button"], + }, + "screen.settings": { + "settings.footer": ["PanelContainer"], + "settings.footer.save_and_exit_button": ["Button"], + "settings.scroll.content.footer_clearance": ["Control"], + "settings.scroll.content.service_inventory_panel.margin.content.description": ["Label"], + "settings.scroll.content.service_inventory_panel.margin.content.inventory_enabled": ["CheckButton"], + "settings.scroll.content.service_inventory_panel.margin.content.purchase_limit_enabled": ["CheckButton"], + "settings.scroll.content.menu_visibility_panel": ["PanelContainer"], + "settings.scroll.content.menu_visibility_panel.margin.content.show_stock_status": ["CheckButton"], + "settings.scroll.content.menu_visibility_panel.margin.content.show_right_character": ["CheckButton"], + "settings.scroll.content.menu_visibility_panel.margin.content.show_promotion_block": ["CheckButton"], + }, +} +const OPTIONAL_SUPPLEMENTAL_RUNTIME_BINDINGS := { + "screen.cash_payment": { + "cash_payment.content.banner_text": ["Label"], + }, + "screen.home": { + "home.decoration.custom_text_block": ["Control"], + "home.decoration.right_character": ["TextureRect"], + }, + "screen.settings": { + "settings.scroll.content.animated_banner_texts_panel": ["Control"], + "settings.scroll.content.advertisement_poster_panel.margin.content.select_button": ["Button"], + "settings.scroll.content.menu_visibility_panel.margin.content.show_interactive_pet": ["CheckButton"], + }, +} +const HOME_PET_SURFACE_VISIBILITY_BINDINGS := { + &"stock_window": "home.scroll.content.stock_status", + &"advertisement_window": "home.scroll.content.advertisement_panel", +} + +var _errors: PackedStringArray = [] + + +func _init() -> void: + call_deferred("_validate") + + +func _validate() -> void: + var contract_path := _argument_value("--contract", DEFAULT_CONTRACT) + var contract := _load_json(contract_path) + _expect(FileAccess.file_exists(PACKAGED_CONTRACT_PATH), "Packaged binding catalog is missing: %s" % PACKAGED_CONTRACT_PATH) + if FileAccess.file_exists(PACKAGED_CONTRACT_PATH): + _expect( + FileAccess.get_sha256(contract_path) == FileAccess.get_sha256(PACKAGED_CONTRACT_PATH), + "Packaged binding catalog differs from the builder authority" + ) + var manifest := _load_json(MANIFEST_PATH) + if contract.is_empty() or manifest.is_empty(): + _finish() + return + + var binding_count := 0 + for screen_value in contract.get("Screens", []): + binding_count += (screen_value as Dictionary).get("Bindings", []).size() + _expect( + binding_count == IMMUTABLE_BINDING_COUNT, + "immutable ThemeView contract must contain %d bindings; found %d" + % [IMMUTABLE_BINDING_COUNT, binding_count] + ) + + _expect(manifest.get("Format") == "ExchangerThemeDlc", "manifest Format must be ExchangerThemeDlc") + _expect(int(manifest.get("FormatVersion", 0)) == 2, "manifest FormatVersion must be 2") + _expect( + str(manifest.get("BackgroundTexturePath", "")).is_empty(), + "self-contained themes must keep manifest BackgroundTexturePath empty" + ) + _expect( + str(manifest.get("BackgroundDecorationPath", "")).is_empty(), + "self-contained themes must keep manifest BackgroundDecorationPath empty" + ) + _validate_interactive_pet(manifest) + var has_interactive_pet := not str(manifest.get(INTERACTIVE_PET_MANIFEST_FIELD, "")).is_empty() + + var screen_map: Dictionary = manifest.get("ScreenScenes", {}) + var global_bindings: Dictionary = {} + for screen_value in contract.get("Screens", []): + var screen: Dictionary = screen_value + var screen_id := str(screen.get("BindingId", "")) + var expected_path := str(screen.get("Path", "")) + var actual_path := str(screen_map.get(screen_id, "")) + _expect(actual_path == expected_path, "%s must map to %s" % [screen_id, expected_path]) + if actual_path != expected_path or not ResourceLoader.exists(actual_path): + _error("Scene is missing: %s" % expected_path) + continue + + var packed := load(actual_path) as PackedScene + if packed == null: + _error("Scene is not a PackedScene: %s" % actual_path) + continue + var root := packed.instantiate() + if root == null: + _error("Scene cannot be instantiated: %s" % actual_path) + continue + + _expect(root.name == StringName(str(screen.get("RootName", ""))), "%s root must be named %s" % [screen_id, screen.get("RootName", "")]) + var screen_metadata_key := str(contract.get("ScreenMetadataKey", "exchanger_screen_binding_id")) + _expect(str(root.get_meta(screen_metadata_key, "")) == screen_id, "%s root metadata must equal its screen binding id" % screen_id) + _validate_self_contained_visuals( + root, + screen_id, + bool(manifest.get("UseNearestTextureFilter", false)) + ) + if has_interactive_pet and screen_id != "screen.settings": + _validate_pet_navigation(root, screen_id) + _validate_bindings(root, screen, global_bindings, str(contract.get("ElementMetadataKey", "exchanger_binding_id"))) + _validate_fixed_slot_defaults(root, screen_id, str(contract.get("ElementMetadataKey", "exchanger_binding_id"))) + root.free() + + for manifest_screen in screen_map: + if not _contract_has_screen(contract, str(manifest_screen)): + _error("Unknown ScreenScenes key: %s" % manifest_screen) + + _finish() + + +func _validate_interactive_pet(manifest: Dictionary) -> void: + var scene_path := str(manifest.get(INTERACTIVE_PET_MANIFEST_FIELD, "")) + if scene_path.is_empty(): + return + _expect( + scene_path.begins_with(CANONICAL_PACKAGE_ROOT), + "%s must point inside %s" % [INTERACTIVE_PET_MANIFEST_FIELD, CANONICAL_PACKAGE_ROOT] + ) + _expect(not scene_path.contains(".."), "%s must not contain '..'" % INTERACTIVE_PET_MANIFEST_FIELD) + _expect(scene_path.ends_with(".tscn"), "%s must point to a .tscn scene" % INTERACTIVE_PET_MANIFEST_FIELD) + if not scene_path.begins_with(CANONICAL_PACKAGE_ROOT) or not ResourceLoader.exists(scene_path): + _error("Interactive pet scene is missing: %s" % scene_path) + return + var packed_scene := load(scene_path) as PackedScene + if packed_scene == null: + _error("Interactive pet resource is not a PackedScene: %s" % scene_path) + return + var pet := packed_scene.instantiate() + if pet == null: + _error("Interactive pet scene cannot be instantiated: %s" % scene_path) + return + _expect(pet is Node2D, "interactive pet root must inherit Node2D") + for method_name in INTERACTIVE_PET_METHODS: + _expect(pet.has_method(method_name), "interactive pet must expose %s()" % method_name) + _expect(pet.has_signal(&"pet_animation_started"), "interactive pet must expose pet_animation_started") + _expect(pet.has_signal(&"pet_animation_completed"), "interactive pet must expose pet_animation_completed") + _validate_optional_pet_profile(pet) + pet.free() + + +func _validate_optional_pet_profile(pet: Node) -> void: + var animation_names: Array[StringName] = [] + var raw_animation_names = pet.call(&"get_pet_animation_names") + if raw_animation_names is Array: + for raw_name in raw_animation_names: + animation_names.append(StringName(str(raw_name))) + + if pet.has_method(&"get_pet_action_animation"): + for action_name in INTERACTIVE_PET_ACTIONS: + var animation_name := StringName(str(pet.call(&"get_pet_action_animation", action_name))) + if not animation_name.is_empty(): + _expect( + animation_names.has(animation_name), + "interactive pet action %s maps to unknown animation %s" % [action_name, animation_name] + ) + + _validate_optional_composite_pet_action(pet) + + _validate_optional_positive_float(pet, &"get_pet_runtime_scale") + _validate_optional_positive_float(pet, &"get_pet_walk_speed") + _validate_optional_positive_float(pet, &"get_pet_climb_speed") + + var reference_size := Vector2.ZERO + if pet.has_method(&"get_pet_hit_size"): + var hit_size = pet.call(&"get_pet_hit_size") + _expect( + hit_size is Vector2 + and _is_finite_vector2(hit_size) + and hit_size.x > 0.0 + and hit_size.y > 0.0, + "get_pet_hit_size() must return a finite positive Vector2" + ) + if hit_size is Vector2: + reference_size = hit_size + elif pet.has_method(&"get_pet_preview_size"): + var preview_size = pet.call(&"get_pet_preview_size") + if preview_size is Vector2: + reference_size = preview_size + + if pet.has_method(&"get_pet_ground_offset"): + var ground_offset = pet.call(&"get_pet_ground_offset") + _expect( + ground_offset is Vector2 and _is_finite_vector2(ground_offset), + "get_pet_ground_offset() must return a finite Vector2" + ) + if ground_offset is Vector2 and reference_size.x > 0.0 and reference_size.y > 0.0: + _expect( + ground_offset.x >= 0.0 + and ground_offset.y >= 0.0 + and ground_offset.x <= reference_size.x + and ground_offset.y <= reference_size.y, + "get_pet_ground_offset() must stay inside pet hit/preview size" + ) + + +func _validate_optional_composite_pet_action(pet: Node) -> void: + var exposed_count := 0 + for method_name in INTERACTIVE_PET_COMPOSITE_METHODS: + if pet.has_method(method_name): + exposed_count += 1 + for signal_name in INTERACTIVE_PET_COMPOSITE_SIGNALS: + if pet.has_signal(signal_name): + exposed_count += 1 + if exposed_count == 0: + return + + for method_name in INTERACTIVE_PET_COMPOSITE_METHODS: + _expect( + pet.has_method(method_name), + "partial composite pet API: missing %s()" % method_name + ) + for signal_name in INTERACTIVE_PET_COMPOSITE_SIGNALS: + _expect( + pet.has_signal(signal_name), + "partial composite pet API: missing signal %s" % signal_name + ) + if exposed_count != INTERACTIVE_PET_COMPOSITE_METHODS.size() + INTERACTIVE_PET_COMPOSITE_SIGNALS.size(): + return + var supported = pet.call(&"has_pet_composite_action", &"interact") + _expect( + supported is bool, + "has_pet_composite_action() must return bool" + ) + + +func _validate_optional_positive_float(pet: Node, method_name: StringName) -> void: + if not pet.has_method(method_name): + return + var value := float(pet.call(method_name)) + _expect(is_finite(value) and value > 0.0, "%s() must return a finite positive value" % method_name) + + +func _validate_pet_navigation(screen_root: Node, screen_id: String) -> void: + var navigation_roots: Array[Node] = [] + for node in _all_nodes(screen_root): + if bool(node.get_meta(PET_NAVIGATION_ROOT_META, false)): + navigation_roots.append(node) + if navigation_roots.is_empty(): + _error("%s must contain one pet navigation root" % screen_id) + return + _expect(navigation_roots.size() == 1, "%s must contain exactly one pet navigation root" % screen_id) + if navigation_roots.size() != 1: + return + + var navigation_root := navigation_roots[0] + _expect(navigation_root is Node2D, "pet navigation root must inherit Node2D") + _expect( + int(navigation_root.get_meta(&"exchanger_pet_navigation_version", 0)) == 1, + "pet navigation root version must be 1" + ) + var bounds_value = navigation_root.get_meta(&"exchanger_pet_roaming_bounds", null) + _expect(typeof(bounds_value) == TYPE_RECT2, "pet navigation root must declare Rect2 roaming bounds") + var bounds := bounds_value as Rect2 if typeof(bounds_value) == TYPE_RECT2 else Rect2() + _expect( + _is_finite_rect2(bounds) and bounds.size.x > 0.0 and bounds.size.y > 0.0, + "pet roaming bounds must be finite and positive" + ) + + var surfaces: Dictionary = {} + var climb_edges: Array[Dictionary] = [] + var climb_ids: Dictionary = {} + for node in _all_nodes(navigation_root): + if bool(node.get_meta(PET_SURFACE_META, false)): + var surface_id := StringName(str(node.get_meta(&"exchanger_pet_surface_id", ""))) + _expect(not surface_id.is_empty(), "pet surface id must not be empty") + _expect(not surfaces.has(surface_id), "duplicate pet surface id %s" % surface_id) + var from_x := float(node.get_meta(&"exchanger_pet_surface_from_x", NAN)) + var to_x := float(node.get_meta(&"exchanger_pet_surface_to_x", NAN)) + var surface_y := float(node.get_meta(&"exchanger_pet_surface_y", NAN)) + _expect( + is_finite(from_x) and is_finite(to_x) and is_finite(surface_y) and from_x < to_x, + "pet surface %s must have finite from_x < to_x and y" % surface_id + ) + if _is_finite_rect2(bounds): + _expect( + _rect_contains_inclusive(bounds, Vector2(from_x, surface_y)) + and _rect_contains_inclusive(bounds, Vector2(to_x, surface_y)), + "pet surface %s must stay inside roaming bounds" % surface_id + ) + var sit_points = node.get_meta(&"exchanger_pet_surface_sit_points", PackedFloat32Array()) + var sit_type := typeof(sit_points) + _expect( + sit_type == TYPE_ARRAY or sit_type == TYPE_PACKED_FLOAT32_ARRAY, + "pet surface %s sit points must be an Array or PackedFloat32Array" % surface_id + ) + if sit_type == TYPE_ARRAY or sit_type == TYPE_PACKED_FLOAT32_ARRAY: + for raw_x in sit_points: + var sit_x := float(raw_x) + _expect( + is_finite(sit_x) and sit_x >= from_x and sit_x <= to_x, + "pet surface %s sit point must be finite and inside the surface" % surface_id + ) + if not surface_id.is_empty() and not surfaces.has(surface_id): + surfaces[surface_id] = {"from_x": from_x, "to_x": to_x, "y": surface_y} + if screen_id == "screen.home" and HOME_PET_SURFACE_VISIBILITY_BINDINGS.has(surface_id): + _expect( + str(node.get_meta(PET_SURFACE_VISIBILITY_BINDING_META, "")) + == str(HOME_PET_SURFACE_VISIBILITY_BINDINGS[surface_id]), + "pet surface %s must target visibility binding %s" + % [surface_id, HOME_PET_SURFACE_VISIBILITY_BINDINGS[surface_id]] + ) + + if bool(node.get_meta(PET_CLIMB_EDGE_META, false)): + var edge_id := StringName(str(node.get_meta(&"exchanger_pet_climb_edge_id", ""))) + _expect(not edge_id.is_empty(), "pet climb edge id must not be empty") + _expect(not climb_ids.has(edge_id), "duplicate pet climb edge id %s" % edge_id) + if not edge_id.is_empty(): + climb_ids[edge_id] = true + climb_edges.append({ + "id": edge_id, + "from": StringName(str(node.get_meta(&"exchanger_pet_climb_from_surface_id", ""))), + "to": StringName(str(node.get_meta(&"exchanger_pet_climb_to_surface_id", ""))), + "x": float(node.get_meta(&"exchanger_pet_climb_x", NAN)), + "from_y": float(node.get_meta(&"exchanger_pet_climb_from_y", NAN)), + "to_y": float(node.get_meta(&"exchanger_pet_climb_to_y", NAN)), + "side": StringName(str(node.get_meta(&"exchanger_pet_climb_side", ""))), + }) + + _expect(not surfaces.is_empty(), "pet navigation root must contain at least one surface") + var default_surface := StringName(str(navigation_root.get_meta(&"exchanger_pet_default_surface_id", ""))) + _expect( + not default_surface.is_empty() and surfaces.has(default_surface), + "pet navigation default surface must reference an existing surface" + ) + for edge in climb_edges: + _validate_pet_climb_edge(edge, surfaces, bounds) + + +func _validate_pet_climb_edge(edge: Dictionary, surfaces: Dictionary, bounds: Rect2) -> void: + var edge_id: StringName = edge.id + var from_id: StringName = edge.from + var to_id: StringName = edge.to + _expect(not from_id.is_empty() and surfaces.has(from_id), "pet climb edge %s has unknown from surface" % edge_id) + _expect(not to_id.is_empty() and surfaces.has(to_id), "pet climb edge %s has unknown to surface" % edge_id) + var x := float(edge.x) + var from_y := float(edge.from_y) + var to_y := float(edge.to_y) + _expect( + is_finite(x) and is_finite(from_y) and is_finite(to_y) and not is_equal_approx(from_y, to_y), + "pet climb edge %s must have finite coordinates and non-zero height" % edge_id + ) + _expect(edge.side == &"left" or edge.side == &"right", "pet climb edge %s side must be left or right" % edge_id) + if _is_finite_rect2(bounds): + _expect( + _rect_contains_inclusive(bounds, Vector2(x, from_y)) + and _rect_contains_inclusive(bounds, Vector2(x, to_y)), + "pet climb edge %s must stay inside roaming bounds" % edge_id + ) + if surfaces.has(from_id): + var from_surface: Dictionary = surfaces[from_id] + _expect( + x >= float(from_surface.from_x) + and x <= float(from_surface.to_x) + and is_equal_approx(from_y, float(from_surface.y)), + "pet climb edge %s must start on its from surface" % edge_id + ) + if surfaces.has(to_id): + var to_surface: Dictionary = surfaces[to_id] + _expect( + x >= float(to_surface.from_x) + and x <= float(to_surface.to_x) + and is_equal_approx(to_y, float(to_surface.y)), + "pet climb edge %s must end on its to surface" % edge_id + ) + + +func _is_finite_vector2(value: Vector2) -> bool: + return is_finite(value.x) and is_finite(value.y) + + +func _is_finite_rect2(value: Rect2) -> bool: + return _is_finite_vector2(value.position) and _is_finite_vector2(value.size) + + +func _rect_contains_inclusive(rect: Rect2, point: Vector2) -> bool: + return ( + point.x >= rect.position.x + and point.y >= rect.position.y + and point.x <= rect.end.x + and point.y <= rect.end.y + ) + + +func _validate_self_contained_visuals( + root: Node, + screen_id: String, + requires_nearest_filter: bool +) -> void: + _expect( + bool(root.get_meta(SELF_CONTAINED_VISUALS_META, false)), + "%s must declare exchanger_self_contained_visuals = true" % screen_id + ) + _expect(root is Control, "%s root must be a Control" % screen_id) + if root is Control: + _expect((root as Control).theme != null, "%s root must apply its package ui_theme" % screen_id) + if requires_nearest_filter: + _expect( + (root as Control).texture_filter == CanvasItem.TEXTURE_FILTER_NEAREST, + "%s root must use Nearest when manifest UseNearestTextureFilter is true" + % screen_id + ) + + var background := root.get_node_or_null("ThemeBackground") as TextureRect + _expect(background != null, "%s must contain ThemeBackground TextureRect" % screen_id) + if background != null: + _expect(background.texture != null, "%s ThemeBackground must have a texture" % screen_id) + _expect( + root.get_node_or_null("BackgroundDecoration") != null, + "%s must contain BackgroundDecoration" % screen_id + ) + + +func _validate_bindings(root: Node, screen: Dictionary, global_bindings: Dictionary, metadata_key: String) -> void: + var expected: Dictionary = {} + var screen_id := str(screen.get("BindingId", "")) + var supplemental: Dictionary = SUPPLEMENTAL_RUNTIME_BINDINGS.get(screen_id, {}) + var optional_supplemental: Dictionary = OPTIONAL_SUPPLEMENTAL_RUNTIME_BINDINGS.get(screen_id, {}) + for binding_value in screen.get("Bindings", []): + var binding: Dictionary = binding_value + expected[str(binding.get("Id", ""))] = binding + + var found: Dictionary = {} + for node in _all_nodes(root): + if not node.has_meta(metadata_key): + continue + var binding_id := str(node.get_meta(metadata_key)) + if binding_id.is_empty(): + _error("Empty exchanger_binding_id in %s at %s" % [screen.get("BindingId", ""), root.get_path_to(node)]) + continue + if found.has(binding_id): + _error("Duplicate binding %s inside %s" % [binding_id, screen.get("BindingId", "")]) + continue + if global_bindings.has(binding_id): + _error("Binding %s is reused by %s and %s" % [binding_id, global_bindings[binding_id], screen.get("BindingId", "")]) + found[binding_id] = node + global_bindings[binding_id] = screen.get("BindingId", "") + var allowed_types: Array = [] + if expected.has(binding_id): + allowed_types = expected[binding_id].get("Types", []) + elif supplemental.has(binding_id): + allowed_types = supplemental[binding_id] + elif optional_supplemental.has(binding_id): + allowed_types = optional_supplemental[binding_id] + else: + _error("Binding %s is not declared for %s" % [binding_id, screen_id]) + continue + if not allowed_types.is_empty() and not _is_any_class(node, allowed_types): + _error("Binding %s has type %s; expected %s" % [binding_id, node.get_class(), ", ".join(allowed_types)]) + + for binding_id in expected: + var binding: Dictionary = expected[binding_id] + if bool(binding.get("Required", true)) and not found.has(binding_id): + _error("Required binding %s is missing from %s" % [binding_id, screen.get("BindingId", "")]) + for binding_id in supplemental: + if not found.has(binding_id): + _error("Required runtime binding %s is missing from %s" % [binding_id, screen_id]) + + +func _validate_fixed_slot_defaults(root: Node, screen_id: String, metadata_key: String) -> void: + var bindings: Dictionary = {} + for node in _all_nodes(root): + if node.has_meta(metadata_key): + bindings[str(node.get_meta(metadata_key))] = node + + if screen_id == "screen.home": + var speech_text := bindings.get("home.scroll.content.speech_text") as Label + if speech_text != null: + _expect(speech_text.autowrap_mode != TextServer.AUTOWRAP_OFF, "HOME speech text must wrap") + _expect(speech_text.clip_text, "HOME speech text must clip overflow") + _expect( + speech_text.horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER, + "HOME speech text must be horizontally centered" + ) + _expect( + speech_text.vertical_alignment == VERTICAL_ALIGNMENT_CENTER, + "HOME speech text must be vertically centered" + ) + return + + if screen_id == "screen.cash_payment": + var cash_caption := root.get_node_or_null( + "SafeMargin/Content/Summary/Margin/Values/Caption" + ) as Label + _expect(cash_caption != null, "CASH PAYMENT summary Caption is missing") + if cash_caption != null: + _expect(cash_caption.text == "К ВЫДАЧЕ", "CASH PAYMENT summary Caption must be К ВЫДАЧЕ") + return + + if screen_id == "screen.card_amount": + for index in range(16): + var slot_id := "card_amount.content.amount_panel.margin.content.preset_grid.slot_%02d" % index + var slot := bindings.get(slot_id) as CanvasItem + if slot != null and index >= 8: + _expect(not slot.visible, "%s must be hidden by default" % slot_id) + var card_amount_value_id := "card_amount.content.reward_panel.content.value" + var card_amount_bonus_id := "card_amount.content.reward_panel.content.bonus" + var card_amount_value := bindings.get(card_amount_value_id) as Label + var card_amount_bonus := bindings.get(card_amount_bonus_id) as Label + _expect( + root.get_node_or_null("SafeMargin/Content/RewardPanel/Content/Value") == card_amount_value, + "%s must keep the canonical Value path" % card_amount_value_id + ) + _expect( + root.get_node_or_null("SafeMargin/Content/RewardPanel/Content/Bonus") == card_amount_bonus, + "%s must use the canonical Bonus path" % card_amount_bonus_id + ) + if card_amount_value != null and card_amount_bonus != null: + _expect( + card_amount_bonus.get_index() == card_amount_value.get_index() + 1, + "%s must be placed immediately after Value" % card_amount_bonus_id + ) + _expect( + card_amount_bonus.position.x > card_amount_value.position.x, + "%s must be laid out to the right of Value" % card_amount_bonus_id + ) + _expect( + card_amount_bonus.text == "БЕЗ БОНУСА", + "%s default text must be БЕЗ БОНУСА" % card_amount_bonus_id + ) + return + + if screen_id == "screen.card_custom_amount": + var clear_button := bindings.get( + "card_custom_amount.content.keypad_panel.content.keypad.clear_button" + ) as Button + if clear_button != null: + _expect(clear_button.visible, "card custom amount clear button must be visible") + _expect(clear_button.text == "СТЕРЕТЬ", "card custom amount clear button text must be СТЕРЕТЬ") + _expect_visible_default( + bindings, + "card_custom_amount.content.keypad_panel.content.keypad.backspace_button", + false + ) + var custom_amount_id := "card_custom_amount.content.amount_panel.margin.values.amount" + var custom_bonus_id := "card_custom_amount.content.amount_panel.margin.values.tokens" + var custom_amount := bindings.get(custom_amount_id) as Label + var custom_bonus := bindings.get(custom_bonus_id) as Label + _expect( + root.get_node_or_null("SafeMargin/Content/AmountPanel/Margin/Values/Amount") == custom_amount, + "%s must keep the canonical Amount path" % custom_amount_id + ) + _expect( + root.get_node_or_null("SafeMargin/Content/AmountPanel/Margin/Values/Tokens") == custom_bonus, + "%s must keep the canonical Tokens path" % custom_bonus_id + ) + if custom_amount != null and custom_bonus != null: + _expect( + custom_bonus.position.x > custom_amount.position.x, + "%s must be laid out to the right of Amount" % custom_bonus_id + ) + _expect( + custom_bonus.text == "+ 50 ЖЕТОНОВ\nВ ПОДАРОК", + "%s default text must use the two-line bonus format" % custom_bonus_id + ) + return + + if screen_id != "screen.settings": + return + + var short_text := bindings.get("settings.scroll.content.branding_panel.margin.content.short_text") as LineEdit + if short_text != null: + _expect(short_text.max_length == 48, "SETTINGS short text must be limited to 48 characters") + + var branding_content_path := "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content" + var application_name_label := root.get_node_or_null( + "%s/ApplicationNameLabel" % branding_content_path + ) as Label + if application_name_label != null: + _expect(not application_name_label.visible, "SETTINGS application name label must be hidden") + var short_text_label := root.get_node_or_null( + "%s/ShortTextLabel" % branding_content_path + ) as Label + if short_text_label != null: + _expect( + short_text_label.text == "СВОЙ ТЕКСТ • ДО 48 ЗНАКОВ", + "SETTINGS short text label must be СВОЙ ТЕКСТ • ДО 48 ЗНАКОВ" + ) + + for hidden_binding_id in [ + "settings.scroll.content.branding_panel.margin.content.application_name", + "settings.scroll.content.branding_panel.margin.content.application_name_error", + "settings.scroll.content.branding_preview_panel", + "settings.scroll.content.branding_preview_panel.margin.content.application_name", + "settings.scroll.content.branding_preview_panel.margin.content.short_text", + "settings.scroll.content.branding_preview_panel.margin.content.support_phone", + ]: + _expect_visible_default(bindings, hidden_binding_id, false) + + _expect_visible_default( + bindings, + "settings.scroll.content.service_inventory_panel", + false + ) + var service_inventory_panel := bindings.get("settings.scroll.content.service_inventory_panel") as Control + if service_inventory_panel != null: + _expect( + is_equal_approx(service_inventory_panel.custom_minimum_size.y, 772.0), + "settings.scroll.content.service_inventory_panel minimum height must be 772" + ) + var inventory_description_id := "settings.scroll.content.service_inventory_panel.margin.content.description" + var inventory_description := bindings.get(inventory_description_id) as Label + if inventory_description != null: + var canonical_inventory_description := root.get_node_or_null( + "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Description" + ) + _expect( + canonical_inventory_description == inventory_description, + "%s must use the canonical Description path" % inventory_description_id + ) + var inventory_enabled_id := "settings.scroll.content.service_inventory_panel.margin.content.inventory_enabled" + var inventory_enabled := bindings.get(inventory_enabled_id) as CheckButton + if inventory_enabled != null: + _expect(inventory_enabled.button_pressed, "%s must be enabled by default" % inventory_enabled_id) + _expect( + inventory_enabled.text == "УЧИТЫВАТЬ ОСТАТОК ЖЕТОНОВ", + "%s text must be УЧИТЫВАТЬ ОСТАТОК ЖЕТОНОВ" % inventory_enabled_id + ) + var canonical_inventory_enabled := root.get_node_or_null( + "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/InventoryAccountingEnabled" + ) + _expect( + canonical_inventory_enabled == inventory_enabled, + "%s must use the canonical InventoryAccountingEnabled path" % inventory_enabled_id + ) + if inventory_description != null: + _expect( + inventory_enabled.get_index() == inventory_description.get_index() + 1, + "%s must be placed immediately after Description" % inventory_enabled_id + ) + var purchase_limit_id := "settings.scroll.content.service_inventory_panel.margin.content.purchase_limit_enabled" + var purchase_limit := bindings.get(purchase_limit_id) as CheckButton + if purchase_limit != null: + _expect(purchase_limit.button_pressed, "%s must be enabled by default" % purchase_limit_id) + _expect( + purchase_limit.text == "НЕ ПРОДАВАТЬ БОЛЬШЕ ОСТАТКА", + "%s text must be НЕ ПРОДАВАТЬ БОЛЬШЕ ОСТАТКА" % purchase_limit_id + ) + var canonical_purchase_limit := root.get_node_or_null( + "SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/PurchaseLimitEnabled" + ) + _expect( + canonical_purchase_limit == purchase_limit, + "%s must use the canonical PurchaseLimitEnabled path" % purchase_limit_id + ) + if inventory_enabled != null: + _expect( + purchase_limit.get_index() == inventory_enabled.get_index() + 1, + "%s must be placed immediately after InventoryAccountingEnabled" % purchase_limit_id + ) + _expect_visible_default( + bindings, + "settings.scroll.content.menu_visibility_panel", + false + ) + for binding_id in [ + "settings.scroll.content.menu_visibility_panel.margin.content.show_stock_status", + "settings.scroll.content.menu_visibility_panel.margin.content.show_right_character", + "settings.scroll.content.menu_visibility_panel.margin.content.show_promotion_block", + "settings.scroll.content.menu_visibility_panel.margin.content.show_interactive_pet", + ]: + var toggle := bindings.get(binding_id) as CheckButton + if toggle != null: + _expect(toggle.button_pressed, "%s must be enabled by default" % binding_id) + + var footer := bindings.get("settings.footer") as PanelContainer + var save_and_exit := bindings.get("settings.footer.save_and_exit_button") as Button + var footer_clearance := bindings.get("settings.scroll.content.footer_clearance") as Control + var settings_scroll := bindings.get("settings.scroll") as ScrollContainer + var scroll_content := bindings.get("settings.scroll.content") as Control + if footer != null: + _expect(footer.visible, "settings.footer must be visible by default") + _expect(footer.get_parent() == root, "settings.footer must be a root sibling of SafeMargin") + _expect( + is_equal_approx(footer.anchor_top, 1.0) and is_equal_approx(footer.anchor_bottom, 1.0), + "settings.footer must stay anchored to the bottom edge" + ) + if save_and_exit != null: + _expect( + save_and_exit.get_parent() == footer, + "settings.footer.save_and_exit_button must be a direct child of settings.footer" + ) + _expect(save_and_exit.text == "СОХРАНИТЬ И ВЫЙТИ", "settings footer button text is invalid") + if footer_clearance != null: + _expect( + footer_clearance.get_parent() == scroll_content, + "settings.scroll.content.footer_clearance must be a direct child of scroll content" + ) + _expect( + footer_clearance.custom_minimum_size.y >= 104.0, + "settings.scroll.content.footer_clearance must reserve the footer height" + ) + if settings_scroll != null: + _expect( + settings_scroll.size_flags_vertical == Control.SIZE_SHRINK_BEGIN, + "settings.scroll must shrink from the bottom to avoid the fixed footer" + ) + _expect( + settings_scroll.custom_minimum_size.y <= 1100.0, + "settings.scroll must leave room for the fixed footer at 720x1280" + ) + _expect_visible_default( + bindings, + "settings.service_dialog_overlay", + false + ) + var service_amount := bindings.get("settings.service_dialog_overlay.dialog.margin.content.amount") as SpinBox + if service_amount != null: + _expect(service_amount.min_value == 0.0, "SETTINGS service amount must allow zero") + _expect(service_amount.max_value >= 999999.0, "SETTINGS service amount must accept large inventory totals") + _expect(service_amount.suffix.is_empty(), "SETTINGS service amount must not reserve space for a suffix") + + _expect_visible_default( + bindings, + "settings.scroll.content.bonus_panel.margin.content.rows.empty_state", + true + ) + _expect_visible_default( + bindings, + "settings.scroll.content.card_options_panel.margin.content.preset_rows.empty_state", + false + ) + _expect_visible_default( + bindings, + "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.empty_state", + true + ) + for index in range(16): + var bonus_id := "settings.scroll.content.bonus_panel.margin.content.rows.slot_%02d.bonus" % index + var bonus_field := bindings.get(bonus_id) as SpinBox + if bonus_field != null: + _expect( + bonus_field.suffix.is_empty(), + "%s must not have a suffix so multi-digit token counts remain visible" % bonus_id + ) + _expect_visible_default( + bindings, + "settings.scroll.content.bonus_panel.margin.content.rows.slot_%02d" % index, + false + ) + _expect_visible_default( + bindings, + "settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_%02d" % index, + index < 8 + ) + for index in range(12): + _expect_visible_default( + bindings, + "settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_%02d" % index, + false + ) + + +func _expect_visible_default(bindings: Dictionary, binding_id: String, expected: bool) -> void: + var item := bindings.get(binding_id) as CanvasItem + if item != null: + _expect(item.visible == expected, "%s default visibility must be %s" % [binding_id, expected]) + + +func _all_nodes(root: Node) -> Array[Node]: + var result: Array[Node] = [root] + var cursor := 0 + while cursor < result.size(): + var current := result[cursor] + for child in current.get_children(): + result.append(child) + cursor += 1 + return result + + +func _is_any_class(node: Node, allowed_types: Array) -> bool: + for type_value in allowed_types: + if node.is_class(str(type_value)): + return true + return false + + +func _contract_has_screen(contract: Dictionary, binding_id: String) -> bool: + for screen_value in contract.get("Screens", []): + if str(screen_value.get("BindingId", "")) == binding_id: + return true + return false + + +func _load_json(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + _error("JSON file is missing: %s" % path) + return {} + var value = JSON.parse_string(FileAccess.get_file_as_string(path)) + if not value is Dictionary: + _error("JSON root must be an object: %s" % path) + return {} + return value + + +func _argument_value(name: String, fallback: String) -> String: + var args := OS.get_cmdline_user_args() + var index := args.find(name) + if index >= 0 and index + 1 < args.size(): + return args[index + 1] + return fallback + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _error(message) + + +func _error(message: String) -> void: + _errors.append(message) + + +func _finish() -> void: + if _errors.is_empty(): + print("[Theme Validator] OK: full-scene contract is valid.") + quit(0) + return + for message in _errors: + push_error("[Theme Validator] %s" % message) + print("[Theme Validator] FAILED: %d error(s)." % _errors.size()) + quit(1) diff --git a/tools/validate_theme.gd.uid b/tools/validate_theme.gd.uid new file mode 100644 index 0000000..0ae02ef --- /dev/null +++ b/tools/validate_theme.gd.uid @@ -0,0 +1 @@ +uid://cvvrmtg44julo diff --git a/tools/verify_interactive_pet.gd b/tools/verify_interactive_pet.gd new file mode 100644 index 0000000..0f6e6d8 --- /dev/null +++ b/tools/verify_interactive_pet.gd @@ -0,0 +1,601 @@ +extends SceneTree + +const ACTIVE_STATE_PATH := "res://build/active-theme.json" +const CANONICAL_PACKAGE_ROOT := "res://exchanger_theme_dlc" +const PACKAGED_MANIFEST_PATH := "res://exchanger_theme_dlc/manifest.json" +const PET_MANIFEST_FIELD := "InteractivePetScene" +const PET_COMPONENT_RELATIVE_PATH := "components/interactive_pet.tscn" +const LEGACY_PET_COMPONENT_RELATIVE_PATH := "components/alien_pet.tscn" +const REQUIRED_METHODS := [ + &"get_pet_animation_names", + &"get_pet_animation_frame_count", + &"has_pet_animation", + &"play_pet_animation", + &"pause_pet_animation", + &"stop_pet_animation", + &"get_current_pet_animation", + &"get_current_pet_frame", +] +const COMPOSITE_METHODS := [ + &"has_pet_composite_action", + &"start_pet_composite_action", + &"cancel_pet_composite_action", +] +const COMPOSITE_SIGNALS := [ + &"pet_composite_action_started", + &"pet_composite_action_finished", +] +const INTERACTIVE_PET_ACTIONS := [ + &"idle", + &"sit", + &"interact", + &"walk_left", + &"walk_right", + &"walk_start_left", + &"walk_start_right", + &"walk_finish_left", + &"walk_finish_right", + &"climb_start", + &"climb_up", + &"climb_down", + &"climb_finish", + &"drag", + &"drop", + &"landing", + &"celebrate", +] +const PET_NAVIGATION_ROOT_META := &"exchanger_pet_navigation_root" +const PET_SURFACE_META := &"exchanger_pet_surface" +const PET_CLIMB_EDGE_META := &"exchanger_pet_climb_edge" + + +func _init() -> void: + call_deferred("_verify") + + +func _verify() -> void: + var failures := PackedStringArray() + var pack_path := _argument_value("--pack", "") + var explicit_scene_path := _argument_value("--scene", "") + var optional := OS.get_cmdline_user_args().has("--optional") + var pet_scene_path := "" + var package_root := "" + + if not pack_path.is_empty(): + if not ProjectSettings.load_resource_pack(pack_path, false): + _fail("Cannot mount PCK %s." % pack_path) + return + package_root = CANONICAL_PACKAGE_ROOT + pet_scene_path = _resolve_packaged_scene_path(explicit_scene_path) + else: + package_root = _resolve_builder_package_root() + pet_scene_path = _resolve_builder_scene_path(explicit_scene_path) + + if pet_scene_path.is_empty(): + if optional: + print("[Interactive Pet] SKIP: theme does not declare an interactive pet.") + quit(0) + return + _fail("Theme does not declare an interactive pet scene.") + return + + var packed_scene := load(pet_scene_path) as PackedScene + if packed_scene == null: + _fail("Cannot load %s." % pet_scene_path) + return + + var instance := packed_scene.instantiate() + if not instance is Node2D: + instance.queue_free() + _fail("Interactive pet root must inherit Node2D: %s." % pet_scene_path) + return + var pet := instance as Node2D + root.add_child(pet) + await process_frame + + for method_name in REQUIRED_METHODS: + if not pet.has_method(method_name): + failures.append("Missing public method %s()." % method_name) + _expect_signal(pet, &"pet_animation_started", failures) + _expect_signal(pet, &"pet_animation_completed", failures) + if not failures.is_empty(): + _finish(failures, pet_scene_path, 0, 0, 0, 0) + return + + var animation_names: Array[StringName] = [] + var raw_names = pet.call(&"get_pet_animation_names") + if not raw_names is Array: + failures.append("get_pet_animation_names() must return an Array.") + else: + for raw_name in raw_names: + var animation_name := StringName(str(raw_name)) + if animation_name.is_empty(): + failures.append("Animation names must not be empty.") + elif animation_names.has(animation_name): + failures.append("Duplicate animation name %s." % animation_name) + else: + animation_names.append(animation_name) + if animation_names.is_empty(): + failures.append("Interactive pet must expose at least one animation.") + + var initial_position := pet.position + var total_frames := 0 + for animation_name in animation_names: + if not bool(pet.call(&"has_pet_animation", animation_name)): + failures.append("has_pet_animation() rejected %s." % animation_name) + continue + var frame_count := int(pet.call(&"get_pet_animation_frame_count", animation_name)) + if frame_count <= 0: + failures.append("Animation %s must contain at least one frame." % animation_name) + else: + total_frames += frame_count + if not bool(pet.call(&"play_pet_animation", animation_name)): + failures.append("play_pet_animation() rejected %s." % animation_name) + + await create_timer(0.25).timeout + if pet.position != initial_position: + failures.append("Animation playback changed the pet root position.") + await _validate_optional_visual_offset(pet, animation_names, failures) + if pet.has_method(&"get_pet_preview_size"): + var preview_size = pet.call(&"get_pet_preview_size") + if not preview_size is Vector2 or preview_size.x <= 0.0 or preview_size.y <= 0.0: + failures.append("get_pet_preview_size() must return a positive Vector2.") + _validate_optional_pet_profile(pet, animation_names, failures) + _validate_landing_animation(pet, animation_names, failures) + _validate_optional_composite_action(pet, failures) + var navigation_counts := _validate_pet_navigation(package_root, failures) + + _finish( + failures, + pet_scene_path, + animation_names.size(), + total_frames, + navigation_counts.x, + navigation_counts.y + ) + + +func _validate_optional_composite_action(pet: Node, failures: PackedStringArray) -> void: + var exposed_count := 0 + for method_name in COMPOSITE_METHODS: + if pet.has_method(method_name): + exposed_count += 1 + for signal_name in COMPOSITE_SIGNALS: + if pet.has_signal(signal_name): + exposed_count += 1 + if exposed_count == 0: + return + + for method_name in COMPOSITE_METHODS: + if not pet.has_method(method_name): + failures.append("Partial composite pet API: missing %s()." % method_name) + for signal_name in COMPOSITE_SIGNALS: + if not pet.has_signal(signal_name): + failures.append("Partial composite pet API: missing signal %s." % signal_name) + if exposed_count != COMPOSITE_METHODS.size() + COMPOSITE_SIGNALS.size(): + return + var supported = pet.call(&"has_pet_composite_action", &"interact") + if not supported is bool: + failures.append("has_pet_composite_action() must return bool.") + + +func _resolve_builder_scene_path(explicit_scene_path: String) -> String: + if not explicit_scene_path.is_empty(): + return explicit_scene_path + var package_path := _resolve_builder_package_root() + if package_path.is_empty(): + return "" + var manifest := _load_json(package_path.path_join("manifest.json")) + return _resolve_scene_from_manifest(package_path, manifest) + + +func _resolve_builder_package_root() -> String: + var theme_id := _argument_value("--theme", "") + var package_path := "" + if not theme_id.is_empty(): + package_path = "res://themes/%s/package" % theme_id + else: + var state := _load_json(ACTIVE_STATE_PATH) + package_path = ProjectSettings.localize_path(str(state.get("Package", ""))) + package_path = package_path.replace("\\", "/").trim_suffix("/") + if not package_path.begins_with("res://"): + return "" + return package_path + + +func _resolve_packaged_scene_path(explicit_scene_path: String) -> String: + if not explicit_scene_path.is_empty(): + return explicit_scene_path + var manifest := _load_json(PACKAGED_MANIFEST_PATH) + return _resolve_scene_from_manifest(CANONICAL_PACKAGE_ROOT, manifest) + + +func _resolve_scene_from_manifest(package_root: String, manifest: Dictionary) -> String: + var declared_path := str(manifest.get(PET_MANIFEST_FIELD, "")) + var candidates := PackedStringArray() + if not declared_path.is_empty(): + var relative_path := _package_relative_path(declared_path) + if not relative_path.is_empty(): + candidates.append(relative_path) + candidates.append(PET_COMPONENT_RELATIVE_PATH) + candidates.append(LEGACY_PET_COMPONENT_RELATIVE_PATH) + for relative_path in candidates: + var candidate := package_root.path_join(relative_path) + if ResourceLoader.exists(candidate): + return candidate + return "" + + +func _package_relative_path(resource_path: String) -> String: + var normalized := resource_path.replace("\\", "/") + if normalized.contains(".."): + return "" + if normalized.begins_with(CANONICAL_PACKAGE_ROOT + "/"): + return normalized.trim_prefix(CANONICAL_PACKAGE_ROOT + "/") + if normalized.begins_with("res://"): + return "" + return normalized.trim_prefix("/") + + +func _expect_signal(pet: Node, signal_name: StringName, failures: PackedStringArray) -> void: + if not pet.has_signal(signal_name): + failures.append("Missing public signal %s." % signal_name) + + +func _validate_optional_visual_offset( + pet: Node2D, + animation_names: Array[StringName], + failures: PackedStringArray +) -> void: + var has_current_offset := pet.has_method(&"get_pet_visual_offset") + var has_animation_offset := pet.has_method(&"get_pet_animation_visual_offset") + if has_current_offset != has_animation_offset: + failures.append("Pet visual offset introspection methods must be provided together.") + return + if not has_current_offset: + return + if not animation_names.has(&"sitting"): + failures.append("A pet exposing get_pet_visual_offset() must provide sitting.") + return + var non_sitting_animation := &"" + for animation_name in animation_names: + if animation_name != &"sitting": + non_sitting_animation = animation_name + break + if non_sitting_animation.is_empty(): + failures.append("A pet exposing get_pet_visual_offset() must provide a non-sitting animation.") + return + if pet.call(&"get_pet_animation_visual_offset", &"sitting") != Vector2(0.0, 24.0): + failures.append("Declared sitting visual offset must be Vector2(0, 24).") + if pet.call(&"get_pet_animation_visual_offset", non_sitting_animation) != Vector2.ZERO: + failures.append("Declared non-sitting visual offset must be Vector2.ZERO.") + + var initial_root_position := pet.position + pet.call(&"play_pet_animation", &"sitting") + await process_frame + if pet.call(&"get_pet_visual_offset") != Vector2(0.0, 24.0): + failures.append("Sitting visual offset must be Vector2(0, 24).") + + pet.call(&"play_pet_animation", non_sitting_animation) + await process_frame + if pet.call(&"get_pet_visual_offset") != Vector2.ZERO: + failures.append("Non-sitting visual offset must be Vector2.ZERO.") + + pet.call(&"play_pet_animation", &"sitting") + await process_frame + if pet.call(&"get_pet_visual_offset") != Vector2(0.0, 24.0): + failures.append("Returning to sitting must restore Vector2(0, 24) visual offset.") + if pet.position != initial_root_position: + failures.append("Visual offset switching changed the pet root position.") + + +func _validate_optional_pet_profile( + pet: Node, + animation_names: Array[StringName], + failures: PackedStringArray +) -> void: + if pet.has_method(&"get_pet_action_animation"): + for action_name in INTERACTIVE_PET_ACTIONS: + var animation_name := StringName(str(pet.call(&"get_pet_action_animation", action_name))) + if not animation_name.is_empty() and not animation_names.has(animation_name): + failures.append("Pet action %s maps to unknown animation %s." % [action_name, animation_name]) + + _validate_positive_float_method(pet, &"get_pet_runtime_scale", failures) + _validate_positive_float_method(pet, &"get_pet_walk_speed", failures) + _validate_positive_float_method(pet, &"get_pet_climb_speed", failures) + + var reference_size := Vector2.ZERO + if pet.has_method(&"get_pet_hit_size"): + var hit_size = pet.call(&"get_pet_hit_size") + if ( + not hit_size is Vector2 + or not _is_finite_vector2(hit_size) + or hit_size.x <= 0.0 + or hit_size.y <= 0.0 + ): + failures.append("get_pet_hit_size() must return a finite positive Vector2.") + elif hit_size is Vector2: + reference_size = hit_size + elif pet.has_method(&"get_pet_preview_size"): + var preview_size = pet.call(&"get_pet_preview_size") + if preview_size is Vector2: + reference_size = preview_size + + if pet.has_method(&"get_pet_ground_offset"): + var ground_offset = pet.call(&"get_pet_ground_offset") + if not ground_offset is Vector2 or not _is_finite_vector2(ground_offset): + failures.append("get_pet_ground_offset() must return a finite Vector2.") + elif ( + reference_size.x > 0.0 + and reference_size.y > 0.0 + and ( + ground_offset.x < 0.0 + or ground_offset.y < 0.0 + or ground_offset.x > reference_size.x + or ground_offset.y > reference_size.y + ) + ): + failures.append("get_pet_ground_offset() must stay inside pet hit/preview size.") + + +func _validate_landing_animation( + pet: Node, + animation_names: Array[StringName], + failures: PackedStringArray +) -> void: + if not animation_names.has(&"landing"): + return + if not bool(pet.call(&"has_pet_animation", &"landing")): + failures.append("Direct landing animation is not playable.") + return + if pet.has_method(&"is_pet_animation_looping") and bool( + pet.call(&"is_pet_animation_looping", &"landing") + ): + failures.append("Direct landing animation must be one-shot, not looping.") + + +func _validate_positive_float_method( + pet: Node, + method_name: StringName, + failures: PackedStringArray +) -> void: + if not pet.has_method(method_name): + return + var value := float(pet.call(method_name)) + if not is_finite(value) or value <= 0.0: + failures.append("%s() must return a finite positive value." % method_name) + + +func _validate_pet_navigation(package_root: String, failures: PackedStringArray) -> Vector2i: + if package_root.is_empty(): + failures.append("Cannot resolve theme package root for pet navigation validation.") + return Vector2i.ZERO + var manifest := _load_json(package_root.path_join("manifest.json")) + var screen_map: Dictionary = manifest.get("ScreenScenes", {}) + var totals := Vector2i.ZERO + for screen_id_value in screen_map: + var screen_id := str(screen_id_value) + if screen_id == "screen.settings": + continue + var declared_path := str(screen_map.get(screen_id, "")) + var relative_path := _package_relative_path(declared_path) + var scene_path := package_root.path_join(relative_path) + var packed_scene := load(scene_path) as PackedScene + if packed_scene == null: + failures.append("Cannot load %s pet navigation scene: %s." % [screen_id, scene_path]) + continue + var screen_root := packed_scene.instantiate() + if screen_root == null: + failures.append("Cannot instantiate %s pet navigation scene: %s." % [screen_id, scene_path]) + continue + + var roots: Array[Node] = [] + for node in _all_nodes(screen_root): + if bool(node.get_meta(PET_NAVIGATION_ROOT_META, false)): + roots.append(node) + if roots.size() != 1: + failures.append("%s must contain exactly one pet navigation root." % screen_id) + screen_root.free() + continue + + totals += _validate_pet_navigation_root(roots[0], failures, screen_id) + screen_root.free() + return totals + + +func _validate_pet_navigation_root( + navigation_root: Node, + failures: PackedStringArray, + screen_id: String +) -> Vector2i: + if not navigation_root is Node2D: + failures.append("%s pet navigation root must inherit Node2D." % screen_id) + if int(navigation_root.get_meta(&"exchanger_pet_navigation_version", 0)) != 1: + failures.append("%s pet navigation root version must be 1." % screen_id) + var bounds_value = navigation_root.get_meta(&"exchanger_pet_roaming_bounds", null) + var bounds := bounds_value as Rect2 if typeof(bounds_value) == TYPE_RECT2 else Rect2() + if ( + typeof(bounds_value) != TYPE_RECT2 + or not _is_finite_rect2(bounds) + or bounds.size.x <= 0.0 + or bounds.size.y <= 0.0 + ): + failures.append("%s pet roaming bounds must be a finite positive Rect2." % screen_id) + + var surfaces: Dictionary = {} + var climb_edges: Array[Dictionary] = [] + var climb_ids: Dictionary = {} + for node in _all_nodes(navigation_root): + if bool(node.get_meta(PET_SURFACE_META, false)): + _validate_pet_surface(node, surfaces, bounds, failures) + if bool(node.get_meta(PET_CLIMB_EDGE_META, false)): + var edge_id := StringName(str(node.get_meta(&"exchanger_pet_climb_edge_id", ""))) + if edge_id.is_empty(): + failures.append("Pet climb edge id must not be empty.") + elif climb_ids.has(edge_id): + failures.append("Duplicate pet climb edge id %s." % edge_id) + else: + climb_ids[edge_id] = true + climb_edges.append({ + "id": edge_id, + "from": StringName(str(node.get_meta(&"exchanger_pet_climb_from_surface_id", ""))), + "to": StringName(str(node.get_meta(&"exchanger_pet_climb_to_surface_id", ""))), + "x": float(node.get_meta(&"exchanger_pet_climb_x", NAN)), + "from_y": float(node.get_meta(&"exchanger_pet_climb_from_y", NAN)), + "to_y": float(node.get_meta(&"exchanger_pet_climb_to_y", NAN)), + "side": StringName(str(node.get_meta(&"exchanger_pet_climb_side", ""))), + }) + + if surfaces.is_empty(): + failures.append("%s pet navigation root must contain at least one surface." % screen_id) + var default_surface := StringName(str(navigation_root.get_meta(&"exchanger_pet_default_surface_id", ""))) + if default_surface.is_empty() or not surfaces.has(default_surface): + failures.append("%s pet navigation default surface must reference an existing surface." % screen_id) + for edge in climb_edges: + _validate_pet_climb_edge(edge, surfaces, bounds, failures) + return Vector2i(surfaces.size(), climb_edges.size()) + + +func _validate_pet_surface( + node: Node, + surfaces: Dictionary, + bounds: Rect2, + failures: PackedStringArray +) -> void: + var surface_id := StringName(str(node.get_meta(&"exchanger_pet_surface_id", ""))) + if surface_id.is_empty(): + failures.append("Pet surface id must not be empty.") + return + if surfaces.has(surface_id): + failures.append("Duplicate pet surface id %s." % surface_id) + return + var from_x := float(node.get_meta(&"exchanger_pet_surface_from_x", NAN)) + var to_x := float(node.get_meta(&"exchanger_pet_surface_to_x", NAN)) + var surface_y := float(node.get_meta(&"exchanger_pet_surface_y", NAN)) + if not is_finite(from_x) or not is_finite(to_x) or not is_finite(surface_y) or from_x >= to_x: + failures.append("Pet surface %s must have finite from_x < to_x and y." % surface_id) + if _is_finite_rect2(bounds) and ( + not _rect_contains_inclusive(bounds, Vector2(from_x, surface_y)) + or not _rect_contains_inclusive(bounds, Vector2(to_x, surface_y)) + ): + failures.append("Pet surface %s must stay inside roaming bounds." % surface_id) + var sit_points = node.get_meta(&"exchanger_pet_surface_sit_points", PackedFloat32Array()) + var sit_type := typeof(sit_points) + if sit_type != TYPE_ARRAY and sit_type != TYPE_PACKED_FLOAT32_ARRAY: + failures.append("Pet surface %s sit points must be an Array or PackedFloat32Array." % surface_id) + else: + for raw_x in sit_points: + var sit_x := float(raw_x) + if not is_finite(sit_x) or sit_x < from_x or sit_x > to_x: + failures.append("Pet surface %s sit point must be finite and inside the surface." % surface_id) + surfaces[surface_id] = {"from_x": from_x, "to_x": to_x, "y": surface_y} + + +func _validate_pet_climb_edge( + edge: Dictionary, + surfaces: Dictionary, + bounds: Rect2, + failures: PackedStringArray +) -> void: + var edge_id: StringName = edge.id + var from_id: StringName = edge.from + var to_id: StringName = edge.to + if from_id.is_empty() or not surfaces.has(from_id): + failures.append("Pet climb edge %s has unknown from surface." % edge_id) + if to_id.is_empty() or not surfaces.has(to_id): + failures.append("Pet climb edge %s has unknown to surface." % edge_id) + var x := float(edge.x) + var from_y := float(edge.from_y) + var to_y := float(edge.to_y) + if not is_finite(x) or not is_finite(from_y) or not is_finite(to_y) or is_equal_approx(from_y, to_y): + failures.append("Pet climb edge %s must have finite coordinates and non-zero height." % edge_id) + if edge.side != &"left" and edge.side != &"right": + failures.append("Pet climb edge %s side must be left or right." % edge_id) + if _is_finite_rect2(bounds) and ( + not _rect_contains_inclusive(bounds, Vector2(x, from_y)) + or not _rect_contains_inclusive(bounds, Vector2(x, to_y)) + ): + failures.append("Pet climb edge %s must stay inside roaming bounds." % edge_id) + if surfaces.has(from_id): + var from_surface: Dictionary = surfaces[from_id] + if ( + x < float(from_surface.from_x) + or x > float(from_surface.to_x) + or not is_equal_approx(from_y, float(from_surface.y)) + ): + failures.append("Pet climb edge %s must start on its from surface." % edge_id) + if surfaces.has(to_id): + var to_surface: Dictionary = surfaces[to_id] + if ( + x < float(to_surface.from_x) + or x > float(to_surface.to_x) + or not is_equal_approx(to_y, float(to_surface.y)) + ): + failures.append("Pet climb edge %s must end on its to surface." % edge_id) + + +func _all_nodes(start: Node) -> Array[Node]: + var result: Array[Node] = [start] + var cursor := 0 + while cursor < result.size(): + for child in result[cursor].get_children(): + result.append(child) + cursor += 1 + return result + + +func _is_finite_vector2(value: Vector2) -> bool: + return is_finite(value.x) and is_finite(value.y) + + +func _is_finite_rect2(value: Rect2) -> bool: + return _is_finite_vector2(value.position) and _is_finite_vector2(value.size) + + +func _rect_contains_inclusive(rect: Rect2, point: Vector2) -> bool: + return ( + point.x >= rect.position.x + and point.y >= rect.position.y + and point.x <= rect.end.x + and point.y <= rect.end.y + ) + + +func _finish( + failures: PackedStringArray, + pet_scene_path: String, + animation_count: int, + total_frames: int, + surface_count: int, + climb_edge_count: int +) -> void: + if failures.is_empty(): + print( + "[Interactive Pet] OK: %d animations, %d frames, stationary root, %d surfaces, %d climb edges across non-settings screens (%s)." + % [animation_count, total_frames, surface_count, climb_edge_count, pet_scene_path] + ) + quit(0) + return + for failure in failures: + push_error("[Interactive Pet] %s" % failure) + quit(1) + + +func _fail(message: String) -> void: + push_error("[Interactive Pet] %s" % message) + quit(1) + + +func _load_json(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var value = JSON.parse_string(FileAccess.get_file_as_string(path)) + return value if value is Dictionary else {} + + +func _argument_value(name: String, fallback: String) -> String: + var args := OS.get_cmdline_user_args() + var index := args.find(name) + if index >= 0 and index + 1 < args.size(): + return args[index + 1] + return fallback diff --git a/tools/verify_interactive_pet.gd.uid b/tools/verify_interactive_pet.gd.uid new file mode 100644 index 0000000..54d195d --- /dev/null +++ b/tools/verify_interactive_pet.gd.uid @@ -0,0 +1 @@ +uid://dra7wpmopbml0 diff --git a/tools/verify_theme_pck.gd b/tools/verify_theme_pck.gd new file mode 100644 index 0000000..d78be3c --- /dev/null +++ b/tools/verify_theme_pck.gd @@ -0,0 +1,80 @@ +extends SceneTree + +const MANIFEST_PATH := "res://exchanger_theme_dlc/manifest.json" + + +func _init() -> void: + call_deferred("_verify") + + +func _verify() -> void: + var pack_path := _argument_value("--pack", "") + var require_scripts := "--require-scripts" in OS.get_cmdline_user_args() + if pack_path.is_empty(): + _fail("Pass an absolute PCK path with --pack.") + return + if not ProjectSettings.load_resource_pack(pack_path, false): + _fail("Could not mount PCK: %s" % pack_path) + return + if not FileAccess.file_exists(MANIFEST_PATH): + _fail("Mounted PCK does not contain %s" % MANIFEST_PATH) + return + + var manifest_value = JSON.parse_string(FileAccess.get_file_as_string(MANIFEST_PATH)) + if not manifest_value is Dictionary: + _fail("Theme manifest is not a JSON object.") + return + var manifest: Dictionary = manifest_value + var screen_scenes: Dictionary = manifest.get("ScreenScenes", {}) + var script_nodes := 0 + + for screen_id in screen_scenes: + var scene_path := str(screen_scenes[screen_id]) + var packed := load(scene_path) as PackedScene + if packed == null: + _fail("Could not load %s for %s." % [scene_path, screen_id]) + return + var instance := packed.instantiate() + if instance == null: + _fail("Could not instantiate %s for %s." % [scene_path, screen_id]) + return + root.add_child(instance) + await process_frame + for node in _all_nodes(instance): + if node.get_script() != null: + script_nodes += 1 + root.remove_child(instance) + instance.free() + + if require_scripts and script_nodes == 0: + _fail("Theme PCK loaded successfully, but no attached runtime scripts were found.") + return + + print( + "[Theme PCK Smoke] OK: %d screen(s), %d scripted node instance(s)." + % [screen_scenes.size(), script_nodes] + ) + quit(0) + + +func _all_nodes(scene_root: Node) -> Array[Node]: + var result: Array[Node] = [scene_root] + var cursor := 0 + while cursor < result.size(): + for child in result[cursor].get_children(): + result.append(child) + cursor += 1 + return result + + +func _argument_value(name: String, fallback: String) -> String: + var args := OS.get_cmdline_user_args() + var index := args.find(name) + if index >= 0 and index + 1 < args.size(): + return args[index + 1] + return fallback + + +func _fail(message: String) -> void: + push_error("[Theme PCK Smoke] %s" % message) + quit(1) diff --git a/tools/verify_theme_pck.gd.uid b/tools/verify_theme_pck.gd.uid new file mode 100644 index 0000000..f20db7a --- /dev/null +++ b/tools/verify_theme_pck.gd.uid @@ -0,0 +1 @@ +uid://bra7xmjf15hwx