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.
348 lines
22 KiB
Markdown
348 lines
22 KiB
Markdown
# Композитные анимации питомца: гайд для дизайнера
|
||
|
||
Композитное действие — это не один клип, а сценарий из нескольких анимаций, визуальных
|
||
преобразований и перемещений. Сценарий хранится в теме, поэтому каждый питомец может вести себя
|
||
по-своему, а 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/<id>/package/manifest.json` укажите production-сцену:
|
||
|
||
```json
|
||
"InteractivePetScene": "res://exchanger_theme_dlc/components/interactive_pet.tscn"
|
||
```
|
||
|
||
Путь должен быть каноническим и находиться внутри `res://exchanger_theme_dlc/`. Сама сцена, скрипт,
|
||
sprite-sheet, звук и другие зависимости должны лежать внутри `themes/<id>/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 <id>`.
|
||
2. Запустите preview и откройте вкладку `PET`.
|
||
3. Проверьте каждый клип отдельно, включая первый и последний кадр.
|
||
4. Запустите «СОСТАВНОЕ ДЕЙСТВИЕ: INTERACT» несколько раз подряд.
|
||
5. Отмените действие через `Esc`, переход в галерею и повторный запуск.
|
||
6. Проверьте обычный и `reduced_effects` сценарии в Exchanger.
|
||
7. Выполните:
|
||
|
||
```powershell
|
||
./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/`.
|