Files
onboarding-project/docs/worklog/07-hit-feedback-sparks-hpbar-damage-numbers.md
2026-08-14 15:03:43 +03:00

14 KiB
Raw Permalink Blame History

Розширений 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/, ще не імпортований) — ймовірно призначений саме для цього.