Files
onboarding-project/CLAUDE.md
T
Oleksandr Vlasiuk 63609eea2c 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>
2026-08-12 18:58:57 +03:00

1162 lines
106 KiB
Markdown
Raw 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.
# MyGamePlayable — контекст проєкту
> Цей файл автоматично підвантажується в кожній сесії Claude Code. Тримай його
> в актуальному стані: онови статуси, коли щось зміниться, видали "Day 5" секцію
> коли вона остаточно перевірена і стабільна, додай нову секцію під наступний день.
## Стек і структура
- **Не Babylon.js** — це **three.js** (`three@0.185`) обгорнутий у пропрієтарний
SDK `@hitplay/playable_template` (плейбл-реклама, HitPlay).
- Фізика: `cannon-es@0.20` + `cannon-es-debugger`. World піднятий через SDK
(`Physics_internal.init(...)` в `src/templateConfig/beforeResourcesLoadedCb.ts:27`),
дебаг-рендер тригериться прапорцем `Template.initConfig({ debug: { physics: false } })`
в `src/index.ts` (поки `false` — увімкнути під час дебагу фізики).
**SDK сам кличе `world.fixedStep()` кожен кадр** (`Physics_internal.update`,
всередині пакету) — свій `world.step()`/`fixedStep()` НЕ викликати, це
вже фіксований таймстеп з коробки.
- `UpdateController.Instance.onUpdate` — єдиний update-event, `delta` в
секундах (з `THREE.Clock.getDelta()`), без фіксованого таймстепу на цьому
рівні (фіксований степ — тільки всередині cannon, через `fixedStep()`).
- `ThreeC.removeFromScene(obj)` існує (просто `obj.removeFromParent()`) —
використовувати для видалення спавнених/знищених мешів.
### Файли
```
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-крейтів;
ініціалізує всі VFX/HUD-контролери (PropVfxC,
SparkleFxC, WeaponTrailC, HudC) в правильному порядку
CameraFollowC.ts - камера-слідкувач, obstacle-avoidance через Raycaster
по тому ж масиву colliders (НЕ торкались)
PhysicsC.ts - PhysicsLayer enum, PhysicsBody (Box/Sphere+cannon Body
з коректним world-space size/rotation), PhysicsObjPair
BreakablePropC.ts - парсить групу "Lootable" в мапі, S1/S2/S3 damage-stages,
2 хіти на стейдж, hit() advance stage / повне
знищення (tween-джус, Day 6), дропає ресурси
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); росте плаский 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/ - 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) і кінематичне
cannon-тіло гравця для тригерів — **були написані й видалені в межах цієї ж
сесії, ще до коміту**: перший дизайн ресурсів вимагав підходити до пікапа й
стояти в тригер-зоні; користувач уточнив, що гравцю підходити не треба
(ресурси самі розлітаються і зараховуються по таймеру), тож фізичні тригери
виявились непотрібні для Day 5. Якщо колись знадобиться proximity-based
тригер для чогось іншого (вода, зона урону, детект ворога) — писати заново,
але сам підхід (`world.addEventListener('beginContact'/'endContact')` +
мапа `body.id -> handlers`) вже перевірений робочим, просто не лишений в коді.
### Ключова знахідка: структура glb вже готова під breaking/loot
Мапа (`ZombiePunk_Map.glb`) містить групу **`Lootable`** (пряма дитина кореня
`Map`) з ~18 дітьми `Wooden_Box_XXX` (XXX = `000`..`017`). Кожен має:
```
Wooden_Box_XXX
BoxCollider.NNN <- obstacle-бокс, він же в загальному /collider/i списку
Wooden_Box_States_XXX
Wooden_Box_XXX_S1 <- найменше пошкоджений (не завжди є)
Wooden_Box_XXX_S2
Wooden_Box_XXX_S3 <- найбільш пошкоджений/уламки (завжди є хоча б цей)
```
Не всі крейти мають усі 3 стейджі — деякі стартують вже пошкодженими
(тільки S2/S3) або взагалі як декоративні уламки (тільки S3). `BreakablePropC`
сортує дітей `..._States_XXX` за номером у назві й показує лише перший —
кожен хіт просуває на наступний, а хіт по останньому стейджу знищує крейт.
Root-рівень gltf-сцени (`getObject("map")` = `gltf.scene`, НЕ сам "Map"-нод)
містить ще 3 сиблінги "Map": `UI_Tool_Zone`, `UI_Interactive_Zone_02`,
`UI_Wood`/`UI_Wood.001` — плоскі quad-меші на позиції origin. В грі це
видно як пунктирний білий квадрат, що лежить на дорозі біля крейтів
(скріншот при першому запуску) плюс текстуру дерева поверх нього.
**`UI_Wood` (mesh `Plane.002`) — це реальний іконка-ресурсу, не сміття.**
Матеріал `Material` (index 6) використовує текстуру з назвою **`Icon_Wood`**
(unlit, alphaMode BLEND, doubleSided) — художник підготував конкретно "іконку
дерева" саме для цього. Тепер підключено: `TestSceneC` бере цей нод,
віддає його в `ResourceC.setPickupTemplate()` (клонується на кожен спавн
ресурсу — `.clone()`) і ховає оригінал (`visible=false`, він більше не
статична декорація, а шаблон для клонування). `ResourceC.createPickupMesh()`
примусово виставляє `clone.visible = true`, бо `.clone()` копіює і
`visible:false` з прихованого оригіналу.
`UI_Wood.001` (mesh `Plane.008`, матеріал `M_Items`/текстура `T_items_icons`
— схоже на спрайт-атлас з кількома іконками) і `UI_Tool_Zone` — призначення
досі не зрозуміле, але користувач попросив прибрати їх як "зайві текстури"
навколо Pay Zone — тепер ховаються в `TestSceneC` (`visible=false`) поруч з
`UI_Wood`. `UI_Interactive_Zone_02` (пунктирний квадрат) — **тепер
підключений** в Day 6 як Pay Zone (див. нижче).
Ще одна знахідка (не сиблінг, а дитина `Map` -> `"UI"`): группа `UI`
(`UI_Background`/`UI_Foreground`/`UI_Middleground`, всі матеріал `_Part2`
— той самий атлас, що й крейти) — окремо повернута група (власний
quaternion), локально приблизно (0.31, 1.21, -1.9), тобто підвішена в
повітрі на висоті голови персонажа. Схоже на фейковий install-button
мокап (типовий playable-ad прийом), рендерився завжди, візуально
"прямокутна синя пластина над плей зоною" з якою скаржився користувач.
Теж ховається тепер (`map.getObjectByName("UI")`, `visible=false`).
## Day 5: Cannon.js — реалізовано (2026-08-11, доопрацьовано того ж дня)
Перша ітерація зробила всі 8 пунктів чек-листа буквально (single-target
дискретний свінг + proximity-тригери на ресурси). Користувач одразу дав
конкретні правки під реальний геймплей-дизайн (враховані в описі нижче) —
**фінальний стан коду відображає ці правки, не буквальний чек-лист**. Білд
(`vite build`) проходить чисто після кожної ітерації. Ручна перевірка в
браузері — **ще не підтверджена користувачем**; я зробив лише один короткий
automated прогін (Playwright, в scratchpad, не в репо) до першої ревізії —
рендер і консоль були чисті, а саме AoE/continuous-attack/resource-burst
поведінку (фінальну версію) користувач попросив перевірити самостійно.
### 1. Колайдери на об'єктах карти — ✅
`TestSceneC.createMap()`: всі `/collider/i` ноди, які НЕ належать `Lootable`,
отримують `PhysicsBody(node, false, 0, PhysicsLayer.Wall, Player|Enemy)`.
Lootable-крейти отримують свої Wall-тіла всередині `BreakablePropC` (щоб
можна було `destroy()` саме це тіло при знищенні крейта, без дублювання).
При ревайві `PhysicsC.PhysicsBody` знайшов і виправив реальний баг:
конструктор рахував size/rotation з `Box3` в world-space, обнуляючи лише
**власний** quaternion об'єкта — обертання батьків (напр. кожен
`Wooden_Box_XXX` root сам повернутий по Y) все одно потрапляло в bbox, тобто
box виходив перекошеним/більшим за реальний. Тепер: size — з
`mesh.geometry.boundingBox` (локальний, без жодних обертань) × world scale,
rotation — з `getWorldQuaternion()`. Коректно для будь-якої глибини вкладеності.
### 2. Колайдери на гравці й персонажах — ✅
Рух гравця **не переписаний** — досі ручний AABB `tryMove()`, як і був. Окреме
cannon-тіло гравця (яке було для тригерів) **видалено** разом з `TriggerC`
(див. вище) — зараз у гравця взагалі немає cannon `Body`, тільки AABB.
### 34. Тригери + start/stop events — ⚠️ зроблено, потім видалено
Був зроблений `TriggerC.ts` на `beginContact`/`endContact`, використовувався
для ресурсів. Після уточнення від користувача ("персонажу не потрібно
підходити для збору") ресурси більше не потребують proximity-тригера —
дивись секцію "Файли" вище. Формально пункт "invisible triggers" з
чек-листа Day 5 **не представлений в фінальному коді** — свідоме рішення
під конкретні вимоги гри, а не пропуск.
### 5. Розбиття пропсів — ✅ (AoE, не single-target)
Атака гравця (`PlayerC.updateCombat`) знаходить **усі** breakable-цілі в
зоні ураження (`findBreakableTargetsInZone` — та сама `INTERACTION_REACH`
AABB-аура навколо гравця, без обмеження кількості цілей) і при кожному
"пульсі" (раз на цикл анімації) хітає їх усі одразу через
`BreakablePropC.hit(prop)` для кожної.
`BreakablePropC.hit(prop)`: якщо є наступний stage — ховає поточний, показує
наступний, дропає 1-2 ресурси (`STAGE_HIT_RESOURCE_RANGE`); якщо це вже
останній stage — дропає 2-5 ресурсів (`DESTROY_RESOURCE_RANGE`), знищує
`physicsBody`, видаляє з `colliders`/`obstacles` (як і раніше) І **ховає
весь `prop.root`** (`root.visible = false`) — крейт зникає повністю, а не
лишається візуально на останньому stage-меші без колайдера.
### 6. Анімація атаки — ✅ (неперервна, перебивається рухом)
`Loot`-кліп — знову `LoopRepeat, Infinity` (НЕ `LoopOnce` — це був перший
варіант, користувач попросив назад неперервний свінг). Логіка в
`updateCombat`:
- Старт: гравець зупинений, є хоч одна breakable-ціль в зоні, і гравець
дивиться на найближчу з них (`isFacing`).
- Поки атакує: рух повністю розблокований (`updateMovement` виконується
щокадру незалежно від `isAttacking` — на відміну від першої версії, де рух
заморожувався на час свінгу). Будь-який стік-інпут -> `stopped=false` ->
атака миттєво скасовується (`equipPistol()`), без "дограти удар".
- Раз на цикл кліпу (на позначці `HIT_TIME_FRACTION`, тобто 50% циклу) —
"пульс" хіта по всіх поточних breakable-цілях в зоні (жива вибірка щокадру,
тож знищені цілі природно випадають з наступного пульсу).
- Зупиняється сама, коли `zoneTargets.length === 0` (усі цілі знищені) —
окремого cooldown між атаками нема, це не дискретний свінг.
### 7–8. Спавн і збір ресурсів — ✅ (розліт + автозбір, без тригерів)
`ResourceC.spawnBurst(origin, count)` / `spawnBurstInRange(origin, min, max)`:
кожен ресурс — клон реального `UI_Wood` (іконка дерева з мапи, див.
"Ключова знахідка" вище; заглушка-куб лишилась тільки на випадок відсутності
цього ноду), що летить від точки спавну по випадковому
напрямку в XZ на `SCATTER_MIN/MAX_DISTANCE` (0.5–1.2) з невеликою дугою
вгору-вниз (`SCATTER_ARC_HEIGHT`) за `FLY_DURATION` (0.45с), потім чекає
`SETTLE_DELAY` (0.5с) на місці і зараховується (`collected++`,
`console.log`) та зникає. **Гравцю не треба підходити** — це чистий
visual+timer ефект, без cannon body/тригера взагалі. Лічильник поки лише в
пам'яті/консолі — немає UI/economy hookup, це прототип.
### Best practices — де застосовано
- **Low-poly shapes**: усюди `Box`/`Sphere`, ніколи трімеш з реальної геометрії.
- **Fixed timestep**: вже було з коробки (`Physics_internal` кличе
`fixedStep()` без аргументів кожен кадр) — нічого додатково не робив.
- **Sync з рендером**: `PhysicsObjPair` (не використовується в Day 5 — нічого
фізика не рухає, ані гравець ані ресурси більше не мають cannon-тіл).
- **Sleep**: НЕ налаштовував `allowSleep`/sleep-ліміти явно — cannon-es має
дефолти (`allowSleep: false` за замовчуванням у `World`!), тобто зараз
**нічого не спить**. З десятками статичних Wall-тіл (тільки для колізій
карти/крейтів, п.1) це навряд чи проблема на цьому масштабі, але якщо буде
помітний perf-хіт — увімкнути `world.allowSleep = true` і виставити
sleep-ліміти на статичних тілах.
- **Debug visualizer**: не трогав прапорець (`debug.physics: false` в
`src/index.ts`) — поставити `true` вручну, коли треба візуально звірити
cannon-боксі з мапою.
- **Collision layers**: використаний існуючий `PhysicsLayer` enum без змін
(хоча `Trigger` тепер ніде не використовується після видалення тригерів).
## 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, в чаті цієї сесії не
зафіксовано; якщо матимеш контекст, допиши сюди.
### 23. 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.
## HUD-лічильник + перенаправлення анімацій (2026-08-12)
Користувач попросив: (1) справжню 2D UI-іконку ресурсу з лічильником у
правому верхньому куті, "як на скріні" (темна округла табличка + іконка в
круглій рамці зліва + число справа), (2) ресурс замість миттєвого
зникнення має анімовано летіти ДО цього лічильника, (3) політ у Pay Zone
має бути ВІД лічильника до зони (не від гравця).
### Іконка
Витягнув сам PNG/WebP з тієї ж текстури `Icon_Wood`, яку вже юзає 3D-меш
`UI_Wood` (`src/resources/meshes/ZombiePunk_Map.glb`, `bufferView` з
`images[6]`) — маленьким одноразовим Node-скриптом (парсинг glb JSON-чанка,
пошук `images[].name === "Icon_Wood"`, зріз байтів з BIN-чанка за
`bufferViews[bufferView]`). Зберіг як `src/resources/images/icon_wood.webp`
(webp з альфа-каналом, 512×512). Новий `src/resources/images/images.ts`
експортує `iconWoodSrc = ConvertToBase64WhenRelease("./icon_wood.webp")`
**той самий паттерн, що і `meshes.ts`** (той же helper з `@hitplay/ads_common`,
викликаний з файлу в ТІЙ САМІЙ директорії, що й ассет — важливо: цей
хелпер в dev/build режимі просто рядково замінює провідний `.` на
`resources` (`ConvertToBase64WhenRelease.js`), а окремий AST-плагін
(`convertToBase64InAST`), підключений через `defineConfigTemplate` в
`vite.config.js`, на build-time переписує сам виклик у справжній inline
`data:...;base64,...` — це працює для ВСІХ режимів (dev/build/export), не
тільки для "export").
### HudC.ts — новий контролер
DOM-паттерн підглянутий у `InstallBanner` з SDK (немає спільного
DOM-builder helper-а в пакетах, усе руками через `document.createElement`
+ власний `<style>`, що вставляється в `<head>`): статичний клас, монтує
DOM-елемент у `#ui` (SDK-контейнер, `position:absolute`, зафіксований
9:16 blocks — `calc(100vh*9/16)` × `100vh`, центрований — це і є "екран
гри", не весь браузер, тож `top/right` у % рахуються відносно НЬОГО).
`.resource-hud { pointer-events:none }` — щоб не перехоплював тач/клік від
джойстика під ним. Стиль — власний CSS, вигаданий (в проєкті НЕ було
жодного готового "плата/банер" стилю для запозичення — перевірено, в
`ui.css`/`main.css` нуль хітів на `border-radius`).
Другий обов'язок `HudC`**світова точка** (`worldAnchor`, порожній
`Object3D`, дитина камери через `CameraC_internal.getCamera().add(...)`,
локальний офсет — спочатку `(1.0, 0.8, -2.2)`, потім підкручено до
`(1.0, 1.2, -2.0)` (див. нижче, чому)), яка приблизно проєктується туди,
де візуально сидить DOM-іконка. **Це не точний screen-to-world розрахунок**
(FOV/aspect не враховані математично) — просто підібраний на око офсет;
якщо HUD переїде/зміниться розмір екрану, можливо треба підкрутити
координати ще раз. `getWorldAnchorPosition()` — це і є точка, куди тепер
летять ресурси (`ResourceC.setFlyTarget`) і звідки стартує депозит-політ
(`PayZoneC.playDepositFlight`).
### Що змінилось у ResourceC/PayZoneC
- `ResourceC`: `scatter` -> settle -> (якщо `flyTarget` заданий, а тепер
він завжди заданий — `HudC.getWorldAnchorPosition`) новий стейт
`toTarget` (0.5с, smoothstep + згасаюча дуга вгору, прямо до лічильника)
-> `collect()`. Без `flyTarget` — стара миттєва поведінка (fallback).
**На відміну від попередньої версії — НЕ телепортується на позицію
гравця перед польотом** (той крок був заточений під "летить З гравця",
зараз відповідь користувача про сам HUD не згадувала цей крок, тож
прибрав його — політ іде прямо з місця, де ресурс осів, до лічильника).
- `PayZoneC.playDepositFlight`: `from` тепер `HudC.getWorldAnchorPosition()`
замість позиції гравця — "від лічильника до пей зони", як попросив
користувач. Тригер (proximity до зони) не змінився — усе ще потребує
стояння гравця в зоні, змінилась лише точка ВИЛЬОТУ візуалу.
Перевірено в браузері (Playwright, scratchpad): іконка рендериться коректно
(512×512 webp, валідний `data:` URI, `naturalWidth`/`naturalHeight` не 0),
лічильник в DOM оновлюється синхронно з `console.log`-ами збору (звірено
скріншотом — "8" на екрані == 8-й `[ResourceC] gathered wood` в консолі).
### Доопрацювання: лічильник має бути "живим балансом", не lifetime-total (2026-08-12)
Спочатку `HudC` показував `ResourceC.getCollectedCount()` напряму — тобто
lifetime-суму, яка ТІЛЬКИ росте (депозит в Pay Zone на неї не впливав).
Користувач попросив: число має рости при зборі І **зменшуватись при
депозиті** — тобто показувати поточний "баланс", а не історичний тотал.
Розв'язання без нової мутабельної змінної (і без циклічного імпорту —
`PayZoneC` вже імпортує `HudC` для `getWorldAnchorPosition()`, тож
`HudC` імпортувати `PayZoneC` напряму означало б цикл):
- `PayZoneC.getDepositedCount()` — новий публічний гетер, повертає
внутрішній `deposited` (лічильник, який і раніше рахував "скільки вже
влетіло в зону", просто не був назовні доступний).
- `HudC.setBalanceGetter(fn)` — інжектований гетер (той самий паттерн, що
й `ResourceC.setFlyTarget`/`onDestroyed` в `BreakablePropC`), замінив
пряму залежність `HudC -> ResourceC`. `HudC` більше НЕ імпортує
`ResourceC` взагалі.
- `TestSceneC` зв'язує: `HudC.setBalanceGetter(() =>
ResourceC.getCollectedCount() - PayZoneC.getDepositedCount())` — чиста
похідна величина, рахується на льоту щокадру в `HudC.update()`, без
жодного окремого "decrement"-виклику. Росте коли `collected` росте
(щось зібрали), падає коли `deposited` росте (щось долетіло в зону) —
саме по собі, без спеціальної синхронізації.
Перевірено: зібрав 7 ресурсів (консоль: `gathered wood (7 total)`), весь
цей час стояв в Pay Zone -> усі 7 злились в зону протягом ~2.1с
(`DEPOSIT_INTERVAL=0.3` × 7) -> лічильник на екрані повернувся до **0**,
хоча `collected` лишився 7 — підтверджує, що баланс дійсно "живий", а не
lifetime-сума.
### Доопрацювання: точка вильоту ресурсів була занизько (2026-08-12)
`WORLD_ANCHOR_LOCAL_OFFSET` підняли з `(1.0, 0.8, -2.2)` до
`(1.0, 1.2, -2.0)` — більший Y (вище в camera-space) і трохи менший |Z|
(ближче до камери, тому той самий Y дає більше вертикальне зміщення на
екрані через перспективу) — все ще підібрано на око, не розраховано
математично з FOV/aspect. **Це найбільш "на око" підібрана частина
роботи — якщо після цього фіксу політ все ще не влучає точно в іконку,
підкрутити ці три числа ще раз** (в `HudC.ts`).
## Камера: прибрано `lookAt`, лінійне зміщення замість орбіти (2026-08-12)
Фідбек від ментора користувача: замість `lookAt`-based слідкування камери
за персонажем — краще визначити точку біля персонажа і зміщувати камеру
відносно неї, залежно від напрямку погляду персонажа. Уточнення від
користувача: **без orbit-behind** — це має бути суто лінійне зміщення
позиції, кут камери взагалі не повинен обертатись.
### Що було
`CameraFollowC.update()` щокадру: (1) демпінгував позицію камери до
`targetPosition + offset + lookAhead` (це вже було чисте лінійне
зміщення — `lookAhead` — той самий "зсув в напрямку погляду персонажа",
про який казав ментор, він вже існував), (2) **окремо** демпінгував
`smoothedLookAt`-точку і кожен кадр робив `camera.lookAt(smoothedLookAt)`
— тобто поворот перераховувався з нуля щокадру з двох незалежно
згладжених точок, а не одного узгодженого стану.
### Що зроблено (перша ітерація)
- Прибрано `smoothedLookAt`, `LOOK_DAMPING`, і сам виклик `camera.lookAt()`
з `update()` повністю. Позиційна частина (`offset` + `lookAhead` +
obstacle-avoidance raycast) лишилась незмінною — вона й раніше була
чистим translation, без жодного обертання.
- Поворот камери відтепер **виставляється один раз** в `init()`:
`camera.position.copy(target.position).add(offset); camera.lookAt(target.position);`
і після цього `update()` більше НІКОЛИ не торкається `camera.rotation`/
`camera.quaternion`. Кут "заморожений" геометрично на старті й лишається
таким назавжди, скільки б персонаж не розвертався.
### Доопрацювання: навіть без обертання камери відчувався рух "по колу" (2026-08-12)
Користувач: коли персонаж РОЗВЕРТАЄТЬСЯ (не рухаючись при цьому), камера
все ще відчутно "йде по колу". Причина — той самий `lookAhead`-вектор, що
лишили в першій ітерації: він рахувався з напрямку, куди персонаж
**дивиться** (`facing`, з `target.quaternion`), а не куди рухається.
Навіть згладжений (`LOOK_AHEAD_DAMPING`), він все одно ОБЕРТАЄТЬСЯ разом
з поворотом персонажа (наприклад, коли той розвертається на місці, щоб
глянути на ящик) — а що обертається, те й тягне позицію камери по дузі
навколо персонажа, хай і невеликій. Користувач хотів буквально "2 точки і
пряма між ними" — жодної залежності від напрямку погляду.
**Перша спроба**: прибрав `lookAhead` повністю (`desiredPosition =
targetPosition + offset`, без жодного додаткового вектора). Круговий рух
дійсно зник, але користувач одразу зауважив: зникло й саме зміщення —
"раніше зміщення було підходяще, єдина проблема була в тому, що воно йшло
по колу". Тобто магнітуда/факт панорамування був потрібен, просто НЕ
прив'язаний до повороту.
**Фінальне виправлення**: `lookAhead` вернув, але тепер рахується з
**реального зміщення позиції персонажа між кадрами**
(`targetPosition - previousTargetPosition`), а не з `target.quaternion`.
`previousTargetPosition` — нове поле, оновлюється щокадру. Якщо кадровий
рух менший за `MOVEMENT_EPSILON_SQ` (стоїть на місці — байдуже, як
розвернутий) — цільовий look-ahead = нульовий вектор; якщо рухається —
нормалізований напрямок руху × `LOOK_AHEAD_DISTANCE` (та сама магнітуда,
що й була). Обидва варіанти йдуть через той самий `smoothedLookAhead.lerp`
з `LOOK_AHEAD_DAMPING`, як і раніше — тільки джерело напрямку інше.
Результат: розворот на місці = нуль руху = камера взагалі не рухається;
ходьба = той самий пан вперед, що й був до всіх цих правок.
**Урок**: "згладжений вектор, що обертається з об'єктом" все одно
читається як орбітальний рух — згладжування прибирає різкість, а не
кривизну; правильний фікс — прив'язати джерело напрямку до РУХУ
(position delta), а не до facing/quaternion, а не просто видалити ефект.
Перевірено в браузері (Playwright) — рендер коректний з першого кадру і
після ходьби/бою, без console-помилок.
### Доопрацювання ×3: зовсім не відчувалось, тримати зсув, миттєвий розворот на 180° (2026-08-12)
Після руху-based lookahead користувач: "Немає зміщення взагалі". Причина —
не баг у логіці (перевірив логуванням, числа рахувались вірно), а те, що
**стара `lookAt`-версія давала подвійний ефект** (і зсув позиції, і
доворот камери в той же бік щокадру), а зараз лишився тільки зсув позиції
— і сама позиційна складова (`LOOK_AHEAD_DISTANCE=1.2`) була занадто
малою, щоб щось означати з відстані ~12.7 од. (`(0,9,-9)` риг). Підняв до
`4`, користувач сам потюнив назад до **`2`** (лишив цю зміну, не
відкатувати).
Ще дві правки в тому ж повідомленні:
- **"Камера після зсуву не має повертатись назад"** — `desiredLookAhead`
тепер **окреме поле**, не локальна константа: оновлюється (`.copy(movement)...`)
ТІЛЬКИ коли `movement.lengthSq() > MOVEMENT_EPSILON_SQ`, і просто НЕ
чіпається, коли персонаж стоїть — замість `: new Vector3()` (нуль) в
тернарному операторі, як було. Тобто пан тримається на місці, де
зупинився, а не з'їжджає назад до центру, поки не з'явиться новий, ІНШИЙ
напрямок руху.
- **"Зроби анімацію плавнішою"** — `LOOK_AHEAD_DAMPING` знижений з `2` до
`1.2` (повільніший ease).
- **"Персонаж має розвертатись моментально, якщо ми змінюємо напрям на
протилежний"** — це вже не про камеру, а про `PlayerC` (`updateMovement`):
новий `OPPOSITE_TURN_THRESHOLD = Math.PI * (150/180)` (~150°). Якщо кут
між поточним і цільовим facing (`quaternion.angleTo(...)`) перевищує цей
порог — `quaternion.copy(facingRotation)` миттєво, інакше — старий
капований `rotateTowards(facingRotation, MAX_TURN_SPEED * delta)`. Тобто
тільки різкі розвороти "в протилежну сторону" миттєві, дрібні корекції
курсу лишаються плавними, як і були.
### ⚠️ Знахідка: `camera_rotation_p`/`camera_rotation_l` (дизайнерський конфіг) виявився нежиттєздатним самостійно
Спочатку спробував залишити кут камери таким, яким його виставляє
`CameraC.setCamera()` з конфіг-параметрів `camera_rotation_p`/`_l`
(дизайнер може їх тюнити через UI) — просто прибравши `lookAt` і НІЧОГО
більше не додаючи. Результат: **порожнє темно-синє небо**, ні мапи, ні
персонажа в кадрі. Причина: цей конфіг-кут ніколи насправді не був
призначений працювати самостійно — `CameraFollowC` з першого ж комміту,
що його додав, миттєво перезаписував поворот через `lookAt()` щокадру,
тож ніхто й не міг помітити (чи потребу) тюнити `camera_rotation_p` під
реальний рух — він був "мертвим" параметром, що впливав щонайбільше на
один кадр до першого `update()`.
**Виправлення**: не покладатись на цей конфіг взагалі для follow-камери —
рахувати фіксований кут геометрично з реального `offset` (`(0,9,-9)`) в
момент `CameraFollowC.init()` (описано вище). Це гарантовано framing
персонажа з самого старту, незалежно від того, що зараз стоїть в
`camera_rotation_p`. **Урок**: значення з `configUIParams`/`globalSettings`
не завжди відображають те, що реально відбувається в грі — перевіряти,
чи щось інше (як тут `CameraFollowC`) не перезаписує їх одразу після
встановлення, перш ніж покладатись на них як на джерело правди.
Перевірено в браузері (Playwright): після фіксу сцена рендериться коректно
з самого старту (мапа/персонаж/крейти на місці, кут ідентичний
попередньому вигляду), і лишається так само коректно framed після
ходьби/бою/розвороту персонажа — камера просто зсувається лінійно, кут
не змінюється.
## Камера підлаштовується під facing-lock на крейт (2026-08-12)
Користувач: коли персонаж зупиняється біля ящика і "тригер збору"
(`PlayerC.faceTowards`, викликається з `updateMovement` коли `stopped &&
nearbyObstacle`) розвертає його лицем до цілі, камера має підлаштовуватись
під новий кут, а не лишатись байдужою. Це саме той кейс, який попередній
рух-based `lookAhead` **свідомо** ігнорував (розворот на місці = нульовий
`movement` = look-ahead не оновлюється) — правильна поведінка для
довільного розвороту, але користувач хотів виключення саме для facing-lock
на breakable-ціль.
**Рішення без повернення до facing/quaternion-залежності** (яка й дала
"рух по колу" раніше): `PlayerC` тепер зберігає, на яку саме ТОЧКУ (не
кут!) він зараз locked — `facingTarget: Vector3 | null`, виставляється в
`updateMovement()` в той самий момент, коли викликається `faceTowards()`
(і скидається в `null`, коли умова `stopped && nearbyObstacle` неправдива).
Публічний `PlayerC.getFacingTarget()`.
`CameraFollowC.setFacingTargetGetter(getter)` — injected getter (той самий
DI-паттерн, що й `ResourceC.setFlyTarget`), підключено в `TestSceneC.init()`
відразу після `CameraFollowC.init()`. В `update()`: якщо `facingTarget` не
`null` — `desiredLookAhead` рахується як напрямок від гравця ДО цієї точки
(XZ, нормалізований) × `LOOK_AHEAD_DISTANCE`, замість напрямку руху; інакше
— стара логіка (рух або hold). Ключове: джерело — фіксована ТОЧКА в
world-space (`nearbyObstacle.center`), а не обертовий вектор — поки гравець
і ящик обидва стоять на місці, цей напрямок сам по собі константний, тож
`smoothedLookAhead.lerp(...)` дає один плавний лінійний зсув до нової
позиції й ЗУПИНЯЄТЬСЯ там, а не описує дугу (на відміну від
facing/quaternion-based версії, яка оберталась разом з тілом персонажа).
Перевірено (Playwright, тимчасове логування `window.__dbgFacing`/
`__dbgLookAhead`, видалене після): після зупинки біля крейта
`facingTarget` стає non-null, і `smoothedLookAhead` плавно зсувається від
~`(-0.02, 1.46)` до нового значення (`(-1.03, 1.08)` і триває сходитись) —
камера реально підлаштовується, а не стоїть на місці.
## Два хіти за один цикл анімації атаки (2026-08-12)
Користувач: анімація "Loot" (удар битою) сама показує **2 фізичні удари**
за один цикл кліпу, але код досі рахував лише **1** damage-пульс за цикл
(`HIT_TIME_FRACTION=0.5`, раз на `attackAction.getClip().duration`) — тобто
`HITS_PER_STAGE=2` вимагав 2 повних циклу анімації на просування стейджа,
хоча мало вистачати одного.
**Виправлення**: `HIT_TIME_FRACTION` (одне число) замінено на
`HIT_TIME_FRACTIONS = [0.25, 0.75]` (масив — дві точки за цикл; підібрані
навмання як симетричний дефолт, можуть потребувати підстройки під реальний
тайминг замаху в кліпі, як і `LOOK_AHEAD_DISTANCE` раніше). Новий
`PlayerC.hitsThisAttack` (рахує ВСІ хіти за весь безперервний свінг, не
скидається щоцикл) + `hitTimeFor(hitIndex)` — рахує абсолютний час
(секунди від старту атаки) для N-го хіта: `duration * (повних_циклів +
HIT_TIME_FRACTIONS[hitIndex % 2])`, тобто коректно переходить через межу
циклу без дрейфу.
Перевірено (Playwright, тимчасове логування `[dbgHit]` з таймстемпом,
видалене після): хіти йдуть з рівним інтервалом ~780мс (замість колишніх
~2×780=1560мс), і стейдж (`prop.stageIndex`) просувається після кожних 2
хітів (`0,1` -> stage++), тобто рівно один цикл анімації = один
просунутий стейдж, як і треба.
## Розширений hit-feedback на крейтах: спарки, HP-бар, числа урону (2026-08-12)
Користувач показав референс-скріншоти з іншої гри (damage-numbers, HP-бар,
іскри в точці контакту) і сказав, що зараз у нас із фідбеку на хіт **лише
невеликий нахил** — флеш кольору, який мав бути з Day 6, візуально не
працює. Плейл поступово (по одному ефекту, з перевіркою між кроками).
### ⚠️ Знайдений і виправлений баг: флеш кольору був тихим no-op з самого Day 6
`playHitFeedback` лерпив `material.color` від `baseColor` до `FLASH_COLOR`
(білий) — але залогувавши реальний матеріал crate-мешів (`MeshBasicMaterial`,
`color: #ffffff`, без `emissive` — це не Standard/Phong), виявилось, що
**`baseColor` вже й був чистим білим**. Текстура дає весь реальний колір,
тінт-колір лишався на дефолтному білому — лерп "білий -> білий" не змінює
візуально НІЧОГО, tween виконувався справно (перевірено таймингом), просто
результат був непомітний. `MeshBasicMaterial` — unlit, `color` множиться на
текстуру, тож "яскравіше за білий" через цей канал взагалі неможливо.
**Виправлення**: замість лерпу `.color` — `StageVisual.flashMesh`, клон тієї
самої mesh-геометрії (`mesh.clone()`), покладений зверху з власним
`MeshBasicMaterial({ color: FLASH_COLOR, transparent: true, opacity: 0,
blending: AdditiveBlending, depthWrite: false })`. Флеш тепер анімує
`flashMesh.material.opacity` (0→1→0) замість кольору базового меша —
працює незалежно від того, який в оригінала колір/текстура, бо це
адитивний шар зверху, а не множник. **Побічний ефект**: `flashMesh` — це
ще один mesh під `map`, тож `TestSceneC`'s `ThreeC.setShadowsStateForChildren
(map, true, true)` (виконується ПІСЛЯ `BreakablePropC.init()`) увімкнув би
йому shadows теж — додано `BreakablePropC.disableFlashShadows()`, викликаний
з `TestSceneC` одразу після цього рядка, щоб вимкнути назад (даремний
shadow-caster на майже завжди прозорому оверлеї).
Перевірено (Playwright, серія скріншотів кожні 80мс під час бою): крейт у
кадрі точно на позначці очікуваного хіта помітно біліший/яскравіший, ніж у
кадрах до/після — флеш реально видно, на відміну від попередньої версії.
**Урок**: тінт-колір (`material.color`) на unlit-текстурованому меші не дає
"флешу", якщо колір вже білий (звичайний дефолт) — для видимого
hit-flash-у на такому матеріалі потрібен окремий адитивний оверлей-шар, а
не лерп самого кольору. Якщо десь ще знадобиться подібний ефект на
текстурованому unlit-меші — той самий підхід (clone + additive overlay).
### Іскри в точці контакту — v1 hand-rolled, замінено (2026-08-12)
Перша версія — новий `SparkFxC.ts`, ручно зібраний `ParticleSystem` (білі
квадратні частинки, `SphereEmitter(radius:0.05)`, звичайний `BillBoard`).
Технічно працював (Playwright підтвердив спалах у момент хіта), але
користувач подивився і сказав: **"Іскри не підходять, це мають бути як
промені. Саме такі як на скріні"** — потрібні тонкі промені-стріки, не
блоб з крапок. Перебудував рендер на `RenderMode.StretchedBillBoard`
(розтягує квад уздовж власної швидкості частинки — це і дає "промінь", а
не крапку) + `speedFactor`, `ColorOverLife`+`Gradient` для згасання альфи.
Це вже виглядало краще, але тут користувач відкрив `temp/VFX_Lootable_Destroy.json`
у редакторі й запитав, що в цих файлах — і виявилось, що це змінює все.
### ⚠️ Знахідка: в `temp/` лежали готові дизайнерські VFX-префаби, повністю невикористані
`temp/VFX_Lootable_Hit.json`, `temp/VFX_Lootable_Destroy.json` (+
`PassionOne-Black.otf` — шрифт, ще не використаний, ймовірно для майбутніх
чисел урону/HUD) — це **справжні експорти з редактора quarks.art**
(`Object3D.toJSON()` на `Group` з `ParticleEmitter`-дітьми, кожен зі своїм
готовим, підібраним дизайнером `ps` — shape/speed/color/behaviors, і
реальними текстурами, вбудованими як base64: `cfxr stretch smoke arc
dissolve.webp`, `novalines.webp` — назва "novalines" **буквально "лінії
нової" = промені-риски**, це саме те, що користувач мав на увазі скріном).
`VFX_Lootable_Hit` = 2 емітери ("StretchSmoke" + "SharpImpact" — це і є
ray-burst). `VFX_Lootable_Destroy` = 4 емітери (дим, уламки-Pieces,
Ground_Dirt як реальна 3D-mesh геометрія (`renderMode: Mesh`), пилова хмара).
**Жодного SDK/іншого коду проєкту ці файли не використовували взагалі** —
чисто сирі ассети, що чекали на підключення. Замінив увесь ручний
`SparkFxC` на завантаження цих реальних префабів — жодна власноруч
підібрана крива/швидкість не заміняє справжній дизайнерський ассет.
**Що таке VFX**: "visual effects" — тут конкретно означає one-shot
частинкові ефекти (спалахи/дим/уламки), авторовані окремо в
редакторі quarks.art і експортовані як самодостатній JSON (геометрія +
матеріали + текстури-base64 + вже налаштовані `ParticleSystem` параметри
для кожного емітера) — не код, чистий даних-ассет, який рушій (`three.quarks`)
вміє розпарсити назад у робочі `Object3D`.
### `PropVfxC.ts` — новий контролер, замінив `SparkFxC.ts`
Завантажує обидва префаби ОДИН РАЗ при `init()` через сирий
`QuarksLoader` (`three.quarks`) + `Template3d.manager` (`@hitplay/playable_template`),
**НЕ** через SDK-хелпер `quarksLoader()`/`ConvertToBase64WhenRelease`
(паттерн `meshes.ts`/`images.ts`) — свідомо, з причини:
**⚠️ Чому не той самий паттерн, що і images/meshes**: `quarksLoader(base64String)`
жорстко очікує рядок формату `data:...;base64,XXXX` (робить
`base64String.split(",")[1]` потім `atob(...)`). Це працює лише ПІСЛЯ
build-time AST-трансформації (`convertToBase64InAST`, підключена в
`vite.config.js`), яка й замінює виклик `ConvertToBase64WhenRelease()` на
реальний base64 рядок. У **звичайному `vite dev`** (як я весь час тестую
через Playwright!) сам хелпер — лише прохідний passthrough, що повертає
ГОЛИЙ ШЛЯХ (`"resources/vfx/....json"`) без коми — `.split(",")[1]` дав би
`undefined`, і `atob(undefined)` зламав би завантаження саме в dev-режимі,
хоча в build/export він працював би. Замість цього ризику — звичайний
Vite JSON-імпорт (`import json from "../resources/vfx/VFX_Lootable_Hit.json"`),
який Vite вміє інлайнити нативно в БУДЬ-ЯКОМУ режимі (dev/build/export)
без жодного fetch — і сирий `new QuarksLoader(Template3d.manager).parse(json)`
(синхронний, повертає `Object3D` напряму, `onLoad` callback не потрібен,
бо всі текстури вже base64 в самому JSON, немає мережевого чекання).
Додав `"resolveJsonModule": true` в `tsconfig.json` (був відсутній) — без
цього `tsc` (не сам vite/esbuild — ті й так все розуміють) скаржився б на
JSON-імпорт типами.
`PropVfxC.spawnHit(point)`/`spawnDestroy(point)` — клонують відповідний
завантажений шаблон (`.clone()` на `Group`), виставляють `position`,
форсують `QuarksUtil.setAutoDestroy(instance, true)` (в оригінальних
префабах `autoDestroy: false` — дизайнер, ймовірно, керував завершенням
вручну в своєму інструменті; нам потрібен чистий "one-shot і забути"),
`QuarksUtil.addToBatchRenderer(instance, renderer)` (реєструє КОЖЕН
`ParticleEmitter` в дереві з рендерером — без цього нічого не малюється),
`QuarksUtil.play(instance)`. Кожен `ParticleSystem` з `autoDestroy`
прибирає СЕБЕ (свій `ParticleEmitter`-нод) з дерева, коли його частинки
померли — але порожній корінь-`Group`, який я додав в сцену, сам не
видаляється (нема кому), тож прибираю його окремим `setTimeout`
(`CLEANUP_DELAY_MS=1500`, з запасом під найдовший `duration+life` в обох
префабах).
`BreakablePropC.playHitFeedback` кличе `PropVfxC.spawnHit(contactPoint)` на
кожному хіті (як і раніше з `SparkFxC`); `BreakablePropC.destroy()`
додатково кличе `PropVfxC.spawnDestroy(...)` в позиції крейта — цього не
було в hand-rolled версії взагалі (не було Destroy-ефекту), додав одразу,
бо це буквально друга половина того самого знайденого ассета.
### ⚠️ Знахідка (потребує рішення користувача, ще не виправлено): "StretchSmoke"-шар вилітає далеко від крейта і виглядає як артефакт
Перевірено в браузері (Playwright): "SharpImpact" (промені/nova-спалах,
`novalines.webp`) рендериться коректно ПРЯМО на крейті в момент хіта —
саме той ефект, що відповідає скріну користувача. Але другий емітер того
самого префаба, "StretchSmoke" (`startSpeed:14`, `worldSpace:true`, життя
0.2-0.3с — тобто до ~4 world units прольоту по прямій!), у нашій сцені
летить ДАЛЕКО від крейта (видно на скріні як сірувата паличка десь у
верхній частині екрана, повністю відірвана від точки удару) — швидше за
все, дизайнер тюнив цей ассет під інший масштаб сцени/камери. Це
відтворюється на КОЖНОМУ хіті (не витік/застигла частинка — просто кожен
новий хіт заново вилітає в приблизно те саме "неправильне" місце).
**Ще не виправлено** — варіанти: (а) лишити як є (автентичний ассет), (б)
притлумити цей конкретний емітер (зменшити `startSpeed`/життя клоном
префаба перед грою), (в) прибрати "StretchSmoke" зовсім і лишити тільки
"SharpImpact". Потребує візуальної перевірки користувачем — не вирішував
сам, це пряма зміна дизайнерського ассета.
Destroy VFX (`VFX_Lootable_Destroy`, 4 емітери включно з mesh-based
"Ground_Dirt") підключений і не кидає помилок в консолі, але **ще не
підтверджений візуально** — не вдалось надійно спричинити повне
знищення крейта в тестовому прогоні за відведений час.
### Наступні кроки (ще не реалізовано, чекає на пріоритети користувача)
HP-бар (потребує рішення: дискретні стейджі vs. реальний HP-пул?) і числа
урону (DOM чи sprite в 3D-просторі? теж потребує реального numeric HP,
якого зараз нема) — ще не почато. `PassionOne-Black.otf` (в `temp/`,
ще не імпортований) — ймовірно призначений саме для цього.
## PayZoneC: прогрес-заповнення зони + два реальні знайдені баги (2026-08-12)
Користувач попросив візуальний індикатор заповненості Pay Zone (скільки з
`RESOURCES_TO_CLOSE_ZONE` вже занесено). Три ітерації дизайну (кожна —
реальна правка коду, не просто обговорення):
1. Горизонтальний квад на землі, що росте вздовж world X — користувач:
"заповнюється... з права на ліво" (не те, що хотів).
2. Box, що росте вгору по Y (буквально "як вода в басейні") — виявився
концептуально хибним: верх/низ box-а завжди займають весь footprint
незалежно від висоти, тож з ізометричної камери зона виглядала "одразу
заповненою" на будь-якій висоті > 0.
3. **Фінал**: плаский квад на землі знову, але росте вздовж world Z (не X)
— з цієї камери (`CAMERA_OFFSET=(0,9,-9)`, дивиться в +Z) рух вздовж +Z
проєктується на екран як "знизу вгору", що і є тим "від краю до краю",
про яке просив користувач. Пінінг біля min-Z (найближчий до камери
край), колір `0x5ce65c` (зеленуватий).
### ⚠️ Знайдений і виправлений баг: депозит-політ інколи застигає в повітрі навіки
Користувач показав скріншот: статична текстура ресурсу, що просто висить
у повітрі. Трасував через тимчасове логування (spawn/complete ID на кожен
`playDepositFlight` — окремий `Tween`, не chain, коректно `TweenC.add()`-
нутий ПЕРЕД `.start()`) — підтвердив: рідко (не щоразу) `onComplete` просто
не спрацьовує, і клон застигає точно на прямій між HUD-точкою і зоною, на
довільному t. Причину всередині `tween.js`/`TweenC` не знайшов (це не той
самий баг з `.chain()` з Day 6 — тут узагалі нема chain). **Замість
подальшого копання — надійний дублюючий `setTimeout`**, що прибирає mesh
через `DEPOSIT_FLIGHT_DURATION_MS + 300мс` незалежно від того, чи
спрацював `onComplete`. Викликати `removeFromScene` двічі — нешкідливо
(другий викоик просто не знаходить батька для detach).
### ⚠️ Знайдений і виправлений баг: `"UI_Wood.001"` в коді не збігається з реальною назвою ноду
Користувач наполягав, що "текстура дерева" в центрі Pay Zone нікуди не
зникає, хоча по черзі виключив усі мої гіпотези (крейти всередині зони,
застиглий ресурс). Трасував живу сцену (`ThreeC.scene.traverse`, шукаючи
все з "UI_Wood" в імені, друкуючи ланцюжок `visible` від ноду до кореня) —
і ось воно: нод з raw glb JSON названий `"UI_Wood.001"` (з крапкою,
підтверджено прямим парсингом бінарника), але в ЖИВІЙ Three.js сцені після
завантаження він `"UI_Wood001"` (**без крапки** — щось у пайплайні
завантаження її стирає). `TestSceneC.createMap()` шукав
`map.getObjectByName("UI_Wood.001")` — рядок ніколи не збігався,
`getObjectByName` тихо повертав `undefined`, `if (woodIconAlt)` була
`false`, і `.visible=false` НІКОЛИ фактично не виконувався — попри те, що
код виглядав абсолютно правильним і "мав" би працювати. Виправлення:
рядок-літерал змінено на `"UI_Wood001"` (без крапки), підтверджено
трасуванням живої сцени, що тепер `visible=false` реально застосовується.
**Урок**: коли `if (identifiedNode)`-гард ЗАВЖДИ хибний (вузол ніби існує
в GLB, але код його "не бачить") — перевіряти РЕАЛЬНЕ ім'я в ЖИВІЙ сцені
(`scene.traverse` + лог імені), не тільки в сирому GLB JSON — завантажувач
може мовчки переписувати рядки (крапки, ймовірно, конфліктують з якоюсь
внутрішньою угодою іменування).
Побічно: під час полювання на цей баг зробив (і одразу відкотив на
прохання користувача) дві помилкові гіпотези-фікси — приховання
`UI_Interactive_Zone_02` (сам маркер зони) і видалення `Wooden_Box_016`/
`Wooden_Box_017` (2 крейти, що геометрично сидять майже точно в центрі
зони, ~0.18 і ~0.99 одиниць від центру — підтверджено обчисленням world
position прямо з GLB node transforms). Жодна з цих гіпотез не була
причиною — обидві повернуто як були. Крейти в зоні — це може бути окрема,
самостійна проблема левел-дизайну (вони справді там стоять), але це
свідоме рішення НЕ трогати без окремого прямого запиту користувача.
- Working tree на момент старту Day 5: `PlayerC.ts` мав незакомічені правки
(accel/decel рух, рефакторинг attack-facing на helper-и) — вони увійшли в
фінальну версію файлу (переписаний повністю, логіка руху збережена).
- Свідоме рішення: не рефакторити робочий AABB рух гравця під фізику; після
видалення тригерів у гравця взагалі немає cannon `Body` — тільки AABB.
- `ResourceC`/`BreakablePropC` — нові файли. `PlayerC.ts`/`TestSceneC.ts`/
`PhysicsC.ts` — модифіковані. `CameraFollowC.ts`/`ThreeC.ts` — не торкались.
`TriggerC.ts` — був створений і видалений в межах цієї ж сесії (див. "Файли").
- Закомічено й запушено в `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`, лишився для косметичного
idle-facing), просто тепер є окремий `findBreakableTargetsInZone` без
обмеження на кількість цілей.
- Day 6 (Tween): `TweenC.init()` викликається один раз в
`beforeResourcesLoadedCb.ts` — якщо колись переносити фізику/tween-бутстрап
в інше місце, не забути перенести й це, інакше всі `TweenC.add()` тихо
ні на що не впливають (сама `Group` існує, просто ніхто її не `.update()`-ить).
- `PayZoneC.init(payZoneNode, target)` МАЄ викликатись після `PlayerC.init()`
в `TestSceneC` (потребує реальний `PlayerC.object`, не просто позицію) —
уже виправлено (виклик з `TestSceneC.init()`, не з `createMap()`), просто
важливо не переносити назад.
- **Будь-який новий `.chain()` в цьому проєкті — додавай ОБИДВІ (усі) ланки
в `TweenC` окремо** (`TweenC.add(a); TweenC.add(b); a.start();`), інакше
повториться баг вище: друга ланка "грає" за прапорцем, але update() на
неї ніхто не кличе, і вона застигає навічно.
- **Важливий урок з цієї сесії**: "збір ресурсу" (`ResourceC`) і "передача
в Pay Zone" (`PayZoneC`) — два НЕЗАЛЕЖНІ механізми, і мають лишатись
такими. Не гейтувати `ResourceC.collect()` жодною умовою про гравця/зону
— це те, що вже одного разу зламало базовий збір (ресурси лежали на
землі й не зникали). Будь-яку майбутню умову про "стояння в зоні" чіпляти
тільки на стороні `PayZoneC` (він і так окремо стежить за
`getCollectedCount()` — `deposited`, тобто "хвостом" з незданого).
- **Ще один урок**: не вгадувати розмір/радіус тригер-зони на око зі
скріншота — рахувати з реальної геометрії (`Box3().setFromObject(node)`).
Саме це зламало `PayZoneC.isPlayerInside` (радіус=2 замалий за фактичний
прямокутник ~6×4.6, ще й не центрований на origin).
- Ручна перевірка (флеш/нахил крейтів, повне зникнення після 2 хітів на
стейдж, базовий збір ресурсів без прив'язки до зони, злив ресурсів у Pay
Zone тільки при стоянні там — тепер по реальному Box3, не вгаданому
радіусу, зникнення зони після `RESOURCES_TO_CLOSE_ZONE` доставлених
(100 на момент цього запису, зараз 20 — див. "прогрес-заповнення зони"),
відсутність зайвих текстур навколо 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`), а не дублювати магічне число, інакше вони
розходяться, як тут.