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