Compare commits

..

7 Commits

Author SHA1 Message Date
min
4a3192ea67 Merge remote-tracking branch 'origin/main' into feat/rbxl-import
All checks were successful
CI / Lint (pull_request) Successful in 55s
CI / Build (pull_request) Successful in 1m35s
CI / Secret scan (pull_request) Successful in 20s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
# Conflicts:
#	src/engine/GameRuntime.js
#	src/engine/ScriptSandboxWorker.js
2026-06-10 00:56:26 +03:00
min
adc950accf feat(player): обновлённый LoadingScreenOverlay с blur-фоном из студии
All checks were successful
CI / Lint (pull_request) Successful in 52s
CI / Build (pull_request) Successful in 1m31s
CI / Secret scan (pull_request) Successful in 21s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
В студии был обновлён loading-экран:
- размытый фон из cover-картинки
- квадратная обложка по центру вместо широкой
- имя автора под названием
- более крупный прогресс с процентом

Плеер остался на старой версии (синий фон, широкая обложка),
поэтому в проде разница была заметна — фикс делает плеер
1-в-1 со студией.
2026-06-10 00:48:36 +03:00
min
60f0ba009d chore(player): удалён мёртвый worker-based Lua стек после миграции на LuaSharedSandbox (Фаза 4)
All checks were successful
CI / Lint (pull_request) Successful in 55s
CI / Build (pull_request) Successful in 1m27s
CI / Secret scan (pull_request) Successful in 26s
CI / PR size check (pull_request) Successful in 10s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-10 00:15:54 +03:00
min
bbc82af819 feat(player): синхронизация JS-API + BabylonScene._meshToTarget(npc) + GUI cmd-handlers (Фаза 3)
- ScriptSandboxWorker: добавлены отсутствующие методы game.self.* (rotate, rotateY,
  setVisible, setCollide, setColor, setLabel, clearLabel) — критично для GUI-карточек
  и интерактивных объектов сцены.
- Добавлены namespace'ы game.remote (RemoteEvent), game.tools (custom Tool.create),
  game.items.define, game.leaderstats (define/set/add/get/onChange/me-shortcut),
  game.achievements (define/unlock/has/bindToStat/setButtonVisible/openPage).
- inventory: добавлены inv2-методы (give/take/open/closeUi/toggle/sort/setActiveHotbar).
- giveTool теперь принимает Tool-объект из tools.create (поле customToolId).
- Роутинг globalEvent: добавлены leaderstatsChange, achievementUnlocked, toolEquipped,
  toolUnequipped, remoteEvent; toolUse теперь вызывает per-tool onActivated.
- tween() нормализует ref через _normRef — теперь принимает не только строку,
  но и объект из scene.spawn/find.
- BabylonScene._meshToTarget: добавлен случай md.npcId != null → kind='npc'.
- BabylonScene._handlePlayClick: в 3-м лице (без pointer-lock) клик теперь
  пикает по реальным координатам мыши, а не из центра экрана. Это чинит
  клики по GUI/3D-карточкам и интерактивным объектам в третьем лице.

Не тронуты старые worker-файлы (roblox-shim.js и т.п.) — снос будет позже.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 00:09:49 +03:00
min
7389dfc660 feat(player): GameRuntime запускает Lua через LuaSharedSandbox + cmd-handlers (Фаза 2) 2026-06-10 00:02:52 +03:00
min
3478ffafd1 feat: перенос Lua-стека из студии (Фаза 1: shim + sandbox + LabelManager + rbxl-integration + HudOverlay) 2026-06-09 23:58:04 +03:00
min
f34320db91 feat(rbxl-import): Lua-runtime (wasmoon) для Roblox-скриптов
All checks were successful
CI / Lint (pull_request) Successful in 54s
CI / Build (pull_request) Successful in 1m33s
CI / Secret scan (pull_request) Successful in 20s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Часть тест-фичи импорта Roblox-карт (см. rublox/studio rbxl-importer/).

Что добавлено:
- wasmoon (Lua 5.4 WASM) как dep.
- RobloxLuaWorker.js — Worker-хост Lua-VM.
- RobloxLuaSandbox.js — main-side обёртка (по аналогии с ScriptSandbox).
- roblox-shim.js — math (Vector3/Color3/CFrame/UDim2),
  Instance прокси (game/workspace/script/GetService/IsA),
  Part свойства (Position/Color/Material/Anchored/CanCollide),
  RBXScriptSignal (Touched/Heartbeat/Stepped/Connect/Wait).
- roblox-scheduler.js — корутины + wait/task.wait/task.delay/task.spawn,
  автоматический fire Heartbeat/Stepped/RenderStepped на tick.
- roblox-tween.js — TweenService с 10 easing-функциями
  (Linear, Quad, Cubic, Quart, Quint, Sine, Bounce, Elastic, Back, Exponential).
- roblox-services.js — Players/LocalPlayer/Character/Humanoid
  (Health, WalkSpeed, JumpPower, TakeDamage, Died, LoadAnimation),
  UserInputService, RemoteEvent (FireServer/FireClient),
  RemoteFunction, DataStoreService, HttpService.
- roblox-physics.js — BodyVelocity/BodyGyro/BodyPosition/BodyForce/
  BodyAngularVelocity/AlignPosition/LinearVelocity.

Интеграция в GameRuntime:
- В start() проверяется script.kind === 'roblox-lua' →
  _startRobloxLuaScript() запускает RobloxLuaSandbox.
- _handleRobloxLuaCommand() мапит IPC команды (partSet/partVel/playerCmd)
  на PrimitiveManager и game.player API.
- _buildRobloxLuaSceneSnap() готовит snap для workspace:GetChildren.

Тесты: **36/36 passed**.
- mvp (9): math, Instance proxy, Part, IsA.
- wait (5): корутины, wait/task.wait/task.delay.
- tween (2): TweenInfo + Linear easing.
- services (8): Humanoid, DataStore, HttpService, RemoteEvent.
- integration (12): KillBrick, WalkSpeed, Tween-door, BodyVelocity конвейер,
  leaderstats, Checkpoint, циклы с wait, task.spawn, Color/Material,
  RemoteEvent client→server, Heartbeat, Vector3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 18:23:32 +03:00
14 changed files with 157 additions and 2299 deletions

View File

