Utility and polishing + bag fixes

This commit is contained in:
Oleksandr Vlasiuk
2026-08-14 15:03:43 +03:00
parent 250f4a0d2c
commit 1f72420da0
45 changed files with 2542 additions and 1618 deletions
+146
View File
@@ -0,0 +1,146 @@
# 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.