Files
onboarding-project/CLAUDE.md
T
Oleksandr Vlasiuk 4a22a9b140 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 <noreply@anthropic.com>
2026-08-12 18:52:40 +03:00

83 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-цілях в
                       зоні ураження, перебивається рухом
  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.

34. Тригери + start/stop events — ⚠️ зроблено, потім видалено

Був зроблений TriggerC.ts на beginContact/endContact, використовувався для ресурсів. Після уточнення від користувача ("персонажу не потрібно підходити для збору") ресурси більше не потребують proximity-тригера — дивись секцію "Файли" вище. Формально пункт "invisible triggers" з чек-листа Day 5 не представлений в фінальному коді — свідоме рішення під конкретні вимоги гри, а не пропуск.

5. Розбиття пропсів — (AoE, не single-target)

Атака гравця (PlayerC.updateCombat) знаходить усі breakable-цілі в зоні ураження (findBreakableTargetsInZone — та сама INTERACTION_REACH AABB-аура навколо гравця, без обмеження кількості цілей) і при кожному "пульсі" (раз на цикл анімації) хітає їх усі одразу через BreakablePropC.hit(prop) для кожної.

BreakablePropC.hit(prop): якщо є наступний stage — ховає поточний, показує наступний, дропає 1-2 ресурси (STAGE_HIT_RESOURCE_RANGE); якщо це вже останній stage — дропає 2-5 ресурсів (DESTROY_RESOURCE_RANGE), знищує physicsBody, видаляє з colliders/obstacles (як і раніше) І ховає весь prop.root (root.visible = false) — крейт зникає повністю, а не лишається візуально на останньому stage-меші без колайдера.

6. Анімація атаки — (неперервна, перебивається рухом)

Loot-кліп — знову LoopRepeat, Infinity (НЕ LoopOnce — це був перший варіант, користувач попросив назад неперервний свінг). Логіка в updateCombat:

  • Старт: гравець зупинений, є хоч одна breakable-ціль в зоні, і гравець дивиться на найближчу з них (isFacing).
  • Поки атакує: рух повністю розблокований (updateMovement виконується щокадру незалежно від isAttacking — на відміну від першої версії, де рух заморожувався на час свінгу). Будь-який стік-інпут -> stopped=false -> атака миттєво скасовується (equipPistol()), без "дограти удар".
  • Раз на цикл кліпу (на позначці HIT_TIME_FRACTION, тобто 50% циклу) — "пульс" хіта по всіх поточних breakable-цілях в зоні (жива вибірка щокадру, тож знищені цілі природно випадають з наступного пульсу).
  • Зупиняється сама, коли zoneTargets.length === 0 (усі цілі знищені) — окремого cooldown між атаками нема, це не дискретний свінг.

7–8. Спавн і збір ресурсів — (розліт + автозбір, без тригерів)

ResourceC.spawnBurst(origin, count) / spawnBurstInRange(origin, min, max): кожен ресурс — клон реального UI_Wood (іконка дерева з мапи, див. "Ключова знахідка" вище; заглушка-куб лишилась тільки на випадок відсутності цього ноду), що летить від точки спавну по випадковому напрямку в XZ на SCATTER_MIN/MAX_DISTANCE (0.5–1.2) з невеликою дугою вгору-вниз (SCATTER_ARC_HEIGHT) за FLY_DURATION (0.45с), потім чекає SETTLE_DELAY (0.5с) на місці і зараховується (collected++, console.log) та зникає. Гравцю не треба підходити — це чистий visual+timer ефект, без cannon body/тригера взагалі. Лічильник поки лише в пам'яті/консолі — немає UI/economy hookup, це прототип.

