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:
Oleksandr Vlasiuk
2026-08-12 18:52:40 +03:00
parent fe1192f4c9
commit 4a22a9b140
20 changed files with 3617 additions and 187 deletions
+910
View File
@@ -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.
### 34. Тригери + 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, в чаті цієї сесії не
зафіксовано; якщо матимеш контекст, допиши сюди.
### 23. 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
View File
@@ -21,7 +21,8 @@
"cannon-es-debugger": "^1.0.0", "cannon-es-debugger": "^1.0.0",
"howler": "^2.2.4", "howler": "^2.2.4",
"nipplejs": "^1.0.4", "nipplejs": "^1.0.4",
"three": "^0.185.1" "three": "^0.185.1",
"three.quarks": "^0.16.0"
}, },
"devDependencies": { "devDependencies": {
"@types/howler": "^2.2.13", "@types/howler": "^2.2.13",
+294
View File
@@ -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();
}
}
+95 -80
View File
@@ -1,79 +1,121 @@
import { Object3D, Raycaster, Vector3 } from "three"; import { Object3D, Vector3 } from "three";
import { CameraC_internal, UpdateController } from "@hitplay/playable_template"; import { CameraC_internal, UpdateController } from "@hitplay/playable_template";
// How fast the camera closes the gap to its target position/look point, per // How fast the camera closes the gap to its target position, per second.
// second. Higher = snappier, lower = floatier. Framerate-independent (see // Higher = snappier, lower = floatier. Framerate-independent (see the
// the exponential smoothing in update()), so the feel is the same at 30fps // exponential smoothing in update()), so the feel is the same at 30fps and
// and 60fps instead of drifting with frame time. // 60fps instead of drifting with frame time.
const POSITION_DAMPING = 5; const POSITION_DAMPING = 5;
const LOOK_DAMPING = 8;
// Keeps the camera a bit off any obstacle it lands on, and never lets it // How far (world units) the camera's target position pans in the direction
// collapse onto the target itself. // the character is actually TRANSLATING — a fixed-length nudge, not scaled
const OBSTACLE_SKIN = 0.4; // by speed, so it's the full offset the instant they're moving at all and
const MIN_DISTANCE = 1.5; // 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 // Smooths transitions in and out of the look-ahead pan (starting/stopping)
// alike — pans toward wherever the character is currently facing, to open // so it doesn't snap. Separate from, and slower than, the position damping
// up more of the space ahead of them instead of centering them dead-on. // below — see update(). Slower than before (was 2) per feedback: the ease
// Matches PlayerC's own forward convention (+z at rest); the two aren't // itself needed to read more gradual.
// coupled in code, but there's only one character to stay in sync with. const LOOK_AHEAD_DAMPING = 1.2;
const LOOK_AHEAD_DISTANCE = 1.2;
// The character's body can spin at up to 540°/s (PlayerC.MAX_TURN_SPEED), // Below this much squared movement in a single frame, treat the character
// which would yank the look-ahead point around just as fast if it followed // as stationary — guards against floating-point noise flickering a tiny
// the raw facing direction. Smoothing the direction itself, separately from // direction in and out while genuinely standing still.
// (and slower than) the position/look damping below, is what actually makes const MOVEMENT_EPSILON_SQ = 0.0005 * 0.0005;
// the pan gentle regardless of how fast the character turns.
const LOOK_AHEAD_DAMPING = 2;
const FORWARD_AXIS = new Vector3(0, 0, 1);
export class CameraFollowC { export class CameraFollowC {
private static target: Object3D | null = null; private static target: Object3D | null = null;
private static offset = new Vector3(); private static offset = new Vector3();
private static obstacles: Object3D[] = []; private static previousTargetPosition = new Vector3();
private static eyeHeight = 0; // The last direction actually walked in, held indefinitely — see update()
private static smoothedLookAt = new Vector3(); // for why this must never get reset to zero on its own.
// The actual (x,y,z) offset, not a direction — see update() for why that private static desiredLookAhead = new Vector3();
// distinction matters. private static smoothedLookAhead = new Vector3();
private static smoothedLookAhead = FORWARD_AXIS.clone().multiplyScalar(LOOK_AHEAD_DISTANCE); // Injected (avoids importing PlayerC directly) — see setFacingTargetGetter.
private static raycaster = new Raycaster(); 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.target = target;
this.offset = offset.clone(); this.offset = offset.clone();
this.obstacles = obstacles; this.previousTargetPosition.copy(target.position);
this.eyeHeight = eyeHeight; this.desiredLookAhead.set(0, 0, 0);
this.smoothedLookAt.copy(target.position); 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)); 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) { private static update(delta: number) {
if (!this.target) return; if (!this.target) return;
const camera = CameraC_internal.getCamera(); const camera = CameraC_internal.getCamera();
const targetPosition = this.target.position; const targetPosition = this.target.position;
const facing = FORWARD_AXIS.clone().applyQuaternion(this.target.quaternion); const movement = targetPosition.clone().sub(this.previousTargetPosition);
facing.y = 0; this.previousTargetPosition.copy(targetPosition);
facing.normalize(); movement.y = 0;
const desiredLookAhead = facing.multiplyScalar(LOOK_AHEAD_DISTANCE);
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); const lookAheadT = 1 - Math.exp(-LOOK_AHEAD_DAMPING * delta);
this.smoothedLookAhead.lerp(desiredLookAhead, lookAheadT); this.smoothedLookAhead.lerp(this.desiredLookAhead, lookAheadT);
const lookAhead = this.smoothedLookAhead;
const desiredPosition = targetPosition.clone().add(this.offset).add(lookAhead); const desiredPosition = targetPosition.clone().add(this.offset).add(this.smoothedLookAhead);
this.avoidObstacles(targetPosition, desiredPosition);
// Exponential (framerate-independent) damping instead of a fixed lerp // Exponential (framerate-independent) damping instead of a fixed lerp
// factor per frame — a fixed factor changes speed with the frame rate // 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); const positionT = 1 - Math.exp(-POSITION_DAMPING * delta);
camera.position.lerp(desiredPosition, positionT); camera.position.lerp(desiredPosition, positionT);
const lookT = 1 - Math.exp(-LOOK_DAMPING * delta); // Rotation is deliberately never touched here (no lookAt) — this is a
this.smoothedLookAt.lerp(targetPosition.clone().add(lookAhead), lookT); // linear-shift follow camera, not an orbiting one. The angle is fixed
camera.lookAt(this.smoothedLookAt); // once in init() and never changes afterward.
}
// 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);
} }
} }
+118
View File
@@ -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");
}
}
+244
View File
@@ -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
View File
@@ -3,8 +3,8 @@ import {
Physics_internal, Physics_internal,
UpdateController, UpdateController,
} from "@hitplay/playable_template"; } from "@hitplay/playable_template";
import { Box3, Object3D, Vector3 } from "three"; import { Box3, Mesh, Object3D, Quaternion, Vector3 } from "three";
import { Body, Box, Quaternion, Sphere, Vec3 } from "cannon-es"; import { Body, Box, Sphere, Vec3 } from "cannon-es";
export enum PhysicsLayer { export enum PhysicsLayer {
Player = 1, Player = 1,
@@ -27,28 +27,19 @@ export class PhysicsBody {
) { ) {
let isPlayer = col_group === PhysicsLayer.Player; let isPlayer = col_group === PhysicsLayer.Player;
let oldQuaternion = threeObj.quaternion.clone(); // Local (unrotated) bounding size scaled by world scale, combined with
// the world quaternion below, instead of a world-space Box3 with just
let nullQuaternion = new Quaternion(); // this object's own rotation zeroed out — colliders here are nested
threeObj.quaternion.copy(nullQuaternion); // 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/
let bbox = new Box3().setFromObject(threeObj); // oversized for anything not axis-aligned all the way up the chain.
let size = isPlayer ? new Vector3() : PhysicsBody.getLocalSize(threeObj);
let size = new Vector3(); let worldScale = threeObj.getWorldScale(new Vector3());
bbox.getSize(size); size.multiply(worldScale);
// if you need custom size
// if (col_group === PhysicsLayer.wall) {
// size.x = size.z = 1;
// size.y = 1;
// }
threeObj.quaternion.copy(oldQuaternion);
this.body = new Body({ this.body = new Body({
isTrigger: trigger, isTrigger: trigger,
mass: mass, mass: mass,
//shape: shape,
shape: isPlayer shape: isPlayer
? new Sphere(player_sphere) ? new Sphere(player_sphere)
: new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2)), : 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()); let worldPos = threeObj.getWorldPosition(new Vector3());
this.body.position.set(worldPos.x, worldPos.y, worldPos.z); this.body.position.set(worldPos.x, worldPos.y, worldPos.z);
this.body.quaternion.setFromEuler( let worldQuat = threeObj.getWorldQuaternion(new Quaternion());
threeObj.rotation.x, this.body.quaternion.set(worldQuat.x, worldQuat.y, worldQuat.z, worldQuat.w);
threeObj.rotation.y,
threeObj.rotation.z,
"XYZ"
);
// if you need sync three obj and physics body // if you need sync three obj and physics body
// if (isEnemy) { // if (isEnemy) {
@@ -81,6 +67,20 @@ export class PhysicsBody {
return this; 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() { disablePhysicsPair() {
if (this.pair) { if (this.pair) {
this.pair.destroyed = true; this.pair.destroyed = true;
+280 -68
View File
@@ -11,10 +11,25 @@ import {
} from "three"; } from "three";
import { JoystickC, UpdateController } from "@hitplay/playable_template"; import { JoystickC, UpdateController } from "@hitplay/playable_template";
import { ThreeC } from "./ThreeC"; import { ThreeC } from "./ThreeC";
import { BreakableProp, BreakablePropC } from "./BreakablePropC";
import { WeaponTrailC } from "./WeaponTrailC";
const MOVE_SPEED = 4; // world units per second 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 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 // 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. // (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); 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. // correction and a full reversal at the same rate, which reads as smooth.
const MAX_TURN_SPEED = Math.PI * 3; // ~540°/s 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 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 // How far off-center (in the XZ plane) an obstacle can be from the
// character's forward direction and still count as "facing it". // character's forward direction and still count as "facing it".
// dot(forward, toObstacle) > this — 0.5 is a 120°-wide cone (60° each side). // dot(forward, toObstacle) > this — -0.5 is a 240°-wide cone (120° each side).
const FACING_DOT_THRESHOLD = 0.5; const FACING_DOT_THRESHOLD = -0.5;
// tryMove() rejects the whole step that would overlap an obstacle, so the // 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 // 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 // movement worth of gap) — Box3.intersectsBox on the exact collision box
// would basically never see contact. This extra margin, checked only for // would basically never see contact. This extra margin, checked only for
// the attack/interaction range (not movement), covers that gap. // 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 UP_AXIS = new Vector3(0, 1, 0);
const FORWARD_AXIS = new Vector3(0, 0, 1); 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 { export class PlayerC {
static object: Object3D; static object: Object3D;
private static moveInput = new Vector2(); 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 facingRotation = new Quaternion();
private static mixer: AnimationMixer; private static mixer: AnimationMixer;
@@ -64,6 +113,17 @@ export class PlayerC {
private static attackAction: AnimationAction; private static attackAction: AnimationAction;
private static currentAction: AnimationAction; private static currentAction: AnimationAction;
private static isAttacking = false; 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 pistol: Object3D;
private static batInHand: Object3D; private static batInHand: Object3D;
@@ -79,14 +139,20 @@ export class PlayerC {
ThreeC.addToScene(this.object); ThreeC.addToScene(this.object);
this.obstacles = colliders this.obstacles = colliders
.map((collider) => new Box3().setFromObject(collider)) .map((collider) => ({ node: collider, box: new Box3().setFromObject(collider) }))
.filter((box) => box.max.y - box.min.y >= MIN_OBSTACLE_HEIGHT); .filter((obstacle) => obstacle.box.max.y - obstacle.box.min.y >= MIN_OBSTACLE_HEIGHT);
this.bindJoystick(); this.bindJoystick();
UpdateController.Instance.onUpdate.addDelegate((delta) => this.update(delta)); 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() { private static createCharacter() {
const character = ThreeC.getObject("character"); const character = ThreeC.getObject("character");
@@ -119,6 +185,16 @@ export class PlayerC {
this.equipPistol(); 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 — // 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 // 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. // 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.idleAction = this.mixer.clipAction(idleClip);
this.walkAction = this.mixer.clipAction(walkClip); this.walkAction = this.mixer.clipAction(walkClip);
this.attackAction = this.mixer.clipAction(attackClip); this.attackAction = this.mixer.clipAction(attackClip);
// Loops continuously while attacking — see updateCombat().
this.attackAction.setLoop(LoopRepeat, Infinity); this.attackAction.setLoop(LoopRepeat, Infinity);
this.currentAction = this.idleAction; this.currentAction = this.idleAction;
@@ -178,44 +255,21 @@ export class PlayerC {
} }
private static update(delta: number) { 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) { this.updateCombat(delta, stopped);
// 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();
if (!this.isAttacking) { if (!this.isAttacking) {
if (inputMagnitude > 0) { if (this.currentSpeed > 0) {
// Match the walk cycle's playback speed to how hard the stick is // Match the walk cycle's playback speed to actual current speed, not
// pushed, not just whether it's pushed — otherwise a small nudge // just whether the stick is pushed — otherwise the animation would
// still plays the animation at full speed while the character // freeze into Idle the instant input stops while the character is
// barely moves, and the feet visibly skate across the ground. // still coasting to a stop, or run at full pace during the initial
this.walkAction.timeScale = inputMagnitude; // ramp-up while barely moving.
this.walkAction.timeScale = this.currentSpeed / MOVE_SPEED;
this.playAction(this.walkAction); this.playAction(this.walkAction);
} else { } else {
this.playAction(this.idleAction); this.playAction(this.idleAction);
@@ -223,45 +277,203 @@ export class PlayerC {
} }
} }
// Attacking requires standing next to a destructible AND facing it — pure // Stick-driven movement, facing, and turning — runs every frame regardless
// proximity (e.g. backing into a crate) shouldn't swing the bat. Re-reads // of combat state (moving is what cancels an attack, see updateCombat, so
// both conditions every frame, so walking or turning away — or the // it can't be the thing gated off during one). Returns whether the
// obstacle later being removed by a destruction system — stops it // character is now fully stopped (no input AND speed decayed to zero),
// automatically, with no separate "stop" case to maintain. // the bar updateCombat uses to allow an attack to start or continue.
private static updateCombat() { private static updateMovement(delta: number, nearbyObstacle: NearbyObstacle | null): boolean {
const obstacleCenter = this.getFacingObstacleCenter(); const inputMagnitude = this.moveInput.length();
const isAttacking = obstacleCenter !== null;
if (isAttacking === this.isAttacking) return; // Movement direction tracks the stick independently of facing below, so
this.isAttacking = isAttacking; // 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
this.equipBat(); // needs to rotate the character's default +z-facing front to point
this.playAction(this.attackAction); // the same way. x is negated because the camera now looks toward +Z
} else { // (see TestSceneC's CAMERA_OFFSET) — that's a 180° yaw from the
this.equipPistol(); // 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;
} }
// Returns the center of the destructible obstacle the player is touching // The point the character is currently locked onto facing (a nearby
// AND facing, or null if there isn't one. // breakable it's stopped next to), or null otherwise — CameraFollowC uses
private static getFacingObstacleCenter(): Vector3 | null { // 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);
}
// 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 bounds = this.getCollisionBounds(this.object.position).expandByScalar(INTERACTION_REACH);
const touching = this.obstacles.find((box) => box.intersectsBox(bounds)); const targets: ZoneTarget[] = [];
if (!touching) return null;
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); return targets;
toObstacle.y = 0; }
if (toObstacle.lengthSq() < 1e-6) return center; // standing right on top of it — count as facing
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); const forward = FORWARD_AXIS.clone().applyQuaternion(this.object.quaternion);
forward.y = 0; forward.y = 0;
forward.normalize(); 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) { private static tryMove(dx: number, dz: number) {
@@ -270,7 +482,7 @@ export class PlayerC {
nextPosition.z += dz; nextPosition.z += dz;
const nextBounds = this.getCollisionBounds(nextPosition); 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; if (blocked) return;
this.object.position.copy(nextPosition); this.object.position.copy(nextPosition);
+90
View File
@@ -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);
}
}
+391
View File
@@ -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);
}
}
+173
View File
@@ -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();
}
}
+88 -10
View File
@@ -1,25 +1,55 @@
import { Object3D, Vector3 } from "three"; import { Object3D, Vector3 } from "three";
import { ThreeC } from "./ThreeC"; import { ThreeC } from "./ThreeC";
import { PlayerC, PLAYER_HEIGHT } from "./PlayerC"; import { PlayerC } from "./PlayerC";
import { CameraFollowC } from "./CameraFollowC"; 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 — // Above and behind the player, along the road's forward (+Z) direction —
// player now spawns at the other end of the road, walking toward +Z, so // player now spawns at the other end of the road, walking toward +Z, so
// "behind" flipped from +Z to -Z to keep looking the same way they walk. // "behind" flipped from +Z to -Z to keep looking the same way they walk.
const CAMERA_OFFSET = new Vector3(0, 9, -9); 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 { export class TestSceneC {
static init() { static init() {
const colliders = this.createMap(); PropVfxC.init();
SparkleFxC.init();
const { colliders, payZoneNode } = this.createMap();
PlayerC.init(colliders); 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() { 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); 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); ThreeC.addToScene(map);
return colliders; return { colliders, payZoneNode: map.getObjectByName("UI_Interactive_Zone_02") };
} }
} }
+256
View File
@@ -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();
}
}
+98
View File
@@ -2,3 +2,101 @@
/* flex-basis: 60%; */ /* flex-basis: 60%; */
flex-grow: 1; 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);
}
+8
View File
@@ -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, Template,
Template3d, Template3d,
ThreeC_internal, ThreeC_internal,
TweenC,
FilterScene, FilterScene,
InstallBanner, InstallBanner,
} from "@hitplay/playable_template"; } from "@hitplay/playable_template";
@@ -25,6 +26,7 @@ export const beforeResourcesLoadedCb = () => {
ThreeC.setupDirectionalLight(); ThreeC.setupDirectionalLight();
Physics_internal.init(new Vec3(0, -9.81, 0)); Physics_internal.init(new Vec3(0, -9.81, 0));
TweenC.init();
// zone is omitted on purpose: JoystickC auto-creates a full-screen zone // 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 // when none is given, so the joystick can appear wherever the player taps
+2 -1
View File
@@ -12,7 +12,8 @@
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noImplicitAny": false, "noImplicitAny": false,
"allowJs": true "allowJs": true,
"resolveJsonModule": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "templateLibs", "build", "zip"] "exclude": ["node_modules", "dist", "templateLibs", "build", "zip"]