From 4a22a9b1402e62c4e5f7ed850241a811c0907765 Mon Sep 17 00:00:00 2001 From: Oleksandr Vlasiuk Date: Wed, 12 Aug 2026 18:52:40 +0300 Subject: [PATCH] Add physics/combat foundation, resource pickup flow, VFX polish, and HUD - Cannon-es colliders for map/props/breakable crates (BreakablePropC, PhysicsC), AoE bat combat with facing-cone gating (PlayerC), camera follow with obstacle avoidance and facing-lock look-ahead (CameraFollowC). - Resource pickups: scatter/bounce/hit-flash/sparkle-burst lifecycle with pooled sparkle VFX (ResourceC, SparkleFxC), flying to a screen-projected HUD anchor and on into the Pay Zone with perspective-corrected sizing (HudC, PayZoneC). - Designer VFX playback via three.quarks for crate hit/destroy (PropVfxC), plus a procedural weapon-swing trail (WeaponTrailC). - Resource-counter HUD UI (ui.css, images.ts) with CSS-driven mount/bump animations. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 910 ++++++++++++++++++ package.json | 3 +- src/controllers/BreakablePropC.ts | 294 ++++++ src/controllers/CameraFollowC.ts | 175 ++-- src/controllers/HudC.ts | 118 +++ src/controllers/PayZoneC.ts | 244 +++++ src/controllers/PhysicsC.ts | 54 +- src/controllers/PlayerC.ts | 348 +++++-- src/controllers/PropVfxC.ts | 90 ++ src/controllers/ResourceC.ts | 391 ++++++++ src/controllers/SparkleFxC.ts | 173 ++++ src/controllers/TestSceneC.ts | 98 +- src/controllers/WeaponTrailC.ts | 256 +++++ src/css/ui.css | 98 ++ src/resources/images/images.ts | 8 + src/resources/images/resource_counter_bg.webp | Bin 0 -> 1322 bytes src/resources/vfx/VFX_Lootable_Destroy.json | 1 + src/resources/vfx/VFX_Lootable_Hit.json | 538 +++++++++++ src/templateConfig/beforeResourcesLoadedCb.ts | 2 + tsconfig.json | 3 +- 20 files changed, 3617 insertions(+), 187 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/controllers/BreakablePropC.ts create mode 100644 src/controllers/HudC.ts create mode 100644 src/controllers/PayZoneC.ts create mode 100644 src/controllers/PropVfxC.ts create mode 100644 src/controllers/ResourceC.ts create mode 100644 src/controllers/SparkleFxC.ts create mode 100644 src/controllers/WeaponTrailC.ts create mode 100644 src/resources/images/images.ts create mode 100644 src/resources/images/resource_counter_bg.webp create mode 100644 src/resources/vfx/VFX_Lootable_Destroy.json create mode 100644 src/resources/vfx/VFX_Lootable_Hit.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..04a0a13 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,910 @@ +# 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-цілях в + зоні ураження, перебивається рухом + TestSceneC.ts - завантажує ZombiePunk_Map.glb, /collider/i -> hidden+colliders, + ставить cannon Wall-тіла на все, крім Lootable-крейтів + 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), дропає ресурси + ResourceC.ts - спавн ресурсів "розльотом" від цілі + автозбір + (Day 5, незмінно) — НЕ знає про Pay Zone/гравця; + візуал — клон реального "UI_Wood" з мапи + (fallback-куб про запас), createVisual() — публічний + доступ до цього візуалу для інших контролерів + PayZoneC.ts - Day 6: окремо зливає 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 + ThreeC.ts/CameraC.ts - базовий сетап three.js/камери від SDK +resources/meshes/ - ZombiePunk_Map.glb, ZombiePunk_Character.glb +resources/images/ - icon_wood.webp (Icon_Wood, витягнутий з мапи), images.ts +``` + +`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. + +### 3–4. Тригери + 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, в чаті цієї сесії не +зафіксовано; якщо матимеш контекст, допиши сюди. + +### 2–3. 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` ++ власний `