Compare commits
68 Commits
chore/rede
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c05ab68e6b | |||
|
|
39eae607e1 | ||
|
|
ccf76d539b | ||
| a5e1558c2d | |||
|
|
f5a96fbec0 | ||
|
|
247a5703c9 | ||
| 3330715781 | |||
|
|
f4a1feb41d | ||
|
|
71f9d4dd11 | ||
| 84fd2d996e | |||
|
|
5a6a222c78 | ||
| 66d74b823f | |||
|
|
2847136819 | ||
|
|
270478b133 | ||
|
|
fed48dd701 | ||
|
|
4364af6e4e | ||
|
|
f452e3794e | ||
|
|
37d1acbba1 | ||
|
|
5f789764a6 | ||
|
|
4cc33daa1a | ||
|
|
d36059e5ce | ||
|
|
909af7a5d8 | ||
|
|
08143b837c | ||
|
|
8f229e2cfb | ||
|
|
3eee24ff48 | ||
|
|
d08497ef3b | ||
|
|
b87d1e2525 | ||
|
|
192e721ba2 | ||
|
|
88f4307308 | ||
|
|
53f9f3be00 | ||
|
|
0417d60bdd | ||
|
|
624bb2a05f | ||
|
|
b6397a3ad0 | ||
|
|
e4125e6488 | ||
|
|
fe7e402ebc | ||
|
|
ffc5341922 | ||
|
|
fd1d6c7fdb | ||
|
|
8cc608ca2a | ||
|
|
c3260e0395 | ||
|
|
6c05c5631d | ||
|
|
a22832628f | ||
|
|
151b95f395 | ||
| 24b6360266 | |||
|
|
eb6430182b | ||
| 4ca8cdd9bd | |||
|
|
b2cff903ba | ||
| dd7688c4d7 | |||
|
|
302db5e1f4 | ||
|
|
f420501481 | ||
|
|
9e3bc60a76 | ||
| 61ac40ab61 | |||
|
|
91af8514c5 | ||
|
|
517545b0cf | ||
| af3dd97f97 | |||
| 37e9f9b2c4 | |||
| 32a2fa6137 | |||
| 93739e13af | |||
| cec58412dc | |||
| 322dd089d9 | |||
| 8504549928 | |||
| ae83926a5a | |||
| d5968f7cb8 | |||
| 80e546eb08 | |||
| 64de6c3683 | |||
| acb5b0b133 | |||
| cd31078e6d | |||
| 256f147568 | |||
| 3edc462741 |
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
|
||||
@ -150,9 +150,9 @@ jobs:
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998 \
|
||||
min@85.175.7.40 \
|
||||
"ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/"
|
||||
"ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/ 2>/dev/null || true"
|
||||
- name: Verify S2 (обязательный)
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22 \
|
||||
min@192.168.0.124 \
|
||||
"ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/"
|
||||
"ls /var/www/rublox-player/build/index.html && (du -sh /var/www/rublox-player/build/ 2>/dev/null || true)"
|
||||
|
||||
41
.gitignore
vendored
41
.gitignore
vendored
@ -41,4 +41,43 @@ public/kubikon-assets/
|
||||
|
||||
# OS
|
||||
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-dom": "18.3.1",
|
||||
"react-router-dom": "7.4.0",
|
||||
"socket.io-client": "^4.8.3"
|
||||
"socket.io-client": "^4.8.3",
|
||||
"wasmoon": "^1.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.12",
|
||||
@ -1427,6 +1428,12 @@
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@ -49,7 +49,8 @@
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "7.4.0",
|
||||
"socket.io-client": "^4.8.3"
|
||||
"socket.io-client": "^4.8.3",
|
||||
"wasmoon": "^1.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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>
|
||||
);
|
||||
}
|
||||
@ -22,6 +22,7 @@ import { useAuth } from '../auth/PlayerAuth';
|
||||
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
|
||||
import useDeviceType from '../hooks/useDeviceType';
|
||||
import KubikonMobileControls from './KubikonMobileControls';
|
||||
import GameLoadingScreen from './GameLoadingScreen';
|
||||
|
||||
// Плеер живёт на player.rublox.pro — он не знает SPA-роутов Майнкрафтии
|
||||
// (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем
|
||||
@ -38,12 +39,12 @@ function exitPlayer(gameId) {
|
||||
// (флаг читает onBeforeUnload listener ниже).
|
||||
try { window.__rubloxExplicitExit = true; } catch {}
|
||||
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) {
|
||||
// Передаём gameId через ?game=<id> — главный сайт прочитает и снова
|
||||
// откроет карточку игры (юзер возвращается на ту же страницу).
|
||||
const sep = RUBLOX_HOME.includes('?') ? '&' : '?';
|
||||
window.location.assign(`${RUBLOX_HOME}${sep}game=${gameId}`);
|
||||
// У страницы игры теперь свой URL: /app/game/<id> (им можно делиться).
|
||||
// Возвращаемся прямо на него. base RUBLOX_HOME оканчивается на /app.
|
||||
const base = RUBLOX_HOME.endsWith('/app') ? RUBLOX_HOME : `${RUBLOX_HOME}/app`;
|
||||
window.location.assign(`${base}/game/${gameId}`);
|
||||
} else {
|
||||
window.location.assign(RUBLOX_HOME);
|
||||
}
|
||||
@ -216,6 +217,9 @@ const KubikonPlayer = () => {
|
||||
const [forbidden, setForbidden] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Задача 05: конфиг красивого экрана загрузки (из project_data.scene.loadingScreen).
|
||||
const [loadingScreenCfg, setLoadingScreenCfg] = useState(null);
|
||||
const [loadProgress, setLoadProgress] = useState(0);
|
||||
// Раньше была стартовая заглушка «тапни чтобы начать» — убрали по
|
||||
// фидбэку, она бесила. Теперь fullscreen опционально через кнопку
|
||||
// в углу. Этот state остался для совместимости с handleMobileStart.
|
||||
@ -517,13 +521,20 @@ const KubikonPlayer = () => {
|
||||
s?.player?.setUiCursorMode?.(true);
|
||||
setChatOpen(false);
|
||||
setTopMenuOpen(true);
|
||||
try { if (s) s._playerMenuOpen = true; } catch (e) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
// ESC в Play → меню-оверлей поверх ЖИВОЙ игры (Roblox-style). Play не
|
||||
// прерывается, скрипты продолжают идти, игрок не респавнится.
|
||||
scene.setOnEscMenu?.(() => {
|
||||
setChatOpen(false);
|
||||
setTopMenuOpen(true);
|
||||
// ESC в Play → TOGGLE меню-оверлея поверх ЖИВОЙ игры (Roblox-style).
|
||||
// Движок сам решает open/close (единый источник истины _playerMenuOpen)
|
||||
// и передаёт сюда. Это убирает гонку двух ESC-обработчиков, из-за которой
|
||||
// меню открывалось поверх меню, а orbit-камера по ПКМ зависала.
|
||||
scene.setOnEscMenu?.((open) => {
|
||||
if (open) {
|
||||
setChatOpen(false);
|
||||
setTopMenuOpen(true);
|
||||
} else {
|
||||
setTopMenuOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Загружаем проект.
|
||||
@ -544,11 +555,18 @@ const KubikonPlayer = () => {
|
||||
setMeta(data);
|
||||
setLikesCount(data.likes_count || 0);
|
||||
setDislikesCount(data.dislikes_count || 0);
|
||||
setLoadProgress(0.3);
|
||||
|
||||
if (data.project_data) {
|
||||
const parsed = JSON.parse(data.project_data);
|
||||
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);
|
||||
setLoadProgress(0.7);
|
||||
}
|
||||
|
||||
// Ждём пока Babylon реально загрузит и скомпилит все
|
||||
@ -585,9 +603,12 @@ const KubikonPlayer = () => {
|
||||
skinFolderRef.current = mySkin;
|
||||
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
|
||||
|
||||
setLoadProgress(1);
|
||||
setLoading(false);
|
||||
// Засчитываем плей
|
||||
Kubikon3DApi.incrementPlay(projectId).catch(() => {});
|
||||
// Засчитываем плей. Передаём user_id (если залогинен) —
|
||||
// это активирует self-cooldown (автор не накручивает себе)
|
||||
// и user-cooldown на бэке (см. /play в Kubikon3D.py).
|
||||
Kubikon3DApi.incrementPlay(projectId, userId).catch(() => {});
|
||||
// Запускаем игру сразу
|
||||
setTimeout(() => {
|
||||
scene.enterPlayMode?.();
|
||||
@ -734,26 +755,20 @@ const KubikonPlayer = () => {
|
||||
p._uiCursorMode = true;
|
||||
setChatOpen(false);
|
||||
setTopMenuOpen(true);
|
||||
// Синхронизируем единый флаг меню в движке, чтобы следующий ESC
|
||||
// сработал как toggle-закрытие (а не открыл второе меню).
|
||||
try { s._playerMenuOpen = true; } catch (e) { /* ignore */ }
|
||||
};
|
||||
// capture-фаза, чтобы успеть раньше PlayerController
|
||||
document.addEventListener('pointerlockchange', onLockChange, true);
|
||||
return () => document.removeEventListener('pointerlockchange', onLockChange, true);
|
||||
}, []);
|
||||
|
||||
// Повторный ESC (когда меню уже открыто) — закрыть меню и вернуть
|
||||
// мышь в игру.
|
||||
useEffect(() => {
|
||||
if (!topMenuOpen) return;
|
||||
const onEsc = (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
const s = sceneRef.current;
|
||||
if (!s || !s._isPlaying) return;
|
||||
setTopMenuOpen(false);
|
||||
s.player?.setUiCursorMode?.(false);
|
||||
};
|
||||
window.addEventListener('keydown', onEsc, true);
|
||||
return () => window.removeEventListener('keydown', onEsc, true);
|
||||
}, [topMenuOpen]);
|
||||
// Повторный ESC (toggle закрытие) теперь обрабатывает движок через
|
||||
// setOnExitRequest → _onEscMenu(false). Отдельный React-обработчик ESC
|
||||
// УБРАН — он слушал тот же ESC, что и движок, и создавал гонку:
|
||||
// меню открывалось поверх себя, а _uiCursorMode застревал в true
|
||||
// (orbit-камера по ПКМ переставала работать после закрытия меню).
|
||||
|
||||
// Горячая клавиша T — открыть/закрыть чат. Игнорируем когда:
|
||||
// • уже введён текст в <input>/<textarea>/contenteditable (юзер печатает)
|
||||
@ -967,6 +982,11 @@ const KubikonPlayer = () => {
|
||||
// Очищаем ref'ы — иначе следующий connectMultiplayer выйдет
|
||||
// на if (mpSyncRef.current || roomRef.current) return.
|
||||
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;
|
||||
roomRef.current = null;
|
||||
// Code 1000 / 1001 — нормальное закрытие. Code >= 4000 — наш
|
||||
@ -1130,46 +1150,13 @@ const KubikonPlayer = () => {
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* Loading-оверлей */}
|
||||
{/* Задача 05: красивый экран загрузки игры (Ken Burns + название места + автор). */}
|
||||
{loading && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
background:
|
||||
'radial-gradient(ellipse at center, rgba(51,87,255,0.22) 0%, rgba(7,10,20,0.96) 60%)',
|
||||
gap: 18, color: HUD.text,
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
animation: 'hudFloat 3s ease-in-out infinite',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: -10,
|
||||
borderRadius: 20,
|
||||
animation: 'hudPulseRing 1.6s ease-out infinite',
|
||||
}} />
|
||||
<RublocsLogo size={72} />
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
fontSize: 15, fontWeight: 700, letterSpacing: 0.3,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 14, height: 14,
|
||||
border: `2.5px solid ${HUD.accentBg}`,
|
||||
borderTopColor: HUD.accent,
|
||||
borderRadius: '50%',
|
||||
animation: 'hudSpin 0.8s linear infinite',
|
||||
}} />
|
||||
Загрузка игры…
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11, color: HUD.textDim,
|
||||
textTransform: 'uppercase', letterSpacing: 1.4, fontWeight: 700,
|
||||
}}>
|
||||
Рублокс • 3D
|
||||
</div>
|
||||
</div>
|
||||
<GameLoadingScreen
|
||||
meta={meta}
|
||||
loadingScreen={loadingScreenCfg}
|
||||
progress={loadProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
|
||||
@ -1619,9 +1606,10 @@ const KubikonPlayer = () => {
|
||||
visible={topMenuOpen}
|
||||
onClose={() => {
|
||||
setTopMenuOpen(false);
|
||||
// Возвращаем мышь в pointer-lock игры (как делал
|
||||
// старый ESC-handler выше).
|
||||
try { sceneRef.current?.player?.setUiCursorMode?.(false); } catch {}
|
||||
// Синхронизируем движок (_playerMenuOpen) И возвращаем мышь
|
||||
// в игру одним вызовом. Без этого следующий ESC решит, что
|
||||
// меню «ещё открыто», и не откроет его.
|
||||
try { sceneRef.current?.setPlayerMenuOpen?.(false); } catch {}
|
||||
}}
|
||||
onExit={() => exitPlayer(id)}
|
||||
onRespawn={() => respawnPlayer()}
|
||||
|
||||
@ -30,10 +30,13 @@ export const STORYS_addres = BASE + '/api-storys';
|
||||
// env-настроенные прямые URL.
|
||||
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
|
||||
?? (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
|
||||
?? (IS_HTTPS ? `wss://${window.location.host}/api-game` : 'ws://localhost:8685');
|
||||
?? (IS_HTTPS ? 'wss://game.rublox.pro' : 'ws://localhost:8685');
|
||||
|
||||
// Главный сайт Рублокса — куда редиректить юзеров без ticket/JWT.
|
||||
export const RUBLOX_HOME = ENV.VITE_RUBLOX_HOME ?? 'https://rublox.pro/app';
|
||||
|
||||
@ -198,8 +198,9 @@ export const getProjectForPlay = (id, userId = null, isAdmin = false) =>
|
||||
},
|
||||
});
|
||||
|
||||
export const incrementPlay = (id) =>
|
||||
api.post(`/kubikon3d/projects/${id}/play`);
|
||||
export const incrementPlay = (id, userId) =>
|
||||
api.post(`/kubikon3d/projects/${id}/play`,
|
||||
userId ? { user_id: userId } : {});
|
||||
|
||||
/** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает,
|
||||
* голос другого типа — переключает. */
|
||||
|
||||
@ -16,6 +16,13 @@ import Icon from './Icon';
|
||||
function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) {
|
||||
if (!visible) return null;
|
||||
|
||||
// ГЛОБАЛЬНОЕ ПРАВИЛО (для всех игр тулбокса): если в инвентаре нет ни
|
||||
// одного предмета — панель инвентаря НЕ показываем вовсе. Пустой hotbar
|
||||
// из 5 серых ячеек загромождает экран в играх, где инвентарь не нужен.
|
||||
// Панель появится автоматически, как только в слот попадёт предмет.
|
||||
const hasAnyItem = Array.isArray(slots) && slots.some((s) => s != null);
|
||||
if (!hasAnyItem) return null;
|
||||
|
||||
const SLOT_COUNT = 5;
|
||||
const cells = [];
|
||||
for (let i = 0; i < SLOT_COUNT; i++) {
|
||||
|
||||
249
src/engine/AchievementsManager.js
Normal file
249
src/engine/AchievementsManager.js
Normal file
@ -0,0 +1,249 @@
|
||||
/**
|
||||
* AchievementsManager — достижения (badges) как в Roblox (задача 20).
|
||||
*
|
||||
* - define([...]) регистрирует достижения проекта.
|
||||
* - unlock(id) разблокирует → toast справа-сверху (4 редкости, очередь, звук).
|
||||
* - bindToStat(id, statName, {gte/lte/eq}) — авто-unlock по leaderstat.
|
||||
* - кнопка-кубок слева-снизу → страница «Мои достижения» (grid + прогресс).
|
||||
* - сохранение разблокированных в localStorage по projectId (закрыл-открыл → остались).
|
||||
*
|
||||
* API (через game.achievements.*): define/unlock/has/list/progress/bindToStat/
|
||||
* setButtonVisible/openPage.
|
||||
*
|
||||
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
||||
*/
|
||||
|
||||
const RARITY = {
|
||||
common: { label: 'Обычное', border: '#9aa3b2', bg: 'linear-gradient(135deg,rgba(120,130,150,0.9),rgba(80,88,104,0.9))', glow: 'rgba(154,163,178,0.5)' },
|
||||
rare: { label: 'Редкое', border: '#4d8bff', bg: 'linear-gradient(135deg,rgba(60,110,220,0.92),rgba(30,60,150,0.92))', glow: 'rgba(77,139,255,0.6)' },
|
||||
epic: { label: 'Эпическое', border: '#a05aff', bg: 'linear-gradient(135deg,rgba(150,80,230,0.92),rgba(90,40,160,0.92))', glow: 'rgba(160,90,255,0.65)' },
|
||||
legendary: { label: 'Легендарное', border: '#ffd23a', bg: 'linear-gradient(135deg,rgba(255,200,60,0.95),rgba(220,140,20,0.95))', glow: 'rgba(255,210,58,0.75)' },
|
||||
};
|
||||
|
||||
export class AchievementsManager {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d;
|
||||
this._defs = []; // [{id,name,description,icon,rarity,points,hidden}]
|
||||
this._unlocked = new Set(); // id разблокированных
|
||||
this._binds = []; // [{id, stat, op, value}]
|
||||
this._toastQueue = [];
|
||||
this._toastActive = false;
|
||||
this._btnVisible = true;
|
||||
this.btn = null; this.toastRoot = null; this.page = null;
|
||||
this._projectKey = 'rublox_ach_' + (this.s?._projectId ?? 'proj');
|
||||
}
|
||||
|
||||
define(list) {
|
||||
const arr = Array.isArray(list) ? list : [list];
|
||||
for (const a of arr) {
|
||||
if (!a || typeof a.id !== 'string') continue;
|
||||
if (this._defs.some(d => d.id === a.id)) continue;
|
||||
this._defs.push({
|
||||
id: a.id, name: a.name || a.id, description: a.description || '',
|
||||
icon: a.icon || '🏆', rarity: RARITY[a.rarity] ? a.rarity : 'common',
|
||||
points: Number(a.points) || 5, hidden: !!a.hidden,
|
||||
});
|
||||
}
|
||||
this._loadSaved();
|
||||
this._mountButton();
|
||||
}
|
||||
|
||||
_loadSaved() {
|
||||
// Резервная локальная копия (мгновенно, до ответа БД).
|
||||
try {
|
||||
const raw = localStorage.getItem(this._projectKey);
|
||||
if (raw) for (const id of JSON.parse(raw)) this._unlocked.add(id);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
/** Загрузить разблокированные достижения из БД (по игроку). Вызывать при Play. */
|
||||
loadFromDB() {
|
||||
const rt = this.s?.gameRuntime;
|
||||
if (!rt || !rt.loadProgress) return;
|
||||
rt.loadProgress('_achievements', (data) => {
|
||||
if (Array.isArray(data)) {
|
||||
for (const id of data) this._unlocked.add(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
_persist() {
|
||||
// 1) локально (быстрый кэш), 2) в БД (между сессиями, любое устройство).
|
||||
try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {}
|
||||
try { this.s?.gameRuntime?.saveProgress?.('_achievements', [...this._unlocked]); } catch (e) {}
|
||||
}
|
||||
|
||||
unlock(id, _playerId) {
|
||||
const def = this._defs.find(d => d.id === id);
|
||||
if (!def || this._unlocked.has(id)) return false;
|
||||
this._unlocked.add(id);
|
||||
this._persist();
|
||||
this._queueToast(def);
|
||||
this._playSound(def.rarity);
|
||||
return true;
|
||||
}
|
||||
|
||||
has(id) { return this._unlocked.has(id); }
|
||||
|
||||
list() {
|
||||
return this._defs.map(d => ({ id: d.id, name: d.name, unlocked: this._unlocked.has(d.id) }));
|
||||
}
|
||||
|
||||
progress() {
|
||||
const total = this._defs.length;
|
||||
const unlocked = this._defs.filter(d => this._unlocked.has(d.id)).length;
|
||||
const pts = this._defs.filter(d => this._unlocked.has(d.id)).reduce((s, d) => s + d.points, 0);
|
||||
const maxPts = this._defs.reduce((s, d) => s + d.points, 0);
|
||||
return { total, unlocked, points: pts, maxPoints: maxPts };
|
||||
}
|
||||
|
||||
/** Авто-unlock при достижении leaderstat значения. */
|
||||
bindToStat(id, statName, cond) {
|
||||
const op = cond && (cond.gte != null ? 'gte' : cond.lte != null ? 'lte' : cond.eq != null ? 'eq' : null);
|
||||
if (!op) return;
|
||||
this._binds.push({ id, stat: statName, op, value: cond[op] });
|
||||
// Подпишемся на leaderstats при первом bind.
|
||||
if (!this._boundLs && this.s?.leaderstats) {
|
||||
this._boundLs = true;
|
||||
this.s.leaderstats.onChange((pid, name, nv) => this._checkBinds(name, nv));
|
||||
}
|
||||
}
|
||||
_checkBinds(statName, value) {
|
||||
for (const b of this._binds) {
|
||||
if (b.stat !== statName || this._unlocked.has(b.id)) continue;
|
||||
const ok = b.op === 'gte' ? value >= b.value : b.op === 'lte' ? value <= b.value : value === b.value;
|
||||
if (ok) this.unlock(b.id);
|
||||
}
|
||||
}
|
||||
|
||||
setButtonVisible(v) { this._btnVisible = !!v; if (this.btn) this.btn.style.display = v ? 'flex' : 'none'; }
|
||||
|
||||
get active() { return this._defs.length > 0; }
|
||||
|
||||
// ── Кнопка-кубок ───────────────────────────────────────────────────────
|
||||
_mountButton() {
|
||||
if (this.btn || !this.active) return;
|
||||
if (!this.s?._isPlaying) return; // кнопка-кубок только в Play
|
||||
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||
const b = document.createElement('button');
|
||||
b.title = 'Мои достижения';
|
||||
b.textContent = '🏆';
|
||||
b.style.cssText = [
|
||||
'position:absolute', 'left:14px', 'bottom:64px', 'z-index:50',
|
||||
'width:46px', 'height:46px', 'border-radius:12px', 'font-size:24px',
|
||||
'background:rgba(18,22,33,0.6)', 'backdrop-filter:blur(8px)',
|
||||
'border:1px solid rgba(255,255,255,0.15)', 'cursor:pointer',
|
||||
'display:flex', 'align-items:center', 'justify-content:center',
|
||||
'box-shadow:0 4px 16px rgba(0,0,0,0.35)', 'pointer-events:auto',
|
||||
].join(';');
|
||||
if (!this._btnVisible) b.style.display = 'none';
|
||||
b.onclick = () => this.openPage();
|
||||
parent.appendChild(b);
|
||||
this.btn = b;
|
||||
}
|
||||
|
||||
// ── Toast ────────────────────────────────────────────────────────────
|
||||
_queueToast(def) { this._toastQueue.push(def); if (!this._toastActive) this._nextToast(); }
|
||||
_nextToast() {
|
||||
if (!this._toastQueue.length) { this._toastActive = false; return; }
|
||||
this._toastActive = true;
|
||||
const def = this._toastQueue.shift();
|
||||
const r = RARITY[def.rarity];
|
||||
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||
const t = document.createElement('div');
|
||||
t.style.cssText = [
|
||||
'position:absolute', 'top:200px', 'right:14px', 'z-index:60',
|
||||
'width:340px', 'display:flex', 'align-items:center', 'gap:12px',
|
||||
'padding:12px 14px', 'border-radius:14px', 'background:' + r.bg,
|
||||
'border:2px solid ' + r.border, 'box-shadow:0 0 24px ' + r.glow + ',0 8px 24px rgba(0,0,0,0.4)',
|
||||
'font-family:Inter,system-ui,sans-serif', 'color:#fff',
|
||||
'transform:translateX(380px)', 'transition:transform .32s cubic-bezier(.2,.8,.3,1)',
|
||||
'pointer-events:auto', 'cursor:pointer',
|
||||
].join(';');
|
||||
t.innerHTML =
|
||||
'<div style="font-size:42px;flex:0 0 auto;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.4))">' + def.icon + '</div>' +
|
||||
'<div style="flex:1;min-width:0">' +
|
||||
'<div style="font-size:11px;opacity:0.85;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Достижение разблокировано · ' + r.label + '</div>' +
|
||||
'<div style="font-size:17px;font-weight:800;margin:1px 0">' + this._esc(def.name) + '</div>' +
|
||||
'<div style="font-size:12px;opacity:0.9">' + this._esc(def.description) + ' · +' + def.points + ' очк.</div>' +
|
||||
'</div>';
|
||||
t.onclick = () => this.openPage();
|
||||
parent.appendChild(t);
|
||||
// slide-in
|
||||
requestAnimationFrame(() => { t.style.transform = 'translateX(0)'; });
|
||||
// через 3с slide-out + следующий
|
||||
setTimeout(() => {
|
||||
t.style.transform = 'translateX(380px)';
|
||||
setTimeout(() => { try { t.remove(); } catch (e) {} this._nextToast(); }, 350);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
_playSound(rarity) {
|
||||
// Используем встроенные звуки движка через gameRuntime/audio.
|
||||
try {
|
||||
const map = { common: 'coin', rare: 'win', epic: 'win', legendary: 'win' };
|
||||
const pitch = { common: 1, rare: 1.1, epic: 0.9, legendary: 0.8 }[rarity] || 1;
|
||||
this.s?.gameRuntime?._playSound?.({ name: map[rarity] || 'coin', pitch });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Страница «Мои достижения» ───────────────────────────────────────────
|
||||
openPage() {
|
||||
if (this.page) { this._closePage(); return; }
|
||||
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = [
|
||||
'position:absolute', 'inset:0', 'z-index:80',
|
||||
'background:rgba(8,10,16,0.78)', 'backdrop-filter:blur(6px)',
|
||||
'display:flex', 'align-items:center', 'justify-content:center',
|
||||
'font-family:Inter,system-ui,sans-serif', 'pointer-events:auto',
|
||||
].join(';');
|
||||
overlay.onclick = (e) => { if (e.target === overlay) this._closePage(); };
|
||||
const pr = this.progress();
|
||||
const pct = pr.total ? Math.round(pr.unlocked / pr.total * 100) : 0;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.style.cssText = 'width:min(720px,92%);max-height:84%;overflow-y:auto;background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:22px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)';
|
||||
|
||||
let html = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
|
||||
'<div style="font-size:22px;font-weight:800">🏆 Мои достижения</div>' +
|
||||
'<button id="_achClose" style="width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:18px;cursor:pointer">✕</button></div>';
|
||||
html += '<div style="font-size:14px;color:#9aa3b2;margin-bottom:6px">' + pr.unlocked + ' из ' + pr.total + ' (' + pr.points + ' / ' + pr.maxPoints + ' очков)</div>';
|
||||
html += '<div style="height:8px;background:rgba(255,255,255,0.1);border-radius:6px;margin-bottom:18px;overflow:hidden"><div style="height:100%;width:' + pct + '%;background:linear-gradient(90deg,#ffd23a,#ff9a3a);border-radius:6px"></div></div>';
|
||||
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px">';
|
||||
for (const d of this._defs) {
|
||||
const un = this._unlocked.has(d.id);
|
||||
const r = RARITY[d.rarity];
|
||||
const hiddenLocked = d.hidden && !un;
|
||||
const icon = hiddenLocked ? '❔' : d.icon;
|
||||
const name = hiddenLocked ? 'Скрытое достижение' : d.name;
|
||||
const desc = hiddenLocked ? 'Найди, чтобы открыть' : d.description;
|
||||
html += '<div style="background:rgba(255,255,255,0.04);border:2px solid ' + (un ? r.border : 'rgba(255,255,255,0.08)') + ';border-radius:14px;padding:14px 10px;text-align:center;' + (un ? '' : 'opacity:0.55;') + '">' +
|
||||
'<div style="font-size:44px;margin-bottom:6px;' + (un ? '' : 'filter:grayscale(1);') + '">' + icon + (un ? '' : ' 🔒') + '</div>' +
|
||||
'<div style="font-size:14px;font-weight:800">' + this._esc(name) + '</div>' +
|
||||
'<div style="font-size:11px;color:#9aa3b2;margin-top:3px;line-height:1.3">' + this._esc(desc) + '</div>' +
|
||||
'<div style="font-size:10px;font-weight:700;margin-top:6px;color:' + r.border + '">' + r.label + ' · ' + d.points + ' очк.</div>' +
|
||||
'</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
panel.innerHTML = html;
|
||||
overlay.appendChild(panel);
|
||||
parent.appendChild(overlay);
|
||||
panel.querySelector('#_achClose').onclick = () => this._closePage();
|
||||
this.page = overlay;
|
||||
}
|
||||
_closePage() { if (this.page) { try { this.page.remove(); } catch (e) {} this.page = null; } }
|
||||
|
||||
_esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
||||
|
||||
serialize() { return this._defs.map(d => ({ ...d })); }
|
||||
load(arr) { if (Array.isArray(arr) && arr.length) this.define(arr); }
|
||||
|
||||
dispose() {
|
||||
for (const el of [this.btn, this.toastRoot, this.page]) { if (el) try { el.remove(); } catch (e) {} }
|
||||
this.btn = null; this.page = null; this._toastQueue = []; this._toastActive = false;
|
||||
}
|
||||
resetRuntime() {
|
||||
// Определения и unlocked сохраняются (достижения «навсегда»). Чистим UI.
|
||||
this._closePage();
|
||||
this._toastQueue = []; this._toastActive = false;
|
||||
}
|
||||
}
|
||||
@ -64,9 +64,19 @@ import { ZombieManager } from './ZombieManager';
|
||||
import { NpcManager } from './NpcManager';
|
||||
import { ConstraintManager } from './ConstraintManager';
|
||||
import { BeamManager } from './BeamManager';
|
||||
import { PlacementManager } from './PlacementManager';
|
||||
import { ShopInventoryUi } from './ShopInventoryUi';
|
||||
import { LoadingScreenOverlay } from './LoadingScreenOverlay';
|
||||
import { VehicleManager } from './VehicleManager';
|
||||
import { VehicleHud } from './VehicleHud';
|
||||
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
|
||||
import { DynamicsManager } from './DynamicsManager';
|
||||
import { Environment } from './Environment';
|
||||
import { SkyboxManager } from './SkyboxManager';
|
||||
import { LeaderstatsManager } from './LeaderstatsManager';
|
||||
import { AchievementsManager } from './AchievementsManager';
|
||||
import { FloaterManager } from './FloaterManager'; // задача 40 — damage floaters
|
||||
import { InventoryUI } from './InventoryUI'; // задача 44 — drag-drop инвентарь
|
||||
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
|
||||
import { GameAudioManager } from './GameAudioManager';
|
||||
import { AssetManager } from './AssetManager';
|
||||
@ -86,6 +96,7 @@ import { GdForest } from './GdForest';
|
||||
import { GdPlayerCube } from './GdPlayerCube';
|
||||
import { GdPlayerTrail } from './GdPlayerTrail';
|
||||
import { GdPostFx } from './GdPostFx';
|
||||
import { GraphicsManager } from './GraphicsManager';
|
||||
import { PhysicsAABB } from './PhysicsAABB';
|
||||
import { PlayerController } from './PlayerController';
|
||||
import { SelectionManager } from './SelectionManager';
|
||||
@ -144,6 +155,20 @@ export class BabylonScene {
|
||||
this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1)
|
||||
this.constraintManager = null; // связи объектов (Фаза 5, Constraints)
|
||||
this.beamManager = null; // лучи и следы (Фаза 5.2)
|
||||
// Placement mode (задача 11) — фича-парность со студией.
|
||||
this.placementManager = null;
|
||||
this.shopInventoryUi = null;
|
||||
this.vehicleManager = null; // задача 14
|
||||
this.vehicleHud = null;
|
||||
this._VehicleHudClass = VehicleHud;
|
||||
this._PlacementManagerClass = PlacementManager;
|
||||
this._ShopInventoryUiClass = ShopInventoryUi;
|
||||
// Экран загрузки (задача 12).
|
||||
this.loadingScreen = null;
|
||||
this._LoadingScreenOverlayClass = LoadingScreenOverlay;
|
||||
this._loadingConfig = null;
|
||||
this._mainMenuConfig = null; // задача 13
|
||||
this._projectThumbnail = null;
|
||||
this.spawnerManager = null; // спавнеры зомби
|
||||
this.environment = null;
|
||||
this.audioManager = null;
|
||||
@ -1290,6 +1315,7 @@ export class BabylonScene {
|
||||
// Voxel-террейн тоже участвует в физике. У террейна свой размер
|
||||
// ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно.
|
||||
this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE);
|
||||
this.vehicleManager = new VehicleManager(this); // задача 14
|
||||
// Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике.
|
||||
// Физика проверяет коллизии в обоих источниках (legacy terrainManager +
|
||||
// voxelWorld), что позволяет постепенно мигрировать без поломки.
|
||||
@ -1298,6 +1324,11 @@ export class BabylonScene {
|
||||
}
|
||||
this.dynamics = new DynamicsManager(this);
|
||||
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
|
||||
this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света)
|
||||
this.floaters = new FloaterManager(this); // задача 40 — damage floaters
|
||||
this.invUI = new InventoryUI(this); // задача 44 — drag-drop инвентарь
|
||||
this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды
|
||||
this.achievements = new AchievementsManager(this); // задача 20 — достижения
|
||||
this.audioManager = new AudioManager();
|
||||
this.assetManager = new AssetManager();
|
||||
// PrimitiveManager должен уметь брать dataURL картинки по id ассета,
|
||||
@ -1401,6 +1432,18 @@ export class BabylonScene {
|
||||
if (this._isPlaying && this.environment) {
|
||||
this.environment.tick(dt);
|
||||
}
|
||||
// Небо: дрейф облаков + fadeTo
|
||||
if (this.skybox) {
|
||||
this.skybox.tick(dt);
|
||||
}
|
||||
// Лидерборды (задача 20) — рендер HUD-таблицы при изменениях.
|
||||
if (this._isPlaying && this.leaderstats) {
|
||||
this.leaderstats.tick();
|
||||
}
|
||||
// Damage floaters (задача 40) — анимация всплывающих цифр.
|
||||
if (this.floaters) {
|
||||
this.floaters.tick(dt);
|
||||
}
|
||||
// Анимация жидкостей — работает всегда (и в редакторе)
|
||||
if (this.blockManager) {
|
||||
this.blockManager.tick(dt);
|
||||
@ -1488,6 +1531,10 @@ export class BabylonScene {
|
||||
if (this._isPlaying && this.modalManager?.tick) {
|
||||
try { this.modalManager.tick(dt); } catch (e) {}
|
||||
}
|
||||
// Задача 12: loadingScreen.tick — fade/auto-duration независимо от paused.
|
||||
if (this._isPlaying && this.loadingScreen?.tick) {
|
||||
try { this.loadingScreen.tick(dt); } catch (e) {}
|
||||
}
|
||||
// Tick пользовательских скриптов: в Play-режиме или в solo-debug
|
||||
if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) {
|
||||
this.gameRuntime.tick(dt);
|
||||
@ -1603,6 +1650,42 @@ export class BabylonScene {
|
||||
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 пост-эффект (контактные тени).
|
||||
* Используем SSAORenderingPipeline v1 — v2 ломал thin-instance рендер
|
||||
@ -1694,8 +1777,8 @@ export class BabylonScene {
|
||||
// peter-panning — тень "уезжала" далеко в сторону от блока (баг
|
||||
// 2026-05-27). 0.005 — баланс между acne и peter-panning для
|
||||
// воксельных кубов 1м.
|
||||
const PCF_BIAS = 0.0005;
|
||||
const PCF_NORMAL_BIAS = 0.005;
|
||||
const PCF_BIAS = 0.0008;
|
||||
const PCF_NORMAL_BIAS = 0.02; // убирает «полосы»-acne на полу от соседних теней
|
||||
|
||||
if (!this._shadowGenerator) {
|
||||
if (wantCsm) {
|
||||
@ -1705,9 +1788,9 @@ export class BabylonScene {
|
||||
const csm = new CascadedShadowGenerator(size, this._sunLight);
|
||||
csm.numCascades = numCascades;
|
||||
csm.stabilizeCascades = true;
|
||||
csm.lambda = 0.8;
|
||||
csm.cascadeBlendPercentage = 0.07;
|
||||
csm.shadowMaxZ = (q === 'high') ? 200 : 120;
|
||||
csm.lambda = 0.6;
|
||||
csm.cascadeBlendPercentage = 0.1;
|
||||
csm.shadowMaxZ = (q === 'high') ? 90 : 60;
|
||||
csm.bias = PCF_BIAS;
|
||||
csm.normalBias = PCF_NORMAL_BIAS;
|
||||
csm.usePercentageCloserFiltering = true;
|
||||
@ -1715,7 +1798,8 @@ export class BabylonScene {
|
||||
? ShadowGenerator.QUALITY_HIGH
|
||||
: ShadowGenerator.QUALITY_MEDIUM;
|
||||
csm.darkness = 0.4;
|
||||
csm.autoCalcDepthBounds = true;
|
||||
csm.autoCalcDepthBounds = false;
|
||||
csm.frustumEdgeFalloff = 12; // убирает «полосу-хвост» тени игрока
|
||||
this._shadowGenerator = csm;
|
||||
} else {
|
||||
// Обычный ShadowGenerator. Soft теперь 2048 (было 1024).
|
||||
@ -1875,6 +1959,20 @@ export class BabylonScene {
|
||||
if (typeof mesh.getBoundingInfo !== 'function') return;
|
||||
if (typeof mesh.getTotalVertices !== 'function') return;
|
||||
if (mesh.getTotalVertices() <= 0) return;
|
||||
// ОПТИМИЗАЦИЯ ТЕНЕЙ (задача 14): мелкие/тонкие меши и огромный плоский
|
||||
// пол НЕ кастят тень — каждый caster дорого стоит в shadow-map
|
||||
// (на сцене из сотен примитивов давало 5-15 FPS вместо 45-60).
|
||||
try {
|
||||
const bb = mesh.getBoundingInfo().boundingBox;
|
||||
const ext = bb.extendSizeWorld || bb.extendSize;
|
||||
if (ext) {
|
||||
const w = ext.x * 2, h = ext.y * 2, d = ext.z * 2;
|
||||
const maxDim = Math.max(w, h, d);
|
||||
const minDim = Math.min(w, h, d);
|
||||
if (maxDim < 1.6 || minDim < 0.35) return;
|
||||
if (maxDim > 30 && Math.min(w, d) > 30 && h < 3) return;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
@ -2185,6 +2283,10 @@ export class BabylonScene {
|
||||
// В Play-режиме ЛКМ — клик игрока в forward-направлении.
|
||||
// При pointer-lock курсор в центре; в third (свободный курсор)
|
||||
// передаём реальные координаты клика для pick по табличкам.
|
||||
if (this.placementManager && this.placementManager.isActive()) {
|
||||
if (e.button === 0) { e.preventDefault(); this.placementManager.confirm(); return; }
|
||||
if (e.button === 2) { e.preventDefault(); this.placementManager.cancel(); return; }
|
||||
}
|
||||
if (e.button === 0) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
this._handlePlayClick(e.clientX - r.left, e.clientY - r.top);
|
||||
@ -2383,6 +2485,10 @@ export class BabylonScene {
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
|
||||
this.placementManager.rotate();
|
||||
return;
|
||||
}
|
||||
const forward = this._getCameraForward();
|
||||
const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED;
|
||||
this.camera.position.addInPlace(forward.scale(delta));
|
||||
@ -2419,6 +2525,22 @@ export class BabylonScene {
|
||||
const key = this._normalizeKey(e);
|
||||
this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code });
|
||||
}
|
||||
// Задача 44: I — открыть/закрыть инвентарь, Esc — закрыть, 1-9 — хотбар.
|
||||
if (this._isPlaying && e.code === 'KeyI' && this.invUI &&
|
||||
(this.invUI.defs.size > 0 || this.invUI.grid.some(Boolean) || this.invUI.hotbar.some(Boolean))) {
|
||||
e.preventDefault(); this.invUI.toggle(); return;
|
||||
}
|
||||
if (this._isPlaying && e.code === 'Escape' && this.invUI?.isOpen()) {
|
||||
e.preventDefault(); this.invUI.close(); return;
|
||||
}
|
||||
if (this._isPlaying && this.invUI && /^Digit[1-9]$/.test(e.code) &&
|
||||
(this.invUI.hotbar.some(Boolean) || this.invUI.defs.size > 0)) {
|
||||
this.invUI.setActiveHotbar(parseInt(e.code.slice(5), 10) - 1);
|
||||
}
|
||||
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
|
||||
if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; }
|
||||
if (e.code === 'Escape') { e.preventDefault(); this.placementManager.cancel(); return; }
|
||||
}
|
||||
if (e.code === 'KeyF') {
|
||||
this._focusOnTarget(new Vector3(0, 0, 0));
|
||||
}
|
||||
@ -2778,6 +2900,7 @@ export class BabylonScene {
|
||||
if (md.isBlock) {
|
||||
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.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
|
||||
return null;
|
||||
@ -2874,12 +2997,59 @@ export class BabylonScene {
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Касания объектов, на которые подписан ГЛОБАЛЬНЫЙ скрипт через
|
||||
// findOne(x).onTouch(...) (rt._watchedTouchRefs). Объекты без скрипта
|
||||
// и не триггеры — например цели туториала. Событие адресное (по ref).
|
||||
const watched = rt._watchedTouchRefs;
|
||||
if (watched && watched.size > 0) {
|
||||
for (const ref of watched) {
|
||||
const target = this._refToTarget(ref);
|
||||
if (!target) continue;
|
||||
const aabb = this._targetAABB(target);
|
||||
if (!aabb) continue;
|
||||
const key = 'w:' + ref;
|
||||
seen.add(key);
|
||||
const overlap =
|
||||
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
|
||||
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
|
||||
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
|
||||
const wasTouching = this._touchState.get(key);
|
||||
if (overlap && !wasTouching) {
|
||||
this._touchState.set(key, true);
|
||||
rt.routeInstEvent(ref, 'instTouch', {});
|
||||
} else if (!overlap && wasTouching) {
|
||||
this._touchState.set(key, false);
|
||||
rt.routeInstEvent(ref, 'instUntouch', {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Чистим устаревшие записи (удалённые скрипты/триггеры)
|
||||
for (const id of this._touchState.keys()) {
|
||||
if (!seen.has(id)) this._touchState.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ref-строка 'primitive:NN' | 'model:NN' → {kind,id} для _targetAABB. */
|
||||
_refToTarget(ref) {
|
||||
if (typeof ref !== 'string') return null;
|
||||
const colon = ref.indexOf(':');
|
||||
if (colon < 0) return null;
|
||||
const kind = ref.slice(0, colon);
|
||||
const rest = ref.slice(colon + 1);
|
||||
if (kind === 'primitive') {
|
||||
const id = this.gameRuntime?._resolvePrimitiveId
|
||||
? this.gameRuntime._resolvePrimitiveId(rest)
|
||||
: (Number.isFinite(Number(rest)) ? Number(rest) : rest);
|
||||
return { kind: 'primitive', id };
|
||||
}
|
||||
if (kind === 'model') {
|
||||
const n = Number(rest);
|
||||
return { kind: 'model', id: Number.isFinite(n) ? n : rest };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Получить мировой AABB target-объекта (для touch-детекции). */
|
||||
_targetAABB(target) {
|
||||
if (!target) return null;
|
||||
@ -2972,7 +3142,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 point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
|
||||
// 1) Self-onClick — только если target есть
|
||||
@ -5172,6 +5364,11 @@ export class BabylonScene {
|
||||
}
|
||||
|
||||
/** Изменить позицию выделенного (используется Inspector). */
|
||||
// ── Небо (задача 16) — обёртки для game-API ──────────────────────────
|
||||
setSkybox(opts) { this.skybox?.setSkybox(opts); }
|
||||
setClouds(opts) { this.skybox?.setClouds(opts); }
|
||||
setSkyFog(opts) { this.skybox?.setFog(opts); }
|
||||
|
||||
moveSelectedTo(x, y, z) {
|
||||
if (!this.selection) return;
|
||||
const sel = this.selection.getSelection();
|
||||
@ -5276,6 +5473,56 @@ export class BabylonScene {
|
||||
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-блок,
|
||||
* запоминаем позицию редактор-камеры чтобы вернуть при exit.
|
||||
@ -5283,6 +5530,9 @@ export class BabylonScene {
|
||||
enterPlayMode() {
|
||||
if (this._isPlaying) return;
|
||||
this._isPlaying = true;
|
||||
// Сброс состояния касаний — каждый прогон начинается «не касаясь».
|
||||
if (this._touchState) this._touchState.clear();
|
||||
this._playerMenuOpen = false; // меню-оверлей закрыт на старте Play
|
||||
// По умолчанию стандартный HUD видим в Play.
|
||||
// Скрипт может скрыть через game.hud.setVisible(false).
|
||||
this._setStdHudVisible(true);
|
||||
@ -5343,14 +5593,24 @@ export class BabylonScene {
|
||||
this.modalManager.close();
|
||||
return;
|
||||
}
|
||||
// ESC в плеере = открыть меню-оверлей поверх ЖИВОЙ игры (как в Roblox).
|
||||
// Раньше тут был exitPlayMode() + _onPlayChange(false), из-за чего
|
||||
// KubikonPlayer заново звал enterPlayMode → игра перезапускалась
|
||||
// (респавн + перезапуск скриптов). Теперь только UI-курсор + сигнал
|
||||
// открыть меню. Play продолжает идти под меню.
|
||||
// ESC в плеере = TOGGLE меню-оверлея поверх ЖИВОЙ игры (как в Roblox).
|
||||
// Единый источник истины — _playerMenuOpen в движке. Раньше состояние
|
||||
// меню держал React, а ESC слушали ДВА обработчика (движок + React) →
|
||||
// гонка: меню открывалось поверх меню, а _uiCursorMode застревал в true
|
||||
// → orbit-камера по ПКМ переставала работать после закрытия меню.
|
||||
// Теперь движок сам решает open/close и шлёт это в _onEscMenu(open).
|
||||
if (typeof this._onEscMenu === 'function') {
|
||||
this.player?.setUiCursorMode?.(true);
|
||||
this._onEscMenu();
|
||||
if (this._playerMenuOpen) {
|
||||
// Меню открыто → ESC закрывает: вернуть мышь в игру.
|
||||
this._playerMenuOpen = false;
|
||||
this.player?.setUiCursorMode?.(false);
|
||||
this._onEscMenu(false);
|
||||
} else {
|
||||
// Меню закрыто → ESC открывает: освободить курсор.
|
||||
this._playerMenuOpen = true;
|
||||
this.player?.setUiCursorMode?.(true);
|
||||
this._onEscMenu(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Фолбэк (если меню не подписано, напр. в студии) — старое поведение.
|
||||
@ -5359,13 +5619,25 @@ export class BabylonScene {
|
||||
});
|
||||
if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange);
|
||||
if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath);
|
||||
this.player.start(this._spawnPoint);
|
||||
// Точка спавна удалена → игрок появляется в (0, безопасная высота, 0).
|
||||
let startPoint = this._spawnPoint;
|
||||
if (this._spawnEnabled === false) {
|
||||
let sy = 3;
|
||||
try {
|
||||
const surf = this.physics?._sampleRobloxSurface?.(0, 0);
|
||||
if (surf !== null && surf !== undefined) sy = surf + 2;
|
||||
} catch (e) { /* ignore */ }
|
||||
startPoint = { x: 0, y: sy, z: 0 };
|
||||
}
|
||||
this.player.start(startPoint);
|
||||
|
||||
// Запускаем пользовательские скрипты (этап 2.1).
|
||||
// Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
|
||||
// поэтому скрипты стартуем в следующем кадре.
|
||||
this.gameRuntime = new GameRuntime(this);
|
||||
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
||||
// Задача 05: стартовый экран загрузки (Ken-Burns + название места).
|
||||
try { this.showStartupLoadingScreen(); } catch (e) {}
|
||||
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
|
||||
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
|
||||
// this.audioManager (AudioManager — ambient/music для всех проектов).
|
||||
@ -5509,9 +5781,24 @@ export class BabylonScene {
|
||||
if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts);
|
||||
// Задача 20: смонтировать HUD лидербордов/достижений если определения уже
|
||||
// загружены из проекта (define из project_data при load).
|
||||
try { if (this.leaderstats?.active) this.leaderstats._mount(); } catch (e) {}
|
||||
try { if (this.achievements?.active) this.achievements._mountButton(); } catch (e) {}
|
||||
try {
|
||||
if (this.invUI && (this.invUI.defs.size > 0 || this.invUI.hotbar.some(Boolean) || this.invUI.grid.some(Boolean))) {
|
||||
this.invUI.mountHotbar();
|
||||
}
|
||||
} catch (e) {}
|
||||
// Старт через requestAnimationFrame — даём Babylon собрать сцену
|
||||
requestAnimationFrame(() => {
|
||||
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
|
||||
// Задача 20: подгрузить сохранённый прогресс игрока из БД ПОСЛЕ define().
|
||||
setTimeout(() => {
|
||||
if (!this._isPlaying) return;
|
||||
try { this.achievements?.loadFromDB?.(); } catch (e) {}
|
||||
try { this.leaderstats?.loadFromDB?.(); } catch (e) {}
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// === Оружие ===
|
||||
@ -5522,6 +5809,10 @@ export class BabylonScene {
|
||||
if (hit?.mesh && this.zombieManager) {
|
||||
this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25);
|
||||
}
|
||||
// Урон скриптовым NPC (киты-враги) → авто-floater над мобом (задача 40).
|
||||
if (hit?.mesh && this.npcManager) {
|
||||
try { this.npcManager.damageByMesh(hit.mesh, hit.damage || 25); } catch (e) {}
|
||||
}
|
||||
if (this._onWeaponHit) {
|
||||
try { this._onWeaponHit(hit); } catch (e) {}
|
||||
}
|
||||
@ -5947,6 +6238,22 @@ export class BabylonScene {
|
||||
this._onEscMenu = cb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизация состояния меню-оверлея из UI (React). Когда меню закрывают
|
||||
* НЕ через ESC (кнопка «Продолжить»/крестик/клик по фону), UI обязан сообщить
|
||||
* движку — иначе _playerMenuOpen рассинхронизируется и следующий ESC решит,
|
||||
* что меню «открыто», и не откроет его. open=false также возвращает мышь в игру.
|
||||
*/
|
||||
setPlayerMenuOpen(open) {
|
||||
const v = !!open;
|
||||
if (this._playerMenuOpen === v) return;
|
||||
this._playerMenuOpen = v;
|
||||
if (!v) {
|
||||
// меню закрыли из UI → вернуть управление камерой/мышью
|
||||
try { this.player?.setUiCursorMode?.(false); } catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Колбэк изменения сцены (любая модификация блоков/моделей).
|
||||
* Используется KubikonEditor для dirty-tracking → auto-save.
|
||||
@ -6866,6 +7173,7 @@ export class BabylonScene {
|
||||
folders: this.folderManager ? this.folderManager.serialize() : [],
|
||||
gui: this.guiManager ? this.guiManager.serialize() : [],
|
||||
inventory: this.inventory ? this.inventory.serialize() : null,
|
||||
inventory2: this.invUI ? this.invUI.serialize() : null, // задача 44
|
||||
spawnPoint: { ...this._spawnPoint },
|
||||
playerModelType: this._playerModelType,
|
||||
skins: this._skinsConfig ? {
|
||||
@ -6882,6 +7190,9 @@ export class BabylonScene {
|
||||
crosshair: this._crosshair || 'dot',
|
||||
shadowQuality: this._shadowQuality || 'soft',
|
||||
environment: this.environment ? this.environment.serialize() : null,
|
||||
skybox: this.skybox ? this.skybox.serialize() : null,
|
||||
leaderstats: this.leaderstats ? this.leaderstats.serialize() : null,
|
||||
achievements: this.achievements ? this.achievements.serialize() : null,
|
||||
audio: this.audioManager ? this.audioManager.serialize() : null,
|
||||
// Библиотека пользовательских картинок (текстуры/GUI-image).
|
||||
assets: this.assetManager ? this.assetManager.serialize() : [],
|
||||
@ -7232,6 +7543,15 @@ export class BabylonScene {
|
||||
} else {
|
||||
this._skinsConfig = null;
|
||||
}
|
||||
// Задача 12+05: конфиг экрана загрузки (через setLoadingConfig — единый маппинг).
|
||||
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
|
||||
this.setLoadingConfig(state.scene.loadingScreen);
|
||||
} else {
|
||||
this._loadingConfig = null;
|
||||
}
|
||||
// Задача 13: конфиг главного меню (passthrough).
|
||||
this._mainMenuConfig = (state.scene.mainMenu && typeof state.scene.mainMenu === 'object')
|
||||
? state.scene.mainMenu : null;
|
||||
|
||||
// ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже —
|
||||
// PlayerController.start() её ждёт, но если предзагрузить сейчас,
|
||||
@ -7264,6 +7584,9 @@ export class BabylonScene {
|
||||
if (this.inventory) {
|
||||
this.inventory.loadFromArray(state.scene.inventory || null);
|
||||
}
|
||||
if (this.invUI && state.scene.inventory2) { // задача 44
|
||||
this.invUI.load(state.scene.inventory2);
|
||||
}
|
||||
// Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле)
|
||||
if (this.blockManager && Array.isArray(state.scene.blocks)) {
|
||||
for (const b of state.scene.blocks) {
|
||||
@ -7307,8 +7630,10 @@ export class BabylonScene {
|
||||
// Точка спавна
|
||||
if (state.scene.spawnPoint) {
|
||||
this._spawnPoint = { ...state.scene.spawnPoint };
|
||||
this._updateSpawnMarker();
|
||||
this._updateSpawnMarker?.();
|
||||
}
|
||||
// Удалена ли точка спавна (плеер: спавн в 0,0 при отсутствии).
|
||||
this._spawnEnabled = state.scene.spawnEnabled !== false;
|
||||
// === Авто-fix спавна для smooth terrain ===
|
||||
// Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности —
|
||||
// поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить".
|
||||
@ -7346,6 +7671,22 @@ export class BabylonScene {
|
||||
if (state.scene.environment && this.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)
|
||||
if (state.scene.skybox && this.skybox) {
|
||||
this.skybox.load(state.scene.skybox);
|
||||
}
|
||||
// Лидерборды и достижения (задача 20) — определения из проекта.
|
||||
if (state.scene.leaderstats && this.leaderstats) {
|
||||
this.leaderstats.load(state.scene.leaderstats);
|
||||
}
|
||||
if (state.scene.achievements && this.achievements) {
|
||||
this.achievements.load(state.scene.achievements);
|
||||
}
|
||||
// Аудио (фоновая музыка/амбиент)
|
||||
if (state.scene.audio && this.audioManager) {
|
||||
this.audioManager.load(state.scene.audio);
|
||||
@ -7412,6 +7753,11 @@ export class BabylonScene {
|
||||
this._isPlaying = false;
|
||||
// Задача 04: закрываем любой активный модал чтобы не «висел» при стопе
|
||||
try { this.modalManager?._instantClose?.(); } catch (e) {}
|
||||
// Задача 20: чистим рантайм лидербордов/достижений (определения остаются).
|
||||
try { this.leaderstats?.resetRuntime?.(); } catch (e) {}
|
||||
try { this.achievements?.resetRuntime?.(); } catch (e) {}
|
||||
try { this.floaters?.resetRuntime?.(); } catch (e) {} // задача 40
|
||||
try { this.invUI?.resetRuntime?.(); } catch (e) {} // задача 44
|
||||
// Сбрасываем таймер прохождения
|
||||
this._timerRunning = false;
|
||||
this._timerStartedAt = null;
|
||||
@ -7439,6 +7785,13 @@ export class BabylonScene {
|
||||
this.gameRuntime = null;
|
||||
}
|
||||
|
||||
// Placement mode (задача 11): сброс активной сессии + виджета магазина.
|
||||
if (this.vehicleManager) { try { this.vehicleManager.dispose(); } catch (e) {} }
|
||||
if (this.vehicleHud) { try { this.vehicleHud.dispose(); } catch (e) {} this.vehicleHud = null; }
|
||||
if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) {} this.placementManager = null; }
|
||||
if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) {} this.shopInventoryUi = null; }
|
||||
if (this.loadingScreen) { try { this.loadingScreen.dispose(); } catch (e) {} this.loadingScreen = null; }
|
||||
|
||||
// Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением)
|
||||
if (this.gdLevelManager) {
|
||||
this.gdLevelManager.stop();
|
||||
|
||||
@ -94,6 +94,10 @@ export class BlockManager {
|
||||
this._lavaSurfaceBaseY = null;
|
||||
this._lavaDirty = false;
|
||||
this._animTime = 0;
|
||||
// Окрашиваемые блоки (studs-block, задача 09): per-instance color через
|
||||
// ThinInstance color buffer. blockTypeId → Float32Array(maxBlocks*4 RGBA).
|
||||
this._colorsByProto = new Map();
|
||||
this._STUDS_MAX = 20000; // макс блоков одного окрашиваемого типа
|
||||
}
|
||||
|
||||
/** Вызывать каждый кадр для анимации воды/лавы. */
|
||||
@ -359,6 +363,23 @@ export class BlockManager {
|
||||
const mat = new StandardMaterial(name, this.scene);
|
||||
mat.specularColor = new Color3(0, 0, 0);
|
||||
|
||||
// Окрашиваемый блок (studs-block): цвет берётся per-instance из vertex
|
||||
// color буфера ThinInstance и умножается на серую текстуру. Включаем
|
||||
// useVertexColors, normal map (выпуклость кружков), мягкий спекуляр.
|
||||
if (blockType.colorable) {
|
||||
const tex = new Texture(texturePath, this.scene);
|
||||
mat.diffuseTexture = tex;
|
||||
mat.diffuseColor = new Color3(1, 1, 1); // нейтраль — цвет идёт из vertex color
|
||||
if (blockType.normal) {
|
||||
try { mat.bumpTexture = new Texture(blockType.normal, this.scene); } catch (e) {}
|
||||
}
|
||||
// Сочность (Roblox-look): почти-белая текстура × яркий vertex color,
|
||||
// specular убран (он белит/тускнит цвет).
|
||||
mat.specularColor = new Color3(0, 0, 0);
|
||||
mat.useVertexColors = true;
|
||||
return mat;
|
||||
}
|
||||
|
||||
if (texturePath) {
|
||||
const tex = new Texture(texturePath, this.scene);
|
||||
tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE);
|
||||
@ -439,7 +460,7 @@ export class BlockManager {
|
||||
* один meshes-prototype на тип блока, тысячи позиций в одном draw call.
|
||||
* Жидкости (water/lava) идут по старому пути — у них свой single-surface.
|
||||
*/
|
||||
addBlock(x, y, z, blockTypeId) {
|
||||
addBlock(x, y, z, blockTypeId, color) {
|
||||
const ix = Math.round(x);
|
||||
const iy = Math.round(y);
|
||||
const iz = Math.round(z);
|
||||
@ -449,6 +470,9 @@ export class BlockManager {
|
||||
const typeDef = getBlockType(blockTypeId);
|
||||
const isWater = !!typeDef?.isWater;
|
||||
const isLava = !!typeDef?.isLava;
|
||||
// Окрашиваемый блок: цвет инстанса (из аргумента или дефолт типа).
|
||||
const colorable = !!typeDef?.colorable;
|
||||
const instColor = colorable ? (color || typeDef.defaultColor || '#cccccc') : null;
|
||||
|
||||
// Для жидкостей оставляем старую логику: невидимый куб + единый surface
|
||||
if (isWater || isLava) {
|
||||
@ -496,6 +520,9 @@ export class BlockManager {
|
||||
keysArr[idx] = key;
|
||||
this._cellToInst.set(key, { typeId: blockTypeId, idx });
|
||||
|
||||
// Окрашиваемый блок — пишем цвет инстанса в color buffer.
|
||||
if (colorable) this._setBlockColorAt(blockTypeId, idx, instColor);
|
||||
|
||||
// Логический «meshProxy» — объект, имитирующий API mesh для совместимости.
|
||||
// НЕ создаёт реального меша. Используется selection / removeBlockByMesh.
|
||||
const meshProxy = {
|
||||
@ -511,6 +538,7 @@ export class BlockManager {
|
||||
mass: 1,
|
||||
folderId: null,
|
||||
_thinIdx: idx,
|
||||
color: instColor, // per-instance цвет окрашиваемого блока
|
||||
},
|
||||
// Минимальные методы, которые ожидает остальной код
|
||||
position: new Vector3(ix, iy + 0.5, iz),
|
||||
@ -538,6 +566,18 @@ export class BlockManager {
|
||||
proto.material = material;
|
||||
if (isMulti) this._setupSubmeshes(proto);
|
||||
|
||||
// Окрашиваемый блок — включаем per-instance color buffer (vertex colors).
|
||||
const _bt = getBlockType(blockTypeId);
|
||||
if (_bt && _bt.colorable) {
|
||||
proto.useVertexColors = true;
|
||||
proto.hasVertexAlpha = false;
|
||||
const buf = new Float32Array(this._STUDS_MAX * 4);
|
||||
// Дефолт-цвет (белый) для всех слотов — иначе невыставленные = чёрные.
|
||||
buf.fill(1);
|
||||
proto.thinInstanceSetBuffer('color', buf, 4, false);
|
||||
this._colorsByProto.set(blockTypeId, buf);
|
||||
}
|
||||
|
||||
proto.checkCollisions = false; // коллизии через thin-instances не работают штатно;
|
||||
// PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB).
|
||||
proto.isPickable = true;
|
||||
@ -571,6 +611,44 @@ export class BlockManager {
|
||||
/** Подписка для уведомлений о создании prototype-меша (для shadow setup). */
|
||||
setOnProtoCreated(cb) { this._onProtoCreated = cb; }
|
||||
|
||||
/**
|
||||
* Записать цвет инстанса окрашиваемого блока в color buffer (RGBA float).
|
||||
* idx — индекс thin-instance. hex — '#rrggbb'. В batch-режиме обновление
|
||||
* GPU откладывается (флаг dirty), иначе сразу thinInstanceBufferUpdated.
|
||||
*/
|
||||
_setBlockColorAt(blockTypeId, idx, hex) {
|
||||
const buf = this._colorsByProto.get(blockTypeId);
|
||||
if (!buf) return;
|
||||
const c = Color3.FromHexString(hex || '#cccccc');
|
||||
const o = idx * 4;
|
||||
buf[o] = c.r; buf[o + 1] = c.g; buf[o + 2] = c.b; buf[o + 3] = 1;
|
||||
const proto = this._protoMeshes.get(blockTypeId);
|
||||
if (!proto) return;
|
||||
if (this._batchMode) {
|
||||
if (!this._colorDirtyProtos) this._colorDirtyProtos = new Set();
|
||||
this._colorDirtyProtos.add(blockTypeId);
|
||||
} else {
|
||||
try { proto.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сменить цвет окрашиваемого блока в (x,y,z) на лету (для scene.setColor /
|
||||
* color-пикера). Возвращает true если блок окрашиваемый и цвет применён.
|
||||
*/
|
||||
setBlockColor(x, y, z, hex) {
|
||||
const key = this._key(Math.round(x), Math.round(y), Math.round(z));
|
||||
const inst = this._cellToInst.get(key);
|
||||
if (!inst) return false;
|
||||
const bt = getBlockType(inst.typeId);
|
||||
if (!bt || !bt.colorable) return false;
|
||||
this._setBlockColorAt(inst.typeId, inst.idx, hex);
|
||||
const mp = this.blocks.get(key);
|
||||
if (mp && mp.metadata) mp.metadata.color = hex;
|
||||
this._notifyChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Установить флаг anchored у блока. */
|
||||
setBlockAnchored(x, y, z, anchored) {
|
||||
const mesh = this.blocks.get(this._key(x, y, z));
|
||||
@ -767,6 +845,8 @@ export class BlockManager {
|
||||
canCollide: m.canCollide !== false,
|
||||
visible: m.visible !== false,
|
||||
mass: m.mass ?? 1,
|
||||
// per-instance цвет окрашиваемого блока (studs-block); иначе пропускаем
|
||||
...(m.color ? { color: m.color } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@ -778,7 +858,7 @@ export class BlockManager {
|
||||
this._batchMode = true;
|
||||
try {
|
||||
for (const b of arr) {
|
||||
const mesh = this.addBlock(b.x, b.y, b.z, b.type);
|
||||
const mesh = this.addBlock(b.x, b.y, b.z, b.type, b.color);
|
||||
if (!mesh) continue;
|
||||
if (b.anchored === false) {
|
||||
mesh.metadata.anchored = false;
|
||||
@ -802,6 +882,14 @@ export class BlockManager {
|
||||
proto.thinInstanceRefreshBoundingInfo(true);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
// Финальный refresh color-буферов окрашиваемых блоков (batch).
|
||||
if (this._colorDirtyProtos) {
|
||||
for (const typeId of this._colorDirtyProtos) {
|
||||
const proto = this._protoMeshes.get(typeId);
|
||||
try { proto?.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
this._colorDirtyProtos.clear();
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
|
||||
@ -105,6 +105,11 @@ export const BLOCK_TYPES = [
|
||||
// top = stone, side = oven (4 стороны с дверцей — лучше чем раньше),
|
||||
// bottom = stone. В будущем для одной двери понадобится 6-face формат.
|
||||
multiCube('oven', 'Печь', 'Особые', `${TEX}/stone.png`, `${TEX}/oven.png`, `${TEX}/stone.png`),
|
||||
|
||||
// === ОКРАШИВАЕМЫЕ (задача 09) — паритет со студией ===
|
||||
cube('studs-block', 'Лего-кирпич', 'Окрашиваемые',
|
||||
'/kubikon-assets/materials/studs_v4_diffuse.png',
|
||||
{ colorable: true, normal: '/kubikon-assets/materials/studs_v4_normal.png', defaultColor: '#3a7aff' }),
|
||||
];
|
||||
|
||||
/** Все доступные категории в порядке появления. */
|
||||
@ -120,6 +125,7 @@ export const CATEGORY_COLORS = {
|
||||
'Кирпич': '#9d4a3a',
|
||||
'Особые': '#9966ff',
|
||||
'Природа': '#5a8c3e',
|
||||
'Окрашиваемые': '#3a7aff',
|
||||
};
|
||||
|
||||
/** Найти описание блока по id. */
|
||||
|
||||
@ -91,10 +91,14 @@ export class Environment {
|
||||
this.fogEnabled = false;
|
||||
this.fogColor = [0.7, 0.8, 0.9];
|
||||
this.fogDensity = 0.01;
|
||||
// Видимые тела на небе (солнце и луна) — создаём по запросу
|
||||
// Видимые тела на небе (солнце и луна).
|
||||
// ВАЖНО (задача 16): единое небо рисует SkyboxManager. Environment больше
|
||||
// НЕ рисует свою жёлтую сферу/луну — иначе на небе два солнца. Здесь
|
||||
// остаётся только управление светом (направление/яркость/ambient).
|
||||
this._drawSkyBodies = false;
|
||||
this._sunMesh = null;
|
||||
this._moonMesh = null;
|
||||
this._createSkyBodies();
|
||||
if (this._drawSkyBodies) this._createSkyBodies();
|
||||
this._applyTime();
|
||||
}
|
||||
|
||||
|
||||
236
src/engine/FloaterManager.js
Normal file
236
src/engine/FloaterManager.js
Normal file
@ -0,0 +1,236 @@
|
||||
/**
|
||||
* FloaterManager — всплывающие цифры урона (Damage Floaters), задача 40.
|
||||
*
|
||||
* game.fx.damageFloater(position, value, opts) → над точкой всплывает число,
|
||||
* поднимается вверх, покачивается, плавно гаснет. Цвета: damage/crit/heal/
|
||||
* mana/miss. Object pool из переиспользуемых billboard-планов (без create/
|
||||
* destroy на каждый удар). Стек одинаковых по stackKey («×N»). Комикс-стиль
|
||||
* (BAM!/KAPOW!/POW!).
|
||||
*
|
||||
* Билборд = плоскость с DynamicTexture (как LabelManager), billboardMode=7,
|
||||
* renderingGroupId=1 (всегда поверх геометрии), disableDepthWrite.
|
||||
*
|
||||
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
||||
*/
|
||||
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
|
||||
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
||||
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
||||
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
||||
import { Mesh } from '@babylonjs/core/Meshes/mesh';
|
||||
|
||||
const POOL_SIZE = 30;
|
||||
const TEX_W = 512, TEX_H = 256;
|
||||
|
||||
// Пресеты типов урона: цвет текста + множители.
|
||||
const PRESETS = {
|
||||
damage: { color: '#ff5a4a', stroke: '#3a0000' },
|
||||
crit: { color: '#ffd23a', stroke: '#5a3a00' },
|
||||
heal: { color: '#46e06a', stroke: '#063a14' },
|
||||
mana: { color: '#4aa8ff', stroke: '#001a3a' },
|
||||
miss: { color: '#b8b8b8', stroke: '#222222' },
|
||||
};
|
||||
|
||||
function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); }
|
||||
|
||||
export class FloaterManager {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d;
|
||||
this.scene = scene3d.scene;
|
||||
this.pool = [];
|
||||
this._initialized = false;
|
||||
this._stacks = new Map(); // stackKey → slot (для накопления ×N)
|
||||
}
|
||||
|
||||
_init() {
|
||||
if (this._initialized) return;
|
||||
this._initialized = true;
|
||||
for (let i = 0; i < POOL_SIZE; i++) {
|
||||
const tex = new DynamicTexture(`floaterTex_${i}`, { width: TEX_W, height: TEX_H }, this.scene, true);
|
||||
tex.hasAlpha = true;
|
||||
const plane = MeshBuilder.CreatePlane(`floater_${i}`, { width: 2.4, height: 1.2, sideOrientation: Mesh.DOUBLESIDE }, this.scene);
|
||||
const mat = new StandardMaterial(`floaterMat_${i}`, this.scene);
|
||||
mat.diffuseTexture = tex;
|
||||
mat.diffuseTexture.hasAlpha = true;
|
||||
mat.emissiveColor = new Color3(1, 1, 1);
|
||||
mat.diffuseColor = new Color3(0, 0, 0);
|
||||
mat.disableLighting = true;
|
||||
mat.backFaceCulling = false;
|
||||
mat.disableDepthWrite = true;
|
||||
mat.useAlphaFromDiffuseTexture = true;
|
||||
plane.material = mat;
|
||||
plane.billboardMode = 7;
|
||||
plane.renderingGroupId = 1;
|
||||
plane.isPickable = false;
|
||||
plane.setEnabled(false);
|
||||
this.pool.push({ plane, tex, mat, active: false, age: 0, lifetime: 0.8 });
|
||||
}
|
||||
}
|
||||
|
||||
_acquire() {
|
||||
for (const slot of this.pool) if (!slot.active) return slot;
|
||||
return null; // все заняты — пропускаем новый floater (норма)
|
||||
}
|
||||
|
||||
/**
|
||||
* Главный API. position: {x,y,z}; value: число|строка; opts — см. задачу 40.
|
||||
*/
|
||||
spawn(position, value, opts = {}) {
|
||||
this._init();
|
||||
if (!position) return;
|
||||
opts = opts || {};
|
||||
|
||||
// Стек: одинаковый stackKey за время жизни накапливает счётчик.
|
||||
if (opts.stackKey && this._stacks.has(opts.stackKey)) {
|
||||
const slot = this._stacks.get(opts.stackKey);
|
||||
if (slot.active) {
|
||||
slot.stackCount = (slot.stackCount || 1) + 1;
|
||||
slot.age = Math.min(slot.age, slot.lifetime * 0.3); // продлеваем
|
||||
this._draw(slot, slot.baseText, slot.preset, slot.fontSize, slot.comic, slot.stackCount);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const slot = this._acquire();
|
||||
if (!slot) return;
|
||||
|
||||
// Тип floater'а.
|
||||
let kind = 'damage';
|
||||
if (opts.isCrit) kind = 'crit';
|
||||
else if (opts.isHeal) kind = 'heal';
|
||||
else if (opts.isMana) kind = 'mana';
|
||||
else if (opts.isMiss) kind = 'miss';
|
||||
const preset = PRESETS[kind];
|
||||
const color = opts.color || preset.color;
|
||||
const stroke = opts.strokeColor || preset.stroke;
|
||||
|
||||
let fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : 60;
|
||||
let floatHeight = Number.isFinite(opts.floatHeight) ? opts.floatHeight : 2;
|
||||
let lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 0.9;
|
||||
const randomOffset = Number.isFinite(opts.randomOffset) ? opts.randomOffset : (opts.isCrit ? 0.5 : 0.25);
|
||||
|
||||
// Текст: число с минусом (урон) или как есть (строка / heal с плюсом).
|
||||
let baseText;
|
||||
if (typeof value === 'string') baseText = value;
|
||||
else if (opts.isHeal) baseText = '+' + value;
|
||||
else if (opts.isMiss) baseText = String(value);
|
||||
else baseText = '-' + Math.abs(value);
|
||||
|
||||
if (opts.isCrit) { fontSize = Math.round(fontSize * 1.4); floatHeight *= 1.2; }
|
||||
|
||||
slot.active = true;
|
||||
slot.age = 0;
|
||||
slot.lifetime = lifetime;
|
||||
slot.floatHeight = floatHeight;
|
||||
slot.isCrit = !!opts.isCrit;
|
||||
slot.color = color; slot.stroke = stroke;
|
||||
slot.preset = { color, stroke };
|
||||
slot.fontSize = fontSize;
|
||||
slot.comic = !!opts.comicStyle;
|
||||
slot.baseText = baseText;
|
||||
slot.stackCount = 1;
|
||||
slot.stackKey = opts.stackKey || null;
|
||||
|
||||
const rx = (Math.random() - 0.5) * 2 * randomOffset;
|
||||
const rz = (Math.random() - 0.5) * 2 * randomOffset;
|
||||
slot.startX = position.x + rx;
|
||||
slot.startY = position.y + (Number.isFinite(opts.yOffset) ? opts.yOffset : 1.5);
|
||||
slot.startZ = position.z + rz;
|
||||
slot.plane.position.set(slot.startX, slot.startY, slot.startZ);
|
||||
slot.plane.scaling.set(1, 1, 1);
|
||||
slot.plane.setEnabled(true);
|
||||
|
||||
this._draw(slot, baseText, slot.preset, fontSize, slot.comic, 1);
|
||||
|
||||
if (opts.stackKey) this._stacks.set(opts.stackKey, slot);
|
||||
}
|
||||
|
||||
_draw(slot, baseText, preset, fontSize, comic, stackCount) {
|
||||
const ctx = slot.tex.getContext();
|
||||
ctx.clearRect(0, 0, TEX_W, TEX_H);
|
||||
|
||||
let text = baseText;
|
||||
if (comic) {
|
||||
const num = parseInt(String(baseText).replace(/[^0-9]/g, ''), 10) || 0;
|
||||
if (slot.isCrit) text = 'POW!';
|
||||
else if (num > 100) text = 'KAPOW!';
|
||||
else if (num > 50) text = 'BAM!';
|
||||
}
|
||||
if (stackCount > 1) text = baseText + ' ×' + stackCount;
|
||||
|
||||
const fs = comic ? Math.round(fontSize * 1.1) : fontSize;
|
||||
ctx.font = `900 ${fs}px ${comic ? 'Bangers, Impact, sans-serif' : 'Inter, Arial, sans-serif'}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Комикс-фон: жёлтая звезда-вспышка.
|
||||
if (comic) {
|
||||
ctx.save();
|
||||
ctx.translate(TEX_W / 2, TEX_H / 2);
|
||||
ctx.fillStyle = 'rgba(255,210,60,0.9)';
|
||||
ctx.beginPath();
|
||||
const spikes = 10, outer = 130, inner = 70;
|
||||
for (let i = 0; i < spikes * 2; i++) {
|
||||
const r = i % 2 === 0 ? outer : inner;
|
||||
const a = (i / (spikes * 2)) * Math.PI * 2 - Math.PI / 2;
|
||||
const px = Math.cos(a) * r, py = Math.sin(a) * r * 0.55;
|
||||
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Обводка + текст.
|
||||
ctx.strokeStyle = comic ? '#000' : preset.stroke;
|
||||
ctx.lineWidth = Math.max(6, fs * 0.16);
|
||||
ctx.strokeText(text, TEX_W / 2, TEX_H / 2);
|
||||
ctx.fillStyle = comic ? '#d22' : preset.color;
|
||||
ctx.fillText(text, TEX_W / 2, TEX_H / 2);
|
||||
|
||||
slot.tex.update(true);
|
||||
}
|
||||
|
||||
/** Вызывать каждый кадр (анимация подъёма + fade + покачивание + crit-pop). */
|
||||
tick(dt) {
|
||||
if (!this._initialized) return;
|
||||
for (const slot of this.pool) {
|
||||
if (!slot.active) continue;
|
||||
slot.age += dt;
|
||||
const t = slot.age / slot.lifetime;
|
||||
if (t >= 1) {
|
||||
slot.active = false;
|
||||
slot.plane.setEnabled(false);
|
||||
if (slot.stackKey && this._stacks.get(slot.stackKey) === slot) this._stacks.delete(slot.stackKey);
|
||||
continue;
|
||||
}
|
||||
const ease = easeOutQuad(t);
|
||||
slot.plane.position.y = slot.startY + slot.floatHeight * ease;
|
||||
slot.plane.position.x = slot.startX + Math.sin(slot.age * 5) * 0.12;
|
||||
|
||||
// fade-in 0.12 / hold / fade-out 0.25
|
||||
let alpha = 1;
|
||||
if (t < 0.12) alpha = t / 0.12;
|
||||
else if (t > 0.75) alpha = 1 - (t - 0.75) / 0.25;
|
||||
slot.mat.alpha = Math.max(0, Math.min(1, alpha));
|
||||
|
||||
// crit pop: scale 1 → 1.3 → 1 в первые 0.4 жизни
|
||||
if (slot.isCrit) {
|
||||
let s = 1;
|
||||
if (t < 0.2) s = 1 + (t / 0.2) * 0.3;
|
||||
else if (t < 0.4) s = 1.3 - ((t - 0.2) / 0.2) * 0.3;
|
||||
slot.plane.scaling.set(s, s, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const slot of this.pool) {
|
||||
try { slot.plane.dispose(); slot.tex.dispose(); slot.mat.dispose(); } catch (e) {}
|
||||
}
|
||||
this.pool = []; this._stacks.clear(); this._initialized = false;
|
||||
}
|
||||
resetRuntime() {
|
||||
for (const slot of this.pool) { slot.active = false; slot.plane?.setEnabled(false); }
|
||||
this._stacks.clear();
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,10 @@
|
||||
import { Color3 } from '@babylonjs/core';
|
||||
import { ScriptSandbox } from './ScriptSandbox';
|
||||
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 крашит в браузере)
|
||||
|
||||
export class GameRuntime {
|
||||
constructor(scene3d) {
|
||||
@ -68,6 +72,20 @@ export class GameRuntime {
|
||||
this._isRunning = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[GameRuntime] start called with scripts:', scripts);
|
||||
// Задача 20: мост leaderstats.onChange (main) → globalEvent в worker'ы.
|
||||
try {
|
||||
const ls = this.scene3d?.leaderstats;
|
||||
if (ls && !ls._bridgeBound) {
|
||||
ls._bridgeBound = true;
|
||||
const meId = ls._resolveMe?.();
|
||||
ls.onChange((pid, name, nv, ov) => {
|
||||
for (const sb of this.sandboxes) sb.sendGlobalEvent({
|
||||
type: 'leaderstatsChange', playerId: pid, name, newValue: nv, oldValue: ov,
|
||||
isMe: String(pid) === String(meId),
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
if (!Array.isArray(scripts) || scripts.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[GameRuntime] start: no scripts to run');
|
||||
@ -81,7 +99,60 @@ export class GameRuntime {
|
||||
modules[s.name] = s.code;
|
||||
}
|
||||
}
|
||||
// Первичный snapshot сцены — собираем СИНХРОННО ДО запуска скриптов и
|
||||
// передаём прямо в init. Иначе findOne() в синхронном теле скрипта
|
||||
// (на старте) возвращает null → подписки obj.onTouch/find не работают.
|
||||
let 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) {
|
||||
// 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()) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[GameRuntime] skipping invalid script entry', s);
|
||||
@ -90,6 +161,7 @@ export class GameRuntime {
|
||||
const sb = new ScriptSandbox(s.code, s.target || null);
|
||||
sb.scriptId = s.id;
|
||||
sb.setModules(modules);
|
||||
if (initialScene) sb.setInitialScene(initialScene);
|
||||
// Если target есть — передаём начальную позицию self до старта
|
||||
if (s.target) {
|
||||
const pos = this._collectSelfPosition(s.target);
|
||||
@ -110,6 +182,132 @@ export class GameRuntime {
|
||||
// eslint-disable-next-line no-console
|
||||
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}`);
|
||||
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
|
||||
// во все sandbox'ы. Не перезаписываем существующий обработчик —
|
||||
@ -305,6 +503,49 @@ export class GameRuntime {
|
||||
return this._skinState;
|
||||
}
|
||||
|
||||
/** Ленивая инициализация PlacementManager (задача 11). */
|
||||
_ensurePlacementManager() {
|
||||
if (this.scene3d?.placementManager) return this.scene3d.placementManager;
|
||||
if (!this.scene3d || !this.scene3d.scene) return null;
|
||||
try {
|
||||
if (this.scene3d._PlacementManagerClass) {
|
||||
this.scene3d.placementManager = new this.scene3d._PlacementManagerClass(this.scene3d);
|
||||
}
|
||||
} catch (e) { this._log('error', 'placementManager init: ' + (e?.message || e)); }
|
||||
return this.scene3d.placementManager || null;
|
||||
}
|
||||
|
||||
/** Ленивая инициализация виджета слот-инвентаря магазина (задача 11). */
|
||||
_ensureShopInventory() {
|
||||
if (this.scene3d?.shopInventoryUi) return this.scene3d.shopInventoryUi;
|
||||
if (!this.scene3d) return null;
|
||||
try {
|
||||
if (this.scene3d._ShopInventoryUiClass) {
|
||||
this.scene3d.shopInventoryUi = new this.scene3d._ShopInventoryUiClass(this.scene3d);
|
||||
}
|
||||
} catch (e) { this._log('error', 'shopInventoryUi init: ' + (e?.message || e)); }
|
||||
return this.scene3d.shopInventoryUi || null;
|
||||
}
|
||||
|
||||
/** Ленивая инициализация экрана загрузки (задача 12). */
|
||||
_ensureLoadingScreen() {
|
||||
if (this.scene3d?.loadingScreen) return this.scene3d.loadingScreen;
|
||||
if (!this.scene3d) return null;
|
||||
try {
|
||||
if (this.scene3d._LoadingScreenOverlayClass) {
|
||||
const ls = new this.scene3d._LoadingScreenOverlayClass(this.scene3d);
|
||||
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: 'loadingComplete', loadingId: id }); },
|
||||
// Задача 05: onHide.
|
||||
() => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingHidden' }); },
|
||||
);
|
||||
this.scene3d.loadingScreen = ls;
|
||||
}
|
||||
} catch (e) { this._log('error', 'loadingScreen init: ' + (e?.message || e)); }
|
||||
return this.scene3d.loadingScreen || null;
|
||||
}
|
||||
|
||||
/** slug → _modelTypeId движка. Встроенные → 'skin_<slug>', character-* как есть. */
|
||||
_resolveSkinTypeId(slug) {
|
||||
if (!slug) return 'character-a';
|
||||
@ -420,6 +661,14 @@ export class GameRuntime {
|
||||
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() {
|
||||
if (this.sandboxes.length > 0) {
|
||||
this._log('info', 'Остановка скриптов');
|
||||
@ -427,6 +676,11 @@ export class GameRuntime {
|
||||
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
|
||||
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 они остаются на сцене
|
||||
// и накапливаются при повторных запусках.
|
||||
@ -445,6 +699,14 @@ export class GameRuntime {
|
||||
this._objectData = {};
|
||||
this._interactables = [];
|
||||
this._activeInteractRef = null;
|
||||
// Задача 14: убрать машины и HUD водителя, чтобы при повторном start
|
||||
// не плодились дубликаты (в плеере start может вызываться повторно).
|
||||
try { this.scene3d?.vehicleManager?.dispose?.(); } catch (e) {}
|
||||
try { this.scene3d?.vehicleHud?.remove?.(); } catch (e) {}
|
||||
this._vehHudShown = false;
|
||||
try { if (this.scene3d?.player) this.scene3d.player._inVehicle = null; } catch (e) {}
|
||||
this._watchedTouchRefs = null;
|
||||
this._watchedClickRefs = null;
|
||||
this._roomState = {};
|
||||
this._seenSessions = null;
|
||||
this._teams = new Map();
|
||||
@ -494,6 +756,10 @@ export class GameRuntime {
|
||||
s?.modelManager?.removeInstance(Number(rest));
|
||||
} else if (kind === 'primitive') {
|
||||
s?.primitiveManager?.removeInstance(Number(rest));
|
||||
} else if (kind === 'usermodel') {
|
||||
// Воксельные модели, наспавненные скриптом (placement) —
|
||||
// удаляем при Stop, иначе placed-объекты остаются. См. studio.
|
||||
s?.userModelManager?.removeInstance(Number(rest));
|
||||
}
|
||||
} catch (e) { /* ignore — объект мог быть уже удалён скриптом */ }
|
||||
}
|
||||
@ -547,7 +813,55 @@ export class GameRuntime {
|
||||
tick(dt) {
|
||||
if (!this._isRunning || this.sandboxes.length === 0) return;
|
||||
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) {
|
||||
// Синк 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
|
||||
const stateForSb = sb.target
|
||||
? { ...state, selfPosition: this._collectSelfPosition(sb.target) }
|
||||
@ -562,6 +876,9 @@ export class GameRuntime {
|
||||
// ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом
|
||||
if (this._interactables.length > 0) this._updateInteractables();
|
||||
|
||||
// Задача 14: HUD водителя.
|
||||
this._updateVehicleHud();
|
||||
|
||||
// Детект смерти игрока — событие game.onPlayerDied (один раз на смерть)
|
||||
const hp = this.scene3d?.player?.hp ?? 100;
|
||||
const aliveNow = hp > 0;
|
||||
@ -731,7 +1048,34 @@ export class GameRuntime {
|
||||
}
|
||||
|
||||
/** Резолв позиции интерактивного объекта (по ref). */
|
||||
_updateVehicleHud() {
|
||||
const player = this.scene3d?.player;
|
||||
const veh = player?._inVehicle;
|
||||
if (veh) {
|
||||
const hud = this._ensureVehicleHud();
|
||||
if (hud) {
|
||||
if (!this._vehHudShown) { try { hud.show(veh.params?.maxSpeed); } catch (e) {} this._vehHudShown = true; }
|
||||
try { hud.update(veh.speed); } catch (e) {}
|
||||
}
|
||||
} else if (this._vehHudShown) {
|
||||
this._vehHudShown = false;
|
||||
try { this.scene3d?.vehicleHud?.remove(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
_ensureVehicleHud() {
|
||||
if (this.scene3d?.vehicleHud) return this.scene3d.vehicleHud;
|
||||
if (!this.scene3d || !this.scene3d._VehicleHudClass) return null;
|
||||
try { this.scene3d.vehicleHud = new this.scene3d._VehicleHudClass(this.scene3d); }
|
||||
catch (e) { this._log('error', 'vehicleHud init: ' + (e?.message || e)); }
|
||||
return this.scene3d.vehicleHud || null;
|
||||
}
|
||||
|
||||
_resolveInteractPos(it) {
|
||||
if (it.ref && it.ref.indexOf('vehicle:') === 0) {
|
||||
const veh = this.scene3d?.vehicleManager?.getById?.(Number(it.ref.slice(8)));
|
||||
return veh ? { x: veh.pos.x, y: veh.pos.y, z: veh.pos.z } : null;
|
||||
}
|
||||
const tgt = this._resolveTweenTarget(it.ref);
|
||||
if (tgt) {
|
||||
const d = tgt.data;
|
||||
@ -749,8 +1093,26 @@ export class GameRuntime {
|
||||
if (!this._activeInteractRef) return;
|
||||
const it = this._interactables.find(x => x.ref === this._activeInteractRef);
|
||||
if (!it || it.key !== String(key).toLowerCase()) return;
|
||||
// событие 'interact' скрипту с target = этим объектом
|
||||
this.routeEvent(it.target, 'interact', {});
|
||||
this._fireInteract(it);
|
||||
}
|
||||
|
||||
_fireInteract(it) {
|
||||
if (!it) return;
|
||||
if (it.isInst) {
|
||||
for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'instInteract', ref: it.ref });
|
||||
} else if (it.target) {
|
||||
this.routeEvent(it.target, 'interact', {});
|
||||
}
|
||||
if (it.ref && it.ref.indexOf('vehicle:') === 0) {
|
||||
const vid = Number(it.ref.slice(8));
|
||||
const veh = this.scene3d?.vehicleManager?.getById?.(vid);
|
||||
const player = this.scene3d?.player;
|
||||
if (veh && player && !player._inVehicle) {
|
||||
player.enterVehicle(veh);
|
||||
player._onVehicleExit = (v) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'vehicleExit', vehicleId: v?.id }); };
|
||||
for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'vehicleEnter', vehicleId: vid });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Прокрутка всех активных твинов на dt секунд. */
|
||||
@ -1135,6 +1497,16 @@ export class GameRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Адресное событие касания/клика КОНКРЕТНОГО объекта по его ref.
|
||||
* Доставляется всем sandbox'ам как globalEvent с type='instTouch'|... + ref;
|
||||
* worker матчит по ref на findOne(x).onTouch/onUntouch/onClick.
|
||||
*/
|
||||
routeInstEvent(ref, type, extra = {}) {
|
||||
if (!ref || !type) return;
|
||||
this.routeGlobalEvent(type, { ref, ...extra });
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомление: моб убит. Рассылается во все скрипты как 'mobKilled'.
|
||||
* Скрипт может подписаться через `game.onMobKilled(fn)`.
|
||||
@ -1302,6 +1674,26 @@ export class GameRuntime {
|
||||
this._log(payload?.level || 'info', payload?.text || '');
|
||||
return;
|
||||
}
|
||||
// inst.watchTouch / inst.watchClick — скрипт подписался на касание/клик
|
||||
// ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch/onClick). Движок начинает
|
||||
// следить за AABB этого объекта в _detectTouchEvents и слать обратно
|
||||
// instTouch/instUntouch (через routeInstEvent).
|
||||
if (cmd === 'inst.watchTouch') {
|
||||
const ref = payload && payload.ref;
|
||||
if (typeof ref === 'string') {
|
||||
if (!this._watchedTouchRefs) this._watchedTouchRefs = new Set();
|
||||
this._watchedTouchRefs.add(ref);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'inst.watchClick') {
|
||||
const ref = payload && payload.ref;
|
||||
if (typeof ref === 'string') {
|
||||
if (!this._watchedClickRefs) this._watchedClickRefs = new Set();
|
||||
this._watchedClickRefs.add(ref);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.teleport') {
|
||||
const player = this.scene3d?.player;
|
||||
if (player && player._pos && payload) {
|
||||
@ -1475,6 +1867,11 @@ export class GameRuntime {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (cmd === 'npc.setAttacking') {
|
||||
this._npcCmd(payload?.ref, (nid) =>
|
||||
this.scene3d?.npcManager?.setAttacking?.(nid, !!payload?.on));
|
||||
return;
|
||||
}
|
||||
if (cmd === 'npc.stop') {
|
||||
this._npcCmd(payload?.ref, (nid) =>
|
||||
this.scene3d?.npcManager?.stopNpc(nid));
|
||||
@ -1547,6 +1944,85 @@ export class GameRuntime {
|
||||
return;
|
||||
}
|
||||
// === Beam / Trail — лучи и следы (Фаза 5.2) ===
|
||||
// === Placement mode (задача 11) ===
|
||||
if (cmd === 'placement.start') {
|
||||
const pm = this._ensurePlacementManager();
|
||||
if (pm && payload) {
|
||||
pm.setCallbacks({
|
||||
onPlace: (res) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeConfirm', ...res }); },
|
||||
onCancel: () => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeCancel' }); },
|
||||
onMove: (mv) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeMove', ...mv }); },
|
||||
});
|
||||
try { pm.start(payload.itemKey, payload.opts || {}); }
|
||||
catch (e) { this._log('error', 'placement.start: ' + (e?.message || e)); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'placement.cancel') { this.scene3d?.placementManager?.cancel(); return; }
|
||||
if (cmd === 'placement.confirm') { this.scene3d?.placementManager?.confirm(); return; }
|
||||
if (cmd === 'placement.rotate') { this.scene3d?.placementManager?.rotate(payload?.deg); return; }
|
||||
if (cmd === 'inventoryUi.create') {
|
||||
const im = this._ensureShopInventory();
|
||||
if (im && payload) {
|
||||
try { im.create(payload, (item) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'invUiSlotClick', key: item.key, item }); }); }
|
||||
catch (e) { this._log('error', 'inventoryUi.create: ' + (e?.message || e)); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'inventoryUi.setBalance') {
|
||||
this.scene3d?.shopInventoryUi?.setBalance(payload?.currency, payload?.amount);
|
||||
this.scene3d?.placementManager?.setBalance(payload?.currency, payload?.amount);
|
||||
return;
|
||||
}
|
||||
if (cmd === 'inventoryUi.remove') { this.scene3d?.shopInventoryUi?.remove(); return; }
|
||||
|
||||
// === Экран загрузки (задача 12) ===
|
||||
if (cmd === 'loading.show') {
|
||||
const ls = this._ensureLoadingScreen();
|
||||
if (ls && payload) {
|
||||
try {
|
||||
const id = ls.show(payload.opts || {});
|
||||
// replyId может отсутствовать (стартовый экран) — всё равно шлём
|
||||
// loadingShown для game.loading.isVisible() (задача 05).
|
||||
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)); }
|
||||
}
|
||||
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.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; }
|
||||
|
||||
// === Damage Floaters (задача 40) ===
|
||||
if (cmd === 'fx.damageFloater') {
|
||||
try {
|
||||
let pos = payload?.position;
|
||||
if (typeof pos === 'string') {
|
||||
if (pos === 'player') {
|
||||
const pl = this.scene3d?.player;
|
||||
const p = pl ? (pl._pos || pl.position || pl.mesh?.position) : null;
|
||||
pos = p ? { x: p.x, y: p.y, z: p.z } : null;
|
||||
} else {
|
||||
const tgt = this._resolveTweenTarget(pos);
|
||||
pos = tgt ? { x: tgt.data.x || 0, y: tgt.data.y || 0, z: tgt.data.z || 0 } : null;
|
||||
}
|
||||
}
|
||||
if (pos) this.scene3d?.floaters?.spawn(pos, payload?.value, payload?.opts || {});
|
||||
} catch (e) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
if (cmd === 'fx.autoMobFloaters') {
|
||||
try {
|
||||
if (this.scene3d?.npcManager) {
|
||||
this.scene3d.npcManager._autoFloater = payload?.enabled
|
||||
? { opts: payload?.opts || {} } : null;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'fx.create') {
|
||||
// payload: { kind: 'beam'|'trail', localRef, ... }
|
||||
const bm = this.scene3d?.beamManager;
|
||||
@ -1709,6 +2185,16 @@ export class GameRuntime {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// === Задача 44: drag-drop инвентарь (invUI) ===
|
||||
if (cmd === 'items.define') { try { this.scene3d?.invUI?.defineItem(payload.def); } catch (e) {} return; }
|
||||
if (cmd === 'inv2.add') { try { this.scene3d?.invUI?.add(payload.itemId, payload.count); this.scene3d?.invUI?.mountHotbar(); } catch (e) {} return; }
|
||||
if (cmd === 'inv2.remove') { try { this.scene3d?.invUI?.remove(payload.itemId, payload.count); } catch (e) {} return; }
|
||||
if (cmd === 'inv2.open') { try { this.scene3d?.invUI?.open(); } catch (e) {} return; }
|
||||
if (cmd === 'inv2.close') { try { this.scene3d?.invUI?.close(); } catch (e) {} return; }
|
||||
if (cmd === 'inv2.toggle') { try { this.scene3d?.invUI?.toggle(); } catch (e) {} return; }
|
||||
if (cmd === 'inv2.sort') { try { this.scene3d?.invUI?.sort(payload.by); } catch (e) {} return; }
|
||||
if (cmd === 'inv2.setActive') { try { this.scene3d?.invUI?.setActiveHotbar(payload.i); } catch (e) {} return; }
|
||||
|
||||
if (cmd === 'inventory.remove') {
|
||||
// payload: { modelTypeId? , name? } — убрать первый совпавший слот.
|
||||
const inv = this.scene3d?.inventory;
|
||||
@ -2180,6 +2666,11 @@ export class GameRuntime {
|
||||
this.scene3d?.player?.cameraReset?.();
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.setInputBlocked') {
|
||||
// Задача 13: блок управления (главное меню — игрок наблюдатель).
|
||||
this.scene3d?.player?.setInputBlocked?.(!!payload?.blocked);
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.setSkinVisible') {
|
||||
const player = this.scene3d?.player;
|
||||
if (player) {
|
||||
@ -2343,13 +2834,93 @@ export class GameRuntime {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// === Небо и атмосфера (задача 16) ===
|
||||
// === Лидерборды и достижения (задача 20) ===
|
||||
if (cmd === 'leaderstats.define') {
|
||||
try { this.scene3d?.leaderstats?.define(payload.name, payload.opts || {}); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'leaderstats.set') {
|
||||
try { this.scene3d?.leaderstats?.set(payload.playerId, payload.name, payload.value); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'leaderstats.add') {
|
||||
try { this.scene3d?.leaderstats?.add(payload.playerId, payload.name, payload.delta); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'achievements.define') {
|
||||
try { this.scene3d?.achievements?.define(payload.list); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'achievements.unlock') {
|
||||
try { this.scene3d?.achievements?.unlock(payload.id, payload.playerId); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'achievements.bindToStat') {
|
||||
try { this.scene3d?.achievements?.bindToStat(payload.id, payload.statName, payload.cond || {}); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'achievements.setButtonVisible') {
|
||||
try { this.scene3d?.achievements?.setButtonVisible(!!payload.visible); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'achievements.openPage') {
|
||||
try { this.scene3d?.achievements?.openPage(); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'scene.setSkybox') {
|
||||
try { this.scene3d?.skybox?.setSkybox(payload?.opts || {}); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'scene.setClouds') {
|
||||
try { this.scene3d?.skybox?.setClouds(payload?.opts || {}); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'scene.setFog') {
|
||||
try { this.scene3d?.skybox?.setFog(payload?.opts || {}); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'scene.skyboxFadeTo') {
|
||||
try { this.scene3d?.skybox?.fadeTo(payload?.opts || {}, payload?.duration || 2); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'scene.skyboxSunDir') {
|
||||
try { this.scene3d?.skybox?.setSunDirection(payload?.dir || {}); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'scene.setScale') {
|
||||
try {
|
||||
const k = Number(payload?.scale);
|
||||
if (!Number.isFinite(k) || k < 0) return;
|
||||
const pm = this.scene3d?.primitiveManager;
|
||||
const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref);
|
||||
const data = (pm && rid != null) ? pm.instances.get(rid) : null;
|
||||
if (data?.mesh) {
|
||||
if (data._worldMatrixFrozen) { try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} data._worldMatrixFrozen = false; }
|
||||
data.mesh.scaling.set(k, k, k);
|
||||
}
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'scene.setColor') {
|
||||
try {
|
||||
const color = payload?.color;
|
||||
if (typeof color !== 'string') return;
|
||||
// Окрашиваемый блок (studs-block): ref 'block:x,y,z' → BlockManager.
|
||||
const ref = payload?.id ?? payload?.ref;
|
||||
if (typeof ref === 'string' && ref.startsWith('block:')) {
|
||||
const parts = ref.slice(6).split(',').map(Number);
|
||||
if (parts.length === 3 && parts.every(Number.isFinite)) {
|
||||
this.scene3d?.blockManager?.setBlockColor?.(parts[0], parts[1], parts[2], color);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const pm = this.scene3d?.primitiveManager;
|
||||
if (!pm) return;
|
||||
const rid = this._resolvePrimitiveId(payload?.id);
|
||||
const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref);
|
||||
const data = rid != null ? pm.instances.get(rid) : null;
|
||||
if (data) {
|
||||
data.color = color;
|
||||
@ -2360,6 +2931,9 @@ export class GameRuntime {
|
||||
if (data.material === 'neon') {
|
||||
data.mesh.material.emissiveColor = c;
|
||||
}
|
||||
if (data.material === 'studs') {
|
||||
data.mesh.material.emissiveColor = new Color3(c.r * 0.45, c.g * 0.45, c.b * 0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@ -2441,6 +3015,8 @@ export class GameRuntime {
|
||||
text: payload.text || 'Взаимодействовать',
|
||||
distance: Number(payload.distance) || 4,
|
||||
key: payload.key || 'e',
|
||||
holdDuration: Number(payload.holdDuration) || 0,
|
||||
isInst: false,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@ -2448,6 +3024,22 @@ export class GameRuntime {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'inst.registerInteract') {
|
||||
try {
|
||||
const ref = payload?.ref;
|
||||
if (ref && !this._interactables.some(it => it.ref === ref)) {
|
||||
this._interactables.push({
|
||||
ref, target: null,
|
||||
text: payload.text || 'Взаимодействовать',
|
||||
distance: Number(payload.distance) || 4,
|
||||
key: payload.key || 'e',
|
||||
holdDuration: Number(payload.holdDuration) || 0,
|
||||
isInst: true,
|
||||
});
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
if (cmd === 'scene.setLabel') {
|
||||
try {
|
||||
const ref = payload?.ref;
|
||||
@ -2455,7 +3047,6 @@ export class GameRuntime {
|
||||
if (typeof ref !== 'string') return;
|
||||
// ленивое создание менеджера меток
|
||||
if (!this.scene3d._labelManager) {
|
||||
const { LabelManager } = require('./LabelManager');
|
||||
this.scene3d._labelManager = new LabelManager(this.scene3d.scene);
|
||||
}
|
||||
const lm = this.scene3d._labelManager;
|
||||
@ -2674,8 +3265,12 @@ export class GameRuntime {
|
||||
}
|
||||
if (cmd === 'scene.setVisible') {
|
||||
try {
|
||||
const kind = payload?.kind;
|
||||
const id = payload?.id;
|
||||
let kind = payload?.kind;
|
||||
let id = payload?.id;
|
||||
if ((kind == null || id == null) && typeof payload?.ref === 'string') {
|
||||
const colon = payload.ref.indexOf(':');
|
||||
if (colon > 0) { kind = payload.ref.slice(0, colon); id = payload.ref.slice(colon + 1); }
|
||||
}
|
||||
const visible = !!payload?.visible;
|
||||
if (id == null) return;
|
||||
if (kind === 'primitive') {
|
||||
@ -2974,6 +3569,10 @@ export class GameRuntime {
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'graphics.set') {
|
||||
try { this.scene3d?.setGraphics?.(payload || {}); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
// === Задача 03: GUI tween ===
|
||||
if (cmd === 'gui.tween') {
|
||||
try {
|
||||
@ -3161,7 +3760,8 @@ export class GameRuntime {
|
||||
if (!this._localToReal) this._localToReal = new Map();
|
||||
try {
|
||||
if (kind === 'block') {
|
||||
this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType);
|
||||
// color — для окрашиваемых блоков (studs-block); иначе игнорируется.
|
||||
this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType, payload.color);
|
||||
// Для блоков ref детерминированный, но запоминаем — чтобы при
|
||||
// Stop удалить заспавненные скриптом блоки (см. stop()).
|
||||
if (ref) this._localToReal.set(ref, ref);
|
||||
@ -3191,6 +3791,7 @@ export class GameRuntime {
|
||||
const opts = payload;
|
||||
const p = this.scene3d?.userModelManager?.addInstance(
|
||||
subType, opts.x, opts.y, opts.z, opts.rotationY || 0,
|
||||
(opts.scale && Number(opts.scale) > 0) ? { scale: Number(opts.scale) } : {},
|
||||
);
|
||||
Promise.resolve(p).then((instId) => {
|
||||
if (instId == null) return;
|
||||
@ -3240,6 +3841,31 @@ export class GameRuntime {
|
||||
}
|
||||
this.scheduleSceneSnapshot();
|
||||
}
|
||||
} else if (kind === 'vehicle') {
|
||||
const opts = payload;
|
||||
const p = this.scene3d?.vehicleManager?.spawn({
|
||||
model: opts.model || 'car-sedan', color: opts.color, name: opts.name,
|
||||
params: opts.params, x: opts.x, y: opts.y, z: opts.z,
|
||||
rotationY: opts.rotationY || 0, ref,
|
||||
});
|
||||
Promise.resolve(p).then((vid) => {
|
||||
if (vid == null) return;
|
||||
const realRef = 'vehicle:' + vid;
|
||||
this._localToReal.set(ref, realRef);
|
||||
this._notifySpawnResolved(ref, realRef);
|
||||
const veh = this.scene3d?.vehicleManager?.getById?.(vid);
|
||||
if (veh && !this._interactables.some(it => it.ref === realRef)) {
|
||||
this._interactables.push({
|
||||
ref: realRef, target: null,
|
||||
text: 'Enter', objectName: veh.name,
|
||||
distance: Math.max(4, veh.half.d + 2), key: 'f',
|
||||
holdDuration: 0.4, isInst: true, isVehicle: true,
|
||||
});
|
||||
}
|
||||
this.scheduleSceneSnapshot();
|
||||
}).catch((err) => {
|
||||
this._log('error', 'spawn vehicle failed: ' + (err?.message || err));
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this._log('error', 'scene.spawn failed: ' + (e?.message || e));
|
||||
@ -3503,6 +4129,11 @@ export class GameRuntime {
|
||||
const id = t.id ?? t.ref;
|
||||
this.scene3d?.primitiveManager?.removeInstance(id);
|
||||
}
|
||||
// Снять interact-подсказку удалённого объекта (иначе «E» висит на пустоте).
|
||||
if (t.kind && (t.ref ?? t.id) != null && Array.isArray(this._interactables)) {
|
||||
const ref = t.kind + ':' + (t.ref ?? t.id);
|
||||
this._interactables = this._interactables.filter(it => it.ref !== ref);
|
||||
}
|
||||
this.scheduleSceneSnapshot();
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
@ -3741,6 +4372,27 @@ export class GameRuntime {
|
||||
} catch (e) {}
|
||||
return h;
|
||||
}
|
||||
/** Задача 20: сохранить прогресс игрока в БД (для leaderstats/achievements). */
|
||||
saveProgress(namespace, data) {
|
||||
const url = this._saveBaseUrl(namespace);
|
||||
if (!url) return;
|
||||
try {
|
||||
fetch(url, {
|
||||
method: 'POST', headers: this._saveAuthHeaders(),
|
||||
body: JSON.stringify({ data }),
|
||||
}).catch(() => {});
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
/** Задача 20: загрузить прогресс из БД (cb(data|null)). */
|
||||
loadProgress(namespace, cb) {
|
||||
const url = this._saveBaseUrl(namespace);
|
||||
if (!url) { cb && cb(null); return; }
|
||||
fetch(url, { headers: this._saveAuthHeaders() })
|
||||
.then(r => r.json())
|
||||
.then(j => cb && cb(j.data ?? null))
|
||||
.catch(() => cb && cb(null));
|
||||
}
|
||||
|
||||
_saveSet(payload) {
|
||||
const url = this._saveBaseUrl(payload?.namespace);
|
||||
if (!url) return;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
370
src/engine/InventoryUI.js
Normal file
370
src/engine/InventoryUI.js
Normal file
@ -0,0 +1,370 @@
|
||||
/**
|
||||
* InventoryUI — drag-drop инвентарь (задача 44): сетка 8×5 + hotbar 9 + стаки +
|
||||
* редкости + ПКМ-меню + tooltip. Самодостаточный DOM-оверлей (как
|
||||
* LoadingScreenOverlay) — крепится к canvas.parentElement, работает в студии и
|
||||
* плеере одинаково.
|
||||
*
|
||||
* Хранит: item-defs (game.items.define), слоты основного инвентаря (GRID),
|
||||
* слоты hotbar (HOTBAR), активный hotbar-слот. Постоянный hotbar внизу HUD;
|
||||
* окно инвентаря по клавише I (toggle).
|
||||
*
|
||||
* API (через game.inventory.* / game.items.*):
|
||||
* game.items.define({id,name,icon,rarity,maxStack,description,value,onUse,tags})
|
||||
* game.inventory.add(itemId, count) / remove / has / count
|
||||
* game.inventory.open() / close() / toggle() / isOpen()
|
||||
* game.inventory.move(from, to) / split(slot, n) / sort(by) / use(slot)
|
||||
* game.inventory.setActiveHotbar(i) / getActiveItem()
|
||||
*
|
||||
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
||||
*/
|
||||
|
||||
const GRID = 40; // 8×5 основной инвентарь
|
||||
const COLS = 8;
|
||||
const HOTBAR = 9;
|
||||
|
||||
const RARITY = {
|
||||
common: { color: '#bbbbbb', label: 'Обычное' },
|
||||
uncommon: { color: '#5cb85c', label: 'Необычное' },
|
||||
rare: { color: '#5bc0de', label: 'Редкое' },
|
||||
epic: { color: '#9b59b6', label: 'Эпическое' },
|
||||
legendary: { color: '#f0ad4e', label: 'Легендарное' },
|
||||
};
|
||||
|
||||
export class InventoryUI {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d;
|
||||
this.defs = new Map(); // itemId → def
|
||||
this.grid = new Array(GRID).fill(null); // {itemId,count}|null
|
||||
this.hotbar = new Array(HOTBAR).fill(null);
|
||||
this.active = 0;
|
||||
this._open = false;
|
||||
this.root = null; this.hotbarRoot = null; this.tooltip = null; this.ctxMenu = null;
|
||||
this._drag = null; // {from:'grid'|'hotbar', idx}
|
||||
this._onChange = [];
|
||||
this._events = { added: [], removed: [], used: [], slot: [] };
|
||||
this._opts = { allowDrop: true, allowSplit: true, showRarity: true };
|
||||
}
|
||||
|
||||
// ── Определения предметов ───────────────────────────────────────────────
|
||||
defineItem(def) {
|
||||
if (!def || typeof def.id !== 'string') return;
|
||||
this.defs.set(def.id, {
|
||||
id: def.id, name: def.name || def.id,
|
||||
icon: def.icon || null, emoji: def.emoji || null,
|
||||
rarity: RARITY[def.rarity] ? def.rarity : 'common',
|
||||
maxStack: Number(def.maxStack) > 0 ? Number(def.maxStack) : 1,
|
||||
description: def.description || '', value: Number(def.value) || 0,
|
||||
tags: Array.isArray(def.tags) ? def.tags : [],
|
||||
onUseEffect: def.onUseEffect || null, // 'heal:50' | 'speed:1.5:5' | null
|
||||
});
|
||||
}
|
||||
_def(id) { return this.defs.get(id) || { id, name: id, rarity: 'common', maxStack: 99, emoji: '📦', icon: null, description: '', value: 0, tags: [] }; }
|
||||
|
||||
// ── Операции ────────────────────────────────────────────────────────────
|
||||
add(itemId, count = 1) {
|
||||
const def = this._def(itemId);
|
||||
let left = count;
|
||||
// 1) долить в существующие стаки (сначала hotbar — он на виду, потом grid)
|
||||
const fill = (arr) => {
|
||||
for (let i = 0; i < arr.length && left > 0; i++) {
|
||||
const s = arr[i];
|
||||
if (s && s.itemId === itemId && s.count < def.maxStack) {
|
||||
const room = def.maxStack - s.count;
|
||||
const take = Math.min(room, left);
|
||||
s.count += take; left -= take;
|
||||
}
|
||||
}
|
||||
};
|
||||
fill(this.hotbar); fill(this.grid);
|
||||
// 2) в пустые слоты (сначала hotbar — собранное видно сразу, потом grid)
|
||||
const place = (arr) => {
|
||||
for (let i = 0; i < arr.length && left > 0; i++) {
|
||||
if (!arr[i]) { const take = Math.min(def.maxStack, left); arr[i] = { itemId, count: take }; left -= take; }
|
||||
}
|
||||
};
|
||||
place(this.hotbar); place(this.grid);
|
||||
const added = count - left;
|
||||
if (added > 0) { this._emit('added', { itemId, count: added }); this._changed(); }
|
||||
return { added, overflow: left };
|
||||
}
|
||||
|
||||
remove(itemId, count = 1) {
|
||||
let left = count;
|
||||
const drain = (arr) => {
|
||||
for (let i = arr.length - 1; i >= 0 && left > 0; i--) {
|
||||
const s = arr[i];
|
||||
if (s && s.itemId === itemId) {
|
||||
const take = Math.min(s.count, left);
|
||||
s.count -= take; left -= take;
|
||||
if (s.count <= 0) arr[i] = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
drain(this.hotbar); drain(this.grid);
|
||||
const removed = count - left;
|
||||
if (removed > 0) { this._emit('removed', { itemId, count: removed }); this._changed(); }
|
||||
return removed;
|
||||
}
|
||||
|
||||
count(itemId) {
|
||||
let n = 0;
|
||||
for (const s of this.grid) if (s && s.itemId === itemId) n += s.count;
|
||||
for (const s of this.hotbar) if (s && s.itemId === itemId) n += s.count;
|
||||
return n;
|
||||
}
|
||||
has(itemId, n = 1) { return this.count(itemId) >= n; }
|
||||
|
||||
/** slot-ref: число 0..39 = grid; строка 'h0'..'h8' = hotbar. */
|
||||
_arrIdx(ref) {
|
||||
if (typeof ref === 'string' && ref[0] === 'h') return { arr: this.hotbar, idx: parseInt(ref.slice(1), 10) };
|
||||
return { arr: this.grid, idx: Number(ref) };
|
||||
}
|
||||
move(from, to) {
|
||||
const a = this._arrIdx(from), b = this._arrIdx(to);
|
||||
if (!a.arr || !b.arr || a.idx == null || b.idx == null) return;
|
||||
if (a.arr === b.arr && a.idx === b.idx) return;
|
||||
const src = a.arr[a.idx], dst = b.arr[b.idx];
|
||||
// merge одинаковых стаков
|
||||
if (src && dst && src.itemId === dst.itemId) {
|
||||
const def = this._def(src.itemId);
|
||||
const room = def.maxStack - dst.count;
|
||||
if (room > 0) {
|
||||
const take = Math.min(room, src.count);
|
||||
dst.count += take; src.count -= take;
|
||||
if (src.count <= 0) a.arr[a.idx] = null;
|
||||
this._changed(); return;
|
||||
}
|
||||
}
|
||||
// swap
|
||||
a.arr[a.idx] = dst; b.arr[b.idx] = src;
|
||||
this._changed();
|
||||
}
|
||||
split(ref, n) {
|
||||
if (!this._opts.allowSplit) return;
|
||||
const { arr, idx } = this._arrIdx(ref);
|
||||
const s = arr[idx]; if (!s || s.count <= 1) return;
|
||||
const take = Math.max(1, Math.min(s.count - 1, n || Math.floor(s.count / 2)));
|
||||
const empty = this.grid.indexOf(null);
|
||||
if (empty < 0) return;
|
||||
s.count -= take; this.grid[empty] = { itemId: s.itemId, count: take };
|
||||
this._changed();
|
||||
}
|
||||
sort(by = 'rarity') {
|
||||
const order = { legendary: 0, epic: 1, rare: 2, uncommon: 3, common: 4 };
|
||||
const all = this.grid.filter(Boolean);
|
||||
all.sort((x, y) => {
|
||||
const dx = this._def(x.itemId), dy = this._def(y.itemId);
|
||||
if (by === 'rarity') return (order[dx.rarity] - order[dy.rarity]) || dx.name.localeCompare(dy.name);
|
||||
if (by === 'name') return dx.name.localeCompare(dy.name);
|
||||
return dx.id.localeCompare(dy.id);
|
||||
});
|
||||
this.grid = all.concat(new Array(GRID - all.length).fill(null));
|
||||
this._changed();
|
||||
}
|
||||
use(ref) {
|
||||
const { arr, idx } = this._arrIdx(ref);
|
||||
const s = arr[idx]; if (!s) return;
|
||||
const def = this._def(s.itemId);
|
||||
let consume = false;
|
||||
if (def.onUseEffect) {
|
||||
const [eff, a, b] = String(def.onUseEffect).split(':');
|
||||
try {
|
||||
if (eff === 'heal') { this.s?.player?.heal?.(Number(a) || 25); consume = true; }
|
||||
else if (eff === 'speed') { this.s?.player?.setSpeed?.(Number(a) || 1.5); consume = true; }
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
this._emit('used', { itemId: s.itemId });
|
||||
if (consume) { s.count -= 1; if (s.count <= 0) arr[idx] = null; this._changed(); }
|
||||
}
|
||||
setActiveHotbar(i) { this.active = Math.max(0, Math.min(HOTBAR - 1, i | 0)); this._renderHotbar(); }
|
||||
getActiveItem() { const s = this.hotbar[this.active]; return s ? { ...s, def: this._def(s.itemId) } : null; }
|
||||
|
||||
onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
|
||||
on(evt, fn) { if (this._events[evt] && typeof fn === 'function') this._events[evt].push(fn); }
|
||||
_emit(evt, data) { for (const fn of (this._events[evt] || [])) { try { fn(data); } catch (e) {} } }
|
||||
_changed() {
|
||||
for (const fn of this._onChange) { try { fn(); } catch (e) {} }
|
||||
this._emit('slot', {});
|
||||
if (this._open) this._renderGrid();
|
||||
this._renderHotbar();
|
||||
}
|
||||
|
||||
// ── DOM: hotbar (постоянный) ───────────────────────────────────────────
|
||||
_parent() { return (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; }
|
||||
mountHotbar() {
|
||||
if (this.hotbarRoot) return;
|
||||
const r = document.createElement('div');
|
||||
r.style.cssText = 'position:absolute;left:50%;bottom:64px;transform:translateX(-50%);z-index:48;display:flex;gap:6px;pointer-events:auto;font-family:Inter,system-ui,sans-serif';
|
||||
this._parent().appendChild(r); this.hotbarRoot = r;
|
||||
this._renderHotbar();
|
||||
}
|
||||
_slotInner(s) {
|
||||
if (!s) return '';
|
||||
const def = this._def(s.itemId);
|
||||
const icon = def.icon ? `<img src="${def.icon}" style="width:80%;height:80%;object-fit:contain;pointer-events:none">`
|
||||
: `<span style="font-size:26px;pointer-events:none">${def.emoji || '📦'}</span>`;
|
||||
const cnt = s.count > 1 ? `<span style="position:absolute;right:3px;bottom:1px;font-size:13px;font-weight:900;color:#fff;text-shadow:0 1px 2px #000">${s.count}</span>` : '';
|
||||
return icon + cnt;
|
||||
}
|
||||
_slotStyle(s, activeBorder) {
|
||||
const rc = (s && this._opts.showRarity) ? RARITY[this._def(s.itemId).rarity].color : 'rgba(255,255,255,0.15)';
|
||||
const border = activeBorder ? '#ffd23a' : rc;
|
||||
return `position:relative;width:52px;height:52px;border-radius:10px;border:2px solid ${border};background:rgba(20,26,40,0.7);display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.3)` + (activeBorder ? ';box-shadow:0 0 12px #ffd23a' : '');
|
||||
}
|
||||
_renderHotbar() {
|
||||
if (!this.hotbarRoot) return;
|
||||
this.hotbarRoot.innerHTML = '';
|
||||
for (let i = 0; i < HOTBAR; i++) {
|
||||
const s = this.hotbar[i];
|
||||
const cell = document.createElement('div');
|
||||
cell.style.cssText = this._slotStyle(s, i === this.active);
|
||||
cell.innerHTML = `<span style="position:absolute;left:3px;top:1px;font-size:11px;color:#9aa3b2;font-weight:700">${i + 1}</span>` + this._slotInner(s);
|
||||
cell.onmouseenter = (e) => this._showTooltip(s, e);
|
||||
cell.onmouseleave = () => this._hideTooltip();
|
||||
cell.onclick = () => { this.setActiveHotbar(i); };
|
||||
cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx('h' + i, e); };
|
||||
this._wireDrag(cell, 'h' + i);
|
||||
this.hotbarRoot.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
// ── DOM: окно инвентаря ─────────────────────────────────────────────────
|
||||
open() { if (this._open) return; this._open = true; this._mountWindow(); }
|
||||
close() { this._open = false; if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; } this._hideTooltip(); this._closeCtx(); }
|
||||
toggle() { this._open ? this.close() : this.open(); }
|
||||
isOpen() { return this._open; }
|
||||
|
||||
_mountWindow() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:absolute;inset:0;z-index:70;background:rgba(8,10,16,0.6);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;font-family:Inter,system-ui,sans-serif;pointer-events:auto';
|
||||
overlay.onclick = (e) => { if (e.target === overlay) this.close(); };
|
||||
const panel = document.createElement('div');
|
||||
panel.style.cssText = 'width:min(640px,94%);background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:20px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)';
|
||||
panel.onclick = (e) => e.stopPropagation();
|
||||
panel.innerHTML =
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">' +
|
||||
'<div style="font-size:22px;font-weight:800">🎒 Инвентарь</div>' +
|
||||
'<div style="display:flex;gap:8px">' +
|
||||
'<button id="_inv_sort" style="height:34px;padding:0 14px;border-radius:9px;background:#2a3550;border:1px solid rgba(255,255,255,0.15);color:#fff;cursor:pointer;font-weight:700">Сорт.</button>' +
|
||||
'<button id="_inv_close" style="width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:18px;cursor:pointer">✕</button>' +
|
||||
'</div></div>' +
|
||||
'<div id="_inv_grid" style="display:grid;grid-template-columns:repeat(' + COLS + ',1fr);gap:6px"></div>' +
|
||||
'<div style="margin:16px 0 6px;font-size:13px;color:#9aa3b2;font-weight:700">Панель быстрого доступа (1-9)</div>' +
|
||||
'<div id="_inv_hb" style="display:grid;grid-template-columns:repeat(' + HOTBAR + ',1fr);gap:6px"></div>';
|
||||
overlay.appendChild(panel); this._parent().appendChild(overlay); this.root = overlay;
|
||||
panel.querySelector('#_inv_close').onclick = () => this.close();
|
||||
panel.querySelector('#_inv_sort').onclick = () => this.sort('rarity');
|
||||
this._gridEl = panel.querySelector('#_inv_grid');
|
||||
this._hbEl = panel.querySelector('#_inv_hb');
|
||||
this._renderGrid();
|
||||
}
|
||||
_renderGrid() {
|
||||
if (!this._gridEl) return;
|
||||
const build = (el, arr, prefix) => {
|
||||
el.innerHTML = '';
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const ref = prefix === 'h' ? 'h' + i : i;
|
||||
const s = arr[i];
|
||||
const cell = document.createElement('div');
|
||||
cell.style.cssText = this._slotStyle(s, prefix === 'h' && i === this.active).replace('52px', '56px');
|
||||
cell.innerHTML = (prefix === 'h' ? `<span style="position:absolute;left:3px;top:1px;font-size:11px;color:#9aa3b2;font-weight:700">${i + 1}</span>` : '') + this._slotInner(s);
|
||||
cell.onmouseenter = (e) => this._showTooltip(s, e);
|
||||
cell.onmouseleave = () => this._hideTooltip();
|
||||
cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx(ref, e); };
|
||||
if (prefix === 'h') cell.onclick = () => this.setActiveHotbar(i);
|
||||
this._wireDrag(cell, ref);
|
||||
el.appendChild(cell);
|
||||
}
|
||||
};
|
||||
build(this._gridEl, this.grid, 'g');
|
||||
if (this._hbEl) build(this._hbEl, this.hotbar, 'h');
|
||||
}
|
||||
|
||||
// ── Drag-drop (HTML5 native) ────────────────────────────────────────────
|
||||
_wireDrag(cell, ref) {
|
||||
cell.draggable = true;
|
||||
cell.addEventListener('dragstart', (e) => {
|
||||
this._drag = ref; cell.style.opacity = '0.4';
|
||||
try { e.dataTransfer.setData('text/plain', String(ref)); e.dataTransfer.effectAllowed = 'move'; } catch (er) {}
|
||||
});
|
||||
cell.addEventListener('dragend', () => { cell.style.opacity = '1'; this._drag = null; });
|
||||
cell.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||
cell.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const from = this._drag;
|
||||
if (from != null && String(from) !== String(ref)) this.move(from, ref);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tooltip ──────────────────────────────────────────────────────────────
|
||||
_showTooltip(s, e) {
|
||||
if (!s) return;
|
||||
this._hideTooltip();
|
||||
const def = this._def(s.itemId), rc = RARITY[def.rarity];
|
||||
const t = document.createElement('div');
|
||||
t.style.cssText = 'position:absolute;z-index:90;max-width:240px;padding:10px 12px;background:rgba(12,16,26,0.96);border:1px solid ' + rc.color + ';border-radius:10px;color:#e8ecf2;font-family:Inter,system-ui,sans-serif;font-size:13px;pointer-events:none;box-shadow:0 6px 20px rgba(0,0,0,0.5)';
|
||||
t.innerHTML =
|
||||
'<div style="font-weight:800;color:' + rc.color + '">' + this._esc(def.name) + '</div>' +
|
||||
'<div style="font-size:11px;color:#9aa3b2;margin:2px 0">' + rc.label + (def.tags.length ? ' · ' + def.tags.join(', ') : '') + '</div>' +
|
||||
(def.description ? '<div style="margin-top:4px">' + this._esc(def.description) + '</div>' : '') +
|
||||
(def.value ? '<div style="margin-top:4px;color:#ffd23a">💰 ' + def.value + '</div>' : '');
|
||||
document.body.appendChild(t);
|
||||
const x = (e && e.clientX) || 0, y = (e && e.clientY) || 0;
|
||||
t.style.left = Math.min(x + 14, window.innerWidth - 250) + 'px';
|
||||
t.style.top = (y + 14) + 'px';
|
||||
this.tooltip = t;
|
||||
}
|
||||
_hideTooltip() { if (this.tooltip) { try { this.tooltip.remove(); } catch (e) {} this.tooltip = null; } }
|
||||
|
||||
// ── ПКМ-меню (Use/Split/Drop) ─────────────────────────────────────────────
|
||||
_openCtx(ref, e) {
|
||||
this._closeCtx();
|
||||
const { arr, idx } = this._arrIdx(ref);
|
||||
const s = arr[idx]; if (!s) return;
|
||||
const m = document.createElement('div');
|
||||
m.style.cssText = 'position:absolute;z-index:95;background:#1a2030;border:1px solid rgba(255,255,255,0.15);border-radius:10px;padding:5px;min-width:140px;font-family:Inter,system-ui,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,0.5)';
|
||||
const item = (label, fn) => {
|
||||
const b = document.createElement('div');
|
||||
b.textContent = label;
|
||||
b.style.cssText = 'padding:8px 12px;border-radius:7px;cursor:pointer;color:#e8ecf2;font-size:14px';
|
||||
b.onmouseenter = () => b.style.background = 'rgba(255,255,255,0.08)';
|
||||
b.onmouseleave = () => b.style.background = 'transparent';
|
||||
b.onclick = () => { fn(); this._closeCtx(); };
|
||||
m.appendChild(b);
|
||||
};
|
||||
item('Использовать', () => this.use(ref));
|
||||
if (this._opts.allowSplit && s.count > 1) item('Разделить', () => this.split(ref, Math.floor(s.count / 2)));
|
||||
if (this._opts.allowDrop && !this._def(s.itemId).tags.includes('quest')) item('Выбросить', () => { arr[idx] = null; this._changed(); });
|
||||
item('Отмена', () => {});
|
||||
document.body.appendChild(m);
|
||||
m.style.left = Math.min((e.clientX || 0), window.innerWidth - 150) + 'px';
|
||||
m.style.top = (e.clientY || 0) + 'px';
|
||||
this.ctxMenu = m;
|
||||
setTimeout(() => { this._ctxCloser = () => this._closeCtx(); window.addEventListener('click', this._ctxCloser, { once: true }); }, 0);
|
||||
}
|
||||
_closeCtx() { if (this.ctxMenu) { try { this.ctxMenu.remove(); } catch (e) {} this.ctxMenu = null; } }
|
||||
|
||||
_esc(str) { return String(str).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
||||
|
||||
// ── Сериализация ──────────────────────────────────────────────────────────
|
||||
serialize() {
|
||||
return { defs: [...this.defs.values()], grid: this.grid, hotbar: this.hotbar, active: this.active, opts: this._opts };
|
||||
}
|
||||
load(data) {
|
||||
if (!data) return;
|
||||
if (Array.isArray(data.defs)) for (const d of data.defs) this.defineItem(d);
|
||||
if (Array.isArray(data.grid)) this.grid = data.grid.slice(0, GRID).concat(new Array(Math.max(0, GRID - data.grid.length)).fill(null));
|
||||
if (Array.isArray(data.hotbar)) this.hotbar = data.hotbar.slice(0, HOTBAR).concat(new Array(Math.max(0, HOTBAR - data.hotbar.length)).fill(null));
|
||||
if (typeof data.active === 'number') this.active = data.active;
|
||||
if (data.opts) this._opts = { ...this._opts, ...data.opts };
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.close();
|
||||
if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; }
|
||||
}
|
||||
resetRuntime() {
|
||||
this.close();
|
||||
if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; }
|
||||
}
|
||||
}
|
||||
@ -1,80 +1,385 @@
|
||||
/**
|
||||
* LabelManager — billboard-метки (текст-плашки) над 3D-объектами.
|
||||
* LabelManager — billboard-плашки (текст-надписи) над 3D-объектами.
|
||||
*
|
||||
* Используется для game.scene.setLabel(ref, text) — имена/HP над
|
||||
* персонажами, врагами, предметами. Метка всегда повёрнута лицом к камере
|
||||
* (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
|
||||
* game.scene.setLabel(ref, text, opts) — имена/HP/таймеры/счётчики над
|
||||
* персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к
|
||||
* камере (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 { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
||||
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
||||
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 {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
// ref-строка объекта → { plane, tex, mat }
|
||||
// ref-строка объекта → { plane, tex, mat, lastKey, opts }
|
||||
this.labels = new Map();
|
||||
this._playerMesh = null; // для maxDistance — задаётся из BabylonScene
|
||||
}
|
||||
|
||||
/** Дать ссылку на меш игрока (для maxDistance-скрытия). */
|
||||
setPlayerMesh(mesh) { this._playerMesh = mesh; }
|
||||
|
||||
/**
|
||||
* Установить/обновить метку над объектом.
|
||||
* ref — ref-строка объекта (от scene.spawn / scene.find).
|
||||
* anchorMesh — Babylon-меш объекта (метка крепится к нему).
|
||||
* text — текст метки.
|
||||
* opts — { color: '#fff', height: 2.5 (м над объектом), size: 1 }
|
||||
* Установить/обновить плашку над объектом.
|
||||
* ref — ref-строка объекта.
|
||||
* anchorMesh — Babylon-меш объекта (плашка крепится к нему).
|
||||
* text — текст (может содержать richText-теги если opts.richText).
|
||||
* opts — см. LABEL_PRESETS + { color, height, size, background,
|
||||
* borderColor, borderWidth, cornerRadius, padding, textStroke,
|
||||
* fontWeight, faceMode, rotationY, attachPoint, preset,
|
||||
* richText, maxDistance }
|
||||
*/
|
||||
setLabel(ref, anchorMesh, text, opts = {}) {
|
||||
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 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);
|
||||
|
||||
// Размер текстуры: чем больше текста — тем шире, чтобы не растягивать.
|
||||
const fontPx = 120;
|
||||
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);
|
||||
tex.updateSamplingMode?.(3); // TRILINEAR
|
||||
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;
|
||||
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}`,
|
||||
{ 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);
|
||||
mat.diffuseTexture = tex;
|
||||
mat.diffuseTexture.hasAlpha = true;
|
||||
mat.emissiveColor = new Color3(1, 1, 1);
|
||||
mat.diffuseColor = new Color3(0, 0, 0);
|
||||
mat.disableLighting = true;
|
||||
// Геометрия двусторонняя (DOUBLESIDE) с прямыми backUVs — culling можно
|
||||
// включить, дублей нет; текст читается с обеих сторон без зеркала.
|
||||
mat.backFaceCulling = false;
|
||||
mat.disableDepthWrite = true;
|
||||
mat.useAlphaFromDiffuseTexture = true;
|
||||
plane.material = mat;
|
||||
plane.billboardMode = 7; // всегда лицом к камере
|
||||
plane.renderingGroupId = 1; // поверх геометрии
|
||||
plane.renderingGroupId = 1;
|
||||
plane.isPickable = false;
|
||||
// Крепим к объекту: метка висит над ним и двигается вместе с ним.
|
||||
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) {
|
||||
const rec = this.labels.get(ref);
|
||||
if (!rec) return;
|
||||
@ -84,7 +389,7 @@ export class LabelManager {
|
||||
this.labels.delete(ref);
|
||||
}
|
||||
|
||||
/** Удалить все метки (при выходе из Play). */
|
||||
/** Удалить все плашки (при выходе из Play). */
|
||||
clearAll() {
|
||||
for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
|
||||
}
|
||||
|
||||
255
src/engine/LeaderstatsManager.js
Normal file
255
src/engine/LeaderstatsManager.js
Normal file
@ -0,0 +1,255 @@
|
||||
/**
|
||||
* LeaderstatsManager — лидерборды (leaderstats) как в Roblox (задача 20).
|
||||
*
|
||||
* Хранит статы игроков и рендерит HUD-таблицу в правом-верхнем углу.
|
||||
* В одиночной игре — один игрок ('me'). Поля сортируются по primary-стату.
|
||||
*
|
||||
* API (через game.leaderstats.*):
|
||||
* define(name, opts) — зарегистрировать стат (initial/format/icon/color/primary)
|
||||
* set(playerId, name, value) / add — изменить стат игрока
|
||||
* get(playerId, name) — прочитать
|
||||
* me.set/add(name, value) — для текущего игрока
|
||||
* onChange(fn) — подписка (для bindToStat достижений)
|
||||
*
|
||||
* format: 'number' (42) | 'time' (mm:ss) | 'short' (1.2K).
|
||||
* DOM-оверлей крепится к canvas.parentElement (как LoadingScreenOverlay).
|
||||
*
|
||||
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
||||
*/
|
||||
|
||||
function fmt(value, format) {
|
||||
const v = Number(value) || 0;
|
||||
if (format === 'time') {
|
||||
const m = Math.floor(v / 60), s = Math.floor(v % 60);
|
||||
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
if (format === 'short') {
|
||||
if (v >= 1e9) return (v / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
|
||||
if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||
if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
|
||||
return String(Math.round(v));
|
||||
}
|
||||
return String(Math.round(v));
|
||||
}
|
||||
|
||||
export class LeaderstatsManager {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d;
|
||||
this._defs = []; // [{name, initial, format, icon, color, primary}]
|
||||
this._stats = new Map(); // playerId → Map(name → value)
|
||||
this._players = new Map(); // playerId → displayName
|
||||
this._onChange = [];
|
||||
this.root = null;
|
||||
this._dirty = false;
|
||||
this._meId = 'me';
|
||||
}
|
||||
|
||||
/** id текущего игрока (одиночка = 'me'). */
|
||||
_resolveMe() {
|
||||
try {
|
||||
const p = this.s?.gameRuntime?._players?.me;
|
||||
if (p && p.id != null) return String(p.id);
|
||||
} catch (e) { /* ignore */ }
|
||||
return 'me';
|
||||
}
|
||||
|
||||
define(name, opts = {}) {
|
||||
if (typeof name !== 'string' || !name) return;
|
||||
if (this._defs.some(d => d.name === name)) return; // уже есть
|
||||
this._defs.push({
|
||||
name,
|
||||
initial: Number(opts.initial) || 0,
|
||||
format: opts.format || 'number',
|
||||
icon: opts.icon || '',
|
||||
color: opts.color || '#e8ecf2',
|
||||
primary: !!opts.primary,
|
||||
});
|
||||
// Если ни один не primary — первый становится primary.
|
||||
if (!this._defs.some(d => d.primary)) this._defs[0].primary = true;
|
||||
// Инициализируем стат у уже известных игроков.
|
||||
for (const [pid] of this._players) this._ensure(pid, name);
|
||||
this._ensureMe();
|
||||
if (this.s?._isPlaying) this._mount(); // HUD только в Play
|
||||
this._dirty = true;
|
||||
}
|
||||
|
||||
_ensureMe() {
|
||||
const me = this._resolveMe();
|
||||
this._meId = me;
|
||||
if (!this._players.has(me)) {
|
||||
let nm = 'Ты';
|
||||
try { nm = this.s?.gameRuntime?._players?.me?.name || 'Ты'; } catch (e) {}
|
||||
this._players.set(me, nm);
|
||||
}
|
||||
for (const d of this._defs) this._ensure(me, d.name);
|
||||
}
|
||||
|
||||
_ensure(pid, name) {
|
||||
if (!this._stats.has(pid)) this._stats.set(pid, new Map());
|
||||
const m = this._stats.get(pid);
|
||||
if (!m.has(name)) {
|
||||
const def = this._defs.find(d => d.name === name);
|
||||
m.set(name, def ? def.initial : 0);
|
||||
}
|
||||
}
|
||||
|
||||
set(playerId, name, value) {
|
||||
const pid = playerId == null ? this._resolveMe() : String(playerId);
|
||||
if (!this._players.has(pid)) this._players.set(pid, pid === this._resolveMe() ? 'Ты' : ('Игрок ' + pid));
|
||||
this._ensure(pid, name);
|
||||
const m = this._stats.get(pid);
|
||||
const old = m.get(name);
|
||||
const nv = Number(value) || 0;
|
||||
if (old === nv) return;
|
||||
m.set(name, nv);
|
||||
this._dirty = true;
|
||||
this._flash = this._flash || {};
|
||||
this._flash[pid + '|' + name] = performance.now ? performance.now() : Date.now();
|
||||
for (const fn of this._onChange) {
|
||||
try { fn(pid, name, nv, old); } catch (e) { /* ignore */ }
|
||||
}
|
||||
// Сохраняем статы текущего игрока в БД (дебаунс 1с) — между сессиями.
|
||||
if (pid === this._resolveMe()) this._scheduleSave();
|
||||
}
|
||||
|
||||
_scheduleSave() {
|
||||
if (this._saveTimer) clearTimeout(this._saveTimer);
|
||||
this._saveTimer = setTimeout(() => {
|
||||
this._saveTimer = null;
|
||||
try {
|
||||
const me = this._resolveMe();
|
||||
const m = this._stats.get(me);
|
||||
if (!m) return;
|
||||
const obj = {}; for (const [k, v] of m) obj[k] = v;
|
||||
this.s?.gameRuntime?.saveProgress?.('_leaderstats', obj);
|
||||
} catch (e) { /* ignore */ }
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/** Загрузить статы текущего игрока из БД (вызывать при Play, после define). */
|
||||
loadFromDB() {
|
||||
const rt = this.s?.gameRuntime;
|
||||
if (!rt || !rt.loadProgress) return;
|
||||
rt.loadProgress('_leaderstats', (data) => {
|
||||
if (data && typeof data === 'object') {
|
||||
const me = this._resolveMe();
|
||||
for (const name of Object.keys(data)) {
|
||||
// Применяем только к зарегистрированным статам, без повторного сейва.
|
||||
if (this._defs.some(d => d.name === name)) {
|
||||
this._ensure(me, name);
|
||||
this._stats.get(me).set(name, Number(data[name]) || 0);
|
||||
}
|
||||
}
|
||||
this._dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
add(playerId, name, delta) {
|
||||
const pid = playerId == null ? this._resolveMe() : String(playerId);
|
||||
this._ensure(pid, name);
|
||||
const cur = this._stats.get(pid).get(name) || 0;
|
||||
this.set(pid, name, cur + (Number(delta) || 0));
|
||||
}
|
||||
|
||||
get(playerId, name) {
|
||||
const pid = playerId == null ? this._resolveMe() : String(playerId);
|
||||
const m = this._stats.get(pid);
|
||||
return m ? (m.get(name) || 0) : 0;
|
||||
}
|
||||
|
||||
onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
|
||||
|
||||
/** Активны ли leaderstats (хотя бы один define). */
|
||||
get active() { return this._defs.length > 0; }
|
||||
|
||||
// ── HUD ──────────────────────────────────────────────────────────────
|
||||
_mount() {
|
||||
if (this.root) return;
|
||||
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||
const root = document.createElement('div');
|
||||
root.style.cssText = [
|
||||
'position:absolute', 'top:14px', 'right:14px', 'z-index:50',
|
||||
'min-width:230px', 'max-width:300px',
|
||||
'background:rgba(18,22,33,0.55)', 'backdrop-filter:blur(8px)',
|
||||
'-webkit-backdrop-filter:blur(8px)',
|
||||
'border:1px solid rgba(255,255,255,0.12)', 'border-radius:12px',
|
||||
'padding:10px 12px', 'font-family:Inter,system-ui,sans-serif',
|
||||
'color:#e8ecf2', 'pointer-events:none', 'user-select:none',
|
||||
'box-shadow:0 6px 24px rgba(0,0,0,0.35)',
|
||||
].join(';');
|
||||
parent.appendChild(root);
|
||||
this.root = root;
|
||||
this._sortBy = null; // имя стата для сортировки (null = primary)
|
||||
}
|
||||
|
||||
/** Вызывать каждый кадр (рендер при изменениях + затухание flash). */
|
||||
tick() {
|
||||
if (!this.active) return;
|
||||
if (!this.root) { this._mount(); this._dirty = true; }
|
||||
if (this._dirty) { this._render(); this._dirty = false; }
|
||||
// flash затухает ~600мс — перерисуем пока активен.
|
||||
if (this._flash && Object.keys(this._flash).length) {
|
||||
const now = performance.now ? performance.now() : Date.now();
|
||||
let any = false;
|
||||
for (const k of Object.keys(this._flash)) {
|
||||
if (now - this._flash[k] < 600) any = true; else delete this._flash[k];
|
||||
}
|
||||
if (any) this._render();
|
||||
}
|
||||
}
|
||||
|
||||
_render() {
|
||||
const defs = this._defs;
|
||||
if (!defs.length) { this.root.innerHTML = ''; return; }
|
||||
const sortStat = this._sortBy || (defs.find(d => d.primary) || defs[0]).name;
|
||||
const me = this._resolveMe();
|
||||
// Строки игроков, сортировка по убыванию sortStat, топ-10.
|
||||
const rows = [...this._players.keys()]
|
||||
.map(pid => ({ pid, name: this._players.get(pid) }))
|
||||
.sort((a, b) => (this.get(b.pid, sortStat) - this.get(a.pid, sortStat)))
|
||||
.slice(0, 10);
|
||||
const now = performance.now ? performance.now() : Date.now();
|
||||
|
||||
let html = '<div style="display:flex;align-items:center;gap:6px;font-weight:800;font-size:13px;margin-bottom:8px;color:#ffd23a">🏆 Таблица лидеров</div>';
|
||||
// Шапка столбцов.
|
||||
html += '<div style="display:grid;grid-template-columns:1fr ' + defs.map(() => 'auto').join(' ') + ';gap:8px;font-size:11px;color:#9aa3b2;font-weight:700;padding-bottom:4px;border-bottom:1px solid rgba(255,255,255,0.1)">';
|
||||
html += '<span>Игрок</span>';
|
||||
for (const d of defs) html += '<span style="text-align:right;color:' + d.color + '">' + (d.icon ? d.icon + ' ' : '') + d.name + '</span>';
|
||||
html += '</div>';
|
||||
// Строки.
|
||||
for (const r of rows) {
|
||||
const mine = r.pid === me;
|
||||
html += '<div style="display:grid;grid-template-columns:1fr ' + defs.map(() => 'auto').join(' ') + ';gap:8px;font-size:13px;padding:4px 2px;border-radius:6px;' + (mine ? 'background:rgba(51,87,255,0.22);' : '') + '">';
|
||||
html += '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:' + (mine ? '800' : '600') + '">' + this._esc(r.name) + '</span>';
|
||||
for (const d of defs) {
|
||||
const flashed = this._flash && (now - (this._flash[r.pid + '|' + d.name] || 0) < 600);
|
||||
const col = flashed ? '#ffe066' : d.color;
|
||||
html += '<span style="text-align:right;font-weight:700;color:' + col + ';transition:color .2s">' + fmt(this.get(r.pid, d.name), d.format) + '</span>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
this.root.innerHTML = html;
|
||||
}
|
||||
|
||||
_esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
||||
|
||||
/** Сериализация определений в project_data. */
|
||||
serialize() {
|
||||
return this._defs.map(d => ({ ...d }));
|
||||
}
|
||||
load(arr) {
|
||||
if (!Array.isArray(arr)) return;
|
||||
for (const d of arr) this.define(d.name, d);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; }
|
||||
this._stats.clear(); this._players.clear(); this._onChange = [];
|
||||
}
|
||||
/** Сброс рантайм-значений при exitPlayMode (определения остаются). */
|
||||
resetRuntime() {
|
||||
this._stats.clear(); this._players.clear(); this._flash = {};
|
||||
if (this.root) this.root.innerHTML = '';
|
||||
}
|
||||
}
|
||||
557
src/engine/LoadingScreenOverlay.js
Normal file
557
src/engine/LoadingScreenOverlay.js
Normal file
@ -0,0 +1,557 @@
|
||||
/**
|
||||
* LoadingScreenOverlay — внутриигровой экран загрузки (задача 12).
|
||||
*
|
||||
* Программный mid-game transition: чёрный фон (fadeIn/Out), картинка-превью
|
||||
* (cover) по центру, прогресс-бар (жёлтый по серому) + процент, спиннер
|
||||
* «ЗАГРУЗКА» справа-снизу (CSS keyframes), кнопка «ПРОПУСТИТЬ» по центру-снизу
|
||||
* (появляется через 0.5с — анти-accidental), логотип игры слева-снизу.
|
||||
*
|
||||
* Вызывается из скрипта через game.loading.show(opts) / game.loading.transition(opts).
|
||||
* Покрывает и кейс задачи 05 (начальный экран при входе).
|
||||
*
|
||||
* Реализация — лёгкий DOM-оверлей поверх canvas (как ShopInventoryUi), а не
|
||||
* Babylon-GUI: фиксированный layout с прогресс-баром/спиннером/кнопкой на HTML
|
||||
* делается быстрее и доступнее. Класс самодостаточен: хранит state, рисует DOM,
|
||||
* имеет tick(dt) для fade-фаз и авто-duration (в отличие от ShopInventoryUi,
|
||||
* которому tick не нужен).
|
||||
*
|
||||
* Один активный экран одновременно: повторный show() мгновенно закрывает
|
||||
* предыдущий (как ModalManager) — нет утечки overlay'ев при нескольких
|
||||
* transition подряд.
|
||||
*
|
||||
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
|
||||
*/
|
||||
|
||||
const EASE_OUT = (t) => 1 - Math.pow(1 - t, 3);
|
||||
|
||||
// CSS спиннера вставляем один раз в <head> (keyframes нельзя инлайнить в style).
|
||||
let _spinCssInjected = false;
|
||||
function injectSpinnerCss() {
|
||||
if (_spinCssInjected) return;
|
||||
_spinCssInjected = true;
|
||||
try {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'kbn-loading-spin-css';
|
||||
style.textContent =
|
||||
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
|
||||
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
|
||||
// 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);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export class LoadingScreenOverlay {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d;
|
||||
this.root = null;
|
||||
this._st = null; // state активного экрана или null
|
||||
this._idSeq = 0;
|
||||
// Мост наружу (GameRuntime подписывает) — id-based колбэки.
|
||||
this._onSkipCb = null; // (id) => void
|
||||
this._onCompleteCb = null; // (id) => void
|
||||
this._onHideCb = null; // () => void — задача 05 (game.loading.onHide)
|
||||
this._parallaxHandler = null;
|
||||
// DOM-ссылки активного экрана:
|
||||
this._els = null;
|
||||
}
|
||||
|
||||
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
|
||||
setBridge(onSkip, onComplete, onHide) {
|
||||
this._onSkipCb = onSkip;
|
||||
this._onCompleteCb = onComplete;
|
||||
if (onHide) this._onHideCb = onHide;
|
||||
}
|
||||
|
||||
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
|
||||
_cfg() {
|
||||
return (this.s && this.s._loadingConfig) || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать экран загрузки. Возвращает числовой id (для матчинга команд).
|
||||
* opts — см. 12_ingame_loading.md §2.2.
|
||||
*/
|
||||
show(opts) {
|
||||
injectSpinnerCss();
|
||||
opts = opts && typeof opts === 'object' ? opts : {};
|
||||
// Один активный — мгновенно убрать предыдущий.
|
||||
if (this._st) this._instantClose();
|
||||
|
||||
const cfg = this._cfg();
|
||||
const accent = opts.progressColor || cfg.accentColor || '#ffc020';
|
||||
const st = {
|
||||
id: ++this._idSeq,
|
||||
// Фон
|
||||
bgColor: opts.bgColor || '#000',
|
||||
bgOpacity: opts.bgOpacity != null ? Number(opts.bgOpacity) : 1,
|
||||
fadeIn: opts.fadeIn != null ? Number(opts.fadeIn) : 0.3,
|
||||
fadeOut: opts.fadeOut != null ? Number(opts.fadeOut) : 0.3,
|
||||
// Прогресс
|
||||
progressBar: opts.progressBar !== false,
|
||||
progressColor: accent,
|
||||
progressBgColor: opts.progressBgColor || '#444',
|
||||
percentText: opts.percentText !== false,
|
||||
progress: Math.max(0, Math.min(1, Number(opts.initialProgress) || 0)),
|
||||
duration: Number.isFinite(opts.duration) && opts.duration > 0 ? Number(opts.duration) : null,
|
||||
manualProgress: false,
|
||||
// Спиннер
|
||||
spinner: opts.spinner != null ? !!opts.spinner : (cfg.defaultSpinner !== false),
|
||||
spinnerText: opts.spinnerText != null ? String(opts.spinnerText) : 'ЗАГРУЗКА',
|
||||
// Кнопка Пропустить
|
||||
skipButton: opts.skipButton != null ? !!opts.skipButton : !!cfg.defaultSkipButton,
|
||||
skipButtonText: opts.skipButtonText != null ? String(opts.skipButtonText) : 'ПРОПУСТИТЬ',
|
||||
skipButtonColor: opts.skipButtonColor || accent,
|
||||
skipShown: false,
|
||||
// Логотип
|
||||
logo: opts.logo || cfg.logo || (this.s && this.s._projectThumbnail) || null,
|
||||
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
|
||||
// Текст под картинкой
|
||||
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,
|
||||
pauseSimulation: opts.pauseSimulation !== false,
|
||||
// Жизненный цикл
|
||||
phase: 'in', // 'in' | 'visible' | 'out'
|
||||
alpha: 0,
|
||||
elapsed: 0, // время с момента полного появления (для duration/skip)
|
||||
fadeT: 0,
|
||||
completed: false, // onComplete уже вызывался
|
||||
};
|
||||
this._st = st;
|
||||
this._build(st, opts.cover);
|
||||
|
||||
// Блок ввода + пауза симуляции.
|
||||
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(true); } catch { /* ignore */ } }
|
||||
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = true; } catch { /* ignore */ } }
|
||||
|
||||
return st.id;
|
||||
}
|
||||
|
||||
/** Резолв cover в URL/dataURL. */
|
||||
_resolveCover(cover) {
|
||||
if (!cover) return null;
|
||||
if (typeof cover === 'string') {
|
||||
// asset:xxx → пробуем через AssetManager, иначе как прямой URL.
|
||||
try {
|
||||
const r = this.s.assetManager?.resolveUrl?.(cover);
|
||||
if (r) return r;
|
||||
} catch { /* ignore */ }
|
||||
return cover;
|
||||
}
|
||||
if (typeof cover === 'object') {
|
||||
if (cover.sceneSnapshot) {
|
||||
try {
|
||||
const canvas = this.s.engine?.getRenderingCanvas?.();
|
||||
if (canvas) return canvas.toDataURL('image/jpeg', 0.72);
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
if (cover.url) return cover.url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_build(st, cover) {
|
||||
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'kbn-loading';
|
||||
root.style.cssText =
|
||||
'position:absolute;inset:0;z-index:60;overflow:hidden;' +
|
||||
'display:flex;align-items:center;justify-content:center;' +
|
||||
'opacity:0;transition:none;font-family:system-ui,"Segoe UI",sans-serif;' +
|
||||
`background:${st.bgColor};`;
|
||||
// фон с настраиваемой непрозрачностью — отдельный слой, чтобы контент был непрозрачным
|
||||
// (используем opacity всего root для fade, а bgOpacity — через rgba фон):
|
||||
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
|
||||
|
||||
// --- Фоновый слой (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);
|
||||
// Режим карточки места (задача 05): квадрат + название + автор под ней.
|
||||
const hasPlaceCard = !!(st.placeName || st.studioName);
|
||||
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 =
|
||||
'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;' +
|
||||
'background-color:#1a1f2b;margin-bottom:140px;';
|
||||
}
|
||||
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');
|
||||
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 =
|
||||
'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);';
|
||||
}
|
||||
textEl.textContent = st.text || '';
|
||||
|
||||
// --- Прогресс-бар ---
|
||||
const barWrap = document.createElement('div');
|
||||
barWrap.style.cssText =
|
||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:120px;' +
|
||||
`width:min(74vw,1180px);height:14px;border-radius:8px;background:${st.progressBgColor};` +
|
||||
'overflow:hidden;box-shadow:inset 0 1px 3px rgba(0,0,0,0.5);' +
|
||||
(st.progressBar ? '' : 'display:none;');
|
||||
const bar = document.createElement('div');
|
||||
bar.style.cssText =
|
||||
`height:100%;width:${(st.progress * 100).toFixed(1)}%;border-radius:8px;` +
|
||||
`background:linear-gradient(90deg,${st.progressColor},${this._lighten(st.progressColor)});` +
|
||||
'transition:width 0.12s linear;box-shadow:0 0 8px rgba(255,200,40,0.4);';
|
||||
barWrap.appendChild(bar);
|
||||
|
||||
// --- Процент ---
|
||||
const percent = document.createElement('div');
|
||||
percent.style.cssText =
|
||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:74px;' +
|
||||
`color:${st.progressColor};font-size:30px;font-weight:800;text-shadow:0 2px 4px rgba(0,0,0,0.5);` +
|
||||
(st.percentText ? '' : 'display:none;');
|
||||
percent.textContent = `${Math.round(st.progress * 100)}%`;
|
||||
|
||||
// --- Кнопка Пропустить ---
|
||||
const skipBtn = document.createElement('button');
|
||||
skipBtn.type = 'button';
|
||||
skipBtn.textContent = st.skipButtonText;
|
||||
skipBtn.style.cssText =
|
||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:18px;' +
|
||||
'min-width:260px;padding:14px 36px;border:none;border-radius:12px;cursor:pointer;' +
|
||||
`background:linear-gradient(180deg,${this._lighten(st.skipButtonColor)},${st.skipButtonColor});` +
|
||||
'color:#3a2a00;font-size:18px;font-weight:800;letter-spacing:0.5px;' +
|
||||
'box-shadow:0 6px 16px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.4);' +
|
||||
'opacity:0;transition:opacity 0.25s,transform 0.1s;pointer-events:none;' +
|
||||
(st.skipButton ? '' : 'display:none;');
|
||||
skipBtn.onmouseenter = () => { skipBtn.style.transform = 'translateX(-50%) translateY(-2px)'; };
|
||||
skipBtn.onmouseleave = () => { skipBtn.style.transform = 'translateX(-50%)'; };
|
||||
skipBtn.onclick = () => {
|
||||
if (skipBtn.style.pointerEvents === 'none') return;
|
||||
this._fireSkip();
|
||||
};
|
||||
|
||||
// --- Логотип (слева снизу) ---
|
||||
const logo = document.createElement('div');
|
||||
logo.style.cssText =
|
||||
'position:absolute;left:28px;bottom:24px;max-width:200px;max-height:110px;' +
|
||||
`border-radius:${st.logoCornerRadius}px;background-size:contain;background-repeat:no-repeat;` +
|
||||
'background-position:left bottom;width:200px;height:90px;';
|
||||
if (st.logo) logo.style.backgroundImage = `url("${st.logo}")`;
|
||||
else logo.style.display = 'none';
|
||||
|
||||
// --- Спиннер + «ЗАГРУЗКА» (справа снизу) ---
|
||||
const spinWrap = document.createElement('div');
|
||||
spinWrap.style.cssText =
|
||||
'position:absolute;right:32px;bottom:32px;display:flex;align-items:center;gap:14px;' +
|
||||
'color:#fff;font-size:20px;font-weight:700;letter-spacing:1px;' +
|
||||
(st.spinner ? '' : 'display:none;');
|
||||
const spinTxt = document.createElement('span');
|
||||
spinTxt.textContent = st.spinnerText;
|
||||
const spinCircle = document.createElement('span');
|
||||
spinCircle.className = 'kbn-ls-spinner';
|
||||
spinCircle.style.cssText =
|
||||
`display:inline-block;width:28px;height:28px;border:3px solid rgba(255,255,255,0.25);` +
|
||||
`border-top-color:${st.progressColor};border-radius:50%;`;
|
||||
spinWrap.appendChild(spinTxt);
|
||||
spinWrap.appendChild(spinCircle);
|
||||
|
||||
// Центральная композиция (карточка + название + автор + текст) — в content.
|
||||
content.appendChild(coverImg);
|
||||
content.appendChild(placeEl);
|
||||
content.appendChild(studioRow);
|
||||
content.appendChild(textEl);
|
||||
root.appendChild(content);
|
||||
// Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content).
|
||||
root.appendChild(barWrap);
|
||||
root.appendChild(percent);
|
||||
root.appendChild(skipBtn);
|
||||
root.appendChild(logo);
|
||||
root.appendChild(spinWrap);
|
||||
parent.appendChild(root);
|
||||
|
||||
this.root = root;
|
||||
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(dt) {
|
||||
const st = this._st;
|
||||
if (!st || !this._els) return;
|
||||
dt = Number(dt) || 0;
|
||||
|
||||
if (st.phase === 'in') {
|
||||
st.fadeT += dt;
|
||||
const d = st.fadeIn > 0 ? st.fadeIn : 0.0001;
|
||||
st.alpha = Math.min(1, EASE_OUT(st.fadeT / d));
|
||||
this._els.root.style.opacity = String(st.alpha);
|
||||
if (st.fadeT >= d) { st.phase = 'visible'; st.alpha = 1; st.fadeT = 0; }
|
||||
} else if (st.phase === 'visible') {
|
||||
st.elapsed += dt;
|
||||
// Кнопка Пропустить — появляется через 0.5с.
|
||||
if (!st.skipShown && st.skipButton && st.elapsed >= 0.5) {
|
||||
st.skipShown = true;
|
||||
this._els.skipBtn.style.opacity = '1';
|
||||
this._els.skipBtn.style.pointerEvents = 'auto';
|
||||
}
|
||||
// Авто-duration (если не было ручного setProgress).
|
||||
if (st.duration && !st.manualProgress) {
|
||||
st.progress = Math.min(1, st.elapsed / st.duration);
|
||||
this._applyProgress(st);
|
||||
if (st.progress >= 1 && !st.completed) {
|
||||
st.completed = true;
|
||||
this._fireComplete();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
} else if (st.phase === 'out') {
|
||||
st.fadeT += dt;
|
||||
const d = st.fadeOut > 0 ? st.fadeOut : 0.0001;
|
||||
st.alpha = Math.max(0, 1 - EASE_OUT(st.fadeT / d));
|
||||
this._els.root.style.opacity = String(st.alpha);
|
||||
if (st.fadeT >= d) this._teardown();
|
||||
}
|
||||
}
|
||||
|
||||
_applyProgress(st) {
|
||||
if (!this._els) return;
|
||||
this._els.bar.style.width = `${(st.progress * 100).toFixed(1)}%`;
|
||||
this._els.percent.textContent = `${Math.round(st.progress * 100)}%`;
|
||||
}
|
||||
|
||||
setProgress(value) {
|
||||
const st = this._st;
|
||||
if (!st) return;
|
||||
st.manualProgress = true;
|
||||
st.progress = Math.max(0, Math.min(1, Number(value) || 0));
|
||||
this._applyProgress(st);
|
||||
if (st.progress >= 1 && !st.completed) {
|
||||
st.completed = true;
|
||||
this._fireComplete();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
setText(text) {
|
||||
const st = this._st;
|
||||
if (!st || !this._els) return;
|
||||
st.text = String(text == null ? '' : text);
|
||||
this._els.textEl.textContent = st.text;
|
||||
}
|
||||
|
||||
setCover(cover) {
|
||||
if (!this._st || !this._els) return;
|
||||
const url = this._resolveCover(cover);
|
||||
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). */
|
||||
close() {
|
||||
const st = this._st;
|
||||
if (!st) return;
|
||||
if (st.phase !== 'out') { st.phase = 'out'; st.fadeT = 0; }
|
||||
}
|
||||
|
||||
_fireSkip() {
|
||||
const st = this._st;
|
||||
if (!st) return;
|
||||
if (this._onSkipCb) { try { this._onSkipCb(st.id); } catch { /* ignore */ } }
|
||||
this.close();
|
||||
}
|
||||
|
||||
_fireComplete() {
|
||||
const st = this._st;
|
||||
if (!st) return;
|
||||
if (this._onCompleteCb) { try { this._onCompleteCb(st.id); } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
/** Мгновенно убрать без fade (повторный show / выход из Play). */
|
||||
_instantClose() {
|
||||
this._teardown();
|
||||
}
|
||||
|
||||
_teardown() {
|
||||
// Снять блок ввода / паузу.
|
||||
const st = this._st;
|
||||
if (st) {
|
||||
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 */ } }
|
||||
}
|
||||
// Снять 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 */ } }
|
||||
this.root = null;
|
||||
this._els = null;
|
||||
this._st = null;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._instantClose();
|
||||
this._onSkipCb = null;
|
||||
this._onCompleteCb = null;
|
||||
}
|
||||
|
||||
// --- утилиты цвета ---
|
||||
_lighten(hex) {
|
||||
try {
|
||||
const h = String(hex).replace('#', '');
|
||||
if (h.length !== 6) return hex;
|
||||
const r = Math.min(255, parseInt(h.slice(0, 2), 16) + 40);
|
||||
const g = Math.min(255, parseInt(h.slice(2, 4), 16) + 40);
|
||||
const b = Math.min(255, parseInt(h.slice(4, 6), 16) + 40);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
} catch { return hex; }
|
||||
}
|
||||
|
||||
_bgRgba(hex, opacity) {
|
||||
try {
|
||||
const h = String(hex).replace('#', '');
|
||||
if (h.length !== 6) return hex;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
const a = opacity != null ? Math.max(0, Math.min(1, opacity)) : 1;
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
} catch { return hex; }
|
||||
}
|
||||
}
|
||||
@ -314,9 +314,10 @@ export class ModelManager {
|
||||
r.getChildMeshes(false).forEach(m => {
|
||||
m.isPickable = true;
|
||||
m.metadata = { isModel: true, instanceId: this._nextInstanceId };
|
||||
// Тени: GLB-модель и принимает тени, и отбрасывает их
|
||||
// (через addShadowCaster в refreshAllShadows).
|
||||
m.receiveShadows = true;
|
||||
// Тени: на InstancedMesh receiveShadows не действует (warning+нагрузка).
|
||||
if (m.getClassName && m.getClassName() !== 'InstancedMesh') {
|
||||
m.receiveShadows = true;
|
||||
}
|
||||
clonedMeshes.push(m);
|
||||
});
|
||||
// И сам root тоже на всякий
|
||||
@ -541,6 +542,7 @@ export class ModelManager {
|
||||
opacity: typeof data.opacity === 'number' ? data.opacity : 1,
|
||||
tint: data.tint || null,
|
||||
name: data.name || null,
|
||||
...(data.folderId != null ? { folderId: data.folderId } : {}), // папка
|
||||
// Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.)
|
||||
gameplayParams: data.gameplayParams || null,
|
||||
});
|
||||
@ -774,6 +776,7 @@ export class ModelManager {
|
||||
if (m.tint) data.tint = m.tint;
|
||||
if (m.name) data.name = m.name;
|
||||
if (m.gameplayParams) data.gameplayParams = m.gameplayParams;
|
||||
if (m.folderId != null) data.folderId = m.folderId;
|
||||
if (data.opacity != null || data.tint) this._applyMaterialOverrides(data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -879,6 +879,85 @@ export const MODEL_TYPES = [
|
||||
m('pg-fence-planks', 'Забор', 'Площадка', 'nature-kit', 'fence_planks',
|
||||
{ targetHeight: 1.5 }),
|
||||
|
||||
// === ЛЕГО-СЕТ (задача 09) — паритет со студией ===
|
||||
mc('lego-brick-1x1', 'Лего 1×1', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#e02a2a', material: 'studs', dy: 0.5 },
|
||||
]),
|
||||
mc('lego-brick-1x2', 'Лего 1×2', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 1, color: '#2a6fe0', material: 'studs', dy: 0.5 },
|
||||
]),
|
||||
mc('lego-brick-1x4', 'Лего 1×4', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 4, sy: 1, sz: 1, color: '#f0c020', material: 'studs', dy: 0.5 },
|
||||
]),
|
||||
mc('lego-brick-2x2', 'Лего 2×2', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 2, color: '#35ba5c', material: 'studs', dy: 0.5 },
|
||||
]),
|
||||
mc('lego-brick-2x4', 'Лего 2×4', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 4, sy: 1, sz: 2, color: '#e07a30', material: 'studs', dy: 0.5 },
|
||||
]),
|
||||
mc('lego-brick-2x8', 'Лего 2×8', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 8, sy: 1, sz: 2, color: '#9b5cf0', material: 'studs', dy: 0.5 },
|
||||
]),
|
||||
mc('lego-plate-1x1', 'Плита 1×1', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 1, sy: 0.35, sz: 1, color: '#cfd2d6', material: 'studs', dy: 0.175 },
|
||||
]),
|
||||
mc('lego-plate-1x2', 'Плита 1×2', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 2, sy: 0.35, sz: 1, color: '#cfd2d6', material: 'studs', dy: 0.175 },
|
||||
]),
|
||||
mc('lego-plate-2x2', 'Плита 2×2', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 2, sy: 0.35, sz: 2, color: '#cfd2d6', material: 'studs', dy: 0.175 },
|
||||
]),
|
||||
mc('lego-plate-4x4', 'Плита 4×4', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 4, sy: 0.35, sz: 4, color: '#9aa0a6', material: 'studs', dy: 0.175 },
|
||||
]),
|
||||
mc('lego-slope-30', 'Скат 30°', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 1, sz: 2, color: '#e02a2a', material: 'studs', dy: 0.5 },
|
||||
]),
|
||||
mc('lego-slope-45', 'Скат 45°', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 2, sz: 2, color: '#2a6fe0', material: 'studs', dy: 1 },
|
||||
]),
|
||||
mc('lego-slope-60', 'Скат 60°', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 3, sz: 2, color: '#f0c020', material: 'studs', dy: 1.5 },
|
||||
]),
|
||||
mc('lego-tree', 'Лего-дерево', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 1, sy: 3, sz: 1, color: '#8a5a2b', material: 'studs', dy: 1.5 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 3, sy: 2, sz: 3, color: '#35ba5c', material: 'studs', dy: 4 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1.5, sz: 2, color: '#2e9e4c', material: 'studs', dy: 5.5 },
|
||||
], { targetHeight: 6 }),
|
||||
mc('lego-bush', 'Лего-куст', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 2, color: '#2e9e4c', material: 'studs', dy: 0.5 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#35ba5c', material: 'studs', dy: 1.3, dx: 0.4 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#35ba5c', material: 'studs', dy: 1.3, dx: -0.5, dz: 0.3 },
|
||||
], { targetHeight: 1.8 }),
|
||||
mc('lego-house-small', 'Лего-дом', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 6, sy: 4, sz: 6, color: '#e02a2a', material: 'studs', dy: 2 },
|
||||
{ kind: 'primitive', type: 'wedge', sx: 7, sy: 2.5, sz: 7, color: '#2a6fe0', material: 'studs', dy: 5.25 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 1.4, sy: 2.4, sz: 0.3, color: '#f0c020', material: 'studs', dy: 1.2, dz: -3.05 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 1.2, sy: 1.2, sz: 0.3, color: '#9ad0ff', material: 'studs', dy: 2.6, dz: -3.05, dx: 1.8 },
|
||||
], { targetHeight: 6.5 }),
|
||||
mc('lego-car-racer', 'Лего-машина', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 6, sy: 1, sz: 3, color: '#e02a2a', material: 'studs', dy: 0.9 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 2.5, sy: 1.2, sz: 2.6, color: '#2a6fe0', material: 'studs', dy: 1.9, dx: 0.6 },
|
||||
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: 1.8, dz: 1.6, rz: Math.PI / 2 },
|
||||
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: 1.8, dz: -1.6, rz: Math.PI / 2 },
|
||||
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: -1.8, dz: 1.6, rz: Math.PI / 2 },
|
||||
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: -1.8, dz: -1.6, rz: Math.PI / 2 },
|
||||
], { targetHeight: 2.5 }),
|
||||
mc('lego-stairs', 'Лего-ступеньки', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 3, sy: 1, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 0.5, dz: 1.8 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 3, sy: 2, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 1.0, dz: 0.6 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 3, sy: 3, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 1.5, dz: -0.6 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 3, sy: 4, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 2.0, dz: -1.8 },
|
||||
], { targetHeight: 4 }),
|
||||
mc('lego-minifig', 'Лего-человечек', 'Лего-сет', [
|
||||
{ kind: 'primitive', type: 'cube', sx: 1.4, sy: 0.6, sz: 0.9, color: '#f0c020', material: 'studs', dy: 0.3 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 1.2, sy: 1.6, sz: 0.8, color: '#2a6fe0', material: 'studs', dy: 1.4 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 0.4, sy: 1.4, sz: 0.4, color: '#f0c020', material: 'studs', dy: 1.4, dx: 0.85 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 0.4, sy: 1.4, sz: 0.4, color: '#f0c020', material: 'studs', dy: 1.4, dx: -0.85 },
|
||||
{ kind: 'primitive', type: 'cube', sx: 1.1, sy: 1.0, sz: 0.85, color: '#f5c84a', material: 'studs', dy: 2.7 },
|
||||
{ kind: 'primitive', type: 'cylinder', sx: 0.9, sy: 0.5, sz: 0.9, color: '#e02a2a', material: 'studs', dy: 3.4 },
|
||||
], { targetHeight: 3.8 }),
|
||||
|
||||
// TOTAL: 644
|
||||
|
||||
];
|
||||
|
||||
@ -137,9 +137,16 @@ export class MultiplayerSync {
|
||||
// 1. Подписки на state
|
||||
const $ = getStateCallbacks(this.room);
|
||||
|
||||
// Защита от повторного срабатывания onAdd (Colyseus 0.16 + immediate:true
|
||||
// может триггерить .onAdd на каждый schema patch). Локальный set хранит
|
||||
// sessionId которые уже обработаны в ТЕКУЩЕМ sync объекте.
|
||||
const _addedSessionIds = new Set();
|
||||
const handleAdd = (player, sessionId) => {
|
||||
// Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController
|
||||
if (sessionId === this.room.sessionId) return;
|
||||
// Защита от дублирующих onAdd событий для уже добавленного игрока
|
||||
if (_addedSessionIds.has(sessionId)) return;
|
||||
_addedSessionIds.add(sessionId);
|
||||
this._addRemotePlayer(sessionId, player);
|
||||
// Подписываемся на изменения этого Player'а
|
||||
$(player).onChange(() => this._updateRemoteTarget(sessionId, player));
|
||||
@ -149,7 +156,11 @@ export class MultiplayerSync {
|
||||
this._attachRemoteWeapon(sessionId, val || '');
|
||||
});
|
||||
};
|
||||
// Используем тот же set в handleRemove чтобы при настоящем уходе игрока
|
||||
// потом можно было его снова добавить.
|
||||
this._addedSessionIds = _addedSessionIds;
|
||||
const handleRemove = (player, sessionId) => {
|
||||
if (this._addedSessionIds) this._addedSessionIds.delete(sessionId);
|
||||
this._removeRemotePlayer(sessionId);
|
||||
};
|
||||
|
||||
@ -289,8 +300,20 @@ export class MultiplayerSync {
|
||||
|
||||
// Интерполяция remote-игроков (позиция + yaw ставится на 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()) {
|
||||
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;
|
||||
cur.x += (rp.target.x - cur.x) * LERP_FACTOR;
|
||||
cur.y += (rp.target.y - cur.y) * LERP_FACTOR;
|
||||
@ -332,13 +355,25 @@ export class MultiplayerSync {
|
||||
// Развилка: R15-скины анимируются процедурно через R15Animator
|
||||
// (как локальный игрок), Kenney-модели — через glTF AnimationGroups.
|
||||
if (rp.isR15 && rp.r15Animator && rp.modelLoaded) {
|
||||
// Серверный animState: 'idle' | 'run' | 'attack'. R15Animator
|
||||
// понимает idle/walk/run/jump/fall. Сервер не различает
|
||||
// walk/run и не шлёт прыжки → маппим run→run, attack→idle
|
||||
// (атака показывается отдельным swing-ом руки ниже).
|
||||
const r15State = rp.isDead
|
||||
? 'idle'
|
||||
: (rp.animState === 'run' ? 'run' : 'idle');
|
||||
// Серверный animState: 'idle' | 'run' | 'jump' | 'fall' | 'attack'.
|
||||
// R15Animator понимает idle/walk/run/jump/fall.
|
||||
// 2026-06-05: раньше run/jump/fall маппились в idle (баг
|
||||
// в маппинге), из-за чего у remote-игроков не было
|
||||
// анимации ни ходьбы, ни прыжка. Теперь пробрасываем
|
||||
// напрямую. attack показывается отдельным swing руки.
|
||||
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.update(dt);
|
||||
} else if (!rp.isR15) {
|
||||
@ -632,6 +667,23 @@ export class MultiplayerSync {
|
||||
// === Внутреннее: меши remote-игроков ===
|
||||
// =================================================================
|
||||
_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 sy = player.y || 0;
|
||||
const sz = player.z || 0;
|
||||
|
||||
@ -161,6 +161,19 @@ export class NpcManager {
|
||||
r15Animator,
|
||||
};
|
||||
this.npcs.set(id, npc);
|
||||
// Пометить меши NPC для попаданий оружия (бластер/меч): pickable + npcId
|
||||
// в metadata. Без pickable raycast оружия проходит сквозь NPC (задача 40).
|
||||
try {
|
||||
const root = npc.data && npc.data.rootMesh;
|
||||
if (root) {
|
||||
root.isPickable = true;
|
||||
root.metadata = Object.assign({}, root.metadata, { npcId: id });
|
||||
for (const m of root.getChildMeshes(false)) {
|
||||
m.isPickable = true;
|
||||
m.metadata = Object.assign({}, m.metadata, { npcId: id });
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return id;
|
||||
}
|
||||
|
||||
@ -275,6 +288,12 @@ export class NpcManager {
|
||||
npc.isMoving = false;
|
||||
}
|
||||
|
||||
/** Включить/выключить анимацию атаки. */
|
||||
setAttacking(id, on) {
|
||||
const npc = this.npcs.get(Number(id));
|
||||
if (npc) npc.attacking = !!on;
|
||||
}
|
||||
|
||||
/** Реплика над головой NPC на duration секунд. */
|
||||
say(id, text, duration = 3) {
|
||||
const npc = this.npcs.get(Number(id));
|
||||
@ -287,10 +306,41 @@ export class NpcManager {
|
||||
damage(id, amount) {
|
||||
const npc = this.npcs.get(Number(id));
|
||||
if (!npc || npc.dead) return;
|
||||
npc.hp = Math.max(0, npc.hp - (Number(amount) || 0));
|
||||
const amt = Number(amount) || 0;
|
||||
npc.hp = Math.max(0, npc.hp - amt);
|
||||
// Авто-floater над мобом (задача 40 доп): game.fx.autoMobFloaters(true).
|
||||
if (this._autoFloater && amt > 0 && this.scene3d?.floaters) {
|
||||
try {
|
||||
this.scene3d.floaters.spawn(
|
||||
{ x: npc.x, y: (npc.y || 0) + 2.2, z: npc.z }, amt, this._autoFloater.opts || {});
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
if (npc.hp <= 0) this._killNpc(npc);
|
||||
}
|
||||
|
||||
/** Нанести урон NPC по мешу-попаданию (бластер/оружие). Ищет NPC, чьи меши
|
||||
* содержат hit-меш (или предка). Вызывает damage() → авто-floater. */
|
||||
damageByMesh(mesh, amount) {
|
||||
if (!mesh) return false;
|
||||
let m = mesh;
|
||||
for (let i = 0; i < 8 && m; i++) {
|
||||
const nid = m.metadata && m.metadata.npcId;
|
||||
if (nid != null && this.npcs.has(nid)) { this.damage(nid, amount); return true; }
|
||||
m = m.parent;
|
||||
}
|
||||
for (const npc of this.npcs.values()) {
|
||||
if (npc.dead) continue;
|
||||
const root = npc.data && npc.data.rootMesh;
|
||||
if (!root) continue;
|
||||
let mm = mesh;
|
||||
for (let i = 0; i < 8 && mm; i++) {
|
||||
if (mm === root) { this.damage(npc.id, amount); return true; }
|
||||
mm = mm.parent;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Удалить NPC по id (без эффекта смерти — просто убрать). */
|
||||
removeNpc(id) {
|
||||
const npc = this.npcs.get(Number(id));
|
||||
@ -391,17 +441,22 @@ export class NpcManager {
|
||||
if (root._isWorldMatrixFrozen) {
|
||||
try { root.unfreezeWorldMatrix(); } catch (e) {}
|
||||
}
|
||||
root.position.set(npc.x, npc.y, npc.z);
|
||||
// Процедурная анимация ходьбы (у Kenney-моделей нет скелета).
|
||||
if (moving) npc.walkPhase += dt * 10;
|
||||
let bobY = 0, lean = 0;
|
||||
if (moving && !npc.r15Animator) {
|
||||
bobY = Math.abs(Math.sin(npc.walkPhase)) * 0.12;
|
||||
lean = Math.sin(npc.walkPhase) * 0.08;
|
||||
}
|
||||
root.position.set(npc.x, npc.y + bobY, npc.z);
|
||||
root.rotation.y = npc.yaw;
|
||||
root.rotation.z = lean;
|
||||
// data.x/y/z — чтобы scene.find/getPosition видели NPC.
|
||||
data.x = npc.x; data.y = npc.y; data.z = npc.z;
|
||||
|
||||
// Анимация ходьбы — простое покачивание (без R15-скелета у Kenney).
|
||||
if (moving) npc.walkPhase += dt * 6;
|
||||
// R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator.
|
||||
if (npc.r15Animator) {
|
||||
try {
|
||||
npc.r15Animator.setState(moving ? 'run' : 'idle');
|
||||
npc.r15Animator.setState(npc.attacking ? 'attack' : (moving ? 'run' : 'idle'));
|
||||
npc.r15Animator.update(dt);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
586
src/engine/PlacementManager.js
Normal file
586
src/engine/PlacementManager.js
Normal file
@ -0,0 +1,586 @@
|
||||
/**
|
||||
* PlacementManager — drag-and-drop размещение объектов в 3D-мире (задача 11).
|
||||
*
|
||||
* Фундамент жанра tycoon/simulator/farm: «кликнул предмет в инвентаре →
|
||||
* полупрозрачный preview летает за курсором → ЛКМ ставит, ПКМ/Esc отменяет».
|
||||
* Аналог placement-системы из Roblox tycoon-игр (Pet Sim, Lumber Tycoon).
|
||||
*
|
||||
* Подключается из BabylonScene: `this.placementManager = new PlacementManager(this)`.
|
||||
* Доступ к движку через scene3d: camera, scene, player, pick, spawn, fx.
|
||||
*
|
||||
* Скриптовый API игры (через GameRuntime → game.placement.*):
|
||||
* start(itemKey, opts) — войти в режим расстановки
|
||||
* cancel() — выйти (как ПКМ/Esc)
|
||||
* confirm() — поставить на текущей позиции (как ЛКМ)
|
||||
* rotate(deg) — повернуть preview (как R / колесо)
|
||||
* onPlace / onCancel / onMove — колбэки (роутятся в worker как события)
|
||||
*
|
||||
* Фича-парность: идентичный модуль есть в rublox-player/src/engine/.
|
||||
*/
|
||||
import { MeshBuilder, StandardMaterial, Color3, Vector3 } from '@babylonjs/core';
|
||||
|
||||
const VALID_TINT = new Color3(0.30, 0.95, 0.40); // зелёный — можно ставить
|
||||
const INVALID_TINT = new Color3(0.95, 0.25, 0.25); // красный — нельзя
|
||||
|
||||
export class PlacementManager {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d; // BabylonScene
|
||||
this.scene = scene3d.scene;
|
||||
this._active = null; // активная сессия placement или null
|
||||
this._tickObs = null; // observer renderLoop
|
||||
this._placementSeq = 0;
|
||||
// Колбэки (вызываются движком, GameRuntime роутит их в worker как события)
|
||||
this._onPlace = null;
|
||||
this._onCancel = null;
|
||||
this._onMove = null;
|
||||
}
|
||||
|
||||
setCallbacks({ onPlace, onCancel, onMove } = {}) {
|
||||
if (onPlace !== undefined) this._onPlace = onPlace;
|
||||
if (onCancel !== undefined) this._onCancel = onCancel;
|
||||
if (onMove !== undefined) this._onMove = onMove;
|
||||
}
|
||||
|
||||
isActive() { return !!this._active; }
|
||||
|
||||
/**
|
||||
* Войти в placement-режим.
|
||||
* @param {string} itemKey — ключ предмета (передаётся обратно в onPlace)
|
||||
* @param {object} opts — см. 11_placement_mode.md §2.1
|
||||
* @returns {string} placementId
|
||||
*/
|
||||
start(itemKey, opts = {}) {
|
||||
// Уже активна сессия — отменим прежнюю (без onCancel-шума автора).
|
||||
if (this._active) this._teardown(false);
|
||||
|
||||
const o = {
|
||||
previewType: opts.previewType || 'primitive:cube',
|
||||
previewColor: opts.previewColor || '#a0522d',
|
||||
previewScale: Number(opts.previewScale) || 1,
|
||||
// modelScale — реальный scale воксельной модели для превью (чтобы
|
||||
// полупрозрачная копия была того же размера, что и ставимый объект).
|
||||
modelScale: Number(opts.modelScale) || Number(opts.previewScale) || 1,
|
||||
ghostOpacity: opts.ghostOpacity != null ? Number(opts.ghostOpacity) : 0.5,
|
||||
surfaceMode: opts.surfaceMode || 'ground', // 'ground'|'any'|'tag'
|
||||
allowSurfaces: Array.isArray(opts.allowSurfaces) ? opts.allowSurfaces : null,
|
||||
forbidOverlap: opts.forbidOverlap !== false,
|
||||
grid: opts.grid != null ? Number(opts.grid) : 1,
|
||||
rotationStep: opts.rotationStep != null ? Number(opts.rotationStep) : 90,
|
||||
targetZone: opts.targetZone || null, // ref-строка примитива-зоны
|
||||
showZoneOutline: opts.showZoneOutline !== false,
|
||||
showArrowFrom: opts.showArrowFrom || null, // 'player' | ref
|
||||
cost: Number(opts.cost) || 0,
|
||||
currency: opts.currency || 'rubles',
|
||||
hint: opts.hint || '',
|
||||
hintError: opts.hintError || 'Разместите в отмеченном месте!',
|
||||
placedType: opts.placedType || null,
|
||||
chainPlace: !!opts.chainPlace,
|
||||
maxDistance: opts.maxDistance != null ? Number(opts.maxDistance) : 0,
|
||||
maxItems: opts.maxItems != null ? Number(opts.maxItems) : 0,
|
||||
forceCameraMode: opts.forceCameraMode !== false,
|
||||
freezePlayer: !!opts.freezePlayer,
|
||||
previewPulse: opts.previewPulse !== false,
|
||||
};
|
||||
|
||||
const id = 'placement_' + (++this._placementSeq);
|
||||
const preview = this._createPreview(o);
|
||||
|
||||
this._active = {
|
||||
id, itemKey, opts: o, preview,
|
||||
rotationY: 0,
|
||||
valid: false,
|
||||
pos: new Vector3(0, 0, 0),
|
||||
zoneOutline: null,
|
||||
arrowFxRef: null,
|
||||
placedCount: 0,
|
||||
pulseT: 0,
|
||||
prevCameraMode: null,
|
||||
prevFrozen: null,
|
||||
};
|
||||
|
||||
// Зона размещения — красный контур по AABB.
|
||||
if (o.targetZone && o.showZoneOutline) this._createZoneOutline();
|
||||
// Стрелка от игрока/объекта к зоне (переиспользует fx.pointer задачи 08).
|
||||
if (o.showArrowFrom && o.targetZone) this._createArrow();
|
||||
// Камера: placement требует видимый курсор — в first переводим в third.
|
||||
if (o.forceCameraMode) this._forceThirdCamera();
|
||||
// Заморозка игрока (опция).
|
||||
if (o.freezePlayer) this._setPlayerFrozen(true);
|
||||
|
||||
// HUD: подсказки снизу-справа + верхний hint. Сообщаем движку.
|
||||
this._emitHud(true);
|
||||
|
||||
this._startTick();
|
||||
return id;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (!this._active) return;
|
||||
const cb = this._onCancel;
|
||||
this._teardown(true);
|
||||
if (typeof cb === 'function') cb();
|
||||
}
|
||||
|
||||
/** Поставить на текущей позиции (как ЛКМ). */
|
||||
confirm() {
|
||||
const a = this._active;
|
||||
if (!a) return false;
|
||||
if (!a.valid) {
|
||||
// Невалидно — звук «не получилось» + мигание preview в красный.
|
||||
this._playFail();
|
||||
this._flashInvalid();
|
||||
return false;
|
||||
}
|
||||
// Позиция для spawn = точка курсора МИНУС offset центра модели (с учётом
|
||||
// поворота), чтобы реальный объект встал ОСНОВАНИЕМ под курсором —
|
||||
// ровно туда, где показывалось превью. Для куба-превью offset = 0.
|
||||
let ox = a._modelOffsetX || 0, oz = a._modelOffsetZ || 0;
|
||||
if (ox || oz) {
|
||||
const c = Math.cos(a.rotationY), s = Math.sin(a.rotationY);
|
||||
const rx = ox * c - oz * s;
|
||||
const rz = ox * s + oz * c;
|
||||
ox = rx; oz = rz;
|
||||
}
|
||||
const result = {
|
||||
itemKey: a.itemKey,
|
||||
position: { x: a.pos.x - ox, y: a.pos.y, z: a.pos.z - oz },
|
||||
rotationY: a.rotationY,
|
||||
};
|
||||
// Списание стоимости (если задана и есть валюта-хелпер в движке).
|
||||
if (a.opts.cost > 0) this._spendCurrency(a.opts.currency, a.opts.cost);
|
||||
a.placedCount++;
|
||||
this._playPlace();
|
||||
|
||||
if (typeof this._onPlace === 'function') this._onPlace(result);
|
||||
|
||||
if (a.opts.chainPlace) {
|
||||
// Остаёмся в режиме для следующего — preview не удаляем, сбрасываем поворот? нет, сохраняем.
|
||||
// Просто продолжаем тик; valid пересчитается в следующем кадре.
|
||||
return true;
|
||||
}
|
||||
this._teardown(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Повернуть preview на N градусов вокруг Y. */
|
||||
rotate(deg) {
|
||||
const a = this._active;
|
||||
if (!a) return;
|
||||
const step = (deg != null ? Number(deg) : a.opts.rotationStep) || 90;
|
||||
a.rotationY = (a.rotationY + step * Math.PI / 180) % (Math.PI * 2);
|
||||
if (a.preview) a.preview.rotation.y = a.rotationY;
|
||||
}
|
||||
|
||||
// ── Внутреннее ──────────────────────────────────────────────────────
|
||||
|
||||
_createPreview(o) {
|
||||
const base = Color3.FromHexString(o.previewColor || '#a0522d');
|
||||
|
||||
// Для воксельной модели (user:<id>) строим ПРЕВЬЮ ИЗ РЕАЛЬНОЙ ГЕОМЕТРИИ
|
||||
// модели — полупрозрачную копию. Так тень точно повторяет форму предмета
|
||||
// И совпадает по позиционированию с реальным spawn (модель растёт от угла
|
||||
// root, а не центрируется — куб-превью раньше центрировался → предмет
|
||||
// вставал в угол превью). Здесь превью = тот же addInstance, поэтому
|
||||
// угол-в-угол. Делается асинхронно (см. _buildUserModelPreview).
|
||||
const pt = o.previewType || '';
|
||||
if (pt.indexOf('user:') === 0 && this.s.userModelManager) {
|
||||
// Временный куб-заглушка пока модель грузится (1-2 кадра), заменим.
|
||||
const stub = MeshBuilder.CreateBox('placementGhostStub', { size: 0.01 }, this.scene);
|
||||
stub.isPickable = false;
|
||||
stub._baseColor = base;
|
||||
this._buildUserModelPreview(pt, o, base);
|
||||
return stub;
|
||||
}
|
||||
|
||||
// Примитивы / прочее — полупрозрачный куб размером previewScale (юниты).
|
||||
const edge = Number(o.previewScale) || 1;
|
||||
const ghost = MeshBuilder.CreateBox('placementGhost', { size: edge }, this.scene);
|
||||
const mat = new StandardMaterial('placementGhostMat', this.scene);
|
||||
mat.diffuseColor = base;
|
||||
mat.emissiveColor = base.scale(0.25);
|
||||
mat.specularColor = new Color3(0, 0, 0);
|
||||
mat.alpha = o.ghostOpacity;
|
||||
mat.disableLighting = true;
|
||||
ghost.material = mat;
|
||||
ghost.isPickable = false;
|
||||
ghost._baseColor = base;
|
||||
return ghost;
|
||||
}
|
||||
|
||||
/** Построить полупрозрачное превью из реальной воксельной модели (async). */
|
||||
async _buildUserModelPreview(previewType, o, base) {
|
||||
try {
|
||||
const um = this.s.userModelManager;
|
||||
// Спавним реальный инстанс модели в (0,0,0) — будем двигать как превью.
|
||||
const instId = await um.addInstance(previewType, 0, 0, 0, 0, {
|
||||
scale: o.modelScale || o.previewScale || 1,
|
||||
canCollide: false, visible: true, anchored: true,
|
||||
currentUserId: this.s._currentUserId || null,
|
||||
});
|
||||
if (instId == null) return;
|
||||
// Сессия уже могла завершиться/смениться, пока грузилось.
|
||||
const a = this._active;
|
||||
if (!a) { try { um.removeInstance(instId); } catch (e) {} return; }
|
||||
const inst = um.instances.get(instId);
|
||||
if (!inst || !inst.rootNode) return;
|
||||
// Применяем ghost-вид ко всем мешам модели: полупрозрачно, не pickable.
|
||||
const ghostMat = new StandardMaterial('placementGhostMatUM', this.scene);
|
||||
ghostMat.diffuseColor = base;
|
||||
ghostMat.emissiveColor = base.scale(0.25);
|
||||
ghostMat.specularColor = new Color3(0, 0, 0);
|
||||
ghostMat.alpha = o.ghostOpacity;
|
||||
ghostMat.disableLighting = true;
|
||||
ghostMat.backFaceCulling = false;
|
||||
for (const m of (inst.meshes || [])) {
|
||||
m.isPickable = false;
|
||||
m.material = ghostMat;
|
||||
}
|
||||
// Центр модели по X/Z (воксели растут углом от root → центр смещён).
|
||||
// Вычисляем из bbox мешей в локальных координатах root (root в 0,0,0).
|
||||
// Этот offset вычитаем из позиции, чтобы ОСНОВАНИЕ модели (её центр
|
||||
// по X/Z) было ровно под курсором, а не угол. Применяется и к превью,
|
||||
// и к реальному spawn (onPlace) — иначе предмет «уезжает» по диагонали.
|
||||
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
|
||||
for (const m of (inst.meshes || [])) {
|
||||
m.computeWorldMatrix(true);
|
||||
const bb = m.getBoundingInfo().boundingBox;
|
||||
minX = Math.min(minX, bb.minimumWorld.x); maxX = Math.max(maxX, bb.maximumWorld.x);
|
||||
minZ = Math.min(minZ, bb.minimumWorld.z); maxZ = Math.max(maxZ, bb.maximumWorld.z);
|
||||
}
|
||||
const offX = Number.isFinite(minX) ? (minX + maxX) / 2 : 0;
|
||||
const offZ = Number.isFinite(minZ) ? (minZ + maxZ) / 2 : 0;
|
||||
a._modelOffsetX = offX;
|
||||
a._modelOffsetZ = offZ;
|
||||
|
||||
// Удаляем временный stub, новый root становится превью.
|
||||
const old = a.preview;
|
||||
a.preview = inst.rootNode;
|
||||
a.preview._baseColor = base;
|
||||
a.preview._userModelInstId = instId; // для teardown
|
||||
a.preview._ghostMat = ghostMat;
|
||||
if (old) { try { old.dispose(); } catch (e) {} }
|
||||
} catch (e) {
|
||||
// тихо — превью некритично, останется stub
|
||||
}
|
||||
}
|
||||
|
||||
_startTick() {
|
||||
this._tickObs = this.scene.onBeforeRenderObservable.add(() => this._tick());
|
||||
}
|
||||
|
||||
_tick() {
|
||||
const a = this._active;
|
||||
if (!a) return;
|
||||
const scn = this.scene;
|
||||
|
||||
// Raycast от камеры через текущую позицию курсора.
|
||||
const pick = scn.pick(scn.pointerX, scn.pointerY, (m) =>
|
||||
m && m.isPickable && m !== a.preview && this._isSurface(m, a.opts));
|
||||
if (pick && pick.hit && pick.pickedPoint) {
|
||||
let p = pick.pickedPoint.clone();
|
||||
// surfaceMode 'ground' — нормаль должна смотреть вверх.
|
||||
// Поверхность валидна, если смотрит вверх (горизонтальная грань).
|
||||
// Это и пол, и ВЕРХ другого объекта → можно строить стопкой.
|
||||
let surfOk = true;
|
||||
if (a.opts.surfaceMode === 'ground') {
|
||||
const n = pick.getNormal(true);
|
||||
surfOk = n && n.y > 0.6; // только грань, обращённая вверх
|
||||
}
|
||||
// Снэппинг к сетке (X/Z). Y берём с поверхности — чтобы объект
|
||||
// лёг ровно сверху на пол ИЛИ на другой объект (стопка).
|
||||
if (a.opts.grid > 0) {
|
||||
p.x = Math.round(p.x / a.opts.grid) * a.opts.grid;
|
||||
p.z = Math.round(p.z / a.opts.grid) * a.opts.grid;
|
||||
}
|
||||
a.pos.copyFrom(p);
|
||||
if (a.preview) {
|
||||
if (a.preview._userModelInstId != null) {
|
||||
// userModel-превью: root = угол модели. Вычитаем offset центра
|
||||
// по X/Z → ОСНОВАНИЕ модели (ствол/центр) точно под курсором.
|
||||
// Высота p.y без сдвига (низ модели на поверхность).
|
||||
a.preview.position.set(
|
||||
p.x - (a._modelOffsetX || 0),
|
||||
p.y,
|
||||
p.z - (a._modelOffsetZ || 0),
|
||||
);
|
||||
} else {
|
||||
// Куб-превью центрирован → поднимаем на полвысоты.
|
||||
a.preview.position.set(p.x, p.y + 0.5 * a.opts.previewScale, p.z);
|
||||
}
|
||||
}
|
||||
// Валидность. forbidOverlap теперь означает «не врезаться вбок в
|
||||
// объект на ТОЙ ЖЕ высоте» — вертикальная стопка разрешена.
|
||||
a.valid = surfOk
|
||||
&& this._inZone(p, a.opts)
|
||||
&& this._distanceOk(p, a.opts)
|
||||
&& this._limitOk(a.opts)
|
||||
&& this._affordable(a)
|
||||
&& (!a.opts.forbidOverlap || !this._overlapsSide(p, a));
|
||||
} else {
|
||||
a.valid = false;
|
||||
}
|
||||
|
||||
// Цвет preview: зелёный/красный.
|
||||
this._applyTint(a, a.valid);
|
||||
|
||||
// Пульсация прозрачности (привлекает внимание). Материал — у куба-превью
|
||||
// напрямую, у userModel-превью в _ghostMat (root — TransformNode без mat).
|
||||
const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
|
||||
if (a.opts.previewPulse && pmat) {
|
||||
a.pulseT += this.scene.getEngine().getDeltaTime() / 1000;
|
||||
const k = 0.5 + 0.5 * Math.sin(a.pulseT * Math.PI); // 0..1
|
||||
pmat.alpha = a.opts.ghostOpacity * (0.7 + 0.3 * k);
|
||||
}
|
||||
|
||||
// HUD-индикатор ошибки (красный текст когда невалидно).
|
||||
this._emitHudError(!a.valid);
|
||||
|
||||
// Стрелка к зоне — обновим конечную точку (если игрок движется).
|
||||
if (a.arrowFxRef) this._updateArrow();
|
||||
|
||||
// onMove колбэк автору (каждый кадр).
|
||||
if (typeof this._onMove === 'function') {
|
||||
this._onMove({ position: { x: a.pos.x, y: a.pos.y, z: a.pos.z }, valid: a.valid });
|
||||
}
|
||||
}
|
||||
|
||||
_applyTint(a, valid) {
|
||||
// Материал куба-превью напрямую, userModel-превью — в _ghostMat.
|
||||
const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
|
||||
if (!pmat) return;
|
||||
if (a._flashUntil && performance && performance.now && performance.now() < a._flashUntil) {
|
||||
return; // во время flash держим красный
|
||||
}
|
||||
const tint = valid ? VALID_TINT : INVALID_TINT;
|
||||
// Смешиваем базовый цвет с tint-ом (multiply-эффект).
|
||||
const b = a.preview._baseColor || new Color3(0.6, 0.4, 0.25);
|
||||
pmat.diffuseColor = new Color3(
|
||||
b.r * tint.r + tint.r * 0.4,
|
||||
b.g * tint.g + tint.g * 0.4,
|
||||
b.b * tint.b + tint.b * 0.4,
|
||||
);
|
||||
pmat.emissiveColor = tint.scale(0.35);
|
||||
}
|
||||
|
||||
_flashInvalid() {
|
||||
const a = this._active;
|
||||
if (!a || !a.preview || !a.preview.material) return;
|
||||
try { a._flashUntil = (performance.now ? performance.now() : Date.now()) + 300; } catch { a._flashUntil = Date.now() + 300; }
|
||||
a.preview.material.diffuseColor = INVALID_TINT;
|
||||
a.preview.material.emissiveColor = INVALID_TINT.scale(0.6);
|
||||
}
|
||||
|
||||
_isSurface(mesh, o) {
|
||||
if (!o.allowSurfaces) return true; // любая поверхность
|
||||
// Совпадение по имени или тегу.
|
||||
const name = mesh.name || '';
|
||||
if (o.allowSurfaces.some(s => name.includes(s))) return true;
|
||||
const tags = mesh.metadata && mesh.metadata.tags;
|
||||
if (Array.isArray(tags) && tags.some(t => o.allowSurfaces.includes(t))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
_inZone(p, o) {
|
||||
if (!o.targetZone) return true;
|
||||
const z = this._resolveZoneMesh(o.targetZone);
|
||||
if (!z) return true;
|
||||
const bb = z.getBoundingInfo().boundingBox;
|
||||
const min = bb.minimumWorld, max = bb.maximumWorld;
|
||||
return p.x >= min.x && p.x <= max.x && p.z >= min.z && p.z <= max.z;
|
||||
}
|
||||
|
||||
_distanceOk(p, o) {
|
||||
if (!o.maxDistance || o.maxDistance <= 0) return true;
|
||||
const pl = this.s.player && this.s.player._pos;
|
||||
if (!pl) return true;
|
||||
const dx = p.x - pl.x, dz = p.z - pl.z;
|
||||
return (dx * dx + dz * dz) <= o.maxDistance * o.maxDistance;
|
||||
}
|
||||
|
||||
_limitOk(o) {
|
||||
if (!o.maxItems || o.maxItems <= 0) return true;
|
||||
return (this._active.placedCount || 0) < o.maxItems;
|
||||
}
|
||||
|
||||
_overlapsSide(p, a) {
|
||||
// Боковое пересечение: объект мешает ТОЛЬКО если он на той же высоте
|
||||
// (его тело пересекает уровень, куда ляжет новый объект). Объект строго
|
||||
// НИЖЕ (фундамент под стопку) или строго ВЫШЕ — не мешает. Это позволяет
|
||||
// строить башню из кубов, но не даёт двум кубам слипнуться вбок.
|
||||
const r = Math.max(0.45, (a.opts.grid || 1) * 0.5);
|
||||
const newY = p.y; // высота поверхности (низ нового объекта)
|
||||
const newTop = newY + (a.opts.previewScale || 1);
|
||||
for (const m of this.scene.meshes) {
|
||||
if (!m.isPickable || m === a.preview) continue;
|
||||
if (!m.getBoundingInfo) continue;
|
||||
const bb = m.getBoundingInfo().boundingBox;
|
||||
const sizeX = bb.maximumWorld.x - bb.minimumWorld.x;
|
||||
if (sizeX > 8) continue; // пол/большая поверхность — не препятствие
|
||||
const c = bb.centerWorld;
|
||||
const dx = c.x - p.x, dz = c.z - p.z;
|
||||
if (Math.abs(dx) >= r || Math.abs(dz) >= r) continue; // не под курсором
|
||||
const mTop = bb.maximumWorld.y, mBot = bb.minimumWorld.y;
|
||||
// Пересечение по вертикали: тела перекрываются по Y → бок в бок.
|
||||
const overlapY = newY < (mTop - 0.05) && newTop > (mBot + 0.05);
|
||||
if (overlapY) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Хватает ли валюты на текущий предмет (если задан баланс). */
|
||||
_affordable(a) {
|
||||
const cur = a.opts.currency;
|
||||
const cost = a.opts.cost || 0;
|
||||
if (!cost) return true;
|
||||
const bal = (this._balances && this._balances[cur] != null) ? this._balances[cur] : Infinity;
|
||||
return cost <= bal;
|
||||
}
|
||||
|
||||
/** Установить баланс валюты (для проверки «нельзя уйти в минус»). */
|
||||
setBalance(currency, amount) {
|
||||
if (!this._balances) this._balances = {};
|
||||
if (currency) this._balances[currency] = Number(amount) || 0;
|
||||
}
|
||||
|
||||
_resolveZoneMesh(ref) {
|
||||
// ref может быть строкой ('primitive:N' / имя) или уже мешем.
|
||||
if (ref && ref.getBoundingInfo) return ref;
|
||||
if (typeof ref === 'string') {
|
||||
// через scene3d — найти примитив/модель по ref
|
||||
try {
|
||||
const mesh = this.s._refStrToMesh ? this.s._refStrToMesh(ref) : null;
|
||||
if (mesh) return mesh;
|
||||
} catch { /* ignore */ }
|
||||
// fallback — по имени
|
||||
return this.scene.getMeshByName(ref) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_createZoneOutline() {
|
||||
const a = this._active;
|
||||
const z = this._resolveZoneMesh(a.opts.targetZone);
|
||||
if (!z) return;
|
||||
const bb = z.getBoundingInfo().boundingBox;
|
||||
const min = bb.minimumWorld, max = bb.maximumWorld;
|
||||
const y = min.y + 0.06;
|
||||
const pts = [
|
||||
new Vector3(min.x, y, min.z), new Vector3(max.x, y, min.z),
|
||||
new Vector3(max.x, y, max.z), new Vector3(min.x, y, max.z),
|
||||
new Vector3(min.x, y, min.z),
|
||||
];
|
||||
const line = MeshBuilder.CreateLines('placementZoneOutline', { points: pts }, this.scene);
|
||||
line.color = new Color3(1, 0.19, 0.19);
|
||||
line.isPickable = false;
|
||||
// glow-имитация: чуть приподнятая полупрозрачная плоскость
|
||||
a.zoneOutline = line;
|
||||
}
|
||||
|
||||
_createArrow() {
|
||||
const a = this._active;
|
||||
// Стрелка-указатель через BeamManager (задача 08: addPointer/setPointerTarget).
|
||||
try {
|
||||
const bm = this.s.beamManager;
|
||||
if (!bm || !bm.addPointer) return;
|
||||
const z = this._resolveZoneMesh(a.opts.targetZone);
|
||||
if (!z) return;
|
||||
const c = z.getBoundingInfo().boundingBox.centerWorld;
|
||||
const from = (a.opts.showArrowFrom === 'player' && this.s.player && this.s.player._pos)
|
||||
? this.s.player._pos
|
||||
: this._resolveZoneMesh(a.opts.showArrowFrom);
|
||||
const fromV = from && from.x != null ? new Vector3(from.x, (from.y || 0) + 1, from.z) : null;
|
||||
if (!fromV) return;
|
||||
a.arrowFxRef = bm.addPointer({
|
||||
from: { x: fromV.x, y: fromV.y, z: fromV.z },
|
||||
to: { x: c.x, y: c.y + 0.6, z: c.z },
|
||||
preset: 'guide',
|
||||
});
|
||||
} catch { /* стрелка не критична */ }
|
||||
}
|
||||
|
||||
_updateArrow() {
|
||||
// Стрелка статична от точки старта к зоне (как в Roblox tycoon —
|
||||
// указатель «куда ставить»). BeamManager не имеет setPointerOrigin,
|
||||
// а пересоздавать каждый кадр дорого. Конец уже привязан к зоне.
|
||||
}
|
||||
|
||||
_forceThirdCamera() {
|
||||
const a = this._active;
|
||||
try {
|
||||
if (this.s.player && this.s.player.getCameraMode && this.s.player.setCameraMode) {
|
||||
a.prevCameraMode = this.s.player.getCameraMode();
|
||||
if (a.prevCameraMode === 'first') this.s.player.setCameraMode('third');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_setPlayerFrozen(frozen) {
|
||||
try {
|
||||
if (this.s.player && this.s.player.setFrozen) {
|
||||
if (this._active) this._active.prevFrozen = true;
|
||||
this.s.player.setFrozen(frozen);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_spendCurrency(currency, amount) {
|
||||
// Движок не держит «кошелёк» — это делает игра через onPlace + save.
|
||||
// Но если есть хелпер валюты, вызовем. Иначе — no-op (автор сам спишет).
|
||||
try {
|
||||
if (this.s.spendCurrency) this.s.spendCurrency(currency, amount);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_playPlace() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('place'); } catch { /* ignore */ } }
|
||||
_playFail() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('lose'); } catch { /* ignore */ } }
|
||||
|
||||
_emitHud(show) {
|
||||
// Сообщаем движку показать/скрыть placement-HUD (подсказки).
|
||||
try {
|
||||
if (this.s.onPlacementHud) this.s.onPlacementHud({ show, hint: this._active ? this._active.opts.hint : '' });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_emitHudError(isError) {
|
||||
try {
|
||||
if (this.s.onPlacementHudError) this.s.onPlacementHudError(isError);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_teardown(emitHudOff) {
|
||||
const a = this._active;
|
||||
if (!a) return;
|
||||
if (this._tickObs) { this.scene.onBeforeRenderObservable.remove(this._tickObs); this._tickObs = null; }
|
||||
if (a.preview) {
|
||||
try {
|
||||
if (a.preview._userModelInstId != null && this.s.userModelManager) {
|
||||
// userModel-превью — это реальный инстанс; удаляем через менеджер
|
||||
// (снимет из Map + dispose мешей). + чистим ghost-материал.
|
||||
try { a.preview._ghostMat && a.preview._ghostMat.dispose(); } catch (e) {}
|
||||
this.s.userModelManager.removeInstance(a.preview._userModelInstId);
|
||||
} else {
|
||||
a.preview.material && a.preview.material.dispose();
|
||||
a.preview.dispose();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (a.zoneOutline) { try { a.zoneOutline.dispose(); } catch { /* ignore */ } }
|
||||
if (a.arrowFxRef != null && this.s.beamManager && this.s.beamManager.remove) {
|
||||
try { this.s.beamManager.remove(a.arrowFxRef); } catch { /* ignore */ }
|
||||
}
|
||||
if (a.prevCameraMode === 'first' && this.s.player && this.s.player.setCameraMode) {
|
||||
try { this.s.player.setCameraMode('first'); } catch { /* ignore */ }
|
||||
}
|
||||
if (a.prevFrozen && this.s.player && this.s.player.setFrozen) {
|
||||
try { this.s.player.setFrozen(false); } catch { /* ignore */ }
|
||||
}
|
||||
this._active = null;
|
||||
if (emitHudOff !== false) this._emitHud(false);
|
||||
}
|
||||
|
||||
/** Полный сброс при Stop игры. */
|
||||
dispose() {
|
||||
this._teardown(true);
|
||||
this._onPlace = this._onCancel = this._onMove = null;
|
||||
}
|
||||
}
|
||||
@ -580,6 +580,10 @@ export class PlayerController {
|
||||
setUiCursorMode(enabled) {
|
||||
this._uiCursorMode = !!enabled;
|
||||
if (enabled) {
|
||||
// Открываем UI (меню/курсор) → сбрасываем удержание ПКМ. Иначе если
|
||||
// меню открыли при зажатой ПКМ, _rmbHeld застревает в true и orbit-
|
||||
// камера после закрытия меню «думает», что ПКМ всё ещё активна.
|
||||
this._rmbHeld = false;
|
||||
// Освобождаем мышь
|
||||
if (document.pointerLockElement === this.canvas) {
|
||||
try { document.exitPointerLock(); } catch (e) { /* ignore */ }
|
||||
@ -1819,6 +1823,143 @@ export class PlayerController {
|
||||
this._cameraOverride = null;
|
||||
}
|
||||
|
||||
// ===== Задача 14: вождение машины =====
|
||||
enterVehicle(veh) {
|
||||
if (!veh) return;
|
||||
this._inVehicle = veh;
|
||||
this._vehicleCamMode = 'follow';
|
||||
veh.driver = 'player';
|
||||
if (this._codes) this._codes.clear();
|
||||
this._skinVisibleScripted = false;
|
||||
this._startEngineSound();
|
||||
}
|
||||
|
||||
// Звук мотора: низкочастотный РОКОТ (бас-пила + отфильтрованный шум +
|
||||
// LFO-пульсация тактов), а не воющий тон. Парность со студией.
|
||||
_startEngineSound() {
|
||||
try {
|
||||
if (!this._audioCtx) {
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctx) return;
|
||||
this._audioCtx = new Ctx();
|
||||
}
|
||||
const ctx = this._audioCtx;
|
||||
if (ctx.state === 'suspended') ctx.resume();
|
||||
if (this._engineNodes) return;
|
||||
const osc = ctx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = 45;
|
||||
const bufLen = ctx.sampleRate * 1.0;
|
||||
const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * 0.6;
|
||||
const noise = ctx.createBufferSource(); noise.buffer = buf; noise.loop = true;
|
||||
const noiseLp = ctx.createBiquadFilter(); noiseLp.type = 'lowpass'; noiseLp.frequency.value = 180; noiseLp.Q.value = 0.7;
|
||||
const noiseGain = ctx.createGain(); noiseGain.gain.value = 0.35;
|
||||
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 350; lp.Q.value = 0.5;
|
||||
const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 12;
|
||||
const lfoGain = ctx.createGain(); lfoGain.gain.value = 0.18;
|
||||
const gain = ctx.createGain(); gain.gain.value = 0.05;
|
||||
osc.connect(lp);
|
||||
noise.connect(noiseLp); noiseLp.connect(noiseGain); noiseGain.connect(lp);
|
||||
lp.connect(gain); gain.connect(ctx.destination);
|
||||
lfo.connect(lfoGain); lfoGain.connect(gain.gain);
|
||||
osc.start(); noise.start(); lfo.start();
|
||||
this._engineNodes = { osc, noise, noiseLp, noiseGain, lp, lfo, lfoGain, gain };
|
||||
} catch (e) {}
|
||||
}
|
||||
_updateEngineSound(speedMs, maxSpeed) {
|
||||
const n = this._engineNodes; if (!n) return;
|
||||
try {
|
||||
const frac = Math.min(1, Math.abs(speedMs) / (maxSpeed || 14));
|
||||
const ctx = this._audioCtx; const t = ctx.currentTime;
|
||||
n.osc.frequency.setTargetAtTime(45 + frac * 50, t, 0.12);
|
||||
n.lfo.frequency.setTargetAtTime(12 + frac * 33, t, 0.12);
|
||||
n.lp.frequency.setTargetAtTime(300 + frac * 500, t, 0.12);
|
||||
n.noiseLp.frequency.setTargetAtTime(150 + frac * 350, t, 0.12);
|
||||
n.noiseGain.gain.setTargetAtTime(0.25 + frac * 0.35, t, 0.12);
|
||||
n.gain.gain.setTargetAtTime(0.05 + frac * 0.08, t, 0.12);
|
||||
} catch (e) {}
|
||||
}
|
||||
_stopEngineSound() {
|
||||
const n = this._engineNodes; if (!n) return;
|
||||
try {
|
||||
const t = this._audioCtx.currentTime;
|
||||
n.gain.gain.setTargetAtTime(0, t, 0.05);
|
||||
n.osc.stop(t + 0.2); n.noise.stop(t + 0.2); n.lfo.stop(t + 0.2);
|
||||
} catch (e) {}
|
||||
this._engineNodes = null;
|
||||
}
|
||||
exitVehicle() {
|
||||
const veh = this._inVehicle;
|
||||
this._inVehicle = null;
|
||||
if (veh) {
|
||||
veh.driver = null;
|
||||
try {
|
||||
const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw));
|
||||
this._pos.set(veh.pos.x + side.x * (veh.half.w + 1.0), veh.pos.y + 1.0, veh.pos.z + side.z * (veh.half.w + 1.0));
|
||||
this._vy = 0;
|
||||
} catch (e) {}
|
||||
}
|
||||
this._stopEngineSound();
|
||||
this._skinVisibleScripted = true;
|
||||
if (this._modelMeshes) {
|
||||
for (const m of this._modelMeshes) {
|
||||
if (m && m.setEnabled) { try { m.setEnabled(true); } catch (e) {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
cycleVehicleCamera() {
|
||||
const modes = ['follow', 'hood', 'cinematic'];
|
||||
const i = modes.indexOf(this._vehicleCamMode || 'follow');
|
||||
this._vehicleCamMode = modes[(i + 1) % modes.length];
|
||||
}
|
||||
_tickVehicle(dt) {
|
||||
const veh = this._inVehicle;
|
||||
if (!veh || !this._scene3d?.vehicleManager) return;
|
||||
if (this._modelMeshes) {
|
||||
for (const m of this._modelMeshes) {
|
||||
if (m && m.isEnabled && m.isEnabled() && m.setEnabled) { try { m.setEnabled(false); } catch (e) {} }
|
||||
}
|
||||
}
|
||||
const c = this._codes;
|
||||
const throttle = (c.has('KeyW') || c.has('ArrowUp') ? 1 : 0) - (c.has('KeyS') || c.has('ArrowDown') ? 1 : 0);
|
||||
const steer = (c.has('KeyD') || c.has('ArrowRight') ? 1 : 0) - (c.has('KeyA') || c.has('ArrowLeft') ? 1 : 0);
|
||||
const handbrake = c.has('Space');
|
||||
this._scene3d.vehicleManager.setInput(veh, throttle, steer, handbrake);
|
||||
const _vres = this._scene3d.vehicleManager.tickVehicle(veh, dt);
|
||||
this._updateEngineSound(veh.speed, veh.params?.maxSpeed);
|
||||
if (_vres && _vres.fellOut) {
|
||||
this.exitVehicle();
|
||||
if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (e) {} }
|
||||
const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 };
|
||||
try { this._pos.set(sp.x, sp.y + 1.0, sp.z); this._vy = 0; } catch (e) {}
|
||||
return;
|
||||
}
|
||||
try { this._pos.set(veh.pos.x, veh.pos.y + 1.0, veh.pos.z); } catch (e) {}
|
||||
if (!this.camera) return;
|
||||
const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
|
||||
const cp = veh.pos;
|
||||
const mode = this._vehicleCamMode || 'follow';
|
||||
let camPos, camTarget;
|
||||
if (mode === 'hood') {
|
||||
camPos = new Vector3(cp.x + dir.x * (veh.half.d + 0.3), cp.y + veh.half.h + 0.6, cp.z + dir.z * (veh.half.d + 0.3));
|
||||
camTarget = new Vector3(cp.x + dir.x * 8, cp.y + 0.5, cp.z + dir.z * 8);
|
||||
} else if (mode === 'cinematic') {
|
||||
const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw));
|
||||
camPos = new Vector3(cp.x + side.x * 7 + dir.x * 2, cp.y + 2.5, cp.z + side.z * 7 + dir.z * 2);
|
||||
camTarget = new Vector3(cp.x, cp.y + 0.8, cp.z);
|
||||
} else {
|
||||
camPos = new Vector3(cp.x - dir.x * 8, cp.y + 3.2, cp.z - dir.z * 8);
|
||||
camTarget = new Vector3(cp.x + dir.x * 2, cp.y + 1.0, cp.z + dir.z * 2);
|
||||
}
|
||||
const k = Math.min(1, dt * 6);
|
||||
this.camera.position.set(
|
||||
this.camera.position.x + (camPos.x - this.camera.position.x) * k,
|
||||
this.camera.position.y + (camPos.y - this.camera.position.y) * k,
|
||||
this.camera.position.z + (camPos.z - this.camera.position.z) * k,
|
||||
);
|
||||
try { this.camera.setTarget(camTarget); } catch (e) {}
|
||||
}
|
||||
|
||||
/** Применить активный режим камеры скрипта (вызывается в _tick). */
|
||||
_applyCameraOverride(dt) {
|
||||
const o = this._cameraOverride;
|
||||
@ -2412,6 +2553,15 @@ export class PlayerController {
|
||||
return;
|
||||
}
|
||||
this._codes.add(e.code);
|
||||
// Задача 14: в машине — V камера, E выход.
|
||||
if (this._inVehicle) {
|
||||
if (e.code === 'KeyV') { this.cycleVehicleCamera(); }
|
||||
else if (e.code === 'KeyE') {
|
||||
const veh = this._inVehicle;
|
||||
this.exitVehicle();
|
||||
if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (err) {} }
|
||||
}
|
||||
}
|
||||
if (e.shiftKey) this._shift = true;
|
||||
// C — переключение first/third. Отключаем в GD-режиме (автобег > 0)
|
||||
// и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview.
|
||||
@ -2483,6 +2633,12 @@ export class PlayerController {
|
||||
if (dt <= 0) return;
|
||||
if (dt > 0.1) dt = 0.1;
|
||||
|
||||
// === Задача 14: режим вождения — инпут идёт в машину, не в ходьбу ===
|
||||
if (this._inVehicle) {
|
||||
try { this._tickVehicle(dt); } catch (e) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
// === Присед: по Ctrl на десктопе, или через мобильную кнопку
|
||||
// (которая шлёт keydown 'ControlLeft'). C — НЕ используется
|
||||
// (это смена вида в Babylon).
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
* При касании игроком обновляет spawnPoint сцены.
|
||||
*/
|
||||
import {
|
||||
MeshBuilder, StandardMaterial, Color3, Vector3, PointLight,
|
||||
MeshBuilder, StandardMaterial, Color3, Vector3, Vector4, PointLight,
|
||||
Mesh, VertexData,
|
||||
} from '@babylonjs/core';
|
||||
// CRA→Vite адаптация: оригинальный код в setTexture()/_applyMaterial()/
|
||||
@ -33,6 +33,57 @@ import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTextur
|
||||
import { Texture } from '@babylonjs/core/Materials/Textures/texture';
|
||||
import { getPrimitiveType } from './PrimitiveTypes';
|
||||
|
||||
// === Материал «studs» (лего-кружки, задача 09) — паритет со студией ===
|
||||
const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png';
|
||||
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png';
|
||||
const STUD_UNIT = 1;
|
||||
const STUDS_GRID = 4;
|
||||
const _studsTexCache = new WeakMap();
|
||||
function _getStudsTextures(scene) {
|
||||
let c = _studsTexCache.get(scene);
|
||||
if (!c) {
|
||||
c = { diffuse: new Texture(STUDS_DIFFUSE_URL, scene), normal: new Texture(STUDS_NORMAL_URL, scene) };
|
||||
_studsTexCache.set(scene, c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
function _studsTiling(type, sx, sy, sz, density) {
|
||||
// density — множитель плотности кружков (1=стандарт, 2=вдвое мельче/чаще).
|
||||
const d = density && density > 0 ? density : 1;
|
||||
const f = (STUD_UNIT * STUDS_GRID) / d;
|
||||
let u = Math.max(sx, sz) / f;
|
||||
let v = sy / f;
|
||||
if (type === 'cylinder') { u = (Math.PI * sx) / f; v = sy / f; }
|
||||
else if (type === 'sphere') { u = (Math.PI * sx) / f; v = (Math.PI * sy) / f; }
|
||||
else if (type === 'plane') { u = sx / f; v = sz / f; }
|
||||
return { u: Math.max(0.25, u), v: Math.max(0.25, v) };
|
||||
}
|
||||
/**
|
||||
* faceUV для куба со studs — КАЖДАЯ грань тайлится по СВОИМ реальным размерам,
|
||||
* чтобы кружки были одного размера на всех гранях (не растягивались на длинных).
|
||||
* Грани CreateBox: 0=front(z-) 1=back(z+) 2=right(x+) 3=left(x-) 4=top(y+) 5=bottom(y-).
|
||||
* front/back → ширина=sx, высота=sy
|
||||
* left/right → ширина=sz, высота=sy
|
||||
* top/bottom → ширина=sx, высота=sz
|
||||
* UV-диапазон грани = (0,0)..(кол-во_studs_по_ширине, кол-во_по_высоте).
|
||||
*/
|
||||
function _studsCubeFaceUV(sx, sy, sz, density) {
|
||||
const d = density && density > 0 ? density : 1;
|
||||
const f = (STUD_UNIT * STUDS_GRID) / d;
|
||||
const nx = Math.max(0.25, sx / f); // studs вдоль X
|
||||
const ny = Math.max(0.25, sy / f); // studs вдоль Y
|
||||
const nz = Math.max(0.25, sz / f); // studs вдоль Z
|
||||
// Vector4(u0, v0, u1, v1)
|
||||
return [
|
||||
new Vector4(0, 0, nx, ny), // front (z-): X×Y
|
||||
new Vector4(0, 0, nx, ny), // back (z+): X×Y
|
||||
new Vector4(0, 0, nz, ny), // right (x+): Z×Y
|
||||
new Vector4(0, 0, nz, ny), // left (x-): Z×Y
|
||||
new Vector4(0, 0, nx, nz), // top (y+): X×Z
|
||||
new Vector4(0, 0, nx, nz), // bottom (y-): X×Z
|
||||
];
|
||||
}
|
||||
|
||||
export class PrimitiveManager {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
@ -73,6 +124,8 @@ export class PrimitiveManager {
|
||||
const isGlowingGd = isGdKind;
|
||||
const isGdSpike = typeDef.kind === 'gd_spike';
|
||||
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
|
||||
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
|
||||
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
|
||||
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
|
||||
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
|
||||
const visible = opts.visible !== false;
|
||||
@ -90,7 +143,7 @@ export class PrimitiveManager {
|
||||
const rotationY = opts.rotationY ?? 0;
|
||||
const rotationZ = opts.rotationZ ?? 0;
|
||||
|
||||
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz);
|
||||
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
|
||||
mesh.position = new Vector3(x, y, z);
|
||||
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
|
||||
mesh.isPickable = true;
|
||||
@ -103,6 +156,7 @@ export class PrimitiveManager {
|
||||
primitiveId: id,
|
||||
primitiveType: type,
|
||||
primitiveKind: typeDef.kind,
|
||||
canCollide, // нужен camera-clamp: камера не цепляется за зоны canCollide:false
|
||||
};
|
||||
|
||||
// textureAsset — id картинки из AssetManager (пользовательская
|
||||
@ -114,13 +168,15 @@ export class PrimitiveManager {
|
||||
id, mesh, type, x, y, z, sx, sy, sz,
|
||||
rotationX, rotationY, rotationZ,
|
||||
color, material, canCollide, visible, anchored, mass,
|
||||
textureAsset,
|
||||
textureAsset, studDensity,
|
||||
// locked — объект защищён от выделения/перемещения в редакторе
|
||||
// (Фаза 5.11). На геймплей не влияет.
|
||||
locked: opts.locked === true,
|
||||
name: opts.name || null,
|
||||
folderId: opts.folderId ?? null,
|
||||
};
|
||||
// Размеры для тайлинга studs-материала (читается в _applyMaterial).
|
||||
mesh._studsDims = { type, sx, sy, sz, density: studDensity };
|
||||
this._applyMaterial(mesh, typeDef, color, material);
|
||||
this._applyVisible(mesh, visible, typeDef);
|
||||
// Пользовательская текстура — поверх базового материала.
|
||||
@ -185,13 +241,17 @@ export class PrimitiveManager {
|
||||
}
|
||||
|
||||
/** Создать базовый mesh нужной формы (без материала). */
|
||||
_createMeshForType(typeDef, id, sx, sy, sz) {
|
||||
_createMeshForType(typeDef, id, sx, sy, sz, material, studDensity) {
|
||||
const name = `prim_${typeDef.id}_${id}`;
|
||||
switch (typeDef.id) {
|
||||
case 'cube':
|
||||
case 'trigger':
|
||||
return MeshBuilder.CreateBox(name,
|
||||
{ width: sx, height: sy, depth: sz }, this.scene);
|
||||
case 'trigger': {
|
||||
const boxOpts = { width: sx, height: sy, depth: sz };
|
||||
// studs — per-face UV, чтобы кружки были одного размера на всех
|
||||
// гранях (не растягивались на длинной стороне).
|
||||
if (material === 'studs') boxOpts.faceUV = _studsCubeFaceUV(sx, sy, sz, studDensity);
|
||||
return MeshBuilder.CreateBox(name, boxOpts, this.scene);
|
||||
}
|
||||
case 'sphere':
|
||||
return MeshBuilder.CreateSphere(name,
|
||||
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
|
||||
@ -425,12 +485,65 @@ export class PrimitiveManager {
|
||||
break;
|
||||
case 'glass':
|
||||
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;
|
||||
case 'neon':
|
||||
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
||||
mat.specularColor = new Color3(0, 0, 0);
|
||||
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': {
|
||||
// Лего-материал (паритет со студией): почти-белая diffuse × цвет
|
||||
// меша + normal map. emissive = доля цвета → сочность (Roblox-look).
|
||||
const tex = _getStudsTextures(this.scene);
|
||||
const dt = tex.diffuse.clone();
|
||||
const nt = tex.normal.clone();
|
||||
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
|
||||
// Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки
|
||||
// одного размера на всех гранях. Остальные формы — через uScale.
|
||||
if (dims.type === 'cube' || dims.type === 'trigger') {
|
||||
dt.uScale = nt.uScale = 1;
|
||||
dt.vScale = nt.vScale = 1;
|
||||
} else {
|
||||
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
|
||||
dt.uScale = nt.uScale = tile.u;
|
||||
dt.vScale = nt.vScale = tile.v;
|
||||
}
|
||||
mat.diffuseTexture = dt;
|
||||
mat.bumpTexture = nt;
|
||||
const sc = Color3.FromHexString(color || '#cccccc');
|
||||
mat.diffuseColor = sc;
|
||||
mat.emissiveColor = new Color3(sc.r * 0.45, sc.g * 0.45, sc.b * 0.45);
|
||||
mat.specularColor = new Color3(0, 0, 0);
|
||||
break;
|
||||
}
|
||||
case 'matte':
|
||||
default:
|
||||
mat.specularColor = new Color3(0, 0, 0);
|
||||
@ -576,6 +689,12 @@ export class PrimitiveManager {
|
||||
if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
|
||||
if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
|
||||
if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; }
|
||||
// Плотность studs (мелкие/крупные кружки) — требует пересоздания меша
|
||||
// (faceUV для куба зашит в геометрию).
|
||||
if (patch.studDensity !== undefined) {
|
||||
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
|
||||
scaleChanged = true;
|
||||
}
|
||||
if (scaleChanged) {
|
||||
// Поскольку MeshBuilder уже создал mesh с базовыми размерами,
|
||||
// изменения через scaling кажутся правильными. Простой способ —
|
||||
@ -601,6 +720,7 @@ export class PrimitiveManager {
|
||||
if (data.mesh.material) {
|
||||
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
|
||||
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
||||
}
|
||||
// Текстуру переприменяем если: сменили саму текстуру, или
|
||||
@ -614,10 +734,14 @@ export class PrimitiveManager {
|
||||
if (data.mesh.material) {
|
||||
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
|
||||
this._applyMaterial(data.mesh, typeDef, data.color, data.material);
|
||||
}
|
||||
|
||||
if (patch.canCollide !== undefined) data.canCollide = patch.canCollide;
|
||||
if (patch.canCollide !== undefined) {
|
||||
data.canCollide = patch.canCollide;
|
||||
if (data.mesh?.metadata) data.mesh.metadata.canCollide = patch.canCollide;
|
||||
}
|
||||
if (patch.locked !== undefined) data.locked = !!patch.locked;
|
||||
if (patch.visible !== undefined) {
|
||||
data.visible = patch.visible;
|
||||
@ -704,10 +828,17 @@ export class PrimitiveManager {
|
||||
const oldMat = oldMesh.material;
|
||||
|
||||
const typeDef = getPrimitiveType(data.type);
|
||||
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz);
|
||||
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
|
||||
newMesh.position = oldPos;
|
||||
if (oldRot) newMesh.rotation = oldRot;
|
||||
newMesh.material = oldMat;
|
||||
// studs — материал пересоздаём заново (свежий faceUV/тайлинг). Иначе перенос.
|
||||
if (data.material === 'studs') {
|
||||
newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz, density: data.studDensity };
|
||||
this._applyMaterial(newMesh, typeDef, data.color, data.material);
|
||||
try { oldMat?.dispose(); } catch (e) { /* ignore */ }
|
||||
} else {
|
||||
newMesh.material = oldMat;
|
||||
}
|
||||
newMesh.isPickable = true;
|
||||
newMesh.metadata = { ...oldMesh.metadata };
|
||||
newMesh.setEnabled(data.visible);
|
||||
@ -717,6 +848,7 @@ export class PrimitiveManager {
|
||||
catch (e) { /* ignore */ }
|
||||
|
||||
data.mesh = newMesh;
|
||||
// _studsDims и материал studs уже выставлены выше.
|
||||
}
|
||||
|
||||
/** Удалить инстанс. */
|
||||
@ -761,10 +893,13 @@ export class PrimitiveManager {
|
||||
anchored: d.anchored,
|
||||
mass: d.mass,
|
||||
name: d.name || null,
|
||||
...(d.folderId != null ? { folderId: d.folderId } : {}), // папка (парность со студией)
|
||||
// locked — защита от выделения/перемещения (Фаза 5.11).
|
||||
...(d.locked ? { locked: true } : {}),
|
||||
// id пользовательской текстуры (картинка из AssetManager).
|
||||
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
|
||||
// Плотность studs (если не 1) — мелкие/крупные кружки.
|
||||
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
|
||||
// Параметры лампы (только для type='light', иначе undefined)
|
||||
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
|
||||
// Параметр эмиттера (только для type='emitter')
|
||||
|
||||
@ -131,6 +131,18 @@ const ANIMS_STD = {
|
||||
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10,
|
||||
times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] },
|
||||
]),
|
||||
attack: makeAnim(0.5, true, [
|
||||
{ bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -95,
|
||||
times: [0.0, 0.15, 0.3, 0.5], values: [0.3, 1.0, 0.2, 0.3] },
|
||||
{ bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -50,
|
||||
times: [0.0, 0.15, 0.3, 0.5], values: [1.0, 0.2, 1.0, 1.0] },
|
||||
{ bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -45,
|
||||
times: [0.0, 0.5], values: [1.0, 1.0] },
|
||||
{ bone: B.LEFT_FOREARM, axis: AXIS_RIGHT, angleDeg: -70,
|
||||
times: [0.0, 0.5], values: [1.0, 1.0] },
|
||||
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: -12,
|
||||
times: [0.0, 0.15, 0.3, 0.5], values: [0.0, 1.0, 0.3, 0.0] },
|
||||
]),
|
||||
|
||||
// === ЭМОЦИИ (game.player.playAnimation) ===
|
||||
// Разовые анимации поверх авто-состояния. loop=false — играют один раз,
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -60,6 +60,7 @@ export class ScriptSandbox {
|
||||
target: this.target,
|
||||
selfPosition: this._initialSelfPosition || null,
|
||||
modules: this._modules || {},
|
||||
initialScene: this._initialScene || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -69,6 +70,11 @@ export class ScriptSandbox {
|
||||
this._initialSelfPosition = p;
|
||||
}
|
||||
|
||||
/** Первичный snapshot сцены (до start) — чтобы findOne работал на старте. */
|
||||
setInitialScene(snap) {
|
||||
this._initialScene = snap;
|
||||
}
|
||||
|
||||
/** Установить код модулей для game.require — { 'имя': 'код' }. Вызывать до start(). */
|
||||
setModules(modules) {
|
||||
this._modules = modules || {};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
132
src/engine/ShopInventoryUi.js
Normal file
132
src/engine/ShopInventoryUi.js
Normal file
@ -0,0 +1,132 @@
|
||||
/**
|
||||
* ShopInventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11).
|
||||
*
|
||||
* Нижняя (или боковая) панель кнопок-слотов с иконкой/названием/ценой и hover.
|
||||
* Клик по слоту → колбэк onSlotClick(item) — обычно автор вызывает внутри
|
||||
* game.placement.start(...). Слот серый и некликабельный, если валюты мало
|
||||
* (показывается, когда заданы showCurrency + текущий баланс через setBalance).
|
||||
*
|
||||
* Реализация — лёгкий DOM-оверлей поверх canvas (а не Babylon-GUI): кнопки с
|
||||
* иконками/hover/disabled делаются на HTML быстрее и доступнее. Крепится к
|
||||
* родителю canvas, абсолютным позиционированием.
|
||||
*
|
||||
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
|
||||
*/
|
||||
|
||||
// Встроенные inline-SVG иконки слотов (без эмодзи — самописные, см. правила UI).
|
||||
const SLOT_ICONS = {
|
||||
crate: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#7a4a1e" stroke-width="1.6"><rect x="3" y="6" width="18" height="13" rx="1" fill="#c2884a"/><path d="M3 10h18M9 6v13M15 6v13" stroke="#7a4a1e"/></svg>',
|
||||
plant: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none"><path d="M12 21V11" stroke="#4a7a2e" stroke-width="2"/><path d="M12 12c-3-1-5-4-5-7 3 0 6 2 5 7zM12 11c3-1 5-3 5-6-3 0-6 1-5 6z" fill="#5aa83a"/></svg>',
|
||||
oven: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#444" stroke-width="1.4"><rect x="4" y="3" width="16" height="18" rx="1.5" fill="#9aa0a6"/><rect x="7" y="9" width="10" height="9" rx="1" fill="#3a3f44"/><circle cx="9" cy="6" r="1" fill="#444"/><circle cx="13" cy="6" r="1" fill="#444"/></svg>',
|
||||
coin: '<svg viewBox="0 0 24 24" width="34" height="34"><circle cx="12" cy="12" r="9" fill="#f5c542" stroke="#b8860b" stroke-width="1.4"/><text x="12" y="16" font-size="10" text-anchor="middle" fill="#7a5a00" font-weight="700">$</text></svg>',
|
||||
box: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#666" stroke-width="1.6"><path d="M12 2l9 5v10l-9 5-9-5V7z" fill="#b0b6bc"/><path d="M12 2v20M3 7l9 5 9-5" /></svg>',
|
||||
};
|
||||
|
||||
function iconSvg(name) {
|
||||
return SLOT_ICONS[name] || SLOT_ICONS.box;
|
||||
}
|
||||
|
||||
export class ShopInventoryUi {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d;
|
||||
this.root = null;
|
||||
this.items = [];
|
||||
this.balance = {}; // currency → amount
|
||||
this.currency = '';
|
||||
this.showCost = true;
|
||||
this._onSlotClick = null;
|
||||
this._slotEls = [];
|
||||
}
|
||||
|
||||
create(opts, onSlotClick) {
|
||||
this.remove();
|
||||
this.items = Array.isArray(opts.items) ? opts.items : [];
|
||||
this.currency = opts.showCurrency || '';
|
||||
this.showCost = opts.showCost !== false;
|
||||
this._onSlotClick = typeof onSlotClick === 'function' ? onSlotClick : null;
|
||||
|
||||
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||
// Контейнер должен быть position:relative чтобы absolute-панель легла поверх.
|
||||
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
|
||||
|
||||
const pos = opts.position || 'bottom';
|
||||
const slotSize = Number(opts.slotSize) || 80;
|
||||
const spacing = Number(opts.spacing) || 4;
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'kbn-shop-inv';
|
||||
const sideStyle = {
|
||||
bottom: `left:50%;bottom:18px;transform:translateX(-50%);flex-direction:row;`,
|
||||
top: `left:50%;top:18px;transform:translateX(-50%);flex-direction:row;`,
|
||||
left: `left:18px;top:50%;transform:translateY(-50%);flex-direction:column;`,
|
||||
right: `right:18px;top:50%;transform:translateY(-50%);flex-direction:column;`,
|
||||
}[pos] || '';
|
||||
root.style.cssText =
|
||||
`position:absolute;display:flex;gap:${spacing}px;z-index:40;` +
|
||||
`padding:8px;border-radius:14px;` +
|
||||
`background:rgba(20,26,38,0.82);backdrop-filter:blur(4px);` +
|
||||
`box-shadow:0 6px 24px rgba(0,0,0,0.4);` + sideStyle;
|
||||
|
||||
this.items.forEach((it, idx) => {
|
||||
const slot = document.createElement('button');
|
||||
slot.type = 'button';
|
||||
slot.dataset.key = it.key;
|
||||
slot.style.cssText =
|
||||
`width:${slotSize}px;height:${slotSize}px;border:none;border-radius:11px;` +
|
||||
`display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;` +
|
||||
`cursor:pointer;color:#fff;font:600 12px/1.1 system-ui,sans-serif;` +
|
||||
`background:linear-gradient(180deg,#3a4a66,#26324a);` +
|
||||
`transition:transform .1s,box-shadow .1s,filter .1s;position:relative;`;
|
||||
slot.innerHTML =
|
||||
`<span style="pointer-events:none">${iconSvg(it.icon)}</span>` +
|
||||
`<span style="pointer-events:none;max-width:${slotSize - 8}px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${it.name || ''}</span>` +
|
||||
(this.showCost && it.cost
|
||||
? `<span class="kbn-cost" style="pointer-events:none;color:#ffd23a;font-size:11px">${it.cost}${this.currency ? ' ' + this._curShort() : ''}</span>`
|
||||
: '');
|
||||
slot.onmouseenter = () => { if (!slot.disabled) { slot.style.transform = 'translateY(-3px)'; slot.style.boxShadow = '0 6px 14px rgba(0,0,0,0.45)'; } };
|
||||
slot.onmouseleave = () => { slot.style.transform = ''; slot.style.boxShadow = ''; };
|
||||
slot.onclick = () => {
|
||||
if (slot.disabled) return;
|
||||
if (this._onSlotClick) this._onSlotClick(it);
|
||||
};
|
||||
this._slotEls[idx] = slot;
|
||||
root.appendChild(slot);
|
||||
});
|
||||
|
||||
parent.appendChild(root);
|
||||
this.root = root;
|
||||
this._refreshAffordability();
|
||||
}
|
||||
|
||||
_curShort() {
|
||||
const map = { rubles: 'руб', coins: 'мон', diamonds: 'алм' };
|
||||
return map[this.currency] || this.currency;
|
||||
}
|
||||
|
||||
/** Обновить баланс валюты — слоты дороже баланса станут серыми. */
|
||||
setBalance(currency, amount) {
|
||||
if (currency) this.balance[currency] = Number(amount) || 0;
|
||||
this._refreshAffordability();
|
||||
}
|
||||
|
||||
_refreshAffordability() {
|
||||
if (!this.currency) return; // без валюты все слоты активны
|
||||
const bal = this.balance[this.currency] != null ? this.balance[this.currency] : Infinity;
|
||||
this.items.forEach((it, idx) => {
|
||||
const slot = this._slotEls[idx];
|
||||
if (!slot) return;
|
||||
const afford = (Number(it.cost) || 0) <= bal;
|
||||
slot.disabled = !afford;
|
||||
slot.style.filter = afford ? '' : 'grayscale(1) brightness(0.6)';
|
||||
slot.style.cursor = afford ? 'pointer' : 'not-allowed';
|
||||
slot.style.opacity = afford ? '1' : '0.7';
|
||||
});
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; }
|
||||
this._slotEls = [];
|
||||
}
|
||||
|
||||
dispose() { this.remove(); this._onSlotClick = null; }
|
||||
}
|
||||
570
src/engine/SkyboxManager.js
Normal file
570
src/engine/SkyboxManager.js
Normal file
@ -0,0 +1,570 @@
|
||||
/**
|
||||
* SkyboxManager — кастомное небо для сцены (задача 16).
|
||||
*
|
||||
* Реализует процедурный gradient-skybox без внешних текстур (работает offline):
|
||||
* - купол-сфера (BACKSIDE) с ShaderMaterial: плавный градиент верх→низ,
|
||||
* солнечный диск, лёгкая дымка у горизонта;
|
||||
* - low-poly горы на горизонте (как в Roblox-эталоне);
|
||||
* - billboard-облака (плоскости, медленный дрейф);
|
||||
* - атмосферный туман (scene.fog).
|
||||
*
|
||||
* Пресеты: clear-summer-day / cloudy / sunset / starry-night / space /
|
||||
* lowpoly-roblox (+ gradient вручную). Плавный fadeTo между ними.
|
||||
*
|
||||
* API (через game.scene.*):
|
||||
* setSkybox({ preset } | { mode:'gradient', topColor, bottomColor, ... })
|
||||
* setClouds({ enabled, cover, density, speed, color })
|
||||
* setFog({ color, density, near, far } | enabled:false)
|
||||
* skybox.fadeTo(opts, durationSec)
|
||||
* skybox.setSunDirection({x,y,z})
|
||||
*
|
||||
* Фича-парность: при портировании в плеер — тот же модуль в rublox-player/src/engine/.
|
||||
*/
|
||||
import {
|
||||
Color3, Color4, Vector3,
|
||||
MeshBuilder, ShaderMaterial, Effect, StandardMaterial, Texture,
|
||||
DynamicTexture, VertexData, Mesh,
|
||||
} from '@babylonjs/core';
|
||||
|
||||
// ── Шейдер градиентного неба ──────────────────────────────────────────────
|
||||
// Купол изнутри: цвет интерполируется по высоте (y от -1 низ до +1 верх),
|
||||
// плюс солнечный диск и осветление у горизонта (дымка).
|
||||
const SKY_VERT = `
|
||||
precision highp float;
|
||||
attribute vec3 position;
|
||||
uniform mat4 worldViewProjection;
|
||||
varying vec3 vDir;
|
||||
void main(void){
|
||||
vDir = normalize(position);
|
||||
gl_Position = worldViewProjection * vec4(position, 1.0);
|
||||
}`;
|
||||
|
||||
const SKY_FRAG = `
|
||||
precision highp float;
|
||||
varying vec3 vDir;
|
||||
uniform vec3 topColor;
|
||||
uniform vec3 bottomColor;
|
||||
uniform vec3 horizonColor;
|
||||
uniform vec3 sunDir;
|
||||
uniform vec3 sunColor;
|
||||
uniform float sunSize; // 0..1 угловой радиус
|
||||
uniform float horizonHaze; // 0..1 сила дымки у горизонта
|
||||
void main(void){
|
||||
float h = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0); // 0 низ .. 1 верх
|
||||
// Градиент: низ→горизонт→верх (двойная интерполяция через горизонт у h~0.5)
|
||||
vec3 col;
|
||||
if (h < 0.5) {
|
||||
col = mix(bottomColor, horizonColor, h * 2.0);
|
||||
} else {
|
||||
col = mix(horizonColor, topColor, (h - 0.5) * 2.0);
|
||||
}
|
||||
// Дымка у горизонта — осветление узкой полосы около h=0.5
|
||||
float haze = smoothstep(0.5, 0.0, abs(h - 0.5)) * horizonHaze;
|
||||
col = mix(col, horizonColor + vec3(0.08), haze * 0.5);
|
||||
// Солнечный диск + гало
|
||||
float d = distance(normalize(vDir), normalize(sunDir));
|
||||
float disk = smoothstep(sunSize, sunSize * 0.4, d);
|
||||
float glow = smoothstep(sunSize * 6.0, 0.0, d) * 0.35;
|
||||
col += sunColor * disk;
|
||||
col += sunColor * glow;
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}`;
|
||||
|
||||
let _shaderRegistered = false;
|
||||
function registerSkyShader() {
|
||||
if (_shaderRegistered) return;
|
||||
Effect.ShadersStore['kubikonSkyVertexShader'] = SKY_VERT;
|
||||
Effect.ShadersStore['kubikonSkyFragmentShader'] = SKY_FRAG;
|
||||
_shaderRegistered = true;
|
||||
}
|
||||
|
||||
const hexToRgb = (hex) => {
|
||||
if (Array.isArray(hex)) return hex;
|
||||
let h = String(hex || '#ffffff').replace('#', '').trim();
|
||||
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
||||
if (h.length < 6) h = (h + 'ffffff').slice(0, 6);
|
||||
const r = parseInt(h.substring(0, 2), 16);
|
||||
const g = parseInt(h.substring(2, 4), 16);
|
||||
const b = parseInt(h.substring(4, 6), 16);
|
||||
return [
|
||||
(Number.isFinite(r) ? r : 255) / 255,
|
||||
(Number.isFinite(g) ? g : 255) / 255,
|
||||
(Number.isFinite(b) ? b : 255) / 255,
|
||||
];
|
||||
};
|
||||
|
||||
// ── Пресеты неба ──────────────────────────────────────────────────────────
|
||||
// top/bottom/horizon — цвета купола; sun — направление/цвет/размер солнца;
|
||||
// mountains — рисовать ли горы; clouds — параметры облаков; fog — туман;
|
||||
// stars — звёздное небо (для ночи/космоса).
|
||||
const PRESETS = {
|
||||
'clear-summer-day': {
|
||||
top: '#3d7fe0', horizon: '#bcd9f2', bottom: '#dcebf7',
|
||||
sunDir: [0.3, 0.85, 0.4], sunColor: '#fff6d8', sunSize: 0.035, haze: 0.6,
|
||||
mountains: false,
|
||||
clouds: { enabled: true, cover: 0.25, color: '#ffffff', speed: 0.015 },
|
||||
fog: { color: '#cfe2f2', density: 0.0035 },
|
||||
light: { sunIntensity: 1.0, sunColor: '#fff6e0', hemiIntensity: 0.7, ambient: '#b9d4ee' },
|
||||
},
|
||||
'lowpoly-roblox': {
|
||||
top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa',
|
||||
sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.85,
|
||||
mountains: true,
|
||||
clouds: { enabled: true, cover: 0.45, color: '#ffffff', speed: 0.012 },
|
||||
fog: { color: '#e2eef7', density: 0.005 },
|
||||
light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' },
|
||||
},
|
||||
'cloudy': {
|
||||
top: '#8fa6bd', horizon: '#c2ccd6', bottom: '#d8dde2',
|
||||
sunDir: [0.2, 0.7, 0.3], sunColor: '#e8e8e8', sunSize: 0.0, haze: 0.4,
|
||||
mountains: false,
|
||||
clouds: { enabled: true, cover: 0.8, color: '#e8ebef', speed: 0.02 },
|
||||
fog: { color: '#cfd6dd', density: 0.008 },
|
||||
light: { sunIntensity: 0.5, sunColor: '#dfe4ea', hemiIntensity: 0.8, ambient: '#c2ccd6' },
|
||||
},
|
||||
'sunset': {
|
||||
top: '#2a3a6b', horizon: '#f5915a', bottom: '#f7c98a',
|
||||
sunDir: [0.7, 0.12, -0.2], sunColor: '#ffd28a', sunSize: 0.05, haze: 1.0,
|
||||
mountains: true,
|
||||
clouds: { enabled: true, cover: 0.35, color: '#ffd9b3', speed: 0.01 },
|
||||
fog: { color: '#f0b483', density: 0.006 },
|
||||
light: { sunIntensity: 0.7, sunColor: '#ff9a52', hemiIntensity: 0.5, ambient: '#9a6a78' },
|
||||
},
|
||||
'starry-night': {
|
||||
top: '#070b1f', horizon: '#1b2547', bottom: '#243056',
|
||||
sunDir: [-0.4, 0.75, 0.3], sunColor: '#cdd6ff', sunSize: 0.02, haze: 0.3,
|
||||
mountains: true, stars: true,
|
||||
clouds: { enabled: false },
|
||||
fog: { color: '#141c38', density: 0.004 },
|
||||
light: { sunIntensity: 0.18, sunColor: '#9aa8d8', hemiIntensity: 0.25, ambient: '#1b2547' },
|
||||
},
|
||||
'space': {
|
||||
top: '#02030a', horizon: '#06070f', bottom: '#0a0c18',
|
||||
sunDir: [0.5, 0.6, 0.4], sunColor: '#ffffff', sunSize: 0.015, haze: 0.0,
|
||||
mountains: false, stars: true,
|
||||
clouds: { enabled: false },
|
||||
fog: { enabled: false },
|
||||
light: { sunIntensity: 0.6, sunColor: '#ffffff', hemiIntensity: 0.2, ambient: '#10121c' },
|
||||
},
|
||||
};
|
||||
|
||||
export class SkyboxManager {
|
||||
constructor(scene, hemiLight, sunLight) {
|
||||
this.scene = scene;
|
||||
this.hemiLight = hemiLight || null; // ambient
|
||||
this.sunLight = sunLight || null; // directional (тени)
|
||||
this._dome = null;
|
||||
this._mat = null;
|
||||
this._mountains = null;
|
||||
this._clouds = []; // [{mesh, baseX, speed}]
|
||||
this._cloudRoot = null;
|
||||
this._stars = null;
|
||||
this._fade = null; // активный fadeTo {from,to,t,dur}
|
||||
this._state = this._defaultState();
|
||||
registerSkyShader();
|
||||
this._buildDome();
|
||||
}
|
||||
|
||||
_defaultState() {
|
||||
return {
|
||||
mode: 'gradient',
|
||||
top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa',
|
||||
sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.8,
|
||||
mountains: false, stars: false,
|
||||
clouds: { enabled: false, cover: 0.4, color: '#ffffff', speed: 0.012 },
|
||||
fog: { enabled: false, color: '#dde8f2', density: 0.005 },
|
||||
light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Купол ──────────────────────────────────────────────────────────────
|
||||
_buildDome() {
|
||||
const dome = MeshBuilder.CreateSphere('kubikonSkyDome', {
|
||||
diameter: 1000, segments: 24, sideOrientation: Mesh.BACKSIDE,
|
||||
}, this.scene);
|
||||
dome.isPickable = false;
|
||||
dome.infiniteDistance = true; // не двигается с камерой
|
||||
dome.renderingGroupId = 0;
|
||||
dome.applyFog = false;
|
||||
|
||||
const mat = new ShaderMaterial('kubikonSkyMat', this.scene, {
|
||||
vertex: 'kubikonSky', fragment: 'kubikonSky',
|
||||
}, {
|
||||
attributes: ['position'],
|
||||
uniforms: ['worldViewProjection', 'topColor', 'bottomColor',
|
||||
'horizonColor', 'sunDir', 'sunColor', 'sunSize', 'horizonHaze'],
|
||||
});
|
||||
mat.backFaceCulling = false;
|
||||
mat.disableDepthWrite = true; // небо всегда позади
|
||||
dome.material = mat;
|
||||
this._dome = dome;
|
||||
this._mat = mat;
|
||||
this._applyShaderUniforms();
|
||||
}
|
||||
|
||||
_applyShaderUniforms() {
|
||||
const s = this._state;
|
||||
const m = this._mat;
|
||||
if (!m) return;
|
||||
m.setColor3('topColor', Color3.FromArray(hexToRgb(s.top)));
|
||||
m.setColor3('bottomColor', Color3.FromArray(hexToRgb(s.bottom)));
|
||||
m.setColor3('horizonColor', Color3.FromArray(hexToRgb(s.horizon)));
|
||||
const sd = Array.isArray(s.sunDir) ? s.sunDir : [0.4, 0.8, 0.45];
|
||||
m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize());
|
||||
m.setColor3('sunColor', Color3.FromArray(hexToRgb(s.sunColor)));
|
||||
m.setFloat('sunSize', s.sunSize || 0.03);
|
||||
m.setFloat('horizonHaze', s.haze != null ? s.haze : 0.7);
|
||||
}
|
||||
|
||||
// ── Горы (low-poly на горизонте) ────────────────────────────────────────
|
||||
_buildMountains(colorHex) {
|
||||
this._disposeMountains();
|
||||
const positions = [], indices = [];
|
||||
const ringR = 420, baseY = -10, segs = 64;
|
||||
// Кольцо из треугольных пиков переменной высоты — стилизованный силуэт.
|
||||
let vi = 0;
|
||||
for (let i = 0; i < segs; i++) {
|
||||
const a0 = (i / segs) * Math.PI * 2;
|
||||
const a1 = ((i + 1) / segs) * Math.PI * 2;
|
||||
const am = (a0 + a1) / 2;
|
||||
// Псевдослучайная высота пика (детерминированно от индекса).
|
||||
const hh = 40 + Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6)) * 130;
|
||||
const x0 = Math.cos(a0) * ringR, z0 = Math.sin(a0) * ringR;
|
||||
const x1 = Math.cos(a1) * ringR, z1 = Math.sin(a1) * ringR;
|
||||
const xm = Math.cos(am) * ringR, zm = Math.sin(am) * ringR;
|
||||
positions.push(x0, baseY, z0, x1, baseY, z1, xm, baseY + hh, zm);
|
||||
indices.push(vi, vi + 1, vi + 2);
|
||||
vi += 3;
|
||||
}
|
||||
const vd = new VertexData();
|
||||
vd.positions = positions; vd.indices = indices;
|
||||
const normals = [];
|
||||
VertexData.ComputeNormals(positions, indices, normals);
|
||||
vd.normals = normals;
|
||||
const mesh = new Mesh('kubikonSkyMountains', this.scene);
|
||||
vd.applyToMesh(mesh);
|
||||
mesh.isPickable = false;
|
||||
mesh.applyFog = true; // горы выцветают в туман (атмосфера)
|
||||
mesh.renderingGroupId = 0;
|
||||
const mat = new StandardMaterial('kubikonSkyMountainsMat', this.scene);
|
||||
const c = hexToRgb(colorHex || '#8fa98a');
|
||||
mat.diffuseColor = new Color3(c[0], c[1], c[2]);
|
||||
mat.specularColor = new Color3(0, 0, 0);
|
||||
mat.emissiveColor = new Color3(c[0] * 0.25, c[1] * 0.25, c[2] * 0.25);
|
||||
mesh.material = mat;
|
||||
this._mountains = mesh;
|
||||
}
|
||||
|
||||
_disposeMountains() {
|
||||
if (this._mountains) { this._mountains.material?.dispose(); this._mountains.dispose(); this._mountains = null; }
|
||||
}
|
||||
|
||||
// ── Облака (billboard-плоскости) ────────────────────────────────────────
|
||||
_buildClouds(opts) {
|
||||
this._disposeClouds();
|
||||
const o = opts || {};
|
||||
if (!o.enabled) return;
|
||||
const cover = o.cover != null ? o.cover : 0.4;
|
||||
const count = Math.round(4 + cover * 16); // 4..20 облаков
|
||||
const tex = this._makeCloudTexture(o.color || '#ffffff');
|
||||
for (let i = 0; i < count; i++) {
|
||||
const w = 60 + Math.random() * 90;
|
||||
const plane = MeshBuilder.CreatePlane(`kubikonCloud_${i}`, { width: w, height: w * 0.55 }, this.scene);
|
||||
plane.billboardMode = Mesh.BILLBOARDMODE_ALL;
|
||||
plane.isPickable = false;
|
||||
plane.applyFog = false;
|
||||
plane.renderingGroupId = 0;
|
||||
const mat = new StandardMaterial(`kubikonCloudMat_${i}`, this.scene);
|
||||
mat.diffuseTexture = tex;
|
||||
mat.opacityTexture = tex;
|
||||
mat.emissiveColor = new Color3(1, 1, 1);
|
||||
mat.disableLighting = true;
|
||||
mat.backFaceCulling = false;
|
||||
plane.material = mat;
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
const rad = 150 + Math.random() * 200;
|
||||
const x = Math.cos(ang) * rad;
|
||||
const z = Math.sin(ang) * rad;
|
||||
const y = 90 + Math.random() * 70;
|
||||
plane.position.set(x, y, z);
|
||||
this._clouds.push({ mesh: plane, speed: (o.speed || 0.012) * (0.6 + Math.random() * 0.8) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Процедурная мягкая текстура-облако (radial-gradient на DynamicTexture). */
|
||||
_makeCloudTexture(colorHex) {
|
||||
const size = 256;
|
||||
const dt = new DynamicTexture('kubikonCloudTex', { width: size, height: size }, this.scene, false);
|
||||
const ctx = dt.getContext();
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
const c = hexToRgb(colorHex);
|
||||
const rgb = `${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}`;
|
||||
// Несколько перекрывающихся мягких кругов → пухлое облако.
|
||||
const blobs = [[128, 140, 70], [90, 150, 50], [170, 150, 55], [128, 110, 55], [110, 130, 45], [150, 130, 45]];
|
||||
for (const [bx, by, br] of blobs) {
|
||||
const g = ctx.createRadialGradient(bx, by, 0, bx, by, br);
|
||||
g.addColorStop(0, `rgba(${rgb},0.9)`);
|
||||
g.addColorStop(0.6, `rgba(${rgb},0.5)`);
|
||||
g.addColorStop(1, `rgba(${rgb},0)`);
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
dt.hasAlpha = true;
|
||||
dt.update();
|
||||
return dt;
|
||||
}
|
||||
|
||||
_disposeClouds() {
|
||||
for (const c of this._clouds) { c.mesh.material?.diffuseTexture?.dispose?.(); c.mesh.material?.dispose(); c.mesh.dispose(); }
|
||||
this._clouds = [];
|
||||
}
|
||||
|
||||
// ── Звёзды (точки на куполе) ─────────────────────────────────────────────
|
||||
_buildStars(enabled) {
|
||||
this._disposeStars();
|
||||
if (!enabled) return;
|
||||
const size = 1024;
|
||||
const dt = new DynamicTexture('kubikonStarsTex', { width: size, height: size }, this.scene, false);
|
||||
const ctx = dt.getContext();
|
||||
ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.clearRect(0, 0, size, size);
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const x = Math.random() * size, y = Math.random() * size;
|
||||
const r = Math.random() * 1.4 + 0.3;
|
||||
const a = 0.4 + Math.random() * 0.6;
|
||||
ctx.fillStyle = `rgba(255,255,255,${a})`;
|
||||
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
dt.hasAlpha = true; dt.update();
|
||||
const dome = MeshBuilder.CreateSphere('kubikonStarsDome', {
|
||||
diameter: 980, segments: 16, sideOrientation: Mesh.BACKSIDE,
|
||||
}, this.scene);
|
||||
dome.isPickable = false; dome.infiniteDistance = true;
|
||||
dome.applyFog = false; dome.renderingGroupId = 0;
|
||||
const mat = new StandardMaterial('kubikonStarsMat', this.scene);
|
||||
mat.diffuseTexture = dt; mat.opacityTexture = dt;
|
||||
mat.emissiveColor = new Color3(1, 1, 1);
|
||||
mat.disableLighting = true; mat.backFaceCulling = false;
|
||||
mat.disableDepthWrite = true;
|
||||
dome.material = mat;
|
||||
this._stars = dome;
|
||||
}
|
||||
|
||||
_disposeStars() {
|
||||
if (this._stars) { this._stars.material?.diffuseTexture?.dispose?.(); this._stars.material?.dispose(); this._stars.dispose(); this._stars = null; }
|
||||
}
|
||||
|
||||
// ── Туман ────────────────────────────────────────────────────────────────
|
||||
_applyFog(fog) {
|
||||
if (!this.scene) return;
|
||||
if (fog && fog.enabled !== false && (fog.density != null || fog.color)) {
|
||||
this.scene.fogMode = 2; // EXP
|
||||
const c = hexToRgb(fog.color || '#dde8f2');
|
||||
this.scene.fogColor = new Color3(c[0], c[1], c[2]);
|
||||
this.scene.fogDensity = fog.density != null ? fog.density : 0.005;
|
||||
} else if (fog && fog.enabled === false) {
|
||||
this.scene.fogMode = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Освещение (единый источник: небо управляет светом сцены) ─────────────
|
||||
/** Выставить направление/яркость солнца и ambient под текущее небо. */
|
||||
_applyLighting(light, sunDir) {
|
||||
if (this.sunLight && sunDir) {
|
||||
// DirectionalLight.direction указывает КУДА падает свет → от солнца вниз.
|
||||
const d = new Vector3(-sunDir[0], -sunDir[1], -sunDir[2]);
|
||||
if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; }
|
||||
}
|
||||
if (!light) return;
|
||||
if (this.sunLight) {
|
||||
if (typeof light.sunIntensity === 'number') this.sunLight.intensity = light.sunIntensity;
|
||||
if (light.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(light.sunColor));
|
||||
}
|
||||
if (this.hemiLight) {
|
||||
if (typeof light.hemiIntensity === 'number') this.hemiLight.intensity = light.hemiIntensity;
|
||||
if (light.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(light.ambient));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Применить пресет или ручные опции gradient. */
|
||||
setSkybox(opts) {
|
||||
if (!opts) return;
|
||||
const preset = opts.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null;
|
||||
const s = this._state;
|
||||
if (preset) {
|
||||
s.top = preset.top; s.horizon = preset.horizon; s.bottom = preset.bottom;
|
||||
s.sunDir = preset.sunDir; s.sunColor = preset.sunColor; s.sunSize = preset.sunSize;
|
||||
s.haze = preset.haze; s.mountains = !!preset.mountains; s.stars = !!preset.stars;
|
||||
s.clouds = { ...(preset.clouds || { enabled: false }) };
|
||||
s.fog = { enabled: preset.fog?.enabled !== false, ...(preset.fog || {}) };
|
||||
s.light = preset.light || null;
|
||||
this._applyLighting(preset.light, preset.sunDir);
|
||||
} else {
|
||||
// Ручной gradient: { topColor, bottomColor, horizonColor, sunDirection, sunColor, sunSize }
|
||||
if (opts.topColor) s.top = opts.topColor;
|
||||
if (opts.bottomColor) s.bottom = opts.bottomColor;
|
||||
if (opts.horizonColor) s.horizon = opts.horizonColor;
|
||||
if (opts.sunDirection) s.sunDir = [opts.sunDirection.x, opts.sunDirection.y, opts.sunDirection.z];
|
||||
if (opts.sunColor) s.sunColor = opts.sunColor;
|
||||
if (typeof opts.sunSize === 'number') s.sunSize = opts.sunSize;
|
||||
if (typeof opts.haze === 'number') s.haze = opts.haze;
|
||||
if (typeof opts.mountains === 'boolean') s.mountains = opts.mountains;
|
||||
if (typeof opts.stars === 'boolean') s.stars = opts.stars;
|
||||
}
|
||||
this._rebuildAll();
|
||||
}
|
||||
|
||||
/** Облака поверх любого режима. */
|
||||
setClouds(opts) {
|
||||
if (!opts) return;
|
||||
this._state.clouds = { ...this._state.clouds, ...opts };
|
||||
if (this._state.clouds.enabled == null) this._state.clouds.enabled = true;
|
||||
this._buildClouds(this._state.clouds);
|
||||
}
|
||||
|
||||
/** Атмосферный туман. */
|
||||
setFog(opts) {
|
||||
if (!opts) { return; }
|
||||
this._state.fog = { ...this._state.fog, ...opts };
|
||||
if (opts.enabled == null) this._state.fog.enabled = true;
|
||||
this._applyFog(this._state.fog);
|
||||
}
|
||||
|
||||
/** Установить направление солнца (для программной анимации). */
|
||||
setSunDirection(dir) {
|
||||
if (!dir) return;
|
||||
this._state.sunDir = [dir.x, dir.y, dir.z];
|
||||
this._applyShaderUniforms();
|
||||
}
|
||||
|
||||
/** Плавный переход к пресету/опциям за durationSec секунд (цвета купола + туман). */
|
||||
fadeTo(opts, durationSec = 2) {
|
||||
const target = opts?.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null;
|
||||
if (!target) { this.setSkybox(opts); return; }
|
||||
// Запоминаем стартовые цвета и целевые — анимируем в tick().
|
||||
this._fade = {
|
||||
t: 0, dur: Math.max(0.1, durationSec),
|
||||
from: {
|
||||
top: hexToRgb(this._state.top), horizon: hexToRgb(this._state.horizon),
|
||||
bottom: hexToRgb(this._state.bottom), sunColor: hexToRgb(this._state.sunColor),
|
||||
sunDir: this._state.sunDir.slice(), sunSize: this._state.sunSize, haze: this._state.haze,
|
||||
},
|
||||
to: {
|
||||
top: hexToRgb(target.top), horizon: hexToRgb(target.horizon),
|
||||
bottom: hexToRgb(target.bottom), sunColor: hexToRgb(target.sunColor),
|
||||
sunDir: target.sunDir.slice(), sunSize: target.sunSize, haze: target.haze,
|
||||
},
|
||||
target,
|
||||
};
|
||||
// Немедленно перестраиваем «дискретные» части (горы/звёзды/облака/туман
|
||||
// целевого пресета появляются сразу, цвета купола — плавно).
|
||||
const s = this._state;
|
||||
s.mountains = !!target.mountains; s.stars = !!target.stars;
|
||||
s.clouds = { ...(target.clouds || { enabled: false }) };
|
||||
s.fog = { enabled: target.fog?.enabled !== false, ...(target.fog || {}) };
|
||||
s.light = target.light || null;
|
||||
this._rebuildExtras();
|
||||
// Запоминаем стартовые/целевые значения света для плавной анимации.
|
||||
if (target.light) {
|
||||
this._fade.lightFrom = {
|
||||
sunInt: this.sunLight?.intensity ?? 1,
|
||||
hemiInt: this.hemiLight?.intensity ?? 0.7,
|
||||
};
|
||||
this._fade.lightTo = {
|
||||
sunInt: target.light.sunIntensity ?? 1,
|
||||
hemiInt: target.light.hemiIntensity ?? 0.7,
|
||||
sunColor: target.light.sunColor, ambient: target.light.ambient,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман + свет). */
|
||||
_rebuildAll() {
|
||||
this._applyShaderUniforms();
|
||||
this._rebuildExtras();
|
||||
this._applyLighting(this._state.light, this._state.sunDir);
|
||||
}
|
||||
|
||||
_rebuildExtras() {
|
||||
const s = this._state;
|
||||
if (s.mountains) {
|
||||
// Цвет гор подбираем под пресет (днём зеленовато-серый, ночью тёмный).
|
||||
const mc = s.stars ? '#2a3550' : '#8fa98a';
|
||||
this._buildMountains(mc);
|
||||
} else this._disposeMountains();
|
||||
this._buildStars(!!s.stars);
|
||||
this._buildClouds(s.clouds);
|
||||
this._applyFog(s.fog);
|
||||
}
|
||||
|
||||
/** Вызывать каждый кадр: дрейф облаков + анимация fadeTo. */
|
||||
tick(dt) {
|
||||
// Дрейф облаков по кругу.
|
||||
for (const c of this._clouds) {
|
||||
c.mesh.position.x += c.speed * dt * 60;
|
||||
if (c.mesh.position.x > 380) c.mesh.position.x = -380;
|
||||
}
|
||||
// Анимация перехода неба.
|
||||
if (this._fade) {
|
||||
this._fade.t += dt;
|
||||
const k = Math.min(1, this._fade.t / this._fade.dur);
|
||||
const f = this._fade.from, t = this._fade.to, m = this._mat;
|
||||
const mix = (a, b) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
|
||||
if (m) {
|
||||
m.setColor3('topColor', Color3.FromArray(mix(f.top, t.top)));
|
||||
m.setColor3('bottomColor', Color3.FromArray(mix(f.bottom, t.bottom)));
|
||||
m.setColor3('horizonColor', Color3.FromArray(mix(f.horizon, t.horizon)));
|
||||
m.setColor3('sunColor', Color3.FromArray(mix(f.sunColor, t.sunColor)));
|
||||
const sd = mix(f.sunDir, t.sunDir);
|
||||
m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize());
|
||||
m.setFloat('sunSize', f.sunSize + (t.sunSize - f.sunSize) * k);
|
||||
m.setFloat('horizonHaze', f.haze + (t.haze - f.haze) * k);
|
||||
// Плавно ведём направление солнца (свет) к целевому (используем sd выше).
|
||||
if (this.sunLight) {
|
||||
const d = new Vector3(-sd[0], -sd[1], -sd[2]);
|
||||
if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; }
|
||||
}
|
||||
}
|
||||
// Плавно ведём яркость/ambient света.
|
||||
if (this._fade.lightFrom && this._fade.lightTo) {
|
||||
const lf = this._fade.lightFrom, lt = this._fade.lightTo;
|
||||
if (this.sunLight) {
|
||||
this.sunLight.intensity = lf.sunInt + (lt.sunInt - lf.sunInt) * k;
|
||||
if (lt.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(lt.sunColor));
|
||||
}
|
||||
if (this.hemiLight) {
|
||||
this.hemiLight.intensity = lf.hemiInt + (lt.hemiInt - lf.hemiInt) * k;
|
||||
if (lt.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(lt.ambient));
|
||||
}
|
||||
}
|
||||
if (k >= 1) {
|
||||
// Зафиксировать целевое состояние в _state (как hex).
|
||||
const tp = this._fade.target;
|
||||
Object.assign(this._state, {
|
||||
top: tp.top, horizon: tp.horizon, bottom: tp.bottom,
|
||||
sunColor: tp.sunColor, sunDir: tp.sunDir.slice(),
|
||||
sunSize: tp.sunSize, haze: tp.haze,
|
||||
});
|
||||
this._fade = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return { ...this._state, _active: true };
|
||||
}
|
||||
|
||||
load(data) {
|
||||
if (!data) return;
|
||||
this._state = { ...this._defaultState(), ...data };
|
||||
this._rebuildAll();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._disposeMountains();
|
||||
this._disposeClouds();
|
||||
this._disposeStars();
|
||||
if (this._dome) { this._mat?.dispose(); this._dome.dispose(); this._dome = null; }
|
||||
}
|
||||
}
|
||||
@ -514,6 +514,10 @@ export class TerrainManager {
|
||||
const mat = new StandardMaterial(name, this.scene);
|
||||
// Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль.
|
||||
mat.specularColor = new Color3(0, 0, 0);
|
||||
// 2026-06-02: воксели «просвечивали» (видна задняя грань сквозь переднюю).
|
||||
// backFaceCulling=false рисует обе стороны, ближняя перекрывает дальнюю
|
||||
// по depth. Прозрачным (water/glacier) culling оставляем. См. studio.
|
||||
mat.backFaceCulling = !(def.alpha != null && def.alpha < 1) ? false : true;
|
||||
// Ambient ставим в белый, чтобы hemisphere-light освещал материал
|
||||
// с любой стороны (иначе нижние/тыловые грани выходят серыми, что
|
||||
// особенно заметно на светло-бежевом песке — он становится серым).
|
||||
@ -543,6 +547,12 @@ export class TerrainManager {
|
||||
mat.diffuseTexture.hasAlpha = true;
|
||||
mat.useAlphaFromDiffuseTexture = true;
|
||||
mat.alpha = def.alpha;
|
||||
} else {
|
||||
// RGBA-текстуры (alpha=255) Babylon мог рендерить с alpha-blend →
|
||||
// воксели просвечивали. Явно OPAQUE для непрозрачных. См. studio.
|
||||
mat.diffuseTexture.hasAlpha = false;
|
||||
mat.useAlphaFromDiffuseTexture = false;
|
||||
mat.transparencyMode = 0;
|
||||
}
|
||||
if (Array.isArray(def.emissive)) {
|
||||
mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]);
|
||||
|
||||
@ -599,6 +599,7 @@ export class UserModelManager {
|
||||
// instanceId — чтобы target-скрипты могли стабильно ссылаться
|
||||
// на конкретный инстанс после перезагрузки.
|
||||
instanceId: inst.instanceId,
|
||||
...(inst.folderId != null ? { folderId: inst.folderId } : {}),
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
@ -663,7 +664,13 @@ export class UserModelManager {
|
||||
forceInstanceId: item.instanceId,
|
||||
},
|
||||
);
|
||||
if (id != null) loaded++;
|
||||
if (id != null) {
|
||||
loaded++;
|
||||
if (item.folderId != null) {
|
||||
const inst = this.instances.get(id);
|
||||
if (inst) inst.folderId = item.folderId;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[UserModelManager] failed to load instance', item, e);
|
||||
}
|
||||
|
||||
95
src/engine/VehicleHud.js
Normal file
95
src/engine/VehicleHud.js
Normal file
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* VehicleHud — HUD водителя (задача 14): круглый спидометр со стрелкой,
|
||||
* передача (D/R/N), подсказки клавиш. DOM-оверлей поверх canvas (как
|
||||
* ShopInventoryUi). Показывается пока игрок за рулём.
|
||||
*
|
||||
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
|
||||
*/
|
||||
export class VehicleHud {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d;
|
||||
this.root = null;
|
||||
this.needle = null;
|
||||
this.speedText = null;
|
||||
this.gearText = null;
|
||||
this._maxKmh = 80;
|
||||
}
|
||||
|
||||
show(maxKmh) {
|
||||
this.remove();
|
||||
this._maxKmh = Math.max(20, Math.round((maxKmh || 14) * 3.6 / 10) * 10 + 10);
|
||||
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'kbn-veh-hud';
|
||||
root.style.cssText =
|
||||
'position:absolute;left:24px;bottom:22px;z-index:45;width:160px;height:160px;' +
|
||||
'pointer-events:none;font-family:system-ui,"Segoe UI",sans-serif;user-select:none;';
|
||||
|
||||
// SVG-циферблат.
|
||||
const R = 70, CX = 80, CY = 80;
|
||||
const startA = 135, endA = 405; // дуга 270°
|
||||
const ticks = [];
|
||||
const N = 8;
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const a = (startA + (endA - startA) * i / N) * Math.PI / 180;
|
||||
const x1 = CX + Math.cos(a) * (R - 4), y1 = CY + Math.sin(a) * (R - 4);
|
||||
const x2 = CX + Math.cos(a) * (R - 14), y2 = CY + Math.sin(a) * (R - 14);
|
||||
ticks.push(`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="#c8d0dc" stroke-width="2"/>`);
|
||||
const lx = CX + Math.cos(a) * (R - 26), ly = CY + Math.sin(a) * (R - 26) + 4;
|
||||
const val = Math.round(this._maxKmh * i / N);
|
||||
ticks.push(`<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" fill="#9aa6b8" font-size="9" text-anchor="middle">${val}</text>`);
|
||||
}
|
||||
root.innerHTML =
|
||||
`<svg viewBox="0 0 160 160" width="160" height="160">` +
|
||||
`<circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(16,20,32,0.82)" stroke="#3a4760" stroke-width="3"/>` +
|
||||
ticks.join('') +
|
||||
`<line id="kbn-veh-needle" x1="${CX}" y1="${CY}" x2="${CX}" y2="${CY - R + 18}" stroke="#ff5a3c" stroke-width="3.5" stroke-linecap="round" transform="rotate(-135 ${CX} ${CY})"/>` +
|
||||
`<circle cx="${CX}" cy="${CY}" r="6" fill="#ff5a3c"/>` +
|
||||
`<text id="kbn-veh-speed" x="${CX}" y="${CY + 30}" fill="#ffe44a" font-size="22" font-weight="800" text-anchor="middle">0</text>` +
|
||||
`<text x="${CX}" y="${CY + 44}" fill="#9aa6b8" font-size="9" text-anchor="middle">км/ч</text>` +
|
||||
`<text id="kbn-veh-gear" x="${CX}" y="${CY - 16}" fill="#7fe0a0" font-size="18" font-weight="900" text-anchor="middle">N</text>` +
|
||||
`</svg>`;
|
||||
parent.appendChild(root);
|
||||
this.root = root;
|
||||
this.needle = root.querySelector('#kbn-veh-needle');
|
||||
this.speedText = root.querySelector('#kbn-veh-speed');
|
||||
this.gearText = root.querySelector('#kbn-veh-gear');
|
||||
this._CX = CX; this._CY = CY;
|
||||
|
||||
// Подсказки клавиш справа снизу.
|
||||
const keys = document.createElement('div');
|
||||
keys.className = 'kbn-veh-keys';
|
||||
keys.style.cssText =
|
||||
'position:absolute;right:24px;bottom:28px;z-index:45;pointer-events:none;' +
|
||||
'color:#cfd6e0;font:600 14px/1.6 system-ui,sans-serif;text-align:right;' +
|
||||
'text-shadow:0 1px 3px rgba(0,0,0,0.7);';
|
||||
keys.innerHTML = '<div><b>WASD</b> — руль</div><div><b>V</b> — камера</div><div><b>E</b> — выйти</div>';
|
||||
parent.appendChild(keys);
|
||||
this._keys = keys;
|
||||
}
|
||||
|
||||
/** Обновить стрелку/число/передачу. speed — м/с (signed). */
|
||||
update(speedMs) {
|
||||
if (!this.needle) return;
|
||||
const kmh = Math.abs(speedMs) * 3.6;
|
||||
const frac = Math.max(0, Math.min(1, kmh / this._maxKmh));
|
||||
const ang = -135 + 270 * frac; // -135°..+135°
|
||||
this.needle.setAttribute('transform', `rotate(${ang.toFixed(1)} ${this._CX} ${this._CY})`);
|
||||
if (this.speedText) this.speedText.textContent = String(Math.round(kmh));
|
||||
if (this.gearText) {
|
||||
const g = speedMs < -0.3 ? 'R' : (Math.abs(speedMs) < 0.3 ? 'N' : 'D');
|
||||
this.gearText.textContent = g;
|
||||
this.gearText.setAttribute('fill', g === 'R' ? '#ff7a5a' : g === 'N' ? '#9aa6b8' : '#7fe0a0');
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; }
|
||||
if (this._keys) { try { this._keys.remove(); } catch { /* ignore */ } this._keys = null; }
|
||||
this.needle = this.speedText = this.gearText = null;
|
||||
}
|
||||
|
||||
dispose() { this.remove(); }
|
||||
}
|
||||
249
src/engine/VehicleManager.js
Normal file
249
src/engine/VehicleManager.js
Normal file
@ -0,0 +1,249 @@
|
||||
import { Vector3, TransformNode } from '@babylonjs/core';
|
||||
|
||||
/**
|
||||
* VehicleManager — система транспорта (задача 14, фаза V1 аркадная + V2 параметры).
|
||||
*
|
||||
* Каждая машина = chassisNode (TransformNode) + GLB-кузов (modelManager-инстанс) +
|
||||
* 4 колеса-визуала (передние доворачивают при руле). Физика АРКАДНАЯ:
|
||||
* speed (скаляр вдоль yaw) += throttle*power*dt; трение; поворот по steer
|
||||
* (масштаб от скорости — нет вращения на месте); коллизия с миром через
|
||||
* physics.moveAABB (тот же солвер что у игрока). Колёса друг с другом и с
|
||||
* другими машинами НЕ сталкиваются (V1) — только chassis с миром.
|
||||
*
|
||||
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
|
||||
*/
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
mass: 1200,
|
||||
enginePower: 14, // ускорение (м/с²) — аркадно, не реальные л.с.
|
||||
maxSpeed: 14, // м/с (~50 км/ч) — для маленьких миров
|
||||
turnSpeed: 1.8, // рад/с при полной скорости
|
||||
brake: 26, // замедление при тормозе/реверсе
|
||||
drive: 'rwd',
|
||||
};
|
||||
|
||||
export class VehicleManager {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d;
|
||||
this.scene = scene3d.scene;
|
||||
this.vehicles = new Map(); // id → veh
|
||||
this._seq = 0;
|
||||
}
|
||||
|
||||
get _physics() { return this.s.physics; }
|
||||
get _models() { return this.s.modelManager; }
|
||||
|
||||
/**
|
||||
* Создать машину. opts: { model:'car-taxi', color, name, params, x,y,z, rotationY }.
|
||||
* Возвращает Promise<id>.
|
||||
*/
|
||||
async spawn(opts) {
|
||||
opts = opts || {};
|
||||
const x0 = Number(opts.x) || 0, z0 = Number(opts.z) || 0;
|
||||
// Идемпотентность: если машина с такой позицией уже есть — не плодим
|
||||
// (защита от двойного выполнения скрипта спавна → дубли машин).
|
||||
for (const v of this.vehicles.values()) {
|
||||
if (Math.abs(v.spawnX - x0) < 0.5 && Math.abs(v.spawnZ - z0) < 0.5) return v.id;
|
||||
}
|
||||
const id = ++this._seq;
|
||||
const x = Number(opts.x) || 0, y = Number(opts.y) || 0.4, z = Number(opts.z) || 0;
|
||||
const yaw = Number(opts.rotationY) || 0;
|
||||
const params = { ...DEFAULT_PARAMS, ...(opts.params || {}) };
|
||||
const modelType = opts.model || 'car-sedan';
|
||||
|
||||
// chassis-узел — родитель кузова и колёс.
|
||||
const chassisNode = new TransformNode(`vehicle_${id}`, this.scene);
|
||||
chassisNode.position = new Vector3(x, y, z);
|
||||
chassisNode.rotation = new Vector3(0, yaw, 0);
|
||||
|
||||
const veh = {
|
||||
id, name: opts.name || 'Машина', params,
|
||||
spawnX: x, spawnZ: z, // для дедупа повторного спавна
|
||||
chassisNode, bodyInstanceId: null, wheels: [],
|
||||
pos: new Vector3(x, y, z), yaw, vy: 0,
|
||||
speed: 0, steerAngle: 0,
|
||||
half: { w: 1.0, h: 0.6, d: 2.0 }, // уточним по bbox кузова
|
||||
throttle: 0, steer: 0, handbrake: false,
|
||||
driver: null,
|
||||
handlers: { onEnter: [], onExit: [], onCollide: [], onSpeedChange: [] },
|
||||
ref: opts.ref || null,
|
||||
};
|
||||
this.vehicles.set(id, veh);
|
||||
|
||||
// Кузов (GLB Kenney car-kit).
|
||||
try {
|
||||
const bodyId = await this._models.addInstance(modelType, x, y, z, yaw);
|
||||
veh.bodyInstanceId = bodyId;
|
||||
const inst = this._models.instances.get(bodyId);
|
||||
if (inst && inst.rootMesh) {
|
||||
// Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга
|
||||
// (в мировых координатах, кузов ещё в (x,y,z)).
|
||||
try {
|
||||
const bb = inst.rootMesh.getHierarchyBoundingVectors(true);
|
||||
veh.half = {
|
||||
w: Math.max(0.6, (bb.max.x - bb.min.x) / 2),
|
||||
h: Math.max(0.4, (bb.max.y - bb.min.y) / 2),
|
||||
d: Math.max(1.0, (bb.max.z - bb.min.z) / 2),
|
||||
};
|
||||
// Насколько низ кузова ниже точки спавна y — чтобы посадить
|
||||
// кузов так, чтобы его НИЗ совпал с низом AABB (машина на земле,
|
||||
// не парит). bodyYOffset применяется к локальной Y кузова.
|
||||
veh.bodyYOffset = -(bb.min.y - y) - veh.half.h;
|
||||
} catch (e) { veh.bodyYOffset = -veh.half.h; }
|
||||
inst.rootMesh.setParent(chassisNode);
|
||||
inst.rootMesh.position = new Vector3(0, veh.bodyYOffset || 0, 0);
|
||||
inst.rootMesh.rotation = Vector3.Zero();
|
||||
// Цвет кузова (tint поверх GLB-текстуры).
|
||||
if (opts.color) { try { this._models.setInstanceProps?.(bodyId, { tint: opts.color }); } catch (e) {} }
|
||||
}
|
||||
} catch (e) { console.warn('[VehicleManager] body load failed', e); }
|
||||
|
||||
// Колёса НЕ спавним отдельно — GLB-модели Kenney car-kit уже содержат
|
||||
// колёса в кузове. Отдельные колёса дублировали/отрывались (баг V1).
|
||||
// Визуальный доворот передних колёс — фаза V3 (там кузов+колёса раздельно).
|
||||
|
||||
// «Оседание»: уроним машину на землю СРАЗУ (до посадки игрока), иначе она
|
||||
// висит/утоплена на стартовой y, пока никто не за рулём (нет tick).
|
||||
this._settle(veh);
|
||||
// Повторное оседание на следующих кадрах: физический грид статики может
|
||||
// ещё не проиндексироваться к моменту спавна (await addInstance), тогда
|
||||
// первый _settle не находит пол и машина зависает в воздухе (баг седана).
|
||||
for (const d of [120, 350, 800]) {
|
||||
setTimeout(() => { try { if (!veh.driver) this._settle(veh); } catch (e) {} }, d);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Опустить машину на поверхность гравитацией. Стартуем ВЫШЕ текущей точки и
|
||||
* роняем большим запасом (много шагов), чтобы гарантированно найти пол даже
|
||||
* если стартовая y оказалась чуть ниже/выше или физика поздно готова.
|
||||
*/
|
||||
_settle(veh) {
|
||||
try {
|
||||
veh.pos.y += 0.5;
|
||||
let landed = false;
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.25, 0);
|
||||
veh.pos.set(r.x, r.y, r.z);
|
||||
if (r.hitY) { landed = true; break; }
|
||||
}
|
||||
if (landed) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.04, 0);
|
||||
veh.pos.set(r.x, r.y, r.z);
|
||||
if (r.hitY) break;
|
||||
}
|
||||
}
|
||||
veh.vy = 0;
|
||||
veh.chassisNode.position.copyFrom(veh.pos);
|
||||
veh.chassisNode.rotation.y = veh.yaw;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
getById(id) { return this.vehicles.get(id) || null; }
|
||||
|
||||
/** Установить ввод водителя (из PlayerController). */
|
||||
setInput(veh, throttle, steer, handbrake) {
|
||||
if (!veh) return;
|
||||
veh.throttle = Math.max(-1, Math.min(1, throttle || 0));
|
||||
veh.steer = Math.max(-1, Math.min(1, steer || 0));
|
||||
veh.handbrake = !!handbrake;
|
||||
}
|
||||
|
||||
/** Физический шаг машины (вызывается каждый кадр пока есть водитель). */
|
||||
tickVehicle(veh, dt) {
|
||||
if (!veh) return;
|
||||
dt = Math.min(dt, 1 / 30);
|
||||
const p = veh.params;
|
||||
const prevSpeed = veh.speed;
|
||||
|
||||
// Ускорение / торможение / реверс.
|
||||
if (veh.throttle > 0) {
|
||||
veh.speed += veh.throttle * p.enginePower * dt;
|
||||
} else if (veh.throttle < 0) {
|
||||
// S: сначала тормоз, потом задний ход (ограничен).
|
||||
if (veh.speed > 0.2) veh.speed -= p.brake * dt;
|
||||
else veh.speed += veh.throttle * p.enginePower * 0.5 * dt;
|
||||
}
|
||||
// Накат-трение.
|
||||
veh.speed *= (1 - 1.2 * dt);
|
||||
if (veh.handbrake) veh.speed *= (1 - 6 * dt);
|
||||
// Клампы.
|
||||
const maxFwd = p.maxSpeed, maxRev = p.maxSpeed * 0.4;
|
||||
if (veh.speed > maxFwd) veh.speed = maxFwd;
|
||||
if (veh.speed < -maxRev) veh.speed = -maxRev;
|
||||
if (Math.abs(veh.speed) < 0.05) veh.speed = 0;
|
||||
|
||||
// Поворот (зависит от скорости — нельзя крутиться на месте).
|
||||
const speedFrac = veh.speed / maxFwd;
|
||||
veh.yaw += veh.steer * p.turnSpeed * speedFrac * dt;
|
||||
// Угол доворота передних колёс (визуал) — плавный lerp.
|
||||
const targetSteer = veh.steer * 0.5;
|
||||
veh.steerAngle += (targetSteer - veh.steerAngle) * Math.min(1, dt * 8);
|
||||
|
||||
// Направление и перемещение.
|
||||
const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
|
||||
const moveX = dir.x * veh.speed * dt;
|
||||
const moveZ = dir.z * veh.speed * dt;
|
||||
// Гравитация (машина сидит на полу/дороге).
|
||||
veh.vy += -22 * dt;
|
||||
|
||||
// Коллизия с миром через тот же солвер что у игрока.
|
||||
let res;
|
||||
try {
|
||||
res = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, moveX, veh.vy * dt, moveZ);
|
||||
} catch (e) {
|
||||
res = { x: veh.pos.x + moveX, y: veh.pos.y, z: veh.pos.z + moveZ, hitX: false, hitY: false, hitZ: false };
|
||||
}
|
||||
veh.pos.set(res.x, res.y, res.z);
|
||||
if (res.hitY) veh.vy = 0;
|
||||
// Удар об стену — гасим ход.
|
||||
if (res.hitX || res.hitZ) {
|
||||
const force = Math.abs(veh.speed);
|
||||
veh.speed *= 0.3;
|
||||
for (const fn of veh.handlers.onCollide) { try { fn(force); } catch (e) {} }
|
||||
}
|
||||
|
||||
// Применить к узлам.
|
||||
veh.chassisNode.position.copyFrom(veh.pos);
|
||||
veh.chassisNode.rotation.y = veh.yaw;
|
||||
// Колёса: передние доворачивают, все катятся.
|
||||
const roll = (veh.speed * dt) / 0.4;
|
||||
for (const w of veh.wheels) {
|
||||
if (w.isFront) w.node.rotation.y = veh.steerAngle;
|
||||
w.node.rotation.x = (w.node.rotation.x + roll) % (Math.PI * 2);
|
||||
}
|
||||
|
||||
if (Math.abs(veh.speed - prevSpeed) > 0.01) {
|
||||
for (const fn of veh.handlers.onSpeedChange) { try { fn(Math.abs(veh.speed)); } catch (e) {} }
|
||||
}
|
||||
// Падение в бездну — сигнал PlayerController высадить + респавн.
|
||||
if (veh.pos.y < -25) return { fellOut: true };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Текущая скорость машины в м/с (для спидометра). */
|
||||
speedOf(veh) { return veh ? Math.abs(veh.speed) : 0; }
|
||||
|
||||
applyImpulse(veh, v) {
|
||||
if (!veh || !v) return;
|
||||
// Простой импульс: вертикальная составляющая в vy, горизонтальная в speed по направлению.
|
||||
if (Number.isFinite(v.y)) veh.vy += Number(v.y);
|
||||
const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
|
||||
const horiz = (Number(v.x) || 0) * dir.x + (Number(v.z) || 0) * dir.z;
|
||||
veh.speed += horiz;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const veh of this.vehicles.values()) {
|
||||
try {
|
||||
if (veh.bodyInstanceId != null) this._models.removeInstance?.(veh.bodyInstanceId);
|
||||
for (const w of veh.wheels) this._models.removeInstance?.(w.instanceId);
|
||||
veh.chassisNode?.dispose?.();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
this.vehicles.clear();
|
||||
}
|
||||
}
|
||||
@ -90,6 +90,17 @@ export class WeaponSystem {
|
||||
if (e.button !== 0) return;
|
||||
// Если UI-режим курсора — не стреляем (мышь работает по GUI)
|
||||
if (this.scene3d?.player?.isUiCursorMode?.()) return;
|
||||
// Свободный курсор (нет pointer-lock, обычно 3-е лицо) → стрелять туда,
|
||||
// куда кликнули, а не в центр камеры.
|
||||
if (document.pointerLockElement !== canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
|
||||
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
|
||||
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
|
||||
this.setAimScreenPoint(cx * (canvas.width / rect.width),
|
||||
cy * (canvas.height / rect.height));
|
||||
}
|
||||
}
|
||||
this._mouseDown = true;
|
||||
this._tryFire();
|
||||
};
|
||||
@ -97,14 +108,26 @@ export class WeaponSystem {
|
||||
if (e.button !== 0) return;
|
||||
this._mouseDown = false;
|
||||
};
|
||||
const onMove = (e) => {
|
||||
if (!this._mouseDown) return;
|
||||
if (document.pointerLockElement === canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
|
||||
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
|
||||
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
|
||||
this._holdAim = { x: cx * (canvas.width / rect.width), y: cy * (canvas.height / rect.height) };
|
||||
}
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.code === 'KeyR') this.reload();
|
||||
};
|
||||
canvas.addEventListener('mousedown', onDown);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('keydown', onKey);
|
||||
this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown });
|
||||
this._listeners.push({ target: window, type: 'mouseup', fn: onUp });
|
||||
this._listeners.push({ target: window, type: 'mousemove', fn: onMove });
|
||||
this._listeners.push({ target: window, type: 'keydown', fn: onKey });
|
||||
|
||||
// Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true)
|
||||
@ -583,7 +606,10 @@ export class WeaponSystem {
|
||||
// (для tap-to-shoot на мобиле). Точка применяется один раз.
|
||||
let hit = null;
|
||||
let ray;
|
||||
const aim = this._aimScreenPoint;
|
||||
let aim = this._aimScreenPoint;
|
||||
if (!aim && this._holdAim && document.pointerLockElement !== this.scene3d?.canvas) {
|
||||
aim = this._holdAim;
|
||||
}
|
||||
try {
|
||||
if (aim) {
|
||||
ray = this.scene.createPickingRay(aim.x, aim.y, null, camera);
|
||||
|
||||
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