Files
onboarding-project/docs/worklog/09-day7-vfx-weapon-trail.md
2026-08-14 15:03:43 +03:00

111 lines
11 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 7: VFX — шлейф зброї, доопрацювання ресурсів (2026-08-12)
**Важливо про верифікацію в цій секції і нижче**: користувач явно попросив
припинити самостійні Playwright/агент-перевірки ("Досить самостійно щось
перевіряти і тестувати" — [[feedback-verification-pace]] в пам'яті) —
тож усе нижче перевірено ВИКЛЮЧНО через живий фідбек користувача в грі
(він тестує, повідомляє що не так, я виправляю), а НЕ через автоматичні
скріншоти/Playwright, як було в Day 5/6. Це свідоме рішення, не пропуск.
## `WeaponTrailC.ts` — новий контролер, шлейф-"вітер" за битою під час замаху
Користувач попросив ефект вітру за зброєю під час замаху — hand-rolled
screen-facing ribbon (не `three.quarks`, бо трейл має слідкувати за
bone-driven точкою щокадру, а не грати фіксований дизайнерський burst,
як `PropVfxC`). Технічно: кожен кадр поки `PlayerC.isAttacking` — семплить
world-позицію "кінця бити" (обчислюється з реальної local bounding box
всіх мешів під `Tool_1`, НЕ вгаданий offset — `computeLocalTipOffset()`,
з підстроюваним `TIP_INSET_FRACTION` — трохи не на самий кінець, за
проханням користувача), тримає ring buffer точок з віком, будує ribbon-
геометрію (пара left/right вершин на точку, перпендикуляр = cross(tangent,
viewDirection камери — камера не обертається, тож напрямок погляду
захоплений один раз при `init()`), затухання ширини/кольору за
`FADE_POWER`-кривою (не лінійно — "не таким плавним", різкіший спад),
additive blending, без тон-мапінгу. `PlayerC.updateCombat` викликає
`WeaponTrailC.setActive(true/false)` на початку/кінці атаки.
## `ResourceC.ts` — повний редизайн pickup-анімації (кілька раундів фідбеку)
Стара версія (Day 5): scatter -> settle -> instant collect/fly, з
ротейтом (`SPIN_SPEED`). Користувач попросив: прибрати ротейт, додати
2 баунси від землі, на 2-му баунсі — білий флеш + стиск + вибух зірочок,
потім зникнення. Фінальний стейт-машин: `scatter` -> `bounce` (2 цикли,
`BOUNCE_HEIGHTS`/`BOUNCE_DURATIONS`, index 0->1) -> `impactHold` (флеш +
стиск на 2-му баунсі) -> `toTarget` (політ до HUD).
**⚠️ Знайдений і виправлений баг: флеш-оверлей був зміщений, а не поверх ресурсу**
`buildFlashOverlays()` клонував меш і чіпляв клон як ДИТИНУ САМОГО МЕША
(`mesh.add(overlay)`), а не як сиблінга під тим самим батьком (як робить
`BreakablePropC.buildStageVisual`). `mesh.clone()` копіює ЛОКАЛЬНУ
трансформацію меша (відносно ЙОГО батька) — вставлена на рівень глибше,
та сама трансформація інтерпретувалась вже у ВЛАСНІЙ системі координат
меша, тобто оверлей був зміщений/спотворений, а не коінцидентний з
мешем. **Фікс**: `overlay.position/rotation/scale.set(...)` в identity
одразу після clone(), перед `mesh.add(overlay)`.
**Стиск (squash) — три ітерації**:
1. Squash-and-widen (X/Z шириться, Y стискається, пружинить назад) —
користувач: "ресурси на прикінці анімації збільшуються, мають навпаки
зменшуватись" — розтягнення X/Z понад 1.0 читалось як РІСТ.
2. Uniform shrink (`scale.setScalar(1-shrink)`, без розтягування) —
краще, але користувач ще раз: "має бути одного розміру від початку до
кінця" про `toTarget`-фазу конкретно.
3. **Фінал**: стиск ЛИШЕ на 2-му баунсі (`impactHold`, тримається, не
пружинить назад — "без повернення до початкового розміру"), а сама
`toTarget`-фаза взагалі НЕ чіпає scale (тільки position + opacity
флешу-конвергенції). Одного разу я неправильно прибрав СТИСК НА
БАУНСІ замість того, щоб залишити його — користувач поправив: "Ти
прибрав зміну розміру при баунсі. Я тобі писав не про це" — стиск на
баунсі БУЛО повернено, лишається бажаним ефектом.
**⚠️ Знайдений і виправлений баг: "зміна розміру під час польоту" — насправді перспектива, не scale**
Після всіх фіксів вище користувач далі бачив "зростання" саме під час
`toTarget`-польоту — а код `toTarget` взагалі не чіпав `.scale`. Причина:
камера перспективна, а ціль польоту (`HudC.getWorldAnchorPosition()`)
сидить БЛИЗЬКО до камери (`ANCHOR_DISTANCE=2.5`), тоді як крейт (звідки
ресурс вилітає) — далеко (~12+ одиниць, через `CAMERA_OFFSET=(0,9,-9)`).
Наближення до камери саме по собі візуально збільшує об'єкт (perspective
foreshortening), без жодної зміни `.scale` в коді. **Фікс**: компенсація —
кожен кадр рахую `currentDistance = camera.position.distanceTo(mesh.position)`,
зберігаю `startDistanceFromCamera` (дистанція в момент старту `toTarget`)
і `baseScale` (розмір після баунсу), виставляю
`scale = baseScale * (currentDistance / startDistanceFromCamera)` —
компенсує наближення так, що АПАРЕНТНИЙ (екранний) розмір лишається
сталим, яким був щойно після баунсу, незалежно від реальної world-space
дистанції. **Урок**: "resource visually grows/shrinks" не завжди означає
"хтось десь чіпає .scale" — перспективна камера сама змінює апарентний
розмір при зміні дистанції, це треба явно компенсувати, якщо небажано.
**⚠️ Знайдений і виправлений баг: ціль польоту застигала, якщо камера рухалась**
`resource.to` виставлявся ОДИН РАЗ (`this.flyTarget()`) при вході в
`toTarget` — якщо камера рухалась під час ~0.5с польоту (гравець йде,
camera-follow), ресурс летів у застарілу world-точку, яка вже не
збігалась з реальною позицією іконки на екрані (користувач: "втрачають
потрібну позицію і летять не туди"). **Фікс**: `this.flyTarget()`
викликається ЩОКАДРУ всередині `toTarget`-блоку, не один раз — ресурс
щокадру перецілюється на актуальну позицію.
**Інші правки за фідбеком**: баунси зроблені відчутнішими (висота
`[0.16,0.08]->[0.4,0.22]`); флеш подовжений і РОЗВʼЯЗАНИЙ від швидкості
стиску (`FLASH_DURATION` vs `SHRINK_RAMP_DURATION` — окремі константи,
інакше подовження одного тягне інше); одна спроба зробити політ
"пришвидшеним" (ease-in `t²` замість smoothstep) БУЛА ВІДКОЧЕНА —
причина: `CONVERGE_START_FRACTION`-конвергенція тригериться по часу `t`,
а ease-in робить просторовий прогрес значно повільнішим за часовий,
тож ресурс зникав, не доїхавши до цілі ("зникають раніше часу") —
**урок**: якщо якийсь ефект прив'язаний до сирого `t`, зміна кривої
`eased` для позиції може розсинхронізувати їх — тримати узгодженим або
привʼязувати ефект до фактичного пройденого шляху, не до часу.
## `SparkleFxC.ts` — новий контролер, пул зіркових спалахів
Спочатку — наївна версія (`new Mesh`/`new MeshBasicMaterial` на кожен
вибух, 5 зірок на баунс). Користувач попросив пулінг (Day 7 бриф явно
рекомендує "use pools for VFX", і кожен крейт може дати 2-5 ресурсів,
кожен зі своїм вибухом = 10-25 create+GC циклів за секунду). **Фінал**:
`POOL_SIZE=30` зіркових мешів/матеріалів створюються ОДИН РАЗ в `init()`
(викликається з `TestSceneC`), приховані/scale=0; `spawnBurst()` шукає
вільні слоти (`inUse`), активує (позиція/lookAt/scale/opacity), звільняє
в `fadeOut.onComplete`. Форма зірки — `ShapeGeometry` з 4-промінним
контуром (outer/inner radius alternating), не sprite-атлас (немає такого
ассету в проєкті). Анімація: pop-in (`Back.Out`) -> hold -> fade-out,
через `TweenC` (обидві ланки чейну додані окремо — той самий Day 6 баг з
`.chain()` враховано одразу).