Compare commits
No commits in common. "main" and "restore/mixamo-skin-2026-06-14" have entirely different histories.
main
...
restore/mi
@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import Icon from '../editor-shared/Icon';
|
import Icon from '../editor-shared/Icon';
|
||||||
import { STORYS_addres, USER_addres } from '../api/API';
|
import { STORYS_addres, USER_addres } from '../api/API';
|
||||||
import { MIXAMO_SKINS } from '../engine/PlayerController';
|
|
||||||
|
|
||||||
const getToken = () => {
|
const getToken = () => {
|
||||||
try {
|
try {
|
||||||
@ -44,10 +43,6 @@ const HUD = {
|
|||||||
font: '"Inter", system-ui, -apple-system, sans-serif',
|
font: '"Inter", system-ui, -apple-system, sans-serif',
|
||||||
};
|
};
|
||||||
|
|
||||||
// В десктоп-приложении (Electron) пункт «Полноэкранный режим» не нужен —
|
|
||||||
// окно и так на весь экран. preload выставляет window.__RUBLOX_DESKTOP__.
|
|
||||||
const IS_DESKTOP_APP = typeof window !== 'undefined' && !!window.__RUBLOX_DESKTOP__;
|
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'people', icon: 'users', title: 'Участники' },
|
{ id: 'people', icon: 'users', title: 'Участники' },
|
||||||
{ id: 'settings', icon: 'settings', title: 'Настройки' },
|
{ id: 'settings', icon: 'settings', title: 'Настройки' },
|
||||||
@ -68,24 +63,14 @@ export default function GameMenu({
|
|||||||
}) {
|
}) {
|
||||||
const [activeTab, setActiveTab] = useState('people');
|
const [activeTab, setActiveTab] = useState('people');
|
||||||
|
|
||||||
// Закрытие меню:
|
// ESC закрывает меню. Регистрируем в capture-фазе чтобы не конфликтовать
|
||||||
// • НЕ fullscreen — Esc (классика)
|
// с pointer-lock логикой KubikonPlayer.
|
||||||
// • Fullscreen — Tab (Esc отдаётся браузеру для выхода из FS)
|
|
||||||
// Регистрируем в capture-фазе чтобы не конфликтовать с pointer-lock.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
const isFs = !!(typeof document !== 'undefined' && document.fullscreenElement);
|
if (e.key === 'Escape') {
|
||||||
if (e.key === 'Escape' && !isFs) {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ((e.key === 'Tab' || e.code === 'Tab') && isFs) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// L/R hotkeys как в Godot
|
// L/R hotkeys как в Godot
|
||||||
if (e.key === 'l' || e.key === 'L') onExit?.();
|
if (e.key === 'l' || e.key === 'L') onExit?.();
|
||||||
@ -233,21 +218,9 @@ function TabBar({ activeTab, onTab }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
// BottomBar — 3 кнопки: L Покинуть / R Возродиться / Esc(Tab) Продолжить
|
// BottomBar — 3 кнопки: L Покинуть / R Возродиться / Esc Продолжить
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
function BottomBar({ onExit, onRespawn, onResume }) {
|
function BottomBar({ onExit, onRespawn, onResume }) {
|
||||||
// В fullscreen Esc отдан браузеру (выход из FS) — меню закрывается
|
|
||||||
// на Tab. В обычном режиме — Esc.
|
|
||||||
const [resumeKey, setResumeKey] = useState(
|
|
||||||
typeof document !== 'undefined' && document.fullscreenElement ? 'Tab' : 'Esc'
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
const onFsChange = () => {
|
|
||||||
setResumeKey(document.fullscreenElement ? 'Tab' : 'Esc');
|
|
||||||
};
|
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
|
||||||
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
|
||||||
}, []);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -260,7 +233,7 @@ function BottomBar({ onExit, onRespawn, onResume }) {
|
|||||||
>
|
>
|
||||||
<ActionBtn hotkey="L" label="Покинуть" onClick={onExit} variant="ghost" />
|
<ActionBtn hotkey="L" label="Покинуть" onClick={onExit} variant="ghost" />
|
||||||
<ActionBtn hotkey="R" label="Возродиться" onClick={onRespawn} variant="ghost" />
|
<ActionBtn hotkey="R" label="Возродиться" onClick={onRespawn} variant="ghost" />
|
||||||
<ActionBtn hotkey={resumeKey} label="Продолжить" onClick={onResume} variant="primary" />
|
<ActionBtn hotkey="Esc" label="Продолжить" onClick={onResume} variant="primary" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -592,39 +565,19 @@ function PlayerCard({ player, isMe, isFriend, isPending, onAddFriend }) {
|
|||||||
const username = String(player.username || '?');
|
const username = String(player.username || '?');
|
||||||
const color = colorForUser(Number(player.user_id || 0), username);
|
const color = colorForUser(Number(player.user_id || 0), username);
|
||||||
|
|
||||||
// Аватар: 1) skin PNG (картинка персонажа) — главный
|
// Аватар: 1) skin PNG (картинка персонажа — bacon/imposter/etc) — главный
|
||||||
// 2) photo_thumb_b64 (аватар майнкрафтии, fallback)
|
// 2) photo_thumb_b64 (аватар майнкрафтии, fallback)
|
||||||
// 3) photo URL (старое поле — fallback)
|
// 3) photo URL (старое поле — fallback)
|
||||||
// 4) буква-инициал
|
// 4) буква-инициал
|
||||||
//
|
//
|
||||||
// 2026-06-14: Mixamo-скины (skin_y-bot, skin_x-bot и т.д.) лежат на
|
// Скины лежат в /kubikon-assets/characters/<slug>/avatar.png — это PNG
|
||||||
// rublox-site в /character-assets/skins/<slug>.png. Legacy R15-скины
|
// персонажа в полный рост. Совпадает с Godot/exe-плеером.
|
||||||
// (skin_bacon-hair, skin_sigma-labubu) — в /kubikon-assets/characters/<slug>/avatar.png.
|
|
||||||
// Mixamo-набор детектим по тому что в slug нет дефиса с known-legacy-словами.
|
|
||||||
// Известные LEGACY R15-скины (бекон, импостер, сигма-лабубу и пр.):
|
|
||||||
// их PNG лежит в /kubikon-assets/characters/<slug>/avatar.png.
|
|
||||||
// ВСЁ ОСТАЛЬНОЕ что начинается на 'skin_' — это Mixamo
|
|
||||||
// (с 2026-06-11 палитра заменена на 80 Mixamo-персонажей).
|
|
||||||
const LEGACY_SKINS = new Set([
|
|
||||||
'skin_bacon-hair', 'skin_sigma-labubu', 'skin_sparks-roblox',
|
|
||||||
'skin_imposter', 'skin_cop', 'skin_baby',
|
|
||||||
'skin_pizza', 'skin_burger', 'skin_taco',
|
|
||||||
]);
|
|
||||||
let avatarUrl = null;
|
let avatarUrl = null;
|
||||||
let isSkin = false;
|
let isSkin = false;
|
||||||
if (player.skin && typeof player.skin === 'string') {
|
if (player.skin && typeof player.skin === 'string') {
|
||||||
// ВАЛИДАЦИЯ: legacy R15-скины (bacon-hair и пр.) больше не существуют.
|
// cache-bust обязателен: на 2026-05-27 фиксили 404 на этом пути,
|
||||||
// Если скин НЕ в наборе валидных Mixamo (80 шт) — показываем аватар
|
// браузеры успели закэшировать негативный ответ
|
||||||
// дефолтного skin_y-bot (как и в самой игре 3D-модель валидируется).
|
avatarUrl = `/kubikon-assets/characters/${player.skin}/avatar.png?v=2026_05_27`;
|
||||||
let skinId = player.skin;
|
|
||||||
if (!MIXAMO_SKINS.has(skinId) && !skinId.startsWith('customskin:')) {
|
|
||||||
skinId = 'skin_y-bot';
|
|
||||||
}
|
|
||||||
const base = (typeof window !== 'undefined'
|
|
||||||
&& window.location.hostname === 'localhost')
|
|
||||||
? 'http://localhost:3000'
|
|
||||||
: 'https://rublox.pro';
|
|
||||||
avatarUrl = `${base}/character-assets/skins/${skinId}.png?v=20260614`;
|
|
||||||
isSkin = true;
|
isSkin = true;
|
||||||
} else if (player.photo_thumb_b64) {
|
} else if (player.photo_thumb_b64) {
|
||||||
avatarUrl = player.photo_thumb_b64.startsWith('data:')
|
avatarUrl = player.photo_thumb_b64.startsWith('data:')
|
||||||
@ -935,7 +888,6 @@ function TabSettings({ sceneRef }) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsSection title="Экран" />
|
<SettingsSection title="Экран" />
|
||||||
{!IS_DESKTOP_APP && (
|
|
||||||
<ArrowsRow
|
<ArrowsRow
|
||||||
label="Полноэкранный режим"
|
label="Полноэкранный режим"
|
||||||
hint="Развернуть игру на весь экран"
|
hint="Развернуть игру на весь экран"
|
||||||
@ -952,7 +904,6 @@ function TabSettings({ sceneRef }) {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<SliderRow
|
<SliderRow
|
||||||
label="Качество графики"
|
label="Качество графики"
|
||||||
hint="Разрешение рендера и тени"
|
hint="Разрешение рендера и тени"
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { jwtDecode } from 'jwt-decode';
|
|||||||
import { Client } from 'colyseus.js';
|
import { Client } from 'colyseus.js';
|
||||||
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
||||||
import { BabylonScene } from '../engine/BabylonScene';
|
import { BabylonScene } from '../engine/BabylonScene';
|
||||||
import { MIXAMO_SKINS } from '../engine/PlayerController';
|
|
||||||
import { attachConsoleHook, devlogReset } from '../engine/devlog';
|
import { attachConsoleHook, devlogReset } from '../engine/devlog';
|
||||||
import { MultiplayerSync } from '../engine/MultiplayerSync';
|
import { MultiplayerSync } from '../engine/MultiplayerSync';
|
||||||
import { REALTIME_WS } from '../api/API';
|
import { REALTIME_WS } from '../api/API';
|
||||||
@ -25,24 +24,6 @@ import useDeviceType from '../hooks/useDeviceType';
|
|||||||
import KubikonMobileControls from './KubikonMobileControls';
|
import KubikonMobileControls from './KubikonMobileControls';
|
||||||
import GameLoadingScreen from './GameLoadingScreen';
|
import GameLoadingScreen from './GameLoadingScreen';
|
||||||
|
|
||||||
// В десктоп-приложении (Electron-обёртка rublox-desktop) окно уже на весь
|
|
||||||
// экран без браузерной панели и без вкладок — fullscreen не нужен (раньше он
|
|
||||||
// защищал от случайного Ctrl+W/Ctrl+T в браузере; в Electron этого риска нет).
|
|
||||||
// preload выставляет window.__RUBLOX_DESKTOP__.
|
|
||||||
const IS_DESKTOP_APP = typeof window !== 'undefined' && !!window.__RUBLOX_DESKTOP__;
|
|
||||||
|
|
||||||
// В Android-приложении (Capacitor-обёртка rublox-android) WebView уже на весь
|
|
||||||
// экран — браузерный fullscreen не нужен, а стартовый оверлей «Нажми чтобы
|
|
||||||
// играть» избыточен (в браузере он нужен для user-gesture перед FS, в APK
|
|
||||||
// этого барьера не требуется). Capacitor выставляет window.Capacitor.
|
|
||||||
const IS_ANDROID_APP = typeof window !== 'undefined'
|
|
||||||
&& (!!window.Capacitor
|
|
||||||
|| /RubloxAndroid/i.test(navigator.userAgent || ''));
|
|
||||||
|
|
||||||
// Объединённый признак «нативного приложения» (десктоп ИЛИ Android) — там,
|
|
||||||
// где поведение совпадает (не дёргать fullscreen).
|
|
||||||
const IS_NATIVE_APP = IS_DESKTOP_APP || IS_ANDROID_APP;
|
|
||||||
|
|
||||||
// Плеер живёт на player.rublox.pro — он не знает SPA-роутов Майнкрафтии
|
// Плеер живёт на player.rublox.pro — он не знает SPA-роутов Майнкрафтии
|
||||||
// (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем
|
// (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем
|
||||||
// явный window.location.assign на внешний домен.
|
// явный window.location.assign на внешний домен.
|
||||||
@ -242,16 +223,9 @@ const KubikonPlayer = () => {
|
|||||||
const [loadingScreenCfg, setLoadingScreenCfg] = useState(null);
|
const [loadingScreenCfg, setLoadingScreenCfg] = useState(null);
|
||||||
const [loadProgress, setLoadProgress] = useState(0);
|
const [loadProgress, setLoadProgress] = useState(0);
|
||||||
// Раньше была стартовая заглушка «тапни чтобы начать» — убрали по
|
// Раньше была стартовая заглушка «тапни чтобы начать» — убрали по
|
||||||
// фидбэку, она бесила. Этот state остался для совместимости.
|
// фидбэку, она бесила. Теперь fullscreen опционально через кнопку
|
||||||
|
// в углу. Этот state остался для совместимости с handleMobileStart.
|
||||||
const [mobileStartTapped, setMobileStartTapped] = useState(true);
|
const [mobileStartTapped, setMobileStartTapped] = useState(true);
|
||||||
// 2026-06-14: вернулся стартовый клик-экран — теперь нужен чтобы
|
|
||||||
// ВКЛЮЧИТЬ fullscreen и заблокировать Ctrl+W/Ctrl+T и др. системные
|
|
||||||
// хоткеи. Без этого браузер закрывает вкладку при случайном Ctrl+W.
|
|
||||||
// requestFullscreen() требует user gesture — поэтому без клика никак.
|
|
||||||
// В нативном приложении (Electron/Capacitor) fullscreen не нужен (окно и
|
|
||||||
// так на весь экран), поэтому стартовый оверлей пропускаем — игра
|
|
||||||
// запускается сразу.
|
|
||||||
const [gameStarted, setGameStarted] = useState(IS_NATIVE_APP);
|
|
||||||
const [hp, setHp] = useState({ hp: 100, maxHp: 100 });
|
const [hp, setHp] = useState({ hp: 100, maxHp: 100 });
|
||||||
// Скрипт через game.hud.setVisible(false) полностью скрывает стандартный HUD.
|
// Скрипт через game.hud.setVisible(false) полностью скрывает стандартный HUD.
|
||||||
const [stdHudVisible, setStdHudVisible] = useState(true);
|
const [stdHudVisible, setStdHudVisible] = useState(true);
|
||||||
@ -337,54 +311,21 @@ const KubikonPlayer = () => {
|
|||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
// Перехват системных Ctrl-комбинаций которые в WASD-игре регулярно
|
// Перехват Ctrl-комбинаций которые ломают игру (Ctrl+W, Ctrl+R, Ctrl+T,
|
||||||
// нажимаются случайно и приводят к закрытию вкладки / открытию диалогов.
|
// Ctrl+N). Большинство браузеров блокирует отмену системных шорткатов,
|
||||||
// В fullscreen Chrome даёт большинству этих хоткеев preventDefault'иться.
|
// но мы пробуем preventDefault — иногда срабатывает.
|
||||||
//
|
//
|
||||||
// 2026-06-14: добавлены KeyD (закладка), KeyS (сохранить страницу),
|
// 2026-06-14: beforeunload-подтверждение убрано по решению UX — системное
|
||||||
// KeyA (выделить всё), KeyP (печать), KeyU (исходник), KeyJ/KeyH (история).
|
// окно браузера невозможно стилизовать (с 2017 Chrome игнорирует кастомный
|
||||||
// Все буквы которые могут зажиматься с Ctrl во время WASD-управления.
|
// текст), а уродливое модальное мешает. Случайное закрытие вкладки теперь
|
||||||
|
// просто закрывает игру без вопроса.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
// 1. Системные F-клавиши и навигация в любой момент:
|
if (!e.ctrlKey && !e.metaKey) return;
|
||||||
// F5 = reload, F11 = fullscreen toggle (нужен Esc-way),
|
const dangerousCodes = ['KeyW', 'KeyR', 'KeyT', 'KeyN'];
|
||||||
// Backspace = browser back (если фокус не на input),
|
if (dangerousCodes.includes(e.code)) {
|
||||||
// Tab — мешает фокусом UI.
|
e.preventDefault();
|
||||||
// F11 ОСТАВЛЯЕМ — даёт юзеру способ выйти из fullscreen.
|
e.stopPropagation();
|
||||||
if (e.code === 'F5' || e.code === 'F3' || e.code === 'F6'
|
|
||||||
|| e.code === 'F7') {
|
|
||||||
e.preventDefault(); e.stopPropagation(); return;
|
|
||||||
}
|
|
||||||
// 2. Ctrl/Cmd-комбинации. WASD-клавиши ОБРАБАТЫВАЕМ отдельно:
|
|
||||||
// блокируем системное действие браузера (preventDefault), но
|
|
||||||
// НЕ stopPropagation — иначе PlayerController не увидит ввод.
|
|
||||||
// Игрок часто приседает (Ctrl) и одновременно идёт (W/A/S/D).
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
const wasd = ['KeyW', 'KeyA', 'KeyS', 'KeyD'];
|
|
||||||
if (wasd.includes(e.code)) {
|
|
||||||
e.preventDefault(); // блокируем Ctrl+W (закрытие), Ctrl+D (закладка) и т.д.
|
|
||||||
return; // НЕ stopPropagation — пусть PlayerController увидит
|
|
||||||
}
|
|
||||||
const blocked = [
|
|
||||||
'KeyR', // reload
|
|
||||||
'KeyT', // new tab
|
|
||||||
'KeyN', // new window
|
|
||||||
'KeyP', // print
|
|
||||||
'KeyU', // view source
|
|
||||||
'KeyJ', // downloads
|
|
||||||
'KeyH', // history
|
|
||||||
'KeyF', // find on page
|
|
||||||
'KeyG', // find next
|
|
||||||
'KeyL', // focus address bar
|
|
||||||
'KeyO', // open file
|
|
||||||
'Tab', // switch tab
|
|
||||||
'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5',
|
|
||||||
'Digit6', 'Digit7', 'Digit8', 'Digit9',
|
|
||||||
];
|
|
||||||
if (blocked.includes(e.code)) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey, { capture: true });
|
window.addEventListener('keydown', onKey, { capture: true });
|
||||||
@ -652,55 +593,31 @@ const KubikonPlayer = () => {
|
|||||||
const isLocalDev = (typeof window !== 'undefined'
|
const isLocalDev = (typeof window !== 'undefined'
|
||||||
&& (window.location.hostname === 'localhost'
|
&& (window.location.hostname === 'localhost'
|
||||||
|| window.location.hostname === '127.0.0.1'));
|
|| window.location.hostname === '127.0.0.1'));
|
||||||
// Источник скина по приоритету:
|
if (isLocalDev) {
|
||||||
// 1) hash-параметр #skin=<id> в URL (передаёт сайт при play-ticket;
|
try {
|
||||||
// работает и на localhost и на проде)
|
// 1) hash-параметр #skin=<id> (от сайта при play-ticket)
|
||||||
// 2) БД через /equipped-skin (если есть userId)
|
const m = window.location.hash.match(/[#&]skin=([\w-]+)/);
|
||||||
// 3) localStorage самого плеера (fallback на localhost для отладки)
|
if (m && m[1]) {
|
||||||
// 4) skin_y-bot (дефолт)
|
mySkin = m[1];
|
||||||
try {
|
console.log('[KubikonPlayer] local-dev skin (URL):', mySkin);
|
||||||
console.log('[KubikonPlayer] hash=', window.location.hash,
|
} else {
|
||||||
'| LS rublox_selected_skin=', (typeof localStorage !== 'undefined' ? localStorage.getItem('rublox_selected_skin') : '?'));
|
// 2) localStorage самого плеера
|
||||||
const m = window.location.hash.match(/[#&]skin=([\w-]+)/);
|
const localPick = localStorage.getItem('rublox_selected_skin');
|
||||||
if (m && m[1]) {
|
if (localPick && typeof localPick === 'string') {
|
||||||
mySkin = m[1];
|
mySkin = localPick;
|
||||||
console.log('[KubikonPlayer] skin from URL:', mySkin);
|
console.log('[KubikonPlayer] local-dev skin (LS):', mySkin);
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
}
|
||||||
if (mySkin === 'skin_y-bot' && userId) {
|
} catch (e) {}
|
||||||
// 2) Лезем в БД (через прод-API). Бэк отдаёт либо
|
} else if (userId) {
|
||||||
// выбранный валидный скин, либо дефолт по полу.
|
|
||||||
try {
|
try {
|
||||||
const skinRes = await Kubikon3DApi.getEquippedSkin(userId);
|
const skinRes = await Kubikon3DApi.getEquippedSkin(userId);
|
||||||
const sf = skinRes?.data?.skin_folder;
|
const sf = skinRes?.data?.skin_folder;
|
||||||
if (sf && typeof sf === 'string') {
|
if (sf && typeof sf === 'string') mySkin = sf;
|
||||||
mySkin = sf;
|
|
||||||
console.log('[KubikonPlayer] skin from DB:', mySkin);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[KubikonPlayer] equipped-skin load failed', e);
|
console.warn('[KubikonPlayer] equipped-skin load failed', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mySkin === 'skin_y-bot' && isLocalDev) {
|
|
||||||
// 3) Локальный fallback на localStorage плеера.
|
|
||||||
try {
|
|
||||||
const localPick = localStorage.getItem('rublox_selected_skin');
|
|
||||||
if (localPick && typeof localPick === 'string') {
|
|
||||||
mySkin = localPick;
|
|
||||||
console.log('[KubikonPlayer] skin from local LS:', mySkin);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
// ВАЛИДАЦИЯ: если скин не из валидного набора Mixamo-скинов
|
|
||||||
// (legacy bacon-hair/sigma-labubu/cop и пр. — их больше нет,
|
|
||||||
// или бэкенд вернул дефолтный bacon) — fallback на skin_y-bot.
|
|
||||||
// Это защита: персонаж не должен пытаться загрузить несуществующий
|
|
||||||
// скин. См. БД rublox_equipped_skin (22+ юзеров с bacon-hair).
|
|
||||||
if (mySkin && !MIXAMO_SKINS.has(mySkin)
|
|
||||||
&& !mySkin.startsWith('customskin:')) {
|
|
||||||
console.log('[KubikonPlayer] skin', mySkin, 'не валиден → skin_y-bot');
|
|
||||||
mySkin = 'skin_y-bot';
|
|
||||||
}
|
|
||||||
skinFolderRef.current = mySkin;
|
skinFolderRef.current = mySkin;
|
||||||
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
|
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
|
||||||
|
|
||||||
@ -1145,29 +1062,12 @@ const KubikonPlayer = () => {
|
|||||||
|| root.webkitRequestFullscreen
|
|| root.webkitRequestFullscreen
|
||||||
|| root.mozRequestFullScreen
|
|| root.mozRequestFullScreen
|
||||||
|| root.msRequestFullscreen;
|
|| root.msRequestFullscreen;
|
||||||
// В нативном приложении (Electron/Capacitor) окно и так на весь
|
if (req) {
|
||||||
// экран — FS не нужен.
|
|
||||||
if (req && !IS_NATIVE_APP) {
|
|
||||||
try { await req.call(root); } catch (e) { /* отменено */ }
|
try { await req.call(root); } catch (e) { /* отменено */ }
|
||||||
}
|
}
|
||||||
setMobileStartTapped(true);
|
setMobileStartTapped(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Стартовый клик «Начать игру» — запрашивает fullscreen
|
|
||||||
* (Chrome блокирует Ctrl+W/Ctrl+T в fullscreen) и снимает оверлей.
|
|
||||||
* В нативном приложении (Electron/Capacitor) FS не нужен. */
|
|
||||||
const handleGameStart = useCallback(async () => {
|
|
||||||
const root = document.documentElement;
|
|
||||||
const req = root.requestFullscreen
|
|
||||||
|| root.webkitRequestFullscreen
|
|
||||||
|| root.mozRequestFullScreen
|
|
||||||
|| root.msRequestFullscreen;
|
|
||||||
if (req && !IS_NATIVE_APP) {
|
|
||||||
try { await req.call(root); } catch (e) { /* юзер запретил — играем без FS */ }
|
|
||||||
}
|
|
||||||
setGameStarted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// При выходе со страницы — снимаем fullscreen / orientation lock,
|
// При выходе со страницы — снимаем fullscreen / orientation lock,
|
||||||
// чтобы возврат в школу не остался залочен в landscape.
|
// чтобы возврат в школу не остался залочен в landscape.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1268,79 +1168,13 @@ const KubikonPlayer = () => {
|
|||||||
outline: 'none',
|
outline: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* GameLoadingScreen НЕ показывается при загрузке плейса.
|
{/* Задача 05: красивый экран загрузки игры (Ken Burns + название места + автор). */}
|
||||||
* Появляется ТОЛЬКО когда автор вызовет его из скрипта игры
|
{loading && (
|
||||||
* (через game.showLoadingScreen или аналог). По дефолту — игра
|
<GameLoadingScreen
|
||||||
* открывается сразу, как в Roblox. */}
|
meta={meta}
|
||||||
|
loadingScreen={loadingScreenCfg}
|
||||||
{/* 2026-06-14: стартовый оверлей. Один клик → fullscreen →
|
progress={loadProgress}
|
||||||
* Chrome блокирует Ctrl+W/Ctrl+T/Ctrl+R и др. Без него
|
/>
|
||||||
* юзер случайно закрывает вкладку, теряет прогресс. */}
|
|
||||||
{!loading && !gameStarted && (
|
|
||||||
<div
|
|
||||||
onClick={handleGameStart}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(7, 11, 26, 0.86)',
|
|
||||||
backdropFilter: 'blur(6px)',
|
|
||||||
zIndex: 2000,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: '#fff',
|
|
||||||
cursor: 'pointer',
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: 800,
|
|
||||||
marginBottom: 14,
|
|
||||||
letterSpacing: '0.5px',
|
|
||||||
}}>
|
|
||||||
Нажми чтобы играть
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: 16,
|
|
||||||
opacity: 0.75,
|
|
||||||
maxWidth: 480,
|
|
||||||
textAlign: 'center',
|
|
||||||
lineHeight: 1.4,
|
|
||||||
padding: '0 24px',
|
|
||||||
}}>
|
|
||||||
{IS_DESKTOP_APP ? (
|
|
||||||
<>Управление: <b>WASD</b> — движение, <b>пробел</b> — прыжок,
|
|
||||||
мышь — камера.</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Игра откроется в полноэкранном режиме —
|
|
||||||
это защитит от случайного закрытия вкладки
|
|
||||||
(Ctrl+W, Ctrl+T и др.).
|
|
||||||
<br />
|
|
||||||
Выход: <b>Esc</b> или <b>F11</b>.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={{
|
|
||||||
marginTop: 28,
|
|
||||||
padding: '14px 38px',
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 700,
|
|
||||||
background: 'linear-gradient(135deg,#4f7df0,#2563eb)',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 12,
|
|
||||||
color: '#fff',
|
|
||||||
cursor: 'pointer',
|
|
||||||
boxShadow: '0 6px 18px rgba(37,99,235,0.45)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
▶ Начать
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
|
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
|
||||||
@ -1581,14 +1415,11 @@ const KubikonPlayer = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Кнопка «полный экран» — маленькая, в правом верхнем углу.
|
{/* Кнопка «полный экран» — маленькая, в правом верхнем углу,
|
||||||
Показывается на ВСЕХ устройствах (desktop + touch):
|
только на тач-устройствах. Браузеры требуют user gesture
|
||||||
- touch — нужна чтобы скрыть UI браузера на телефоне
|
для requestFullscreen() — поэтому без кнопки никак.
|
||||||
- desktop — в fullscreen Chrome блокирует Ctrl+W/Ctrl+T
|
Кнопка автоматически скрывается после входа в fullscreen. */}
|
||||||
и прочие системные хоткеи, которые иначе закрывают вкладку.
|
{isTouch && !loading && !document.fullscreenElement && (
|
||||||
Браузеры требуют user gesture для requestFullscreen() —
|
|
||||||
поэтому без кнопки никак. */}
|
|
||||||
{!loading && !document.fullscreenElement && (
|
|
||||||
<button
|
<button
|
||||||
data-mobile-hud="fullscreen"
|
data-mobile-hud="fullscreen"
|
||||||
onClick={handleMobileStart}
|
onClick={handleMobileStart}
|
||||||
@ -1606,7 +1437,7 @@ const KubikonPlayer = () => {
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
WebkitTapHighlightColor: 'transparent',
|
WebkitTapHighlightColor: 'transparent',
|
||||||
}}
|
}}
|
||||||
title="Полный экран (блокирует Ctrl+W и др. системные хоткеи)"
|
title="Полноэкранный режим"
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
|
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
|
||||||
|
|||||||
@ -1787,8 +1787,8 @@ export class BabylonScene {
|
|||||||
const csm = new CascadedShadowGenerator(size, this._sunLight);
|
const csm = new CascadedShadowGenerator(size, this._sunLight);
|
||||||
csm.numCascades = numCascades;
|
csm.numCascades = numCascades;
|
||||||
csm.stabilizeCascades = true;
|
csm.stabilizeCascades = true;
|
||||||
csm.lambda = 0.8; // 0.8 даёт больше детали ближе, меньше дальше → границы менее заметны
|
csm.lambda = 0.6;
|
||||||
csm.cascadeBlendPercentage = 0.35; // 0.10 → 0.35: плавный переход между каскадами (убирает резкие полосы на полу)
|
csm.cascadeBlendPercentage = 0.1;
|
||||||
csm.shadowMaxZ = (q === 'high') ? 90 : 60;
|
csm.shadowMaxZ = (q === 'high') ? 90 : 60;
|
||||||
csm.bias = PCF_BIAS;
|
csm.bias = PCF_BIAS;
|
||||||
csm.normalBias = PCF_NORMAL_BIAS;
|
csm.normalBias = PCF_NORMAL_BIAS;
|
||||||
@ -1798,10 +1798,7 @@ export class BabylonScene {
|
|||||||
: ShadowGenerator.QUALITY_MEDIUM;
|
: ShadowGenerator.QUALITY_MEDIUM;
|
||||||
csm.darkness = 0.4;
|
csm.darkness = 0.4;
|
||||||
csm.autoCalcDepthBounds = false;
|
csm.autoCalcDepthBounds = false;
|
||||||
csm.frustumEdgeFalloff = 12; // убирает «полосу-хвост» тени игрока
|
csm.frustumEdgeFalloff = 12; // убирает «полосу-хвост» тени игрока
|
||||||
// depthClamp убирает обрезку shadow на границе depth — без этого
|
|
||||||
// иногда видны тонкие линии где shadow texel «выпадает» за depth-bound.
|
|
||||||
csm.depthClamp = true;
|
|
||||||
this._shadowGenerator = csm;
|
this._shadowGenerator = csm;
|
||||||
} else {
|
} else {
|
||||||
// Обычный ShadowGenerator. Soft теперь 2048 (было 1024).
|
// Обычный ShadowGenerator. Soft теперь 2048 (было 1024).
|
||||||
@ -5638,9 +5635,8 @@ export class BabylonScene {
|
|||||||
// поэтому скрипты стартуем в следующем кадре.
|
// поэтому скрипты стартуем в следующем кадре.
|
||||||
this.gameRuntime = new GameRuntime(this);
|
this.gameRuntime = new GameRuntime(this);
|
||||||
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
||||||
// НЕ показывать стартовый экран загрузки автоматически.
|
// Задача 05: стартовый экран загрузки (Ken-Burns + название места).
|
||||||
// По дефолту игра открывается мгновенно (как в Roblox). Экран загрузки
|
try { this.showStartupLoadingScreen(); } catch (e) {}
|
||||||
// только если автор явно вызовет showLoadingScreen() из скрипта.
|
|
||||||
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
|
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
|
||||||
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
|
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
|
||||||
// this.audioManager (AudioManager — ambient/music для всех проектов).
|
// this.audioManager (AudioManager — ambient/music для всех проектов).
|
||||||
|
|||||||
@ -1,593 +0,0 @@
|
|||||||
/**
|
|
||||||
* MixamoAnimator — проигрывает Mixamo-анимации на скелете персонажа.
|
|
||||||
*
|
|
||||||
* Mixamo-скины (skin_y-bot, skin_x-bot, и ещё 78) приходят БЕЗ
|
|
||||||
* AnimationGroups в их собственном GLB. Анимации лежат отдельными
|
|
||||||
* GLB-файлами в /character-assets/animations/:
|
|
||||||
*
|
|
||||||
* idle.glb, walk.glb, run.glb, jump.glb, fall.glb
|
|
||||||
* emote_capoeira.glb, emote_defeated.glb, emote_shoved.glb, emote_taunt.glb
|
|
||||||
*
|
|
||||||
* Каждый GLB содержит ровно одну AnimationGroup, нацеленную на bones
|
|
||||||
* с именами `mixamorig:Hips`, `mixamorig:Spine` и т.д.
|
|
||||||
*
|
|
||||||
* Что делает этот класс:
|
|
||||||
* 1. Загружает 5 базовых GLB параллельно и кэширует AnimationGroup'ы
|
|
||||||
* (singleton — один loader на сессию).
|
|
||||||
* 2. Для конкретного скина РЕТАРГЕТИТ AnimationGroup на его кости.
|
|
||||||
* Mixamo-скины разных вышедших времён имеют префикс `mixamorig:`,
|
|
||||||
* `mixamorig9:` или вообще без префикса — детектим автоматически.
|
|
||||||
* 3. Управление: `setState('idle'|'walk'|'run'|'jump'|'fall')` +
|
|
||||||
* плавный кросс-фейд (blending) между состояниями.
|
|
||||||
* 4. `playEmote(name, onDone)` — одноразово проиграть эмоцию поверх,
|
|
||||||
* после конца автоматически вернуться в текущее состояние.
|
|
||||||
*
|
|
||||||
* Bone-имена которые ретаргетим (24 обязательных):
|
|
||||||
* Hips, Spine, Spine1, Spine2, Neck, Head,
|
|
||||||
* LeftShoulder, LeftArm, LeftForeArm, LeftHand,
|
|
||||||
* RightShoulder, RightArm, RightForeArm, RightHand,
|
|
||||||
* LeftUpLeg, LeftLeg, LeftFoot, LeftToeBase,
|
|
||||||
* RightUpLeg, RightLeg, RightFoot, RightToeBase
|
|
||||||
*
|
|
||||||
* Использование:
|
|
||||||
* const anim = new MixamoAnimator();
|
|
||||||
* await anim.load(); // один раз на сессию
|
|
||||||
* anim.attach(scene, skeleton, modelRoot); // на каждую загрузку скина
|
|
||||||
* anim.setState('idle');
|
|
||||||
* // каждый кадр в _tick (необязательно — Babylon сам тикает groups):
|
|
||||||
* anim.update(dt);
|
|
||||||
* // эмоция:
|
|
||||||
* anim.playEmote('emote_taunt');
|
|
||||||
* // при смене скина:
|
|
||||||
* anim.detach();
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { SceneLoader, AnimationGroup, Animation } from "@babylonjs/core";
|
|
||||||
import "@babylonjs/loaders/glTF";
|
|
||||||
|
|
||||||
// Базовые состояния — соответствуют файлам *.glb в animations/.
|
|
||||||
// Базовые (всегда грузятся при старте — нужны для движения):
|
|
||||||
const BASE_STATES = ["idle", "walk", "run", "jump", "fall"];
|
|
||||||
|
|
||||||
// Дополнительные движения (грузятся лениво при первом setState):
|
|
||||||
const EXTRA_STATES = [
|
|
||||||
"jump_anticipate", "jump_air", "jump_land",
|
|
||||||
"jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land",
|
|
||||||
"jump_run_anticipate", "jump_run_air", "jump_run_land",
|
|
||||||
"walk_backward", "run_backward", "run_to_stop", "run_slide",
|
|
||||||
"jump_forward", "jump_backward", "jump_down",
|
|
||||||
"crouch_enter", "crouch_idle", "crouch_walk", "crouch_to_stand",
|
|
||||||
"climb_up", "climb_down", "climb_to_top", "sit_idle", "lie_idle", "sleeping",
|
|
||||||
"hit_react", "die_forward", "die_back",
|
|
||||||
"punch_left", "kick_low", "kick_high",
|
|
||||||
"gun_fire", "gun_reload", "rifle_walk",
|
|
||||||
"sword_idle", "sword_slash",
|
|
||||||
"push_button", "open_door", "throw_action",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Эмоции (вызываются через playEmote()):
|
|
||||||
const EMOTES = [
|
|
||||||
"emote_capoeira", "emote_defeated", "emote_shoved", "emote_taunt",
|
|
||||||
"emote_salute", "emote_pointing", "emote_no",
|
|
||||||
"dance_hiphop", "dance_rumba", "dance_breakdance",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Все известные анимации (для опциональной полной предзагрузки)
|
|
||||||
const ALL_ANIMATIONS = [...BASE_STATES, ...EXTRA_STATES, ...EMOTES];
|
|
||||||
|
|
||||||
// Кэш сырых данных анимаций между инстансами (singleton-ish):
|
|
||||||
// один раз загрузили — используем для всех аватаров.
|
|
||||||
let _cachedRawTargets = null; // { idle: [{boneName, animations:[Anim]}], walk: [...] , ... }
|
|
||||||
let _loadPromise = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Строит абсолютный URL для статики Mixamo-анимаций.
|
|
||||||
* Локально — localhost:3000 (rublox-site dev-server),
|
|
||||||
* на проде — rublox.pro/character-assets/.
|
|
||||||
*/
|
|
||||||
function _assetsBase() {
|
|
||||||
if (typeof window === "undefined") return "";
|
|
||||||
const isLocal = window.location.hostname === "localhost"
|
|
||||||
|| window.location.hostname === "127.0.0.1";
|
|
||||||
return isLocal ? "http://localhost:3000" : "https://rublox.pro";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Нормализует имя кости: убирает префикс `mixamorig:`, `mixamorig9:`,
|
|
||||||
* `mixamorig_` и т.п. Возвращает чистое имя типа `Hips`, `Spine`, `LeftArm`.
|
|
||||||
*/
|
|
||||||
function _normalizeBone(name) {
|
|
||||||
if (!name) return "";
|
|
||||||
// mixamorig:Hips, mixamorig9:Hips, mixamorig_Hips, Armature|mixamorig:Hips, etc
|
|
||||||
let n = name;
|
|
||||||
const colon = n.lastIndexOf(":");
|
|
||||||
if (colon >= 0) n = n.slice(colon + 1);
|
|
||||||
n = n.replace(/^mixamorig\d*[_:.]?/i, "");
|
|
||||||
n = n.replace(/^Armature\|/, "");
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Загружает один GLB-файл с анимациями. Возвращает массив
|
|
||||||
* { boneName, animations: [Babylon.Animation] } — сырые треки,
|
|
||||||
* привязанные к именам костей (без префикса).
|
|
||||||
*/
|
|
||||||
async function _loadAnimGlb(scene, url) {
|
|
||||||
// ImportAnimations не годится — он сразу target-ит конкретный
|
|
||||||
// скелет. Нам нужны сырые animations[], чтобы потом каждому
|
|
||||||
// скину пристёгивать отдельно.
|
|
||||||
const result = await SceneLoader.LoadAssetContainerAsync(
|
|
||||||
url.substring(0, url.lastIndexOf("/") + 1),
|
|
||||||
url.substring(url.lastIndexOf("/") + 1),
|
|
||||||
scene,
|
|
||||||
);
|
|
||||||
const out = [];
|
|
||||||
// В GLB от Mixamo каждая кость — это TransformNode (или Bone),
|
|
||||||
// содержит свои keyframe animations. После загрузки они на
|
|
||||||
// result.transformNodes / result.skeletons[].bones.
|
|
||||||
const allNodes = [
|
|
||||||
...(result.transformNodes || []),
|
|
||||||
...((result.skeletons || []).flatMap(sk => sk.bones || [])),
|
|
||||||
];
|
|
||||||
for (const node of allNodes) {
|
|
||||||
if (!node.animations || node.animations.length === 0) continue;
|
|
||||||
const cleanName = _normalizeBone(node.name);
|
|
||||||
if (!cleanName) continue;
|
|
||||||
out.push({ boneName: cleanName, animations: node.animations.slice() });
|
|
||||||
}
|
|
||||||
// Освободим геометрию (если случайно приехала — у анимаций мешей нет)
|
|
||||||
result.dispose();
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Загрузить базовые анимации (idle/walk/run/jump/fall) один раз.
|
|
||||||
* Дополнительные анимации (extra + эмоции) грузятся лениво в _ensureLoaded
|
|
||||||
* при первом обращении — это экономит трафик: юзер качает только то что
|
|
||||||
* реально использует в игре.
|
|
||||||
*/
|
|
||||||
export async function loadMixamoAnimations(scene) {
|
|
||||||
if (_loadPromise) return _loadPromise;
|
|
||||||
_cachedRawTargets = _cachedRawTargets || {};
|
|
||||||
_loadPromise = (async () => {
|
|
||||||
const base = _assetsBase();
|
|
||||||
const entries = await Promise.all(
|
|
||||||
BASE_STATES.map(async (name) => {
|
|
||||||
try {
|
|
||||||
const tracks = await _loadAnimGlb(
|
|
||||||
scene, `${base}/character-assets/animations/${name}.glb`);
|
|
||||||
return [name, tracks];
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[MixamoAnimator] не загрузилась '${name}':`, e?.message || e);
|
|
||||||
return [name, []];
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
for (const [k, v] of entries) _cachedRawTargets[k] = v;
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log("[MixamoAnimator] базовые анимации загружены:",
|
|
||||||
Object.entries(_cachedRawTargets).map(([k, v]) => `${k}=${v.length}tracks`).join(", "));
|
|
||||||
return _cachedRawTargets;
|
|
||||||
})();
|
|
||||||
return _loadPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ленивая подгрузка одной анимации по имени (если ещё не в кэше).
|
|
||||||
* Возвращает массив tracks или null если не удалось.
|
|
||||||
*/
|
|
||||||
async function _ensureLoaded(scene, name) {
|
|
||||||
if (!_cachedRawTargets) _cachedRawTargets = {};
|
|
||||||
if (_cachedRawTargets[name]) return _cachedRawTargets[name];
|
|
||||||
const base = _assetsBase();
|
|
||||||
try {
|
|
||||||
const tracks = await _loadAnimGlb(
|
|
||||||
scene, `${base}/character-assets/animations/${name}.glb`);
|
|
||||||
_cachedRawTargets[name] = tracks;
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`[MixamoAnimator] lazy-load '${name}': ${tracks.length} tracks`);
|
|
||||||
return tracks;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[MixamoAnimator] не удалось загрузить '${name}':`, e?.message || e);
|
|
||||||
_cachedRawTargets[name] = [];
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MixamoAnimator {
|
|
||||||
constructor() {
|
|
||||||
this.scene = null;
|
|
||||||
this.skeleton = null;
|
|
||||||
this.modelRoot = null;
|
|
||||||
/** Map<state, AnimationGroup> — кастомные группы для ЭТОГО скелета */
|
|
||||||
this._groups = new Map();
|
|
||||||
this._currentState = null;
|
|
||||||
this._currentGroup = null;
|
|
||||||
this._currentEmote = null;
|
|
||||||
this._emoteOnDone = null;
|
|
||||||
this._blendInProgress = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Пристёгивает аниматор к конкретному скелету (после загрузки модели).
|
|
||||||
* scene — Babylon Scene, skeleton — Babylon Skeleton, modelRoot — TransformNode.
|
|
||||||
*/
|
|
||||||
attach(scene, skeleton, modelRoot) {
|
|
||||||
this.scene = scene;
|
|
||||||
this.skeleton = skeleton;
|
|
||||||
this.modelRoot = modelRoot;
|
|
||||||
// Резолвим маппинг "clean name" → Bone (из текущего скелета).
|
|
||||||
this._cleanToBone = new Map();
|
|
||||||
for (const b of (skeleton.bones || [])) {
|
|
||||||
const clean = _normalizeBone(b.name);
|
|
||||||
if (clean && !this._cleanToBone.has(clean)) {
|
|
||||||
this._cleanToBone.set(clean, b);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Также детектим target-property: TransformNode? linkedTransformNode?
|
|
||||||
// Mixamo-анимации обычно нацелены на linkedTransformNode'ы (если есть),
|
|
||||||
// потому что в glTF skin'ы делают joints через nodes, не через Bones.
|
|
||||||
// Для каждой кости берём её _linkedTransformNode (Babylon API).
|
|
||||||
this._cleanToTarget = new Map();
|
|
||||||
for (const [name, bone] of this._cleanToBone) {
|
|
||||||
const tnode = bone.getTransformNode ? bone.getTransformNode() : null;
|
|
||||||
this._cleanToTarget.set(name, tnode || bone);
|
|
||||||
}
|
|
||||||
// Запомним bind-pose позиции (особенно Hips) — нужны для нормализации
|
|
||||||
// Hips.position в jump_air/jump_land и для сброса после анимаций.
|
|
||||||
this._restPositions = new Map();
|
|
||||||
for (const [name, target] of this._cleanToTarget) {
|
|
||||||
if (target && target.position) {
|
|
||||||
this._restPositions.set(name, {
|
|
||||||
x: target.position.x,
|
|
||||||
y: target.position.y,
|
|
||||||
z: target.position.z,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Создать (или достать из кэша) AnimationGroup для конкретного состояния. */
|
|
||||||
_ensureGroup(state) {
|
|
||||||
if (this._groups.has(state)) return this._groups.get(state);
|
|
||||||
if (!_cachedRawTargets || !_cachedRawTargets[state]) return null;
|
|
||||||
const raw = _cachedRawTargets[state];
|
|
||||||
const group = new AnimationGroup(`mixamo_${state}`, this.scene);
|
|
||||||
let attached = 0;
|
|
||||||
for (const t of raw) {
|
|
||||||
const target = this._cleanToTarget.get(t.boneName);
|
|
||||||
if (!target) continue;
|
|
||||||
for (const anim of t.animations) {
|
|
||||||
// Клонируем анимацию (одна Babylon.Animation не может
|
|
||||||
// быть в двух разных AnimationGroup одновременно).
|
|
||||||
const cloned = anim.clone();
|
|
||||||
// Mixamo всегда грузит Hips.position — это сдвигает
|
|
||||||
// персонажа по сцене. В in-place анимациях должно быть
|
|
||||||
// близко к нулю, но иногда сдвиг есть. Для базовых
|
|
||||||
// движений (walk/run/jump) фильтруем targetProperty=position
|
|
||||||
// у кости с именем Hips — её двигает наш PlayerController.
|
|
||||||
if (t.boneName === "Hips" && cloned.targetProperty === "position") {
|
|
||||||
// 3-фазная модель прыжка:
|
|
||||||
// jump_anticipate — присед перед прыжком. baseY = первый кадр
|
|
||||||
// (стоячая поза → опускается ниже).
|
|
||||||
// jump_air — физика поднимает _modelRoot, Hips.Y не используем.
|
|
||||||
// jump_land — приземление с амортизацией. baseY = МИНИМУМ
|
|
||||||
// (самая низкая точка приседа), так первый кадр будет Y > 0
|
|
||||||
// (только что приземлились, ноги пружинят к bind),
|
|
||||||
// середина = 0 (присед на полу), конец = выпрямление.
|
|
||||||
// Для всех остальных — фильтруем (физика двигает _modelRoot).
|
|
||||||
const PHASES = new Set([
|
|
||||||
'jump_anticipate', 'jump_land',
|
|
||||||
'jump_fwd_anticipate', 'jump_fwd_land',
|
|
||||||
'jump_run_anticipate', 'jump_run_land',
|
|
||||||
]);
|
|
||||||
if (!PHASES.has(state)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const rest = this._restPositions?.get('Hips');
|
|
||||||
try {
|
|
||||||
const keys = cloned.getKeys();
|
|
||||||
if (keys && keys.length > 0 && keys[0].value) {
|
|
||||||
// baseY = МАКСИМУМ Y по клипу. Тогда delta = k.Y - max
|
|
||||||
// всегда ≤ 0 → Hips только опускается ниже bind.
|
|
||||||
// jump_land: персонаж приземлился (ноги на полу = bind),
|
|
||||||
// потом корпус опускается = присед амортизации,
|
|
||||||
// потом возвращается обратно к bind (выпрямление).
|
|
||||||
// jump_anticipate: то же — корпус опускается из стоячей.
|
|
||||||
let maxY = -Infinity;
|
|
||||||
for (const k of keys) {
|
|
||||||
const y = k.value.y || 0;
|
|
||||||
if (y > maxY) maxY = y;
|
|
||||||
}
|
|
||||||
const baseY = Number.isFinite(maxY) ? maxY : (keys[0].value.y || 0);
|
|
||||||
const newKeys = keys.map(k => ({
|
|
||||||
frame: k.frame,
|
|
||||||
value: new (k.value.constructor)(
|
|
||||||
rest ? rest.x : 0,
|
|
||||||
(rest ? rest.y : 0) + ((k.value.y || 0) - baseY),
|
|
||||||
rest ? rest.z : 0,
|
|
||||||
),
|
|
||||||
inTangent: k.inTangent,
|
|
||||||
outTangent: k.outTangent,
|
|
||||||
interpolation: k.interpolation,
|
|
||||||
}));
|
|
||||||
cloned.setKeys(newKeys);
|
|
||||||
}
|
|
||||||
} catch (e) { continue; }
|
|
||||||
}
|
|
||||||
group.addTargetedAnimation(cloned, target);
|
|
||||||
attached++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (attached === 0) {
|
|
||||||
group.dispose();
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(`[MixamoAnimator] state='${state}' — 0 целей зарезолвлено, skip`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Зацикливаем базовые состояния, кроме jump (он one-shot).
|
|
||||||
// ВАЖНО: для AnimationGroup нужно ставить loopAnimation=true НА
|
|
||||||
// САМОМ GROUP до start(). Параметр loop в start() игнорируется в
|
|
||||||
// некоторых версиях Babylon 7.x.
|
|
||||||
// One-shot анимации (играются один раз, не зацикливаются):
|
|
||||||
// jump, crouch_enter, crouch_to_stand, crouch_exit + все эмоции и
|
|
||||||
// эпизодические действия (hit, die, throw, pickup, gun_fire, gun_reload и т.д.)
|
|
||||||
const ONE_SHOT = new Set([
|
|
||||||
"jump", "jump_forward", "jump_backward", "jump_down",
|
|
||||||
"jump_anticipate", "jump_land",
|
|
||||||
"jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land",
|
|
||||||
"jump_run_anticipate", "jump_run_air", "jump_run_land",
|
|
||||||
"crouch_enter", "crouch_to_stand",
|
|
||||||
"climb_to_top",
|
|
||||||
"hit_react", "die_forward", "die_back",
|
|
||||||
"throw_action", "pickup", "push_button", "open_door",
|
|
||||||
"gun_fire", "gun_reload", "sword_slash",
|
|
||||||
"kick_low", "kick_high", "punch_left",
|
|
||||||
]);
|
|
||||||
// emote_* — one-shot (один жест), dance_* — лупим (танцы должны крутиться)
|
|
||||||
const loopable = !ONE_SHOT.has(state) && !state.startsWith("emote_");
|
|
||||||
group.loopAnimation = loopable;
|
|
||||||
group.normalize();
|
|
||||||
// Safety-net: если Babylon всё равно по какой-то причине отыграл
|
|
||||||
// клип до конца И не зациклил (что бывает с короткими "still pose"
|
|
||||||
// клипами от Mixamo вроде Crouched Idle ~0.5s) — перезапускаем
|
|
||||||
// принудительно. Это даёт стабильно зацикленную анимацию.
|
|
||||||
if (loopable) {
|
|
||||||
group.onAnimationGroupEndObservable.add(() => {
|
|
||||||
if (this._currentGroup === group && !this._currentEmote) {
|
|
||||||
try {
|
|
||||||
group.reset();
|
|
||||||
group.start(true, 1.0, group.from, group.to, false);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`[MixamoAnimator] group '${state}': ${attached} tracks, loop=${loopable}, duration=${((group.to - group.from) / 60).toFixed(2)}s`);
|
|
||||||
this._groups.set(state, group);
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Установить состояние с плавным кросс-фейдом 150 мс.
|
|
||||||
* Если анимация ещё не подгружена — стартует lazy-load, при этом
|
|
||||||
* setState вернётся синхронно (без ожидания) — анимация подхватится
|
|
||||||
* на следующем тике после успешной загрузки.
|
|
||||||
*
|
|
||||||
* Anti-flicker: между переключениями требуется минимальная задержка
|
|
||||||
* 120мс (кроме переходов в воздух/идл из приземления). Это убирает
|
|
||||||
* «дрожание» crouch_walk ↔ crouch_idle когда игрок едет по диагонали
|
|
||||||
* и одно из направлений физически дёргается между кадрами. */
|
|
||||||
setState(state) {
|
|
||||||
if (this._currentEmote) return; // эмоция блокирует смену состояния
|
|
||||||
if (state === this._currentState) return;
|
|
||||||
// Сброс Hips.position в bind-pose при выходе из jump-фаз.
|
|
||||||
// Иначе последний keyframe анимации остаётся на Hips и idle/walk
|
|
||||||
// подхватывает смещённую позицию → персонаж проседает.
|
|
||||||
const JUMP_STATES = new Set([
|
|
||||||
'jump_air', 'jump_land', 'jump_in_place', 'jump_anticipate',
|
|
||||||
'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land',
|
|
||||||
'jump_run_anticipate', 'jump_run_air', 'jump_run_land',
|
|
||||||
]);
|
|
||||||
if (JUMP_STATES.has(this._currentState) && !JUMP_STATES.has(state)
|
|
||||||
&& this._restPositions) {
|
|
||||||
const rest = this._restPositions.get('Hips');
|
|
||||||
const hips = this._cleanToTarget?.get('Hips');
|
|
||||||
if (rest && hips && hips.position) {
|
|
||||||
try {
|
|
||||||
hips.position.x = rest.x;
|
|
||||||
hips.position.y = rest.y;
|
|
||||||
hips.position.z = rest.z;
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
||||||
// Anti-flicker debounce: не даём переключать состояние чаще чем раз в 120мс,
|
|
||||||
// КРОМЕ переходов с/на воздушные состояния (jump/fall) — там важна скорость
|
|
||||||
// и one-shot crouch_enter/crouch_to_stand (они короткие).
|
|
||||||
const JUMP_VITAL = new Set([
|
|
||||||
'jump', 'fall', 'jump_air', 'jump_land', 'jump_anticipate',
|
|
||||||
'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land',
|
|
||||||
'jump_run_anticipate', 'jump_run_air', 'jump_run_land',
|
|
||||||
]);
|
|
||||||
const isVitalSwitch = JUMP_VITAL.has(state)
|
|
||||||
|| JUMP_VITAL.has(this._currentState)
|
|
||||||
|| state === 'crouch_enter' || state === 'crouch_to_stand';
|
|
||||||
if (!isVitalSwitch && this._lastSwitchAt && (now - this._lastSwitchAt) < 120) {
|
|
||||||
// Запомним последний запрошенный state — если он не изменится за
|
|
||||||
// окно debounce, тогда применим, иначе отбросим вспышку.
|
|
||||||
this._pendingState = state;
|
|
||||||
if (!this._debounceTimer) {
|
|
||||||
const delay = Math.max(0, 120 - (now - this._lastSwitchAt));
|
|
||||||
this._debounceTimer = setTimeout(() => {
|
|
||||||
this._debounceTimer = null;
|
|
||||||
const s = this._pendingState;
|
|
||||||
this._pendingState = null;
|
|
||||||
if (s && s !== this._currentState) this.setState(s);
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._lastSwitchAt = now;
|
|
||||||
// Если ещё не загружено — стартуем lazy-load, но ТЕКУЩУЮ анимацию
|
|
||||||
// НЕ останавливаем (иначе в момент Ctrl-on/off персонаж зависает
|
|
||||||
// в bind-pose пока crouch_idle асинхронно качается).
|
|
||||||
if (!_cachedRawTargets || !_cachedRawTargets[state]) {
|
|
||||||
if (!this._pendingLoads) this._pendingLoads = new Set();
|
|
||||||
if (!this._pendingLoads.has(state)) {
|
|
||||||
this._pendingLoads.add(state);
|
|
||||||
_ensureLoaded(this.scene, state).then(() => {
|
|
||||||
this._pendingLoads.delete(state);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return; // подхватится при следующем setState когда tracks будут
|
|
||||||
}
|
|
||||||
const next = this._ensureGroup(state);
|
|
||||||
if (!next) return;
|
|
||||||
const prev = this._currentGroup;
|
|
||||||
// Loop-флаг берём напрямую с group — _ensureGroup уже разрулил
|
|
||||||
// (one-shot list + emote_* → не лупим).
|
|
||||||
const loop = next.loopAnimation;
|
|
||||||
// Лог переключений (только если изменилось — иначе спам)
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`[MixamoAnimator] setState: ${this._currentState || 'none'} → ${state} (loop=${loop})`);
|
|
||||||
|
|
||||||
// Per-state speedRatio: подгоняем длительность под физику.
|
|
||||||
// jump_fwd_air: Mixamo Jump полёт = 0.43с, физика = 0.73с
|
|
||||||
// → speedRatio = 0.59 (замедлить чтобы клип не зациклился).
|
|
||||||
// jump_fwd_air: Mixamo Jump полёт 0.43с, физика 0.73с → 0.59
|
|
||||||
// jump_run_air: Mixamo Running Jump полёт 0.52с, физика 0.73с → 0.71
|
|
||||||
const SPEED_RATIO = {
|
|
||||||
jump_fwd_air: 0.59,
|
|
||||||
jump_run_air: 0.71,
|
|
||||||
};
|
|
||||||
const speedRatio = SPEED_RATIO[state] || 1.0;
|
|
||||||
// Запустить новую анимацию. Babylon 7 ВНИМАНИЕ: параметр loop
|
|
||||||
// в start() иногда игнорится — дублируем через loopAnimation
|
|
||||||
// (выставлен в _ensureGroup).
|
|
||||||
try {
|
|
||||||
next.reset();
|
|
||||||
next.start(loop, speedRatio, next.from, next.to, false);
|
|
||||||
} catch (e) {
|
|
||||||
try { next.play(loop); } catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кросс-фейд через weight (0→1 у новой, 1→0 у старой) за BLEND_MS.
|
|
||||||
// Climb-состояния переключаем МГНОВЕННО (0мс) — при blend'е персонаж
|
|
||||||
// на доли секунды виден в промежуточном развороте (старая поза + новый
|
|
||||||
// _modelYaw), что выглядит как «дёрг разворота» при входе/выходе с лестницы.
|
|
||||||
const CLIMB_STATES = new Set(['climb_up', 'climb_down', 'climb_to_top']);
|
|
||||||
const BLEND_MS = (CLIMB_STATES.has(state) || CLIMB_STATES.has(this._currentState))
|
|
||||||
? 0 : 150;
|
|
||||||
try { next.setWeightForAllAnimatables(0); } catch (_) {}
|
|
||||||
// Снимаем ВСЕ предыдущие blend-observers — rapid-switching
|
|
||||||
// (Ctrl on/off с интервалом 50ms) оставлял несколько ticker'ов.
|
|
||||||
if (this._blendObservers && this._blendObservers.length) {
|
|
||||||
for (const o of this._blendObservers) {
|
|
||||||
try { this.scene.onBeforeRenderObservable.remove(o); } catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._blendObservers = [];
|
|
||||||
// КРИТИЧНО: при ЛЮБОМ setState останавливаем ВСЕ остальные группы
|
|
||||||
// кроме новой. Это убирает кейсы когда rapid-switching между
|
|
||||||
// prev/next/третий оставляет висящую группу из позапрошлого setState
|
|
||||||
// (и она «крутится» дальше в фоне с весом 1).
|
|
||||||
for (const g of this._groups.values()) {
|
|
||||||
if (g !== next) {
|
|
||||||
// Не стопим текущую blend-исходную — она нужна для фейда.
|
|
||||||
if (g !== prev) {
|
|
||||||
try { g.stop(); g.setWeightForAllAnimatables(0); } catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (prev && prev !== next) {
|
|
||||||
const startedAt = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
||||||
const prevGroup = prev;
|
|
||||||
const nextGroup = next;
|
|
||||||
const obs = this.scene.onBeforeRenderObservable.add(() => {
|
|
||||||
const nowMs = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
||||||
const t = Math.min(1, (nowMs - startedAt) / BLEND_MS);
|
|
||||||
// Если за это время _currentGroup сменилась ещё раз —
|
|
||||||
// прекращаем blend (новый setState уже разрулил).
|
|
||||||
if (this._currentGroup !== nextGroup) {
|
|
||||||
try { this.scene.onBeforeRenderObservable.remove(obs); } catch (_) {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
prevGroup.setWeightForAllAnimatables(1 - t);
|
|
||||||
nextGroup.setWeightForAllAnimatables(t);
|
|
||||||
} catch (_) {}
|
|
||||||
if (t >= 1) {
|
|
||||||
try { prevGroup.stop(); prevGroup.setWeightForAllAnimatables(0); } catch (_) {}
|
|
||||||
try { nextGroup.setWeightForAllAnimatables(1); } catch (_) {}
|
|
||||||
this.scene.onBeforeRenderObservable.remove(obs);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this._blendObservers.push(obs);
|
|
||||||
} else {
|
|
||||||
try { next.setWeightForAllAnimatables(1); } catch (_) {}
|
|
||||||
}
|
|
||||||
this._currentState = state;
|
|
||||||
this._currentGroup = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Проиграть эмоцию (one-shot), потом вернуться в idle.
|
|
||||||
* Если эмоция ещё не подгружена — подгружает на лету и стартует. */
|
|
||||||
async playEmote(name, onDone) {
|
|
||||||
const tracks = await _ensureLoaded(this.scene, name);
|
|
||||||
if (!tracks || tracks.length === 0) {
|
|
||||||
console.warn(`[MixamoAnimator] эмоция '${name}' не загружена`);
|
|
||||||
if (onDone) onDone();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const group = this._ensureGroup(name);
|
|
||||||
if (!group) { if (onDone) onDone(); return; }
|
|
||||||
// Стоп текущего состояния
|
|
||||||
if (this._currentGroup) {
|
|
||||||
try { this._currentGroup.stop(); } catch (_) {}
|
|
||||||
}
|
|
||||||
this._currentEmote = name;
|
|
||||||
this._emoteOnDone = onDone || null;
|
|
||||||
const savedState = this._currentState;
|
|
||||||
try {
|
|
||||||
group.start(false, 1.0, group.from, group.to, false);
|
|
||||||
} catch (e) {
|
|
||||||
try { group.play(false); } catch (_) {}
|
|
||||||
}
|
|
||||||
const onEnd = () => {
|
|
||||||
this._currentEmote = null;
|
|
||||||
this._currentState = null; // принудим setState заново запустить
|
|
||||||
this.setState(savedState || "idle");
|
|
||||||
if (this._emoteOnDone) {
|
|
||||||
const cb = this._emoteOnDone;
|
|
||||||
this._emoteOnDone = null;
|
|
||||||
try { cb(); } catch (_) {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
group.onAnimationGroupEndObservable.addOnce(onEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Тихая предзагрузка анимации в кэш (БЕЗ проигрывания). Нужно чтобы
|
|
||||||
* при первом setState анимация уже была готова (нет дёрга от walk). */
|
|
||||||
preload(name) {
|
|
||||||
try { _ensureLoaded(this.scene, name); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Babylon сам тикает AnimationGroup, но оставим для интерфейса. */
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
update(dt) { /* noop */ }
|
|
||||||
|
|
||||||
/** Остановить и освободить все группы для этого скелета. */
|
|
||||||
detach() {
|
|
||||||
if (this._currentGroup) { try { this._currentGroup.stop(); } catch (_) {} }
|
|
||||||
for (const g of this._groups.values()) {
|
|
||||||
try { g.dispose(); } catch (_) {}
|
|
||||||
}
|
|
||||||
this._groups.clear();
|
|
||||||
this._currentGroup = null;
|
|
||||||
this._currentState = null;
|
|
||||||
this._currentEmote = null;
|
|
||||||
this.scene = null;
|
|
||||||
this.skeleton = null;
|
|
||||||
this.modelRoot = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1192,24 +1192,4 @@ export class PhysicsAABB {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Найти лестницу (ladder_vertical), которой касается AABB игрока.
|
|
||||||
* Лестницы проходимы (canCollide=false) → НЕ попадают в spatial-grid,
|
|
||||||
* поэтому итерируем напрямую по инстансам (их на сцене единицы).
|
|
||||||
* Возвращает data ближайшей пересекающейся лестницы или null.
|
|
||||||
*/
|
|
||||||
getOverlappingLadder(cx, cy, cz, hw, hh, hd) {
|
|
||||||
if (!this.primitiveManager) return null;
|
|
||||||
let best = null, bestDist = Infinity;
|
|
||||||
for (const data of this.primitiveManager.instances.values()) {
|
|
||||||
if (data.type !== 'ladder_vertical') continue;
|
|
||||||
if (data.visible === false) continue;
|
|
||||||
if (!this._aabbIntersectsPrimitive(cx, cy, cz, hw, hh, hd, data)) continue;
|
|
||||||
const dx = data.x - cx, dz = data.z - cz;
|
|
||||||
const d = dx * dx + dz * dz;
|
|
||||||
if (d < bestDist) { bestDist = d; best = data; }
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,6 @@ import {
|
|||||||
import { getModelType } from './ModelTypes';
|
import { getModelType } from './ModelTypes';
|
||||||
import { R15Skeleton } from './R15Skeleton';
|
import { R15Skeleton } from './R15Skeleton';
|
||||||
import { R15Animator } from './R15Animator';
|
import { R15Animator } from './R15Animator';
|
||||||
import { MixamoAnimator, loadMixamoAnimations } from './MixamoAnimator';
|
|
||||||
// Подфаза 3.2/3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md
|
// Подфаза 3.2/3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md
|
||||||
import { AccessoryManager } from './AccessoryManager';
|
import { AccessoryManager } from './AccessoryManager';
|
||||||
|
|
||||||
@ -38,7 +37,7 @@ import { AccessoryManager } from './AccessoryManager';
|
|||||||
* 2026-06-11: эти 80 ID перенесены сюда из data/skinsCatalog.js фронта
|
* 2026-06-11: эти 80 ID перенесены сюда из data/skinsCatalog.js фронта
|
||||||
* чтобы плеер их распознавал и грузил по правильному пути.
|
* чтобы плеер их распознавал и грузил по правильному пути.
|
||||||
* Дефолтные: skin_x-bot (male), skin_y-bot (female/null). */
|
* Дефолтные: skin_x-bot (male), skin_y-bot (female/null). */
|
||||||
export const MIXAMO_SKINS = new Set([
|
const MIXAMO_SKINS = new Set([
|
||||||
'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas',
|
'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas',
|
||||||
'skin_castle-guard-1', 'skin_castle-guard-2',
|
'skin_castle-guard-1', 'skin_castle-guard-2',
|
||||||
'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08',
|
'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08',
|
||||||
@ -107,11 +106,6 @@ export class PlayerController {
|
|||||||
this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз)
|
this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз)
|
||||||
this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump)
|
this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump)
|
||||||
this._robotBoostLeft = 0; // оставшееся время boost-фазы (с)
|
this._robotBoostLeft = 0; // оставшееся время boost-фазы (с)
|
||||||
// Лестница (ladder_vertical): касание + W/S → ladder-mode (гравитация
|
|
||||||
// отключена, W/S = вверх/вниз, Space = отпрыг).
|
|
||||||
this._ladderMode = false;
|
|
||||||
this._ladderData = null;
|
|
||||||
this.CLIMB_SPEED = 2.5; // скорость лазания вверх/вниз (м/с)
|
|
||||||
// Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с.
|
// Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с.
|
||||||
// Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся.
|
// Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся.
|
||||||
this._autoRunSpeed = 0;
|
this._autoRunSpeed = 0;
|
||||||
@ -220,7 +214,6 @@ export class PlayerController {
|
|||||||
this._isR15 = false; // флаг: загружен валидный R15-скелет
|
this._isR15 = false; // флаг: загружен валидный R15-скелет
|
||||||
this._r15Skeleton = null; // R15Skeleton — резолвер костей
|
this._r15Skeleton = null; // R15Skeleton — резолвер костей
|
||||||
this._r15Animator = null; // R15Animator — процедурные анимации
|
this._r15Animator = null; // R15Animator — процедурные анимации
|
||||||
this._mixamoAnimator = null; // MixamoAnimator — Mixamo-скины
|
|
||||||
this._skinManifest = null; // кеш skins_manifest.json
|
this._skinManifest = null; // кеш skins_manifest.json
|
||||||
this._skinOverrides = {}; // overrides текущего скина
|
this._skinOverrides = {}; // overrides текущего скина
|
||||||
|
|
||||||
@ -371,8 +364,6 @@ export class PlayerController {
|
|||||||
this._r15Skeleton = null;
|
this._r15Skeleton = null;
|
||||||
this._r15Animator = null;
|
this._r15Animator = null;
|
||||||
this._isR15 = false;
|
this._isR15 = false;
|
||||||
try { if (this._mixamoAnimator) this._mixamoAnimator.detach(); } catch (e) {}
|
|
||||||
this._mixamoAnimator = null;
|
|
||||||
this._modelKind = 'r15';
|
this._modelKind = 'r15';
|
||||||
this._modelHipHeight = null;
|
this._modelHipHeight = null;
|
||||||
this._nonHumanoidBox = null;
|
this._nonHumanoidBox = null;
|
||||||
@ -832,7 +823,7 @@ export class PlayerController {
|
|||||||
? 'http://localhost:3000'
|
? 'http://localhost:3000'
|
||||||
: 'https://rublox.pro';
|
: 'https://rublox.pro';
|
||||||
return {
|
return {
|
||||||
file: `${base}/character-assets/skins/${typeId}.glb?v=20260614`,
|
file: `${base}/character-assets/skins/${typeId}.glb`,
|
||||||
isR15: false,
|
isR15: false,
|
||||||
kind: 'non-humanoid-rigged', // Mixamo-rig, не R15
|
kind: 'non-humanoid-rigged', // Mixamo-rig, не R15
|
||||||
overrides: {},
|
overrides: {},
|
||||||
@ -1212,59 +1203,8 @@ export class PlayerController {
|
|||||||
// R15-скины не содержат AnimationGroups (анимируются процедурно
|
// R15-скины не содержат AnimationGroups (анимируются процедурно
|
||||||
// через R15Animator в _tick). Kenney-модели — наоборот, имеют
|
// через R15Animator в _tick). Kenney-модели — наоборот, имеют
|
||||||
// встроенные AnimationGroups (idle/walk/sprint/jump).
|
// встроенные AnimationGroups (idle/walk/sprint/jump).
|
||||||
// Mixamo-скины (kind=non-humanoid-rigged) — анимируются через
|
|
||||||
// MixamoAnimator: 5 базовых анимаций грузятся отдельными GLB
|
|
||||||
// из /character-assets/animations/ и ретаргетятся на скелет.
|
|
||||||
this._animations = {};
|
this._animations = {};
|
||||||
this._mixamoAnimator = null;
|
if (!this._isR15) {
|
||||||
if (source.isMixamo || source.kind === 'non-humanoid-rigged') {
|
|
||||||
// Найдём скелет Mixamo-модели (отдельно от R15-ветки —
|
|
||||||
// та валидацию не прошла, скелет другого формата).
|
|
||||||
let mixSk = (inst.skeletons && inst.skeletons[0]) || null;
|
|
||||||
if (!mixSk && container.skeletons && container.skeletons.length > 0) {
|
|
||||||
mixSk = container.skeletons[0];
|
|
||||||
}
|
|
||||||
if (!mixSk) {
|
|
||||||
const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton);
|
|
||||||
if (meshWithSkel) mixSk = meshWithSkel.skeleton;
|
|
||||||
}
|
|
||||||
if (mixSk) {
|
|
||||||
try {
|
|
||||||
// Грузим базовые анимации (singleton-кэш — после первого
|
|
||||||
// скина следующие переключаются мгновенно).
|
|
||||||
const animator = new MixamoAnimator();
|
|
||||||
loadMixamoAnimations(this.scene)
|
|
||||||
.then(() => {
|
|
||||||
animator.attach(this.scene, mixSk, root);
|
|
||||||
animator.setState('idle');
|
|
||||||
this._mixamoAnimator = animator;
|
|
||||||
// Предзагрузим climb-анимации заранее (тихо),
|
|
||||||
// чтобы при первом касании лестницы не было кадра
|
|
||||||
// walk с climb-поворотом (дёрг на 180°).
|
|
||||||
try {
|
|
||||||
animator.preload('climb_up');
|
|
||||||
animator.preload('climb_down');
|
|
||||||
animator.preload('climb_to_top');
|
|
||||||
} catch (e) {}
|
|
||||||
// Глобально для отладки/скриптов:
|
|
||||||
// window.__mixamo.playEmote('dance_hiphop')
|
|
||||||
try { window.__mixamo = animator; } catch (e) {}
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('[PlayerController] MixamoAnimator готов, скелет=' + mixSk.bones.length + ' bones');
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn('[PlayerController] MixamoAnimator не загрузился:', e);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn('[PlayerController] MixamoAnimator init fail:', e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn('[PlayerController] Mixamo-скин', this._modelTypeId, '— скелет не найден');
|
|
||||||
}
|
|
||||||
} else if (!this._isR15) {
|
|
||||||
const groups = inst.animationGroups || [];
|
const groups = inst.animationGroups || [];
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
const name = (g.name || '').toLowerCase();
|
const name = (g.name || '').toLowerCase();
|
||||||
@ -1721,9 +1661,6 @@ export class PlayerController {
|
|||||||
this._r15Animator = null;
|
this._r15Animator = null;
|
||||||
this._r15Skeleton = null;
|
this._r15Skeleton = null;
|
||||||
this._isR15 = false;
|
this._isR15 = false;
|
||||||
// Сброс MixamoAnimator
|
|
||||||
try { if (this._mixamoAnimator) this._mixamoAnimator.detach(); } catch (e) {}
|
|
||||||
this._mixamoAnimator = null;
|
|
||||||
|
|
||||||
// Удаляем модель
|
// Удаляем модель
|
||||||
if (this._modelRoot) {
|
if (this._modelRoot) {
|
||||||
@ -2634,26 +2571,10 @@ export class PlayerController {
|
|||||||
const onKeyDown = (e) => {
|
const onKeyDown = (e) => {
|
||||||
if (!this._active) return;
|
if (!this._active) return;
|
||||||
if (isTypingTarget(e.target)) return;
|
if (isTypingTarget(e.target)) return;
|
||||||
// Меню в игре (Roblox-style — Участники / Настройки / etc).
|
// Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest
|
||||||
//
|
// (там стоит проверка modalManager.isOpen). Без явного перехвата Esc
|
||||||
// ПРАВИЛО (2026-06-14):
|
// в third (без pointer-lock) сразу выходил из Play.
|
||||||
// • НЕ в fullscreen — Esc открывает меню (классика)
|
if (e.code === 'Escape') {
|
||||||
// • В fullscreen — Esc отдаётся БРАУЗЕРУ (выход из FS, hardcoded),
|
|
||||||
// а меню открывается на Tab (как в CS/BF)
|
|
||||||
//
|
|
||||||
// Это компромисс: в fullscreen нельзя перехватить Esc — браузер
|
|
||||||
// принудительно выкидывает в обычный режим. Поэтому добавили
|
|
||||||
// вторую клавишу. Tab безопасен (не блокирует UI-фокус
|
|
||||||
// потому что мы делаем preventDefault).
|
|
||||||
const isFs = !!(typeof document !== 'undefined' && document.fullscreenElement);
|
|
||||||
if (e.code === 'Escape' && !isFs) {
|
|
||||||
if (this._onExitRequest) {
|
|
||||||
this._onExitRequest();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e.code === 'Tab' && isFs) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (this._onExitRequest) {
|
if (this._onExitRequest) {
|
||||||
this._onExitRequest();
|
this._onExitRequest();
|
||||||
return;
|
return;
|
||||||
@ -2769,30 +2690,18 @@ export class PlayerController {
|
|||||||
const dH = this.HALF_H_CROUCH - this.HALF_H;
|
const dH = this.HALF_H_CROUCH - this.HALF_H;
|
||||||
this.HALF_H = this.HALF_H_CROUCH;
|
this.HALF_H = this.HALF_H_CROUCH;
|
||||||
if (this._pos) this._pos.y += dH;
|
if (this._pos) this._pos.y += dH;
|
||||||
// Помечаем: при следующем _tick mixamo-ветка проиграет
|
|
||||||
// one-shot crouch_enter (движение присеста) ПЕРЕД зацикленным
|
|
||||||
// crouch_idle. Без этого визуально нет "присеста" — персонаж
|
|
||||||
// мгновенно оказывается в позе.
|
|
||||||
this._crouchEnterPending = true;
|
|
||||||
this._crouchTransitionUntil = Date.now() + 600; // длительность анимации Standing→Crouch ~0.6s
|
|
||||||
} else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) {
|
} else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) {
|
||||||
this._crouching = false;
|
this._crouching = false;
|
||||||
const dH = this.HALF_H_NORMAL - this.HALF_H;
|
const dH = this.HALF_H_NORMAL - this.HALF_H;
|
||||||
this.HALF_H = this.HALF_H_NORMAL;
|
this.HALF_H = this.HALF_H_NORMAL;
|
||||||
if (this._pos) this._pos.y += dH;
|
if (this._pos) this._pos.y += dH;
|
||||||
// Анимация выхода — crouch_to_stand
|
|
||||||
this._crouchExitPending = true;
|
|
||||||
this._crouchTransitionUntil = Date.now() + 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Горизонтальное движение ===
|
// === Горизонтальное движение ===
|
||||||
const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw));
|
const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw));
|
||||||
const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw));
|
const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw));
|
||||||
// Crouch имеет ПРИОРИТЕТ над sprint: если Ctrl зажат — Shift игнорится.
|
const isSprinting = this._shift;
|
||||||
// Скорость в crouch = 0.45 от walk (медленный шаг на корточках).
|
const speedMult = isSprinting ? this.SPRINT_MULT : 1;
|
||||||
const isSprinting = this._shift && !this._crouching;
|
|
||||||
const crouchMult = this._crouching ? 0.45 : 1;
|
|
||||||
const speedMult = (isSprinting ? this.SPRINT_MULT : 1) * crouchMult;
|
|
||||||
const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt;
|
const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt;
|
||||||
|
|
||||||
let moveX = 0, moveZ = 0;
|
let moveX = 0, moveZ = 0;
|
||||||
@ -2876,154 +2785,8 @@ export class PlayerController {
|
|||||||
moveZ *= 0.5;
|
moveZ *= 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Лестница (ladder_vertical) ===
|
|
||||||
// Детект касания лестницы. В воде/машине/GD-режиме лестница отключена.
|
|
||||||
let ladder = null;
|
|
||||||
if (!inWater && !inGdMode && this.physics?.getOverlappingLadder) {
|
|
||||||
ladder = this.physics.getOverlappingLadder(
|
|
||||||
this._pos.x, this._pos.y, this._pos.z,
|
|
||||||
this.HALF_W, this.HALF_H, this.HALF_D
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Предзагрузка climb-анимаций при касании лестницы (ДО лазания),
|
|
||||||
// чтобы при входе в ladder-mode climb_up уже был в кэше. Без этого
|
|
||||||
// первый кадр играет walk с climb-поворотом → персонаж «дёргается»
|
|
||||||
// на 180° пока climb_up асинхронно подгружается.
|
|
||||||
if (ladder && this._mixamoAnimator && !this._climbPreloaded) {
|
|
||||||
this._climbPreloaded = true;
|
|
||||||
try {
|
|
||||||
this._mixamoAnimator.preload('climb_up');
|
|
||||||
this._mixamoAnimator.preload('climb_down');
|
|
||||||
this._mixamoAnimator.preload('climb_to_top');
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
const wantUp = c.has('KeyW') || c.has('ArrowUp');
|
|
||||||
const wantDown = c.has('KeyS') || c.has('ArrowDown');
|
|
||||||
// Фаза climb_to_top — вылезание на площадку (4с). Блокирует всё:
|
|
||||||
// управление, физику, обычный ladder-mode. Игрок плавно перемещается
|
|
||||||
// из _climbTopStart в _climbTopEnd (lerp), анимация climb_to_top играет.
|
|
||||||
if (this._climbingTop) {
|
|
||||||
const total = 4000;
|
|
||||||
const left = this._climbingTopUntil - Date.now();
|
|
||||||
const t = Math.max(0, Math.min(1, 1 - left / total));
|
|
||||||
const a = this._climbTopStart, b = this._climbTopEnd;
|
|
||||||
if (a && b) {
|
|
||||||
this._pos.x = a.x + (b.x - a.x) * t;
|
|
||||||
this._pos.y = a.y + (b.y - a.y) * t;
|
|
||||||
this._pos.z = a.z + (b.z - a.z) * t;
|
|
||||||
}
|
|
||||||
this._vy = 0;
|
|
||||||
if (left <= 0) {
|
|
||||||
// Завершили вылезание — выходим в обычный режим.
|
|
||||||
this._climbingTop = false;
|
|
||||||
this._ladderMode = false;
|
|
||||||
this._ladderData = null;
|
|
||||||
this._climbTopStart = null;
|
|
||||||
this._climbTopEnd = null;
|
|
||||||
}
|
|
||||||
// Пропускаем остальную ladder/движение логику в этом кадре.
|
|
||||||
// Но позволяем анимационной ветке проиграть climb_to_top.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вход в ladder-mode: касаемся лестницы И жмём вверх/вниз.
|
|
||||||
if (!this._climbingTop && ladder && !this._ladderMode && (wantUp || wantDown)) {
|
|
||||||
this._ladderMode = true;
|
|
||||||
this._ladderData = ladder;
|
|
||||||
this._vy = 0;
|
|
||||||
// Прижать игрока к плоскости лестницы и повернуть лицом к ней.
|
|
||||||
// Лестница плоская: её фронт — вдоль локальной оси -Z, повёрнутой
|
|
||||||
// на rotationY. Нормаль фронта = (sin(rY), 0, cos(rY)).
|
|
||||||
const rY = (ladder.rotationY || 0) * Math.PI / 180;
|
|
||||||
const nx = Math.sin(rY);
|
|
||||||
const nz = Math.cos(rY);
|
|
||||||
// Игрок стоит ПЕРЕД лестницей: позиция = центр лестницы по XZ
|
|
||||||
// + нормаль * (полглубины лестницы + полширины игрока).
|
|
||||||
const standOff = (ladder.sz || 0.25) / 2 + this.HALF_D + 0.05;
|
|
||||||
this._pos.x = ladder.x + nx * standOff;
|
|
||||||
this._pos.z = ladder.z + nz * standOff;
|
|
||||||
// Повернуть лицом К лестнице (смотрит против нормали).
|
|
||||||
// climb_up-клип сам разворачивает Hips на 180°, поэтому модель
|
|
||||||
// доворачиваем на +π, чтобы персонаж смотрел на перекладины.
|
|
||||||
const faceYaw = Math.atan2(-nx, -nz);
|
|
||||||
this._yaw = faceYaw; // камера смотрит на лестницу
|
|
||||||
this._modelYaw = faceYaw + Math.PI; // +180° компенсация анимации
|
|
||||||
this._ladderMoving = null; // сброс — climb-анимация стартует заново
|
|
||||||
}
|
|
||||||
// Пока в ladder-mode: обновляем ссылку на лестницу если ещё касаемся.
|
|
||||||
// (НЕ во время climb_to_top — там своя логика перемещения.)
|
|
||||||
if (this._ladderMode && !this._climbingTop) {
|
|
||||||
if (ladder) this._ladderData = ladder;
|
|
||||||
const ld = this._ladderData;
|
|
||||||
// Верх лестницы (мировая координата). Поднялись выше — выходим наверх.
|
|
||||||
const ladderTop = ld ? (ld.y + (ld.sy || 0) / 2) : Infinity;
|
|
||||||
// Гистерезис выхода: НЕ выходим по мгновенному !ladder (детект
|
|
||||||
// нестабилен на грани AABB → мигание climb↔walk каждый кадр).
|
|
||||||
// Выходим только если игрок РЕАЛЬНО отошёл по XZ от сохранённой
|
|
||||||
// лестницы (> половины ширины + запас).
|
|
||||||
let farFromLadder = false;
|
|
||||||
if (ld) {
|
|
||||||
const dx = this._pos.x - ld.x;
|
|
||||||
const dz = this._pos.z - ld.z;
|
|
||||||
const distXZ = Math.hypot(dx, dz);
|
|
||||||
const exitDist = Math.max(ld.sx || 1, ld.sz || 0.25) / 2 + this.HALF_D + 0.6;
|
|
||||||
farFromLadder = distXZ > exitDist;
|
|
||||||
} else {
|
|
||||||
farFromLadder = true;
|
|
||||||
}
|
|
||||||
// Space → отпрыг назад + выход.
|
|
||||||
if (c.has('Space')) {
|
|
||||||
this._ladderMode = false;
|
|
||||||
this._ladderData = null;
|
|
||||||
this._vy = 5;
|
|
||||||
this._jumpHeld = true;
|
|
||||||
} else if (farFromLadder) {
|
|
||||||
// Реально отошли от лестницы — выходим (гравитация включится).
|
|
||||||
this._ladderMode = false;
|
|
||||||
this._ladderData = null;
|
|
||||||
} else {
|
|
||||||
// Лазание: гравитация отключена, A/D заблокированы.
|
|
||||||
// Вертикальное движение задаём через _vy (climb-скорость),
|
|
||||||
// чтобы moveAABB обработал коллизию корректно. Прямое
|
|
||||||
// _pos.y += не годилось: персонаж стоит на земле, и moveAABB
|
|
||||||
// снапил его обратно (онГраунд держал внизу).
|
|
||||||
moveX = 0;
|
|
||||||
moveZ = 0;
|
|
||||||
if (wantUp) this._vy = this.CLIMB_SPEED;
|
|
||||||
else if (wantDown) this._vy = -this.CLIMB_SPEED;
|
|
||||||
else this._vy = 0;
|
|
||||||
// Достигли верха лестницы И лезем вверх → запускаем переход
|
|
||||||
// climb_to_top (вылезание на площадку, 4с one-shot). Управление
|
|
||||||
// блокируется, физика замораживается, в конце игрок ставится
|
|
||||||
// на площадку над лестницей.
|
|
||||||
if (this._pos.y + this.HALF_H > ladderTop - 0.3 && wantUp
|
|
||||||
&& !this._climbingTop) {
|
|
||||||
this._climbingTop = true;
|
|
||||||
this._climbingTopUntil = Date.now() + 4000;
|
|
||||||
this._vy = 0;
|
|
||||||
// Куда вылезти: вперёд (по нормали от лестницы, внутрь
|
|
||||||
// площадки) + на верх лестницы.
|
|
||||||
const ldd = this._ladderData;
|
|
||||||
const rY = (ldd?.rotationY || 0) * Math.PI / 180;
|
|
||||||
// Нормаль фронта (откуда лез) — игрок перед лестницей.
|
|
||||||
// Площадка — за лестницей (противоположная сторона).
|
|
||||||
const fnx = Math.sin(rY), fnz = Math.cos(rY);
|
|
||||||
const fwd = (ldd?.sz || 0.25) / 2 + this.HALF_D + 0.4;
|
|
||||||
this._climbTopStart = { x: this._pos.x, y: this._pos.y, z: this._pos.z };
|
|
||||||
this._climbTopEnd = {
|
|
||||||
x: ldd.x - fnx * fwd, // на другую сторону лестницы
|
|
||||||
y: ladderTop + this.HALF_H, // на верх
|
|
||||||
z: ldd.z - fnz * fwd,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Вертикальное ===
|
// === Вертикальное ===
|
||||||
if (this._ladderMode) {
|
if (inWater) {
|
||||||
// На лестнице гравитация НЕ применяется — _vy уже выставлен
|
|
||||||
// (=CLIMB_SPEED вверх / -CLIMB_SPEED вниз / 0 на месте) выше,
|
|
||||||
// moveAABB применит его с коллизией.
|
|
||||||
} else if (inWater) {
|
|
||||||
// Плавание: лёгкая гравитация + плавучесть к поверхности
|
// Плавание: лёгкая гравитация + плавучесть к поверхности
|
||||||
const buoyancy = submerged ? 6 : 0;
|
const buoyancy = submerged ? 6 : 0;
|
||||||
const swimGravity = -3;
|
const swimGravity = -3;
|
||||||
@ -3107,15 +2870,10 @@ export class PlayerController {
|
|||||||
|
|
||||||
// PERF-METRICS: замер физики игрока
|
// PERF-METRICS: замер физики игрока
|
||||||
const _pt0 = performance.now();
|
const _pt0 = performance.now();
|
||||||
// Во время climb_to_top физику пропускаем — _pos двигается lerp'ом
|
const result = this.physics.moveAABB(
|
||||||
// вручную (вылезание на площадку), коллизия не нужна.
|
this._pos, this.HALF_W, this.HALF_H, this.HALF_D,
|
||||||
const result = this._climbingTop
|
moveX, this._vy * dt, moveZ
|
||||||
? { x: this._pos.x, y: this._pos.y, z: this._pos.z,
|
);
|
||||||
onGround: false, hitY: false, surfaceFollowed: false }
|
|
||||||
: this.physics.moveAABB(
|
|
||||||
this._pos, this.HALF_W, this.HALF_H, this.HALF_D,
|
|
||||||
moveX, this._vy * dt, moveZ
|
|
||||||
);
|
|
||||||
const _bs = this._scene3d || this.scene3d;
|
const _bs = this._scene3d || this.scene3d;
|
||||||
if (_bs && _bs._perfMetrics) {
|
if (_bs && _bs._perfMetrics) {
|
||||||
_bs._perfMetrics.physics_ms_sum += performance.now() - _pt0;
|
_bs._perfMetrics.physics_ms_sum += performance.now() - _pt0;
|
||||||
@ -3256,44 +3014,17 @@ export class PlayerController {
|
|||||||
} else
|
} else
|
||||||
if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) {
|
if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) {
|
||||||
if (!this._jumpHeld) {
|
if (!this._jumpHeld) {
|
||||||
// 3-фазная модель прыжка.
|
// Robot — стартовый импульс полный (как куб) для тапа достаточный,
|
||||||
// _jumpKind определяется по нажатым клавишам в момент Space:
|
// boost-фаза 0.45с удлиняет подъём при удержании Space.
|
||||||
// in_place — нет WASD (анимация Mixamo Jumping)
|
this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
|
||||||
// forward — есть WASD (анимация Mixamo Jump = прыжок вперёд)
|
this._playJumpSound();
|
||||||
const cc = this._codes;
|
|
||||||
const wasdHeld = cc && (cc.has('KeyW') || cc.has('KeyS')
|
|
||||||
|| cc.has('KeyA') || cc.has('KeyD')
|
|
||||||
|| cc.has('ArrowUp') || cc.has('ArrowDown')
|
|
||||||
|| cc.has('ArrowLeft') || cc.has('ArrowRight'));
|
|
||||||
// in_place — нет WASD
|
|
||||||
// forward — WASD без Shift (Mixamo Jump)
|
|
||||||
// run — WASD + Shift (Mixamo Running Jump)
|
|
||||||
const sprinting = this._shift && !this._crouching;
|
|
||||||
if (!wasdHeld) this._jumpKind = 'in_place';
|
|
||||||
else if (sprinting) this._jumpKind = 'run';
|
|
||||||
else this._jumpKind = 'forward';
|
|
||||||
// anticipate-фаза разной длительности.
|
|
||||||
const antDuration = this._jumpKind === 'in_place' ? 375
|
|
||||||
: this._jumpKind === 'run' ? 125 : 170;
|
|
||||||
this._jumpHeld = true;
|
this._jumpHeld = true;
|
||||||
this._coyoteLeft = 0;
|
this._coyoteLeft = 0;
|
||||||
this._jumpAnticipateUntil = Date.now() + antDuration;
|
|
||||||
this._jumpPendingImpulse = true;
|
|
||||||
// Robot: запускаем boost-фазу на 0.45с
|
// Robot: запускаем boost-фазу на 0.45с
|
||||||
if (this._robotMode) {
|
if (this._robotMode) {
|
||||||
this._robotBoostLeft = 0.45;
|
this._robotBoostLeft = 0.45;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Запускаем физический прыжок ровно в конце anticipate-фазы.
|
|
||||||
if (this._jumpPendingImpulse
|
|
||||||
&& this._jumpAnticipateUntil
|
|
||||||
&& Date.now() >= this._jumpAnticipateUntil
|
|
||||||
&& !inWater && !this._shipMode && !this._ufoMode) {
|
|
||||||
this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
|
|
||||||
this._playJumpSound();
|
|
||||||
this._jumpPendingImpulse = false;
|
|
||||||
// _jumpAnticipateUntil оставляем для анимационной ветки
|
|
||||||
} else if (this._shipMode && c.has('Space')) {
|
} else if (this._shipMode && c.has('Space')) {
|
||||||
this._jumpHeld = true;
|
this._jumpHeld = true;
|
||||||
} else if (this._ufoMode && c.has('Space') && !inWater) {
|
} else if (this._ufoMode && c.has('Space') && !inWater) {
|
||||||
@ -3393,46 +3124,17 @@ export class PlayerController {
|
|||||||
const fwdShift = inWater ? bodyLen * tiltFrac : 0;
|
const fwdShift = inWater ? bodyLen * tiltFrac : 0;
|
||||||
const fx = Math.sin(this._modelYaw);
|
const fx = Math.sin(this._modelYaw);
|
||||||
const fz = Math.cos(this._modelYaw);
|
const fz = Math.cos(this._modelYaw);
|
||||||
// Crouch-смещение: разные Mixamo-клипы имеют разный hip-baseline.
|
|
||||||
// crouch_idle (Crouching Idle) — hip ПРИПОДНЯТ (~0.35м над землёй)
|
|
||||||
// crouch_walk (Sneak Walk) — hip нормальный, ноги стандартные
|
|
||||||
// crouch_enter/crouch_to_stand — переход, плавно меняется
|
|
||||||
// Поэтому drop зависит от текущего проигрываемого state, не от _crouching.
|
|
||||||
let crouchYDrop = 0;
|
|
||||||
if (this._crouching && this._mixamoAnimator) {
|
|
||||||
const ms = this._mixamoAnimator._currentState;
|
|
||||||
if (ms === 'crouch_idle') crouchYDrop = 0.45;
|
|
||||||
else if (ms === 'crouch_walk') crouchYDrop = 0.25;
|
|
||||||
else if (ms === 'crouch_enter' || ms === 'crouch_to_stand') crouchYDrop = 0.30;
|
|
||||||
else crouchYDrop = 0.30;
|
|
||||||
}
|
|
||||||
this._modelRoot.position.set(
|
this._modelRoot.position.set(
|
||||||
this._pos.x + fx * fwdShift,
|
this._pos.x + fx * fwdShift,
|
||||||
this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset - crouchYDrop,
|
this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset,
|
||||||
this._pos.z + fz * fwdShift
|
this._pos.z + fz * fwdShift
|
||||||
);
|
);
|
||||||
|
|
||||||
// Поворот модели:
|
// Поворот модели:
|
||||||
// - на лестнице: лицом К лестнице, yaw зафиксирован при входе.
|
|
||||||
// - на суше: направление РЕАЛЬНОГО движения (как было).
|
// - на суше: направление РЕАЛЬНОГО движения (как было).
|
||||||
// - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто
|
// - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто
|
||||||
// двигает тело вбок без вращения, как на суше при first-person.
|
// двигает тело вбок без вращения, как на суше при first-person.
|
||||||
if (this._climbingTop) {
|
if (inWater) {
|
||||||
// climb_to_top: модель смотрит В сторону площадки (куда вылазит).
|
|
||||||
// Эта анимация имеет другую ориентацию Hips чем climb_up,
|
|
||||||
// поэтому БЕЗ +π компенсации — иначе развёрнута на 180°.
|
|
||||||
if (this._climbTopStart && this._climbTopEnd) {
|
|
||||||
const dx = this._climbTopEnd.x - this._climbTopStart.x;
|
|
||||||
const dz = this._climbTopEnd.z - this._climbTopStart.z;
|
|
||||||
if (Math.abs(dx) > 0.001 || Math.abs(dz) > 0.001) {
|
|
||||||
this._modelYaw = Math.atan2(dx, dz);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (this._ladderMode) {
|
|
||||||
// _modelYaw уже выставлен при входе в ladder-mode (лицом к лестнице).
|
|
||||||
// Анимация climb_up даёт ~180° поворот Hips → персонаж лицом к
|
|
||||||
// перекладинам. Ничего не доворачиваем.
|
|
||||||
} else if (inWater) {
|
|
||||||
const targetYaw = this._yaw;
|
const targetYaw = this._yaw;
|
||||||
let diff = targetYaw - this._modelYaw;
|
let diff = targetYaw - this._modelYaw;
|
||||||
while (diff > Math.PI) diff -= Math.PI * 2;
|
while (diff > Math.PI) diff -= Math.PI * 2;
|
||||||
@ -3527,136 +3229,6 @@ export class PlayerController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mixamo-скин: AnimationGroup для каждого состояния, грузятся отдельно
|
|
||||||
// из /character-assets/animations/*.glb. Состояния:
|
|
||||||
// idle/walk/run/jump/fall — базовые
|
|
||||||
// crouch_idle/crouch_walk — присед (Ctrl)
|
|
||||||
// sprint → run. crouch имеет приоритет над sprint.
|
|
||||||
if (this._mixamoAnimator) {
|
|
||||||
let mState;
|
|
||||||
const now = Date.now();
|
|
||||||
// climb_to_top — вылезание на площадку (приоритет над всем).
|
|
||||||
if (this._climbingTop) {
|
|
||||||
this._mixamoAnimator.setState('climb_to_top');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Лазание по лестнице имеет приоритет над всеми анимациями.
|
|
||||||
// climb_up — движется вверх (W), climb_down — вниз (S),
|
|
||||||
// на месте на лестнице — анимация продолжает играть циклично
|
|
||||||
// (НЕ паузим: g.pause() останавливал обновление скелета →
|
|
||||||
// bounding box не обновлялся → frustum culling прятал скин).
|
|
||||||
if (this._ladderMode) {
|
|
||||||
const climbUp = this._codes.has('KeyW') || this._codes.has('ArrowUp');
|
|
||||||
const climbDown = this._codes.has('KeyS') || this._codes.has('ArrowDown');
|
|
||||||
const moving = climbUp || climbDown;
|
|
||||||
// Меняем state ТОЛЬКО при реальном движении. На месте держим
|
|
||||||
// текущую анимацию (не дёргаем setState — это убирает мигание
|
|
||||||
// climb_up↔climb_down и исчезание скина).
|
|
||||||
if (climbUp) this._mixamoAnimator.setState('climb_up');
|
|
||||||
else if (climbDown) this._mixamoAnimator.setState('climb_down');
|
|
||||||
// play/pause трогаем ТОЛЬКО при смене режима движения (как в jump).
|
|
||||||
if (moving !== this._ladderMoving) {
|
|
||||||
this._ladderMoving = moving;
|
|
||||||
try {
|
|
||||||
const g = this._mixamoAnimator._currentGroup;
|
|
||||||
if (g) {
|
|
||||||
if (moving) g.play(true); // возобновить (снять паузу)
|
|
||||||
else g.pause(); // заморозить позу
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const inCrouchTransition = this._crouchTransitionUntil
|
|
||||||
&& now < this._crouchTransitionUntil;
|
|
||||||
// 3-фазная анимация прыжка. Выбираем семейство фаз по _jumpKind:
|
|
||||||
// in_place: jump_* (Mixamo Jumping)
|
|
||||||
// forward: jump_fwd_* (Mixamo Jump, прыжок с шага)
|
|
||||||
// run: jump_run_* (Mixamo Running Jump, прыжок с бега)
|
|
||||||
const jk = this._jumpKind;
|
|
||||||
const isAirborneJump = jk === 'forward' || jk === 'run';
|
|
||||||
let stAnticipate, stAir, stLand, landDuration;
|
|
||||||
if (jk === 'run') {
|
|
||||||
stAnticipate = 'jump_run_anticipate';
|
|
||||||
stAir = 'jump_run_air';
|
|
||||||
stLand = 'jump_run_land';
|
|
||||||
landDuration = 175;
|
|
||||||
} else if (jk === 'forward') {
|
|
||||||
stAnticipate = 'jump_fwd_anticipate';
|
|
||||||
stAir = 'jump_fwd_air';
|
|
||||||
stLand = 'jump_fwd_land';
|
|
||||||
landDuration = 142;
|
|
||||||
} else {
|
|
||||||
stAnticipate = 'jump_anticipate';
|
|
||||||
stAir = 'jump_air';
|
|
||||||
stLand = 'jump_land';
|
|
||||||
landDuration = 570;
|
|
||||||
}
|
|
||||||
const inAnticipate = this._jumpAnticipateUntil
|
|
||||||
&& now < this._jumpAnticipateUntil
|
|
||||||
&& this._jumpPendingImpulse;
|
|
||||||
const inJumpLand = this._jumpLandUntil && now < this._jumpLandUntil;
|
|
||||||
// Coyote-фильтр для микро-полётов на ступеньках. При спуске по
|
|
||||||
// лестнице из блоков персонаж 30-700мс физически в воздухе, и
|
|
||||||
// jump_air мигает между шагами walk. Критерий — ВЫСОТА падения
|
|
||||||
// от последней наземной позиции (а не время — полёт может быть
|
|
||||||
// длинным при спуске лицом к камере). Опустился <1.3 блока И не
|
|
||||||
// прыгал → ступенька, играем walk/run.
|
|
||||||
if (result.onGround) {
|
|
||||||
this._lastGroundY = this._pos.y;
|
|
||||||
}
|
|
||||||
const dropFromGround = (this._lastGroundY != null)
|
|
||||||
? (this._lastGroundY - this._pos.y) : Infinity;
|
|
||||||
const microAir = !result.onGround
|
|
||||||
&& !this._jumpHeld // не прыжок со Space
|
|
||||||
&& !this._wasAirborne // не продолжение реального прыжка
|
|
||||||
&& dropFromGround < 1.3 // опустился меньше 1.3 блока
|
|
||||||
&& this._vy < 4; // не подлетает вверх (степ-ап импульс)
|
|
||||||
if (inAnticipate) {
|
|
||||||
mState = stAnticipate;
|
|
||||||
} else if (microAir) {
|
|
||||||
// Микро-полёт между ступеньками — наземная анимация.
|
|
||||||
mState = this._crouching
|
|
||||||
? (isMoving ? 'crouch_walk' : 'crouch_idle')
|
|
||||||
: (isMoving ? (isSprinting ? 'run' : 'walk') : 'idle');
|
|
||||||
} else if (!result.onGround) {
|
|
||||||
mState = stAir;
|
|
||||||
this._wasAirborne = true;
|
|
||||||
this._crouchEnterPending = false;
|
|
||||||
this._crouchExitPending = false;
|
|
||||||
this._crouchTransitionUntil = 0;
|
|
||||||
this._jumpAnticipateUntil = 0;
|
|
||||||
} else if (this._wasAirborne) {
|
|
||||||
this._jumpLandUntil = now + landDuration;
|
|
||||||
this._wasAirborne = false;
|
|
||||||
mState = stLand;
|
|
||||||
} else if (inJumpLand) {
|
|
||||||
// Для forward — доигрываем land даже при движении
|
|
||||||
// (там короткая фаза 142мс)
|
|
||||||
if (isAirborneJump || !isMoving) mState = stLand;
|
|
||||||
} else if (this._crouchEnterPending && inCrouchTransition && !isMoving) {
|
|
||||||
mState = 'crouch_enter';
|
|
||||||
} else if (this._crouchExitPending && inCrouchTransition && !isMoving) {
|
|
||||||
mState = 'crouch_to_stand';
|
|
||||||
} else if (this._crouching) {
|
|
||||||
this._crouchEnterPending = false;
|
|
||||||
this._crouchExitPending = false;
|
|
||||||
mState = isMoving ? 'crouch_walk' : 'crouch_idle';
|
|
||||||
} else if (inWater) {
|
|
||||||
mState = isMoving ? 'walk' : 'idle';
|
|
||||||
} else if (isMoving) {
|
|
||||||
this._crouchExitPending = false;
|
|
||||||
this._crouchTransitionUntil = 0;
|
|
||||||
this._jumpLandUntil = 0; // прерываем jump_land если пошли
|
|
||||||
mState = isSprinting ? 'run' : 'walk';
|
|
||||||
} else {
|
|
||||||
this._crouchExitPending = false;
|
|
||||||
mState = 'idle';
|
|
||||||
}
|
|
||||||
this._mixamoAnimator.setState(mState);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// R15-скин: процедурный аниматор (нет glTF AnimationGroups).
|
// R15-скин: процедурный аниматор (нет glTF AnimationGroups).
|
||||||
// Состояния: idle/walk/run/jump/fall. sprint → run.
|
// Состояния: idle/walk/run/jump/fall. sprint → run.
|
||||||
if (this._isR15 && this._r15Animator) {
|
if (this._isR15 && this._r15Animator) {
|
||||||
|
|||||||
@ -38,11 +38,6 @@ const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png';
|
|||||||
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png';
|
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png';
|
||||||
const STUD_UNIT = 1;
|
const STUD_UNIT = 1;
|
||||||
const STUDS_GRID = 4;
|
const STUDS_GRID = 4;
|
||||||
|
|
||||||
// Вертикальный шаг между ступеньками лестницы (юниты). Полная высота
|
|
||||||
// лестницы = stepCount * LADDER_STEP_SPACING.
|
|
||||||
export const LADDER_STEP_SPACING = 0.45;
|
|
||||||
|
|
||||||
const _studsTexCache = new WeakMap();
|
const _studsTexCache = new WeakMap();
|
||||||
function _getStudsTextures(scene) {
|
function _getStudsTextures(scene) {
|
||||||
let c = _studsTexCache.get(scene);
|
let c = _studsTexCache.get(scene);
|
||||||
@ -119,15 +114,8 @@ export class PrimitiveManager {
|
|||||||
id = this._nextId++;
|
id = this._nextId++;
|
||||||
}
|
}
|
||||||
const sx = opts.sx ?? typeDef.defaultScale.x;
|
const sx = opts.sx ?? typeDef.defaultScale.x;
|
||||||
let sy = opts.sy ?? typeDef.defaultScale.y;
|
const sy = opts.sy ?? typeDef.defaultScale.y;
|
||||||
const sz = opts.sz ?? typeDef.defaultScale.z;
|
const sz = opts.sz ?? typeDef.defaultScale.z;
|
||||||
// Лестница: высота ДЕРИВИРУЕТСЯ из stepCount (а не из sy) — даёт
|
|
||||||
// корректный AABB для детекта касания и совпадает с геометрией меша.
|
|
||||||
const isLadder = typeDef.id === 'ladder_vertical';
|
|
||||||
const stepCount = isLadder
|
|
||||||
? Math.max(2, Math.min(40, Math.round(opts.stepCount != null ? opts.stepCount : 8)))
|
|
||||||
: undefined;
|
|
||||||
if (isLadder) sy = stepCount * LADDER_STEP_SPACING;
|
|
||||||
const color = opts.color ?? typeDef.defaultColor;
|
const color = opts.color ?? typeDef.defaultColor;
|
||||||
// GD-сущности (порталы/шипы/финиш/монеты) — neon (светятся) и без коллизии физики.
|
// GD-сущности (порталы/шипы/финиш/монеты) — neon (светятся) и без коллизии физики.
|
||||||
// Логику касания обрабатывает GdPortalManager/GdLevelManager по дистанции.
|
// Логику касания обрабатывает GdPortalManager/GdLevelManager по дистанции.
|
||||||
@ -138,10 +126,8 @@ export class PrimitiveManager {
|
|||||||
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
|
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
|
||||||
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
|
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
|
||||||
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
|
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
|
||||||
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции).
|
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
|
||||||
// Лестница — тоже проходима (canCollide=false), чтобы игрок мог войти в её
|
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
|
||||||
// объём и лезть (ladder-mode в PlayerController по детекту касания).
|
|
||||||
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike && !isLadder;
|
|
||||||
const visible = opts.visible !== false;
|
const visible = opts.visible !== false;
|
||||||
const anchored = opts.anchored !== false; // по умолчанию заякорен
|
const anchored = opts.anchored !== false; // по умолчанию заякорен
|
||||||
// Масса по умолчанию = объём (sx*sy*sz), округлённый до 2 знаков.
|
// Масса по умолчанию = объём (sx*sy*sz), округлённый до 2 знаков.
|
||||||
@ -157,11 +143,7 @@ export class PrimitiveManager {
|
|||||||
const rotationY = opts.rotationY ?? 0;
|
const rotationY = opts.rotationY ?? 0;
|
||||||
const rotationZ = opts.rotationZ ?? 0;
|
const rotationZ = opts.rotationZ ?? 0;
|
||||||
|
|
||||||
// Передаём stepCount в builder через временное поле (читается в
|
|
||||||
// _buildLadderMesh внутри _createMeshForType).
|
|
||||||
this._ladderStepCount = stepCount;
|
|
||||||
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
|
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
|
||||||
this._ladderStepCount = undefined;
|
|
||||||
mesh.position = new Vector3(x, y, z);
|
mesh.position = new Vector3(x, y, z);
|
||||||
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
|
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
|
||||||
mesh.isPickable = true;
|
mesh.isPickable = true;
|
||||||
@ -187,8 +169,6 @@ export class PrimitiveManager {
|
|||||||
rotationX, rotationY, rotationZ,
|
rotationX, rotationY, rotationZ,
|
||||||
color, material, canCollide, visible, anchored, mass,
|
color, material, canCollide, visible, anchored, mass,
|
||||||
textureAsset, studDensity,
|
textureAsset, studDensity,
|
||||||
// Лестница: число ступенек (высота). undefined для прочих.
|
|
||||||
...(isLadder ? { stepCount } : {}),
|
|
||||||
// locked — объект защищён от выделения/перемещения в редакторе
|
// locked — объект защищён от выделения/перемещения в редакторе
|
||||||
// (Фаза 5.11). На геймплей не влияет.
|
// (Фаза 5.11). На геймплей не влияет.
|
||||||
locked: opts.locked === true,
|
locked: opts.locked === true,
|
||||||
@ -325,10 +305,6 @@ export class PrimitiveManager {
|
|||||||
return this._buildWedgeMesh(name, sx, sy, sz);
|
return this._buildWedgeMesh(name, sx, sy, sz);
|
||||||
case 'cornerwedge':
|
case 'cornerwedge':
|
||||||
return this._buildCornerWedgeMesh(name, sx, sy, sz);
|
return this._buildCornerWedgeMesh(name, sx, sy, sz);
|
||||||
case 'ladder_vertical':
|
|
||||||
// Лестница строится из stepCount ступенек — высота зависит от
|
|
||||||
// количества ступенек, а не от sy.
|
|
||||||
return this._buildLadderMesh(name, sx, sz, this._ladderStepCount || 8);
|
|
||||||
default:
|
default:
|
||||||
return MeshBuilder.CreateBox(name,
|
return MeshBuilder.CreateBox(name,
|
||||||
{ width: sx, height: sy, depth: sz }, this.scene);
|
{ width: sx, height: sy, depth: sz }, this.scene);
|
||||||
@ -476,43 +452,6 @@ export class PrimitiveManager {
|
|||||||
return mesh;
|
return mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Вертикальная лестница: 2 боковые стойки + N перекладин (ступенек).
|
|
||||||
* Полная высота = stepCount * LADDER_STEP_SPACING. При изменении stepCount
|
|
||||||
* лестница ПЕРЕСТРАИВАЕТСЯ. Меш центрирован по (0,0,0) как CreateBox.
|
|
||||||
* sx — ширина, sz — глубина стоек/перекладин.
|
|
||||||
*/
|
|
||||||
_buildLadderMesh(name, sx, sz, stepCount) {
|
|
||||||
const n = Math.max(2, Math.min(40, Math.round(stepCount || 8)));
|
|
||||||
const SPACING = LADDER_STEP_SPACING;
|
|
||||||
const height = n * SPACING;
|
|
||||||
const railW = Math.min(0.12, sx * 0.12);
|
|
||||||
const railD = Math.max(0.06, sz);
|
|
||||||
const rungH = Math.min(0.1, SPACING * 0.3);
|
|
||||||
const halfH = height / 2;
|
|
||||||
const railX = sx / 2 - railW / 2;
|
|
||||||
const parts = [];
|
|
||||||
const railL = MeshBuilder.CreateBox(name + '_railL',
|
|
||||||
{ width: railW, height, depth: railD }, this.scene);
|
|
||||||
railL.position.x = -railX;
|
|
||||||
parts.push(railL);
|
|
||||||
const railR = MeshBuilder.CreateBox(name + '_railR',
|
|
||||||
{ width: railW, height, depth: railD }, this.scene);
|
|
||||||
railR.position.x = railX;
|
|
||||||
parts.push(railR);
|
|
||||||
const rungWidth = sx - railW;
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const y = -halfH + SPACING * (i + 0.5);
|
|
||||||
const rung = MeshBuilder.CreateBox(name + '_rung' + i,
|
|
||||||
{ width: rungWidth, height: rungH, depth: railD }, this.scene);
|
|
||||||
rung.position.y = y;
|
|
||||||
parts.push(rung);
|
|
||||||
}
|
|
||||||
const merged = Mesh.MergeMeshes(parts, true, true, undefined, false, true);
|
|
||||||
if (merged) { merged.name = name; return merged; }
|
|
||||||
return MeshBuilder.CreateBox(name, { width: sx, height, depth: sz }, this.scene);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Применить цвет и материал. */
|
/** Применить цвет и материал. */
|
||||||
_applyMaterial(mesh, typeDef, color, material, textureUrl) {
|
_applyMaterial(mesh, typeDef, color, material, textureUrl) {
|
||||||
const matName = `${mesh.name}_mat`;
|
const matName = `${mesh.name}_mat`;
|
||||||
@ -756,14 +695,6 @@ export class PrimitiveManager {
|
|||||||
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
|
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
|
||||||
scaleChanged = true;
|
scaleChanged = true;
|
||||||
}
|
}
|
||||||
// Лестница: смена числа ступенек → пересборка меша. Высота (sy)
|
|
||||||
// деривируется из stepCount, поэтому AABB касания остаётся корректным.
|
|
||||||
if (patch.stepCount !== undefined && data.type === 'ladder_vertical') {
|
|
||||||
const sc = Math.max(2, Math.min(40, Math.round(patch.stepCount)));
|
|
||||||
data.stepCount = sc;
|
|
||||||
data.sy = sc * LADDER_STEP_SPACING;
|
|
||||||
scaleChanged = true;
|
|
||||||
}
|
|
||||||
if (scaleChanged) {
|
if (scaleChanged) {
|
||||||
// Поскольку MeshBuilder уже создал mesh с базовыми размерами,
|
// Поскольку MeshBuilder уже создал mesh с базовыми размерами,
|
||||||
// изменения через scaling кажутся правильными. Простой способ —
|
// изменения через scaling кажутся правильными. Простой способ —
|
||||||
@ -897,10 +828,7 @@ export class PrimitiveManager {
|
|||||||
const oldMat = oldMesh.material;
|
const oldMat = oldMesh.material;
|
||||||
|
|
||||||
const typeDef = getPrimitiveType(data.type);
|
const typeDef = getPrimitiveType(data.type);
|
||||||
// Лестница: передаём актуальный stepCount в builder.
|
|
||||||
this._ladderStepCount = data.stepCount;
|
|
||||||
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
|
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
|
||||||
this._ladderStepCount = undefined;
|
|
||||||
newMesh.position = oldPos;
|
newMesh.position = oldPos;
|
||||||
if (oldRot) newMesh.rotation = oldRot;
|
if (oldRot) newMesh.rotation = oldRot;
|
||||||
// studs — материал пересоздаём заново (свежий faceUV/тайлинг). Иначе перенос.
|
// studs — материал пересоздаём заново (свежий faceUV/тайлинг). Иначе перенос.
|
||||||
@ -976,8 +904,6 @@ export class PrimitiveManager {
|
|||||||
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
||||||
// Параметр эмиттера (только для type='emitter')
|
// Параметр эмиттера (только для type='emitter')
|
||||||
...(d.effect !== undefined ? { effect: d.effect } : {}),
|
...(d.effect !== undefined ? { effect: d.effect } : {}),
|
||||||
// Число ступенек лестницы (только для type='ladder_vertical')
|
|
||||||
...(d.type === 'ladder_vertical' ? { stepCount: d.stepCount } : {}),
|
|
||||||
// Параметры билборда (только для type='billboard')
|
// Параметры билборда (только для type='billboard')
|
||||||
...(d.billboard ? {
|
...(d.billboard ? {
|
||||||
template: d.billboard.template,
|
template: d.billboard.template,
|
||||||
|
|||||||
@ -66,13 +66,6 @@ export const PRIMITIVE_TYPES = [
|
|||||||
{ id: 'billboard', name: '3D-табличка', icon: 'prim-billboard', kind: 'billboard',
|
{ id: 'billboard', name: '3D-табличка', icon: 'prim-billboard', kind: 'billboard',
|
||||||
defaultScale: { x: 2, y: 1.2, z: 0.05 }, defaultColor: '#1a1a2e' },
|
defaultScale: { x: 2, y: 1.2, z: 0.05 }, defaultColor: '#1a1a2e' },
|
||||||
|
|
||||||
// === Вертикальная лестница — по ней можно лазить вверх/вниз ===
|
|
||||||
// Высота настраивается параметром stepCount (количество ступенек).
|
|
||||||
// При изменении stepCount лестница перестраивается (НЕ растягивается модель,
|
|
||||||
// а добавляются/убираются ступеньки). Касание → ladder-mode в PlayerController.
|
|
||||||
{ id: 'ladder_vertical', name: 'Лестница (вертикальная)', icon: 'prim-ladder', kind: 'ladder',
|
|
||||||
defaultScale: { x: 1, y: 4, z: 0.12 }, defaultColor: '#a8743a' },
|
|
||||||
|
|
||||||
// === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока ===
|
// === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока ===
|
||||||
// Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим.
|
// Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим.
|
||||||
{ id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube',
|
{ id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube',
|
||||||
@ -103,7 +96,7 @@ export const PRIMITIVE_TYPES = [
|
|||||||
/** Категории для группировки в палитре. */
|
/** Категории для группировки в палитре. */
|
||||||
export const PRIMITIVE_CATEGORIES = [
|
export const PRIMITIVE_CATEGORIES = [
|
||||||
{ id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] },
|
{ id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] },
|
||||||
{ id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard', 'ladder_vertical'] },
|
{ id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard'] },
|
||||||
{ id: 'gd-portals', name: 'GD-порталы', ids: ['gd_cube','gd_ship','gd_ball','gd_ufo','gd_wave','gd_robot'] },
|
{ id: 'gd-portals', name: 'GD-порталы', ids: ['gd_cube','gd_ship','gd_ball','gd_ufo','gd_wave','gd_robot'] },
|
||||||
{ id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] },
|
{ id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] },
|
||||||
];
|
];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user