@ -1,198 +0,0 @@
/**
* GameLoadingScreen красивый экран загрузки игры в плеере (задача 05).
*
* Показывается пока грузится игра (после клика «Играть» на странице игры
* открытие плеера). Композиция как в Roblox:
* - размытый фон-обложка игры с медленным Ken Burns (pan + zoom);
* - карточка-витрина по центру (обложка игры);
* - крупное название места;
* - автор + verified-галочка;
* - прогресс-бар + спиннер «ЗАГРУЗКА».
*
* Данные берёт из меты игры (title/thumbnail/автор) и, если автор настроил в
* студии вкладку «Стартовый экран» из project_data.scene.loadingScreen
* (placeName / studioName / style / verified / background / cover).
*/
import React, { useEffect, useRef, useState } from 'react';
// Один раз вставляем CSS-keyframes (нельзя инлайнить в style).
let _cssInjected = false;
function injectCss() {
if (_cssInjected || typeof document === 'undefined') return;
_cssInjected = true;
const s = document.createElement('style');
s.id = 'kbn-game-loading-css';
s.textContent =
'@keyframes kbnGlsKen{0%{transform:scale(1.05) translate3d(0,0,0)}50%{transform:scale(1.15) translate3d(-3%,-2%,0)}100%{transform:scale(1.05) translate3d(-6%,0,0)}}' +
'.kbnGlsKen{animation:kbnGlsKen 22s ease-in-out infinite}' +
'@keyframes kbnGlsSpin{to{transform:rotate(360deg)}}' +
'.kbnGlsSpin{animation:kbnGlsSpin 0.85s linear infinite}' +
'@keyframes kbnGlsRise{0%{transform:translateY(0) scale(1);opacity:0}12%{opacity:.9}88%{opacity:.6}100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' +
'.kbnGlsP{animation:kbnGlsRise linear infinite}' +
'@keyframes kbnGlsGlow{0%,100%{box-shadow:0 18px 60px rgba(0,0,0,.6),0 0 0 rgba(120,160,255,0)}50%{box-shadow:0 18px 60px rgba(0,0,0,.6),0 0 44px rgba(120,160,255,.4)}}' +
'.kbnGlsCard{animation:kbnGlsGlow 4s ease-in-out infinite}' +
'@keyframes kbnGlsBar{0%{transform:translateX(-100%)}100%{transform:translateX(250%)}}' +
'.kbnGlsBarRun{animation:kbnGlsBar 1.2s ease-in-out infinite}' +
'@media (prefers-reduced-motion:reduce){.kbnGlsKen,.kbnGlsP,.kbnGlsCard,.kbnGlsBarRun{animation:none}}';
document.head.appendChild(s);
}
function VerifiedBadge() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" style={{ flex: '0 0 auto' }} aria-label="verified">
<circle cx="12" cy="12" r="11" fill="#3897f0" />
<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" strokeWidth="2.4"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/**
* props:
* meta ответ getProjectForPlay (title, thumbnail, author_username/username, ...)
* loadingScreen project_data.scene.loadingScreen (опц., настройки автора)
* progress 0..1 (если null «бегущая» полоса без процента)
*/
export default function GameLoadingScreen({ meta, loadingScreen, progress }) {
injectCss();
const ls = loadingScreen || {};
const [fade, setFade] = useState(0);
const rootRef = useRef(null);
useEffect(() => { const t = setTimeout(() => setFade(1), 20); return () => clearTimeout(t); }, []);
// Источники данных: настройки автора мета игры дефолт.
const bg = ls.background || meta?.thumbnail || null;
const cover = ls.cover || meta?.thumbnail || null;
const placeName = ls.placeName || meta?.title || 'Загрузка игры';
const studioName = ls.studioName
|| meta?.author_username || meta?.username || meta?.author || '';
const verified = ls.verified != null ? !!ls.verified
: !!(meta?.author_verified || meta?.is_verified);
const style = ls.style || 'ken-burns';
const accent = ls.accentColor || '#5fd0ff';
const hasProgress = typeof progress === 'number' && progress >= 0;
const pct = hasProgress ? Math.round(Math.max(0, Math.min(1, progress)) * 100) : null;
// parallax по мыши
const bgRef = useRef(null);
useEffect(() => {
if (style !== 'parallax' || !bgRef.current) return;
const h = (e) => {
const cx = (e.clientX / window.innerWidth - 0.5) * 26;
const cy = (e.clientY / window.innerHeight - 0.5) * 16;
if (bgRef.current) bgRef.current.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.1)`;
};
window.addEventListener('mousemove', h);
return () => window.removeEventListener('mousemove', h);
}, [style]);
const particles = style === 'particles'
? Array.from({ length: 24 }, (_, i) => {
const size = 2 + (i % 4);
const dur = 7 + (i % 7);
return (
<span key={i} className="kbnGlsP" style={{
position: 'absolute', bottom: -10, left: `${(i * 37) % 100}%`,
width: size, height: size, borderRadius: '50%',
background: `rgba(${180 + (i * 7) % 70},${190 + (i * 5) % 60},255,0.85)`,
boxShadow: `0 0 ${size * 2}px rgba(140,170,255,0.7)`,
animationDuration: `${dur}s`, animationDelay: `${-(i % 7)}s`,
}} />
);
}) : null;
return (
<div ref={rootRef} style={{
position: 'absolute', inset: 0, zIndex: 60, overflow: 'hidden',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: 'radial-gradient(ellipse at center, #0e1430 0%, #070a14 70%)',
opacity: fade, transition: 'opacity 0.4s ease',
fontFamily: 'system-ui,"Segoe UI",sans-serif',
}}>
{/* Фоновый слой (Ken Burns / parallax / static) */}
{bg && (
<div ref={bgRef}
className={style === 'ken-burns' ? 'kbnGlsKen' : undefined}
style={{
position: 'absolute', inset: '-8%', zIndex: 0,
backgroundImage: `url("${bg}")`, backgroundSize: 'cover', backgroundPosition: 'center',
filter: 'blur(9px) brightness(0.5)', willChange: 'transform',
transition: style === 'parallax' ? 'transform 0.25s ease-out' : 'none',
}} />
)}
{/* particles */}
{particles && <div style={{ position: 'absolute', inset: 0, zIndex: 1, pointerEvents: 'none' }}>{particles}</div>}
{/* Контент */}
<div style={{
position: 'relative', zIndex: 2, display: 'flex',
flexDirection: 'column', alignItems: 'center',
}}>
{/* Карточка-витрина */}
<div className="kbnGlsCard" style={{
width: 'min(40vw,300px)', aspectRatio: '1/1', borderRadius: 18,
backgroundImage: cover ? `url("${cover}")` : 'none',
backgroundColor: '#1a1f2b', backgroundSize: 'cover', backgroundPosition: 'center',
border: '2px solid rgba(255,255,255,0.14)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{!cover && (
<span style={{ color: '#5a6178', fontSize: 14, fontWeight: 700 }}>РУБЛОКС 3D</span>
)}
</div>
{/* Название места */}
<div style={{
marginTop: 22, color: '#fff', fontSize: 34, fontWeight: 800,
letterSpacing: 0.4, textAlign: 'center', maxWidth: '80vw',
textShadow: '0 3px 14px rgba(0,0,0,0.7)',
}}>{placeName}</div>
{/* Автор + verified */}
{studioName && (
<div style={{
marginTop: 8, display: 'flex', alignItems: 'center', gap: 7,
color: '#cdd6e6', fontSize: 16, fontWeight: 600,
textShadow: '0 1px 4px rgba(0,0,0,0.6)',
}}>
<span>{studioName}</span>
{verified && <VerifiedBadge />}
</div>
)}
{/* Прогресс-бар */}
<div style={{
marginTop: 26, width: 'min(64vw,420px)', height: 10, borderRadius: 6,
background: 'rgba(255,255,255,0.12)', overflow: 'hidden',
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.5)', position: 'relative',
}}>
{hasProgress ? (
<div style={{
height: '100%', width: `${pct}%`, borderRadius: 6,
background: `linear-gradient(90deg, ${accent}, #ffffff)`,
transition: 'width 0.2s linear', boxShadow: `0 0 10px ${accent}`,
}} />
) : (
<div className="kbnGlsBarRun" style={{
height: '100%', width: '40%', borderRadius: 6,
background: `linear-gradient(90deg, transparent, ${accent}, transparent)`,
}} />
)}
</div>
{/* Спиннер + статус */}
<div style={{
marginTop: 16, display: 'flex', alignItems: 'center', gap: 12,
color: '#fff', fontSize: 15, fontWeight: 700, letterSpacing: 0.5,
}}>
<span className="kbnGlsSpin" style={{
display: 'inline-block', width: 20, height: 20,
border: '3px solid rgba(255,255,255,0.25)', borderTopColor: accent, borderRadius: '50%',
}} />
{pct != null ? `${pct}%` : 'ЗАГРУЗКА'}
</div>
</div>
</div>
);
}

View File

@ -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="Разрешение рендера и тени"

View File

