# Композитные анимации питомца: гайд для дизайнера Композитное действие — это не один клип, а сценарий из нескольких анимаций, визуальных преобразований и перемещений. Сценарий хранится в теме, поэтому каждый питомец может вести себя по-своему, а 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/`.