Was committed stale in the previous push — catches it up with WeaponTrailC, SparkleFxC pooling, ResourceC's bounce/flash/shrink/perspective-compensation redesign, the HudC UI overhaul, and PayZoneC's deposit-flight fixes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
106 KiB
MyGamePlayable — контекст проєкту
Цей файл автоматично підвантажується в кожній сесії Claude Code. Тримай його в актуальному стані: онови статуси, коли щось зміниться, видали "Day 5" секцію коли вона остаточно перевірена і стабільна, додай нову секцію під наступний день.
Стек і структура
- Не Babylon.js — це three.js (
three@0.185) обгорнутий у пропрієтарний SDK@hitplay/playable_template(плейбл-реклама, HitPlay). - Фізика:
cannon-es@0.20+cannon-es-debugger. World піднятий через SDK (Physics_internal.init(...)вsrc/templateConfig/beforeResourcesLoadedCb.ts:27), дебаг-рендер тригериться прапорцемTemplate.initConfig({ debug: { physics: false } })вsrc/index.ts(покиfalse— увімкнути під час дебагу фізики). SDK сам кличеworld.fixedStep()кожен кадр (Physics_internal.update, всередині пакету) — свійworld.step()/fixedStep()НЕ викликати, це вже фіксований таймстеп з коробки. UpdateController.Instance.onUpdate— єдиний update-event,deltaв секундах (зTHREE.Clock.getDelta()), без фіксованого таймстепу на цьому рівні (фіксований степ — тільки всередині cannon, черезfixedStep()).ThreeC.removeFromScene(obj)існує (простоobj.removeFromParent()) — використовувати для видалення спавнених/знищених мешів.
Файли
src/controllers/
PlayerC.ts - джойстик-рух (nipplejs), AABB-колізії з "colliders",
бій: неперервний AoE-swing по всіх breakable-цілях в
зоні ураження (240°-конус, FACING_DOT_THRESHOLD=-0.5,
Day 7 — див. "Кут ураження"), перебивається рухом
TestSceneC.ts - завантажує ZombiePunk_Map.glb, /collider/i -> hidden+colliders,
ставить cannon Wall-тіла на все, крім Lootable-крейтів;
ініціалізує всі VFX/HUD-контролери (PropVfxC,
SparkleFxC, WeaponTrailC, HudC) в правильному порядку
CameraFollowC.ts - камера-слідкувач, obstacle-avoidance через Raycaster
по тому ж масиву colliders (НЕ торкались)
PhysicsC.ts - PhysicsLayer enum, PhysicsBody (Box/Sphere+cannon Body
з коректним world-space size/rotation), PhysicsObjPair
BreakablePropC.ts - парсить групу "Lootable" в мапі, S1/S2/S3 damage-stages,
2 хіти на стейдж, hit() advance stage / повне
знищення (tween-джус, Day 6), дропає ресурси
WeaponTrailC.ts - Day 7: hand-rolled screen-facing ribbon-шлейф за
битою під час замаху (samples PlayerC.getWeaponAnchor(),
обчислює "кінець бити" з реальної local bbox, не
вгадує offset), тає/звужується за час, не тон-мап,
toggled через PlayerC.updateCombat (isAttacking)
ResourceC.ts - Day 7: спавн ресурсу з крейта -> scatter -> 2 баунси
(відчутні, БЕЗ ротейту) -> на 2-му баунсі: білий
флеш (окремий additive-оверлей-меш, БЕЗ
squash-and-widen — чистий uniform shrink, тримається,
не пружинить назад) + SparkleFxC-вибух зірочок ->
staggered (по черзі, не купою) політ до
HudC.getWorldAnchorPosition() (ціль пере-семплюється
ЩОКАДРУ, не застигає, якщо камера рухається) з
компенсацією перспективи (див. RESOURCE_ICON_BASE_SCALE,
нижче) -> яскравіє й зникає рівно на іконці. НЕ знає
про Pay Zone/гравця; візуал — клон реального
"UI_Wood" з мапи; createVisual() — публічний доступ
до цього візуалу (без баунс/флеш начинки) для інших
контролерів (PayZoneC)
PayZoneC.ts - Day 6/8: окремо зливає ResourceC.getCollectedCount() в
Pay Zone (вузол мапи "UI_Interactive_Zone_02"), по
одному ресурсу, тільки поки гравець стоїть в зоні;
зникає після RESOURCES_TO_CLOSE_ZONE доставлених
(зараз 20); росте плаский fill-overlay квад по мірі
заповнення; політ депозиту (HudC.getWorldAnchorPosition()
-> Pay Zone) БЕЗ ротейту, з тією ж компенсацією
перспективи й базовим розміром, що й ResourceC
(RESOURCE_ICON_BASE_SCALE, спільна константа —
інакше токен виглядає більшим за ресурс, що щойно
був на тому ж місці), плюс окремий
DEPOSIT_FLIGHT_SCALE_MULTIPLIER для локального тюнінгу
HudC.ts - Day 8: лічильник ресурсу — реальний дизайнерський
ассет (resourceCounterBgSrc, Tool_15.webp з temp/,
планка вже вбудована в картинку, не окрема іконка),
DOM-оверлей в #ui, стилі в src/css/ui.css (не inline
<style>); getWorldAnchorPosition() — СПРАВЖНЯ
screen-to-world проєкція (getBoundingClientRect()
правого краю плейта + unproject через камеру), НЕ
вгаданий локальний офсет від камери (той підхід
видалений повністю, разом з WORLD_ANCHOR_LOCAL_OFFSET)
SparkleFxC.ts - Day 7: пул із 30 зіркових мешів/матеріалів (pop-in
Back.Out -> hold -> fade-out через TweenC), жодних
нових Mesh/Material під час гри — spawnBurst() бере
вільний слот з пулу; використовується ResourceC на
2-му баунсі
PropVfxC.ts - Day 6: завантажує дизайнерські quarks.art VFX-префаби
(JSON з temp/, sparks/dust/debris) для хіту/знищення
крейтів
ThreeC.ts/CameraC.ts - базовий сетап three.js/камери від SDK
resources/meshes/ - ZombiePunk_Map.glb, ZombiePunk_Character.glb
resources/images/ - resource_counter_bg.webp (Tool_15.webp з temp/,
дизайнерський плейт лічильника), images.ts
(icon_wood.webp/iconWoodSrc видалені — стали
осиротілими після заміни на resourceCounterBgSrc)
resources/vfx/ - VFX_Lootable_Hit.json, VFX_Lootable_Destroy.json
(quarks.art префаби, копії з temp/, реально
використовуються з коду — див. PropVfxC)
TriggerC.ts (beginContact/endContact -> onEnter/onExit) і кінематичне
cannon-тіло гравця для тригерів — були написані й видалені в межах цієї ж
сесії, ще до коміту: перший дизайн ресурсів вимагав підходити до пікапа й
стояти в тригер-зоні; користувач уточнив, що гравцю підходити не треба
(ресурси самі розлітаються і зараховуються по таймеру), тож фізичні тригери
виявились непотрібні для Day 5. Якщо колись знадобиться proximity-based
тригер для чогось іншого (вода, зона урону, детект ворога) — писати заново,
але сам підхід (world.addEventListener('beginContact'/'endContact') +
мапа body.id -> handlers) вже перевірений робочим, просто не лишений в коді.
Ключова знахідка: структура glb вже готова під breaking/loot
Мапа (ZombiePunk_Map.glb) містить групу Lootable (пряма дитина кореня
Map) з ~18 дітьми Wooden_Box_XXX (XXX = 000..017). Кожен має:
Wooden_Box_XXX
BoxCollider.NNN <- obstacle-бокс, він же в загальному /collider/i списку
Wooden_Box_States_XXX
Wooden_Box_XXX_S1 <- найменше пошкоджений (не завжди є)
Wooden_Box_XXX_S2
Wooden_Box_XXX_S3 <- найбільш пошкоджений/уламки (завжди є хоча б цей)
Не всі крейти мають усі 3 стейджі — деякі стартують вже пошкодженими
(тільки S2/S3) або взагалі як декоративні уламки (тільки S3). BreakablePropC
сортує дітей ..._States_XXX за номером у назві й показує лише перший —
кожен хіт просуває на наступний, а хіт по останньому стейджу знищує крейт.
Root-рівень gltf-сцени (getObject("map") = gltf.scene, НЕ сам "Map"-нод)
містить ще 3 сиблінги "Map": UI_Tool_Zone, UI_Interactive_Zone_02,
UI_Wood/UI_Wood.001 — плоскі quad-меші на позиції origin. В грі це
видно як пунктирний білий квадрат, що лежить на дорозі біля крейтів
(скріншот при першому запуску) плюс текстуру дерева поверх нього.
UI_Wood (mesh Plane.002) — це реальний іконка-ресурсу, не сміття.
Матеріал Material (index 6) використовує текстуру з назвою Icon_Wood
(unlit, alphaMode BLEND, doubleSided) — художник підготував конкретно "іконку
дерева" саме для цього. Тепер підключено: TestSceneC бере цей нод,
віддає його в ResourceC.setPickupTemplate() (клонується на кожен спавн
ресурсу — .clone()) і ховає оригінал (visible=false, він більше не
статична декорація, а шаблон для клонування). ResourceC.createPickupMesh()
примусово виставляє clone.visible = true, бо .clone() копіює і
visible:false з прихованого оригіналу.
UI_Wood.001 (mesh Plane.008, матеріал M_Items/текстура T_items_icons
— схоже на спрайт-атлас з кількома іконками) і UI_Tool_Zone — призначення
досі не зрозуміле, але користувач попросив прибрати їх як "зайві текстури"
навколо Pay Zone — тепер ховаються в TestSceneC (visible=false) поруч з
UI_Wood. UI_Interactive_Zone_02 (пунктирний квадрат) — тепер
підключений в Day 6 як Pay Zone (див. нижче).
Ще одна знахідка (не сиблінг, а дитина Map -> "UI"): группа UI
(UI_Background/UI_Foreground/UI_Middleground, всі матеріал _Part2
— той самий атлас, що й крейти) — окремо повернута група (власний
quaternion), локально приблизно (0.31, 1.21, -1.9), тобто підвішена в
повітрі на висоті голови персонажа. Схоже на фейковий install-button
мокап (типовий playable-ad прийом), рендерився завжди, візуально
"прямокутна синя пластина над плей зоною" з якою скаржився користувач.
Теж ховається тепер (map.getObjectByName("UI"), visible=false).
Day 5: Cannon.js — реалізовано (2026-08-11, доопрацьовано того ж дня)
Перша ітерація зробила всі 8 пунктів чек-листа буквально (single-target
дискретний свінг + proximity-тригери на ресурси). Користувач одразу дав
конкретні правки під реальний геймплей-дизайн (враховані в описі нижче) —
фінальний стан коду відображає ці правки, не буквальний чек-лист. Білд
(vite build) проходить чисто після кожної ітерації. Ручна перевірка в
браузері — ще не підтверджена користувачем; я зробив лише один короткий
automated прогін (Playwright, в scratchpad, не в репо) до першої ревізії —
рендер і консоль були чисті, а саме AoE/continuous-attack/resource-burst
поведінку (фінальну версію) користувач попросив перевірити самостійно.
1. Колайдери на об'єктах карти — ✅
TestSceneC.createMap(): всі /collider/i ноди, які НЕ належать Lootable,
отримують PhysicsBody(node, false, 0, PhysicsLayer.Wall, Player|Enemy).
Lootable-крейти отримують свої Wall-тіла всередині BreakablePropC (щоб
можна було destroy() саме це тіло при знищенні крейта, без дублювання).
При ревайві PhysicsC.PhysicsBody знайшов і виправив реальний баг:
конструктор рахував size/rotation з Box3 в world-space, обнуляючи лише
власний quaternion об'єкта — обертання батьків (напр. кожен
Wooden_Box_XXX root сам повернутий по Y) все одно потрапляло в bbox, тобто
box виходив перекошеним/більшим за реальний. Тепер: size — з
mesh.geometry.boundingBox (локальний, без жодних обертань) × world scale,
rotation — з getWorldQuaternion(). Коректно для будь-якої глибини вкладеності.
2. Колайдери на гравці й персонажах — ✅
Рух гравця не переписаний — досі ручний AABB tryMove(), як і був. Окреме
cannon-тіло гравця (яке було для тригерів) видалено разом з TriggerC
(див. вище) — зараз у гравця взагалі немає cannon Body, тільки AABB.
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: використаний існуючий
PhysicsLayerenum без змін (хоча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) — але обов'язково додавати кожну ланку вTweenCgroup окремо (див. баг вище) —.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 вже занесено). Три ітерації дизайну (кожна —
реальна правка коду, не просто обговорення):
- Горизонтальний квад на землі, що росте вздовж world X — користувач: "заповнюється... з права на ліво" (не те, що хотів).
- Box, що росте вгору по Y (буквально "як вода в басейні") — виявився концептуально хибним: верх/низ box-а завжди займають весь footprint незалежно від висоти, тож з ізометричної камери зона виглядала "одразу заповненою" на будь-якій висоті > 0.
- Фінал: плаский квад на землі знову, але росте вздовж 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/master2026-08-12 (commit4a22a9b, "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) — три ітерації:
- Squash-and-widen (X/Z шириться, Y стискається, пружинить назад) — користувач: "ресурси на прикінці анімації збільшуються, мають навпаки зменшуватись" — розтягнення X/Z понад 1.0 читалось як РІСТ.
- Uniform shrink (
scale.setScalar(1-shrink), без розтягування) — краще, але користувач ще раз: "має бути одного розміру від початку до кінця" проtoTarget-фазу конкретно. - Фінал: стиск ЛИШЕ на 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), а не дублювати магічне число, інакше вони розходяться, як тут.