Compare commits
2 Commits
fix/multip
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c5d18806b | |||
| 9be2f363f3 |
@ -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)"
|
||||||
|
|||||||
@ -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) {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user