63609eea2c
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>
1162 lines
106 KiB
Markdown
1162 lines
106 KiB
Markdown
# 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**: використаний існуючий `PhysicsLayer` enum без змін
|
||
(хоча `Trigger` тепер ніде не використовується після видалення тригерів).
|
||
|
||
## Day 6: Tween Animations — реалізовано (2026-08-11, виправлено того ж дня)
|
||
|
||
`@tweenjs/tween.js` був установлений, але **ніде не використовувався** до
|
||
цього дня. SDK має готову обгортку `TweenC` (`@hitplay/playable_template`,
|
||
`import { TweenC } from "@hitplay/playable_template"`) з власною `Group` і
|
||
автопідпискою на `UpdateController` — **треба викликати `TweenC.init()`
|
||
один раз** (додано в `beforeResourcesLoadedCb.ts`, поруч з
|
||
`Physics_internal.init(...)`), інакше `TweenC.add()`/`.create()` тихо
|
||
нічого не анімують.
|
||
|
||
### ⚠️ Знайдений і виправлений баг: `.chain()` НЕ реєструє другу ланку в групі
|
||
Перша реалізація хіт-фідбеку й disappear-анімації використовувала
|
||
`tweenA.chain(tweenB); TweenC.add(tweenA); tweenA.start();` — і крейти
|
||
переставали зникати повністю (застигали розтягнутими на punch-фазі),
|
||
а флеш/нахил не виглядав як задуманий ефект (застигав на пікові й лишався).
|
||
Причина, перевірена читанням `tween.cjs`: `.chain()` каже tweenA лише
|
||
покликати `tweenB.start()` при завершенні — `.start()` ставить
|
||
`_isPlaying=true`, але **не додає tweenB в жодну `Group`**. `Group.update()`
|
||
ітерує тільки тіли, додані через `Group.add()`, тож tweenB ніколи не
|
||
отримує `.update()`, назавжди застигаючи "playing" в останньому кадрі
|
||
tweenA. **Виправлення: додавати кожну ланку ланцюжка в групу окремо**
|
||
(`TweenC.add(tweenA); TweenC.add(tweenB);` — обидва, до `tweenA.start()`).
|
||
Підтверджено repro+fix через Playwright у scratchpad (консоль показувала
|
||
`onUpdate` для другої ланки, що ніколи не спрацьовував до фіксу).
|
||
|
||
`Tween.stop()` **каскадно зупиняє весь `.chain()`**, навіть якщо зараз
|
||
виконується вже ланка ланцюжка, а не голова (`stop()` завжди спочатку
|
||
кличе `stopChainedTweens()`, до перевірки `_isPlaying`) — це підтверджено
|
||
і лишається правильним механізмом "always kill or reuse tweens": досить
|
||
тримати посилання на ГОЛОВУ ланцюжка і кликати на ній `.stop()` (саме
|
||
зупинку це чіпляє коректно; сама помилка була тільки в реєстрації в групі).
|
||
|
||
Перед реалізацією користувач попросив **питати по кожному з 4 пунктів**, а
|
||
згодом дав ще правки під час перевірки — відповіді й правки (важливі для
|
||
майбутніх сесій):
|
||
- "Гроші" з Day 6 = те саме дерево, що вже рахує `ResourceC` (не окрема валюта).
|
||
- Pay Zone: гейм-дизайн "що буде після" — **не важливо**, лише сам механізм.
|
||
- HUD не існує і Day 6 його НЕ додає.
|
||
- Hit-feedback: короткий білий флеш ін-аут + нахил у протилежну від удару
|
||
сторону і повернення.
|
||
- (правка) Гроші мають летіти **прямо з персонажа в Pay Zone** — без
|
||
проміжної "UI-точки", яка була в першій версії.
|
||
- (правка) Pay Zone потребує **100** зібраних ресурсів, щоб зникнути (не
|
||
просто ">0", як було спочатку). (Пізніше значення `RESOURCES_TO_CLOSE_ZONE`
|
||
підкручено до **20** — див. секцію "прогрес-заповнення зони" нижче;
|
||
сам механізм — "N доставлених закриває зону" — не змінився.)
|
||
- (правка) Кожен damage-stage крейта потребує **2 хіти**, не 1.
|
||
|
||
### 1. Hit feedback + disappearing (крейти) — ✅
|
||
`BreakablePropC`: матеріал кожного stage-меша **клонується один раз при
|
||
білді** (`buildStageVisual`) — без цього флеш одного крейта підсвітив би
|
||
ВСІ 18 крейтів одразу, бо всі стейджі всіх крейтів шарять один Material
|
||
з glb (перевірено по індексу матеріалу в самому glb). `HITS_PER_STAGE = 2`
|
||
— `hit()` рахує `prop.hitsOnStage`, і тільки на 2-му хіті стейдж
|
||
просувається/крейт руйнується; **кожен** хіт (і 1-й, і 2-й, якщо не
|
||
руйнує) грає `playHitFeedback` — один `Tween<{t}>` ланцюжком (flash-in
|
||
90мс -> flash-out 150мс, **обидві ланки додані в `TweenC` окремо**, див.
|
||
баг вище), в `onUpdate` одночасно виставляє `root.rotation` як
|
||
`baseRotation + tilt*t` (`baseRotation` теж збережений один раз при білді,
|
||
щоб повторні хіти під час ще не завершеного нахилу не накопичували дрейф
|
||
кута) і, зараз, **опасіті окремого additive-оверлей-меша** (`flashMesh`,
|
||
не лерп `material.color` — це вже пізніша правка, `material.color` на цих
|
||
unlit-крейтах виявився тихим no-op, див. секцію "Розширений hit-feedback"
|
||
нижче за повним поясненням). `tilt` рахується з `hitDirection`, який
|
||
передає `PlayerC` (`hitDirectionTo()` — attacker->target, XZ, нормалізований).
|
||
|
||
На хіті, що руйнує крейт (2-й хіт останнього стейджу) — окремий ефект
|
||
замість флешу/нахилу: **(оновлено пізніше)** зараз це sink+topple —
|
||
`root.position.y` занурюється вниз (`DESTROY_SINK_DEPTH`, `Quadratic.In`,
|
||
`DESTROY_SINK_MS`) з одночасним нахилом від удару (`DESTROY_TILT_ANGLE`,
|
||
той самий `tilt`-принцип, що й у hit-feedback), `root.visible=false`
|
||
виставляється в `onComplete`, а НЕ миттєво — фізика/обстакл-клінап
|
||
(`physicsBody.destroy()`, видалення з масивів) лишились синхронними в
|
||
момент хіта (гравець вже не натикається на нього, поки крейт ще візуально
|
||
занурюється). Це заміна першої версії (punch-scale 1 -> 1.15 `Back.Out` ->
|
||
0 `Quadratic.In`) — коли й чому саме на sink+topple, в чаті цієї сесії не
|
||
зафіксовано; якщо матимеш контекст, допиши сюди.
|
||
|
||
### 2–3. Money flying out of the player into the Pay Zone — ✅ (два ОКРЕМІ механізми)
|
||
Проміжна версія об'єднала "збір ресурсу" і "передачу в Pay Zone" в один
|
||
конвеєр, гейтований стоянням в зоні (`ResourceC` мав стейт `holding`, що
|
||
чекав `inZoneCheck()` перш ніж рахувати ресурс — тобто лічильник взагалі
|
||
не рухався, поки гравець не заходив в зону). Це **зламало базовий збір**:
|
||
ресурси лежали на землі й не зникали, якщо гравець ламав ящики далеко від
|
||
зони. Користувач уточнив: **збір має працювати як і раніше** (Day 5,
|
||
нічого спільного з Pay Zone), а гейтинг по стоянню в зоні має стосуватись
|
||
**лише окремого механізму "передачі" вже зібраного в зону**.
|
||
|
||
Фінальний дизайн (на момент Day 6) — `ResourceC` і `PayZoneC` повністю
|
||
незалежні:
|
||
- **`ResourceC`** — Day 5 логіка збору (`scatter` -> settle) без жодної
|
||
згадки про Pay Zone/гравця/зону — просто "зібрав ресурс". Що саме
|
||
відбувається ПІСЛЯ settle (миттєво рахується, чи летить кудись) —
|
||
контролюється ззовні через `setFlyTarget()` (додано пізніше, див.
|
||
"HUD" нижче — спочатку тут стояв просто `collect()` без польоту).
|
||
`createVisual()` — публічний метод, що віддає клон іконки-ресурсу для
|
||
чужого використання (зараз юзає `PayZoneC`).
|
||
- **`PayZoneC`** — окремо "зливає" вже зібране (`ResourceC.getCollectedCount()`)
|
||
в зону, по одному ресурсу за раз, **тільки поки гравець стоїть в зоні**
|
||
і є "хвіст" (`collected - deposited > 0`): кожні `DEPOSIT_INTERVAL`
|
||
(0.3с) — новий `ResourceC.createVisual()` летить в зону (`Quadratic.InOut`,
|
||
500мс) і зникає, `deposited++`. Звідки саме стартує цей політ — теж
|
||
змінилось пізніше (див. "HUD" нижче).
|
||
|
||
### ⚠️ Знайдений і виправлений баг: `isPlayerInside` мав фіксований радіус замалий за фактичний розмір зони
|
||
Перша версія `isPlayerInside` рахувала XZ-відстань до пивота ноду й
|
||
порівнювала з `ZONE_RADIUS = 2` (підібраним на око зі скріншота). За
|
||
фактом гравець міг зібрати 20+ ресурсів, зайти всередину видимого
|
||
пунктирного квадрата — і нічого не відбувалось, бо реальний розмір зони
|
||
значно більший за коло радіусом 2. Перевірено вимірюванням: `new
|
||
Box3().setFromObject(payZoneNode)` дав `size ≈ [5.96, 0.09, 4.60]` (X/Z),
|
||
тобто прямокутник ~6×4.6, з центром зсунутим від origin (`min.x≈-2.89,
|
||
max.x≈3.06` — не симетрично!). Коло радіусом 2 покривало тільки малу
|
||
частку видимого квадрата, переважно по X.
|
||
|
||
**Виправлення**: `isPlayerInside` тепер рахує `Box3` один раз в `init()`
|
||
(`new Box3().setFromObject(payZoneNode)` — бере фактичну геометрію з
|
||
урахуванням трансформів, а не вгадану цифру) і перевіряє просте
|
||
point-in-rectangle по X/Z (`min <= pos <= max`), ігноруючи Y. Ніяких
|
||
магічних констант радіуса більше нема — footprint завжди відповідає
|
||
реальній мапі, навіть якщо художник поміняє розмір/форму зони.
|
||
**Урок**: для будь-якої майбутньої "чи гравець в зоні X" перевірки —
|
||
рахувати `Box3().setFromObject()` з реального ноду, не вгадувати
|
||
радіус/розмір на око зі скріншота.
|
||
|
||
### 4. Payzone disappearing — ✅ (потребує `RESOURCES_TO_CLOSE_ZONE` *доставлених*, не просто зібраних)
|
||
`PayZoneC.update()`: коли `deposited >= RESOURCES_TO_CLOSE_ZONE` (значення
|
||
на момент Day 6 було 100, зараз в коді **20** — підкручено пізніше, див.
|
||
"прогрес-заповнення зони"; в будь-якому разі це "N ресурсів фактично
|
||
долетіли в зону", не просто зібрані десь на мапі) — одноразово (`closed`
|
||
флаг) запускає `playDisappear()`: `scale 1->0` (`Quadratic.In`, 400мс),
|
||
`visible=false` в `onComplete`. Одноразово, назавжди (немає ре-спавну/
|
||
циклу — "що буде після" лишається не реалізованим за проханням
|
||
користувача). Пізніше (див. "прогрес-заповнення зони") цей самий
|
||
`playDisappear` розширений — тепер синхронно стискає ще й fill-overlay
|
||
квад, не тільки сам маркер зони.
|
||
|
||
### Best practices — де застосовано
|
||
- **Sequences**: усюди `.chain()` замість ручного стейт-машину (flash in->out,
|
||
punch->shrink) — але **обов'язково додавати кожну ланку в `TweenC` group
|
||
окремо** (див. баг вище) — `.chain()` сам лише стартує наступну ланку,
|
||
не реєструє її для update-тіків.
|
||
- **Kill or reuse tweens**: `BreakableProp.hitTween` зберігає голову
|
||
ланцюжка; `.stop()` перед кожним новим хітом і при `destroy()` (каскадно
|
||
зупиняє й активну ланку, це підтверджено робочим). `PayZoneC`'s
|
||
disappear — одноразовий по флагу (`closed`), тому без явного kill.
|
||
|
||
## HUD-лічильник + перенаправлення анімацій (2026-08-12)
|
||
|
||
Користувач попросив: (1) справжню 2D UI-іконку ресурсу з лічильником у
|
||
правому верхньому куті, "як на скріні" (темна округла табличка + іконка в
|
||
круглій рамці зліва + число справа), (2) ресурс замість миттєвого
|
||
зникнення має анімовано летіти ДО цього лічильника, (3) політ у Pay Zone
|
||
має бути ВІД лічильника до зони (не від гравця).
|
||
|
||
### Іконка
|
||
Витягнув сам PNG/WebP з тієї ж текстури `Icon_Wood`, яку вже юзає 3D-меш
|
||
`UI_Wood` (`src/resources/meshes/ZombiePunk_Map.glb`, `bufferView` з
|
||
`images[6]`) — маленьким одноразовим Node-скриптом (парсинг glb JSON-чанка,
|
||
пошук `images[].name === "Icon_Wood"`, зріз байтів з BIN-чанка за
|
||
`bufferViews[bufferView]`). Зберіг як `src/resources/images/icon_wood.webp`
|
||
(webp з альфа-каналом, 512×512). Новий `src/resources/images/images.ts`
|
||
експортує `iconWoodSrc = ConvertToBase64WhenRelease("./icon_wood.webp")` —
|
||
**той самий паттерн, що і `meshes.ts`** (той же helper з `@hitplay/ads_common`,
|
||
викликаний з файлу в ТІЙ САМІЙ директорії, що й ассет — важливо: цей
|
||
хелпер в dev/build режимі просто рядково замінює провідний `.` на
|
||
`resources` (`ConvertToBase64WhenRelease.js`), а окремий AST-плагін
|
||
(`convertToBase64InAST`), підключений через `defineConfigTemplate` в
|
||
`vite.config.js`, на build-time переписує сам виклик у справжній inline
|
||
`data:...;base64,...` — це працює для ВСІХ режимів (dev/build/export), не
|
||
тільки для "export").
|
||
|
||
### HudC.ts — новий контролер
|
||
DOM-паттерн підглянутий у `InstallBanner` з SDK (немає спільного
|
||
DOM-builder helper-а в пакетах, усе руками через `document.createElement`
|
||
+ власний `<style>`, що вставляється в `<head>`): статичний клас, монтує
|
||
DOM-елемент у `#ui` (SDK-контейнер, `position:absolute`, зафіксований
|
||
9:16 blocks — `calc(100vh*9/16)` × `100vh`, центрований — це і є "екран
|
||
гри", не весь браузер, тож `top/right` у % рахуються відносно НЬОГО).
|
||
`.resource-hud { pointer-events:none }` — щоб не перехоплював тач/клік від
|
||
джойстика під ним. Стиль — власний CSS, вигаданий (в проєкті НЕ було
|
||
жодного готового "плата/банер" стилю для запозичення — перевірено, в
|
||
`ui.css`/`main.css` нуль хітів на `border-radius`).
|
||
|
||
Другий обов'язок `HudC` — **світова точка** (`worldAnchor`, порожній
|
||
`Object3D`, дитина камери через `CameraC_internal.getCamera().add(...)`,
|
||
локальний офсет — спочатку `(1.0, 0.8, -2.2)`, потім підкручено до
|
||
`(1.0, 1.2, -2.0)` (див. нижче, чому)), яка приблизно проєктується туди,
|
||
де візуально сидить DOM-іконка. **Це не точний screen-to-world розрахунок**
|
||
(FOV/aspect не враховані математично) — просто підібраний на око офсет;
|
||
якщо HUD переїде/зміниться розмір екрану, можливо треба підкрутити
|
||
координати ще раз. `getWorldAnchorPosition()` — це і є точка, куди тепер
|
||
летять ресурси (`ResourceC.setFlyTarget`) і звідки стартує депозит-політ
|
||
(`PayZoneC.playDepositFlight`).
|
||
|
||
### Що змінилось у ResourceC/PayZoneC
|
||
- `ResourceC`: `scatter` -> settle -> (якщо `flyTarget` заданий, а тепер
|
||
він завжди заданий — `HudC.getWorldAnchorPosition`) новий стейт
|
||
`toTarget` (0.5с, smoothstep + згасаюча дуга вгору, прямо до лічильника)
|
||
-> `collect()`. Без `flyTarget` — стара миттєва поведінка (fallback).
|
||
**На відміну від попередньої версії — НЕ телепортується на позицію
|
||
гравця перед польотом** (той крок був заточений під "летить З гравця",
|
||
зараз відповідь користувача про сам HUD не згадувала цей крок, тож
|
||
прибрав його — політ іде прямо з місця, де ресурс осів, до лічильника).
|
||
- `PayZoneC.playDepositFlight`: `from` тепер `HudC.getWorldAnchorPosition()`
|
||
замість позиції гравця — "від лічильника до пей зони", як попросив
|
||
користувач. Тригер (proximity до зони) не змінився — усе ще потребує
|
||
стояння гравця в зоні, змінилась лише точка ВИЛЬОТУ візуалу.
|
||
|
||
Перевірено в браузері (Playwright, scratchpad): іконка рендериться коректно
|
||
(512×512 webp, валідний `data:` URI, `naturalWidth`/`naturalHeight` не 0),
|
||
лічильник в DOM оновлюється синхронно з `console.log`-ами збору (звірено
|
||
скріншотом — "8" на екрані == 8-й `[ResourceC] gathered wood` в консолі).
|
||
|
||
### Доопрацювання: лічильник має бути "живим балансом", не lifetime-total (2026-08-12)
|
||
Спочатку `HudC` показував `ResourceC.getCollectedCount()` напряму — тобто
|
||
lifetime-суму, яка ТІЛЬКИ росте (депозит в Pay Zone на неї не впливав).
|
||
Користувач попросив: число має рости при зборі І **зменшуватись при
|
||
депозиті** — тобто показувати поточний "баланс", а не історичний тотал.
|
||
|
||
Розв'язання без нової мутабельної змінної (і без циклічного імпорту —
|
||
`PayZoneC` вже імпортує `HudC` для `getWorldAnchorPosition()`, тож
|
||
`HudC` імпортувати `PayZoneC` напряму означало б цикл):
|
||
- `PayZoneC.getDepositedCount()` — новий публічний гетер, повертає
|
||
внутрішній `deposited` (лічильник, який і раніше рахував "скільки вже
|
||
влетіло в зону", просто не був назовні доступний).
|
||
- `HudC.setBalanceGetter(fn)` — інжектований гетер (той самий паттерн, що
|
||
й `ResourceC.setFlyTarget`/`onDestroyed` в `BreakablePropC`), замінив
|
||
пряму залежність `HudC -> ResourceC`. `HudC` більше НЕ імпортує
|
||
`ResourceC` взагалі.
|
||
- `TestSceneC` зв'язує: `HudC.setBalanceGetter(() =>
|
||
ResourceC.getCollectedCount() - PayZoneC.getDepositedCount())` — чиста
|
||
похідна величина, рахується на льоту щокадру в `HudC.update()`, без
|
||
жодного окремого "decrement"-виклику. Росте коли `collected` росте
|
||
(щось зібрали), падає коли `deposited` росте (щось долетіло в зону) —
|
||
саме по собі, без спеціальної синхронізації.
|
||
|
||
Перевірено: зібрав 7 ресурсів (консоль: `gathered wood (7 total)`), весь
|
||
цей час стояв в Pay Zone -> усі 7 злились в зону протягом ~2.1с
|
||
(`DEPOSIT_INTERVAL=0.3` × 7) -> лічильник на екрані повернувся до **0**,
|
||
хоча `collected` лишився 7 — підтверджує, що баланс дійсно "живий", а не
|
||
lifetime-сума.
|
||
|
||
### Доопрацювання: точка вильоту ресурсів була занизько (2026-08-12)
|
||
`WORLD_ANCHOR_LOCAL_OFFSET` підняли з `(1.0, 0.8, -2.2)` до
|
||
`(1.0, 1.2, -2.0)` — більший Y (вище в camera-space) і трохи менший |Z|
|
||
(ближче до камери, тому той самий Y дає більше вертикальне зміщення на
|
||
екрані через перспективу) — все ще підібрано на око, не розраховано
|
||
математично з FOV/aspect. **Це найбільш "на око" підібрана частина
|
||
роботи — якщо після цього фіксу політ все ще не влучає точно в іконку,
|
||
підкрутити ці три числа ще раз** (в `HudC.ts`).
|
||
|
||
## Камера: прибрано `lookAt`, лінійне зміщення замість орбіти (2026-08-12)
|
||
|
||
Фідбек від ментора користувача: замість `lookAt`-based слідкування камери
|
||
за персонажем — краще визначити точку біля персонажа і зміщувати камеру
|
||
відносно неї, залежно від напрямку погляду персонажа. Уточнення від
|
||
користувача: **без orbit-behind** — це має бути суто лінійне зміщення
|
||
позиції, кут камери взагалі не повинен обертатись.
|
||
|
||
### Що було
|
||
`CameraFollowC.update()` щокадру: (1) демпінгував позицію камери до
|
||
`targetPosition + offset + lookAhead` (це вже було чисте лінійне
|
||
зміщення — `lookAhead` — той самий "зсув в напрямку погляду персонажа",
|
||
про який казав ментор, він вже існував), (2) **окремо** демпінгував
|
||
`smoothedLookAt`-точку і кожен кадр робив `camera.lookAt(smoothedLookAt)`
|
||
— тобто поворот перераховувався з нуля щокадру з двох незалежно
|
||
згладжених точок, а не одного узгодженого стану.
|
||
|
||
### Що зроблено (перша ітерація)
|
||
- Прибрано `smoothedLookAt`, `LOOK_DAMPING`, і сам виклик `camera.lookAt()`
|
||
з `update()` повністю. Позиційна частина (`offset` + `lookAhead` +
|
||
obstacle-avoidance raycast) лишилась незмінною — вона й раніше була
|
||
чистим translation, без жодного обертання.
|
||
- Поворот камери відтепер **виставляється один раз** в `init()`:
|
||
`camera.position.copy(target.position).add(offset); camera.lookAt(target.position);`
|
||
— і після цього `update()` більше НІКОЛИ не торкається `camera.rotation`/
|
||
`camera.quaternion`. Кут "заморожений" геометрично на старті й лишається
|
||
таким назавжди, скільки б персонаж не розвертався.
|
||
|
||
### Доопрацювання: навіть без обертання камери відчувався рух "по колу" (2026-08-12)
|
||
Користувач: коли персонаж РОЗВЕРТАЄТЬСЯ (не рухаючись при цьому), камера
|
||
все ще відчутно "йде по колу". Причина — той самий `lookAhead`-вектор, що
|
||
лишили в першій ітерації: він рахувався з напрямку, куди персонаж
|
||
**дивиться** (`facing`, з `target.quaternion`), а не куди рухається.
|
||
Навіть згладжений (`LOOK_AHEAD_DAMPING`), він все одно ОБЕРТАЄТЬСЯ разом
|
||
з поворотом персонажа (наприклад, коли той розвертається на місці, щоб
|
||
глянути на ящик) — а що обертається, те й тягне позицію камери по дузі
|
||
навколо персонажа, хай і невеликій. Користувач хотів буквально "2 точки і
|
||
пряма між ними" — жодної залежності від напрямку погляду.
|
||
|
||
**Перша спроба**: прибрав `lookAhead` повністю (`desiredPosition =
|
||
targetPosition + offset`, без жодного додаткового вектора). Круговий рух
|
||
дійсно зник, але користувач одразу зауважив: зникло й саме зміщення —
|
||
"раніше зміщення було підходяще, єдина проблема була в тому, що воно йшло
|
||
по колу". Тобто магнітуда/факт панорамування був потрібен, просто НЕ
|
||
прив'язаний до повороту.
|
||
|
||
**Фінальне виправлення**: `lookAhead` вернув, але тепер рахується з
|
||
**реального зміщення позиції персонажа між кадрами**
|
||
(`targetPosition - previousTargetPosition`), а не з `target.quaternion`.
|
||
`previousTargetPosition` — нове поле, оновлюється щокадру. Якщо кадровий
|
||
рух менший за `MOVEMENT_EPSILON_SQ` (стоїть на місці — байдуже, як
|
||
розвернутий) — цільовий look-ahead = нульовий вектор; якщо рухається —
|
||
нормалізований напрямок руху × `LOOK_AHEAD_DISTANCE` (та сама магнітуда,
|
||
що й була). Обидва варіанти йдуть через той самий `smoothedLookAhead.lerp`
|
||
з `LOOK_AHEAD_DAMPING`, як і раніше — тільки джерело напрямку інше.
|
||
Результат: розворот на місці = нуль руху = камера взагалі не рухається;
|
||
ходьба = той самий пан вперед, що й був до всіх цих правок.
|
||
**Урок**: "згладжений вектор, що обертається з об'єктом" все одно
|
||
читається як орбітальний рух — згладжування прибирає різкість, а не
|
||
кривизну; правильний фікс — прив'язати джерело напрямку до РУХУ
|
||
(position delta), а не до facing/quaternion, а не просто видалити ефект.
|
||
|
||
Перевірено в браузері (Playwright) — рендер коректний з першого кадру і
|
||
після ходьби/бою, без console-помилок.
|
||
|
||
### Доопрацювання ×3: зовсім не відчувалось, тримати зсув, миттєвий розворот на 180° (2026-08-12)
|
||
Після руху-based lookahead користувач: "Немає зміщення взагалі". Причина —
|
||
не баг у логіці (перевірив логуванням, числа рахувались вірно), а те, що
|
||
**стара `lookAt`-версія давала подвійний ефект** (і зсув позиції, і
|
||
доворот камери в той же бік щокадру), а зараз лишився тільки зсув позиції
|
||
— і сама позиційна складова (`LOOK_AHEAD_DISTANCE=1.2`) була занадто
|
||
малою, щоб щось означати з відстані ~12.7 од. (`(0,9,-9)` риг). Підняв до
|
||
`4`, користувач сам потюнив назад до **`2`** (лишив цю зміну, не
|
||
відкатувати).
|
||
|
||
Ще дві правки в тому ж повідомленні:
|
||
- **"Камера після зсуву не має повертатись назад"** — `desiredLookAhead`
|
||
тепер **окреме поле**, не локальна константа: оновлюється (`.copy(movement)...`)
|
||
ТІЛЬКИ коли `movement.lengthSq() > MOVEMENT_EPSILON_SQ`, і просто НЕ
|
||
чіпається, коли персонаж стоїть — замість `: new Vector3()` (нуль) в
|
||
тернарному операторі, як було. Тобто пан тримається на місці, де
|
||
зупинився, а не з'їжджає назад до центру, поки не з'явиться новий, ІНШИЙ
|
||
напрямок руху.
|
||
- **"Зроби анімацію плавнішою"** — `LOOK_AHEAD_DAMPING` знижений з `2` до
|
||
`1.2` (повільніший ease).
|
||
- **"Персонаж має розвертатись моментально, якщо ми змінюємо напрям на
|
||
протилежний"** — це вже не про камеру, а про `PlayerC` (`updateMovement`):
|
||
новий `OPPOSITE_TURN_THRESHOLD = Math.PI * (150/180)` (~150°). Якщо кут
|
||
між поточним і цільовим facing (`quaternion.angleTo(...)`) перевищує цей
|
||
порог — `quaternion.copy(facingRotation)` миттєво, інакше — старий
|
||
капований `rotateTowards(facingRotation, MAX_TURN_SPEED * delta)`. Тобто
|
||
тільки різкі розвороти "в протилежну сторону" миттєві, дрібні корекції
|
||
курсу лишаються плавними, як і були.
|
||
|
||
### ⚠️ Знахідка: `camera_rotation_p`/`camera_rotation_l` (дизайнерський конфіг) виявився нежиттєздатним самостійно
|
||
Спочатку спробував залишити кут камери таким, яким його виставляє
|
||
`CameraC.setCamera()` з конфіг-параметрів `camera_rotation_p`/`_l`
|
||
(дизайнер може їх тюнити через UI) — просто прибравши `lookAt` і НІЧОГО
|
||
більше не додаючи. Результат: **порожнє темно-синє небо**, ні мапи, ні
|
||
персонажа в кадрі. Причина: цей конфіг-кут ніколи насправді не був
|
||
призначений працювати самостійно — `CameraFollowC` з першого ж комміту,
|
||
що його додав, миттєво перезаписував поворот через `lookAt()` щокадру,
|
||
тож ніхто й не міг помітити (чи потребу) тюнити `camera_rotation_p` під
|
||
реальний рух — він був "мертвим" параметром, що впливав щонайбільше на
|
||
один кадр до першого `update()`.
|
||
|
||
**Виправлення**: не покладатись на цей конфіг взагалі для follow-камери —
|
||
рахувати фіксований кут геометрично з реального `offset` (`(0,9,-9)`) в
|
||
момент `CameraFollowC.init()` (описано вище). Це гарантовано framing
|
||
персонажа з самого старту, незалежно від того, що зараз стоїть в
|
||
`camera_rotation_p`. **Урок**: значення з `configUIParams`/`globalSettings`
|
||
не завжди відображають те, що реально відбувається в грі — перевіряти,
|
||
чи щось інше (як тут `CameraFollowC`) не перезаписує їх одразу після
|
||
встановлення, перш ніж покладатись на них як на джерело правди.
|
||
|
||
Перевірено в браузері (Playwright): після фіксу сцена рендериться коректно
|
||
з самого старту (мапа/персонаж/крейти на місці, кут ідентичний
|
||
попередньому вигляду), і лишається так само коректно framed після
|
||
ходьби/бою/розвороту персонажа — камера просто зсувається лінійно, кут
|
||
не змінюється.
|
||
|
||
## Камера підлаштовується під facing-lock на крейт (2026-08-12)
|
||
|
||
Користувач: коли персонаж зупиняється біля ящика і "тригер збору"
|
||
(`PlayerC.faceTowards`, викликається з `updateMovement` коли `stopped &&
|
||
nearbyObstacle`) розвертає його лицем до цілі, камера має підлаштовуватись
|
||
під новий кут, а не лишатись байдужою. Це саме той кейс, який попередній
|
||
рух-based `lookAhead` **свідомо** ігнорував (розворот на місці = нульовий
|
||
`movement` = look-ahead не оновлюється) — правильна поведінка для
|
||
довільного розвороту, але користувач хотів виключення саме для facing-lock
|
||
на breakable-ціль.
|
||
|
||
**Рішення без повернення до facing/quaternion-залежності** (яка й дала
|
||
"рух по колу" раніше): `PlayerC` тепер зберігає, на яку саме ТОЧКУ (не
|
||
кут!) він зараз locked — `facingTarget: Vector3 | null`, виставляється в
|
||
`updateMovement()` в той самий момент, коли викликається `faceTowards()`
|
||
(і скидається в `null`, коли умова `stopped && nearbyObstacle` неправдива).
|
||
Публічний `PlayerC.getFacingTarget()`.
|
||
|
||
`CameraFollowC.setFacingTargetGetter(getter)` — injected getter (той самий
|
||
DI-паттерн, що й `ResourceC.setFlyTarget`), підключено в `TestSceneC.init()`
|
||
відразу після `CameraFollowC.init()`. В `update()`: якщо `facingTarget` не
|
||
`null` — `desiredLookAhead` рахується як напрямок від гравця ДО цієї точки
|
||
(XZ, нормалізований) × `LOOK_AHEAD_DISTANCE`, замість напрямку руху; інакше
|
||
— стара логіка (рух або hold). Ключове: джерело — фіксована ТОЧКА в
|
||
world-space (`nearbyObstacle.center`), а не обертовий вектор — поки гравець
|
||
і ящик обидва стоять на місці, цей напрямок сам по собі константний, тож
|
||
`smoothedLookAhead.lerp(...)` дає один плавний лінійний зсув до нової
|
||
позиції й ЗУПИНЯЄТЬСЯ там, а не описує дугу (на відміну від
|
||
facing/quaternion-based версії, яка оберталась разом з тілом персонажа).
|
||
|
||
Перевірено (Playwright, тимчасове логування `window.__dbgFacing`/
|
||
`__dbgLookAhead`, видалене після): після зупинки біля крейта
|
||
`facingTarget` стає non-null, і `smoothedLookAhead` плавно зсувається від
|
||
~`(-0.02, 1.46)` до нового значення (`(-1.03, 1.08)` і триває сходитись) —
|
||
камера реально підлаштовується, а не стоїть на місці.
|
||
|
||
## Два хіти за один цикл анімації атаки (2026-08-12)
|
||
|
||
Користувач: анімація "Loot" (удар битою) сама показує **2 фізичні удари**
|
||
за один цикл кліпу, але код досі рахував лише **1** damage-пульс за цикл
|
||
(`HIT_TIME_FRACTION=0.5`, раз на `attackAction.getClip().duration`) — тобто
|
||
`HITS_PER_STAGE=2` вимагав 2 повних циклу анімації на просування стейджа,
|
||
хоча мало вистачати одного.
|
||
|
||
**Виправлення**: `HIT_TIME_FRACTION` (одне число) замінено на
|
||
`HIT_TIME_FRACTIONS = [0.25, 0.75]` (масив — дві точки за цикл; підібрані
|
||
навмання як симетричний дефолт, можуть потребувати підстройки під реальний
|
||
тайминг замаху в кліпі, як і `LOOK_AHEAD_DISTANCE` раніше). Новий
|
||
`PlayerC.hitsThisAttack` (рахує ВСІ хіти за весь безперервний свінг, не
|
||
скидається щоцикл) + `hitTimeFor(hitIndex)` — рахує абсолютний час
|
||
(секунди від старту атаки) для N-го хіта: `duration * (повних_циклів +
|
||
HIT_TIME_FRACTIONS[hitIndex % 2])`, тобто коректно переходить через межу
|
||
циклу без дрейфу.
|
||
|
||
Перевірено (Playwright, тимчасове логування `[dbgHit]` з таймстемпом,
|
||
видалене після): хіти йдуть з рівним інтервалом ~780мс (замість колишніх
|
||
~2×780=1560мс), і стейдж (`prop.stageIndex`) просувається після кожних 2
|
||
хітів (`0,1` -> stage++), тобто рівно один цикл анімації = один
|
||
просунутий стейдж, як і треба.
|
||
|
||
## Розширений hit-feedback на крейтах: спарки, HP-бар, числа урону (2026-08-12)
|
||
|
||
Користувач показав референс-скріншоти з іншої гри (damage-numbers, HP-бар,
|
||
іскри в точці контакту) і сказав, що зараз у нас із фідбеку на хіт **лише
|
||
невеликий нахил** — флеш кольору, який мав бути з Day 6, візуально не
|
||
працює. Плейл поступово (по одному ефекту, з перевіркою між кроками).
|
||
|
||
### ⚠️ Знайдений і виправлений баг: флеш кольору був тихим no-op з самого Day 6
|
||
`playHitFeedback` лерпив `material.color` від `baseColor` до `FLASH_COLOR`
|
||
(білий) — але залогувавши реальний матеріал crate-мешів (`MeshBasicMaterial`,
|
||
`color: #ffffff`, без `emissive` — це не Standard/Phong), виявилось, що
|
||
**`baseColor` вже й був чистим білим**. Текстура дає весь реальний колір,
|
||
тінт-колір лишався на дефолтному білому — лерп "білий -> білий" не змінює
|
||
візуально НІЧОГО, tween виконувався справно (перевірено таймингом), просто
|
||
результат був непомітний. `MeshBasicMaterial` — unlit, `color` множиться на
|
||
текстуру, тож "яскравіше за білий" через цей канал взагалі неможливо.
|
||
|
||
**Виправлення**: замість лерпу `.color` — `StageVisual.flashMesh`, клон тієї
|
||
самої mesh-геометрії (`mesh.clone()`), покладений зверху з власним
|
||
`MeshBasicMaterial({ color: FLASH_COLOR, transparent: true, opacity: 0,
|
||
blending: AdditiveBlending, depthWrite: false })`. Флеш тепер анімує
|
||
`flashMesh.material.opacity` (0→1→0) замість кольору базового меша —
|
||
працює незалежно від того, який в оригінала колір/текстура, бо це
|
||
адитивний шар зверху, а не множник. **Побічний ефект**: `flashMesh` — це
|
||
ще один mesh під `map`, тож `TestSceneC`'s `ThreeC.setShadowsStateForChildren
|
||
(map, true, true)` (виконується ПІСЛЯ `BreakablePropC.init()`) увімкнув би
|
||
йому shadows теж — додано `BreakablePropC.disableFlashShadows()`, викликаний
|
||
з `TestSceneC` одразу після цього рядка, щоб вимкнути назад (даремний
|
||
shadow-caster на майже завжди прозорому оверлеї).
|
||
|
||
Перевірено (Playwright, серія скріншотів кожні 80мс під час бою): крейт у
|
||
кадрі точно на позначці очікуваного хіта помітно біліший/яскравіший, ніж у
|
||
кадрах до/після — флеш реально видно, на відміну від попередньої версії.
|
||
|
||
**Урок**: тінт-колір (`material.color`) на unlit-текстурованому меші не дає
|
||
"флешу", якщо колір вже білий (звичайний дефолт) — для видимого
|
||
hit-flash-у на такому матеріалі потрібен окремий адитивний оверлей-шар, а
|
||
не лерп самого кольору. Якщо десь ще знадобиться подібний ефект на
|
||
текстурованому unlit-меші — той самий підхід (clone + additive overlay).
|
||
|
||
### Іскри в точці контакту — v1 hand-rolled, замінено (2026-08-12)
|
||
Перша версія — новий `SparkFxC.ts`, ручно зібраний `ParticleSystem` (білі
|
||
квадратні частинки, `SphereEmitter(radius:0.05)`, звичайний `BillBoard`).
|
||
Технічно працював (Playwright підтвердив спалах у момент хіта), але
|
||
користувач подивився і сказав: **"Іскри не підходять, це мають бути як
|
||
промені. Саме такі як на скріні"** — потрібні тонкі промені-стріки, не
|
||
блоб з крапок. Перебудував рендер на `RenderMode.StretchedBillBoard`
|
||
(розтягує квад уздовж власної швидкості частинки — це і дає "промінь", а
|
||
не крапку) + `speedFactor`, `ColorOverLife`+`Gradient` для згасання альфи.
|
||
Це вже виглядало краще, але тут користувач відкрив `temp/VFX_Lootable_Destroy.json`
|
||
у редакторі й запитав, що в цих файлах — і виявилось, що це змінює все.
|
||
|
||
### ⚠️ Знахідка: в `temp/` лежали готові дизайнерські VFX-префаби, повністю невикористані
|
||
`temp/VFX_Lootable_Hit.json`, `temp/VFX_Lootable_Destroy.json` (+
|
||
`PassionOne-Black.otf` — шрифт, ще не використаний, ймовірно для майбутніх
|
||
чисел урону/HUD) — це **справжні експорти з редактора quarks.art**
|
||
(`Object3D.toJSON()` на `Group` з `ParticleEmitter`-дітьми, кожен зі своїм
|
||
готовим, підібраним дизайнером `ps` — shape/speed/color/behaviors, і
|
||
реальними текстурами, вбудованими як base64: `cfxr stretch smoke arc
|
||
dissolve.webp`, `novalines.webp` — назва "novalines" **буквально "лінії
|
||
нової" = промені-риски**, це саме те, що користувач мав на увазі скріном).
|
||
`VFX_Lootable_Hit` = 2 емітери ("StretchSmoke" + "SharpImpact" — це і є
|
||
ray-burst). `VFX_Lootable_Destroy` = 4 емітери (дим, уламки-Pieces,
|
||
Ground_Dirt як реальна 3D-mesh геометрія (`renderMode: Mesh`), пилова хмара).
|
||
**Жодного SDK/іншого коду проєкту ці файли не використовували взагалі** —
|
||
чисто сирі ассети, що чекали на підключення. Замінив увесь ручний
|
||
`SparkFxC` на завантаження цих реальних префабів — жодна власноруч
|
||
підібрана крива/швидкість не заміняє справжній дизайнерський ассет.
|
||
|
||
**Що таке VFX**: "visual effects" — тут конкретно означає one-shot
|
||
частинкові ефекти (спалахи/дим/уламки), авторовані окремо в
|
||
редакторі quarks.art і експортовані як самодостатній JSON (геометрія +
|
||
матеріали + текстури-base64 + вже налаштовані `ParticleSystem` параметри
|
||
для кожного емітера) — не код, чистий даних-ассет, який рушій (`three.quarks`)
|
||
вміє розпарсити назад у робочі `Object3D`.
|
||
|
||
### `PropVfxC.ts` — новий контролер, замінив `SparkFxC.ts`
|
||
Завантажує обидва префаби ОДИН РАЗ при `init()` через сирий
|
||
`QuarksLoader` (`three.quarks`) + `Template3d.manager` (`@hitplay/playable_template`),
|
||
**НЕ** через SDK-хелпер `quarksLoader()`/`ConvertToBase64WhenRelease`
|
||
(паттерн `meshes.ts`/`images.ts`) — свідомо, з причини:
|
||
|
||
**⚠️ Чому не той самий паттерн, що і images/meshes**: `quarksLoader(base64String)`
|
||
жорстко очікує рядок формату `data:...;base64,XXXX` (робить
|
||
`base64String.split(",")[1]` потім `atob(...)`). Це працює лише ПІСЛЯ
|
||
build-time AST-трансформації (`convertToBase64InAST`, підключена в
|
||
`vite.config.js`), яка й замінює виклик `ConvertToBase64WhenRelease()` на
|
||
реальний base64 рядок. У **звичайному `vite dev`** (як я весь час тестую
|
||
через Playwright!) сам хелпер — лише прохідний passthrough, що повертає
|
||
ГОЛИЙ ШЛЯХ (`"resources/vfx/....json"`) без коми — `.split(",")[1]` дав би
|
||
`undefined`, і `atob(undefined)` зламав би завантаження саме в dev-режимі,
|
||
хоча в build/export він працював би. Замість цього ризику — звичайний
|
||
Vite JSON-імпорт (`import json from "../resources/vfx/VFX_Lootable_Hit.json"`),
|
||
який Vite вміє інлайнити нативно в БУДЬ-ЯКОМУ режимі (dev/build/export)
|
||
без жодного fetch — і сирий `new QuarksLoader(Template3d.manager).parse(json)`
|
||
(синхронний, повертає `Object3D` напряму, `onLoad` callback не потрібен,
|
||
бо всі текстури вже base64 в самому JSON, немає мережевого чекання).
|
||
Додав `"resolveJsonModule": true` в `tsconfig.json` (був відсутній) — без
|
||
цього `tsc` (не сам vite/esbuild — ті й так все розуміють) скаржився б на
|
||
JSON-імпорт типами.
|
||
|
||
`PropVfxC.spawnHit(point)`/`spawnDestroy(point)` — клонують відповідний
|
||
завантажений шаблон (`.clone()` на `Group`), виставляють `position`,
|
||
форсують `QuarksUtil.setAutoDestroy(instance, true)` (в оригінальних
|
||
префабах `autoDestroy: false` — дизайнер, ймовірно, керував завершенням
|
||
вручну в своєму інструменті; нам потрібен чистий "one-shot і забути"),
|
||
`QuarksUtil.addToBatchRenderer(instance, renderer)` (реєструє КОЖЕН
|
||
`ParticleEmitter` в дереві з рендерером — без цього нічого не малюється),
|
||
`QuarksUtil.play(instance)`. Кожен `ParticleSystem` з `autoDestroy`
|
||
прибирає СЕБЕ (свій `ParticleEmitter`-нод) з дерева, коли його частинки
|
||
померли — але порожній корінь-`Group`, який я додав в сцену, сам не
|
||
видаляється (нема кому), тож прибираю його окремим `setTimeout`
|
||
(`CLEANUP_DELAY_MS=1500`, з запасом під найдовший `duration+life` в обох
|
||
префабах).
|
||
|
||
`BreakablePropC.playHitFeedback` кличе `PropVfxC.spawnHit(contactPoint)` на
|
||
кожному хіті (як і раніше з `SparkFxC`); `BreakablePropC.destroy()`
|
||
додатково кличе `PropVfxC.spawnDestroy(...)` в позиції крейта — цього не
|
||
було в hand-rolled версії взагалі (не було Destroy-ефекту), додав одразу,
|
||
бо це буквально друга половина того самого знайденого ассета.
|
||
|
||
### ⚠️ Знахідка (потребує рішення користувача, ще не виправлено): "StretchSmoke"-шар вилітає далеко від крейта і виглядає як артефакт
|
||
Перевірено в браузері (Playwright): "SharpImpact" (промені/nova-спалах,
|
||
`novalines.webp`) рендериться коректно ПРЯМО на крейті в момент хіта —
|
||
саме той ефект, що відповідає скріну користувача. Але другий емітер того
|
||
самого префаба, "StretchSmoke" (`startSpeed:14`, `worldSpace:true`, життя
|
||
0.2-0.3с — тобто до ~4 world units прольоту по прямій!), у нашій сцені
|
||
летить ДАЛЕКО від крейта (видно на скріні як сірувата паличка десь у
|
||
верхній частині екрана, повністю відірвана від точки удару) — швидше за
|
||
все, дизайнер тюнив цей ассет під інший масштаб сцени/камери. Це
|
||
відтворюється на КОЖНОМУ хіті (не витік/застигла частинка — просто кожен
|
||
новий хіт заново вилітає в приблизно те саме "неправильне" місце).
|
||
**Ще не виправлено** — варіанти: (а) лишити як є (автентичний ассет), (б)
|
||
притлумити цей конкретний емітер (зменшити `startSpeed`/життя клоном
|
||
префаба перед грою), (в) прибрати "StretchSmoke" зовсім і лишити тільки
|
||
"SharpImpact". Потребує візуальної перевірки користувачем — не вирішував
|
||
сам, це пряма зміна дизайнерського ассета.
|
||
|
||
Destroy VFX (`VFX_Lootable_Destroy`, 4 емітери включно з mesh-based
|
||
"Ground_Dirt") підключений і не кидає помилок в консолі, але **ще не
|
||
підтверджений візуально** — не вдалось надійно спричинити повне
|
||
знищення крейта в тестовому прогоні за відведений час.
|
||
|
||
### Наступні кроки (ще не реалізовано, чекає на пріоритети користувача)
|
||
HP-бар (потребує рішення: дискретні стейджі vs. реальний HP-пул?) і числа
|
||
урону (DOM чи sprite в 3D-просторі? теж потребує реального numeric HP,
|
||
якого зараз нема) — ще не почато. `PassionOne-Black.otf` (в `temp/`,
|
||
ще не імпортований) — ймовірно призначений саме для цього.
|
||
|
||
## PayZoneC: прогрес-заповнення зони + два реальні знайдені баги (2026-08-12)
|
||
|
||
Користувач попросив візуальний індикатор заповненості Pay Zone (скільки з
|
||
`RESOURCES_TO_CLOSE_ZONE` вже занесено). Три ітерації дизайну (кожна —
|
||
реальна правка коду, не просто обговорення):
|
||
1. Горизонтальний квад на землі, що росте вздовж world X — користувач:
|
||
"заповнюється... з права на ліво" (не те, що хотів).
|
||
2. Box, що росте вгору по Y (буквально "як вода в басейні") — виявився
|
||
концептуально хибним: верх/низ box-а завжди займають весь footprint
|
||
незалежно від висоти, тож з ізометричної камери зона виглядала "одразу
|
||
заповненою" на будь-якій висоті > 0.
|
||
3. **Фінал**: плаский квад на землі знову, але росте вздовж world Z (не X)
|
||
— з цієї камери (`CAMERA_OFFSET=(0,9,-9)`, дивиться в +Z) рух вздовж +Z
|
||
проєктується на екран як "знизу вгору", що і є тим "від краю до краю",
|
||
про яке просив користувач. Пінінг біля min-Z (найближчий до камери
|
||
край), колір `0x5ce65c` (зеленуватий).
|
||
|
||
### ⚠️ Знайдений і виправлений баг: депозит-політ інколи застигає в повітрі навіки
|
||
Користувач показав скріншот: статична текстура ресурсу, що просто висить
|
||
у повітрі. Трасував через тимчасове логування (spawn/complete ID на кожен
|
||
`playDepositFlight` — окремий `Tween`, не chain, коректно `TweenC.add()`-
|
||
нутий ПЕРЕД `.start()`) — підтвердив: рідко (не щоразу) `onComplete` просто
|
||
не спрацьовує, і клон застигає точно на прямій між HUD-точкою і зоною, на
|
||
довільному t. Причину всередині `tween.js`/`TweenC` не знайшов (це не той
|
||
самий баг з `.chain()` з Day 6 — тут узагалі нема chain). **Замість
|
||
подальшого копання — надійний дублюючий `setTimeout`**, що прибирає mesh
|
||
через `DEPOSIT_FLIGHT_DURATION_MS + 300мс` незалежно від того, чи
|
||
спрацював `onComplete`. Викликати `removeFromScene` двічі — нешкідливо
|
||
(другий викоик просто не знаходить батька для detach).
|
||
|
||
### ⚠️ Знайдений і виправлений баг: `"UI_Wood.001"` в коді не збігається з реальною назвою ноду
|
||
Користувач наполягав, що "текстура дерева" в центрі Pay Zone нікуди не
|
||
зникає, хоча по черзі виключив усі мої гіпотези (крейти всередині зони,
|
||
застиглий ресурс). Трасував живу сцену (`ThreeC.scene.traverse`, шукаючи
|
||
все з "UI_Wood" в імені, друкуючи ланцюжок `visible` від ноду до кореня) —
|
||
і ось воно: нод з raw glb JSON названий `"UI_Wood.001"` (з крапкою,
|
||
підтверджено прямим парсингом бінарника), але в ЖИВІЙ Three.js сцені після
|
||
завантаження він `"UI_Wood001"` (**без крапки** — щось у пайплайні
|
||
завантаження її стирає). `TestSceneC.createMap()` шукав
|
||
`map.getObjectByName("UI_Wood.001")` — рядок ніколи не збігався,
|
||
`getObjectByName` тихо повертав `undefined`, `if (woodIconAlt)` була
|
||
`false`, і `.visible=false` НІКОЛИ фактично не виконувався — попри те, що
|
||
код виглядав абсолютно правильним і "мав" би працювати. Виправлення:
|
||
рядок-літерал змінено на `"UI_Wood001"` (без крапки), підтверджено
|
||
трасуванням живої сцени, що тепер `visible=false` реально застосовується.
|
||
**Урок**: коли `if (identifiedNode)`-гард ЗАВЖДИ хибний (вузол ніби існує
|
||
в GLB, але код його "не бачить") — перевіряти РЕАЛЬНЕ ім'я в ЖИВІЙ сцені
|
||
(`scene.traverse` + лог імені), не тільки в сирому GLB JSON — завантажувач
|
||
може мовчки переписувати рядки (крапки, ймовірно, конфліктують з якоюсь
|
||
внутрішньою угодою іменування).
|
||
|
||
Побічно: під час полювання на цей баг зробив (і одразу відкотив на
|
||
прохання користувача) дві помилкові гіпотези-фікси — приховання
|
||
`UI_Interactive_Zone_02` (сам маркер зони) і видалення `Wooden_Box_016`/
|
||
`Wooden_Box_017` (2 крейти, що геометрично сидять майже точно в центрі
|
||
зони, ~0.18 і ~0.99 одиниць від центру — підтверджено обчисленням world
|
||
position прямо з GLB node transforms). Жодна з цих гіпотез не була
|
||
причиною — обидві повернуто як були. Крейти в зоні — це може бути окрема,
|
||
самостійна проблема левел-дизайну (вони справді там стоять), але це
|
||
свідоме рішення НЕ трогати без окремого прямого запиту користувача.
|
||
|
||
- Working tree на момент старту Day 5: `PlayerC.ts` мав незакомічені правки
|
||
(accel/decel рух, рефакторинг attack-facing на helper-и) — вони увійшли в
|
||
фінальну версію файлу (переписаний повністю, логіка руху збережена).
|
||
- Свідоме рішення: не рефакторити робочий AABB рух гравця під фізику; після
|
||
видалення тригерів у гравця взагалі немає cannon `Body` — тільки AABB.
|
||
- `ResourceC`/`BreakablePropC` — нові файли. `PlayerC.ts`/`TestSceneC.ts`/
|
||
`PhysicsC.ts` — модифіковані. `CameraFollowC.ts`/`ThreeC.ts` — не торкались.
|
||
`TriggerC.ts` — був створений і видалений в межах цієї ж сесії (див. "Файли").
|
||
- Закомічено й запушено в `origin/master` 2026-08-12 (commit `4a22a9b`,
|
||
"Add physics/combat foundation, resource pickup flow, VFX polish, and
|
||
HUD") — усе, що описано в цьому файлі до Day 8 включно, вже в репо.
|
||
`stats.html` (rollup-visualizer, артефакт білда) свідомо НЕ закомічено —
|
||
не вихідний код, генерується заново при білді.
|
||
- Якщо наступного разу знову треба буде правити combat: зона ураження —
|
||
та сама `INTERACTION_REACH`-аура, що й раніше використовувалась для
|
||
single-target пошуку (`findNearbyObstacle`, лишився для косметичного
|
||
idle-facing), просто тепер є окремий `findBreakableTargetsInZone` без
|
||
обмеження на кількість цілей.
|
||
- Day 6 (Tween): `TweenC.init()` викликається один раз в
|
||
`beforeResourcesLoadedCb.ts` — якщо колись переносити фізику/tween-бутстрап
|
||
в інше місце, не забути перенести й це, інакше всі `TweenC.add()` тихо
|
||
ні на що не впливають (сама `Group` існує, просто ніхто її не `.update()`-ить).
|
||
- `PayZoneC.init(payZoneNode, target)` МАЄ викликатись після `PlayerC.init()`
|
||
в `TestSceneC` (потребує реальний `PlayerC.object`, не просто позицію) —
|
||
уже виправлено (виклик з `TestSceneC.init()`, не з `createMap()`), просто
|
||
важливо не переносити назад.
|
||
- **Будь-який новий `.chain()` в цьому проєкті — додавай ОБИДВІ (усі) ланки
|
||
в `TweenC` окремо** (`TweenC.add(a); TweenC.add(b); a.start();`), інакше
|
||
повториться баг вище: друга ланка "грає" за прапорцем, але update() на
|
||
неї ніхто не кличе, і вона застигає навічно.
|
||
- **Важливий урок з цієї сесії**: "збір ресурсу" (`ResourceC`) і "передача
|
||
в Pay Zone" (`PayZoneC`) — два НЕЗАЛЕЖНІ механізми, і мають лишатись
|
||
такими. Не гейтувати `ResourceC.collect()` жодною умовою про гравця/зону
|
||
— це те, що вже одного разу зламало базовий збір (ресурси лежали на
|
||
землі й не зникали). Будь-яку майбутню умову про "стояння в зоні" чіпляти
|
||
тільки на стороні `PayZoneC` (він і так окремо стежить за
|
||
`getCollectedCount()` — `deposited`, тобто "хвостом" з незданого).
|
||
- **Ще один урок**: не вгадувати розмір/радіус тригер-зони на око зі
|
||
скріншота — рахувати з реальної геометрії (`Box3().setFromObject(node)`).
|
||
Саме це зламало `PayZoneC.isPlayerInside` (радіус=2 замалий за фактичний
|
||
прямокутник ~6×4.6, ще й не центрований на origin).
|
||
- Ручна перевірка (флеш/нахил крейтів, повне зникнення після 2 хітів на
|
||
стейдж, базовий збір ресурсів без прив'язки до зони, злив ресурсів у Pay
|
||
Zone тільки при стоянні там — тепер по реальному Box3, не вгаданому
|
||
радіусу, зникнення зони після `RESOURCES_TO_CLOSE_ZONE` доставлених
|
||
(100 на момент цього запису, зараз 20 — див. "прогрес-заповнення зони"),
|
||
відсутність зайвих текстур навколо Pay Zone) — стан на кінець Day 5/6;
|
||
усе, що сталось після (HUD, камера, VFX, прогрес-бар зони), має власні
|
||
"Перевірено" нотатки нижче за текстом.
|
||
|
||
## Day 7: VFX — шлейф зброї, доопрацювання ресурсів (2026-08-12)
|
||
|
||
**Важливо про верифікацію в цій секції і нижче**: користувач явно попросив
|
||
припинити самостійні Playwright/агент-перевірки ("Досить самостійно щось
|
||
перевіряти і тестувати" — [[feedback-verification-pace]] в пам'яті) —
|
||
тож усе нижче перевірено ВИКЛЮЧНО через живий фідбек користувача в грі
|
||
(він тестує, повідомляє що не так, я виправляю), а НЕ через автоматичні
|
||
скріншоти/Playwright, як було в Day 5/6. Це свідоме рішення, не пропуск.
|
||
|
||
### `WeaponTrailC.ts` — новий контролер, шлейф-"вітер" за битою під час замаху
|
||
Користувач попросив ефект вітру за зброєю під час замаху — hand-rolled
|
||
screen-facing ribbon (не `three.quarks`, бо трейл має слідкувати за
|
||
bone-driven точкою щокадру, а не грати фіксований дизайнерський burst,
|
||
як `PropVfxC`). Технічно: кожен кадр поки `PlayerC.isAttacking` — семплить
|
||
world-позицію "кінця бити" (обчислюється з реальної local bounding box
|
||
всіх мешів під `Tool_1`, НЕ вгаданий offset — `computeLocalTipOffset()`,
|
||
з підстроюваним `TIP_INSET_FRACTION` — трохи не на самий кінець, за
|
||
проханням користувача), тримає ring buffer точок з віком, будує ribbon-
|
||
геометрію (пара left/right вершин на точку, перпендикуляр = cross(tangent,
|
||
viewDirection камери — камера не обертається, тож напрямок погляду
|
||
захоплений один раз при `init()`), затухання ширини/кольору за
|
||
`FADE_POWER`-кривою (не лінійно — "не таким плавним", різкіший спад),
|
||
additive blending, без тон-мапінгу. `PlayerC.updateCombat` викликає
|
||
`WeaponTrailC.setActive(true/false)` на початку/кінці атаки.
|
||
|
||
### `ResourceC.ts` — повний редизайн pickup-анімації (кілька раундів фідбеку)
|
||
Стара версія (Day 5): scatter -> settle -> instant collect/fly, з
|
||
ротейтом (`SPIN_SPEED`). Користувач попросив: прибрати ротейт, додати
|
||
2 баунси від землі, на 2-му баунсі — білий флеш + стиск + вибух зірочок,
|
||
потім зникнення. Фінальний стейт-машин: `scatter` -> `bounce` (2 цикли,
|
||
`BOUNCE_HEIGHTS`/`BOUNCE_DURATIONS`, index 0->1) -> `impactHold` (флеш +
|
||
стиск на 2-му баунсі) -> `toTarget` (політ до HUD).
|
||
|
||
**⚠️ Знайдений і виправлений баг: флеш-оверлей був зміщений, а не поверх ресурсу**
|
||
`buildFlashOverlays()` клонував меш і чіпляв клон як ДИТИНУ САМОГО МЕША
|
||
(`mesh.add(overlay)`), а не як сиблінга під тим самим батьком (як робить
|
||
`BreakablePropC.buildStageVisual`). `mesh.clone()` копіює ЛОКАЛЬНУ
|
||
трансформацію меша (відносно ЙОГО батька) — вставлена на рівень глибше,
|
||
та сама трансформація інтерпретувалась вже у ВЛАСНІЙ системі координат
|
||
меша, тобто оверлей був зміщений/спотворений, а не коінцидентний з
|
||
мешем. **Фікс**: `overlay.position/rotation/scale.set(...)` в identity
|
||
одразу після clone(), перед `mesh.add(overlay)`.
|
||
|
||
**Стиск (squash) — три ітерації**:
|
||
1. Squash-and-widen (X/Z шириться, Y стискається, пружинить назад) —
|
||
користувач: "ресурси на прикінці анімації збільшуються, мають навпаки
|
||
зменшуватись" — розтягнення X/Z понад 1.0 читалось як РІСТ.
|
||
2. Uniform shrink (`scale.setScalar(1-shrink)`, без розтягування) —
|
||
краще, але користувач ще раз: "має бути одного розміру від початку до
|
||
кінця" про `toTarget`-фазу конкретно.
|
||
3. **Фінал**: стиск ЛИШЕ на 2-му баунсі (`impactHold`, тримається, не
|
||
пружинить назад — "без повернення до початкового розміру"), а сама
|
||
`toTarget`-фаза взагалі НЕ чіпає scale (тільки position + opacity
|
||
флешу-конвергенції). Одного разу я неправильно прибрав СТИСК НА
|
||
БАУНСІ замість того, щоб залишити його — користувач поправив: "Ти
|
||
прибрав зміну розміру при баунсі. Я тобі писав не про це" — стиск на
|
||
баунсі БУЛО повернено, лишається бажаним ефектом.
|
||
|
||
**⚠️ Знайдений і виправлений баг: "зміна розміру під час польоту" — насправді перспектива, не scale**
|
||
Після всіх фіксів вище користувач далі бачив "зростання" саме під час
|
||
`toTarget`-польоту — а код `toTarget` взагалі не чіпав `.scale`. Причина:
|
||
камера перспективна, а ціль польоту (`HudC.getWorldAnchorPosition()`)
|
||
сидить БЛИЗЬКО до камери (`ANCHOR_DISTANCE=2.5`), тоді як крейт (звідки
|
||
ресурс вилітає) — далеко (~12+ одиниць, через `CAMERA_OFFSET=(0,9,-9)`).
|
||
Наближення до камери саме по собі візуально збільшує об'єкт (perspective
|
||
foreshortening), без жодної зміни `.scale` в коді. **Фікс**: компенсація —
|
||
кожен кадр рахую `currentDistance = camera.position.distanceTo(mesh.position)`,
|
||
зберігаю `startDistanceFromCamera` (дистанція в момент старту `toTarget`)
|
||
і `baseScale` (розмір після баунсу), виставляю
|
||
`scale = baseScale * (currentDistance / startDistanceFromCamera)` —
|
||
компенсує наближення так, що АПАРЕНТНИЙ (екранний) розмір лишається
|
||
сталим, яким був щойно після баунсу, незалежно від реальної world-space
|
||
дистанції. **Урок**: "resource visually grows/shrinks" не завжди означає
|
||
"хтось десь чіпає .scale" — перспективна камера сама змінює апарентний
|
||
розмір при зміні дистанції, це треба явно компенсувати, якщо небажано.
|
||
|
||
**⚠️ Знайдений і виправлений баг: ціль польоту застигала, якщо камера рухалась**
|
||
`resource.to` виставлявся ОДИН РАЗ (`this.flyTarget()`) при вході в
|
||
`toTarget` — якщо камера рухалась під час ~0.5с польоту (гравець йде,
|
||
camera-follow), ресурс летів у застарілу world-точку, яка вже не
|
||
збігалась з реальною позицією іконки на екрані (користувач: "втрачають
|
||
потрібну позицію і летять не туди"). **Фікс**: `this.flyTarget()`
|
||
викликається ЩОКАДРУ всередині `toTarget`-блоку, не один раз — ресурс
|
||
щокадру перецілюється на актуальну позицію.
|
||
|
||
**Інші правки за фідбеком**: баунси зроблені відчутнішими (висота
|
||
`[0.16,0.08]->[0.4,0.22]`); флеш подовжений і РОЗВʼЯЗАНИЙ від швидкості
|
||
стиску (`FLASH_DURATION` vs `SHRINK_RAMP_DURATION` — окремі константи,
|
||
інакше подовження одного тягне інше); одна спроба зробити політ
|
||
"пришвидшеним" (ease-in `t²` замість smoothstep) БУЛА ВІДКОЧЕНА —
|
||
причина: `CONVERGE_START_FRACTION`-конвергенція тригериться по часу `t`,
|
||
а ease-in робить просторовий прогрес значно повільнішим за часовий,
|
||
тож ресурс зникав, не доїхавши до цілі ("зникають раніше часу") —
|
||
**урок**: якщо якийсь ефект прив'язаний до сирого `t`, зміна кривої
|
||
`eased` для позиції може розсинхронізувати їх — тримати узгодженим або
|
||
привʼязувати ефект до фактичного пройденого шляху, не до часу.
|
||
|
||
### `SparkleFxC.ts` — новий контролер, пул зіркових спалахів
|
||
Спочатку — наївна версія (`new Mesh`/`new MeshBasicMaterial` на кожен
|
||
вибух, 5 зірок на баунс). Користувач попросив пулінг (Day 7 бриф явно
|
||
рекомендує "use pools for VFX", і кожен крейт може дати 2-5 ресурсів,
|
||
кожен зі своїм вибухом = 10-25 create+GC циклів за секунду). **Фінал**:
|
||
`POOL_SIZE=30` зіркових мешів/матеріалів створюються ОДИН РАЗ в `init()`
|
||
(викликається з `TestSceneC`), приховані/scale=0; `spawnBurst()` шукає
|
||
вільні слоти (`inUse`), активує (позиція/lookAt/scale/opacity), звільняє
|
||
в `fadeOut.onComplete`. Форма зірки — `ShapeGeometry` з 4-промінним
|
||
контуром (outer/inner radius alternating), не sprite-атлас (немає такого
|
||
ассету в проєкті). Анімація: pop-in (`Back.Out`) -> hold -> fade-out,
|
||
через `TweenC` (обидві ланки чейну додані окремо — той самий Day 6 баг з
|
||
`.chain()` враховано одразу).
|
||
|
||
## Day 8: UI — резорс-каунтер (2026-08-12)
|
||
|
||
Референс — скріншот іншої гри ("Zombie Invasion") з двома стековани
|
||
лічильниками (дерево + метал); у нас лише один тип ресурсу, тож зроблено
|
||
тільки один лічильник, без плейсхолдера під другий (за проханням
|
||
користувача, поки не підтверджено, що другий ресурс дійсно буде).
|
||
|
||
### Реальний дизайнерський ассет замість CSS-пілла
|
||
Користувач: "Tool_15 знайди цей файл в temp. Це юай для каунтера
|
||
ресурсу." `temp/Tool_15.webp` (184×71, темна округла плата з діагональною
|
||
деревʼяною планкою, вже вбудованою в саму картинку) — скопійований у
|
||
`src/resources/images/resource_counter_bg.webp`, підключений через
|
||
`images.ts` (`resourceCounterBgSrc`, той самий `ConvertToBase64WhenRelease`-
|
||
паттерн). Стара кругла іконка-рамка + окремий `<img>` (з `icon_wood.webp`)
|
||
прибрані повністю — планка вже частина фонового зображення.
|
||
`icon_wood.webp`/`iconWoodSrc` після цього стали осиротілими і видалені.
|
||
|
||
### Стилі винесені в `ui.css`, живлені реальним аспектом і CSS-змінною
|
||
Користувач: "Всі стилі для юай мають бути в css файлі, у нас є відповідний
|
||
файл вже" — весь inline `document.createElement("style")` з `HudC.ts`
|
||
прибраний, стилі перенесені в `src/css/ui.css` (вже підключений в
|
||
`index.html`). `HudC.ts` лишає в JS тільки `holder.style.backgroundImage`
|
||
(шлях до ассету відомий лише в runtime через build-пайплайн, статичний
|
||
CSS-файл цього не може). Розмір плейта: спочатку `width`+`height` окремо
|
||
в `vh` (руками підібрана пропорція 184:71) — користувач: "не подобається,
|
||
що задана і висота і ширина, давай контролювати через аспект рейтіо" ->
|
||
`aspect-ratio: 184/71` замість другого числа. Потім: CSS custom property
|
||
`--hud-width` на `.resource-hud`, і `font-size: calc(var(--hud-width) *
|
||
0.175)` замість фіксованого `vh` — користувач змінював `width`, шрифт не
|
||
слідував; тепер один параметр (`--hud-width`) керує і розміром плати, і
|
||
шрифтом (успадковується вниз по DOM).
|
||
|
||
### Дві CSS-анімації замість JS-коду (Day 8 технічна вимога)
|
||
"Use built-in animations/transitions instead of changing elements through
|
||
code" — (1) `.resource-hud` грає `resource-hud-enter` (fade+slide) один
|
||
раз при монтуванні, самостійно, без JS-таймингу; (2) `.resource-hud__plate`
|
||
(НЕ сам `.resource-hud` — окремо, дивись нижче чому) грає
|
||
`resource-hud-bump` при кожній зміні числа — `HudC.update()` лише
|
||
toggle-ить клас `is-bumping` (remove -> force reflow через `offsetWidth`
|
||
-> add), сам keyframe рахує браузер.
|
||
|
||
**⚠️ Знахідка: анімацію не можна вішати на ТОЙ САМИЙ елемент, що вже має іншу**
|
||
Спочатку bump-анімація була на `.resource-hud__count` (сам текст) —
|
||
користувач: "Анімація має бути на всю юайку не тільки на каунтер". Але
|
||
просто перенести на `.resource-hud` (той самий елемент, що вже грає
|
||
`resource-hud-enter`) НЕ можна: перемикання класу, що змінює
|
||
`animation-name` (навіть тимчасово, назад до того самого значення),
|
||
ЗАВЖДИ перезапускає анімацію — тобто кожен збір ресурсу знову
|
||
проганяв би fade+slide-in ("з'явлення") поверх бампу. **Фікс**: два
|
||
вкладені div — зовнішній `.resource-hud` (позиція/розмір + enter-анімація,
|
||
статична), внутрішній `.resource-hud__plate` (фон+число разом,
|
||
bump-анімація) — дві незалежні `animation`-лінії на двох елементах.
|
||
|
||
### `getWorldAnchorPosition()` — справжня screen-to-world проєкція
|
||
Раніше (Day "HUD-лічильник"): фіктивний `Object3D`, дитина камери, з
|
||
вгаданим локальним офсетом (`WORLD_ANCHOR_LOCAL_OFFSET`, підбирався на
|
||
око, документований як "не точний screen-to-world розрахунок").
|
||
Користувач: "Треба дістати позицію правого края юайки і направляти
|
||
ресурси саме туди." **Фінал**: `getBoundingClientRect()` правого краю
|
||
`.resource-hud__plate` (реальний DOM), конвертація в NDC відносно
|
||
`ThreeC.renderer.domElement.getBoundingClientRect()` (canvas, НЕ
|
||
`window.innerWidth/Height` — SDK рендерить у letterboxed 9:16 бокс,
|
||
canvas-rect гарантовано збігається з тим, що бачить камера),
|
||
`new Vector3(ndcX,ndcY,0.5).unproject(camera)` -> напрямок від камери ->
|
||
точка на фіксованій `ANCHOR_DISTANCE=2.5` вздовж цього променя.
|
||
Рахується ЖИВО при кожному викликі (не раз при init) — автоматично
|
||
лишається коректним при resize/зміні орієнтації, без окремого resize-
|
||
слухача. `Object3D`/`WORLD_ANCHOR_LOCAL_OFFSET`-підхід видалений
|
||
повністю.
|
||
|
||
## PayZoneC: уніфікація депозит-флайту з ResourceC (2026-08-12)
|
||
|
||
`playDepositFlight` (політ токена з HUD в Pay Zone) використовував
|
||
окремий TweenJS-based шлях, не звʼязаний з новою логікою `ResourceC`:
|
||
мав ротейт (`mesh.rotation.y += 0.3`) і стартовий `scale=1` (дефолт
|
||
свіжого `ResourceC.createVisual()`, без жодного стиску).
|
||
|
||
- **Ротейт прибрано** повністю (користувач: "ротейту не має бути").
|
||
- **Компенсація перспективи** — та ж сама техніка, що й у
|
||
`ResourceC.toTarget`, але в ПРОТИЛЕЖНИЙ бік: цей політ стартує БЛИЗЬКО
|
||
до камери (HUD-анкер) і летить ДАЛІ (Pay Zone, зазвичай далі від
|
||
камери) — без компенсації токен би візуально ЗМЕНШУВАВСЯ на льоту.
|
||
- **⚠️ Знайдений і виправлений баг: токен був значно більшим за ресурс з наземної анімації**
|
||
Причина: `ResourceC.createVisual()` не проходить через `SHRINK_AMOUNT`-
|
||
стиск (той стосується лише "живих" `FlyingResource` з баунсом) — токен
|
||
стартував з `scale=1`, тоді як ресурс, який щойно долетів на те саме
|
||
місце з `ResourceC`, виглядав як `0.85`. **Фікс**: `RESOURCE_ICON_BASE_SCALE
|
||
= 1 - SHRINK_AMOUNT` (0.85) винесений з `ResourceC.ts` як `export`,
|
||
`PayZoneC` множить на нього. Плюс окремий `DEPOSIT_FLIGHT_SCALE_MULTIPLIER`
|
||
(зараз `1`, користувач підкрутив до `0.3` в UI-сесії) — локальний
|
||
тюнер розміру САМЕ цього польоту, не торкаючись спільної константи
|
||
(яка вплинула б і на "земля->юай" анімацію).
|
||
- **Урок**: коли два різні контролери візуально показують "той самий"
|
||
ресурс в різних сценах польоту — тримати спільну константу базового
|
||
розміру (`export`), а не дублювати магічне число, інакше вони
|
||
розходяться, як тут.
|