Files
onboarding-project/docs/worklog/02-day6-tween-animations.md
2026-08-14 15:03:43 +03:00

147 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Day 6: Tween Animations — реалізовано (2026-08-11, виправлено того ж дня)
`@tweenjs/tween.js` був установлений, але **ніде не використовувався** до
цього дня. SDK має готову обгортку `TweenC` (`@hitplay/playable_template`,
`import { TweenC } from "@hitplay/playable_template"`) з власною `Group` і
автопідпискою на `UpdateController` — **треба викликати `TweenC.init()`
один раз** (додано в `beforeResourcesLoadedCb.ts`, поруч з
`Physics_internal.init(...)`), інакше `TweenC.add()`/`.create()` тихо
нічого не анімують.
## ⚠️ Знайдений і виправлений баг: `.chain()` НЕ реєструє другу ланку в групі
Перша реалізація хіт-фідбеку й disappear-анімації використовувала
`tweenA.chain(tweenB); TweenC.add(tweenA); tweenA.start();` — і крейти
переставали зникати повністю (застигали розтягнутими на punch-фазі),
а флеш/нахил не виглядав як задуманий ефект (застигав на пікові й лишався).
Причина, перевірена читанням `tween.cjs`: `.chain()` каже tweenA лише
покликати `tweenB.start()` при завершенні — `.start()` ставить
`_isPlaying=true`, але **не додає tweenB в жодну `Group`**. `Group.update()`
ітерує тільки тіли, додані через `Group.add()`, тож tweenB ніколи не
отримує `.update()`, назавжди застигаючи "playing" в останньому кадрі
tweenA. **Виправлення: додавати кожну ланку ланцюжка в групу окремо**
(`TweenC.add(tweenA); TweenC.add(tweenB);` — обидва, до `tweenA.start()`).
Підтверджено repro+fix через Playwright у scratchpad (консоль показувала
`onUpdate` для другої ланки, що ніколи не спрацьовував до фіксу).
`Tween.stop()` **каскадно зупиняє весь `.chain()`**, навіть якщо зараз
виконується вже ланка ланцюжка, а не голова (`stop()` завжди спочатку
кличе `stopChainedTweens()`, до перевірки `_isPlaying`) — це підтверджено
і лишається правильним механізмом "always kill or reuse tweens": досить
тримати посилання на ГОЛОВУ ланцюжка і кликати на ній `.stop()` (саме
зупинку це чіпляє коректно; сама помилка була тільки в реєстрації в групі).
Перед реалізацією користувач попросив **питати по кожному з 4 пунктів**, а
згодом дав ще правки під час перевірки — відповіді й правки (важливі для
майбутніх сесій):
- "Гроші" з Day 6 = те саме дерево, що вже рахує `ResourceC` (не окрема валюта).
- Pay Zone: гейм-дизайн "що буде після" — **не важливо**, лише сам механізм.
- HUD не існує і Day 6 його НЕ додає.
- Hit-feedback: короткий білий флеш ін-аут + нахил у протилежну від удару
сторону і повернення.
- (правка) Гроші мають летіти **прямо з персонажа в Pay Zone** — без
проміжної "UI-точки", яка була в першій версії.
- (правка) Pay Zone потребує **100** зібраних ресурсів, щоб зникнути (не
просто ">0", як було спочатку). (Пізніше значення `RESOURCES_TO_CLOSE_ZONE`
підкручено до **20** — див. секцію "прогрес-заповнення зони" нижче;
сам механізм — "N доставлених закриває зону" — не змінився.)
- (правка) Кожен damage-stage крейта потребує **2 хіти**, не 1.
## 1. Hit feedback + disappearing (крейти) — ✅
`BreakablePropC`: матеріал кожного stage-меша **клонується один раз при
білді** (`buildStageVisual`) — без цього флеш одного крейта підсвітив би
ВСІ 18 крейтів одразу, бо всі стейджі всіх крейтів шарять один Material
з glb (перевірено по індексу матеріалу в самому glb). `HITS_PER_STAGE = 2`
— `hit()` рахує `prop.hitsOnStage`, і тільки на 2-му хіті стейдж
просувається/крейт руйнується; **кожен** хіт (і 1-й, і 2-й, якщо не
руйнує) грає `playHitFeedback` — один `Tween<{t}>` ланцюжком (flash-in
90мс -> flash-out 150мс, **обидві ланки додані в `TweenC` окремо**, див.
баг вище), в `onUpdate` одночасно виставляє `root.rotation` як
`baseRotation + tilt*t` (`baseRotation` теж збережений один раз при білді,
щоб повторні хіти під час ще не завершеного нахилу не накопичували дрейф
кута) і, зараз, **опасіті окремого additive-оверлей-меша** (`flashMesh`,
не лерп `material.color` — це вже пізніша правка, `material.color` на цих
unlit-крейтах виявився тихим no-op, див. секцію "Розширений hit-feedback"
нижче за повним поясненням). `tilt` рахується з `hitDirection`, який
передає `PlayerC` (`hitDirectionTo()` — attacker->target, XZ, нормалізований).
На хіті, що руйнує крейт (2-й хіт останнього стейджу) — окремий ефект
замість флешу/нахилу: **(оновлено пізніше)** зараз це sink+topple —
`root.position.y` занурюється вниз (`DESTROY_SINK_DEPTH`, `Quadratic.In`,
`DESTROY_SINK_MS`) з одночасним нахилом від удару (`DESTROY_TILT_ANGLE`,
той самий `tilt`-принцип, що й у hit-feedback), `root.visible=false`
виставляється в `onComplete`, а НЕ миттєво — фізика/обстакл-клінап
(`physicsBody.destroy()`, видалення з масивів) лишились синхронними в
момент хіта (гравець вже не натикається на нього, поки крейт ще візуально
занурюється). Це заміна першої версії (punch-scale 1 -> 1.15 `Back.Out` ->
0 `Quadratic.In`) — коли й чому саме на sink+topple, в чаті цієї сесії не
зафіксовано; якщо матимеш контекст, допиши сюди.
## 2–3. Money flying out of the player into the Pay Zone — ✅ (два ОКРЕМІ механізми)
Проміжна версія об'єднала "збір ресурсу" і "передачу в Pay Zone" в один
конвеєр, гейтований стоянням в зоні (`ResourceC` мав стейт `holding`, що
чекав `inZoneCheck()` перш ніж рахувати ресурс — тобто лічильник взагалі
не рухався, поки гравець не заходив в зону). Це **зламало базовий збір**:
ресурси лежали на землі й не зникали, якщо гравець ламав ящики далеко від
зони. Користувач уточнив: **збір має працювати як і раніше** (Day 5,
нічого спільного з Pay Zone), а гейтинг по стоянню в зоні має стосуватись
**лише окремого механізму "передачі" вже зібраного в зону**.
Фінальний дизайн (на момент Day 6) — `ResourceC` і `PayZoneC` повністю
незалежні:
- **`ResourceC`** — Day 5 логіка збору (`scatter` -> settle) без жодної
згадки про Pay Zone/гравця/зону — просто "зібрав ресурс". Що саме
відбувається ПІСЛЯ settle (миттєво рахується, чи летить кудись) —
контролюється ззовні через `setFlyTarget()` (додано пізніше, див.
"HUD" нижче — спочатку тут стояв просто `collect()` без польоту).
`createVisual()` — публічний метод, що віддає клон іконки-ресурсу для
чужого використання (зараз юзає `PayZoneC`).
- **`PayZoneC`** — окремо "зливає" вже зібране (`ResourceC.getCollectedCount()`)
в зону, по одному ресурсу за раз, **тільки поки гравець стоїть в зоні**
і є "хвіст" (`collected - deposited > 0`): кожні `DEPOSIT_INTERVAL`
(0.3с) — новий `ResourceC.createVisual()` летить в зону (`Quadratic.InOut`,
500мс) і зникає, `deposited++`. Звідки саме стартує цей політ — теж
змінилось пізніше (див. "HUD" нижче).
## ⚠️ Знайдений і виправлений баг: `isPlayerInside` мав фіксований радіус замалий за фактичний розмір зони
Перша версія `isPlayerInside` рахувала XZ-відстань до пивота ноду й
порівнювала з `ZONE_RADIUS = 2` (підібраним на око зі скріншота). За
фактом гравець міг зібрати 20+ ресурсів, зайти всередину видимого
пунктирного квадрата — і нічого не відбувалось, бо реальний розмір зони
значно більший за коло радіусом 2. Перевірено вимірюванням: `new
Box3().setFromObject(payZoneNode)` дав `size ≈ [5.96, 0.09, 4.60]` (X/Z),
тобто прямокутник ~6×4.6, з центром зсунутим від origin (`min.x≈-2.89,
max.x≈3.06` — не симетрично!). Коло радіусом 2 покривало тільки малу
частку видимого квадрата, переважно по X.
**Виправлення**: `isPlayerInside` тепер рахує `Box3` один раз в `init()`
(`new Box3().setFromObject(payZoneNode)` — бере фактичну геометрію з
урахуванням трансформів, а не вгадану цифру) і перевіряє просте
point-in-rectangle по X/Z (`min <= pos <= max`), ігноруючи Y. Ніяких
магічних констант радіуса більше нема — footprint завжди відповідає
реальній мапі, навіть якщо художник поміняє розмір/форму зони.
**Урок**: для будь-якої майбутньої "чи гравець в зоні X" перевірки —
рахувати `Box3().setFromObject()` з реального ноду, не вгадувати
радіус/розмір на око зі скріншота.
## 4. Payzone disappearing — ✅ (потребує `RESOURCES_TO_CLOSE_ZONE` *доставлених*, не просто зібраних)
`PayZoneC.update()`: коли `deposited >= RESOURCES_TO_CLOSE_ZONE` (значення
на момент Day 6 було 100, зараз в коді **20** — підкручено пізніше, див.
"прогрес-заповнення зони"; в будь-якому разі це "N ресурсів фактично
долетіли в зону", не просто зібрані десь на мапі) — одноразово (`closed`
флаг) запускає `playDisappear()`: `scale 1->0` (`Quadratic.In`, 400мс),
`visible=false` в `onComplete`. Одноразово, назавжди (немає ре-спавну/
циклу — "що буде після" лишається не реалізованим за проханням
користувача). Пізніше (див. "прогрес-заповнення зони") цей самий
`playDisappear` розширений — тепер синхронно стискає ще й fill-overlay
квад, не тільки сам маркер зони.
## Best practices — де застосовано
- **Sequences**: усюди `.chain()` замість ручного стейт-машину (flash in->out,
punch->shrink) — але **обов'язково додавати кожну ланку в `TweenC` group
окремо** (див. баг вище) — `.chain()` сам лише стартує наступну ланку,
не реєструє її для update-тіків.
- **Kill or reuse tweens**: `BreakableProp.hitTween` зберігає голову
ланцюжка; `.stop()` перед кожним новим хітом і при `destroy()` (каскадно
зупиняє й активну ланку, це підтверджено робочим). `PayZoneC`'s
disappear — одноразовий по флагу (`closed`), тому без явного kill.