Merge pull request 'feat(studio): Mixamo skins + crouch + fullscreen + skin from DB' (#41) from feat/mixamo-skins-fullscreen-2026-06-14 into main
This commit is contained in:
commit
a697438661
@ -640,6 +640,37 @@ const KubikonEditor = () => {
|
|||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [isPlaying]);
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
// 2026-06-14: блокировка системных Ctrl-хоткеев во время Play.
|
||||||
|
// F-клавиши и Ctrl+W/D/T/R/S/A/P/F/U/J/H/L/O/G + Ctrl+1..9 + Ctrl+Tab.
|
||||||
|
// В fullscreen Chrome даёт preventDefault'иться. WASD-хоткеи
|
||||||
|
// (Ctrl+W/A/S/D) НЕ stopPropagation — PlayerController должен их видеть
|
||||||
|
// (одновременный crouch+движение).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.code === 'F5' || e.code === 'F3' || e.code === 'F6' || e.code === 'F7') {
|
||||||
|
e.preventDefault(); e.stopPropagation(); return;
|
||||||
|
}
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
const wasd = ['KeyW', 'KeyA', 'KeyS', 'KeyD'];
|
||||||
|
if (wasd.includes(e.code)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blocked = ['KeyR','KeyT','KeyN','KeyP','KeyU','KeyJ','KeyH',
|
||||||
|
'KeyF','KeyG','KeyL','KeyO','Tab',
|
||||||
|
'Digit1','Digit2','Digit3','Digit4','Digit5',
|
||||||
|
'Digit6','Digit7','Digit8','Digit9'];
|
||||||
|
if (blocked.includes(e.code)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, { capture: true });
|
||||||
|
return () => window.removeEventListener('keydown', onKey, { capture: true });
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
// При выходе из Play сбросим HP к полному (для следующего захода)
|
// При выходе из Play сбросим HP к полному (для следующего захода)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPlaying) {
|
if (!isPlaying) {
|
||||||
@ -2032,7 +2063,7 @@ const KubikonEditor = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = async () => {
|
||||||
const scene = sceneRef.current;
|
const scene = sceneRef.current;
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
if (scene.isPlaying()) {
|
if (scene.isPlaying()) {
|
||||||
@ -2043,6 +2074,39 @@ const KubikonEditor = () => {
|
|||||||
// дёргается только на Esc-выход, кнопка Стоп — нет.
|
// дёргается только на Esc-выход, кнопка Стоп — нет.
|
||||||
hudRef.current?.reset?.();
|
hudRef.current?.reset?.();
|
||||||
} else {
|
} else {
|
||||||
|
// 2026-06-14: Перед входом в Play подтягиваем СКИН ЮЗЕРА из БД
|
||||||
|
// (если ещё не передан в URL #skin=). Источник:
|
||||||
|
// 1) URL hash #skin=<id> (если уже есть — не трогаем)
|
||||||
|
// 2) БД (rublox_equipped_skin) через /equipped-skin GET
|
||||||
|
// BabylonScene.enterPlayMode сам прочитает hash, поэтому
|
||||||
|
// записываем туда найденный скин.
|
||||||
|
try {
|
||||||
|
const hasHashSkin = /[#&]skin=/.test(window.location.hash || '');
|
||||||
|
if (!hasHashSkin) {
|
||||||
|
const uid = getCurrentUserId();
|
||||||
|
if (uid) {
|
||||||
|
const r = await Kubikon3DApi.getEquippedSkin(uid);
|
||||||
|
const sf = r?.data?.skin_folder;
|
||||||
|
if (sf && typeof sf === 'string') {
|
||||||
|
// Подмешиваем в hash так чтобы не сломать ticket=...
|
||||||
|
const cur = window.location.hash || '';
|
||||||
|
const sep = cur && !cur.endsWith('&') ? '&' : '';
|
||||||
|
const newHash = cur
|
||||||
|
? `${cur}${sep}skin=${encodeURIComponent(sf)}`
|
||||||
|
: `#skin=${encodeURIComponent(sf)}`;
|
||||||
|
// history.replaceState чтобы не сломать react-router
|
||||||
|
window.history.replaceState(
|
||||||
|
null, '',
|
||||||
|
window.location.pathname + window.location.search + newHash,
|
||||||
|
);
|
||||||
|
console.log('[KubikonEditor] play skin from DB:', sf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[KubikonEditor] equipped-skin fetch failed:', e?.message || e);
|
||||||
|
}
|
||||||
|
|
||||||
// Флаш ScriptEditor — иначе при печати → сразу Play игра пойдёт
|
// Флаш ScriptEditor — иначе при печати → сразу Play игра пойдёт
|
||||||
// со старым кодом (debounce 600мс ещё не сработал).
|
// со старым кодом (debounce 600мс ещё не сработал).
|
||||||
try { scriptEditorFlushRef.current?.(); } catch (_) {}
|
try { scriptEditorFlushRef.current?.(); } catch (_) {}
|
||||||
@ -2061,6 +2125,20 @@ const KubikonEditor = () => {
|
|||||||
scene.setSpawnPoint(sp.x, spawnY, sp.z);
|
scene.setSpawnPoint(sp.x, spawnY, sp.z);
|
||||||
scene.enterPlayMode();
|
scene.enterPlayMode();
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
// 2026-06-14: при входе в Play автоматически запрашиваем
|
||||||
|
// fullscreen — иначе Ctrl+W/Ctrl+D случайно закрывают вкладку
|
||||||
|
// в режиме игры. Это user gesture (клик по кнопке Play),
|
||||||
|
// поэтому requestFullscreen() разрешён.
|
||||||
|
try {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const req = root.requestFullscreen
|
||||||
|
|| root.webkitRequestFullscreen
|
||||||
|
|| root.mozRequestFullScreen
|
||||||
|
|| root.msRequestFullscreen;
|
||||||
|
if (req && !document.fullscreenElement) {
|
||||||
|
req.call(root).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) { /* юзер запретил — играем без FS */ }
|
||||||
// Если активен таб скрипта — авто-переключение на «Сцена»,
|
// Если активен таб скрипта — авто-переключение на «Сцена»,
|
||||||
// чтобы пользователь сразу видел игру.
|
// чтобы пользователь сразу видел игру.
|
||||||
setActiveTabId('scene');
|
setActiveTabId('scene');
|
||||||
|
|||||||
@ -214,7 +214,7 @@ export class BabylonScene {
|
|||||||
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
|
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
|
||||||
// 'skin_*' грузится из characters/<id>/body.glb (R15-скелет),
|
// 'skin_*' грузится из characters/<id>/body.glb (R15-скелет),
|
||||||
// 'character-*' — старые Kenney-модели.
|
// 'character-*' — старые Kenney-модели.
|
||||||
this._playerModelType = 'skin_bacon-hair';
|
this._playerModelType = 'skin_y-bot';
|
||||||
// Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z.
|
// Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z.
|
||||||
// По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize().
|
// По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize().
|
||||||
this._worldHalf = 40;
|
this._worldHalf = 40;
|
||||||
@ -347,7 +347,13 @@ export class BabylonScene {
|
|||||||
this.blockManager = new BlockManager(this.scene);
|
this.blockManager = new BlockManager(this.scene);
|
||||||
// При создании нового proto-меша блока — сразу регистрируем его
|
// При создании нового proto-меша блока — сразу регистрируем его
|
||||||
// как shadow caster (если генератор уже создан).
|
// как shadow caster (если генератор уже создан).
|
||||||
|
// ОПТИМИЗАЦИЯ: на БОЛЬШИХ block-картах (стриминг включён — лабиринты,
|
||||||
|
// 200к+ блоков) НЕ кастуем тени от блоков. Shadow-map иначе рендерит
|
||||||
|
// всю видимую геометрию повторно — это и есть причина «idle 220мс/кадр»
|
||||||
|
// при крошечном render_ms. Точно как с terrain (см. ниже). Блоки всё
|
||||||
|
// равно ПРИНИМАЮТ тени (receiveShadows на proto), но сами не кастуют.
|
||||||
this.blockManager.setOnProtoCreated((proto) => {
|
this.blockManager.setOnProtoCreated((proto) => {
|
||||||
|
if (this._blockStreamingEnabled) return;
|
||||||
this.addShadowCaster(proto);
|
this.addShadowCaster(proto);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1552,6 +1558,38 @@ export class BabylonScene {
|
|||||||
const decoRadius = Math.max(18, radius * 0.35);
|
const decoRadius = Math.max(18, radius * 0.35);
|
||||||
this.decoManager.updateStreaming(cx, cz, decoRadius);
|
this.decoManager.updateStreaming(cx, cz, decoRadius);
|
||||||
}
|
}
|
||||||
|
// Чанковый стриминг БЛОКОВ (большие block-карты:
|
||||||
|
// лабиринты). Радиус больше террейна — высокие стены
|
||||||
|
// нужно видеть дальше. Регионы вне радиуса скрыты.
|
||||||
|
if (this._blockStreamingEnabled && this.blockManager?.updateStreaming) {
|
||||||
|
const blockRadius = Math.max(90, radius * 1.6);
|
||||||
|
this.blockManager.updateStreaming(cx, cz, blockRadius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Block-стриминг работает и когда terrain-стриминг ВЫКЛЮЧЕН
|
||||||
|
// (block-карта без воксельного террейна — как лабиринт).
|
||||||
|
else if (this._blockStreamingEnabled && this.blockManager?.updateStreaming) {
|
||||||
|
const nowMs3 = performance.now();
|
||||||
|
if (nowMs3 - (this._blockStreamingLastUpdate || 0) > 200) {
|
||||||
|
this._blockStreamingLastUpdate = nowMs3;
|
||||||
|
let bx, bz;
|
||||||
|
if (this._isPlaying && this.player && this.player._pos) {
|
||||||
|
bx = this.player._pos.x; bz = this.player._pos.z;
|
||||||
|
} else if (this.camera && this.camera.position) {
|
||||||
|
bx = this.camera.position.x; bz = this.camera.position.z;
|
||||||
|
}
|
||||||
|
if (bx !== undefined) {
|
||||||
|
const px = this._blockStreamingPrevX, pz = this._blockStreamingPrevZ;
|
||||||
|
const moved = (px === undefined) ||
|
||||||
|
((bx - px) * (bx - px) + (bz - pz) * (bz - pz) >= 9);
|
||||||
|
if (moved) {
|
||||||
|
this._blockStreamingPrevX = bx; this._blockStreamingPrevZ = bz;
|
||||||
|
const camY = (this.camera && this.camera.position && this.camera.position.y) || 0;
|
||||||
|
const blockRadius = 90 + Math.max(0, Math.min(40, camY * 0.4));
|
||||||
|
this.blockManager.updateStreaming(bx, bz, blockRadius);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2066,8 +2104,13 @@ export class BabylonScene {
|
|||||||
refreshAllShadows() {
|
refreshAllShadows() {
|
||||||
if (!this._shadowGenerator) return;
|
if (!this._shadowGenerator) return;
|
||||||
if (this.blockManager) {
|
if (this.blockManager) {
|
||||||
|
// ОПТИМИЗАЦИЯ: на БОЛЬШИХ block-картах (стриминг — лабиринты, 200к+
|
||||||
|
// блоков) НЕ кастуем тени от блоков. Иначе shadow-map рендерит всю
|
||||||
|
// видимую геометрию ВТОРОЙ раз → idle ~350мс/кадр при render_ms 1.5.
|
||||||
|
// Блоки всё равно ПРИНИМАЮТ тени (receiveShadows на proto). Точно
|
||||||
|
// как terrain (он вообще исключён из shadow casters).
|
||||||
|
if (!this._blockStreamingEnabled && this.blockManager._protoMeshes) {
|
||||||
// Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы
|
// Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы
|
||||||
if (this.blockManager._protoMeshes) {
|
|
||||||
for (const proto of this.blockManager._protoMeshes.values()) {
|
for (const proto of this.blockManager._protoMeshes.values()) {
|
||||||
this.addShadowCaster(proto);
|
this.addShadowCaster(proto);
|
||||||
}
|
}
|
||||||
@ -6189,9 +6232,30 @@ export class BabylonScene {
|
|||||||
// Запускаем фоновую музыку и амбиент
|
// Запускаем фоновую музыку и амбиент
|
||||||
this.audioManager?.start();
|
this.audioManager?.start();
|
||||||
|
|
||||||
// Создаём PlayerController и стартуем
|
// Создаём PlayerController и стартуем.
|
||||||
|
// 2026-06-14: В тест-режиме студии (Play) персонаж = СКИН ЮЗЕРА,
|
||||||
|
// а не из настроек проекта. Источник:
|
||||||
|
// 1) hash #skin=<id> (передаёт сайт при openStudio)
|
||||||
|
// 2) localStorage 'rublox_selected_skin' (если открыли студию напрямую)
|
||||||
|
// 3) _playerModelType из настроек проекта (фолбэк)
|
||||||
|
let userSkin = null;
|
||||||
|
try {
|
||||||
|
const m = (typeof window !== 'undefined' ? window.location.hash : '')
|
||||||
|
.match(/[#&]skin=([\w-]+)/);
|
||||||
|
if (m && m[1]) userSkin = m[1];
|
||||||
|
else if (typeof localStorage !== 'undefined') {
|
||||||
|
const ls = localStorage.getItem('rublox_selected_skin');
|
||||||
|
if (ls && typeof ls === 'string') userSkin = ls;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
const finalSkin = userSkin || this._playerModelType;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('[BabylonScene] play skin:',
|
||||||
|
'project=' + this._playerModelType,
|
||||||
|
'user=' + (userSkin || 'none'),
|
||||||
|
'→ final=' + finalSkin);
|
||||||
this.player = new PlayerController(this.scene, this.canvas, this.physics, this);
|
this.player = new PlayerController(this.scene, this.canvas, this.physics, this);
|
||||||
this.player.setModelType(this._playerModelType);
|
this.player.setModelType(finalSkin);
|
||||||
// Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck
|
// Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck
|
||||||
try {
|
try {
|
||||||
this.modalManager?.attachPlayer?.(this.player);
|
this.modalManager?.attachPlayer?.(this.player);
|
||||||
@ -7731,7 +7795,23 @@ export class BabylonScene {
|
|||||||
serialize() {
|
serialize() {
|
||||||
// Принадлежность объектов папкам — серилизуется в их собственных
|
// Принадлежность объектов папкам — серилизуется в их собственных
|
||||||
// данных (folderId), а сами папки в отдельном массиве.
|
// данных (folderId), а сами папки в отдельном массиве.
|
||||||
const blocksWithFolders = this.blockManager ? this.blockManager.serialize() : [];
|
// БЛОКИ: для БОЛЬШИХ карт (лабиринты, 200к+ блоков) — RLE-формат
|
||||||
|
// (×20-30 меньше, async-загрузка по чанкам без фриза). RLE не хранит
|
||||||
|
// folderId на блоках (для процедурных карт он не нужен — все null);
|
||||||
|
// если на блоках есть реальные folderId — остаёмся на плоском массиве.
|
||||||
|
let blocksWithFolders;
|
||||||
|
const blockCount = this.blockManager ? this.blockManager.count() : 0;
|
||||||
|
let blocksHaveFolders = false;
|
||||||
|
if (this.blockManager && blockCount > 5000 && typeof this.blockManager.serializeRLE === 'function') {
|
||||||
|
for (const mesh of this.blockManager.blocks.values()) {
|
||||||
|
if (mesh?.metadata?.folderId != null) { blocksHaveFolders = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.blockManager && blockCount > 5000 && !blocksHaveFolders
|
||||||
|
&& typeof this.blockManager.serializeRLE === 'function') {
|
||||||
|
blocksWithFolders = this.blockManager.serializeRLE(); // {format:'blocks-rle-v1',...}
|
||||||
|
} else {
|
||||||
|
blocksWithFolders = this.blockManager ? this.blockManager.serialize() : [];
|
||||||
// BlockManager.serialize не знает про folderId — добавляем его поверх.
|
// BlockManager.serialize не знает про folderId — добавляем его поверх.
|
||||||
if (this.blockManager) {
|
if (this.blockManager) {
|
||||||
for (const item of blocksWithFolders) {
|
for (const item of blocksWithFolders) {
|
||||||
@ -7739,6 +7819,7 @@ export class BabylonScene {
|
|||||||
item.folderId = mesh?.metadata?.folderId ?? null;
|
item.folderId = mesh?.metadata?.folderId ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : [];
|
const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : [];
|
||||||
if (this.modelManager) {
|
if (this.modelManager) {
|
||||||
// Дописываем instanceId + folderId поверх стандартной сериализации
|
// Дописываем instanceId + folderId поверх стандартной сериализации
|
||||||
@ -7945,9 +8026,29 @@ export class BabylonScene {
|
|||||||
this.setShadowQuality(state.scene.shadowQuality);
|
this.setShadowQuality(state.scene.shadowQuality);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Блоки — синхронно
|
// Блоки — синхронно. Для БОЛЬШИХ block-карт (лабиринты и т.п.) включаем
|
||||||
if (this.blockManager && Array.isArray(state.scene.blocks)) {
|
// чанковый стриминг: блоки бьются на регионы 48×48, дальние скрываются
|
||||||
this.blockManager.loadFromArray(state.scene.blocks);
|
// по радиусу вокруг камеры/игрока (см. blockManager.updateStreaming в
|
||||||
|
// onBeforeRender). Иначе 200к+ блоков рендерятся все сразу → FPS висит.
|
||||||
|
// 48 (а не 32) — баланс: меньше proto-мешей/draw-call, скрытие ~75%.
|
||||||
|
// Поддерживаем 2 формата блоков (как террейн):
|
||||||
|
// 1. Legacy: blocks = [{x,y,z,type}, ...] — малые карты
|
||||||
|
// 2. RLE: blocks = {format:'blocks-rle-v1', palette, chunks, props}
|
||||||
|
// — большие карты (лабиринты), ×20-30 меньше, async без фриза
|
||||||
|
const bs = state.scene.blocks;
|
||||||
|
if (this.blockManager && bs && bs.format === 'blocks-rle-v1') {
|
||||||
|
// RLE-карта всегда большая → стриминг + тени-OFF
|
||||||
|
if (this.blockManager.enableStreaming) {
|
||||||
|
this.blockManager.enableStreaming(48);
|
||||||
|
this._blockStreamingEnabled = true;
|
||||||
|
}
|
||||||
|
await this.blockManager.loadFromRLE(bs);
|
||||||
|
} else if (this.blockManager && Array.isArray(bs)) {
|
||||||
|
if (bs.length >= 5000 && this.blockManager.enableStreaming) {
|
||||||
|
this.blockManager.enableStreaming(48);
|
||||||
|
this._blockStreamingEnabled = true;
|
||||||
|
}
|
||||||
|
this.blockManager.loadFromArray(bs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Террейн (voxel-ландшафт). Поддерживаем 2 формата:
|
// Террейн (voxel-ландшафт). Поддерживаем 2 формата:
|
||||||
@ -8293,7 +8394,7 @@ export class BabylonScene {
|
|||||||
if (state.scene.playerModelType) {
|
if (state.scene.playerModelType) {
|
||||||
const pmt = state.scene.playerModelType;
|
const pmt = state.scene.playerModelType;
|
||||||
if (pmt.startsWith('character-')) {
|
if (pmt.startsWith('character-')) {
|
||||||
this._playerModelType = 'skin_bacon-hair';
|
this._playerModelType = 'skin_y-bot';
|
||||||
} else {
|
} else {
|
||||||
this._playerModelType = pmt;
|
this._playerModelType = pmt;
|
||||||
}
|
}
|
||||||
|
|||||||
482
src/editor/engine/MixamoAnimator.js
Normal file
482
src/editor/engine/MixamoAnimator.js
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
/**
|
||||||
|
* MixamoAnimator — проигрывает Mixamo-анимации на скелете персонажа.
|
||||||
|
*
|
||||||
|
* Mixamo-скины (skin_y-bot, skin_x-bot, и ещё 78) приходят БЕЗ
|
||||||
|
* AnimationGroups в их собственном GLB. Анимации лежат отдельными
|
||||||
|
* GLB-файлами в /character-assets/animations/:
|
||||||
|
*
|
||||||
|
* idle.glb, walk.glb, run.glb, jump.glb, fall.glb
|
||||||
|
* emote_capoeira.glb, emote_defeated.glb, emote_shoved.glb, emote_taunt.glb
|
||||||
|
*
|
||||||
|
* Каждый GLB содержит ровно одну AnimationGroup, нацеленную на bones
|
||||||
|
* с именами `mixamorig:Hips`, `mixamorig:Spine` и т.д.
|
||||||
|
*
|
||||||
|
* Что делает этот класс:
|
||||||
|
* 1. Загружает 5 базовых GLB параллельно и кэширует AnimationGroup'ы
|
||||||
|
* (singleton — один loader на сессию).
|
||||||
|
* 2. Для конкретного скина РЕТАРГЕТИТ AnimationGroup на его кости.
|
||||||
|
* Mixamo-скины разных вышедших времён имеют префикс `mixamorig:`,
|
||||||
|
* `mixamorig9:` или вообще без префикса — детектим автоматически.
|
||||||
|
* 3. Управление: `setState('idle'|'walk'|'run'|'jump'|'fall')` +
|
||||||
|
* плавный кросс-фейд (blending) между состояниями.
|
||||||
|
* 4. `playEmote(name, onDone)` — одноразово проиграть эмоцию поверх,
|
||||||
|
* после конца автоматически вернуться в текущее состояние.
|
||||||
|
*
|
||||||
|
* Bone-имена которые ретаргетим (24 обязательных):
|
||||||
|
* Hips, Spine, Spine1, Spine2, Neck, Head,
|
||||||
|
* LeftShoulder, LeftArm, LeftForeArm, LeftHand,
|
||||||
|
* RightShoulder, RightArm, RightForeArm, RightHand,
|
||||||
|
* LeftUpLeg, LeftLeg, LeftFoot, LeftToeBase,
|
||||||
|
* RightUpLeg, RightLeg, RightFoot, RightToeBase
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* const anim = new MixamoAnimator();
|
||||||
|
* await anim.load(); // один раз на сессию
|
||||||
|
* anim.attach(scene, skeleton, modelRoot); // на каждую загрузку скина
|
||||||
|
* anim.setState('idle');
|
||||||
|
* // каждый кадр в _tick (необязательно — Babylon сам тикает groups):
|
||||||
|
* anim.update(dt);
|
||||||
|
* // эмоция:
|
||||||
|
* anim.playEmote('emote_taunt');
|
||||||
|
* // при смене скина:
|
||||||
|
* anim.detach();
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SceneLoader, AnimationGroup, Animation } from "@babylonjs/core";
|
||||||
|
import "@babylonjs/loaders/glTF";
|
||||||
|
|
||||||
|
// Базовые состояния — соответствуют файлам *.glb в animations/.
|
||||||
|
// Базовые (всегда грузятся при старте — нужны для движения):
|
||||||
|
const BASE_STATES = ["idle", "walk", "run", "jump", "fall"];
|
||||||
|
|
||||||
|
// Дополнительные движения (грузятся лениво при первом setState):
|
||||||
|
const EXTRA_STATES = [
|
||||||
|
"walk_backward", "run_backward", "run_to_stop", "run_slide",
|
||||||
|
"jump_forward", "jump_backward", "jump_down",
|
||||||
|
"crouch_enter", "crouch_idle", "crouch_walk", "crouch_to_stand",
|
||||||
|
"climb_up", "climb_down", "sit_idle", "lie_idle", "sleeping",
|
||||||
|
"hit_react", "die_forward", "die_back",
|
||||||
|
"punch_left", "kick_low", "kick_high",
|
||||||
|
"gun_fire", "gun_reload", "rifle_walk",
|
||||||
|
"sword_idle", "sword_slash",
|
||||||
|
"push_button", "open_door", "throw_action",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Эмоции (вызываются через playEmote()):
|
||||||
|
const EMOTES = [
|
||||||
|
"emote_capoeira", "emote_defeated", "emote_shoved", "emote_taunt",
|
||||||
|
"emote_salute", "emote_pointing", "emote_no",
|
||||||
|
"dance_hiphop", "dance_rumba", "dance_breakdance",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Все известные анимации (для опциональной полной предзагрузки)
|
||||||
|
const ALL_ANIMATIONS = [...BASE_STATES, ...EXTRA_STATES, ...EMOTES];
|
||||||
|
|
||||||
|
// Кэш сырых данных анимаций между инстансами (singleton-ish):
|
||||||
|
// один раз загрузили — используем для всех аватаров.
|
||||||
|
let _cachedRawTargets = null; // { idle: [{boneName, animations:[Anim]}], walk: [...] , ... }
|
||||||
|
let _loadPromise = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строит абсолютный URL для статики Mixamo-анимаций.
|
||||||
|
* Локально — localhost:3000 (rublox-site dev-server),
|
||||||
|
* на проде — rublox.pro/character-assets/.
|
||||||
|
*/
|
||||||
|
function _assetsBase() {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
const isLocal = window.location.hostname === "localhost"
|
||||||
|
|| window.location.hostname === "127.0.0.1";
|
||||||
|
return isLocal ? "http://localhost:3000" : "https://rublox.pro";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нормализует имя кости: убирает префикс `mixamorig:`, `mixamorig9:`,
|
||||||
|
* `mixamorig_` и т.п. Возвращает чистое имя типа `Hips`, `Spine`, `LeftArm`.
|
||||||
|
*/
|
||||||
|
function _normalizeBone(name) {
|
||||||
|
if (!name) return "";
|
||||||
|
// mixamorig:Hips, mixamorig9:Hips, mixamorig_Hips, Armature|mixamorig:Hips, etc
|
||||||
|
let n = name;
|
||||||
|
const colon = n.lastIndexOf(":");
|
||||||
|
if (colon >= 0) n = n.slice(colon + 1);
|
||||||
|
n = n.replace(/^mixamorig\d*[_:.]?/i, "");
|
||||||
|
n = n.replace(/^Armature\|/, "");
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает один GLB-файл с анимациями. Возвращает массив
|
||||||
|
* { boneName, animations: [Babylon.Animation] } — сырые треки,
|
||||||
|
* привязанные к именам костей (без префикса).
|
||||||
|
*/
|
||||||
|
async function _loadAnimGlb(scene, url) {
|
||||||
|
// ImportAnimations не годится — он сразу target-ит конкретный
|
||||||
|
// скелет. Нам нужны сырые animations[], чтобы потом каждому
|
||||||
|
// скину пристёгивать отдельно.
|
||||||
|
const result = await SceneLoader.LoadAssetContainerAsync(
|
||||||
|
url.substring(0, url.lastIndexOf("/") + 1),
|
||||||
|
url.substring(url.lastIndexOf("/") + 1),
|
||||||
|
scene,
|
||||||
|
);
|
||||||
|
const out = [];
|
||||||
|
// В GLB от Mixamo каждая кость — это TransformNode (или Bone),
|
||||||
|
// содержит свои keyframe animations. После загрузки они на
|
||||||
|
// result.transformNodes / result.skeletons[].bones.
|
||||||
|
const allNodes = [
|
||||||
|
...(result.transformNodes || []),
|
||||||
|
...((result.skeletons || []).flatMap(sk => sk.bones || [])),
|
||||||
|
];
|
||||||
|
for (const node of allNodes) {
|
||||||
|
if (!node.animations || node.animations.length === 0) continue;
|
||||||
|
const cleanName = _normalizeBone(node.name);
|
||||||
|
if (!cleanName) continue;
|
||||||
|
out.push({ boneName: cleanName, animations: node.animations.slice() });
|
||||||
|
}
|
||||||
|
// Освободим геометрию (если случайно приехала — у анимаций мешей нет)
|
||||||
|
result.dispose();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузить базовые анимации (idle/walk/run/jump/fall) один раз.
|
||||||
|
* Дополнительные анимации (extra + эмоции) грузятся лениво в _ensureLoaded
|
||||||
|
* при первом обращении — это экономит трафик: юзер качает только то что
|
||||||
|
* реально использует в игре.
|
||||||
|
*/
|
||||||
|
export async function loadMixamoAnimations(scene) {
|
||||||
|
if (_loadPromise) return _loadPromise;
|
||||||
|
_cachedRawTargets = _cachedRawTargets || {};
|
||||||
|
_loadPromise = (async () => {
|
||||||
|
const base = _assetsBase();
|
||||||
|
const entries = await Promise.all(
|
||||||
|
BASE_STATES.map(async (name) => {
|
||||||
|
try {
|
||||||
|
const tracks = await _loadAnimGlb(
|
||||||
|
scene, `${base}/character-assets/animations/${name}.glb`);
|
||||||
|
return [name, tracks];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[MixamoAnimator] не загрузилась '${name}':`, e?.message || e);
|
||||||
|
return [name, []];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
for (const [k, v] of entries) _cachedRawTargets[k] = v;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[MixamoAnimator] базовые анимации загружены:",
|
||||||
|
Object.entries(_cachedRawTargets).map(([k, v]) => `${k}=${v.length}tracks`).join(", "));
|
||||||
|
return _cachedRawTargets;
|
||||||
|
})();
|
||||||
|
return _loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ленивая подгрузка одной анимации по имени (если ещё не в кэше).
|
||||||
|
* Возвращает массив tracks или null если не удалось.
|
||||||
|
*/
|
||||||
|
async function _ensureLoaded(scene, name) {
|
||||||
|
if (!_cachedRawTargets) _cachedRawTargets = {};
|
||||||
|
if (_cachedRawTargets[name]) return _cachedRawTargets[name];
|
||||||
|
const base = _assetsBase();
|
||||||
|
try {
|
||||||
|
const tracks = await _loadAnimGlb(
|
||||||
|
scene, `${base}/character-assets/animations/${name}.glb`);
|
||||||
|
_cachedRawTargets[name] = tracks;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[MixamoAnimator] lazy-load '${name}': ${tracks.length} tracks`);
|
||||||
|
return tracks;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[MixamoAnimator] не удалось загрузить '${name}':`, e?.message || e);
|
||||||
|
_cachedRawTargets[name] = [];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MixamoAnimator {
|
||||||
|
constructor() {
|
||||||
|
this.scene = null;
|
||||||
|
this.skeleton = null;
|
||||||
|
this.modelRoot = null;
|
||||||
|
/** Map<state, AnimationGroup> — кастомные группы для ЭТОГО скелета */
|
||||||
|
this._groups = new Map();
|
||||||
|
this._currentState = null;
|
||||||
|
this._currentGroup = null;
|
||||||
|
this._currentEmote = null;
|
||||||
|
this._emoteOnDone = null;
|
||||||
|
this._blendInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пристёгивает аниматор к конкретному скелету (после загрузки модели).
|
||||||
|
* scene — Babylon Scene, skeleton — Babylon Skeleton, modelRoot — TransformNode.
|
||||||
|
*/
|
||||||
|
attach(scene, skeleton, modelRoot) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.skeleton = skeleton;
|
||||||
|
this.modelRoot = modelRoot;
|
||||||
|
// Резолвим маппинг "clean name" → Bone (из текущего скелета).
|
||||||
|
this._cleanToBone = new Map();
|
||||||
|
for (const b of (skeleton.bones || [])) {
|
||||||
|
const clean = _normalizeBone(b.name);
|
||||||
|
if (clean && !this._cleanToBone.has(clean)) {
|
||||||
|
this._cleanToBone.set(clean, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Также детектим target-property: TransformNode? linkedTransformNode?
|
||||||
|
// Mixamo-анимации обычно нацелены на linkedTransformNode'ы (если есть),
|
||||||
|
// потому что в glTF skin'ы делают joints через nodes, не через Bones.
|
||||||
|
// Для каждой кости берём её _linkedTransformNode (Babylon API).
|
||||||
|
this._cleanToTarget = new Map();
|
||||||
|
for (const [name, bone] of this._cleanToBone) {
|
||||||
|
const tnode = bone.getTransformNode ? bone.getTransformNode() : null;
|
||||||
|
this._cleanToTarget.set(name, tnode || bone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Создать (или достать из кэша) AnimationGroup для конкретного состояния. */
|
||||||
|
_ensureGroup(state) {
|
||||||
|
if (this._groups.has(state)) return this._groups.get(state);
|
||||||
|
if (!_cachedRawTargets || !_cachedRawTargets[state]) return null;
|
||||||
|
const raw = _cachedRawTargets[state];
|
||||||
|
const group = new AnimationGroup(`mixamo_${state}`, this.scene);
|
||||||
|
let attached = 0;
|
||||||
|
for (const t of raw) {
|
||||||
|
const target = this._cleanToTarget.get(t.boneName);
|
||||||
|
if (!target) continue;
|
||||||
|
for (const anim of t.animations) {
|
||||||
|
// Клонируем анимацию (одна Babylon.Animation не может
|
||||||
|
// быть в двух разных AnimationGroup одновременно).
|
||||||
|
const cloned = anim.clone();
|
||||||
|
// Mixamo всегда грузит Hips.position — это сдвигает
|
||||||
|
// персонажа по сцене. В in-place анимациях должно быть
|
||||||
|
// близко к нулю, но иногда сдвиг есть. Для базовых
|
||||||
|
// движений (walk/run/jump) фильтруем targetProperty=position
|
||||||
|
// у кости с именем Hips — её двигает наш PlayerController.
|
||||||
|
if (t.boneName === "Hips" && cloned.targetProperty === "position") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
group.addTargetedAnimation(cloned, target);
|
||||||
|
attached++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attached === 0) {
|
||||||
|
group.dispose();
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`[MixamoAnimator] state='${state}' — 0 целей зарезолвлено, skip`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Зацикливаем базовые состояния, кроме jump (он one-shot).
|
||||||
|
// ВАЖНО: для AnimationGroup нужно ставить loopAnimation=true НА
|
||||||
|
// САМОМ GROUP до start(). Параметр loop в start() игнорируется в
|
||||||
|
// некоторых версиях Babylon 7.x.
|
||||||
|
// One-shot анимации (играются один раз, не зацикливаются):
|
||||||
|
// jump, crouch_enter, crouch_to_stand, crouch_exit + все эмоции и
|
||||||
|
// эпизодические действия (hit, die, throw, pickup, gun_fire, gun_reload и т.д.)
|
||||||
|
const ONE_SHOT = new Set([
|
||||||
|
"jump", "jump_forward", "jump_backward", "jump_down",
|
||||||
|
"crouch_enter", "crouch_to_stand",
|
||||||
|
"hit_react", "die_forward", "die_back",
|
||||||
|
"throw_action", "pickup", "push_button", "open_door",
|
||||||
|
"gun_fire", "gun_reload", "sword_slash",
|
||||||
|
"kick_low", "kick_high", "punch_left",
|
||||||
|
]);
|
||||||
|
// emote_* — one-shot (один жест), dance_* — лупим (танцы должны крутиться)
|
||||||
|
const loopable = !ONE_SHOT.has(state) && !state.startsWith("emote_");
|
||||||
|
group.loopAnimation = loopable;
|
||||||
|
group.normalize();
|
||||||
|
// Safety-net: если Babylon всё равно по какой-то причине отыграл
|
||||||
|
// клип до конца И не зациклил (что бывает с короткими "still pose"
|
||||||
|
// клипами от Mixamo вроде Crouched Idle ~0.5s) — перезапускаем
|
||||||
|
// принудительно. Это даёт стабильно зацикленную анимацию.
|
||||||
|
if (loopable) {
|
||||||
|
group.onAnimationGroupEndObservable.add(() => {
|
||||||
|
if (this._currentGroup === group && !this._currentEmote) {
|
||||||
|
try {
|
||||||
|
group.reset();
|
||||||
|
group.start(true, 1.0, group.from, group.to, false);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[MixamoAnimator] group '${state}': ${attached} tracks, loop=${loopable}, duration=${((group.to - group.from) / 60).toFixed(2)}s`);
|
||||||
|
this._groups.set(state, group);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Установить состояние с плавным кросс-фейдом 150 мс.
|
||||||
|
* Если анимация ещё не подгружена — стартует lazy-load, при этом
|
||||||
|
* setState вернётся синхронно (без ожидания) — анимация подхватится
|
||||||
|
* на следующем тике после успешной загрузки.
|
||||||
|
*
|
||||||
|
* Anti-flicker: между переключениями требуется минимальная задержка
|
||||||
|
* 120мс (кроме переходов в воздух/идл из приземления). Это убирает
|
||||||
|
* «дрожание» crouch_walk ↔ crouch_idle когда игрок едет по диагонали
|
||||||
|
* и одно из направлений физически дёргается между кадрами. */
|
||||||
|
setState(state) {
|
||||||
|
if (this._currentEmote) return; // эмоция блокирует смену состояния
|
||||||
|
if (state === this._currentState) return;
|
||||||
|
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||||
|
// Anti-flicker debounce: не даём переключать состояние чаще чем раз в 120мс,
|
||||||
|
// КРОМЕ переходов с/на воздушные состояния (jump/fall) — там важна скорость
|
||||||
|
// и one-shot crouch_enter/crouch_to_stand (они короткие).
|
||||||
|
const isVitalSwitch = state === 'jump' || state === 'fall'
|
||||||
|
|| this._currentState === 'jump' || this._currentState === 'fall'
|
||||||
|
|| state === 'crouch_enter' || state === 'crouch_to_stand';
|
||||||
|
if (!isVitalSwitch && this._lastSwitchAt && (now - this._lastSwitchAt) < 120) {
|
||||||
|
// Запомним последний запрошенный state — если он не изменится за
|
||||||
|
// окно debounce, тогда применим, иначе отбросим вспышку.
|
||||||
|
this._pendingState = state;
|
||||||
|
if (!this._debounceTimer) {
|
||||||
|
const delay = Math.max(0, 120 - (now - this._lastSwitchAt));
|
||||||
|
this._debounceTimer = setTimeout(() => {
|
||||||
|
this._debounceTimer = null;
|
||||||
|
const s = this._pendingState;
|
||||||
|
this._pendingState = null;
|
||||||
|
if (s && s !== this._currentState) this.setState(s);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._lastSwitchAt = now;
|
||||||
|
// Если ещё не загружено — стартуем lazy-load, но ТЕКУЩУЮ анимацию
|
||||||
|
// НЕ останавливаем (иначе в момент Ctrl-on/off персонаж зависает
|
||||||
|
// в bind-pose пока crouch_idle асинхронно качается).
|
||||||
|
if (!_cachedRawTargets || !_cachedRawTargets[state]) {
|
||||||
|
if (!this._pendingLoads) this._pendingLoads = new Set();
|
||||||
|
if (!this._pendingLoads.has(state)) {
|
||||||
|
this._pendingLoads.add(state);
|
||||||
|
_ensureLoaded(this.scene, state).then(() => {
|
||||||
|
this._pendingLoads.delete(state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return; // подхватится при следующем setState когда tracks будут
|
||||||
|
}
|
||||||
|
const next = this._ensureGroup(state);
|
||||||
|
if (!next) return;
|
||||||
|
const prev = this._currentGroup;
|
||||||
|
// Loop-флаг берём напрямую с group — _ensureGroup уже разрулил
|
||||||
|
// (one-shot list + emote_* → не лупим).
|
||||||
|
const loop = next.loopAnimation;
|
||||||
|
// Лог переключений (только если изменилось — иначе спам)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[MixamoAnimator] setState: ${this._currentState || 'none'} → ${state} (loop=${loop})`);
|
||||||
|
|
||||||
|
// Запустить новую анимацию. Babylon 7 ВНИМАНИЕ: параметр loop
|
||||||
|
// в start() иногда игнорится — дублируем через loopAnimation
|
||||||
|
// (выставлен в _ensureGroup).
|
||||||
|
try {
|
||||||
|
next.reset();
|
||||||
|
next.start(loop, 1.0, next.from, next.to, false);
|
||||||
|
} catch (e) {
|
||||||
|
try { next.play(loop); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кросс-фейд через weight (0→1 у новой, 1→0 у старой) за BLEND_MS.
|
||||||
|
const BLEND_MS = 150;
|
||||||
|
try { next.setWeightForAllAnimatables(0); } catch (_) {}
|
||||||
|
// Снимаем ВСЕ предыдущие blend-observers — rapid-switching
|
||||||
|
// (Ctrl on/off с интервалом 50ms) оставлял несколько ticker'ов.
|
||||||
|
if (this._blendObservers && this._blendObservers.length) {
|
||||||
|
for (const o of this._blendObservers) {
|
||||||
|
try { this.scene.onBeforeRenderObservable.remove(o); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._blendObservers = [];
|
||||||
|
// КРИТИЧНО: при ЛЮБОМ setState останавливаем ВСЕ остальные группы
|
||||||
|
// кроме новой. Это убирает кейсы когда rapid-switching между
|
||||||
|
// prev/next/третий оставляет висящую группу из позапрошлого setState
|
||||||
|
// (и она «крутится» дальше в фоне с весом 1).
|
||||||
|
for (const g of this._groups.values()) {
|
||||||
|
if (g !== next) {
|
||||||
|
// Не стопим текущую blend-исходную — она нужна для фейда.
|
||||||
|
if (g !== prev) {
|
||||||
|
try { g.stop(); g.setWeightForAllAnimatables(0); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prev && prev !== next) {
|
||||||
|
const startedAt = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||||
|
const prevGroup = prev;
|
||||||
|
const nextGroup = next;
|
||||||
|
const obs = this.scene.onBeforeRenderObservable.add(() => {
|
||||||
|
const nowMs = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||||
|
const t = Math.min(1, (nowMs - startedAt) / BLEND_MS);
|
||||||
|
// Если за это время _currentGroup сменилась ещё раз —
|
||||||
|
// прекращаем blend (новый setState уже разрулил).
|
||||||
|
if (this._currentGroup !== nextGroup) {
|
||||||
|
try { this.scene.onBeforeRenderObservable.remove(obs); } catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
prevGroup.setWeightForAllAnimatables(1 - t);
|
||||||
|
nextGroup.setWeightForAllAnimatables(t);
|
||||||
|
} catch (_) {}
|
||||||
|
if (t >= 1) {
|
||||||
|
try { prevGroup.stop(); prevGroup.setWeightForAllAnimatables(0); } catch (_) {}
|
||||||
|
try { nextGroup.setWeightForAllAnimatables(1); } catch (_) {}
|
||||||
|
this.scene.onBeforeRenderObservable.remove(obs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._blendObservers.push(obs);
|
||||||
|
} else {
|
||||||
|
try { next.setWeightForAllAnimatables(1); } catch (_) {}
|
||||||
|
}
|
||||||
|
this._currentState = state;
|
||||||
|
this._currentGroup = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проиграть эмоцию (one-shot), потом вернуться в idle.
|
||||||
|
* Если эмоция ещё не подгружена — подгружает на лету и стартует. */
|
||||||
|
async playEmote(name, onDone) {
|
||||||
|
const tracks = await _ensureLoaded(this.scene, name);
|
||||||
|
if (!tracks || tracks.length === 0) {
|
||||||
|
console.warn(`[MixamoAnimator] эмоция '${name}' не загружена`);
|
||||||
|
if (onDone) onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const group = this._ensureGroup(name);
|
||||||
|
if (!group) { if (onDone) onDone(); return; }
|
||||||
|
// Стоп текущего состояния
|
||||||
|
if (this._currentGroup) {
|
||||||
|
try { this._currentGroup.stop(); } catch (_) {}
|
||||||
|
}
|
||||||
|
this._currentEmote = name;
|
||||||
|
this._emoteOnDone = onDone || null;
|
||||||
|
const savedState = this._currentState;
|
||||||
|
try {
|
||||||
|
group.start(false, 1.0, group.from, group.to, false);
|
||||||
|
} catch (e) {
|
||||||
|
try { group.play(false); } catch (_) {}
|
||||||
|
}
|
||||||
|
const onEnd = () => {
|
||||||
|
this._currentEmote = null;
|
||||||
|
this._currentState = null; // принудим setState заново запустить
|
||||||
|
this.setState(savedState || "idle");
|
||||||
|
if (this._emoteOnDone) {
|
||||||
|
const cb = this._emoteOnDone;
|
||||||
|
this._emoteOnDone = null;
|
||||||
|
try { cb(); } catch (_) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
group.onAnimationGroupEndObservable.addOnce(onEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Babylon сам тикает AnimationGroup, но оставим для интерфейса. */
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
update(dt) { /* noop */ }
|
||||||
|
|
||||||
|
/** Остановить и освободить все группы для этого скелета. */
|
||||||
|
detach() {
|
||||||
|
if (this._currentGroup) { try { this._currentGroup.stop(); } catch (_) {} }
|
||||||
|
for (const g of this._groups.values()) {
|
||||||
|
try { g.dispose(); } catch (_) {}
|
||||||
|
}
|
||||||
|
this._groups.clear();
|
||||||
|
this._currentGroup = null;
|
||||||
|
this._currentState = null;
|
||||||
|
this._currentEmote = null;
|
||||||
|
this.scene = null;
|
||||||
|
this.skeleton = null;
|
||||||
|
this.modelRoot = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,6 +28,28 @@ import {
|
|||||||
import { getModelType } from './ModelTypes';
|
import { getModelType } from './ModelTypes';
|
||||||
import { R15Skeleton } from './R15Skeleton';
|
import { R15Skeleton } from './R15Skeleton';
|
||||||
import { R15Animator } from './R15Animator';
|
import { R15Animator } from './R15Animator';
|
||||||
|
import { MixamoAnimator, loadMixamoAnimations } from './MixamoAnimator';
|
||||||
|
|
||||||
|
// Список всех Mixamo-скинов. Должен совпадать со списком в плеере и
|
||||||
|
// каталоге сайта (rublox-site/src/data/skinsCatalog.js).
|
||||||
|
const MIXAMO_SKINS = new Set([
|
||||||
|
'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas',
|
||||||
|
'skin_castle-guard-1', 'skin_castle-guard-2',
|
||||||
|
'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08',
|
||||||
|
'skin_ch09', 'skin_ch10', 'skin_ch11', 'skin_ch13', 'skin_ch14', 'skin_ch15',
|
||||||
|
'skin_ch16', 'skin_ch17', 'skin_ch18', 'skin_ch19', 'skin_ch20', 'skin_ch21',
|
||||||
|
'skin_ch22', 'skin_ch23', 'skin_ch24', 'skin_ch29', 'skin_ch31', 'skin_ch32',
|
||||||
|
'skin_ch33', 'skin_ch34', 'skin_ch35', 'skin_ch39', 'skin_ch40', 'skin_ch42',
|
||||||
|
'skin_ch43', 'skin_ch44', 'skin_ch45', 'skin_ch46', 'skin_ch47', 'skin_ch48',
|
||||||
|
'skin_claire', 'skin_demon', 'skin_ely', 'skin_erika-archer',
|
||||||
|
'skin_eve', 'skin_exo-gray', 'skin_exo-red', 'skin_ganfaul', 'skin_heraklios',
|
||||||
|
'skin_kachujin', 'skin_kaya', 'skin_knight', 'skin_lola', 'skin_maria',
|
||||||
|
'skin_maw', 'skin_medea', 'skin_mutant', 'skin_nightshade',
|
||||||
|
'skin_paladin', 'skin_passive-marker-man', 'skin_peasant-girl', 'skin_peasant-man',
|
||||||
|
'skin_prisoner', 'skin_pumpkinhulk', 'skin_skeleton-zombie', 'skin_sporty-granny',
|
||||||
|
'skin_survivor', 'skin_swat', 'skin_ty', 'skin_uriel', 'skin_vampire',
|
||||||
|
'skin_war-zombie', 'skin_warrok', 'skin_white-clown', 'skin_x-bot', 'skin_y-bot',
|
||||||
|
]);
|
||||||
|
|
||||||
// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом).
|
// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом).
|
||||||
// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа.
|
// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа.
|
||||||
@ -177,6 +199,7 @@ export class PlayerController {
|
|||||||
this._isR15 = false; // флаг: загружен валидный R15-скелет
|
this._isR15 = false; // флаг: загружен валидный R15-скелет
|
||||||
this._r15Skeleton = null; // R15Skeleton — резолвер костей
|
this._r15Skeleton = null; // R15Skeleton — резолвер костей
|
||||||
this._r15Animator = null; // R15Animator — процедурные анимации
|
this._r15Animator = null; // R15Animator — процедурные анимации
|
||||||
|
this._mixamoAnimator = null; // MixamoAnimator — Mixamo-скины
|
||||||
this._skinManifest = null; // кеш skins_manifest.json
|
this._skinManifest = null; // кеш skins_manifest.json
|
||||||
this._skinOverrides = {}; // overrides текущего скина
|
this._skinOverrides = {}; // overrides текущего скина
|
||||||
|
|
||||||
@ -312,6 +335,8 @@ export class PlayerController {
|
|||||||
this._r15Skeleton = null;
|
this._r15Skeleton = null;
|
||||||
this._r15Animator = null;
|
this._r15Animator = null;
|
||||||
this._isR15 = false;
|
this._isR15 = false;
|
||||||
|
try { if (this._mixamoAnimator) this._mixamoAnimator.detach(); } catch (e) {}
|
||||||
|
this._mixamoAnimator = null;
|
||||||
this._modelKind = 'r15';
|
this._modelKind = 'r15';
|
||||||
this._modelHipHeight = null;
|
this._modelHipHeight = null;
|
||||||
this._nonHumanoidBox = null;
|
this._nonHumanoidBox = null;
|
||||||
@ -654,6 +679,21 @@ export class PlayerController {
|
|||||||
async _resolveModelSource() {
|
async _resolveModelSource() {
|
||||||
const typeId = this._modelTypeId || 'character-a';
|
const typeId = this._modelTypeId || 'character-a';
|
||||||
if (typeId.startsWith('skin_')) {
|
if (typeId.startsWith('skin_')) {
|
||||||
|
// 2026-06-14: Mixamo-скины (80 шт) — отдельные GLB на rublox-site
|
||||||
|
// (/character-assets/skins/), без R15-скелета, с Mixamo-rig.
|
||||||
|
if (MIXAMO_SKINS.has(typeId)) {
|
||||||
|
const base = (typeof window !== 'undefined'
|
||||||
|
&& window.location.hostname === 'localhost')
|
||||||
|
? 'http://localhost:3000'
|
||||||
|
: 'https://rublox.pro';
|
||||||
|
return {
|
||||||
|
file: `${base}/character-assets/skins/${typeId}.glb`,
|
||||||
|
isR15: false,
|
||||||
|
kind: 'non-humanoid-rigged',
|
||||||
|
overrides: {},
|
||||||
|
isMixamo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
const manifest = await this._loadSkinManifest();
|
const manifest = await this._loadSkinManifest();
|
||||||
const entry = manifest.find((s) => s.id === typeId);
|
const entry = manifest.find((s) => s.id === typeId);
|
||||||
const baseUrl = this._skinManifestBaseUrl || '/kubikon-assets';
|
const baseUrl = this._skinManifestBaseUrl || '/kubikon-assets';
|
||||||
@ -893,11 +933,43 @@ export class PlayerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Анимации.
|
// Анимации.
|
||||||
// R15-скины не содержат AnimationGroups (анимируются процедурно
|
// R15-скины — процедурно через R15Animator.
|
||||||
// через R15Animator в _tick). Kenney-модели — наоборот, имеют
|
// Mixamo-скины (non-humanoid-rigged) — через MixamoAnimator
|
||||||
// встроенные AnimationGroups (idle/walk/sprint/jump).
|
// (5 базовых + lazy эмоции грузятся с /character-assets/animations/).
|
||||||
|
// Kenney-модели — встроенные AnimationGroups (idle/walk/sprint/jump).
|
||||||
this._animations = {};
|
this._animations = {};
|
||||||
if (!this._isR15) {
|
this._mixamoAnimator = null;
|
||||||
|
if (source.isMixamo || source.kind === 'non-humanoid-rigged') {
|
||||||
|
let mixSk = (inst.skeletons && inst.skeletons[0]) || null;
|
||||||
|
if (!mixSk && container.skeletons && container.skeletons.length > 0) {
|
||||||
|
mixSk = container.skeletons[0];
|
||||||
|
}
|
||||||
|
if (!mixSk) {
|
||||||
|
const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton);
|
||||||
|
if (meshWithSkel) mixSk = meshWithSkel.skeleton;
|
||||||
|
}
|
||||||
|
if (mixSk) {
|
||||||
|
try {
|
||||||
|
const animator = new MixamoAnimator();
|
||||||
|
loadMixamoAnimations(this.scene)
|
||||||
|
.then(() => {
|
||||||
|
animator.attach(this.scene, mixSk, root);
|
||||||
|
animator.setState('idle');
|
||||||
|
this._mixamoAnimator = animator;
|
||||||
|
try { window.__mixamo = animator; } catch (e) {}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('[PlayerController] MixamoAnimator готов, скелет=' + mixSk.bones.length + ' bones');
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[PlayerController] MixamoAnimator не загрузился:', e);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[PlayerController] MixamoAnimator init fail:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!this._isR15) {
|
||||||
const groups = inst.animationGroups || [];
|
const groups = inst.animationGroups || [];
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
const name = (g.name || '').toLowerCase();
|
const name = (g.name || '').toLowerCase();
|
||||||
@ -2448,22 +2520,27 @@ export class PlayerController {
|
|||||||
&& (this._codes.has('ControlLeft') || this._codes.has('ControlRight'));
|
&& (this._codes.has('ControlLeft') || this._codes.has('ControlRight'));
|
||||||
if (wantCrouch && !this._crouching) {
|
if (wantCrouch && !this._crouching) {
|
||||||
this._crouching = true;
|
this._crouching = true;
|
||||||
// сдвигаем центр капсулы вниз — низ ног остаётся на земле
|
|
||||||
const dH = this.HALF_H_CROUCH - this.HALF_H;
|
const dH = this.HALF_H_CROUCH - this.HALF_H;
|
||||||
this.HALF_H = this.HALF_H_CROUCH;
|
this.HALF_H = this.HALF_H_CROUCH;
|
||||||
if (this._pos) this._pos.y += dH;
|
if (this._pos) this._pos.y += dH;
|
||||||
|
this._crouchEnterPending = true;
|
||||||
|
this._crouchTransitionUntil = Date.now() + 600;
|
||||||
} else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) {
|
} else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) {
|
||||||
this._crouching = false;
|
this._crouching = false;
|
||||||
const dH = this.HALF_H_NORMAL - this.HALF_H;
|
const dH = this.HALF_H_NORMAL - this.HALF_H;
|
||||||
this.HALF_H = this.HALF_H_NORMAL;
|
this.HALF_H = this.HALF_H_NORMAL;
|
||||||
if (this._pos) this._pos.y += dH;
|
if (this._pos) this._pos.y += dH;
|
||||||
|
this._crouchExitPending = true;
|
||||||
|
this._crouchTransitionUntil = Date.now() + 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Горизонтальное движение ===
|
// === Горизонтальное движение ===
|
||||||
const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw));
|
const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw));
|
||||||
const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw));
|
const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw));
|
||||||
const isSprinting = this._shift;
|
// Crouch имеет ПРИОРИТЕТ над sprint
|
||||||
const speedMult = isSprinting ? this.SPRINT_MULT : 1;
|
const isSprinting = this._shift && !this._crouching;
|
||||||
|
const crouchMult = this._crouching ? 0.45 : 1;
|
||||||
|
const speedMult = (isSprinting ? this.SPRINT_MULT : 1) * crouchMult;
|
||||||
const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt;
|
const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt;
|
||||||
|
|
||||||
let moveX = 0, moveZ = 0;
|
let moveX = 0, moveZ = 0;
|
||||||
@ -2886,9 +2963,18 @@ export class PlayerController {
|
|||||||
const fwdShift = inWater ? bodyLen * tiltFrac : 0;
|
const fwdShift = inWater ? bodyLen * tiltFrac : 0;
|
||||||
const fx = Math.sin(this._modelYaw);
|
const fx = Math.sin(this._modelYaw);
|
||||||
const fz = Math.cos(this._modelYaw);
|
const fz = Math.cos(this._modelYaw);
|
||||||
|
// Crouch Y-drop для Mixamo (см. rublox-player PlayerController.js).
|
||||||
|
let crouchYDrop = 0;
|
||||||
|
if (this._crouching && this._mixamoAnimator) {
|
||||||
|
const ms = this._mixamoAnimator._currentState;
|
||||||
|
if (ms === 'crouch_idle') crouchYDrop = 0.45;
|
||||||
|
else if (ms === 'crouch_walk') crouchYDrop = 0.25;
|
||||||
|
else if (ms === 'crouch_enter' || ms === 'crouch_to_stand') crouchYDrop = 0.30;
|
||||||
|
else crouchYDrop = 0.30;
|
||||||
|
}
|
||||||
this._modelRoot.position.set(
|
this._modelRoot.position.set(
|
||||||
this._pos.x + fx * fwdShift,
|
this._pos.x + fx * fwdShift,
|
||||||
this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset,
|
this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset - crouchYDrop,
|
||||||
this._pos.z + fz * fwdShift
|
this._pos.z + fz * fwdShift
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -3012,6 +3098,40 @@ export class PlayerController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mixamo-скин: AnimationGroup для каждого состояния (idle/walk/run/jump/fall
|
||||||
|
// + crouch_idle/crouch_walk). Грузятся отдельными GLB.
|
||||||
|
if (this._mixamoAnimator) {
|
||||||
|
let mState;
|
||||||
|
const now = Date.now();
|
||||||
|
const inCrouchTransition = this._crouchTransitionUntil
|
||||||
|
&& now < this._crouchTransitionUntil;
|
||||||
|
if (!result.onGround) {
|
||||||
|
mState = (this._vy > 0.5) ? 'jump' : 'fall';
|
||||||
|
this._crouchEnterPending = false;
|
||||||
|
this._crouchExitPending = false;
|
||||||
|
this._crouchTransitionUntil = 0;
|
||||||
|
} else if (this._crouchEnterPending && inCrouchTransition && !isMoving) {
|
||||||
|
mState = 'crouch_enter';
|
||||||
|
} else if (this._crouchExitPending && inCrouchTransition && !isMoving) {
|
||||||
|
mState = 'crouch_to_stand';
|
||||||
|
} else if (this._crouching) {
|
||||||
|
this._crouchEnterPending = false;
|
||||||
|
this._crouchExitPending = false;
|
||||||
|
mState = isMoving ? 'crouch_walk' : 'crouch_idle';
|
||||||
|
} else if (inWater) {
|
||||||
|
mState = isMoving ? 'walk' : 'idle';
|
||||||
|
} else if (isMoving) {
|
||||||
|
this._crouchExitPending = false;
|
||||||
|
this._crouchTransitionUntil = 0;
|
||||||
|
mState = isSprinting ? 'run' : 'walk';
|
||||||
|
} else {
|
||||||
|
this._crouchExitPending = false;
|
||||||
|
mState = 'idle';
|
||||||
|
}
|
||||||
|
this._mixamoAnimator.setState(mState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// R15-скин: процедурный аниматор (нет glTF AnimationGroups).
|
// R15-скин: процедурный аниматор (нет glTF AnimationGroups).
|
||||||
// Состояния: idle/walk/run/jump/fall. sprint → run.
|
// Состояния: idle/walk/run/jump/fall. sprint → run.
|
||||||
if (this._isR15 && this._r15Animator) {
|
if (this._isR15 && this._r15Animator) {
|
||||||
|
|||||||
@ -115,7 +115,7 @@ const KubikonPlayer = () => {
|
|||||||
const mpSyncRef = useRef(null);
|
const mpSyncRef = useRef(null);
|
||||||
/** Выбранный R15-скин текущего игрока (из rublox_equipped_skin).
|
/** Выбранный R15-скин текущего игрока (из rublox_equipped_skin).
|
||||||
* Грузится при старте, уходит в мультиплеер как modelType. */
|
* Грузится при старте, уходит в мультиплеер как modelType. */
|
||||||
const skinFolderRef = useRef('skin_bacon-hair');
|
const skinFolderRef = useRef('skin_y-bot');
|
||||||
|
|
||||||
const [meta, setMeta] = useState(null); // { title, description, user_id, ... }
|
const [meta, setMeta] = useState(null); // { title, description, user_id, ... }
|
||||||
const [forbidden, setForbidden] = useState(false);
|
const [forbidden, setForbidden] = useState(false);
|
||||||
@ -446,22 +446,43 @@ const KubikonPlayer = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// === Персональный скин игрока ===
|
// === Персональный скин игрока ===
|
||||||
// Грузим выбранный скин из БД (rublox_equipped_skin) и
|
// Источник скина по приоритету:
|
||||||
// применяем его к локальному игроку ДО enterPlayMode —
|
// 1) hash-параметр #skin=<id> в URL (если сайт передал)
|
||||||
// тогда player.setModelType подхватит правильный скин.
|
// 2) БД (rublox_equipped_skin через /equipped-skin)
|
||||||
// Этот же skinFolder уйдёт в мультиплеер как modelType,
|
// 3) localStorage студии (fallback для отладки)
|
||||||
// чтобы соперники видели наш реальный скин.
|
// 4) skin_y-bot (дефолт)
|
||||||
let mySkin = 'skin_bacon-hair';
|
let mySkin = 'skin_y-bot';
|
||||||
if (userId) {
|
try {
|
||||||
|
const m = window.location.hash.match(/[#&]skin=([\w-]+)/);
|
||||||
|
if (m && m[1]) {
|
||||||
|
mySkin = m[1];
|
||||||
|
console.log('[KubikonPlayer] skin from URL:', mySkin);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
if (mySkin === 'skin_y-bot' && userId) {
|
||||||
try {
|
try {
|
||||||
const skinRes = await Kubikon3DApi.getEquippedSkin(userId);
|
const skinRes = await Kubikon3DApi.getEquippedSkin(userId);
|
||||||
const sf = skinRes?.data?.skin_folder;
|
const sf = skinRes?.data?.skin_folder;
|
||||||
if (sf && typeof sf === 'string') mySkin = sf;
|
if (sf && typeof sf === 'string') {
|
||||||
|
mySkin = sf;
|
||||||
|
console.log('[KubikonPlayer] skin from DB:', mySkin);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Сеть/ошибка — играем с дефолтным скином, не блокируем.
|
|
||||||
console.warn('[KubikonPlayer] equipped-skin load failed', e);
|
console.warn('[KubikonPlayer] equipped-skin load failed', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const isLocalDev = (typeof window !== 'undefined'
|
||||||
|
&& (window.location.hostname === 'localhost'
|
||||||
|
|| window.location.hostname === '127.0.0.1'));
|
||||||
|
if (mySkin === 'skin_y-bot' && isLocalDev) {
|
||||||
|
try {
|
||||||
|
const localPick = localStorage.getItem('rublox_selected_skin');
|
||||||
|
if (localPick && typeof localPick === 'string') {
|
||||||
|
mySkin = localPick;
|
||||||
|
console.log('[KubikonPlayer] skin from local LS:', mySkin);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
skinFolderRef.current = mySkin;
|
skinFolderRef.current = mySkin;
|
||||||
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
|
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
|
||||||
|
|
||||||
@ -646,7 +667,7 @@ const KubikonPlayer = () => {
|
|||||||
// загружен при старте в skinFolderRef). Сервер всё равно перепроверит
|
// загружен при старте в skinFolderRef). Сервер всё равно перепроверит
|
||||||
// скин по userId из JWT и при расхождении возьмёт значение из БД —
|
// скин по userId из JWT и при расхождении возьмёт значение из БД —
|
||||||
// так каждый игрок виден соперникам в своём реальном скине.
|
// так каждый игрок виден соперникам в своём реальном скине.
|
||||||
const modelType = skinFolderRef.current || 'skin_bacon-hair';
|
const modelType = skinFolderRef.current || 'skin_y-bot';
|
||||||
const room = await client.joinOrCreate('battle', {
|
const room = await client.joinOrCreate('battle', {
|
||||||
projectId: projectMeta?.id || projectId,
|
projectId: projectMeta?.id || projectId,
|
||||||
token: tokenRaw,
|
token: tokenRaw,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user