diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 7c19171..4b32dee 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -640,6 +640,37 @@ const KubikonEditor = () => { return () => clearInterval(t); }, [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 к полному (для следующего захода) useEffect(() => { if (!isPlaying) { @@ -2032,7 +2063,7 @@ const KubikonEditor = () => { } }; - const handlePlay = () => { + const handlePlay = async () => { const scene = sceneRef.current; if (!scene) return; if (scene.isPlaying()) { @@ -2043,6 +2074,39 @@ const KubikonEditor = () => { // дёргается только на Esc-выход, кнопка Стоп — нет. hudRef.current?.reset?.(); } else { + // 2026-06-14: Перед входом в Play подтягиваем СКИН ЮЗЕРА из БД + // (если ещё не передан в URL #skin=). Источник: + // 1) URL hash #skin= (если уже есть — не трогаем) + // 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 игра пойдёт // со старым кодом (debounce 600мс ещё не сработал). try { scriptEditorFlushRef.current?.(); } catch (_) {} @@ -2061,6 +2125,20 @@ const KubikonEditor = () => { scene.setSpawnPoint(sp.x, spawnY, sp.z); scene.enterPlayMode(); 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'); diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index a391d0d..b1fd219 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -214,7 +214,7 @@ export class BabylonScene { // Дефолт — R15-скин bacon-hair (классический Roblox-вид). // 'skin_*' грузится из characters//body.glb (R15-скелет), // 'character-*' — старые Kenney-модели. - this._playerModelType = 'skin_bacon-hair'; + this._playerModelType = 'skin_y-bot'; // Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z. // По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize(). this._worldHalf = 40; @@ -347,7 +347,13 @@ export class BabylonScene { this.blockManager = new BlockManager(this.scene); // При создании нового proto-меша блока — сразу регистрируем его // как shadow caster (если генератор уже создан). + // ОПТИМИЗАЦИЯ: на БОЛЬШИХ block-картах (стриминг включён — лабиринты, + // 200к+ блоков) НЕ кастуем тени от блоков. Shadow-map иначе рендерит + // всю видимую геометрию повторно — это и есть причина «idle 220мс/кадр» + // при крошечном render_ms. Точно как с terrain (см. ниже). Блоки всё + // равно ПРИНИМАЮТ тени (receiveShadows на proto), но сами не кастуют. this.blockManager.setOnProtoCreated((proto) => { + if (this._blockStreamingEnabled) return; this.addShadowCaster(proto); }); @@ -1552,6 +1558,38 @@ export class BabylonScene { const decoRadius = Math.max(18, radius * 0.35); 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() { if (!this._shadowGenerator) return; if (this.blockManager) { - // Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы - if (this.blockManager._protoMeshes) { + // ОПТИМИЗАЦИЯ: на БОЛЬШИХ 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 на тип = все инстансы for (const proto of this.blockManager._protoMeshes.values()) { this.addShadowCaster(proto); } @@ -6189,9 +6232,30 @@ export class BabylonScene { // Запускаем фоновую музыку и амбиент this.audioManager?.start(); - // Создаём PlayerController и стартуем + // Создаём PlayerController и стартуем. + // 2026-06-14: В тест-режиме студии (Play) персонаж = СКИН ЮЗЕРА, + // а не из настроек проекта. Источник: + // 1) hash #skin= (передаёт сайт при 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.setModelType(this._playerModelType); + this.player.setModelType(finalSkin); // Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck try { this.modalManager?.attachPlayer?.(this.player); @@ -7731,12 +7795,29 @@ export class BabylonScene { serialize() { // Принадлежность объектов папкам — серилизуется в их собственных // данных (folderId), а сами папки в отдельном массиве. - const blocksWithFolders = this.blockManager ? this.blockManager.serialize() : []; - // BlockManager.serialize не знает про folderId — добавляем его поверх. - if (this.blockManager) { - for (const item of blocksWithFolders) { - const mesh = this.blockManager.blocks.get(`${item.x},${item.y},${item.z}`); - item.folderId = mesh?.metadata?.folderId ?? null; + // БЛОКИ: для БОЛЬШИХ карт (лабиринты, 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 — добавляем его поверх. + if (this.blockManager) { + for (const item of blocksWithFolders) { + const mesh = this.blockManager.blocks.get(`${item.x},${item.y},${item.z}`); + item.folderId = mesh?.metadata?.folderId ?? null; + } } } const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : []; @@ -7945,9 +8026,29 @@ export class BabylonScene { this.setShadowQuality(state.scene.shadowQuality); } - // Блоки — синхронно - if (this.blockManager && Array.isArray(state.scene.blocks)) { - this.blockManager.loadFromArray(state.scene.blocks); + // Блоки — синхронно. Для БОЛЬШИХ block-карт (лабиринты и т.п.) включаем + // чанковый стриминг: блоки бьются на регионы 48×48, дальние скрываются + // по радиусу вокруг камеры/игрока (см. 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 формата: @@ -8293,7 +8394,7 @@ export class BabylonScene { if (state.scene.playerModelType) { const pmt = state.scene.playerModelType; if (pmt.startsWith('character-')) { - this._playerModelType = 'skin_bacon-hair'; + this._playerModelType = 'skin_y-bot'; } else { this._playerModelType = pmt; } diff --git a/src/editor/engine/MixamoAnimator.js b/src/editor/engine/MixamoAnimator.js new file mode 100644 index 0000000..1605d83 --- /dev/null +++ b/src/editor/engine/MixamoAnimator.js @@ -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 — кастомные группы для ЭТОГО скелета */ + 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; + } +} diff --git a/src/editor/engine/PlayerController.js b/src/editor/engine/PlayerController.js index e3a755f..1609a30 100644 --- a/src/editor/engine/PlayerController.js +++ b/src/editor/engine/PlayerController.js @@ -28,6 +28,28 @@ import { import { getModelType } from './ModelTypes'; import { R15Skeleton } from './R15Skeleton'; 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' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа. @@ -177,6 +199,7 @@ export class PlayerController { this._isR15 = false; // флаг: загружен валидный R15-скелет this._r15Skeleton = null; // R15Skeleton — резолвер костей this._r15Animator = null; // R15Animator — процедурные анимации + this._mixamoAnimator = null; // MixamoAnimator — Mixamo-скины this._skinManifest = null; // кеш skins_manifest.json this._skinOverrides = {}; // overrides текущего скина @@ -312,6 +335,8 @@ export class PlayerController { this._r15Skeleton = null; this._r15Animator = null; this._isR15 = false; + try { if (this._mixamoAnimator) this._mixamoAnimator.detach(); } catch (e) {} + this._mixamoAnimator = null; this._modelKind = 'r15'; this._modelHipHeight = null; this._nonHumanoidBox = null; @@ -654,6 +679,21 @@ export class PlayerController { async _resolveModelSource() { const typeId = this._modelTypeId || 'character-a'; 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 entry = manifest.find((s) => s.id === typeId); const baseUrl = this._skinManifestBaseUrl || '/kubikon-assets'; @@ -893,11 +933,43 @@ export class PlayerController { } // Анимации. - // R15-скины не содержат AnimationGroups (анимируются процедурно - // через R15Animator в _tick). Kenney-модели — наоборот, имеют - // встроенные AnimationGroups (idle/walk/sprint/jump). + // R15-скины — процедурно через R15Animator. + // Mixamo-скины (non-humanoid-rigged) — через MixamoAnimator + // (5 базовых + lazy эмоции грузятся с /character-assets/animations/). + // Kenney-модели — встроенные AnimationGroups (idle/walk/sprint/jump). 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 || []; for (const g of groups) { const name = (g.name || '').toLowerCase(); @@ -2448,22 +2520,27 @@ export class PlayerController { && (this._codes.has('ControlLeft') || this._codes.has('ControlRight')); if (wantCrouch && !this._crouching) { this._crouching = true; - // сдвигаем центр капсулы вниз — низ ног остаётся на земле const dH = this.HALF_H_CROUCH - this.HALF_H; this.HALF_H = this.HALF_H_CROUCH; if (this._pos) this._pos.y += dH; + this._crouchEnterPending = true; + this._crouchTransitionUntil = Date.now() + 600; } else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) { this._crouching = false; const dH = this.HALF_H_NORMAL - this.HALF_H; this.HALF_H = this.HALF_H_NORMAL; 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 right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw)); - const isSprinting = this._shift; - const speedMult = isSprinting ? this.SPRINT_MULT : 1; + // Crouch имеет ПРИОРИТЕТ над sprint + 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; let moveX = 0, moveZ = 0; @@ -2886,9 +2963,18 @@ export class PlayerController { const fwdShift = inWater ? bodyLen * tiltFrac : 0; const fx = Math.sin(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._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 ); @@ -3012,6 +3098,40 @@ export class PlayerController { 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). // Состояния: idle/walk/run/jump/fall. sprint → run. if (this._isR15 && this._r15Animator) { diff --git a/src/preview-player/KubikonPlayer.jsx b/src/preview-player/KubikonPlayer.jsx index 40b4c77..4da60e9 100644 --- a/src/preview-player/KubikonPlayer.jsx +++ b/src/preview-player/KubikonPlayer.jsx @@ -115,7 +115,7 @@ const KubikonPlayer = () => { const mpSyncRef = useRef(null); /** Выбранный R15-скин текущего игрока (из rublox_equipped_skin). * Грузится при старте, уходит в мультиплеер как modelType. */ - const skinFolderRef = useRef('skin_bacon-hair'); + const skinFolderRef = useRef('skin_y-bot'); const [meta, setMeta] = useState(null); // { title, description, user_id, ... } const [forbidden, setForbidden] = useState(false); @@ -446,22 +446,43 @@ const KubikonPlayer = () => { }); // === Персональный скин игрока === - // Грузим выбранный скин из БД (rublox_equipped_skin) и - // применяем его к локальному игроку ДО enterPlayMode — - // тогда player.setModelType подхватит правильный скин. - // Этот же skinFolder уйдёт в мультиплеер как modelType, - // чтобы соперники видели наш реальный скин. - let mySkin = 'skin_bacon-hair'; - if (userId) { + // Источник скина по приоритету: + // 1) hash-параметр #skin= в URL (если сайт передал) + // 2) БД (rublox_equipped_skin через /equipped-skin) + // 3) localStorage студии (fallback для отладки) + // 4) skin_y-bot (дефолт) + let mySkin = 'skin_y-bot'; + 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 { const skinRes = await Kubikon3DApi.getEquippedSkin(userId); 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) { - // Сеть/ошибка — играем с дефолтным скином, не блокируем. 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; try { scene.setPlayerModelType?.(mySkin); } catch (e) {} @@ -646,7 +667,7 @@ const KubikonPlayer = () => { // загружен при старте в skinFolderRef). Сервер всё равно перепроверит // скин по userId из JWT и при расхождении возьмёт значение из БД — // так каждый игрок виден соперникам в своём реальном скине. - const modelType = skinFolderRef.current || 'skin_bacon-hair'; + const modelType = skinFolderRef.current || 'skin_y-bot'; const room = await client.joinOrCreate('battle', { projectId: projectMeta?.id || projectId, token: tokenRaw,