Best practices — де застосовано

  • Low-poly shapes: усюди Box/Sphere, ніколи трімеш з реальної геометрії.
  • Fixed timestep: вже було з коробки (Physics_internal кличе fixedStep() без аргументів кожен кадр) — нічого додатково не робив.
  • Sync з рендером: PhysicsObjPair (не використовується в Day 5 — нічого фізика не рухає, ані гравець ані ресурси більше не мають cannon-тіл).
  • Sleep: НЕ налаштовував allowSleep/sleep-ліміти явно — cannon-es має дефолти (allowSleep: false за замовчуванням у World!), тобто зараз нічого не спить. З десятками статичних Wall-тіл (тільки для колізій карти/крейтів, п.1) це навряд чи проблема на цьому масштабі, але якщо буде помітний perf-хіт — увімкнути world.allowSleep = true і виставити sleep-ліміти на статичних тілах.
  • Debug visualizer: не трогав прапорець (debug.physics: false в src/index.ts) — поставити true вручну, коли треба візуально звірити cannon-боксі з мапою.
  • Collision layers: використаний існуючий PhysicsLayer enum без змін (хоча Trigger тепер ніде не використовується після видалення тригерів).

Day 6: Tween Animations — реалізовано (2026-08-11, виправлено того ж дня)

@tweenjs/tween.js був установлений, але ніде не використовувався до цього дня. SDK має готову обгортку TweenC (@hitplay/playable_template, import { TweenC } from "@hitplay/playable_template") з власною Group і автопідпискою на UpdateControllerтреба викликати TweenC.init() один раз (додано в beforeResourcesLoadedCb.ts, поруч з Physics_internal.init(...)), інакше TweenC.add()/.create() тихо нічого не анімують.

⚠️ Знайдений і виправлений баг: .chain() НЕ реєструє другу ланку в групі

Перша реалізація хіт-фідбеку й disappear-анімації використовувала tweenA.chain(tweenB); TweenC.add(tweenA); tweenA.start(); — і крейти переставали зникати повністю (застигали розтягнутими на punch-фазі), а флеш/нахил не виглядав як задуманий ефект (застигав на пікові й лишався). Причина, перевірена читанням tween.cjs: .chain() каже tweenA лише покликати tweenB.start() при завершенні — .start() ставить _isPlaying=true, але не додає tweenB в жодну Group. Group.update() ітерує тільки тіли, додані через Group.add(), тож tweenB ніколи не отримує .update(), назавжди застигаючи "playing" в останньому кадрі tweenA. Виправлення: додавати кожну ланку ланцюжка в групу окремо (TweenC.add(tweenA); TweenC.add(tweenB); — обидва, до tweenA.start()). Підтверджено repro+fix через Playwright у scratchpad (консоль показувала onUpdate для другої ланки, що ніколи не спрацьовував до фіксу).

Tween.stop() каскадно зупиняє весь .chain(), навіть якщо зараз виконується вже ланка ланцюжка, а не голова (stop() завжди спочатку кличе stopChainedTweens(), до перевірки _isPlaying) — це підтверджено і лишається правильним механізмом "always kill or reuse tweens": досить тримати посилання на ГОЛОВУ ланцюжка і кликати на ній .stop() (саме зупинку це чіпляє коректно; сама помилка була тільки в реєстрації в групі).

Перед реалізацією користувач попросив питати по кожному з 4 пунктів, а згодом дав ще правки під час перевірки — відповіді й правки (важливі для майбутніх сесій):

  • "Гроші" з Day 6 = те саме дерево, що вже рахує ResourceC (не окрема валюта).
  • Pay Zone: гейм-дизайн "що буде після" — не важливо, лише сам механізм.
  • HUD не існує і Day 6 його НЕ додає.
  • Hit-feedback: короткий білий флеш ін-аут + нахил у протилежну від удару сторону і повернення.
  • (правка) Гроші мають летіти прямо з персонажа в Pay Zone — без проміжної "UI-точки", яка була в першій версії.
  • (правка) Pay Zone потребує 100 зібраних ресурсів, щоб зникнути (не просто ">0", як було спочатку). (Пізніше значення RESOURCES_TO_CLOSE_ZONE підкручено до 20 — див. секцію "прогрес-заповнення зони" нижче; сам механізм — "N доставлених закриває зону" — не змінився.)
  • (правка) Кожен damage-stage крейта потребує 2 хіти, не 1.

