exchanger-themes/docs/COMPOSITE_PET_ANIMATIONS.md
Danil Iarosh ca52efbedc 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.
2026-09-22 11:43:13 +03:00

22 KiB
Raw Permalink Blame History

Композитные анимации питомца: гайд для дизайнера

Композитное действие — это не один клип, а сценарий из нескольких анимаций, визуальных преобразований и перемещений. Сценарий хранится в теме, поэтому каждый питомец может вести себя по-своему, а Exchanger только предоставляет безопасные операции для экранной позиции, границ и опор.

Рабочий пример находится в сцене interactive_pet.tscn и скрипте interactive_pet.gd темы space-pixel. Нейтральный API полностью описан в SCRIPTING.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. Отделите экранную позицию от визуального масштаба

Рекомендуемая структура сцены:

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/<id>/package/manifest.json укажите production-сцену:

"InteractivePetScene": "res://exchanger_theme_dlc/components/interactive_pet.tscn"

Путь должен быть каноническим и находиться внутри res://exchanger_theme_dlc/. Сама сцена, скрипт, sprite-sheet, звук и другие зависимости должны лежать внутри themes/<id>/package/.

4. Реализуйте базовый animation API

Композитный контракт расширяет обычного питомца, поэтому сначала должны работать базовые методы и сигналы:

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.

5. Добавьте композитный API целиком

Нельзя реализовать только часть интерфейса. Валидатор требует сразу два сигнала и три метода:

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.

Минимальное состояние одного активного запуска:

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 до самого конца общей длительности клипа и сразу переключить анимацию, финальный масштаб формально будет вычислен, но пользователь не увидит его до начала следующего клипа.

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.

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. Количество повторов падения можно вычислить так:

полная длина = путь от текущего 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.

11. Проверьте результат

  1. Активируйте тему: ./activate-theme.ps1 -ThemeId <id>.
  2. Запустите preview и откройте вкладку PET.
  3. Проверьте каждый клип отдельно, включая первый и последний кадр.
  4. Запустите «СОСТАВНОЕ ДЕЙСТВИЕ: INTERACT» несколько раз подряд.
  5. Отмените действие через Esc, переход в галерею и повторный запуск.
  6. Проверьте обычный и reduced_effects сценарии в Exchanger.
  7. Выполните:
./tools/Test-PackagedBindingContract.ps1
./test-theme.ps1 -ThemeId <id>
./build-theme.ps1 -ThemeId <id>

Готовое действие не дёргает 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/.