Compare commits
39 Commits
feat/vehic
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe86ee723 | |||
|
|
86620eee1c | ||
| e0f5ac9a29 | |||
|
|
f77a741428 | ||
| f2b74a2597 | |||
|
|
3754ecf4a1 | ||
| b2b0eab546 | |||
|
|
71139def77 | ||
|
|
2294981597 | ||
|
|
42b3c26382 | ||
|
|
6782a42ba3 | ||
|
|
4db93592d2 | ||
|
|
eef7008416 | ||
| 308b183db1 | |||
|
|
fc45d819e0 | ||
| 6c07a9f679 | |||
|
|
ca1ce23205 | ||
| 831b525cfc | |||
|
|
dbdd61b4d6 | ||
|
|
8047cd366c | ||
| 62ff0b0100 | |||
|
|
91870d7a09 | ||
| 830f4b8f4a | |||
|
|
94da0e1409 | ||
| 7d6e14a08f | |||
|
|
494ccd2358 | ||
|
|
1d0be0fa6a | ||
| c05ab68e6b | |||
|
|
39eae607e1 | ||
|
|
ccf76d539b | ||
| a5e1558c2d | |||
|
|
f5a96fbec0 | ||
|
|
247a5703c9 | ||
| 3330715781 | |||
|
|
f4a1feb41d | ||
|
|
71f9d4dd11 | ||
| 84fd2d996e | |||
|
|
5a6a222c78 | ||
| 66d74b823f |
11
.WORKTREE_NOTICE.md
Normal file
11
.WORKTREE_NOTICE.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Активная сессия: импорт Roblox .rbxl
|
||||||
|
|
||||||
|
Это **отдельный worktree** для разработки `feat/rbxl-import` — импорта Roblox-карт в Rublox.
|
||||||
|
|
||||||
|
**Не работайте здесь параллельно из других сессий!**
|
||||||
|
|
||||||
|
Ветка: `feat/rbxl-import`
|
||||||
|
Сервис на сервере: VM 130 на S1
|
||||||
|
Сопутствующий worktree: `Desktop/studio-rbxl-import`
|
||||||
|
|
||||||
|
Started: 2026-06-07
|
||||||
5
.env.production
Normal file
5
.env.production
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
VITE_API_BASE=https://minecraftia-school.ru
|
||||||
|
VITE_REALTIME_HTTP=https://minecraftia-school.ru/api-game
|
||||||
|
VITE_REALTIME_WS=wss://minecraftia-school.ru/api-game
|
||||||
|
VITE_RUBLOX_HOME=https://rublox.pro/app
|
||||||
|
VITE_STANDALONE=false
|
||||||
41
.gitignore
vendored
41
.gitignore
vendored
@ -41,4 +41,43 @@ public/kubikon-assets/
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.env.production
|
|
||||||
|
# ============================================================
|
||||||
|
# SECURITY — добавлено после взлома 2026-06-04
|
||||||
|
# НИКОГДА не коммитить эти файлы — они могут содержать секреты!
|
||||||
|
# ============================================================
|
||||||
|
CLAUDE.md
|
||||||
|
INFO_PROCESS.md
|
||||||
|
PASSWORD_*.md
|
||||||
|
SECRETS*
|
||||||
|
*_SECRETS*
|
||||||
|
*.kdbx
|
||||||
|
*.kdbx.bak
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.sample
|
||||||
|
# .env.production содержит ТОЛЬКО публичные URL (api-base, realtime, rublox.pro)
|
||||||
|
# — без секретов. Нужен в git, чтобы CI собирал прод-бандл с правильным
|
||||||
|
# VITE_API_BASE (иначе API уходит на origin вместо minecraftia-school.ru,
|
||||||
|
# redeem-ticket падает → плеер выбивает на /app). Инцидент 2026-06-07.
|
||||||
|
!.env.production
|
||||||
|
secrets/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
id_rsa
|
||||||
|
id_ed25519
|
||||||
|
known_hosts
|
||||||
|
authorized_keys
|
||||||
|
|
||||||
|
# Текстовые заметки разработчика (могут содержать всё что угодно)
|
||||||
|
NOTES*.md
|
||||||
|
TODO*.md
|
||||||
|
PRIVATE*.md
|
||||||
|
INTERNAL_*.md
|
||||||
|
|
||||||
|
# Бэкапы кода с предыдущих версий
|
||||||
|
*.bak
|
||||||
|
*.bak_*
|
||||||
|
BackUp/
|
||||||
|
backup/
|
||||||
|
|||||||
21
package-lock.json
generated
21
package-lock.json
generated
@ -18,7 +18,8 @@
|
|||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router-dom": "7.4.0",
|
"react-router-dom": "7.4.0",
|
||||||
"socket.io-client": "^4.8.3"
|
"socket.io-client": "^4.8.3",
|
||||||
|
"wasmoon": "^1.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
@ -1427,6 +1428,12 @@
|
|||||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/emscripten": {
|
||||||
|
"version": "1.39.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.10.tgz",
|
||||||
|
"integrity": "sha512-TB/6hBkYQJxsZHSqyeuO1Jt0AB/bW6G7rHt9g7lML7SOF6lbgcHvw/Lr+69iqN0qxgXLhWKScAon73JNnptuDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@ -5206,6 +5213,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wasmoon": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wasmoon/-/wasmoon-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-FlRLb15WwAOz1A9OQDbf6oOKKSiefi5VK0ZRF2wgH9xk3o5SnU11tNPaOnQuAh1Ucr66cwwvVXaeVRaFdRBt5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/emscripten": "1.39.10"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"wasmoon": "bin/wasmoon"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@ -49,7 +49,8 @@
|
|||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router-dom": "7.4.0",
|
"react-router-dom": "7.4.0",
|
||||||
"socket.io-client": "^4.8.3"
|
"socket.io-client": "^4.8.3",
|
||||||
|
"wasmoon": "^1.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
|
|||||||
198
src/KubikonPlayer/GameLoadingScreen.jsx
Normal file
198
src/KubikonPlayer/GameLoadingScreen.jsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
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 {
|
||||||
@ -43,6 +44,10 @@ 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: 'Настройки' },
|
||||||
@ -63,14 +68,24 @@ export default function GameMenu({
|
|||||||
}) {
|
}) {
|
||||||
const [activeTab, setActiveTab] = useState('people');
|
const [activeTab, setActiveTab] = useState('people');
|
||||||
|
|
||||||
// ESC закрывает меню. Регистрируем в capture-фазе чтобы не конфликтовать
|
// Закрытие меню:
|
||||||
// с pointer-lock логикой KubikonPlayer.
|
// • НЕ fullscreen — Esc (классика)
|
||||||
|
// • Fullscreen — Tab (Esc отдаётся браузеру для выхода из FS)
|
||||||
|
// Регистрируем в capture-фазе чтобы не конфликтовать с pointer-lock.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
if (e.key === 'Escape') {
|
const isFs = !!(typeof document !== 'undefined' && document.fullscreenElement);
|
||||||
|
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?.();
|
||||||
@ -218,9 +233,21 @@ function TabBar({ activeTab, onTab }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
// BottomBar — 3 кнопки: L Покинуть / R Возродиться / Esc Продолжить
|
// BottomBar — 3 кнопки: L Покинуть / R Возродиться / Esc(Tab) Продолжить
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
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={{
|
||||||
@ -233,7 +260,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="Esc" label="Продолжить" onClick={onResume} variant="primary" />
|
<ActionBtn hotkey={resumeKey} label="Продолжить" onClick={onResume} variant="primary" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -565,19 +592,39 @@ 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 (картинка персонажа — bacon/imposter/etc) — главный
|
// Аватар: 1) skin PNG (картинка персонажа) — главный
|
||||||
// 2) photo_thumb_b64 (аватар майнкрафтии, fallback)
|
// 2) photo_thumb_b64 (аватар майнкрафтии, fallback)
|
||||||
// 3) photo URL (старое поле — fallback)
|
// 3) photo URL (старое поле — fallback)
|
||||||
// 4) буква-инициал
|
// 4) буква-инициал
|
||||||
//
|
//
|
||||||
// Скины лежат в /kubikon-assets/characters/<slug>/avatar.png — это PNG
|
// 2026-06-14: Mixamo-скины (skin_y-bot, skin_x-bot и т.д.) лежат на
|
||||||
// персонажа в полный рост. Совпадает с Godot/exe-плеером.
|
// rublox-site в /character-assets/skins/<slug>.png. Legacy R15-скины
|
||||||
|
// (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') {
|
||||||
// cache-bust обязателен: на 2026-05-27 фиксили 404 на этом пути,
|
// ВАЛИДАЦИЯ: legacy R15-скины (bacon-hair и пр.) больше не существуют.
|
||||||
// браузеры успели закэшировать негативный ответ
|
// Если скин НЕ в наборе валидных Mixamo (80 шт) — показываем аватар
|
||||||
avatarUrl = `/kubikon-assets/characters/${player.skin}/avatar.png?v=2026_05_27`;
|
// дефолтного skin_y-bot (как и в самой игре 3D-модель валидируется).
|
||||||
|
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:')
|
||||||
@ -888,6 +935,7 @@ function TabSettings({ sceneRef }) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsSection title="Экран" />
|
<SettingsSection title="Экран" />
|
||||||
|
{!IS_DESKTOP_APP && (
|
||||||
<ArrowsRow
|
<ArrowsRow
|
||||||
label="Полноэкранный режим"
|
label="Полноэкранный режим"
|
||||||
hint="Развернуть игру на весь экран"
|
hint="Развернуть игру на весь экран"
|
||||||
@ -904,6 +952,7 @@ function TabSettings({ sceneRef }) {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<SliderRow
|
<SliderRow
|
||||||
label="Качество графики"
|
label="Качество графики"
|
||||||
hint="Разрешение рендера и тени"
|
hint="Разрешение рендера и тени"
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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';
|
||||||
@ -22,6 +23,25 @@ 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(...) делаем
|
||||||
@ -38,12 +58,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';
|
const RUBLOX_HOME = (env.VITE_RUBLOX_HOME || 'https://rublox.pro/app').replace(/\/+$/, '');
|
||||||
if (gameId) {
|
if (gameId) {
|
||||||
// Передаём gameId через ?game=<id> — главный сайт прочитает и снова
|
// У страницы игры теперь свой URL: /app/game/<id> (им можно делиться).
|
||||||
// откроет карточку игры (юзер возвращается на ту же страницу).
|
// Возвращаемся прямо на него. base RUBLOX_HOME оканчивается на /app.
|
||||||
const sep = RUBLOX_HOME.includes('?') ? '&' : '?';
|
const base = RUBLOX_HOME.endsWith('/app') ? RUBLOX_HOME : `${RUBLOX_HOME}/app`;
|
||||||
window.location.assign(`${RUBLOX_HOME}${sep}game=${gameId}`);
|
window.location.assign(`${base}/game/${gameId}`);
|
||||||
} else {
|
} else {
|
||||||
window.location.assign(RUBLOX_HOME);
|
window.location.assign(RUBLOX_HOME);
|
||||||
}
|
}
|
||||||
@ -208,18 +228,30 @@ const KubikonPlayer = () => {
|
|||||||
const roomRef = useRef(null);
|
const roomRef = useRef(null);
|
||||||
/** MultiplayerSync (мост между room и Babylon-сценой). */
|
/** MultiplayerSync (мост между room и Babylon-сценой). */
|
||||||
const mpSyncRef = useRef(null);
|
const mpSyncRef = useRef(null);
|
||||||
/** Выбранный R15-скин текущего игрока (из rublox_equipped_skin).
|
/** Выбранный Mixamo-скин текущего игрока (из rublox_equipped_skin).
|
||||||
* Грузится при старте, уходит в мультиплеер как modelType. */
|
* Грузится при старте, уходит в мультиплеер как modelType.
|
||||||
const skinFolderRef = useRef('skin_bacon-hair');
|
* 2026-06-13: дефолт сменён с skin_bacon-hair на skin_y-bot
|
||||||
|
* (Игрек-Бот, новый 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);
|
||||||
// Раньше была стартовая заглушка «тапни чтобы начать» — убрали по
|
// Раньше была стартовая заглушка «тапни чтобы начать» — убрали по
|
||||||
// фидбэку, она бесила. Теперь fullscreen опционально через кнопку
|
// фидбэку, она бесила. Этот state остался для совместимости.
|
||||||
// в углу. Этот 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);
|
||||||
@ -305,36 +337,59 @@ const KubikonPlayer = () => {
|
|||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
// Перехват Ctrl-комбинаций которые ломают игру (Ctrl+W = закрыть вкладку,
|
// Перехват системных Ctrl-комбинаций которые в WASD-игре регулярно
|
||||||
// Ctrl+R = reload, Ctrl+T/N — мешают). Большинство браузеров блокирует
|
// нажимаются случайно и приводят к закрытию вкладки / открытию диалогов.
|
||||||
// отмену системных шорткатов, но beforeunload даёт пользователю шанс
|
// В fullscreen Chrome даёт большинству этих хоткеев preventDefault'иться.
|
||||||
// подтвердить выход. Также превентим preventDefault на keydown для
|
//
|
||||||
// случаев когда фокус НЕ на window-уровне (Chrome иногда позволяет).
|
// 2026-06-14: добавлены KeyD (закладка), KeyS (сохранить страницу),
|
||||||
|
// KeyA (выделить всё), KeyP (печать), KeyU (исходник), KeyJ/KeyH (история).
|
||||||
|
// Все буквы которые могут зажиматься с Ctrl во время WASD-управления.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
if (!e.ctrlKey && !e.metaKey) return;
|
// 1. Системные F-клавиши и навигация в любой момент:
|
||||||
// Список «опасных» в игре сочетаний — превентим
|
// F5 = reload, F11 = fullscreen toggle (нужен Esc-way),
|
||||||
const dangerousCodes = ['KeyW', 'KeyR', 'KeyT', 'KeyN'];
|
// Backspace = browser back (если фокус не на input),
|
||||||
if (dangerousCodes.includes(e.code)) {
|
// Tab — мешает фокусом UI.
|
||||||
|
// 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);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -551,11 +606,18 @@ 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 реально загрузит и скомпилит все
|
||||||
@ -578,20 +640,71 @@ const KubikonPlayer = () => {
|
|||||||
// тогда player.setModelType подхватит правильный скин.
|
// тогда player.setModelType подхватит правильный скин.
|
||||||
// Этот же skinFolder уйдёт в мультиплеер как modelType,
|
// Этот же skinFolder уйдёт в мультиплеер как modelType,
|
||||||
// чтобы соперники видели наш реальный скин.
|
// чтобы соперники видели наш реальный скин.
|
||||||
let mySkin = 'skin_bacon-hair';
|
//
|
||||||
if (userId) {
|
// LOCAL DEV (localhost): берём скин из URL #skin=<id>
|
||||||
|
// (передаётся сайтом 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') mySkin = sf;
|
if (sf && typeof sf === 'string') {
|
||||||
|
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 (автор не накручивает себе)
|
||||||
@ -819,7 +932,7 @@ const KubikonPlayer = () => {
|
|||||||
// загружен при старте в skinFolderRef). Сервер всё равно перепроверит
|
// загружен при старте в skinFolderRef). Сервер всё равно перепроверит
|
||||||
// скин по userId из JWT и при расхождении возьмёт значение из БД —
|
// скин по userId из JWT и при расхождении возьмёт значение из БД —
|
||||||
// так каждый игрок виден соперникам в своём реальном скине.
|
// так каждый игрок виден соперникам в своём реальном скине.
|
||||||
const modelType = skinFolderRef.current || 'skin_bacon-hair';
|
const modelType = skinFolderRef.current || 'skin_y-bot';
|
||||||
// Если у нас есть валидный reconnectionToken от прошлой сессии —
|
// Если у нас есть валидный reconnectionToken от прошлой сессии —
|
||||||
// используем Colyseus reconnect (это та же сессия для сервера,
|
// используем Colyseus reconnect (это та же сессия для сервера,
|
||||||
// allowReconnection(5) её подхватит, не будет +join/-leave цикла).
|
// allowReconnection(5) её подхватит, не будет +join/-leave цикла).
|
||||||
@ -970,6 +1083,11 @@ 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 — наш
|
||||||
@ -1027,12 +1145,29 @@ const KubikonPlayer = () => {
|
|||||||
|| root.webkitRequestFullscreen
|
|| root.webkitRequestFullscreen
|
||||||
|| root.mozRequestFullScreen
|
|| root.mozRequestFullScreen
|
||||||
|| root.msRequestFullscreen;
|
|| root.msRequestFullscreen;
|
||||||
if (req) {
|
// В нативном приложении (Electron/Capacitor) окно и так на весь
|
||||||
|
// экран — 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(() => {
|
||||||
@ -1133,45 +1268,78 @@ const KubikonPlayer = () => {
|
|||||||
outline: 'none',
|
outline: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Loading-оверлей */}
|
{/* GameLoadingScreen НЕ показывается при загрузке плейса.
|
||||||
{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={{
|
||||||
position: 'absolute', inset: 0,
|
fontSize: 36,
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
fontWeight: 800,
|
||||||
background:
|
marginBottom: 14,
|
||||||
'radial-gradient(ellipse at center, rgba(51,87,255,0.22) 0%, rgba(7,10,20,0.96) 60%)',
|
letterSpacing: '0.5px',
|
||||||
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={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
fontSize: 16,
|
||||||
fontSize: 15, fontWeight: 700, letterSpacing: 0.3,
|
opacity: 0.75,
|
||||||
|
maxWidth: 480,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
padding: '0 24px',
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
{IS_DESKTOP_APP ? (
|
||||||
width: 14, height: 14,
|
<>Управление: <b>WASD</b> — движение, <b>пробел</b> — прыжок,
|
||||||
border: `2.5px solid ${HUD.accentBg}`,
|
мышь — камера.</>
|
||||||
borderTopColor: HUD.accent,
|
) : (
|
||||||
borderRadius: '50%',
|
<>
|
||||||
animation: 'hudSpin 0.8s linear infinite',
|
Игра откроется в полноэкранном режиме —
|
||||||
}} />
|
это защитит от случайного закрытия вкладки
|
||||||
Загрузка игры…
|
(Ctrl+W, Ctrl+T и др.).
|
||||||
</div>
|
<br />
|
||||||
<div style={{
|
Выход: <b>Esc</b> или <b>F11</b>.
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1413,11 +1581,14 @@ const KubikonPlayer = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Кнопка «полный экран» — маленькая, в правом верхнем углу,
|
{/* Кнопка «полный экран» — маленькая, в правом верхнем углу.
|
||||||
только на тач-устройствах. Браузеры требуют user gesture
|
Показывается на ВСЕХ устройствах (desktop + touch):
|
||||||
для requestFullscreen() — поэтому без кнопки никак.
|
- touch — нужна чтобы скрыть UI браузера на телефоне
|
||||||
Кнопка автоматически скрывается после входа в fullscreen. */}
|
- desktop — в fullscreen Chrome блокирует Ctrl+W/Ctrl+T
|
||||||
{isTouch && !loading && !document.fullscreenElement && (
|
и прочие системные хоткеи, которые иначе закрывают вкладку.
|
||||||
|
Браузеры требуют user gesture для requestFullscreen() —
|
||||||
|
поэтому без кнопки никак. */}
|
||||||
|
{!loading && !document.fullscreenElement && (
|
||||||
<button
|
<button
|
||||||
data-mobile-hud="fullscreen"
|
data-mobile-hud="fullscreen"
|
||||||
onClick={handleMobileStart}
|
onClick={handleMobileStart}
|
||||||
@ -1435,7 +1606,7 @@ const KubikonPlayer = () => {
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
WebkitTapHighlightColor: 'transparent',
|
WebkitTapHighlightColor: 'transparent',
|
||||||
}}
|
}}
|
||||||
title="Полноэкранный режим"
|
title="Полный экран (блокирует Ctrl+W и др. системные хоткеи)"
|
||||||
>
|
>
|
||||||
<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"
|
||||||
|
|||||||
@ -30,10 +30,13 @@ 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 ? `${window.location.origin}/api-game` : 'http://localhost:8685');
|
?? (IS_HTTPS ? 'https://game.rublox.pro' : 'http://localhost:8685');
|
||||||
export const REALTIME_WS = ENV.VITE_REALTIME_WS
|
export const REALTIME_WS = ENV.VITE_REALTIME_WS
|
||||||
?? (IS_HTTPS ? `wss://${window.location.host}/api-game` : 'ws://localhost:8685');
|
?? (IS_HTTPS ? 'wss://game.rublox.pro' : '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';
|
||||||
|
|||||||
@ -96,6 +96,7 @@ 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';
|
||||||
@ -198,10 +199,9 @@ 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.
|
||||||
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
|
// 2026-06-13: дефолт сменён на skin_y-bot (Mixamo Y-Bot,
|
||||||
// 'skin_*' грузится из characters/<id>/body.glb (R15-скелет),
|
// нейтральный по полу). Старые скины (skin_bacon-hair и др.) удалены.
|
||||||
// 'character-*' — старые Kenney-модели.
|
this._playerModelType = 'skin_y-bot';
|
||||||
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,6 +1649,42 @@ 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 рендер
|
||||||
@ -1751,8 +1787,8 @@ export class BabylonScene {
|
|||||||
const csm = new CascadedShadowGenerator(size, this._sunLight);
|
const csm = new CascadedShadowGenerator(size, this._sunLight);
|
||||||
csm.numCascades = numCascades;
|
csm.numCascades = numCascades;
|
||||||
csm.stabilizeCascades = true;
|
csm.stabilizeCascades = true;
|
||||||
csm.lambda = 0.6;
|
csm.lambda = 0.8; // 0.8 даёт больше детали ближе, меньше дальше → границы менее заметны
|
||||||
csm.cascadeBlendPercentage = 0.1;
|
csm.cascadeBlendPercentage = 0.35; // 0.10 → 0.35: плавный переход между каскадами (убирает резкие полосы на полу)
|
||||||
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;
|
||||||
@ -1763,6 +1799,9 @@ 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).
|
||||||
@ -2863,6 +2902,7 @@ export class BabylonScene {
|
|||||||
if (md.isBlock) {
|
if (md.isBlock) {
|
||||||
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } };
|
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } };
|
||||||
}
|
}
|
||||||
|
if (md.npcId != null) return { kind: 'npc', id: md.npcId };
|
||||||
if (md.isModel) return { kind: 'model', id: md.instanceId };
|
if (md.isModel) return { kind: 'model', id: md.instanceId };
|
||||||
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
|
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
|
||||||
return null;
|
return null;
|
||||||
@ -3104,7 +3144,29 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pick = this._pickFromCenter();
|
// В pointer-lock (1-е лицо) курсор скрыт в центре — пикаем центром.
|
||||||
|
// В 3-м лице (свободный курсор) — пикаем по реальным координатам клика.
|
||||||
|
const locked = (document.pointerLockElement === this.canvas);
|
||||||
|
let pick;
|
||||||
|
if (!locked && Number.isFinite(clickX) && Number.isFinite(clickY)) {
|
||||||
|
const pi = this.scene.pick(clickX, clickY, (mesh) => {
|
||||||
|
if (!mesh.isPickable) return false;
|
||||||
|
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (pi?.hit) {
|
||||||
|
let m = pi.pickedMesh;
|
||||||
|
if (m?.metadata?._isBlockProto && this.blockManager) {
|
||||||
|
const proxy = this.blockManager.findProxyByPickInfo?.(pi);
|
||||||
|
if (proxy) m = proxy;
|
||||||
|
}
|
||||||
|
pick = { mesh: m, point: pi.pickedPoint, pickInfo: pi };
|
||||||
|
} else {
|
||||||
|
pick = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pick = this._pickFromCenter();
|
||||||
|
}
|
||||||
const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null;
|
const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null;
|
||||||
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
|
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
|
||||||
// 1) Self-onClick — только если target есть
|
// 1) Self-onClick — только если target есть
|
||||||
@ -5413,6 +5475,56 @@ 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.
|
||||||
@ -5526,6 +5638,9 @@ 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 для всех проектов).
|
||||||
@ -7411,7 +7526,9 @@ 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;
|
||||||
this._playerModelType = pmt.startsWith('character-') ? 'skin_bacon-hair' : pmt;
|
// character-a..g (Kenney) и legacy R15 (skin_bacon-hair и др.)
|
||||||
|
// мигрируем на новый дефолт 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') {
|
||||||
@ -7431,15 +7548,9 @@ export class BabylonScene {
|
|||||||
} else {
|
} else {
|
||||||
this._skinsConfig = null;
|
this._skinsConfig = null;
|
||||||
}
|
}
|
||||||
// Задача 12: конфиг экрана загрузки.
|
// Задача 12+05: конфиг экрана загрузки (через setLoadingConfig — единый маппинг).
|
||||||
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
|
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
|
||||||
const ls = state.scene.loadingScreen;
|
this.setLoadingConfig(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;
|
||||||
}
|
}
|
||||||
@ -7565,6 +7676,11 @@ 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);
|
||||||
|
|||||||
@ -17,6 +17,9 @@
|
|||||||
import { Color3 } from '@babylonjs/core';
|
import { Color3 } from '@babylonjs/core';
|
||||||
import { ScriptSandbox } from './ScriptSandbox';
|
import { ScriptSandbox } from './ScriptSandbox';
|
||||||
import { STORYS_addres } from '../api/API';
|
import { STORYS_addres } from '../api/API';
|
||||||
|
import { handleLuaCommand, unpackRobloxLuaCode, parseRobloxLuaMeta } from './rbxl-lua-integration.js';
|
||||||
|
import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js';
|
||||||
|
import { RbxlHudOverlay } from './RbxlHudOverlay.js';
|
||||||
import { LabelManager } from './LabelManager'; // задача: scene.setLabel (require крашит в браузере)
|
import { LabelManager } from './LabelManager'; // задача: scene.setLabel (require крашит в браузере)
|
||||||
|
|
||||||
export class GameRuntime {
|
export class GameRuntime {
|
||||||
@ -101,7 +104,55 @@ export class GameRuntime {
|
|||||||
// (на старте) возвращает null → подписки obj.onTouch/find не работают.
|
// (на старте) возвращает null → подписки obj.onTouch/find не работают.
|
||||||
let initialScene = null;
|
let initialScene = null;
|
||||||
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
|
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
|
||||||
|
// Фаза 2 синхронизации со студией: и user-Lua (language='lua'), и
|
||||||
|
// импортированные .rbxl-скрипты (с маркером // @roblox-lua) теперь
|
||||||
|
// идут через ОДИН LuaSharedSandbox в main thread (wasmoon один раз).
|
||||||
|
// Снимает WASM OOM лимит и устраняет race с worker'ом.
|
||||||
|
const luaUserBatch = [];
|
||||||
|
const runImportedRbxl = !(typeof window !== 'undefined' && window.__RBXL_SKIP_IMPORTED === true);
|
||||||
|
let rbxlSkipped = 0;
|
||||||
for (const s of scripts) {
|
for (const s of scripts) {
|
||||||
|
// Roblox-Lua скрипты импортированные через rbxl-importer.
|
||||||
|
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
|
||||||
|
if (!runImportedRbxl) { rbxlSkipped++; continue; }
|
||||||
|
const meta = parseRobloxLuaMeta(s.code);
|
||||||
|
if (meta && meta.enabled === false) { rbxlSkipped++; continue; }
|
||||||
|
const sname = String(s.name || '').toLowerCase();
|
||||||
|
if (sname.startsWith('regenerate') || sname === 'regenerationscript') {
|
||||||
|
rbxlSkipped++; continue;
|
||||||
|
}
|
||||||
|
const luaSource = unpackRobloxLuaCode(s.code);
|
||||||
|
if (luaSource && (
|
||||||
|
/while\s+not\s+\w+[:.]FindFirstChild/.test(luaSource) ||
|
||||||
|
/ChildAdded:[Ww]ait\(\)/.test(luaSource) ||
|
||||||
|
/:[Gg]etChildren\(\)\s*\[\d/.test(luaSource)
|
||||||
|
)) {
|
||||||
|
rbxlSkipped++;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`[GameRuntime] skipped ${s.name}: tight-loop (WaitForChild/ChildAdded:wait)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (luaSource && luaSource.trim()) {
|
||||||
|
let toolName = null;
|
||||||
|
if (s.target == null && /(script\.Parent|Tool)\.(Equipped|Unequipped|Activated|Deactivated)/.test(luaSource)) {
|
||||||
|
toolName = 'Tool';
|
||||||
|
}
|
||||||
|
luaUserBatch.push({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
target: s.target,
|
||||||
|
toolName,
|
||||||
|
language: 'lua',
|
||||||
|
code: luaSource,
|
||||||
|
_rbxlImported: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (s && s.language === 'lua') {
|
||||||
|
if (typeof s.code === 'string' && s.code.trim()) luaUserBatch.push(s);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!s || typeof s.code !== 'string' || !s.code.trim()) {
|
if (!s || typeof s.code !== 'string' || !s.code.trim()) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[GameRuntime] skipping invalid script entry', s);
|
console.warn('[GameRuntime] skipping invalid script entry', s);
|
||||||
@ -131,6 +182,132 @@ export class GameRuntime {
|
|||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('[GameRuntime] sandbox started for script id=', s.id);
|
console.log('[GameRuntime] sandbox started for script id=', s.id);
|
||||||
}
|
}
|
||||||
|
// === Фаза 2: единый LuaSharedSandbox для user-Lua + импортированных .rbxl ===
|
||||||
|
let luaUserCount = 0;
|
||||||
|
if (luaUserBatch.length > 0) {
|
||||||
|
try {
|
||||||
|
const sb = new LuaSharedSandbox();
|
||||||
|
sb.setOnCommand(({ cmd, payload }) => {
|
||||||
|
if (cmd === 'partSet' || cmd === 'partVel' ||
|
||||||
|
cmd === 'sceneCreate' || cmd === 'sceneDelete') {
|
||||||
|
try {
|
||||||
|
handleLuaCommand(null, cmd, payload, this);
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[GameRuntime] handleLuaCommand failed:', cmd, payload, e);
|
||||||
|
}
|
||||||
|
} else if (cmd === 'toolRegistered') {
|
||||||
|
try { this._registerRbxlTool?.(payload); } catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[GameRuntime] toolRegistered failed', e);
|
||||||
|
}
|
||||||
|
} else if (cmd === 'lightingTimeUpdate') {
|
||||||
|
try {
|
||||||
|
const baseHour = Number(payload?.hour);
|
||||||
|
if (baseHour >= 0 && baseHour < 24) {
|
||||||
|
if (this._lightBaseHour == null) {
|
||||||
|
this._lightBaseHour = baseHour;
|
||||||
|
this._lightStartReal = performance.now();
|
||||||
|
}
|
||||||
|
const dGame = baseHour - this._lightBaseHour;
|
||||||
|
const accel = 8;
|
||||||
|
const hour = ((this._lightBaseHour + dGame * accel) % 24 + 24) % 24;
|
||||||
|
this.scene3d?.setTimeOfDay?.(hour);
|
||||||
|
let targetPreset;
|
||||||
|
if (hour >= 6 && hour < 8) targetPreset = 'sunset';
|
||||||
|
else if (hour >= 8 && hour < 17) targetPreset = 'lowpoly-roblox';
|
||||||
|
else if (hour >= 17 && hour < 19) targetPreset = 'sunset';
|
||||||
|
else targetPreset = 'starry-night';
|
||||||
|
if (this._lightPreset !== targetPreset) {
|
||||||
|
this._lightPreset = targetPreset;
|
||||||
|
try {
|
||||||
|
const sky = this.scene3d?.skybox;
|
||||||
|
if (sky?.fadeTo) sky.fadeTo({ preset: targetPreset }, 2);
|
||||||
|
else this.scene3d?.setSkybox?.({ preset: targetPreset });
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
} else if (cmd === 'particleCreated') {
|
||||||
|
this._rbxlPendingParticles = this._rbxlPendingParticles || [];
|
||||||
|
this._rbxlPendingParticles.push(payload);
|
||||||
|
} else if (cmd === 'mouseIconChanged') {
|
||||||
|
try {
|
||||||
|
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
|
||||||
|
if (canvas) canvas.style.cursor = payload.cssCursor || 'default';
|
||||||
|
} catch (_) {}
|
||||||
|
} else if (cmd === 'hudMessage') {
|
||||||
|
try {
|
||||||
|
this._ensureRbxlHud();
|
||||||
|
if (payload.visible && payload.text) {
|
||||||
|
this._rbxlHud.showMessage(payload.text);
|
||||||
|
} else {
|
||||||
|
this._rbxlHud.hideMessage();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
} else if (cmd === 'killFeed') {
|
||||||
|
try {
|
||||||
|
this._ensureRbxlHud();
|
||||||
|
this._rbxlHud.addKillFeed(payload.killer, payload.victim, payload.weapon);
|
||||||
|
} catch (_) {}
|
||||||
|
} else if (cmd === 'winShow') {
|
||||||
|
try {
|
||||||
|
this._ensureRbxlHud();
|
||||||
|
this._rbxlHud.showWin(payload.text || 'WIN!');
|
||||||
|
} catch (_) {}
|
||||||
|
} else if (cmd === 'ui.showText') {
|
||||||
|
try {
|
||||||
|
this._ensureRbxlHud();
|
||||||
|
this._rbxlHud.showMessage(payload.text || '');
|
||||||
|
const dur = Number(payload.duration) || 2;
|
||||||
|
const t = payload.text || '';
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (this._rbxlHud._lastMessage === t) {
|
||||||
|
this._rbxlHud.hideMessage();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, dur * 1000);
|
||||||
|
try { this._rbxlHud._lastMessage = t; } catch (_) {}
|
||||||
|
} catch (_) {}
|
||||||
|
} else if (cmd === 'leaderstatSet') {
|
||||||
|
try {
|
||||||
|
const lm = this.scene3d?.leaderstats;
|
||||||
|
if (lm) {
|
||||||
|
const statName = String(payload.statName || 'Stat');
|
||||||
|
if (!lm._defs.some(d => d.name === statName)) {
|
||||||
|
lm.define(statName, { initial: 0 });
|
||||||
|
}
|
||||||
|
lm.set(lm._meId || 'me', statName, Number(payload.value) || 0);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
} else {
|
||||||
|
this._handleCommand(null, cmd, payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const snap = this._buildSceneSnapshot();
|
||||||
|
sb.sendSceneSnapshot(snap);
|
||||||
|
} catch (_) {}
|
||||||
|
for (const s of luaUserBatch) {
|
||||||
|
sb.addScript(s.id, s.code, s.target, s.name, { toolName: s.toolName });
|
||||||
|
}
|
||||||
|
sb.start();
|
||||||
|
this.sandboxes.push(sb);
|
||||||
|
this._luaUserSandbox = sb;
|
||||||
|
luaUserCount = luaUserBatch.length;
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[GameRuntime] Lua user runtime failed to init', e);
|
||||||
|
this._log('error', `Lua-runtime ошибка: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rbxlSkipped > 0) {
|
||||||
|
this._log('info', `Импортированных .rbxl-скриптов пропущено: ${rbxlSkipped}`);
|
||||||
|
}
|
||||||
|
if (luaUserCount > 0) {
|
||||||
|
this._log('info', `Запущено Lua-скриптов (включая .rbxl): ${luaUserCount}`);
|
||||||
|
}
|
||||||
this._log('info', `Запущено скриптов: ${this.sandboxes.length}`);
|
this._log('info', `Запущено скриптов: ${this.sandboxes.length}`);
|
||||||
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
|
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
|
||||||
// во все sandbox'ы. Не перезаписываем существующий обработчик —
|
// во все sandbox'ы. Не перезаписываем существующий обработчик —
|
||||||
@ -360,6 +537,8 @@ 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;
|
||||||
}
|
}
|
||||||
@ -482,6 +661,14 @@ export class GameRuntime {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** DOM-overlay для импортированных Roblox-карт (KillFeed/Message/WinGui). */
|
||||||
|
_ensureRbxlHud() {
|
||||||
|
if (this._rbxlHud) return;
|
||||||
|
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
|
||||||
|
const parent = canvas?.parentElement || document.body;
|
||||||
|
this._rbxlHud = new RbxlHudOverlay(parent);
|
||||||
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
if (this.sandboxes.length > 0) {
|
if (this.sandboxes.length > 0) {
|
||||||
this._log('info', 'Остановка скриптов');
|
this._log('info', 'Остановка скриптов');
|
||||||
@ -489,6 +676,11 @@ export class GameRuntime {
|
|||||||
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
|
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
|
||||||
for (const sb of this.sandboxes) sb.stop();
|
for (const sb of this.sandboxes) sb.stop();
|
||||||
}
|
}
|
||||||
|
// Очищаем Roblox HUD overlay (KillFeed/Message/WinGui) — Фаза 2.
|
||||||
|
try { this._rbxlHud?.dispose?.(); } catch (_) {}
|
||||||
|
this._rbxlHud = null;
|
||||||
|
this._rbxlPendingParticles = null;
|
||||||
|
this._luaUserSandbox = null;
|
||||||
// Удаляем все объекты, которые скрипты наспавнили через
|
// Удаляем все объекты, которые скрипты наспавнили через
|
||||||
// game.scene.spawn/clone — иначе после Stop они остаются на сцене
|
// game.scene.spawn/clone — иначе после Stop они остаются на сцене
|
||||||
// и накапливаются при повторных запусках.
|
// и накапливаются при повторных запусках.
|
||||||
@ -621,7 +813,55 @@ export class GameRuntime {
|
|||||||
tick(dt) {
|
tick(dt) {
|
||||||
if (!this._isRunning || this.sandboxes.length === 0) return;
|
if (!this._isRunning || this.sandboxes.length === 0) return;
|
||||||
const state = this._collectState();
|
const state = this._collectState();
|
||||||
|
// Реальная позиция игрока для Lua __rbxl_player_pos()
|
||||||
|
const playerObj = this.scene3d?.player;
|
||||||
|
let realPos = null;
|
||||||
|
if (playerObj?._pos) {
|
||||||
|
const halfH = playerObj.HALF_H ?? 0.9;
|
||||||
|
realPos = { x: playerObj._pos.x, y: playerObj._pos.y - halfH, z: playerObj._pos.z };
|
||||||
|
} else if (state?.player) {
|
||||||
|
realPos = { x: state.player.x, y: state.player.y, z: state.player.z };
|
||||||
|
}
|
||||||
|
// Позиции спавненных динамических примитивов (id >= 800000)
|
||||||
|
let spawnedPositions = null;
|
||||||
|
try {
|
||||||
|
const pm = this.scene3d?.primitiveManager;
|
||||||
|
if (pm && pm.instances) {
|
||||||
|
for (const [id, data] of pm.instances.entries()) {
|
||||||
|
if (id < 800000 || data.anchored !== false) continue;
|
||||||
|
if (!spawnedPositions) spawnedPositions = [];
|
||||||
|
spawnedPositions.push([id, data.x, data.y, data.z]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
// Позиции NPC для Lua-shim
|
||||||
|
const npcPositions = [];
|
||||||
|
try {
|
||||||
|
const nm = this.scene3d?.npcManager;
|
||||||
|
if (nm && nm.npcs && this._localToReal) {
|
||||||
|
for (const [localRef, realRef] of this._localToReal.entries()) {
|
||||||
|
if (typeof realRef !== 'string' || !realRef.startsWith('npc:')) continue;
|
||||||
|
const npcId = Number(realRef.slice(4));
|
||||||
|
const npc = nm.npcs.get(npcId);
|
||||||
|
if (npc) npcPositions.push([localRef, npc.x, npc.y, npc.z]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
for (const sb of this.sandboxes) {
|
for (const sb of this.sandboxes) {
|
||||||
|
// Синк Lua-shim позиций (LuaSharedSandbox имеет sb.api.update*)
|
||||||
|
if (realPos && sb.api?.updatePlayerPos) {
|
||||||
|
try { sb.api.updatePlayerPos(realPos.x, realPos.y, realPos.z); } catch (_) {}
|
||||||
|
}
|
||||||
|
if (spawnedPositions && sb.api?.updateSpawnedPos) {
|
||||||
|
for (const [id, x, y, z] of spawnedPositions) {
|
||||||
|
try { sb.api.updateSpawnedPos(id, x, y, z); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (npcPositions.length > 0 && sb.api?.updateNpcPos) {
|
||||||
|
for (const [ref, x, y, z] of npcPositions) {
|
||||||
|
try { sb.api.updateNpcPos(ref, x, y, z); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Для скриптов с target — добавляем актуальную позицию self
|
// Для скриптов с target — добавляем актуальную позицию self
|
||||||
const stateForSb = sb.target
|
const stateForSb = sb.target
|
||||||
? { ...state, selfPosition: this._collectSelfPosition(sb.target) }
|
? { ...state, selfPosition: this._collectSelfPosition(sb.target) }
|
||||||
@ -1742,9 +1982,9 @@ export class GameRuntime {
|
|||||||
if (ls && payload) {
|
if (ls && payload) {
|
||||||
try {
|
try {
|
||||||
const id = ls.show(payload.opts || {});
|
const id = ls.show(payload.opts || {});
|
||||||
if (payload.replyId != null) {
|
// replyId может отсутствовать (стартовый экран) — всё равно шлём
|
||||||
|
// 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;
|
||||||
@ -1752,6 +1992,7 @@ 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) ===
|
||||||
@ -3328,6 +3569,10 @@ 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 {
|
||||||
|
|||||||
328
src/engine/GraphicsManager.js
Normal file
328
src/engine/GraphicsManager.js
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,80 +1,385 @@
|
|||||||
/**
|
/**
|
||||||
* LabelManager — billboard-метки (текст-плашки) над 3D-объектами.
|
* LabelManager — billboard-плашки (текст-надписи) над 3D-объектами.
|
||||||
*
|
*
|
||||||
* Используется для game.scene.setLabel(ref, text) — имена/HP над
|
* game.scene.setLabel(ref, text, opts) — имена/HP/таймеры/счётчики над
|
||||||
* персонажами, врагами, предметами. Метка всегда повёрнута лицом к камере
|
* персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к
|
||||||
* (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
|
* камере (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
|
||||||
*
|
*
|
||||||
* Метка привязывается к мешу объекта (parent) и висит над ним.
|
* Задача 10 — расширенные стили: фон/обводка/скругление (пресеты gameui/
|
||||||
|
* warning/reward/boss-hp/plain), обводка текста, richText (<color>/<b>/<size>),
|
||||||
|
* faceMode billboard|fixed, attachPoint, maxDistance.
|
||||||
|
*
|
||||||
|
* Плашка привязывается к мешу объекта (parent) и висит над ним.
|
||||||
*/
|
*/
|
||||||
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
|
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
|
||||||
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
||||||
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
||||||
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
||||||
|
import { Mesh } from '@babylonjs/core/Meshes/mesh';
|
||||||
|
|
||||||
|
// === Пресеты стилей плашки (фон/обводка/текст) ===
|
||||||
|
// gameui — жёлтая обводка + тёмно-синий фон + белый текст (как в Roblox-UI).
|
||||||
|
export const LABEL_PRESETS = {
|
||||||
|
plain: {
|
||||||
|
background: null, borderColor: null, borderWidth: 0, cornerRadius: 0,
|
||||||
|
color: '#ffffff', textStroke: { color: '#000', width: 8 },
|
||||||
|
},
|
||||||
|
gameui: {
|
||||||
|
background: '#1a3a8a', borderColor: '#f5c020', borderWidth: 10, cornerRadius: 28,
|
||||||
|
color: '#ffffff', textStroke: { color: '#0a1430', width: 6 },
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
background: '#b01e1e', borderColor: '#ffce3a', borderWidth: 10, cornerRadius: 28,
|
||||||
|
color: '#ffffff', textStroke: { color: '#000', width: 6 },
|
||||||
|
},
|
||||||
|
reward: {
|
||||||
|
background: '#caa018', borderColor: '#fff0a0', borderWidth: 10, cornerRadius: 28,
|
||||||
|
color: '#fff8e0', textStroke: { color: '#6b4e00', width: 6 },
|
||||||
|
gradient: ['#f7d34a', '#c98a18'], // золотой градиент фона
|
||||||
|
},
|
||||||
|
'boss-hp': {
|
||||||
|
background: '#3a0a0a', borderColor: '#ff4040', borderWidth: 8, cornerRadius: 20,
|
||||||
|
color: '#ffd0d0', textStroke: { color: '#000', width: 6 },
|
||||||
|
gradient: ['#8a1414', '#3a0a0a'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export class LabelManager {
|
export class LabelManager {
|
||||||
constructor(scene) {
|
constructor(scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
// ref-строка объекта → { plane, tex, mat }
|
// ref-строка объекта → { plane, tex, mat, lastKey, opts }
|
||||||
this.labels = new Map();
|
this.labels = new Map();
|
||||||
|
this._playerMesh = null; // для maxDistance — задаётся из BabylonScene
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Дать ссылку на меш игрока (для maxDistance-скрытия). */
|
||||||
|
setPlayerMesh(mesh) { this._playerMesh = mesh; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Установить/обновить метку над объектом.
|
* Установить/обновить плашку над объектом.
|
||||||
* ref — ref-строка объекта (от scene.spawn / scene.find).
|
* ref — ref-строка объекта.
|
||||||
* anchorMesh — Babylon-меш объекта (метка крепится к нему).
|
* anchorMesh — Babylon-меш объекта (плашка крепится к нему).
|
||||||
* text — текст метки.
|
* text — текст (может содержать richText-теги если opts.richText).
|
||||||
* opts — { color: '#fff', height: 2.5 (м над объектом), size: 1 }
|
* opts — см. LABEL_PRESETS + { color, height, size, background,
|
||||||
|
* borderColor, borderWidth, cornerRadius, padding, textStroke,
|
||||||
|
* fontWeight, faceMode, rotationY, attachPoint, preset,
|
||||||
|
* richText, maxDistance }
|
||||||
*/
|
*/
|
||||||
setLabel(ref, anchorMesh, text, opts = {}) {
|
setLabel(ref, anchorMesh, text, opts = {}) {
|
||||||
if (!anchorMesh) return;
|
if (!anchorMesh) return;
|
||||||
const color = opts.color || '#ffffff';
|
text = String(text == null ? '' : text);
|
||||||
|
|
||||||
|
// Пресет → база, поверх — явные opts.
|
||||||
|
const preset = opts.preset && LABEL_PRESETS[opts.preset] ? LABEL_PRESETS[opts.preset] : null;
|
||||||
|
const st = { ...(preset || {}), ...opts };
|
||||||
|
const color = st.color || '#ffffff';
|
||||||
const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5;
|
const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5;
|
||||||
const sizeMul = Number.isFinite(opts.size) ? opts.size : 1;
|
const sizeMul = Number.isFinite(opts.size) ? opts.size : 1;
|
||||||
|
const richText = !!opts.richText;
|
||||||
|
|
||||||
|
// Диф-чек: если ничего не поменялось — не пересоздаём (важно для bindLabel).
|
||||||
|
const styleKey = JSON.stringify({ text, color, preset: opts.preset, bg: st.background,
|
||||||
|
bc: st.borderColor, bw: st.borderWidth, cr: st.cornerRadius, rich: richText,
|
||||||
|
fw: st.fontWeight, h: heightAbove, sz: sizeMul, fm: st.faceMode, ry: st.rotationY,
|
||||||
|
af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null });
|
||||||
|
const existing = this.labels.get(ref);
|
||||||
|
if (existing && existing.lastKey === styleKey && existing.plane.parent === anchorMesh) {
|
||||||
|
return; // ничего не изменилось
|
||||||
|
}
|
||||||
|
|
||||||
|
// Меняется только текст (тот же стиль/размер) → перерисуем canvas без
|
||||||
|
// пересоздания меша (дешевле). Иначе — полное пересоздание.
|
||||||
|
const sameStruct = existing && existing.styleStruct === this._structKey(st, richText, heightAbove, sizeMul);
|
||||||
|
if (sameStruct) {
|
||||||
|
this._drawCanvas(existing.tex, text, color, st, richText);
|
||||||
|
existing.tex.update(true);
|
||||||
|
existing.lastKey = styleKey;
|
||||||
|
existing.lastText = text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Если метка уже есть — пересоздаём (текст/цвет могли измениться).
|
|
||||||
this.clearLabel(ref);
|
this.clearLabel(ref);
|
||||||
|
|
||||||
|
// Размер текстуры: чем больше текста — тем шире, чтобы не растягивать.
|
||||||
|
const fontPx = 120;
|
||||||
const W = 1024, H = 256;
|
const W = 1024, H = 256;
|
||||||
const tex = new DynamicTexture(`lblTex_${ref}_${Date.now()}`,
|
const tex = new DynamicTexture(`lblTex_${ref}_${this._uid()}`,
|
||||||
{ width: W, height: H }, this.scene, true);
|
{ width: W, height: H }, this.scene, true);
|
||||||
tex.updateSamplingMode?.(3); // TRILINEAR
|
tex.updateSamplingMode?.(3); // TRILINEAR
|
||||||
tex.anisotropicFilteringLevel = 8;
|
tex.anisotropicFilteringLevel = 8;
|
||||||
const ctx = tex.getContext();
|
|
||||||
ctx.clearRect(0, 0, W, H);
|
|
||||||
ctx.font = 'bold 120px "Roboto Condensed", "Segoe UI", sans-serif';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.lineWidth = 16;
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.strokeStyle = '#000';
|
|
||||||
ctx.strokeText(String(text), W / 2, H / 2);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fillText(String(text), W / 2, H / 2);
|
|
||||||
tex.update(true);
|
|
||||||
tex.hasAlpha = true;
|
tex.hasAlpha = true;
|
||||||
|
this._drawCanvas(tex, text, color, st, richText);
|
||||||
|
tex.update(true);
|
||||||
|
|
||||||
|
// Соотношение плашки — 4:1 (1024×256). Крупная и читаемая (как в Roblox).
|
||||||
|
// ОДНОСТОРОННЯЯ плоскость (FRONTSIDE): лицо = нормаль +Z. Мы всегда
|
||||||
|
// разворачиваем плашку лицом к наблюдателю снаружи грани, поэтому текст
|
||||||
|
// читается прямо. DOUBLESIDE НЕ используем — задняя грань зеркалит UV
|
||||||
|
// (баг: «Ключ от тайника» наоборот). backFaceCulling выключен только
|
||||||
|
// чтобы плашка не пропадала при взгляде сзади (без отражённого текста).
|
||||||
const plane = MeshBuilder.CreatePlane(`lbl_${ref}`,
|
const plane = MeshBuilder.CreatePlane(`lbl_${ref}`,
|
||||||
{ width: 2.2 * sizeMul, height: 0.55 * sizeMul }, this.scene);
|
{ width: 3.4 * sizeMul, height: 0.85 * sizeMul,
|
||||||
|
sideOrientation: Mesh.FRONTSIDE }, this.scene);
|
||||||
const mat = new StandardMaterial(`lblMat_${ref}`, this.scene);
|
const mat = new StandardMaterial(`lblMat_${ref}`, this.scene);
|
||||||
mat.diffuseTexture = tex;
|
mat.diffuseTexture = tex;
|
||||||
mat.diffuseTexture.hasAlpha = true;
|
mat.diffuseTexture.hasAlpha = true;
|
||||||
mat.emissiveColor = new Color3(1, 1, 1);
|
mat.emissiveColor = new Color3(1, 1, 1);
|
||||||
|
mat.diffuseColor = new Color3(0, 0, 0);
|
||||||
mat.disableLighting = true;
|
mat.disableLighting = true;
|
||||||
|
// Геометрия двусторонняя (DOUBLESIDE) с прямыми backUVs — culling можно
|
||||||
|
// включить, дублей нет; текст читается с обеих сторон без зеркала.
|
||||||
mat.backFaceCulling = false;
|
mat.backFaceCulling = false;
|
||||||
mat.disableDepthWrite = true;
|
mat.disableDepthWrite = true;
|
||||||
|
mat.useAlphaFromDiffuseTexture = true;
|
||||||
plane.material = mat;
|
plane.material = mat;
|
||||||
plane.billboardMode = 7; // всегда лицом к камере
|
plane.renderingGroupId = 1;
|
||||||
plane.renderingGroupId = 1; // поверх геометрии
|
|
||||||
plane.isPickable = false;
|
plane.isPickable = false;
|
||||||
// Крепим к объекту: метка висит над ним и двигается вместе с ним.
|
|
||||||
plane.parent = anchorMesh;
|
plane.parent = anchorMesh;
|
||||||
plane.position.set(0, heightAbove, 0);
|
|
||||||
|
|
||||||
this.labels.set(ref, { plane, tex, mat });
|
// Полуразмеры объекта по каждой оси (для крепления над верхом ИЛИ на
|
||||||
|
// грань). Берём bounding box в ЛОКАЛЬНЫХ координатах меша (min/max), чтобы
|
||||||
|
// позиция плашки-ребёнка была верной при любом масштабе/вращении родителя.
|
||||||
|
let halfX = 0.5, halfY = 0.5, halfZ = 0.5;
|
||||||
|
try {
|
||||||
|
const bb = anchorMesh.getBoundingInfo?.().boundingBox;
|
||||||
|
if (bb && bb.minimum && bb.maximum) {
|
||||||
|
halfX = (bb.maximum.x - bb.minimum.x) / 2;
|
||||||
|
halfY = (bb.maximum.y - bb.minimum.y) / 2;
|
||||||
|
halfZ = (bb.maximum.z - bb.minimum.z) / 2;
|
||||||
|
} else if (anchorMesh.scaling) {
|
||||||
|
halfX = Math.abs(anchorMesh.scaling.x) / 2;
|
||||||
|
halfY = Math.abs(anchorMesh.scaling.y) / 2;
|
||||||
|
halfZ = Math.abs(anchorMesh.scaling.z) / 2;
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
const halfH = halfY;
|
||||||
|
const halfPlane = 0.45 * sizeMul; // полувысота самой плашки (3.4×0.85)
|
||||||
|
|
||||||
|
// attachFace — ПРИКРЕПИТЬ плашку НА ГРАНЬ объекта (как табличка на
|
||||||
|
// стене/полу): плашка лежит В ПЛОСКОСТИ грани, фиксированной ориентации,
|
||||||
|
// и перемещается/вращается ВМЕСТЕ с объектом (она его ребёнок). Это
|
||||||
|
// Roblox-style «надпись = часть постройки» (в отличие от billboard над
|
||||||
|
// верхом). Значения: 'top'|'bottom'|'front'|'back'|'left'|'right'
|
||||||
|
// (или сырые '+y'/'-y'/'+z'/'-z'/'+x'/'-x').
|
||||||
|
const FACE = { top: '+y', bottom: '-y', front: '+z', back: '-z',
|
||||||
|
right: '+x', left: '-x' };
|
||||||
|
let face = st.attachFace;
|
||||||
|
if (face && FACE[face]) face = FACE[face];
|
||||||
|
|
||||||
|
if (face) {
|
||||||
|
// На грань — всегда фиксированная ориентация (не billboard), иначе
|
||||||
|
// «связки с примитивом» не будет (плашка крутилась бы к камере).
|
||||||
|
plane.billboardMode = 0;
|
||||||
|
const gap = Number.isFinite(opts.height) ? opts.height : 0.05;
|
||||||
|
// ВАЖНО: Babylon CreatePlane (FRONTSIDE) лицевой стороной (где UV/текст
|
||||||
|
// не зеркалятся) смотрит в −Z. Поэтому чтобы ЛИЦО таблички смотрело
|
||||||
|
// НАРУЖУ выбранной грани, поворачиваем плоскость так, чтобы её −Z
|
||||||
|
// совпал с внешней нормалью грани. tiltSign — знак наклона tilt с
|
||||||
|
// учётом того, что для грани +z плоскость развёрнута на π.
|
||||||
|
let tiltSign = 1;
|
||||||
|
if (face === '+z') { plane.position.set(0, 0, halfZ + gap); plane.rotation.set(0, Math.PI, 0); tiltSign = -1; }
|
||||||
|
else if (face === '-z') { plane.position.set(0, 0, -(halfZ + gap)); plane.rotation.set(0, 0, 0); }
|
||||||
|
else if (face === '+x') { plane.position.set( halfX + gap, 0, 0); plane.rotation.set(0, -Math.PI / 2, 0); }
|
||||||
|
else if (face === '-x') { plane.position.set(-(halfX + gap), 0, 0); plane.rotation.set(0, Math.PI / 2, 0); }
|
||||||
|
else if (face === '+y') { plane.position.set(0, halfY + gap, 0); plane.rotation.set( Math.PI / 2, 0, 0); }
|
||||||
|
else if (face === '-y') { plane.position.set(0, -(halfY + gap), 0); plane.rotation.set(-Math.PI / 2, 0, 0); }
|
||||||
|
if (Number.isFinite(st.rotationY)) plane.rotation.y += st.rotationY;
|
||||||
|
// tilt — наклон таблички вокруг локальной X (ценник под ~45°, как на
|
||||||
|
// витрине Roblox). Знак нормализуем (tiltSign), чтобы «верх назад» был
|
||||||
|
// одинаковым для всех граней. Отрицательный tilt = верх отклоняется
|
||||||
|
// назад (от наблюдателя), как пюпитр.
|
||||||
|
if (Number.isFinite(st.tilt)) plane.rotation.x += st.tilt * tiltSign;
|
||||||
|
} else {
|
||||||
|
// faceMode: 'fixed' — фиксированная ориентация (вращается с объектом),
|
||||||
|
// но позиционируется как обычная плашка (над верхом/центром/низом).
|
||||||
|
if (st.faceMode === 'fixed') {
|
||||||
|
plane.billboardMode = 0;
|
||||||
|
if (Number.isFinite(st.rotationY)) plane.rotation.y = st.rotationY;
|
||||||
|
} else {
|
||||||
|
plane.billboardMode = 7; // всегда лицом к камере
|
||||||
|
}
|
||||||
|
// attachPoint: 'top'(default) — над верхом + небольшой зазор (height);
|
||||||
|
// 'center' — по центру; 'bottom' — у низа; {x,y,z} — явно.
|
||||||
|
const gap = Number.isFinite(opts.height) ? opts.height : 0.6;
|
||||||
|
let py = halfH + gap + halfPlane; // верх объекта + зазор + полувысота плашки
|
||||||
|
if (st.attachPoint === 'center') py = 0;
|
||||||
|
else if (st.attachPoint === 'bottom') py = -(halfH + gap);
|
||||||
|
else if (st.attachPoint && typeof st.attachPoint === 'object') {
|
||||||
|
plane.position.set(st.attachPoint.x || 0, st.attachPoint.y || 0, st.attachPoint.z || 0);
|
||||||
|
py = null;
|
||||||
|
}
|
||||||
|
if (py !== null) plane.position.set(0, py, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Убрать метку с объекта. */
|
this.labels.set(ref, {
|
||||||
|
plane, tex, mat,
|
||||||
|
lastKey: styleKey,
|
||||||
|
lastText: text,
|
||||||
|
styleStruct: this._structKey(st, richText, heightAbove, sizeMul),
|
||||||
|
maxDistance: Number.isFinite(opts.maxDistance) ? opts.maxDistance : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключ «структуры» (всё кроме текста) — для решения перерисовать ли canvas. */
|
||||||
|
_structKey(st, richText, h, sz) {
|
||||||
|
return JSON.stringify({ p: st.preset, bg: st.background, bc: st.borderColor,
|
||||||
|
bw: st.borderWidth, cr: st.cornerRadius, rich: richText, fw: st.fontWeight,
|
||||||
|
grad: st.gradient, ts: st.textStroke, h, sz, fm: st.faceMode,
|
||||||
|
af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null });
|
||||||
|
}
|
||||||
|
|
||||||
|
_uid() { this._seq = (this._seq || 0) + 1; return this._seq; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нарисовать плашку на canvas DynamicTexture.
|
||||||
|
* Фон (roundRect + gradient/fill) → обводка border → текст (с обводкой).
|
||||||
|
*/
|
||||||
|
_drawCanvas(tex, text, color, st, richText) {
|
||||||
|
const W = 1024, H = 256;
|
||||||
|
const ctx = tex.getContext();
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
const hasBg = !!st.background || (Array.isArray(st.gradient) && st.gradient.length === 2);
|
||||||
|
const pad = Number.isFinite(st.padding) ? st.padding : 28;
|
||||||
|
const cr = Number.isFinite(st.cornerRadius) ? st.cornerRadius : 0;
|
||||||
|
const bw = Number.isFinite(st.borderWidth) ? st.borderWidth : 0;
|
||||||
|
|
||||||
|
const weight = st.fontWeight || 700;
|
||||||
|
const innerPad = (hasBg ? (bw + pad) : 24); // отступ от краёв (под рамку)
|
||||||
|
const maxTextW = W - innerPad * 2;
|
||||||
|
// Auto-fit: подбираем размер шрифта, чтобы текст влез по ширине (не обрезался).
|
||||||
|
let fontPx = 120;
|
||||||
|
if (!richText) {
|
||||||
|
ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`;
|
||||||
|
const tw = ctx.measureText(text).width;
|
||||||
|
if (tw > maxTextW) fontPx = Math.max(40, Math.floor(fontPx * maxTextW / tw));
|
||||||
|
}
|
||||||
|
ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
// === Фон-плашка ===
|
||||||
|
if (hasBg) {
|
||||||
|
const m = bw / 2 + 4; // отступ рамки от края текстуры
|
||||||
|
const x = m, y = m, w = W - m * 2, h = H - m * 2;
|
||||||
|
this._roundRectPath(ctx, x, y, w, h, cr);
|
||||||
|
if (Array.isArray(st.gradient) && st.gradient.length === 2) {
|
||||||
|
const g = ctx.createLinearGradient(0, y, 0, y + h);
|
||||||
|
g.addColorStop(0, st.gradient[0]);
|
||||||
|
g.addColorStop(1, st.gradient[1]);
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = st.background;
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = Number.isFinite(st.backgroundOpacity) ? st.backgroundOpacity : 0.92;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
if (bw > 0 && st.borderColor) {
|
||||||
|
ctx.lineWidth = bw;
|
||||||
|
ctx.strokeStyle = st.borderColor;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Текст ===
|
||||||
|
const ts = st.textStroke || { color: '#000', width: hasBg ? 6 : 10 };
|
||||||
|
if (richText) {
|
||||||
|
this._drawRichText(ctx, text, color, ts, W, H);
|
||||||
|
} else {
|
||||||
|
if (ts && ts.width > 0) {
|
||||||
|
ctx.lineWidth = ts.width;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.strokeStyle = ts.color || '#000';
|
||||||
|
ctx.strokeText(text, W / 2, H / 2 + 4);
|
||||||
|
}
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillText(text, W / 2, H / 2 + 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Путь скруглённого прямоугольника (roundRect не везде есть). */
|
||||||
|
_roundRectPath(ctx, x, y, w, h, r) {
|
||||||
|
r = Math.min(r, w / 2, h / 2);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||||
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||||
|
ctx.arcTo(x, y + h, x, y, r);
|
||||||
|
ctx.arcTo(x, y, x + w, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RichText: парсим теги <color=#hex>...</color>, <b>...</b>, <size=N>...</size>.
|
||||||
|
* Рисуем сегментами по горизонтали, центрируя всю строку. Вложенность не
|
||||||
|
* поддерживается (на MVP) — берём последний открытый тег каждого типа.
|
||||||
|
*/
|
||||||
|
_drawRichText(ctx, text, baseColor, ts, W, H) {
|
||||||
|
const segs = this._parseRich(text, baseColor);
|
||||||
|
const fontPx = 120;
|
||||||
|
// Замер ширины каждого сегмента в его размере.
|
||||||
|
let total = 0;
|
||||||
|
for (const s of segs) {
|
||||||
|
ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`;
|
||||||
|
s.w = ctx.measureText(s.text).width;
|
||||||
|
total += s.w;
|
||||||
|
}
|
||||||
|
let x = (W - total) / 2;
|
||||||
|
for (const s of segs) {
|
||||||
|
ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
if (ts && ts.width > 0) {
|
||||||
|
ctx.lineWidth = ts.width;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.strokeStyle = ts.color || '#000';
|
||||||
|
ctx.strokeText(s.text, x, H / 2 + 4);
|
||||||
|
}
|
||||||
|
ctx.fillStyle = s.color;
|
||||||
|
ctx.fillText(s.text, x, H / 2 + 4);
|
||||||
|
x += s.w;
|
||||||
|
}
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Простой парсер richText → [{text, color, bold, sizeMul}]. */
|
||||||
|
_parseRich(text, baseColor) {
|
||||||
|
const segs = [];
|
||||||
|
let color = baseColor, bold = false, sizeMul = 1;
|
||||||
|
// Разбиваем по тегам (открывающим/закрывающим).
|
||||||
|
const re = /<(\/?)(?:(color)=([#0-9a-fA-F]+)|(b)|(i)|(size)=(\d+))>|([^<]+)/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(text)) !== null) {
|
||||||
|
const closing = m[1] === '/';
|
||||||
|
if (m[8] != null) {
|
||||||
|
// текстовый кусок
|
||||||
|
if (m[8]) segs.push({ text: m[8], color, bold, sizeMul });
|
||||||
|
} else if (m[2]) { // <color=...>
|
||||||
|
color = closing ? baseColor : m[3];
|
||||||
|
} else if (m[4]) { // <b>
|
||||||
|
bold = !closing;
|
||||||
|
} else if (m[6]) { // <size=N>
|
||||||
|
sizeMul = closing ? 1 : Math.max(0.4, Math.min(2, Number(m[7]) / 100));
|
||||||
|
}
|
||||||
|
// <i> игнорим визуально (italic в canvas через font-style — опускаем на MVP)
|
||||||
|
}
|
||||||
|
if (segs.length === 0) segs.push({ text: '', color: baseColor, bold: false, sizeMul: 1 });
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Каждый кадр: maxDistance-скрытие плашек дальше порога от игрока. */
|
||||||
|
update() {
|
||||||
|
if (!this._playerMesh) return;
|
||||||
|
const pp = this._playerMesh.position;
|
||||||
|
for (const rec of this.labels.values()) {
|
||||||
|
if (rec.maxDistance == null) continue;
|
||||||
|
const ap = rec.plane.getAbsolutePosition();
|
||||||
|
const dx = ap.x - pp.x, dy = ap.y - pp.y, dz = ap.z - pp.z;
|
||||||
|
const far = (dx * dx + dy * dy + dz * dz) > rec.maxDistance * rec.maxDistance;
|
||||||
|
rec.plane.setEnabled(!far);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Убрать плашку с объекта. */
|
||||||
clearLabel(ref) {
|
clearLabel(ref) {
|
||||||
const rec = this.labels.get(ref);
|
const rec = this.labels.get(ref);
|
||||||
if (!rec) return;
|
if (!rec) return;
|
||||||
@ -84,7 +389,7 @@ export class LabelManager {
|
|||||||
this.labels.delete(ref);
|
this.labels.delete(ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Удалить все метки (при выходе из Play). */
|
/** Удалить все плашки (при выходе из Play). */
|
||||||
clearAll() {
|
clearAll() {
|
||||||
for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
|
for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,7 +35,25 @@ function injectSpinnerCss() {
|
|||||||
style.textContent =
|
style.textContent =
|
||||||
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
|
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
|
||||||
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
|
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
|
||||||
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner{animation:none}}';
|
// Ken Burns — медленный pan+zoom фона (задача 05).
|
||||||
|
'@keyframes kbn-ls-kenburns{' +
|
||||||
|
'0%{transform:scale(1.0) translate3d(0,0,0)}' +
|
||||||
|
'50%{transform:scale(1.10) translate3d(-3%,-2%,0)}' +
|
||||||
|
'100%{transform:scale(1.0) translate3d(-6%,0,0)}}' +
|
||||||
|
'.kbn-ls-kenburns{animation:kbn-ls-kenburns 24s ease-in-out infinite}' +
|
||||||
|
// particles — медленно всплывающие искры.
|
||||||
|
'@keyframes kbn-ls-rise{' +
|
||||||
|
'0%{transform:translateY(0) scale(1);opacity:0}' +
|
||||||
|
'10%{opacity:0.9}' +
|
||||||
|
'90%{opacity:0.7}' +
|
||||||
|
'100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' +
|
||||||
|
'.kbn-ls-particle{animation:kbn-ls-rise linear infinite}' +
|
||||||
|
// лёгкий «дыхательный» glow карточки-превью.
|
||||||
|
'@keyframes kbn-ls-cardglow{' +
|
||||||
|
'0%,100%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 0 rgba(120,160,255,0)}' +
|
||||||
|
'50%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 40px rgba(120,160,255,0.35)}}' +
|
||||||
|
'.kbn-ls-cardglow{animation:kbn-ls-cardglow 4s ease-in-out infinite}' +
|
||||||
|
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner,.kbn-ls-kenburns,.kbn-ls-particle,.kbn-ls-cardglow{animation:none}}';
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
@ -49,14 +67,17 @@ export class LoadingScreenOverlay {
|
|||||||
// Мост наружу (GameRuntime подписывает) — id-based колбэки.
|
// Мост наружу (GameRuntime подписывает) — id-based колбэки.
|
||||||
this._onSkipCb = null; // (id) => void
|
this._onSkipCb = null; // (id) => void
|
||||||
this._onCompleteCb = null; // (id) => void
|
this._onCompleteCb = null; // (id) => void
|
||||||
|
this._onHideCb = null; // () => void — задача 05 (game.loading.onHide)
|
||||||
|
this._parallaxHandler = null;
|
||||||
// DOM-ссылки активного экрана:
|
// DOM-ссылки активного экрана:
|
||||||
this._els = null;
|
this._els = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
|
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
|
||||||
setBridge(onSkip, onComplete) {
|
setBridge(onSkip, onComplete, onHide) {
|
||||||
this._onSkipCb = onSkip;
|
this._onSkipCb = onSkip;
|
||||||
this._onCompleteCb = onComplete;
|
this._onCompleteCb = onComplete;
|
||||||
|
if (onHide) this._onHideCb = onHide;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
|
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
|
||||||
@ -104,6 +125,15 @@ export class LoadingScreenOverlay {
|
|||||||
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
|
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
|
||||||
// Текст под картинкой
|
// Текст под картинкой
|
||||||
text: opts.text != null ? String(opts.text) : '',
|
text: opts.text != null ? String(opts.text) : '',
|
||||||
|
// --- Задача 05: Ken-Burns фон + карточка места ---
|
||||||
|
// style: 'ken-burns' | 'static' | 'parallax' | 'particles'
|
||||||
|
style: opts.style || cfg.style || 'ken-burns',
|
||||||
|
// фоновое размытое изображение (на весь экран); резолвится в _resolveCover.
|
||||||
|
background: opts.background != null ? opts.background : (cfg.background || null),
|
||||||
|
// карточка-витрина по центру (название места + автор), как в Roblox.
|
||||||
|
placeName: opts.placeName != null ? String(opts.placeName) : (cfg.placeName || ''),
|
||||||
|
studioName: opts.studioName != null ? String(opts.studioName) : (cfg.studioName || ''),
|
||||||
|
verified: opts.verified != null ? !!opts.verified : !!cfg.verified,
|
||||||
// Поведение
|
// Поведение
|
||||||
blockInput: opts.blockInput !== false,
|
blockInput: opts.blockInput !== false,
|
||||||
pauseSimulation: opts.pauseSimulation !== false,
|
pauseSimulation: opts.pauseSimulation !== false,
|
||||||
@ -163,20 +193,107 @@ export class LoadingScreenOverlay {
|
|||||||
// (используем opacity всего root для fade, а bgOpacity — через rgba фон):
|
// (используем opacity всего root для fade, а bgOpacity — через rgba фон):
|
||||||
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
|
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
|
||||||
|
|
||||||
// --- Cover (картинка по центру) ---
|
// --- Фоновый слой (Ken Burns / parallax / static) ---
|
||||||
|
// Размытое изображение игры на весь экран. Отдельный div под контентом,
|
||||||
|
// чтобы blur/анимация не трогали карточку и текст.
|
||||||
|
const bgUrl = this._resolveCover(st.background);
|
||||||
|
const bgLayer = document.createElement('div');
|
||||||
|
let bgClass = '';
|
||||||
|
if (bgUrl) {
|
||||||
|
if (st.style === 'ken-burns') bgClass = 'kbn-ls-kenburns';
|
||||||
|
bgLayer.className = bgClass;
|
||||||
|
bgLayer.style.cssText =
|
||||||
|
'position:absolute;inset:-8%;z-index:0;background-size:cover;background-position:center;' +
|
||||||
|
'filter:blur(8px) brightness(0.55);will-change:transform;' +
|
||||||
|
`background-image:url("${bgUrl}");`;
|
||||||
|
// parallax — лёгкий сдвиг по мыши (2 слоя имитируем одним + transform).
|
||||||
|
if (st.style === 'parallax') {
|
||||||
|
bgLayer.style.transition = 'transform 0.25s ease-out';
|
||||||
|
this._parallaxHandler = (e) => {
|
||||||
|
const cx = (e.clientX / window.innerWidth - 0.5) * 28;
|
||||||
|
const cy = (e.clientY / window.innerHeight - 0.5) * 18;
|
||||||
|
bgLayer.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.06)`;
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', this._parallaxHandler);
|
||||||
|
}
|
||||||
|
root.appendChild(bgLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- particles слой (медленные искры) ---
|
||||||
|
if (st.style === 'particles') {
|
||||||
|
const pLayer = document.createElement('div');
|
||||||
|
pLayer.style.cssText = 'position:absolute;inset:0;z-index:1;overflow:hidden;pointer-events:none;';
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
const sp = document.createElement('span');
|
||||||
|
sp.className = 'kbn-ls-particle';
|
||||||
|
const size = 2 + Math.round(Math.random() * 4);
|
||||||
|
const dur = 7 + Math.random() * 10;
|
||||||
|
sp.style.cssText =
|
||||||
|
`position:absolute;bottom:-10px;left:${(Math.random() * 100).toFixed(1)}%;` +
|
||||||
|
`width:${size}px;height:${size}px;border-radius:50%;` +
|
||||||
|
`background:rgba(${180 + Math.round(Math.random() * 70)},${190 + Math.round(Math.random() * 60)},255,0.85);` +
|
||||||
|
`box-shadow:0 0 ${size * 2}px rgba(140,170,255,0.7);` +
|
||||||
|
`animation-duration:${dur.toFixed(1)}s;animation-delay:${(-Math.random() * dur).toFixed(1)}s;`;
|
||||||
|
pLayer.appendChild(sp);
|
||||||
|
}
|
||||||
|
root.appendChild(pLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обёртка контента (над фоном).
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.style.cssText =
|
||||||
|
'position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;height:100%;';
|
||||||
|
|
||||||
|
// --- Cover (картинка-карточка по центру) ---
|
||||||
const coverUrl = this._resolveCover(cover);
|
const coverUrl = this._resolveCover(cover);
|
||||||
|
// Режим карточки места (задача 05): квадрат + название + автор под ней.
|
||||||
|
const hasPlaceCard = !!(st.placeName || st.studioName);
|
||||||
const coverImg = document.createElement('div');
|
const coverImg = document.createElement('div');
|
||||||
|
if (hasPlaceCard) {
|
||||||
|
coverImg.className = 'kbn-ls-cardglow';
|
||||||
|
coverImg.style.cssText =
|
||||||
|
'width:min(42vw,400px);aspect-ratio:1/1;border-radius:18px;' +
|
||||||
|
'background-size:cover;background-position:center;background-color:#1a1f2b;' +
|
||||||
|
'border:2px solid rgba(255,255,255,0.12);';
|
||||||
|
} else {
|
||||||
coverImg.style.cssText =
|
coverImg.style.cssText =
|
||||||
'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' +
|
'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' +
|
||||||
'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' +
|
'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' +
|
||||||
'background-color:#1a1f2b;margin-bottom:140px;';
|
'background-color:#1a1f2b;margin-bottom:140px;';
|
||||||
|
}
|
||||||
if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`;
|
if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`;
|
||||||
|
else if (bgUrl && hasPlaceCard) coverImg.style.backgroundImage = `url("${bgUrl}")`;
|
||||||
|
|
||||||
// --- Текст под картинкой ---
|
// --- Название места (крупный белый, под карточкой) ---
|
||||||
|
const placeEl = document.createElement('div');
|
||||||
|
placeEl.style.cssText =
|
||||||
|
'margin-top:22px;color:#fff;font-size:38px;font-weight:800;letter-spacing:0.5px;' +
|
||||||
|
'text-align:center;text-shadow:0 3px 14px rgba(0,0,0,0.7);' +
|
||||||
|
(st.placeName ? '' : 'display:none;');
|
||||||
|
placeEl.textContent = st.placeName || '';
|
||||||
|
|
||||||
|
// --- Автор + verified-галочка ---
|
||||||
|
const studioRow = document.createElement('div');
|
||||||
|
studioRow.style.cssText =
|
||||||
|
'margin-top:8px;display:flex;align-items:center;gap:7px;' +
|
||||||
|
'color:#cdd6e6;font-size:16px;font-weight:600;text-shadow:0 1px 4px rgba(0,0,0,0.6);' +
|
||||||
|
(st.studioName ? '' : 'display:none;');
|
||||||
|
const studioTxt = document.createElement('span');
|
||||||
|
studioTxt.textContent = st.studioName || '';
|
||||||
|
studioRow.appendChild(studioTxt);
|
||||||
|
if (st.verified) studioRow.appendChild(this._buildVerifiedBadge());
|
||||||
|
|
||||||
|
// --- Текст под картинкой (для не-карточного режима / mid-game) ---
|
||||||
const textEl = document.createElement('div');
|
const textEl = document.createElement('div');
|
||||||
|
if (hasPlaceCard) {
|
||||||
|
textEl.style.cssText =
|
||||||
|
'margin-top:14px;color:#e8edf5;font-size:16px;font-weight:500;text-align:center;' +
|
||||||
|
'text-shadow:0 1px 3px rgba(0,0,0,0.6);' + (st.text ? '' : 'display:none;');
|
||||||
|
} else {
|
||||||
textEl.style.cssText =
|
textEl.style.cssText =
|
||||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' +
|
'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' +
|
||||||
'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);';
|
'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);';
|
||||||
|
}
|
||||||
textEl.textContent = st.text || '';
|
textEl.textContent = st.text || '';
|
||||||
|
|
||||||
// --- Прогресс-бар ---
|
// --- Прогресс-бар ---
|
||||||
@ -245,8 +362,13 @@ export class LoadingScreenOverlay {
|
|||||||
spinWrap.appendChild(spinTxt);
|
spinWrap.appendChild(spinTxt);
|
||||||
spinWrap.appendChild(spinCircle);
|
spinWrap.appendChild(spinCircle);
|
||||||
|
|
||||||
root.appendChild(coverImg);
|
// Центральная композиция (карточка + название + автор + текст) — в content.
|
||||||
root.appendChild(textEl);
|
content.appendChild(coverImg);
|
||||||
|
content.appendChild(placeEl);
|
||||||
|
content.appendChild(studioRow);
|
||||||
|
content.appendChild(textEl);
|
||||||
|
root.appendChild(content);
|
||||||
|
// Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content).
|
||||||
root.appendChild(barWrap);
|
root.appendChild(barWrap);
|
||||||
root.appendChild(percent);
|
root.appendChild(percent);
|
||||||
root.appendChild(skipBtn);
|
root.appendChild(skipBtn);
|
||||||
@ -255,7 +377,19 @@ export class LoadingScreenOverlay {
|
|||||||
parent.appendChild(root);
|
parent.appendChild(root);
|
||||||
|
|
||||||
this.root = root;
|
this.root = root;
|
||||||
this._els = { root, coverImg, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
|
this._els = { root, content, coverImg, placeEl, studioRow, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verified-галочка (синий круг + белый чек) — inline SVG, без emoji-шрифта. */
|
||||||
|
_buildVerifiedBadge() {
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.style.cssText = 'display:inline-flex;align-items:center;';
|
||||||
|
wrap.innerHTML =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" aria-label="verified">' +
|
||||||
|
'<circle cx="12" cy="12" r="11" fill="#3897f0"/>' +
|
||||||
|
'<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" stroke-width="2.4" ' +
|
||||||
|
'stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||||
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
|
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
|
||||||
@ -329,6 +463,23 @@ export class LoadingScreenOverlay {
|
|||||||
if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`;
|
if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Задача 05: сменить фоновое (Ken-Burns) изображение на лету. */
|
||||||
|
setBackground(bg) {
|
||||||
|
if (!this._st || !this._els) return;
|
||||||
|
const url = this._resolveCover(bg);
|
||||||
|
if (!url) return;
|
||||||
|
this._st.background = bg;
|
||||||
|
// фоновый слой — первый ребёнок root с background-image; найдём его.
|
||||||
|
const layer = this._els.root.querySelector('.kbn-ls-kenburns')
|
||||||
|
|| this._els.root.firstElementChild;
|
||||||
|
if (layer && layer !== this._els.content) layer.style.backgroundImage = `url("${url}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Задача 05: виден ли экран сейчас. */
|
||||||
|
isVisible() {
|
||||||
|
return !!(this._st && this._st.phase !== 'out');
|
||||||
|
}
|
||||||
|
|
||||||
/** Закрыть программно (с fadeOut). */
|
/** Закрыть программно (с fadeOut). */
|
||||||
close() {
|
close() {
|
||||||
const st = this._st;
|
const st = this._st;
|
||||||
@ -361,6 +512,13 @@ export class LoadingScreenOverlay {
|
|||||||
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } }
|
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } }
|
||||||
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } }
|
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } }
|
||||||
}
|
}
|
||||||
|
// Снять parallax-listener (задача 05).
|
||||||
|
if (this._parallaxHandler) {
|
||||||
|
try { window.removeEventListener('mousemove', this._parallaxHandler); } catch { /* ignore */ }
|
||||||
|
this._parallaxHandler = null;
|
||||||
|
}
|
||||||
|
// onHide-мост (задача 05) — сообщаем скриптам что экран скрылся.
|
||||||
|
if (this._onHideCb) { try { this._onHideCb(); } catch { /* ignore */ } }
|
||||||
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } }
|
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } }
|
||||||
this.root = null;
|
this.root = null;
|
||||||
this._els = null;
|
this._els = null;
|
||||||
|
|||||||
593
src/engine/MixamoAnimator.js
Normal file
593
src/engine/MixamoAnimator.js
Normal file
@ -0,0 +1,593 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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_bacon-hair';
|
const typeId = modelType || 'skin_y-bot';
|
||||||
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,9 +137,16 @@ 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));
|
||||||
@ -149,7 +156,11 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -289,8 +300,20 @@ 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;
|
||||||
@ -332,13 +355,25 @@ 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' | 'attack'. R15Animator
|
// Серверный animState: 'idle' | 'run' | 'jump' | 'fall' | 'attack'.
|
||||||
// понимает idle/walk/run/jump/fall. Сервер не различает
|
// R15Animator понимает idle/walk/run/jump/fall.
|
||||||
// walk/run и не шлёт прыжки → маппим run→run, attack→idle
|
// 2026-06-05: раньше run/jump/fall маппились в idle (баг
|
||||||
// (атака показывается отдельным swing-ом руки ниже).
|
// в маппинге), из-за чего у remote-игроков не было
|
||||||
const r15State = rp.isDead
|
// анимации ни ходьбы, ни прыжка. Теперь пробрасываем
|
||||||
? 'idle'
|
// напрямую. attack показывается отдельным swing руки.
|
||||||
: (rp.animState === 'run' ? 'run' : 'idle');
|
let r15State;
|
||||||
|
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) {
|
||||||
@ -632,6 +667,23 @@ 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;
|
||||||
@ -661,7 +713,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_bacon-hair',
|
modelType: player.modelType || 'skin_y-bot',
|
||||||
animState: player.animState || 'idle',
|
animState: player.animState || 'idle',
|
||||||
// Если модель не успеет загрузиться, висит fallback-капсула.
|
// Если модель не успеет загрузиться, висит fallback-капсула.
|
||||||
fallbackMesh: null,
|
fallbackMesh: null,
|
||||||
|
|||||||
@ -1192,4 +1192,24 @@ export class PhysicsAABB {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найти лестницу (ladder_vertical), которой касается AABB игрока.
|
||||||
|
* Лестницы проходимы (canCollide=false) → НЕ попадают в spatial-grid,
|
||||||
|
* поэтому итерируем напрямую по инстансам (их на сцене единицы).
|
||||||
|
* Возвращает data ближайшей пересекающейся лестницы или null.
|
||||||
|
*/
|
||||||
|
getOverlappingLadder(cx, cy, cz, hw, hh, hd) {
|
||||||
|
if (!this.primitiveManager) return null;
|
||||||
|
let best = null, bestDist = Infinity;
|
||||||
|
for (const data of this.primitiveManager.instances.values()) {
|
||||||
|
if (data.type !== 'ladder_vertical') continue;
|
||||||
|
if (data.visible === false) continue;
|
||||||
|
if (!this._aabbIntersectsPrimitive(cx, cy, cz, hw, hh, hd, data)) continue;
|
||||||
|
const dx = data.x - cx, dz = data.z - cz;
|
||||||
|
const d = dx * dx + dz * dz;
|
||||||
|
if (d < bestDist) { bestDist = d; best = data; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,11 +28,35 @@ 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)
|
||||||
@ -83,6 +107,11 @@ 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;
|
||||||
@ -168,8 +197,8 @@ export class PlayerController {
|
|||||||
this._stepUpDecay = 4.5;
|
this._stepUpDecay = 4.5;
|
||||||
|
|
||||||
// Модель игрока (грузится в start)
|
// Модель игрока (грузится в start)
|
||||||
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
|
// 2026-06-13: дефолт сменён на skin_y-bot (Mixamo Y-Bot).
|
||||||
this._modelTypeId = 'skin_bacon-hair';
|
this._modelTypeId = 'skin_y-bot';
|
||||||
this._modelRoot = null;
|
this._modelRoot = null;
|
||||||
this._modelMeshes = [];
|
this._modelMeshes = [];
|
||||||
// Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет
|
// Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет
|
||||||
@ -191,6 +220,7 @@ 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 текущего скина
|
||||||
|
|
||||||
@ -341,6 +371,8 @@ 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;
|
||||||
@ -786,19 +818,32 @@ 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;
|
||||||
@ -812,7 +857,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,
|
||||||
@ -1167,8 +1212,59 @@ 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 = {};
|
||||||
if (!this._isR15) {
|
this._mixamoAnimator = null;
|
||||||
|
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();
|
||||||
@ -1625,6 +1721,9 @@ 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) {
|
||||||
@ -2535,10 +2634,26 @@ 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;
|
||||||
// Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest
|
// Меню в игре (Roblox-style — Участники / Настройки / etc).
|
||||||
// (там стоит проверка modalManager.isOpen). Без явного перехвата Esc
|
//
|
||||||
// в third (без pointer-lock) сразу выходил из Play.
|
// ПРАВИЛО (2026-06-14):
|
||||||
if (e.code === 'Escape') {
|
// • НЕ в fullscreen — Esc открывает меню (классика)
|
||||||
|
// • В 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;
|
||||||
@ -2654,18 +2769,30 @@ 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));
|
||||||
const isSprinting = this._shift;
|
// Crouch имеет ПРИОРИТЕТ над sprint: если Ctrl зажат — Shift игнорится.
|
||||||
const speedMult = isSprinting ? this.SPRINT_MULT : 1;
|
// Скорость в crouch = 0.45 от walk (медленный шаг на корточках).
|
||||||
|
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;
|
||||||
@ -2749,8 +2876,154 @@ 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 (inWater) {
|
if (this._ladderMode) {
|
||||||
|
// На лестнице гравитация НЕ применяется — _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;
|
||||||
@ -2834,7 +3107,12 @@ export class PlayerController {
|
|||||||
|
|
||||||
// PERF-METRICS: замер физики игрока
|
// PERF-METRICS: замер физики игрока
|
||||||
const _pt0 = performance.now();
|
const _pt0 = performance.now();
|
||||||
const result = this.physics.moveAABB(
|
// Во время climb_to_top физику пропускаем — _pos двигается lerp'ом
|
||||||
|
// вручную (вылезание на площадку), коллизия не нужна.
|
||||||
|
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
|
||||||
);
|
);
|
||||||
@ -2978,17 +3256,44 @@ 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) {
|
||||||
// Robot — стартовый импульс полный (как куб) для тапа достаточный,
|
// 3-фазная модель прыжка.
|
||||||
// boost-фаза 0.45с удлиняет подъём при удержании Space.
|
// _jumpKind определяется по нажатым клавишам в момент Space:
|
||||||
this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
|
// in_place — нет WASD (анимация Mixamo Jumping)
|
||||||
this._playJumpSound();
|
// forward — есть WASD (анимация Mixamo Jump = прыжок вперёд)
|
||||||
|
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) {
|
||||||
@ -3088,17 +3393,46 @@ 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,
|
this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset - crouchYDrop,
|
||||||
this._pos.z + fz * fwdShift
|
this._pos.z + fz * fwdShift
|
||||||
);
|
);
|
||||||
|
|
||||||
// Поворот модели:
|
// Поворот модели:
|
||||||
|
// - на лестнице: лицом К лестнице, yaw зафиксирован при входе.
|
||||||
// - на суше: направление РЕАЛЬНОГО движения (как было).
|
// - на суше: направление РЕАЛЬНОГО движения (как было).
|
||||||
// - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто
|
// - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто
|
||||||
// двигает тело вбок без вращения, как на суше при first-person.
|
// двигает тело вбок без вращения, как на суше при first-person.
|
||||||
if (inWater) {
|
if (this._climbingTop) {
|
||||||
|
// 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;
|
||||||
@ -3193,6 +3527,136 @@ export class PlayerController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mixamo-скин: AnimationGroup для каждого состояния, грузятся отдельно
|
||||||
|
// из /character-assets/animations/*.glb. Состояния:
|
||||||
|
// idle/walk/run/jump/fall — базовые
|
||||||
|
// crouch_idle/crouch_walk — присед (Ctrl)
|
||||||
|
// sprint → run. crouch имеет приоритет над sprint.
|
||||||
|
if (this._mixamoAnimator) {
|
||||||
|
let mState;
|
||||||
|
const now = Date.now();
|
||||||
|
// climb_to_top — вылезание на площадку (приоритет над всем).
|
||||||
|
if (this._climbingTop) {
|
||||||
|
this._mixamoAnimator.setState('climb_to_top');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Лазание по лестнице имеет приоритет над всеми анимациями.
|
||||||
|
// climb_up — движется вверх (W), climb_down — вниз (S),
|
||||||
|
// на месте на лестнице — анимация продолжает играть циклично
|
||||||
|
// (НЕ паузим: g.pause() останавливал обновление скелета →
|
||||||
|
// bounding box не обновлялся → frustum culling прятал скин).
|
||||||
|
if (this._ladderMode) {
|
||||||
|
const climbUp = this._codes.has('KeyW') || this._codes.has('ArrowUp');
|
||||||
|
const climbDown = this._codes.has('KeyS') || this._codes.has('ArrowDown');
|
||||||
|
const moving = climbUp || climbDown;
|
||||||
|
// Меняем state ТОЛЬКО при реальном движении. На месте держим
|
||||||
|
// текущую анимацию (не дёргаем setState — это убирает мигание
|
||||||
|
// climb_up↔climb_down и исчезание скина).
|
||||||
|
if (climbUp) this._mixamoAnimator.setState('climb_up');
|
||||||
|
else if (climbDown) this._mixamoAnimator.setState('climb_down');
|
||||||
|
// play/pause трогаем ТОЛЬКО при смене режима движения (как в jump).
|
||||||
|
if (moving !== this._ladderMoving) {
|
||||||
|
this._ladderMoving = moving;
|
||||||
|
try {
|
||||||
|
const g = this._mixamoAnimator._currentGroup;
|
||||||
|
if (g) {
|
||||||
|
if (moving) g.play(true); // возобновить (снять паузу)
|
||||||
|
else g.pause(); // заморозить позу
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inCrouchTransition = this._crouchTransitionUntil
|
||||||
|
&& now < this._crouchTransitionUntil;
|
||||||
|
// 3-фазная анимация прыжка. Выбираем семейство фаз по _jumpKind:
|
||||||
|
// in_place: jump_* (Mixamo Jumping)
|
||||||
|
// forward: jump_fwd_* (Mixamo Jump, прыжок с шага)
|
||||||
|
// run: jump_run_* (Mixamo Running Jump, прыжок с бега)
|
||||||
|
const jk = this._jumpKind;
|
||||||
|
const isAirborneJump = jk === 'forward' || jk === 'run';
|
||||||
|
let stAnticipate, stAir, stLand, landDuration;
|
||||||
|
if (jk === 'run') {
|
||||||
|
stAnticipate = 'jump_run_anticipate';
|
||||||
|
stAir = 'jump_run_air';
|
||||||
|
stLand = 'jump_run_land';
|
||||||
|
landDuration = 175;
|
||||||
|
} else if (jk === 'forward') {
|
||||||
|
stAnticipate = 'jump_fwd_anticipate';
|
||||||
|
stAir = 'jump_fwd_air';
|
||||||
|
stLand = 'jump_fwd_land';
|
||||||
|
landDuration = 142;
|
||||||
|
} else {
|
||||||
|
stAnticipate = 'jump_anticipate';
|
||||||
|
stAir = 'jump_air';
|
||||||
|
stLand = 'jump_land';
|
||||||
|
landDuration = 570;
|
||||||
|
}
|
||||||
|
const inAnticipate = this._jumpAnticipateUntil
|
||||||
|
&& now < this._jumpAnticipateUntil
|
||||||
|
&& this._jumpPendingImpulse;
|
||||||
|
const inJumpLand = this._jumpLandUntil && now < this._jumpLandUntil;
|
||||||
|
// Coyote-фильтр для микро-полётов на ступеньках. При спуске по
|
||||||
|
// лестнице из блоков персонаж 30-700мс физически в воздухе, и
|
||||||
|
// jump_air мигает между шагами walk. Критерий — ВЫСОТА падения
|
||||||
|
// от последней наземной позиции (а не время — полёт может быть
|
||||||
|
// длинным при спуске лицом к камере). Опустился <1.3 блока И не
|
||||||
|
// прыгал → ступенька, играем walk/run.
|
||||||
|
if (result.onGround) {
|
||||||
|
this._lastGroundY = this._pos.y;
|
||||||
|
}
|
||||||
|
const dropFromGround = (this._lastGroundY != null)
|
||||||
|
? (this._lastGroundY - this._pos.y) : Infinity;
|
||||||
|
const microAir = !result.onGround
|
||||||
|
&& !this._jumpHeld // не прыжок со Space
|
||||||
|
&& !this._wasAirborne // не продолжение реального прыжка
|
||||||
|
&& dropFromGround < 1.3 // опустился меньше 1.3 блока
|
||||||
|
&& this._vy < 4; // не подлетает вверх (степ-ап импульс)
|
||||||
|
if (inAnticipate) {
|
||||||
|
mState = stAnticipate;
|
||||||
|
} else if (microAir) {
|
||||||
|
// Микро-полёт между ступеньками — наземная анимация.
|
||||||
|
mState = this._crouching
|
||||||
|
? (isMoving ? 'crouch_walk' : 'crouch_idle')
|
||||||
|
: (isMoving ? (isSprinting ? 'run' : 'walk') : 'idle');
|
||||||
|
} else if (!result.onGround) {
|
||||||
|
mState = stAir;
|
||||||
|
this._wasAirborne = true;
|
||||||
|
this._crouchEnterPending = false;
|
||||||
|
this._crouchExitPending = false;
|
||||||
|
this._crouchTransitionUntil = 0;
|
||||||
|
this._jumpAnticipateUntil = 0;
|
||||||
|
} else if (this._wasAirborne) {
|
||||||
|
this._jumpLandUntil = now + landDuration;
|
||||||
|
this._wasAirborne = false;
|
||||||
|
mState = stLand;
|
||||||
|
} else if (inJumpLand) {
|
||||||
|
// Для forward — доигрываем land даже при движении
|
||||||
|
// (там короткая фаза 142мс)
|
||||||
|
if (isAirborneJump || !isMoving) mState = stLand;
|
||||||
|
} else if (this._crouchEnterPending && inCrouchTransition && !isMoving) {
|
||||||
|
mState = 'crouch_enter';
|
||||||
|
} else if (this._crouchExitPending && inCrouchTransition && !isMoving) {
|
||||||
|
mState = 'crouch_to_stand';
|
||||||
|
} else if (this._crouching) {
|
||||||
|
this._crouchEnterPending = false;
|
||||||
|
this._crouchExitPending = false;
|
||||||
|
mState = isMoving ? 'crouch_walk' : 'crouch_idle';
|
||||||
|
} else if (inWater) {
|
||||||
|
mState = isMoving ? 'walk' : 'idle';
|
||||||
|
} else if (isMoving) {
|
||||||
|
this._crouchExitPending = false;
|
||||||
|
this._crouchTransitionUntil = 0;
|
||||||
|
this._jumpLandUntil = 0; // прерываем jump_land если пошли
|
||||||
|
mState = isSprinting ? 'run' : 'walk';
|
||||||
|
} else {
|
||||||
|
this._crouchExitPending = false;
|
||||||
|
mState = 'idle';
|
||||||
|
}
|
||||||
|
this._mixamoAnimator.setState(mState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// R15-скин: процедурный аниматор (нет glTF AnimationGroups).
|
// R15-скин: процедурный аниматор (нет glTF AnimationGroups).
|
||||||
// Состояния: idle/walk/run/jump/fall. sprint → run.
|
// Состояния: idle/walk/run/jump/fall. sprint → run.
|
||||||
if (this._isR15 && this._r15Animator) {
|
if (this._isR15 && this._r15Animator) {
|
||||||
|
|||||||
@ -38,6 +38,11 @@ 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);
|
||||||
@ -114,8 +119,15 @@ export class PrimitiveManager {
|
|||||||
id = this._nextId++;
|
id = this._nextId++;
|
||||||
}
|
}
|
||||||
const sx = opts.sx ?? typeDef.defaultScale.x;
|
const sx = opts.sx ?? typeDef.defaultScale.x;
|
||||||
const sy = opts.sy ?? typeDef.defaultScale.y;
|
let 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 по дистанции.
|
||||||
@ -126,8 +138,10 @@ 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 (игрок проходит сквозь, логика по дистанции).
|
||||||
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
|
// Лестница — тоже проходима (canCollide=false), чтобы игрок мог войти в её
|
||||||
|
// объём и лезть (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 знаков.
|
||||||
@ -143,7 +157,11 @@ 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;
|
||||||
@ -169,6 +187,8 @@ 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,
|
||||||
@ -305,6 +325,10 @@ 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);
|
||||||
@ -452,6 +476,43 @@ 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`;
|
||||||
@ -485,12 +546,40 @@ export class PrimitiveManager {
|
|||||||
break;
|
break;
|
||||||
case 'glass':
|
case 'glass':
|
||||||
mat.alpha = 0.4;
|
mat.alpha = 0.4;
|
||||||
mat.specularColor = new Color3(0.5, 0.5, 0.5);
|
mat.specularColor = new Color3(0.8, 0.85, 0.9);
|
||||||
|
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).
|
||||||
@ -667,6 +756,14 @@ 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 кажутся правильными. Простой способ —
|
||||||
@ -800,7 +897,10 @@ 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/тайлинг). Иначе перенос.
|
||||||
@ -876,6 +976,8 @@ export class PrimitiveManager {
|
|||||||
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
||||||
// Параметр эмиттера (только для type='emitter')
|
// Параметр эмиттера (только для type='emitter')
|
||||||
...(d.effect !== undefined ? { effect: d.effect } : {}),
|
...(d.effect !== undefined ? { effect: d.effect } : {}),
|
||||||
|
// Число ступенек лестницы (только для type='ladder_vertical')
|
||||||
|
...(d.type === 'ladder_vertical' ? { stepCount: d.stepCount } : {}),
|
||||||
// Параметры билборда (только для type='billboard')
|
// Параметры билборда (только для type='billboard')
|
||||||
...(d.billboard ? {
|
...(d.billboard ? {
|
||||||
template: d.billboard.template,
|
template: d.billboard.template,
|
||||||
|
|||||||
@ -66,6 +66,13 @@ 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',
|
||||||
@ -96,7 +103,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'] },
|
{ id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard', 'ladder_vertical'] },
|
||||||
{ 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'] },
|
||||||
];
|
];
|
||||||
|
|||||||
177
src/engine/RbxlHudOverlay.js
Normal file
177
src/engine/RbxlHudOverlay.js
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* RbxlHudOverlay — DOM-оверлей с HUD-элементами для импортированных
|
||||||
|
* Roblox-карт: KillFeed (правый верх), Message/Hint (центр), WinGui.
|
||||||
|
*
|
||||||
|
* Один контейнер `.rbxl-hud-overlay` на canvas.parentElement, в нём дочерние
|
||||||
|
* блоки по типу. Стили inline, ничего не зависит от CSS приложения.
|
||||||
|
*
|
||||||
|
* API:
|
||||||
|
* const hud = new RbxlHudOverlay(canvasParent);
|
||||||
|
* hud.addKillFeed(killer, victim, weapon)
|
||||||
|
* hud.showMessage(text, opts)
|
||||||
|
* hud.hideMessage()
|
||||||
|
* hud.showWin(text)
|
||||||
|
* hud.dispose()
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class RbxlHudOverlay {
|
||||||
|
constructor(parent) {
|
||||||
|
this._parent = parent || document.body;
|
||||||
|
this._root = null;
|
||||||
|
this._killFeed = null;
|
||||||
|
this._message = null;
|
||||||
|
this._winBox = null;
|
||||||
|
this._killEntries = []; // [{el, expireAt}]
|
||||||
|
this._mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
_mount() {
|
||||||
|
if (this._root) return;
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.className = 'rbxl-hud-overlay';
|
||||||
|
Object.assign(root.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '0',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: '999',
|
||||||
|
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
|
||||||
|
});
|
||||||
|
this._parent.appendChild(root);
|
||||||
|
this._root = root;
|
||||||
|
|
||||||
|
// KillFeed — правый верхний угол
|
||||||
|
const kf = document.createElement('div');
|
||||||
|
Object.assign(kf.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '60px',
|
||||||
|
right: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '6px',
|
||||||
|
maxWidth: '320px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
root.appendChild(kf);
|
||||||
|
this._killFeed = kf;
|
||||||
|
|
||||||
|
// Message — центр сверху (Roblox Message по центру экрана,
|
||||||
|
// но в верхней трети чтобы не мешать игре)
|
||||||
|
const msg = document.createElement('div');
|
||||||
|
Object.assign(msg.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '15%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
padding: '10px 24px',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: '600',
|
||||||
|
borderRadius: '6px',
|
||||||
|
textShadow: '0 2px 4px rgba(0,0,0,0.8)',
|
||||||
|
display: 'none',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
root.appendChild(msg);
|
||||||
|
this._message = msg;
|
||||||
|
|
||||||
|
// WinGui — большая надпись по центру
|
||||||
|
const win = document.createElement('div');
|
||||||
|
Object.assign(win.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
padding: '24px 48px',
|
||||||
|
background: 'rgba(0,0,0,0.75)',
|
||||||
|
color: '#ffd86b',
|
||||||
|
fontSize: '48px',
|
||||||
|
fontWeight: '800',
|
||||||
|
borderRadius: '12px',
|
||||||
|
textShadow: '0 4px 8px rgba(0,0,0,0.8)',
|
||||||
|
display: 'none',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
root.appendChild(win);
|
||||||
|
this._winBox = win;
|
||||||
|
|
||||||
|
// Тик для авто-исчезновения KillFeed entries (через 5с)
|
||||||
|
this._tickInterval = setInterval(() => this._cleanupKills(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
addKillFeed(killer, victim, weapon) {
|
||||||
|
if (!this._killFeed) return;
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
Object.assign(entry.style, {
|
||||||
|
background: 'rgba(0,0,0,0.55)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '6px',
|
||||||
|
alignItems: 'center',
|
||||||
|
animation: 'rbxlHudFadeIn 0.3s',
|
||||||
|
});
|
||||||
|
const killerEl = document.createElement('span');
|
||||||
|
killerEl.textContent = String(killer || '?');
|
||||||
|
killerEl.style.color = '#5bd1e8';
|
||||||
|
const arrow = document.createElement('span');
|
||||||
|
arrow.textContent = weapon ? `→ [${weapon}] →` : '→';
|
||||||
|
arrow.style.color = '#ff9a52';
|
||||||
|
const victimEl = document.createElement('span');
|
||||||
|
victimEl.textContent = String(victim || '?');
|
||||||
|
victimEl.style.color = '#f87a7a';
|
||||||
|
entry.appendChild(killerEl);
|
||||||
|
entry.appendChild(arrow);
|
||||||
|
entry.appendChild(victimEl);
|
||||||
|
this._killFeed.appendChild(entry);
|
||||||
|
this._killEntries.push({ el: entry, expireAt: performance.now() + 5000 });
|
||||||
|
// Keep only last 8
|
||||||
|
while (this._killEntries.length > 8) {
|
||||||
|
const old = this._killEntries.shift();
|
||||||
|
try { old.el.remove(); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanupKills() {
|
||||||
|
const now = performance.now();
|
||||||
|
const keep = [];
|
||||||
|
for (const e of this._killEntries) {
|
||||||
|
if (e.expireAt < now) { try { e.el.remove(); } catch (_) {} }
|
||||||
|
else keep.push(e);
|
||||||
|
}
|
||||||
|
this._killEntries = keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage(text, opts = {}) {
|
||||||
|
if (!this._message) return;
|
||||||
|
this._message.textContent = String(text || '');
|
||||||
|
this._message.style.display = text ? 'block' : 'none';
|
||||||
|
if (opts.duration) {
|
||||||
|
clearTimeout(this._msgTimer);
|
||||||
|
this._msgTimer = setTimeout(() => this.hideMessage(), opts.duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideMessage() {
|
||||||
|
if (this._message) this._message.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
showWin(text) {
|
||||||
|
if (!this._winBox) return;
|
||||||
|
this._winBox.textContent = String(text || '');
|
||||||
|
this._winBox.style.display = 'block';
|
||||||
|
// Auto-hide через 6с
|
||||||
|
clearTimeout(this._winTimer);
|
||||||
|
this._winTimer = setTimeout(() => { if (this._winBox) this._winBox.style.display = 'none'; }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
try { this._root?.remove(); } catch (_) {}
|
||||||
|
clearInterval(this._tickInterval);
|
||||||
|
clearTimeout(this._msgTimer);
|
||||||
|
clearTimeout(this._winTimer);
|
||||||
|
this._root = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -70,6 +70,8 @@ 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.
|
||||||
@ -121,6 +123,13 @@ let _unlockedSkins = [];
|
|||||||
let _currentSkin = null;
|
let _currentSkin = null;
|
||||||
let _skinChangeHandlers = [];
|
let _skinChangeHandlers = [];
|
||||||
let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта)
|
let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта)
|
||||||
|
// Phase 6.4 / задача 20: custom tools, leaderstats, achievements, remote events
|
||||||
|
// (_lsMirror / _lsChangeHandlers / _achUnlocked уже объявлены выше у задачи 20 —
|
||||||
|
// здесь НЕ переобъявляем, иначе SyntaxError «already declared» рушит весь
|
||||||
|
// скриптинг плеера. Оставляем только уникальные для этого блока.)
|
||||||
|
let _toolSeq = 0;
|
||||||
|
let _toolCallbacks = {}; // toolId → { activated, equipped, unequipped }
|
||||||
|
let _remoteHandlers = {}; // remoteName → [fn]
|
||||||
// Подписки game.gui.onClick(id, fn)
|
// Подписки game.gui.onClick(id, fn)
|
||||||
let _guiClickHandlers = {};
|
let _guiClickHandlers = {};
|
||||||
// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter)
|
// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter)
|
||||||
@ -682,7 +691,9 @@ function _buildSelfApi() {
|
|||||||
_send('self.move', { target: _target, x: nx, y: ny, z: nz });
|
_send('self.move', { target: _target, x: nx, y: ny, z: nz });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/** Повернуть объект-носитель вокруг оси Y на угол ry (радианы). */
|
/**
|
||||||
|
* Повернуть объект-носитель вокруг вертикальной оси Y на угол ry (радианы).
|
||||||
|
*/
|
||||||
rotate(ry) {
|
rotate(ry) {
|
||||||
const r = Number(ry);
|
const r = Number(ry);
|
||||||
if (!Number.isFinite(r)) return;
|
if (!Number.isFinite(r)) return;
|
||||||
@ -697,7 +708,7 @@ function _buildSelfApi() {
|
|||||||
const id = _target.id ?? _target.ref;
|
const id = _target.id ?? _target.ref;
|
||||||
_send('scene.setVisible', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, visible: !!vis });
|
_send('scene.setVisible', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, visible: !!vis });
|
||||||
},
|
},
|
||||||
/** Включить/выключить столкновения объекта-носителя. */
|
/** Включить/выключить столкновения объекта-носителя (проходимость). */
|
||||||
setCollide(can) {
|
setCollide(can) {
|
||||||
const k = _target.kind;
|
const k = _target.kind;
|
||||||
const id = _target.id ?? _target.ref;
|
const id = _target.id ?? _target.ref;
|
||||||
@ -710,13 +721,14 @@ function _buildSelfApi() {
|
|||||||
const id = _target.id ?? _target.ref;
|
const id = _target.id ?? _target.ref;
|
||||||
_send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex });
|
_send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex });
|
||||||
},
|
},
|
||||||
/** Повесить текст-метку над объектом-носителем. */
|
/** Повесить текст-метку над объектом-носителем (имя/HP). */
|
||||||
setLabel(text, opts) {
|
setLabel(text, opts) {
|
||||||
const k = _target.kind;
|
const k = _target.kind;
|
||||||
const id = _target.id ?? _target.ref;
|
const id = _target.id ?? _target.ref;
|
||||||
const ref = (k && id != null) ? (k + ':' + id) : undefined;
|
const ref = (k && id != null) ? (k + ':' + id) : undefined;
|
||||||
_send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {} });
|
_send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {} });
|
||||||
},
|
},
|
||||||
|
/** Убрать метку с объекта-носителя. */
|
||||||
clearLabel() {
|
clearLabel() {
|
||||||
const k = _target.kind;
|
const k = _target.kind;
|
||||||
const id = _target.id ?? _target.ref;
|
const id = _target.id ?? _target.ref;
|
||||||
@ -1155,6 +1167,18 @@ const game = {
|
|||||||
* game.player.giveTool('blaster-blaster-a', { equip: true });
|
* game.player.giveTool('blaster-blaster-a', { equip: true });
|
||||||
*/
|
*/
|
||||||
giveTool(toolType, opts) {
|
giveTool(toolType, opts) {
|
||||||
|
// Phase 6.4: принимаем и Tool-объект (из game.tools.create), и строку.
|
||||||
|
if (toolType && typeof toolType === 'object' && toolType.id) {
|
||||||
|
_send('inventory.give', {
|
||||||
|
kind: toolType.kind || 'tool',
|
||||||
|
modelTypeId: toolType.modelTypeId || null,
|
||||||
|
name: toolType.name,
|
||||||
|
customToolId: toolType.id,
|
||||||
|
params: {},
|
||||||
|
equip: opts?.equip === true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (typeof toolType !== 'string' || !toolType) return;
|
if (typeof toolType !== 'string' || !toolType) return;
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
const isBlaster = toolType.indexOf('blaster') === 0;
|
const isBlaster = toolType.indexOf('blaster') === 0;
|
||||||
@ -1269,7 +1293,8 @@ const game = {
|
|||||||
* game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 });
|
* game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 });
|
||||||
*/
|
*/
|
||||||
tween(ref, props, opts) {
|
tween(ref, props, opts) {
|
||||||
if (typeof ref !== 'string' || !props || typeof props !== 'object') return null;
|
ref = _normRef(ref);
|
||||||
|
if (!ref || !props || typeof props !== 'object') return null;
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
const id = ++_tweenSeq;
|
const id = ++_tweenSeq;
|
||||||
if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone;
|
if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone;
|
||||||
@ -1380,6 +1405,32 @@ const game = {
|
|||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
_send('mp.sendTo', { sessionId, name, data });
|
_send('mp.sendTo', { sessionId, name, data });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 6.6: RemoteEvent — именованные сетевые события (как в Roblox).
|
||||||
|
* const ev = game.remote.create('PlayerShoot');
|
||||||
|
* ev.fireAllClients({ x: 10, y: 5 });
|
||||||
|
* ev.on(({ from, data }) => { ... });
|
||||||
|
*/
|
||||||
|
remote: {
|
||||||
|
create(name) {
|
||||||
|
const evName = String(name || '');
|
||||||
|
return {
|
||||||
|
get name() { return evName; },
|
||||||
|
fireAllClients(data) { _send('mp.remoteFire', { name: evName, target: 'all', data }); },
|
||||||
|
fireOthers(data) { _send('mp.remoteFire', { name: evName, target: 'others', data }); },
|
||||||
|
fireClient(player, data) {
|
||||||
|
const sid = typeof player === 'string' ? player : (player && player.sessionId);
|
||||||
|
if (!sid) return;
|
||||||
|
_send('mp.remoteFire', { name: evName, target: sid, data });
|
||||||
|
},
|
||||||
|
on(fn) {
|
||||||
|
if (typeof fn !== 'function') return;
|
||||||
|
(_remoteHandlers[evName] = _remoteHandlers[evName] || []).push(fn);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Подписаться на изменение HP игрока (получение урона / лечение / смерть).
|
* Подписаться на изменение HP игрока (получение урона / лечение / смерть).
|
||||||
* fn(event) где event = { hp, maxHp, source, damaged, delta }.
|
* fn(event) где event = { hp, maxHp, source, damaged, delta }.
|
||||||
@ -2727,6 +2778,7 @@ 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;
|
||||||
@ -2745,11 +2797,20 @@ 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 } : {};
|
||||||
@ -2807,7 +2868,7 @@ const game = {
|
|||||||
clear() {
|
clear() {
|
||||||
_send('inventory.clear', {});
|
_send('inventory.clear', {});
|
||||||
},
|
},
|
||||||
// === Задача 44: drag-drop инвентарь ===
|
// === Задача 44: drag-drop инвентарь (сетка 8×5 + hotbar 9 + стаки) ===
|
||||||
give(itemId, count) { _send('inv2.add', { itemId, count: Number(count) || 1 }); },
|
give(itemId, count) { _send('inv2.add', { itemId, count: Number(count) || 1 }); },
|
||||||
take(itemId, count) { _send('inv2.remove', { itemId, count: Number(count) || 1 }); },
|
take(itemId, count) { _send('inv2.remove', { itemId, count: Number(count) || 1 }); },
|
||||||
open() { _send('inv2.open', {}); },
|
open() { _send('inv2.open', {}); },
|
||||||
@ -2816,12 +2877,87 @@ const game = {
|
|||||||
sort(by) { _send('inv2.sort', { by: by || 'rarity' }); },
|
sort(by) { _send('inv2.sort', { by: by || 'rarity' }); },
|
||||||
setActiveHotbar(i) { _send('inv2.setActive', { i: Number(i) || 0 }); },
|
setActiveHotbar(i) { _send('inv2.setActive', { i: Number(i) || 0 }); },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// === Phase 6.4: пользовательские tools (как Roblox Tool) ===
|
||||||
|
tools: {
|
||||||
|
create(name, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
_toolSeq++;
|
||||||
|
const toolId = 'custom:' + _toolSeq;
|
||||||
|
_toolCallbacks[toolId] = {};
|
||||||
|
const tool = {
|
||||||
|
get id() { return toolId; },
|
||||||
|
get name() { return String(name || ('Tool ' + _toolSeq)); },
|
||||||
|
get modelTypeId() { return opts.model || null; },
|
||||||
|
get kind() { return opts.kind || 'tool'; },
|
||||||
|
onActivated(fn) { if (typeof fn === 'function') _toolCallbacks[toolId].activated = fn; },
|
||||||
|
onEquipped(fn) { if (typeof fn === 'function') _toolCallbacks[toolId].equipped = fn; },
|
||||||
|
onUnequipped(fn) { if (typeof fn === 'function') _toolCallbacks[toolId].unequipped = fn; },
|
||||||
|
dropAt(pos) {
|
||||||
|
if (!pos || typeof pos !== 'object') return;
|
||||||
|
_send('tools.drop', {
|
||||||
|
toolId, name: String(name), model: opts.model || null,
|
||||||
|
params: opts.params || {},
|
||||||
|
x: Number(pos.x) || 0, y: Number(pos.y) || 0, z: Number(pos.z) || 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return tool;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Определения предметов (задача 44) ===
|
||||||
items: {
|
items: {
|
||||||
define(def) {
|
define(def) {
|
||||||
if (Array.isArray(def)) { for (const d of def) _send('items.define', { def: d }); return; }
|
if (Array.isArray(def)) { for (const d of def) _send('items.define', { def: d }); return; }
|
||||||
_send('items.define', { def: def || {} });
|
_send('items.define', { def: def || {} });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// === Лидерборды (leaderstats) — задача 20 ===
|
||||||
|
leaderstats: {
|
||||||
|
define(name, opts) {
|
||||||
|
if (typeof name !== 'string' || !name) return;
|
||||||
|
_send('leaderstats.define', { name, opts: opts || {} });
|
||||||
|
},
|
||||||
|
set(playerId, name, value) {
|
||||||
|
_send('leaderstats.set', { playerId: playerId == null ? null : String(playerId), name, value: Number(value) || 0 });
|
||||||
|
const pid = playerId == null ? '@me' : String(playerId);
|
||||||
|
if (!_lsMirror[pid]) _lsMirror[pid] = {};
|
||||||
|
_lsMirror[pid][name] = Number(value) || 0;
|
||||||
|
},
|
||||||
|
add(playerId, name, delta) {
|
||||||
|
_send('leaderstats.add', { playerId: playerId == null ? null : String(playerId), name, delta: Number(delta) || 0 });
|
||||||
|
const pid = playerId == null ? '@me' : String(playerId);
|
||||||
|
if (!_lsMirror[pid]) _lsMirror[pid] = {};
|
||||||
|
_lsMirror[pid][name] = (_lsMirror[pid][name] || 0) + (Number(delta) || 0);
|
||||||
|
},
|
||||||
|
get(playerId, name) {
|
||||||
|
const pid = playerId == null ? '@me' : String(playerId);
|
||||||
|
return (_lsMirror[pid] && _lsMirror[pid][name]) || 0;
|
||||||
|
},
|
||||||
|
onChange(fn) { if (typeof fn === 'function') _lsChangeHandlers.push(fn); },
|
||||||
|
me: {
|
||||||
|
set(name, value) { game.leaderstats.set(null, name, value); },
|
||||||
|
add(name, delta) { game.leaderstats.add(null, name, delta); },
|
||||||
|
get(name) { return game.leaderstats.get(null, name); },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Достижения — задача 20 ===
|
||||||
|
achievements: {
|
||||||
|
define(list) { _send('achievements.define', { list: Array.isArray(list) ? list : [list] }); },
|
||||||
|
unlock(id, playerId) {
|
||||||
|
if (typeof id !== 'string') return;
|
||||||
|
_achUnlocked[id] = true;
|
||||||
|
_send('achievements.unlock', { id, playerId: playerId == null ? null : String(playerId) });
|
||||||
|
},
|
||||||
|
has(id) { return !!_achUnlocked[id]; },
|
||||||
|
bindToStat(id, statName, cond) { _send('achievements.bindToStat', { id, statName, cond: cond || {} }); },
|
||||||
|
setButtonVisible(v) { _send('achievements.setButtonVisible', { visible: !!v }); },
|
||||||
|
openPage() { _send('achievements.openPage', {}); },
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Игроки комнаты (Фаза 4.3 — мультиплеер).
|
* Игроки комнаты (Фаза 4.3 — мультиплеер).
|
||||||
* В одиночной игре (редактор) — только локальный игрок.
|
* В одиночной игре (редактор) — только локальный игрок.
|
||||||
@ -3365,6 +3501,41 @@ 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' мышь работает как обычный курсор (как в браузере),
|
||||||
@ -3935,12 +4106,15 @@ self.onmessage = (e) => {
|
|||||||
if (t === 'click') {
|
if (t === 'click') {
|
||||||
for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick');
|
for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick');
|
||||||
} else if (t === 'leaderstatsChange') {
|
} else if (t === 'leaderstatsChange') {
|
||||||
// Задача 20: стат изменился на main — обновляем зеркало + onChange.
|
// Задача 20: стат изменился на сервере/main — обновляем зеркало + onChange.
|
||||||
const pid = payload.playerId == null ? '@me' : String(payload.playerId);
|
const pid = payload.playerId == null ? '@me' : String(payload.playerId);
|
||||||
if (!_lsMirror[pid]) _lsMirror[pid] = {};
|
if (!_lsMirror[pid]) _lsMirror[pid] = {};
|
||||||
_lsMirror[pid][payload.name] = payload.newValue;
|
_lsMirror[pid][payload.name] = payload.newValue;
|
||||||
if (payload.isMe) { if (!_lsMirror['@me']) _lsMirror['@me'] = {}; _lsMirror['@me'][payload.name] = payload.newValue; }
|
if (payload.isMe) { if (!_lsMirror['@me']) _lsMirror['@me'] = {}; _lsMirror['@me'][payload.name] = payload.newValue; }
|
||||||
for (const fn of _lsChangeHandlers) { try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); } catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); } }
|
for (const fn of _lsChangeHandlers) {
|
||||||
|
try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); }
|
||||||
|
catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); }
|
||||||
|
}
|
||||||
} else if (t === 'achievementUnlocked') {
|
} else if (t === 'achievementUnlocked') {
|
||||||
_achUnlocked[payload.id] = true;
|
_achUnlocked[payload.id] = true;
|
||||||
} else if (t === 'mouseMove') {
|
} else if (t === 'mouseMove') {
|
||||||
@ -3997,13 +4171,34 @@ self.onmessage = (e) => {
|
|||||||
for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath');
|
for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath');
|
||||||
}
|
}
|
||||||
} else if (t === 'toolUse') {
|
} else if (t === 'toolUse') {
|
||||||
// payload: { tool: {kind, modelTypeId, name}, point, target }
|
// payload: { tool: {kind, modelTypeId, name, customToolId?}, point, target }
|
||||||
const ev = {
|
const ev = {
|
||||||
tool: payload.tool || null,
|
tool: payload.tool || null,
|
||||||
point: payload.point || null,
|
point: payload.point || null,
|
||||||
target: payload.target || null,
|
target: payload.target || null,
|
||||||
};
|
};
|
||||||
|
// Phase 6.4: per-tool callback из game.tools.create -> onActivated.
|
||||||
|
const customId = payload.tool && payload.tool.customToolId;
|
||||||
|
if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].activated) {
|
||||||
|
_safeCall(_toolCallbacks[customId].activated, ev, 'tool.onActivated:' + customId);
|
||||||
|
}
|
||||||
for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse');
|
for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse');
|
||||||
|
} else if (t === 'toolEquipped') {
|
||||||
|
const customId = payload && payload.tool && payload.tool.customToolId;
|
||||||
|
if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].equipped) {
|
||||||
|
_safeCall(_toolCallbacks[customId].equipped, payload, 'tool.onEquipped:' + customId);
|
||||||
|
}
|
||||||
|
} else if (t === 'toolUnequipped') {
|
||||||
|
const customId = payload && payload.tool && payload.tool.customToolId;
|
||||||
|
if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].unequipped) {
|
||||||
|
_safeCall(_toolCallbacks[customId].unequipped, payload, 'tool.onUnequipped:' + customId);
|
||||||
|
}
|
||||||
|
} else if (t === 'remoteEvent') {
|
||||||
|
// Phase 6.6: RemoteEvent от сервера. payload: { from, name, data }
|
||||||
|
const arr = _remoteHandlers[payload.name] || [];
|
||||||
|
for (const fn of arr) {
|
||||||
|
_safeCall(fn, { from: payload.from, data: payload.data }, 'remote.on:' + payload.name);
|
||||||
|
}
|
||||||
} else if (t === 'cutsceneDone') {
|
} else if (t === 'cutsceneDone') {
|
||||||
// Катсцена камеры завершилась (Фаза 5.7).
|
// Катсцена камеры завершилась (Фаза 5.7).
|
||||||
for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone');
|
for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone');
|
||||||
@ -4122,6 +4317,7 @@ 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) {
|
||||||
@ -4131,6 +4327,13 @@ 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 {
|
||||||
|
|||||||
337
src/engine/lua/LuaSharedSandbox.js
Normal file
337
src/engine/lua/LuaSharedSandbox.js
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* LuaSharedSandbox (v3, main-thread) — wasmoon-VM работает в MAIN потоке,
|
||||||
|
* без Web Worker. Это позволяет:
|
||||||
|
* - Видеть точные Lua-ошибки в DevTools (через console.error)
|
||||||
|
* - Использовать debugger / breakpoints прямо в RobloxShim.js
|
||||||
|
* - Не возиться с молчаливыми Worker-падениями
|
||||||
|
*
|
||||||
|
* Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style
|
||||||
|
* скриптов это нестрашно — они быстрые.
|
||||||
|
*
|
||||||
|
* API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent /
|
||||||
|
* sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot /
|
||||||
|
* sendTerrainHeightmap / stop / tick / target.
|
||||||
|
*
|
||||||
|
* Что добавлено сверх ScriptSandbox:
|
||||||
|
* - addScript(id, code, target) — добавить скрипт в общий VM. Можно
|
||||||
|
* до или после start().
|
||||||
|
* - start() — асинхронен (createEngine), но возвращает сразу. После init
|
||||||
|
* стартует main loop (Heartbeat + scheduler).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LuaFactory } from 'wasmoon';
|
||||||
|
import { registerRobloxShim } from './RobloxShim.js';
|
||||||
|
|
||||||
|
export class LuaSharedSandbox {
|
||||||
|
constructor() {
|
||||||
|
this.vm = null;
|
||||||
|
this.api = null;
|
||||||
|
this._onCommand = null;
|
||||||
|
this._isReady = false;
|
||||||
|
this._isStopped = false;
|
||||||
|
this._isKickedOff = false;
|
||||||
|
this._pendingScripts = []; // [{id, code, target, name}]
|
||||||
|
this._scriptsById = new Map();
|
||||||
|
this._scenes = null;
|
||||||
|
this._guiTree = null;
|
||||||
|
this._loopHandle = null;
|
||||||
|
this._lastTickAt = 0;
|
||||||
|
// Маркер для GameRuntime.routeEvent — этот sandbox принимает все
|
||||||
|
// события и сам маршрутизирует через shim.fireTargetEvent.
|
||||||
|
this._luaShared = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnCommand(cb) { this._onCommand = cb; }
|
||||||
|
|
||||||
|
get target() { return null; }
|
||||||
|
tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ }
|
||||||
|
|
||||||
|
addScript(id, code, target, name, extra) {
|
||||||
|
const entry = {
|
||||||
|
id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`),
|
||||||
|
code: String(code || ''),
|
||||||
|
target: target == null ? null : target,
|
||||||
|
name: name || null,
|
||||||
|
toolName: extra?.toolName || null,
|
||||||
|
};
|
||||||
|
this._scriptsById.set(entry.id, entry);
|
||||||
|
if (!this._isKickedOff) {
|
||||||
|
this._pendingScripts.push(entry);
|
||||||
|
} else {
|
||||||
|
this._startSingleScript(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeScript(id) {
|
||||||
|
this._scriptsById.delete(String(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Стартует VM, регистрирует shim, запускает main-loop. */
|
||||||
|
start() {
|
||||||
|
if (this.vm || this._isStopped) return;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...');
|
||||||
|
this._initAsync().catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[LuaSharedSandbox] FATAL init error:', err);
|
||||||
|
this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _initAsync() {
|
||||||
|
const factory = new LuaFactory();
|
||||||
|
this.vm = await factory.createEngine({ openStandardLibs: true });
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('[LuaSharedSandbox] VM created. Registering Roblox shim...');
|
||||||
|
|
||||||
|
// Адаптер для shim'а: он ожидает send(cmd, payload), getSceneSnapshot, getGuiTree, scheduleWait.
|
||||||
|
const send = (cmd, payload) => this._emit(cmd, payload);
|
||||||
|
|
||||||
|
this.api = registerRobloxShim(this.vm, {
|
||||||
|
send,
|
||||||
|
getSceneSnapshot: () => this._scenes,
|
||||||
|
getGuiTree: () => this._guiTree,
|
||||||
|
scheduleWait: () => null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('[LuaSharedSandbox] Shim registered. api keys:', Object.keys(this.api || {}));
|
||||||
|
|
||||||
|
// Применим snapshot если он есть
|
||||||
|
if (this._scenes && this.api?.onSceneSnapshot) {
|
||||||
|
try { this.api.onSceneSnapshot(this._scenes); } catch (e) {
|
||||||
|
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isReady = true;
|
||||||
|
this._kickoff();
|
||||||
|
}
|
||||||
|
|
||||||
|
_kickoff() {
|
||||||
|
if (this._isKickedOff || this._isStopped) return;
|
||||||
|
this._isKickedOff = true;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[LuaSharedSandbox] kickoff: starting ${this._pendingScripts.length} scripts`);
|
||||||
|
const pending = this._pendingScripts;
|
||||||
|
this._pendingScripts = [];
|
||||||
|
// Запускаем main-loop сразу — он начнёт tick'ать как только будут coroutines.
|
||||||
|
this._lastTickAt = performance.now();
|
||||||
|
this._startMainLoop();
|
||||||
|
// Init батчами по 5 с задержкой 20мс между ними, чтобы UI отзывался.
|
||||||
|
const BATCH_SIZE = 5;
|
||||||
|
let idx = 0;
|
||||||
|
const initBatch = () => {
|
||||||
|
if (this._isStopped) return;
|
||||||
|
const end = Math.min(idx + BATCH_SIZE, pending.length);
|
||||||
|
for (let i = idx; i < end; i++) {
|
||||||
|
try { this._startSingleScript(pending[i]); }
|
||||||
|
catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[LuaSharedSandbox] init batch err:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx = end;
|
||||||
|
if (idx < pending.length) {
|
||||||
|
setTimeout(initBatch, 20);
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`);
|
||||||
|
// После того как все скрипты подключили хендлеры — фейрим
|
||||||
|
// events для уже существующих сущностей. Roblox-конвенция:
|
||||||
|
// если игрок уже на сервере когда скрипт подключается,
|
||||||
|
// Players.PlayerAdded не сработает повторно. Юзеру нужно
|
||||||
|
// делать ручной обход GetPlayers() — но это редко кто помнит.
|
||||||
|
// Мы дублируем событие через короткую задержку.
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (this.api?.fireExistingPlayers) {
|
||||||
|
this.api.fireExistingPlayers();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[LuaSharedSandbox] fireExistingPlayers failed:', e);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(initBatch, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_startSingleScript(entry) {
|
||||||
|
if (!this.vm || !entry || typeof entry.code !== 'string') return;
|
||||||
|
let primId = null;
|
||||||
|
if (typeof entry.target === 'number') primId = entry.target;
|
||||||
|
else if (entry.target && typeof entry.target === 'object') {
|
||||||
|
if (entry.target.kind === 'primitive') primId = entry.target.id ?? entry.target.ref;
|
||||||
|
}
|
||||||
|
const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||||
|
const scriptName = entry.name || `Script_${safeId}`;
|
||||||
|
// Скрипт оборачиваем в coroutine — это позволяет task.wait через yield.
|
||||||
|
// Резюмим coroutine из main-loop когда наступило время.
|
||||||
|
// Регистрируем coroutine в __rbxl_coroutines с id для возобновления.
|
||||||
|
// Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает
|
||||||
|
// delay из resume → планируем следующий resume через scheduleResume.
|
||||||
|
// Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) —
|
||||||
|
// подсовываем виртуальный Tool как script.Parent. Иначе primitive по id,
|
||||||
|
// иначе workspace.
|
||||||
|
let parentExpr;
|
||||||
|
if (entry.toolName) {
|
||||||
|
// Tool создаётся в shim как Instance.new('Tool'). По имени достаём.
|
||||||
|
// Если не нашли — fallback на новый Tool того же имени.
|
||||||
|
const safeName = JSON.stringify(entry.toolName);
|
||||||
|
parentExpr = `(function()
|
||||||
|
local existing = __rbxl_get_tool_by_name(${safeName})
|
||||||
|
if existing then return existing end
|
||||||
|
local t = Instance.new("Tool")
|
||||||
|
t.Name = ${safeName}
|
||||||
|
return t
|
||||||
|
end)()`;
|
||||||
|
} else if (primId != null) {
|
||||||
|
parentExpr = `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)`;
|
||||||
|
} else {
|
||||||
|
parentExpr = 'workspace';
|
||||||
|
}
|
||||||
|
const wrapped = `
|
||||||
|
do
|
||||||
|
-- Если parentExpr вернул primitive — у него уже есть :FindFirstChild и пр.
|
||||||
|
-- Если ничего не вернёт — workspace (всегда валидный).
|
||||||
|
-- script.Parent.Parent (Tool.Parent = StarterPack / Backpack / workspace).
|
||||||
|
local _scriptParent = ${parentExpr}
|
||||||
|
if _scriptParent == nil then _scriptParent = workspace end
|
||||||
|
if _scriptParent.Parent == nil then _scriptParent.Parent = workspace end
|
||||||
|
local script = setmetatable({
|
||||||
|
Name = ${JSON.stringify(scriptName)},
|
||||||
|
Parent = _scriptParent,
|
||||||
|
ClassName = "Script",
|
||||||
|
Disabled = false,
|
||||||
|
Source = nil,
|
||||||
|
}, {
|
||||||
|
-- Любой доступ к несуществующему полю → workspace
|
||||||
|
-- (на случай script.Foo:Bar() в старом коде)
|
||||||
|
__index = function(t, k)
|
||||||
|
if k == "FindFirstChild" or k == "WaitForChild" or k == "GetChildren" then
|
||||||
|
return function() return nil end
|
||||||
|
end
|
||||||
|
return workspace[k]
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
local co = coroutine.create(function()
|
||||||
|
-- WATCHDOG: каждые 100000 инструкций — yield 1 кадр.
|
||||||
|
-- НЕ оборачиваем в pcall — внутри C-call boundary yield
|
||||||
|
-- упадёт ошибкой, что прервёт скрипт. Это лучше чем виснуть.
|
||||||
|
debug.sethook(function()
|
||||||
|
coroutine.yield(0.016)
|
||||||
|
end, "", 20000)
|
||||||
|
-- pcall защищает от runtime-ошибок которые иначе крашат
|
||||||
|
-- coroutine и могут повредить WASM-стейт. Возвраты
|
||||||
|
-- handler'а намеренно поглощаются.
|
||||||
|
local ok_, err_ = pcall(function()
|
||||||
|
${entry.code}
|
||||||
|
end)
|
||||||
|
if not ok_ then
|
||||||
|
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err_))
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
__rbxl_register_coroutine(${JSON.stringify(entry.id)}, co)
|
||||||
|
local ok, ret = coroutine.resume(co)
|
||||||
|
if not ok then
|
||||||
|
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(ret))
|
||||||
|
__rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
|
||||||
|
elseif type(ret) == 'number' then
|
||||||
|
-- скрипт yield'нул с delay (через task.wait) — планируем resume
|
||||||
|
__rbxl_schedule_resume(${JSON.stringify(entry.id)}, ret)
|
||||||
|
elseif coroutine.status(co) == 'dead' then
|
||||||
|
__rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
`;
|
||||||
|
try {
|
||||||
|
this.vm.doStringSync(wrapped);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[LuaSharedSandbox] script ${entry.id} initialized OK`);
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[LuaSharedSandbox] script ${entry.id} init FAILED:`, err);
|
||||||
|
this._emit('log', { level: 'error', text: `[Lua ${entry.id}] ${err?.message || err}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startMainLoop() {
|
||||||
|
const tick = () => {
|
||||||
|
if (this._isStopped) return;
|
||||||
|
try {
|
||||||
|
const now = performance.now();
|
||||||
|
const dt = Math.min(0.1, (now - this._lastTickAt) / 1000);
|
||||||
|
this._lastTickAt = now;
|
||||||
|
if (this.api?.tickScheduler) this.api.tickScheduler(dt);
|
||||||
|
if (this.api?.fireHeartbeat) this.api.fireHeartbeat(dt);
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[LuaSharedSandbox tick]', e);
|
||||||
|
}
|
||||||
|
this._loopHandle = setTimeout(tick, 16);
|
||||||
|
};
|
||||||
|
this._loopHandle = setTimeout(tick, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
_emit(cmd, payload) {
|
||||||
|
if (typeof this._onCommand === 'function') {
|
||||||
|
try { this._onCommand({ cmd, payload }); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- API совместимый с ScriptSandbox -----
|
||||||
|
sendEvent(payload) {
|
||||||
|
if (!this.api?.fireTargetEvent || !this._isReady) return;
|
||||||
|
try { this.api.fireTargetEvent(payload); } catch (e) {
|
||||||
|
console.error('[LuaSharedSandbox] sendEvent:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendGlobalEvent(payload) {
|
||||||
|
if (!this.api?.fireGlobalEvent || !this._isReady) return;
|
||||||
|
try { this.api.fireGlobalEvent(payload); } catch (e) {
|
||||||
|
console.error('[LuaSharedSandbox] sendGlobalEvent:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSceneSnapshot(snapshot) {
|
||||||
|
this._scenes = snapshot;
|
||||||
|
if (this.api?.onSceneSnapshot && this._isReady) {
|
||||||
|
try { this.api.onSceneSnapshot(snapshot); } catch (e) {
|
||||||
|
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendGuiSnapshot(snapshot) {
|
||||||
|
this._guiTree = snapshot;
|
||||||
|
if (this.api?.onGuiSnapshot && this._isReady) {
|
||||||
|
try { this.api.onGuiSnapshot(snapshot); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendDataSnapshot(snapshot) {
|
||||||
|
if (this.api?.onDataSnapshot && this._isReady) {
|
||||||
|
try { this.api.onDataSnapshot(snapshot); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ }
|
||||||
|
sendTerrainHeightmap(_) { /* no-op */ }
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this._isStopped = true;
|
||||||
|
if (this._loopHandle) {
|
||||||
|
clearTimeout(this._loopHandle);
|
||||||
|
this._loopHandle = null;
|
||||||
|
}
|
||||||
|
if (this.vm) {
|
||||||
|
try { this.vm.global.close(); } catch (_) {}
|
||||||
|
this.vm = null;
|
||||||
|
}
|
||||||
|
this.api = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LuaSharedSandbox;
|
||||||
2500
src/engine/lua/RobloxShim.js
Normal file
2500
src/engine/lua/RobloxShim.js
Normal file
File diff suppressed because it is too large
Load Diff
210
src/engine/rbxl-lua-integration.js
Normal file
210
src/engine/rbxl-lua-integration.js
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* rbxl-lua-integration.js — вспомогательные функции для импорта .rbxl-карт.
|
||||||
|
*
|
||||||
|
* Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные
|
||||||
|
* Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua
|
||||||
|
* (см. GameRuntime.start()). Этот файл оставлен только для:
|
||||||
|
* - unpackRobloxLuaCode() — распаковка Lua из JS-комментария-обёртки;
|
||||||
|
* - handleLuaCommand() — обработка partSet/sceneCreate/sceneDelete/playerCmd
|
||||||
|
* команд от Lua-VM в BabylonScene.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Распаковка lua_source из packed-кода. */
|
||||||
|
export function unpackRobloxLuaCode(code) {
|
||||||
|
const openTag = '/[*] lua_source:\n'.replace('[*]', '*');
|
||||||
|
const i = code.indexOf(openTag);
|
||||||
|
if (i < 0) return null;
|
||||||
|
const start = i + openTag.length;
|
||||||
|
const closeIdx = code.lastIndexOf('\n*' + '/');
|
||||||
|
if (closeIdx < start) return null;
|
||||||
|
return code.slice(start, closeIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Парсит JSON-метадату из 2-й строки packed-кода (`// {"roblox_class":..., "enabled": true}`). */
|
||||||
|
export function parseRobloxLuaMeta(code) {
|
||||||
|
if (typeof code !== 'string') return null;
|
||||||
|
const lines = code.split('\n');
|
||||||
|
if (lines.length < 2) return null;
|
||||||
|
const metaLine = lines[1];
|
||||||
|
if (!metaLine.startsWith('// ')) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(metaLine.slice(3));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сцена → snap для shim'а (workspace:GetChildren). */
|
||||||
|
export function buildLuaSceneSnap(primitives) {
|
||||||
|
const out = { primitives: {} };
|
||||||
|
if (!Array.isArray(primitives)) return out;
|
||||||
|
for (const p of primitives) {
|
||||||
|
out.primitives[p.id] = {
|
||||||
|
id: p.id, type: p.type, name: p.name,
|
||||||
|
x: p.x, y: p.y, z: p.z,
|
||||||
|
sx: p.sx, sy: p.sy, sz: p.sz,
|
||||||
|
color: p.color, material: p.material,
|
||||||
|
anchored: !!p.anchored, canCollide: p.canCollide !== false,
|
||||||
|
opacity: typeof p.opacity === 'number' ? p.opacity : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-tree для shim'а. Mapping origin → __roblox_class.
|
||||||
|
* scene.gui — массив элементов с {id, type, name, parentId, ...origin}.
|
||||||
|
* Возвращаем массив сохраняя порядок parent → child (важно для tree-сборки).
|
||||||
|
*/
|
||||||
|
export function buildLuaGuiTree(guiElements) {
|
||||||
|
if (!Array.isArray(guiElements)) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const el of guiElements) {
|
||||||
|
// origin = 'roblox-textbutton' → 'TextButton'
|
||||||
|
let rblClass = 'Frame';
|
||||||
|
const origin = el.origin || '';
|
||||||
|
if (origin.startsWith('roblox-')) {
|
||||||
|
const tail = origin.slice(7);
|
||||||
|
rblClass = tail.charAt(0).toUpperCase() + tail.slice(1);
|
||||||
|
// Camel-case "textbutton" → "TextButton"
|
||||||
|
if (rblClass.toLowerCase() === 'textbutton') rblClass = 'TextButton';
|
||||||
|
else if (rblClass.toLowerCase() === 'textlabel') rblClass = 'TextLabel';
|
||||||
|
else if (rblClass.toLowerCase() === 'imagebutton') rblClass = 'ImageButton';
|
||||||
|
else if (rblClass.toLowerCase() === 'imagelabel') rblClass = 'ImageLabel';
|
||||||
|
else if (rblClass.toLowerCase() === 'textbox') rblClass = 'TextBox';
|
||||||
|
else if (rblClass.toLowerCase() === 'scrollingframe') rblClass = 'ScrollingFrame';
|
||||||
|
else if (rblClass.toLowerCase() === 'frame') rblClass = 'Frame';
|
||||||
|
} else {
|
||||||
|
// Если origin не задан — гадаем по type
|
||||||
|
const t = el.type;
|
||||||
|
if (t === 'button') rblClass = 'TextButton';
|
||||||
|
else if (t === 'text') rblClass = 'TextLabel';
|
||||||
|
else if (t === 'image') rblClass = 'ImageLabel';
|
||||||
|
else if (t === 'textbox') rblClass = 'TextBox';
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
id: el.id,
|
||||||
|
name: el.name || rblClass,
|
||||||
|
parentId: el.parentId || null,
|
||||||
|
visible: el.visible !== false,
|
||||||
|
text: el.text || '',
|
||||||
|
__roblox_class: rblClass,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка IPC команд от worker'а — мапим на действия в Babylon-сцене.
|
||||||
|
*/
|
||||||
|
export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
|
||||||
|
if (cmd === 'log') {
|
||||||
|
const fn = payload?.level === 'error' ? console.error
|
||||||
|
: payload?.level === 'warn' ? console.warn : console.log;
|
||||||
|
fn('[rbxl-lua]', payload?.text || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'partSet') {
|
||||||
|
const pm = runtime.scene3d?.primitiveManager;
|
||||||
|
if (!pm) {
|
||||||
|
console.warn('[partSet] no primitiveManager. scene3d=', !!runtime.scene3d);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const primId = payload?.primId;
|
||||||
|
const prop = payload?.prop;
|
||||||
|
const value = payload?.value;
|
||||||
|
const patch = {};
|
||||||
|
if (prop === 'position' && value) {
|
||||||
|
patch.x = value.x; patch.y = value.y; patch.z = value.z;
|
||||||
|
} else if (prop === 'cframe' && value) {
|
||||||
|
patch.x = value.x; patch.y = value.y; patch.z = value.z;
|
||||||
|
patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz;
|
||||||
|
} else if (prop === 'size' && value) {
|
||||||
|
patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz;
|
||||||
|
} else if (prop === 'color') patch.color = value;
|
||||||
|
else if (prop === 'material') patch.material = value;
|
||||||
|
else if (prop === 'anchored') patch.anchored = value;
|
||||||
|
else if (prop === 'canCollide') patch.canCollide = value;
|
||||||
|
else if (prop === 'opacity') patch.opacity = value;
|
||||||
|
try {
|
||||||
|
if (typeof pm.updateInstance === 'function') pm.updateInstance(primId, patch);
|
||||||
|
else if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
|
||||||
|
else if (typeof pm.update === 'function') pm.update(primId, patch);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[partSet] updateInstance failed:', e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'sceneCreate') {
|
||||||
|
// Lua: Instance.new("Part") + part.Parent = workspace → создание примитива.
|
||||||
|
// payload: { primId (предложенный id), type, x, y, z, sx, sy, sz, color, anchored }
|
||||||
|
try {
|
||||||
|
const pm = runtime.scene3d?.primitiveManager;
|
||||||
|
if (!pm || typeof pm.addInstance !== 'function') return;
|
||||||
|
const opts = {
|
||||||
|
id: payload?.primId,
|
||||||
|
x: payload?.x || 0, y: payload?.y || 0, z: payload?.z || 0,
|
||||||
|
sx: payload?.sx || 1, sy: payload?.sy || 1, sz: payload?.sz || 1,
|
||||||
|
color: payload?.color,
|
||||||
|
anchored: payload?.anchored !== false,
|
||||||
|
canCollide: payload?.canCollide !== false,
|
||||||
|
};
|
||||||
|
pm.addInstance(payload?.type || 'cube', opts);
|
||||||
|
// Если unanchored — регистрируем в физике на лету, иначе он не падает.
|
||||||
|
if (opts.anchored === false) {
|
||||||
|
try {
|
||||||
|
const dm = runtime.scene3d?.dynamics;
|
||||||
|
const data = pm.instances?.get?.(opts.id);
|
||||||
|
if (dm && data && typeof dm.registerPrimitive === 'function') {
|
||||||
|
dm.registerPrimitive(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[sceneCreate] registerPrimitive failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[sceneCreate]', e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'sceneDelete') {
|
||||||
|
// Lua: part:Destroy() → удаление примитива.
|
||||||
|
try {
|
||||||
|
const pm = runtime.scene3d?.primitiveManager;
|
||||||
|
if (!pm || typeof pm.removeInstance !== 'function') return;
|
||||||
|
const id = payload?.primId;
|
||||||
|
if (id != null) pm.removeInstance(Number(id));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[sceneDelete]', e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'partVel') {
|
||||||
|
try {
|
||||||
|
const pm = runtime.scene3d?.primitiveManager;
|
||||||
|
if (pm && typeof pm.setVelocity === 'function') {
|
||||||
|
pm.setVelocity(payload.primId, payload.vx, payload.vy, payload.vz);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'playerCmd') {
|
||||||
|
try {
|
||||||
|
const p = runtime.game?.player;
|
||||||
|
if (!p) return;
|
||||||
|
const method = payload?.method;
|
||||||
|
const args = payload?.args || [];
|
||||||
|
if (method === 'teleport') p.teleport && p.teleport(args[0], args[1], args[2]);
|
||||||
|
else if (method === 'setWalkSpeed') p.setWalkSpeed && p.setWalkSpeed(args[0]);
|
||||||
|
else if (method === 'setJumpPower') p.setJumpPower && p.setJumpPower(args[0]);
|
||||||
|
else if (method === 'setHealth') p.setHealth && p.setHealth(args[0]);
|
||||||
|
else if (method === 'die') p.die && p.die();
|
||||||
|
else if (method === 'damage' || method === 'takeDamage') p.damage && p.damage(args[0]);
|
||||||
|
} catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd === 'guiUpdate') {
|
||||||
|
// TODO: scripts setting Visible/Text/Color on GUI → передать в GuiManager
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
243
tests/rbxl-lua-integration.test.js
Normal file
243
tests/rbxl-lua-integration.test.js
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* rbxl-lua-integration.test.js — реалистичные Roblox-сниппеты из obby/simulator карт.
|
||||||
|
*/
|
||||||
|
import { LuaFactory } from 'wasmoon';
|
||||||
|
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
|
||||||
|
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
|
||||||
|
import { installRobloxServices } from '../src/engine/roblox-services.js';
|
||||||
|
import { RobloxTweenManager } from '../src/engine/roblox-tween.js';
|
||||||
|
import { RobloxPhysicsManager } from '../src/engine/roblox-physics.js';
|
||||||
|
|
||||||
|
function makeScene() {
|
||||||
|
return {
|
||||||
|
primitives: {
|
||||||
|
10: { id: 10, type: 'cube', name: 'KillPart', x: 5, y: 1, z: 0, sx: 4, sy: 1, sz: 4,
|
||||||
|
color: '#ff0000', material: 'neon', anchored: true, canCollide: true, opacity: 1 },
|
||||||
|
11: { id: 11, type: 'cube', name: 'WinPart', x: 30, y: 1, z: 0, sx: 4, sy: 1, sz: 4,
|
||||||
|
color: '#00ff00', material: 'neon', anchored: true, canCollide: true, opacity: 1 },
|
||||||
|
12: { id: 12, type: 'cube', name: 'Conveyor', x: 15, y: 1, z: 0, sx: 8, sy: 0.5, sz: 4,
|
||||||
|
color: '#888888', material: 'metal', anchored: true, canCollide: true, opacity: 1 },
|
||||||
|
13: { id: 13, type: 'cube', name: 'Door', x: 20, y: 3, z: 0, sx: 2, sy: 6, sz: 4,
|
||||||
|
color: '#a0522d', material: 'matte', anchored: true, canCollide: true, opacity: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORE = new Map();
|
||||||
|
|
||||||
|
async function run(luaSource, targetPrimId = 10, ticks = []) {
|
||||||
|
const factory = new LuaFactory();
|
||||||
|
const lua = await factory.createEngine();
|
||||||
|
const sent = [];
|
||||||
|
const send = (cmd, payload) => sent.push({ cmd, payload });
|
||||||
|
let playerState = { x: 0, y: 5, z: 0, hp: 100 };
|
||||||
|
registerRobloxApi(lua, { getSceneSnap: makeScene, targetPrimitiveId: targetPrimId, send });
|
||||||
|
const sched = new RobloxScheduler(lua);
|
||||||
|
sched.install();
|
||||||
|
installRobloxServices(lua, {
|
||||||
|
send,
|
||||||
|
getPlayerState: () => playerState,
|
||||||
|
loadSave: (k) => STORE.get(k),
|
||||||
|
saveSave: (k, v) => STORE.set(k, v),
|
||||||
|
removeSave: (k) => STORE.delete(k),
|
||||||
|
});
|
||||||
|
const tween = new RobloxTweenManager();
|
||||||
|
tween.install(lua);
|
||||||
|
const phys = new RobloxPhysicsManager(send);
|
||||||
|
phys.install(lua);
|
||||||
|
|
||||||
|
await sched.spawnMain(luaSource);
|
||||||
|
for (const dt of ticks) {
|
||||||
|
await sched.tick(dt);
|
||||||
|
tween.tick(dt);
|
||||||
|
phys.tick(dt);
|
||||||
|
}
|
||||||
|
lua.global.close();
|
||||||
|
return {
|
||||||
|
logs: sent.filter(s => s.cmd === 'log').map(s => s.payload),
|
||||||
|
partSets: sent.filter(s => s.cmd === 'partSet').map(s => s.payload),
|
||||||
|
partVels: sent.filter(s => s.cmd === 'partVel').map(s => s.payload),
|
||||||
|
playerCmds: sent.filter(s => s.cmd === 'playerCmd').map(s => s.payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TESTS = [
|
||||||
|
{
|
||||||
|
name: 'KillBrick (Touched → Humanoid.Health = 0)',
|
||||||
|
lua: `
|
||||||
|
local part = script.Parent
|
||||||
|
part.Touched:Connect(function(hit)
|
||||||
|
local hum = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
|
||||||
|
if hum then hum.Health = 0 end
|
||||||
|
end)
|
||||||
|
print("kill brick armed")
|
||||||
|
`,
|
||||||
|
ticks: [],
|
||||||
|
check: (r) => r.logs.some(l => l.text === 'kill brick armed'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'WalkSpeed boost через trigger',
|
||||||
|
lua: `
|
||||||
|
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
|
||||||
|
h.WalkSpeed = 32
|
||||||
|
print("speed boosted to", h.WalkSpeed)
|
||||||
|
`,
|
||||||
|
check: (r) => r.playerCmds.some(c => c.method === 'setWalkSpeed' && c.args[0] === 32)
|
||||||
|
&& r.logs.some(l => l.text.includes('speed boosted')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Door open: TweenService двигает дверь вверх',
|
||||||
|
lua: `
|
||||||
|
local door = workspace:FindFirstChild("Door")
|
||||||
|
local TS = game:GetService("TweenService")
|
||||||
|
local goal = { Position = Vector3.new(door.Position.X, door.Position.Y + 10, door.Position.Z) }
|
||||||
|
local info = TweenInfo.new(1, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
|
||||||
|
local tw = TS:Create(door, info, goal)
|
||||||
|
tw:Play()
|
||||||
|
print("door opening")
|
||||||
|
`,
|
||||||
|
ticks: [0.5, 0.5, 0.1],
|
||||||
|
check: (r) => r.partSets.some(p => p.primId === 13 && p.prop === 'position'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Конвейер: BodyVelocity толкает игрока',
|
||||||
|
lua: `
|
||||||
|
local conv = workspace:FindFirstChild("Conveyor")
|
||||||
|
local bv = Instance.new("BodyVelocity", conv)
|
||||||
|
bv.Velocity = Vector3.new(20, 0, 0)
|
||||||
|
bv.MaxForce = Vector3.new(4000, 0, 4000)
|
||||||
|
print("conveyor started")
|
||||||
|
`,
|
||||||
|
ticks: [0.1],
|
||||||
|
check: (r) => r.partVels.some(v => v.primId === 12 && v.vx === 20),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'leaderstats (как в tycoon)',
|
||||||
|
lua: `
|
||||||
|
local Players = game:GetService("Players")
|
||||||
|
local plr = Players.LocalPlayer
|
||||||
|
local money = Instance.new("IntValue", plr.leaderstats)
|
||||||
|
money.Name = "Money"
|
||||||
|
money.Value = 100
|
||||||
|
print("money:", money.Value)
|
||||||
|
`,
|
||||||
|
check: (r) => r.logs.some(l => l.text === 'money:\t100'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Checkpoint сохраняется в DataStore',
|
||||||
|
lua: `
|
||||||
|
local DSS = game:GetService("DataStoreService")
|
||||||
|
local store = DSS:GetDataStore("checkpoints")
|
||||||
|
store:SetAsync("player1", 5)
|
||||||
|
local cp = store:GetAsync("player1")
|
||||||
|
print("checkpoint:", cp)
|
||||||
|
`,
|
||||||
|
check: (r) => r.logs.some(l => l.text === 'checkpoint:\t5'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Цикл с wait — подсчёт',
|
||||||
|
lua: `
|
||||||
|
for i = 1, 3 do
|
||||||
|
print("count:", i)
|
||||||
|
wait(0.3)
|
||||||
|
end
|
||||||
|
print("done")
|
||||||
|
`,
|
||||||
|
ticks: [0.3, 0.3, 0.3, 0.3],
|
||||||
|
check: (r) => {
|
||||||
|
const texts = r.logs.map(l => l.text);
|
||||||
|
return texts.includes('count:\t1') && texts.includes('count:\t2')
|
||||||
|
&& texts.includes('count:\t3') && texts.includes('done');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'task.spawn — параллельные функции',
|
||||||
|
lua: `
|
||||||
|
task.spawn(function() print("parallel A") end)
|
||||||
|
task.spawn(function() print("parallel B") end)
|
||||||
|
print("main")
|
||||||
|
`,
|
||||||
|
check: (r) => {
|
||||||
|
const texts = r.logs.map(l => l.text);
|
||||||
|
return texts.includes('parallel A') && texts.includes('parallel B') && texts.includes('main');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Color3 + Material смена при Touched',
|
||||||
|
lua: `
|
||||||
|
local part = workspace:FindFirstChild("KillPart")
|
||||||
|
part.Touched:Connect(function()
|
||||||
|
part.Color = Color3.fromRGB(0, 0, 255)
|
||||||
|
part.Material = "Neon"
|
||||||
|
end)
|
||||||
|
-- симулируем touch
|
||||||
|
part.Touched:Fire(workspace)
|
||||||
|
`,
|
||||||
|
check: (r) => r.partSets.some(p => p.primId === 10 && p.prop === 'color')
|
||||||
|
&& r.partSets.some(p => p.primId === 10 && p.prop === 'material'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'RemoteEvent: client→server message',
|
||||||
|
lua: `
|
||||||
|
local re = Instance.new("RemoteEvent", workspace)
|
||||||
|
re.Name = "Coins"
|
||||||
|
re.OnServerEvent:Connect(function(player, amount)
|
||||||
|
print("server received:", amount)
|
||||||
|
end)
|
||||||
|
re:FireServer(50)
|
||||||
|
`,
|
||||||
|
check: (r) => r.logs.some(l => l.text === 'server received:\t50'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Heartbeat: счётчик через RunService',
|
||||||
|
lua: `
|
||||||
|
local RS = game:GetService("RunService")
|
||||||
|
local count = 0
|
||||||
|
RS.Heartbeat:Connect(function(dt)
|
||||||
|
count = count + 1
|
||||||
|
if count == 3 then print("tick3") end
|
||||||
|
end)
|
||||||
|
`,
|
||||||
|
ticks: [0.1, 0.1, 0.1],
|
||||||
|
check: (r) => r.logs.some(l => l.text === 'tick3'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Math: Vector3 arithmetic',
|
||||||
|
lua: `
|
||||||
|
local a = Vector3.new(1, 2, 3)
|
||||||
|
local b = Vector3.new(4, 5, 6)
|
||||||
|
local sum = a:add(b)
|
||||||
|
print("sum:", sum.X, sum.Y, sum.Z)
|
||||||
|
local d = a:Dot(b)
|
||||||
|
print("dot:", d)
|
||||||
|
`,
|
||||||
|
check: (r) => {
|
||||||
|
const texts = r.logs.map(l => l.text);
|
||||||
|
return texts.some(t => t === 'sum:\t5\t7\t9') && texts.some(t => t === 'dot:\t32');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
for (const t of TESTS) {
|
||||||
|
try {
|
||||||
|
const r = await run(t.lua, t.targetPrimId, t.ticks || []);
|
||||||
|
const ok = t.check(r);
|
||||||
|
if (ok) { console.log(`✓ ${t.name}`); passed++; }
|
||||||
|
else {
|
||||||
|
console.log(`✗ ${t.name}`);
|
||||||
|
console.log(` logs: ${JSON.stringify(r.logs.map(l => l.text))}`);
|
||||||
|
if (r.partSets.length) console.log(` partSets: ${JSON.stringify(r.partSets)}`);
|
||||||
|
if (r.partVels.length) console.log(` partVels: ${JSON.stringify(r.partVels)}`);
|
||||||
|
if (r.playerCmds.length) console.log(` playerCmds: ${JSON.stringify(r.playerCmds)}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`✗ ${t.name} — exception: ${e.message || e}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
})();
|
||||||
187
tests/rbxl-lua-mvp.test.js
Normal file
187
tests/rbxl-lua-mvp.test.js
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* rbxl-lua-mvp.test.js — headless smoke-тест Roblox Lua API shim.
|
||||||
|
*
|
||||||
|
* НЕ запускает Worker (это требует браузерного Worker API). Вместо этого
|
||||||
|
* напрямую импортирует roblox-shim.js и инициализирует Lua в текущем потоке.
|
||||||
|
*
|
||||||
|
* Запуск: node --experimental-vm-modules tests/rbxl-lua-mvp.test.js
|
||||||
|
*/
|
||||||
|
import { LuaFactory } from 'wasmoon';
|
||||||
|
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
|
||||||
|
|
||||||
|
const FAKE_SCENE_SNAP = {
|
||||||
|
primitives: {
|
||||||
|
1: { id: 1, type: 'cube', name: 'Floor', x: 0, y: 0, z: 0, sx: 10, sy: 1, sz: 10,
|
||||||
|
color: '#888888', material: 'glossy', anchored: true, canCollide: true, opacity: 1 },
|
||||||
|
2: { id: 2, type: 'cube', name: 'KillBrick', x: 5, y: 1, z: 0, sx: 2, sy: 1, sz: 2,
|
||||||
|
color: '#ff0000', material: 'neon', anchored: true, canCollide: true, opacity: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SNIPPETS = [
|
||||||
|
{
|
||||||
|
name: 'print hello',
|
||||||
|
lua: `print("Hello from Lua!")`,
|
||||||
|
expectLogs: [{ level: 'info', text: 'Hello from Lua!' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Vector3 math',
|
||||||
|
lua: `
|
||||||
|
local v = Vector3.new(3, 4, 0)
|
||||||
|
print("magnitude:", v.Magnitude)
|
||||||
|
local u = v.Unit
|
||||||
|
print("unit:", u.X, u.Y, u.Z)
|
||||||
|
`,
|
||||||
|
expectLogs: [
|
||||||
|
{ level: 'info', text: 'magnitude:\t5' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'workspace iteration',
|
||||||
|
lua: `
|
||||||
|
local children = workspace:GetChildren()
|
||||||
|
print("count:", #children)
|
||||||
|
for i, c in ipairs(children) do
|
||||||
|
print("child:", c.Name, "class:", c.ClassName)
|
||||||
|
end
|
||||||
|
`,
|
||||||
|
expectLogs: [
|
||||||
|
{ level: 'info', text: 'count:\t2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FindFirstChild',
|
||||||
|
lua: `
|
||||||
|
local kb = workspace:FindFirstChild("KillBrick")
|
||||||
|
if kb then print("found:", kb.Name)
|
||||||
|
else print("not found") end
|
||||||
|
`,
|
||||||
|
expectLogs: [{ level: 'info', text: 'found:\tKillBrick' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Part.Position get',
|
||||||
|
lua: `
|
||||||
|
local kb = workspace:FindFirstChild("KillBrick")
|
||||||
|
print("position:", kb.Position.X, kb.Position.Y, kb.Position.Z)
|
||||||
|
`,
|
||||||
|
expectLogs: [{ level: 'info', text: 'position:\t5\t1\t0' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Part.Color set',
|
||||||
|
lua: `
|
||||||
|
local kb = workspace:FindFirstChild("KillBrick")
|
||||||
|
kb.Color = Color3.new(0, 1, 0)
|
||||||
|
print("new color hex (via Position):", kb.Color.R, kb.Color.G, kb.Color.B)
|
||||||
|
`,
|
||||||
|
expectPartSet: { primId: 2, prop: 'color' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CFrame.Angles',
|
||||||
|
lua: `
|
||||||
|
local cf = CFrame.Angles(0, math.pi/2, 0)
|
||||||
|
print("lookvector:", cf.LookVector.X, cf.LookVector.Y, cf.LookVector.Z)
|
||||||
|
`,
|
||||||
|
expectLogs: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Instance.new + Parent',
|
||||||
|
lua: `
|
||||||
|
local f = Instance.new("Folder", workspace)
|
||||||
|
f.Name = "MyFolder"
|
||||||
|
print("folder name:", f.Name, "parent:", f.Parent.Name)
|
||||||
|
`,
|
||||||
|
expectLogs: [{ level: 'info', text: 'folder name:\tMyFolder\tparent:\tWorkspace' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'IsA hierarchy',
|
||||||
|
lua: `
|
||||||
|
local kb = workspace:FindFirstChild("KillBrick")
|
||||||
|
print("isa Part:", kb:IsA("Part"))
|
||||||
|
print("isa BasePart:", kb:IsA("BasePart"))
|
||||||
|
print("isa Instance:", kb:IsA("Instance"))
|
||||||
|
print("isa Sound:", kb:IsA("Sound"))
|
||||||
|
`,
|
||||||
|
expectLogs: [
|
||||||
|
{ level: 'info', text: 'isa Part:\ttrue' },
|
||||||
|
{ level: 'info', text: 'isa BasePart:\ttrue' },
|
||||||
|
{ level: 'info', text: 'isa Instance:\ttrue' },
|
||||||
|
{ level: 'info', text: 'isa Sound:\tfalse' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function runSnippet(snippet) {
|
||||||
|
const factory = new LuaFactory();
|
||||||
|
const lua = await factory.createEngine();
|
||||||
|
|
||||||
|
const logs = [];
|
||||||
|
const sent = [];
|
||||||
|
const send = (cmd, payload) => sent.push({ cmd, payload });
|
||||||
|
|
||||||
|
registerRobloxApi(lua, {
|
||||||
|
getSceneSnap: () => FAKE_SCENE_SNAP,
|
||||||
|
targetPrimitiveId: 2, // как будто скрипт прикреплён к KillBrick
|
||||||
|
send,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Перехват print через send('log', ...)
|
||||||
|
let errMsg = null;
|
||||||
|
try {
|
||||||
|
await lua.doString(snippet.lua);
|
||||||
|
} catch (e) {
|
||||||
|
errMsg = e && e.message ? e.message : String(e);
|
||||||
|
}
|
||||||
|
lua.global.close();
|
||||||
|
|
||||||
|
const captured = sent.filter(s => s.cmd === 'log');
|
||||||
|
return { logs: captured.map(s => s.payload), partSets: sent.filter(s => s.cmd === 'partSet'), error: errMsg };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const s of SNIPPETS) {
|
||||||
|
const result = await runSnippet(s);
|
||||||
|
const ok = checkExpectations(s, result);
|
||||||
|
if (ok.success) {
|
||||||
|
console.log(`✓ ${s.name}`);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.log(`✗ ${s.name}`);
|
||||||
|
console.log(` error: ${result.error || 'none'}`);
|
||||||
|
console.log(` logs received:`);
|
||||||
|
for (const l of result.logs) console.log(` [${l.level}] ${JSON.stringify(l.text)}`);
|
||||||
|
if (result.partSets.length) {
|
||||||
|
console.log(` partSets:`, JSON.stringify(result.partSets));
|
||||||
|
}
|
||||||
|
console.log(` reason: ${ok.reason}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function checkExpectations(snippet, result) {
|
||||||
|
if (result.error) {
|
||||||
|
return { success: false, reason: `lua error: ${result.error}` };
|
||||||
|
}
|
||||||
|
if (snippet.expectLogs) {
|
||||||
|
for (const exp of snippet.expectLogs) {
|
||||||
|
const found = result.logs.find(l => l.level === exp.level && l.text === exp.text);
|
||||||
|
if (!found) {
|
||||||
|
return { success: false, reason: `missing log: [${exp.level}] ${JSON.stringify(exp.text)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (snippet.expectPartSet) {
|
||||||
|
const found = result.partSets.find(s =>
|
||||||
|
s.payload.primId === snippet.expectPartSet.primId &&
|
||||||
|
s.payload.prop === snippet.expectPartSet.prop
|
||||||
|
);
|
||||||
|
if (!found) {
|
||||||
|
return { success: false, reason: `missing partSet ${JSON.stringify(snippet.expectPartSet)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
144
tests/rbxl-lua-services.test.js
Normal file
144
tests/rbxl-lua-services.test.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* rbxl-lua-services.test.js — тесты Humanoid, RemoteEvent, DataStore, HttpService.
|
||||||
|
*/
|
||||||
|
import { LuaFactory } from 'wasmoon';
|
||||||
|
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
|
||||||
|
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
|
||||||
|
import { installRobloxServices } from '../src/engine/roblox-services.js';
|
||||||
|
|
||||||
|
const SCENE = { primitives: {} };
|
||||||
|
|
||||||
|
const STORE = new Map();
|
||||||
|
|
||||||
|
async function run(luaSource, ticks = []) {
|
||||||
|
const factory = new LuaFactory();
|
||||||
|
const lua = await factory.createEngine();
|
||||||
|
const sent = [];
|
||||||
|
const send = (cmd, payload) => sent.push({ cmd, payload });
|
||||||
|
let playerState = { x: 0, y: 5, z: 0 };
|
||||||
|
|
||||||
|
registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: null, send });
|
||||||
|
const sched = new RobloxScheduler(lua);
|
||||||
|
sched.install();
|
||||||
|
installRobloxServices(lua, {
|
||||||
|
send,
|
||||||
|
getPlayerState: () => playerState,
|
||||||
|
loadSave: (k) => STORE.get(k),
|
||||||
|
saveSave: (k, v) => STORE.set(k, v),
|
||||||
|
removeSave: (k) => STORE.delete(k),
|
||||||
|
});
|
||||||
|
|
||||||
|
await sched.spawnMain(luaSource);
|
||||||
|
for (const dt of ticks) await sched.tick(dt);
|
||||||
|
lua.global.close();
|
||||||
|
return { logs: sent.filter(s => s.cmd === 'log').map(s => s.payload),
|
||||||
|
playerCmds: sent.filter(s => s.cmd === 'playerCmd').map(s => s.payload),
|
||||||
|
broadcasts: sent.filter(s => s.cmd === 'broadcast').map(s => s.payload) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TESTS = [
|
||||||
|
{
|
||||||
|
name: 'Players.LocalPlayer.Character.Humanoid существует',
|
||||||
|
lua: `
|
||||||
|
local p = game:GetService("Players").LocalPlayer
|
||||||
|
local h = p.Character:WaitForChild("Humanoid")
|
||||||
|
print("hp:", h.Health, "ws:", h.WalkSpeed)
|
||||||
|
`,
|
||||||
|
expect: [{ level: 'info', text: 'hp:\t100\tws:\t16' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Humanoid.WalkSpeed = 50 → playerCmd setWalkSpeed',
|
||||||
|
lua: `
|
||||||
|
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
|
||||||
|
h.WalkSpeed = 50
|
||||||
|
`,
|
||||||
|
expectPlayerCmd: { method: 'setWalkSpeed', argsCheck: (a) => a[0] === 50 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Humanoid:TakeDamage уменьшает HP',
|
||||||
|
lua: `
|
||||||
|
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
|
||||||
|
h:TakeDamage(30)
|
||||||
|
print("after damage:", h.Health)
|
||||||
|
`,
|
||||||
|
expect: [{ level: 'info', text: 'after damage:\t70' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Humanoid.Health = 0 → Died fires',
|
||||||
|
lua: `
|
||||||
|
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
|
||||||
|
h.Died:Connect(function() print("DIED") end)
|
||||||
|
h.Health = 0
|
||||||
|
`,
|
||||||
|
expect: [{ level: 'info', text: 'DIED' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DataStoreService GetAsync/SetAsync',
|
||||||
|
lua: `
|
||||||
|
local DSS = game:GetService("DataStoreService")
|
||||||
|
local store = DSS:GetDataStore("coins")
|
||||||
|
store:SetAsync("player1", 100)
|
||||||
|
print("got:", store:GetAsync("player1"))
|
||||||
|
`,
|
||||||
|
expect: [{ level: 'info', text: 'got:\t100' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DataStoreService IncrementAsync',
|
||||||
|
lua: `
|
||||||
|
local store = game:GetService("DataStoreService"):GetDataStore("score")
|
||||||
|
store:SetAsync("p1", 50)
|
||||||
|
store:IncrementAsync("p1", 25)
|
||||||
|
print("final:", store:GetAsync("p1"))
|
||||||
|
`,
|
||||||
|
expect: [{ level: 'info', text: 'final:\t75' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HttpService:JSONEncode/Decode',
|
||||||
|
lua: `
|
||||||
|
local HS = game:GetService("HttpService")
|
||||||
|
local s = HS:JSONEncode({a=1, b="two"})
|
||||||
|
print("encoded len:", #s)
|
||||||
|
local d = HS:JSONDecode('{"x":42}')
|
||||||
|
print("decoded x:", d.x)
|
||||||
|
`,
|
||||||
|
expect: [{ level: 'info', text: 'decoded x:\t42' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'RemoteEvent FireServer + OnServerEvent',
|
||||||
|
lua: `
|
||||||
|
local re = Instance.new("RemoteEvent", workspace)
|
||||||
|
re.Name = "MyEvent"
|
||||||
|
re.OnServerEvent:Connect(function(player, msg)
|
||||||
|
print("server got:", msg)
|
||||||
|
end)
|
||||||
|
re:FireServer("hello")
|
||||||
|
`,
|
||||||
|
expect: [{ level: 'info', text: 'server got:\thello' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
for (const t of TESTS) {
|
||||||
|
try {
|
||||||
|
const r = await run(t.lua, t.ticks);
|
||||||
|
let ok = true; let reason = '';
|
||||||
|
for (const exp of (t.expect || [])) {
|
||||||
|
const found = r.logs.find(l => l.level === exp.level && l.text === exp.text);
|
||||||
|
if (!found) { ok = false; reason = `missing log: ${exp.text}; got: ${JSON.stringify(r.logs)}`; break; }
|
||||||
|
}
|
||||||
|
if (t.expectPlayerCmd) {
|
||||||
|
const found = r.playerCmds.find(c => c.method === t.expectPlayerCmd.method
|
||||||
|
&& (!t.expectPlayerCmd.argsCheck || t.expectPlayerCmd.argsCheck(c.args)));
|
||||||
|
if (!found) { ok = false; reason = `missing playerCmd ${t.expectPlayerCmd.method}; got: ${JSON.stringify(r.playerCmds)}`; }
|
||||||
|
}
|
||||||
|
if (ok) { console.log(`✓ ${t.name}`); passed++; }
|
||||||
|
else { console.log(`✗ ${t.name} — ${reason}`); failed++; }
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`✗ ${t.name} — exception: ${e.message || e}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
})();
|
||||||
89
tests/rbxl-lua-tween.test.js
Normal file
89
tests/rbxl-lua-tween.test.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* rbxl-lua-tween.test.js — тесты TweenService.
|
||||||
|
*/
|
||||||
|
import { LuaFactory } from 'wasmoon';
|
||||||
|
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
|
||||||
|
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
|
||||||
|
import { RobloxTweenManager } from '../src/engine/roblox-tween.js';
|
||||||
|
|
||||||
|
const SCENE = {
|
||||||
|
primitives: {
|
||||||
|
1: { id: 1, type: 'cube', name: 'Movable', x: 0, y: 5, z: 0, sx: 1, sy: 1, sz: 1,
|
||||||
|
color: '#ffffff', material: 'glossy', anchored: false, canCollide: true, opacity: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function run(luaSource, ticks = []) {
|
||||||
|
const factory = new LuaFactory();
|
||||||
|
const lua = await factory.createEngine();
|
||||||
|
const sent = [];
|
||||||
|
const send = (cmd, payload) => sent.push({ cmd, payload });
|
||||||
|
registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: 1, send });
|
||||||
|
const sched = new RobloxScheduler(lua);
|
||||||
|
sched.install();
|
||||||
|
const tweenMgr = new RobloxTweenManager();
|
||||||
|
tweenMgr.install(lua);
|
||||||
|
|
||||||
|
await sched.spawnMain(luaSource);
|
||||||
|
for (const dt of ticks) {
|
||||||
|
await sched.tick(dt);
|
||||||
|
tweenMgr.tick(dt);
|
||||||
|
}
|
||||||
|
lua.global.close();
|
||||||
|
return { logs: sent.filter(s => s.cmd === 'log').map(s => s.payload),
|
||||||
|
partSets: sent.filter(s => s.cmd === 'partSet').map(s => s.payload) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TESTS = [
|
||||||
|
{
|
||||||
|
name: 'TweenInfo создаётся',
|
||||||
|
lua: `
|
||||||
|
local info = TweenInfo.new(2, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
|
||||||
|
print("time:", info.Time, "style:", info.EasingStyle)
|
||||||
|
`,
|
||||||
|
ticks: [],
|
||||||
|
expectLogs: [{ level: 'info', text: 'time:\t2\tstyle:\tLinear' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'TweenService:Create + Play (Linear)',
|
||||||
|
lua: `
|
||||||
|
local TS = game:GetService("TweenService")
|
||||||
|
local p = workspace:FindFirstChild("Movable")
|
||||||
|
local info = TweenInfo.new(1, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
|
||||||
|
local tw = TS:Create(p, info, { Position = Vector3.new(10, 5, 0) })
|
||||||
|
tw:Play()
|
||||||
|
print("started")
|
||||||
|
`,
|
||||||
|
ticks: [0.5, 0.5, 0.1], // больше 1 сек — должен завершиться
|
||||||
|
// Ожидаем что хотя бы один partSet с prop=position
|
||||||
|
expectPartSet: { primId: 1, prop: 'position' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
for (const t of TESTS) {
|
||||||
|
try {
|
||||||
|
const r = await run(t.lua, t.ticks);
|
||||||
|
let ok = true;
|
||||||
|
let reason = '';
|
||||||
|
for (const exp of (t.expectLogs || [])) {
|
||||||
|
const found = r.logs.find(l => l.level === exp.level && l.text === exp.text);
|
||||||
|
if (!found) { ok = false; reason = `missing log: ${exp.text}`; break; }
|
||||||
|
}
|
||||||
|
if (t.expectPartSet) {
|
||||||
|
const found = r.partSets.find(p => p.primId === t.expectPartSet.primId && p.prop === t.expectPartSet.prop);
|
||||||
|
if (!found) {
|
||||||
|
ok = false; reason = `missing partSet: ${JSON.stringify(t.expectPartSet)}; got: ${JSON.stringify(r.partSets.slice(0,3))}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ok) { console.log(`✓ ${t.name}`); passed++; }
|
||||||
|
else { console.log(`✗ ${t.name} — ${reason}`); failed++; }
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`✗ ${t.name} — exception: ${e}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
})();
|
||||||
104
tests/rbxl-lua-wait.test.js
Normal file
104
tests/rbxl-lua-wait.test.js
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* rbxl-lua-wait.test.js — тесты wait/task.wait через шедулер.
|
||||||
|
*/
|
||||||
|
import { LuaFactory } from 'wasmoon';
|
||||||
|
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
|
||||||
|
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
|
||||||
|
|
||||||
|
const SCENE = { primitives: {} };
|
||||||
|
|
||||||
|
async function run(luaSource, ticks = [0.5, 0.5, 0.5, 0.5, 0.5]) {
|
||||||
|
const factory = new LuaFactory();
|
||||||
|
const lua = await factory.createEngine();
|
||||||
|
const sent = [];
|
||||||
|
const send = (cmd, payload) => sent.push({ cmd, payload });
|
||||||
|
registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: null, send });
|
||||||
|
const sched = new RobloxScheduler(lua);
|
||||||
|
sched.install();
|
||||||
|
|
||||||
|
await sched.spawnMain(luaSource);
|
||||||
|
for (const dt of ticks) {
|
||||||
|
await sched.tick(dt);
|
||||||
|
}
|
||||||
|
lua.global.close();
|
||||||
|
return sent.filter(s => s.cmd === 'log').map(s => s.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TESTS = [
|
||||||
|
{
|
||||||
|
name: 'wait(0) — мгновенный',
|
||||||
|
lua: `
|
||||||
|
print("before")
|
||||||
|
wait(0)
|
||||||
|
print("after")
|
||||||
|
`,
|
||||||
|
expect: ['before', 'after'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'wait(1) — резюм после tick',
|
||||||
|
lua: `
|
||||||
|
print("step1")
|
||||||
|
wait(1)
|
||||||
|
print("step2")
|
||||||
|
`,
|
||||||
|
ticks: [0.5, 0.5, 0.5], // 1.5 сек суммарно
|
||||||
|
expect: ['step1', 'step2'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'task.wait(0.5)',
|
||||||
|
lua: `
|
||||||
|
print("a")
|
||||||
|
task.wait(0.5)
|
||||||
|
print("b")
|
||||||
|
`,
|
||||||
|
ticks: [0.5, 0.5],
|
||||||
|
expect: ['a', 'b'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'несколько wait подряд',
|
||||||
|
lua: `
|
||||||
|
print("p1")
|
||||||
|
wait(0.5)
|
||||||
|
print("p2")
|
||||||
|
wait(0.5)
|
||||||
|
print("p3")
|
||||||
|
`,
|
||||||
|
ticks: [0.5, 0.5, 0.5, 0.5], // 2 сек
|
||||||
|
expect: ['p1', 'p2', 'p3'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'task.delay (не блокирует)',
|
||||||
|
lua: `
|
||||||
|
print("immediate")
|
||||||
|
task.delay(0.3, function() print("delayed") end)
|
||||||
|
print("after delay-call")
|
||||||
|
`,
|
||||||
|
ticks: [0.5],
|
||||||
|
expect: ['immediate', 'after delay-call', 'delayed'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
for (const t of TESTS) {
|
||||||
|
try {
|
||||||
|
const logs = await run(t.lua, t.ticks);
|
||||||
|
const texts = logs.map(l => l.text);
|
||||||
|
const ok = JSON.stringify(texts) === JSON.stringify(t.expect);
|
||||||
|
if (ok) {
|
||||||
|
console.log(`✓ ${t.name}`);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.log(`✗ ${t.name}`);
|
||||||
|
console.log(` expected: ${JSON.stringify(t.expect)}`);
|
||||||
|
console.log(` got: ${JSON.stringify(texts)}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`✗ ${t.name} — exception: ${e}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
})();
|
||||||
Loading…
x
Reference in New Issue
Block a user