Utility and polishing + bag fixes
This commit is contained in:
@@ -151,3 +151,4 @@ export_test/
|
||||
extra_compress/
|
||||
preview_dist/
|
||||
build/
|
||||
.studio/
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Стек і структура
|
||||
|
||||
- **Не Babylon.js** — це **three.js** (`three@0.185`) обгорнутий у пропрієтарний
|
||||
SDK `@hitplay/playable_template` (плейбл-реклама, HitPlay).
|
||||
- Фізика: `cannon-es@0.20` + `cannon-es-debugger`. World піднятий через SDK
|
||||
(`Physics_internal.init(...)` в `src/templateConfig/beforeResourcesLoadedCb.ts:27`),
|
||||
дебаг-рендер тригериться прапорцем `Template.initConfig({ debug: { physics: false } })`
|
||||
в `src/index.ts` (поки `false` — увімкнути під час дебагу фізики).
|
||||
**SDK сам кличе `world.fixedStep()` кожен кадр** (`Physics_internal.update`,
|
||||
всередині пакету) — свій `world.step()`/`fixedStep()` НЕ викликати, це
|
||||
вже фіксований таймстеп з коробки.
|
||||
- `UpdateController.Instance.onUpdate` — єдиний update-event, `delta` в
|
||||
секундах (з `THREE.Clock.getDelta()`), без фіксованого таймстепу на цьому
|
||||
рівні (фіксований степ — тільки всередині cannon, через `fixedStep()`).
|
||||
- `ThreeC.removeFromScene(obj)` існує (просто `obj.removeFromParent()`) —
|
||||
використовувати для видалення спавнених/знищених мешів.
|
||||
|
||||
## Файли
|
||||
|
||||
```
|
||||
src/controllers/
|
||||
PlayerC.ts - джойстик-рух (nipplejs), AABB-колізії з "colliders",
|
||||
бій: неперервний AoE-swing по всіх breakable-цілях в
|
||||
зоні ураження (240°-конус, FACING_DOT_THRESHOLD=-0.5,
|
||||
Day 7 — див. "Кут ураження"), перебивається рухом
|
||||
TestSceneC.ts - завантажує ZombiePunk_Map.glb, /collider/i -> hidden+colliders,
|
||||
ставить cannon Wall-тіла на все, крім Lootable-крейтів;
|
||||
ініціалізує всі VFX/HUD-контролери (PropVfxC,
|
||||
SparkleFxC, WeaponTrailC, HudC) в правильному порядку
|
||||
CameraFollowC.ts - камера-слідкувач, obstacle-avoidance через Raycaster
|
||||
по тому ж масиву colliders (НЕ торкались)
|
||||
PhysicsC.ts - PhysicsLayer enum, PhysicsBody (Box/Sphere+cannon Body
|
||||
з коректним world-space size/rotation), PhysicsObjPair
|
||||
BreakablePropC.ts - парсить групу "Lootable" в мапі, S1/S2/S3 damage-stages,
|
||||
2 хіти на стейдж, hit() advance stage / повне
|
||||
знищення (tween-джус, Day 6), дропає ресурси
|
||||
WeaponTrailC.ts - Day 7: hand-rolled screen-facing ribbon-шлейф за
|
||||
битою під час замаху (samples PlayerC.getWeaponAnchor(),
|
||||
обчислює "кінець бити" з реальної local bbox, не
|
||||
вгадує offset), тає/звужується за час, не тон-мап,
|
||||
toggled через PlayerC.updateCombat (isAttacking)
|
||||
ResourceC.ts - Day 7: спавн ресурсу з крейта -> scatter -> 2 баунси
|
||||
(відчутні, БЕЗ ротейту) -> на 2-му баунсі: білий
|
||||
флеш (окремий additive-оверлей-меш, БЕЗ
|
||||
squash-and-widen — чистий uniform shrink, тримається,
|
||||
не пружинить назад) + SparkleFxC-вибух зірочок ->
|
||||
staggered (по черзі, не купою) політ до
|
||||
HudC.getWorldAnchorPosition() (ціль пере-семплюється
|
||||
ЩОКАДРУ, не застигає, якщо камера рухається) з
|
||||
компенсацією перспективи (див. RESOURCE_ICON_BASE_SCALE,
|
||||
нижче) -> яскравіє й зникає рівно на іконці. НЕ знає
|
||||
про Pay Zone/гравця; візуал — клон реального
|
||||
"UI_Wood" з мапи; createVisual() — публічний доступ
|
||||
до цього візуалу (без баунс/флеш начинки) для інших
|
||||
контролерів (PayZoneC)
|
||||
PayZoneC.ts - Day 6/8: окремо зливає ResourceC.getCollectedCount() в
|
||||
Pay Zone (вузол мапи "UI_Interactive_Zone_02"), по
|
||||
одному ресурсу, тільки поки гравець стоїть в зоні;
|
||||
зникає після RESOURCES_TO_CLOSE_ZONE доставлених
|
||||
(зараз 20); росте плаский fill-overlay квад по мірі
|
||||
заповнення; політ депозиту (HudC.getWorldAnchorPosition()
|
||||
-> Pay Zone) БЕЗ ротейту, з тією ж компенсацією
|
||||
перспективи й базовим розміром, що й ResourceC
|
||||
(RESOURCE_ICON_BASE_SCALE, спільна константа —
|
||||
інакше токен виглядає більшим за ресурс, що щойно
|
||||
був на тому ж місці), плюс окремий
|
||||
DEPOSIT_FLIGHT_SCALE_MULTIPLIER для локального тюнінгу
|
||||
HudC.ts - Day 8: лічильник ресурсу — реальний дизайнерський
|
||||
ассет (resourceCounterBgSrc, Tool_15.webp з temp/,
|
||||
планка вже вбудована в картинку, не окрема іконка),
|
||||
DOM-оверлей в #ui, стилі в src/css/ui.css (не inline
|
||||
<style>); getWorldAnchorPosition() — СПРАВЖНЯ
|
||||
screen-to-world проєкція (getBoundingClientRect()
|
||||
правого краю плейта + unproject через камеру), НЕ
|
||||
вгаданий локальний офсет від камери (той підхід
|
||||
видалений повністю, разом з WORLD_ANCHOR_LOCAL_OFFSET)
|
||||
SparkleFxC.ts - Day 7: пул із 30 зіркових мешів/матеріалів (pop-in
|
||||
Back.Out -> hold -> fade-out через TweenC), жодних
|
||||
нових Mesh/Material під час гри — spawnBurst() бере
|
||||
вільний слот з пулу; використовується ResourceC на
|
||||
2-му баунсі
|
||||
PropVfxC.ts - Day 6: завантажує дизайнерські quarks.art VFX-префаби
|
||||
(JSON з temp/, sparks/dust/debris) для хіту/знищення
|
||||
крейтів
|
||||
WeaponHudC.ts - Day 8: статична юайка внизу-зліва — восьмикутна
|
||||
плита (weaponHudBgSrc, ZombiePunk_Icon-Bottom.webp з
|
||||
temp/) + іконка бити (weaponIconBatSrc, Tool_2.webp)
|
||||
+ хардкоджений текст "LVL 0" (нема системи рівнів
|
||||
зброї в проєкті — суто візуал під референс). Іконка
|
||||
завжди бита, не стежить за поточною екіпірованою
|
||||
зброєю (pistol/bat) — окреме, свідомо не зроблене
|
||||
рішення
|
||||
ZombieInvasionHudC.ts - Day 8: топ-центр "ZOMBIE INVASION" — заголовок +
|
||||
таймер-бар. Голова зомбі (zombieHeadIconSrc,
|
||||
REF_1.png з temp/ — файл НАСПРАВДІ webp, не png,
|
||||
розширення в джерелі помилкове) їде з
|
||||
right:100% -> left:0% за TIMER_DURATION_S (120с)
|
||||
через CSS transition (JS лише раз перемикає
|
||||
значення); окремий .zombie-invasion__fill росте
|
||||
0%->100% width з right:0 в ТОЙ САМИЙ момент/тривалість
|
||||
— їх ліва межа завжди математично збігається з
|
||||
позицією голови, без пофреймової синхронізації.
|
||||
Останні WARNING_FRACTION=0.05 (6с) — pulse+червоний
|
||||
флеш через setTimeout+клас (CSS keyframes самі
|
||||
крутяться в infinite, ніхто їх не зупиняє після
|
||||
заповнення — свідомо, за проханням користувача).
|
||||
Немає дизайнерського ассету під сам бар/трек —
|
||||
чистий CSS pill, лише голова — реальна картинка
|
||||
ThreeC.ts/CameraC.ts - базовий сетап three.js/камери від SDK
|
||||
resources/meshes/ - ZombiePunk_Map.glb, ZombiePunk_Character.glb
|
||||
resources/images/ - resource_counter_bg.webp (Tool_15.webp з temp/,
|
||||
дизайнерський плейт лічильника), weapon_hud_bg.webp,
|
||||
weapon_icon_bat.webp, zombie_head_icon.webp,
|
||||
images.ts (icon_wood.webp/iconWoodSrc видалені —
|
||||
стали осиротілими після заміни на resourceCounterBgSrc)
|
||||
resources/fonts/ - PassionOne-Black.otf (з temp/) — див. "gameFont"
|
||||
нижче
|
||||
resources/vfx/ - VFX_Lootable_Hit.json, VFX_Lootable_Destroy.json
|
||||
(quarks.art префаби, копії з temp/, реально
|
||||
використовуються з коду — див. PropVfxC)
|
||||
```
|
||||
|
||||
`TriggerC.ts` (beginContact/endContact -> onEnter/onExit) і кінематичне
|
||||
cannon-тіло гравця для тригерів — **були написані й видалені в межах цієї ж
|
||||
сесії, ще до коміту**: перший дизайн ресурсів вимагав підходити до пікапа й
|
||||
стояти в тригер-зоні; користувач уточнив, що гравцю підходити не треба
|
||||
(ресурси самі розлітаються і зараховуються по таймеру), тож фізичні тригери
|
||||
виявились непотрібні для Day 5. Якщо колись знадобиться proximity-based
|
||||
тригер для чогось іншого (вода, зона урону, детект ворога) — писати заново,
|
||||
але сам підхід (`world.addEventListener('beginContact'/'endContact')` +
|
||||
мапа `body.id -> handlers`) вже перевірений робочим, просто не лишений в коді.
|
||||
|
||||
## Ключова знахідка: структура glb вже готова під breaking/loot
|
||||
|
||||
Мапа (`ZombiePunk_Map.glb`) містить групу **`Lootable`** (пряма дитина кореня
|
||||
`Map`) з ~18 дітьми `Wooden_Box_XXX` (XXX = `000`..`017`). Кожен має:
|
||||
|
||||
```
|
||||
Wooden_Box_XXX
|
||||
BoxCollider.NNN <- obstacle-бокс, він же в загальному /collider/i списку
|
||||
Wooden_Box_States_XXX
|
||||
Wooden_Box_XXX_S1 <- найменше пошкоджений (не завжди є)
|
||||
Wooden_Box_XXX_S2
|
||||
Wooden_Box_XXX_S3 <- найбільш пошкоджений/уламки (завжди є хоча б цей)
|
||||
```
|
||||
|
||||
Не всі крейти мають усі 3 стейджі — деякі стартують вже пошкодженими
|
||||
(тільки S2/S3) або взагалі як декоративні уламки (тільки S3). `BreakablePropC`
|
||||
сортує дітей `..._States_XXX` за номером у назві й показує лише перший —
|
||||
кожен хіт просуває на наступний, а хіт по останньому стейджу знищує крейт.
|
||||
|
||||
Root-рівень gltf-сцени (`getObject("map")` = `gltf.scene`, НЕ сам "Map"-нод)
|
||||
містить ще 3 сиблінги "Map": `UI_Tool_Zone`, `UI_Interactive_Zone_02`,
|
||||
`UI_Wood`/`UI_Wood.001` — плоскі quad-меші на позиції origin. В грі це
|
||||
видно як пунктирний білий квадрат, що лежить на дорозі біля крейтів
|
||||
(скріншот при першому запуску) плюс текстуру дерева поверх нього.
|
||||
|
||||
**`UI_Wood` (mesh `Plane.002`) — це реальний іконка-ресурсу, не сміття.**
|
||||
Матеріал `Material` (index 6) використовує текстуру з назвою **`Icon_Wood`**
|
||||
(unlit, alphaMode BLEND, doubleSided) — художник підготував конкретно "іконку
|
||||
дерева" саме для цього. Тепер підключено: `TestSceneC` бере цей нод,
|
||||
віддає його в `ResourceC.setPickupTemplate()` (клонується на кожен спавн
|
||||
ресурсу — `.clone()`) і ховає оригінал (`visible=false`, він більше не
|
||||
статична декорація, а шаблон для клонування). `ResourceC.createPickupMesh()`
|
||||
примусово виставляє `clone.visible = true`, бо `.clone()` копіює і
|
||||
`visible:false` з прихованого оригіналу.
|
||||
|
||||
`UI_Wood.001` (mesh `Plane.008`, матеріал `M_Items`/текстура `T_items_icons`
|
||||
— схоже на спрайт-атлас з кількома іконками) і `UI_Tool_Zone` — призначення
|
||||
досі не зрозуміле, але користувач попросив прибрати їх як "зайві текстури"
|
||||
навколо Pay Zone — тепер ховаються в `TestSceneC` (`visible=false`) поруч з
|
||||
`UI_Wood`. `UI_Interactive_Zone_02` (пунктирний квадрат) — **тепер
|
||||
підключений** в Day 6 як Pay Zone (див. нижче).
|
||||
|
||||
Ще одна знахідка (не сиблінг, а дитина `Map` -> `"UI"`): группа `UI`
|
||||
(`UI_Background`/`UI_Foreground`/`UI_Middleground`, всі матеріал `_Part2`
|
||||
— той самий атлас, що й крейти) — окремо повернута група (власний
|
||||
quaternion), локально приблизно (0.31, 1.21, -1.9), тобто підвішена в
|
||||
повітрі на висоті голови персонажа. Схоже на фейковий install-button
|
||||
мокап (типовий playable-ad прийом), рендерився завжди, візуально
|
||||
"прямокутна синя пластина над плей зоною" з якою скаржився користувач.
|
||||
Теж ховається тепер (`map.getObjectByName("UI")`, `visible=false`).
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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` тепер ніде не використовується після видалення тригерів).
|
||||
@@ -0,0 +1,146 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,104 @@
|
||||
# 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`).
|
||||
@@ -0,0 +1,120 @@
|
||||
# Камера: прибрано `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 після
|
||||
ходьби/бою/розвороту персонажа — камера просто зсувається лінійно, кут
|
||||
не змінюється.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Камера підлаштовується під 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)` і триває сходитись) —
|
||||
камера реально підлаштовується, а не стоїть на місці.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Два хіти за один цикл анімації атаки (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++), тобто рівно один цикл анімації = один
|
||||
просунутий стейдж, як і треба.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Розширений 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/`,
|
||||
ще не імпортований) — ймовірно призначений саме для цього.
|
||||
@@ -0,0 +1,110 @@
|
||||
# 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, прогрес-бар зони), має власні
|
||||
"Перевірено" нотатки нижче за текстом.
|
||||
@@ -0,0 +1,110 @@
|
||||
# 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()` враховано одразу).
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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`-підхід видалений
|
||||
повністю.
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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`), а не дублювати магічне число, інакше вони
|
||||
розходяться, як тут.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Day 8 (продовження): WeaponHudC, ZombieInvasionHudC, InstallBanner-рестайл, шрифт, ротація (2026-08-13)
|
||||
|
||||
Усе нижче — **ще не закомічено** (тільки `4a22a9b`/`63609ee` в git; це
|
||||
робота вже після другого коміту). `npm run export` вже прогонявся раз
|
||||
успішно (з побічним авто-встановленням `three.quarks`, бо в `export.cjs`
|
||||
своя мережева воркфлоу — див. окремий output_export_browser_en.html).
|
||||
|
||||
## `WeaponHudC.ts`/`ZombieInvasionHudC.ts` — нові статичні/напівстатичні юайки
|
||||
Реалізовані аналогічно `HudC` (окремий контролер на юайку, `init()` з
|
||||
`TestSceneC`). Ассети: `ZombiePunk_Icon-Bottom.webp`+`Tool_2.webp` для
|
||||
зброї, `REF_1.png` (**насправді webp**, розширення джерела — брехня,
|
||||
перевірено по сирих байтах RIFF/WEBP заголовку) для голови зомбі.
|
||||
|
||||
**ZombieInvasionHudC пройшов кілька раундів фідбеку**:
|
||||
1. Спочатку `.zombie-invasion__bar` мав суцільний зелений `background`
|
||||
(статично "повний" від t=0) — користувач: "WARNING_FRACTION чомусь
|
||||
почався тільки коли шкала повністю заповнилась". Причина НЕ в
|
||||
таймінгу (`114000мс з 120000мс` — точно останні 5%, перевірено), а в
|
||||
тому, що шкала і так ЗАВЖДИ виглядала на 100% — різницю "майже" vs
|
||||
"повна" було неможливо побачити. **Фікс**: реальна заливка
|
||||
(`.zombie-invasion__track` з `overflow:hidden` + `.zombie-invasion__fill`,
|
||||
росте `width:0%->100%` з `right:0`, стартує в ТОЙ САМИЙ тік з тією ж
|
||||
тривалістю, що й голова — їхні межі математично завжди збігаються, без
|
||||
пофреймової синхронізації).
|
||||
2. Голова мала бути БІЛЬШОЮ за саму шкалу (`0.24 * width` проти
|
||||
`0.15 * width` висоти бару) — але `.zombie-invasion__bar` мав
|
||||
`overflow:hidden` для заливки, що обрізало б і голову. **Фікс**: голова
|
||||
винесена сиблінгом `.zombie-invasion__track` (не її дитиною) — обрізає
|
||||
тільки заливку/флеш, не голову.
|
||||
|
||||
## InstallBanner (SDK-компонент) — свідомо НЕ переписаний, лише CSS-рестайл
|
||||
Круглий портрет персонажа + кнопка "INSTALL" знизу — це **не наша**
|
||||
юайка, а SDK `InstallBanner` (`node_modules/@hitplay/playable_template`,
|
||||
підключений в `beforeResourcesLoadedCb.ts`) з реальним кліком
|
||||
(`AnalyticsC.sendDownload()` -> `Template.end()`) і власною
|
||||
конфіг-системою (`src/configUIParams/installBanner.ts`, `upload_file`
|
||||
+ X/Y/scale-відсотки окремо для portrait/landscape — реальні значення це
|
||||
**треті числа** в кожному `values: [min, max, default]`). Користувач
|
||||
попросив "уніфікувати" з іншими юайками — явно спитав через
|
||||
`AskUserQuestion`, чи переписувати логіку самому, чи тільки CSS —
|
||||
**вибрав тільки CSS-рестайл** (безпечний варіант). Додано лише
|
||||
адитивні властивості (`filter: drop-shadow`, `-webkit-text-stroke` на
|
||||
`.bannerButtonText`) — нічого, що конфліктувало б з інжектованим SDK-стилем.
|
||||
|
||||
**⚠️ Знахідка: purgecss мовчки вирізає `.bannerButton`, але не `.bannerLogo`/`.bannerButtonText`**
|
||||
При білді (не тільки в dev) `purgecss` скановує лише `src/**/*.{js,ts,tsx}`
|
||||
+ `**/*.html` на предмет "чи цей клас реально використовується" — а
|
||||
`InstallBanner`'s класи призначаються в JS всередині ВЖЕ мінифікованого
|
||||
SDK dist-бандла (не в нашому `src/`). Емпірично перевірено в реальному
|
||||
білд-виводі (`export/index.html`): `.bannerLogo{filter:...}` і
|
||||
`.bannerButtonText{...}` виживають, а `.bannerButton{filter:...}` — ні,
|
||||
навіть як окреме правило. Ймовірна причина: мінифікатор пакета
|
||||
де-дублював рядок `"bannerButton"` (4 похідні: сам +
|
||||
`Holder`/`Img`/`Text`) на спільний префікс, тож він більше не існує
|
||||
цілим рядком у бандлі — `"bannerLogo"` (лише 2 похідні) не досяг порогу
|
||||
для такої оптимізації. Поза нашим контролем (сторонній пакет) — просто
|
||||
не писати CSS-правила під `.bannerButton` окремо, вони будуть мертвим кодом.
|
||||
|
||||
## `gameFont` нарешті став реальним шрифтом
|
||||
`font-family: gameFont, sans-serif` використовувався в `main.css`/`ui.css`
|
||||
з самого початку проєкту, але `gameFont` НІКОЛИ не був заведений через
|
||||
`@font-face` — тихо відкатувався на дефолтний sans-serif усюди. Користувач:
|
||||
"весь юай має використовувати шрифт PassionOne-Black, він у нас є в
|
||||
temp". Додано `@font-face` в `main.css` (`src/resources/fonts/PassionOne-Black.otf`)
|
||||
під іменем `gameFont` — усі існуючі посилання почали резолвитись сами,
|
||||
без жодної іншої зміни. Технічний нюанс: для шрифтів (на відміну від
|
||||
`images.ts`/`ConvertToBase64WhenRelease`-паттерна) НЕ треба нічого
|
||||
особливого — `assetsInlineLimit: 100000000` в `vite.config.js`
|
||||
(через `defineConfigTemplate`) вже інлайнить будь-який `url()` в CSS як
|
||||
base64 і в dev, і в білді. Перевірено прямо в білд-виводі (не на око).
|
||||
|
||||
## `--lvh`/`--lvw` замість сирого `vh`/`vw`/`vmin` — ротація/адаптивність
|
||||
Користувач (Day 8 чекліст, "All UI elements must be adaptive to changing
|
||||
device rotation"): розвернув екран — нічого не змінилось. **Знахідка**:
|
||||
SDK має `OrientationC` (`node_modules/@hitplay/playable_template`), який
|
||||
під лок-режимом орієнтації обгортає сторінку в
|
||||
`transform: rotate(-90deg)...`, щоб контент лишався візуально портретним
|
||||
навіть на фізично горизонтальному екрані. Сирі `vh`/`vw`/`vmin`
|
||||
**ігнорують transform батьків** і завжди рахуються від фізичного
|
||||
(неповернутого) вьюпорта — тож будь-який розмір у `vh` стає неправильним
|
||||
щойно лок реально спрацює. `OrientationC` для цього тримає `--lvw`/`--lvh`
|
||||
("логічні" версії, коректно стежать за поворотом; в Adaptive-режимі без
|
||||
локу — просто дорівнюють фізичним vw/vh, тобто нічого не коштують) —
|
||||
той самий підхід, що вже й в `InstallBanner.ts` (`--lvw` в коментарях
|
||||
"physical vw doesn't follow the orientation lock"). Усі `--*-width` в
|
||||
`ui.css` тепер `calc(var(--lvh, 100vh) * ratio)`.
|
||||
|
||||
**⚠️ Важливо**: дефолт в `src/configUIParams/orientation.ts` — **"Adaptive"**,
|
||||
НЕ лок. У цьому режимі `--lvh == vh` і фікс вище нічого не змінює
|
||||
візуально прямо зараз — це радше "правильно про запас", коли/якщо
|
||||
проєкт зрештою поставлять на Portrait Lock (майже неминуче для
|
||||
вертикального плейбла). Коли користувач після цього фіксу все одно
|
||||
сказав "нічого не змінилось, юайка так само зосереджена в центрі" —
|
||||
попросив **медіа-правила** замість покладання на `--lvh`/лок-механізм.
|
||||
**Фінал**: кожна з трьох юайок (`resource-hud`/`weapon-hud`/`zombie-invasion`)
|
||||
тепер має власний `@media (orientation: landscape) { ... }` блок одразу
|
||||
після portrait-правила, що перевизначає `top`/`left`/`right`/`bottom` і
|
||||
`--*-width` — незалежно від `OrientationC`/локу, реагує на реальне
|
||||
співвідношення сторін вьюпорта. Значення в landscape-блоках зараз лише
|
||||
дублюють portrait як стартову точку — **ще НЕ підтверджені користувачем
|
||||
візуально**, він вже почав підкручувати `.resource-hud`'s landscape
|
||||
`--hud-width` (з `0.09` на `0.19` — бачив у diff, не сам робив).
|
||||
|
||||
## Стан на кінець цієї сесії
|
||||
- Working tree: `src/configUIParams/installBanner.ts` (можливо просто
|
||||
line-ending, не досліджував — не моя зміна), `TestSceneC.ts`,
|
||||
`main.css`, `ui.css`, `images.ts` — модифіковані; `WeaponHudC.ts`,
|
||||
`ZombieInvasionHudC.ts`, `resources/fonts/`, три нові `.webp` — нові.
|
||||
**Не закомічено.**
|
||||
- `stats.html` — як завжди, build-артефакт, свідомо не в git.
|
||||
- Наступний крок, якщо буде запит: підтвердити/підкрутити landscape
|
||||
`@media`-значення разом з користувачем, коли він реально побачить це
|
||||
на розгорнутому екрані.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Health bar для дестройбл-елементів (2026-08-13)
|
||||
|
||||
Референс-скріншот: прямокутний хелз бар з бордером, синя заливка HP,
|
||||
чорним — вже відняте попередніми ударами, білим — відняте саме поточним
|
||||
ударом (з коротким затуханням у чорне), число нанесеного урону над баром.
|
||||
Нехай 10 урону за удар, 100хп у цілого ящика.
|
||||
|
||||
## `BreakablePropC` — перехід з дискретного лічильника хітів на HP
|
||||
`HITS_PER_STAGE`/`hitsOnStage` повністю видалені. `BreakableProp` тепер
|
||||
несе `currentHp`/`maxHp` (`MAX_HP = 100`, `export`-нутий, щоб `HealthBarC`
|
||||
рахував той самий `0..1` дріб без дублювання числа) + `startStageNumber`/
|
||||
`totalPossibleStages` — реальні номери з `_SN` у назвах стейдж-нод (не
|
||||
вигадані), з яких рахується, скільки HP пошкоджений з коробки крейт вже
|
||||
має "по дефолту" (пошкоджені S2/S3-старт крейти стартують не з 100).
|
||||
`hit()` тепер: відняти `HIT_DAMAGE`, повідомити `HealthBarC.onHit(...)`,
|
||||
знищити на HP<=0 (раніше — "просунувся за останній стейдж"), інакше —
|
||||
перевести `stageIndex` на `Math.floor((1 - currentHp/maxHp) *
|
||||
totalPossibleStages)` (з відступом на `startStageNumber`), змаплений у
|
||||
межі власного (можливо короткого) масиву стейджів.
|
||||
|
||||
## `HealthBarC.ts` — новий контролер, world-tracking DOM-бари
|
||||
Одна мапа `BreakableProp -> BarInstance`, живиться з `BreakablePropC.hit()`
|
||||
через `onHit`/`onDestroyed` (аналогічний DI-паттерн до `ResourceC.setFlyTarget`).
|
||||
|
||||
**Позиціювання**: `position:fixed` + сирі px з `canvasRect` (та ж техніка,
|
||||
що й `HudC.getWorldAnchorPosition`, лише в протилежний бік — тепер
|
||||
винесена в `src/utils/screenWorldProjection.ts`, обидва напрямки). Точка
|
||||
прив'язки — реальний `Box3().setFromObject(prop.root)` (верх+центр X/Z),
|
||||
не вгаданий офсет.
|
||||
|
||||
**Три кольори — чистий CSS, без пофреймової JS-анімації**: `.health-bar__white`
|
||||
й `.health-bar__blue` — два накладені div. На кожен хіт: білий миттєво
|
||||
(`transition-duration: 0s`) стрибає на ПОПЕРЕДНІЙ `%`, форс-reflow через
|
||||
`offsetWidth`, потім і білий (з `transition-delay`), і синій одразу
|
||||
виставляються на ПОТОЧНИЙ `%` — синій доїжджає без затримки, білий
|
||||
"зависає" на старому значенні й наздоганяє з лагом, показуючи чорний трек
|
||||
під собою в проміжку. Ніякого `requestAnimationFrame`/tween — сама
|
||||
CSS-transition timing і є ефектом.
|
||||
|
||||
**Авто-приховування**: `idleTime` рахується щокадру, `>= HIDE_DELAY_S` (2с)
|
||||
без нового хіта — `is-hiding` клас + `FADE_OUT_MS` (250мс) opacity-transition,
|
||||
потім видалення з DOM. Скидається на 0 при кожному `onHit`. `onDestroyed`
|
||||
видаляє бар негайно (порожній бар не має лишатись висіти).
|
||||
|
||||
**Числа урону — кілька раундів фідбеку, фінал**:
|
||||
- Спочатку плавали вгору з затуханням — користувач попросив натомість
|
||||
різку появу непрозорого числа (`-webkit-text-stroke`, не text-shadow-глов)
|
||||
з різким скейл-панчем до 1.5x + ротейтом і поверненням, і тільки ПОТІМ
|
||||
затуханням (не рухом).
|
||||
- Ротейт — послідовно звужувався: спершу довільний напрямок/кут, потім
|
||||
користувач уточнив "тільки по годинниковій стрілці, до 2 години дня"
|
||||
(тобто 0..60°, `--damage-rotate`, рандомізується в JS на кожен спавн,
|
||||
читається в keyframe через `var()`).
|
||||
- **Баг знайдений і виправлений**: затухання на спокої (force=0, у
|
||||
контексті джойстика — не плутати з цим, тут йдеться про сам панч)
|
||||
показувало прозору прогалину замість базового кольору, бо альфа
|
||||
інтерполювалась до 0 — фікс: альфа фіксована, змінюється лише
|
||||
RGB-канал (той самий принцип пізніше повторився і в джойстику, див.
|
||||
нижче).
|
||||
- Тривалість/розмір/швидкість анімації (920мс, +20% розмір шрифту,
|
||||
expo-out крива "швидкий старт — повільний кінець") — прямі числові
|
||||
правки за проханням користувача.
|
||||
|
||||
## `src/css/health-bar.css` — новий файл
|
||||
Слідує тому ж house convention, що й `ui.css` (одна `--health-bar-width`
|
||||
змінна, `--lvh` не сирий `vh`) — **окремо зазначено користувачем не
|
||||
чіпати**: файл (як і `ui.css`) свідомо не отримав landscape
|
||||
`@media`-блоку і досі на `vh`, не `rem`/`%` — відомий, прийнятий
|
||||
відступ від Day 8 чекліста, відкладений на потім.
|
||||
@@ -0,0 +1,24 @@
|
||||
# PayZoneC: політ депозиту тепер з голови гравця, не з юайки (2026-08-13)
|
||||
|
||||
Був: політ ресурсу в Pay Zone стартував з `HudC.getWorldAnchorPosition()`
|
||||
(іконка лічильника). Користувач попросив: **з персонажа**, летить від
|
||||
голови вгору й плавно спускається в **центр** зони (не з юайки взагалі).
|
||||
|
||||
`PlayerC.getHeadWorldPosition()` — новий публічний метод, `object.position
|
||||
+ (0, PLAYER_HEIGHT, 0)` — `PLAYER_HEIGHT` вже був реальним заміряним
|
||||
розміром рига (`PLAYER_SIZE.y`), не вигаданим числом, тож верх голови
|
||||
рахується без нової магічної константи. `PayZoneC.playDepositFlight`
|
||||
тепер цілиться в геометричний центр `zoneBounds` (не в pivot самого
|
||||
маркер-ноду — не гарантовано, що він точно в центрі footprint-у), і
|
||||
лишень до прямого lerp додає `Math.sin(t * Math.PI) *
|
||||
DEPOSIT_FLIGHT_ARC_HEIGHT` (той самий трюк дуги, що вже в `ResourceC`) —
|
||||
вгору-і-плавно-вниз замість прямої лінії.
|
||||
|
||||
**Знайдено й виправлено**: `DEPOSIT_FLIGHT_SCALE_MULTIPLIER` був
|
||||
підкручений (`0.3`) під СТАРИЙ політ, де перспективна компенсація
|
||||
(`perspectiveCompensationScale`, camera-distance ratio) відчутно
|
||||
збільшувала розмір за час польоту, бо старт був близько до камери (HUD),
|
||||
а кінець далеко. Тепер старт — голова гравця, приблизно та ж дистанція
|
||||
до камери, що й Pay Zone (камера жорстко слідує за гравцем) — компенсація
|
||||
майже не працює, і `0.3` робив токен практично невидимим. Підняли до
|
||||
`1.3`.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Джойстик: редизайн під референс + два реальні баги (2026-08-13)
|
||||
|
||||
Референс: прозора середина, градієнтний бордер, помаранчева точка пальця
|
||||
з підсвіткою навколо. `JoystickC.init()` (SDK-обгортка nipplejs) приймає
|
||||
будь-які опції nipplejs напряму, включно з `color: {back, front}` —
|
||||
кожен рядок йде прямо в `background` відповідного div, підтримує
|
||||
градієнти й навіть `var()`/`calc()` (живі CSS-змінні всередині inline
|
||||
style реально оновлюються).
|
||||
|
||||
**Фінальна реалізація** (`beforeResourcesLoadedCb.ts` + `main.css`):
|
||||
- `back` (кільце) — `conic-gradient(from var(--joy-angle), ...)`, базовий
|
||||
колір однотонний темний, підсвітка (тон точки пальця) лише у широкій
|
||||
дузі навпроти пальця, з `calc()`-інтерполяцією RGB-каналів (не альфи!)
|
||||
за `--joy-force`.
|
||||
- `front` (точка) — суцільний плоский колір, без градієнта/тіні.
|
||||
- Форма кільця (прозора середина) — `mask-image: radial-gradient(...)`
|
||||
з **абсолютними px**, не відсотками — щоб зміна `size` (радіус зони) не
|
||||
розтягувала товщину бордера пропорційно.
|
||||
- Розмір точки пальця (`.front`) — фіксований px-оверрайд, повністю
|
||||
відв'язаний від `size` (nipplejs хардкодить точку як `size/2`, тож
|
||||
збільшення радіуса зони інакше тягнуло за собою і точку).
|
||||
- `JoystickC.onJoysticMove` — окрема підписка (SDK підтримує кілька
|
||||
делегатів на подію, `PlayerC` підписаний окремо на той самий івент для
|
||||
руху) ставить `--joy-angle`/`--joy-force` на `instance.ui.el` щокадру.
|
||||
|
||||
**⚠️ Знайдений і виправлений баг: підсвітка на 90° проти стрілки від
|
||||
реального положення пальця.** `Math.atan2(y, x)` на скрін-координатах (y
|
||||
вниз) і CSS `conic-gradient(from Xdeg, ...)` обидва рахують по
|
||||
годинниковій, але з різною точкою нуля: atan2 — схід (3 година),
|
||||
conic-gradient — північ (12 година). Фікс — плюс 90° один раз, не свап
|
||||
знаку і не переписування напрямку градієнта.
|
||||
|
||||
**⚠️ Знайдений і виправлений баг: підсвітка не зупинялась на межі
|
||||
зони, а продовжувала змінюватись, коли палець вели далі за край.**
|
||||
Трасування в самому nipplejs (`Collection.ts`): `force` рахується з
|
||||
СИРОЇ, необмеженої дистанції дотику (`dist / size`), тоді як
|
||||
`frontPosition` (сама видима точка й кут вище) — з ОБМЕЖЕНОЇ. За краєм
|
||||
кільця `force` продовжує необмежено зростати, хоча точка вже візуально
|
||||
запінена на місці — усі `calc()` розраховані на `force` в межах `0..1`.
|
||||
Це ж переповнення й давало жовтуватий відтінок (RGB-канали переїжджали
|
||||
за цільовий колір). Фікс — один `Math.min(Math.max(force, 0), 1)` перед
|
||||
записом у CSS-змінну, а не clamp у кожному calc() окремо.
|
||||
|
||||
Розмір: `size` (радіус зони) підняли на +50%, потім усі елементи
|
||||
(зона+точка+товщина кільця) зменшені на -30% від того — фінальні числа:
|
||||
`size: 105`, точка `35px`/`-17.5px` margin, кільце `43.4/44.8/46.9/48.3px`.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Камера: `CAMERA_OFFSET` підкручений (2026-08-13)
|
||||
|
||||
Користувач попросив камеру ближче, показавши референс-кут. `CAMERA_OFFSET`
|
||||
в `TestSceneC.ts` з `(0, 9, -9)` (45° нахил, дистанція ~12.7) на
|
||||
`(0, 8, -6)` — коротша дистанція, трохи інше (положче) співвідношення
|
||||
Y:Z. Порадив користувачу для самостійного підбору: сам `CAMERA_OFFSET`
|
||||
(єдиний вектор, що визначає і дистанцію, і кут — `CameraFollowC.init()`
|
||||
ставить позицію й раз викликає `lookAt`, більше кут не змінюється) і
|
||||
`camera_fov_p` в редакторі конфігів (дефолт 60, діапазон 30-150) —
|
||||
окремий, незалежний важіль "наближення" через FOV. **Важливо**:
|
||||
`camera_position_p`/`camera_rotation_p` слайдери в редакторі — мертві,
|
||||
`CameraFollowC` перезаписує їх одразу при старті.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Day 9-10: рефактор і чистка (2026-08-13)
|
||||
|
||||
Бриф прямо просив "думай як розробник, що передає код іншому" — без
|
||||
зміни логіки/поведінки гри, лише структура/дублювання/коментарі.
|
||||
Погоджений план (`EnterPlanMode`/`ExitPlanMode`) виконаний фазами, кожна
|
||||
верифікована окремим `npx vite build`:
|
||||
|
||||
- **Мертвий код прибрано**: `PayZoneC.getTargetWorldPosition()` (0
|
||||
викликів), увесь `PhysicsC.PhysicsObjPair`/`pair`-поле/
|
||||
`disablePhysicsPair()` (клас був повністю нежиттєздатним — єдине
|
||||
місце, що присвоювало `pair`, саме закомічене); 2 прототипні
|
||||
`console.log` (`PayZoneC`, `ResourceC`). `SparkleFxC`'s `console.debug`
|
||||
на виснаження пулу — свідомо залишений (реальна діагностика
|
||||
деградованого режиму, не сміттєвий лог).
|
||||
- **4 нові утиліти в `src/utils/`** — прибрали дублювання, знайдене між
|
||||
файлами (не новий functionality, лише перенесення існуючої формули
|
||||
без зміни):
|
||||
- `perspectiveScale.ts` — camera-distance-ratio компенсація, була
|
||||
незалежно написана і в `ResourceC` (`toTarget`), і в `PayZoneC`
|
||||
(`playDepositFlight`).
|
||||
- `screenWorldProjection.ts` — world<->screen NDC-математика, була в
|
||||
`HudC` (screen->world, unproject) і `HealthBarC` (world->screen,
|
||||
project) — обидва незалежно читали `canvasRect`.
|
||||
- `tweenChain.ts` — той самий "TweenC.add() обидві ланки окремо"
|
||||
гочтя й бойлерплейт, дослівно повторений у `BreakablePropC` і
|
||||
`SparkleFxC`.
|
||||
- `flashOverlayMaterial.ts` — адитивний флеш-матеріал (клон + white
|
||||
additive overlay), дослівно повторений у `BreakablePropC` і
|
||||
`ResourceC`, разом з дубльованою константою `FLASH_COLOR`.
|
||||
- **Коментарі підчищені** у всіх 16 контролерах, `beforeResourcesLoadedCb.ts`
|
||||
і всіх 3 CSS-файлах — прибрана вся хронологія/цитати чату/"Day N"
|
||||
посилання, лишились тільки короткі WHY-нотатки на дійсно неочевидні
|
||||
речі (усі гочті з цього файлу — TweenC-чейн, glTF dot-stripping,
|
||||
nipplejs force-переповнення, atan2/conic-gradient зсув, MeshBasicMaterial
|
||||
білий-тінт-no-op — лишились, просто по одному разу в коді, не по
|
||||
два-три). Додана одна нова коротка нота в `ui.css` — `.bannerButton`
|
||||
реально мертвий у білді (purgecss-квірк), раніше це знав тільки цей
|
||||
файл.
|
||||
- **Свідомо НЕ зроблено**: домен-розбиття `src/controllers/` на
|
||||
підпапки (gameplay/vfx/ui/core) — розглянуто і відхилено: жодної
|
||||
зовнішньої конвенції під це нема (SDK сам плаский), `resources/` —
|
||||
єдиний прецедент підпапок в проєкті, і той групує по типу ассету, не
|
||||
по домену. Переїзд 16 файлів заради нуля функціональної вигоди —
|
||||
саме "overengineering", проти якого прямо просив бриф.
|
||||
- Два CSS-пункти з попереднього Day 8 аудиту (rem/% замість vh,
|
||||
відсутній landscape-медіазапит у `health-bar.css`) — **свідомо не
|
||||
торкались**, за проханням користувача відкласти.
|
||||
|
||||
Перевірено виключно `npx vite build` після кожної фази (усі 883 модулі,
|
||||
без помилок) — жодного self-testing у браузері, як завжди в цьому проєкті.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Камера з конфігу платформи стала реальною (2026-08-14)
|
||||
|
||||
Слайдери `camera_position_p/_l` і `camera_rotation_p/_l` у Scene settings були
|
||||
мертві: `CameraFollowC.init()` рахував позицію з константи `CAMERA_OFFSET` і
|
||||
виводив кут одноразовим `lookAt()`, а конфіг не читав узагалі. Продюсер міг
|
||||
крутити шість повзунків без жодного ефекту.
|
||||
|
||||
Вимога: повзунки прибирати не можна, вони мають працювати — і в експорті, і в
|
||||
прев'ю платформи.
|
||||
|
||||
## Що з'ясувалось під час розбору
|
||||
|
||||
**Формат конфігу читається не так, як виглядає.** У `globalSettings.ts` кожна
|
||||
вісь масивного параметра описана як `[min, max, default]`, тому
|
||||
`camera_position_p` виглядав як три трійки координат. Насправді дефолт — це
|
||||
третій елемент кожної трійки, а в рантаймі `Template.getCategory("global")`
|
||||
віддає плаский `[x, y, z]`. Перевірено прогоном справжнього
|
||||
`formConfigForPlayable(formConfigUI(...))` над нашим же `globalSettings`.
|
||||
|
||||
**Старі дефолти були з іншої моделі камери.** `[-2, 5, 42]` — це абсолютна
|
||||
позиція камери зі статичного SDK-шаблону. У грі з follow-камерою абсолютна
|
||||
позиція безглузда (її перезаписує кожен кадр), осмислений лише офсет відносно
|
||||
гравця. Оскільки ці значення ніколи ні на що не впливали, від них нічого не
|
||||
залежало — семантику переінтерпретовано на офсет.
|
||||
|
||||
**`Object3D.lookAt` і `Camera.lookAt` — різні.** Перша спроба вирахувати
|
||||
еквівалентний кут дала `x=53.13, y=0, z=0` і виглядала правдоподібно, але
|
||||
контрольна перевірка (зібрати кватерніон назад із цих кутів і зміряти
|
||||
`forward · напрямок_на_ціль`) дала **−1.0** — камера дивилась рівно в
|
||||
протилежний бік. Причина в самому three.js:
|
||||
|
||||
```js
|
||||
if (this.isCamera || this.isLight) _m1.lookAt(_position, _target, this.up);
|
||||
else _m1.lookAt(_target, _position, this.up);
|
||||
```
|
||||
|
||||
Звичайний `Object3D` наводить на ціль свою вісь **+Z**, камера — **−Z**. Розрахунок
|
||||
на `Object3D` замість `PerspectiveCamera` дає дзеркальний результат.
|
||||
**Урок**: будь-який офлайн-розрахунок орієнтації перевіряти зворотним ходом
|
||||
(відновити з чисел і зміряти скалярний добуток), а не приймати правдоподібні
|
||||
числа на віру.
|
||||
|
||||
Справжній кут для офсету `(0, 8, -6)` — `x=-126.87, y=0, z=-180`. Еквівалентна,
|
||||
але читабельніша форма `x=53.13, y=180, z=0` дає той самий кватерніон і той
|
||||
самий up-вектор — саме її записано в дефолти. `y=180` тут не довільне: риг стоїть
|
||||
позаду гравця на −Z і дивиться назад у +Z, а камера three.js за замовчуванням
|
||||
дивиться в −Z.
|
||||
|
||||
Тип параметра — `int`, тож `53.13` округлено до `53`. Відхилення від старого
|
||||
кадру — **0.13°**, візуально непомітно.
|
||||
|
||||
## Що змінено
|
||||
|
||||
- `globalSettings.ts` — тільки дефолти (id, назви, типи й діапазони не чіпані;
|
||||
діапазони вже покривали нові значення). Портрет і ландшафт отримали однаковий
|
||||
риг, бо гра й досі використовує один `CAMERA_OFFSET` для обох — тепер їх можна
|
||||
розвести з платформи.
|
||||
- `CameraFollowC` — новий `applyRigFromConfig()`: читає офсет і кут за поточною
|
||||
орієнтацією, застосовує кут, лишається єдиним писарем `camera.rotation`.
|
||||
`readVec3()` відсіює биті значення, і тоді кут виводиться старим способом
|
||||
(`frameTargetAndStoreRotation()`) — зламаний конфіг деградує до «кадрує
|
||||
гравця», а не до порожнього неба.
|
||||
- `beforeResourcesLoadedCb` — `updateVariableConfig` тепер маршрутизує
|
||||
`camera_position_*`/`camera_rotation_*` у `CameraFollowC`, FOV лишається за
|
||||
`CameraC`. Роздільна власність — це і є захист від повторення бага з двома
|
||||
писарями.
|
||||
- `resizeCb` — на зміні орієнтації перечитує і FOV, і риг.
|
||||
- `DebugConfigC` — повзунки кута/дистанції сідають із того самого конфігу
|
||||
(`readCameraRigFromConfig()`), дубльована константа `DEFAULT_CAMERA_OFFSET`
|
||||
прибрана. `setOffset()` і далі авто-кадрує й синхронізує `rotationDeg`.
|
||||
- `TestSceneC.CAMERA_OFFSET` — лишився як фолбек, коментар оновлено.
|
||||
|
||||
## Перевірено
|
||||
|
||||
- `npx vite build` — 886 модулів, чисто.
|
||||
- Прогін реального SDK-пайплайна: гра читає `camera_position_p = [0, 8, -6]`,
|
||||
`camera_rotation_p = [53, 180, 0]`; відновлена з цих чисел камера дивиться на
|
||||
гравця з похибкою 0.13°.
|
||||
- `npx tsc --noEmit` у проєкті **не запускається** — `tsconfig.json` містить
|
||||
`moduleResolution: node10`, яку TypeScript 7 видалив (TS5108). З обходом цієї
|
||||
опції лишаються дві доіснуючі помилки, обидві не в зміненому коді:
|
||||
deep-import `three/src/math/Color` у `ThreeC.ts` і застаріла декларація SDK для
|
||||
`JoystickC.init({ color })`.
|
||||
|
||||
## Не перевірено
|
||||
|
||||
Візуально в браузері нічого не запускалось. Потрібен ручний прогін: старт у
|
||||
портреті, поворот у ландшафт, правка повзунків на платформі наживо.
|
||||
|
||||
## Відкрите
|
||||
|
||||
Значення, збережені для вже наявних креативів на платформі, лишились у старій
|
||||
(абсолютній) семантиці — їх треба одноразово перевиставити на офсет-значення.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Worklog — архів історії розробки
|
||||
|
||||
Хронологічний лог сесій, винесений з `CLAUDE.md` (він розрісся до 1508 рядків
|
||||
і повністю вантажився в контекст кожної сесії).
|
||||
|
||||
**Це архів, а не інструкція.** Актуальні правила, конвенції й живі граблі — в
|
||||
кореневому `CLAUDE.md`. Сюди заглядають, коли треба зрозуміти *чому* щось
|
||||
зроблено саме так, або відновити контекст конкретної підсистеми.
|
||||
|
||||
## Зміст
|
||||
|
||||
- [Стек і структура](00-stack-and-structure.md)
|
||||
- [Day 5: Cannon.js — реалізовано (2026-08-11, доопрацьовано того ж дня)](01-day5-cannon-physics.md)
|
||||
- [Day 6: Tween Animations — реалізовано (2026-08-11, виправлено того ж дня)](02-day6-tween-animations.md)
|
||||
- [HUD-лічильник + перенаправлення анімацій (2026-08-12)](03-hud-counter-and-flight-retarget.md)
|
||||
- [Камера: прибрано `lookAt`, лінійне зміщення замість орбіти (2026-08-12)](04-camera-linear-offset.md)
|
||||
- [Камера підлаштовується під facing-lock на крейт (2026-08-12)](05-camera-facing-lock.md)
|
||||
- [Два хіти за один цикл анімації атаки (2026-08-12)](06-two-hits-per-swing.md)
|
||||
- [Розширений hit-feedback на крейтах: спарки, HP-бар, числа урону (2026-08-12)](07-hit-feedback-sparks-hpbar-damage-numbers.md)
|
||||
- [PayZoneC: прогрес-заповнення зони + два реальні знайдені баги (2026-08-12)](08-payzone-fill-progress.md)
|
||||
- [Day 7: VFX — шлейф зброї, доопрацювання ресурсів (2026-08-12)](09-day7-vfx-weapon-trail.md)
|
||||
- [Day 8: UI — резорс-каунтер (2026-08-12)](10-day8-resource-counter.md)
|
||||
- [PayZoneC: уніфікація депозит-флайту з ResourceC (2026-08-12)](11-payzone-deposit-flight-unified.md)
|
||||
- [Day 8 (продовження): WeaponHudC, ZombieInvasionHudC, InstallBanner-рестайл, шрифт, ротація (2026-08-13)](12-day8-weapon-hud-zombie-timer-font.md)
|
||||
- [Health bar для дестройбл-елементів (2026-08-13)](13-health-bars.md)
|
||||
- [PayZoneC: політ депозиту тепер з голови гравця, не з юайки (2026-08-13)](14-payzone-flight-from-head.md)
|
||||
- [Джойстик: редизайн під референс + два реальні баги (2026-08-13)](15-joystick-redesign.md)
|
||||
- [Камера: `CAMERA_OFFSET` підкручений (2026-08-13)](16-camera-offset-tuning.md)
|
||||
- [Day 9-10: рефактор і чистка (2026-08-13)](17-day9-10-refactor.md)
|
||||
- [Камера з конфігу платформи стала реальною (2026-08-14)](18-camera-config-made-live.md)
|
||||
@@ -20,6 +20,7 @@
|
||||
"cannon-es": "^0.20.0",
|
||||
"cannon-es-debugger": "^1.0.0",
|
||||
"howler": "^2.2.4",
|
||||
"lil-gui": "^0.21.0",
|
||||
"nipplejs": "^1.0.4",
|
||||
"three": "^0.185.1",
|
||||
"three.quarks": "^0.16.0"
|
||||
|
||||
@@ -7,11 +7,13 @@ import { installBanner } from "./installBanner";
|
||||
//@ts-ignore
|
||||
import { screenOrientation } from "./orientation";
|
||||
import { globalSettings } from "./globalSettings";
|
||||
import { gameplay } from "./gameplay";
|
||||
|
||||
export const configUIParams: ConfigUiParamsCategories[][] = [
|
||||
analytics,
|
||||
installBanner,
|
||||
globalSettings,
|
||||
gameplay,
|
||||
sounds,
|
||||
screenOrientation,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ConfigUiParamsCategories } from "@hitplay/ads_common";
|
||||
|
||||
// Platform-editable versions of the same values DebugConfigC's dev-only
|
||||
// panel exposes (press "C") — these are what actually take effect in a
|
||||
// real build; DebugConfigC.init() seeds its own state from this category
|
||||
// at startup so the debug panel starts out showing whatever's configured
|
||||
// here, not a separate hardcoded default.
|
||||
export const gameplay: ConfigUiParamsCategories[] = [
|
||||
{
|
||||
id: "gameplay",
|
||||
name: "Gameplay tuning",
|
||||
params: [
|
||||
{
|
||||
id: "hit_damage",
|
||||
name: "Damage per hit",
|
||||
type: "int",
|
||||
values: [0, 100, 10],
|
||||
},
|
||||
{
|
||||
id: "damage_number_style",
|
||||
name: "Damage number animation",
|
||||
type: "select",
|
||||
values: [
|
||||
["Punch (default)", "punch", true],
|
||||
["Float + fade", "float"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pay_zone_ripple_enabled",
|
||||
name: "Pay Zone landing ripple",
|
||||
type: "bool",
|
||||
values: [true],
|
||||
},
|
||||
{
|
||||
id: "zombie_invasion_timer_s",
|
||||
name: "Zombie Invasion timer (s)",
|
||||
type: "int",
|
||||
values: [5, 300, 120],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,21 @@
|
||||
import { ConfigUiParamsCategories } from "@hitplay/ads_common";
|
||||
|
||||
// Each array entry is [min, max, default] for ONE axis, in X/Y/Z order —
|
||||
// the runtime value the game reads back is a plain [x, y, z] (verified
|
||||
// against the SDK's own formConfigForPlayable pipeline).
|
||||
//
|
||||
// camera_position_* is the camera's offset RELATIVE TO THE PLAYER, not an
|
||||
// absolute world position: this is a follow camera (see CameraFollowC), so
|
||||
// an absolute position would be meaningless — it's overwritten every frame.
|
||||
// The shipped defaults used to be absolute-camera values inherited from the
|
||||
// SDK's static-camera template (e.g. Z: 42) and were never applied to
|
||||
// anything, so nothing depended on them.
|
||||
//
|
||||
// camera_rotation_* is the camera's fixed angle in degrees. Y: 180 is not
|
||||
// arbitrary — the rig sits BEHIND the player on -Z and looks back toward
|
||||
// +Z, while a three.js camera looks down -Z by default, so the yaw flip is
|
||||
// inherent to this offset convention. Defaults below reproduce the exact
|
||||
// angle the old hardcoded lookAt() produced for offset (0, 8, -6).
|
||||
export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
{
|
||||
id: "global",
|
||||
@@ -43,17 +59,17 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
[
|
||||
-20,
|
||||
30,
|
||||
-2
|
||||
0
|
||||
],
|
||||
[
|
||||
-5,
|
||||
40,
|
||||
5
|
||||
8
|
||||
],
|
||||
[
|
||||
-20,
|
||||
70,
|
||||
42
|
||||
-6
|
||||
]
|
||||
]
|
||||
},
|
||||
@@ -67,12 +83,12 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
[
|
||||
-360,
|
||||
360,
|
||||
-25
|
||||
53
|
||||
],
|
||||
[
|
||||
-360,
|
||||
360,
|
||||
0
|
||||
180
|
||||
],
|
||||
[
|
||||
-360,
|
||||
@@ -101,17 +117,17 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
[
|
||||
-20,
|
||||
30,
|
||||
-2
|
||||
0
|
||||
],
|
||||
[
|
||||
-5,
|
||||
40,
|
||||
6
|
||||
8
|
||||
],
|
||||
[
|
||||
-20,
|
||||
70,
|
||||
37
|
||||
-6
|
||||
]
|
||||
]
|
||||
},
|
||||
@@ -125,12 +141,12 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
[
|
||||
-360,
|
||||
360,
|
||||
-60
|
||||
53
|
||||
],
|
||||
[
|
||||
-360,
|
||||
360,
|
||||
0
|
||||
180
|
||||
],
|
||||
[
|
||||
-360,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
|
||||
import { ResourceC } from "./ResourceC";
|
||||
import { PropVfxC } from "./PropVfxC";
|
||||
import { HealthBarC } from "./HealthBarC";
|
||||
import { DebugConfigC } from "./DebugConfigC";
|
||||
import { chainAndRegisterTweens } from "../utils/tweenChain";
|
||||
import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial";
|
||||
|
||||
@@ -61,7 +62,6 @@ const SHADOW_MESH_PATTERN = /shadow/i;
|
||||
// fills without duplicating this number — the fresh/whole-crate reference,
|
||||
// not a per-instance value (see BreakableProp.maxHp for why those differ).
|
||||
export const MAX_HP = 100;
|
||||
const HIT_DAMAGE = 10;
|
||||
|
||||
// A hit that only advances a damage stage drops a small trickle; the hit
|
||||
// that finally destroys the crate dumps out most of its loot at once.
|
||||
@@ -117,15 +117,17 @@ export class BreakablePropC {
|
||||
return this.byColliderNode.get(node);
|
||||
}
|
||||
|
||||
// Every hit takes HIT_DAMAGE off currentHp; the visual stage is derived
|
||||
// from the remaining HP fraction (see below), and reaching 0 HP destroys
|
||||
// it. Every hit plays feedback + updates HealthBarC; a stage advance (or
|
||||
// the destroying hit) also drops resources. `hitDirection` (attacker ->
|
||||
// Every hit takes the debug panel's configured damage (default 10, see
|
||||
// DebugConfigC) off currentHp; the visual stage is derived from the
|
||||
// remaining HP fraction (see below), and reaching 0 HP destroys it.
|
||||
// Every hit plays feedback + updates HealthBarC; a stage advance (or the
|
||||
// destroying hit) also drops resources. `hitDirection` (attacker ->
|
||||
// target, XZ, normalized) drives which way the crate leans/topples.
|
||||
static hit(prop: BreakableProp, hitDirection: Vector3) {
|
||||
const damage = DebugConfigC.getHitDamage();
|
||||
const previousHp = prop.currentHp;
|
||||
prop.currentHp = Math.max(0, prop.currentHp - HIT_DAMAGE);
|
||||
HealthBarC.onHit(prop, previousHp, prop.currentHp, HIT_DAMAGE);
|
||||
prop.currentHp = Math.max(0, prop.currentHp - damage);
|
||||
HealthBarC.onHit(prop, previousHp, prop.currentHp, damage);
|
||||
|
||||
// A bit above the root's ground-level pivot, so the burst visually
|
||||
// comes from around the crate's body rather than the floor under it.
|
||||
|
||||
+14
-18
@@ -1,26 +1,22 @@
|
||||
import { CameraC_internal, Helper, Template } from "@hitplay/playable_template";
|
||||
import { CameraC_internal, Template } from "@hitplay/playable_template";
|
||||
import { PerspectiveCamera } from "three";
|
||||
|
||||
export class CameraC extends CameraC_internal {
|
||||
// FOV only, deliberately. This used to also copy camera_position_p/_l and
|
||||
// camera_rotation_p/_l onto the camera, and because it re-runs live on
|
||||
// every camera_* edit (see beforeResourcesLoadedCb's updateVariableConfig
|
||||
// listener), it permanently stomped the angle CameraFollowC owns — with
|
||||
// nothing to ever put it back for the rest of the session.
|
||||
//
|
||||
// Those fields are now real, live controls again, but they belong to
|
||||
// CameraFollowC.applyRigFromConfig(), which is the only writer of
|
||||
// camera.rotation. Two writers is precisely what broke before, so resist
|
||||
// adding position/rotation back here.
|
||||
static setCamera(portraitOrientation: boolean) {
|
||||
const CATEGORY = Template.getCategory("global");
|
||||
if (this.camera !== null) {
|
||||
let position = portraitOrientation
|
||||
? Helper.returnVectorCamera(CATEGORY["camera_position_p"] as number[])
|
||||
: Helper.returnVectorCamera(CATEGORY["camera_position_l"] as number[]);
|
||||
const rotation = portraitOrientation
|
||||
? Helper.returnEulerCamera(CATEGORY["camera_rotation_p"] as number[])
|
||||
: Helper.returnEulerCamera(CATEGORY["camera_rotation_l"] as number[]);
|
||||
this.camera.rotation.x = rotation.x;
|
||||
this.camera.rotation.y = rotation.y;
|
||||
this.camera.rotation.z = rotation.z;
|
||||
this.camera.position.copy(position.clone());
|
||||
if (this.camera instanceof PerspectiveCamera) {
|
||||
this.camera.fov = portraitOrientation
|
||||
? Number(CATEGORY["camera_fov_p"])
|
||||
: Number(CATEGORY["camera_fov_l"]);
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
if (this.camera !== null && this.camera instanceof PerspectiveCamera) {
|
||||
this.camera.fov = portraitOrientation ? Number(CATEGORY["camera_fov_p"]) : Number(CATEGORY["camera_fov_l"]);
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Object3D, Vector3 } from "three";
|
||||
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
|
||||
import { MathUtils, Object3D, Vector3 } from "three";
|
||||
import { CameraC_internal, Template, UpdateController } from "@hitplay/playable_template";
|
||||
|
||||
// How fast the camera closes the gap to its target position, per second.
|
||||
// Higher = snappier, lower = floatier. Framerate-independent (see the
|
||||
@@ -30,6 +30,14 @@ const MOVEMENT_EPSILON_SQ = 0.0005 * 0.0005;
|
||||
export class CameraFollowC {
|
||||
private static target: Object3D | null = null;
|
||||
private static offset = new Vector3();
|
||||
// Used only when the platform config can't supply a rig (missing or
|
||||
// malformed values) — see applyRigFromConfig.
|
||||
private static fallbackOffset = new Vector3();
|
||||
// The camera's fixed angle, in degrees, mirroring camera_rotation_p/_l.
|
||||
// Kept in sync whenever the rig changes from ANY source (config edit,
|
||||
// orientation flip, debug panel) so there is only ever one answer to
|
||||
// "what angle is the camera at".
|
||||
private static rotationDeg = new Vector3();
|
||||
private static previousTargetPosition = new Vector3();
|
||||
// The last direction actually walked in, held indefinitely — see update()
|
||||
// for why this must never get reset to zero on its own.
|
||||
@@ -38,26 +46,118 @@ export class CameraFollowC {
|
||||
// Injected (avoids importing PlayerC directly) — see setFacingTargetGetter.
|
||||
private static facingTargetGetter: (() => Vector3 | null) | null = null;
|
||||
|
||||
static init(target: Object3D, offset: Vector3) {
|
||||
static init(target: Object3D, fallbackOffset: Vector3) {
|
||||
this.target = target;
|
||||
this.offset = offset.clone();
|
||||
this.fallbackOffset.copy(fallbackOffset);
|
||||
this.previousTargetPosition.copy(target.position);
|
||||
this.desiredLookAhead.set(0, 0, 0);
|
||||
this.smoothedLookAhead.set(0, 0, 0);
|
||||
|
||||
// The camera's angle is set once, here, from the actual offset geometry
|
||||
// — not left at whatever camera_rotation_p/_l holds, since that config
|
||||
// value isn't tuned to work standalone. update() below never touches
|
||||
// rotation again after this — camera.position is the only thing it
|
||||
// ever moves, in a straight line toward a single target point (no
|
||||
// facing-based pivoting).
|
||||
const camera = CameraC_internal.getCamera();
|
||||
camera.position.copy(target.position).add(this.offset);
|
||||
camera.lookAt(target.position);
|
||||
// Offset and angle both come from the platform config (see
|
||||
// applyRigFromConfig) rather than being derived here from a hardcoded
|
||||
// offset — that's what makes the Scene settings sliders real controls
|
||||
// instead of dead ones. update() below still never touches rotation:
|
||||
// the angle is fixed until something explicitly changes the rig, so
|
||||
// this stays a linear-shift follow camera, not an orbiting one.
|
||||
this.applyRigFromConfig();
|
||||
|
||||
// Start already framed instead of easing in from wherever the camera
|
||||
// happened to be constructed.
|
||||
CameraC_internal.getCamera().position.copy(target.position).add(this.offset);
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
|
||||
}
|
||||
|
||||
// The single place the camera rig is read out of the platform's "Scene
|
||||
// settings". Called at init, whenever one of those fields is edited live
|
||||
// on the platform (see beforeResourcesLoadedCb's updateVariableConfig
|
||||
// listener) and on every orientation flip (see resizeCb), so portrait and
|
||||
// landscape can carry different rigs.
|
||||
//
|
||||
// Nothing else may write camera.rotation — an earlier version had
|
||||
// CameraC.setCamera() applying these same fields on every camera_* edit
|
||||
// while this class also owned the angle, and the two writers permanently
|
||||
// fought over it. One owner, one path.
|
||||
static applyRigFromConfig() {
|
||||
const CATEGORY = Template.getCategory("global");
|
||||
const portrait = window.screenSize.portrait;
|
||||
|
||||
const offset = this.readVec3(CATEGORY[portrait ? "camera_position_p" : "camera_position_l"]);
|
||||
const rotation = this.readVec3(CATEGORY[portrait ? "camera_rotation_p" : "camera_rotation_l"]);
|
||||
|
||||
this.offset.copy(offset ?? this.fallbackOffset);
|
||||
|
||||
if (rotation) {
|
||||
this.rotationDeg.copy(rotation);
|
||||
this.applyRotation();
|
||||
} else {
|
||||
// No usable angle configured — fall back to the old behaviour of
|
||||
// deriving one that frames the player from the current offset, so a
|
||||
// broken/absent config degrades to "looks right" rather than to the
|
||||
// empty-sky shot an untuned angle used to give.
|
||||
this.frameTargetAndStoreRotation();
|
||||
}
|
||||
}
|
||||
|
||||
private static readVec3(value: unknown): Vector3 | null {
|
||||
if (!Array.isArray(value) || value.length < 3) return null;
|
||||
const [x, y, z] = value.map(Number);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return null;
|
||||
return new Vector3(x, y, z);
|
||||
}
|
||||
|
||||
private static applyRotation() {
|
||||
CameraC_internal.getCamera().rotation.set(
|
||||
MathUtils.degToRad(this.rotationDeg.x),
|
||||
MathUtils.degToRad(this.rotationDeg.y),
|
||||
MathUtils.degToRad(this.rotationDeg.z)
|
||||
);
|
||||
}
|
||||
|
||||
// Points the camera at the target from the current offset and records the
|
||||
// resulting angle, so rotationDeg never goes stale relative to what's
|
||||
// actually on screen.
|
||||
private static frameTargetAndStoreRotation() {
|
||||
if (!this.target) return;
|
||||
|
||||
const camera = CameraC_internal.getCamera();
|
||||
camera.position.copy(this.target.position).add(this.offset).add(this.smoothedLookAhead);
|
||||
camera.lookAt(this.target.position);
|
||||
this.rotationDeg.set(
|
||||
MathUtils.radToDeg(camera.rotation.x),
|
||||
MathUtils.radToDeg(camera.rotation.y),
|
||||
MathUtils.radToDeg(camera.rotation.z)
|
||||
);
|
||||
}
|
||||
|
||||
// Exposed for the debug config panel (see DebugConfigC, press "C") to
|
||||
// tweak the rig live — re-applies position+lookAt immediately, the same
|
||||
// one-time geometry setup init() does above, just callable again on
|
||||
// demand instead of only once. Doesn't reintroduce the per-frame
|
||||
// lookAt/orbiting problem documented in init() and update() below: this
|
||||
// only fires when a human explicitly drags a debug slider, never on its
|
||||
// own every frame.
|
||||
static setOffset(offset: Vector3) {
|
||||
this.offset.copy(offset);
|
||||
// Re-derives the angle so dragging distance/angle keeps the player
|
||||
// framed, and keeps rotationDeg matching what's on screen. Editing the
|
||||
// platform's own rotation fields goes through applyRigFromConfig
|
||||
// instead and wins the next time either is touched — the debug panel is
|
||||
// a local comparison tool, not a second source of truth.
|
||||
this.frameTargetAndStoreRotation();
|
||||
}
|
||||
|
||||
// Debug-panel-facing convenience over setOffset: angle is the pitch
|
||||
// looking down at the target (0deg = level with it, 90deg = straight
|
||||
// down), distance is how far back along that same line of sight — the
|
||||
// two numbers a person actually wants to drag, rather than raw X/Y/Z.
|
||||
// X is always 0 in this project's offset convention (no yaw/roll, see
|
||||
// TestSceneC's CAMERA_OFFSET), so this only reconstructs Y/Z.
|
||||
static setAngleAndDistance(angleDeg: number, distance: number) {
|
||||
const angleRad = (angleDeg * Math.PI) / 180;
|
||||
this.setOffset(new Vector3(0, distance * Math.sin(angleRad), -distance * Math.cos(angleRad)));
|
||||
}
|
||||
|
||||
// Lets the look-ahead react to the character locking onto a nearby
|
||||
// breakable while stopped (see PlayerC.getFacingTarget) — that rotation
|
||||
// carries zero movement, so it would otherwise be invisible to the
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Vector3 } from "three";
|
||||
import GUI from "lil-gui";
|
||||
import { Template } from "@hitplay/playable_template";
|
||||
import { CameraFollowC } from "./CameraFollowC";
|
||||
import { ZombieInvasionHudC, TIMER_DURATION_S } from "./ZombieInvasionHudC";
|
||||
|
||||
export type DamageNumberStyle = "punch" | "float";
|
||||
|
||||
// Press this to show/hide the panel — never visible by default, and never
|
||||
// part of #ui/#ui-safe-area (lil-gui mounts its own root straight onto
|
||||
// document.body), so it can't show up in an actual export/playable build,
|
||||
// only while someone is actively debugging with devtools/a live preview.
|
||||
const TOGGLE_KEY = "c";
|
||||
|
||||
// Last-resort rig if the platform config can't be read at all — the panel
|
||||
// seeds from Scene settings instead (see readCameraRigFromConfig), the same
|
||||
// source CameraFollowC uses, so the two can't drift apart.
|
||||
const FALLBACK_CAMERA_OFFSET = new Vector3(0, 8, -6);
|
||||
|
||||
// The camera rig as the panel prefers to express it: pitch down at the
|
||||
// player, and distance back along that line of sight — the two numbers
|
||||
// worth dragging, versus raw X/Y/Z. X is 0 in this project's offset
|
||||
// convention (no yaw/roll in the rig itself), so only Y/Z are involved.
|
||||
function readCameraRigFromConfig(): { angleDeg: number; distance: number } {
|
||||
const CATEGORY = Template.getCategory("global");
|
||||
const raw = CATEGORY[window.screenSize.portrait ? "camera_position_p" : "camera_position_l"];
|
||||
const offset =
|
||||
Array.isArray(raw) && raw.length >= 3 && raw.every((n) => Number.isFinite(Number(n)))
|
||||
? new Vector3(Number(raw[0]), Number(raw[1]), Number(raw[2]))
|
||||
: FALLBACK_CAMERA_OFFSET;
|
||||
|
||||
return {
|
||||
angleDeg: (Math.atan2(offset.y, -offset.z) * 180) / Math.PI,
|
||||
distance: Math.hypot(offset.y, offset.z),
|
||||
};
|
||||
}
|
||||
|
||||
// Internal dev-only toggle panel for comparing animation/logic variants
|
||||
// with the team before committing to one — not part of the game's real
|
||||
// UI. Add new controls here as more variants need comparing; this file
|
||||
// only owns the panel itself and its current values, not what each
|
||||
// controller does with them.
|
||||
export class DebugConfigC {
|
||||
private static gui: GUI | null = null;
|
||||
private static visible = false;
|
||||
private static state = {
|
||||
damageNumberStyle: "punch" as DamageNumberStyle,
|
||||
hitDamage: 10,
|
||||
rippleEffectEnabled: true,
|
||||
cameraAngleDeg: 0,
|
||||
cameraDistance: 0,
|
||||
zombieInvasionTimerS: TIMER_DURATION_S,
|
||||
};
|
||||
|
||||
static init() {
|
||||
if (this.gui) return;
|
||||
|
||||
// Seeds the panel's starting values from the platform's own
|
||||
// configUIParams (see configUIParams/gameplay.ts) — so this shows
|
||||
// whatever's actually configured for the real build, not a separate
|
||||
// hardcoded default that could silently drift from it. Only read
|
||||
// once, at startup, not kept live-synced afterward — a producer
|
||||
// editing these on the platform and a dev comparing variants via this
|
||||
// panel are two separate workflows that aren't expected to run at the
|
||||
// same time.
|
||||
const CATEGORY = Template.getCategory("gameplay");
|
||||
if (CATEGORY["hit_damage"] !== undefined) this.state.hitDamage = Number(CATEGORY["hit_damage"]);
|
||||
if (CATEGORY["damage_number_style"] !== undefined) {
|
||||
this.state.damageNumberStyle = CATEGORY["damage_number_style"] as DamageNumberStyle;
|
||||
}
|
||||
if (CATEGORY["pay_zone_ripple_enabled"] !== undefined) {
|
||||
this.state.rippleEffectEnabled = Boolean(CATEGORY["pay_zone_ripple_enabled"]);
|
||||
}
|
||||
if (CATEGORY["zombie_invasion_timer_s"] !== undefined) {
|
||||
this.state.zombieInvasionTimerS = Number(CATEGORY["zombie_invasion_timer_s"]);
|
||||
}
|
||||
|
||||
// Camera rig comes from the same Scene settings CameraFollowC reads, so
|
||||
// the panel opens showing the real current rig rather than a duplicated
|
||||
// constant that could silently drift from it.
|
||||
const rig = readCameraRigFromConfig();
|
||||
this.state.cameraAngleDeg = rig.angleDeg;
|
||||
this.state.cameraDistance = rig.distance;
|
||||
|
||||
this.gui = new GUI({ title: `Debug config (press "${TOGGLE_KEY.toUpperCase()}" to hide)` });
|
||||
this.gui
|
||||
.add(this.state, "damageNumberStyle", { "Punch (default)": "punch", "Float + fade": "float" })
|
||||
.name("Damage number animation");
|
||||
// Plain number input, not a min/max slider (lil-gui only renders the
|
||||
// two-handle slider once BOTH min and max are set) — min(0) alone just
|
||||
// clamps typed/dragged values without changing the control's look.
|
||||
this.gui.add(this.state, "hitDamage").name("Damage per hit").min(0).step(1);
|
||||
this.gui.add(this.state, "rippleEffectEnabled").name("Pay Zone landing ripple");
|
||||
|
||||
// Live camera rig — angle (pitch looking down at the player) and
|
||||
// distance along that same line of sight, not raw X/Y/Z (see
|
||||
// CameraFollowC.setAngleAndDistance for why those two are what's
|
||||
// actually worth dragging). onChange (not onFinishChange) so the
|
||||
// camera actually moves while dragging, not just once released.
|
||||
this.gui
|
||||
.add(this.state, "cameraAngleDeg", 0, 90, 0.5)
|
||||
.name("Camera angle (deg)")
|
||||
.onChange(() => this.applyCameraRig());
|
||||
this.gui
|
||||
.add(this.state, "cameraDistance", 2, 25, 0.5)
|
||||
.name("Camera distance")
|
||||
.onChange(() => this.applyCameraRig());
|
||||
|
||||
// onFinishChange, not onChange — restarting the CSS transition on
|
||||
// every intermediate drag tick would look chaotic; only re-run it once
|
||||
// the number is actually settled.
|
||||
this.gui
|
||||
.add(this.state, "zombieInvasionTimerS", 5, 300, 1)
|
||||
.name("Zombie Invasion timer (s)")
|
||||
.onFinishChange((value: number) => ZombieInvasionHudC.restartTimer(value));
|
||||
|
||||
this.gui.hide();
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key.toLowerCase() === TOGGLE_KEY) this.toggle();
|
||||
});
|
||||
}
|
||||
|
||||
static getDamageNumberStyle(): DamageNumberStyle {
|
||||
return this.state.damageNumberStyle;
|
||||
}
|
||||
|
||||
static getHitDamage(): number {
|
||||
return this.state.hitDamage;
|
||||
}
|
||||
|
||||
static isRippleEffectEnabled(): boolean {
|
||||
return this.state.rippleEffectEnabled;
|
||||
}
|
||||
|
||||
// Not read reactively from ZombieInvasionHudC itself (that would cycle —
|
||||
// this file already imports ZombieInvasionHudC for restartTimer/
|
||||
// TIMER_DURATION_S) — TestSceneC reads this once, at startup, and passes
|
||||
// it into ZombieInvasionHudC.init() directly, same DI pattern already
|
||||
// used elsewhere in this project to sidestep import cycles (e.g.
|
||||
// ResourceC.setFlyTarget).
|
||||
static getZombieInvasionTimerS(): number {
|
||||
return this.state.zombieInvasionTimerS;
|
||||
}
|
||||
|
||||
private static applyCameraRig() {
|
||||
CameraFollowC.setAngleAndDistance(this.state.cameraAngleDeg, this.state.cameraDistance);
|
||||
}
|
||||
|
||||
private static toggle() {
|
||||
this.visible = !this.visible;
|
||||
if (this.visible) this.gui?.show();
|
||||
else this.gui?.hide();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import type { BreakableProp } from "./BreakablePropC";
|
||||
import { projectWorldPointToScreen } from "../utils/screenWorldProjection";
|
||||
import { DebugConfigC } from "./DebugConfigC";
|
||||
|
||||
// If a bar's crate stops getting hit and the player walks away before it's
|
||||
// destroyed, the bar lingers this long before fading out — reset to 0 on
|
||||
@@ -14,14 +15,18 @@ const FADE_OUT_MS = 250;
|
||||
// guessed pixel offset — see getOrCreateBar) the bar sits.
|
||||
const BAR_HEIGHT_OFFSET = 0.2;
|
||||
|
||||
// Kept in sync with health-bar.css's health-bar-damage-punch animation
|
||||
// duration, so the popup element is removed right as the CSS animation
|
||||
// finishes, not before/after.
|
||||
const DAMAGE_POPUP_LIFETIME_MS = 920;
|
||||
// Kept in sync with health-bar.css's health-bar-damage-punch/-float
|
||||
// animation durations, so the popup element is removed right as whichever
|
||||
// CSS animation is active finishes, not before/after (see
|
||||
// DebugConfigC.getDamageNumberStyle — the debug panel's damage-number
|
||||
// switch).
|
||||
const DAMAGE_POPUP_LIFETIME_MS = 490;
|
||||
const DAMAGE_POPUP_FLOAT_LIFETIME_MS = 700;
|
||||
|
||||
// Random per-popup punch-rotation range (degrees) — see spawnDamagePopup.
|
||||
// Always clockwise from the 12 o'clock resting angle (0deg), never past the
|
||||
// 2 o'clock mark (2 * 30deg/hour = 60deg).
|
||||
// Magnitude never past the 2 o'clock mark (2 * 30deg/hour = 60deg); the
|
||||
// sign (clockwise vs counter-clockwise) is randomized per popup, same
|
||||
// magnitude range on both sides.
|
||||
const DAMAGE_ROTATE_MIN_DEG = 10;
|
||||
const DAMAGE_ROTATE_MAX_DEG = 50;
|
||||
|
||||
@@ -37,6 +42,12 @@ interface BarInstance {
|
||||
idleTime: number;
|
||||
hiding: boolean;
|
||||
removeTimeout: ReturnType<typeof setTimeout> | null;
|
||||
// The currently-showing damage popup (if any) and its pending removal
|
||||
// timeout — tracked so a new hit landing before the old popup finishes
|
||||
// (e.g. the attack's two hits per swing, close together) can remove it
|
||||
// immediately instead of the two overlapping on screen.
|
||||
damagePopup: HTMLElement | null;
|
||||
damagePopupTimeout: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
// World-tracking DOM health bars for breakable crates: a bordered
|
||||
@@ -160,9 +171,19 @@ export class HealthBarC {
|
||||
idleTime: 0,
|
||||
hiding: false,
|
||||
removeTimeout: null,
|
||||
damagePopup: null,
|
||||
damagePopupTimeout: null,
|
||||
};
|
||||
|
||||
this.bars.set(prop, bar);
|
||||
|
||||
// Positioned synchronously right away — otherwise the container sits at
|
||||
// CSS's default position:fixed placement (top-left of the viewport,
|
||||
// since .health-bar sets no left/top) until the next update() tick, and
|
||||
// flashes there for a frame before jumping to its real spot above the
|
||||
// crate.
|
||||
this.positionBar(bar, CameraC_internal.getCamera(), ThreeC.renderer.domElement.getBoundingClientRect());
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
@@ -190,15 +211,49 @@ export class HealthBarC {
|
||||
}
|
||||
|
||||
private static spawnDamagePopup(bar: BarInstance, damage: number) {
|
||||
// A new hit landing before the previous popup finished (e.g. the
|
||||
// attack's two hits per swing, close together) replaces it immediately
|
||||
// instead of the two overlapping on screen.
|
||||
if (bar.damagePopup) {
|
||||
if (bar.damagePopupTimeout !== null) clearTimeout(bar.damagePopupTimeout);
|
||||
bar.damagePopup.remove();
|
||||
}
|
||||
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "health-bar__damage";
|
||||
popup.textContent = `-${damage}`;
|
||||
|
||||
// Text lives in its own inline-block child (.gamefont-x, see
|
||||
// main.css) so the global font-width squeeze doesn't fight this
|
||||
// element's own animated transform (health-bar-damage-punch owns
|
||||
// .health-bar__damage's `transform` completely, every keyframe).
|
||||
const popupText = document.createElement("span");
|
||||
popupText.className = "gamefont-x";
|
||||
popupText.textContent = `-${damage}`;
|
||||
popup.appendChild(popupText);
|
||||
|
||||
// Debug-panel switch (see DebugConfigC, press "C") between the default
|
||||
// punch animation and a plain float-up-and-fade — for comparing the
|
||||
// two with the team, not a real gameplay option.
|
||||
const isFloatStyle = DebugConfigC.getDamageNumberStyle() === "float";
|
||||
popup.classList.toggle("style-float", isFloatStyle);
|
||||
|
||||
// Random per-popup punch angle so every hit reads differently — the
|
||||
// CSS keyframes just read var(--damage-rotate). Always clockwise,
|
||||
// never negative/left.
|
||||
const rotateDeg = DAMAGE_ROTATE_MIN_DEG + Math.random() * (DAMAGE_ROTATE_MAX_DEG - DAMAGE_ROTATE_MIN_DEG);
|
||||
popup.style.setProperty("--damage-rotate", `${rotateDeg}deg`);
|
||||
// CSS keyframes just read var(--damage-rotate). Sign (clockwise vs
|
||||
// counter-clockwise) is random too, same magnitude range either way.
|
||||
// Unused by the float style, harmless to always set.
|
||||
const magnitude = DAMAGE_ROTATE_MIN_DEG + Math.random() * (DAMAGE_ROTATE_MAX_DEG - DAMAGE_ROTATE_MIN_DEG);
|
||||
const sign = Math.random() < 0.5 ? 1 : -1;
|
||||
popup.style.setProperty("--damage-rotate", `${sign * magnitude}deg`);
|
||||
bar.container.appendChild(popup);
|
||||
setTimeout(() => popup.remove(), DAMAGE_POPUP_LIFETIME_MS);
|
||||
|
||||
bar.damagePopup = popup;
|
||||
bar.damagePopupTimeout = setTimeout(
|
||||
() => {
|
||||
popup.remove();
|
||||
bar.damagePopup = null;
|
||||
bar.damagePopupTimeout = null;
|
||||
},
|
||||
isFloatStyle ? DAMAGE_POPUP_FLOAT_LIFETIME_MS : DAMAGE_POPUP_LIFETIME_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-4
@@ -18,7 +18,7 @@ const ANCHOR_DISTANCE = 2.5;
|
||||
export class HudC {
|
||||
private static initialized = false;
|
||||
private static plateEl: HTMLElement;
|
||||
private static countEl: HTMLElement;
|
||||
private static countTextEl: HTMLElement;
|
||||
private static lastDisplayedCount = -1;
|
||||
private static balanceGetter: (() => number) | null = null;
|
||||
|
||||
@@ -77,7 +77,14 @@ export class HudC {
|
||||
|
||||
const count = document.createElement("span");
|
||||
count.className = "resource-hud__count";
|
||||
count.textContent = "0";
|
||||
|
||||
// Text lives in its own inline-block child (.gamefont-x, see main.css)
|
||||
// so the global font-width squeeze doesn't need to touch this
|
||||
// element's own layout/positioning.
|
||||
const countText = document.createElement("span");
|
||||
countText.className = "gamefont-x";
|
||||
countText.textContent = "0";
|
||||
count.appendChild(countText);
|
||||
|
||||
plate.appendChild(count);
|
||||
holder.appendChild(plate);
|
||||
@@ -86,7 +93,7 @@ export class HudC {
|
||||
uiRoot.appendChild(holder);
|
||||
|
||||
this.plateEl = plate;
|
||||
this.countEl = count;
|
||||
this.countTextEl = countText;
|
||||
}
|
||||
|
||||
private static update() {
|
||||
@@ -94,7 +101,7 @@ export class HudC {
|
||||
const count = this.balanceGetter();
|
||||
if (count === this.lastDisplayedCount) return;
|
||||
this.lastDisplayedCount = count;
|
||||
this.countEl.textContent = String(count);
|
||||
this.countTextEl.textContent = String(count);
|
||||
|
||||
// The actual "pop" is a CSS animation on the whole plate (.is-bumping,
|
||||
// see ui.css), not just the digit — this only toggles the class,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Box3, Mesh, MeshBasicMaterial, Object3D, PlaneGeometry, Vector3 } from "three";
|
||||
import { Box3, Mesh, MeshBasicMaterial, Object3D, PlaneGeometry, RingGeometry, Vector3 } from "three";
|
||||
import { CameraC_internal, TweenC, UpdateController } from "@hitplay/playable_template";
|
||||
import { Easing, Tween } from "@tweenjs/tween.js";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import { RESOURCE_ICON_BASE_SCALE, ResourceC } from "./ResourceC";
|
||||
import { PlayerC } from "./PlayerC";
|
||||
import { perspectiveCompensationScale } from "../utils/perspectiveScale";
|
||||
import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial";
|
||||
import { DebugConfigC } from "./DebugConfigC";
|
||||
|
||||
// How many resources need to actually make it into the zone before it
|
||||
// disappears.
|
||||
@@ -12,15 +14,15 @@ const RESOURCES_TO_CLOSE_ZONE = 20;
|
||||
|
||||
// While standing in the zone with resources still to deliver, one flies in
|
||||
// every DEPOSIT_INTERVAL seconds — a steady drain rather than an instant dump.
|
||||
const DEPOSIT_INTERVAL = 0.3;
|
||||
const DEPOSIT_FLIGHT_DURATION_MS = 500;
|
||||
const DEPOSIT_INTERVAL = 0.12;
|
||||
const DEPOSIT_FLIGHT_DURATION_MS = 280;
|
||||
// Redundant safety-net cleanup: the deposit tween occasionally never fires
|
||||
// onComplete and freezes mid-flight — cause not pinned down (not the usual
|
||||
// chain()-registration gotcha, this tween isn't chained). This plain
|
||||
// timeout removes the mesh regardless. Calling removeFromScene twice (if
|
||||
// onComplete also fires) is harmless — the second call just finds no
|
||||
// parent to detach from.
|
||||
const DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS = 300;
|
||||
const DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS = 200;
|
||||
|
||||
const PAY_ZONE_SHRINK_MS = 400;
|
||||
|
||||
@@ -38,6 +40,22 @@ const DEPOSIT_FLIGHT_SCALE_MULTIPLIER = 1.3;
|
||||
// of the ordinary from->to lerp.
|
||||
const DEPOSIT_FLIGHT_ARC_HEIGHT = 2;
|
||||
|
||||
// Landing ripple: a thin ring that pops in at the token's arrival point and
|
||||
// expands while fading out, like a drop landing in water — feedback for
|
||||
// the flight's end instead of the token just cutting out at ground level.
|
||||
// Deliberately understated (modest peak opacity, no color tint beyond the
|
||||
// same white-additive flash convention used elsewhere) rather than a loud
|
||||
// splash.
|
||||
const RIPPLE_START_SCALE = 0.25;
|
||||
const RIPPLE_END_SCALE = 1.6;
|
||||
const RIPPLE_DURATION_MS = 550;
|
||||
const RIPPLE_MAX_OPACITY = 0.35;
|
||||
const RIPPLE_Y_OFFSET = 0.03; // just above the ground, avoids z-fighting
|
||||
// A unit ring (radius 1), scaled per-instance above — built once and
|
||||
// shared read-only across every ripple, same pattern as SparkleFxC's
|
||||
// shared star geometry.
|
||||
const rippleGeometry = new RingGeometry(0.55, 1, 48);
|
||||
|
||||
// Fill overlay: a flat quad over the zone's own footprint, growing from its
|
||||
// near edge (fixed, closest to camera) toward the far edge as deposits come
|
||||
// in. Grows along world Z, not X or Y — under this camera, +Z (forward/
|
||||
@@ -185,7 +203,12 @@ export class PayZoneC {
|
||||
const perspectiveScale = perspectiveCompensationScale(cameraPosition, mesh.position, startDistance);
|
||||
mesh.scale.setScalar(perspectiveScale * RESOURCE_ICON_BASE_SCALE * DEPOSIT_FLIGHT_SCALE_MULTIPLIER);
|
||||
})
|
||||
.onComplete(() => ThreeC.removeFromScene(mesh));
|
||||
.onComplete(() => {
|
||||
ThreeC.removeFromScene(mesh);
|
||||
// Debug-panel switch (see DebugConfigC, press "C") for comparing
|
||||
// with/without the ripple — not a real gameplay option.
|
||||
if (DebugConfigC.isRippleEffectEnabled()) this.playLandingRipple(to);
|
||||
});
|
||||
|
||||
TweenC.add(flight);
|
||||
flight.start();
|
||||
@@ -193,6 +216,29 @@ export class PayZoneC {
|
||||
setTimeout(() => ThreeC.removeFromScene(mesh), DEPOSIT_FLIGHT_DURATION_MS + DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS);
|
||||
}
|
||||
|
||||
private static playLandingRipple(position: Vector3) {
|
||||
const mesh = new Mesh(rippleGeometry, createFlashOverlayMaterial());
|
||||
mesh.rotation.x = -Math.PI / 2; // lie flat on the ground, facing up
|
||||
mesh.position.copy(position);
|
||||
mesh.position.y += RIPPLE_Y_OFFSET;
|
||||
mesh.scale.setScalar(RIPPLE_START_SCALE);
|
||||
ThreeC.addToScene(mesh);
|
||||
|
||||
const material = mesh.material as MeshBasicMaterial;
|
||||
|
||||
const ripple = new Tween({ t: 0 })
|
||||
.to({ t: 1 }, RIPPLE_DURATION_MS)
|
||||
.easing(Easing.Quadratic.Out)
|
||||
.onUpdate(({ t }) => {
|
||||
mesh.scale.setScalar(RIPPLE_START_SCALE + (RIPPLE_END_SCALE - RIPPLE_START_SCALE) * t);
|
||||
material.opacity = RIPPLE_MAX_OPACITY * (1 - t);
|
||||
})
|
||||
.onComplete(() => ThreeC.removeFromScene(mesh));
|
||||
|
||||
TweenC.add(ripple);
|
||||
ripple.start();
|
||||
}
|
||||
|
||||
// Shrinks the zone's own marker AND the fill overlay together, as one
|
||||
// piece, toward their own centers in lockstep.
|
||||
private static playDisappear(node: Object3D) {
|
||||
|
||||
@@ -13,14 +13,22 @@ import { PropVfxC } from "./PropVfxC";
|
||||
import { WeaponTrailC } from "./WeaponTrailC";
|
||||
import { SparkleFxC } from "./SparkleFxC";
|
||||
import { HealthBarC } from "./HealthBarC";
|
||||
import { DebugConfigC } from "./DebugConfigC";
|
||||
|
||||
// Above and behind the player, along the road's forward (+Z) direction —
|
||||
// player now spawns at the other end of the road, walking toward +Z, so
|
||||
// "behind" flipped from +Z to -Z to keep looking the same way they walk.
|
||||
const CAMERA_OFFSET = new Vector3(0, 8, -6);
|
||||
//
|
||||
// This is now only the FALLBACK rig: the live one comes from the platform's
|
||||
// Scene settings (camera_position_p/_l, see CameraFollowC.applyRigFromConfig),
|
||||
// and these numbers are also what those fields default to. It stays here so
|
||||
// a missing or malformed config still yields a framed shot rather than a
|
||||
// black screen.
|
||||
export const CAMERA_OFFSET = new Vector3(0, 8, -6);
|
||||
|
||||
export class TestSceneC {
|
||||
static init() {
|
||||
DebugConfigC.init();
|
||||
PropVfxC.init();
|
||||
SparkleFxC.init();
|
||||
HealthBarC.init();
|
||||
@@ -60,9 +68,12 @@ export class TestSceneC {
|
||||
WeaponHudC.init();
|
||||
|
||||
// Top-center "ZOMBIE INVASION" timer bar — also fully independent,
|
||||
// starts its own 2-minute slide immediately on init (see
|
||||
// ZombieInvasionHudC).
|
||||
ZombieInvasionHudC.init();
|
||||
// starts its own timer slide immediately on init (see
|
||||
// ZombieInvasionHudC). Duration comes from DebugConfigC (itself seeded
|
||||
// from the platform's own configUIParams, see gameplay.ts) rather than
|
||||
// ZombieInvasionHudC's own hardcoded default, so a platform-configured
|
||||
// value actually takes effect in a real build.
|
||||
ZombieInvasionHudC.init(DebugConfigC.getZombieInvasionTimerS());
|
||||
}
|
||||
|
||||
private static createMap() {
|
||||
|
||||
@@ -33,7 +33,14 @@ export class WeaponHudC {
|
||||
|
||||
const level = document.createElement("span");
|
||||
level.className = "weapon-hud__level";
|
||||
level.textContent = "LVL 0";
|
||||
|
||||
// Text lives in its own inline-block child (.gamefont-x, see
|
||||
// main.css) so the global font-width squeeze doesn't need to touch
|
||||
// this element's own layout/positioning.
|
||||
const levelText = document.createElement("span");
|
||||
levelText.className = "gamefont-x";
|
||||
levelText.textContent = "LVL 0";
|
||||
level.appendChild(levelText);
|
||||
|
||||
holder.appendChild(icon);
|
||||
holder.appendChild(level);
|
||||
|
||||
@@ -3,13 +3,15 @@ import { zombieHeadIconSrc } from "../resources/images/images";
|
||||
// How long the head takes to slide from the bar's right edge to its left
|
||||
// edge ("full" happens when it arrives). Single source of truth: set as
|
||||
// the actual CSS transition-duration from here (see startTimer), not
|
||||
// duplicated as a magic number in ui.css.
|
||||
const TIMER_DURATION_S = 120;
|
||||
// duplicated as a magic number in ui.css. Exported so DebugConfigC's timer
|
||||
// slider (see restartTimer) starts at this same default instead of
|
||||
// duplicating the number.
|
||||
export const TIMER_DURATION_S = 120;
|
||||
|
||||
// Last stretch of the timer where the bar starts pulsing + flashing red to
|
||||
// warn the player it's about to fill. 0.05 = last 5%, i.e. the last 6s of
|
||||
// warn the player it's about to fill. 0.2 = last 20%, i.e. the last 24s of
|
||||
// a 120s timer.
|
||||
const WARNING_FRACTION = 0.05;
|
||||
const WARNING_FRACTION = 0.2;
|
||||
|
||||
// Top-center "ZOMBIE INVASION" timer bar (title + a zombie-head marker
|
||||
// sliding right-to-left over TIMER_DURATION_S). No bar/track art exists —
|
||||
@@ -25,12 +27,38 @@ export class ZombieInvasionHudC {
|
||||
private static fillEl: HTMLElement;
|
||||
private static flashEl: HTMLElement;
|
||||
private static headEl: HTMLElement;
|
||||
private static warningTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
static init() {
|
||||
// durationS defaults to TIMER_DURATION_S but is normally passed in
|
||||
// explicitly by TestSceneC, sourced from DebugConfigC.getZombieInvasionTimerS()
|
||||
// (itself seeded from the platform's own configUIParams) — not read from
|
||||
// DebugConfigC directly in here, since DebugConfigC already imports this
|
||||
// file for restartTimer/TIMER_DURATION_S and importing back would cycle.
|
||||
static init(durationS: number = TIMER_DURATION_S) {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
this.buildDom();
|
||||
this.startTimer();
|
||||
this.startTimer(durationS);
|
||||
}
|
||||
|
||||
// Exposed for the debug config panel (see DebugConfigC, press "C") to
|
||||
// re-run the timer at a different duration for live comparison — snaps
|
||||
// the head/fill back to their starting position (duration forced to 0s
|
||||
// for that one frame) and clears any pending warning-flash timeout
|
||||
// before re-triggering the CSS transition, same as the very first call
|
||||
// from init() above.
|
||||
static restartTimer(durationS: number) {
|
||||
if (this.warningTimeout !== null) clearTimeout(this.warningTimeout);
|
||||
this.barEl.classList.remove("is-almost-full");
|
||||
this.flashEl.classList.remove("is-almost-full");
|
||||
|
||||
this.headEl.style.transitionDuration = "0s";
|
||||
this.fillEl.style.transitionDuration = "0s";
|
||||
this.headEl.style.left = "100%";
|
||||
this.fillEl.style.width = "0%";
|
||||
void this.headEl.offsetWidth; // force reflow: commit the instant snap-back before startTimer re-enables the transition
|
||||
|
||||
this.startTimer(durationS);
|
||||
}
|
||||
|
||||
// .zombie-invasion__track wraps fill+flash in its own overflow:hidden
|
||||
@@ -43,7 +71,14 @@ export class ZombieInvasionHudC {
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "zombie-invasion__title";
|
||||
title.textContent = "ZOMBIE INVASION";
|
||||
|
||||
// Text lives in its own inline-block child (.gamefont-x, see
|
||||
// main.css) so the global font-width squeeze doesn't need to touch
|
||||
// this element's own layout/positioning.
|
||||
const titleText = document.createElement("span");
|
||||
titleText.className = "gamefont-x";
|
||||
titleText.textContent = "ZOMBIE INVASION";
|
||||
title.appendChild(titleText);
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "zombie-invasion__bar";
|
||||
@@ -84,15 +119,15 @@ export class ZombieInvasionHudC {
|
||||
// the head with no per-frame sync needed. Reading offsetWidth forces a
|
||||
// reflow between setting transitionDuration and the target values —
|
||||
// without it the browser would jump straight there instead of animating.
|
||||
private static startTimer() {
|
||||
this.headEl.style.transitionDuration = `${TIMER_DURATION_S}s`;
|
||||
this.fillEl.style.transitionDuration = `${TIMER_DURATION_S}s`;
|
||||
private static startTimer(durationS: number) {
|
||||
this.headEl.style.transitionDuration = `${durationS}s`;
|
||||
this.fillEl.style.transitionDuration = `${durationS}s`;
|
||||
void this.headEl.offsetWidth;
|
||||
this.headEl.style.left = "0%";
|
||||
this.fillEl.style.width = "100%";
|
||||
|
||||
const warningDelayMs = TIMER_DURATION_S * 1000 * (1 - WARNING_FRACTION);
|
||||
setTimeout(() => {
|
||||
const warningDelayMs = durationS * 1000 * (1 - WARNING_FRACTION);
|
||||
this.warningTimeout = setTimeout(() => {
|
||||
this.barEl.classList.add("is-almost-full");
|
||||
this.flashEl.classList.add("is-almost-full");
|
||||
}, warningDelayMs);
|
||||
|
||||
+57
-22
@@ -15,7 +15,18 @@
|
||||
position: fixed;
|
||||
transform: translate(-20%, 200%);
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
/* ⚠️ Found and fixed: this used to be z-index:50, the SAME as
|
||||
.resource-hud/.weapon-hud/.zombie-invasion (see ui.css) — with equal
|
||||
z-index, stacking falls back to DOM order, and health-bar elements
|
||||
are only created lazily on a crate's first hit (getOrCreateBar),
|
||||
appended to #ui-safe-area well AFTER those three widgets (built once
|
||||
at scene init) — so a health bar landing over a HUD widget on screen
|
||||
always painted ON TOP of it. Lower than every HUD widget's z-index on
|
||||
purpose: the HUD widgets must always win that stacking fight. The
|
||||
only thing intentionally allowed to render over the UI is a
|
||||
collected resource's own flight — a 3D mesh on the WebGL canvas, a
|
||||
completely separate layer from this DOM overlay, unaffected by this. */
|
||||
z-index: 40;
|
||||
/* Width and height are separate variables — border/padding/radius below
|
||||
are all derived from the HEIGHT variable, not width, so the bezel
|
||||
doesn't get chunkier as the bar stretches wider. */
|
||||
@@ -82,45 +93,46 @@
|
||||
bottom: calc(100% + var(--health-bar-height) * 1.5);
|
||||
color: #ffffff;
|
||||
font-family: gameFont, sans-serif;
|
||||
font-size: calc(var(--health-bar-width) * 0.288);
|
||||
/* +15% over the previous 0.288, for more contrast/visibility. */
|
||||
font-size: calc(var(--health-bar-width) * 0.331);
|
||||
/* Real stroke (not just a glow) — a black outline around an opaque
|
||||
number reads as "solid" the instant it appears, no fade-in needed.
|
||||
Same -webkit-text-stroke technique already used on .bannerButtonText.
|
||||
Scaled with font-size so the border stays the same proportion of the
|
||||
glyph. */
|
||||
-webkit-text-stroke: calc(var(--health-bar-width) * 0.024) #000000;
|
||||
+35% over the previous 0.024 for a more pronounced border, on top of
|
||||
already scaling with the font-size bump above. */
|
||||
-webkit-text-stroke: calc(var(--health-bar-width) * 0.0324 * var(--game-font-stroke-width)) #000000;
|
||||
paint-order: stroke fill;
|
||||
white-space: nowrap;
|
||||
transform-origin: 50% 100%;
|
||||
/* Fast start, slow finish across the WHOLE animation — an expo-out
|
||||
curve, so the punch snaps out instantly and the fade-out eases off
|
||||
gently instead of both phases reading the same speed. */
|
||||
animation: health-bar-damage-punch 920ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
gently instead of both phases reading the same speed. Total duration
|
||||
shortened from 920ms to 490ms (see chat: "числа швидше зникають") —
|
||||
the punch (0-28%) and settle (28-60%) keep the SAME absolute
|
||||
millisecond timing as before (138ms/156ms), only the fade at the end
|
||||
got shorter. */
|
||||
animation: health-bar-damage-punch 490ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
/* Sharp punch-then-fade: pops in already fully opaque (no fade-in — the
|
||||
appearance itself is the animation's start), snaps up to 1.5x scale
|
||||
with a rotation — the ANGLE comes from --damage-rotate, a per-popup
|
||||
random value set from JS (see HealthBarC.spawnDamagePopup), so every
|
||||
hit reads differently. Fully opaque at the 1.5x peak; the return to
|
||||
scale 1/rotate 0 and the fade-out run IN PARALLEL (opacity already
|
||||
dropping while the punch snaps back), finishing the fade a little after
|
||||
it's back at rest, not the instant it arrives. */
|
||||
/* Sharp punch, THEN fade — decoupled on purpose: pops in already fully
|
||||
opaque (no fade-in), snaps up to 1.5x scale with a rotation (the ANGLE
|
||||
comes from --damage-rotate, a per-popup random value set from JS, see
|
||||
HealthBarC.spawnDamagePopup, so every hit reads differently), settles
|
||||
back to scale 1/rotate 0 while STAYING fully opaque, and fades out
|
||||
immediately from there — no hold/hang at full opacity before the fade
|
||||
starts (see chat: "прибери взагалі зависання числа на одному місці в
|
||||
кінці"). */
|
||||
@keyframes health-bar-damage-punch {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1) rotate(0deg);
|
||||
}
|
||||
18% {
|
||||
28% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1.5) rotate(var(--damage-rotate, 60deg));
|
||||
}
|
||||
38% {
|
||||
opacity: 0.35;
|
||||
transform: translateX(-50%) scale(1) rotate(0deg);
|
||||
}
|
||||
58% {
|
||||
opacity: 0;
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
@@ -128,3 +140,26 @@
|
||||
transform: translateX(-50%) scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Alternate style, toggled from HealthBarC.spawnDamagePopup via the debug
|
||||
config panel (see DebugConfigC.ts, press "C") — plain float-up-and-fade,
|
||||
no punch/rotate, kept only for side-by-side comparison with the default
|
||||
above. Compound selector (two classes, not one) so it reliably beats
|
||||
.health-bar__damage's own animation regardless of source order. */
|
||||
.health-bar__damage.style-float {
|
||||
animation: health-bar-damage-float 700ms ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes health-bar-damage-float {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(calc(var(--health-bar-height) * -3));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,36 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Single dial for how horizontally/vertically compressed or stretched
|
||||
every gameFont text run looks — PassionOne-Black has no condensed
|
||||
variant/variable-width axis to select via `font-stretch`, so a real
|
||||
per-character resize has to come from scaling the rendered text
|
||||
itself (see .gamefont-x below). 1 = no change on that axis, lower =
|
||||
narrower/shorter, higher = wider/taller. Tune these two values only. */
|
||||
--game-font-stretch-x: 0.80;
|
||||
--game-font-stretch-y: 1.1;
|
||||
/* Multiplier on every gameFont-adjacent text's -webkit-text-stroke width
|
||||
across the project (zombie-invasion__title, health-bar__damage,
|
||||
bannerButtonText — see ui.css) — each already has its own base
|
||||
thickness calc()'d from its own widget's width variable, this just
|
||||
scales all of them together. 1 = current thickness, higher = bolder
|
||||
border, lower = thinner. */
|
||||
--game-font-stroke-width: 1.3;
|
||||
}
|
||||
|
||||
/* Wrap gameFont text CONTENT in this class (not the outer positioned/
|
||||
animated element around it) to resize character width/height without
|
||||
touching layout or fighting whatever transform/animation the outer
|
||||
element already has (e.g. health-bar__damage's punch keyframes, which
|
||||
own that element's `transform` completely). `transform` has no effect
|
||||
on plain inline boxes, hence inline-block. */
|
||||
.gamefont-x {
|
||||
display: inline-block;
|
||||
transform: scale(var(--game-font-stretch-x), var(--game-font-stretch-y));
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
|
||||
+4
-4
@@ -221,8 +221,8 @@
|
||||
|
||||
.weapon-hud__level {
|
||||
position: absolute;
|
||||
bottom: 6%;
|
||||
left: 7%;
|
||||
bottom: 8%;
|
||||
left: 2%;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
@@ -266,7 +266,7 @@
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
font-size: calc(var(--zombie-invasion-width) * 0.12);
|
||||
-webkit-text-stroke: calc(var(--zombie-invasion-width) * 0.004) #000000;
|
||||
-webkit-text-stroke: calc(var(--zombie-invasion-width) * 0.004 * var(--game-font-stroke-width)) #000000;
|
||||
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.6);
|
||||
margin: 0 0 calc(var(--zombie-invasion-width) * 0.045) 0;
|
||||
}
|
||||
@@ -404,6 +404,6 @@
|
||||
|
||||
.bannerButtonText {
|
||||
font-weight: bold;
|
||||
-webkit-text-stroke: 0.05em #000000;
|
||||
-webkit-text-stroke: calc(0.05em * var(--game-font-stroke-width)) #000000;
|
||||
text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
Binary file not shown.
+19
-2
@@ -1,3 +1,20 @@
|
||||
import { FontFamily, formFontFamily } from "@hitplay/ads_common";
|
||||
import { formFontFamily } from "@hitplay/ads_common";
|
||||
|
||||
export const customFont: undefined | Promise<FontFamily> = undefined
|
||||
// This exact shape — `export const customFont = formFontFamily(name, path)`
|
||||
// with two string-literal arguments — is required verbatim: @hitplay/ads_common's
|
||||
// export tooling (createFontFamilyByPath, and the "local" branch of
|
||||
// insertFontFamily) statically parses THIS FILE'S SOURCE TEXT via Babel, not
|
||||
// its runtime behavior, looking for precisely this pattern to register our
|
||||
// font with the platform/export pipeline. This was left as a bare
|
||||
// `undefined` stub — the platform never received any custom font, so
|
||||
// "font_custom_default" was always empty and gameFont never showed up in
|
||||
// the platform's own preview, even though our own main.css's separate
|
||||
// @font-face (an unrelated mechanism, see main.css) always rendered gameFont
|
||||
// correctly in a plain `npm run export`/`vite build`.
|
||||
//
|
||||
// Path is relative to THIS file's own directory (src/fonts/) per the tool's
|
||||
// convention — PassionOne-Black.otf is duplicated here from resources/fonts/
|
||||
// specifically for this; the two consumers (this file and main.css's
|
||||
// @font-face) use unrelated path-resolution rules, so one copy can't serve
|
||||
// both.
|
||||
export const customFont = formFontFamily("gameFont", "./PassionOne-Black.otf");
|
||||
|
||||
+12
-1
@@ -24,7 +24,18 @@ window.setupConfig = async function (config) {
|
||||
config: config || formConfigForPlayable(formConfigUI({
|
||||
type_default: Template.templateType,
|
||||
params_categories: formConfigUIParams(...configUIParams),
|
||||
font_custom_default: await customFont
|
||||
// formFontFamily's XHR fetch (see customFont.ts) has no timeout/error
|
||||
// handling of its own and would hang forever if the font file isn't
|
||||
// actually reachable relative to wherever this page is running — true
|
||||
// for this project's single-file export target, where nothing else is
|
||||
// served alongside the final HTML at all. Racing it against a short
|
||||
// timeout means a failed/unreachable fetch degrades to no custom font
|
||||
// instead of blocking Template.initConfig().init() — and so the whole
|
||||
// game — from ever running.
|
||||
font_custom_default: await Promise.race([
|
||||
customFont,
|
||||
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 2000))
|
||||
])
|
||||
})),
|
||||
resources,
|
||||
afterResourcesLoadedCb,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
InstallBanner,
|
||||
} from "@hitplay/playable_template";
|
||||
import { CameraC } from "../controllers/CameraC";
|
||||
import { CameraFollowC } from "../controllers/CameraFollowC";
|
||||
import { ThreeC } from "../controllers/ThreeC";
|
||||
import { Color } from "three";
|
||||
import { Vec3 } from "cannon-es";
|
||||
@@ -114,17 +115,19 @@ export const beforeResourcesLoadedCb = () => {
|
||||
break;
|
||||
|
||||
default:
|
||||
if (
|
||||
[
|
||||
"camera_fov_p",
|
||||
"camera_position_p",
|
||||
"camera_rotation_p",
|
||||
"camera_fov_l",
|
||||
"camera_position_l",
|
||||
"camera_rotation_l",
|
||||
].includes(variable)
|
||||
) {
|
||||
// The camera's config is split between two owners on purpose:
|
||||
// FOV lives on the camera itself (CameraC), while the rig —
|
||||
// offset and angle — belongs to CameraFollowC, which is the only
|
||||
// thing allowed to write camera.rotation. Routing each field to
|
||||
// its own owner is what keeps them from stomping each other.
|
||||
if (["camera_fov_p", "camera_fov_l"].includes(variable)) {
|
||||
CameraC.setCamera(window.screenSize.portrait);
|
||||
} else if (
|
||||
["camera_position_p", "camera_position_l", "camera_rotation_p", "camera_rotation_l"].includes(variable)
|
||||
) {
|
||||
// No-ops safely before the scene exists: CameraFollowC reads
|
||||
// the same config again in its own init().
|
||||
CameraFollowC.applyRigFromConfig();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Template3d } from "@hitplay/playable_template";
|
||||
import { CameraC } from "../controllers/CameraC";
|
||||
import { CameraFollowC } from "../controllers/CameraFollowC";
|
||||
|
||||
export const resizeCb = () => {
|
||||
Template3d.resize();
|
||||
|
||||
// Scene settings carry a separate rig and FOV per orientation
|
||||
// (camera_*_p vs camera_*_l), so both have to be re-read after a flip —
|
||||
// window.screenSize.portrait is already updated by the resize above.
|
||||
// Without this the playable would keep portrait's camera in landscape.
|
||||
CameraC.setCamera(window.screenSize.portrait);
|
||||
CameraFollowC.applyRigFromConfig();
|
||||
};
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user