11 KiB
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) — три ітерації:
- Squash-and-widen (X/Z шириться, Y стискається, пружинить назад) — користувач: "ресурси на прикінці анімації збільшуються, мають навпаки зменшуватись" — розтягнення X/Z понад 1.0 читалось як РІСТ.
- Uniform shrink (
scale.setScalar(1-shrink), без розтягування) — краще, але користувач ще раз: "має бути одного розміру від початку до кінця" проtoTarget-фазу конкретно. - Фінал: стиск ЛИШЕ на 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() враховано одразу).