Compare commits

..

No commits in common. "6c07a9f6793f0c17360c9078cb58b39e4f4d78d7" and "831b525cfc2ad20bbcc0d532cf4bc47d8fb4a98b" have entirely different histories.

5 changed files with 60 additions and 856 deletions

View File

@ -63,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?.();
@ -228,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={{
@ -255,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>
); );
} }
@ -587,40 +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') {
const isLegacy = LEGACY_SKINS.has(player.skin); // cache-bust обязателен: на 2026-05-27 фиксили 404 на этом пути,
if (!isLegacy && player.skin.startsWith('skin_')) { // браузеры успели закэшировать негативный ответ
// Mixamo: PNG на rublox-site (на проде rublox.pro, avatarUrl = `/kubikon-assets/characters/${player.skin}/avatar.png?v=2026_05_27`;
// локально localhost:3000) рядом с GLB.
const base = (typeof window !== 'undefined'
&& window.location.hostname === 'localhost')
? 'http://localhost:3000'
: 'https://rublox.pro';
avatarUrl = `${base}/character-assets/skins/${player.skin}.png`;
} else {
// Legacy R15: путь по старому шаблону.
avatarUrl = `/kubikon-assets/characters/${player.skin}/avatar.png?v=2026_05_27`;
}
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:')

View File

@ -223,13 +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 поэтому без клика никак.
const [gameStarted, setGameStarted] = useState(false);
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);
@ -315,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 });
@ -630,45 +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) {}
}
skinFolderRef.current = mySkin; skinFolderRef.current = mySkin;
try { scene.setPlayerModelType?.(mySkin); } catch (e) {} try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
@ -1119,20 +1068,6 @@ const KubikonPlayer = () => {
setMobileStartTapped(true); setMobileStartTapped(true);
}, []); }, []);
/** Стартовый клик «Начать игру» запрашивает fullscreen
* (Chrome блокирует Ctrl+W/Ctrl+T в fullscreen) и снимает оверлей. */
const handleGameStart = useCallback(async () => {
const root = document.documentElement;
const req = root.requestFullscreen
|| root.webkitRequestFullscreen
|| root.mozRequestFullScreen
|| root.msRequestFullscreen;
if (req) {
try { await req.call(root); } catch (e) { /* юзер запретил — играем без FS */ }
}
setGameStarted(true);
}, []);
// При выходе со страницы снимаем fullscreen / orientation lock, // При выходе со страницы снимаем fullscreen / orientation lock,
// чтобы возврат в школу не остался залочен в landscape. // чтобы возврат в школу не остался залочен в landscape.
useEffect(() => { useEffect(() => {
@ -1242,69 +1177,6 @@ const KubikonPlayer = () => {
/> />
)} )}
{/* 2026-06-14: стартовый оверлей. Один клик fullscreen
* 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',
}}>
Игра откроется в полноэкранном режиме
это защитит от случайного закрытия вкладки
(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, прицел в первом лице) */}
{!loading && ( {!loading && (
<> <>
@ -1543,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}
@ -1568,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"

View File

@ -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).

View File

@ -1,482 +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 = [
"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", "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);
}
}
/** Создать (или достать из кэша) 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") {
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",
"crouch_enter", "crouch_to_stand",
"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;
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
// Anti-flicker debounce: не даём переключать состояние чаще чем раз в 120мс,
// КРОМЕ переходов с/на воздушные состояния (jump/fall) — там важна скорость
// и one-shot crouch_enter/crouch_to_stand (они короткие).
const isVitalSwitch = state === 'jump' || state === 'fall'
|| this._currentState === 'jump' || this._currentState === 'fall'
|| 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})`);
// Запустить новую анимацию. Babylon 7 ВНИМАНИЕ: параметр loop
// в start() иногда игнорится — дублируем через loopAnimation
// (выставлен в _ensureGroup).
try {
next.reset();
next.start(loop, 1.0, next.from, next.to, false);
} catch (e) {
try { next.play(loop); } catch (_) {}
}
// Кросс-фейд через weight (0→1 у новой, 1→0 у старой) за BLEND_MS.
const BLEND_MS = 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);
}
/** 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;
}
}

View File

@ -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';
@ -215,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 текущего скина
@ -366,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;
@ -1207,51 +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;
// Глобально для отладки/скриптов:
// 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();
@ -1708,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) {
@ -2621,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;
@ -2756,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;
@ -3202,22 +3124,9 @@ 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
); );
@ -3320,52 +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();
const inCrouchTransition = this._crouchTransitionUntil
&& now < this._crouchTransitionUntil;
if (!result.onGround) {
mState = (this._vy > 0.5) ? 'jump' : 'fall';
// Воздух — отменяем pending crouch-переход
this._crouchEnterPending = false;
this._crouchExitPending = false;
this._crouchTransitionUntil = 0;
} else if (this._crouchEnterPending && inCrouchTransition && !isMoving) {
// ВХОД в присед: одноразовая анимация Standing→Crouch.
// Если игрок начал двигаться сразу — пропускаем переход
// и идём в crouch_walk.
mState = 'crouch_enter';
} else if (this._crouchExitPending && inCrouchTransition && !isMoving) {
// ВЫХОД из приседа: одноразовая анимация Crouched→Standing.
// Если игрок сразу пошёл/побежал — пропускаем переход и
// идём прямо в walk/run. Иначе персонаж скользит вдоль
// пола в позе вставания.
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) {
// Сбросим pending crouch_to_stand — игрок уже бежит
this._crouchExitPending = false;
this._crouchTransitionUntil = 0;
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) {