@ -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';
@ -23,25 +22,6 @@ import { useAuth } from '../auth/PlayerAuth';
import RublocsLogo from '../components/RublocsLogo/RublocsLogo'; import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../hooks/useDeviceType'; import useDeviceType from '../hooks/useDeviceType';
import KubikonMobileControls from './KubikonMobileControls'; import KubikonMobileControls from './KubikonMobileControls';
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(...) делаем
@ -58,12 +38,12 @@ function exitPlayer(gameId) {
// (флаг читает onBeforeUnload listener ниже). // (флаг читает onBeforeUnload listener ниже).
try { window.__rubloxExplicitExit = true; } catch {} try { window.__rubloxExplicitExit = true; } catch {}
const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
const RUBLOX_HOME = (env.VITE_RUBLOX_HOME || 'https://rublox.pro/app').replace(/\/+$/, ''); const RUBLOX_HOME = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app';
if (gameId) { if (gameId) {
// У страницы игры теперь свой URL: /app/game/<id> (им можно делиться). // Передаём gameId через ?game=<id> главный сайт прочитает и снова
// Возвращаемся прямо на него. base RUBLOX_HOME оканчивается на /app. // откроет карточку игры (юзер возвращается на ту же страницу).
const base = RUBLOX_HOME.endsWith('/app') ? RUBLOX_HOME : `${RUBLOX_HOME}/app`; const sep = RUBLOX_HOME.includes('?') ? '&' : '?';
window.location.assign(`${base}/game/${gameId}`); window.location.assign(`${RUBLOX_HOME}${sep}game=${gameId}`);
} else { } else {
window.location.assign(RUBLOX_HOME); window.location.assign(RUBLOX_HOME);
} }
@ -228,30 +208,18 @@ const KubikonPlayer = () => {
const roomRef = useRef(null); const roomRef = useRef(null);
/** MultiplayerSync (мост между room и Babylon-сценой). */ /** MultiplayerSync (мост между room и Babylon-сценой). */
const mpSyncRef = useRef(null); const mpSyncRef = useRef(null);
/** Выбранный Mixamo-скин текущего игрока (из rublox_equipped_skin). /** Выбранный R15-скин текущего игрока (из rublox_equipped_skin).
* Грузится при старте, уходит в мультиплеер как modelType. * Грузится при старте, уходит в мультиплеер как modelType. */
* 2026-06-13: дефолт сменён с skin_bacon-hair на skin_y-bot const skinFolderRef = useRef('skin_bacon-hair');
* (Игрек-Бот, новый Mixamo-каталог). */
const skinFolderRef = useRef('skin_y-bot');
const [meta, setMeta] = useState(null); // { title, description, user_id, ... } const [meta, setMeta] = useState(null); // { title, description, user_id, ... }
const [forbidden, setForbidden] = useState(false); const [forbidden, setForbidden] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Задача 05: конфиг красивого экрана загрузки (из project_data.scene.loadingScreen).
const [loadingScreenCfg, setLoadingScreenCfg] = useState(null);
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,59 +305,36 @@ const KubikonPlayer = () => {
return () => { active = false; }; return () => { active = false; };
}, [projectId]); }, [projectId]);
// Перехват системных Ctrl-комбинаций которые в WASD-игре регулярно // Перехват Ctrl-комбинаций которые ломают игру (Ctrl+W = закрыть вкладку,
// нажимаются случайно и приводят к закрытию вкладки / открытию диалогов. // Ctrl+R = reload, Ctrl+T/N мешают). Большинство браузеров блокирует
// В fullscreen Chrome даёт большинству этих хоткеев preventDefault'иться. // отмену системных шорткатов, но beforeunload даёт пользователю шанс
// // подтвердить выход. Также превентим preventDefault на keydown для
// 2026-06-14: добавлены KeyD (закладка), KeyS (сохранить страницу), // случаев когда фокус НЕ на window-уровне (Chrome иногда позволяет).
// KeyA (выделить всё), KeyP (печать), KeyU (исходник), KeyJ/KeyH (история).
// Все буквы которые могут зажиматься с 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), // Список «опасных» в игре сочетаний превентим
// Backspace = browser back (если фокус не на input), const dangerousCodes = ['KeyW', 'KeyR', 'KeyT', 'KeyN'];
// Tab мешает фокусом UI. if (dangerousCodes.includes(e.code)) {
// F11 ОСТАВЛЯЕМ даёт юзеру способ выйти из fullscreen.
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.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
} };
const onBeforeUnload = (e) => {
// Если юзер сам нажал «Покинуть» в меню пропускаем без
// подтверждения. Флаг ставит exitPlayer().
if (window.__rubloxExplicitExit) return undefined;
// Случайное закрытие вкладки (Ctrl+W, X-кнопка) показываем
// подтверждение чтобы не потерять прогресс игры.
e.preventDefault();
e.returnValue = '';
return '';
}; };
window.addEventListener('keydown', onKey, { capture: true }); window.addEventListener('keydown', onKey, { capture: true });
window.addEventListener('beforeunload', onBeforeUnload);
return () => { return () => {
window.removeEventListener('keydown', onKey, { capture: true }); window.removeEventListener('keydown', onKey, { capture: true });
window.removeEventListener('beforeunload', onBeforeUnload);
}; };
}, []); }, []);
@ -606,18 +551,11 @@ const KubikonPlayer = () => {
setMeta(data); setMeta(data);
setLikesCount(data.likes_count || 0); setLikesCount(data.likes_count || 0);
setDislikesCount(data.dislikes_count || 0); setDislikesCount(data.dislikes_count || 0);
setLoadProgress(0.3);
if (data.project_data) { if (data.project_data) {
const parsed = JSON.parse(data.project_data); const parsed = JSON.parse(data.project_data);
initialStateRef.current = parsed; initialStateRef.current = parsed;
// Задача 05: красивый экран загрузки конфиг автора (если задан в студии).
try {
const lsc = parsed?.scene?.loadingScreen;
if (lsc && typeof lsc === 'object' && lsc.enabled !== false) setLoadingScreenCfg(lsc);
} catch (e) { /* ignore */ }
await scene.loadFromState(parsed); await scene.loadFromState(parsed);
setLoadProgress(0.7);
} }
// Ждём пока Babylon реально загрузит и скомпилит все // Ждём пока Babylon реально загрузит и скомпилит все
@ -640,71 +578,20 @@ const KubikonPlayer = () => {
// тогда player.setModelType подхватит правильный скин. // тогда player.setModelType подхватит правильный скин.
// Этот же skinFolder уйдёт в мультиплеер как modelType, // Этот же skinFolder уйдёт в мультиплеер как modelType,
// чтобы соперники видели наш реальный скин. // чтобы соперники видели наш реальный скин.
// let mySkin = 'skin_bacon-hair';
// LOCAL DEV (localhost): берём скин из URL #skin=<id> if (userId) {
// (передаётся сайтом 3000 при нажатии «Играть»), потом из
// localStorage самого плеера, потом дефолт. В БД НЕ лезем
// прод-БД хранит легаси скины (skin_sigma-labubu и др.),
// которые мы не хотим грузить локально.
//
// PROD: только БД (rublox_equipped_skin).
let mySkin = 'skin_y-bot';
const isLocalDev = (typeof window !== 'undefined'
&& (window.location.hostname === 'localhost'
|| window.location.hostname === '127.0.0.1'));
// Источник скина по приоритету:
// 1) hash-параметр #skin=<id> в URL (передаёт сайт при play-ticket;
// работает и на localhost и на проде)
// 2) БД через /equipped-skin (если есть userId)
// 3) localStorage самого плеера (fallback на localhost для отладки)
// 4) skin_y-bot (дефолт)
try {
console.log('[KubikonPlayer] hash=', window.location.hash,
'| LS rublox_selected_skin=', (typeof localStorage !== 'undefined' ? localStorage.getItem('rublox_selected_skin') : '?'));
const m = window.location.hash.match(/[#&]skin=([\w-]+)/);
if (m && m[1]) {
mySkin = m[1];
console.log('[KubikonPlayer] skin from URL:', mySkin);
}
} catch (e) {}
if (mySkin === 'skin_y-bot' && userId) {
// 2) Лезем в БД (через прод-API). Бэк отдаёт либо
// выбранный валидный скин, либо дефолт по полу.
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) {}
setLoadProgress(1);
setLoading(false); setLoading(false);
// Засчитываем плей. Передаём user_id (если залогинен) // Засчитываем плей. Передаём user_id (если залогинен)
// это активирует self-cooldown (автор не накручивает себе) // это активирует self-cooldown (автор не накручивает себе)
@ -932,7 +819,7 @@ const KubikonPlayer = () => {
// загружен при старте в skinFolderRef). Сервер всё равно перепроверит // загружен при старте в skinFolderRef). Сервер всё равно перепроверит
// скин по userId из JWT и при расхождении возьмёт значение из БД // скин по userId из JWT и при расхождении возьмёт значение из БД
// так каждый игрок виден соперникам в своём реальном скине. // так каждый игрок виден соперникам в своём реальном скине.
const modelType = skinFolderRef.current || 'skin_y-bot'; const modelType = skinFolderRef.current || 'skin_bacon-hair';
// Если у нас есть валидный reconnectionToken от прошлой сессии // Если у нас есть валидный reconnectionToken от прошлой сессии
// используем Colyseus reconnect (это та же сессия для сервера, // используем Colyseus reconnect (это та же сессия для сервера,
// allowReconnection(5) её подхватит, не будет +join/-leave цикла). // allowReconnection(5) её подхватит, не будет +join/-leave цикла).
@ -1083,11 +970,6 @@ const KubikonPlayer = () => {
// Очищаем ref'ы иначе следующий connectMultiplayer выйдет // Очищаем ref'ы иначе следующий connectMultiplayer выйдет
// на if (mpSyncRef.current || roomRef.current) return. // на if (mpSyncRef.current || roomRef.current) return.
try { sync.stop?.(); } catch (e) {} try { sync.stop?.(); } catch (e) {}
// ВАЖНО: dispose() сносит ВСЕ старые меши remote-игроков со
// сцены. Без этого при auto-reconnect (Colyseus rejoin) новый
// MultiplayerSync видит пустую Map и при +remote создаёт
// дубль-меш на каждый кадр (см. фикс 2026-06-05).
try { sync.dispose?.(); } catch (e) {}
mpSyncRef.current = null; mpSyncRef.current = null;
roomRef.current = null; roomRef.current = null;
// Code 1000 / 1001 нормальное закрытие. Code >= 4000 наш // Code 1000 / 1001 нормальное закрытие. Code >= 4000 наш
@ -1145,29 +1027,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,78 +1133,45 @@ const KubikonPlayer = () => {
outline: 'none', outline: 'none',
}} }}
/> />
{/* GameLoadingScreen НЕ показывается при загрузке плейса. {/* Loading-оверлей */}
* Появляется ТОЛЬКО когда автор вызовет его из скрипта игры {loading && (
* (через game.showLoadingScreen или аналог). По дефолту игра
* открывается сразу, как в Roblox. */}
{/* 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={{ <div style={{
fontSize: 36, position: 'absolute', inset: 0,
fontWeight: 800, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
marginBottom: 14, background:
letterSpacing: '0.5px', 'radial-gradient(ellipse at center, rgba(51,87,255,0.22) 0%, rgba(7,10,20,0.96) 60%)',
gap: 18, color: HUD.text,
}}> }}>
Нажми чтобы играть <div style={{
position: 'relative',
animation: 'hudFloat 3s ease-in-out infinite',
}}>
<div style={{
position: 'absolute', inset: -10,
borderRadius: 20,
animation: 'hudPulseRing 1.6s ease-out infinite',
}} />
<RublocsLogo size={72} />
</div> </div>
<div style={{ <div style={{
fontSize: 16, display: 'flex', alignItems: 'center', gap: 10,
opacity: 0.75, fontSize: 15, fontWeight: 700, letterSpacing: 0.3,
maxWidth: 480,
textAlign: 'center',
lineHeight: 1.4,
padding: '0 24px',
}}> }}>
{IS_DESKTOP_APP ? ( <div style={{
<>Управление: <b>WASD</b> движение, <b>пробел</b> прыжок, width: 14, height: 14,
мышь камера.</> border: `2.5px solid ${HUD.accentBg}`,
) : ( borderTopColor: HUD.accent,
<> borderRadius: '50%',
Игра откроется в полноэкранном режиме animation: 'hudSpin 0.8s linear infinite',
это защитит от случайного закрытия вкладки }} />
(Ctrl+W, Ctrl+T и др.). Загрузка игры
<br /> </div>
Выход: <b>Esc</b> или <b>F11</b>. <div style={{
</> fontSize: 11, color: HUD.textDim,
)} textTransform: 'uppercase', letterSpacing: 1.4, fontWeight: 700,
}}>
Рублокс 3D
</div> </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> </div>
)} )}
@ -1581,14 +1413,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 +1435,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

@ -30,13 +30,10 @@ export const STORYS_addres = BASE + '/api-storys';
// env-настроенные прямые URL. // env-настроенные прямые URL.
const IS_HTTPS = typeof window !== 'undefined' && window.location.protocol === 'https:'; const IS_HTTPS = typeof window !== 'undefined' && window.location.protocol === 'https:';
// 2026-06-05: realtime теперь прямо на game.rublox.pro (S1 NPM → S1 VM 110),
// не через minecraftia-school.ru/api-game (лишний hop S2 NPM → S1 NAT
// давал разрывы WebSocket каждую секунду).
export const REALTIME_HTTP = ENV.VITE_REALTIME_HTTP export const REALTIME_HTTP = ENV.VITE_REALTIME_HTTP
?? (IS_HTTPS ? 'https://game.rublox.pro' : 'http://localhost:8685'); ?? (IS_HTTPS ? `${window.location.origin}/api-game` : 'http://localhost:8685');
export const REALTIME_WS = ENV.VITE_REALTIME_WS export const REALTIME_WS = ENV.VITE_REALTIME_WS
?? (IS_HTTPS ? 'wss://game.rublox.pro' : 'ws://localhost:8685'); ?? (IS_HTTPS ? `wss://${window.location.host}/api-game` : 'ws://localhost:8685');
// Главный сайт Рублокса — куда редиректить юзеров без ticket/JWT. // Главный сайт Рублокса — куда редиректить юзеров без ticket/JWT.
export const RUBLOX_HOME = ENV.VITE_RUBLOX_HOME ?? 'https://rublox.pro/app'; export const RUBLOX_HOME = ENV.VITE_RUBLOX_HOME ?? 'https://rublox.pro/app';

View File

@ -96,7 +96,6 @@ import { GdForest } from './GdForest';
import { GdPlayerCube } from './GdPlayerCube'; import { GdPlayerCube } from './GdPlayerCube';
import { GdPlayerTrail } from './GdPlayerTrail'; import { GdPlayerTrail } from './GdPlayerTrail';
import { GdPostFx } from './GdPostFx'; import { GdPostFx } from './GdPostFx';
import { GraphicsManager } from './GraphicsManager';
import { PhysicsAABB } from './PhysicsAABB'; import { PhysicsAABB } from './PhysicsAABB';
import { PlayerController } from './PlayerController'; import { PlayerController } from './PlayerController';
import { SelectionManager } from './SelectionManager'; import { SelectionManager } from './SelectionManager';
@ -199,9 +198,10 @@ export class BabylonScene {
// Точка спавна игрока в режиме Play (обновляется setSpawnPoint) // Точка спавна игрока в режиме Play (обновляется setSpawnPoint)
this._spawnPoint = { x: 0, y: 5, z: 0 }; this._spawnPoint = { x: 0, y: 5, z: 0 };
// Модель персонажа для режима Play. // Модель персонажа для режима Play.
// 2026-06-13: дефолт сменён на skin_y-bot (Mixamo Y-Bot, // Дефолт — R15-скин bacon-hair (классический Roblox-вид).
// нейтральный по полу). Старые скины (skin_bacon-hair и др.) удалены. // 'skin_*' грузится из characters/<id>/body.glb (R15-скелет),
this._playerModelType = 'skin_y-bot'; // 'character-*' — старые Kenney-модели.
this._playerModelType = 'skin_bacon-hair';
// Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z. // Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z.
// По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize(). // По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize().
this._worldHalf = 40; this._worldHalf = 40;
@ -1649,42 +1649,6 @@ export class BabylonScene {
this._ssaoEnabled = false; this._ssaoEnabled = false;
} }
/**
* Система графики/эффектов («шейдеры»). Лениво создаём GraphicsManager.
* Идентична студийной (фича-парность). Применяется при загрузке игры,
* если автор настроил graphics в проекте (и не 'off').
*/
_ensureGraphics() {
if (this._graphics) {
const cam = this.scene?.activeCamera || this.camera;
if (cam) this._graphics.setCamera(cam);
return this._graphics;
}
const cam = this.scene?.activeCamera || this.camera;
if (!this.scene || !cam) return null;
this._graphics = new GraphicsManager(this.scene, cam, this, {
mobile: !!this._isMobileMode,
});
return this._graphics;
}
setGraphics(settings) {
const g = this._ensureGraphics();
if (!g) return null;
const cfg = g.apply(settings || {});
this._graphicsConfig = cfg;
return cfg;
}
getGraphicsState() {
return this._graphics ? this._graphics.serialize() : (this._graphicsConfig || null);
}
disableGraphics() {
if (this._graphics) this._graphics.disableAll();
this._graphicsConfig = null;
}
/** /**
* Включить/выключить SSAO пост-эффект (контактные тени). * Включить/выключить SSAO пост-эффект (контактные тени).
* Используем SSAORenderingPipeline v1 v2 ломал thin-instance рендер * Используем SSAORenderingPipeline v1 v2 ломал thin-instance рендер
@ -1787,8 +1751,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;
@ -1799,9 +1763,6 @@ export class BabylonScene {
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).
@ -5475,56 +5436,6 @@ export class BabylonScene {
return this._isPlaying; return this._isPlaying;
} }
/** Задача 12+05: конфиг экрана загрузки из настроек проекта. */
setLoadingConfig(cfg, thumbnail) {
if (cfg && typeof cfg === 'object') {
this._loadingConfig = {
logo: cfg.logo || null,
accentColor: cfg.accentColor || '#ffc020',
defaultSpinner: cfg.defaultSpinner !== false,
defaultSkipButton: !!cfg.defaultSkipButton,
// Задача 05:
enabled: cfg.enabled !== false,
background: cfg.background || cfg.backgroundUrl || null,
cover: cfg.cover || cfg.coverUrl || null,
style: cfg.style || 'ken-burns',
placeName: cfg.placeName || '',
studioName: cfg.studioName || '',
verified: !!cfg.verified,
duration: Number.isFinite(cfg.duration) && cfg.duration > 0 ? Number(cfg.duration) : 2.5,
progressBar: cfg.progressBar !== false,
};
} else {
this._loadingConfig = null;
}
if (thumbnail !== undefined) this._projectThumbnail = thumbnail || null;
}
/** Задача 05: стартовый экран загрузки при входе в Play (Ken-Burns + название места). */
showStartupLoadingScreen() {
const cfg = this._loadingConfig;
if (!cfg || cfg.enabled === false) return;
if (!this.gameRuntime) return;
try {
const ls = this.gameRuntime._ensureLoadingScreen?.();
if (!ls) return;
ls.show({
style: cfg.style,
background: cfg.background || cfg.cover || this._projectThumbnail,
cover: cfg.cover || this._projectThumbnail,
placeName: cfg.placeName || this._projectName || '',
studioName: cfg.studioName || '',
verified: cfg.verified,
duration: cfg.duration,
progressBar: cfg.progressBar,
spinner: true,
bgColor: '#070a14',
pauseSimulation: false,
blockInput: true,
});
} catch (e) { /* ignore */ }
}
/** /**
* Переключить в режим игры. Создаём PlayerController, прячем ghost-блок, * Переключить в режим игры. Создаём PlayerController, прячем ghost-блок,
* запоминаем позицию редактор-камеры чтобы вернуть при exit. * запоминаем позицию редактор-камеры чтобы вернуть при exit.
@ -5638,9 +5549,6 @@ 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) {}
// НЕ показывать стартовый экран загрузки автоматически.
// По дефолту игра открывается мгновенно (как в Roblox). Экран загрузки
// только если автор явно вызовет showLoadingScreen() из скрипта.
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime. // Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным // ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
// this.audioManager (AudioManager — ambient/music для всех проектов). // this.audioManager (AudioManager — ambient/music для всех проектов).
@ -7526,9 +7434,7 @@ export class BabylonScene {
// форсим R15 bacon-hair. Явно выбранные 'skin_*' не трогаем. // форсим R15 bacon-hair. Явно выбранные 'skin_*' не трогаем.
if (state.scene.playerModelType) { if (state.scene.playerModelType) {
const pmt = state.scene.playerModelType; const pmt = state.scene.playerModelType;
// character-a..g (Kenney) и legacy R15 (skin_bacon-hair и др.) this._playerModelType = pmt.startsWith('character-') ? 'skin_bacon-hair' : pmt;
// мигрируем на новый дефолт skin_y-bot.
this._playerModelType = pmt.startsWith('character-') ? 'skin_y-bot' : pmt;
} }
// Задача 07: конфиг скинов { default, unlocked, shopVisible, coins, customGlbs }. // Задача 07: конфиг скинов { default, unlocked, shopVisible, coins, customGlbs }.
if (state.scene.skins && typeof state.scene.skins === 'object') { if (state.scene.skins && typeof state.scene.skins === 'object') {
@ -7548,9 +7454,15 @@ export class BabylonScene {
} else { } else {
this._skinsConfig = null; this._skinsConfig = null;
} }
// Задача 12+05: конфиг экрана загрузки (через setLoadingConfig — единый маппинг). // Задача 12: конфиг экрана загрузки.
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') { if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
this.setLoadingConfig(state.scene.loadingScreen); const ls = state.scene.loadingScreen;
this._loadingConfig = {
logo: ls.logo || null,
accentColor: ls.accentColor || '#ffc020',
defaultSpinner: ls.defaultSpinner !== false,
defaultSkipButton: !!ls.defaultSkipButton,
};
} else { } else {
this._loadingConfig = null; this._loadingConfig = null;
} }
@ -7676,11 +7588,6 @@ export class BabylonScene {
if (state.scene.environment && this.environment) { if (state.scene.environment && this.environment) {
this.environment.load(state.scene.environment); this.environment.load(state.scene.environment);
} }
// Графика/эффекты (шейдеры) — применяем если автор настроил и не 'off'.
if (state.scene.graphics && state.scene.graphics.preset
&& state.scene.graphics.preset !== 'off') {
try { this.setGraphics(state.scene.graphics); } catch (e) { /* ignore */ }
}
// Кастомное небо (задача 16) // Кастомное небо (задача 16)
if (state.scene.skybox && this.skybox) { if (state.scene.skybox && this.skybox) {
this.skybox.load(state.scene.skybox); this.skybox.load(state.scene.skybox);

View File

@ -537,8 +537,6 @@ export class GameRuntime {
ls.setBridge( ls.setBridge(
(id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingSkip', loadingId: id }); }, (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingSkip', loadingId: id }); },
(id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); }, (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); },
// Задача 05: onHide.
() => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingHidden' }); },
); );
this.scene3d.loadingScreen = ls; this.scene3d.loadingScreen = ls;
} }
@ -1982,9 +1980,9 @@ export class GameRuntime {
if (ls && payload) { if (ls && payload) {
try { try {
const id = ls.show(payload.opts || {}); const id = ls.show(payload.opts || {});
// replyId может отсутствовать (стартовый экран) — всё равно шлём if (payload.replyId != null) {
// loadingShown для game.loading.isVisible() (задача 05).
for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id }); for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id });
}
} catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); } } catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); }
} }
return; return;
@ -1992,7 +1990,6 @@ export class GameRuntime {
if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; } if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; }
if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; } if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; }
if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; } if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; }
if (cmd === 'loading.setBackground') { this.scene3d?.loadingScreen?.setBackground?.(payload?.background); return; }
if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; } if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; }
// === Damage Floaters (задача 40) === // === Damage Floaters (задача 40) ===
@ -3569,10 +3566,6 @@ export class GameRuntime {
} catch (e) {} } catch (e) {}
return; return;
} }
if (cmd === 'graphics.set') {
try { this.scene3d?.setGraphics?.(payload || {}); } catch (e) {}
return;
}
// === Задача 03: GUI tween === // === Задача 03: GUI tween ===
if (cmd === 'gui.tween') { if (cmd === 'gui.tween') {
try { try {

View File

@ -1,328 +0,0 @@
/**
* GraphicsManager система визуальных эффектов («шейдеры») для игр Рублокса.
*
* Управляет:
* - постобработкой экрана через Babylon DefaultRenderingPipeline:
* bloom (свечение), FXAA (сглаживание), виньетка, цветокоррекция
* (контраст/насыщенность/экспозиция), тонмаппинг, глубина резкости (DoF);
* - качеством теней (через scene3d.setShadowQuality);
* - контактными тенями SSAO (через scene3d.setSsaoEnabled).
*
* Управляется И из настроек игры (вкладка «Графика»), И из скриптов
* (game.graphics.*). По умолчанию ВСЁ ВЫКЛЮЧЕНО (preset 'off') старые игры
* не меняются, FPS не страдает. Автор включает осознанно.
*
* Mobile-safe: на слабых устройствах тяжёлые эффекты (DoF, SSAO, ultra-тени,
* HDR-bloom) автоматически урезаются, даже если в пресете включены.
*
* Один и тот же класс используется в студии и плеере (фича-парность).
*
* Использование:
* const gfx = new GraphicsManager(scene, camera, scene3d, { mobile });
* gfx.apply({ preset: 'cinematic' });
* gfx.apply({ bloom: { enabled: true, intensity: 0.7 } });
* gfx.dispose();
*/
import {
DefaultRenderingPipeline, Color4, ImageProcessingConfiguration,
} from '@babylonjs/core';
/**
* Именованные пресеты. Каждый полный набор настроек. 'off' = чистая картинка
* (pipeline не создаётся вовсе). Значения подобраны так, чтобы быть заметными,
* но не «кислотными».
*/
export const GRAPHICS_PRESETS = {
off: {
bloom: { enabled: false },
fxaa: false,
vignette: { enabled: false },
grading: { enabled: false },
dof: { enabled: false },
ssao: false,
shadows: null, // null = не трогаем текущее качество теней
},
// Лёгкий: только мягкое свечение + сглаживание. Дёшево, годится почти везде.
low: {
bloom: { enabled: true, intensity: 0.3, threshold: 0.9 },
fxaa: true,
vignette: { enabled: false },
grading: { enabled: false },
dof: { enabled: false },
ssao: false,
shadows: 'hard',
},
// Средний: свечение + лёгкая виньетка + чуть насыщенности.
medium: {
bloom: { enabled: true, intensity: 0.45, threshold: 0.85 },
fxaa: true,
vignette: { enabled: true, weight: 0.5 },
grading: { enabled: true, contrast: 1.05, saturation: 1.1, exposure: 1.0 },
dof: { enabled: false },
ssao: false,
shadows: 'soft',
},
// Высокий: всё кроме DoF, SSAO включён.
high: {
bloom: { enabled: true, intensity: 0.6, threshold: 0.82 },
fxaa: true,
vignette: { enabled: true, weight: 0.6 },
grading: { enabled: true, contrast: 1.1, saturation: 1.2, exposure: 1.05 },
dof: { enabled: false },
ssao: true,
shadows: 'soft',
},
// Ультра: + глубина резкости + мягкие каскадные тени.
ultra: {
bloom: { enabled: true, intensity: 0.7, threshold: 0.8 },
fxaa: true,
vignette: { enabled: true, weight: 0.65 },
grading: { enabled: true, contrast: 1.12, saturation: 1.25, exposure: 1.05 },
dof: { enabled: true, focusDistance: 18, focalLength: 50, aperture: 1.2 },
ssao: true,
shadows: 'high',
},
// === Стилевые пресеты (художественные) ===
cinematic: {
bloom: { enabled: true, intensity: 0.55, threshold: 0.8 },
fxaa: true,
vignette: { enabled: true, weight: 0.85 },
grading: { enabled: true, contrast: 1.18, saturation: 1.05, exposure: 1.0 },
dof: { enabled: true, focusDistance: 22, focalLength: 60, aperture: 1.0 },
ssao: true,
shadows: 'soft',
},
vivid: {
bloom: { enabled: true, intensity: 0.65, threshold: 0.78 },
fxaa: true,
vignette: { enabled: false },
grading: { enabled: true, contrast: 1.1, saturation: 1.5, exposure: 1.1 },
dof: { enabled: false },
ssao: false,
shadows: 'soft',
},
night: {
bloom: { enabled: true, intensity: 0.8, threshold: 0.7 },
fxaa: true,
vignette: { enabled: true, weight: 1.0 },
grading: { enabled: true, contrast: 1.2, saturation: 0.85, exposure: 0.8 },
dof: { enabled: false },
ssao: true,
shadows: 'soft',
},
retro: {
bloom: { enabled: false },
fxaa: false, // намеренно «пиксельно»
vignette: { enabled: true, weight: 1.2 },
grading: { enabled: true, contrast: 1.3, saturation: 0.7, exposure: 0.95 },
dof: { enabled: false },
ssao: false,
shadows: 'hard',
},
soft: {
bloom: { enabled: true, intensity: 0.4, threshold: 0.88 },
fxaa: true,
vignette: { enabled: true, weight: 0.4 },
grading: { enabled: true, contrast: 0.95, saturation: 1.05, exposure: 1.05 },
dof: { enabled: false },
ssao: false,
shadows: 'soft',
},
};
// Глубокое слияние пресета и пользовательских оверрайдов.
function _mergeConfig(base, over) {
const out = JSON.parse(JSON.stringify(base || {}));
if (!over) return out;
for (const k of Object.keys(over)) {
const v = over[k];
if (v && typeof v === 'object' && !Array.isArray(v)) {
out[k] = { ...(out[k] || {}), ...v };
} else {
out[k] = v;
}
}
return out;
}
export class GraphicsManager {
/**
* @param scene Babylon Scene
* @param camera активная камера (для pipeline)
* @param scene3d ссылка на BabylonScene (для setShadowQuality / setSsaoEnabled / света)
* @param opts { mobile:boolean }
*/
constructor(scene, camera, scene3d, opts = {}) {
this.scene = scene;
this.camera = camera;
this.scene3d = scene3d;
this.mobile = !!opts.mobile;
this._pipeline = null;
// Текущая активная конфигурация (после merge + mobile-clamp).
this.config = _mergeConfig(GRAPHICS_PRESETS.off, null);
this.config.preset = 'off';
this.enabled = false;
}
/** Сменить камеру (например после смены режима камеры) — пересобрать pipeline. */
setCamera(camera) {
if (camera === this.camera) return;
this.camera = camera;
if (this.enabled) this._rebuildPipeline();
}
/**
* Применить настройки графики. Принимает либо {preset}, либо отдельные
* секции (bloom/vignette/grading/dof/ssao/fxaa/shadows), либо и то и другое
* (оверрайды поверх пресета). Сохраняет состояние в this.config.
*/
apply(settings = {}) {
let cfg;
if (settings.preset && GRAPHICS_PRESETS[settings.preset]) {
cfg = _mergeConfig(GRAPHICS_PRESETS[settings.preset], settings);
cfg.preset = settings.preset;
} else {
// частичный апдейт поверх текущего
cfg = _mergeConfig(this.config, settings);
cfg.preset = settings.preset || this.config.preset || 'custom';
}
this.config = this._clampForMobile(cfg);
this._applyConfig();
return this.config;
}
/** Полностью выключить эффекты (как preset 'off'). */
disableAll() {
return this.apply({ preset: 'off' });
}
/** Текущая конфигурация (для serialize). */
serialize() {
// Храним «как просили» (preset + явные оверрайды). Для простоты — весь cfg.
return JSON.parse(JSON.stringify(this.config));
}
// --- внутреннее ---
/** На слабых устройствах гасим самое дорогое, что бы ни просили. */
_clampForMobile(cfg) {
if (!this.mobile) return cfg;
const c = JSON.parse(JSON.stringify(cfg));
if (c.dof) c.dof.enabled = false; // DoF дорогой
c.ssao = false; // SSAO дорогой
if (c.shadows === 'high' || c.shadows === 'medium') c.shadows = 'hard';
// bloom оставляем, но без HDR (решается в _rebuildPipeline)
c._mobileClamped = true;
return c;
}
_applyConfig() {
const c = this.config;
const anyPipelineFx = (c.bloom && c.bloom.enabled) || c.fxaa
|| (c.vignette && c.vignette.enabled) || (c.grading && c.grading.enabled)
|| (c.dof && c.dof.enabled);
// Тени и SSAO — через scene3d (они вне pipeline).
try {
if (c.shadows && this.scene3d?.setShadowQuality) {
this.scene3d.setShadowQuality(c.shadows);
}
} catch (e) { /* ignore */ }
try {
if (this.scene3d?.setSsaoEnabled) this.scene3d.setSsaoEnabled(!!c.ssao);
} catch (e) { /* ignore */ }
if (!anyPipelineFx) {
this.enabled = false;
this._disposePipeline();
return;
}
this.enabled = true;
this._rebuildPipeline();
}
_rebuildPipeline() {
this._disposePipeline();
if (!this.scene || !this.camera) return;
const c = this.config;
const useHdr = (c.bloom && c.bloom.enabled) && !this.mobile;
const p = new DefaultRenderingPipeline('rbx_graphics', useHdr, this.scene, [this.camera]);
// Bloom
p.bloomEnabled = !!(c.bloom && c.bloom.enabled);
if (p.bloomEnabled) {
p.bloomThreshold = c.bloom.threshold ?? 0.85;
p.bloomWeight = c.bloom.intensity ?? 0.5;
p.bloomKernel = this.mobile ? 32 : 64;
p.bloomScale = 0.5;
}
// FXAA
p.fxaaEnabled = !!c.fxaa;
p.samples = this.mobile ? 1 : 4;
// Image processing: виньетка + цветокоррекция + (опц.) тонмаппинг
const ip = p.imageProcessing;
if (ip) {
p.imageProcessingEnabled = true;
ip.toneMappingEnabled = false; // как в GdPostFx — иначе картинка темнеет
// экспозиция/контраст из grading
if (c.grading && c.grading.enabled) {
ip.exposure = c.grading.exposure ?? 1.0;
ip.contrast = c.grading.contrast ?? 1.0;
ip.colorCurvesEnabled = true;
try {
const curves = ip.colorCurves;
if (curves) {
// saturation: 1.0 = норма → curves в диапазоне примерно -100..100
const sat = c.grading.saturation ?? 1.0;
curves.globalSaturation = Math.round((sat - 1.0) * 60);
}
} catch (e) { /* ignore */ }
} else {
ip.exposure = 1.0; ip.contrast = 1.0;
}
// виньетка
if (c.vignette && c.vignette.enabled) {
ip.vignetteEnabled = true;
ip.vignetteWeight = c.vignette.weight ?? 0.6;
ip.vignetteColor = new Color4(0, 0, 0, 0);
ip.vignetteStretch = 0.3;
ip.vignetteCameraFov = 0.5;
ip.vignetteBlendMode = ImageProcessingConfiguration.VIGNETTEMODE_MULTIPLY;
} else {
ip.vignetteEnabled = false;
}
}
// Depth of Field (глубина резкости) — только desktop
if (c.dof && c.dof.enabled && !this.mobile) {
p.depthOfFieldEnabled = true;
try {
p.depthOfFieldBlurLevel = 1; // 0..2
p.depthOfField.focusDistance = (c.dof.focusDistance ?? 18) * 1000; // мм
p.depthOfField.focalLength = c.dof.focalLength ?? 50;
p.depthOfField.fStop = c.dof.aperture ?? 1.2;
} catch (e) { /* ignore */ }
} else {
p.depthOfFieldEnabled = false;
}
this._pipeline = p;
}
_disposePipeline() {
if (this._pipeline) {
try { this._pipeline.dispose(); } catch (e) { /* ignore */ }
this._pipeline = null;
}
}
dispose() {
this._disposePipeline();
this.scene = null;
this.camera = null;
this.scene3d = null;
}
}

View File

@ -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;
}
}

View File

@ -64,7 +64,7 @@ function loadSkinManifest() {
* @returns {Promise<{file:string, isR15:boolean, overrides:object}|null>} * @returns {Promise<{file:string, isR15:boolean, overrides:object}|null>}
*/ */
async function resolveRemoteModelSource(modelType) { async function resolveRemoteModelSource(modelType) {
const typeId = modelType || 'skin_y-bot'; const typeId = modelType || 'skin_bacon-hair';
if (typeId.startsWith('skin_')) { if (typeId.startsWith('skin_')) {
const manifest = await loadSkinManifest(); const manifest = await loadSkinManifest();
const entry = manifest.find((s) => s.id === typeId); const entry = manifest.find((s) => s.id === typeId);
@ -137,16 +137,9 @@ export class MultiplayerSync {
// 1. Подписки на state // 1. Подписки на state
const $ = getStateCallbacks(this.room); const $ = getStateCallbacks(this.room);
// Защита от повторного срабатывания onAdd (Colyseus 0.16 + immediate:true
// может триггерить .onAdd на каждый schema patch). Локальный set хранит
// sessionId которые уже обработаны в ТЕКУЩЕМ sync объекте.
const _addedSessionIds = new Set();
const handleAdd = (player, sessionId) => { const handleAdd = (player, sessionId) => {
// Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController // Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController
if (sessionId === this.room.sessionId) return; if (sessionId === this.room.sessionId) return;
// Защита от дублирующих onAdd событий для уже добавленного игрока
if (_addedSessionIds.has(sessionId)) return;
_addedSessionIds.add(sessionId);
this._addRemotePlayer(sessionId, player); this._addRemotePlayer(sessionId, player);
// Подписываемся на изменения этого Player'а // Подписываемся на изменения этого Player'а
$(player).onChange(() => this._updateRemoteTarget(sessionId, player)); $(player).onChange(() => this._updateRemoteTarget(sessionId, player));
@ -156,11 +149,7 @@ export class MultiplayerSync {
this._attachRemoteWeapon(sessionId, val || ''); this._attachRemoteWeapon(sessionId, val || '');
}); });
}; };
// Используем тот же set в handleRemove чтобы при настоящем уходе игрока
// потом можно было его снова добавить.
this._addedSessionIds = _addedSessionIds;
const handleRemove = (player, sessionId) => { const handleRemove = (player, sessionId) => {
if (this._addedSessionIds) this._addedSessionIds.delete(sessionId);
this._removeRemotePlayer(sessionId); this._removeRemotePlayer(sessionId);
}; };
@ -300,20 +289,8 @@ export class MultiplayerSync {
// Интерполяция remote-игроков (позиция + yaw ставится на root, // Интерполяция remote-игроков (позиция + yaw ставится на root,
// модель — child root'а — следует за ним). // модель — child root'а — следует за ним).
// 2026-06-05: читаем target напрямую из room.state.players —
// в Colyseus 0.16 onChange может не срабатывать для всех полей
// (особенно yaw/animState), а target.x/y/z/yaw обновляется
// через _updateRemoteTarget только из onChange. Подстраховка.
for (const rp of this.remotePlayers.values()) { for (const rp of this.remotePlayers.values()) {
if (!rp.root || !rp.target) continue; if (!rp.root || !rp.target) continue;
const livePlayer = this.room?.state?.players?.get?.(rp.sessionId);
if (livePlayer) {
rp.target.x = livePlayer.x;
rp.target.y = livePlayer.y;
rp.target.z = livePlayer.z;
rp.target.yaw = livePlayer.yaw || 0;
if (livePlayer.animState) rp.animState = livePlayer.animState;
}
const cur = rp.current; const cur = rp.current;
cur.x += (rp.target.x - cur.x) * LERP_FACTOR; cur.x += (rp.target.x - cur.x) * LERP_FACTOR;
cur.y += (rp.target.y - cur.y) * LERP_FACTOR; cur.y += (rp.target.y - cur.y) * LERP_FACTOR;
@ -355,25 +332,13 @@ export class MultiplayerSync {
// Развилка: R15-скины анимируются процедурно через R15Animator // Развилка: R15-скины анимируются процедурно через R15Animator
// (как локальный игрок), Kenney-модели — через glTF AnimationGroups. // (как локальный игрок), Kenney-модели — через glTF AnimationGroups.
if (rp.isR15 && rp.r15Animator && rp.modelLoaded) { if (rp.isR15 && rp.r15Animator && rp.modelLoaded) {
// Серверный animState: 'idle' | 'run' | 'jump' | 'fall' | 'attack'. // Серверный animState: 'idle' | 'run' | 'attack'. R15Animator
// R15Animator понимает idle/walk/run/jump/fall. // понимает idle/walk/run/jump/fall. Сервер не различает
// 2026-06-05: раньше run/jump/fall маппились в idle (баг // walk/run и не шлёт прыжки → маппим run→run, attack→idle
// в маппинге), из-за чего у remote-игроков не было // (атака показывается отдельным swing-ом руки ниже).
// анимации ни ходьбы, ни прыжка. Теперь пробрасываем const r15State = rp.isDead
// напрямую. attack показывается отдельным swing руки. ? 'idle'
let r15State; : (rp.animState === 'run' ? 'run' : 'idle');
if (rp.isDead) {
r15State = 'idle';
} else if (rp.animState === 'jump') {
r15State = 'jump';
} else if (rp.animState === 'fall') {
r15State = 'fall';
} else if (rp.animState === 'run') {
r15State = 'run';
} else {
// 'attack' или 'idle' или неизвестное — стоим
r15State = 'idle';
}
rp.r15Animator.setState(r15State); rp.r15Animator.setState(r15State);
rp.r15Animator.update(dt); rp.r15Animator.update(dt);
} else if (!rp.isR15) { } else if (!rp.isR15) {
@ -667,23 +632,6 @@ export class MultiplayerSync {
// === Внутреннее: меши remote-игроков === // === Внутреннее: меши remote-игроков ===
// ================================================================= // =================================================================
_addRemotePlayer(sessionId, player) { _addRemotePlayer(sessionId, player) {
// Защита от дублей при Colyseus reconnect: state получается заново
// и onAdd срабатывает для тех же sessionId. Без этой проверки в
// сцене появляются клоны игроков (см. issue после 2026-06-05).
if (this.remotePlayers && this.remotePlayers.has(sessionId)) {
const existing = this.remotePlayers.get(sessionId);
// Обновим target позицию и пометим что игрок жив
const sx2 = player.x || 0, sy2 = player.y || 0, sz2 = player.z || 0, yaw2 = player.yaw || 0;
existing.target = { x: sx2, y: sy2, z: sz2, yaw: yaw2 };
existing.username = player.username || sessionId;
existing.modelType = player.modelType || existing.modelType;
existing.hp = player.hp ?? existing.hp;
existing.maxHp = player.maxHp ?? existing.maxHp;
existing.isDead = !!player.isDead;
existing.animState = player.animState || existing.animState;
console.log(`[MultiplayerSync] re-add (reconnect): ${sessionId} (${player.username}) — обновили без пересоздания меша`);
return;
}
const sx = player.x || 0; const sx = player.x || 0;
const sy = player.y || 0; const sy = player.y || 0;
const sz = player.z || 0; const sz = player.z || 0;
@ -713,7 +661,7 @@ export class MultiplayerSync {
maxHp: player.maxHp ?? 100, maxHp: player.maxHp ?? 100,
isDead: !!player.isDead, isDead: !!player.isDead,
username: player.username || sessionId, username: player.username || sessionId,
modelType: player.modelType || 'skin_y-bot', modelType: player.modelType || 'skin_bacon-hair',
animState: player.animState || 'idle', animState: player.animState || 'idle',
// Если модель не успеет загрузиться, висит fallback-капсула. // Если модель не успеет загрузиться, висит fallback-капсула.
fallbackMesh: null, fallbackMesh: null,

View File

@ -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;
}
} }

View File

@ -28,35 +28,11 @@ 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';
// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом). // Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом).
// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа. // 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа.
/* Mixamo-скины (новые персонажи rublox-site /character-assets/skins/).
* 2026-06-11: эти 80 ID перенесены сюда из data/skinsCatalog.js фронта
* чтобы плеер их распознавал и грузил по правильному пути.
* Дефолтные: skin_x-bot (male), skin_y-bot (female/null). */
export const MIXAMO_SKINS = new Set([
'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas',
'skin_castle-guard-1', 'skin_castle-guard-2',
'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08',
'skin_ch09', 'skin_ch10', 'skin_ch11', 'skin_ch13', 'skin_ch14', 'skin_ch15',
'skin_ch16', 'skin_ch17', 'skin_ch18', 'skin_ch19', 'skin_ch20', 'skin_ch21',
'skin_ch22', 'skin_ch23', 'skin_ch24', 'skin_ch29', 'skin_ch31', 'skin_ch32',
'skin_ch33', 'skin_ch34', 'skin_ch35', 'skin_ch39', 'skin_ch40', 'skin_ch42',
'skin_ch43', 'skin_ch44', 'skin_ch45', 'skin_ch46', 'skin_ch47', 'skin_ch48',
'skin_claire', 'skin_demon', 'skin_ely', 'skin_erika-archer', 'skin_erika-archer-bow',
'skin_eve', 'skin_exo-gray', 'skin_exo-red', 'skin_ganfaul', 'skin_heraklios',
'skin_kachujin', 'skin_kaya', 'skin_knight', 'skin_lola', 'skin_maria',
'skin_maria-wprop', 'skin_maw', 'skin_medea', 'skin_mutant', 'skin_nightshade',
'skin_paladin', 'skin_passive-marker-man', 'skin_peasant-girl', 'skin_peasant-man',
'skin_prisoner', 'skin_pumpkinhulk', 'skin_skeleton-zombie', 'skin_sporty-granny',
'skin_survivor', 'skin_swat', 'skin_ty', 'skin_uriel', 'skin_vampire',
'skin_war-zombie', 'skin_warrok', 'skin_white-clown', 'skin_x-bot', 'skin_y-bot',
]);
const CAMERA_MODES = ['third', 'first', 'front']; const CAMERA_MODES = ['third', 'first', 'front'];
// Для режима 'sideview' (Кубикон Dash): // Для режима 'sideview' (Кубикон Dash):
// - камера фиксирована сбоку (смотрит на +Z с расстояния SIDEVIEW_DIST по -Z) // - камера фиксирована сбоку (смотрит на +Z с расстояния SIDEVIEW_DIST по -Z)
@ -107,11 +83,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;
@ -197,8 +168,8 @@ export class PlayerController {
this._stepUpDecay = 4.5; this._stepUpDecay = 4.5;
// Модель игрока (грузится в start) // Модель игрока (грузится в start)
// 2026-06-13: дефолт сменён на skin_y-bot (Mixamo Y-Bot). // Дефолт — R15-скин bacon-hair (классический Roblox-вид).
this._modelTypeId = 'skin_y-bot'; this._modelTypeId = 'skin_bacon-hair';
this._modelRoot = null; this._modelRoot = null;
this._modelMeshes = []; this._modelMeshes = [];
// Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет // Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет
@ -220,7 +191,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 +341,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;
@ -818,32 +786,19 @@ export class PlayerController {
return null; return null;
} }
if (typeId.startsWith('skin_')) { if (typeId.startsWith('skin_')) {
// 2026-06-11: палитра скинов Рублокса заменена на 80 Mixamo.
// Mixamo-скины: /character-assets/skins/<id>.glb (на rublox-site).
// Legacy-скины (skin_bacon-hair / skin_sigma-labubu / skin_cop / ...)
// ещё могут приходить из БД пока feature-flag в storys выключен —
// их грузим из старого /kubikon-assets/characters/<id>/body.glb
// (R15-скелет). После заливки 80 GLB на rublox.pro и включения
// RUBLOX_NEW_SKINS_AVAILABLE=true legacy-ветка перестанет
// срабатывать (бэк начнёт отдавать только новые типы).
if (MIXAMO_SKINS.has(typeId)) {
const base = (typeof window !== 'undefined'
&& window.location.hostname === 'localhost')
? 'http://localhost:3000'
: 'https://rublox.pro';
return {
file: `${base}/character-assets/skins/${typeId}.glb?v=20260614`,
isR15: false,
kind: 'non-humanoid-rigged', // Mixamo-rig, не R15
overrides: {},
isMixamo: true,
};
}
// Legacy R15-скин — через старый manifest.
const manifest = await this._loadSkinManifest(); const manifest = await this._loadSkinManifest();
const entry = manifest.find((s) => s.id === typeId); const entry = manifest.find((s) => s.id === typeId);
if (entry) { if (entry) {
// kind определяет систему анимации:
// 'r15' → R15-скелет (как раньше)
// 'non-humanoid-mesh' → single-mesh, процедурное покачивание
// 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup
// Отсутствие kind = 'r15' (обратная совместимость со старым манифестом).
const kind = entry.kind || 'r15'; const kind = entry.kind || 'r15';
// absolute_file=true (источник /rublox/avatars) — file уже
// полный URL (legacy /kubikon-assets/... или дизайнерский
// /api-storys/...). Без флага — это легаси-формат
// skins_manifest.json без префикса.
const file = entry.absolute_file const file = entry.absolute_file
? entry.file ? entry.file
: '/kubikon-assets/' + entry.file; : '/kubikon-assets/' + entry.file;
@ -857,7 +812,7 @@ export class PlayerController {
rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0, rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
}; };
} }
// нет ни в Mixamo, ни в manifest — пробуем прямой legacy-путь // нет в манифесте — пробуем прямой путь
return { return {
file: `/kubikon-assets/characters/${typeId}/body.glb`, file: `/kubikon-assets/characters/${typeId}/body.glb`,
isR15: true, isR15: true,
@ -1212,59 +1167,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 +1625,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 +2535,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 +2654,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 +2749,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,12 +2834,7 @@ 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(
// вручную (вылезание на площадку), коллизия не нужна.
const result = this._climbingTop
? { 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, this._pos, this.HALF_W, this.HALF_H, this.HALF_D,
moveX, this._vy * dt, moveZ moveX, this._vy * dt, moveZ
); );
@ -3256,44 +2978,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 +3088,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 +3193,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) {

View File

@ -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`;
@ -546,40 +485,12 @@ export class PrimitiveManager {
break; break;
case 'glass': case 'glass':
mat.alpha = 0.4; mat.alpha = 0.4;
mat.specularColor = new Color3(0.8, 0.85, 0.9); mat.specularColor = new Color3(0.5, 0.5, 0.5);
mat.specularPower = 96;
mat.backFaceCulling = false;
break; break;
case 'neon': case 'neon':
mat.emissiveColor = Color3.FromHexString(color || '#888888'); mat.emissiveColor = Color3.FromHexString(color || '#888888');
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
break; break;
case 'chrome': {
const cc = Color3.FromHexString(color || '#cfd6e0');
mat.diffuseColor = new Color3(cc.r * 0.6, cc.g * 0.6, cc.b * 0.6);
mat.specularColor = new Color3(1, 1, 1);
mat.specularPower = 128;
mat.emissiveColor = new Color3(cc.r * 0.12, cc.g * 0.12, cc.b * 0.14);
break;
}
case 'water': {
const wc = Color3.FromHexString(color || '#3aa0ff');
mat.diffuseColor = wc;
mat.alpha = 0.55;
mat.specularColor = new Color3(0.9, 0.95, 1.0);
mat.specularPower = 64;
mat.emissiveColor = new Color3(wc.r * 0.1, wc.g * 0.14, wc.b * 0.2);
mesh._isWater = true;
break;
}
case 'iridescent': {
const ic = Color3.FromHexString(color || '#a06bff');
mat.diffuseColor = ic;
mat.emissiveColor = new Color3(ic.r * 0.5, ic.g * 0.35, ic.b * 0.6);
mat.specularColor = new Color3(1, 1, 1);
mat.specularPower = 96;
break;
}
case 'studs': { case 'studs': {
// Лего-материал (паритет со студией): почти-белая diffuse × цвет // Лего-материал (паритет со студией): почти-белая diffuse × цвет
// меша + normal map. emissive = доля цвета → сочность (Roblox-look). // меша + normal map. emissive = доля цвета → сочность (Roblox-look).
@ -756,14 +667,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 +800,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 +876,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,

View File

@ -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'] },
]; ];

View File

@ -70,8 +70,6 @@ let _placeOnPlaceHandlers = [];
let _placeOnCancelHandlers = []; let _placeOnCancelHandlers = [];
let _placeOnMoveHandlers = []; let _placeOnMoveHandlers = [];
let _invUiSlotClickHandlers = []; let _invUiSlotClickHandlers = [];
// Задача 05: зеркало видимости экрана загрузки (для game.loading.isVisible()).
let _loadingVisible = false;
// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }. // Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }.
let _players = { me: null, list: [] }; let _players = { me: null, list: [] };
// Общее состояние комнаты game.room.get/set — зеркало из main thread. // Общее состояние комнаты game.room.get/set — зеркало из main thread.
@ -124,11 +122,11 @@ let _currentSkin = null;
let _skinChangeHandlers = []; let _skinChangeHandlers = [];
let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта) let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта)
// Phase 6.4 / задача 20: custom tools, leaderstats, achievements, remote events // Phase 6.4 / задача 20: custom tools, leaderstats, achievements, remote events
// (_lsMirror / _lsChangeHandlers / _achUnlocked уже объявлены выше у задачи 20 —
// здесь НЕ переобъявляем, иначе SyntaxError «already declared» рушит весь
// скриптинг плеера. Оставляем только уникальные для этого блока.)
let _toolSeq = 0; let _toolSeq = 0;
let _toolCallbacks = {}; // toolId → { activated, equipped, unequipped } let _toolCallbacks = {}; // toolId → { activated, equipped, unequipped }
let _lsMirror = {}; // playerId('@me'|sid) → { statName: value }
let _lsChangeHandlers = [];
let _achUnlocked = {}; // id → true
let _remoteHandlers = {}; // remoteName → [fn] let _remoteHandlers = {}; // remoteName → [fn]
// Подписки game.gui.onClick(id, fn) // Подписки game.gui.onClick(id, fn)
let _guiClickHandlers = {}; let _guiClickHandlers = {};
@ -2778,7 +2776,6 @@ const game = {
_localSeq: 0, _localSeq: 0,
_localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown) _localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown)
_handlers: new Map(), // localId → { onSkip:[], onComplete:[] } _handlers: new Map(), // localId → { onSkip:[], onComplete:[] }
_onHide: [], // задача 05 — глобальные подписки на скрытие
show(opts) { show(opts) {
opts = opts && typeof opts === 'object' ? opts : {}; opts = opts && typeof opts === 'object' ? opts : {};
const localId = ++this._localSeq; const localId = ++this._localSeq;
@ -2797,20 +2794,11 @@ const game = {
setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); }, setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); },
setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); }, setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); },
setCover(c) { _send('loading.setCover', { localId, cover: c }); }, setCover(c) { _send('loading.setCover', { localId, cover: c }); },
setBackground(b) { _send('loading.setBackground', { localId, background: b }); },
close() { _send('loading.close', { localId }); }, close() { _send('loading.close', { localId }); },
onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); }, onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); },
onComplete(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onComplete.push(fn); }, onComplete(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onComplete.push(fn); },
}; };
}, },
// --- Задача 05: управление активным экраном без хэндла (стартовый/любой текущий) ---
onHide(fn) { if (typeof fn === 'function') this._onHide.push(fn); },
setBackground(b) { _send('loading.setBackground', { background: b }); },
setText(t) { _send('loading.setText', { text: String(t == null ? '' : t) }); },
setCover(c) { _send('loading.setCover', { cover: c }); },
setProgress(v) { _send('loading.setProgress', { value: Number(v) || 0 }); },
hide() { _send('loading.close', {}); },
isVisible() { return !!_loadingVisible; },
/** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */ /** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */
transition(opts) { transition(opts) {
opts = opts && typeof opts === 'object' ? { ...opts } : {}; opts = opts && typeof opts === 'object' ? { ...opts } : {};
@ -3501,41 +3489,6 @@ const game = {
_send('environment.setTimeOfDay', { hours: h }); _send('environment.setTimeOfDay', { hours: h });
}, },
}, },
/**
* graphics визуальные эффекты («шейдеры»): постобработка, свечение,
* цветокоррекция, тени. По умолчанию всё выключено.
*/
graphics: {
setPreset(preset) {
if (typeof preset !== 'string') return;
_send('graphics.set', { preset });
},
set(settings) {
if (typeof settings !== 'object' || !settings) return;
_send('graphics.set', settings);
},
setBloom(on, opts) {
_send('graphics.set', { bloom: { enabled: !!on, ...(opts || {}) } });
},
setVignette(weight) {
const w = Number(weight) || 0;
_send('graphics.set', { vignette: { enabled: w > 0, weight: w } });
},
setColorGrading(opts) {
if (typeof opts !== 'object' || !opts) return;
_send('graphics.set', { grading: { enabled: true, ...opts } });
},
setAntialiasing(on) { _send('graphics.set', { fxaa: !!on }); },
setDepthOfField(on, opts) {
_send('graphics.set', { dof: { enabled: !!on, ...(opts || {}) } });
},
setShadows(quality) {
if (typeof quality !== 'string') return;
_send('graphics.set', { shadows: quality });
},
setSSAO(on) { _send('graphics.set', { ssao: !!on }); },
off() { _send('graphics.set', { preset: 'off' }); },
},
/** /**
* Управление режимами ввода курсор и камера. * Управление режимами ввода курсор и камера.
* В режиме 'ui' мышь работает как обычный курсор (как в браузере), * В режиме 'ui' мышь работает как обычный курсор (как в браузере),
@ -4317,7 +4270,6 @@ self.onmessage = (e) => {
} catch (e) {} } catch (e) {}
} else if (t === 'loadingShown') { } else if (t === 'loadingShown') {
// Задача 12: реальный loadingId от runtime — маппим local→real. // Задача 12: реальный loadingId от runtime — маппим local→real.
_loadingVisible = true;
try { try {
const lo = (typeof game !== 'undefined') && game.loading; const lo = (typeof game !== 'undefined') && game.loading;
if (lo && payload && payload.replyId) { if (lo && payload && payload.replyId) {
@ -4327,13 +4279,6 @@ self.onmessage = (e) => {
} }
} }
} catch (e) {} } catch (e) {}
} else if (t === 'loadingHidden') {
// Задача 05: экран скрылся — зеркало + onHide-подписки.
_loadingVisible = false;
try {
const lo = (typeof game !== 'undefined') && game.loading;
if (lo) for (const fn of (lo._onHide || [])) _safeCall(fn, undefined, 'loading.onHide');
} catch (e) {}
} else if (t === 'loadingSkip' || t === 'loadingComplete') { } else if (t === 'loadingSkip' || t === 'loadingComplete') {
// Игрок нажал «Пропустить» или прогресс достиг 100% — зовём подписчиков. // Игрок нажал «Пропустить» или прогресс достиг 100% — зовём подписчиков.
try { try {