1. Hit feedback + disappearing (крейти) —

BreakablePropC: матеріал кожного stage-меша клонується один раз при білді (buildStageVisual) — без цього флеш одного крейта підсвітив би ВСІ 18 крейтів одразу, бо всі стейджі всіх крейтів шарять один Material з glb (перевірено по індексу матеріалу в самому glb). HITS_PER_STAGE = 2hit() рахує prop.hitsOnStage, і тільки на 2-му хіті стейдж просувається/крейт руйнується; кожен хіт (і 1-й, і 2-й, якщо не руйнує) грає playHitFeedback — один Tween<{t}> ланцюжком (flash-in 90мс -> flash-out 150мс, обидві ланки додані в TweenC окремо, див. баг вище), в onUpdate одночасно виставляє root.rotation як baseRotation + tilt*t (baseRotation теж збережений один раз при білді, щоб повторні хіти під час ще не завершеного нахилу не накопичували дрейф кута) і, зараз, опасіті окремого additive-оверлей-меша (flashMesh, не лерп material.color — це вже пізніша правка, material.color на цих unlit-крейтах виявився тихим no-op, див. секцію "Розширений hit-feedback" нижче за повним поясненням). tilt рахується з hitDirection, який передає PlayerC (hitDirectionTo() — attacker->target, XZ, нормалізований).

На хіті, що руйнує крейт (2-й хіт останнього стейджу) — окремий ефект замість флешу/нахилу: (оновлено пізніше) зараз це sink+topple — root.position.y занурюється вниз (DESTROY_SINK_DEPTH, Quadratic.In, DESTROY_SINK_MS) з одночасним нахилом від удару (DESTROY_TILT_ANGLE, той самий tilt-принцип, що й у hit-feedback), root.visible=false виставляється в onComplete, а НЕ миттєво — фізика/обстакл-клінап (physicsBody.destroy(), видалення з масивів) лишились синхронними в момент хіта (гравець вже не натикається на нього, поки крейт ще візуально занурюється). Це заміна першої версії (punch-scale 1 -> 1.15 Back.Out -> 0 Quadratic.In) — коли й чому саме на sink+topple, в чаті цієї сесії не зафіксовано; якщо матимеш контекст, допиши сюди.

23. Money flying out of the player into the Pay Zone — (два ОКРЕМІ механізми)

Проміжна версія об'єднала "збір ресурсу" і "передачу в Pay Zone" в один конвеєр, гейтований стоянням в зоні (ResourceC мав стейт holding, що чекав inZoneCheck() перш ніж рахувати ресурс — тобто лічильник взагалі не рухався, поки гравець не заходив в зону). Це зламало базовий збір: ресурси лежали на землі й не зникали, якщо гравець ламав ящики далеко від зони. Користувач уточнив: збір має працювати як і раніше (Day 5, нічого спільного з Pay Zone), а гейтинг по стоянню в зоні має стосуватись лише окремого механізму "передачі" вже зібраного в зону.

Фінальний дизайн (на момент Day 6) — ResourceC і PayZoneC повністю незалежні:

  • ResourceC — Day 5 логіка збору (scatter -> settle) без жодної згадки про Pay Zone/гравця/зону — просто "зібрав ресурс". Що саме відбувається ПІСЛЯ settle (миттєво рахується, чи летить кудись) — контролюється ззовні через setFlyTarget() (додано пізніше, див. "HUD" нижче — спочатку тут стояв просто collect() без польоту). createVisual() — публічний метод, що віддає клон іконки-ресурсу для чужого використання (зараз юзає PayZoneC).
  • PayZoneC — окремо "зливає" вже зібране (ResourceC.getCollectedCount()) в зону, по одному ресурсу за раз, тільки поки гравець стоїть в зоні і є "хвіст" (collected - deposited > 0): кожні DEPOSIT_INTERVAL (0.3с) — новий ResourceC.createVisual() летить в зону (Quadratic.InOut, 500мс) і зникає, deposited++. Звідки саме стартує цей політ — теж змінилось пізніше (див. "HUD" нижче).

