/** * 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//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; } }