fix(multiplayer): ������� ����� remote-������� ��� Mixamo, �� R15 #35

Merged
min merged 1 commits from fix/multiplayer-mixamo-skins into main 2026-06-20 14:54:01 +00:00

View File

@ -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/<id>/body.glb + overrides из манифеста
* - 'skin_*' Mixamo-скин из /character-assets/skins/<id>.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) {
// 2026-06: скины Рублокса — Mixamo (single GLB на rublox-site).
// R15-формат (characters/<id>/body.glb) больше НЕ используется.
// Грузим как локальный игрок (PlayerController._resolveModelSource):
// /character-assets/skins/<id>.glb. Логика 1:1 с PlayerController,
// чтобы remote-игроки выглядели так же, как сам игрок.
const base = (typeof window !== 'undefined'
&& window.location.hostname === 'localhost')
? 'http://localhost:3000'
: 'https://rublox.pro';
return {
file: '/kubikon-assets/' + entry.file,
isR15: true,
overrides: entry.overrides || {},
};
}
// Нет в манифесте — пробуем прямой путь к body.glb.
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);
const animator = new MixamoAnimator();
// Анимации грузятся асинхронно; mixamoAnimator появится в rp
// только после загрузки (render-loop проверяет его наличие).
loadMixamoAnimations(this.scene).then(() => {
// игрок мог уйти, пока грузились анимации
if (!this.remotePlayers.has(rp.sessionId)) {
try { animator.detach(); } catch (e) {}
return;
}
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);
});
}
rp.pendingAccessories = null;
}
} else {
console.warn(`[MultiplayerSync] ${rp.sessionId} R15-скин `
+ `'${rp.modelType}' — скелет не прошёл валидацию`);
}
} 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) {}