⚠️ Знайдений і виправлений баг: isPlayerInside мав фіксований радіус замалий за фактичний розмір зони

Перша версія isPlayerInside рахувала XZ-відстань до пивота ноду й порівнювала з ZONE_RADIUS = 2 (підібраним на око зі скріншота). За фактом гравець міг зібрати 20+ ресурсів, зайти всередину видимого пунктирного квадрата — і нічого не відбувалось, бо реальний розмір зони значно більший за коло радіусом 2. Перевірено вимірюванням: new Box3().setFromObject(payZoneNode) дав size ≈ [5.96, 0.09, 4.60] (X/Z), тобто прямокутник ~6×4.6, з центром зсунутим від origin (min.x≈-2.89, max.x≈3.06 — не симетрично!). Коло радіусом 2 покривало тільки малу частку видимого квадрата, переважно по X.

Виправлення: isPlayerInside тепер рахує Box3 один раз в init() (new Box3().setFromObject(payZoneNode) — бере фактичну геометрію з урахуванням трансформів, а не вгадану цифру) і перевіряє просте point-in-rectangle по X/Z (min <= pos <= max), ігноруючи Y. Ніяких магічних констант радіуса більше нема — footprint завжди відповідає реальній мапі, навіть якщо художник поміняє розмір/форму зони. Урок: для будь-якої майбутньої "чи гравець в зоні X" перевірки — рахувати Box3().setFromObject() з реального ноду, не вгадувати радіус/розмір на око зі скріншота.

4. Payzone disappearing — (потребує RESOURCES_TO_CLOSE_ZONE доставлених, не просто зібраних)

PayZoneC.update(): коли deposited >= RESOURCES_TO_CLOSE_ZONE (значення на момент Day 6 було 100, зараз в коді 20 — підкручено пізніше, див. "прогрес-заповнення зони"; в будь-якому разі це "N ресурсів фактично долетіли в зону", не просто зібрані десь на мапі) — одноразово (closed флаг) запускає playDisappear(): scale 1->0 (Quadratic.In, 400мс), visible=false в onComplete. Одноразово, назавжди (немає ре-спавну/ циклу — "що буде після" лишається не реалізованим за проханням користувача). Пізніше (див. "прогрес-заповнення зони") цей самий playDisappear розширений — тепер синхронно стискає ще й fill-overlay квад, не тільки сам маркер зони.

Best practices — де застосовано

  • Sequences: усюди .chain() замість ручного стейт-машину (flash in->out, punch->shrink) — але обов'язково додавати кожну ланку в TweenC group окремо (див. баг вище) — .chain() сам лише стартує наступну ланку, не реєструє її для update-тіків.
  • Kill or reuse tweens: BreakableProp.hitTween зберігає голову ланцюжка; .stop() перед кожним новим хітом і при destroy() (каскадно зупиняє й активну ланку, це підтверджено робочим). PayZoneC's disappear — одноразовий по флагу (closed), тому без явного kill.

HUD-лічильник + перенаправлення анімацій (2026-08-12)

Користувач попросив: (1) справжню 2D UI-іконку ресурсу з лічильником у правому верхньому куті, "як на скріні" (темна округла табличка + іконка в круглій рамці зліва + число справа), (2) ресурс замість миттєвого зникнення має анімовано летіти ДО цього лічильника, (3) політ у Pay Zone має бути ВІД лічильника до зони (не від гравця).

Іконка

