14 KiB
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) — але обов'язково додавати кожну ланку вTweenCgroup окремо (див. баг вище) —.chain()сам лише стартує наступну ланку, не реєструє її для update-тіків. - Kill or reuse tweens:
BreakableProp.hitTweenзберігає голову ланцюжка;.stop()перед кожним новим хітом і приdestroy()(каскадно зупиняє й активну ланку, це підтверджено робочим).PayZoneC's disappear — одноразовий по флагу (closed), тому без явного kill.