Files
onboarding-project/CLAUDE.md
T
Oleksandr Vlasiuk 250f4a0d2c update cloud.md
2026-08-13 18:59:21 +03:00

136 KiB
Raw Blame History

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) для хіту/знищення
                       крейтів
  WeaponHudC.ts      - Day 8: статична юайка внизу-зліва — восьмикутна
                       плита (weaponHudBgSrc, ZombiePunk_Icon-Bottom.webp з
                       temp/) + іконка бити (weaponIconBatSrc, Tool_2.webp)
                       + хардкоджений текст "LVL 0" (нема системи рівнів
                       зброї в проєкті — суто візуал під референс). Іконка
                       завжди бита, не стежить за поточною екіпірованою
                       зброєю (pistol/bat) — окреме, свідомо не зроблене
                       рішення
  ZombieInvasionHudC.ts - Day 8: топ-центр "ZOMBIE INVASION" — заголовок +
                       таймер-бар. Голова зомбі (zombieHeadIconSrc,
                       REF_1.png з temp/ — файл НАСПРАВДІ webp, не png,
                       розширення в джерелі помилкове) їде з
                       right:100% -> left:0% за TIMER_DURATION_S (120с)
                       через CSS transition (JS лише раз перемикає
                       значення); окремий .zombie-invasion__fill росте
                       0%->100% width з right:0 в ТОЙ САМИЙ момент/тривалість
                       — їх ліва межа завжди математично збігається з
                       позицією голови, без пофреймової синхронізації.
                       Останні WARNING_FRACTION=0.05 (6с) — pulse+червоний
                       флеш через setTimeout+клас (CSS keyframes самі
                       крутяться в infinite, ніхто їх не зупиняє після
                       заповнення — свідомо, за проханням користувача).
                       Немає дизайнерського ассету під сам бар/трек —
                       чистий CSS pill, лише голова — реальна картинка
  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/,
                       дизайнерський плейт лічильника), weapon_hud_bg.webp,
                       weapon_icon_bat.webp, zombie_head_icon.webp,
                       images.ts (icon_wood.webp/iconWoodSrc видалені —
                       стали осиротілими після заміни на resourceCounterBgSrc)
resources/fonts/     - PassionOne-Black.otf (з temp/) — див. "gameFont"
                       нижче
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.

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

  • власний <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 t² замість smoothstep) БУЛА ВІДКОЧЕНА — причина: CONVERGE_START_FRACTION-конвергенція тригериться по часу t, а ease-in робить просторовий прогрес значно повільнішим за часовий, тож ресурс зникав, не доїхавши до цілі ("зникають раніше часу") — урок: якщо якийсь ефект прив'язаний до сирого t, зміна кривої eased для позиції може розсинхронізувати їх — тримати узгодженим або привʼязувати ефект до фактичного пройденого шляху, не до часу.

SparkleFxC.ts — новий контролер, пул зіркових спалахів

Спочатку — наївна версія (new Mesh/new MeshBasicMaterial на кожен вибух, 5 зірок на баунс). Користувач попросив пулінг (Day 7 бриф явно рекомендує "use pools for VFX", і кожен крейт може дати 2-5 ресурсів, кожен зі своїм вибухом = 10-25 create+GC циклів за секунду). Фінал: POOL_SIZE=30 зіркових мешів/матеріалів створюються ОДИН РАЗ в init() (викликається з TestSceneC), приховані/scale=0; spawnBurst() шукає вільні слоти (inUse), активує (позиція/lookAt/scale/opacity), звільняє в fadeOut.onComplete. Форма зірки — ShapeGeometry з 4-промінним контуром (outer/inner radius alternating), не sprite-атлас (немає такого ассету в проєкті). Анімація: pop-in (Back.Out) -> hold -> fade-out, через TweenC (обидві ланки чейну додані окремо — той самий Day 6 баг з .chain() враховано одразу).

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), а не дублювати магічне число, інакше вони розходяться, як тут.

Day 8 (продовження): WeaponHudC, ZombieInvasionHudC, InstallBanner-рестайл, шрифт, ротація (2026-08-13)