Витягнув сам PNG/WebP з тієї ж текстури Icon_Wood, яку вже юзає 3D-меш UI_Wood (src/resources/meshes/ZombiePunk_Map.glb, bufferView з images[6]) — маленьким одноразовим Node-скриптом (парсинг glb JSON-чанка, пошук images[].name === "Icon_Wood", зріз байтів з BIN-чанка за bufferViews[bufferView]). Зберіг як src/resources/images/icon_wood.webp (webp з альфа-каналом, 512×512). Новий src/resources/images/images.ts експортує iconWoodSrc = ConvertToBase64WhenRelease("./icon_wood.webp")той самий паттерн, що і meshes.ts (той же helper з @hitplay/ads_common, викликаний з файлу в ТІЙ САМІЙ директорії, що й ассет — важливо: цей хелпер в dev/build режимі просто рядково замінює провідний . на resources (ConvertToBase64WhenRelease.js), а окремий AST-плагін (convertToBase64InAST), підключений через defineConfigTemplate в vite.config.js, на build-time переписує сам виклик у справжній inline data:...;base64,... — це працює для ВСІХ режимів (dev/build/export), не тільки для "export").

HudC.ts — новий контролер

DOM-паттерн підглянутий у InstallBanner з SDK (немає спільного DOM-builder helper-а в пакетах, усе руками через document.createElement

  • власний <style>, що вставляється в <head>): статичний клас, монтує DOM-елемент у #ui (SDK-контейнер, position:absolute, зафіксований 9:16 blocks — calc(100vh*9/16) × 100vh, центрований — це і є "екран гри", не весь браузер, тож top/right у % рахуються відносно НЬОГО). .resource-hud { pointer-events:none } — щоб не перехоплював тач/клік від джойстика під ним. Стиль — власний CSS, вигаданий (в проєкті НЕ було жодного готового "плата/банер" стилю для запозичення — перевірено, в ui.css/main.css нуль хітів на border-radius).

Другий обов'язок HudCсвітова точка (worldAnchor, порожній Object3D, дитина камери через CameraC_internal.getCamera().add(...), локальний офсет — спочатку (1.0, 0.8, -2.2), потім підкручено до (1.0, 1.2, -2.0) (див. нижче, чому)), яка приблизно проєктується туди, де візуально сидить DOM-іконка. Це не точний screen-to-world розрахунок (FOV/aspect не враховані математично) — просто підібраний на око офсет; якщо HUD переїде/зміниться розмір екрану, можливо треба підкрутити координати ще раз. getWorldAnchorPosition() — це і є точка, куди тепер летять ресурси (ResourceC.setFlyTarget) і звідки стартує депозит-політ (PayZoneC.playDepositFlight).

Що змінилось у ResourceC/PayZoneC

  • ResourceC: scatter -> settle -> (якщо flyTarget заданий, а тепер він завжди заданий — HudC.getWorldAnchorPosition) новий стейт toTarget (0.5с, smoothstep + згасаюча дуга вгору, прямо до лічильника) -> collect(). Без flyTarget — стара миттєва поведінка (fallback). На відміну від попередньої версії — НЕ телепортується на позицію гравця перед польотом (той крок був заточений під "летить З гравця", зараз відповідь користувача про сам HUD не згадувала цей крок, тож прибрав його — політ іде прямо з місця, де ресурс осів, до лічильника).
  • PayZoneC.playDepositFlight: from тепер HudC.getWorldAnchorPosition() замість позиції гравця — "від лічильника до пей зони", як попросив користувач. Тригер (proximity до зони) не змінився — усе ще потребує стояння гравця в зоні, змінилась лише точка ВИЛЬОТУ візуалу.

Перевірено в браузері (Playwright, scratchpad): іконка рендериться коректно (512×512 webp, валідний data: URI, naturalWidth/naturalHeight не 0), лічильник в DOM оновлюється синхронно з console.log-ами збору (звірено скріншотом — "8" на екрані == 8-й [ResourceC] gathered wood в консолі).

Доопрацювання: лічильник має бути "живим балансом", не lifetime-total (2026-08-12)

Спочатку HudC показував ResourceC.getCollectedCount() напряму — тобто lifetime-суму, яка ТІЛЬКИ росте (депозит в Pay Zone на неї не впливав). Користувач попросив: число має рости при зборі І зменшуватись при депозиті — тобто показувати поточний "баланс", а не історичний тотал.

