111 lines
11 KiB
Markdown
111 lines
11 KiB
Markdown
# 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()` враховано одразу).
|