Усе нижче — ще не закомічено (тільки 4a22a9b/63609ee в git; це робота вже після другого коміту). npm run export вже прогонявся раз успішно (з побічним авто-встановленням three.quarks, бо в export.cjs своя мережева воркфлоу — див. окремий output_export_browser_en.html).

WeaponHudC.ts/ZombieInvasionHudC.ts — нові статичні/напівстатичні юайки

Реалізовані аналогічно HudC (окремий контролер на юайку, init() з TestSceneC). Ассети: ZombiePunk_Icon-Bottom.webp+Tool_2.webp для зброї, REF_1.png (насправді webp, розширення джерела — брехня, перевірено по сирих байтах RIFF/WEBP заголовку) для голови зомбі.

ZombieInvasionHudC пройшов кілька раундів фідбеку:

  1. Спочатку .zombie-invasion__bar мав суцільний зелений background (статично "повний" від t=0) — користувач: "WARNING_FRACTION чомусь почався тільки коли шкала повністю заповнилась". Причина НЕ в таймінгу (114000мс з 120000мс — точно останні 5%, перевірено), а в тому, що шкала і так ЗАВЖДИ виглядала на 100% — різницю "майже" vs "повна" було неможливо побачити. Фікс: реальна заливка (.zombie-invasion__track з overflow:hidden + .zombie-invasion__fill, росте width:0%->100% з right:0, стартує в ТОЙ САМИЙ тік з тією ж тривалістю, що й голова — їхні межі математично завжди збігаються, без пофреймової синхронізації).
  2. Голова мала бути БІЛЬШОЮ за саму шкалу (0.24 * width проти 0.15 * width висоти бару) — але .zombie-invasion__bar мав overflow:hidden для заливки, що обрізало б і голову. Фікс: голова винесена сиблінгом .zombie-invasion__track (не її дитиною) — обрізає тільки заливку/флеш, не голову.

InstallBanner (SDK-компонент) — свідомо НЕ переписаний, лише CSS-рестайл

Круглий портрет персонажа + кнопка "INSTALL" знизу — це не наша юайка, а SDK InstallBanner (node_modules/@hitplay/playable_template, підключений в beforeResourcesLoadedCb.ts) з реальним кліком (AnalyticsC.sendDownload() -> Template.end()) і власною конфіг-системою (src/configUIParams/installBanner.ts, upload_file

  • X/Y/scale-відсотки окремо для portrait/landscape — реальні значення це треті числа в кожному values: [min, max, default]). Користувач попросив "уніфікувати" з іншими юайками — явно спитав через AskUserQuestion, чи переписувати логіку самому, чи тільки CSS — вибрав тільки CSS-рестайл (безпечний варіант). Додано лише адитивні властивості (filter: drop-shadow, -webkit-text-stroke на .bannerButtonText) — нічого, що конфліктувало б з інжектованим SDK-стилем.