Розв'язання без нової мутабельної змінної (і без циклічного імпорту — PayZoneC вже імпортує HudC для getWorldAnchorPosition(), тож HudC імпортувати PayZoneC напряму означало б цикл):

  • PayZoneC.getDepositedCount() — новий публічний гетер, повертає внутрішній deposited (лічильник, який і раніше рахував "скільки вже влетіло в зону", просто не був назовні доступний).
  • HudC.setBalanceGetter(fn) — інжектований гетер (той самий паттерн, що й ResourceC.setFlyTarget/onDestroyed в BreakablePropC), замінив пряму залежність HudC -> ResourceC. HudC більше НЕ імпортує ResourceC взагалі.
  • TestSceneC зв'язує: HudC.setBalanceGetter(() => ResourceC.getCollectedCount() - PayZoneC.getDepositedCount()) — чиста похідна величина, рахується на льоту щокадру в HudC.update(), без жодного окремого "decrement"-виклику. Росте коли collected росте (щось зібрали), падає коли deposited росте (щось долетіло в зону) — саме по собі, без спеціальної синхронізації.

Перевірено: зібрав 7 ресурсів (консоль: gathered wood (7 total)), весь цей час стояв в Pay Zone -> усі 7 злились в зону протягом ~2.1с (DEPOSIT_INTERVAL=0.3 × 7) -> лічильник на екрані повернувся до 0, хоча collected лишився 7 — підтверджує, що баланс дійсно "живий", а не lifetime-сума.

Доопрацювання: точка вильоту ресурсів була занизько (2026-08-12)

WORLD_ANCHOR_LOCAL_OFFSET підняли з (1.0, 0.8, -2.2) до (1.0, 1.2, -2.0) — більший Y (вище в camera-space) і трохи менший |Z| (ближче до камери, тому той самий Y дає більше вертикальне зміщення на екрані через перспективу) — все ще підібрано на око, не розраховано математично з FOV/aspect. Це найбільш "на око" підібрана частина роботи — якщо після цього фіксу політ все ще не влучає точно в іконку, підкрутити ці три числа ще разHudC.ts).

Камера: прибрано lookAt, лінійне зміщення замість орбіти (2026-08-12)

Фідбек від ментора користувача: замість lookAt-based слідкування камери за персонажем — краще визначити точку біля персонажа і зміщувати камеру відносно неї, залежно від напрямку погляду персонажа. Уточнення від користувача: без orbit-behind — це має бути суто лінійне зміщення позиції, кут камери взагалі не повинен обертатись.

Що було

CameraFollowC.update() щокадру: (1) демпінгував позицію камери до targetPosition + offset + lookAhead (це вже було чисте лінійне зміщення — lookAhead — той самий "зсув в напрямку погляду персонажа", про який казав ментор, він вже існував), (2) окремо демпінгував smoothedLookAt-точку і кожен кадр робив camera.lookAt(smoothedLookAt) — тобто поворот перераховувався з нуля щокадру з двох незалежно згладжених точок, а не одного узгодженого стану.

Що зроблено (перша ітерація)

  • Прибрано smoothedLookAt, LOOK_DAMPING, і сам виклик camera.lookAt() з update() повністю. Позиційна частина (offset + lookAhead + obstacle-avoidance raycast) лишилась незмінною — вона й раніше була чистим translation, без жодного обертання.
  • Поворот камери відтепер виставляється один раз в init(): camera.position.copy(target.position).add(offset); camera.lookAt(target.position);і після цього update() більше НІКОЛИ не торкається camera.rotation/ camera.quaternion. Кут "заморожений" геометрично на старті й лишається таким назавжди, скільки б персонаж не розвертався.

Доопрацювання: навіть без обертання камери відчувався рух "по колу" (2026-08-12)

