Document Day 7/8 work in CLAUDE.md (weapon trail, resource pickup redesign, HUD overhaul)

Was committed stale in the previous push — catches it up with WeaponTrailC,
SparkleFxC pooling, ResourceC's bounce/flash/shrink/perspective-compensation
redesign, the HudC UI overhaul, and PayZoneC's deposit-flight fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Oleksandr Vlasiuk
2026-08-12 18:58:57 +03:00
parent 4a22a9b140
commit 63609eea2c
+270 -19
View File
@@ -27,9 +27,12 @@
src/controllers/
PlayerC.ts - джойстик-рух (nipplejs), AABB-колізії з "colliders",
бій: неперервний AoE-swing по всіх breakable-цілях в
зоні ураження, перебивається рухом
зоні ураження (240°-конус, FACING_DOT_THRESHOLD=-0.5,
Day 7 — див. "Кут ураження"), перебивається рухом
TestSceneC.ts - завантажує ZombiePunk_Map.glb, /collider/i -> hidden+colliders,
ставить cannon Wall-тіла на все, крім Lootable-крейтів
ставить cannon Wall-тіла на все, крім Lootable-крейтів;
ініціалізує всі VFX/HUD-контролери (PropVfxC,
SparkleFxC, WeaponTrailC, HudC) в правильному порядку
CameraFollowC.ts - камера-слідкувач, obstacle-avoidance через Raycaster
по тому ж масиву colliders (НЕ торкались)
PhysicsC.ts - PhysicsLayer enum, PhysicsBody (Box/Sphere+cannon Body
@@ -37,26 +40,63 @@ src/controllers/
BreakablePropC.ts - парсить групу "Lootable" в мапі, S1/S2/S3 damage-stages,
2 хіти на стейдж, hit() advance stage / повне
знищення (tween-джус, Day 6), дропає ресурси
ResourceC.ts - спавн ресурсів "розльотом" від цілі + автозбір
(Day 5, незмінно) — НЕ знає про Pay Zone/гравця;
візуал — клон реального "UI_Wood" з мапи
(fallback-куб про запас), createVisual() — публічний
доступ до цього візуалу для інших контролерів
PayZoneC.ts - Day 6: окремо зливає ResourceC.getCollectedCount() в
WeaponTrailC.ts - Day 7: hand-rolled screen-facing ribbon-шлейф за
битою під час замаху (samples PlayerC.getWeaponAnchor(),
обчислює "кінець бити" з реальної local bbox, не
вгадує offset), тає/звужується за час, не тон-мап,
toggled через PlayerC.updateCombat (isAttacking)
ResourceC.ts - Day 7: спавн ресурсу з крейта -> scatter -> 2 баунси
(відчутні, БЕЗ ротейту) -> на 2-му баунсі: білий
флеш (окремий additive-оверлей-меш, БЕЗ
squash-and-widen — чистий uniform shrink, тримається,
не пружинить назад) + SparkleFxC-вибух зірочок ->
staggered (по черзі, не купою) політ до
HudC.getWorldAnchorPosition() (ціль пере-семплюється
ЩОКАДРУ, не застигає, якщо камера рухається) з
компенсацією перспективи (див. RESOURCE_ICON_BASE_SCALE,
нижче) -> яскравіє й зникає рівно на іконці. НЕ знає
про Pay Zone/гравця; візуал — клон реального
"UI_Wood" з мапи; createVisual() — публічний доступ
до цього візуалу (без баунс/флеш начинки) для інших
контролерів (PayZoneC)
PayZoneC.ts - Day 6/8: окремо зливає ResourceC.getCollectedCount() в
Pay Zone (вузол мапи "UI_Interactive_Zone_02"), по
одному ресурсу, тільки поки гравець стоїть в зоні;
зникає після RESOURCES_TO_CLOSE_ZONE доставлених
(зараз 20, підкручено після Day 6 — див. секцію
"прогрес-заповнення зони"); росте плаский
fill-overlay квад по мірі заповнення; політ депозиту
летить з HudC.getWorldAnchorPosition(), не з гравця
HudC.ts - лічильник ресурсу (іконка+число) у правому верхньому
куті, DOM-оверлей в #ui; володіє "світовою" точкою
(дитина камери), куди летять зібрані ресурси і
звідки стартує політ депозиту в Pay Zone
(зараз 20); росте плаский fill-overlay квад по мірі
заповнення; політ депозиту (HudC.getWorldAnchorPosition()
-> Pay Zone) БЕЗ ротейту, з тією ж компенсацією
перспективи й базовим розміром, що й ResourceC
(RESOURCE_ICON_BASE_SCALE, спільна константа —
інакше токен виглядає більшим за ресурс, що щойно
був на тому ж місці), плюс окремий
DEPOSIT_FLIGHT_SCALE_MULTIPLIER для локального тюнінгу
HudC.ts - Day 8: лічильник ресурсу — реальний дизайнерський
ассет (resourceCounterBgSrc, Tool_15.webp з temp/,
планка вже вбудована в картинку, не окрема іконка),
DOM-оверлей в #ui, стилі в src/css/ui.css (не inline
<style>); getWorldAnchorPosition() — СПРАВЖНЯ
screen-to-world проєкція (getBoundingClientRect()
правого краю плейта + unproject через камеру), НЕ
вгаданий локальний офсет від камери (той підхід
видалений повністю, разом з WORLD_ANCHOR_LOCAL_OFFSET)
SparkleFxC.ts - Day 7: пул із 30 зіркових мешів/матеріалів (pop-in
Back.Out -> hold -> fade-out через TweenC), жодних
нових Mesh/Material під час гри — spawnBurst() бере
вільний слот з пулу; використовується ResourceC на
2-му баунсі
PropVfxC.ts - Day 6: завантажує дизайнерські quarks.art VFX-префаби
(JSON з temp/, sparks/dust/debris) для хіту/знищення
крейтів
ThreeC.ts/CameraC.ts - базовий сетап three.js/камери від SDK
resources/meshes/ - ZombiePunk_Map.glb, ZombiePunk_Character.glb
resources/images/ - icon_wood.webp (Icon_Wood, витягнутий з мапи), images.ts
resources/images/ - resource_counter_bg.webp (Tool_15.webp з temp/,
дизайнерський плейт лічильника), images.ts
(icon_wood.webp/iconWoodSrc видалені — стали
осиротілими після заміни на resourceCounterBgSrc)
resources/vfx/ - VFX_Lootable_Hit.json, VFX_Lootable_Destroy.json
(quarks.art префаби, копії з temp/, реально
використовуються з коду — див. PropVfxC)
```
`TriggerC.ts` (beginContact/endContact -> onEnter/onExit) і кінематичне
@@ -870,8 +910,11 @@ position прямо з GLB node transforms). Жодна з цих гіпотез
- `ResourceC`/`BreakablePropC` — нові файли. `PlayerC.ts`/`TestSceneC.ts`/
`PhysicsC.ts` — модифіковані. `CameraFollowC.ts`/`ThreeC.ts` — не торкались.
`TriggerC.ts` — був створений і видалений в межах цієї ж сесії (див. "Файли").
- Ще не закомічено в git (working tree) — користувач сам вирішує коли
комітити.
- Закомічено й запушено в `origin/master` 2026-08-12 (commit `4a22a9b`,
"Add physics/combat foundation, resource pickup flow, VFX polish, and
HUD") — усе, що описано в цьому файлі до Day 8 включно, вже в репо.
`stats.html` (rollup-visualizer, артефакт білда) свідомо НЕ закомічено —
не вихідний код, генерується заново при білді.
- Якщо наступного разу знову треба буде правити combat: зона ураження —
та сама `INTERACTION_REACH`-аура, що й раніше використовувалась для
single-target пошуку (`findNearbyObstacle`, лишився для косметичного
@@ -908,3 +951,211 @@ position прямо з GLB node transforms). Жодна з цих гіпотез
відсутність зайвих текстур навколо Pay Zone) — стан на кінець Day 5/6;
усе, що сталось після (HUD, камера, VFX, прогрес-бар зони), має власні
"Перевірено" нотатки нижче за текстом.
## 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 `` замість 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()` враховано одразу).
## Day 8: UI — резорс-каунтер (2026-08-12)
Референс — скріншот іншої гри ("Zombie Invasion") з двома стековани
лічильниками (дерево + метал); у нас лише один тип ресурсу, тож зроблено
тільки один лічильник, без плейсхолдера під другий (за проханням
користувача, поки не підтверджено, що другий ресурс дійсно буде).
### Реальний дизайнерський ассет замість CSS-пілла
Користувач: "Tool_15 знайди цей файл в temp. Це юай для каунтера
ресурсу." `temp/Tool_15.webp` (184×71, темна округла плата з діагональною
деревʼяною планкою, вже вбудованою в саму картинку) — скопійований у
`src/resources/images/resource_counter_bg.webp`, підключений через
`images.ts` (`resourceCounterBgSrc`, той самий `ConvertToBase64WhenRelease`-
паттерн). Стара кругла іконка-рамка + окремий `<img>` (з `icon_wood.webp`)
прибрані повністю — планка вже частина фонового зображення.
`icon_wood.webp`/`iconWoodSrc` після цього стали осиротілими і видалені.
### Стилі винесені в `ui.css`, живлені реальним аспектом і CSS-змінною
Користувач: "Всі стилі для юай мають бути в css файлі, у нас є відповідний
файл вже" — весь inline `document.createElement("style")` з `HudC.ts`
прибраний, стилі перенесені в `src/css/ui.css` (вже підключений в
`index.html`). `HudC.ts` лишає в JS тільки `holder.style.backgroundImage`
(шлях до ассету відомий лише в runtime через build-пайплайн, статичний
CSS-файл цього не може). Розмір плейта: спочатку `width`+`height` окремо
в `vh` (руками підібрана пропорція 184:71) — користувач: "не подобається,
що задана і висота і ширина, давай контролювати через аспект рейтіо" ->
`aspect-ratio: 184/71` замість другого числа. Потім: CSS custom property
`--hud-width` на `.resource-hud`, і `font-size: calc(var(--hud-width) *
0.175)` замість фіксованого `vh` — користувач змінював `width`, шрифт не
слідував; тепер один параметр (`--hud-width`) керує і розміром плати, і
шрифтом (успадковується вниз по DOM).
### Дві CSS-анімації замість JS-коду (Day 8 технічна вимога)
"Use built-in animations/transitions instead of changing elements through
code" — (1) `.resource-hud` грає `resource-hud-enter` (fade+slide) один
раз при монтуванні, самостійно, без JS-таймингу; (2) `.resource-hud__plate`
(НЕ сам `.resource-hud` — окремо, дивись нижче чому) грає
`resource-hud-bump` при кожній зміні числа — `HudC.update()` лише
toggle-ить клас `is-bumping` (remove -> force reflow через `offsetWidth`
-> add), сам keyframe рахує браузер.
**⚠️ Знахідка: анімацію не можна вішати на ТОЙ САМИЙ елемент, що вже має іншу**
Спочатку bump-анімація була на `.resource-hud__count` (сам текст) —
користувач: "Анімація має бути на всю юайку не тільки на каунтер". Але
просто перенести на `.resource-hud` (той самий елемент, що вже грає
`resource-hud-enter`) НЕ можна: перемикання класу, що змінює
`animation-name` (навіть тимчасово, назад до того самого значення),
ЗАВЖДИ перезапускає анімацію — тобто кожен збір ресурсу знову
проганяв би fade+slide-in ("з'явлення") поверх бампу. **Фікс**: два
вкладені div — зовнішній `.resource-hud` (позиція/розмір + enter-анімація,
статична), внутрішній `.resource-hud__plate` (фон+число разом,
bump-анімація) — дві незалежні `animation`-лінії на двох елементах.
### `getWorldAnchorPosition()` — справжня screen-to-world проєкція
Раніше (Day "HUD-лічильник"): фіктивний `Object3D`, дитина камери, з
вгаданим локальним офсетом (`WORLD_ANCHOR_LOCAL_OFFSET`, підбирався на
око, документований як "не точний screen-to-world розрахунок").
Користувач: "Треба дістати позицію правого края юайки і направляти
ресурси саме туди." **Фінал**: `getBoundingClientRect()` правого краю
`.resource-hud__plate` (реальний DOM), конвертація в NDC відносно
`ThreeC.renderer.domElement.getBoundingClientRect()` (canvas, НЕ
`window.innerWidth/Height` — SDK рендерить у letterboxed 9:16 бокс,
canvas-rect гарантовано збігається з тим, що бачить камера),
`new Vector3(ndcX,ndcY,0.5).unproject(camera)` -> напрямок від камери ->
точка на фіксованій `ANCHOR_DISTANCE=2.5` вздовж цього променя.
Рахується ЖИВО при кожному викликі (не раз при init) — автоматично
лишається коректним при resize/зміні орієнтації, без окремого resize-
слухача. `Object3D`/`WORLD_ANCHOR_LOCAL_OFFSET`-підхід видалений
повністю.
## PayZoneC: уніфікація депозит-флайту з ResourceC (2026-08-12)
`playDepositFlight` (політ токена з HUD в Pay Zone) використовував
окремий TweenJS-based шлях, не звʼязаний з новою логікою `ResourceC`:
мав ротейт (`mesh.rotation.y += 0.3`) і стартовий `scale=1` (дефолт
свіжого `ResourceC.createVisual()`, без жодного стиску).
- **Ротейт прибрано** повністю (користувач: "ротейту не має бути").
- **Компенсація перспективи** — та ж сама техніка, що й у
`ResourceC.toTarget`, але в ПРОТИЛЕЖНИЙ бік: цей політ стартує БЛИЗЬКО
до камери (HUD-анкер) і летить ДАЛІ (Pay Zone, зазвичай далі від
камери) — без компенсації токен би візуально ЗМЕНШУВАВСЯ на льоту.
- **⚠️ Знайдений і виправлений баг: токен був значно більшим за ресурс з наземної анімації**
Причина: `ResourceC.createVisual()` не проходить через `SHRINK_AMOUNT`-
стиск (той стосується лише "живих" `FlyingResource` з баунсом) — токен
стартував з `scale=1`, тоді як ресурс, який щойно долетів на те саме
місце з `ResourceC`, виглядав як `0.85`. **Фікс**: `RESOURCE_ICON_BASE_SCALE
= 1 - SHRINK_AMOUNT` (0.85) винесений з `ResourceC.ts` як `export`,
`PayZoneC` множить на нього. Плюс окремий `DEPOSIT_FLIGHT_SCALE_MULTIPLIER`
(зараз `1`, користувач підкрутив до `0.3` в UI-сесії) — локальний
тюнер розміру САМЕ цього польоту, не торкаючись спільної константи
(яка вплинула б і на "земля->юай" анімацію).
- **Урок**: коли два різні контролери візуально показують "той самий"
ресурс в різних сценах польоту — тримати спільну константу базового
розміру (`export`), а не дублювати магічне число, інакше вони
розходяться, як тут.