⚠️ Знахідка: purgecss мовчки вирізає .bannerButton, але не .bannerLogo/.bannerButtonText При білді (не тільки в dev) purgecss скановує лише src/**/*.{js,ts,tsx}

  • **/*.html на предмет "чи цей клас реально використовується" — а InstallBanner's класи призначаються в JS всередині ВЖЕ мінифікованого SDK dist-бандла (не в нашому src/). Емпірично перевірено в реальному білд-виводі (export/index.html): .bannerLogo{filter:...} і .bannerButtonText{...} виживають, а .bannerButton{filter:...} — ні, навіть як окреме правило. Ймовірна причина: мінифікатор пакета де-дублював рядок "bannerButton" (4 похідні: сам + Holder/Img/Text) на спільний префікс, тож він більше не існує цілим рядком у бандлі — "bannerLogo" (лише 2 похідні) не досяг порогу для такої оптимізації. Поза нашим контролем (сторонній пакет) — просто не писати CSS-правила під .bannerButton окремо, вони будуть мертвим кодом.

gameFont нарешті став реальним шрифтом

font-family: gameFont, sans-serif використовувався в main.css/ui.css з самого початку проєкту, але gameFont НІКОЛИ не був заведений через @font-face — тихо відкатувався на дефолтний sans-serif усюди. Користувач: "весь юай має використовувати шрифт PassionOne-Black, він у нас є в temp". Додано @font-face в main.css (src/resources/fonts/PassionOne-Black.otf) під іменем gameFont — усі існуючі посилання почали резолвитись сами, без жодної іншої зміни. Технічний нюанс: для шрифтів (на відміну від images.ts/ConvertToBase64WhenRelease-паттерна) НЕ треба нічого особливого — assetsInlineLimit: 100000000 в vite.config.js (через defineConfigTemplate) вже інлайнить будь-який url() в CSS як base64 і в dev, і в білді. Перевірено прямо в білд-виводі (не на око).

--lvh/--lvw замість сирого vh/vw/vmin — ротація/адаптивність

Користувач (Day 8 чекліст, "All UI elements must be adaptive to changing device rotation"): розвернув екран — нічого не змінилось. Знахідка: SDK має OrientationC (node_modules/@hitplay/playable_template), який під лок-режимом орієнтації обгортає сторінку в transform: rotate(-90deg)..., щоб контент лишався візуально портретним навіть на фізично горизонтальному екрані. Сирі vh/vw/vmin ігнорують transform батьків і завжди рахуються від фізичного (неповернутого) вьюпорта — тож будь-який розмір у vh стає неправильним щойно лок реально спрацює. OrientationC для цього тримає --lvw/--lvh ("логічні" версії, коректно стежать за поворотом; в Adaptive-режимі без локу — просто дорівнюють фізичним vw/vh, тобто нічого не коштують) — той самий підхід, що вже й в InstallBanner.ts (--lvw в коментарях "physical vw doesn't follow the orientation lock"). Усі --*-width в ui.css тепер calc(var(--lvh, 100vh) * ratio).

⚠️ Важливо: дефолт в src/configUIParams/orientation.ts — "Adaptive", НЕ лок. У цьому режимі --lvh == vh і фікс вище нічого не змінює візуально прямо зараз — це радше "правильно про запас", коли/якщо проєкт зрештою поставлять на Portrait Lock (майже неминуче для вертикального плейбла). Коли користувач після цього фіксу все одно сказав "нічого не змінилось, юайка так само зосереджена в центрі" — попросив медіа-правила замість покладання на --lvh/лок-механізм. Фінал: кожна з трьох юайок (resource-hud/weapon-hud/zombie-invasion) тепер має власний @media (orientation: landscape) { ... } блок одразу після portrait-правила, що перевизначає top/left/right/bottom і --*-width — незалежно від OrientationC/локу, реагує на реальне співвідношення сторін вьюпорта. Значення в landscape-блоках зараз лише дублюють portrait як стартову точку — ще НЕ підтверджені користувачем візуально, він вже почав підкручувати .resource-hud's landscape --hud-width (з 0.09 на 0.19 — бачив у diff, не сам робив).

Стан на кінець цієї сесії

  • Working tree: src/configUIParams/installBanner.ts (можливо просто line-ending, не досліджував — не моя зміна), TestSceneC.ts, main.css, ui.css, images.ts — модифіковані; WeaponHudC.ts, ZombieInvasionHudC.ts, resources/fonts/, три нові .webp — нові. Не закомічено.
  • stats.html — як завжди, build-артефакт, свідомо не в git.
  • Наступний крок, якщо буде запит: підтвердити/підкрутити landscape @media-значення разом з користувачем, коли він реально побачить це на розгорнутому екрані.

Health bar для дестройбл-елементів (2026-08-13)

Референс-скріншот: прямокутний хелз бар з бордером, синя заливка HP, чорним — вже відняте попередніми ударами, білим — відняте саме поточним ударом (з коротким затуханням у чорне), число нанесеного урону над баром. Нехай 10 урону за удар, 100хп у цілого ящика.

BreakablePropC — перехід з дискретного лічильника хітів на HP

HITS_PER_STAGE/hitsOnStage повністю видалені. BreakableProp тепер несе currentHp/maxHp (MAX_HP = 100, export-нутий, щоб HealthBarC рахував той самий 0..1 дріб без дублювання числа) + startStageNumber/ totalPossibleStages — реальні номери з _SN у назвах стейдж-нод (не вигадані), з яких рахується, скільки HP пошкоджений з коробки крейт вже має "по дефолту" (пошкоджені S2/S3-старт крейти стартують не з 100). hit() тепер: відняти HIT_DAMAGE, повідомити HealthBarC.onHit(...), знищити на HP<=0 (раніше — "просунувся за останній стейдж"), інакше — перевести stageIndex на Math.floor((1 - currentHp/maxHp) * totalPossibleStages) (з відступом на startStageNumber), змаплений у межі власного (можливо короткого) масиву стейджів.

HealthBarC.ts — новий контролер, world-tracking DOM-бари

Одна мапа BreakableProp -> BarInstance, живиться з BreakablePropC.hit() через onHit/onDestroyed (аналогічний DI-паттерн до ResourceC.setFlyTarget).

Позиціювання: position:fixed + сирі px з canvasRect (та ж техніка, що й HudC.getWorldAnchorPosition, лише в протилежний бік — тепер винесена в src/utils/screenWorldProjection.ts, обидва напрямки). Точка прив'язки — реальний Box3().setFromObject(prop.root) (верх+центр X/Z), не вгаданий офсет.

Три кольори — чистий CSS, без пофреймової JS-анімації: .health-bar__white й .health-bar__blue — два накладені div. На кожен хіт: білий миттєво (transition-duration: 0s) стрибає на ПОПЕРЕДНІЙ %, форс-reflow через offsetWidth, потім і білий (з transition-delay), і синій одразу виставляються на ПОТОЧНИЙ % — синій доїжджає без затримки, білий "зависає" на старому значенні й наздоганяє з лагом, показуючи чорний трек під собою в проміжку. Ніякого requestAnimationFrame/tween — сама CSS-transition timing і є ефектом.

Авто-приховування: idleTime рахується щокадру, >= HIDE_DELAY_S (2с) без нового хіта — is-hiding клас + FADE_OUT_MS (250мс) opacity-transition, потім видалення з DOM. Скидається на 0 при кожному onHit. onDestroyed видаляє бар негайно (порожній бар не має лишатись висіти).

Числа урону — кілька раундів фідбеку, фінал:

  • Спочатку плавали вгору з затуханням — користувач попросив натомість різку появу непрозорого числа (-webkit-text-stroke, не text-shadow-глов) з різким скейл-панчем до 1.5x + ротейтом і поверненням, і тільки ПОТІМ затуханням (не рухом).
  • Ротейт — послідовно звужувався: спершу довільний напрямок/кут, потім користувач уточнив "тільки по годинниковій стрілці, до 2 години дня" (тобто 0..60°, --damage-rotate, рандомізується в JS на кожен спавн, читається в keyframe через var()).
  • Баг знайдений і виправлений: затухання на спокої (force=0, у контексті джойстика — не плутати з цим, тут йдеться про сам панч) показувало прозору прогалину замість базового кольору, бо альфа інтерполювалась до 0 — фікс: альфа фіксована, змінюється лише RGB-канал (той самий принцип пізніше повторився і в джойстику, див. нижче).
  • Тривалість/розмір/швидкість анімації (920мс, +20% розмір шрифту, expo-out крива "швидкий старт — повільний кінець") — прямі числові правки за проханням користувача.

src/css/health-bar.css — новий файл

Слідує тому ж house convention, що й ui.css (одна --health-bar-width змінна, --lvh не сирий vh) — окремо зазначено користувачем не чіпати: файл (як і ui.css) свідомо не отримав landscape @media-блоку і досі на vh, не rem/% — відомий, прийнятий відступ від Day 8 чекліста, відкладений на потім.

PayZoneC: політ депозиту тепер з голови гравця, не з юайки (2026-08-13)

Був: політ ресурсу в Pay Zone стартував з HudC.getWorldAnchorPosition() (іконка лічильника). Користувач попросив: з персонажа, летить від голови вгору й плавно спускається в центр зони (не з юайки взагалі).

PlayerC.getHeadWorldPosition() — новий публічний метод, `object.position

  • (0, PLAYER_HEIGHT, 0)—PLAYER_HEIGHT вже був реальним заміряним розміром рига (PLAYER_SIZE.y), не вигаданим числом, тож верх голови рахується без нової магічної константи. PayZoneC.playDepositFlightтепер цілиться в геометричний центрzoneBounds(не в pivot самого маркер-ноду — не гарантовано, що він точно в центрі footprint-у), і лишень до прямого lerp додаєMath.sin(t * Math.PI) * DEPOSIT_FLIGHT_ARC_HEIGHT(той самий трюк дуги, що вже вResourceC`) — вгору-і-плавно-вниз замість прямої лінії.

Знайдено й виправлено: DEPOSIT_FLIGHT_SCALE_MULTIPLIER був підкручений (0.3) під СТАРИЙ політ, де перспективна компенсація (perspectiveCompensationScale, camera-distance ratio) відчутно збільшувала розмір за час польоту, бо старт був близько до камери (HUD), а кінець далеко. Тепер старт — голова гравця, приблизно та ж дистанція до камери, що й Pay Zone (камера жорстко слідує за гравцем) — компенсація майже не працює, і 0.3 робив токен практично невидимим. Підняли до 1.3.

Джойстик: редизайн під референс + два реальні баги (2026-08-13)

Референс: прозора середина, градієнтний бордер, помаранчева точка пальця з підсвіткою навколо. JoystickC.init() (SDK-обгортка nipplejs) приймає будь-які опції nipplejs напряму, включно з color: {back, front} — кожен рядок йде прямо в background відповідного div, підтримує градієнти й навіть var()/calc() (живі CSS-змінні всередині inline style реально оновлюються).

Фінальна реалізація (beforeResourcesLoadedCb.ts + main.css):

  • back (кільце) — conic-gradient(from var(--joy-angle), ...), базовий колір однотонний темний, підсвітка (тон точки пальця) лише у широкій дузі навпроти пальця, з calc()-інтерполяцією RGB-каналів (не альфи!) за --joy-force.
  • front (точка) — суцільний плоский колір, без градієнта/тіні.
  • Форма кільця (прозора середина) — mask-image: radial-gradient(...) з абсолютними px, не відсотками — щоб зміна size (радіус зони) не розтягувала товщину бордера пропорційно.
  • Розмір точки пальця (.front) — фіксований px-оверрайд, повністю відв'язаний від size (nipplejs хардкодить точку як size/2, тож збільшення радіуса зони інакше тягнуло за собою і точку).
  • JoystickC.onJoysticMove — окрема підписка (SDK підтримує кілька делегатів на подію, PlayerC підписаний окремо на той самий івент для руху) ставить --joy-angle/--joy-force на instance.ui.el щокадру.

⚠️ Знайдений і виправлений баг: підсвітка на 90° проти стрілки від реального положення пальця. Math.atan2(y, x) на скрін-координатах (y вниз) і CSS conic-gradient(from Xdeg, ...) обидва рахують по годинниковій, але з різною точкою нуля: atan2 — схід (3 година), conic-gradient — північ (12 година). Фікс — плюс 90° один раз, не свап знаку і не переписування напрямку градієнта.

⚠️ Знайдений і виправлений баг: підсвітка не зупинялась на межі зони, а продовжувала змінюватись, коли палець вели далі за край. Трасування в самому nipplejs (Collection.ts): force рахується з СИРОЇ, необмеженої дистанції дотику (dist / size), тоді як frontPosition (сама видима точка й кут вище) — з ОБМЕЖЕНОЇ. За краєм кільця force продовжує необмежено зростати, хоча точка вже візуально запінена на місці — усі calc() розраховані на force в межах 0..1. Це ж переповнення й давало жовтуватий відтінок (RGB-канали переїжджали за цільовий колір). Фікс — один Math.min(Math.max(force, 0), 1) перед записом у CSS-змінну, а не clamp у кожному calc() окремо.

Розмір: size (радіус зони) підняли на +50%, потім усі елементи (зона+точка+товщина кільця) зменшені на -30% від того — фінальні числа: size: 105, точка 35px/-17.5px margin, кільце 43.4/44.8/46.9/48.3px.

Камера: CAMERA_OFFSET підкручений (2026-08-13)

Користувач попросив камеру ближче, показавши референс-кут. CAMERA_OFFSET в TestSceneC.ts з (0, 9, -9) (45° нахил, дистанція ~12.7) на (0, 8, -6) — коротша дистанція, трохи інше (положче) співвідношення Y:Z. Порадив користувачу для самостійного підбору: сам CAMERA_OFFSET (єдиний вектор, що визначає і дистанцію, і кут — CameraFollowC.init() ставить позицію й раз викликає lookAt, більше кут не змінюється) і camera_fov_p в редакторі конфігів (дефолт 60, діапазон 30-150) — окремий, незалежний важіль "наближення" через FOV. Важливо: camera_position_p/camera_rotation_p слайдери в редакторі — мертві, CameraFollowC перезаписує їх одразу при старті.

Day 9-10: рефактор і чистка (2026-08-13)

Бриф прямо просив "думай як розробник, що передає код іншому" — без зміни логіки/поведінки гри, лише структура/дублювання/коментарі. Погоджений план (EnterPlanMode/ExitPlanMode) виконаний фазами, кожна верифікована окремим npx vite build:

  • Мертвий код прибрано: PayZoneC.getTargetWorldPosition() (0 викликів), увесь PhysicsC.PhysicsObjPair/pair-поле/ disablePhysicsPair() (клас був повністю нежиттєздатним — єдине місце, що присвоювало pair, саме закомічене); 2 прототипні console.log (PayZoneC, ResourceC). SparkleFxC's console.debug на виснаження пулу — свідомо залишений (реальна діагностика деградованого режиму, не сміттєвий лог).
  • 4 нові утиліти в src/utils/ — прибрали дублювання, знайдене між файлами (не новий functionality, лише перенесення існуючої формули без зміни):
    • perspectiveScale.ts — camera-distance-ratio компенсація, була незалежно написана і в ResourceC (toTarget), і в PayZoneC (playDepositFlight).
    • screenWorldProjection.ts — world<->screen NDC-математика, була в HudC (screen->world, unproject) і HealthBarC (world->screen, project) — обидва незалежно читали canvasRect.
    • tweenChain.ts — той самий "TweenC.add() обидві ланки окремо" гочтя й бойлерплейт, дослівно повторений у BreakablePropC і SparkleFxC.
    • flashOverlayMaterial.ts — адитивний флеш-матеріал (клон + white additive overlay), дослівно повторений у BreakablePropC і ResourceC, разом з дубльованою константою FLASH_COLOR.
  • Коментарі підчищені у всіх 16 контролерах, beforeResourcesLoadedCb.ts і всіх 3 CSS-файлах — прибрана вся хронологія/цитати чату/"Day N" посилання, лишились тільки короткі WHY-нотатки на дійсно неочевидні речі (усі гочті з цього файлу — TweenC-чейн, glTF dot-stripping, nipplejs force-переповнення, atan2/conic-gradient зсув, MeshBasicMaterial білий-тінт-no-op — лишились, просто по одному разу в коді, не по два-три). Додана одна нова коротка нота в ui.css — .bannerButton реально мертвий у білді (purgecss-квірк), раніше це знав тільки цей файл.
  • Свідомо НЕ зроблено: домен-розбиття src/controllers/ на підпапки (gameplay/vfx/ui/core) — розглянуто і відхилено: жодної зовнішньої конвенції під це нема (SDK сам плаский), resources/ — єдиний прецедент підпапок в проєкті, і той групує по типу ассету, не по домену. Переїзд 16 файлів заради нуля функціональної вигоди — саме "overengineering", проти якого прямо просив бриф.
  • Два CSS-пункти з попереднього Day 8 аудиту (rem/% замість vh, відсутній landscape-медіазапит у health-bar.css) — свідомо не торкались, за проханням користувача відкласти.

Перевірено виключно npx vite build після кожної фази (усі 883 модулі, без помилок) — жодного self-testing у браузері, як завжди в цьому проєкті.