Utility and polishing + bag fixes

This commit is contained in:
Oleksandr Vlasiuk
2026-08-14 15:03:43 +03:00
parent 250f4a0d2c
commit 1f72420da0
45 changed files with 2542 additions and 1618 deletions
+1
View File
@@ -151,3 +151,4 @@ export_test/
extra_compress/ extra_compress/
preview_dist/ preview_dist/
build/ build/
.studio/
+227 -1492
View File
File diff suppressed because it is too large Load Diff
+182
View File
@@ -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`).
+98
View File
@@ -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` тепер ніде не використовується після видалення тригерів).
+146
View File
@@ -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`).
+120
View File
@@ -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 після
ходьби/бою/розвороту персонажа — камера просто зсувається лінійно, кут
не змінюється.
+35
View File
@@ -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)` і триває сходитись) —
камера реально підлаштовується, а не стоїть на місці.
+23
View File
@@ -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/`,
ще не імпортований) — ймовірно призначений саме для цього.
+110
View File
@@ -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, прогрес-бар зони), має власні
"Перевірено" нотатки нижче за текстом.
+110
View File
@@ -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()` враховано одразу).
+69
View File
@@ -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`-значення разом з користувачем, коли він реально побачить це
на розгорнутому екрані.
+69
View File
@@ -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`.
+46
View File
@@ -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`.
+12
View File
@@ -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` перезаписує їх одразу при старті.
+50
View File
@@ -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 })`.
## Не перевірено
Візуально в браузері нічого не запускалось. Потрібен ручний прогін: старт у
портреті, поворот у ландшафт, правка повзунків на платформі наживо.
## Відкрите
Значення, збережені для вже наявних креативів на платформі, лишились у старій
(абсолютній) семантиці — їх треба одноразово перевиставити на офсет-значення.
+30
View File
@@ -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)
+1
View File
@@ -20,6 +20,7 @@
"cannon-es": "^0.20.0", "cannon-es": "^0.20.0",
"cannon-es-debugger": "^1.0.0", "cannon-es-debugger": "^1.0.0",
"howler": "^2.2.4", "howler": "^2.2.4",
"lil-gui": "^0.21.0",
"nipplejs": "^1.0.4", "nipplejs": "^1.0.4",
"three": "^0.185.1", "three": "^0.185.1",
"three.quarks": "^0.16.0" "three.quarks": "^0.16.0"
+2
View File
@@ -7,11 +7,13 @@ import { installBanner } from "./installBanner";
//@ts-ignore //@ts-ignore
import { screenOrientation } from "./orientation"; import { screenOrientation } from "./orientation";
import { globalSettings } from "./globalSettings"; import { globalSettings } from "./globalSettings";
import { gameplay } from "./gameplay";
export const configUIParams: ConfigUiParamsCategories[][] = [ export const configUIParams: ConfigUiParamsCategories[][] = [
analytics, analytics,
installBanner, installBanner,
globalSettings, globalSettings,
gameplay,
sounds, sounds,
screenOrientation, screenOrientation,
]; ];
+42
View File
@@ -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],
},
],
},
];
+26 -10
View File
@@ -1,5 +1,21 @@
import { ConfigUiParamsCategories } from "@hitplay/ads_common"; 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[] = [ export const globalSettings: ConfigUiParamsCategories[] = [
{ {
id: "global", id: "global",
@@ -43,17 +59,17 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[ [
-20, -20,
30, 30,
-2 0
], ],
[ [
-5, -5,
40, 40,
5 8
], ],
[ [
-20, -20,
70, 70,
42 -6
] ]
] ]
}, },
@@ -67,12 +83,12 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[ [
-360, -360,
360, 360,
-25 53
], ],
[ [
-360, -360,
360, 360,
0 180
], ],
[ [
-360, -360,
@@ -101,17 +117,17 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[ [
-20, -20,
30, 30,
-2 0
], ],
[ [
-5, -5,
40, 40,
6 8
], ],
[ [
-20, -20,
70, 70,
37 -6
] ]
] ]
}, },
@@ -125,12 +141,12 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[ [
-360, -360,
360, 360,
-60 53
], ],
[ [
-360, -360,
360, 360,
0 180
], ],
[ [
-360, -360,
+9 -7
View File
@@ -5,6 +5,7 @@ import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
import { ResourceC } from "./ResourceC"; import { ResourceC } from "./ResourceC";
import { PropVfxC } from "./PropVfxC"; import { PropVfxC } from "./PropVfxC";
import { HealthBarC } from "./HealthBarC"; import { HealthBarC } from "./HealthBarC";
import { DebugConfigC } from "./DebugConfigC";
import { chainAndRegisterTweens } from "../utils/tweenChain"; import { chainAndRegisterTweens } from "../utils/tweenChain";
import { createFlashOverlayMaterial } from "../utils/flashOverlayMaterial"; 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, // fills without duplicating this number — the fresh/whole-crate reference,
// not a per-instance value (see BreakableProp.maxHp for why those differ). // not a per-instance value (see BreakableProp.maxHp for why those differ).
export const MAX_HP = 100; export const MAX_HP = 100;
const HIT_DAMAGE = 10;
// A hit that only advances a damage stage drops a small trickle; the hit // 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. // 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); return this.byColliderNode.get(node);
} }
// Every hit takes HIT_DAMAGE off currentHp; the visual stage is derived // Every hit takes the debug panel's configured damage (default 10, see
// from the remaining HP fraction (see below), and reaching 0 HP destroys // DebugConfigC) off currentHp; the visual stage is derived from the
// it. Every hit plays feedback + updates HealthBarC; a stage advance (or // remaining HP fraction (see below), and reaching 0 HP destroys it.
// the destroying hit) also drops resources. `hitDirection` (attacker -> // 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. // target, XZ, normalized) drives which way the crate leans/topples.
static hit(prop: BreakableProp, hitDirection: Vector3) { static hit(prop: BreakableProp, hitDirection: Vector3) {
const damage = DebugConfigC.getHitDamage();
const previousHp = prop.currentHp; const previousHp = prop.currentHp;
prop.currentHp = Math.max(0, prop.currentHp - HIT_DAMAGE); prop.currentHp = Math.max(0, prop.currentHp - damage);
HealthBarC.onHit(prop, previousHp, prop.currentHp, HIT_DAMAGE); HealthBarC.onHit(prop, previousHp, prop.currentHp, damage);
// A bit above the root's ground-level pivot, so the burst visually // 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. // comes from around the crate's body rather than the floor under it.
+13 -17
View File
@@ -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"; import { PerspectiveCamera } from "three";
export class CameraC extends CameraC_internal { 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) { static setCamera(portraitOrientation: boolean) {
const CATEGORY = Template.getCategory("global"); const CATEGORY = Template.getCategory("global");
if (this.camera !== null) { if (this.camera !== null && this.camera instanceof PerspectiveCamera) {
let position = portraitOrientation this.camera.fov = portraitOrientation ? Number(CATEGORY["camera_fov_p"]) : Number(CATEGORY["camera_fov_l"]);
? 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(); this.camera.updateProjectionMatrix();
} }
} }
}
} }
+113 -13
View File
@@ -1,5 +1,5 @@
import { Object3D, Vector3 } from "three"; import { MathUtils, Object3D, Vector3 } from "three";
import { CameraC_internal, UpdateController } from "@hitplay/playable_template"; import { CameraC_internal, Template, UpdateController } from "@hitplay/playable_template";
// How fast the camera closes the gap to its target position, per second. // How fast the camera closes the gap to its target position, per second.
// Higher = snappier, lower = floatier. Framerate-independent (see the // Higher = snappier, lower = floatier. Framerate-independent (see the
@@ -30,6 +30,14 @@ const MOVEMENT_EPSILON_SQ = 0.0005 * 0.0005;
export class CameraFollowC { export class CameraFollowC {
private static target: Object3D | null = null; private static target: Object3D | null = null;
private static offset = new Vector3(); 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(); private static previousTargetPosition = new Vector3();
// The last direction actually walked in, held indefinitely — see update() // The last direction actually walked in, held indefinitely — see update()
// for why this must never get reset to zero on its own. // 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. // Injected (avoids importing PlayerC directly) — see setFacingTargetGetter.
private static facingTargetGetter: (() => Vector3 | null) | null = null; private static facingTargetGetter: (() => Vector3 | null) | null = null;
static init(target: Object3D, offset: Vector3) { static init(target: Object3D, fallbackOffset: Vector3) {
this.target = target; this.target = target;
this.offset = offset.clone(); this.fallbackOffset.copy(fallbackOffset);
this.previousTargetPosition.copy(target.position); this.previousTargetPosition.copy(target.position);
this.desiredLookAhead.set(0, 0, 0); this.desiredLookAhead.set(0, 0, 0);
this.smoothedLookAhead.set(0, 0, 0); this.smoothedLookAhead.set(0, 0, 0);
// The camera's angle is set once, here, from the actual offset geometry // Offset and angle both come from the platform config (see
// — not left at whatever camera_rotation_p/_l holds, since that config // applyRigFromConfig) rather than being derived here from a hardcoded
// value isn't tuned to work standalone. update() below never touches // offset — that's what makes the Scene settings sliders real controls
// rotation again after this — camera.position is the only thing it // instead of dead ones. update() below still never touches rotation:
// ever moves, in a straight line toward a single target point (no // the angle is fixed until something explicitly changes the rig, so
// facing-based pivoting). // this stays a linear-shift follow camera, not an orbiting one.
const camera = CameraC_internal.getCamera(); this.applyRigFromConfig();
camera.position.copy(target.position).add(this.offset);
camera.lookAt(target.position); // 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)); 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 // Lets the look-ahead react to the character locking onto a nearby
// breakable while stopped (see PlayerC.getFacingTarget) — that rotation // breakable while stopped (see PlayerC.getFacingTarget) — that rotation
// carries zero movement, so it would otherwise be invisible to the // carries zero movement, so it would otherwise be invisible to the
+155
View File
@@ -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();
}
}
+67 -12
View File
@@ -3,6 +3,7 @@ import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC"; import { ThreeC } from "./ThreeC";
import type { BreakableProp } from "./BreakablePropC"; import type { BreakableProp } from "./BreakablePropC";
import { projectWorldPointToScreen } from "../utils/screenWorldProjection"; 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 // 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 // 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. // guessed pixel offset — see getOrCreateBar) the bar sits.
const BAR_HEIGHT_OFFSET = 0.2; const BAR_HEIGHT_OFFSET = 0.2;
// Kept in sync with health-bar.css's health-bar-damage-punch animation // Kept in sync with health-bar.css's health-bar-damage-punch/-float
// duration, so the popup element is removed right as the CSS animation // animation durations, so the popup element is removed right as whichever
// finishes, not before/after. // CSS animation is active finishes, not before/after (see
const DAMAGE_POPUP_LIFETIME_MS = 920; // 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. // Random per-popup punch-rotation range (degrees) — see spawnDamagePopup.
// Always clockwise from the 12 o'clock resting angle (0deg), never past the // Magnitude never past the 2 o'clock mark (2 * 30deg/hour = 60deg); the
// 2 o'clock mark (2 * 30deg/hour = 60deg). // sign (clockwise vs counter-clockwise) is randomized per popup, same
// magnitude range on both sides.
const DAMAGE_ROTATE_MIN_DEG = 10; const DAMAGE_ROTATE_MIN_DEG = 10;
const DAMAGE_ROTATE_MAX_DEG = 50; const DAMAGE_ROTATE_MAX_DEG = 50;
@@ -37,6 +42,12 @@ interface BarInstance {
idleTime: number; idleTime: number;
hiding: boolean; hiding: boolean;
removeTimeout: ReturnType<typeof setTimeout> | null; 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 // World-tracking DOM health bars for breakable crates: a bordered
@@ -160,9 +171,19 @@ export class HealthBarC {
idleTime: 0, idleTime: 0,
hiding: false, hiding: false,
removeTimeout: null, removeTimeout: null,
damagePopup: null,
damagePopupTimeout: null,
}; };
this.bars.set(prop, bar); 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; return bar;
} }
@@ -190,15 +211,49 @@ export class HealthBarC {
} }
private static spawnDamagePopup(bar: BarInstance, damage: number) { 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"); const popup = document.createElement("div");
popup.className = "health-bar__damage"; 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 // Random per-popup punch angle so every hit reads differently — the
// CSS keyframes just read var(--damage-rotate). Always clockwise, // CSS keyframes just read var(--damage-rotate). Sign (clockwise vs
// never negative/left. // counter-clockwise) is random too, same magnitude range either way.
const rotateDeg = DAMAGE_ROTATE_MIN_DEG + Math.random() * (DAMAGE_ROTATE_MAX_DEG - DAMAGE_ROTATE_MIN_DEG); // Unused by the float style, harmless to always set.
popup.style.setProperty("--damage-rotate", `${rotateDeg}deg`); 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); 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
View File
@@ -18,7 +18,7 @@ const ANCHOR_DISTANCE = 2.5;
export class HudC { export class HudC {
private static initialized = false; private static initialized = false;
private static plateEl: HTMLElement; private static plateEl: HTMLElement;
private static countEl: HTMLElement; private static countTextEl: HTMLElement;
private static lastDisplayedCount = -1; private static lastDisplayedCount = -1;
private static balanceGetter: (() => number) | null = null; private static balanceGetter: (() => number) | null = null;
@@ -77,7 +77,14 @@ export class HudC {
const count = document.createElement("span"); const count = document.createElement("span");
count.className = "resource-hud__count"; 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); plate.appendChild(count);
holder.appendChild(plate); holder.appendChild(plate);
@@ -86,7 +93,7 @@ export class HudC {
uiRoot.appendChild(holder); uiRoot.appendChild(holder);
this.plateEl = plate; this.plateEl = plate;
this.countEl = count; this.countTextEl = countText;
} }
private static update() { private static update() {
@@ -94,7 +101,7 @@ export class HudC {
const count = this.balanceGetter(); const count = this.balanceGetter();
if (count === this.lastDisplayedCount) return; if (count === this.lastDisplayedCount) return;
this.lastDisplayedCount = count; 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, // 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, // see ui.css), not just the digit — this only toggles the class,
+51 -5
View File
@@ -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 { CameraC_internal, TweenC, UpdateController } from "@hitplay/playable_template";
import { Easing, Tween } from "@tweenjs/tween.js"; import { Easing, Tween } from "@tweenjs/tween.js";
import { ThreeC } from "./ThreeC"; import { ThreeC } from "./ThreeC";
import { RESOURCE_ICON_BASE_SCALE, ResourceC } from "./ResourceC"; import { RESOURCE_ICON_BASE_SCALE, ResourceC } from "./ResourceC";
import { PlayerC } from "./PlayerC"; import { PlayerC } from "./PlayerC";
import { perspectiveCompensationScale } from "../utils/perspectiveScale"; 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 // How many resources need to actually make it into the zone before it
// disappears. // disappears.
@@ -12,15 +14,15 @@ const RESOURCES_TO_CLOSE_ZONE = 20;
// While standing in the zone with resources still to deliver, one flies in // 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. // every DEPOSIT_INTERVAL seconds — a steady drain rather than an instant dump.
const DEPOSIT_INTERVAL = 0.3; const DEPOSIT_INTERVAL = 0.12;
const DEPOSIT_FLIGHT_DURATION_MS = 500; const DEPOSIT_FLIGHT_DURATION_MS = 280;
// Redundant safety-net cleanup: the deposit tween occasionally never fires // Redundant safety-net cleanup: the deposit tween occasionally never fires
// onComplete and freezes mid-flight — cause not pinned down (not the usual // onComplete and freezes mid-flight — cause not pinned down (not the usual
// chain()-registration gotcha, this tween isn't chained). This plain // chain()-registration gotcha, this tween isn't chained). This plain
// timeout removes the mesh regardless. Calling removeFromScene twice (if // timeout removes the mesh regardless. Calling removeFromScene twice (if
// onComplete also fires) is harmless — the second call just finds no // onComplete also fires) is harmless — the second call just finds no
// parent to detach from. // parent to detach from.
const DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS = 300; const DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS = 200;
const PAY_ZONE_SHRINK_MS = 400; const PAY_ZONE_SHRINK_MS = 400;
@@ -38,6 +40,22 @@ const DEPOSIT_FLIGHT_SCALE_MULTIPLIER = 1.3;
// of the ordinary from->to lerp. // of the ordinary from->to lerp.
const DEPOSIT_FLIGHT_ARC_HEIGHT = 2; 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 // 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 // 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/ // 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); const perspectiveScale = perspectiveCompensationScale(cameraPosition, mesh.position, startDistance);
mesh.scale.setScalar(perspectiveScale * RESOURCE_ICON_BASE_SCALE * DEPOSIT_FLIGHT_SCALE_MULTIPLIER); 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); TweenC.add(flight);
flight.start(); flight.start();
@@ -193,6 +216,29 @@ export class PayZoneC {
setTimeout(() => ThreeC.removeFromScene(mesh), DEPOSIT_FLIGHT_DURATION_MS + DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS); 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 // Shrinks the zone's own marker AND the fill overlay together, as one
// piece, toward their own centers in lockstep. // piece, toward their own centers in lockstep.
private static playDisappear(node: Object3D) { private static playDisappear(node: Object3D) {
+15 -4
View File
@@ -13,14 +13,22 @@ import { PropVfxC } from "./PropVfxC";
import { WeaponTrailC } from "./WeaponTrailC"; import { WeaponTrailC } from "./WeaponTrailC";
import { SparkleFxC } from "./SparkleFxC"; import { SparkleFxC } from "./SparkleFxC";
import { HealthBarC } from "./HealthBarC"; import { HealthBarC } from "./HealthBarC";
import { DebugConfigC } from "./DebugConfigC";
// Above and behind the player, along the road's forward (+Z) direction — // 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 // 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. // "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 { export class TestSceneC {
static init() { static init() {
DebugConfigC.init();
PropVfxC.init(); PropVfxC.init();
SparkleFxC.init(); SparkleFxC.init();
HealthBarC.init(); HealthBarC.init();
@@ -60,9 +68,12 @@ export class TestSceneC {
WeaponHudC.init(); WeaponHudC.init();
// Top-center "ZOMBIE INVASION" timer bar — also fully independent, // Top-center "ZOMBIE INVASION" timer bar — also fully independent,
// starts its own 2-minute slide immediately on init (see // starts its own timer slide immediately on init (see
// ZombieInvasionHudC). // ZombieInvasionHudC). Duration comes from DebugConfigC (itself seeded
ZombieInvasionHudC.init(); // 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() { private static createMap() {
+8 -1
View File
@@ -33,7 +33,14 @@ export class WeaponHudC {
const level = document.createElement("span"); const level = document.createElement("span");
level.className = "weapon-hud__level"; 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(icon);
holder.appendChild(level); holder.appendChild(level);
+47 -12
View File
@@ -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 // 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 // edge ("full" happens when it arrives). Single source of truth: set as
// the actual CSS transition-duration from here (see startTimer), not // the actual CSS transition-duration from here (see startTimer), not
// duplicated as a magic number in ui.css. // duplicated as a magic number in ui.css. Exported so DebugConfigC's timer
const TIMER_DURATION_S = 120; // 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 // 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. // a 120s timer.
const WARNING_FRACTION = 0.05; const WARNING_FRACTION = 0.2;
// Top-center "ZOMBIE INVASION" timer bar (title + a zombie-head marker // Top-center "ZOMBIE INVASION" timer bar (title + a zombie-head marker
// sliding right-to-left over TIMER_DURATION_S). No bar/track art exists — // 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 fillEl: HTMLElement;
private static flashEl: HTMLElement; private static flashEl: HTMLElement;
private static headEl: 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; if (this.initialized) return;
this.initialized = true; this.initialized = true;
this.buildDom(); 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 // .zombie-invasion__track wraps fill+flash in its own overflow:hidden
@@ -43,7 +71,14 @@ export class ZombieInvasionHudC {
const title = document.createElement("div"); const title = document.createElement("div");
title.className = "zombie-invasion__title"; 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"); const bar = document.createElement("div");
bar.className = "zombie-invasion__bar"; bar.className = "zombie-invasion__bar";
@@ -84,15 +119,15 @@ export class ZombieInvasionHudC {
// the head with no per-frame sync needed. Reading offsetWidth forces a // the head with no per-frame sync needed. Reading offsetWidth forces a
// reflow between setting transitionDuration and the target values — // reflow between setting transitionDuration and the target values —
// without it the browser would jump straight there instead of animating. // without it the browser would jump straight there instead of animating.
private static startTimer() { private static startTimer(durationS: number) {
this.headEl.style.transitionDuration = `${TIMER_DURATION_S}s`; this.headEl.style.transitionDuration = `${durationS}s`;
this.fillEl.style.transitionDuration = `${TIMER_DURATION_S}s`; this.fillEl.style.transitionDuration = `${durationS}s`;
void this.headEl.offsetWidth; void this.headEl.offsetWidth;
this.headEl.style.left = "0%"; this.headEl.style.left = "0%";
this.fillEl.style.width = "100%"; this.fillEl.style.width = "100%";
const warningDelayMs = TIMER_DURATION_S * 1000 * (1 - WARNING_FRACTION); const warningDelayMs = durationS * 1000 * (1 - WARNING_FRACTION);
setTimeout(() => { this.warningTimeout = setTimeout(() => {
this.barEl.classList.add("is-almost-full"); this.barEl.classList.add("is-almost-full");
this.flashEl.classList.add("is-almost-full"); this.flashEl.classList.add("is-almost-full");
}, warningDelayMs); }, warningDelayMs);
+57 -22
View File
@@ -15,7 +15,18 @@
position: fixed; position: fixed;
transform: translate(-20%, 200%); transform: translate(-20%, 200%);
pointer-events: none; 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 /* Width and height are separate variables — border/padding/radius below
are all derived from the HEIGHT variable, not width, so the bezel are all derived from the HEIGHT variable, not width, so the bezel
doesn't get chunkier as the bar stretches wider. */ doesn't get chunkier as the bar stretches wider. */
@@ -82,45 +93,46 @@
bottom: calc(100% + var(--health-bar-height) * 1.5); bottom: calc(100% + var(--health-bar-height) * 1.5);
color: #ffffff; color: #ffffff;
font-family: gameFont, sans-serif; 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 /* Real stroke (not just a glow) — a black outline around an opaque
number reads as "solid" the instant it appears, no fade-in needed. number reads as "solid" the instant it appears, no fade-in needed.
Same -webkit-text-stroke technique already used on .bannerButtonText. Same -webkit-text-stroke technique already used on .bannerButtonText.
Scaled with font-size so the border stays the same proportion of the +35% over the previous 0.024 for a more pronounced border, on top of
glyph. */ already scaling with the font-size bump above. */
-webkit-text-stroke: calc(var(--health-bar-width) * 0.024) #000000; -webkit-text-stroke: calc(var(--health-bar-width) * 0.0324 * var(--game-font-stroke-width)) #000000;
paint-order: stroke fill; paint-order: stroke fill;
white-space: nowrap; white-space: nowrap;
transform-origin: 50% 100%; transform-origin: 50% 100%;
/* Fast start, slow finish across the WHOLE animation — an expo-out /* Fast start, slow finish across the WHOLE animation — an expo-out
curve, so the punch snaps out instantly and the fade-out eases off curve, so the punch snaps out instantly and the fade-out eases off
gently instead of both phases reading the same speed. */ gently instead of both phases reading the same speed. Total duration
animation: health-bar-damage-punch 920ms cubic-bezier(0.16, 1, 0.3, 1) forwards; 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 /* Sharp punch, THEN fade — decoupled on purpose: pops in already fully
appearance itself is the animation's start), snaps up to 1.5x scale opaque (no fade-in), snaps up to 1.5x scale with a rotation (the ANGLE
with a rotation — the ANGLE comes from --damage-rotate, a per-popup comes from --damage-rotate, a per-popup random value set from JS, see
random value set from JS (see HealthBarC.spawnDamagePopup), so every HealthBarC.spawnDamagePopup, so every hit reads differently), settles
hit reads differently. Fully opaque at the 1.5x peak; the return to back to scale 1/rotate 0 while STAYING fully opaque, and fades out
scale 1/rotate 0 and the fade-out run IN PARALLEL (opacity already immediately from there — no hold/hang at full opacity before the fade
dropping while the punch snaps back), finishing the fade a little after starts (see chat: "прибери взагалі зависання числа на одному місці в
it's back at rest, not the instant it arrives. */ кінці"). */
@keyframes health-bar-damage-punch { @keyframes health-bar-damage-punch {
0% { 0% {
opacity: 1; opacity: 1;
transform: translateX(-50%) scale(1) rotate(0deg); transform: translateX(-50%) scale(1) rotate(0deg);
} }
18% { 28% {
opacity: 1; opacity: 1;
transform: translateX(-50%) scale(1.5) rotate(var(--damage-rotate, 60deg)); transform: translateX(-50%) scale(1.5) rotate(var(--damage-rotate, 60deg));
} }
38% { 60% {
opacity: 0.35; opacity: 1;
transform: translateX(-50%) scale(1) rotate(0deg);
}
58% {
opacity: 0;
transform: translateX(-50%) scale(1) rotate(0deg); transform: translateX(-50%) scale(1) rotate(0deg);
} }
100% { 100% {
@@ -128,3 +140,26 @@
transform: translateX(-50%) scale(1) rotate(0deg); 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));
}
}
+30
View File
@@ -15,6 +15,36 @@
font-style: normal; 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, html,
body { body {
overflow-x: hidden; overflow-x: hidden;
+4 -4
View File
@@ -221,8 +221,8 @@
.weapon-hud__level { .weapon-hud__level {
position: absolute; position: absolute;
bottom: 6%; bottom: 8%;
left: 7%; left: 2%;
width: 100%; width: 100%;
text-align: center; text-align: center;
color: #ffffff; color: #ffffff;
@@ -266,7 +266,7 @@
color: #ffffff; color: #ffffff;
font-weight: bold; font-weight: bold;
font-size: calc(var(--zombie-invasion-width) * 0.12); 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); text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.6);
margin: 0 0 calc(var(--zombie-invasion-width) * 0.045) 0; margin: 0 0 calc(var(--zombie-invasion-width) * 0.045) 0;
} }
@@ -404,6 +404,6 @@
.bannerButtonText { .bannerButtonText {
font-weight: bold; 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); text-shadow: 0 0.05em 0.1em rgba(0, 0, 0, 0.8);
} }
Binary file not shown.
+19 -2
View File
@@ -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
View File
@@ -24,7 +24,18 @@ window.setupConfig = async function (config) {
config: config || formConfigForPlayable(formConfigUI({ config: config || formConfigForPlayable(formConfigUI({
type_default: Template.templateType, type_default: Template.templateType,
params_categories: formConfigUIParams(...configUIParams), 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, resources,
afterResourcesLoadedCb, afterResourcesLoadedCb,
+13 -10
View File
@@ -11,6 +11,7 @@ import {
InstallBanner, InstallBanner,
} from "@hitplay/playable_template"; } from "@hitplay/playable_template";
import { CameraC } from "../controllers/CameraC"; import { CameraC } from "../controllers/CameraC";
import { CameraFollowC } from "../controllers/CameraFollowC";
import { ThreeC } from "../controllers/ThreeC"; import { ThreeC } from "../controllers/ThreeC";
import { Color } from "three"; import { Color } from "three";
import { Vec3 } from "cannon-es"; import { Vec3 } from "cannon-es";
@@ -114,17 +115,19 @@ export const beforeResourcesLoadedCb = () => {
break; break;
default: default:
if ( // The camera's config is split between two owners on purpose:
[ // FOV lives on the camera itself (CameraC), while the rig —
"camera_fov_p", // offset and angle — belongs to CameraFollowC, which is the only
"camera_position_p", // thing allowed to write camera.rotation. Routing each field to
"camera_rotation_p", // its own owner is what keeps them from stomping each other.
"camera_fov_l", if (["camera_fov_p", "camera_fov_l"].includes(variable)) {
"camera_position_l",
"camera_rotation_l",
].includes(variable)
) {
CameraC.setCamera(window.screenSize.portrait); 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; break;
} }
+9
View File
@@ -1,5 +1,14 @@
import { Template3d } from "@hitplay/playable_template"; import { Template3d } from "@hitplay/playable_template";
import { CameraC } from "../controllers/CameraC";
import { CameraFollowC } from "../controllers/CameraFollowC";
export const resizeCb = () => { export const resizeCb = () => {
Template3d.resize(); 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
View File
File diff suppressed because one or more lines are too long