Користувач: коли персонаж РОЗВЕРТАЄТЬСЯ (не рухаючись при цьому), камера все ще відчутно "йде по колу". Причина — той самий lookAhead-вектор, що лишили в першій ітерації: він рахувався з напрямку, куди персонаж дивиться (facing, з target.quaternion), а не куди рухається. Навіть згладжений (LOOK_AHEAD_DAMPING), він все одно ОБЕРТАЄТЬСЯ разом з поворотом персонажа (наприклад, коли той розвертається на місці, щоб глянути на ящик) — а що обертається, те й тягне позицію камери по дузі навколо персонажа, хай і невеликій. Користувач хотів буквально "2 точки і пряма між ними" — жодної залежності від напрямку погляду.

Перша спроба: прибрав lookAhead повністю (desiredPosition = targetPosition + offset, без жодного додаткового вектора). Круговий рух дійсно зник, але користувач одразу зауважив: зникло й саме зміщення — "раніше зміщення було підходяще, єдина проблема була в тому, що воно йшло по колу". Тобто магнітуда/факт панорамування був потрібен, просто НЕ прив'язаний до повороту.

Фінальне виправлення: lookAhead вернув, але тепер рахується з реального зміщення позиції персонажа між кадрами (targetPosition - previousTargetPosition), а не з target.quaternion. previousTargetPosition — нове поле, оновлюється щокадру. Якщо кадровий рух менший за MOVEMENT_EPSILON_SQ (стоїть на місці — байдуже, як розвернутий) — цільовий look-ahead = нульовий вектор; якщо рухається — нормалізований напрямок руху × LOOK_AHEAD_DISTANCE (та сама магнітуда, що й була). Обидва варіанти йдуть через той самий smoothedLookAhead.lerp з LOOK_AHEAD_DAMPING, як і раніше — тільки джерело напрямку інше. Результат: розворот на місці = нуль руху = камера взагалі не рухається; ходьба = той самий пан вперед, що й був до всіх цих правок. Урок: "згладжений вектор, що обертається з об'єктом" все одно читається як орбітальний рух — згладжування прибирає різкість, а не кривизну; правильний фікс — прив'язати джерело напрямку до РУХУ (position delta), а не до facing/quaternion, а не просто видалити ефект.

Перевірено в браузері (Playwright) — рендер коректний з першого кадру і після ходьби/бою, без console-помилок.

Доопрацювання ×3: зовсім не відчувалось, тримати зсув, миттєвий розворот на 180° (2026-08-12)

Після руху-based lookahead користувач: "Немає зміщення взагалі". Причина — не баг у логіці (перевірив логуванням, числа рахувались вірно), а те, що стара lookAt-версія давала подвійний ефект (і зсув позиції, і доворот камери в той же бік щокадру), а зараз лишився тільки зсув позиції — і сама позиційна складова (LOOK_AHEAD_DISTANCE=1.2) була занадто малою, щоб щось означати з відстані ~12.7 од. ((0,9,-9) риг). Підняв до 4, користувач сам потюнив назад до 2 (лишив цю зміну, не відкатувати).

Ще дві правки в тому ж повідомленні:

  • "Камера після зсуву не має повертатись назад"desiredLookAhead тепер окреме поле, не локальна константа: оновлюється (.copy(movement)...) ТІЛЬКИ коли movement.lengthSq() > MOVEMENT_EPSILON_SQ, і просто НЕ чіпається, коли персонаж стоїть — замість : new Vector3() (нуль) в тернарному операторі, як було. Тобто пан тримається на місці, де зупинився, а не з'їжджає назад до центру, поки не з'явиться новий, ІНШИЙ напрямок руху.
  • "Зроби анімацію плавнішою"LOOK_AHEAD_DAMPING знижений з 2 до 1.2 (повільніший ease).
  • "Персонаж має розвертатись моментально, якщо ми змінюємо напрям на протилежний" — це вже не про камеру, а про PlayerC (updateMovement): новий OPPOSITE_TURN_THRESHOLD = Math.PI * (150/180) (~150°). Якщо кут між поточним і цільовим facing (quaternion.angleTo(...)) перевищує цей порог — quaternion.copy(facingRotation) миттєво, інакше — старий капований rotateTowards(facingRotation, MAX_TURN_SPEED * delta). Тобто тільки різкі розвороти "в протилежну сторону" миттєві, дрібні корекції курсу лишаються плавними, як і були.

