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

14 KiB
Raw Blame History

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.