studio/src/editor/engine/MultiplayerSync.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

1121 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* MultiplayerSync — мост между Colyseus.js room и Babylon-сценой плеера.
*
* Что делает:
* 1. Принимает room (colyseus.js Room) и scene (BabylonScene).
* 2. На каждый remote-игрока создаёт Babylon-меш (капсула + ник-плашка).
* 3. На state-snapshot обновляет ТАРГЕТНЫЕ позиции, рендер интерполирует
* между ними (lerp 60 FPS) — движение плавное, без рывков на 50 мс между snapshot'ами.
* 4. Раз в 50 мс шлёт серверу свою позицию из PlayerController._pos.
* 5. Слушает 'shot' для трейсеров, 'hit' для красной вспышки, 'kill' для лога,
* 'respawn' для возврата в spawn-point.
*
* Использование:
* const sync = new MultiplayerSync(scene, room, getMyPlayerCtl);
* sync.start();
* ...
* sync.dispose(); // отключает все колбэки и удаляет меши
*
* Координаты Colyseus state: x/y/z в мире (метры). y игнорируем — у нас
* 2D-плоскость в этом этапе; реальная высота берётся с сервера в подэтапе
* физики (4.x). Сейчас all players y=0.
*/
import {
MeshBuilder,
StandardMaterial,
Color3,
Vector3,
DynamicTexture,
TransformNode,
SceneLoader,
} from '@babylonjs/core';
import { getStateCallbacks } from 'colyseus.js';
import { getModelType } from './ModelTypes';
import { R15Skeleton } from './R15Skeleton';
import { R15Animator } from './R15Animator';
// === 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 из манифеста
* - иначе → старая Kenney-модель через getModelType()
* @returns {Promise<{file:string, isR15:boolean, overrides:object}|null>}
*/
async function resolveRemoteModelSource(modelType) {
const typeId = modelType || 'skin_bacon-hair';
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.
return {
file: `/kubikon-assets/characters/${typeId}/body.glb`,
isR15: true,
overrides: {},
};
}
const mt = getModelType(typeId);
if (!mt || !mt.file) return null;
return { file: mt.file, isR15: false, overrides: {} };
}
/** Как часто шлём свою позицию серверу (ms). */
const INPUT_INTERVAL_MS = 50;
/** Максимальная скорость интерполяции — 1 / lerpFactor.
* 0.18 ≈ комфортно: за ~7 кадров (60fps = 110 мс) почти догоняет таргет. */
const LERP_FACTOR = 0.18;
export class MultiplayerSync {
/**
* @param {object} scene Babylon scene
* @param {object} room Colyseus.js Room
* @param {() => {x:number,y:number,z:number,yaw:number}} getMyPos
* Колбэк, возвращающий текущую позицию ЛОКАЛЬНОГО игрока.
* Обычно: () => {
* const p = playerController._pos;
* return { x: p.x, y: 0, z: p.z, yaw: playerController._yaw };
* }
* @param {object} [callbacks] Опциональные колбэки для UI
* {
* onChat: (msg) => void,
* onLocalHit: (damage, hp, maxHp) => void,
* onKilled: (killerName) => void,
* onRespawn: () => void,
* onLog: (level, ...parts) => void,
* }
*/
constructor(scene, room, getMyPos, callbacks = {}) {
this.scene = scene;
this.room = room;
this.getMyPos = getMyPos;
this.cb = callbacks;
/** sessionId → { mesh, label, target, current, hp, maxHp, isDead } */
this.remotePlayers = new Map();
/** Активные трейсеры выстрелов. */
this.shots = [];
this._inputTimer = null;
this._renderObserver = null;
this._cleanupFns = [];
}
start() {
// 1. Подписки на state
const $ = getStateCallbacks(this.room);
const handleAdd = (player, sessionId) => {
// Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController
if (sessionId === this.room.sessionId) return;
this._addRemotePlayer(sessionId, player);
// Подписываемся на изменения этого Player'а
$(player).onChange(() => this._updateRemoteTarget(sessionId, player));
// Точечная подписка на смену оружия — чтобы перецеплять GLB
// только когда поле реально поменялось (а не каждый input).
$(player).listen('weaponModelId', (val) => {
this._attachRemoteWeapon(sessionId, val || '');
});
};
const handleRemove = (player, sessionId) => {
this._removeRemotePlayer(sessionId);
};
// immediate=true — критично! Без этого onAdd НЕ сработает для тех
// игроков, которые уже были в комнате до нашего подключения.
// Мы видим только тех, кто зашёл ПОСЛЕ нас, что для мультиплеера = баг.
$(this.room.state).players.onAdd(handleAdd, true);
$(this.room.state).players.onRemove(handleRemove);
// 2. Сообщения боя
const onShot = (m) => {
// Не рисуем трейсер от своего же выстрела — клиент уже его «выстрелил»
// (хотя для отладки можно показать; пока скрываем своих)
if (m.shooterSessionId === this.room.sessionId) return;
// Origin от сервера = центр игрока (XZ его позиции).
// Дуло пушки находится впереди и выше центра — смещаем:
// - по горизонтали на ~0.9м вперёд (рука + ствол)
// - по вертикали на ~1.4м (уровень руки) от позиции стрелка
// Без этого смещения трейсер начинается из живота персонажа.
const shooter = this.remotePlayers.get(m.shooterSessionId);
const baseY = shooter ? shooter.current.y : 0;
const MUZZLE_FWD = 0.9;
const MUZZLE_UP = 1.4;
const muzzleX = m.originX + m.dirX * MUZZLE_FWD;
const muzzleZ = m.originZ + m.dirZ * MUZZLE_FWD;
this.shots.push({
originX: muzzleX,
originZ: muzzleZ,
dirX: m.dirX,
dirZ: m.dirZ,
distance: Math.max(0.5, (m.hitDistance || 30) - MUZZLE_FWD),
hit: !!m.hit,
expiresAt: Date.now() + 200,
mesh: this._createTracerMesh(
muzzleX, baseY + MUZZLE_UP, muzzleZ,
m.dirX, m.dirZ,
Math.max(0.5, (m.hitDistance || 30) - MUZZLE_FWD),
!!m.hit,
),
});
};
this.room.onMessage('shot', onShot);
const onHit = (m) => {
if (m.victimSessionId === this.room.sessionId) {
// Сами получили урон — даём UI показать flash и уменьшить HP
this.cb.onLocalHit?.(m.damage, m.victimHp, m.victimMaxHp);
} else {
// Кто-то получил урон — мигаем меш красным
const rp = this.remotePlayers.get(m.victimSessionId);
if (rp) this._flashRemote(rp);
}
};
this.room.onMessage('hit', onHit);
const onKill = (m) => {
if (m.victimSessionId === this.room.sessionId) {
this.cb.onKilled?.(m.killerName);
}
this.cb.onLog?.('info', `${m.killerName} убил ${m.victimName}`);
};
this.room.onMessage('kill', onKill);
const onRespawn = (m) => {
if (m.sessionId === this.room.sessionId) {
this.cb.onRespawn?.();
}
};
this.room.onMessage('respawn', onRespawn);
// === Мультиплеер-API скриптов (Фаза 4.3) ===
// roomData — общее состояние комнаты (game.room.set/get/onChange).
// Сервер кладёт значения как JSON-строки; парсим и шлём в GameRuntime
// событие roomChange — у скриптов сработает room.onChange.
const rt = () => this.scene?.gameRuntime || null;
const emitRoomChange = (key, jsonValue) => {
let value;
try { value = JSON.parse(jsonValue); } catch (e) { value = jsonValue; }
const r = rt();
if (r) {
if (!r._roomState) r._roomState = {};
r._roomState[key] = value;
r.routeGlobalEvent('roomChange', { key, value });
}
};
$(this.room.state).roomData.onAdd((value, key) => emitRoomChange(key, value), true);
$(this.room.state).roomData.onChange((value, key) => emitRoomChange(key, value));
// scriptMessage — адресное сообщение от game.sendTo. Сервер релеит
// только тому клиенту, кому адресовано → шлём всем скриптам как mpMessage.
this.room.onMessage('scriptMessage', (m) => {
const r = rt();
if (r && m) {
r.routeGlobalEvent('mpMessage', {
from: m.from, name: m.name, data: m.data,
});
}
});
// Phase 6.6: RemoteEvent. Сервер релеит scriptRemote — шлём всем скриптам
// как 'remoteEvent'. Воркер матчит по имени и зовёт подписку.
this.room.onMessage('scriptRemote', (m) => {
const r = rt();
if (r && m) {
r.routeGlobalEvent('remoteEvent', {
from: m.from, name: m.name, data: m.data,
});
}
});
// 3. Тик отправки позиции серверу
this._inputTimer = setInterval(() => {
const p = this.getMyPos();
if (!p) return;
try {
this.room.send('input', {
x: p.x, y: p.y || 0, z: p.z, yaw: p.yaw || 0,
});
} catch (e) { /* room closed */ }
}, INPUT_INTERVAL_MS);
// 4. Каждый кадр Babylon — интерполируем remote-меши к target-позициям
// и чистим устаревшие трейсеры.
this._lastTickAt = performance.now();
this._debris = [];
this._renderObserver = this.scene.onBeforeRenderObservable.add(() => {
const nowMs = Date.now();
const nowPerf = performance.now();
const dt = Math.min(0.05, (nowPerf - this._lastTickAt) / 1000);
this._lastTickAt = nowPerf;
// Интерполяция remote-игроков (позиция + yaw ставится на root,
// модель — child root'а — следует за ним).
for (const rp of this.remotePlayers.values()) {
if (!rp.root || !rp.target) continue;
const cur = rp.current;
cur.x += (rp.target.x - cur.x) * LERP_FACTOR;
cur.y += (rp.target.y - cur.y) * LERP_FACTOR;
cur.z += (rp.target.z - cur.z) * LERP_FACTOR;
cur.yaw += this._lerpAngle(cur.yaw, rp.target.yaw, LERP_FACTOR);
// Серверный y — это ЦЕНТР игрока (HALF_H выше пола), а origin
// GLB-модели находится у НОГ. Сдвигаем root вниз на HALF_H=0.9,
// чтобы ноги попали на пол. Без этого вся модель парит на полблока.
rp.root.position.x = cur.x;
rp.root.position.y = cur.y - 0.9;
rp.root.position.z = cur.z;
rp.root.rotation.y = cur.yaw;
// Ник-плашка следует за игроком. Высота: серверный y — это
// центр игрока (HALF_H=0.9 над полом), макушка модели на
// ~+0.9 от центра, ник ставим ещё на 0.6м выше неё → +1.5.
// НО мы выше сдвинули root на -0.9, а label НЕ привязан к
// root — его координаты в мире. Значит относительно мира
// ник = пол + (cur.y + offset). Чтобы ник был на ~0.6м над
// макушкой 1.7м-модели, стоящей на полу: y = 1.7 + 0.6 = 2.3.
// cur.y=0.9 (центр) → offset = 2.3 - 0.9 = 1.4.
// Но если игрок в воздухе/прыжке, cur.y растёт → ник тоже.
// На скрине ник почти впритык к голове — поднимаю до +2.5.
if (rp.label) {
rp.label.position.x = cur.x;
rp.label.position.y = cur.y + 2.5;
rp.label.position.z = cur.z;
}
// === Анимация удара ===
// Сервер выставляет animState='attack' на 300мс при выстреле.
// Ловим фронт переключения в attack — запускаем swing.
if (rp.animState === 'attack' && rp.lastAnimState !== 'attack') {
rp.attackAnimStart = nowPerf;
}
rp.lastAnimState = rp.animState;
// === Анимация ===
// Развилка: R15-скины анимируются процедурно через R15Animator
// (как локальный игрок), Kenney-модели — через glTF AnimationGroups.
if (rp.isR15 && rp.r15Animator && rp.modelLoaded) {
// Серверный animState: 'idle' | 'run' | 'attack'. R15Animator
// понимает idle/walk/run/jump/fall. Сервер не различает
// walk/run и не шлёт прыжки → маппим run→run, attack→idle
// (атака показывается отдельным swing-ом руки ниже).
const r15State = rp.isDead
? 'idle'
: (rp.animState === 'run' ? 'run' : 'idle');
rp.r15Animator.setState(r15State);
rp.r15Animator.update(dt);
} else if (!rp.isR15) {
// === Kenney: поза руки с оружием ===
// Форсируем меш правой руки в «вытянутую вперёд» позу
// (rotation.x=-π/2). glTF-анимация постоянно возвращает руку
// в idle через quaternion — каждый кадр обнуляем и пишем Эйлер.
if (rp.rightArmMesh && rp.weaponModelId) {
if (rp.rightArmMesh.rotationQuaternion) {
rp.rightArmMesh.rotationQuaternion = null;
}
rp.rightArmMesh.rotation.x = -Math.PI / 2;
rp.rightArmMesh.rotation.y = 0;
rp.rightArmMesh.rotation.z = 0;
}
// Анимация ходьбы/idle через AnimationGroups.
const wantAnim = rp.isDead
? 'idle'
: (rp.animState === 'attack'
? (rp.lastNonAttackAnim || 'idle')
: (rp.animState || 'idle'));
if (rp.animState !== 'attack') {
rp.lastNonAttackAnim = rp.animState || 'idle';
}
if (rp.currentAnim !== wantAnim) {
this._playRemoteAnim(rp, wantAnim);
}
}
// === Анимация удара рукой (swing) ===
// Работает и для Kenney, и для R15 — короткий замах правой руки
// при animState='attack'. _tickAttackSwing сам проверяет
// наличие rightArmMesh.
this._tickAttackSwing(rp, nowPerf);
// Видимость модели/оружия/ника при смерти.
// Покойник полностью невидим — на сцене остаются только debris-кубики,
// которые мы спавним в _spawnDeathDebris. После респавна сервер
// выставит isDead=false и модель снова покажется.
if (rp._lastDead !== rp.isDead) {
const visible = !rp.isDead;
if (rp.modelRoot) rp.modelRoot.setEnabled(visible);
if (rp.weaponRoot) rp.weaponRoot.setEnabled(visible);
if (rp.weaponAnchor) rp.weaponAnchor.setEnabled(visible);
if (rp.label) rp.label.setEnabled(visible);
if (rp.fallbackMesh) rp.fallbackMesh.setEnabled(visible);
rp._lastDead = rp.isDead;
}
}
// Тикаем debris (оживший visual после смерти соперника)
this._tickDebris(dt);
// Удаляем просроченные трейсеры
for (let i = this.shots.length - 1; i >= 0; i--) {
if (this.shots[i].expiresAt <= nowMs) {
try { this.shots[i].mesh?.dispose(); } catch (e) {}
this.shots.splice(i, 1);
}
}
});
}
/**
* Анимация удара рукой у remote-игрока. Делает короткий swing
* правой руки (~300мс): замах вверх → удар вниз → возврат.
* Если активная анимация GLB крутит ту же руку — наш override
* перетирается в следующем кадре, поэтому пишем каждый кадр.
*/
_tickAttackSwing(rp, nowPerf) {
if (!rp.rightArmMesh) return;
if (!rp.attackAnimStart) return;
const dur = 300;
const elapsed = nowPerf - rp.attackAnimStart;
// База: если оружие есть — рука зафиксирована в -π/2 (предыдущий блок
// в render-loop). Если нет — берём исходную ротацию.
const hasWeapon = !!rp.weaponModelId;
const baseX = hasWeapon ? -Math.PI / 2 : (rp.rightArmBaseRotation?.x ?? 0);
const baseZ = hasWeapon ? 0 : (rp.rightArmBaseRotation?.z ?? 0);
if (elapsed >= dur) {
// Возвращаем базовую позу. Если оружия нет — пишем явно;
// если есть — render-loop в следующем кадре поставит -π/2.
if (!hasWeapon && rp.rightArmBaseRotation) {
rp.rightArmMesh.rotation.x = rp.rightArmBaseRotation.x;
rp.rightArmMesh.rotation.y = rp.rightArmBaseRotation.y;
rp.rightArmMesh.rotation.z = rp.rightArmBaseRotation.z;
}
rp.attackAnimStart = 0;
return;
}
const k = elapsed / dur;
// k=0..0.3 — замах назад/вверх
// k=0.3..0.7 — резкий удар вперёд
// k=0.7..1.0 — возврат к 0
let off;
if (k < 0.3) {
off = -0.6 * (k / 0.3);
} else if (k < 0.7) {
off = -0.6 + 1.8 * ((k - 0.3) / 0.4);
} else {
off = 1.2 * (1 - (k - 0.7) / 0.3);
}
if (rp.rightArmMesh.rotationQuaternion) {
rp.rightArmMesh.rotationQuaternion = null;
}
rp.rightArmMesh.rotation.x = baseX + off;
rp.rightArmMesh.rotation.z = baseZ + Math.sin(k * Math.PI) * 0.25;
}
/**
* Спавнит ~12 кубов в позиции игрока: разлетаются по гравитации,
* затухают за 2 секунды. Вызывается при isDead false→true.
*/
_spawnDeathDebris(rp) {
const cx = rp.current.x;
const cy = rp.current.y;
const cz = rp.current.z;
// eslint-disable-next-line no-console
console.log('[debris] spawn for', rp.sessionId, 'at',
cx.toFixed(2), cy.toFixed(2), cz.toFixed(2));
const colors = [
Color3.FromHexString('#f4c79a'), // кожа
Color3.FromHexString('#b2826a'),
Color3.FromHexString('#6680c4'), // одежда
Color3.FromHexString('#4a3f33'),
];
for (let i = 0; i < 12; i++) {
const size = 0.18 + Math.random() * 0.14;
const cube = MeshBuilder.CreateBox(
`mpDebris_${rp.sessionId}_${i}_${Date.now()}`,
{ size }, this.scene,
);
const mat = new StandardMaterial(`mpDebrisMat_${i}_${Date.now()}`, this.scene);
mat.diffuseColor = colors[i % colors.length];
mat.specularColor = new Color3(0, 0, 0);
cube.material = mat;
cube.position.set(
cx + (Math.random() - 0.5) * 0.5,
cy + 0.8 + Math.random() * 0.6,
cz + (Math.random() - 0.5) * 0.5,
);
cube.rotation.set(
Math.random() * Math.PI,
Math.random() * Math.PI,
Math.random() * Math.PI,
);
cube.isPickable = false;
cube.alwaysSelectAsActiveMesh = true;
cube.renderingGroupId = 1; // поверх всего — точно видно
this._debris.push({
mesh: cube, mat,
vx: (Math.random() - 0.5) * 5,
vy: 4 + Math.random() * 3,
vz: (Math.random() - 0.5) * 5,
rx: (Math.random() - 0.5) * 10,
ry: (Math.random() - 0.5) * 10,
rz: (Math.random() - 0.5) * 10,
age: 0,
life: 2.0,
});
}
}
_tickDebris(dt) {
if (!this._debris || this._debris.length === 0) return;
const G = -10;
const next = [];
for (const d of this._debris) {
d.age += dt;
if (d.age >= d.life) {
try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
continue;
}
d.vy += G * dt;
d.mesh.position.x += d.vx * dt;
d.mesh.position.y += d.vy * dt;
d.mesh.position.z += d.vz * dt;
if (d.mesh.position.y < 0.1) {
d.mesh.position.y = 0.1;
d.vy *= -0.4;
d.vx *= 0.6;
d.vz *= 0.6;
}
d.mesh.rotation.x += d.rx * dt;
d.mesh.rotation.y += d.ry * dt;
d.mesh.rotation.z += d.rz * dt;
const fadeStart = d.life - 0.5;
if (d.age > fadeStart) {
const k = 1 - (d.age - fadeStart) / 0.5;
d.mesh.visibility = Math.max(0, k);
}
next.push(d);
}
this._debris = next;
}
/** Сообщить серверу что мы экипировали оружие с modelId.
* Передаётся в state.weaponModelId, чужие клиенты подгрузят GLB
* и прикрепят к руке нашей модели. */
sendWeapon(modelId) {
try {
this.room.send('weapon', { modelId: modelId || '' });
} catch (e) { /* ignore */ }
}
/** Отправить выстрел в сторону точки worldX/worldZ. */
sendShoot(originX, originZ, dirX, dirZ) {
const len = Math.hypot(dirX, dirZ);
if (len < 0.001) return;
try {
this.room.send('shoot', {
originX, originZ,
dirX: dirX / len, dirZ: dirZ / len,
});
} catch (e) { /* ignore */ }
}
dispose() {
if (this._inputTimer) {
clearInterval(this._inputTimer);
this._inputTimer = null;
}
if (this._renderObserver) {
this.scene.onBeforeRenderObservable.remove(this._renderObserver);
this._renderObserver = null;
}
for (const rp of this.remotePlayers.values()) {
rp.r15Animator = null; // снимаем ссылку до dispose скелета
try { rp.fallbackMesh?.dispose(); } catch (e) {}
try { rp.label?.dispose(); } catch (e) {}
try { rp.weaponRoot?.dispose(false, true); } catch (e) {}
try { rp.weaponAnchor?.dispose(false, true); } catch (e) {}
try { rp.modelRoot?.dispose(false, true); } catch (e) {}
try { rp.root?.dispose(false, true); } catch (e) {}
}
this.remotePlayers.clear();
for (const s of this.shots) {
try { s.mesh?.dispose(); } catch (e) {}
}
this.shots = [];
if (this._debris) {
for (const d of this._debris) {
try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
}
this._debris = [];
}
}
// =================================================================
// === Внутреннее: меши remote-игроков ===
// =================================================================
_addRemotePlayer(sessionId, player) {
const sx = player.x || 0;
const sy = player.y || 0;
const sz = player.z || 0;
const yaw = player.yaw || 0;
// === Корневой transform-node ===
// Сюда грузится модель и крепится ник-плашка.
// На него же ставим интерполированную позицию.
const root = new TransformNode(`remoteRoot_${sessionId}`, this.scene);
root.position.x = sx;
root.position.y = sy;
root.position.z = sz;
// Запись в реестр СРАЗУ (до загрузки модели) — чтобы _updateRemoteTarget
// и onChange находили её даже пока модель ещё не загружена.
const rp = {
sessionId,
root,
modelRoot: null, // сюда придёт TransformNode загруженной модели
label: null,
animations: {}, // { idle, run/walk/sprint, attack/jump }
currentAnim: null,
material: null, // основной mat первого меша (для смены цвета на смерть)
target: { x: sx, y: sy, z: sz, yaw },
current: { x: sx, y: sy, z: sz, yaw },
hp: player.hp ?? 100,
maxHp: player.maxHp ?? 100,
isDead: !!player.isDead,
username: player.username || sessionId,
modelType: player.modelType || 'skin_bacon-hair',
animState: player.animState || 'idle',
// Если модель не успеет загрузиться, висит fallback-капсула.
fallbackMesh: null,
// === R15-скин (skin_*) ===
// R15-скины не имеют glTF-анимаций — анимируются процедурно
// через R15Animator (как у локального игрока в PlayerController).
isR15: false, // true → анимируем через r15Animator
r15Animator: null, // R15Animator или null для Kenney-моделей
modelLoaded: false, // флаг: модель уже на сцене (для тика анимаций)
// === Оружие ===
weaponModelId: player.weaponModelId || '',
weaponRoot: null, // TransformNode загруженного оружия
weaponMeshes: [], // меши, для cleanup
weaponLoadingId: null, // защита от race при быстрых сменах
// === Анимация удара ===
rightArmMesh: null, // меш правой руки (для swing)
rightArmBaseRotation: null, // исходная ротация руки (Vector3)
weaponAnchor: null, // TransformNode на плече для крепления оружия
attackAnimStart: 0, // performance.now() начала, 0 = не активна
lastAnimState: 'idle', // для отслеживания фронта attack
};
this.remotePlayers.set(sessionId, rp);
// === Fallback-капсула (видна сразу, пока модель грузится) ===
const cap = MeshBuilder.CreateCapsule(`remoteCap_${sessionId}`, {
height: 1.8, radius: 0.3, tessellation: 8,
}, this.scene);
cap.parent = root;
cap.position.y = 0;
const capMat = new StandardMaterial(`remoteCapMat_${sessionId}`, this.scene);
capMat.diffuseColor = Color3.FromHexString('#3357ff');
capMat.alpha = 0.5;
cap.material = capMat;
cap.isPickable = false;
rp.fallbackMesh = cap;
// === Ник-плашка над головой ===
// НЕ парентим к root — иначе billboard может срабатывать криво
// из-за вращения parent'а. Позицию обновляем каждый кадр в
// render-loop: root.position + (0, 2.0, 0).
const label = this._createNameLabel(player.username || sessionId);
rp.label = label;
// === Грузим GLB-модель асинхронно ===
this._loadRemoteModel(rp).catch(err => {
console.warn(`[MultiplayerSync] failed to load model for ${sessionId}:`, err);
});
console.log(`[MultiplayerSync] +remote ${sessionId} (${player.username}) at (${sx.toFixed(2)}, ${sy.toFixed(2)}, ${sz.toFixed(2)}) modelType=${rp.modelType}`);
this.cb.onLog?.('info', `+ player ${player.username || sessionId}`);
}
/**
* Загрузить GLB-модель удалённого игрока. По образцу PlayerController._loadPlayerModel.
* Модель цепляется как child корневого transform-node.
*/
async _loadRemoteModel(rp) {
// Резолвим источник: R15-скин ('skin_*') или старая Kenney-модель.
const source = await resolveRemoteModelSource(rp.modelType);
if (!source || !source.file) {
console.warn(`[MultiplayerSync] unknown modelType=${rp.modelType}`);
return;
}
// ВАЖНО: грузим напрямую через SceneLoader, НЕ через ModelManager-кэш.
// Если использовать shared AssetContainer (который ModelManager уже
// мог инстанцировать ранее с freeze materials), повторный
// instantiateModelsToScene даёт меши с битыми ссылками на материалы.
// Babylon HTTP-кэш всё равно убирает сетевые запросы.
const lastSlash = source.file.lastIndexOf('/');
const rootUrl = source.file.substring(0, lastSlash + 1);
const filename = source.file.substring(lastSlash + 1);
let container;
try {
container = await SceneLoader.LoadAssetContainerAsync(
rootUrl, filename, this.scene
);
} catch (e) {
console.warn(`[MultiplayerSync] failed to load model `
+ `${source.file} for ${rp.sessionId}:`, e);
return;
}
// Если игрок успел уйти, пока модель грузилась — выбрасываем
if (!this.remotePlayers.has(rp.sessionId)) {
try { container.dispose(); } catch (e) {}
return;
}
// Создаём корневой узел для модели — applies scale, parent = root
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.
// - Kenney-модели: 0.72.
let modelScale = source.isR15 ? 0.301 : 0.72;
const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
modelScale *= scaleMult;
modelRoot.scaling = new Vector3(modelScale, modelScale, modelScale);
const inst = container.instantiateModelsToScene(
(name) => `remote_${rp.sessionId}_${name}`,
/*cloneAnimations*/ true,
{ doNotInstantiate: false },
);
for (const r of inst.rootNodes) {
r.parent = modelRoot;
}
// Запоминаем меши (для смены alpha при смерти)
const meshes = modelRoot.getChildMeshes(false);
for (const m of meshes) {
m.isPickable = false;
// alwaysSelectAsActiveMesh: даже если в сцене активен freeze
// или octree не знает о новых мешах — этот меш всегда виден.
if (m.alwaysSelectAsActiveMesh !== undefined) {
m.alwaysSelectAsActiveMesh = true;
}
}
rp.modelMeshes = meshes;
rp.modelRoot = modelRoot;
// === R15-скин: детекция скелета и создание аниматора ===
// R15-скины приходят со встроенным скелетом Mixamo (без glTF-анимаций).
// Логика — копия PlayerController._loadPlayerModel.
rp.isR15 = false;
rp.r15Animator = null;
if (source.isR15) {
let sk = (inst.skeletons && inst.skeletons[0]) || null;
if (!sk && container.skeletons && container.skeletons.length > 0) {
sk = container.skeletons[0];
}
if (!sk) {
const meshWithSkel = meshes.find((m) => m.skeleton);
if (meshWithSkel) sk = meshWithSkel.skeleton;
}
if (sk) {
const r15 = new R15Skeleton(sk);
if (r15.isValidR15()) {
rp.isR15 = true;
rp.r15Animator = new R15Animator(r15, source.overrides || {});
console.log(`[MultiplayerSync] ${rp.sessionId} R15-скин `
+ `'${rp.modelType}' загружен — костей `
+ `${r15.resolvedNames().length}`);
} else {
console.warn(`[MultiplayerSync] ${rp.sessionId} R15-скин `
+ `'${rp.modelType}' — скелет не прошёл валидацию`);
}
} else {
console.warn(`[MultiplayerSync] ${rp.sessionId} R15-скин `
+ `'${rp.modelType}' — нет скелета в glb`);
}
}
// Ищем правую руку — точно по тем же правилам что в PlayerController.
// На этой меше будет крепиться оружие и крутиться swing-анимация атаки.
let rightArm = null;
for (const m of meshes) {
const n = (m.name || '').toLowerCase();
if (n.endsWith('arm-right')
|| n.includes('right-arm') || n.includes('rightarm')
|| n.includes('right-hand') || n.includes('hand-right')) {
rightArm = m;
break;
}
}
// Fallback по позиции: ищем меш с x<0 на уровне рук
if (!rightArm) {
let bestX = Infinity;
for (const m of meshes) {
if (!m.position) continue;
const n = (m.name || '').toLowerCase();
if (n.includes('leg') || n.includes('foot') || n.includes('head')
|| n.includes('hat') || n.includes('torso') || n.includes('root')) continue;
const px = m.position.x;
const py = m.position.y;
if (py < 0.8 || py > 2.2) continue;
if (px >= -0.05) continue;
if (px < bestX) { bestX = px; rightArm = m; }
}
}
if (rightArm && rightArm.rotation) {
rp.rightArmMesh = rightArm;
rp.rightArmBaseRotation = rightArm.rotation.clone();
// Чистый якорь оружия — TransformNode без вращения, координаты
// в _modelRoot отзеркалены по X относительно меша руки.
// Формула 1:1 как в PlayerController._updateExtendedArm.
const weaponAnchor = new TransformNode(
`remoteWpnAnchor_${rp.sessionId}`, this.scene,
);
weaponAnchor.parent = modelRoot;
const ax = -(rightArm.position?.x ?? -0.4) + 0.15;
const ay = (rightArm.position?.y ?? 1.1) + 0.7;
const az = (rightArm.position?.z ?? 0) + 0.95;
weaponAnchor.position.set(ax, ay, az);
rp.weaponAnchor = weaponAnchor;
}
// Анимации glTF — ТОЛЬКО для Kenney-моделей. R15-скины анимируются
// процедурно через r15Animator (см. render-loop в start()).
if (!rp.isR15) {
const allGroups = inst.animationGroups || [];
for (const g of allGroups) {
const n = (g.name || '').toLowerCase();
if (n.includes('idle')) rp.animations.idle = g;
else if (n.includes('sprint') || n.includes('run')) rp.animations.run = g;
else if (n.includes('walk') && !rp.animations.run) rp.animations.run = g;
else if (n.includes('attack') || n.includes('punch') || n.includes('kick')) {
rp.animations.attack = g;
}
else if (n.includes('jump')) rp.animations.jump = g;
g.stop();
}
console.log(`[MultiplayerSync] ${rp.sessionId} animations:`,
allGroups.map(g => g.name),
'→ mapped:', Object.keys(rp.animations));
// Стартовая анимация
this._playRemoteAnim(rp, rp.animState || 'idle');
}
// Модель полностью на сцене — render-loop теперь может тикать анимации.
rp.modelLoaded = true;
// Удаляем fallback-капсулу
if (rp.fallbackMesh) {
try { rp.fallbackMesh.dispose(); } catch (e) {}
rp.fallbackMesh = null;
}
// Если за время загрузки модели игрок уже успел экипировать оружие
// (или зашёл в комнату с оружием) — цепляем его сейчас.
if (rp.weaponModelId) {
this._attachRemoteWeapon(rp.sessionId, rp.weaponModelId);
}
}
/**
* Прицепить (или сменить) GLB-оружие к правой руке remote-игрока.
* modelId='' → снять оружие.
*/
async _attachRemoteWeapon(sessionId, modelId) {
const rp = this.remotePlayers.get(sessionId);
if (!rp) return;
rp.weaponModelId = modelId;
// Снимаем старое оружие (всегда — и при смене, и при пустом modelId)
if (rp.weaponMeshes && rp.weaponMeshes.length) {
for (const m of rp.weaponMeshes) {
try { m.dispose(); } catch (e) {}
}
rp.weaponMeshes = [];
}
rp.weaponRoot = null;
if (!modelId) return;
// Если якорь ещё не создан (модель грузится) — пропустим;
// когда модель доедет, _loadRemoteModel сам вызовет _attachRemoteWeapon.
if (!rp.weaponAnchor) return;
const proto = getModelType(modelId);
if (!proto || !proto.file) return;
// Token для защиты от гонок: пока грузим — пользователь мог сменить
// оружие 3 раза. Применяем только результат последнего запроса.
const token = (rp.weaponLoadingId = `${modelId}_${Date.now()}_${Math.random()}`);
const lastSlash = proto.file.lastIndexOf('/');
const rootUrl = proto.file.substring(0, lastSlash + 1);
const filename = proto.file.substring(lastSlash + 1);
let container;
try {
container = await SceneLoader.LoadAssetContainerAsync(
rootUrl, filename, this.scene
);
} catch (e) {
console.warn(`[MultiplayerSync] failed to load weapon ${modelId}:`, e);
return;
}
// Стейл-проверки: игрок ушёл / оружие сменилось пока грузили.
if (!this.remotePlayers.has(sessionId)
|| rp.weaponLoadingId !== token
|| !rp.weaponAnchor) {
container.dispose();
return;
}
const inst = container.instantiateModelsToScene(
(name) => `remoteWpn_${sessionId}_${name}`,
true,
{ doNotInstantiate: false },
);
// Корневой узел оружия парентится к weaponAnchor — TransformNode
// на плече без вращений. Меш руки крутится анимацией, но оружие
// через якорь смотрит ровно вперёд персонажа.
// ВАЖНО: добавляем +π к Y-rotation, потому что у локального игрока
// viewModel рисуется с учётом ориентации меша руки (которая повёрнута
// на ~180° относительно weaponAnchor), а у нас родитель — anchor без
// вращения. Дефолт rotation3rd:{0,0,0} в ModelTypes подкручен ровно
// под локальный сценарий, поэтому здесь компенсируем разворотом.
const weaponRoot = new TransformNode(`remoteWpnRoot_${sessionId}`, this.scene);
weaponRoot.parent = rp.weaponAnchor;
const vm = proto.gameplay?.viewModel;
const scale = vm?.scale3rd ?? 1.2;
const pos = vm?.position3rd ?? { x: 0, y: 0, z: 0 };
const rot = vm?.rotation3rd ?? { x: 0, y: 0, z: 0 };
weaponRoot.position.set(pos.x, pos.y, pos.z);
weaponRoot.rotation.set(rot.x, rot.y + Math.PI, rot.z);
weaponRoot.scaling.set(scale, scale, scale);
const weaponMeshes = [];
for (const r of inst.rootNodes) {
r.parent = weaponRoot;
weaponMeshes.push(r);
if (r.getChildMeshes) {
for (const cm of r.getChildMeshes(false)) {
cm.isPickable = false;
weaponMeshes.push(cm);
}
}
}
rp.weaponRoot = weaponRoot;
rp.weaponMeshes = weaponMeshes;
}
/** Запустить указанную анимацию у remote-игрока (с fallback на idle). */
_playRemoteAnim(rp, name) {
if (!rp.animations) return;
if (rp.currentAnim === name) return;
let target = rp.animations[name];
if (!target && name !== 'idle') target = rp.animations.idle;
if (!target) return;
// Стопим предыдущую
if (rp.currentAnim && rp.animations[rp.currentAnim]) {
try { rp.animations[rp.currentAnim].stop(); } catch (e) {}
}
try { target.start(/*loop*/ true, /*speed*/ 1); } catch (e) {}
rp.currentAnim = name;
}
_updateRemoteTarget(sessionId, player) {
const rp = this.remotePlayers.get(sessionId);
if (!rp) return;
rp.target.x = player.x;
rp.target.y = player.y;
rp.target.z = player.z;
rp.target.yaw = player.yaw || 0;
rp.hp = player.hp;
rp.maxHp = player.maxHp;
const wasDead = rp.isDead;
rp.isDead = !!player.isDead;
// Фронт false→true — игрок только что умер, спавним debris.
if (!wasDead && rp.isDead) {
this._spawnDeathDebris(rp);
}
if (player.animState) rp.animState = player.animState;
}
_removeRemotePlayer(sessionId) {
const rp = this.remotePlayers.get(sessionId);
if (!rp) return;
// Стопим анимации, удаляем меши
if (rp.animations) {
for (const a of Object.values(rp.animations)) {
try { a.stop(); a.dispose?.(); } catch (e) {}
}
}
// R15-аниматор: dispose модели снесёт скелет; обнуляем ссылку чтобы
// render-loop в следующем кадре не дёргал невалидный аниматор.
rp.r15Animator = null;
rp.isR15 = false;
rp.modelLoaded = false;
try { rp.fallbackMesh?.dispose(); } catch (e) {}
try { rp.label?.dispose(); } catch (e) {}
try { rp.weaponRoot?.dispose(false, true); } catch (e) {}
try { rp.weaponAnchor?.dispose(false, true); } catch (e) {}
try { rp.modelRoot?.dispose(false, true); } catch (e) {}
try { rp.root?.dispose(false, true); } catch (e) {}
this.remotePlayers.delete(sessionId);
this.cb.onLog?.('info', `- player ${rp.username}`);
}
/**
* Создать плоскость с ником в стиле Roblox: прозрачный фон, белый
* текст с чёрной обводкой, без плашки. Всегда поверх всего, всегда
* лицом к камере.
*/
_createNameLabel(text) {
// Высокое разрешение текстуры — ник остаётся чётким даже на
// близком расстоянии и при низком DPR рендер-буфера. 1024×256 ок
// по памяти (~1 МБ на текстуру).
const W = 1024, H = 256;
// 4-й аргумент DynamicTexture — generateMipMaps. Включаем (true),
// плюс trilinear sampling даёт чёткий текст на любых расстояниях
// без алиасинга (мерцания).
const tex = new DynamicTexture(`nameTex_${text}_${Date.now()}`,
{ width: W, height: H }, this.scene, true);
tex.updateSamplingMode?.(3); // 3 = TRILINEAR_SAMPLINGMODE
tex.anisotropicFilteringLevel = 8;
const ctx = tex.getContext();
ctx.clearRect(0, 0, W, H);
// Чёрная обводка + белый текст. Размер шрифта подобран под H=256.
ctx.font = 'bold 128px "Roboto Condensed", "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineWidth = 16;
ctx.lineJoin = 'round';
ctx.miterLimit = 2;
ctx.strokeStyle = '#000';
ctx.strokeText(text, W / 2, H / 2);
ctx.fillStyle = '#fff';
ctx.fillText(text, W / 2, H / 2);
// invertY=true (дефолт). Без этого текст оказывается вверх ногами,
// потому что UV.v у CreatePlane по Babylon-конвенции инвертирован.
tex.update(true);
tex.hasAlpha = true;
// Размеры plane подбираем под пропорции текстуры (4:1).
// Делаем побольше — чтобы ник было видно с дистанции.
const plane = MeshBuilder.CreatePlane(`nameLabel_${text}`,
{ width: 2.2, height: 0.55 },
this.scene);
const mat = new StandardMaterial(`nameLabelMat_${text}`, this.scene);
mat.diffuseTexture = tex;
mat.diffuseTexture.hasAlpha = true;
mat.useAlphaFromDiffuseTexture = false;
mat.emissiveColor = new Color3(1, 1, 1);
mat.disableLighting = true;
mat.backFaceCulling = false;
// Без depth-теста — ник всегда видно, даже сквозь стены/тело
// (как в Roblox). disableDepthWrite + alphaIndex для корректной
// прозрачности.
mat.disableDepthWrite = true;
plane.material = mat;
// BILLBOARDMODE_ALL = 7 (X|Y|Z). Каждый кадр Babylon разворачивает
// плоскость лицом к камере независимо от parent-вращения.
plane.billboardMode = 7;
// renderingGroupId=1 — рисуется ПОСЛЕ всей геометрии (group=0),
// поверх торса/стен/деревьев.
plane.renderingGroupId = 1;
plane.isPickable = false;
return plane;
}
/** Создать яркий трейсер выстрела (статичный цилиндр от origin до hit).
* Y передаётся явно — обычно `shooter.y + 1.4` (уровень руки в мире).
* renderingGroupId=1 + alwaysSelectAsActiveMesh — виден поверх всего,
* не отбрасывается octree/freeze. */
_createTracerMesh(originX, originY, originZ, dirX, dirZ, distance, hit) {
const start = new Vector3(originX, originY, originZ);
const end = new Vector3(
originX + dirX * distance, originY, originZ + dirZ * distance,
);
const len = Vector3.Distance(start, end);
const tracer = MeshBuilder.CreateCylinder(
`tracer_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
{ height: len, diameter: 0.12, tessellation: 6 },
this.scene,
);
const mat = new StandardMaterial(`tracerMat_${Date.now()}`, this.scene);
const colorHit = new Color3(1.0, 0.45, 0.30); // оранжево-красный для попадания
const colorMiss = new Color3(1.0, 0.95, 0.50); // ярко-жёлтый для промаха
mat.diffuseColor = hit ? colorHit : colorMiss;
mat.emissiveColor = hit ? colorHit : colorMiss;
mat.disableLighting = true;
tracer.material = mat;
tracer.isPickable = false;
tracer.alwaysSelectAsActiveMesh = true;
tracer.renderingGroupId = 1;
// Позиционируем посередине отрезка и поворачиваем в направлении конца.
const mid = start.add(end).scale(0.5);
tracer.position = mid;
const forward = end.subtract(start).normalize();
const yawAngle = Math.atan2(forward.x, forward.z);
tracer.rotation.y = yawAngle;
tracer.rotation.x = Math.PI / 2;
return tracer;
}
_flashRemote(rp) {
if (!rp.material) return;
const orig = rp.material.diffuseColor.clone();
rp.material.diffuseColor = Color3.FromHexString('#ef4444');
setTimeout(() => {
try {
if (!rp._appliedDead) {
rp.material.diffuseColor = orig;
}
} catch (e) { /* mesh disposed */ }
}, 200);
}
/** Lerp угла с учётом цикличности [-π, π]. */
_lerpAngle(a, b, t) {
let diff = b - a;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
return diff * t;
}
}