Compare commits

...

2 Commits

Author SHA1 Message Date
min
4c5d18806b ci: ������ ��������� trufflehog � secret-scan (���� ���������� deploy) (#36)
All checks were successful
CI / Lint (push) Successful in 56s
CI / Build (push) Successful in 1m31s
CI / Secret scan (push) Successful in 45s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m59s
2026-06-20 16:38:20 +00:00
min
9be2f363f3 fix(multiplayer): ������� ����� remote-������� ��� Mixamo, �� R15 (#35)
Some checks failed
CI / Lint (push) Successful in 56s
CI / Build (push) Successful in 1m30s
CI / Secret scan (push) Failing after 5m9s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Has been skipped
2026-06-20 14:54:00 +00:00
2 changed files with 260 additions and 268 deletions

View File

@ -1,158 +1,177 @@
# CI плеера Рублокса. # CI плеера Рублокса.
# Запускается на каждый push и pull_request. # Запускается на каждый push и pull_request.
# #
# Что проверяем: # Что проверяем:
# 1. lint — ESLint без warning'ов # 1. lint — ESLint без warning'ов
# 2. format-check — Prettier формат не нарушен # 2. format-check — Prettier формат не нарушен
# 3. build — vite build проходит без ошибок # 3. build — vite build проходит без ошибок
# 4. secret-scan — trufflehog не нашёл утечек секретов # 4. secret-scan — trufflehog не нашёл утечек секретов
# 5. size-check — PR не больше 1000 строк (предупреждение) # 5. size-check — PR не больше 1000 строк (предупреждение)
name: CI name: CI
on: on:
push: push:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
jobs: jobs:
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: '18' node-version: '18'
- run: npm ci - run: npm ci
# format:check временно отключён до массового npx prettier --write # format:check временно отключён до массового npx prettier --write
# (см. docs/ONBOARDING.md → «Форматирование кода»). После прогона # (см. docs/ONBOARDING.md → «Форматирование кода»). После прогона
# верни строку `- run: npm run format:check` перед npm run lint. # верни строку `- run: npm run format:check` перед npm run lint.
- run: npm run lint - run: npm run lint
build: build:
name: Build name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: '18' node-version: '18'
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
- name: Save build size - name: Save build size
# set -o pipefail (default Gitea Actions) валит step при SIGPIPE # set -o pipefail (default Gitea Actions) валит step при SIGPIPE
# от head. Делаем команды непадающими через || true. # от head. Делаем команды непадающими через || true.
run: | run: |
du -sh build/ || true du -sh build/ || true
ls -la build/assets/ 2>/dev/null | head -10 || true ls -la build/assets/ 2>/dev/null | head -10 || true
secret-scan: secret-scan:
name: Secret scan name: Secret scan
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install trufflehog - name: Install trufflehog
run: | # Установка trufflehog тянет бинарь с github.com/releases, который из
curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh \ # runner'а периодически недоступен (install.sh падает на скачивании,
| sh -s -- -b /usr/local/bin # exit 1) и раньше валил ВЕСЬ secret-scan → deploy skipped, хотя код
- name: Run trufflehog # корректен. Делаем установку best-effort: пробуем 3 раза, но НЕ роняем
run: | # job если не вышло. Скан-шаг ниже сам решает, что делать без бинаря.
trufflehog git "file://$(pwd)" \ continue-on-error: true
--only-verified --fail \ run: |
--exclude-paths .trufflehog-ignore 2>&1 | tee scan.log || EXIT=$? for i in 1 2 3; do
if [ -n "$EXIT" ] && [ "$EXIT" -ne 0 ]; then curl -sSfL --connect-timeout 15 --max-time 120 \
echo "::error::Найдены секреты в коммитах! См. лог выше." https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh \
exit 1 | sh -s -- -b /usr/local/bin && break
fi echo "Попытка $i установить trufflehog не удалась, повтор через 10с…"
sleep 10
size-check: done
name: PR size check command -v trufflehog || echo "trufflehog НЕ установлен (сетевой сбой runner'а)"
if: github.event_name == 'pull_request' - name: Run trufflehog
runs-on: ubuntu-latest run: |
steps: # Если бинарь не установился (недоступен github.com из runner'а) —
- uses: actions/checkout@v3 # НЕ блокируем pipeline: это сбой инфраструктуры, а не найденный
with: # секрет. На коммите уже отработал локальный pre-commit secret-scan.
fetch-depth: 0 if ! command -v trufflehog >/dev/null 2>&1; then
- name: Check PR size echo "::warning::trufflehog недоступен (не скачался из runner'а) — скан секретов ПРОПУЩЕН. Это сбой сети CI, не утечка."
run: | exit 0
ADDED=$(git diff origin/${{ github.base_ref }}...HEAD --shortstat | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0) fi
REMOVED=$(git diff origin/${{ github.base_ref }}...HEAD --shortstat | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo 0) trufflehog git "file://$(pwd)" \
TOTAL=$((ADDED + REMOVED)) --only-verified --fail \
echo "PR изменяет $TOTAL строк (+$ADDED / -$REMOVED)" --exclude-paths .trufflehog-ignore 2>&1 | tee scan.log || EXIT=$?
if [ "$TOTAL" -gt 1000 ]; then if [ -n "$EXIT" ] && [ "$EXIT" -ne 0 ]; then
echo "::warning::PR изменяет $TOTAL строк (> 1000). Подумай о дроблении на несколько меньших." echo "::error::Найдены секреты в коммитах! См. лог выше."
fi exit 1
fi
# ────────────────────────────────────────────────────────────────────
# DEPLOY — собирает прод-бандл и заливает на ОБА сервера (S1+S2) size-check:
# параллельно через rsync over SSH. name: PR size check
# if: github.event_name == 'pull_request'
# Запускается ТОЛЬКО на push в main (т.е. после успешного мержа PR). runs-on: ubuntu-latest
# PR-проверки выше (lint/build/secret-scan/size-check) гарантируют steps:
# что в main попадает только корректный код. - uses: actions/checkout@v3
# with:
# Секреты: fetch-depth: 0
# DEPLOY_SSH_KEY — приватный ed25519 ключ (CI-only, отдельный от - name: Check PR size
# админских), pubkey уже на ~min/.ssh/authorized_keys run: |
# на S1 VM 124 и S2 VM 124 ADDED=$(git diff origin/${{ github.base_ref }}...HEAD --shortstat | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0)
# KNOWN_HOSTS — host-keys S1 и S2 (защита от MITM) REMOVED=$(git diff origin/${{ github.base_ref }}...HEAD --shortstat | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo 0)
# TOTAL=$((ADDED + REMOVED))
# Цели (на VM 124 обоих серверов): echo "PR изменяет $TOTAL строк (+$ADDED / -$REMOVED)"
# /var/www/rublox-player/build/ if [ "$TOTAL" -gt 1000 ]; then
# ──────────────────────────────────────────────────────────────────── echo "::warning::PR изменяет $TOTAL строк (> 1000). Подумай о дроблении на несколько меньших."
deploy: fi
name: Deploy to S1 + S2
if: github.event_name == 'push' && github.ref == 'refs/heads/main' # ────────────────────────────────────────────────────────────────────
# Lint НЕ в needs — он опциональный (исторический долг empty-блоков # DEPLOY — собирает прод-бандл и заливает на ОБА сервера (S1+S2)
# ещё не вычищен, см. branch protection без 'CI / Lint' в required). # параллельно через rsync over SSH.
# Deploy всё равно зависит от Build и Secret-scan — это критично. #
needs: [build, secret-scan] # Запускается ТОЛЬКО на push в main (т.е. после успешного мержа PR).
runs-on: ubuntu-latest # PR-проверки выше (lint/build/secret-scan/size-check) гарантируют
steps: # что в main попадает только корректный код.
- uses: actions/checkout@v3 #
- uses: actions/setup-node@v3 # Секреты:
with: # DEPLOY_SSH_KEY — приватный ed25519 ключ (CI-only, отдельный от
node-version: '18' # админских), pubkey уже на ~min/.ssh/authorized_keys
- name: Install deps # на S1 VM 124 и S2 VM 124
run: npm ci # KNOWN_HOSTS — host-keys S1 и S2 (защита от MITM)
- name: Production build #
run: npm run build # Цели (на VM 124 обоих серверов):
- name: Prepare SSH # /var/www/rublox-player/build/
env: # ────────────────────────────────────────────────────────────────────
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} deploy:
KNOWN_HOSTS: ${{ secrets.KNOWN_HOSTS }} name: Deploy to S1 + S2
run: | if: github.event_name == 'push' && github.ref == 'refs/heads/main'
mkdir -p ~/.ssh && chmod 700 ~/.ssh # Lint НЕ в needs — он опциональный (исторический долг empty-блоков
echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy # ещё не вычищен, см. branch protection без 'CI / Lint' в required).
chmod 600 ~/.ssh/id_deploy # Deploy всё равно зависит от Build и Secret-scan — это критично.
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts needs: [build, secret-scan]
chmod 600 ~/.ssh/known_hosts runs-on: ubuntu-latest
- name: Install rsync steps:
run: apt-get update -qq && apt-get install -y rsync openssh-client - uses: actions/checkout@v3
# S1 — НЕ блокирующий: при недоступности S1 (downtime) деплой не должен - uses: actions/setup-node@v3
# валиться, главное доставить на S2. ConnectTimeout 20с чтобы не висеть. with:
- name: Deploy to S1 (85.175.7.40:1998) node-version: '18'
continue-on-error: true - name: Install deps
run: | run: npm ci
rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \ - name: Production build
-e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998" \ run: npm run build
build/ min@85.175.7.40:/var/www/rublox-player/build/ - name: Prepare SSH
- name: Deploy to S2 (192.168.0.124:22, runner в той же сети) env:
run: | DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \ KNOWN_HOSTS: ${{ secrets.KNOWN_HOSTS }}
-e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22" \ run: |
build/ min@192.168.0.124:/var/www/rublox-player/build/ mkdir -p ~/.ssh && chmod 700 ~/.ssh
- name: Verify S1 (не блокирующий) echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy
continue-on-error: true chmod 600 ~/.ssh/id_deploy
run: | echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998 \ chmod 600 ~/.ssh/known_hosts
min@85.175.7.40 \ - name: Install rsync
"ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/ 2>/dev/null || true" run: apt-get update -qq && apt-get install -y rsync openssh-client
- name: Verify S2 (обязательный) # S1 — НЕ блокирующий: при недоступности S1 (downtime) деплой не должен
run: | # валиться, главное доставить на S2. ConnectTimeout 20с чтобы не висеть.
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22 \ - name: Deploy to S1 (85.175.7.40:1998)
min@192.168.0.124 \ continue-on-error: true
"ls /var/www/rublox-player/build/index.html && (du -sh /var/www/rublox-player/build/ 2>/dev/null || true)" run: |
rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \
-e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998" \
build/ min@85.175.7.40:/var/www/rublox-player/build/
- name: Deploy to S2 (192.168.0.124:22, runner в той же сети)
run: |
rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \
-e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22" \
build/ min@192.168.0.124:/var/www/rublox-player/build/
- name: Verify S1 (не блокирующий)
continue-on-error: true
run: |
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998 \
min@85.175.7.40 \
"ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/ 2>/dev/null || true"
- name: Verify S2 (обязательный)
run: |
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22 \
min@192.168.0.124 \
"ls /var/www/rublox-player/build/index.html && (du -sh /var/www/rublox-player/build/ 2>/dev/null || true)"

View File

@ -32,59 +32,41 @@ import {
} from '@babylonjs/core'; } from '@babylonjs/core';
import { getStateCallbacks } from 'colyseus.js'; import { getStateCallbacks } from 'colyseus.js';
import { getModelType } from './ModelTypes'; import { getModelType } from './ModelTypes';
import { R15Skeleton } from './R15Skeleton'; import { MIXAMO_SKINS } from './PlayerController';
import { R15Animator } from './R15Animator'; import { MixamoAnimator, loadMixamoAnimations } from './MixamoAnimator';
// Подфаза 3.10 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md // Подфаза 3.10 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md
import { AccessoryManager } from './AccessoryManager'; 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 удалённого игрока. * Определить источник модели для modelType удалённого игрока.
* Точная копия логики PlayerController._resolveModelSource: * Точная копия логики PlayerController._resolveModelSource:
* - 'skin_*' R15-скин из characters/<id>/body.glb + overrides из манифеста * - 'skin_*' Mixamo-скин из /character-assets/skins/<id>.glb
* - иначе старая Kenney-модель через getModelType() * - иначе старая Kenney-модель через getModelType()
* @returns {Promise<{file:string, isR15:boolean, overrides:object}|null>} * @returns {Promise<{file:string, isMixamo?:boolean, kind?:string}|null>}
*/ */
async function resolveRemoteModelSource(modelType) { async function resolveRemoteModelSource(modelType) {
const typeId = modelType || 'skin_y-bot'; const typeId = modelType || 'skin_y-bot';
if (typeId.startsWith('skin_')) { if (typeId.startsWith('skin_')) {
const manifest = await loadSkinManifest(); // 2026-06: скины Рублокса — Mixamo (single GLB на rublox-site).
const entry = manifest.find((s) => s.id === typeId); // R15-формат (characters/<id>/body.glb) больше НЕ используется.
if (entry) { // Грузим как локальный игрок (PlayerController._resolveModelSource):
return { // /character-assets/skins/<id>.glb. Логика 1:1 с PlayerController,
file: '/kubikon-assets/' + entry.file, // чтобы remote-игроки выглядели так же, как сам игрок.
isR15: true, const base = (typeof window !== 'undefined'
overrides: entry.overrides || {}, && window.location.hostname === 'localhost')
}; ? 'http://localhost:3000'
} : 'https://rublox.pro';
// Нет в манифесте — пробуем прямой путь к body.glb.
return { return {
file: `/kubikon-assets/characters/${typeId}/body.glb`, file: `${base}/character-assets/skins/${typeId}.glb?v=20260614`,
isR15: true, isR15: false,
kind: 'non-humanoid-rigged', // Mixamo-rig
isMixamo: true,
overrides: {}, overrides: {},
}; };
} }
const mt = getModelType(typeId); const mt = getModelType(typeId);
if (!mt || !mt.file) return null; if (!mt || !mt.file) return null;
return { file: mt.file, isR15: false, overrides: {} }; return { file: mt.file, isMixamo: false, overrides: {} };
} }
/** Как часто шлём свою позицию серверу (ms). /** Как часто шлём свою позицию серверу (ms).
@ -352,31 +334,28 @@ export class MultiplayerSync {
rp.lastAnimState = rp.animState; rp.lastAnimState = rp.animState;
// === Анимация === // === Анимация ===
// Развилка: R15-скины анимируются процедурно через R15Animator // Развилка: Mixamo-скины анимируются через MixamoAnimator
// (как локальный игрок), Kenney-модели — через glTF AnimationGroups. // (как локальный игрок), Kenney-модели — через glTF AnimationGroups.
if (rp.isR15 && rp.r15Animator && rp.modelLoaded) { if (rp.isMixamo && rp.mixamoAnimator && rp.modelLoaded) {
// Серверный animState: 'idle' | 'run' | 'jump' | 'fall' | 'attack'. // Серверный animState: 'idle' | 'run' | 'jump' | 'fall' | 'attack'.
// R15Animator понимает idle/walk/run/jump/fall. // MixamoAnimator понимает idle/walk/run/jump/fall.
// 2026-06-05: раньше run/jump/fall маппились в idle (баг // attack показывается отдельным swing руки (ниже).
// в маппинге), из-за чего у remote-игроков не было let mixState;
// анимации ни ходьбы, ни прыжка. Теперь пробрасываем
// напрямую. attack показывается отдельным swing руки.
let r15State;
if (rp.isDead) { if (rp.isDead) {
r15State = 'idle'; mixState = 'idle';
} else if (rp.animState === 'jump') { } else if (rp.animState === 'jump') {
r15State = 'jump'; mixState = 'jump';
} else if (rp.animState === 'fall') { } else if (rp.animState === 'fall') {
r15State = 'fall'; mixState = 'fall';
} else if (rp.animState === 'run') { } else if (rp.animState === 'run') {
r15State = 'run'; mixState = 'run';
} else { } else {
// 'attack' или 'idle' или неизвестное — стоим // 'attack' / 'idle' / неизвестное — стоим
r15State = 'idle'; mixState = 'idle';
} }
rp.r15Animator.setState(r15State); rp.mixamoAnimator.setState(mixState);
rp.r15Animator.update(dt); // update(dt) не нужен — Babylon сам тикает AnimationGroup'ы.
} else if (!rp.isR15) { } else if (!rp.isMixamo) {
// === Kenney: поза руки с оружием === // === Kenney: поза руки с оружием ===
// Форсируем меш правой руки в «вытянутую вперёд» позу // Форсируем меш правой руки в «вытянутую вперёд» позу
// (rotation.x=-π/2). glTF-анимация постоянно возвращает руку // (rotation.x=-π/2). glTF-анимация постоянно возвращает руку
@ -404,8 +383,8 @@ export class MultiplayerSync {
} }
// === Анимация удара рукой (swing) === // === Анимация удара рукой (swing) ===
// Работает и для Kenney, и для R15 — короткий замах правой руки // Работает и для Kenney, и для Mixamo — короткий замах правой
// при animState='attack'. _tickAttackSwing сам проверяет // руки при animState='attack'. _tickAttackSwing сам проверяет
// наличие rightArmMesh. // наличие rightArmMesh.
this._tickAttackSwing(rp, nowPerf); this._tickAttackSwing(rp, nowPerf);
@ -642,7 +621,9 @@ export class MultiplayerSync {
this._renderObserver = null; this._renderObserver = null;
} }
for (const rp of this.remotePlayers.values()) { for (const rp of this.remotePlayers.values()) {
rp.r15Animator = null; // снимаем ссылку до dispose скелета // снимаем аниматор до dispose скелета
try { rp.mixamoAnimator?.detach(); } catch (e) {}
rp.mixamoAnimator = null;
try { rp.fallbackMesh?.dispose(); } catch (e) {} try { rp.fallbackMesh?.dispose(); } catch (e) {}
try { rp.label?.dispose(); } catch (e) {} try { rp.label?.dispose(); } catch (e) {}
try { rp.weaponRoot?.dispose(false, true); } catch (e) {} try { rp.weaponRoot?.dispose(false, true); } catch (e) {}
@ -717,11 +698,12 @@ export class MultiplayerSync {
animState: player.animState || 'idle', animState: player.animState || 'idle',
// Если модель не успеет загрузиться, висит fallback-капсула. // Если модель не успеет загрузиться, висит fallback-капсула.
fallbackMesh: null, fallbackMesh: null,
// === R15-скин (skin_*) === // === Mixamo-скин (skin_*) ===
// R15-скины не имеют glTF-анимаций — анимируются процедурно // Mixamo-скины анимируются через MixamoAnimator (как у локального
// через R15Animator (как у локального игрока в PlayerController). // игрока в PlayerController). mixamoAnimator появляется асинхронно,
isR15: false, // true → анимируем через r15Animator // после загрузки анимаций.
r15Animator: null, // R15Animator или null для Kenney-моделей isMixamo: false, // true → анимируем через mixamoAnimator
mixamoAnimator: null, // MixamoAnimator или null для Kenney-моделей
modelLoaded: false, // флаг: модель уже на сцене (для тика анимаций) modelLoaded: false, // флаг: модель уже на сцене (для тика анимаций)
// === Оружие === // === Оружие ===
weaponModelId: player.weaponModelId || '', weaponModelId: player.weaponModelId || '',
@ -771,7 +753,7 @@ export class MultiplayerSync {
* Модель цепляется как child корневого transform-node. * Модель цепляется как child корневого transform-node.
*/ */
async _loadRemoteModel(rp) { async _loadRemoteModel(rp) {
// Резолвим источник: R15-скин ('skin_*') или старая Kenney-модель. // Резолвим источник: Mixamo-скин ('skin_*') или старая Kenney-модель.
const source = await resolveRemoteModelSource(rp.modelType); const source = await resolveRemoteModelSource(rp.modelType);
if (!source || !source.file) { if (!source || !source.file) {
console.warn(`[MultiplayerSync] unknown modelType=${rp.modelType}`); console.warn(`[MultiplayerSync] unknown modelType=${rp.modelType}`);
@ -807,12 +789,9 @@ export class MultiplayerSync {
const modelRoot = new TransformNode(`remoteModel_${rp.sessionId}`, this.scene); const modelRoot = new TransformNode(`remoteModel_${rp.sessionId}`, this.scene);
modelRoot.parent = rp.root; modelRoot.parent = rp.root;
// Масштаб — точно как у локального игрока (PlayerController): // Масштаб — точно как у локального игрока (PlayerController):
// - R15-скины: 0.301 (модели нормализованы пайплауном auto_rig // - Mixamo-скины: 1.0 (GLB авторингованы в мировом масштабе ~1.8м).
// к 5.98 ед; 1.8/5.98≈0.301) × per-skin overrides.scale_mult.
// - Kenney-модели: 0.72. // - Kenney-модели: 0.72.
let modelScale = source.isR15 ? 0.301 : 0.72; const modelScale = source.isMixamo ? 1.0 : 0.72;
const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
modelScale *= scaleMult;
modelRoot.scaling = new Vector3(modelScale, modelScale, modelScale); modelRoot.scaling = new Vector3(modelScale, modelScale, modelScale);
const inst = container.instantiateModelsToScene( const inst = container.instantiateModelsToScene(
@ -843,12 +822,14 @@ export class MultiplayerSync {
rp.modelMeshes = meshes; rp.modelMeshes = meshes;
rp.modelRoot = modelRoot; rp.modelRoot = modelRoot;
// === R15-скин: детекция скелета и создание аниматора === // === Mixamo-скин: детекция скелета и создание аниматора ===
// R15-скины приходят со встроенным скелетом Mixamo (без glTF-анимаций). // Скины Рублокса = Mixamo-rig (single GLB). Анимации (idle/walk/run/
// Логика — копия PlayerController._loadPlayerModel. // jump/fall) грузятся отдельными GLB через loadMixamoAnimations
rp.isR15 = false; // (singleton-кэш — повторно по сети не качаются). Логика 1:1 с
rp.r15Animator = null; // PlayerController._loadPlayerModel (Mixamo-ветка).
if (source.isR15) { rp.isMixamo = false;
rp.mixamoAnimator = null;
if (source.isMixamo || source.kind === 'non-humanoid-rigged') {
let sk = (inst.skeletons && inst.skeletons[0]) || null; let sk = (inst.skeletons && inst.skeletons[0]) || null;
if (!sk && container.skeletons && container.skeletons.length > 0) { if (!sk && container.skeletons && container.skeletons.length > 0) {
sk = container.skeletons[0]; sk = container.skeletons[0];
@ -858,37 +839,27 @@ export class MultiplayerSync {
if (meshWithSkel) sk = meshWithSkel.skeleton; if (meshWithSkel) sk = meshWithSkel.skeleton;
} }
if (sk) { if (sk) {
const r15 = new R15Skeleton(sk); const animator = new MixamoAnimator();
if (r15.isValidR15()) { // Анимации грузятся асинхронно; mixamoAnimator появится в rp
rp.isR15 = true; // только после загрузки (render-loop проверяет его наличие).
rp.r15Skeleton = r15; loadMixamoAnimations(this.scene).then(() => {
rp.r15Animator = new R15Animator(r15, source.overrides || {}); // игрок мог уйти, пока грузились анимации
console.log(`[MultiplayerSync] ${rp.sessionId} R15-скин ` if (!this.remotePlayers.has(rp.sessionId)) {
+ `'${rp.modelType}' загружен — костей ` try { animator.detach(); } catch (e) {}
+ `${r15.resolvedNames().length}`); return;
// Подфаза 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 { animator.attach(this.scene, sk, modelRoot);
console.warn(`[MultiplayerSync] ${rp.sessionId} R15-скин ` animator.setState('idle');
+ `'${rp.modelType}' — скелет не прошёл валидацию`); rp.mixamoAnimator = animator;
} rp.isMixamo = true;
console.log(`[MultiplayerSync] ${rp.sessionId} Mixamo-скин `
+ `'${rp.modelType}' готов — костей ${sk.bones.length}`);
}).catch((e) => {
console.warn(`[MultiplayerSync] ${rp.sessionId} Mixamo-анимации `
+ `не загрузились:`, e);
});
} else { } else {
console.warn(`[MultiplayerSync] ${rp.sessionId} R15-скин ` console.warn(`[MultiplayerSync] ${rp.sessionId} Mixamo-скин `
+ `'${rp.modelType}' — нет скелета в glb`); + `'${rp.modelType}' — нет скелета в glb`);
} }
} }
@ -938,9 +909,11 @@ export class MultiplayerSync {
rp.weaponAnchor = weaponAnchor; rp.weaponAnchor = weaponAnchor;
} }
// Анимации glTF — ТОЛЬКО для Kenney-моделей. R15-скины анимируются // Анимации glTF — ТОЛЬКО для Kenney-моделей. Mixamo-скины анимируются
// процедурно через r15Animator (см. render-loop в start()). // через mixamoAnimator (см. render-loop в start()). Mixamo загружается
if (!rp.isR15) { // асинхронно, поэтому проверяем по source.isMixamo, а не по rp.isMixamo
// (тот выставится позже, после loadMixamoAnimations).
if (!source.isMixamo && source.kind !== 'non-humanoid-rigged') {
const allGroups = inst.animationGroups || []; const allGroups = inst.animationGroups || [];
for (const g of allGroups) { for (const g of allGroups) {
const n = (g.name || '').toLowerCase(); const n = (g.name || '').toLowerCase();
@ -1110,15 +1083,15 @@ export class MultiplayerSync {
try { a.stop(); a.dispose?.(); } catch (e) {} try { a.stop(); a.dispose?.(); } catch (e) {}
} }
} }
// R15-аниматор: dispose модели снесёт скелет; обнуляем ссылку чтобы // Mixamo-аниматор: dispose модели снесёт скелет; detach + обнуляем
// render-loop в следующем кадре не дёргал невалидный аниматор. // ссылку, чтобы render-loop в следующем кадре не дёргал невалидный.
rp.r15Animator = null; try { rp.mixamoAnimator?.detach(); } catch (e) {}
rp.isR15 = false; rp.mixamoAnimator = null;
rp.isMixamo = false;
rp.modelLoaded = false; rp.modelLoaded = false;
// Подфаза 3.10: чистим AccessoryManager если был // Подфаза 3.10: чистим AccessoryManager если был
try { rp.accessoryManager?.detachAll(); } catch (e) {} try { rp.accessoryManager?.detachAll(); } catch (e) {}
rp.accessoryManager = null; rp.accessoryManager = null;
rp.r15Skeleton = null;
try { rp.fallbackMesh?.dispose(); } catch (e) {} try { rp.fallbackMesh?.dispose(); } catch (e) {}
try { rp.label?.dispose(); } catch (e) {} try { rp.label?.dispose(); } catch (e) {}
try { rp.weaponRoot?.dispose(false, true); } catch (e) {} try { rp.weaponRoot?.dispose(false, true); } catch (e) {}