⚠️ Знахідка: camera_rotation_p/camera_rotation_l (дизайнерський конфіг) виявився нежиттєздатним самостійно

Спочатку спробував залишити кут камери таким, яким його виставляє CameraC.setCamera() з конфіг-параметрів camera_rotation_p/_l (дизайнер може їх тюнити через UI) — просто прибравши lookAt і НІЧОГО більше не додаючи. Результат: порожнє темно-синє небо, ні мапи, ні персонажа в кадрі. Причина: цей конфіг-кут ніколи насправді не був призначений працювати самостійно — CameraFollowC з першого ж комміту, що його додав, миттєво перезаписував поворот через lookAt() щокадру, тож ніхто й не міг помітити (чи потребу) тюнити camera_rotation_p під реальний рух — він був "мертвим" параметром, що впливав щонайбільше на один кадр до першого update().

Виправлення: не покладатись на цей конфіг взагалі для follow-камери — рахувати фіксований кут геометрично з реального offset ((0,9,-9)) в момент CameraFollowC.init() (описано вище). Це гарантовано framing персонажа з самого старту, незалежно від того, що зараз стоїть в camera_rotation_p. Урок: значення з configUIParams/globalSettings не завжди відображають те, що реально відбувається в грі — перевіряти, чи щось інше (як тут CameraFollowC) не перезаписує їх одразу після встановлення, перш ніж покладатись на них як на джерело правди.

Перевірено в браузері (Playwright): після фіксу сцена рендериться коректно з самого старту (мапа/персонаж/крейти на місці, кут ідентичний попередньому вигляду), і лишається так само коректно framed після ходьби/бою/розвороту персонажа — камера просто зсувається лінійно, кут не змінюється.

Камера підлаштовується під facing-lock на крейт (2026-08-12)

Користувач: коли персонаж зупиняється біля ящика і "тригер збору" (PlayerC.faceTowards, викликається з updateMovement коли stopped && nearbyObstacle) розвертає його лицем до цілі, камера має підлаштовуватись під новий кут, а не лишатись байдужою. Це саме той кейс, який попередній рух-based lookAhead свідомо ігнорував (розворот на місці = нульовий movement = look-ahead не оновлюється) — правильна поведінка для довільного розвороту, але користувач хотів виключення саме для facing-lock на breakable-ціль.

Рішення без повернення до facing/quaternion-залежності (яка й дала "рух по колу" раніше): PlayerC тепер зберігає, на яку саме ТОЧКУ (не кут!) він зараз locked — facingTarget: Vector3 | null, виставляється в updateMovement() в той самий момент, коли викликається faceTowards() (і скидається в null, коли умова stopped && nearbyObstacle неправдива). Публічний PlayerC.getFacingTarget().

CameraFollowC.setFacingTargetGetter(getter) — injected getter (той самий DI-паттерн, що й ResourceC.setFlyTarget), підключено в TestSceneC.init() відразу після CameraFollowC.init(). В update(): якщо facingTarget не nulldesiredLookAhead рахується як напрямок від гравця ДО цієї точки (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 множиться на текстуру, тож "яскравіше за білий" через цей канал взагалі неможливо.

Виправлення: замість лерпу .colorStageVisual.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.otftemp/, ще не імпортований) — ймовірно призначений саме для цього.

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 — був створений і видалений в межах цієї ж сесії (див. "Файли").
  • Ще не закомічено в git (working tree) — користувач сам вирішує коли комітити.
  • Якщо наступного разу знову треба буде правити 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, прогрес-бар зони), має власні "Перевірено" нотатки нижче за текстом.