Open-source web player for Rublox games, dual-licensed under AGPL-3.0 + Commercial. Highlights: - Babylon.js 7 + React 18 + Vite 5 stack - Self-contained engine (~46k lines): BlockManager, ModelManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD gamemodes - Configurable backend via VITE_API_BASE and friends — works against staging (dev-api.rublox.pro) out of the box - Standalone mode (VITE_STANDALONE=true) loads a bundled sample game for first-run without any backend - Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - Lint + format scaffolding (ESLint + Prettier + EditorConfig) - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Removed before public release: - frontend_deploy.py (contained production SSH credentials) - ~27 admin endpoints (kept in private repo) - Hard-coded internal URLs and IPs - All previous git history (clean repo init)
1201 lines
59 KiB
JavaScript
1201 lines
59 KiB
JavaScript
/**
|
||
* 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';
|
||
// Подфаза 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 из манифеста
|
||
* - иначе → старая 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).
|
||
* Было 50 (20Hz) — приводило к backpressure WS-буфера при 4+ игроках,
|
||
* сервер закрывал сокет → бесконечный цикл reconnect.
|
||
* 100мс = 10Hz — стандарт для multiplayer FPS (Counter-Strike, Valorant).
|
||
* Сервер интерполирует, на клиенте лагов не видно. */
|
||
const INPUT_INTERVAL_MS = 100;
|
||
/** Максимальная скорость интерполяции — 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,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 3. Тик отправки позиции серверу.
|
||
// ВАЖНО: шлём ВСЕГДА, даже если позиция ещё не готова — иначе сервер
|
||
// выкинет нас по timeout (Colyseus default pingTimeout ~30c).
|
||
// Если позиции нет, используем последнюю известную или нули.
|
||
this._lastSentPos = null;
|
||
this._inputTimer = setInterval(() => {
|
||
// Защита от спама консоли: если WebSocket уже закрыт/закрывается,
|
||
// НЕ зовём send (Colyseus сам кричит "WebSocket is already in
|
||
// CLOSING or CLOSED state" в setInterval каждые 50мс).
|
||
// Также самостоятельно останавливаем таймер — нечего пинать.
|
||
const ws = this.room?.connection?.transport?.ws;
|
||
if (ws && ws.readyState !== 1 /* OPEN */) {
|
||
clearInterval(this._inputTimer);
|
||
this._inputTimer = null;
|
||
return;
|
||
}
|
||
const p = this.getMyPos();
|
||
// Если позиции нет — повторяем последнюю известную (keepalive).
|
||
// Если её тоже нет — шлём нули, чтобы сервер видел что мы живы.
|
||
const send = p || this._lastSentPos || { x: 0, y: 0, z: 0, yaw: 0 };
|
||
if (p) this._lastSentPos = p;
|
||
try {
|
||
this.room.send('input', {
|
||
x: send.x, y: send.y || 0, z: send.z, yaw: send.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 */ }
|
||
}
|
||
|
||
/** Подфаза 3.10 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md.
|
||
* Сообщить серверу список accessory item-ов которые мы носим. Сервер
|
||
* передаст соседям → они вызовут applyRemoteAccessories у своей сцены.
|
||
*
|
||
* Формат: { hat: 42, tool: 17, hair: 5, face: null, shirt: null, pants: null }
|
||
* (item-id для каждого слота или null).
|
||
*
|
||
* ПРИМЕЧАНИЕ: серверная сторона (kubikon-realtime/GameRoom.ts) пока
|
||
* не поддерживает это сообщение — она его получит и просто проигнорирует.
|
||
* Будет работать после деплоя серверной части.
|
||
*/
|
||
sendAccessories(slotsToItemIds) {
|
||
try {
|
||
this.room.send('accessories', slotsToItemIds || {});
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
/** Применить полный список аксессуаров на удалённого игрока.
|
||
* Если модель ещё не загружена — складываем в pendingAccessories,
|
||
* применим когда AccessoryManager будет создан в _loadRemoteModel.
|
||
*
|
||
* items: массив RubloxItem.serialize (с path и attachment).
|
||
*/
|
||
applyRemoteAccessories(sessionId, items) {
|
||
const rp = this.remotePlayers.get(sessionId);
|
||
if (!rp) return;
|
||
const list = Array.isArray(items) ? items.filter((i) => i && !i.is_body_skin) : [];
|
||
if (!rp.accessoryManager) {
|
||
rp.pendingAccessories = list;
|
||
return;
|
||
}
|
||
// Снимаем старые и наносим новые
|
||
rp.accessoryManager.detachAll();
|
||
for (const it of list) {
|
||
rp.accessoryManager.attach(it).catch((e) => {
|
||
console.warn('[MultiplayerSync] remote accessory failed', e);
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Отправить выстрел в сторону точки 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;
|
||
// 2026-05-27: тот же fix face_dir_invert что в PlayerController
|
||
// (см. PlayerController.js:786). Без этого remote-игроки с
|
||
// half аватаров бегают задом наперёд.
|
||
if (source.overrides && source.overrides.face_dir_invert) {
|
||
r.rotation.y = (r.rotation.y || 0) + Math.PI;
|
||
}
|
||
}
|
||
|
||
// Запоминаем меши (для смены 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.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;
|
||
}
|
||
} 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;
|
||
// Подфаза 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) {}
|
||
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;
|
||
}
|
||
}
|