From 1db043c5fdd1688acc922caa927c7eb0aecd10e8 Mon Sep 17 00:00:00 2001 From: min Date: Sat, 20 Jun 2026 17:48:51 +0300 Subject: [PATCH] =?UTF-8?q?fix(multiplayer):=20=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=BA=D0=B8=D0=BD=D1=8B=20remote-?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=20Mixamo,=20=D0=BD=D0=B5=20R15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Скины других игроков в мультиплеере грузились по старому R15-пути /kubikon-assets/characters//body.glb, которого больше нет → 404 → "Unexpected magic" → вместо персонажа капсула-таблетка с ником. Скины Рублокса теперь Mixamo (single GLB на /character-assets/skins/), R15-формат не используется. MultiplayerSync приведён к логике PlayerController: - resolveRemoteModelSource: skin_* → /character-assets/skins/.glb - scale 1.0 (Mixamo авторингованы в мировом масштабе), не 0.301 - скелет → MixamoAnimator вместо R15Animator (анимации внешними GLB, loadMixamoAnimations; update(dt) не нужен — Babylon тикает сам) - render-loop, rp-конструктор, cleanup переведены на mixamoAnimator - удалён мёртвый R15-код (loadSkinManifest, R15Skeleton/R15Animator) Co-Authored-By: Claude Opus 4.8 --- src/engine/MultiplayerSync.js | 193 +++++++++++++++------------------- 1 file changed, 83 insertions(+), 110 deletions(-) diff --git a/src/engine/MultiplayerSync.js b/src/engine/MultiplayerSync.js index 7aa1ac5..5af572e 100644 --- a/src/engine/MultiplayerSync.js +++ b/src/engine/MultiplayerSync.js @@ -32,59 +32,41 @@ import { } from '@babylonjs/core'; import { getStateCallbacks } from 'colyseus.js'; import { getModelType } from './ModelTypes'; -import { R15Skeleton } from './R15Skeleton'; -import { R15Animator } from './R15Animator'; +import { MIXAMO_SKINS } from './PlayerController'; +import { MixamoAnimator, loadMixamoAnimations } from './MixamoAnimator'; // Подфаза 3.10 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md import { AccessoryManager } from './AccessoryManager'; -// === R15-скины: кеш манифеста (один на весь модуль) === -// skins_manifest.json содержит для каждого скина file + overrides. -// Кешируем как модуль-уровневый промис: первый remote-игрок инициирует -// загрузку, остальные ждут тот же промис — манифест грузится ровно раз. -let _skinManifestPromise = null; - -function loadSkinManifest() { - if (_skinManifestPromise) return _skinManifestPromise; - _skinManifestPromise = fetch('/kubikon-assets/characters/skins_manifest.json') - .then((r) => r.json()) - .then((j) => j.skins || []) - .catch((e) => { - // eslint-disable-next-line no-console - console.warn('[MultiplayerSync] skins_manifest load failed:', e); - return []; - }); - return _skinManifestPromise; -} - /** * Определить источник модели для modelType удалённого игрока. * Точная копия логики PlayerController._resolveModelSource: - * - 'skin_*' → R15-скин из characters//body.glb + overrides из манифеста + * - 'skin_*' → Mixamo-скин из /character-assets/skins/.glb * - иначе → старая Kenney-модель через getModelType() - * @returns {Promise<{file:string, isR15:boolean, overrides:object}|null>} + * @returns {Promise<{file:string, isMixamo?:boolean, kind?:string}|null>} */ async function resolveRemoteModelSource(modelType) { const typeId = modelType || 'skin_y-bot'; if (typeId.startsWith('skin_')) { - const manifest = await loadSkinManifest(); - const entry = manifest.find((s) => s.id === typeId); - if (entry) { - return { - file: '/kubikon-assets/' + entry.file, - isR15: true, - overrides: entry.overrides || {}, - }; - } - // Нет в манифесте — пробуем прямой путь к body.glb. + // 2026-06: скины Рублокса — Mixamo (single GLB на rublox-site). + // R15-формат (characters//body.glb) больше НЕ используется. + // Грузим как локальный игрок (PlayerController._resolveModelSource): + // /character-assets/skins/.glb. Логика 1:1 с PlayerController, + // чтобы remote-игроки выглядели так же, как сам игрок. + const base = (typeof window !== 'undefined' + && window.location.hostname === 'localhost') + ? 'http://localhost:3000' + : 'https://rublox.pro'; return { - file: `/kubikon-assets/characters/${typeId}/body.glb`, - isR15: true, + file: `${base}/character-assets/skins/${typeId}.glb?v=20260614`, + isR15: false, + kind: 'non-humanoid-rigged', // Mixamo-rig + isMixamo: true, overrides: {}, }; } const mt = getModelType(typeId); if (!mt || !mt.file) return null; - return { file: mt.file, isR15: false, overrides: {} }; + return { file: mt.file, isMixamo: false, overrides: {} }; } /** Как часто шлём свою позицию серверу (ms). @@ -352,31 +334,28 @@ export class MultiplayerSync { rp.lastAnimState = rp.animState; // === Анимация === - // Развилка: R15-скины анимируются процедурно через R15Animator + // Развилка: Mixamo-скины анимируются через MixamoAnimator // (как локальный игрок), Kenney-модели — через glTF AnimationGroups. - if (rp.isR15 && rp.r15Animator && rp.modelLoaded) { + if (rp.isMixamo && rp.mixamoAnimator && rp.modelLoaded) { // Серверный animState: 'idle' | 'run' | 'jump' | 'fall' | 'attack'. - // R15Animator понимает idle/walk/run/jump/fall. - // 2026-06-05: раньше run/jump/fall маппились в idle (баг - // в маппинге), из-за чего у remote-игроков не было - // анимации ни ходьбы, ни прыжка. Теперь пробрасываем - // напрямую. attack показывается отдельным swing руки. - let r15State; + // MixamoAnimator понимает idle/walk/run/jump/fall. + // attack показывается отдельным swing руки (ниже). + let mixState; if (rp.isDead) { - r15State = 'idle'; + mixState = 'idle'; } else if (rp.animState === 'jump') { - r15State = 'jump'; + mixState = 'jump'; } else if (rp.animState === 'fall') { - r15State = 'fall'; + mixState = 'fall'; } else if (rp.animState === 'run') { - r15State = 'run'; + mixState = 'run'; } else { - // 'attack' или 'idle' или неизвестное — стоим - r15State = 'idle'; + // 'attack' / 'idle' / неизвестное — стоим + mixState = 'idle'; } - rp.r15Animator.setState(r15State); - rp.r15Animator.update(dt); - } else if (!rp.isR15) { + rp.mixamoAnimator.setState(mixState); + // update(dt) не нужен — Babylon сам тикает AnimationGroup'ы. + } else if (!rp.isMixamo) { // === Kenney: поза руки с оружием === // Форсируем меш правой руки в «вытянутую вперёд» позу // (rotation.x=-π/2). glTF-анимация постоянно возвращает руку @@ -404,8 +383,8 @@ export class MultiplayerSync { } // === Анимация удара рукой (swing) === - // Работает и для Kenney, и для R15 — короткий замах правой руки - // при animState='attack'. _tickAttackSwing сам проверяет + // Работает и для Kenney, и для Mixamo — короткий замах правой + // руки при animState='attack'. _tickAttackSwing сам проверяет // наличие rightArmMesh. this._tickAttackSwing(rp, nowPerf); @@ -642,7 +621,9 @@ export class MultiplayerSync { this._renderObserver = null; } for (const rp of this.remotePlayers.values()) { - rp.r15Animator = null; // снимаем ссылку до dispose скелета + // снимаем аниматор до dispose скелета + try { rp.mixamoAnimator?.detach(); } catch (e) {} + rp.mixamoAnimator = null; try { rp.fallbackMesh?.dispose(); } catch (e) {} try { rp.label?.dispose(); } catch (e) {} try { rp.weaponRoot?.dispose(false, true); } catch (e) {} @@ -717,11 +698,12 @@ export class MultiplayerSync { animState: player.animState || 'idle', // Если модель не успеет загрузиться, висит fallback-капсула. fallbackMesh: null, - // === R15-скин (skin_*) === - // R15-скины не имеют glTF-анимаций — анимируются процедурно - // через R15Animator (как у локального игрока в PlayerController). - isR15: false, // true → анимируем через r15Animator - r15Animator: null, // R15Animator или null для Kenney-моделей + // === Mixamo-скин (skin_*) === + // Mixamo-скины анимируются через MixamoAnimator (как у локального + // игрока в PlayerController). mixamoAnimator появляется асинхронно, + // после загрузки анимаций. + isMixamo: false, // true → анимируем через mixamoAnimator + mixamoAnimator: null, // MixamoAnimator или null для Kenney-моделей modelLoaded: false, // флаг: модель уже на сцене (для тика анимаций) // === Оружие === weaponModelId: player.weaponModelId || '', @@ -771,7 +753,7 @@ export class MultiplayerSync { * Модель цепляется как child корневого transform-node. */ async _loadRemoteModel(rp) { - // Резолвим источник: R15-скин ('skin_*') или старая Kenney-модель. + // Резолвим источник: Mixamo-скин ('skin_*') или старая Kenney-модель. const source = await resolveRemoteModelSource(rp.modelType); if (!source || !source.file) { console.warn(`[MultiplayerSync] unknown modelType=${rp.modelType}`); @@ -807,12 +789,9 @@ export class MultiplayerSync { const modelRoot = new TransformNode(`remoteModel_${rp.sessionId}`, this.scene); modelRoot.parent = rp.root; // Масштаб — точно как у локального игрока (PlayerController): - // - R15-скины: 0.301 (модели нормализованы пайплауном auto_rig - // к 5.98 ед; 1.8/5.98≈0.301) × per-skin overrides.scale_mult. + // - Mixamo-скины: 1.0 (GLB авторингованы в мировом масштабе ~1.8м). // - Kenney-модели: 0.72. - let modelScale = source.isR15 ? 0.301 : 0.72; - const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0; - modelScale *= scaleMult; + const modelScale = source.isMixamo ? 1.0 : 0.72; modelRoot.scaling = new Vector3(modelScale, modelScale, modelScale); const inst = container.instantiateModelsToScene( @@ -843,12 +822,14 @@ export class MultiplayerSync { rp.modelMeshes = meshes; rp.modelRoot = modelRoot; - // === R15-скин: детекция скелета и создание аниматора === - // R15-скины приходят со встроенным скелетом Mixamo (без glTF-анимаций). - // Логика — копия PlayerController._loadPlayerModel. - rp.isR15 = false; - rp.r15Animator = null; - if (source.isR15) { + // === Mixamo-скин: детекция скелета и создание аниматора === + // Скины Рублокса = Mixamo-rig (single GLB). Анимации (idle/walk/run/ + // jump/fall) грузятся отдельными GLB через loadMixamoAnimations + // (singleton-кэш — повторно по сети не качаются). Логика 1:1 с + // PlayerController._loadPlayerModel (Mixamo-ветка). + rp.isMixamo = false; + rp.mixamoAnimator = null; + if (source.isMixamo || source.kind === 'non-humanoid-rigged') { let sk = (inst.skeletons && inst.skeletons[0]) || null; if (!sk && container.skeletons && container.skeletons.length > 0) { sk = container.skeletons[0]; @@ -858,37 +839,27 @@ export class MultiplayerSync { if (meshWithSkel) sk = meshWithSkel.skeleton; } if (sk) { - const r15 = new R15Skeleton(sk); - if (r15.isValidR15()) { - rp.isR15 = true; - rp.r15Skeleton = r15; - rp.r15Animator = new R15Animator(r15, source.overrides || {}); - console.log(`[MultiplayerSync] ${rp.sessionId} R15-скин ` - + `'${rp.modelType}' загружен — костей ` - + `${r15.resolvedNames().length}`); - // Подфаза 3.10 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md: - // создаём AccessoryManager для удалённого игрока. modelRoot - // здесь = rp.modelRoot который был выставлен выше в этом - // методе (см. `rp.modelRoot = root` около строки 715). - rp.accessoryManager = new AccessoryManager( - this.scene, r15, rp.modelRoot, - ); - // Если из колызеуса уже пришёл outfit (pendingAccessories) — - // применяем. Иначе ждём applyRemoteAccessories(sessionId, items). - if (rp.pendingAccessories && rp.pendingAccessories.length) { - for (const it of rp.pendingAccessories) { - rp.accessoryManager.attach(it).catch((e) => { - console.warn('[MultiplayerSync] remote accessory failed', e); - }); - } - rp.pendingAccessories = null; + const animator = new MixamoAnimator(); + // Анимации грузятся асинхронно; mixamoAnimator появится в rp + // только после загрузки (render-loop проверяет его наличие). + loadMixamoAnimations(this.scene).then(() => { + // игрок мог уйти, пока грузились анимации + if (!this.remotePlayers.has(rp.sessionId)) { + try { animator.detach(); } catch (e) {} + return; } - } else { - console.warn(`[MultiplayerSync] ${rp.sessionId} R15-скин ` - + `'${rp.modelType}' — скелет не прошёл валидацию`); - } + animator.attach(this.scene, sk, modelRoot); + animator.setState('idle'); + rp.mixamoAnimator = animator; + rp.isMixamo = true; + console.log(`[MultiplayerSync] ${rp.sessionId} Mixamo-скин ` + + `'${rp.modelType}' готов — костей ${sk.bones.length}`); + }).catch((e) => { + console.warn(`[MultiplayerSync] ${rp.sessionId} Mixamo-анимации ` + + `не загрузились:`, e); + }); } else { - console.warn(`[MultiplayerSync] ${rp.sessionId} R15-скин ` + console.warn(`[MultiplayerSync] ${rp.sessionId} Mixamo-скин ` + `'${rp.modelType}' — нет скелета в glb`); } } @@ -938,9 +909,11 @@ export class MultiplayerSync { rp.weaponAnchor = weaponAnchor; } - // Анимации glTF — ТОЛЬКО для Kenney-моделей. R15-скины анимируются - // процедурно через r15Animator (см. render-loop в start()). - if (!rp.isR15) { + // Анимации glTF — ТОЛЬКО для Kenney-моделей. Mixamo-скины анимируются + // через mixamoAnimator (см. render-loop в start()). Mixamo загружается + // асинхронно, поэтому проверяем по source.isMixamo, а не по rp.isMixamo + // (тот выставится позже, после loadMixamoAnimations). + if (!source.isMixamo && source.kind !== 'non-humanoid-rigged') { const allGroups = inst.animationGroups || []; for (const g of allGroups) { const n = (g.name || '').toLowerCase(); @@ -1110,15 +1083,15 @@ export class MultiplayerSync { try { a.stop(); a.dispose?.(); } catch (e) {} } } - // R15-аниматор: dispose модели снесёт скелет; обнуляем ссылку чтобы - // render-loop в следующем кадре не дёргал невалидный аниматор. - rp.r15Animator = null; - rp.isR15 = false; + // Mixamo-аниматор: dispose модели снесёт скелет; detach + обнуляем + // ссылку, чтобы render-loop в следующем кадре не дёргал невалидный. + try { rp.mixamoAnimator?.detach(); } catch (e) {} + rp.mixamoAnimator = null; + rp.isMixamo = false; rp.modelLoaded = false; // Подфаза 3.10: чистим AccessoryManager если был try { rp.accessoryManager?.detachAll(); } catch (e) {} rp.accessoryManager = null; - rp.r15Skeleton = null; try { rp.fallbackMesh?.dispose(); } catch (e) {} try { rp.label?.dispose(); } catch (e) {} try { rp.weaponRoot?.dispose(false, true); } catch (e) {}