Add physics/combat foundation, resource pickup flow, VFX polish, and HUD
- Cannon-es colliders for map/props/breakable crates (BreakablePropC, PhysicsC), AoE bat combat with facing-cone gating (PlayerC), camera follow with obstacle avoidance and facing-lock look-ahead (CameraFollowC). - Resource pickups: scatter/bounce/hit-flash/sparkle-burst lifecycle with pooled sparkle VFX (ResourceC, SparkleFxC), flying to a screen-projected HUD anchor and on into the Pay Zone with perspective-corrected sizing (HudC, PayZoneC). - Designer VFX playback via three.quarks for crate hit/destroy (PropVfxC), plus a procedural weapon-swing trail (WeaponTrailC). - Resource-counter HUD UI (ui.css, images.ts) with CSS-driven mount/bump animations. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,910 @@
|
||||
# MyGamePlayable — контекст проєкту
|
||||
|
||||
> Цей файл автоматично підвантажується в кожній сесії Claude Code. Тримай його
|
||||
> в актуальному стані: онови статуси, коли щось зміниться, видали "Day 5" секцію
|
||||
> коли вона остаточно перевірена і стабільна, додай нову секцію під наступний день.
|
||||
|
||||
## Стек і структура
|
||||
|
||||
- **Не Babylon.js** — це **three.js** (`three@0.185`) обгорнутий у пропрієтарний
|
||||
SDK `@hitplay/playable_template` (плейбл-реклама, HitPlay).
|
||||
- Фізика: `cannon-es@0.20` + `cannon-es-debugger`. World піднятий через SDK
|
||||
(`Physics_internal.init(...)` в `src/templateConfig/beforeResourcesLoadedCb.ts:27`),
|
||||
дебаг-рендер тригериться прапорцем `Template.initConfig({ debug: { physics: false } })`
|
||||
в `src/index.ts` (поки `false` — увімкнути під час дебагу фізики).
|
||||
**SDK сам кличе `world.fixedStep()` кожен кадр** (`Physics_internal.update`,
|
||||
всередині пакету) — свій `world.step()`/`fixedStep()` НЕ викликати, це
|
||||
вже фіксований таймстеп з коробки.
|
||||
- `UpdateController.Instance.onUpdate` — єдиний update-event, `delta` в
|
||||
секундах (з `THREE.Clock.getDelta()`), без фіксованого таймстепу на цьому
|
||||
рівні (фіксований степ — тільки всередині cannon, через `fixedStep()`).
|
||||
- `ThreeC.removeFromScene(obj)` існує (просто `obj.removeFromParent()`) —
|
||||
використовувати для видалення спавнених/знищених мешів.
|
||||
|
||||
### Файли
|
||||
|
||||
```
|
||||
src/controllers/
|
||||
PlayerC.ts - джойстик-рух (nipplejs), AABB-колізії з "colliders",
|
||||
бій: неперервний AoE-swing по всіх breakable-цілях в
|
||||
зоні ураження, перебивається рухом
|
||||
TestSceneC.ts - завантажує ZombiePunk_Map.glb, /collider/i -> hidden+colliders,
|
||||
ставить cannon Wall-тіла на все, крім Lootable-крейтів
|
||||
CameraFollowC.ts - камера-слідкувач, obstacle-avoidance через Raycaster
|
||||
по тому ж масиву colliders (НЕ торкались)
|
||||
PhysicsC.ts - PhysicsLayer enum, PhysicsBody (Box/Sphere+cannon Body
|
||||
з коректним world-space size/rotation), PhysicsObjPair
|
||||
BreakablePropC.ts - парсить групу "Lootable" в мапі, S1/S2/S3 damage-stages,
|
||||
2 хіти на стейдж, hit() advance stage / повне
|
||||
знищення (tween-джус, Day 6), дропає ресурси
|
||||
ResourceC.ts - спавн ресурсів "розльотом" від цілі + автозбір
|
||||
(Day 5, незмінно) — НЕ знає про Pay Zone/гравця;
|
||||
візуал — клон реального "UI_Wood" з мапи
|
||||
(fallback-куб про запас), createVisual() — публічний
|
||||
доступ до цього візуалу для інших контролерів
|
||||
PayZoneC.ts - Day 6: окремо зливає ResourceC.getCollectedCount() в
|
||||
Pay Zone (вузол мапи "UI_Interactive_Zone_02"), по
|
||||
одному ресурсу, тільки поки гравець стоїть в зоні;
|
||||
зникає після RESOURCES_TO_CLOSE_ZONE доставлених
|
||||
(зараз 20, підкручено після Day 6 — див. секцію
|
||||
"прогрес-заповнення зони"); росте плаский
|
||||
fill-overlay квад по мірі заповнення; політ депозиту
|
||||
летить з HudC.getWorldAnchorPosition(), не з гравця
|
||||
HudC.ts - лічильник ресурсу (іконка+число) у правому верхньому
|
||||
куті, DOM-оверлей в #ui; володіє "світовою" точкою
|
||||
(дитина камери), куди летять зібрані ресурси і
|
||||
звідки стартує політ депозиту в Pay Zone
|
||||
ThreeC.ts/CameraC.ts - базовий сетап three.js/камери від SDK
|
||||
resources/meshes/ - ZombiePunk_Map.glb, ZombiePunk_Character.glb
|
||||
resources/images/ - icon_wood.webp (Icon_Wood, витягнутий з мапи), images.ts
|
||||
```
|
||||
|
||||
`TriggerC.ts` (beginContact/endContact -> onEnter/onExit) і кінематичне
|
||||
cannon-тіло гравця для тригерів — **були написані й видалені в межах цієї ж
|
||||
сесії, ще до коміту**: перший дизайн ресурсів вимагав підходити до пікапа й
|
||||
стояти в тригер-зоні; користувач уточнив, що гравцю підходити не треба
|
||||
(ресурси самі розлітаються і зараховуються по таймеру), тож фізичні тригери
|
||||
виявились непотрібні для Day 5. Якщо колись знадобиться proximity-based
|
||||
тригер для чогось іншого (вода, зона урону, детект ворога) — писати заново,
|
||||
але сам підхід (`world.addEventListener('beginContact'/'endContact')` +
|
||||
мапа `body.id -> handlers`) вже перевірений робочим, просто не лишений в коді.
|
||||
|
||||
### Ключова знахідка: структура glb вже готова під breaking/loot
|
||||
|
||||
Мапа (`ZombiePunk_Map.glb`) містить групу **`Lootable`** (пряма дитина кореня
|
||||
`Map`) з ~18 дітьми `Wooden_Box_XXX` (XXX = `000`..`017`). Кожен має:
|
||||
|
||||
```
|
||||
Wooden_Box_XXX
|
||||
BoxCollider.NNN <- obstacle-бокс, він же в загальному /collider/i списку
|
||||
Wooden_Box_States_XXX
|
||||
Wooden_Box_XXX_S1 <- найменше пошкоджений (не завжди є)
|
||||
Wooden_Box_XXX_S2
|
||||
Wooden_Box_XXX_S3 <- найбільш пошкоджений/уламки (завжди є хоча б цей)
|
||||
```
|
||||
|
||||
Не всі крейти мають усі 3 стейджі — деякі стартують вже пошкодженими
|
||||
(тільки S2/S3) або взагалі як декоративні уламки (тільки S3). `BreakablePropC`
|
||||
сортує дітей `..._States_XXX` за номером у назві й показує лише перший —
|
||||
кожен хіт просуває на наступний, а хіт по останньому стейджу знищує крейт.
|
||||
|
||||
Root-рівень gltf-сцени (`getObject("map")` = `gltf.scene`, НЕ сам "Map"-нод)
|
||||
містить ще 3 сиблінги "Map": `UI_Tool_Zone`, `UI_Interactive_Zone_02`,
|
||||
`UI_Wood`/`UI_Wood.001` — плоскі quad-меші на позиції origin. В грі це
|
||||
видно як пунктирний білий квадрат, що лежить на дорозі біля крейтів
|
||||
(скріншот при першому запуску) плюс текстуру дерева поверх нього.
|
||||
|
||||
**`UI_Wood` (mesh `Plane.002`) — це реальний іконка-ресурсу, не сміття.**
|
||||
Матеріал `Material` (index 6) використовує текстуру з назвою **`Icon_Wood`**
|
||||
(unlit, alphaMode BLEND, doubleSided) — художник підготував конкретно "іконку
|
||||
дерева" саме для цього. Тепер підключено: `TestSceneC` бере цей нод,
|
||||
віддає його в `ResourceC.setPickupTemplate()` (клонується на кожен спавн
|
||||
ресурсу — `.clone()`) і ховає оригінал (`visible=false`, він більше не
|
||||
статична декорація, а шаблон для клонування). `ResourceC.createPickupMesh()`
|
||||
примусово виставляє `clone.visible = true`, бо `.clone()` копіює і
|
||||
`visible:false` з прихованого оригіналу.
|
||||
|
||||
`UI_Wood.001` (mesh `Plane.008`, матеріал `M_Items`/текстура `T_items_icons`
|
||||
— схоже на спрайт-атлас з кількома іконками) і `UI_Tool_Zone` — призначення
|
||||
досі не зрозуміле, але користувач попросив прибрати їх як "зайві текстури"
|
||||
навколо Pay Zone — тепер ховаються в `TestSceneC` (`visible=false`) поруч з
|
||||
`UI_Wood`. `UI_Interactive_Zone_02` (пунктирний квадрат) — **тепер
|
||||
підключений** в Day 6 як Pay Zone (див. нижче).
|
||||
|
||||
Ще одна знахідка (не сиблінг, а дитина `Map` -> `"UI"`): группа `UI`
|
||||
(`UI_Background`/`UI_Foreground`/`UI_Middleground`, всі матеріал `_Part2`
|
||||
— той самий атлас, що й крейти) — окремо повернута група (власний
|
||||
quaternion), локально приблизно (0.31, 1.21, -1.9), тобто підвішена в
|
||||
повітрі на висоті голови персонажа. Схоже на фейковий install-button
|
||||
мокап (типовий playable-ad прийом), рендерився завжди, візуально
|
||||
"прямокутна синя пластина над плей зоною" з якою скаржився користувач.
|
||||
Теж ховається тепер (`map.getObjectByName("UI")`, `visible=false`).
|
||||
|
||||
## Day 5: Cannon.js — реалізовано (2026-08-11, доопрацьовано того ж дня)
|
||||
|
||||
Перша ітерація зробила всі 8 пунктів чек-листа буквально (single-target
|
||||
дискретний свінг + proximity-тригери на ресурси). Користувач одразу дав
|
||||
конкретні правки під реальний геймплей-дизайн (враховані в описі нижче) —
|
||||
**фінальний стан коду відображає ці правки, не буквальний чек-лист**. Білд
|
||||
(`vite build`) проходить чисто після кожної ітерації. Ручна перевірка в
|
||||
браузері — **ще не підтверджена користувачем**; я зробив лише один короткий
|
||||
automated прогін (Playwright, в scratchpad, не в репо) до першої ревізії —
|
||||
рендер і консоль були чисті, а саме AoE/continuous-attack/resource-burst
|
||||
поведінку (фінальну версію) користувач попросив перевірити самостійно.
|
||||
|
||||
### 1. Колайдери на об'єктах карти — ✅
|
||||
`TestSceneC.createMap()`: всі `/collider/i` ноди, які НЕ належать `Lootable`,
|
||||
отримують `PhysicsBody(node, false, 0, PhysicsLayer.Wall, Player|Enemy)`.
|
||||
Lootable-крейти отримують свої Wall-тіла всередині `BreakablePropC` (щоб
|
||||
можна було `destroy()` саме це тіло при знищенні крейта, без дублювання).
|
||||
|
||||
При ревайві `PhysicsC.PhysicsBody` знайшов і виправив реальний баг:
|
||||
конструктор рахував size/rotation з `Box3` в world-space, обнуляючи лише
|
||||
**власний** quaternion об'єкта — обертання батьків (напр. кожен
|
||||
`Wooden_Box_XXX` root сам повернутий по Y) все одно потрапляло в bbox, тобто
|
||||
box виходив перекошеним/більшим за реальний. Тепер: size — з
|
||||
`mesh.geometry.boundingBox` (локальний, без жодних обертань) × world scale,
|
||||
rotation — з `getWorldQuaternion()`. Коректно для будь-якої глибини вкладеності.
|
||||
|
||||
### 2. Колайдери на гравці й персонажах — ✅
|
||||
Рух гравця **не переписаний** — досі ручний AABB `tryMove()`, як і був. Окреме
|
||||
cannon-тіло гравця (яке було для тригерів) **видалено** разом з `TriggerC`
|
||||
(див. вище) — зараз у гравця взагалі немає cannon `Body`, тільки AABB.
|
||||
|
||||
### 3–4. Тригери + start/stop events — ⚠️ зроблено, потім видалено
|
||||
Був зроблений `TriggerC.ts` на `beginContact`/`endContact`, використовувався
|
||||
для ресурсів. Після уточнення від користувача ("персонажу не потрібно
|
||||
підходити для збору") ресурси більше не потребують proximity-тригера —
|
||||
дивись секцію "Файли" вище. Формально пункт "invisible triggers" з
|
||||
чек-листа Day 5 **не представлений в фінальному коді** — свідоме рішення
|
||||
під конкретні вимоги гри, а не пропуск.
|
||||
|
||||
### 5. Розбиття пропсів — ✅ (AoE, не single-target)
|
||||
Атака гравця (`PlayerC.updateCombat`) знаходить **усі** breakable-цілі в
|
||||
зоні ураження (`findBreakableTargetsInZone` — та сама `INTERACTION_REACH`
|
||||
AABB-аура навколо гравця, без обмеження кількості цілей) і при кожному
|
||||
"пульсі" (раз на цикл анімації) хітає їх усі одразу через
|
||||
`BreakablePropC.hit(prop)` для кожної.
|
||||
|
||||
`BreakablePropC.hit(prop)`: якщо є наступний stage — ховає поточний, показує
|
||||
наступний, дропає 1-2 ресурси (`STAGE_HIT_RESOURCE_RANGE`); якщо це вже
|
||||
останній stage — дропає 2-5 ресурсів (`DESTROY_RESOURCE_RANGE`), знищує
|
||||
`physicsBody`, видаляє з `colliders`/`obstacles` (як і раніше) І **ховає
|
||||
весь `prop.root`** (`root.visible = false`) — крейт зникає повністю, а не
|
||||
лишається візуально на останньому stage-меші без колайдера.
|
||||
|
||||
### 6. Анімація атаки — ✅ (неперервна, перебивається рухом)
|
||||
`Loot`-кліп — знову `LoopRepeat, Infinity` (НЕ `LoopOnce` — це був перший
|
||||
варіант, користувач попросив назад неперервний свінг). Логіка в
|
||||
`updateCombat`:
|
||||
- Старт: гравець зупинений, є хоч одна breakable-ціль в зоні, і гравець
|
||||
дивиться на найближчу з них (`isFacing`).
|
||||
- Поки атакує: рух повністю розблокований (`updateMovement` виконується
|
||||
щокадру незалежно від `isAttacking` — на відміну від першої версії, де рух
|
||||
заморожувався на час свінгу). Будь-який стік-інпут -> `stopped=false` ->
|
||||
атака миттєво скасовується (`equipPistol()`), без "дограти удар".
|
||||
- Раз на цикл кліпу (на позначці `HIT_TIME_FRACTION`, тобто 50% циклу) —
|
||||
"пульс" хіта по всіх поточних breakable-цілях в зоні (жива вибірка щокадру,
|
||||
тож знищені цілі природно випадають з наступного пульсу).
|
||||
- Зупиняється сама, коли `zoneTargets.length === 0` (усі цілі знищені) —
|
||||
окремого cooldown між атаками нема, це не дискретний свінг.
|
||||
|
||||
### 7–8. Спавн і збір ресурсів — ✅ (розліт + автозбір, без тригерів)
|
||||
`ResourceC.spawnBurst(origin, count)` / `spawnBurstInRange(origin, min, max)`:
|
||||
кожен ресурс — клон реального `UI_Wood` (іконка дерева з мапи, див.
|
||||
"Ключова знахідка" вище; заглушка-куб лишилась тільки на випадок відсутності
|
||||
цього ноду), що летить від точки спавну по випадковому
|
||||
напрямку в XZ на `SCATTER_MIN/MAX_DISTANCE` (0.5–1.2) з невеликою дугою
|
||||
вгору-вниз (`SCATTER_ARC_HEIGHT`) за `FLY_DURATION` (0.45с), потім чекає
|
||||
`SETTLE_DELAY` (0.5с) на місці і зараховується (`collected++`,
|
||||
`console.log`) та зникає. **Гравцю не треба підходити** — це чистий
|
||||
visual+timer ефект, без cannon body/тригера взагалі. Лічильник поки лише в
|
||||
пам'яті/консолі — немає UI/economy hookup, це прототип.
|
||||
|
||||
### Best practices — де застосовано
|
||||
- **Low-poly shapes**: усюди `Box`/`Sphere`, ніколи трімеш з реальної геометрії.
|
||||
- **Fixed timestep**: вже було з коробки (`Physics_internal` кличе
|
||||
`fixedStep()` без аргументів кожен кадр) — нічого додатково не робив.
|
||||
- **Sync з рендером**: `PhysicsObjPair` (не використовується в Day 5 — нічого
|
||||
фізика не рухає, ані гравець ані ресурси більше не мають cannon-тіл).
|
||||
- **Sleep**: НЕ налаштовував `allowSleep`/sleep-ліміти явно — cannon-es має
|
||||
дефолти (`allowSleep: false` за замовчуванням у `World`!), тобто зараз
|
||||
**нічого не спить**. З десятками статичних Wall-тіл (тільки для колізій
|
||||
карти/крейтів, п.1) це навряд чи проблема на цьому масштабі, але якщо буде
|
||||
помітний perf-хіт — увімкнути `world.allowSleep = true` і виставити
|
||||
sleep-ліміти на статичних тілах.
|
||||
- **Debug visualizer**: не трогав прапорець (`debug.physics: false` в
|
||||
`src/index.ts`) — поставити `true` вручну, коли треба візуально звірити
|
||||
cannon-боксі з мапою.
|
||||
- **Collision layers**: використаний існуючий `PhysicsLayer` enum без змін
|
||||
(хоча `Trigger` тепер ніде не використовується після видалення тригерів).
|
||||
|
||||
## Day 6: Tween Animations — реалізовано (2026-08-11, виправлено того ж дня)
|
||||
|
||||
`@tweenjs/tween.js` був установлений, але **ніде не використовувався** до
|
||||
цього дня. SDK має готову обгортку `TweenC` (`@hitplay/playable_template`,
|
||||
`import { TweenC } from "@hitplay/playable_template"`) з власною `Group` і
|
||||
автопідпискою на `UpdateController` — **треба викликати `TweenC.init()`
|
||||
один раз** (додано в `beforeResourcesLoadedCb.ts`, поруч з
|
||||
`Physics_internal.init(...)`), інакше `TweenC.add()`/`.create()` тихо
|
||||
нічого не анімують.
|
||||
|
||||
### ⚠️ Знайдений і виправлений баг: `.chain()` НЕ реєструє другу ланку в групі
|
||||
Перша реалізація хіт-фідбеку й disappear-анімації використовувала
|
||||
`tweenA.chain(tweenB); TweenC.add(tweenA); tweenA.start();` — і крейти
|
||||
переставали зникати повністю (застигали розтягнутими на punch-фазі),
|
||||
а флеш/нахил не виглядав як задуманий ефект (застигав на пікові й лишався).
|
||||
Причина, перевірена читанням `tween.cjs`: `.chain()` каже tweenA лише
|
||||
покликати `tweenB.start()` при завершенні — `.start()` ставить
|
||||
`_isPlaying=true`, але **не додає tweenB в жодну `Group`**. `Group.update()`
|
||||
ітерує тільки тіли, додані через `Group.add()`, тож tweenB ніколи не
|
||||
отримує `.update()`, назавжди застигаючи "playing" в останньому кадрі
|
||||
tweenA. **Виправлення: додавати кожну ланку ланцюжка в групу окремо**
|
||||
(`TweenC.add(tweenA); TweenC.add(tweenB);` — обидва, до `tweenA.start()`).
|
||||
Підтверджено repro+fix через Playwright у scratchpad (консоль показувала
|
||||
`onUpdate` для другої ланки, що ніколи не спрацьовував до фіксу).
|
||||
|
||||
`Tween.stop()` **каскадно зупиняє весь `.chain()`**, навіть якщо зараз
|
||||
виконується вже ланка ланцюжка, а не голова (`stop()` завжди спочатку
|
||||
кличе `stopChainedTweens()`, до перевірки `_isPlaying`) — це підтверджено
|
||||
і лишається правильним механізмом "always kill or reuse tweens": досить
|
||||
тримати посилання на ГОЛОВУ ланцюжка і кликати на ній `.stop()` (саме
|
||||
зупинку це чіпляє коректно; сама помилка була тільки в реєстрації в групі).
|
||||
|
||||
Перед реалізацією користувач попросив **питати по кожному з 4 пунктів**, а
|
||||
згодом дав ще правки під час перевірки — відповіді й правки (важливі для
|
||||
майбутніх сесій):
|
||||
- "Гроші" з Day 6 = те саме дерево, що вже рахує `ResourceC` (не окрема валюта).
|
||||
- Pay Zone: гейм-дизайн "що буде після" — **не важливо**, лише сам механізм.
|
||||
- HUD не існує і Day 6 його НЕ додає.
|
||||
- Hit-feedback: короткий білий флеш ін-аут + нахил у протилежну від удару
|
||||
сторону і повернення.
|
||||
- (правка) Гроші мають летіти **прямо з персонажа в Pay Zone** — без
|
||||
проміжної "UI-точки", яка була в першій версії.
|
||||
- (правка) Pay Zone потребує **100** зібраних ресурсів, щоб зникнути (не
|
||||
просто ">0", як було спочатку). (Пізніше значення `RESOURCES_TO_CLOSE_ZONE`
|
||||
підкручено до **20** — див. секцію "прогрес-заповнення зони" нижче;
|
||||
сам механізм — "N доставлених закриває зону" — не змінився.)
|
||||
- (правка) Кожен damage-stage крейта потребує **2 хіти**, не 1.
|
||||
|
||||
### 1. Hit feedback + disappearing (крейти) — ✅
|
||||
`BreakablePropC`: матеріал кожного stage-меша **клонується один раз при
|
||||
білді** (`buildStageVisual`) — без цього флеш одного крейта підсвітив би
|
||||
ВСІ 18 крейтів одразу, бо всі стейджі всіх крейтів шарять один Material
|
||||
з glb (перевірено по індексу матеріалу в самому glb). `HITS_PER_STAGE = 2`
|
||||
— `hit()` рахує `prop.hitsOnStage`, і тільки на 2-му хіті стейдж
|
||||
просувається/крейт руйнується; **кожен** хіт (і 1-й, і 2-й, якщо не
|
||||
руйнує) грає `playHitFeedback` — один `Tween<{t}>` ланцюжком (flash-in
|
||||
90мс -> flash-out 150мс, **обидві ланки додані в `TweenC` окремо**, див.
|
||||
баг вище), в `onUpdate` одночасно виставляє `root.rotation` як
|
||||
`baseRotation + tilt*t` (`baseRotation` теж збережений один раз при білді,
|
||||
щоб повторні хіти під час ще не завершеного нахилу не накопичували дрейф
|
||||
кута) і, зараз, **опасіті окремого additive-оверлей-меша** (`flashMesh`,
|
||||
не лерп `material.color` — це вже пізніша правка, `material.color` на цих
|
||||
unlit-крейтах виявився тихим no-op, див. секцію "Розширений hit-feedback"
|
||||
нижче за повним поясненням). `tilt` рахується з `hitDirection`, який
|
||||
передає `PlayerC` (`hitDirectionTo()` — attacker->target, XZ, нормалізований).
|
||||
|
||||
На хіті, що руйнує крейт (2-й хіт останнього стейджу) — окремий ефект
|
||||
замість флешу/нахилу: **(оновлено пізніше)** зараз це sink+topple —
|
||||
`root.position.y` занурюється вниз (`DESTROY_SINK_DEPTH`, `Quadratic.In`,
|
||||
`DESTROY_SINK_MS`) з одночасним нахилом від удару (`DESTROY_TILT_ANGLE`,
|
||||
той самий `tilt`-принцип, що й у hit-feedback), `root.visible=false`
|
||||
виставляється в `onComplete`, а НЕ миттєво — фізика/обстакл-клінап
|
||||
(`physicsBody.destroy()`, видалення з масивів) лишились синхронними в
|
||||
момент хіта (гравець вже не натикається на нього, поки крейт ще візуально
|
||||
занурюється). Це заміна першої версії (punch-scale 1 -> 1.15 `Back.Out` ->
|
||||
0 `Quadratic.In`) — коли й чому саме на sink+topple, в чаті цієї сесії не
|
||||
зафіксовано; якщо матимеш контекст, допиши сюди.
|
||||
|
||||
### 2–3. Money flying out of the player into the Pay Zone — ✅ (два ОКРЕМІ механізми)
|
||||
Проміжна версія об'єднала "збір ресурсу" і "передачу в Pay Zone" в один
|
||||
конвеєр, гейтований стоянням в зоні (`ResourceC` мав стейт `holding`, що
|
||||
чекав `inZoneCheck()` перш ніж рахувати ресурс — тобто лічильник взагалі
|
||||
не рухався, поки гравець не заходив в зону). Це **зламало базовий збір**:
|
||||
ресурси лежали на землі й не зникали, якщо гравець ламав ящики далеко від
|
||||
зони. Користувач уточнив: **збір має працювати як і раніше** (Day 5,
|
||||
нічого спільного з Pay Zone), а гейтинг по стоянню в зоні має стосуватись
|
||||
**лише окремого механізму "передачі" вже зібраного в зону**.
|
||||
|
||||
Фінальний дизайн (на момент Day 6) — `ResourceC` і `PayZoneC` повністю
|
||||
незалежні:
|
||||
- **`ResourceC`** — Day 5 логіка збору (`scatter` -> settle) без жодної
|
||||
згадки про Pay Zone/гравця/зону — просто "зібрав ресурс". Що саме
|
||||
відбувається ПІСЛЯ settle (миттєво рахується, чи летить кудись) —
|
||||
контролюється ззовні через `setFlyTarget()` (додано пізніше, див.
|
||||
"HUD" нижче — спочатку тут стояв просто `collect()` без польоту).
|
||||
`createVisual()` — публічний метод, що віддає клон іконки-ресурсу для
|
||||
чужого використання (зараз юзає `PayZoneC`).
|
||||
- **`PayZoneC`** — окремо "зливає" вже зібране (`ResourceC.getCollectedCount()`)
|
||||
в зону, по одному ресурсу за раз, **тільки поки гравець стоїть в зоні**
|
||||
і є "хвіст" (`collected - deposited > 0`): кожні `DEPOSIT_INTERVAL`
|
||||
(0.3с) — новий `ResourceC.createVisual()` летить в зону (`Quadratic.InOut`,
|
||||
500мс) і зникає, `deposited++`. Звідки саме стартує цей політ — теж
|
||||
змінилось пізніше (див. "HUD" нижче).
|
||||
|
||||
### ⚠️ Знайдений і виправлений баг: `isPlayerInside` мав фіксований радіус замалий за фактичний розмір зони
|
||||
Перша версія `isPlayerInside` рахувала XZ-відстань до пивота ноду й
|
||||
порівнювала з `ZONE_RADIUS = 2` (підібраним на око зі скріншота). За
|
||||
фактом гравець міг зібрати 20+ ресурсів, зайти всередину видимого
|
||||
пунктирного квадрата — і нічого не відбувалось, бо реальний розмір зони
|
||||
значно більший за коло радіусом 2. Перевірено вимірюванням: `new
|
||||
Box3().setFromObject(payZoneNode)` дав `size ≈ [5.96, 0.09, 4.60]` (X/Z),
|
||||
тобто прямокутник ~6×4.6, з центром зсунутим від origin (`min.x≈-2.89,
|
||||
max.x≈3.06` — не симетрично!). Коло радіусом 2 покривало тільки малу
|
||||
частку видимого квадрата, переважно по X.
|
||||
|
||||
**Виправлення**: `isPlayerInside` тепер рахує `Box3` один раз в `init()`
|
||||
(`new Box3().setFromObject(payZoneNode)` — бере фактичну геометрію з
|
||||
урахуванням трансформів, а не вгадану цифру) і перевіряє просте
|
||||
point-in-rectangle по X/Z (`min <= pos <= max`), ігноруючи Y. Ніяких
|
||||
магічних констант радіуса більше нема — footprint завжди відповідає
|
||||
реальній мапі, навіть якщо художник поміняє розмір/форму зони.
|
||||
**Урок**: для будь-якої майбутньої "чи гравець в зоні X" перевірки —
|
||||
рахувати `Box3().setFromObject()` з реального ноду, не вгадувати
|
||||
радіус/розмір на око зі скріншота.
|
||||
|
||||
### 4. Payzone disappearing — ✅ (потребує `RESOURCES_TO_CLOSE_ZONE` *доставлених*, не просто зібраних)
|
||||
`PayZoneC.update()`: коли `deposited >= RESOURCES_TO_CLOSE_ZONE` (значення
|
||||
на момент Day 6 було 100, зараз в коді **20** — підкручено пізніше, див.
|
||||
"прогрес-заповнення зони"; в будь-якому разі це "N ресурсів фактично
|
||||
долетіли в зону", не просто зібрані десь на мапі) — одноразово (`closed`
|
||||
флаг) запускає `playDisappear()`: `scale 1->0` (`Quadratic.In`, 400мс),
|
||||
`visible=false` в `onComplete`. Одноразово, назавжди (немає ре-спавну/
|
||||
циклу — "що буде після" лишається не реалізованим за проханням
|
||||
користувача). Пізніше (див. "прогрес-заповнення зони") цей самий
|
||||
`playDisappear` розширений — тепер синхронно стискає ще й fill-overlay
|
||||
квад, не тільки сам маркер зони.
|
||||
|
||||
### Best practices — де застосовано
|
||||
- **Sequences**: усюди `.chain()` замість ручного стейт-машину (flash in->out,
|
||||
punch->shrink) — але **обов'язково додавати кожну ланку в `TweenC` group
|
||||
окремо** (див. баг вище) — `.chain()` сам лише стартує наступну ланку,
|
||||
не реєструє її для update-тіків.
|
||||
- **Kill or reuse tweens**: `BreakableProp.hitTween` зберігає голову
|
||||
ланцюжка; `.stop()` перед кожним новим хітом і при `destroy()` (каскадно
|
||||
зупиняє й активну ланку, це підтверджено робочим). `PayZoneC`'s
|
||||
disappear — одноразовий по флагу (`closed`), тому без явного kill.
|
||||
|
||||
## HUD-лічильник + перенаправлення анімацій (2026-08-12)
|
||||
|
||||
Користувач попросив: (1) справжню 2D UI-іконку ресурсу з лічильником у
|
||||
правому верхньому куті, "як на скріні" (темна округла табличка + іконка в
|
||||
круглій рамці зліва + число справа), (2) ресурс замість миттєвого
|
||||
зникнення має анімовано летіти ДО цього лічильника, (3) політ у Pay Zone
|
||||
має бути ВІД лічильника до зони (не від гравця).
|
||||
|
||||
### Іконка
|
||||
Витягнув сам PNG/WebP з тієї ж текстури `Icon_Wood`, яку вже юзає 3D-меш
|
||||
`UI_Wood` (`src/resources/meshes/ZombiePunk_Map.glb`, `bufferView` з
|
||||
`images[6]`) — маленьким одноразовим Node-скриптом (парсинг glb JSON-чанка,
|
||||
пошук `images[].name === "Icon_Wood"`, зріз байтів з BIN-чанка за
|
||||
`bufferViews[bufferView]`). Зберіг як `src/resources/images/icon_wood.webp`
|
||||
(webp з альфа-каналом, 512×512). Новий `src/resources/images/images.ts`
|
||||
експортує `iconWoodSrc = ConvertToBase64WhenRelease("./icon_wood.webp")` —
|
||||
**той самий паттерн, що і `meshes.ts`** (той же helper з `@hitplay/ads_common`,
|
||||
викликаний з файлу в ТІЙ САМІЙ директорії, що й ассет — важливо: цей
|
||||
хелпер в dev/build режимі просто рядково замінює провідний `.` на
|
||||
`resources` (`ConvertToBase64WhenRelease.js`), а окремий AST-плагін
|
||||
(`convertToBase64InAST`), підключений через `defineConfigTemplate` в
|
||||
`vite.config.js`, на build-time переписує сам виклик у справжній inline
|
||||
`data:...;base64,...` — це працює для ВСІХ режимів (dev/build/export), не
|
||||
тільки для "export").
|
||||
|
||||
### HudC.ts — новий контролер
|
||||
DOM-паттерн підглянутий у `InstallBanner` з SDK (немає спільного
|
||||
DOM-builder helper-а в пакетах, усе руками через `document.createElement`
|
||||
+ власний `<style>`, що вставляється в `<head>`): статичний клас, монтує
|
||||
DOM-елемент у `#ui` (SDK-контейнер, `position:absolute`, зафіксований
|
||||
9:16 blocks — `calc(100vh*9/16)` × `100vh`, центрований — це і є "екран
|
||||
гри", не весь браузер, тож `top/right` у % рахуються відносно НЬОГО).
|
||||
`.resource-hud { pointer-events:none }` — щоб не перехоплював тач/клік від
|
||||
джойстика під ним. Стиль — власний CSS, вигаданий (в проєкті НЕ було
|
||||
жодного готового "плата/банер" стилю для запозичення — перевірено, в
|
||||
`ui.css`/`main.css` нуль хітів на `border-radius`).
|
||||
|
||||
Другий обов'язок `HudC` — **світова точка** (`worldAnchor`, порожній
|
||||
`Object3D`, дитина камери через `CameraC_internal.getCamera().add(...)`,
|
||||
локальний офсет — спочатку `(1.0, 0.8, -2.2)`, потім підкручено до
|
||||
`(1.0, 1.2, -2.0)` (див. нижче, чому)), яка приблизно проєктується туди,
|
||||
де візуально сидить DOM-іконка. **Це не точний screen-to-world розрахунок**
|
||||
(FOV/aspect не враховані математично) — просто підібраний на око офсет;
|
||||
якщо HUD переїде/зміниться розмір екрану, можливо треба підкрутити
|
||||
координати ще раз. `getWorldAnchorPosition()` — це і є точка, куди тепер
|
||||
летять ресурси (`ResourceC.setFlyTarget`) і звідки стартує депозит-політ
|
||||
(`PayZoneC.playDepositFlight`).
|
||||
|
||||
### Що змінилось у ResourceC/PayZoneC
|
||||
- `ResourceC`: `scatter` -> settle -> (якщо `flyTarget` заданий, а тепер
|
||||
він завжди заданий — `HudC.getWorldAnchorPosition`) новий стейт
|
||||
`toTarget` (0.5с, smoothstep + згасаюча дуга вгору, прямо до лічильника)
|
||||
-> `collect()`. Без `flyTarget` — стара миттєва поведінка (fallback).
|
||||
**На відміну від попередньої версії — НЕ телепортується на позицію
|
||||
гравця перед польотом** (той крок був заточений під "летить З гравця",
|
||||
зараз відповідь користувача про сам HUD не згадувала цей крок, тож
|
||||
прибрав його — політ іде прямо з місця, де ресурс осів, до лічильника).
|
||||
- `PayZoneC.playDepositFlight`: `from` тепер `HudC.getWorldAnchorPosition()`
|
||||
замість позиції гравця — "від лічильника до пей зони", як попросив
|
||||
користувач. Тригер (proximity до зони) не змінився — усе ще потребує
|
||||
стояння гравця в зоні, змінилась лише точка ВИЛЬОТУ візуалу.
|
||||
|
||||
Перевірено в браузері (Playwright, scratchpad): іконка рендериться коректно
|
||||
(512×512 webp, валідний `data:` URI, `naturalWidth`/`naturalHeight` не 0),
|
||||
лічильник в DOM оновлюється синхронно з `console.log`-ами збору (звірено
|
||||
скріншотом — "8" на екрані == 8-й `[ResourceC] gathered wood` в консолі).
|
||||
|
||||
### Доопрацювання: лічильник має бути "живим балансом", не lifetime-total (2026-08-12)
|
||||
Спочатку `HudC` показував `ResourceC.getCollectedCount()` напряму — тобто
|
||||
lifetime-суму, яка ТІЛЬКИ росте (депозит в Pay Zone на неї не впливав).
|
||||
Користувач попросив: число має рости при зборі І **зменшуватись при
|
||||
депозиті** — тобто показувати поточний "баланс", а не історичний тотал.
|
||||
|
||||
Розв'язання без нової мутабельної змінної (і без циклічного імпорту —
|
||||
`PayZoneC` вже імпортує `HudC` для `getWorldAnchorPosition()`, тож
|
||||
`HudC` імпортувати `PayZoneC` напряму означало б цикл):
|
||||
- `PayZoneC.getDepositedCount()` — новий публічний гетер, повертає
|
||||
внутрішній `deposited` (лічильник, який і раніше рахував "скільки вже
|
||||
влетіло в зону", просто не був назовні доступний).
|
||||
- `HudC.setBalanceGetter(fn)` — інжектований гетер (той самий паттерн, що
|
||||
й `ResourceC.setFlyTarget`/`onDestroyed` в `BreakablePropC`), замінив
|
||||
пряму залежність `HudC -> ResourceC`. `HudC` більше НЕ імпортує
|
||||
`ResourceC` взагалі.
|
||||
- `TestSceneC` зв'язує: `HudC.setBalanceGetter(() =>
|
||||
ResourceC.getCollectedCount() - PayZoneC.getDepositedCount())` — чиста
|
||||
похідна величина, рахується на льоту щокадру в `HudC.update()`, без
|
||||
жодного окремого "decrement"-виклику. Росте коли `collected` росте
|
||||
(щось зібрали), падає коли `deposited` росте (щось долетіло в зону) —
|
||||
саме по собі, без спеціальної синхронізації.
|
||||
|
||||
Перевірено: зібрав 7 ресурсів (консоль: `gathered wood (7 total)`), весь
|
||||
цей час стояв в Pay Zone -> усі 7 злились в зону протягом ~2.1с
|
||||
(`DEPOSIT_INTERVAL=0.3` × 7) -> лічильник на екрані повернувся до **0**,
|
||||
хоча `collected` лишився 7 — підтверджує, що баланс дійсно "живий", а не
|
||||
lifetime-сума.
|
||||
|
||||
### Доопрацювання: точка вильоту ресурсів була занизько (2026-08-12)
|
||||
`WORLD_ANCHOR_LOCAL_OFFSET` підняли з `(1.0, 0.8, -2.2)` до
|
||||
`(1.0, 1.2, -2.0)` — більший Y (вище в camera-space) і трохи менший |Z|
|
||||
(ближче до камери, тому той самий Y дає більше вертикальне зміщення на
|
||||
екрані через перспективу) — все ще підібрано на око, не розраховано
|
||||
математично з FOV/aspect. **Це найбільш "на око" підібрана частина
|
||||
роботи — якщо після цього фіксу політ все ще не влучає точно в іконку,
|
||||
підкрутити ці три числа ще раз** (в `HudC.ts`).
|
||||
|
||||
## Камера: прибрано `lookAt`, лінійне зміщення замість орбіти (2026-08-12)
|
||||
|
||||
Фідбек від ментора користувача: замість `lookAt`-based слідкування камери
|
||||
за персонажем — краще визначити точку біля персонажа і зміщувати камеру
|
||||
відносно неї, залежно від напрямку погляду персонажа. Уточнення від
|
||||
користувача: **без orbit-behind** — це має бути суто лінійне зміщення
|
||||
позиції, кут камери взагалі не повинен обертатись.
|
||||
|
||||
### Що було
|
||||
`CameraFollowC.update()` щокадру: (1) демпінгував позицію камери до
|
||||
`targetPosition + offset + lookAhead` (це вже було чисте лінійне
|
||||
зміщення — `lookAhead` — той самий "зсув в напрямку погляду персонажа",
|
||||
про який казав ментор, він вже існував), (2) **окремо** демпінгував
|
||||
`smoothedLookAt`-точку і кожен кадр робив `camera.lookAt(smoothedLookAt)`
|
||||
— тобто поворот перераховувався з нуля щокадру з двох незалежно
|
||||
згладжених точок, а не одного узгодженого стану.
|
||||
|
||||
### Що зроблено (перша ітерація)
|
||||
- Прибрано `smoothedLookAt`, `LOOK_DAMPING`, і сам виклик `camera.lookAt()`
|
||||
з `update()` повністю. Позиційна частина (`offset` + `lookAhead` +
|
||||
obstacle-avoidance raycast) лишилась незмінною — вона й раніше була
|
||||
чистим translation, без жодного обертання.
|
||||
- Поворот камери відтепер **виставляється один раз** в `init()`:
|
||||
`camera.position.copy(target.position).add(offset); camera.lookAt(target.position);`
|
||||
— і після цього `update()` більше НІКОЛИ не торкається `camera.rotation`/
|
||||
`camera.quaternion`. Кут "заморожений" геометрично на старті й лишається
|
||||
таким назавжди, скільки б персонаж не розвертався.
|
||||
|
||||
### Доопрацювання: навіть без обертання камери відчувався рух "по колу" (2026-08-12)
|
||||
Користувач: коли персонаж РОЗВЕРТАЄТЬСЯ (не рухаючись при цьому), камера
|
||||
все ще відчутно "йде по колу". Причина — той самий `lookAhead`-вектор, що
|
||||
лишили в першій ітерації: він рахувався з напрямку, куди персонаж
|
||||
**дивиться** (`facing`, з `target.quaternion`), а не куди рухається.
|
||||
Навіть згладжений (`LOOK_AHEAD_DAMPING`), він все одно ОБЕРТАЄТЬСЯ разом
|
||||
з поворотом персонажа (наприклад, коли той розвертається на місці, щоб
|
||||
глянути на ящик) — а що обертається, те й тягне позицію камери по дузі
|
||||
навколо персонажа, хай і невеликій. Користувач хотів буквально "2 точки і
|
||||
пряма між ними" — жодної залежності від напрямку погляду.
|
||||
|
||||
**Перша спроба**: прибрав `lookAhead` повністю (`desiredPosition =
|
||||
targetPosition + offset`, без жодного додаткового вектора). Круговий рух
|
||||
дійсно зник, але користувач одразу зауважив: зникло й саме зміщення —
|
||||
"раніше зміщення було підходяще, єдина проблема була в тому, що воно йшло
|
||||
по колу". Тобто магнітуда/факт панорамування був потрібен, просто НЕ
|
||||
прив'язаний до повороту.
|
||||
|
||||
**Фінальне виправлення**: `lookAhead` вернув, але тепер рахується з
|
||||
**реального зміщення позиції персонажа між кадрами**
|
||||
(`targetPosition - previousTargetPosition`), а не з `target.quaternion`.
|
||||
`previousTargetPosition` — нове поле, оновлюється щокадру. Якщо кадровий
|
||||
рух менший за `MOVEMENT_EPSILON_SQ` (стоїть на місці — байдуже, як
|
||||
розвернутий) — цільовий look-ahead = нульовий вектор; якщо рухається —
|
||||
нормалізований напрямок руху × `LOOK_AHEAD_DISTANCE` (та сама магнітуда,
|
||||
що й була). Обидва варіанти йдуть через той самий `smoothedLookAhead.lerp`
|
||||
з `LOOK_AHEAD_DAMPING`, як і раніше — тільки джерело напрямку інше.
|
||||
Результат: розворот на місці = нуль руху = камера взагалі не рухається;
|
||||
ходьба = той самий пан вперед, що й був до всіх цих правок.
|
||||
**Урок**: "згладжений вектор, що обертається з об'єктом" все одно
|
||||
читається як орбітальний рух — згладжування прибирає різкість, а не
|
||||
кривизну; правильний фікс — прив'язати джерело напрямку до РУХУ
|
||||
(position delta), а не до facing/quaternion, а не просто видалити ефект.
|
||||
|
||||
Перевірено в браузері (Playwright) — рендер коректний з першого кадру і
|
||||
після ходьби/бою, без console-помилок.
|
||||
|
||||
### Доопрацювання ×3: зовсім не відчувалось, тримати зсув, миттєвий розворот на 180° (2026-08-12)
|
||||
Після руху-based lookahead користувач: "Немає зміщення взагалі". Причина —
|
||||
не баг у логіці (перевірив логуванням, числа рахувались вірно), а те, що
|
||||
**стара `lookAt`-версія давала подвійний ефект** (і зсув позиції, і
|
||||
доворот камери в той же бік щокадру), а зараз лишився тільки зсув позиції
|
||||
— і сама позиційна складова (`LOOK_AHEAD_DISTANCE=1.2`) була занадто
|
||||
малою, щоб щось означати з відстані ~12.7 од. (`(0,9,-9)` риг). Підняв до
|
||||
`4`, користувач сам потюнив назад до **`2`** (лишив цю зміну, не
|
||||
відкатувати).
|
||||
|
||||
Ще дві правки в тому ж повідомленні:
|
||||
- **"Камера після зсуву не має повертатись назад"** — `desiredLookAhead`
|
||||
тепер **окреме поле**, не локальна константа: оновлюється (`.copy(movement)...`)
|
||||
ТІЛЬКИ коли `movement.lengthSq() > MOVEMENT_EPSILON_SQ`, і просто НЕ
|
||||
чіпається, коли персонаж стоїть — замість `: new Vector3()` (нуль) в
|
||||
тернарному операторі, як було. Тобто пан тримається на місці, де
|
||||
зупинився, а не з'їжджає назад до центру, поки не з'явиться новий, ІНШИЙ
|
||||
напрямок руху.
|
||||
- **"Зроби анімацію плавнішою"** — `LOOK_AHEAD_DAMPING` знижений з `2` до
|
||||
`1.2` (повільніший ease).
|
||||
- **"Персонаж має розвертатись моментально, якщо ми змінюємо напрям на
|
||||
протилежний"** — це вже не про камеру, а про `PlayerC` (`updateMovement`):
|
||||
новий `OPPOSITE_TURN_THRESHOLD = Math.PI * (150/180)` (~150°). Якщо кут
|
||||
між поточним і цільовим facing (`quaternion.angleTo(...)`) перевищує цей
|
||||
порог — `quaternion.copy(facingRotation)` миттєво, інакше — старий
|
||||
капований `rotateTowards(facingRotation, MAX_TURN_SPEED * delta)`. Тобто
|
||||
тільки різкі розвороти "в протилежну сторону" миттєві, дрібні корекції
|
||||
курсу лишаються плавними, як і були.
|
||||
|
||||
### ⚠️ Знахідка: `camera_rotation_p`/`camera_rotation_l` (дизайнерський конфіг) виявився нежиттєздатним самостійно
|
||||
Спочатку спробував залишити кут камери таким, яким його виставляє
|
||||
`CameraC.setCamera()` з конфіг-параметрів `camera_rotation_p`/`_l`
|
||||
(дизайнер може їх тюнити через UI) — просто прибравши `lookAt` і НІЧОГО
|
||||
більше не додаючи. Результат: **порожнє темно-синє небо**, ні мапи, ні
|
||||
персонажа в кадрі. Причина: цей конфіг-кут ніколи насправді не був
|
||||
призначений працювати самостійно — `CameraFollowC` з першого ж комміту,
|
||||
що його додав, миттєво перезаписував поворот через `lookAt()` щокадру,
|
||||
тож ніхто й не міг помітити (чи потребу) тюнити `camera_rotation_p` під
|
||||
реальний рух — він був "мертвим" параметром, що впливав щонайбільше на
|
||||
один кадр до першого `update()`.
|
||||
|
||||
**Виправлення**: не покладатись на цей конфіг взагалі для follow-камери —
|
||||
рахувати фіксований кут геометрично з реального `offset` (`(0,9,-9)`) в
|
||||
момент `CameraFollowC.init()` (описано вище). Це гарантовано framing
|
||||
персонажа з самого старту, незалежно від того, що зараз стоїть в
|
||||
`camera_rotation_p`. **Урок**: значення з `configUIParams`/`globalSettings`
|
||||
не завжди відображають те, що реально відбувається в грі — перевіряти,
|
||||
чи щось інше (як тут `CameraFollowC`) не перезаписує їх одразу після
|
||||
встановлення, перш ніж покладатись на них як на джерело правди.
|
||||
|
||||
Перевірено в браузері (Playwright): після фіксу сцена рендериться коректно
|
||||
з самого старту (мапа/персонаж/крейти на місці, кут ідентичний
|
||||
попередньому вигляду), і лишається так само коректно framed після
|
||||
ходьби/бою/розвороту персонажа — камера просто зсувається лінійно, кут
|
||||
не змінюється.
|
||||
|
||||
## Камера підлаштовується під facing-lock на крейт (2026-08-12)
|
||||
|
||||
Користувач: коли персонаж зупиняється біля ящика і "тригер збору"
|
||||
(`PlayerC.faceTowards`, викликається з `updateMovement` коли `stopped &&
|
||||
nearbyObstacle`) розвертає його лицем до цілі, камера має підлаштовуватись
|
||||
під новий кут, а не лишатись байдужою. Це саме той кейс, який попередній
|
||||
рух-based `lookAhead` **свідомо** ігнорував (розворот на місці = нульовий
|
||||
`movement` = look-ahead не оновлюється) — правильна поведінка для
|
||||
довільного розвороту, але користувач хотів виключення саме для facing-lock
|
||||
на breakable-ціль.
|
||||
|
||||
**Рішення без повернення до facing/quaternion-залежності** (яка й дала
|
||||
"рух по колу" раніше): `PlayerC` тепер зберігає, на яку саме ТОЧКУ (не
|
||||
кут!) він зараз locked — `facingTarget: Vector3 | null`, виставляється в
|
||||
`updateMovement()` в той самий момент, коли викликається `faceTowards()`
|
||||
(і скидається в `null`, коли умова `stopped && nearbyObstacle` неправдива).
|
||||
Публічний `PlayerC.getFacingTarget()`.
|
||||
|
||||
`CameraFollowC.setFacingTargetGetter(getter)` — injected getter (той самий
|
||||
DI-паттерн, що й `ResourceC.setFlyTarget`), підключено в `TestSceneC.init()`
|
||||
відразу після `CameraFollowC.init()`. В `update()`: якщо `facingTarget` не
|
||||
`null` — `desiredLookAhead` рахується як напрямок від гравця ДО цієї точки
|
||||
(XZ, нормалізований) × `LOOK_AHEAD_DISTANCE`, замість напрямку руху; інакше
|
||||
— стара логіка (рух або hold). Ключове: джерело — фіксована ТОЧКА в
|
||||
world-space (`nearbyObstacle.center`), а не обертовий вектор — поки гравець
|
||||
і ящик обидва стоять на місці, цей напрямок сам по собі константний, тож
|
||||
`smoothedLookAhead.lerp(...)` дає один плавний лінійний зсув до нової
|
||||
позиції й ЗУПИНЯЄТЬСЯ там, а не описує дугу (на відміну від
|
||||
facing/quaternion-based версії, яка оберталась разом з тілом персонажа).
|
||||
|
||||
Перевірено (Playwright, тимчасове логування `window.__dbgFacing`/
|
||||
`__dbgLookAhead`, видалене після): після зупинки біля крейта
|
||||
`facingTarget` стає non-null, і `smoothedLookAhead` плавно зсувається від
|
||||
~`(-0.02, 1.46)` до нового значення (`(-1.03, 1.08)` і триває сходитись) —
|
||||
камера реально підлаштовується, а не стоїть на місці.
|
||||
|
||||
## Два хіти за один цикл анімації атаки (2026-08-12)
|
||||
|
||||
Користувач: анімація "Loot" (удар битою) сама показує **2 фізичні удари**
|
||||
за один цикл кліпу, але код досі рахував лише **1** damage-пульс за цикл
|
||||
(`HIT_TIME_FRACTION=0.5`, раз на `attackAction.getClip().duration`) — тобто
|
||||
`HITS_PER_STAGE=2` вимагав 2 повних циклу анімації на просування стейджа,
|
||||
хоча мало вистачати одного.
|
||||
|
||||
**Виправлення**: `HIT_TIME_FRACTION` (одне число) замінено на
|
||||
`HIT_TIME_FRACTIONS = [0.25, 0.75]` (масив — дві точки за цикл; підібрані
|
||||
навмання як симетричний дефолт, можуть потребувати підстройки під реальний
|
||||
тайминг замаху в кліпі, як і `LOOK_AHEAD_DISTANCE` раніше). Новий
|
||||
`PlayerC.hitsThisAttack` (рахує ВСІ хіти за весь безперервний свінг, не
|
||||
скидається щоцикл) + `hitTimeFor(hitIndex)` — рахує абсолютний час
|
||||
(секунди від старту атаки) для N-го хіта: `duration * (повних_циклів +
|
||||
HIT_TIME_FRACTIONS[hitIndex % 2])`, тобто коректно переходить через межу
|
||||
циклу без дрейфу.
|
||||
|
||||
Перевірено (Playwright, тимчасове логування `[dbgHit]` з таймстемпом,
|
||||
видалене після): хіти йдуть з рівним інтервалом ~780мс (замість колишніх
|
||||
~2×780=1560мс), і стейдж (`prop.stageIndex`) просувається після кожних 2
|
||||
хітів (`0,1` -> stage++), тобто рівно один цикл анімації = один
|
||||
просунутий стейдж, як і треба.
|
||||
|
||||
## Розширений hit-feedback на крейтах: спарки, HP-бар, числа урону (2026-08-12)
|
||||
|
||||
Користувач показав референс-скріншоти з іншої гри (damage-numbers, HP-бар,
|
||||
іскри в точці контакту) і сказав, що зараз у нас із фідбеку на хіт **лише
|
||||
невеликий нахил** — флеш кольору, який мав бути з Day 6, візуально не
|
||||
працює. Плейл поступово (по одному ефекту, з перевіркою між кроками).
|
||||
|
||||
### ⚠️ Знайдений і виправлений баг: флеш кольору був тихим no-op з самого Day 6
|
||||
`playHitFeedback` лерпив `material.color` від `baseColor` до `FLASH_COLOR`
|
||||
(білий) — але залогувавши реальний матеріал crate-мешів (`MeshBasicMaterial`,
|
||||
`color: #ffffff`, без `emissive` — це не Standard/Phong), виявилось, що
|
||||
**`baseColor` вже й був чистим білим**. Текстура дає весь реальний колір,
|
||||
тінт-колір лишався на дефолтному білому — лерп "білий -> білий" не змінює
|
||||
візуально НІЧОГО, tween виконувався справно (перевірено таймингом), просто
|
||||
результат був непомітний. `MeshBasicMaterial` — unlit, `color` множиться на
|
||||
текстуру, тож "яскравіше за білий" через цей канал взагалі неможливо.
|
||||
|
||||
**Виправлення**: замість лерпу `.color` — `StageVisual.flashMesh`, клон тієї
|
||||
самої mesh-геометрії (`mesh.clone()`), покладений зверху з власним
|
||||
`MeshBasicMaterial({ color: FLASH_COLOR, transparent: true, opacity: 0,
|
||||
blending: AdditiveBlending, depthWrite: false })`. Флеш тепер анімує
|
||||
`flashMesh.material.opacity` (0→1→0) замість кольору базового меша —
|
||||
працює незалежно від того, який в оригінала колір/текстура, бо це
|
||||
адитивний шар зверху, а не множник. **Побічний ефект**: `flashMesh` — це
|
||||
ще один mesh під `map`, тож `TestSceneC`'s `ThreeC.setShadowsStateForChildren
|
||||
(map, true, true)` (виконується ПІСЛЯ `BreakablePropC.init()`) увімкнув би
|
||||
йому shadows теж — додано `BreakablePropC.disableFlashShadows()`, викликаний
|
||||
з `TestSceneC` одразу після цього рядка, щоб вимкнути назад (даремний
|
||||
shadow-caster на майже завжди прозорому оверлеї).
|
||||
|
||||
Перевірено (Playwright, серія скріншотів кожні 80мс під час бою): крейт у
|
||||
кадрі точно на позначці очікуваного хіта помітно біліший/яскравіший, ніж у
|
||||
кадрах до/після — флеш реально видно, на відміну від попередньої версії.
|
||||
|
||||
**Урок**: тінт-колір (`material.color`) на unlit-текстурованому меші не дає
|
||||
"флешу", якщо колір вже білий (звичайний дефолт) — для видимого
|
||||
hit-flash-у на такому матеріалі потрібен окремий адитивний оверлей-шар, а
|
||||
не лерп самого кольору. Якщо десь ще знадобиться подібний ефект на
|
||||
текстурованому unlit-меші — той самий підхід (clone + additive overlay).
|
||||
|
||||
### Іскри в точці контакту — v1 hand-rolled, замінено (2026-08-12)
|
||||
Перша версія — новий `SparkFxC.ts`, ручно зібраний `ParticleSystem` (білі
|
||||
квадратні частинки, `SphereEmitter(radius:0.05)`, звичайний `BillBoard`).
|
||||
Технічно працював (Playwright підтвердив спалах у момент хіта), але
|
||||
користувач подивився і сказав: **"Іскри не підходять, це мають бути як
|
||||
промені. Саме такі як на скріні"** — потрібні тонкі промені-стріки, не
|
||||
блоб з крапок. Перебудував рендер на `RenderMode.StretchedBillBoard`
|
||||
(розтягує квад уздовж власної швидкості частинки — це і дає "промінь", а
|
||||
не крапку) + `speedFactor`, `ColorOverLife`+`Gradient` для згасання альфи.
|
||||
Це вже виглядало краще, але тут користувач відкрив `temp/VFX_Lootable_Destroy.json`
|
||||
у редакторі й запитав, що в цих файлах — і виявилось, що це змінює все.
|
||||
|
||||
### ⚠️ Знахідка: в `temp/` лежали готові дизайнерські VFX-префаби, повністю невикористані
|
||||
`temp/VFX_Lootable_Hit.json`, `temp/VFX_Lootable_Destroy.json` (+
|
||||
`PassionOne-Black.otf` — шрифт, ще не використаний, ймовірно для майбутніх
|
||||
чисел урону/HUD) — це **справжні експорти з редактора quarks.art**
|
||||
(`Object3D.toJSON()` на `Group` з `ParticleEmitter`-дітьми, кожен зі своїм
|
||||
готовим, підібраним дизайнером `ps` — shape/speed/color/behaviors, і
|
||||
реальними текстурами, вбудованими як base64: `cfxr stretch smoke arc
|
||||
dissolve.webp`, `novalines.webp` — назва "novalines" **буквально "лінії
|
||||
нової" = промені-риски**, це саме те, що користувач мав на увазі скріном).
|
||||
`VFX_Lootable_Hit` = 2 емітери ("StretchSmoke" + "SharpImpact" — це і є
|
||||
ray-burst). `VFX_Lootable_Destroy` = 4 емітери (дим, уламки-Pieces,
|
||||
Ground_Dirt як реальна 3D-mesh геометрія (`renderMode: Mesh`), пилова хмара).
|
||||
**Жодного SDK/іншого коду проєкту ці файли не використовували взагалі** —
|
||||
чисто сирі ассети, що чекали на підключення. Замінив увесь ручний
|
||||
`SparkFxC` на завантаження цих реальних префабів — жодна власноруч
|
||||
підібрана крива/швидкість не заміняє справжній дизайнерський ассет.
|
||||
|
||||
**Що таке VFX**: "visual effects" — тут конкретно означає one-shot
|
||||
частинкові ефекти (спалахи/дим/уламки), авторовані окремо в
|
||||
редакторі quarks.art і експортовані як самодостатній JSON (геометрія +
|
||||
матеріали + текстури-base64 + вже налаштовані `ParticleSystem` параметри
|
||||
для кожного емітера) — не код, чистий даних-ассет, який рушій (`three.quarks`)
|
||||
вміє розпарсити назад у робочі `Object3D`.
|
||||
|
||||
### `PropVfxC.ts` — новий контролер, замінив `SparkFxC.ts`
|
||||
Завантажує обидва префаби ОДИН РАЗ при `init()` через сирий
|
||||
`QuarksLoader` (`three.quarks`) + `Template3d.manager` (`@hitplay/playable_template`),
|
||||
**НЕ** через SDK-хелпер `quarksLoader()`/`ConvertToBase64WhenRelease`
|
||||
(паттерн `meshes.ts`/`images.ts`) — свідомо, з причини:
|
||||
|
||||
**⚠️ Чому не той самий паттерн, що і images/meshes**: `quarksLoader(base64String)`
|
||||
жорстко очікує рядок формату `data:...;base64,XXXX` (робить
|
||||
`base64String.split(",")[1]` потім `atob(...)`). Це працює лише ПІСЛЯ
|
||||
build-time AST-трансформації (`convertToBase64InAST`, підключена в
|
||||
`vite.config.js`), яка й замінює виклик `ConvertToBase64WhenRelease()` на
|
||||
реальний base64 рядок. У **звичайному `vite dev`** (як я весь час тестую
|
||||
через Playwright!) сам хелпер — лише прохідний passthrough, що повертає
|
||||
ГОЛИЙ ШЛЯХ (`"resources/vfx/....json"`) без коми — `.split(",")[1]` дав би
|
||||
`undefined`, і `atob(undefined)` зламав би завантаження саме в dev-режимі,
|
||||
хоча в build/export він працював би. Замість цього ризику — звичайний
|
||||
Vite JSON-імпорт (`import json from "../resources/vfx/VFX_Lootable_Hit.json"`),
|
||||
який Vite вміє інлайнити нативно в БУДЬ-ЯКОМУ режимі (dev/build/export)
|
||||
без жодного fetch — і сирий `new QuarksLoader(Template3d.manager).parse(json)`
|
||||
(синхронний, повертає `Object3D` напряму, `onLoad` callback не потрібен,
|
||||
бо всі текстури вже base64 в самому JSON, немає мережевого чекання).
|
||||
Додав `"resolveJsonModule": true` в `tsconfig.json` (був відсутній) — без
|
||||
цього `tsc` (не сам vite/esbuild — ті й так все розуміють) скаржився б на
|
||||
JSON-імпорт типами.
|
||||
|
||||
`PropVfxC.spawnHit(point)`/`spawnDestroy(point)` — клонують відповідний
|
||||
завантажений шаблон (`.clone()` на `Group`), виставляють `position`,
|
||||
форсують `QuarksUtil.setAutoDestroy(instance, true)` (в оригінальних
|
||||
префабах `autoDestroy: false` — дизайнер, ймовірно, керував завершенням
|
||||
вручну в своєму інструменті; нам потрібен чистий "one-shot і забути"),
|
||||
`QuarksUtil.addToBatchRenderer(instance, renderer)` (реєструє КОЖЕН
|
||||
`ParticleEmitter` в дереві з рендерером — без цього нічого не малюється),
|
||||
`QuarksUtil.play(instance)`. Кожен `ParticleSystem` з `autoDestroy`
|
||||
прибирає СЕБЕ (свій `ParticleEmitter`-нод) з дерева, коли його частинки
|
||||
померли — але порожній корінь-`Group`, який я додав в сцену, сам не
|
||||
видаляється (нема кому), тож прибираю його окремим `setTimeout`
|
||||
(`CLEANUP_DELAY_MS=1500`, з запасом під найдовший `duration+life` в обох
|
||||
префабах).
|
||||
|
||||
`BreakablePropC.playHitFeedback` кличе `PropVfxC.spawnHit(contactPoint)` на
|
||||
кожному хіті (як і раніше з `SparkFxC`); `BreakablePropC.destroy()`
|
||||
додатково кличе `PropVfxC.spawnDestroy(...)` в позиції крейта — цього не
|
||||
було в hand-rolled версії взагалі (не було Destroy-ефекту), додав одразу,
|
||||
бо це буквально друга половина того самого знайденого ассета.
|
||||
|
||||
### ⚠️ Знахідка (потребує рішення користувача, ще не виправлено): "StretchSmoke"-шар вилітає далеко від крейта і виглядає як артефакт
|
||||
Перевірено в браузері (Playwright): "SharpImpact" (промені/nova-спалах,
|
||||
`novalines.webp`) рендериться коректно ПРЯМО на крейті в момент хіта —
|
||||
саме той ефект, що відповідає скріну користувача. Але другий емітер того
|
||||
самого префаба, "StretchSmoke" (`startSpeed:14`, `worldSpace:true`, життя
|
||||
0.2-0.3с — тобто до ~4 world units прольоту по прямій!), у нашій сцені
|
||||
летить ДАЛЕКО від крейта (видно на скріні як сірувата паличка десь у
|
||||
верхній частині екрана, повністю відірвана від точки удару) — швидше за
|
||||
все, дизайнер тюнив цей ассет під інший масштаб сцени/камери. Це
|
||||
відтворюється на КОЖНОМУ хіті (не витік/застигла частинка — просто кожен
|
||||
новий хіт заново вилітає в приблизно те саме "неправильне" місце).
|
||||
**Ще не виправлено** — варіанти: (а) лишити як є (автентичний ассет), (б)
|
||||
притлумити цей конкретний емітер (зменшити `startSpeed`/життя клоном
|
||||
префаба перед грою), (в) прибрати "StretchSmoke" зовсім і лишити тільки
|
||||
"SharpImpact". Потребує візуальної перевірки користувачем — не вирішував
|
||||
сам, це пряма зміна дизайнерського ассета.
|
||||
|
||||
Destroy VFX (`VFX_Lootable_Destroy`, 4 емітери включно з mesh-based
|
||||
"Ground_Dirt") підключений і не кидає помилок в консолі, але **ще не
|
||||
підтверджений візуально** — не вдалось надійно спричинити повне
|
||||
знищення крейта в тестовому прогоні за відведений час.
|
||||
|
||||
### Наступні кроки (ще не реалізовано, чекає на пріоритети користувача)
|
||||
HP-бар (потребує рішення: дискретні стейджі vs. реальний HP-пул?) і числа
|
||||
урону (DOM чи sprite в 3D-просторі? теж потребує реального numeric HP,
|
||||
якого зараз нема) — ще не почато. `PassionOne-Black.otf` (в `temp/`,
|
||||
ще не імпортований) — ймовірно призначений саме для цього.
|
||||
|
||||
## PayZoneC: прогрес-заповнення зони + два реальні знайдені баги (2026-08-12)
|
||||
|
||||
Користувач попросив візуальний індикатор заповненості Pay Zone (скільки з
|
||||
`RESOURCES_TO_CLOSE_ZONE` вже занесено). Три ітерації дизайну (кожна —
|
||||
реальна правка коду, не просто обговорення):
|
||||
1. Горизонтальний квад на землі, що росте вздовж world X — користувач:
|
||||
"заповнюється... з права на ліво" (не те, що хотів).
|
||||
2. Box, що росте вгору по Y (буквально "як вода в басейні") — виявився
|
||||
концептуально хибним: верх/низ box-а завжди займають весь footprint
|
||||
незалежно від висоти, тож з ізометричної камери зона виглядала "одразу
|
||||
заповненою" на будь-якій висоті > 0.
|
||||
3. **Фінал**: плаский квад на землі знову, але росте вздовж world Z (не X)
|
||||
— з цієї камери (`CAMERA_OFFSET=(0,9,-9)`, дивиться в +Z) рух вздовж +Z
|
||||
проєктується на екран як "знизу вгору", що і є тим "від краю до краю",
|
||||
про яке просив користувач. Пінінг біля min-Z (найближчий до камери
|
||||
край), колір `0x5ce65c` (зеленуватий).
|
||||
|
||||
### ⚠️ Знайдений і виправлений баг: депозит-політ інколи застигає в повітрі навіки
|
||||
Користувач показав скріншот: статична текстура ресурсу, що просто висить
|
||||
у повітрі. Трасував через тимчасове логування (spawn/complete ID на кожен
|
||||
`playDepositFlight` — окремий `Tween`, не chain, коректно `TweenC.add()`-
|
||||
нутий ПЕРЕД `.start()`) — підтвердив: рідко (не щоразу) `onComplete` просто
|
||||
не спрацьовує, і клон застигає точно на прямій між HUD-точкою і зоною, на
|
||||
довільному t. Причину всередині `tween.js`/`TweenC` не знайшов (це не той
|
||||
самий баг з `.chain()` з Day 6 — тут узагалі нема chain). **Замість
|
||||
подальшого копання — надійний дублюючий `setTimeout`**, що прибирає mesh
|
||||
через `DEPOSIT_FLIGHT_DURATION_MS + 300мс` незалежно від того, чи
|
||||
спрацював `onComplete`. Викликати `removeFromScene` двічі — нешкідливо
|
||||
(другий викоик просто не знаходить батька для detach).
|
||||
|
||||
### ⚠️ Знайдений і виправлений баг: `"UI_Wood.001"` в коді не збігається з реальною назвою ноду
|
||||
Користувач наполягав, що "текстура дерева" в центрі Pay Zone нікуди не
|
||||
зникає, хоча по черзі виключив усі мої гіпотези (крейти всередині зони,
|
||||
застиглий ресурс). Трасував живу сцену (`ThreeC.scene.traverse`, шукаючи
|
||||
все з "UI_Wood" в імені, друкуючи ланцюжок `visible` від ноду до кореня) —
|
||||
і ось воно: нод з raw glb JSON названий `"UI_Wood.001"` (з крапкою,
|
||||
підтверджено прямим парсингом бінарника), але в ЖИВІЙ Three.js сцені після
|
||||
завантаження він `"UI_Wood001"` (**без крапки** — щось у пайплайні
|
||||
завантаження її стирає). `TestSceneC.createMap()` шукав
|
||||
`map.getObjectByName("UI_Wood.001")` — рядок ніколи не збігався,
|
||||
`getObjectByName` тихо повертав `undefined`, `if (woodIconAlt)` була
|
||||
`false`, і `.visible=false` НІКОЛИ фактично не виконувався — попри те, що
|
||||
код виглядав абсолютно правильним і "мав" би працювати. Виправлення:
|
||||
рядок-літерал змінено на `"UI_Wood001"` (без крапки), підтверджено
|
||||
трасуванням живої сцени, що тепер `visible=false` реально застосовується.
|
||||
**Урок**: коли `if (identifiedNode)`-гард ЗАВЖДИ хибний (вузол ніби існує
|
||||
в GLB, але код його "не бачить") — перевіряти РЕАЛЬНЕ ім'я в ЖИВІЙ сцені
|
||||
(`scene.traverse` + лог імені), не тільки в сирому GLB JSON — завантажувач
|
||||
може мовчки переписувати рядки (крапки, ймовірно, конфліктують з якоюсь
|
||||
внутрішньою угодою іменування).
|
||||
|
||||
Побічно: під час полювання на цей баг зробив (і одразу відкотив на
|
||||
прохання користувача) дві помилкові гіпотези-фікси — приховання
|
||||
`UI_Interactive_Zone_02` (сам маркер зони) і видалення `Wooden_Box_016`/
|
||||
`Wooden_Box_017` (2 крейти, що геометрично сидять майже точно в центрі
|
||||
зони, ~0.18 і ~0.99 одиниць від центру — підтверджено обчисленням world
|
||||
position прямо з GLB node transforms). Жодна з цих гіпотез не була
|
||||
причиною — обидві повернуто як були. Крейти в зоні — це може бути окрема,
|
||||
самостійна проблема левел-дизайну (вони справді там стоять), але це
|
||||
свідоме рішення НЕ трогати без окремого прямого запиту користувача.
|
||||
|
||||
- Working tree на момент старту Day 5: `PlayerC.ts` мав незакомічені правки
|
||||
(accel/decel рух, рефакторинг attack-facing на helper-и) — вони увійшли в
|
||||
фінальну версію файлу (переписаний повністю, логіка руху збережена).
|
||||
- Свідоме рішення: не рефакторити робочий AABB рух гравця під фізику; після
|
||||
видалення тригерів у гравця взагалі немає cannon `Body` — тільки AABB.
|
||||
- `ResourceC`/`BreakablePropC` — нові файли. `PlayerC.ts`/`TestSceneC.ts`/
|
||||
`PhysicsC.ts` — модифіковані. `CameraFollowC.ts`/`ThreeC.ts` — не торкались.
|
||||
`TriggerC.ts` — був створений і видалений в межах цієї ж сесії (див. "Файли").
|
||||
- Ще не закомічено в git (working tree) — користувач сам вирішує коли
|
||||
комітити.
|
||||
- Якщо наступного разу знову треба буде правити combat: зона ураження —
|
||||
та сама `INTERACTION_REACH`-аура, що й раніше використовувалась для
|
||||
single-target пошуку (`findNearbyObstacle`, лишився для косметичного
|
||||
idle-facing), просто тепер є окремий `findBreakableTargetsInZone` без
|
||||
обмеження на кількість цілей.
|
||||
- Day 6 (Tween): `TweenC.init()` викликається один раз в
|
||||
`beforeResourcesLoadedCb.ts` — якщо колись переносити фізику/tween-бутстрап
|
||||
в інше місце, не забути перенести й це, інакше всі `TweenC.add()` тихо
|
||||
ні на що не впливають (сама `Group` існує, просто ніхто її не `.update()`-ить).
|
||||
- `PayZoneC.init(payZoneNode, target)` МАЄ викликатись після `PlayerC.init()`
|
||||
в `TestSceneC` (потребує реальний `PlayerC.object`, не просто позицію) —
|
||||
уже виправлено (виклик з `TestSceneC.init()`, не з `createMap()`), просто
|
||||
важливо не переносити назад.
|
||||
- **Будь-який новий `.chain()` в цьому проєкті — додавай ОБИДВІ (усі) ланки
|
||||
в `TweenC` окремо** (`TweenC.add(a); TweenC.add(b); a.start();`), інакше
|
||||
повториться баг вище: друга ланка "грає" за прапорцем, але update() на
|
||||
неї ніхто не кличе, і вона застигає навічно.
|
||||
- **Важливий урок з цієї сесії**: "збір ресурсу" (`ResourceC`) і "передача
|
||||
в Pay Zone" (`PayZoneC`) — два НЕЗАЛЕЖНІ механізми, і мають лишатись
|
||||
такими. Не гейтувати `ResourceC.collect()` жодною умовою про гравця/зону
|
||||
— це те, що вже одного разу зламало базовий збір (ресурси лежали на
|
||||
землі й не зникали). Будь-яку майбутню умову про "стояння в зоні" чіпляти
|
||||
тільки на стороні `PayZoneC` (він і так окремо стежить за
|
||||
`getCollectedCount()` — `deposited`, тобто "хвостом" з незданого).
|
||||
- **Ще один урок**: не вгадувати розмір/радіус тригер-зони на око зі
|
||||
скріншота — рахувати з реальної геометрії (`Box3().setFromObject(node)`).
|
||||
Саме це зламало `PayZoneC.isPlayerInside` (радіус=2 замалий за фактичний
|
||||
прямокутник ~6×4.6, ще й не центрований на origin).
|
||||
- Ручна перевірка (флеш/нахил крейтів, повне зникнення після 2 хітів на
|
||||
стейдж, базовий збір ресурсів без прив'язки до зони, злив ресурсів у Pay
|
||||
Zone тільки при стоянні там — тепер по реальному Box3, не вгаданому
|
||||
радіусу, зникнення зони після `RESOURCES_TO_CLOSE_ZONE` доставлених
|
||||
(100 на момент цього запису, зараз 20 — див. "прогрес-заповнення зони"),
|
||||
відсутність зайвих текстур навколо Pay Zone) — стан на кінець Day 5/6;
|
||||
усе, що сталось після (HUD, камера, VFX, прогрес-бар зони), має власні
|
||||
"Перевірено" нотатки нижче за текстом.
|
||||
+2
-1
@@ -21,7 +21,8 @@
|
||||
"cannon-es-debugger": "^1.0.0",
|
||||
"howler": "^2.2.4",
|
||||
"nipplejs": "^1.0.4",
|
||||
"three": "^0.185.1"
|
||||
"three": "^0.185.1",
|
||||
"three.quarks": "^0.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/howler": "^2.2.13",
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { AdditiveBlending, Color, Euler, Mesh, MeshBasicMaterial, Object3D, Vector3 } from "three";
|
||||
import { Easing, Tween } from "@tweenjs/tween.js";
|
||||
import { TweenC } from "@hitplay/playable_template";
|
||||
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
|
||||
import { ResourceC } from "./ResourceC";
|
||||
import { PropVfxC } from "./PropVfxC";
|
||||
|
||||
// A "Lootable" crate in the map glb is authored as:
|
||||
// Wooden_Box_XXX (root)
|
||||
// BoxCollider.NNN <- the obstacle/hit box, also in the generic /collider/i list
|
||||
// Wooden_Box_States_XXX
|
||||
// Wooden_Box_XXX_S1 <- least damaged
|
||||
// Wooden_Box_XXX_S2
|
||||
// Wooden_Box_XXX_S3 <- most damaged/rubble
|
||||
// Not every crate has all three stages — some start pre-damaged (S2/S3 only)
|
||||
// or pre-broken (S3 only) as level-design variety, so "hit" always just
|
||||
// advances to the next stage that exists, whatever that is.
|
||||
interface StageVisual {
|
||||
node: Object3D; // the "_S1"/"_S2"/"_S3" group — toggled visible per stage
|
||||
mesh: Mesh | null; // the actual textured mesh inside it, for the hit-flash
|
||||
// A same-geometry clone rendered on top with additive blending, opacity
|
||||
// animated for the hit-flash — see buildStageVisual for why lerping the
|
||||
// real material's own color doesn't work here.
|
||||
flashMesh: Mesh | null;
|
||||
}
|
||||
|
||||
export interface BreakableProp {
|
||||
root: Object3D;
|
||||
colliderNode: Object3D;
|
||||
baseRotation: Euler;
|
||||
stages: StageVisual[];
|
||||
stageIndex: number;
|
||||
hitsOnStage: number;
|
||||
physicsBody: PhysicsBody;
|
||||
hitTween: Tween<{ t: number }> | null;
|
||||
}
|
||||
|
||||
const STAGE_NUMBER_PATTERN = /_S(\d+)$/i;
|
||||
const COLLIDER_NAME_PATTERN = /collider/i;
|
||||
const STATES_GROUP_PATTERN = /_states/i;
|
||||
const SHADOW_MESH_PATTERN = /shadow/i;
|
||||
|
||||
// Two hits to knock a crate from one damage stage to the next (or to
|
||||
// destroy it, on its last stage).
|
||||
const HITS_PER_STAGE = 2;
|
||||
|
||||
// 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.
|
||||
const STAGE_HIT_RESOURCE_RANGE: [number, number] = [1, 2];
|
||||
const DESTROY_RESOURCE_RANGE: [number, number] = [2, 5];
|
||||
|
||||
// Hit feedback: a brief white flash (in, then out) plus a lean away from
|
||||
// wherever the hit came from, springing back to its resting angle.
|
||||
const HIT_FLASH_UP_MS = 90;
|
||||
const HIT_FLASH_DOWN_MS = 150;
|
||||
const HIT_TILT_ANGLE = -0.16; // radians, ~7°
|
||||
const FLASH_COLOR = new Color(0xffffff);
|
||||
|
||||
// Where the spark burst spawns, relative to the crate's own pivot: nudged
|
||||
// toward the attacker (opposite hitDirection) so it sits near the surface
|
||||
// facing them rather than dead-center inside the crate, and up to roughly
|
||||
// where the bat actually connects rather than at the ground.
|
||||
const CONTACT_POINT_INSET = 0.3;
|
||||
const CONTACT_POINT_HEIGHT = 0.6;
|
||||
|
||||
// Disappear-on-destroy: sinks straight down into the ground while toppling
|
||||
// away from the hit that destroyed it, instead of shrinking in place.
|
||||
const DESTROY_SINK_MS = 250;
|
||||
const DESTROY_SINK_DEPTH = 1.2; // world units — enough to fully submerge below the visible ground
|
||||
const DESTROY_TILT_ANGLE = 0.6; // radians, ~34° — a pronounced topple, not a subtle lean
|
||||
|
||||
export class BreakablePropC {
|
||||
private static byColliderNode = new Map<Object3D, BreakableProp>();
|
||||
private static onDestroyed: ((colliderNode: Object3D) => void) | null = null;
|
||||
|
||||
// Returns the collider nodes it took ownership of, so the caller (which
|
||||
// already built a generic /collider/i list for movement/camera raycasts)
|
||||
// knows not to also give those the same treatment as plain walls.
|
||||
static init(
|
||||
lootableRoot: Object3D | undefined,
|
||||
onDestroyed: (colliderNode: Object3D) => void
|
||||
): Set<Object3D> {
|
||||
this.byColliderNode.clear();
|
||||
this.onDestroyed = onDestroyed;
|
||||
|
||||
if (lootableRoot) {
|
||||
for (const crateRoot of lootableRoot.children) {
|
||||
const prop = this.buildProp(crateRoot);
|
||||
if (prop) this.byColliderNode.set(prop.colliderNode, prop);
|
||||
}
|
||||
}
|
||||
|
||||
return new Set(this.byColliderNode.keys());
|
||||
}
|
||||
|
||||
static getByColliderNode(node: Object3D): BreakableProp | undefined {
|
||||
return this.byColliderNode.get(node);
|
||||
}
|
||||
|
||||
// HITS_PER_STAGE hits advance exactly one stage; past the last stage that
|
||||
// exists for this crate, the hit that would've been one more advance
|
||||
// fully destroys it instead. Every hit plays feedback; a stage advance
|
||||
// (or the destroying hit) also drops resources — more on that final hit.
|
||||
// `hitDirection` (attacker -> target, XZ, normalized) drives which way
|
||||
// the crate leans on a non-destroying hit.
|
||||
static hit(prop: BreakableProp, hitDirection: Vector3) {
|
||||
prop.hitsOnStage++;
|
||||
if (prop.hitsOnStage < HITS_PER_STAGE) {
|
||||
this.playHitFeedback(prop, hitDirection);
|
||||
return;
|
||||
}
|
||||
prop.hitsOnStage = 0;
|
||||
|
||||
// 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.
|
||||
const dropOrigin = prop.root.getWorldPosition(new Vector3()).add(new Vector3(0, 0.5, 0));
|
||||
|
||||
const nextIndex = prop.stageIndex + 1;
|
||||
if (nextIndex < prop.stages.length) {
|
||||
prop.stages[prop.stageIndex].node.visible = false;
|
||||
prop.stages[nextIndex].node.visible = true;
|
||||
prop.stageIndex = nextIndex;
|
||||
ResourceC.spawnBurstInRange(dropOrigin, ...STAGE_HIT_RESOURCE_RANGE);
|
||||
this.playHitFeedback(prop, hitDirection);
|
||||
return;
|
||||
}
|
||||
|
||||
ResourceC.spawnBurstInRange(dropOrigin, ...DESTROY_RESOURCE_RANGE);
|
||||
this.destroy(prop, hitDirection);
|
||||
}
|
||||
|
||||
private static buildProp(root: Object3D): BreakableProp | null {
|
||||
const colliderNode = root.children.find((child) => COLLIDER_NAME_PATTERN.test(child.name));
|
||||
const statesGroup = root.children.find((child) => STATES_GROUP_PATTERN.test(child.name));
|
||||
if (!colliderNode || !statesGroup) return null;
|
||||
|
||||
const stageNodes = [...statesGroup.children].sort(
|
||||
(a, b) => this.stageNumber(a) - this.stageNumber(b)
|
||||
);
|
||||
if (stageNodes.length === 0) return null;
|
||||
|
||||
const stages: StageVisual[] = stageNodes.map((node, index) => {
|
||||
node.visible = index === 0;
|
||||
return this.buildStageVisual(node);
|
||||
});
|
||||
|
||||
const physicsBody = new PhysicsBody(
|
||||
colliderNode,
|
||||
false,
|
||||
0,
|
||||
PhysicsLayer.Wall,
|
||||
PhysicsLayer.Player | PhysicsLayer.Enemy
|
||||
);
|
||||
|
||||
return {
|
||||
root,
|
||||
colliderNode,
|
||||
baseRotation: root.rotation.clone(),
|
||||
stages,
|
||||
stageIndex: 0,
|
||||
hitsOnStage: 0,
|
||||
physicsBody,
|
||||
hitTween: null,
|
||||
};
|
||||
}
|
||||
|
||||
// The crate mesh uses an unlit MeshBasicMaterial with color already at
|
||||
// pure white (confirmed by logging it — the texture supplies all the
|
||||
// actual color, the tint is just left at its default) — lerping that
|
||||
// color toward FLASH_COLOR (also white) is a no-op, which is why the
|
||||
// flash was invisible despite the tween running. Instead, a same-geometry
|
||||
// clone is layered on top with additive blending: animating ITS opacity
|
||||
// reads as a flash regardless of what color/texture the base mesh has.
|
||||
private static buildStageVisual(node: Object3D): StageVisual {
|
||||
const mesh = node.children.find(
|
||||
(child): child is Mesh => child instanceof Mesh && !SHADOW_MESH_PATTERN.test(child.name)
|
||||
);
|
||||
if (!mesh) return { node, mesh: null, flashMesh: null };
|
||||
|
||||
const flashMesh = mesh.clone();
|
||||
flashMesh.material = new MeshBasicMaterial({
|
||||
color: FLASH_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
// Shadows off — this is a same-shape overlay that's invisible almost
|
||||
// all the time, not a real surface; TestSceneC's later
|
||||
// setShadowsStateForChildren(map, true, true) would otherwise flip
|
||||
// these back on (see disableFlashShadows, called after that).
|
||||
flashMesh.castShadow = false;
|
||||
flashMesh.receiveShadow = false;
|
||||
node.add(flashMesh);
|
||||
|
||||
return { node, mesh, flashMesh };
|
||||
}
|
||||
|
||||
// TestSceneC's setShadowsStateForChildren(map, true, true) runs after
|
||||
// BreakablePropC.init() and force-enables shadows on every mesh under the
|
||||
// map, including the flash overlays built above — call this right after
|
||||
// that to turn them back off, so the flash meshes don't double the
|
||||
// currently-visible crates' shadow-casting cost for no visual benefit.
|
||||
static disableFlashShadows() {
|
||||
for (const prop of this.byColliderNode.values()) {
|
||||
for (const stage of prop.stages) {
|
||||
if (!stage.flashMesh) continue;
|
||||
stage.flashMesh.castShadow = false;
|
||||
stage.flashMesh.receiveShadow = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static stageNumber(stageNode: Object3D): number {
|
||||
const match = STAGE_NUMBER_PATTERN.exec(stageNode.name);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
// Kills any feedback still playing from a previous hit before starting a
|
||||
// new one — always relative to the crate's original resting rotation
|
||||
// (captured once at build time), never the current one, so interrupting
|
||||
// mid-lean can't drift the crate further off-angle with each hit.
|
||||
private static playHitFeedback(prop: BreakableProp, hitDirection: Vector3) {
|
||||
prop.hitTween?.stop();
|
||||
|
||||
const contactPoint = prop.root.getWorldPosition(new Vector3());
|
||||
contactPoint.addScaledVector(hitDirection, -CONTACT_POINT_INSET);
|
||||
contactPoint.y += CONTACT_POINT_HEIGHT;
|
||||
PropVfxC.spawnHit(contactPoint);
|
||||
|
||||
const stage = prop.stages[prop.stageIndex];
|
||||
const flashMaterial = stage.flashMesh?.material as MeshBasicMaterial | undefined;
|
||||
|
||||
const tiltX = -hitDirection.z * HIT_TILT_ANGLE;
|
||||
const tiltZ = hitDirection.x * HIT_TILT_ANGLE;
|
||||
|
||||
const apply = ({ t }: { t: number }) => {
|
||||
if (flashMaterial) flashMaterial.opacity = t;
|
||||
prop.root.rotation.set(
|
||||
prop.baseRotation.x + tiltX * t,
|
||||
prop.baseRotation.y,
|
||||
prop.baseRotation.z + tiltZ * t
|
||||
);
|
||||
};
|
||||
|
||||
const flashIn = new Tween({ t: 0 }).to({ t: 1 }, HIT_FLASH_UP_MS).easing(Easing.Quadratic.Out).onUpdate(apply);
|
||||
const flashOut = new Tween({ t: 1 }).to({ t: 0 }, HIT_FLASH_DOWN_MS).easing(Easing.Quadratic.In).onUpdate(apply);
|
||||
// chain() only tells flashIn to call flashOut.start() on completion —
|
||||
// it does NOT add flashOut to any group. Without adding it here too,
|
||||
// flashOut marks itself "playing" but nothing ever calls its update(),
|
||||
// so it just freezes at t=1 (fully flashed/tilted) forever.
|
||||
flashIn.chain(flashOut);
|
||||
|
||||
TweenC.add(flashIn);
|
||||
TweenC.add(flashOut);
|
||||
flashIn.start();
|
||||
prop.hitTween = flashIn;
|
||||
}
|
||||
|
||||
private static destroy(prop: BreakableProp, hitDirection: Vector3) {
|
||||
prop.physicsBody.destroy();
|
||||
this.byColliderNode.delete(prop.colliderNode);
|
||||
this.onDestroyed?.(prop.colliderNode);
|
||||
prop.hitTween?.stop();
|
||||
PropVfxC.spawnDestroy(prop.root.getWorldPosition(new Vector3()));
|
||||
this.playDisappearAnimation(prop, hitDirection);
|
||||
}
|
||||
|
||||
// Fully gone, not just its collider — sinks straight down into the ground
|
||||
// while toppling away from the hit that destroyed it (same lean
|
||||
// convention as playHitFeedback), only hidden once fully submerged.
|
||||
private static playDisappearAnimation(prop: BreakableProp, hitDirection: Vector3) {
|
||||
const root = prop.root;
|
||||
const baseY = root.position.y;
|
||||
const tiltX = -hitDirection.z * DESTROY_TILT_ANGLE;
|
||||
const tiltZ = hitDirection.x * DESTROY_TILT_ANGLE;
|
||||
|
||||
const sink = new Tween({ t: 0 })
|
||||
.to({ t: 1 }, DESTROY_SINK_MS)
|
||||
.easing(Easing.Quadratic.In)
|
||||
.onUpdate(({ t }) => {
|
||||
root.position.y = baseY - DESTROY_SINK_DEPTH * t;
|
||||
root.rotation.set(
|
||||
prop.baseRotation.x + tiltX * t,
|
||||
prop.baseRotation.y,
|
||||
prop.baseRotation.z + tiltZ * t
|
||||
);
|
||||
})
|
||||
.onComplete(() => (root.visible = false));
|
||||
|
||||
TweenC.add(sink);
|
||||
sink.start();
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,121 @@
|
||||
import { Object3D, Raycaster, Vector3 } from "three";
|
||||
import { Object3D, Vector3 } from "three";
|
||||
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
|
||||
|
||||
// How fast the camera closes the gap to its target position/look point, per
|
||||
// second. Higher = snappier, lower = floatier. Framerate-independent (see
|
||||
// the exponential smoothing in update()), so the feel is the same at 30fps
|
||||
// and 60fps instead of drifting with frame time.
|
||||
// How fast the camera closes the gap to its target position, per second.
|
||||
// Higher = snappier, lower = floatier. Framerate-independent (see the
|
||||
// exponential smoothing in update()), so the feel is the same at 30fps and
|
||||
// 60fps instead of drifting with frame time.
|
||||
const POSITION_DAMPING = 5;
|
||||
const LOOK_DAMPING = 8;
|
||||
|
||||
// Keeps the camera a bit off any obstacle it lands on, and never lets it
|
||||
// collapse onto the target itself.
|
||||
const OBSTACLE_SKIN = 0.4;
|
||||
const MIN_DISTANCE = 1.5;
|
||||
// How far (world units) the camera's target position pans in the direction
|
||||
// the character is actually TRANSLATING — a fixed-length nudge, not scaled
|
||||
// by speed, so it's the full offset the instant they're moving at all and
|
||||
// zero the instant they stop. Deliberately derived from position deltas,
|
||||
// not facing/quaternion: that was the earlier version, and even damped, a
|
||||
// vector tied to facing rotates right along with a stationary turn (e.g.
|
||||
// pivoting to face a crate) and drags the camera along an arc. A vector
|
||||
// tied to actual movement can't do that — turning in place is zero
|
||||
// movement, so it's zero look-ahead, however the character is turning.
|
||||
//
|
||||
// Bigger than the old facing-based version's 1.2 — that version's visible
|
||||
// "shift" came from two effects stacked together (the position nudge AND
|
||||
// camera.lookAt() re-aiming toward the shifted point every frame); with
|
||||
// rotation now completely fixed, only the position nudge is left, so it
|
||||
// has to move further on its own to read as a comparable shift from a
|
||||
// camera sitting ~12.7 units away (the (0,9,-9) rig offset).
|
||||
const LOOK_AHEAD_DISTANCE = 1.5;
|
||||
|
||||
// How far (world units) the whole camera rig — position and look-at point
|
||||
// alike — pans toward wherever the character is currently facing, to open
|
||||
// up more of the space ahead of them instead of centering them dead-on.
|
||||
// Matches PlayerC's own forward convention (+z at rest); the two aren't
|
||||
// coupled in code, but there's only one character to stay in sync with.
|
||||
const LOOK_AHEAD_DISTANCE = 1.2;
|
||||
// Smooths transitions in and out of the look-ahead pan (starting/stopping)
|
||||
// so it doesn't snap. Separate from, and slower than, the position damping
|
||||
// below — see update(). Slower than before (was 2) per feedback: the ease
|
||||
// itself needed to read more gradual.
|
||||
const LOOK_AHEAD_DAMPING = 1.2;
|
||||
|
||||
// The character's body can spin at up to 540°/s (PlayerC.MAX_TURN_SPEED),
|
||||
// which would yank the look-ahead point around just as fast if it followed
|
||||
// the raw facing direction. Smoothing the direction itself, separately from
|
||||
// (and slower than) the position/look damping below, is what actually makes
|
||||
// the pan gentle regardless of how fast the character turns.
|
||||
const LOOK_AHEAD_DAMPING = 2;
|
||||
|
||||
const FORWARD_AXIS = new Vector3(0, 0, 1);
|
||||
// Below this much squared movement in a single frame, treat the character
|
||||
// as stationary — guards against floating-point noise flickering a tiny
|
||||
// direction in and out while genuinely standing still.
|
||||
const MOVEMENT_EPSILON_SQ = 0.0005 * 0.0005;
|
||||
|
||||
export class CameraFollowC {
|
||||
private static target: Object3D | null = null;
|
||||
private static offset = new Vector3();
|
||||
private static obstacles: Object3D[] = [];
|
||||
private static eyeHeight = 0;
|
||||
private static smoothedLookAt = new Vector3();
|
||||
// The actual (x,y,z) offset, not a direction — see update() for why that
|
||||
// distinction matters.
|
||||
private static smoothedLookAhead = FORWARD_AXIS.clone().multiplyScalar(LOOK_AHEAD_DISTANCE);
|
||||
private static raycaster = new Raycaster();
|
||||
private static previousTargetPosition = new Vector3();
|
||||
// The last direction actually walked in, held indefinitely — see update()
|
||||
// for why this must never get reset to zero on its own.
|
||||
private static desiredLookAhead = new Vector3();
|
||||
private static smoothedLookAhead = new Vector3();
|
||||
// Injected (avoids importing PlayerC directly) — see setFacingTargetGetter.
|
||||
private static facingTargetGetter: (() => Vector3 | null) | null = null;
|
||||
|
||||
static init(target: Object3D, offset: Vector3, obstacles: Object3D[] = [], eyeHeight = 0) {
|
||||
static init(target: Object3D, offset: Vector3) {
|
||||
this.target = target;
|
||||
this.offset = offset.clone();
|
||||
this.obstacles = obstacles;
|
||||
this.eyeHeight = eyeHeight;
|
||||
this.smoothedLookAt.copy(target.position);
|
||||
this.previousTargetPosition.copy(target.position);
|
||||
this.desiredLookAhead.set(0, 0, 0);
|
||||
this.smoothedLookAhead.set(0, 0, 0);
|
||||
|
||||
// The camera's angle is set once, here, from the actual offset geometry
|
||||
// — not left at whatever camera_rotation_p/_l happens to hold, since
|
||||
// that config value was never actually tuned to work standalone (the
|
||||
// old per-frame lookAt() always overwrote it immediately). update()
|
||||
// below never touches rotation again after this — camera.position is
|
||||
// the only thing it ever moves, in a straight line toward a single
|
||||
// target point (see chat: no facing-based pivoting, just two points
|
||||
// and a line between them).
|
||||
const camera = CameraC_internal.getCamera();
|
||||
camera.position.copy(target.position).add(this.offset);
|
||||
camera.lookAt(target.position);
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
|
||||
}
|
||||
|
||||
// Lets the look-ahead react to the character locking onto a nearby
|
||||
// breakable while stopped (see PlayerC.getFacingTarget) — that rotation
|
||||
// carries zero movement, so it would otherwise be invisible to the
|
||||
// movement-only look-ahead below. Deliberately fed a fixed world POINT,
|
||||
// not the character's facing/quaternion: a point doesn't rotate as the
|
||||
// body turns to face it, so easing toward it is a single linear shift,
|
||||
// not the arc/orbit a facing-tied vector produced (see chat).
|
||||
static setFacingTargetGetter(getter: () => Vector3 | null) {
|
||||
this.facingTargetGetter = getter;
|
||||
}
|
||||
|
||||
private static update(delta: number) {
|
||||
if (!this.target) return;
|
||||
|
||||
const camera = CameraC_internal.getCamera();
|
||||
const targetPosition = this.target.position;
|
||||
|
||||
const facing = FORWARD_AXIS.clone().applyQuaternion(this.target.quaternion);
|
||||
facing.y = 0;
|
||||
facing.normalize();
|
||||
const desiredLookAhead = facing.multiplyScalar(LOOK_AHEAD_DISTANCE);
|
||||
const movement = targetPosition.clone().sub(this.previousTargetPosition);
|
||||
this.previousTargetPosition.copy(targetPosition);
|
||||
movement.y = 0;
|
||||
|
||||
const facingTarget = this.facingTargetGetter ? this.facingTargetGetter() : null;
|
||||
|
||||
if (facingTarget) {
|
||||
// Locked onto a nearby breakable — pan toward that fixed point
|
||||
// instead of a movement delta (there isn't one; the character is
|
||||
// stopped). See setFacingTargetGetter for why a point is safe here
|
||||
// where facing/quaternion wasn't.
|
||||
const toTarget = facingTarget.clone().sub(targetPosition);
|
||||
toTarget.y = 0;
|
||||
if (toTarget.lengthSq() > 1e-6) {
|
||||
this.desiredLookAhead.copy(toTarget.normalize().multiplyScalar(LOOK_AHEAD_DISTANCE));
|
||||
}
|
||||
} else if (movement.lengthSq() > MOVEMENT_EPSILON_SQ) {
|
||||
// Only updates the held direction while actually translating — when
|
||||
// the character stops (and isn't locked onto a target either), this
|
||||
// is simply skipped, so the pan holds at wherever it last settled
|
||||
// instead of easing back to center (see chat: "камера після зсуву не
|
||||
// має повертатись назад... тільки якщо він змінить напрям"). It only
|
||||
// moves again once movement resumes in a different direction.
|
||||
this.desiredLookAhead.copy(movement).normalize().multiplyScalar(LOOK_AHEAD_DISTANCE);
|
||||
}
|
||||
|
||||
// Damping the raw offset vector (not a unit direction re-normalized
|
||||
// every frame) is what keeps this a straight-line move through the
|
||||
// character on a direction change, instead of an arc: re-normalizing
|
||||
// pins the vector's length at LOOK_AHEAD_DISTANCE the whole time, which
|
||||
// forces it to sweep around a circle of that radius as the direction
|
||||
// changes. Left as plain (x,y,z) lerp, it can shrink through zero and
|
||||
// grow back out the other way — a straight line, not a curve.
|
||||
const lookAheadT = 1 - Math.exp(-LOOK_AHEAD_DAMPING * delta);
|
||||
this.smoothedLookAhead.lerp(desiredLookAhead, lookAheadT);
|
||||
const lookAhead = this.smoothedLookAhead;
|
||||
this.smoothedLookAhead.lerp(this.desiredLookAhead, lookAheadT);
|
||||
|
||||
const desiredPosition = targetPosition.clone().add(this.offset).add(lookAhead);
|
||||
this.avoidObstacles(targetPosition, desiredPosition);
|
||||
const desiredPosition = targetPosition.clone().add(this.offset).add(this.smoothedLookAhead);
|
||||
|
||||
// Exponential (framerate-independent) damping instead of a fixed lerp
|
||||
// factor per frame — a fixed factor changes speed with the frame rate
|
||||
@@ -81,35 +123,8 @@ export class CameraFollowC {
|
||||
const positionT = 1 - Math.exp(-POSITION_DAMPING * delta);
|
||||
camera.position.lerp(desiredPosition, positionT);
|
||||
|
||||
const lookT = 1 - Math.exp(-LOOK_DAMPING * delta);
|
||||
this.smoothedLookAt.lerp(targetPosition.clone().add(lookAhead), lookT);
|
||||
camera.lookAt(this.smoothedLookAt);
|
||||
}
|
||||
|
||||
// Only pulls the camera in when an obstacle would actually hide the
|
||||
// character. The check ray starts at (roughly) eye height instead of the
|
||||
// character's center/feet, so something that doesn't reach that high never
|
||||
// triggers a zoom — the character is still visible over it. When it does
|
||||
// trigger, the ray's own hit distance (measured from the character, not
|
||||
// from the camera) is what places the camera, so it lands right next to
|
||||
// the obstacle on the character's side instead of collapsing onto the
|
||||
// character whenever they aren't standing flush against it.
|
||||
private static avoidObstacles(targetPosition: Vector3, desired: Vector3) {
|
||||
if (this.obstacles.length === 0) return;
|
||||
|
||||
const eyePosition = targetPosition.clone().add(new Vector3(0, this.eyeHeight, 0));
|
||||
const toDesired = desired.clone().sub(eyePosition);
|
||||
const distance = toDesired.length();
|
||||
if (distance < 1e-4) return;
|
||||
|
||||
const direction = toDesired.divideScalar(distance);
|
||||
this.raycaster.set(eyePosition, direction);
|
||||
this.raycaster.far = distance;
|
||||
|
||||
const hits = this.raycaster.intersectObjects(this.obstacles, true);
|
||||
if (hits.length === 0) return;
|
||||
|
||||
const safeDistance = Math.max(hits[0].distance - OBSTACLE_SKIN, MIN_DISTANCE);
|
||||
desired.copy(eyePosition).addScaledVector(direction, safeDistance);
|
||||
// Rotation is deliberately never touched here (no lookAt) — this is a
|
||||
// linear-shift follow camera, not an orbiting one. The angle is fixed
|
||||
// once in init() and never changes afterward.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Vector3 } from "three";
|
||||
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import { resourceCounterBgSrc } from "../resources/images/images";
|
||||
|
||||
// How far in front of the camera (world units, along the screen ray) to
|
||||
// place the projected anchor point — getWorldAnchorPosition() below finds
|
||||
// the *direction* from the real DOM position via screen-to-NDC + unproject,
|
||||
// but a single 2D screen point maps to a whole ray in 3D, so a depth still
|
||||
// has to be chosen. Roughly matches the distance the old hand-picked local
|
||||
// offset sat at, so the flight arcs read about the same as before.
|
||||
const ANCHOR_DISTANCE = 2.5;
|
||||
|
||||
// Purely a prototype HUD — one counter for the one resource type that
|
||||
// exists (wood). No install-banner-style config UI wiring; just a fixed
|
||||
// DOM overlay mounted into the SDK's #ui container.
|
||||
export class HudC {
|
||||
private static initialized = false;
|
||||
private static plateEl: HTMLElement;
|
||||
private static countEl: HTMLElement;
|
||||
private static lastDisplayedCount = -1;
|
||||
private static balanceGetter: (() => number) | null = null;
|
||||
|
||||
static init() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
|
||||
this.buildDom();
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate(() => this.update());
|
||||
}
|
||||
|
||||
// Where collected resources fly to, and where PayZoneC's deposit flight
|
||||
// into the Pay Zone originates from — the plate's own right edge,
|
||||
// projected into 3D via the real DOM position (see chat: "дістати
|
||||
// позицію правого края юайки"), not a hand-picked local offset off the
|
||||
// camera like before. Recomputed from scratch on every call rather than
|
||||
// cached: it's only called once per resource-fly event (not per frame),
|
||||
// cheap enough, and this way it stays correct through window
|
||||
// resize/orientation changes for free.
|
||||
static getWorldAnchorPosition(): Vector3 {
|
||||
const canvasRect = ThreeC.renderer.domElement.getBoundingClientRect();
|
||||
const plateRect = this.plateEl.getBoundingClientRect();
|
||||
|
||||
const screenX = plateRect.right;
|
||||
const screenY = plateRect.top + plateRect.height / 2;
|
||||
|
||||
// NDC relative to the actual canvas box, not window.innerWidth/Height —
|
||||
// the SDK letterboxes/centers the canvas into a fixed 9:16 area (see
|
||||
// #ui/#editor in main.css), so the canvas's own bounding rect is the
|
||||
// only thing guaranteed to line up with the camera's projection.
|
||||
const ndcX = ((screenX - canvasRect.left) / canvasRect.width) * 2 - 1;
|
||||
const ndcY = -((screenY - canvasRect.top) / canvasRect.height) * 2 + 1;
|
||||
|
||||
const camera = CameraC_internal.getCamera();
|
||||
const point = new Vector3(ndcX, ndcY, 0.5).unproject(camera);
|
||||
const direction = point.sub(camera.position).normalize();
|
||||
return camera.position.clone().addScaledVector(direction, ANCHOR_DISTANCE);
|
||||
}
|
||||
|
||||
// The displayed number is a live balance, not a lifetime total — it
|
||||
// should rise as resources are collected and fall as they're delivered
|
||||
// into the Pay Zone (see chat). Injected rather than importing
|
||||
// ResourceC/PayZoneC directly, since PayZoneC already imports HudC (for
|
||||
// getWorldAnchorPosition) and importing back would cycle.
|
||||
static setBalanceGetter(getBalance: () => number) {
|
||||
this.balanceGetter = getBalance;
|
||||
}
|
||||
|
||||
// Layout/styling lives in src/css/ui.css (.resource-hud/__plate/__count) —
|
||||
// only the background image itself is set here, since its actual path
|
||||
// (base64 in a real build, a bare path in dev) is only known via the
|
||||
// asset import, not something a static CSS file can reference.
|
||||
//
|
||||
// Two nested divs, not one: .resource-hud positions/sizes and plays the
|
||||
// one-shot mount animation; .resource-hud__plate (background + number
|
||||
// together) is the separate element the update-bump animates — see the
|
||||
// comment in ui.css for why these can't share one element without the
|
||||
// two animations fighting each other.
|
||||
private static buildDom() {
|
||||
const holder = document.createElement("div");
|
||||
holder.className = "resource-hud";
|
||||
|
||||
const plate = document.createElement("div");
|
||||
plate.className = "resource-hud__plate";
|
||||
plate.style.backgroundImage = `url(${resourceCounterBgSrc})`;
|
||||
|
||||
const count = document.createElement("span");
|
||||
count.className = "resource-hud__count";
|
||||
count.textContent = "0";
|
||||
|
||||
plate.appendChild(count);
|
||||
holder.appendChild(plate);
|
||||
|
||||
const uiRoot = document.getElementById("ui") ?? document.body;
|
||||
uiRoot.appendChild(holder);
|
||||
|
||||
this.plateEl = plate;
|
||||
this.countEl = count;
|
||||
}
|
||||
|
||||
private static update() {
|
||||
if (!this.balanceGetter) return;
|
||||
const count = this.balanceGetter();
|
||||
if (count === this.lastDisplayedCount) return;
|
||||
this.lastDisplayedCount = count;
|
||||
this.countEl.textContent = String(count);
|
||||
|
||||
// The actual "pop" is a CSS animation on the whole plate (.is-bumping,
|
||||
// see ui.css), not just the digit — this only toggles the class,
|
||||
// forcing a reflow in between so it restarts cleanly even if the count
|
||||
// changes again before the previous bump finished (e.g. PayZoneC
|
||||
// draining resources every 0.3s).
|
||||
this.plateEl.classList.remove("is-bumping");
|
||||
void this.plateEl.offsetWidth;
|
||||
this.plateEl.classList.add("is-bumping");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Box3, Mesh, MeshBasicMaterial, Object3D, PlaneGeometry, Vector3 } from "three";
|
||||
import { CameraC_internal, TweenC, UpdateController } from "@hitplay/playable_template";
|
||||
import { Easing, Tween } from "@tweenjs/tween.js";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import { RESOURCE_ICON_BASE_SCALE, ResourceC } from "./ResourceC";
|
||||
import { HudC } from "./HudC";
|
||||
|
||||
// How many resources need to actually make it into the zone before it's
|
||||
// done and disappears — what happens after that is intentionally out of
|
||||
// scope for now (see chat).
|
||||
const RESOURCES_TO_CLOSE_ZONE = 20;
|
||||
|
||||
// While standing in the zone with resources still to deliver, one flies in
|
||||
// every DEPOSIT_INTERVAL seconds — a steady drain rather than a single
|
||||
// instant dump.
|
||||
const DEPOSIT_INTERVAL = 0.3;
|
||||
const DEPOSIT_FLIGHT_DURATION_MS = 500;
|
||||
// Redundant safety-net cleanup delay past the tween's own duration — see
|
||||
// the note in playDepositFlight on why this exists.
|
||||
const DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS = 300;
|
||||
|
||||
const PAY_ZONE_SHRINK_MS = 400;
|
||||
|
||||
// Extra multiplier on top of RESOURCE_ICON_BASE_SCALE (see ResourceC), just
|
||||
// for this deposit flight — tune this, not the shared constant, if only
|
||||
// the fly-to-Pay-Zone token needs to look smaller/bigger (see chat: "де я
|
||||
// можу зменшити розмір ресурсу під час анімації заповнення пей зони?").
|
||||
const DEPOSIT_FLIGHT_SCALE_MULTIPLIER = 0.3;
|
||||
|
||||
// Fill overlay: a flat quad laid over the zone's own footprint, growing
|
||||
// from its near edge (fixed, closest to the camera) toward the far edge as
|
||||
// deposits come in — a ground-level wipe, not a rising column (see chat:
|
||||
// two earlier attempts — a horizontal X wipe read as "right to left", then
|
||||
// a rising box read as "water filling a pool" — both wrong; this reads as
|
||||
// "bottom to top" on screen because +Z, this game's forward/"away from
|
||||
// camera" direction, projects toward the top of the screen under this
|
||||
// isometric-ish camera).
|
||||
const FILL_COLOR = 0x5ce65c;
|
||||
const FILL_OPACITY = 0.55;
|
||||
// Sits just above the zone's own ground-level quad so it doesn't z-fight
|
||||
// with it, but still reads as "on the ground" rather than floating.
|
||||
const FILL_Y_OFFSET = 0.02;
|
||||
|
||||
// Reuses the map's existing (already-visible) "UI_Interactive_Zone_02"
|
||||
// marker as both the pay zone's location and its visual — see CLAUDE.md.
|
||||
// ResourceC.getCollectedCount() keeps counting resources the moment
|
||||
// they're picked up near whatever crate dropped them (unrelated to this
|
||||
// zone); this controller separately drains that total into the zone,
|
||||
// resource by resource, only while the player is standing inside it.
|
||||
export class PayZoneC {
|
||||
private static payZoneNode: Object3D | null = null;
|
||||
private static target: Object3D | null = null;
|
||||
// The zone's actual world-space footprint (an XZ rectangle), computed
|
||||
// once from its real geometry rather than guessed as a fixed radius — a
|
||||
// hardcoded radius previously undershot the visible dashed square, so
|
||||
// standing anywhere but right on its center pivot silently never
|
||||
// counted as "inside".
|
||||
private static zoneBounds: Box3 | null = null;
|
||||
private static closed = false;
|
||||
private static deposited = 0;
|
||||
private static depositCooldown = 0;
|
||||
private static fillMesh: Mesh | null = null;
|
||||
|
||||
static init(payZoneNode: Object3D | undefined, target: Object3D) {
|
||||
this.payZoneNode = payZoneNode ?? null;
|
||||
this.target = target;
|
||||
this.zoneBounds = this.payZoneNode ? new Box3().setFromObject(this.payZoneNode) : null;
|
||||
this.closed = false;
|
||||
this.deposited = 0;
|
||||
this.depositCooldown = 0;
|
||||
this.fillMesh = this.zoneBounds ? this.buildFillMesh(this.zoneBounds) : null;
|
||||
this.updateFillVisual();
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
|
||||
}
|
||||
|
||||
// A unit quad, later scaled/positioned per-frame in updateFillVisual — a
|
||||
// fixed width (full X span of the zone) but a depth (world Z) that grows
|
||||
// from 0 up to the zone's full Z span as `deposited` approaches
|
||||
// RESOURCES_TO_CLOSE_ZONE.
|
||||
private static buildFillMesh(bounds: Box3): Mesh {
|
||||
const mesh = new Mesh(
|
||||
new PlaneGeometry(1, 1),
|
||||
new MeshBasicMaterial({
|
||||
color: FILL_COLOR,
|
||||
transparent: true,
|
||||
opacity: FILL_OPACITY,
|
||||
depthWrite: false,
|
||||
})
|
||||
);
|
||||
mesh.rotation.x = -Math.PI / 2; // lie flat on the ground, facing up
|
||||
mesh.position.y = bounds.min.y + FILL_Y_OFFSET;
|
||||
mesh.position.x = (bounds.min.x + bounds.max.x) / 2;
|
||||
mesh.scale.x = bounds.max.x - bounds.min.x;
|
||||
ThreeC.addToScene(mesh);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// Grows the fill quad from the zone's min-Z edge (fixed, nearest the
|
||||
// camera) toward its max-Z edge as `fraction` (0..1) increases — scaling
|
||||
// a centered quad also moves its center, so the Z position is re-derived
|
||||
// each time to keep that near edge pinned in place instead of drifting.
|
||||
private static updateFillVisual() {
|
||||
if (!this.fillMesh || !this.zoneBounds) return;
|
||||
|
||||
const fraction = this.closed ? 0 : Math.min(this.deposited / RESOURCES_TO_CLOSE_ZONE, 1);
|
||||
const depth = this.zoneBounds.max.z - this.zoneBounds.min.z;
|
||||
const filledDepth = depth * fraction;
|
||||
|
||||
this.fillMesh.scale.y = filledDepth; // local Y — maps to world Z after the rotation above
|
||||
this.fillMesh.position.z = this.zoneBounds.min.z + filledDepth / 2;
|
||||
}
|
||||
|
||||
static getTargetWorldPosition(): Vector3 {
|
||||
return this.payZoneNode ? this.payZoneNode.getWorldPosition(new Vector3()) : new Vector3();
|
||||
}
|
||||
|
||||
// How many resources have actually been delivered so far — HudC reads
|
||||
// this (via an injected getter, not a direct import, to avoid a
|
||||
// HudC <-> PayZoneC import cycle) to show a live balance that rises on
|
||||
// collection and falls as resources leave for the Pay Zone.
|
||||
static getDepositedCount(): number {
|
||||
return this.deposited;
|
||||
}
|
||||
|
||||
// XZ containment against the zone's real footprint (ignores Y — the
|
||||
// zone lies flat on the ground, height doesn't matter).
|
||||
static isPlayerInside(playerPosition: Vector3): boolean {
|
||||
if (!this.zoneBounds) return false;
|
||||
return (
|
||||
playerPosition.x >= this.zoneBounds.min.x &&
|
||||
playerPosition.x <= this.zoneBounds.max.x &&
|
||||
playerPosition.z >= this.zoneBounds.min.z &&
|
||||
playerPosition.z <= this.zoneBounds.max.z
|
||||
);
|
||||
}
|
||||
|
||||
private static update(delta: number) {
|
||||
if (this.closed || !this.payZoneNode || !this.target) return;
|
||||
if (this.depositCooldown > 0) this.depositCooldown -= delta;
|
||||
|
||||
const backlog = ResourceC.getCollectedCount() - this.deposited;
|
||||
if (backlog <= 0) return;
|
||||
if (this.depositCooldown > 0) return;
|
||||
if (!this.isPlayerInside(this.target.position)) return;
|
||||
|
||||
this.depositCooldown = DEPOSIT_INTERVAL;
|
||||
this.deposited++;
|
||||
this.playDepositFlight(HudC.getWorldAnchorPosition(), this.payZoneNode);
|
||||
this.updateFillVisual();
|
||||
|
||||
if (this.deposited >= RESOURCES_TO_CLOSE_ZONE) {
|
||||
this.closed = true;
|
||||
console.log(`[PayZoneC] zone full (${this.deposited} delivered) — closing`);
|
||||
this.playDisappear(this.payZoneNode);
|
||||
}
|
||||
}
|
||||
|
||||
// One resource, flying from the HUD counter (see chat: "від лічильника
|
||||
// до пей зони") into the zone itself — the player only needs to be
|
||||
// standing in the zone to trigger it, not to be the flight's origin.
|
||||
//
|
||||
// Traced via temporary logging (see chat): occasionally one of these
|
||||
// tweens never fires onComplete and freezes mid-flight forever — a
|
||||
// visible board-textured icon just hanging in the air (user report:
|
||||
// "текстура доски... постійно статично відображається"). Couldn't pin
|
||||
// down why inside tween.js/TweenC (a single, non-chained tween, added to
|
||||
// the group before .start() — the usual "chain() doesn't register the
|
||||
// second link" bug from Day 6 doesn't apply here). Rather than keep
|
||||
// digging, added a hard, redundant cleanup: a plain timeout that removes
|
||||
// the mesh regardless of whether the tween ever completes. Calling
|
||||
// removeFromScene twice (once from onComplete, once from the timeout, if
|
||||
// both fire) is harmless — the second call just finds no parent to
|
||||
// detach from.
|
||||
private static playDepositFlight(from: Vector3, payZoneNode: Object3D) {
|
||||
const mesh = ResourceC.createVisual();
|
||||
const to = payZoneNode.getWorldPosition(new Vector3());
|
||||
mesh.position.copy(from);
|
||||
ThreeC.addToScene(mesh);
|
||||
|
||||
// No rotation (see chat: "ротейту не має бути") — same as the
|
||||
// ground-to-HUD flight in ResourceC, this is position/scale only.
|
||||
//
|
||||
// Same perspective-compensation trick as ResourceC's toTarget phase,
|
||||
// just in reverse: this flight starts right at the HUD anchor (close
|
||||
// to the camera, see HudC.getWorldAnchorPosition) and moves out to the
|
||||
// Pay Zone (typically much farther away), so without this it would
|
||||
// visibly *shrink* as it recedes — pure perspective, not an actual
|
||||
// size change. Scaling by the ratio of current to starting
|
||||
// camera-distance cancels that, keeping it the one size the whole
|
||||
// flight (see chat: "розмір має бути таким самим як і в цій
|
||||
// анімації"). Anchored to RESOURCE_ICON_BASE_SCALE, not the mesh's own
|
||||
// default (1) — this token is a fresh ResourceC.createVisual(), not one
|
||||
// of ResourceC's own bounced-and-shrunk FlyingResources, so without
|
||||
// this it started noticeably larger than the resource that was just
|
||||
// sitting at that same HUD icon a moment ago (see chat: "дуже великий,
|
||||
// требя його уніфікувати").
|
||||
const cameraPosition = CameraC_internal.getCamera().position;
|
||||
const startDistance = cameraPosition.distanceTo(from);
|
||||
|
||||
const flight = new Tween({ t: 0 })
|
||||
.to({ t: 1 }, DEPOSIT_FLIGHT_DURATION_MS)
|
||||
.easing(Easing.Quadratic.InOut)
|
||||
.onUpdate(({ t }) => {
|
||||
mesh.position.lerpVectors(from, to, t);
|
||||
const currentDistance = cameraPosition.distanceTo(mesh.position);
|
||||
const perspectiveScale = startDistance > 1e-6 ? currentDistance / startDistance : 1;
|
||||
mesh.scale.setScalar(perspectiveScale * RESOURCE_ICON_BASE_SCALE * DEPOSIT_FLIGHT_SCALE_MULTIPLIER);
|
||||
})
|
||||
.onComplete(() => ThreeC.removeFromScene(mesh));
|
||||
|
||||
TweenC.add(flight);
|
||||
flight.start();
|
||||
|
||||
setTimeout(() => ThreeC.removeFromScene(mesh), DEPOSIT_FLIGHT_DURATION_MS + DEPOSIT_FLIGHT_CLEANUP_MARGIN_MS);
|
||||
}
|
||||
|
||||
// Shrinks the zone's own marker AND the fill overlay together, as one
|
||||
// piece — the fill previously just popped invisible instantly once the
|
||||
// marker's shrink finished (see chat: "прогрес заповненості не
|
||||
// зменшується разом з пей зоною"). Scaling the fill quad down from
|
||||
// whatever it currently is (full, since this only runs once the zone is
|
||||
// done) shrinks it toward its own center in lockstep with the marker.
|
||||
private static playDisappear(node: Object3D) {
|
||||
const fillMesh = this.fillMesh;
|
||||
const fillBaseScaleX = fillMesh?.scale.x ?? 0;
|
||||
const fillBaseScaleY = fillMesh?.scale.y ?? 0;
|
||||
|
||||
const shrink = new Tween({ s: 1 })
|
||||
.to({ s: 0 }, PAY_ZONE_SHRINK_MS)
|
||||
.easing(Easing.Quadratic.In)
|
||||
.onUpdate(({ s }) => {
|
||||
node.scale.setScalar(s);
|
||||
fillMesh?.scale.set(fillBaseScaleX * s, fillBaseScaleY * s, 1);
|
||||
})
|
||||
.onComplete(() => {
|
||||
node.visible = false;
|
||||
if (fillMesh) fillMesh.visible = false;
|
||||
});
|
||||
|
||||
TweenC.add(shrink);
|
||||
shrink.start();
|
||||
}
|
||||
}
|
||||
+27
-27
@@ -3,8 +3,8 @@ import {
|
||||
Physics_internal,
|
||||
UpdateController,
|
||||
} from "@hitplay/playable_template";
|
||||
import { Box3, Object3D, Vector3 } from "three";
|
||||
import { Body, Box, Quaternion, Sphere, Vec3 } from "cannon-es";
|
||||
import { Box3, Mesh, Object3D, Quaternion, Vector3 } from "three";
|
||||
import { Body, Box, Sphere, Vec3 } from "cannon-es";
|
||||
|
||||
export enum PhysicsLayer {
|
||||
Player = 1,
|
||||
@@ -27,28 +27,19 @@ export class PhysicsBody {
|
||||
) {
|
||||
let isPlayer = col_group === PhysicsLayer.Player;
|
||||
|
||||
let oldQuaternion = threeObj.quaternion.clone();
|
||||
|
||||
let nullQuaternion = new Quaternion();
|
||||
threeObj.quaternion.copy(nullQuaternion);
|
||||
|
||||
let bbox = new Box3().setFromObject(threeObj);
|
||||
|
||||
let size = new Vector3();
|
||||
bbox.getSize(size);
|
||||
|
||||
// if you need custom size
|
||||
// if (col_group === PhysicsLayer.wall) {
|
||||
// size.x = size.z = 1;
|
||||
// size.y = 1;
|
||||
// }
|
||||
|
||||
threeObj.quaternion.copy(oldQuaternion);
|
||||
// Local (unrotated) bounding size scaled by world scale, combined with
|
||||
// the world quaternion below, instead of a world-space Box3 with just
|
||||
// this object's own rotation zeroed out — colliders here are nested
|
||||
// under rotated parents (e.g. a Lootable crate's root), and a Box3
|
||||
// still bakes in every ancestor's rotation, so it comes out skewed/
|
||||
// oversized for anything not axis-aligned all the way up the chain.
|
||||
let size = isPlayer ? new Vector3() : PhysicsBody.getLocalSize(threeObj);
|
||||
let worldScale = threeObj.getWorldScale(new Vector3());
|
||||
size.multiply(worldScale);
|
||||
|
||||
this.body = new Body({
|
||||
isTrigger: trigger,
|
||||
mass: mass,
|
||||
//shape: shape,
|
||||
shape: isPlayer
|
||||
? new Sphere(player_sphere)
|
||||
: new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2)),
|
||||
@@ -57,15 +48,10 @@ export class PhysicsBody {
|
||||
});
|
||||
|
||||
let worldPos = threeObj.getWorldPosition(new Vector3());
|
||||
|
||||
this.body.position.set(worldPos.x, worldPos.y, worldPos.z);
|
||||
|
||||
this.body.quaternion.setFromEuler(
|
||||
threeObj.rotation.x,
|
||||
threeObj.rotation.y,
|
||||
threeObj.rotation.z,
|
||||
"XYZ"
|
||||
);
|
||||
let worldQuat = threeObj.getWorldQuaternion(new Quaternion());
|
||||
this.body.quaternion.set(worldQuat.x, worldQuat.y, worldQuat.z, worldQuat.w);
|
||||
|
||||
// if you need sync three obj and physics body
|
||||
// if (isEnemy) {
|
||||
@@ -81,6 +67,20 @@ export class PhysicsBody {
|
||||
return this;
|
||||
}
|
||||
|
||||
// Mesh geometry's own bounding box lives in local, unrotated mesh space —
|
||||
// exactly what an oriented Box shape needs before the world quaternion
|
||||
// above places and rotates it. Falls back to a Box3 for non-mesh objects
|
||||
// (e.g. an empty group), which is only correct if nothing up its parent
|
||||
// chain is rotated.
|
||||
private static getLocalSize(threeObj: Object3D): Vector3 {
|
||||
const mesh = threeObj as Mesh;
|
||||
if (mesh.geometry) {
|
||||
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
|
||||
return mesh.geometry.boundingBox!.getSize(new Vector3());
|
||||
}
|
||||
return new Box3().setFromObject(threeObj).getSize(new Vector3());
|
||||
}
|
||||
|
||||
disablePhysicsPair() {
|
||||
if (this.pair) {
|
||||
this.pair.destroyed = true;
|
||||
|
||||
+279
-67
@@ -11,10 +11,25 @@ import {
|
||||
} from "three";
|
||||
import { JoystickC, UpdateController } from "@hitplay/playable_template";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import { BreakableProp, BreakablePropC } from "./BreakablePropC";
|
||||
import { WeaponTrailC } from "./WeaponTrailC";
|
||||
|
||||
const MOVE_SPEED = 4; // world units per second
|
||||
const START_POSITION = { x: 1, y: 0, z: -8 }; // start of the road, the other end, feet on the ground
|
||||
|
||||
// Rate-limited ramps (units/second²) instead of snapping straight to
|
||||
// MOVE_SPEED — reaching full speed takes MOVE_SPEED/ACCELERATION seconds
|
||||
// (~0.33s), and stopping takes MOVE_SPEED/DECELERATION (~0.2s). Braking
|
||||
// faster than starting is the usual game-feel choice: it keeps stops feeling
|
||||
// responsive while movement still has some weight to it.
|
||||
const ACCELERATION = 12;
|
||||
const DECELERATION = 20;
|
||||
|
||||
function moveTowards(current: number, target: number, maxDelta: number): number {
|
||||
if (Math.abs(target - current) <= maxDelta) return target;
|
||||
return current + Math.sign(target - current) * maxDelta;
|
||||
}
|
||||
|
||||
// Collision box used for movement — a rough silhouette of the rigged model
|
||||
// (bbox is ~0.9 x 1.72 x 1.0), not its exact bounds.
|
||||
const PLAYER_SIZE = new Vector3(0.8, 1.7, 0.8);
|
||||
@@ -34,28 +49,62 @@ const MIN_OBSTACLE_HEIGHT = 0.5;
|
||||
// correction and a full reversal at the same rate, which reads as smooth.
|
||||
const MAX_TURN_SPEED = Math.PI * 3; // ~540°/s
|
||||
|
||||
// A near-full reversal (stick flicked to roughly the opposite direction)
|
||||
// snaps the facing instantly instead of turning gradually at MAX_TURN_SPEED
|
||||
// — rotating through a slow half-circle reads as sluggish for a direction
|
||||
// flip specifically. Smaller corrections still use the capped gradual turn.
|
||||
// ~150°, not a full 180°, so a nearly-opposite flick still snaps too.
|
||||
const OPPOSITE_TURN_THRESHOLD = Math.PI * (150 / 180);
|
||||
|
||||
const ANIMATION_FADE = 0.2; // seconds, crossfade between idle/walk/attack
|
||||
|
||||
// How far off-center (in the XZ plane) an obstacle can be from the
|
||||
// character's forward direction and still count as "facing it".
|
||||
// dot(forward, toObstacle) > this — 0.5 is a 120°-wide cone (60° each side).
|
||||
const FACING_DOT_THRESHOLD = 0.5;
|
||||
// dot(forward, toObstacle) > this — -0.5 is a 240°-wide cone (120° each side).
|
||||
const FACING_DOT_THRESHOLD = -0.5;
|
||||
|
||||
// tryMove() rejects the whole step that would overlap an obstacle, so the
|
||||
// player always stops just short of actually touching it (up to one frame's
|
||||
// movement worth of gap) — Box3.intersectsBox on the exact collision box
|
||||
// would basically never see contact. This extra margin, checked only for
|
||||
// the attack/interaction range (not movement), covers that gap.
|
||||
const INTERACTION_REACH = 0.4;
|
||||
const INTERACTION_REACH = 0.6; // +50%
|
||||
|
||||
// The attack loops continuously (see updateCombat) — the "Loot" clip's swing
|
||||
// actually connects twice per loop (two distinct bat impacts), so each loop
|
||||
// lands two damage pulses, not one, timed to these fractions of the clip
|
||||
// rather than right at the loop seam so the bat visually connects first.
|
||||
// Tune these against the actual clip if the impacts don't line up visually.
|
||||
const HIT_TIME_FRACTIONS = [0.40,0.60];
|
||||
|
||||
const UP_AXIS = new Vector3(0, 1, 0);
|
||||
const FORWARD_AXIS = new Vector3(0, 0, 1);
|
||||
|
||||
interface Obstacle {
|
||||
node: Object3D;
|
||||
box: Box3;
|
||||
}
|
||||
|
||||
interface NearbyObstacle {
|
||||
node: Object3D;
|
||||
center: Vector3;
|
||||
}
|
||||
|
||||
interface ZoneTarget {
|
||||
center: Vector3;
|
||||
prop: BreakableProp;
|
||||
}
|
||||
|
||||
export class PlayerC {
|
||||
static object: Object3D;
|
||||
|
||||
private static moveInput = new Vector2();
|
||||
private static obstacles: Box3[] = [];
|
||||
// Last non-zero input direction (normalized) — kept around so
|
||||
// deceleration still has a direction to coast along after the stick is
|
||||
// released and moveInput snaps to zero.
|
||||
private static moveDirection = new Vector2(0, 1);
|
||||
private static currentSpeed = 0;
|
||||
private static obstacles: Obstacle[] = [];
|
||||
private static facingRotation = new Quaternion();
|
||||
|
||||
private static mixer: AnimationMixer;
|
||||
@@ -64,6 +113,17 @@ export class PlayerC {
|
||||
private static attackAction: AnimationAction;
|
||||
private static currentAction: AnimationAction;
|
||||
private static isAttacking = false;
|
||||
private static attackElapsed = 0;
|
||||
private static nextHitTime = 0;
|
||||
// Counts up across the whole continuous attack (not reset per loop) —
|
||||
// used to pick which of HIT_TIME_FRACTIONS is next and which loop it
|
||||
// falls in, so timing stays correct across loop seams.
|
||||
private static hitsThisAttack = 0;
|
||||
|
||||
// The point the character is currently locked onto facing (a nearby
|
||||
// breakable it's stopped next to), or null while just moving/idling with
|
||||
// nothing to face. Exposed for CameraFollowC — see getFacingTarget().
|
||||
private static facingTarget: Vector3 | null = null;
|
||||
|
||||
private static pistol: Object3D;
|
||||
private static batInHand: Object3D;
|
||||
@@ -79,14 +139,20 @@ export class PlayerC {
|
||||
ThreeC.addToScene(this.object);
|
||||
|
||||
this.obstacles = colliders
|
||||
.map((collider) => new Box3().setFromObject(collider))
|
||||
.filter((box) => box.max.y - box.min.y >= MIN_OBSTACLE_HEIGHT);
|
||||
.map((collider) => ({ node: collider, box: new Box3().setFromObject(collider) }))
|
||||
.filter((obstacle) => obstacle.box.max.y - obstacle.box.min.y >= MIN_OBSTACLE_HEIGHT);
|
||||
|
||||
this.bindJoystick();
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
|
||||
}
|
||||
|
||||
// Called when a breakable prop's collider is torn down (see
|
||||
// BreakablePropC.destroy) so it stops blocking movement.
|
||||
static removeObstacle(node: Object3D) {
|
||||
this.obstacles = this.obstacles.filter((obstacle) => obstacle.node !== node);
|
||||
}
|
||||
|
||||
private static createCharacter() {
|
||||
const character = ThreeC.getObject("character");
|
||||
|
||||
@@ -119,6 +185,16 @@ export class PlayerC {
|
||||
this.equipPistol();
|
||||
}
|
||||
|
||||
// The in-hand bat prop's own node — WeaponTrailC samples its world
|
||||
// position every frame while attacking to build the swing trail. Its
|
||||
// pivot is whatever the artist rigged it to (likely the grip, not the
|
||||
// business end) — this hands over the raw node rather than guessing a
|
||||
// local tip offset; retune with a local-space offset in WeaponTrailC if
|
||||
// the trail visibly anchors at the handle instead of the bat's tip.
|
||||
static getWeaponAnchor(): Object3D {
|
||||
return this.batInHand;
|
||||
}
|
||||
|
||||
// Exactly one of {pistol, bat} is ever in hand, and the other is stowed —
|
||||
// the pistol has no separate "in hand" prop to show (see PlayerC chat
|
||||
// notes), so its own single mesh just toggles at its holster spot instead.
|
||||
@@ -150,6 +226,7 @@ export class PlayerC {
|
||||
this.idleAction = this.mixer.clipAction(idleClip);
|
||||
this.walkAction = this.mixer.clipAction(walkClip);
|
||||
this.attackAction = this.mixer.clipAction(attackClip);
|
||||
// Loops continuously while attacking — see updateCombat().
|
||||
this.attackAction.setLoop(LoopRepeat, Infinity);
|
||||
|
||||
this.currentAction = this.idleAction;
|
||||
@@ -178,44 +255,21 @@ export class PlayerC {
|
||||
}
|
||||
|
||||
private static update(delta: number) {
|
||||
const inputMagnitude = this.moveInput.length();
|
||||
// Movement always runs, attacking or not — moving is what interrupts an
|
||||
// attack (see updateCombat), so it can never be locked out.
|
||||
const nearbyObstacle = this.findNearbyObstacle();
|
||||
const stopped = this.updateMovement(delta, nearbyObstacle);
|
||||
|
||||
if (inputMagnitude > 0) {
|
||||
// Matches the position mapping below (-x -> x, y -> z): the angle a
|
||||
// movement vector needs to rotate the character's default +z-facing
|
||||
// front to point the same way. x is negated because the camera now
|
||||
// looks toward +Z (see TestSceneC's CAMERA_OFFSET) — that's a 180°
|
||||
// yaw from the default view, which mirrors world +X to screen-left,
|
||||
// so moving/facing needs the same mirror to keep "stick right" mean
|
||||
// "screen right".
|
||||
const angle = Math.atan2(-this.moveInput.x, this.moveInput.y);
|
||||
this.facingRotation.setFromAxisAngle(UP_AXIS, angle);
|
||||
|
||||
const step = MOVE_SPEED * delta;
|
||||
|
||||
// Move one axis at a time so a wall blocking one direction still lets
|
||||
// the player slide along it, instead of getting fully stuck.
|
||||
this.tryMove(-this.moveInput.x * step, 0);
|
||||
this.tryMove(0, this.moveInput.y * step);
|
||||
}
|
||||
|
||||
// Turn toward the last movement direction and hold it while idle,
|
||||
// rather than resetting to face forward the moment input stops.
|
||||
// rotateTowards caps the step at MAX_TURN_SPEED * delta radians instead
|
||||
// of interpolating a percentage of the remaining angle. Moving or
|
||||
// turning away from the obstacle is exactly what breaks updateCombat()'s
|
||||
// facing check below — no separate "walked/turned away" case needed.
|
||||
this.object.quaternion.rotateTowards(this.facingRotation, MAX_TURN_SPEED * delta);
|
||||
|
||||
this.updateCombat();
|
||||
this.updateCombat(delta, stopped);
|
||||
|
||||
if (!this.isAttacking) {
|
||||
if (inputMagnitude > 0) {
|
||||
// Match the walk cycle's playback speed to how hard the stick is
|
||||
// pushed, not just whether it's pushed — otherwise a small nudge
|
||||
// still plays the animation at full speed while the character
|
||||
// barely moves, and the feet visibly skate across the ground.
|
||||
this.walkAction.timeScale = inputMagnitude;
|
||||
if (this.currentSpeed > 0) {
|
||||
// Match the walk cycle's playback speed to actual current speed, not
|
||||
// just whether the stick is pushed — otherwise the animation would
|
||||
// freeze into Idle the instant input stops while the character is
|
||||
// still coasting to a stop, or run at full pace during the initial
|
||||
// ramp-up while barely moving.
|
||||
this.walkAction.timeScale = this.currentSpeed / MOVE_SPEED;
|
||||
this.playAction(this.walkAction);
|
||||
} else {
|
||||
this.playAction(this.idleAction);
|
||||
@@ -223,45 +277,203 @@ export class PlayerC {
|
||||
}
|
||||
}
|
||||
|
||||
// Attacking requires standing next to a destructible AND facing it — pure
|
||||
// proximity (e.g. backing into a crate) shouldn't swing the bat. Re-reads
|
||||
// both conditions every frame, so walking or turning away — or the
|
||||
// obstacle later being removed by a destruction system — stops it
|
||||
// automatically, with no separate "stop" case to maintain.
|
||||
private static updateCombat() {
|
||||
const obstacleCenter = this.getFacingObstacleCenter();
|
||||
const isAttacking = obstacleCenter !== null;
|
||||
// Stick-driven movement, facing, and turning — runs every frame regardless
|
||||
// of combat state (moving is what cancels an attack, see updateCombat, so
|
||||
// it can't be the thing gated off during one). Returns whether the
|
||||
// character is now fully stopped (no input AND speed decayed to zero),
|
||||
// the bar updateCombat uses to allow an attack to start or continue.
|
||||
private static updateMovement(delta: number, nearbyObstacle: NearbyObstacle | null): boolean {
|
||||
const inputMagnitude = this.moveInput.length();
|
||||
|
||||
if (isAttacking === this.isAttacking) return;
|
||||
this.isAttacking = isAttacking;
|
||||
// Movement direction tracks the stick independently of facing below, so
|
||||
// walking around/past an obstacle in range still works even though
|
||||
// facing itself may be locked onto it once stopped.
|
||||
if (inputMagnitude > 0) {
|
||||
this.moveDirection.copy(this.moveInput).divideScalar(inputMagnitude);
|
||||
|
||||
if (isAttacking) {
|
||||
// Direct world-space direction -> angle: the angle a movement vector
|
||||
// needs to rotate the character's default +z-facing front to point
|
||||
// the same way. x is negated because the camera now looks toward +Z
|
||||
// (see TestSceneC's CAMERA_OFFSET) — that's a 180° yaw from the
|
||||
// default view, which mirrors world +X to screen-left, so facing needs
|
||||
// the same mirror to keep "stick right" mean "screen right". Facing
|
||||
// follows the raw stick immediately — it's intent, not momentum, so it
|
||||
// shouldn't wait on the speed ramp below.
|
||||
const angle = Math.atan2(-this.moveInput.x, this.moveInput.y);
|
||||
this.facingRotation.setFromAxisAngle(UP_AXIS, angle);
|
||||
}
|
||||
|
||||
// Ramp actual speed toward the stick's target instead of snapping to it:
|
||||
// moveDirection (not moveInput) drives the step below, so releasing the
|
||||
// stick keeps coasting along the last heading while DECELERATION brings
|
||||
// this to zero, instead of stopping dead the instant input hits zero.
|
||||
const targetSpeed = inputMagnitude * MOVE_SPEED;
|
||||
const rate = targetSpeed > this.currentSpeed ? ACCELERATION : DECELERATION;
|
||||
this.currentSpeed = moveTowards(this.currentSpeed, targetSpeed, rate * delta);
|
||||
|
||||
if (this.currentSpeed > 0) {
|
||||
const step = this.currentSpeed * delta;
|
||||
|
||||
// Move one axis at a time so a wall blocking one direction still lets
|
||||
// the player slide along it, instead of getting fully stuck.
|
||||
this.tryMove(-this.moveDirection.x * step, 0);
|
||||
this.tryMove(0, this.moveDirection.y * step);
|
||||
}
|
||||
|
||||
// Only take over facing once fully stopped (no input AND speed decayed
|
||||
// to zero) — otherwise the face would snap onto the obstacle mid-stride
|
||||
// or mid-coast while the body is still visibly heading somewhere else.
|
||||
const stopped = inputMagnitude === 0 && this.currentSpeed <= 0;
|
||||
if (stopped && nearbyObstacle) {
|
||||
this.faceTowards(nearbyObstacle.center);
|
||||
this.facingTarget = nearbyObstacle.center;
|
||||
} else {
|
||||
this.facingTarget = null;
|
||||
}
|
||||
|
||||
// Turn toward the current facing target (obstacle or movement) and hold
|
||||
// it while idle, rather than resetting to face forward the moment input
|
||||
// stops. rotateTowards caps the step at MAX_TURN_SPEED * delta radians
|
||||
// instead of interpolating a percentage of the remaining angle — except
|
||||
// for a near-180° reversal, which snaps immediately (see chat).
|
||||
if (this.object.quaternion.angleTo(this.facingRotation) > OPPOSITE_TURN_THRESHOLD) {
|
||||
this.object.quaternion.copy(this.facingRotation);
|
||||
} else {
|
||||
this.object.quaternion.rotateTowards(this.facingRotation, MAX_TURN_SPEED * delta);
|
||||
}
|
||||
|
||||
return stopped;
|
||||
}
|
||||
|
||||
// The point the character is currently locked onto facing (a nearby
|
||||
// breakable it's stopped next to), or null otherwise — CameraFollowC uses
|
||||
// this to pan toward the same point, since it's a fixed world position
|
||||
// (not a rotation), so it can't produce the arc/orbit motion a
|
||||
// facing/quaternion-driven look-ahead did (see CameraFollowC chat notes).
|
||||
static getFacingTarget(): Vector3 | null {
|
||||
return this.facingTarget;
|
||||
}
|
||||
|
||||
private static faceTowards(point: Vector3) {
|
||||
const toPoint = point.clone().sub(this.object.position);
|
||||
toPoint.y = 0;
|
||||
if (toPoint.lengthSq() < 1e-6) return; // standing right on top of it — keep whatever we're already facing
|
||||
|
||||
const angle = Math.atan2(toPoint.x, toPoint.z);
|
||||
this.facingRotation.setFromAxisAngle(UP_AXIS, angle);
|
||||
}
|
||||
|
||||
// The attack loops continuously and hits *every* breakable target in the
|
||||
// zone each cycle (not just the one the character turned to face) — it
|
||||
// keeps going, unattended, until the zone is empty. The only things that
|
||||
// stop it early are the player moving again or running out of targets;
|
||||
// there's no commitment to "finish the swing" the way a discrete attack
|
||||
// would have.
|
||||
private static updateCombat(delta: number, stopped: boolean) {
|
||||
const zoneTargets = this.findBreakableTargetsInZone();
|
||||
|
||||
if (this.isAttacking) {
|
||||
if (!stopped || zoneTargets.length === 0) {
|
||||
this.isAttacking = false;
|
||||
WeaponTrailC.setActive(false);
|
||||
this.equipPistol();
|
||||
return;
|
||||
}
|
||||
|
||||
this.attackElapsed += delta;
|
||||
if (this.attackElapsed >= this.nextHitTime) {
|
||||
zoneTargets.forEach((target) => BreakablePropC.hit(target.prop, this.hitDirectionTo(target.center)));
|
||||
this.hitsThisAttack++;
|
||||
this.nextHitTime = this.hitTimeFor(this.hitsThisAttack);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stopped || zoneTargets.length === 0) return;
|
||||
|
||||
const nearest = this.nearestZoneTarget(zoneTargets);
|
||||
if (!this.isFacing(nearest.center)) return;
|
||||
|
||||
this.isAttacking = true;
|
||||
WeaponTrailC.setActive(true);
|
||||
this.attackElapsed = 0;
|
||||
this.hitsThisAttack = 0;
|
||||
this.nextHitTime = this.hitTimeFor(0);
|
||||
this.equipBat();
|
||||
this.playAction(this.attackAction);
|
||||
} else {
|
||||
this.equipPistol();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the center of the destructible obstacle the player is touching
|
||||
// AND facing, or null if there isn't one.
|
||||
private static getFacingObstacleCenter(): Vector3 | null {
|
||||
// Time (seconds since the attack started) the Nth hit lands — cycles
|
||||
// through HIT_TIME_FRACTIONS within each loop of the clip, advancing a
|
||||
// full extra loop every time the cycle wraps.
|
||||
private static hitTimeFor(hitIndex: number): number {
|
||||
const duration = this.attackAction.getClip().duration;
|
||||
const loopsCompleted = Math.floor(hitIndex / HIT_TIME_FRACTIONS.length);
|
||||
const fraction = HIT_TIME_FRACTIONS[hitIndex % HIT_TIME_FRACTIONS.length];
|
||||
return duration * (loopsCompleted + fraction);
|
||||
}
|
||||
|
||||
private static nearestZoneTarget(targets: ZoneTarget[]): ZoneTarget {
|
||||
return targets.reduce((closest, target) =>
|
||||
this.object.position.distanceToSquared(target.center) <
|
||||
this.object.position.distanceToSquared(closest.center)
|
||||
? target
|
||||
: closest
|
||||
);
|
||||
}
|
||||
|
||||
// Every breakable prop within interaction range AND within the frontal
|
||||
// swing arc (same cone as isFacing, see chat: hitting crates standing
|
||||
// behind the player didn't make sense for a forward bat swing) —
|
||||
// "unlimited targets in the hit zone" per the design, just not a full
|
||||
// 360° one. Non-breakable obstacles (walls) are excluded: there's
|
||||
// nothing for the attack to do to them.
|
||||
private static findBreakableTargetsInZone(): ZoneTarget[] {
|
||||
const bounds = this.getCollisionBounds(this.object.position).expandByScalar(INTERACTION_REACH);
|
||||
const touching = this.obstacles.find((box) => box.intersectsBox(bounds));
|
||||
if (!touching) return null;
|
||||
const targets: ZoneTarget[] = [];
|
||||
|
||||
const center = touching.getCenter(new Vector3());
|
||||
for (const obstacle of this.obstacles) {
|
||||
if (!obstacle.box.intersectsBox(bounds)) continue;
|
||||
const prop = BreakablePropC.getByColliderNode(obstacle.node);
|
||||
if (!prop) continue;
|
||||
const center = obstacle.box.getCenter(new Vector3());
|
||||
if (!this.isFacing(center)) continue;
|
||||
targets.push({ center, prop });
|
||||
}
|
||||
|
||||
const toObstacle = center.clone().sub(this.object.position);
|
||||
toObstacle.y = 0;
|
||||
if (toObstacle.lengthSq() < 1e-6) return center; // standing right on top of it — count as facing
|
||||
return targets;
|
||||
}
|
||||
|
||||
toObstacle.normalize();
|
||||
// Returns the nearest obstacle (breakable or not) within interaction
|
||||
// range, purely so idle facing (see updateMovement) has something to turn
|
||||
// toward — unrelated to which targets an attack actually hits.
|
||||
private static findNearbyObstacle(): NearbyObstacle | null {
|
||||
const bounds = this.getCollisionBounds(this.object.position).expandByScalar(INTERACTION_REACH);
|
||||
const touching = this.obstacles.find((obstacle) => obstacle.box.intersectsBox(bounds));
|
||||
return touching ? { node: touching.node, center: touching.box.getCenter(new Vector3()) } : null;
|
||||
}
|
||||
|
||||
// Attacker -> target, flattened to XZ and normalized — how BreakablePropC
|
||||
// decides which way a hit crate should lean. Zero vector (standing right
|
||||
// on top of the target) is a valid, harmless result: no lean either way.
|
||||
private static hitDirectionTo(point: Vector3): Vector3 {
|
||||
const direction = point.clone().sub(this.object.position);
|
||||
direction.y = 0;
|
||||
if (direction.lengthSq() > 1e-6) direction.normalize();
|
||||
return direction;
|
||||
}
|
||||
|
||||
private static isFacing(point: Vector3): boolean {
|
||||
const toPoint = point.clone().sub(this.object.position);
|
||||
toPoint.y = 0;
|
||||
if (toPoint.lengthSq() < 1e-6) return true; // standing right on top of it
|
||||
|
||||
toPoint.normalize();
|
||||
const forward = FORWARD_AXIS.clone().applyQuaternion(this.object.quaternion);
|
||||
forward.y = 0;
|
||||
forward.normalize();
|
||||
|
||||
return forward.dot(toObstacle) > FACING_DOT_THRESHOLD ? center : null;
|
||||
return forward.dot(toPoint) > FACING_DOT_THRESHOLD;
|
||||
}
|
||||
|
||||
private static tryMove(dx: number, dz: number) {
|
||||
@@ -270,7 +482,7 @@ export class PlayerC {
|
||||
nextPosition.z += dz;
|
||||
|
||||
const nextBounds = this.getCollisionBounds(nextPosition);
|
||||
const blocked = this.obstacles.some((box) => box.intersectsBox(nextBounds));
|
||||
const blocked = this.obstacles.some((obstacle) => obstacle.box.intersectsBox(nextBounds));
|
||||
if (blocked) return;
|
||||
|
||||
this.object.position.copy(nextPosition);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Object3D, Vector3 } from "three";
|
||||
import { BatchedRenderer, QuarksLoader, QuarksUtil } from "three.quarks";
|
||||
import { Template3d, UpdateController } from "@hitplay/playable_template";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import vfxLootableHitJson from "../resources/vfx/VFX_Lootable_Hit.json";
|
||||
import vfxLootableDestroyJson from "../resources/vfx/VFX_Lootable_Destroy.json";
|
||||
|
||||
// Real designer-authored VFX for breakable props — found sitting unused in
|
||||
// temp/ (exported from the quarks.art editor as plain Object3D.toJSON()
|
||||
// groups, each a couple of ParticleEmitter children with their own tuned
|
||||
// shape/speed/color/behaviors, referencing real textures like
|
||||
// "novalines.webp" for the ray-streak look). Replaces the earlier
|
||||
// hand-built SparkFxC, whose particles read as a soft dot-blob rather than
|
||||
// the rays the reference screenshots called for — no amount of tuning a
|
||||
// from-scratch system was going to match a real authored asset anyway.
|
||||
//
|
||||
// Loaded via plain Vite JSON imports + three.quarks' own QuarksLoader
|
||||
// directly, NOT the SDK's quarksLoader()/ConvertToBase64WhenRelease
|
||||
// (the pattern meshes.ts/images.ts use): quarksLoader() hardcodes
|
||||
// `base64String.split(",")[1]` then atob() on it, which assumes it's
|
||||
// always handed an actual base64 data: URI — true only once the build-time
|
||||
// AST plugin rewrites the ConvertToBase64WhenRelease() call; in plain
|
||||
// `vite dev` that call is a no-op passthrough returning a bare path
|
||||
// string, which has no comma and would break the split/atob chain. A
|
||||
// static JSON import sidesteps that entirely — Vite inlines JSON content
|
||||
// into the bundle natively in every mode (dev, build, and the single-file
|
||||
// export), no fetch, no base64 round-trip, no dev/build inconsistency.
|
||||
const CLEANUP_DELAY_MS = 1500; // covers the longest emitter's duration+life in either prefab
|
||||
|
||||
export class PropVfxC {
|
||||
private static renderer: BatchedRenderer | null = null;
|
||||
private static hitTemplate: Object3D | null = null;
|
||||
private static destroyTemplate: Object3D | null = null;
|
||||
|
||||
static init() {
|
||||
if (this.renderer) return;
|
||||
|
||||
this.renderer = new BatchedRenderer();
|
||||
ThreeC.addToScene(this.renderer);
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => this.renderer!.update(delta));
|
||||
|
||||
// QuarksLoader.parse() is synchronous — the JSON already embeds every
|
||||
// texture as a base64 data: URI (see the "images" array in the
|
||||
// exported files), so there's no network fetch to wait on.
|
||||
const loader = new QuarksLoader(Template3d.manager);
|
||||
this.hitTemplate = loader.parse(vfxLootableHitJson);
|
||||
this.destroyTemplate = loader.parse(vfxLootableDestroyJson);
|
||||
|
||||
// "Ground_Dirt" renders as a raw Mesh (RenderMode.Mesh) using a plain
|
||||
// PlaneGeometry (XY-plane, normal +Z) — unlike its "BreakingDust"
|
||||
// sibling in the same prefab, its own node has no compensating rotation
|
||||
// baked in, so unrotated it stands upright on its edge instead of lying
|
||||
// on the ground. -90° about local X maps that local Z normal to world
|
||||
// +Y (same convention as PayZoneC's ground-fill quad). Per-particle
|
||||
// startRotation spins around the emitter's own local Z (three.quarks
|
||||
// hardcodes UP=(0,0,1) for Mesh-mode rotation, ignoring the JSON's own
|
||||
// "axis" field), so this also keeps that spin flat around the
|
||||
// corrected world-up axis rather than tumbling the decal out of plane.
|
||||
const groundDirt = this.destroyTemplate.getObjectByName("Ground_Dirt");
|
||||
if (groundDirt) groundDirt.rotation.x = -Math.PI / 2;
|
||||
}
|
||||
|
||||
static spawnHit(position: Vector3) {
|
||||
this.spawn(this.hitTemplate, position);
|
||||
}
|
||||
|
||||
static spawnDestroy(position: Vector3) {
|
||||
this.spawn(this.destroyTemplate, position);
|
||||
}
|
||||
|
||||
// Each call clones the loaded template (so concurrent bursts don't share
|
||||
// particle state), positions it at the world-space point, and forces
|
||||
// autoDestroy so every emitter in it removes itself once its particles
|
||||
// die — same self-cleanup contract as the old SparkFxC. The clone's root
|
||||
// Group itself doesn't get removed by that (only its ParticleEmitter
|
||||
// children do, once each one's particles finish), so it's swept from the
|
||||
// scene on a plain timeout sized to the prefabs' own durations.
|
||||
private static spawn(template: Object3D | null, position: Vector3) {
|
||||
if (!template || !this.renderer) return;
|
||||
|
||||
const instance = template.clone();
|
||||
instance.position.copy(position);
|
||||
QuarksUtil.setAutoDestroy(instance, true);
|
||||
QuarksUtil.addToBatchRenderer(instance, this.renderer);
|
||||
ThreeC.addToScene(instance);
|
||||
QuarksUtil.play(instance);
|
||||
|
||||
setTimeout(() => ThreeC.removeFromScene(instance), CLEANUP_DELAY_MS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import {
|
||||
AdditiveBlending,
|
||||
BoxGeometry,
|
||||
Color,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
MeshStandardMaterial,
|
||||
Object3D,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import { SparkleFxC } from "./SparkleFxC";
|
||||
|
||||
// The map ships an actual wood-icon quad ("UI_Wood", textured with
|
||||
// Icon_Wood) sitting unused in the scene — TestSceneC hands it in via
|
||||
// setPickupTemplate() and every spawned resource is a clone of it. This
|
||||
// placeholder cube only covers the case where that node is ever missing.
|
||||
const PICKUP_SIZE = 0.28;
|
||||
|
||||
// Resources pop out of a hit target, scatter a short, fixed distance, land,
|
||||
// bounce twice, then fly on to the HUD counter (see HudC) — the player
|
||||
// never has to walk over and trigger any of this (see chat: "персонажу не
|
||||
// потрібно підходити для збору"). Carrying the collected total on into the
|
||||
// Pay Zone is a separate, later mechanic — see PayZoneC, which drains
|
||||
// ResourceC.getCollectedCount() on its own.
|
||||
const SCATTER_MIN_DISTANCE = 0.5;
|
||||
const SCATTER_MAX_DISTANCE = 1.2;
|
||||
const SCATTER_ARC_HEIGHT = 0.6;
|
||||
const FLY_DURATION = 0.45; // seconds to reach the scattered spot
|
||||
|
||||
// Two bounces off the ground where it lands — decreasing height/duration
|
||||
// each time, index 0 first. On the second (last) bounce's touchdown, the
|
||||
// resource flashes white (like BreakablePropC's crate hit-flash) and
|
||||
// shrinks while a sparkle burst plays around it (see chat: exact reference
|
||||
// look). This shrink is intentional and stays (see chat: "Ти прибрав зміну
|
||||
// розміру при баунсі... я тобі писав не про це" — a later, unrelated
|
||||
// request to remove scaling from the toTarget *flight* got misread as
|
||||
// "remove scaling everywhere" and briefly took this out too; it's back).
|
||||
// The only place scale must NOT change is the toTarget flight itself —
|
||||
// see that phase below, which deliberately never touches scale, so
|
||||
// whatever value this phase lands on just carries through unchanged.
|
||||
const BOUNCE_HEIGHTS = [0.4, 0.22];
|
||||
const BOUNCE_DURATIONS = [0.26, 0.2];
|
||||
|
||||
// The shrink ramps in quickly (SHRINK_RAMP_DURATION) and then just holds —
|
||||
// it does NOT ease back out to the original size (see chat: "стискання
|
||||
// без повернення до початкового розміру"). Uniform shrink, not a
|
||||
// squash-and-widen — an earlier version widened X/Z while flattening Y,
|
||||
// which read as the resource *growing* (see chat: "мають навпаки
|
||||
// зменшуватись"). The flash fades out on its own, longer, slower timeline
|
||||
// (see chat: "нехай флеш буде довшим") — decoupled from the shrink so
|
||||
// lengthening one doesn't drag the other out too.
|
||||
const SHRINK_RAMP_DURATION = 0.22;
|
||||
const FLASH_DURATION = 0.45;
|
||||
// Must be >= FLASH_DURATION or the flash gets cut off mid-fade before the
|
||||
// resource flies off — a little slack on top so the fully-faded state
|
||||
// actually reads for a beat first.
|
||||
const IMPACT_HOLD_DURATION = 0.55;
|
||||
const SHRINK_AMOUNT = 0.15; // fraction shrunk, held once reached
|
||||
const FLASH_COLOR = new Color(0xffffff);
|
||||
|
||||
// The world-space scale a resource settles at after the bounce shrink and
|
||||
// carries all the way to the HUD (see toTarget's perspective compensation
|
||||
// below, which holds this constant in apparent/on-screen size). Exported
|
||||
// so PayZoneC's deposit-flight token — a separate, fresh mesh, not one of
|
||||
// these FlyingResources — can start from the same baseline instead of its
|
||||
// own default (1), which read as noticeably larger (see chat: "розмір...
|
||||
// дуже великий, треба уніфікувати").
|
||||
export const RESOURCE_ICON_BASE_SCALE = 1 - SHRINK_AMOUNT;
|
||||
|
||||
// Once settled, a resource flies on to wherever setFlyTarget() points (the
|
||||
// HUD counter, see HudC). Without a target set, it's counted immediately
|
||||
// instead (no flight) — see update() below.
|
||||
//
|
||||
// An accelerating (ease-in) version of this was tried and reverted (see
|
||||
// chat: "зникають раніше часу") — CONVERGE_START_FRACTION triggers the
|
||||
// shrink-to-nothing off raw elapsed time `t`, not spatial progress, and
|
||||
// ease-in makes spatial progress lag well behind `t` (at t=0.7 an eased-in
|
||||
// t² curve has only covered half the distance), so the resource visibly
|
||||
// vanished while still far short of the target. Smoothstep keeps those two
|
||||
// closely matched enough that convergence reads as "arriving", not
|
||||
// "disappearing early".
|
||||
const TO_TARGET_DURATION = 0.5;
|
||||
const TO_TARGET_ARC_HEIGHT = 1;
|
||||
// Fraction of the toTarget flight (0..1) where the arrival brighten (see
|
||||
// update()) starts ramping in — 0.7 means the last 30% of the flight.
|
||||
const CONVERGE_START_FRACTION = 0.7;
|
||||
|
||||
// Resources dropped by the same hit shouldn't all launch toward the HUD in
|
||||
// the same instant — each one already bounces on identical timing
|
||||
// constants, so without this they'd all reach "toTarget" together and fly
|
||||
// as one visual clump (see chat: "не мають летіти однією кучею"). Instead,
|
||||
// each resource in a burst waits STAGGER_INTERVAL longer than the previous
|
||||
// one before launching — see spawnOne's staggerDelay param and its use in
|
||||
// the impactHold exit condition below.
|
||||
const STAGGER_INTERVAL = 0.12;
|
||||
|
||||
function randomInt(min: number, max: number): number {
|
||||
return min + Math.floor(Math.random() * (max - min + 1));
|
||||
}
|
||||
|
||||
// Same trick as BreakablePropC's hit-flash: a same-geometry clone layered
|
||||
// on top with additive blending, opacity animated for the flash — lerping
|
||||
// an unlit mesh's own (already-white) material color is a silent no-op
|
||||
// (see BreakablePropC/CLAUDE.md for why). Collected up front, before
|
||||
// anything mutates the tree, so adding overlay children mid-traversal
|
||||
// can't make traverse() walk into the overlays it just added.
|
||||
function buildFlashOverlays(root: Object3D): Mesh[] {
|
||||
const meshes: Mesh[] = [];
|
||||
root.traverse((child) => {
|
||||
if (child instanceof Mesh) meshes.push(child);
|
||||
});
|
||||
|
||||
return meshes.map((mesh) => {
|
||||
const overlay = mesh.clone();
|
||||
// clone() copies mesh's own local transform (relative to ITS parent) —
|
||||
// parenting the overlay under the mesh itself, one level deeper than
|
||||
// that, would reinterpret those same numbers in the mesh's local space
|
||||
// and displace the overlay instead of overlaying it exactly on top.
|
||||
// Reset to identity so it sits precisely where its new parent (mesh)
|
||||
// already is.
|
||||
overlay.position.set(0, 0, 0);
|
||||
overlay.rotation.set(0, 0, 0);
|
||||
overlay.scale.set(1, 1, 1);
|
||||
overlay.material = new MeshBasicMaterial({
|
||||
color: FLASH_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
overlay.castShadow = false;
|
||||
overlay.receiveShadow = false;
|
||||
mesh.add(overlay);
|
||||
return overlay;
|
||||
});
|
||||
}
|
||||
|
||||
type Phase = "scatter" | "bounce" | "impactHold" | "toTarget";
|
||||
|
||||
interface FlyingResource {
|
||||
mesh: Object3D;
|
||||
flashOverlays: Mesh[];
|
||||
phase: Phase;
|
||||
age: number;
|
||||
from: Vector3;
|
||||
to: Vector3;
|
||||
groundY: number; // the y level the two bounces oscillate around
|
||||
bounceIndex: number; // which of BOUNCE_HEIGHTS/BOUNCE_DURATIONS is playing
|
||||
staggerDelay: number; // extra hold time before launching to the HUD — see STAGGER_INTERVAL
|
||||
baseScale: number; // scale.x when toTarget starts — see the perspective compensation there
|
||||
startDistanceFromCamera: number; // camera distance when toTarget starts — see toTarget phase
|
||||
}
|
||||
|
||||
export class ResourceC {
|
||||
private static flying: FlyingResource[] = [];
|
||||
private static collected = 0;
|
||||
private static initialized = false;
|
||||
private static pickupTemplate: Object3D | null = null;
|
||||
private static flyTarget: (() => Vector3) | null = null;
|
||||
|
||||
static getCollectedCount() {
|
||||
return this.collected;
|
||||
}
|
||||
|
||||
// The real wood-icon node from the map (see TestSceneC.createMap) — every
|
||||
// spawned pickup is a clone of this rather than the fallback cube.
|
||||
static setPickupTemplate(template: Object3D) {
|
||||
this.pickupTemplate = template;
|
||||
}
|
||||
|
||||
// Where settled resources fly to before being counted — the HUD counter
|
||||
// (see HudC.getWorldAnchorPosition).
|
||||
static setFlyTarget(getTarget: () => Vector3) {
|
||||
this.flyTarget = getTarget;
|
||||
}
|
||||
|
||||
// A standalone clone of the pickup visual, for callers (PayZoneC) that
|
||||
// need to fly a "resource" token somewhere outside of the normal
|
||||
// spawn/scatter/collect lifecycle below. No flash overlays — those are
|
||||
// only built for the actual bounce-and-collect resources below.
|
||||
static createVisual(): Object3D {
|
||||
return this.createPickupMesh();
|
||||
}
|
||||
|
||||
static spawnBurst(origin: Vector3, count: number) {
|
||||
this.ensureUpdating();
|
||||
for (let i = 0; i < count; i++) this.spawnOne(origin, i * STAGGER_INTERVAL);
|
||||
}
|
||||
|
||||
static spawnBurstInRange(origin: Vector3, min: number, max: number) {
|
||||
this.spawnBurst(origin, randomInt(min, max));
|
||||
}
|
||||
|
||||
private static spawnOne(origin: Vector3, staggerDelay: number) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = SCATTER_MIN_DISTANCE + Math.random() * (SCATTER_MAX_DISTANCE - SCATTER_MIN_DISTANCE);
|
||||
const target = origin
|
||||
.clone()
|
||||
.add(new Vector3(Math.cos(angle) * distance, 0, Math.sin(angle) * distance));
|
||||
|
||||
const mesh = this.createPickupMesh();
|
||||
mesh.position.copy(origin);
|
||||
ThreeC.addToScene(mesh);
|
||||
|
||||
this.flying.push({
|
||||
mesh,
|
||||
flashOverlays: buildFlashOverlays(mesh),
|
||||
phase: "scatter",
|
||||
age: 0,
|
||||
from: origin.clone(),
|
||||
to: target,
|
||||
groundY: 0,
|
||||
bounceIndex: 0,
|
||||
staggerDelay,
|
||||
baseScale: 1,
|
||||
startDistanceFromCamera: 1,
|
||||
});
|
||||
}
|
||||
|
||||
private static createPickupMesh(): Object3D {
|
||||
if (this.pickupTemplate) {
|
||||
const clone = this.pickupTemplate.clone();
|
||||
// The template itself is hidden in its resting spot in the map (see
|
||||
// TestSceneC) — clone() copies that visible:false too, so force it
|
||||
// back on regardless of which order those two happen in.
|
||||
clone.visible = true;
|
||||
return clone;
|
||||
}
|
||||
|
||||
const mesh = new Mesh(
|
||||
new BoxGeometry(PICKUP_SIZE, PICKUP_SIZE, PICKUP_SIZE),
|
||||
new MeshStandardMaterial({ color: 0x8a5a34 })
|
||||
);
|
||||
mesh.castShadow = true;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
private static ensureUpdating() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
|
||||
}
|
||||
|
||||
private static update(delta: number) {
|
||||
for (let i = this.flying.length - 1; i >= 0; i--) {
|
||||
const resource = this.flying[i];
|
||||
resource.age += delta;
|
||||
|
||||
if (resource.phase === "scatter") {
|
||||
if (resource.age <= FLY_DURATION) {
|
||||
const t = resource.age / FLY_DURATION;
|
||||
const eased = 1 - (1 - t) * (1 - t); // easeOutQuad outward
|
||||
resource.mesh.position.lerpVectors(resource.from, resource.to, eased);
|
||||
// simple up-then-down arc on top of the outward lerp, so it "pops"
|
||||
// rather than sliding flat along the ground
|
||||
resource.mesh.position.y = resource.from.y + Math.sin(t * Math.PI) * SCATTER_ARC_HEIGHT;
|
||||
continue;
|
||||
}
|
||||
|
||||
resource.phase = "bounce";
|
||||
resource.age = 0;
|
||||
resource.bounceIndex = 0;
|
||||
resource.groundY = resource.to.y;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resource.phase === "bounce") {
|
||||
const duration = BOUNCE_DURATIONS[resource.bounceIndex];
|
||||
const height = BOUNCE_HEIGHTS[resource.bounceIndex];
|
||||
|
||||
if (resource.age <= duration) {
|
||||
const t = resource.age / duration;
|
||||
resource.mesh.position.y = resource.groundY + Math.sin(t * Math.PI) * height;
|
||||
continue;
|
||||
}
|
||||
|
||||
resource.mesh.position.y = resource.groundY;
|
||||
|
||||
if (resource.bounceIndex < BOUNCE_HEIGHTS.length - 1) {
|
||||
resource.bounceIndex++;
|
||||
resource.age = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Last bounce just touched down — flash/sparkle, once.
|
||||
SparkleFxC.spawnBurst(resource.mesh.getWorldPosition(new Vector3()));
|
||||
resource.phase = "impactHold";
|
||||
resource.age = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resource.phase === "impactHold") {
|
||||
const shrinkT = Math.min(resource.age / SHRINK_RAMP_DURATION, 1);
|
||||
// Ease in toward the shrink and stay there — no easing back out to
|
||||
// the original size (see chat).
|
||||
const shrink = shrinkT * shrinkT * (3 - 2 * shrinkT) * SHRINK_AMOUNT;
|
||||
resource.mesh.scale.setScalar(1 - shrink);
|
||||
|
||||
const flashT = Math.min(resource.age / FLASH_DURATION, 1);
|
||||
const flashOpacity = 1 - flashT;
|
||||
for (const overlay of resource.flashOverlays) {
|
||||
(overlay.material as MeshBasicMaterial).opacity = flashOpacity;
|
||||
}
|
||||
|
||||
// The stagger delay (see STAGGER_INTERVAL) just extends this wait
|
||||
// per-resource — the shrink/flash above are already fully settled
|
||||
// by IMPACT_HOLD_DURATION regardless, so waiting longer here only
|
||||
// delays the *launch*, not any of the visible impact feedback.
|
||||
if (resource.age < IMPACT_HOLD_DURATION + resource.staggerDelay) continue;
|
||||
|
||||
for (const overlay of resource.flashOverlays) {
|
||||
(overlay.material as MeshBasicMaterial).opacity = 0;
|
||||
}
|
||||
|
||||
if (!this.flyTarget) {
|
||||
this.collect(resource);
|
||||
this.flying.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
resource.phase = "toTarget";
|
||||
resource.age = 0;
|
||||
resource.from = resource.mesh.position.clone();
|
||||
resource.baseScale = resource.mesh.scale.x;
|
||||
resource.startDistanceFromCamera = CameraC_internal.getCamera().position.distanceTo(resource.from);
|
||||
continue;
|
||||
}
|
||||
|
||||
// phase === "toTarget"
|
||||
if (!this.flyTarget) {
|
||||
this.collect(resource);
|
||||
this.flying.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const t = Math.min(resource.age / TO_TARGET_DURATION, 1);
|
||||
const eased = t * t * (3 - 2 * t); // smoothstep — eases in and out of the flight
|
||||
// Re-sampled every frame, not snapshotted once at launch — the
|
||||
// camera (and so the HUD anchor's world position, see
|
||||
// HudC.getWorldAnchorPosition) can keep moving during the ~0.5s
|
||||
// flight (player walking, camera follow), so a one-time snapshot
|
||||
// would leave the resource flying toward a stale point that no
|
||||
// longer lines up with the icon (see chat: "втрачають потрібну
|
||||
// позицію і летять не туди"). Re-fetching each frame means it always
|
||||
// curves toward wherever the icon actually is *right now*.
|
||||
resource.to = this.flyTarget();
|
||||
resource.mesh.position.lerpVectors(resource.from, resource.to, eased);
|
||||
resource.mesh.position.y += Math.sin(t * Math.PI) * TO_TARGET_ARC_HEIGHT * (1 - t);
|
||||
|
||||
// The flight target sits much closer to the camera than where
|
||||
// resources spawn (see HudC.getWorldAnchorPosition — it's pinned a
|
||||
// fixed, short distance in front of the camera), so without this the
|
||||
// resource would visibly *grow* as it approaches — pure perspective
|
||||
// foreshortening from closing distance, not any actual change to
|
||||
// world-space scale (see chat: "ресурс не має змінюватись в розмірі
|
||||
// в залежності від відстані"). Scaling by the ratio of current to
|
||||
// starting camera-distance exactly cancels that: apparent (on-screen)
|
||||
// size stays pinned to whatever it already was leaving the bounce,
|
||||
// no matter how close it gets.
|
||||
const cameraPosition = CameraC_internal.getCamera().position;
|
||||
const currentDistance = cameraPosition.distanceTo(resource.mesh.position);
|
||||
const perspectiveScale =
|
||||
resource.startDistanceFromCamera > 1e-6 ? currentDistance / resource.startDistanceFromCamera : 1;
|
||||
resource.mesh.scale.setScalar(resource.baseScale * perspectiveScale);
|
||||
|
||||
// Over the last stretch of the flight, brightens right as it reaches
|
||||
// the target — reads as being absorbed into the counter's icon,
|
||||
// rather than just popping out of existence mid-air (see chat: "має
|
||||
// наприкінці збиратись в одиницю ресурсу і... залітати на місце
|
||||
// іконки"). Reuses the same flashOverlays the bounce-impact flash
|
||||
// used, idle since then.
|
||||
const convergeT = Math.max(0, (t - CONVERGE_START_FRACTION) / (1 - CONVERGE_START_FRACTION));
|
||||
for (const overlay of resource.flashOverlays) {
|
||||
(overlay.material as MeshBasicMaterial).opacity = convergeT;
|
||||
}
|
||||
|
||||
if (t >= 1) {
|
||||
this.collect(resource);
|
||||
this.flying.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static collect(resource: FlyingResource) {
|
||||
this.collected++;
|
||||
console.log(`[ResourceC] gathered wood (${this.collected} total)`);
|
||||
ThreeC.removeFromScene(resource.mesh);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
AdditiveBlending,
|
||||
Color,
|
||||
DoubleSide,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
Shape,
|
||||
ShapeGeometry,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { CameraC_internal, TweenC } from "@hitplay/playable_template";
|
||||
import { Easing, Tween } from "@tweenjs/tween.js";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
|
||||
const SPARKLE_COUNT = 5;
|
||||
const SPARKLE_SCATTER_RADIUS = 0.28; // world units around the burst point
|
||||
const SPARKLE_OUTER_RADIUS = 0.07;
|
||||
const SPARKLE_INNER_RADIUS = 0.02;
|
||||
const SPARKLE_COLOR = new Color(0xfff6d8); // warm white — a "reward" twinkle, not a colored magic effect
|
||||
|
||||
const POP_IN_MS = 80;
|
||||
const HOLD_MS = 90;
|
||||
const FADE_OUT_MS = 220;
|
||||
|
||||
// Sized for the worst realistic case, not the common one: a crate can drop
|
||||
// several resources at once (see ResourceC), each bouncing independently
|
||||
// but on identical timing constants, so their second-bounce bursts land
|
||||
// within the same frame or two of each other — up to
|
||||
// (max resources per hit) * SPARKLE_COUNT stars alive simultaneously.
|
||||
const POOL_SIZE = 30;
|
||||
|
||||
// A small 4-point twinkle star (outer/inner radius alternating around the
|
||||
// ring) — the classic "sparkle" shape, not a designer sprite/atlas asset
|
||||
// (none exists yet for this, see PropVfxC for the kind of VFX that does
|
||||
// have one). Built once and shared read-only across every spawned star.
|
||||
function buildStarShape(): Shape {
|
||||
const points = 4;
|
||||
const shape = new Shape();
|
||||
for (let i = 0; i < points * 2; i++) {
|
||||
const radius = i % 2 === 0 ? SPARKLE_OUTER_RADIUS : SPARKLE_INNER_RADIUS;
|
||||
const angle = (i / (points * 2)) * Math.PI * 2;
|
||||
const x = Math.cos(angle) * radius;
|
||||
const y = Math.sin(angle) * radius;
|
||||
if (i === 0) shape.moveTo(x, y);
|
||||
else shape.lineTo(x, y);
|
||||
}
|
||||
shape.closePath();
|
||||
return shape;
|
||||
}
|
||||
|
||||
const starGeometry = new ShapeGeometry(buildStarShape());
|
||||
|
||||
interface StarSlot {
|
||||
mesh: Mesh;
|
||||
material: MeshBasicMaterial;
|
||||
inUse: boolean;
|
||||
}
|
||||
|
||||
// A short "twinkle" burst of a handful of star-shaped quads around a point
|
||||
// — used for the second bounce of a landed resource (see ResourceC), but
|
||||
// not tied to that specifically, so it's its own small reusable effect.
|
||||
//
|
||||
// Pooled (see chat: "давай зробимо пул") — every burst used to `new Mesh` +
|
||||
// `new MeshBasicMaterial` per star and throw them away ~390ms later via
|
||||
// removeFromScene(). Multiple resources bouncing in from the same crate
|
||||
// hit fire their bursts within a frame or two of each other, so that
|
||||
// pattern meant a dozen-plus create+GC cycles clustered into under half a
|
||||
// second — exactly the "small VFX objects churning" case object pooling
|
||||
// exists for (see Day 7 brief: "recommended to use pools for VFX"). Now a
|
||||
// fixed set of star meshes is built once at init() and reused: spawnBurst
|
||||
// just grabs whichever slots aren't currently animating.
|
||||
export class SparkleFxC {
|
||||
private static pool: StarSlot[] = [];
|
||||
private static initialized = false;
|
||||
|
||||
static init() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
|
||||
for (let i = 0; i < POOL_SIZE; i++) {
|
||||
const material = new MeshBasicMaterial({
|
||||
color: SPARKLE_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: AdditiveBlending,
|
||||
depthWrite: false,
|
||||
side: DoubleSide,
|
||||
toneMapped: false,
|
||||
});
|
||||
|
||||
const mesh = new Mesh(starGeometry, material);
|
||||
mesh.visible = false;
|
||||
mesh.scale.setScalar(0);
|
||||
mesh.renderOrder = 10; // draws after the resource/flash it bursts around
|
||||
ThreeC.addToScene(mesh);
|
||||
|
||||
this.pool.push({ mesh, material, inUse: false });
|
||||
}
|
||||
}
|
||||
|
||||
static spawnBurst(position: Vector3) {
|
||||
const cameraPosition = CameraC_internal.getCamera().getWorldPosition(new Vector3());
|
||||
|
||||
let spawned = 0;
|
||||
for (const slot of this.pool) {
|
||||
if (slot.inUse) continue;
|
||||
|
||||
this.activate(slot, position, cameraPosition);
|
||||
spawned++;
|
||||
if (spawned >= SPARKLE_COUNT) return;
|
||||
}
|
||||
|
||||
// Not a hard failure — just fewer stars than usual this burst — but
|
||||
// worth knowing about if it ever actually happens, rather than a
|
||||
// silently smaller effect (see Day 7 workflow guidance on silent caps).
|
||||
if (spawned < SPARKLE_COUNT) {
|
||||
console.debug(`[SparkleFxC] pool exhausted — spawned ${spawned}/${SPARKLE_COUNT} stars`);
|
||||
}
|
||||
}
|
||||
|
||||
private static activate(slot: StarSlot, position: Vector3, cameraPosition: Vector3) {
|
||||
slot.inUse = true;
|
||||
const { mesh, material } = slot;
|
||||
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const radius = Math.random() * SPARKLE_SCATTER_RADIUS;
|
||||
const offset = new Vector3(
|
||||
Math.cos(angle) * radius,
|
||||
Math.random() * SPARKLE_SCATTER_RADIUS * 0.6,
|
||||
Math.sin(angle) * radius
|
||||
);
|
||||
|
||||
mesh.position.copy(position).add(offset);
|
||||
// Faces the actual camera position rather than a shared fixed
|
||||
// quaternion — cheap since it only runs once per activation, and stays
|
||||
// correct even though each star sits at a slightly different offset
|
||||
// around the burst point.
|
||||
mesh.lookAt(cameraPosition);
|
||||
mesh.scale.setScalar(0);
|
||||
mesh.visible = true;
|
||||
material.opacity = 0;
|
||||
|
||||
const popIn = new Tween({ t: 0 })
|
||||
.to({ t: 1 }, POP_IN_MS)
|
||||
.easing(Easing.Back.Out)
|
||||
.onUpdate(({ t }) => {
|
||||
mesh.scale.setScalar(Math.max(t, 0));
|
||||
material.opacity = Math.min(t, 1);
|
||||
});
|
||||
|
||||
const fadeOut = new Tween({ t: 1 })
|
||||
.to({ t: 0 }, FADE_OUT_MS)
|
||||
.delay(HOLD_MS)
|
||||
.easing(Easing.Quadratic.In)
|
||||
.onUpdate(({ t }) => {
|
||||
mesh.scale.setScalar(t);
|
||||
material.opacity = t;
|
||||
})
|
||||
.onComplete(() => {
|
||||
mesh.visible = false;
|
||||
slot.inUse = false;
|
||||
});
|
||||
|
||||
// Both links added to TweenC individually — .chain() only tells
|
||||
// popIn to call fadeOut.start() on completion, it doesn't register
|
||||
// fadeOut for update ticks on its own (the same gotcha BreakablePropC
|
||||
// hit with its hit-flash chain — see CLAUDE.md).
|
||||
popIn.chain(fadeOut);
|
||||
TweenC.add(popIn);
|
||||
TweenC.add(fadeOut);
|
||||
popIn.start();
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,55 @@
|
||||
import { Object3D, Vector3 } from "three";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import { PlayerC, PLAYER_HEIGHT } from "./PlayerC";
|
||||
import { PlayerC } from "./PlayerC";
|
||||
import { CameraFollowC } from "./CameraFollowC";
|
||||
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
|
||||
import { BreakablePropC } from "./BreakablePropC";
|
||||
import { ResourceC } from "./ResourceC";
|
||||
import { PayZoneC } from "./PayZoneC";
|
||||
import { HudC } from "./HudC";
|
||||
import { PropVfxC } from "./PropVfxC";
|
||||
import { WeaponTrailC } from "./WeaponTrailC";
|
||||
import { SparkleFxC } from "./SparkleFxC";
|
||||
|
||||
// Above and behind the player, along the road's forward (+Z) direction —
|
||||
// player now spawns at the other end of the road, walking toward +Z, so
|
||||
// "behind" flipped from +Z to -Z to keep looking the same way they walk.
|
||||
const CAMERA_OFFSET = new Vector3(0, 9, -9);
|
||||
|
||||
// Obstacle checks look from roughly head height rather than the player's
|
||||
// feet (position.y is anchored at the feet), so short obstacles don't
|
||||
// falsely count as hiding the player. 0.9 of the full height keeps the ray
|
||||
// just under the very top edge, avoiding edge-grazing false negatives.
|
||||
const CAMERA_EYE_HEIGHT = PLAYER_HEIGHT * 0.9;
|
||||
|
||||
export class TestSceneC {
|
||||
static init() {
|
||||
const colliders = this.createMap();
|
||||
PropVfxC.init();
|
||||
SparkleFxC.init();
|
||||
|
||||
const { colliders, payZoneNode } = this.createMap();
|
||||
|
||||
PlayerC.init(colliders);
|
||||
CameraFollowC.init(PlayerC.object, CAMERA_OFFSET, colliders, CAMERA_EYE_HEIGHT);
|
||||
CameraFollowC.init(PlayerC.object, CAMERA_OFFSET);
|
||||
// Lets the camera's look-ahead react when the player locks facing onto
|
||||
// a nearby breakable while stopped (a pure in-place rotation with no
|
||||
// movement of its own) — see CameraFollowC.setFacingTargetGetter.
|
||||
CameraFollowC.setFacingTargetGetter(() => PlayerC.getFacingTarget());
|
||||
|
||||
// Must come after CameraFollowC.init(): the trail's screen-facing
|
||||
// ribbon captures the camera's view direction once at init time,
|
||||
// relying on the fact that CameraFollowC's rotation is frozen for good
|
||||
// right after its own init (see CameraFollowC — it never rotates again).
|
||||
WeaponTrailC.init(PlayerC.getWeaponAnchor());
|
||||
|
||||
// Reuses the map's own "UI_Interactive_Zone_02" marker as the Pay
|
||||
// Zone's position/visual (see PayZoneC) — drains ResourceC's collected
|
||||
// total into the zone while the player stands in it. Must come after
|
||||
// PlayerC.init(): PlayerC.object doesn't exist yet inside createMap().
|
||||
PayZoneC.init(payZoneNode, PlayerC.object);
|
||||
|
||||
// HUD counter (top-right) — collected resources fly to it instead of
|
||||
// just vanishing, and it's also where PayZoneC's deposit-into-the-zone
|
||||
// flight originates from. The displayed number is collected minus
|
||||
// delivered — rises on collection, falls as PayZoneC drains it into
|
||||
// the zone.
|
||||
HudC.init();
|
||||
ResourceC.setFlyTarget(() => HudC.getWorldAnchorPosition());
|
||||
HudC.setBalanceGetter(() => ResourceC.getCollectedCount() - PayZoneC.getDepositedCount());
|
||||
}
|
||||
|
||||
private static createMap() {
|
||||
@@ -35,10 +65,58 @@ export class TestSceneC {
|
||||
}
|
||||
});
|
||||
|
||||
// The map ships a real wood-icon quad, just sitting unused in the scene
|
||||
// (textured with Icon_Wood) — hand it to ResourceC as the pickup visual
|
||||
// instead of the generic placeholder cube, and hide the static instance
|
||||
// since it's now a template to clone, not scenery.
|
||||
const woodIcon = map.getObjectByName("UI_Wood");
|
||||
if (woodIcon) {
|
||||
ResourceC.setPickupTemplate(woodIcon);
|
||||
woodIcon.visible = false;
|
||||
}
|
||||
|
||||
// Leftover art scaffolding near the Pay Zone — a second wood-icon quad
|
||||
// stacked on it, an unused "tool zone" plate, and a rotated
|
||||
// Background/Foreground/Middleground billboard group (likely a fake
|
||||
// install-button mockup, floating above ground nearby) — always
|
||||
// visible by default, unused by any code, just cluttering the scene.
|
||||
// ⚠️ Named "UI_Wood.001" (with a dot) in the source glb, but whatever
|
||||
// loads it here strips the dot — the live scene has it as "UI_Wood001".
|
||||
// getObjectByName("UI_Wood.001") always silently returned undefined,
|
||||
// so this never actually hid anything (confirmed by traversing the live
|
||||
// scene — see chat). This is why it kept showing up no matter what else
|
||||
// got hidden/removed.
|
||||
const woodIconAlt = map.getObjectByName("UI_Wood001");
|
||||
if (woodIconAlt) woodIconAlt.visible = false;
|
||||
|
||||
const toolZone = map.getObjectByName("UI_Tool_Zone");
|
||||
if (toolZone) toolZone.visible = false;
|
||||
|
||||
const installMockup = map.getObjectByName("UI");
|
||||
if (installMockup) installMockup.visible = false;
|
||||
|
||||
// "Lootable" crates own their collider bodies themselves (so they can be
|
||||
// torn down again when a crate breaks) — everything else just gets a
|
||||
// plain static wall body.
|
||||
const propColliders = BreakablePropC.init(map.getObjectByName("Lootable"), (colliderNode) => {
|
||||
const index = colliders.indexOf(colliderNode);
|
||||
if (index !== -1) colliders.splice(index, 1);
|
||||
PlayerC.removeObstacle(colliderNode);
|
||||
});
|
||||
|
||||
for (const collider of colliders) {
|
||||
if (propColliders.has(collider)) continue;
|
||||
new PhysicsBody(collider, false, 0, PhysicsLayer.Wall, PhysicsLayer.Player | PhysicsLayer.Enemy);
|
||||
}
|
||||
|
||||
ThreeC.setShadowsStateForChildren(map, true, true);
|
||||
// Must run after the line above — it force-enables shadows on every
|
||||
// mesh under the map, including BreakablePropC's flash overlays, which
|
||||
// this turns back off (see disableFlashShadows).
|
||||
BreakablePropC.disableFlashShadows();
|
||||
|
||||
ThreeC.addToScene(map);
|
||||
|
||||
return colliders;
|
||||
return { colliders, payZoneNode: map.getObjectByName("UI_Interactive_Zone_02") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
AdditiveBlending,
|
||||
Box3,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
Color,
|
||||
DoubleSide,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
|
||||
// How long (seconds) a sampled point stays visible before fully fading —
|
||||
// short and snappy on purpose (see Day 7 "keep effects short"). Governs the
|
||||
// actual trail length in time, independent of framerate. Shortened from an
|
||||
// earlier 0.15 — the longer tail read as too smooth/flowing for a bat swing
|
||||
// (see chat: "не таким плавним").
|
||||
const TRAIL_LIFETIME = 0.30;
|
||||
|
||||
// Safety cap on buffered points — real length is TRAIL_LIFETIME (above),
|
||||
// this just sizes the backing arrays generously for high-refresh displays
|
||||
// (120Hz * 0.1s = 12 samples) with headroom.
|
||||
const MAX_POINTS = 24;
|
||||
|
||||
// Full ribbon width at the freshest point, in world units — shrinks toward
|
||||
// zero as a point ages (see FADE_POWER below for the curve), so the trail
|
||||
// tapers to a point at its tail instead of ending in a hard rectangle.
|
||||
const TRAIL_WIDTH = 0.35;
|
||||
|
||||
// fade = (1 - age/TRAIL_LIFETIME) ^ FADE_POWER — 1 (linear) reads as a
|
||||
// smooth, even gradient; a higher power holds close to full width/color
|
||||
// near the head and then drops off sharply toward the tail, which reads as
|
||||
// snappier/choppier rather than a silky ribbon (see chat: "не таким
|
||||
// плавним"). Applied to both the per-point width and color intensity below.
|
||||
const FADE_POWER = 4.5;
|
||||
|
||||
// Pale, slightly cool white — reads as air/wind displacement rather than a
|
||||
// colored magic effect. Additive blending + fading the color itself toward
|
||||
// black (not a separate alpha channel) is the same cheap trick
|
||||
// BreakablePropC's hit-flash overlay uses, no custom shader needed.
|
||||
const TRAIL_COLOR = new Color(0xdff3ff);
|
||||
// Lowered from an earlier 0.85 — the trail read as too opaque/solid (see
|
||||
// chat: "прозорішим").
|
||||
const MATERIAL_OPACITY = 0.15;
|
||||
|
||||
const UP_AXIS = new Vector3(0, 1, 0);
|
||||
const FALLBACK_TANGENT = new Vector3(0, 0, 1);
|
||||
|
||||
// How far back from the bat's very tip the trail actually samples, as a
|
||||
// fraction of the half-length along the bat's own length axis — 0 = exact
|
||||
// tip corner, 1 = box center. See chat: "не на самий кінець бити".
|
||||
const TIP_INSET_FRACTION = 0.25;
|
||||
|
||||
interface TrailPoint {
|
||||
position: Vector3;
|
||||
age: number;
|
||||
}
|
||||
|
||||
// A hand-rolled screen-facing ribbon that follows the weapon anchor's world
|
||||
// position while active, visualizing the swing's arc — not a three.quarks
|
||||
// prefab, because this needs to track an *animated bone-driven* point every
|
||||
// frame rather than play a fixed, designer-authored burst (see PropVfxC for
|
||||
// that other kind of VFX). Same category of trick as BreakablePropC's
|
||||
// flashMesh: plain geometry + additive blending, no custom shader.
|
||||
export class WeaponTrailC {
|
||||
private static weapon: Object3D | null = null;
|
||||
private static points: TrailPoint[] = [];
|
||||
private static active = false;
|
||||
private static mesh: Mesh;
|
||||
private static geometry: BufferGeometry;
|
||||
// Captured once, after CameraFollowC has set its permanently-frozen look
|
||||
// angle (see CameraFollowC — the camera's rotation never changes again
|
||||
// after init) — the ribbon's width faces this fixed direction instead of
|
||||
// re-querying the camera every frame.
|
||||
private static viewDirection = new Vector3();
|
||||
// Where on the bat the trail actually samples, in the weapon node's own
|
||||
// local space — see computeLocalTipOffset. The node itself is pivoted at
|
||||
// the grip (see chat: the trail was following the hand, not the bat's
|
||||
// business end), so sampling weapon.getWorldPosition() directly anchored
|
||||
// the ribbon at the palm instead of the tip.
|
||||
private static localTipOffset = new Vector3();
|
||||
|
||||
static init(weapon: Object3D) {
|
||||
this.weapon = weapon;
|
||||
this.localTipOffset = this.computeLocalTipOffset(weapon);
|
||||
CameraC_internal.getCamera().getWorldDirection(this.viewDirection);
|
||||
|
||||
this.geometry = this.buildGeometry();
|
||||
const material = new MeshBasicMaterial({
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: MATERIAL_OPACITY,
|
||||
blending: AdditiveBlending,
|
||||
depthWrite: false,
|
||||
side: DoubleSide,
|
||||
toneMapped: false,
|
||||
});
|
||||
|
||||
this.mesh = new Mesh(this.geometry, material);
|
||||
this.mesh.visible = false;
|
||||
ThreeC.addToScene(this.mesh);
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta));
|
||||
}
|
||||
|
||||
// Toggled by PlayerC alongside isAttacking — the trail samples the
|
||||
// weapon's position every frame while true, and keeps aging out whatever
|
||||
// points it already has once false (so a swing that just ended still
|
||||
// trails off naturally instead of vanishing mid-air).
|
||||
static setActive(active: boolean) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
// The weapon node's own pivot is wherever the artist rigged it (the grip,
|
||||
// so it can be parented to the hand bone) — not necessarily "the tip".
|
||||
// Rather than guess a local offset, derive it from the actual geometry:
|
||||
// union the local-space bounding box of every mesh under the node, then
|
||||
// take the far end of its longest axis (the bat's length) as the tip,
|
||||
// centered on the other two axes. Computed once at init — a rigid prop's
|
||||
// own local transform relative to its parent bone doesn't change with
|
||||
// animation, so this is stable regardless of when in the swing it runs.
|
||||
private static computeLocalTipOffset(root: Object3D): Vector3 {
|
||||
root.updateWorldMatrix(true, true);
|
||||
|
||||
const box = new Box3();
|
||||
const corner = new Vector3();
|
||||
root.traverse((child) => {
|
||||
if (!(child instanceof Mesh)) return;
|
||||
child.geometry.computeBoundingBox();
|
||||
const meshBox = child.geometry.boundingBox;
|
||||
if (!meshBox) return;
|
||||
|
||||
for (const x of [meshBox.min.x, meshBox.max.x]) {
|
||||
for (const y of [meshBox.min.y, meshBox.max.y]) {
|
||||
for (const z of [meshBox.min.z, meshBox.max.z]) {
|
||||
corner.set(x, y, z);
|
||||
child.localToWorld(corner);
|
||||
root.worldToLocal(corner);
|
||||
box.expandByPoint(corner);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (box.isEmpty()) return new Vector3();
|
||||
|
||||
const size = box.getSize(new Vector3());
|
||||
const lengthAxis: "x" | "y" | "z" =
|
||||
size.x >= size.y && size.x >= size.z ? "x" : size.y >= size.z ? "y" : "z";
|
||||
|
||||
const tip = box.getCenter(new Vector3());
|
||||
const centerValue = tip[lengthAxis];
|
||||
const farValue = Math.abs(box.max[lengthAxis]) > Math.abs(box.min[lengthAxis]) ? box.max[lengthAxis] : box.min[lengthAxis];
|
||||
tip[lengthAxis] = farValue + (centerValue - farValue) * TIP_INSET_FRACTION;
|
||||
return tip;
|
||||
}
|
||||
|
||||
private static sampleWeaponTip(): Vector3 {
|
||||
this.weapon!.updateWorldMatrix(true, false);
|
||||
return this.weapon!.localToWorld(this.localTipOffset.clone());
|
||||
}
|
||||
|
||||
private static buildGeometry(): BufferGeometry {
|
||||
const segments = MAX_POINTS - 1;
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(new Float32Array(segments * 6 * 3), 3));
|
||||
geometry.setAttribute("color", new BufferAttribute(new Float32Array(segments * 6 * 3), 3));
|
||||
geometry.setDrawRange(0, 0);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
private static update(delta: number) {
|
||||
if (!this.weapon) return;
|
||||
|
||||
for (const point of this.points) point.age += delta;
|
||||
this.points = this.points.filter((point) => point.age < TRAIL_LIFETIME);
|
||||
|
||||
if (this.active) {
|
||||
this.points.push({ position: this.sampleWeaponTip(), age: 0 });
|
||||
if (this.points.length > MAX_POINTS) this.points.shift();
|
||||
}
|
||||
|
||||
this.rebuildGeometry();
|
||||
}
|
||||
|
||||
private static rebuildGeometry() {
|
||||
const n = this.points.length;
|
||||
this.mesh.visible = n >= 2;
|
||||
if (n < 2) {
|
||||
this.geometry.setDrawRange(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// One pass to derive each point's own tangent/perpendicular/fade first,
|
||||
// then a second pass to stitch adjacent points into quads — sharing a
|
||||
// point's edge between its two neighboring segments keeps the ribbon
|
||||
// continuous instead of faceted at every sample.
|
||||
const lefts: Vector3[] = [];
|
||||
const rights: Vector3[] = [];
|
||||
const fades: number[] = [];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const point = this.points[i];
|
||||
const forward =
|
||||
i < n - 1
|
||||
? this.points[i + 1].position.clone().sub(point.position)
|
||||
: point.position.clone().sub(this.points[i - 1].position);
|
||||
if (forward.lengthSq() < 1e-8) forward.copy(FALLBACK_TANGENT);
|
||||
forward.normalize();
|
||||
|
||||
const perp = new Vector3().crossVectors(forward, this.viewDirection);
|
||||
if (perp.lengthSq() < 1e-8) perp.crossVectors(forward, UP_AXIS);
|
||||
perp.normalize();
|
||||
|
||||
const fade = Math.pow(Math.max(0, 1 - point.age / TRAIL_LIFETIME), FADE_POWER);
|
||||
const halfWidth = (TRAIL_WIDTH / 2) * fade;
|
||||
|
||||
lefts.push(point.position.clone().addScaledVector(perp, halfWidth));
|
||||
rights.push(point.position.clone().addScaledVector(perp, -halfWidth));
|
||||
fades.push(fade);
|
||||
}
|
||||
|
||||
const positions = this.geometry.attributes.position.array as Float32Array;
|
||||
const colors = this.geometry.attributes.color.array as Float32Array;
|
||||
let vertex = 0;
|
||||
|
||||
const write = (position: Vector3, fade: number) => {
|
||||
const base = vertex * 3;
|
||||
positions[base] = position.x;
|
||||
positions[base + 1] = position.y;
|
||||
positions[base + 2] = position.z;
|
||||
colors[base] = TRAIL_COLOR.r * fade;
|
||||
colors[base + 1] = TRAIL_COLOR.g * fade;
|
||||
colors[base + 2] = TRAIL_COLOR.b * fade;
|
||||
vertex++;
|
||||
};
|
||||
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
write(lefts[i], fades[i]);
|
||||
write(rights[i], fades[i]);
|
||||
write(lefts[i + 1], fades[i + 1]);
|
||||
|
||||
write(rights[i], fades[i]);
|
||||
write(lefts[i + 1], fades[i + 1]);
|
||||
write(rights[i + 1], fades[i + 1]);
|
||||
}
|
||||
|
||||
this.geometry.attributes.position.needsUpdate = true;
|
||||
this.geometry.attributes.color.needsUpdate = true;
|
||||
this.geometry.setDrawRange(0, vertex);
|
||||
this.geometry.computeBoundingSphere();
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,101 @@
|
||||
/* flex-basis: 60%; */
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* HudC — resource counter plate (dark background + diagonal wood-plank
|
||||
icon baked into resourceCounterBgSrc, see images.ts/temp/Tool_15.webp).
|
||||
184x71 source aspect ratio (~2.59:1) — aspect-ratio below derives height
|
||||
from width automatically, so the two can never drift out of sync the way
|
||||
two independently-tuned vh values could if only one got changed later.
|
||||
background-image itself is set from HudC.ts (the asset path/base64 is
|
||||
only known at runtime via the build's asset pipeline), everything else
|
||||
lives here.
|
||||
|
||||
Split into two elements on purpose: .resource-hud only positions/sizes
|
||||
and plays the one-shot mount animation; .resource-hud__plate (background
|
||||
+ number together) is what the update-bump animates. Doing both on the
|
||||
same element would fight each other — toggling a class that swaps
|
||||
.resource-hud's own `animation-name` away from resource-hud-enter and
|
||||
back again makes the browser replay resource-hud-enter every time
|
||||
(a value change, even back to the same value, restarts a CSS animation),
|
||||
so every resource pickup would re-trigger the fade/slide-in on top of
|
||||
the bump. Two elements means two independent animation lifecycles. */
|
||||
.resource-hud {
|
||||
/* Single source of truth for the plate's scale — width below and the
|
||||
count's font-size (see .resource-hud__count) both derive from this one
|
||||
value, so resizing the whole HUD is a one-line change instead of
|
||||
hunting down every place a size was independently tuned in vh. */
|
||||
--hud-width: 9vh;
|
||||
position: absolute;
|
||||
top: 6%;
|
||||
right: 10%;
|
||||
width: var(--hud-width);
|
||||
aspect-ratio: 184 / 71;
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
/* Runs once as soon as the element mounts — no JS timing/opacity code
|
||||
needed, the browser drives it (Day 8: "use built-in animations instead
|
||||
of changing elements through code"). */
|
||||
animation: resource-hud-enter 400ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes resource-hud-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(1.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.resource-hud__plate {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
/* Toggled by HudC whenever the displayed number actually changes — a pure
|
||||
CSS "pop" of the whole plate (background + number together), not a
|
||||
JS-driven tween (Day 8 technical note) and not just the digit (see
|
||||
chat: "не тільки на каунтер"). HudC removes and re-adds this class
|
||||
(forcing a reflow in between) each time, which is what lets the
|
||||
animation restart cleanly even if it retriggers before the previous run
|
||||
finished — e.g. PayZoneC draining one resource every 0.3s while the
|
||||
player stands in the zone. */
|
||||
.resource-hud__plate.is-bumping {
|
||||
animation: resource-hud-bump 220ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes resource-hud-bump {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.resource-hud__count {
|
||||
position: absolute;
|
||||
left: 16%;
|
||||
top: 46%;
|
||||
width: 46%;
|
||||
transform: translateY(-50%);
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
/* Scales with --hud-width (set on .resource-hud, inherited down through
|
||||
.resource-hud__plate) instead of a fixed vh value, so it tracks
|
||||
whatever the plate is currently sized to — ratio matches the 2.1vh
|
||||
that read well at the original 12vh plate width (2.1/12 ≈ 0.175). */
|
||||
font-size: calc(var(--hud-width) * 0.175);
|
||||
font-family: gameFont, sans-serif;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ConvertToBase64WhenRelease } from "@hitplay/ads_common";
|
||||
|
||||
// Designer-authored resource-counter plate (dark rounded plate with a
|
||||
// diagonal wood-plank icon already baked into the art, see temp/Tool_15.webp
|
||||
// and the reference screenshots alongside it) — replaces HudC's own
|
||||
// CSS-drawn pill background; the plank icon is part of this image, not a
|
||||
// separate <img>.
|
||||
export const resourceCounterBgSrc = ConvertToBase64WhenRelease("./resource_counter_bg.webp");
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ import {
|
||||
Template,
|
||||
Template3d,
|
||||
ThreeC_internal,
|
||||
TweenC,
|
||||
FilterScene,
|
||||
InstallBanner,
|
||||
} from "@hitplay/playable_template";
|
||||
@@ -25,6 +26,7 @@ export const beforeResourcesLoadedCb = () => {
|
||||
ThreeC.setupDirectionalLight();
|
||||
|
||||
Physics_internal.init(new Vec3(0, -9.81, 0));
|
||||
TweenC.init();
|
||||
|
||||
// zone is omitted on purpose: JoystickC auto-creates a full-screen zone
|
||||
// when none is given, so the joystick can appear wherever the player taps
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": false,
|
||||
"allowJs": true
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "templateLibs", "build", "zip"]
|
||||
|
||||
Reference in New Issue
Block a user