Compare commits

..

No commits in common. "main" and "feat/arrow-pointer" have entirely different histories.

49 changed files with 19590 additions and 30490 deletions

View File

@ -1,11 +0,0 @@
# Активная сессия: импорт Roblox .rbxl
Это **отдельный worktree** для разработки `feat/rbxl-import` — импорта Roblox-карт в Rublox.
**Не работайте здесь параллельно из других сессий!**
Ветка: `feat/rbxl-import`
Сервис на сервере: VM 130 на S1
Сопутствующий worktree: `Desktop/studio-rbxl-import`
Started: 2026-06-07

View File

@ -1,5 +0,0 @@
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

View File

@ -150,9 +150,9 @@ jobs:
run: | run: |
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998 \ ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998 \
min@85.175.7.40 \ min@85.175.7.40 \
"ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/ 2>/dev/null || true" "ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/"
- name: Verify S2 (обязательный) - name: Verify S2 (обязательный)
run: | run: |
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22 \ ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22 \
min@192.168.0.124 \ min@192.168.0.124 \
"ls /var/www/rublox-player/build/index.html && (du -sh /var/www/rublox-player/build/ 2>/dev/null || true)" "ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/"

41
.gitignore vendored
View File

@ -41,43 +41,4 @@ public/kubikon-assets/
# OS # OS
Thumbs.db Thumbs.db
.env.production
# ============================================================
# SECURITY — добавлено после взлома 2026-06-04
# НИКОГДА не коммитить эти файлы — они могут содержать секреты!
# ============================================================
CLAUDE.md
INFO_PROCESS.md
PASSWORD_*.md
SECRETS*
*_SECRETS*
*.kdbx
*.kdbx.bak
.env
.env.*
!.env.example
!.env.sample
# .env.production содержит ТОЛЬКО публичные URL (api-base, realtime, rublox.pro)
# — без секретов. Нужен в git, чтобы CI собирал прод-бандл с правильным
# VITE_API_BASE (иначе API уходит на origin вместо minecraftia-school.ru,
# redeem-ticket падает → плеер выбивает на /app). Инцидент 2026-06-07.
!.env.production
secrets/
*.pem
*.key
id_rsa
id_ed25519
known_hosts
authorized_keys
# Текстовые заметки разработчика (могут содержать всё что угодно)
NOTES*.md
TODO*.md
PRIVATE*.md
INTERNAL_*.md
# Бэкапы кода с предыдущих версий
*.bak
*.bak_*
BackUp/
backup/

21
package-lock.json generated
View File

@ -18,8 +18,7 @@
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-router-dom": "7.4.0", "react-router-dom": "7.4.0",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3"
"wasmoon": "^1.16.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "18.3.12", "@types/react": "18.3.12",
@ -1428,12 +1427,6 @@
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/emscripten": {
"version": "1.39.10",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.10.tgz",
"integrity": "sha512-TB/6hBkYQJxsZHSqyeuO1Jt0AB/bW6G7rHt9g7lML7SOF6lbgcHvw/Lr+69iqN0qxgXLhWKScAon73JNnptuDw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -5213,18 +5206,6 @@
} }
} }
}, },
"node_modules/wasmoon": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/wasmoon/-/wasmoon-1.16.0.tgz",
"integrity": "sha512-FlRLb15WwAOz1A9OQDbf6oOKKSiefi5VK0ZRF2wgH9xk3o5SnU11tNPaOnQuAh1Ucr66cwwvVXaeVRaFdRBt5g==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "1.39.10"
},
"bin": {
"wasmoon": "bin/wasmoon"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -49,8 +49,7 @@
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-router-dom": "7.4.0", "react-router-dom": "7.4.0",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3"
"wasmoon": "^1.16.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "18.3.12", "@types/react": "18.3.12",

View File

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

View File

@ -22,7 +22,6 @@ import { useAuth } from '../auth/PlayerAuth';
import RublocsLogo from '../components/RublocsLogo/RublocsLogo'; import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../hooks/useDeviceType'; import useDeviceType from '../hooks/useDeviceType';
import KubikonMobileControls from './KubikonMobileControls'; import KubikonMobileControls from './KubikonMobileControls';
import GameLoadingScreen from './GameLoadingScreen';
// Плеер живёт на player.rublox.pro он не знает SPA-роутов Майнкрафтии // Плеер живёт на player.rublox.pro он не знает SPA-роутов Майнкрафтии
// (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем // (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем
@ -39,12 +38,12 @@ function exitPlayer(gameId) {
// (флаг читает onBeforeUnload listener ниже). // (флаг читает onBeforeUnload listener ниже).
try { window.__rubloxExplicitExit = true; } catch {} try { window.__rubloxExplicitExit = true; } catch {}
const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
const RUBLOX_HOME = (env.VITE_RUBLOX_HOME || 'https://rublox.pro/app').replace(/\/+$/, ''); const RUBLOX_HOME = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app';
if (gameId) { if (gameId) {
// У страницы игры теперь свой URL: /app/game/<id> (им можно делиться). // Передаём gameId через ?game=<id> главный сайт прочитает и снова
// Возвращаемся прямо на него. base RUBLOX_HOME оканчивается на /app. // откроет карточку игры (юзер возвращается на ту же страницу).
const base = RUBLOX_HOME.endsWith('/app') ? RUBLOX_HOME : `${RUBLOX_HOME}/app`; const sep = RUBLOX_HOME.includes('?') ? '&' : '?';
window.location.assign(`${base}/game/${gameId}`); window.location.assign(`${RUBLOX_HOME}${sep}game=${gameId}`);
} else { } else {
window.location.assign(RUBLOX_HOME); window.location.assign(RUBLOX_HOME);
} }
@ -217,9 +216,6 @@ const KubikonPlayer = () => {
const [forbidden, setForbidden] = useState(false); const [forbidden, setForbidden] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Задача 05: конфиг красивого экрана загрузки (из project_data.scene.loadingScreen).
const [loadingScreenCfg, setLoadingScreenCfg] = useState(null);
const [loadProgress, setLoadProgress] = useState(0);
// Раньше была стартовая заглушка «тапни чтобы начать» убрали по // Раньше была стартовая заглушка «тапни чтобы начать» убрали по
// фидбэку, она бесила. Теперь fullscreen опционально через кнопку // фидбэку, она бесила. Теперь fullscreen опционально через кнопку
// в углу. Этот state остался для совместимости с handleMobileStart. // в углу. Этот state остался для совместимости с handleMobileStart.
@ -521,20 +517,13 @@ const KubikonPlayer = () => {
s?.player?.setUiCursorMode?.(true); s?.player?.setUiCursorMode?.(true);
setChatOpen(false); setChatOpen(false);
setTopMenuOpen(true); setTopMenuOpen(true);
try { if (s) s._playerMenuOpen = true; } catch (e) { /* ignore */ }
} }
}); });
// ESC в Play TOGGLE меню-оверлея поверх ЖИВОЙ игры (Roblox-style). // ESC в Play меню-оверлей поверх ЖИВОЙ игры (Roblox-style). Play не
// Движок сам решает open/close (единый источник истины _playerMenuOpen) // прерывается, скрипты продолжают идти, игрок не респавнится.
// и передаёт сюда. Это убирает гонку двух ESC-обработчиков, из-за которой scene.setOnEscMenu?.(() => {
// меню открывалось поверх меню, а orbit-камера по ПКМ зависала. setChatOpen(false);
scene.setOnEscMenu?.((open) => { setTopMenuOpen(true);
if (open) {
setChatOpen(false);
setTopMenuOpen(true);
} else {
setTopMenuOpen(false);
}
}); });
// Загружаем проект. // Загружаем проект.
@ -555,18 +544,11 @@ const KubikonPlayer = () => {
setMeta(data); setMeta(data);
setLikesCount(data.likes_count || 0); setLikesCount(data.likes_count || 0);
setDislikesCount(data.dislikes_count || 0); setDislikesCount(data.dislikes_count || 0);
setLoadProgress(0.3);
if (data.project_data) { if (data.project_data) {
const parsed = JSON.parse(data.project_data); const parsed = JSON.parse(data.project_data);
initialStateRef.current = parsed; initialStateRef.current = parsed;
// Задача 05: красивый экран загрузки конфиг автора (если задан в студии).
try {
const lsc = parsed?.scene?.loadingScreen;
if (lsc && typeof lsc === 'object' && lsc.enabled !== false) setLoadingScreenCfg(lsc);
} catch (e) { /* ignore */ }
await scene.loadFromState(parsed); await scene.loadFromState(parsed);
setLoadProgress(0.7);
} }
// Ждём пока Babylon реально загрузит и скомпилит все // Ждём пока Babylon реально загрузит и скомпилит все
@ -603,12 +585,9 @@ const KubikonPlayer = () => {
skinFolderRef.current = mySkin; skinFolderRef.current = mySkin;
try { scene.setPlayerModelType?.(mySkin); } catch (e) {} try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
setLoadProgress(1);
setLoading(false); setLoading(false);
// Засчитываем плей. Передаём user_id (если залогинен) // Засчитываем плей
// это активирует self-cooldown (автор не накручивает себе) Kubikon3DApi.incrementPlay(projectId).catch(() => {});
// и user-cooldown на бэке (см. /play в Kubikon3D.py).
Kubikon3DApi.incrementPlay(projectId, userId).catch(() => {});
// Запускаем игру сразу // Запускаем игру сразу
setTimeout(() => { setTimeout(() => {
scene.enterPlayMode?.(); scene.enterPlayMode?.();
@ -755,20 +734,26 @@ const KubikonPlayer = () => {
p._uiCursorMode = true; p._uiCursorMode = true;
setChatOpen(false); setChatOpen(false);
setTopMenuOpen(true); setTopMenuOpen(true);
// Синхронизируем единый флаг меню в движке, чтобы следующий ESC
// сработал как toggle-закрытие (а не открыл второе меню).
try { s._playerMenuOpen = true; } catch (e) { /* ignore */ }
}; };
// capture-фаза, чтобы успеть раньше PlayerController // capture-фаза, чтобы успеть раньше PlayerController
document.addEventListener('pointerlockchange', onLockChange, true); document.addEventListener('pointerlockchange', onLockChange, true);
return () => document.removeEventListener('pointerlockchange', onLockChange, true); return () => document.removeEventListener('pointerlockchange', onLockChange, true);
}, []); }, []);
// Повторный ESC (toggle закрытие) теперь обрабатывает движок через // Повторный ESC (когда меню уже открыто) закрыть меню и вернуть
// setOnExitRequest _onEscMenu(false). Отдельный React-обработчик ESC // мышь в игру.
// УБРАН он слушал тот же ESC, что и движок, и создавал гонку: useEffect(() => {
// меню открывалось поверх себя, а _uiCursorMode застревал в true if (!topMenuOpen) return;
// (orbit-камера по ПКМ переставала работать после закрытия меню). 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]);
// Горячая клавиша T открыть/закрыть чат. Игнорируем когда: // Горячая клавиша T открыть/закрыть чат. Игнорируем когда:
// уже введён текст в <input>/<textarea>/contenteditable (юзер печатает) // уже введён текст в <input>/<textarea>/contenteditable (юзер печатает)
@ -982,11 +967,6 @@ const KubikonPlayer = () => {
// Очищаем ref'ы иначе следующий connectMultiplayer выйдет // Очищаем ref'ы иначе следующий connectMultiplayer выйдет
// на if (mpSyncRef.current || roomRef.current) return. // на if (mpSyncRef.current || roomRef.current) return.
try { sync.stop?.(); } catch (e) {} try { sync.stop?.(); } catch (e) {}
// ВАЖНО: dispose() сносит ВСЕ старые меши remote-игроков со
// сцены. Без этого при auto-reconnect (Colyseus rejoin) новый
// MultiplayerSync видит пустую Map и при +remote создаёт
// дубль-меш на каждый кадр (см. фикс 2026-06-05).
try { sync.dispose?.(); } catch (e) {}
mpSyncRef.current = null; mpSyncRef.current = null;
roomRef.current = null; roomRef.current = null;
// Code 1000 / 1001 нормальное закрытие. Code >= 4000 наш // Code 1000 / 1001 нормальное закрытие. Code >= 4000 наш
@ -1150,13 +1130,46 @@ const KubikonPlayer = () => {
outline: 'none', outline: 'none',
}} }}
/> />
{/* Задача 05: красивый экран загрузки игры (Ken Burns + название места + автор). */} {/* Loading-оверлей */}
{loading && ( {loading && (
<GameLoadingScreen <div style={{
meta={meta} position: 'absolute', inset: 0,
loadingScreen={loadingScreenCfg} display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
progress={loadProgress} 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>
)} )}
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */} {/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
@ -1606,10 +1619,9 @@ const KubikonPlayer = () => {
visible={topMenuOpen} visible={topMenuOpen}
onClose={() => { onClose={() => {
setTopMenuOpen(false); setTopMenuOpen(false);
// Синхронизируем движок (_playerMenuOpen) И возвращаем мышь // Возвращаем мышь в pointer-lock игры (как делал
// в игру одним вызовом. Без этого следующий ESC решит, что // старый ESC-handler выше).
// меню «ещё открыто», и не откроет его. try { sceneRef.current?.player?.setUiCursorMode?.(false); } catch {}
try { sceneRef.current?.setPlayerMenuOpen?.(false); } catch {}
}} }}
onExit={() => exitPlayer(id)} onExit={() => exitPlayer(id)}
onRespawn={() => respawnPlayer()} onRespawn={() => respawnPlayer()}

View File

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

View File

@ -1,488 +1,487 @@
/** /**
* API-сервис для Рублокс 3D (отдельный от 2D-Кубоши). * API-сервис для Рублокс 3D (отдельный от 2D-Кубоши).
* Бэкенд: storys-микросервис, префикс /kubikon3d/... * Бэкенд: storys-микросервис, префикс /kubikon3d/...
*/ */
import axios from 'axios'; import axios from 'axios';
import { STORYS_addres } from './API'; import { STORYS_addres } from './API';
const api = axios.create({ const api = axios.create({
baseURL: STORYS_addres, baseURL: STORYS_addres,
timeout: 30000, timeout: 30000,
// Поднимаем лимит размера body — без этого axios отказывается отправлять // Поднимаем лимит размера body — без этого axios отказывается отправлять
// payload > ~10МБ. После Этапа 3 voxel-движка RLE-сжатие даёт <2МБ // payload > ~10МБ. После Этапа 3 voxel-движка RLE-сжатие даёт <2МБ
// для 250м карты, но запас не помешает. // для 250м карты, но запас не помешает.
maxContentLength: 100 * 1024 * 1024, // 100 МБ maxContentLength: 100 * 1024 * 1024, // 100 МБ
maxBodyLength: 100 * 1024 * 1024, maxBodyLength: 100 * 1024 * 1024,
}); });
// Подкладываем JWT в каждый запрос — storys использует его, чтобы дёрнуть // Подкладываем JWT в каждый запрос — storys использует его, чтобы дёрнуть
// user-микросервис и узнать имя пользователя (resolve_my_username). // user-микросервис и узнать имя пользователя (resolve_my_username).
// Если токена нет (гость) — заголовок не ставим, бэк отдаст пустое имя. // Если токена нет (гость) — заголовок не ставим, бэк отдаст пустое имя.
// //
// В плеере JWT хранится в localStorage['player_jwt'] (а не 'Authorization' // В плеере JWT хранится в localStorage['player_jwt'] (а не 'Authorization'
// как в Майнкрафтии), потому что плеер живёт на отдельном поддомене // как в Майнкрафтии), потому что плеер живёт на отдельном поддомене
// player.rublox.pro и его localStorage изолирован. Ключ перенумерован // player.rublox.pro и его localStorage изолирован. Ключ перенумерован
// в Этапе 2 портирования плеера. // в Этапе 2 портирования плеера.
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
try { try {
const token = localStorage.getItem('player_jwt'); const token = localStorage.getItem('player_jwt');
if (token) { if (token) {
config.headers = config.headers || {}; config.headers = config.headers || {};
config.headers.Authorization = token; config.headers.Authorization = token;
} }
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
return config; return config;
}); });
// ============ ПРОЕКТЫ ============ // ============ ПРОЕКТЫ ============
// Save-операции с увеличенным таймаутом (120с) — для больших карт. // Save-операции с увеличенным таймаутом (120с) — для больших карт.
// Стандартный 30с таймаут вызывал ошибку «не удалось сохранить» на 250м // Стандартный 30с таймаут вызывал ошибку «не удалось сохранить» на 250м
// карте, потому что upload 5+МБ JSON по медленной сети занимает больше. // карте, потому что upload 5+МБ JSON по медленной сети занимает больше.
const SAVE_TIMEOUT = 120000; const SAVE_TIMEOUT = 120000;
export const createProject = (userId, data) => export const createProject = (userId, data) =>
api.post('/kubikon3d/projects', { user_id: userId, ...data }, { timeout: SAVE_TIMEOUT }); api.post('/kubikon3d/projects', { user_id: userId, ...data }, { timeout: SAVE_TIMEOUT });
/** /**
* Загрузить проект по id. * Загрузить проект по id.
* *
* Бэкенд проверяет права доступа по правилам: * Бэкенд проверяет права доступа по правилам:
* - published открыто всем (можно вызвать без userId) * - published открыто всем (можно вызвать без userId)
* - draft / review / blocked только автору и админу * - draft / review / blocked только автору и админу
* *
* Поэтому если открываем чужой/свой черновик в редакторе обязательно * Поэтому если открываем чужой/свой черновик в редакторе обязательно
* передаём userId, иначе бэк отдаст 403. * передаём userId, иначе бэк отдаст 403.
*/ */
export const getProject = (id, userId = null) => { export const getProject = (id, userId = null) => {
const params = {}; const params = {};
if (userId != null) params.user_id = userId; if (userId != null) params.user_id = userId;
return api.get(`/kubikon3d/projects/${id}`, { params }); return api.get(`/kubikon3d/projects/${id}`, { params });
}; };
/** /**
* Загрузить проект с retry на случай зависшего/медленного запроса. * Загрузить проект с retry на случай зависшего/медленного запроса.
* *
* ПРОБЛЕМА: иногда запрос getProject подвисает (dev-proxy, перегруженный * ПРОБЛЕМА: иногда запрос getProject подвисает (dev-proxy, перегруженный
* пул соединений, сетевой лаг) и страница "Загрузка проекта… 0%" * пул соединений, сетевой лаг) и страница "Загрузка проекта… 0%"
* замирает на 30с (таймаут axios) или до 60с (safety-timer редактора). * замирает на 30с (таймаут axios) или до 60с (safety-timer редактора).
* Приходилось перезагружать вручную по 5 раз. * Приходилось перезагружать вручную по 5 раз.
* *
* РЕШЕНИЕ: короткий таймаут на ПОПЫТКУ (12с) + до 3 попыток. Зависшая * РЕШЕНИЕ: короткий таймаут на ПОПЫТКУ (12с) + до 3 попыток. Зависшая
* попытка отменяется и повторяется сама, без ручной перезагрузки. * попытка отменяется и повторяется сама, без ручной перезагрузки.
* Сетевые/таймаут-ошибки retry; 4xx (403/404) сразу пробрасываем * Сетевые/таймаут-ошибки retry; 4xx (403/404) сразу пробрасываем
* (повтор не поможет). * (повтор не поможет).
* *
* @param {number} id id проекта * @param {number} id id проекта
* @param {number|null} userId * @param {number|null} userId
* @param {number} attempts сколько попыток (по умолчанию 3) * @param {number} attempts сколько попыток (по умолчанию 3)
* @param {number} perTryTimeout таймаут одной попытки в мс (по умолчанию 12000) * @param {number} perTryTimeout таймаут одной попытки в мс (по умолчанию 12000)
*/ */
export const getProjectWithRetry = async ( export const getProjectWithRetry = async (
id, userId = null, attempts = 3, perTryTimeout = 12000, id, userId = null, attempts = 3, perTryTimeout = 12000,
) => { ) => {
const params = {}; const params = {};
if (userId != null) params.user_id = userId; if (userId != null) params.user_id = userId;
let lastErr = null; let lastErr = null;
for (let i = 0; i < attempts; i++) { for (let i = 0; i < attempts; i++) {
try { try {
return await api.get(`/kubikon3d/projects/${id}`, { return await api.get(`/kubikon3d/projects/${id}`, {
params, params,
timeout: perTryTimeout, timeout: perTryTimeout,
}); });
} catch (err) { } catch (err) {
lastErr = err; lastErr = err;
const status = err.response?.status; const status = err.response?.status;
// 4xx (кроме 408/429) — повтор не имеет смысла, пробрасываем сразу. // 4xx (кроме 408/429) — повтор не имеет смысла, пробрасываем сразу.
if (status && status >= 400 && status < 500 if (status && status >= 400 && status < 500
&& status !== 408 && status !== 429) { && status !== 408 && status !== 429) {
throw err; throw err;
} }
// Сеть/таймаут/5xx — пробуем ещё раз. // Сеть/таймаут/5xx — пробуем ещё раз.
console.warn( console.warn(
`[Kubikon3DApi] getProject attempt ${i + 1}/${attempts} failed` `[Kubikon3DApi] getProject attempt ${i + 1}/${attempts} failed`
+ ` (${err.code || status || 'network'}), retrying...`, + ` (${err.code || status || 'network'}), retrying...`,
); );
} }
} }
throw lastErr; throw lastErr;
}; };
export const updateProject = (id, data) => export const updateProject = (id, data) =>
api.put(`/kubikon3d/projects/${id}`, data, { timeout: SAVE_TIMEOUT }); api.put(`/kubikon3d/projects/${id}`, data, { timeout: SAVE_TIMEOUT });
export const deleteProject = (id, userId) => export const deleteProject = (id, userId) =>
api.delete(`/kubikon3d/projects/${id}`, { data: { user_id: userId } }); api.delete(`/kubikon3d/projects/${id}`, { data: { user_id: userId } });
export const getMyProjects = (userId) => export const getMyProjects = (userId) =>
api.get('/kubikon3d/my-projects', { params: { user_id: userId } }); api.get('/kubikon3d/my-projects', { params: { user_id: userId } });
/** /**
* Лента игр Рублокса (умная лента RUBLOX_SMART_FEED_PLAN.md). * Лента игр Рублокса (умная лента RUBLOX_SMART_FEED_PLAN.md).
* *
* Второй аргумент вкладка ленты: * Второй аргумент вкладка ленты:
* recommended ранжирование по hot_score (умная лента); * recommended ранжирование по hot_score (умная лента);
* new самые свежие; * new самые свежие;
* popular по числу запусков; * popular по числу запусков;
* top_week топ за неделю. * top_week топ за неделю.
* Бэкенд принимает этот параметр и как `tab`, и как `sort` (старое имя) * Бэкенд принимает этот параметр и как `tab`, и как `sort` (старое имя)
* шлём под обоими именами, чтобы не зависеть от версии бэкенда. * шлём под обоими именами, чтобы не зависеть от версии бэкенда.
*/ */
export const getFeed = (page = 1, tab = 'recommended', maxAge = null, minRating = null, opts = {}) => export const getFeed = (page = 1, tab = 'recommended', maxAge = null, minRating = null, opts = {}) =>
api.get('/kubikon3d/feed', { api.get('/kubikon3d/feed', {
params: { params: {
page, tab, sort: tab, page, tab, sort: tab,
...(maxAge != null ? { max_age: maxAge } : {}), ...(maxAge != null ? { max_age: maxAge } : {}),
...(minRating != null ? { min_rating: minRating } : {}), ...(minRating != null ? { min_rating: minRating } : {}),
...(opts.rank ? { rank: opts.rank } : {}), ...(opts.rank ? { rank: opts.rank } : {}),
...(opts.multiplayer != null ...(opts.multiplayer != null
? { multiplayer: opts.multiplayer ? 'true' : 'false' } : {}), ? { multiplayer: opts.multiplayer ? 'true' : 'false' } : {}),
...(opts.genre ? { genre: opts.genre } : {}), ...(opts.genre ? { genre: opts.genre } : {}),
...(opts.per_page ? { per_page: opts.per_page } : {}), ...(opts.per_page ? { per_page: opts.per_page } : {}),
}, },
}); });
export const searchProjects = (q, maxAge = null) => export const searchProjects = (q, maxAge = null) =>
api.get('/kubikon3d/search', { api.get('/kubikon3d/search', {
params: { q, ...(maxAge != null ? { max_age: maxAge } : {}) }, params: { q, ...(maxAge != null ? { max_age: maxAge } : {}) },
}); });
// ============ ПУБЛИКАЦИЯ И МОДЕРАЦИЯ (Этап 3) ============ // ============ ПУБЛИКАЦИЯ И МОДЕРАЦИЯ (Этап 3) ============
/** /**
* Опубликовать проект (умная лента RUBLOX_SMART_FEED_PLAN.md). * Опубликовать проект (умная лента RUBLOX_SMART_FEED_PLAN.md).
* Премодерации нет: чистая игра сразу в ленте, подозрительная review. * Премодерации нет: чистая игра сразу в ленте, подозрительная review.
* payload: { user_id, age_rating: 6|12|16|18, title?, description?, thumbnail? } * payload: { user_id, age_rating: 6|12|16|18, title?, description?, thumbnail? }
* Ответ: { project, review: bool, too_empty: bool } * Ответ: { project, review: bool, too_empty: bool }
*/ */
export const publishProject = (id, payload) => export const publishProject = (id, payload) =>
api.post(`/kubikon3d/projects/${id}/publish`, payload); api.post(`/kubikon3d/projects/${id}/publish`, payload);
export const unpublishProject = (id, userId) => export const unpublishProject = (id, userId) =>
api.post(`/kubikon3d/projects/${id}/unpublish`, { user_id: userId }); api.post(`/kubikon3d/projects/${id}/unpublish`, { user_id: userId });
/** Очередь проверки админа — игры со status='review' (подозрительные скрипты). */ /** Очередь проверки админа — игры со status='review' (подозрительные скрипты). */
export const getModerationQueue = () => export const getModerationQueue = () =>
api.get('/kubikon3d/admin/moderation-queue'); api.get('/kubikon3d/admin/moderation-queue');
/** /**
* Решение админа по игре из очереди проверки. * Решение админа по игре из очереди проверки.
* payload: { admin_user_id, action: 'approve'|'block', comment?, age_rating? } * payload: { admin_user_id, action: 'approve'|'block', comment?, age_rating? }
*/ */
export const moderateProject = (id, payload) => export const moderateProject = (id, payload) =>
api.post(`/kubikon3d/admin/projects/${id}/moderate`, payload); api.post(`/kubikon3d/admin/projects/${id}/moderate`, payload);
/** Заблокировать игру (умная лента, Фаза 7). payload: { admin_user_id, comment? } */ /** Заблокировать игру (умная лента, Фаза 7). payload: { admin_user_id, comment? } */
export const blockProject = (id, payload) => export const blockProject = (id, payload) =>
api.post(`/kubikon3d/admin/projects/${id}/block`, payload); api.post(`/kubikon3d/admin/projects/${id}/block`, payload);
/** Разблокировать игру → published. payload: { admin_user_id } */ /** Разблокировать игру → published. payload: { admin_user_id } */
export const unblockProject = (id, payload) => export const unblockProject = (id, payload) =>
api.post(`/kubikon3d/admin/projects/${id}/unblock`, payload); api.post(`/kubikon3d/admin/projects/${id}/unblock`, payload);
/** Вернуть demoted-игру в ленту вручную. payload: { admin_user_id } */ /** Вернуть demoted-игру в ленту вручную. payload: { admin_user_id } */
export const restoreFeed = (id, payload) => export const restoreFeed = (id, payload) =>
api.post(`/kubikon3d/admin/projects/${id}/restore-feed`, payload); api.post(`/kubikon3d/admin/projects/${id}/restore-feed`, payload);
export const getModerationHistory = (id) => export const getModerationHistory = (id) =>
api.get(`/kubikon3d/projects/${id}/moderation-history`); api.get(`/kubikon3d/projects/${id}/moderation-history`);
// ============ ПЛЕЕР / СОЦИАЛКА (Этап 3.3) ============ // ============ ПЛЕЕР / СОЦИАЛКА (Этап 3.3) ============
/** Загрузить проект для плеера. Передаём user_id для проверки доступа. */ /** Загрузить проект для плеера. Передаём user_id для проверки доступа. */
export const getProjectForPlay = (id, userId = null, isAdmin = false) => export const getProjectForPlay = (id, userId = null, isAdmin = false) =>
api.get(`/kubikon3d/projects/${id}`, { api.get(`/kubikon3d/projects/${id}`, {
params: { params: {
...(userId ? { user_id: userId } : {}), ...(userId ? { user_id: userId } : {}),
...(isAdmin ? { is_admin: 'true' } : {}), ...(isAdmin ? { is_admin: 'true' } : {}),
}, },
}); });
export const incrementPlay = (id, userId) => export const incrementPlay = (id) =>
api.post(`/kubikon3d/projects/${id}/play`, api.post(`/kubikon3d/projects/${id}/play`);
userId ? { user_id: userId } : {});
/** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает,
/** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает, * голос другого типа переключает. */
* голос другого типа переключает. */ export const toggleLike = (id, userId, kind = 'like') =>
export const toggleLike = (id, userId, kind = 'like') => api.post(`/kubikon3d/projects/${id}/like`, { user_id: userId, kind });
api.post(`/kubikon3d/projects/${id}/like`, { user_id: userId, kind });
export const getLikeStatus = (id, userId) =>
export const getLikeStatus = (id, userId) => api.get(`/kubikon3d/projects/${id}/like-status`, { params: { user_id: userId } });
api.get(`/kubikon3d/projects/${id}/like-status`, { params: { user_id: userId } });
/** payload: { reporter_user_id, target_type, target_id, category, text } */
/** payload: { reporter_user_id, target_type, target_id, category, text } */ export const createReport = (payload) =>
export const createReport = (payload) => api.post('/kubikon3d/reports', payload);
api.post('/kubikon3d/reports', payload);
/** Публичные игры автора. */
/** Публичные игры автора. */ export const getUserGames = (userId, maxAge = null) =>
export const getUserGames = (userId, maxAge = null) => api.get(`/kubikon3d/users/${userId}/games`, {
api.get(`/kubikon3d/users/${userId}/games`, { params: maxAge != null ? { max_age: maxAge } : {},
params: maxAge != null ? { max_age: maxAge } : {}, });
});
// ============ ЛИДЕРБОРД (рекорды прохождения игр) ============
// ============ ЛИДЕРБОРД (рекорды прохождения игр) ============
/** Топ-N рекордов прохождения проекта. По умолчанию 5. */
/** Топ-N рекордов прохождения проекта. По умолчанию 5. */ export const getLeaderboard = (projectId, limit = 5) =>
export const getLeaderboard = (projectId, limit = 5) => api.get(`/kubikon3d/projects/${projectId}/leaderboard`, {
api.get(`/kubikon3d/projects/${projectId}/leaderboard`, { params: { limit },
params: { limit }, });
});
/** Отправить рекорд прохождения. Если время лучше предыдущего — обновляется. */
/** Отправить рекорд прохождения. Если время лучше предыдущего — обновляется. */ export const submitLeaderboard = (projectId, userId, timeMs) =>
export const submitLeaderboard = (projectId, userId, timeMs) => api.post(`/kubikon3d/projects/${projectId}/leaderboard`, {
api.post(`/kubikon3d/projects/${projectId}/leaderboard`, { user_id: userId,
user_id: userId, time_ms: timeMs,
time_ms: timeMs, });
});
// ============ ИЗБРАННОЕ ============
// ============ ИЗБРАННОЕ ============ export const toggleFavorite = (projectId, userId) =>
export const toggleFavorite = (projectId, userId) => api.post(`/kubikon3d/projects/${projectId}/favorite`, { user_id: userId });
api.post(`/kubikon3d/projects/${projectId}/favorite`, { user_id: userId }); export const getFavoriteStatus = (projectId, userId) =>
export const getFavoriteStatus = (projectId, userId) => api.get(`/kubikon3d/projects/${projectId}/favorite-status`,
api.get(`/kubikon3d/projects/${projectId}/favorite-status`, { params: { user_id: userId } });
{ params: { user_id: userId } }); export const getMyFavorites = (userId) =>
export const getMyFavorites = (userId) => api.get(`/kubikon3d/users/${userId}/favorites`);
api.get(`/kubikon3d/users/${userId}/favorites`);
// ============ ИСТОРИЯ ============
// ============ ИСТОРИЯ ============ export const getPlayHistory = (userId, limit = 8) =>
export const getPlayHistory = (userId, limit = 8) => api.get(`/kubikon3d/users/${userId}/history`, { params: { limit } });
api.get(`/kubikon3d/users/${userId}/history`, { params: { limit } });
// ============ ТРЕНДЫ / ТОП-АВТОРЫ / АКТИВНОСТЬ ============
// ============ ТРЕНДЫ / ТОП-АВТОРЫ / АКТИВНОСТЬ ============ export const getTrending = (limit = 8) =>
export const getTrending = (limit = 8) => api.get('/kubikon3d/trending', { params: { limit } });
api.get('/kubikon3d/trending', { params: { limit } }); export const getTopAuthors = (limit = 10) =>
export const getTopAuthors = (limit = 10) => api.get('/kubikon3d/top-authors', { params: { limit } });
api.get('/kubikon3d/top-authors', { params: { limit } }); export const getActivity = (limit = 10) =>
export const getActivity = (limit = 10) => api.get('/kubikon3d/activity', { params: { limit } });
api.get('/kubikon3d/activity', { params: { limit } }); export const getCollections = () =>
export const getCollections = () => api.get('/kubikon3d/collections');
api.get('/kubikon3d/collections'); export const getEvents = () =>
export const getEvents = () => api.get('/kubikon3d/events');
api.get('/kubikon3d/events');
// ============ PERF LOGS ============
// ============ PERF LOGS ============ export const submitPerfLog = (sample) =>
export const submitPerfLog = (sample) => api.post('/kubikon3d/perf-log', sample);
api.post('/kubikon3d/perf-log', sample);
// ============ БАГ-РЕПОРТЫ РУБЛОКСА (с прикреплением скриншота) ============
// ============ БАГ-РЕПОРТЫ РУБЛОКСА (с прикреплением скриншота) ============
/**
/** * Создать баг-репорт. Использует multipart/form-data, потому что может нести файл.
* Создать баг-репорт. Использует multipart/form-data, потому что может нести файл. * fields: { bug_type, title, message, url_path, user_agent, user_id?, project_id?, screenshot? File }
* fields: { bug_type, title, message, url_path, user_agent, user_id?, project_id?, screenshot? File } */
*/ export const createBugReport = (fields) => {
export const createBugReport = (fields) => { const fd = new FormData();
const fd = new FormData(); Object.entries(fields).forEach(([k, v]) => {
Object.entries(fields).forEach(([k, v]) => { if (v == null || v === '') return;
if (v == null || v === '') return; fd.append(k, v);
fd.append(k, v); });
}); return api.post('/kubikon3d/bug-reports', fd, {
return api.post('/kubikon3d/bug-reports', fd, { headers: { 'Content-Type': 'multipart/form-data' },
headers: { 'Content-Type': 'multipart/form-data' }, });
}); };
};
export const getAdminBugReports = (status = 'open', limit = 100) =>
export const getAdminBugReports = (status = 'open', limit = 100) => api.get('/kubikon3d/admin/bug-reports', { params: { status, limit } });
api.get('/kubikon3d/admin/bug-reports', { params: { status, limit } });
export const updateAdminBugReport = (id, payload) =>
export const updateAdminBugReport = (id, payload) => api.post(`/kubikon3d/admin/bug-reports/${id}`, payload);
api.post(`/kubikon3d/admin/bug-reports/${id}`, payload);
// ============ HEARTBEAT / ОНЛАЙН ============
// ============ HEARTBEAT / ОНЛАЙН ============
export const playHeartbeat = (sessionId, projectId, userId = null) =>
export const playHeartbeat = (sessionId, projectId, userId = null) => api.post('/kubikon3d/play/heartbeat', {
api.post('/kubikon3d/play/heartbeat', { session_id: sessionId,
session_id: sessionId, project_id: projectId,
project_id: projectId, user_id: userId,
user_id: userId, });
});
export const getOnline = () =>
export const getOnline = () => api.get('/kubikon3d/admin/online');
api.get('/kubikon3d/admin/online');
// ============ DASHBOARD / СТАТИСТИКА ============
// ============ DASHBOARD / СТАТИСТИКА ============
export const getDashboard = () =>
export const getDashboard = () => api.get('/kubikon3d/admin/dashboard');
api.get('/kubikon3d/admin/dashboard');
export const getAdminAllGames = (status = 'all', sort = 'new', limit = 200) =>
export const getAdminAllGames = (status = 'all', sort = 'new', limit = 200) => api.get('/kubikon3d/admin/all-games', { params: { status, sort, limit } });
api.get('/kubikon3d/admin/all-games', { params: { status, sort, limit } });
export const getAdminAuthors = (limit = 100) =>
export const getAdminAuthors = (limit = 100) => api.get('/kubikon3d/admin/authors', { params: { limit } });
api.get('/kubikon3d/admin/authors', { params: { limit } });
// ============ ЖАЛОБЫ (АДМИНКА) ============
// ============ ЖАЛОБЫ (АДМИНКА) ============
export const getAdminReports = (status = 'open', limit = 200) =>
export const getAdminReports = (status = 'open', limit = 200) => api.get('/kubikon3d/admin/reports', { params: { status, limit } });
api.get('/kubikon3d/admin/reports', { params: { status, limit } });
export const resolveAdminReport = (id, status, adminUserId) =>
export const resolveAdminReport = (id, status, adminUserId) => api.post(`/kubikon3d/admin/reports/${id}`, { status, admin_user_id: adminUserId });
api.post(`/kubikon3d/admin/reports/${id}`, { status, admin_user_id: adminUserId });
// ============ БАН ПУБЛИКАЦИЙ (этап 3.10) ============
// ============ БАН ПУБЛИКАЦИЙ (этап 3.10) ============
/** Публичный — узнать активный бан публикаций пользователя. */
/** Публичный — узнать активный бан публикаций пользователя. */ export const getPublishBanStatus = (userId) =>
export const getPublishBanStatus = (userId) => api.get(`/kubikon3d/users/${userId}/publish-ban-status`);
api.get(`/kubikon3d/users/${userId}/publish-ban-status`); export const getPublishBanHistory = (userId) =>
export const getPublishBanHistory = (userId) => api.get(`/kubikon3d/admin/users/${userId}/publish-ban-history`);
api.get(`/kubikon3d/admin/users/${userId}/publish-ban-history`);
// ============ КОММЕНТАРИИ ПОД ИГРАМИ (этап 3.9) ============
// ============ КОММЕНТАРИИ ПОД ИГРАМИ (этап 3.9) ============
export const getProjectComments = (projectId) =>
export const getProjectComments = (projectId) => api.get(`/kubikon3d/projects/${projectId}/comments`);
api.get(`/kubikon3d/projects/${projectId}/comments`);
/** payload: { user_id, username, text } */
/** payload: { user_id, username, text } */ export const createProjectComment = (projectId, payload) =>
export const createProjectComment = (projectId, payload) => api.post(`/kubikon3d/projects/${projectId}/comments`, payload);
api.post(`/kubikon3d/projects/${projectId}/comments`, payload);
export const deleteProjectComment = (commentId, userId) =>
export const deleteProjectComment = (commentId, userId) => api.delete(`/kubikon3d/comments/${commentId}`, { data: { user_id: userId } });
api.delete(`/kubikon3d/comments/${commentId}`, { data: { user_id: userId } });
export const editProjectComment = (commentId, userId, text) =>
export const editProjectComment = (commentId, userId, text) => api.put(`/kubikon3d/comments/${commentId}`, { user_id: userId, text });
api.put(`/kubikon3d/comments/${commentId}`, { user_id: userId, text });
// Админ
// Админ export const getAdminComments = (filter = 'flagged', projectId = null, limit = 200) =>
export const getAdminComments = (filter = 'flagged', projectId = null, limit = 200) => api.get('/kubikon3d/admin/comments', {
api.get('/kubikon3d/admin/comments', { params: { filter, ...(projectId ? { project: projectId } : {}), limit },
params: { filter, ...(projectId ? { project: projectId } : {}), limit }, });
});
// ============ ВНУТРИИГРОВОЙ ЧАТ (этап 3.5/3.6) ============
// ============ ВНУТРИИГРОВОЙ ЧАТ (этап 3.5/3.6) ============
/** since (опц.) — id последнего сообщения; если задан — вернётся только дельта. */
/** since (опц.) — id последнего сообщения; если задан — вернётся только дельта. */ export const getChat = (projectId, since = null, limit = 50) =>
export const getChat = (projectId, since = null, limit = 50) => api.get(`/kubikon3d/projects/${projectId}/chat`, {
api.get(`/kubikon3d/projects/${projectId}/chat`, { params: { ...(since ? { since } : {}), limit },
params: { ...(since ? { since } : {}), limit }, });
});
/** payload: { user_id, username, text } */
/** payload: { user_id, username, text } */ export const postChatMessage = (projectId, payload) =>
export const postChatMessage = (projectId, payload) => api.post(`/kubikon3d/projects/${projectId}/chat`, payload);
api.post(`/kubikon3d/projects/${projectId}/chat`, payload);
/** Узнать активный мьют чата для пользователя. */
/** Узнать активный мьют чата для пользователя. */ export const getChatMuteStatus = (userId) =>
export const getChatMuteStatus = (userId) => api.get(`/kubikon3d/users/${userId}/chat-mute-status`);
api.get(`/kubikon3d/users/${userId}/chat-mute-status`);
// Админ
// Админ export const getAdminChatMessages = (filter = 'flagged', projectId = null, limit = 200) =>
export const getAdminChatMessages = (filter = 'flagged', projectId = null, limit = 200) => api.get('/kubikon3d/admin/chat/messages', {
api.get('/kubikon3d/admin/chat/messages', { params: { filter, ...(projectId ? { project: projectId } : {}), limit },
params: { filter, ...(projectId ? { project: projectId } : {}), limit }, });
});
export const getAdminChatBans = (filter = 'active', limit = 200) =>
export const getAdminChatBans = (filter = 'active', limit = 200) => api.get('/kubikon3d/admin/chat/bans', { params: { filter, limit } });
api.get('/kubikon3d/admin/chat/bans', { params: { filter, limit } });
// ============================================================================
// ============================================================================ // Пользовательские модели (Этап 1+ редактора моделей)
// Пользовательские модели (Этап 1+ редактора моделей) // ============================================================================
// ============================================================================ // Эндпоинты для воксельных и гладких моделей, созданных пользователями.
// Эндпоинты для воксельных и гладких моделей, созданных пользователями. // Отображаются в Toolbox в разделах "Мои модели" и "Сообщество".
// Отображаются в Toolbox в разделах "Мои модели" и "Сообщество". // См. KUBIKON_MODEL_EDITOR_PLAN.md.
// См. KUBIKON_MODEL_EDITOR_PLAN.md.
/** Создать модель.
/** Создать модель. * payload: { title, kind: 'voxel'|'smooth', model_data: stringJSON,
* payload: { title, kind: 'voxel'|'smooth', model_data: stringJSON, * description?, thumbnail_b64? }
* description?, thumbnail_b64? } * Возвращает serialize_full (с model_data).
* Возвращает serialize_full (с model_data). */
*/ export const createUserModel = (userId, payload) =>
export const createUserModel = (userId, payload) => api.post('/kubikon3d/models', { user_id: userId, ...payload },
api.post('/kubikon3d/models', { user_id: userId, ...payload }, { timeout: SAVE_TIMEOUT });
{ timeout: SAVE_TIMEOUT });
/** Загрузить модель по id. userId — для проверки доступа к приватной. */
/** Загрузить модель по id. userId — для проверки доступа к приватной. */ export const getUserModel = (id, userId = null) => {
export const getUserModel = (id, userId = null) => { const params = {};
const params = {}; if (userId != null) params.user_id = userId;
if (userId != null) params.user_id = userId; return api.get(`/kubikon3d/models/${id}`, { params });
return api.get(`/kubikon3d/models/${id}`, { params }); };
};
/** Обновить модель. payload: { title?, description?, model_data?, thumbnail_b64? } */
/** Обновить модель. payload: { title?, description?, model_data?, thumbnail_b64? } */ export const updateUserModel = (id, userId, payload) =>
export const updateUserModel = (id, userId, payload) => api.put(`/kubikon3d/models/${id}`, { user_id: userId, ...payload },
api.put(`/kubikon3d/models/${id}`, { user_id: userId, ...payload }, { timeout: SAVE_TIMEOUT });
{ timeout: SAVE_TIMEOUT });
export const deleteUserModel = (id, userId) =>
export const deleteUserModel = (id, userId) => api.delete(`/kubikon3d/models/${id}`, {
api.delete(`/kubikon3d/models/${id}`, { data: { user_id: userId },
data: { user_id: userId }, params: { user_id: userId },
params: { user_id: userId }, });
});
/** Мои модели (для раздела "Мои" в Toolbox). */
/** Мои модели (для раздела "Мои" в Toolbox). */ export const getMyUserModels = (userId, opts = {}) =>
export const getMyUserModels = (userId, opts = {}) => api.get('/kubikon3d/models/mine', {
api.get('/kubikon3d/models/mine', { params: {
params: { user_id: userId,
user_id: userId, ...(opts.kind ? { kind: opts.kind } : {}),
...(opts.kind ? { kind: opts.kind } : {}), ...(opts.limit ? { limit: opts.limit } : {}),
...(opts.limit ? { limit: opts.limit } : {}), ...(opts.offset ? { offset: opts.offset } : {}),
...(opts.offset ? { offset: opts.offset } : {}), },
}, });
});
/** Публичные модели (для раздела "Сообщество" в Toolbox).
/** Публичные модели (для раздела "Сообщество" в Toolbox). * opts: { q, kind, limit, offset, userId }
* opts: { q, kind, limit, offset, userId } * userId чтобы у моделей пришёл флаг liked_by_me (подсветка лайка). */
* userId чтобы у моделей пришёл флаг liked_by_me (подсветка лайка). */ export const getPublicUserModels = (opts = {}) =>
export const getPublicUserModels = (opts = {}) => api.get('/kubikon3d/models/public', {
api.get('/kubikon3d/models/public', { params: {
params: { ...(opts.q ? { q: opts.q } : {}),
...(opts.q ? { q: opts.q } : {}), ...(opts.kind ? { kind: opts.kind } : {}),
...(opts.kind ? { kind: opts.kind } : {}), ...(opts.limit ? { limit: opts.limit } : {}),
...(opts.limit ? { limit: opts.limit } : {}), ...(opts.offset ? { offset: opts.offset } : {}),
...(opts.offset ? { offset: opts.offset } : {}), ...(opts.userId != null ? { user_id: opts.userId } : {}),
...(opts.userId != null ? { user_id: opts.userId } : {}), },
}, });
});
export const publishUserModel = (id, userId) =>
export const publishUserModel = (id, userId) => api.post(`/kubikon3d/models/${id}/publish`, { user_id: userId });
api.post(`/kubikon3d/models/${id}/publish`, { user_id: userId });
export const unpublishUserModel = (id, userId) =>
export const unpublishUserModel = (id, userId) => api.post(`/kubikon3d/models/${id}/unpublish`, { user_id: userId });
api.post(`/kubikon3d/models/${id}/unpublish`, { user_id: userId });
/** Инкремент uses_count — вызывать когда модель ставят в проект. */
/** Инкремент uses_count — вызывать когда модель ставят в проект. */ export const incrementModelUses = (id) =>
export const incrementModelUses = (id) => api.post(`/kubikon3d/models/${id}/use`);
api.post(`/kubikon3d/models/${id}/use`);
/** Поставить/снять лайк пользовательской модели (toggle).
/** Поставить/снять лайк пользовательской модели (toggle). * Возвращает { liked, likes_count }. */
* Возвращает { liked, likes_count }. */ export const likeUserModel = (id, userId) =>
export const likeUserModel = (id, userId) => api.post(`/kubikon3d/models/${id}/like`, { user_id: userId });
api.post(`/kubikon3d/models/${id}/like`, { user_id: userId });
// ============ СКИНЫ ИГРОКА (R15) ============
// ============ СКИНЫ ИГРОКА (R15) ============
/** Список купленных скинов игрока. Возвращает { skins: [...], count }. */
/** Список купленных скинов игрока. Возвращает { skins: [...], count }. */ export const getOwnedSkins = (userId) =>
export const getOwnedSkins = (userId) => api.get('/kubikon3d/rublox/owned-skins', { params: { user_id: userId } });
api.get('/kubikon3d/rublox/owned-skins', { params: { user_id: userId } });
/** Выбранный (надетый) скин игрока. Возвращает { user_id, skin_folder }.
/** Выбранный (надетый) скин игрока. Возвращает { user_id, skin_folder }. * Если записи нет бэк отдаёт дефолт skin_bacon-hair. */
* Если записи нет бэк отдаёт дефолт skin_bacon-hair. */ export const getEquippedSkin = (userId) =>
export const getEquippedSkin = (userId) => api.get('/kubikon3d/rublox/equipped-skin', { params: { user_id: userId } });
api.get('/kubikon3d/rublox/equipped-skin', { params: { user_id: userId } });
/** Установить выбранный скин игрока. Бэк проверяет владение скином.
/** Установить выбранный скин игрока. Бэк проверяет владение скином. * Возвращает { ok, skin_folder } или ошибку. */
* Возвращает { ok, skin_folder } или ошибку. */ export const setEquippedSkin = (userId, skinFolder) =>
export const setEquippedSkin = (userId, skinFolder) => api.post('/kubikon3d/rublox/equipped-skin', {
api.post('/kubikon3d/rublox/equipped-skin', { user_id: userId, skin_folder: skinFolder,
user_id: userId, skin_folder: skinFolder, });
});
/** Дизайнерский эндпоинт получить один скин по id (видит и draft/testing).
/** Дизайнерский эндпоинт получить один скин по id (видит и draft/testing). * Используется в preview-режиме `/_preview-skin/:itemId`.
* Используется в preview-режиме `/_preview-skin/:itemId`. * Требует JWT с ролью designer или owner. Возвращает item.serialize. */
* Требует JWT с ролью designer или owner. Возвращает item.serialize. */ export const getDesignerSkin = (itemId) =>
export const getDesignerSkin = (itemId) => api.get(`/designer/skins/${itemId}`);
api.get(`/designer/skins/${itemId}`);
/** Текущий outfit игрока (Подфаза 3.9 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md).
/** Текущий outfit игрока (Подфаза 3.9 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md). * Возвращает { slots: {hat_id, tool_id, ...}, items: {<id>: serialize, ...} }.
* Возвращает { slots: {hat_id, tool_id, ...}, items: {<id>: serialize, ...} }. * В items уже есть attachment-поля плеер сразу делает equipAccessory. */
* В items уже есть attachment-поля плеер сразу делает equipAccessory. */ export const getRubloxOutfit = (userId) =>
export const getRubloxOutfit = (userId) => api.get('/rublox/outfit', { params: { user_id: userId } });
api.get('/rublox/outfit', { params: { user_id: userId } });
/** Дизайнерская модель (Фаза 5 RUBLOX_DESIGNER_PLAN.md).
/** Дизайнерская модель (Фаза 5 RUBLOX_DESIGNER_PLAN.md). * Видит draft/testing требует JWT с ролью designer/owner.
* Видит draft/testing требует JWT с ролью designer/owner. * Используется в preview-режиме /_preview-model/:id. */
* Используется в preview-режиме /_preview-model/:id. */ export const getDesignerModel = (modelId) =>
export const getDesignerModel = (modelId) => api.get(`/designer/models/${modelId}`);
api.get(`/designer/models/${modelId}`);
/** Дизайнерский аватар (2026-05-27). Видит draft/testing.
/** Дизайнерский аватар (2026-05-27). Видит draft/testing. * Используется в preview-режиме /_preview-avatar/:id. */
* Используется в preview-режиме /_preview-avatar/:id. */ export const getDesignerAvatar = (avatarId) =>
export const getDesignerAvatar = (avatarId) => api.get(`/designer/avatars/${avatarId}`);
api.get(`/designer/avatars/${avatarId}`);

View File

@ -16,13 +16,6 @@ import Icon from './Icon';
function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) { function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) {
if (!visible) return null; 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 SLOT_COUNT = 5;
const cells = []; const cells = [];
for (let i = 0; i < SLOT_COUNT; i++) { for (let i = 0; i < SLOT_COUNT; i++) {

View File

@ -1,249 +0,0 @@
/**
* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -94,10 +94,6 @@ export class BlockManager {
this._lavaSurfaceBaseY = null; this._lavaSurfaceBaseY = null;
this._lavaDirty = false; this._lavaDirty = false;
this._animTime = 0; 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; // макс блоков одного окрашиваемого типа
} }
/** Вызывать каждый кадр для анимации воды/лавы. */ /** Вызывать каждый кадр для анимации воды/лавы. */
@ -363,23 +359,6 @@ export class BlockManager {
const mat = new StandardMaterial(name, this.scene); const mat = new StandardMaterial(name, this.scene);
mat.specularColor = new Color3(0, 0, 0); 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) { if (texturePath) {
const tex = new Texture(texturePath, this.scene); const tex = new Texture(texturePath, this.scene);
tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE); tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE);
@ -460,7 +439,7 @@ export class BlockManager {
* один meshes-prototype на тип блока, тысячи позиций в одном draw call. * один meshes-prototype на тип блока, тысячи позиций в одном draw call.
* Жидкости (water/lava) идут по старому пути у них свой single-surface. * Жидкости (water/lava) идут по старому пути у них свой single-surface.
*/ */
addBlock(x, y, z, blockTypeId, color) { addBlock(x, y, z, blockTypeId) {
const ix = Math.round(x); const ix = Math.round(x);
const iy = Math.round(y); const iy = Math.round(y);
const iz = Math.round(z); const iz = Math.round(z);
@ -470,9 +449,6 @@ export class BlockManager {
const typeDef = getBlockType(blockTypeId); const typeDef = getBlockType(blockTypeId);
const isWater = !!typeDef?.isWater; const isWater = !!typeDef?.isWater;
const isLava = !!typeDef?.isLava; const isLava = !!typeDef?.isLava;
// Окрашиваемый блок: цвет инстанса (из аргумента или дефолт типа).
const colorable = !!typeDef?.colorable;
const instColor = colorable ? (color || typeDef.defaultColor || '#cccccc') : null;
// Для жидкостей оставляем старую логику: невидимый куб + единый surface // Для жидкостей оставляем старую логику: невидимый куб + единый surface
if (isWater || isLava) { if (isWater || isLava) {
@ -520,9 +496,6 @@ export class BlockManager {
keysArr[idx] = key; keysArr[idx] = key;
this._cellToInst.set(key, { typeId: blockTypeId, idx }); this._cellToInst.set(key, { typeId: blockTypeId, idx });
// Окрашиваемый блок — пишем цвет инстанса в color buffer.
if (colorable) this._setBlockColorAt(blockTypeId, idx, instColor);
// Логический «meshProxy» — объект, имитирующий API mesh для совместимости. // Логический «meshProxy» — объект, имитирующий API mesh для совместимости.
// НЕ создаёт реального меша. Используется selection / removeBlockByMesh. // НЕ создаёт реального меша. Используется selection / removeBlockByMesh.
const meshProxy = { const meshProxy = {
@ -538,7 +511,6 @@ export class BlockManager {
mass: 1, mass: 1,
folderId: null, folderId: null,
_thinIdx: idx, _thinIdx: idx,
color: instColor, // per-instance цвет окрашиваемого блока
}, },
// Минимальные методы, которые ожидает остальной код // Минимальные методы, которые ожидает остальной код
position: new Vector3(ix, iy + 0.5, iz), position: new Vector3(ix, iy + 0.5, iz),
@ -566,18 +538,6 @@ export class BlockManager {
proto.material = material; proto.material = material;
if (isMulti) this._setupSubmeshes(proto); 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 не работают штатно; proto.checkCollisions = false; // коллизии через thin-instances не работают штатно;
// PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB). // PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB).
proto.isPickable = true; proto.isPickable = true;
@ -611,44 +571,6 @@ export class BlockManager {
/** Подписка для уведомлений о создании prototype-меша (для shadow setup). */ /** Подписка для уведомлений о создании prototype-меша (для shadow setup). */
setOnProtoCreated(cb) { this._onProtoCreated = cb; } 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 у блока. */ /** Установить флаг anchored у блока. */
setBlockAnchored(x, y, z, anchored) { setBlockAnchored(x, y, z, anchored) {
const mesh = this.blocks.get(this._key(x, y, z)); const mesh = this.blocks.get(this._key(x, y, z));
@ -845,8 +767,6 @@ export class BlockManager {
canCollide: m.canCollide !== false, canCollide: m.canCollide !== false,
visible: m.visible !== false, visible: m.visible !== false,
mass: m.mass ?? 1, mass: m.mass ?? 1,
// per-instance цвет окрашиваемого блока (studs-block); иначе пропускаем
...(m.color ? { color: m.color } : {}),
}); });
} }
return out; return out;
@ -858,7 +778,7 @@ export class BlockManager {
this._batchMode = true; this._batchMode = true;
try { try {
for (const b of arr) { for (const b of arr) {
const mesh = this.addBlock(b.x, b.y, b.z, b.type, b.color); const mesh = this.addBlock(b.x, b.y, b.z, b.type);
if (!mesh) continue; if (!mesh) continue;
if (b.anchored === false) { if (b.anchored === false) {
mesh.metadata.anchored = false; mesh.metadata.anchored = false;
@ -882,14 +802,6 @@ export class BlockManager {
proto.thinInstanceRefreshBoundingInfo(true); proto.thinInstanceRefreshBoundingInfo(true);
} catch (e) { /* ignore */ } } 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() { clear() {

View File

@ -105,11 +105,6 @@ export const BLOCK_TYPES = [
// top = stone, side = oven (4 стороны с дверцей — лучше чем раньше), // top = stone, side = oven (4 стороны с дверцей — лучше чем раньше),
// bottom = stone. В будущем для одной двери понадобится 6-face формат. // bottom = stone. В будущем для одной двери понадобится 6-face формат.
multiCube('oven', 'Печь', 'Особые', `${TEX}/stone.png`, `${TEX}/oven.png`, `${TEX}/stone.png`), 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' }),
]; ];
/** Все доступные категории в порядке появления. */ /** Все доступные категории в порядке появления. */
@ -125,7 +120,6 @@ export const CATEGORY_COLORS = {
'Кирпич': '#9d4a3a', 'Кирпич': '#9d4a3a',
'Особые': '#9966ff', 'Особые': '#9966ff',
'Природа': '#5a8c3e', 'Природа': '#5a8c3e',
'Окрашиваемые': '#3a7aff',
}; };
/** Найти описание блока по id. */ /** Найти описание блока по id. */

View File

@ -91,14 +91,10 @@ export class Environment {
this.fogEnabled = false; this.fogEnabled = false;
this.fogColor = [0.7, 0.8, 0.9]; this.fogColor = [0.7, 0.8, 0.9];
this.fogDensity = 0.01; this.fogDensity = 0.01;
// Видимые тела на небе (солнце и луна). // Видимые тела на небе (солнце и луна) — создаём по запросу
// ВАЖНО (задача 16): единое небо рисует SkyboxManager. Environment больше
// НЕ рисует свою жёлтую сферу/луну — иначе на небе два солнца. Здесь
// остаётся только управление светом (направление/яркость/ambient).
this._drawSkyBodies = false;
this._sunMesh = null; this._sunMesh = null;
this._moonMesh = null; this._moonMesh = null;
if (this._drawSkyBodies) this._createSkyBodies(); this._createSkyBodies();
this._applyTime(); this._applyTime();
} }

View File

@ -1,236 +0,0 @@
/**
* 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();
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,370 +0,0 @@
/**
* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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; }
}
}

View File

@ -1,385 +1,80 @@
/** /**
* LabelManager billboard-плашки (текст-надписи) над 3D-объектами. * LabelManager billboard-метки (текст-плашки) над 3D-объектами.
* *
* game.scene.setLabel(ref, text, opts) имена/HP/таймеры/счётчики над * Используется для game.scene.setLabel(ref, text) имена/HP над
* персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к * персонажами, врагами, предметами. Метка всегда повёрнута лицом к камере
* камере (billboardMode=7), всегда поверх геометрии (renderingGroupId=1). * (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
* *
* Задача 10 расширенные стили: фон/обводка/скругление (пресеты gameui/ * Метка привязывается к мешу объекта (parent) и висит над ним.
* warning/reward/boss-hp/plain), обводка текста, richText (<color>/<b>/<size>),
* faceMode billboard|fixed, attachPoint, maxDistance.
*
* Плашка привязывается к мешу объекта (parent) и висит над ним.
*/ */
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture'; import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { Color3 } from '@babylonjs/core/Maths/math.color'; import { Color3 } from '@babylonjs/core/Maths/math.color';
import { Mesh } from '@babylonjs/core/Meshes/mesh';
// === Пресеты стилей плашки (фон/обводка/текст) ===
// gameui — жёлтая обводка + тёмно-синий фон + белый текст (как в Roblox-UI).
export const LABEL_PRESETS = {
plain: {
background: null, borderColor: null, borderWidth: 0, cornerRadius: 0,
color: '#ffffff', textStroke: { color: '#000', width: 8 },
},
gameui: {
background: '#1a3a8a', borderColor: '#f5c020', borderWidth: 10, cornerRadius: 28,
color: '#ffffff', textStroke: { color: '#0a1430', width: 6 },
},
warning: {
background: '#b01e1e', borderColor: '#ffce3a', borderWidth: 10, cornerRadius: 28,
color: '#ffffff', textStroke: { color: '#000', width: 6 },
},
reward: {
background: '#caa018', borderColor: '#fff0a0', borderWidth: 10, cornerRadius: 28,
color: '#fff8e0', textStroke: { color: '#6b4e00', width: 6 },
gradient: ['#f7d34a', '#c98a18'], // золотой градиент фона
},
'boss-hp': {
background: '#3a0a0a', borderColor: '#ff4040', borderWidth: 8, cornerRadius: 20,
color: '#ffd0d0', textStroke: { color: '#000', width: 6 },
gradient: ['#8a1414', '#3a0a0a'],
},
};
export class LabelManager { export class LabelManager {
constructor(scene) { constructor(scene) {
this.scene = scene; this.scene = scene;
// ref-строка объекта → { plane, tex, mat, lastKey, opts } // ref-строка объекта → { plane, tex, mat }
this.labels = new Map(); this.labels = new Map();
this._playerMesh = null; // для maxDistance — задаётся из BabylonScene
} }
/** Дать ссылку на меш игрока (для maxDistance-скрытия). */
setPlayerMesh(mesh) { this._playerMesh = mesh; }
/** /**
* Установить/обновить плашку над объектом. * Установить/обновить метку над объектом.
* ref ref-строка объекта. * ref ref-строка объекта (от scene.spawn / scene.find).
* anchorMesh Babylon-меш объекта (плашка крепится к нему). * anchorMesh Babylon-меш объекта (метка крепится к нему).
* text текст (может содержать richText-теги если opts.richText). * text текст метки.
* opts см. LABEL_PRESETS + { color, height, size, background, * opts { color: '#fff', height: 2.5 (м над объектом), size: 1 }
* borderColor, borderWidth, cornerRadius, padding, textStroke,
* fontWeight, faceMode, rotationY, attachPoint, preset,
* richText, maxDistance }
*/ */
setLabel(ref, anchorMesh, text, opts = {}) { setLabel(ref, anchorMesh, text, opts = {}) {
if (!anchorMesh) return; if (!anchorMesh) return;
text = String(text == null ? '' : text); const color = opts.color || '#ffffff';
// Пресет → база, поверх — явные opts.
const preset = opts.preset && LABEL_PRESETS[opts.preset] ? LABEL_PRESETS[opts.preset] : null;
const st = { ...(preset || {}), ...opts };
const color = st.color || '#ffffff';
const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5; const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5;
const sizeMul = Number.isFinite(opts.size) ? opts.size : 1; const sizeMul = Number.isFinite(opts.size) ? opts.size : 1;
const richText = !!opts.richText;
// Диф-чек: если ничего не поменялось — не пересоздаём (важно для bindLabel).
const styleKey = JSON.stringify({ text, color, preset: opts.preset, bg: st.background,
bc: st.borderColor, bw: st.borderWidth, cr: st.cornerRadius, rich: richText,
fw: st.fontWeight, h: heightAbove, sz: sizeMul, fm: st.faceMode, ry: st.rotationY,
af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null });
const existing = this.labels.get(ref);
if (existing && existing.lastKey === styleKey && existing.plane.parent === anchorMesh) {
return; // ничего не изменилось
}
// Меняется только текст (тот же стиль/размер) → перерисуем canvas без
// пересоздания меша (дешевле). Иначе — полное пересоздание.
const sameStruct = existing && existing.styleStruct === this._structKey(st, richText, heightAbove, sizeMul);
if (sameStruct) {
this._drawCanvas(existing.tex, text, color, st, richText);
existing.tex.update(true);
existing.lastKey = styleKey;
existing.lastText = text;
return;
}
// Если метка уже есть — пересоздаём (текст/цвет могли измениться).
this.clearLabel(ref); this.clearLabel(ref);
// Размер текстуры: чем больше текста — тем шире, чтобы не растягивать.
const fontPx = 120;
const W = 1024, H = 256; const W = 1024, H = 256;
const tex = new DynamicTexture(`lblTex_${ref}_${this._uid()}`, const tex = new DynamicTexture(`lblTex_${ref}_${Date.now()}`,
{ width: W, height: H }, this.scene, true); { width: W, height: H }, this.scene, true);
tex.updateSamplingMode?.(3); // TRILINEAR tex.updateSamplingMode?.(3); // TRILINEAR
tex.anisotropicFilteringLevel = 8; tex.anisotropicFilteringLevel = 8;
tex.hasAlpha = true; const ctx = tex.getContext();
this._drawCanvas(tex, text, color, st, richText); 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.update(true);
tex.hasAlpha = true;
// Соотношение плашки — 4:1 (1024×256). Крупная и читаемая (как в Roblox).
// ОДНОСТОРОННЯЯ плоскость (FRONTSIDE): лицо = нормаль +Z. Мы всегда
// разворачиваем плашку лицом к наблюдателю снаружи грани, поэтому текст
// читается прямо. DOUBLESIDE НЕ используем — задняя грань зеркалит UV
// (баг: «Ключ от тайника» наоборот). backFaceCulling выключен только
// чтобы плашка не пропадала при взгляде сзади (без отражённого текста).
const plane = MeshBuilder.CreatePlane(`lbl_${ref}`, const plane = MeshBuilder.CreatePlane(`lbl_${ref}`,
{ width: 3.4 * sizeMul, height: 0.85 * sizeMul, { width: 2.2 * sizeMul, height: 0.55 * sizeMul }, this.scene);
sideOrientation: Mesh.FRONTSIDE }, this.scene);
const mat = new StandardMaterial(`lblMat_${ref}`, this.scene); const mat = new StandardMaterial(`lblMat_${ref}`, this.scene);
mat.diffuseTexture = tex; mat.diffuseTexture = tex;
mat.diffuseTexture.hasAlpha = true; mat.diffuseTexture.hasAlpha = true;
mat.emissiveColor = new Color3(1, 1, 1); mat.emissiveColor = new Color3(1, 1, 1);
mat.diffuseColor = new Color3(0, 0, 0);
mat.disableLighting = true; mat.disableLighting = true;
// Геометрия двусторонняя (DOUBLESIDE) с прямыми backUVs — culling можно
// включить, дублей нет; текст читается с обеих сторон без зеркала.
mat.backFaceCulling = false; mat.backFaceCulling = false;
mat.disableDepthWrite = true; mat.disableDepthWrite = true;
mat.useAlphaFromDiffuseTexture = true;
plane.material = mat; plane.material = mat;
plane.renderingGroupId = 1; plane.billboardMode = 7; // всегда лицом к камере
plane.renderingGroupId = 1; // поверх геометрии
plane.isPickable = false; plane.isPickable = false;
// Крепим к объекту: метка висит над ним и двигается вместе с ним.
plane.parent = anchorMesh; plane.parent = anchorMesh;
plane.position.set(0, heightAbove, 0);
// Полуразмеры объекта по каждой оси (для крепления над верхом ИЛИ на this.labels.set(ref, { plane, tex, mat });
// грань). Берём bounding box в ЛОКАЛЬНЫХ координатах меша (min/max), чтобы
// позиция плашки-ребёнка была верной при любом масштабе/вращении родителя.
let halfX = 0.5, halfY = 0.5, halfZ = 0.5;
try {
const bb = anchorMesh.getBoundingInfo?.().boundingBox;
if (bb && bb.minimum && bb.maximum) {
halfX = (bb.maximum.x - bb.minimum.x) / 2;
halfY = (bb.maximum.y - bb.minimum.y) / 2;
halfZ = (bb.maximum.z - bb.minimum.z) / 2;
} else if (anchorMesh.scaling) {
halfX = Math.abs(anchorMesh.scaling.x) / 2;
halfY = Math.abs(anchorMesh.scaling.y) / 2;
halfZ = Math.abs(anchorMesh.scaling.z) / 2;
}
} catch (e) { /* ignore */ }
const halfH = halfY;
const halfPlane = 0.45 * sizeMul; // полувысота самой плашки (3.4×0.85)
// attachFace — ПРИКРЕПИТЬ плашку НА ГРАНЬ объекта (как табличка на
// стене/полу): плашка лежит В ПЛОСКОСТИ грани, фиксированной ориентации,
// и перемещается/вращается ВМЕСТЕ с объектом (она его ребёнок). Это
// Roblox-style «надпись = часть постройки» (в отличие от billboard над
// верхом). Значения: 'top'|'bottom'|'front'|'back'|'left'|'right'
// (или сырые '+y'/'-y'/'+z'/'-z'/'+x'/'-x').
const FACE = { top: '+y', bottom: '-y', front: '+z', back: '-z',
right: '+x', left: '-x' };
let face = st.attachFace;
if (face && FACE[face]) face = FACE[face];
if (face) {
// На грань — всегда фиксированная ориентация (не billboard), иначе
// «связки с примитивом» не будет (плашка крутилась бы к камере).
plane.billboardMode = 0;
const gap = Number.isFinite(opts.height) ? opts.height : 0.05;
// ВАЖНО: Babylon CreatePlane (FRONTSIDE) лицевой стороной (где UV/текст
// не зеркалятся) смотрит в Z. Поэтому чтобы ЛИЦО таблички смотрело
// НАРУЖУ выбранной грани, поворачиваем плоскость так, чтобы её Z
// совпал с внешней нормалью грани. tiltSign — знак наклона tilt с
// учётом того, что для грани +z плоскость развёрнута на π.
let tiltSign = 1;
if (face === '+z') { plane.position.set(0, 0, halfZ + gap); plane.rotation.set(0, Math.PI, 0); tiltSign = -1; }
else if (face === '-z') { plane.position.set(0, 0, -(halfZ + gap)); plane.rotation.set(0, 0, 0); }
else if (face === '+x') { plane.position.set( halfX + gap, 0, 0); plane.rotation.set(0, -Math.PI / 2, 0); }
else if (face === '-x') { plane.position.set(-(halfX + gap), 0, 0); plane.rotation.set(0, Math.PI / 2, 0); }
else if (face === '+y') { plane.position.set(0, halfY + gap, 0); plane.rotation.set( Math.PI / 2, 0, 0); }
else if (face === '-y') { plane.position.set(0, -(halfY + gap), 0); plane.rotation.set(-Math.PI / 2, 0, 0); }
if (Number.isFinite(st.rotationY)) plane.rotation.y += st.rotationY;
// tilt — наклон таблички вокруг локальной X (ценник под ~45°, как на
// витрине Roblox). Знак нормализуем (tiltSign), чтобы «верх назад» был
// одинаковым для всех граней. Отрицательный tilt = верх отклоняется
// назад (от наблюдателя), как пюпитр.
if (Number.isFinite(st.tilt)) plane.rotation.x += st.tilt * tiltSign;
} else {
// faceMode: 'fixed' — фиксированная ориентация (вращается с объектом),
// но позиционируется как обычная плашка (над верхом/центром/низом).
if (st.faceMode === 'fixed') {
plane.billboardMode = 0;
if (Number.isFinite(st.rotationY)) plane.rotation.y = st.rotationY;
} else {
plane.billboardMode = 7; // всегда лицом к камере
}
// attachPoint: 'top'(default) — над верхом + небольшой зазор (height);
// 'center' — по центру; 'bottom' — у низа; {x,y,z} — явно.
const gap = Number.isFinite(opts.height) ? opts.height : 0.6;
let py = halfH + gap + halfPlane; // верх объекта + зазор + полувысота плашки
if (st.attachPoint === 'center') py = 0;
else if (st.attachPoint === 'bottom') py = -(halfH + gap);
else if (st.attachPoint && typeof st.attachPoint === 'object') {
plane.position.set(st.attachPoint.x || 0, st.attachPoint.y || 0, st.attachPoint.z || 0);
py = null;
}
if (py !== null) plane.position.set(0, py, 0);
}
this.labels.set(ref, {
plane, tex, mat,
lastKey: styleKey,
lastText: text,
styleStruct: this._structKey(st, richText, heightAbove, sizeMul),
maxDistance: Number.isFinite(opts.maxDistance) ? opts.maxDistance : null,
});
} }
/** Ключ «структуры» (всё кроме текста) — для решения перерисовать ли canvas. */ /** Убрать метку с объекта. */
_structKey(st, richText, h, sz) {
return JSON.stringify({ p: st.preset, bg: st.background, bc: st.borderColor,
bw: st.borderWidth, cr: st.cornerRadius, rich: richText, fw: st.fontWeight,
grad: st.gradient, ts: st.textStroke, h, sz, fm: st.faceMode,
af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null });
}
_uid() { this._seq = (this._seq || 0) + 1; return this._seq; }
/**
* Нарисовать плашку на canvas DynamicTexture.
* Фон (roundRect + gradient/fill) обводка border текст (с обводкой).
*/
_drawCanvas(tex, text, color, st, richText) {
const W = 1024, H = 256;
const ctx = tex.getContext();
ctx.clearRect(0, 0, W, H);
const hasBg = !!st.background || (Array.isArray(st.gradient) && st.gradient.length === 2);
const pad = Number.isFinite(st.padding) ? st.padding : 28;
const cr = Number.isFinite(st.cornerRadius) ? st.cornerRadius : 0;
const bw = Number.isFinite(st.borderWidth) ? st.borderWidth : 0;
const weight = st.fontWeight || 700;
const innerPad = (hasBg ? (bw + pad) : 24); // отступ от краёв (под рамку)
const maxTextW = W - innerPad * 2;
// Auto-fit: подбираем размер шрифта, чтобы текст влез по ширине (не обрезался).
let fontPx = 120;
if (!richText) {
ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`;
const tw = ctx.measureText(text).width;
if (tw > maxTextW) fontPx = Math.max(40, Math.floor(fontPx * maxTextW / tw));
}
ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// === Фон-плашка ===
if (hasBg) {
const m = bw / 2 + 4; // отступ рамки от края текстуры
const x = m, y = m, w = W - m * 2, h = H - m * 2;
this._roundRectPath(ctx, x, y, w, h, cr);
if (Array.isArray(st.gradient) && st.gradient.length === 2) {
const g = ctx.createLinearGradient(0, y, 0, y + h);
g.addColorStop(0, st.gradient[0]);
g.addColorStop(1, st.gradient[1]);
ctx.fillStyle = g;
} else {
ctx.fillStyle = st.background;
}
ctx.globalAlpha = Number.isFinite(st.backgroundOpacity) ? st.backgroundOpacity : 0.92;
ctx.fill();
ctx.globalAlpha = 1;
if (bw > 0 && st.borderColor) {
ctx.lineWidth = bw;
ctx.strokeStyle = st.borderColor;
ctx.stroke();
}
}
// === Текст ===
const ts = st.textStroke || { color: '#000', width: hasBg ? 6 : 10 };
if (richText) {
this._drawRichText(ctx, text, color, ts, W, H);
} else {
if (ts && ts.width > 0) {
ctx.lineWidth = ts.width;
ctx.lineJoin = 'round';
ctx.strokeStyle = ts.color || '#000';
ctx.strokeText(text, W / 2, H / 2 + 4);
}
ctx.fillStyle = color;
ctx.fillText(text, W / 2, H / 2 + 4);
}
}
/** Путь скруглённого прямоугольника (roundRect не везде есть). */
_roundRectPath(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/**
* RichText: парсим теги <color=#hex>...</color>, <b>...</b>, <size=N>...</size>.
* Рисуем сегментами по горизонтали, центрируя всю строку. Вложенность не
* поддерживается (на MVP) берём последний открытый тег каждого типа.
*/
_drawRichText(ctx, text, baseColor, ts, W, H) {
const segs = this._parseRich(text, baseColor);
const fontPx = 120;
// Замер ширины каждого сегмента в его размере.
let total = 0;
for (const s of segs) {
ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`;
s.w = ctx.measureText(s.text).width;
total += s.w;
}
let x = (W - total) / 2;
for (const s of segs) {
ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`;
ctx.textAlign = 'left';
if (ts && ts.width > 0) {
ctx.lineWidth = ts.width;
ctx.lineJoin = 'round';
ctx.strokeStyle = ts.color || '#000';
ctx.strokeText(s.text, x, H / 2 + 4);
}
ctx.fillStyle = s.color;
ctx.fillText(s.text, x, H / 2 + 4);
x += s.w;
}
ctx.textAlign = 'center';
}
/** Простой парсер richText → [{text, color, bold, sizeMul}]. */
_parseRich(text, baseColor) {
const segs = [];
let color = baseColor, bold = false, sizeMul = 1;
// Разбиваем по тегам (открывающим/закрывающим).
const re = /<(\/?)(?:(color)=([#0-9a-fA-F]+)|(b)|(i)|(size)=(\d+))>|([^<]+)/g;
let m;
while ((m = re.exec(text)) !== null) {
const closing = m[1] === '/';
if (m[8] != null) {
// текстовый кусок
if (m[8]) segs.push({ text: m[8], color, bold, sizeMul });
} else if (m[2]) { // <color=...>
color = closing ? baseColor : m[3];
} else if (m[4]) { // <b>
bold = !closing;
} else if (m[6]) { // <size=N>
sizeMul = closing ? 1 : Math.max(0.4, Math.min(2, Number(m[7]) / 100));
}
// <i> игнорим визуально (italic в canvas через font-style — опускаем на MVP)
}
if (segs.length === 0) segs.push({ text: '', color: baseColor, bold: false, sizeMul: 1 });
return segs;
}
/** Каждый кадр: maxDistance-скрытие плашек дальше порога от игрока. */
update() {
if (!this._playerMesh) return;
const pp = this._playerMesh.position;
for (const rec of this.labels.values()) {
if (rec.maxDistance == null) continue;
const ap = rec.plane.getAbsolutePosition();
const dx = ap.x - pp.x, dy = ap.y - pp.y, dz = ap.z - pp.z;
const far = (dx * dx + dy * dy + dz * dz) > rec.maxDistance * rec.maxDistance;
rec.plane.setEnabled(!far);
}
}
/** Убрать плашку с объекта. */
clearLabel(ref) { clearLabel(ref) {
const rec = this.labels.get(ref); const rec = this.labels.get(ref);
if (!rec) return; if (!rec) return;
@ -389,7 +84,7 @@ export class LabelManager {
this.labels.delete(ref); this.labels.delete(ref);
} }
/** Удалить все плашки (при выходе из Play). */ /** Удалить все метки (при выходе из Play). */
clearAll() { clearAll() {
for (const ref of [...this.labels.keys()]) this.clearLabel(ref); for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
} }

View File

@ -1,255 +0,0 @@
/**
* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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 = '';
}
}

View File

@ -1,557 +0,0 @@
/**
* 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; }
}
}

View File

@ -314,10 +314,9 @@ export class ModelManager {
r.getChildMeshes(false).forEach(m => { r.getChildMeshes(false).forEach(m => {
m.isPickable = true; m.isPickable = true;
m.metadata = { isModel: true, instanceId: this._nextInstanceId }; m.metadata = { isModel: true, instanceId: this._nextInstanceId };
// Тени: на InstancedMesh receiveShadows не действует (warning+нагрузка). // Тени: GLB-модель и принимает тени, и отбрасывает их
if (m.getClassName && m.getClassName() !== 'InstancedMesh') { // (через addShadowCaster в refreshAllShadows).
m.receiveShadows = true; m.receiveShadows = true;
}
clonedMeshes.push(m); clonedMeshes.push(m);
}); });
// И сам root тоже на всякий // И сам root тоже на всякий
@ -542,7 +541,6 @@ export class ModelManager {
opacity: typeof data.opacity === 'number' ? data.opacity : 1, opacity: typeof data.opacity === 'number' ? data.opacity : 1,
tint: data.tint || null, tint: data.tint || null,
name: data.name || null, name: data.name || null,
...(data.folderId != null ? { folderId: data.folderId } : {}), // папка
// Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.) // Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.)
gameplayParams: data.gameplayParams || null, gameplayParams: data.gameplayParams || null,
}); });
@ -776,7 +774,6 @@ export class ModelManager {
if (m.tint) data.tint = m.tint; if (m.tint) data.tint = m.tint;
if (m.name) data.name = m.name; if (m.name) data.name = m.name;
if (m.gameplayParams) data.gameplayParams = m.gameplayParams; if (m.gameplayParams) data.gameplayParams = m.gameplayParams;
if (m.folderId != null) data.folderId = m.folderId;
if (data.opacity != null || data.tint) this._applyMaterialOverrides(data); if (data.opacity != null || data.tint) this._applyMaterialOverrides(data);
} }
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -161,19 +161,6 @@ export class NpcManager {
r15Animator, r15Animator,
}; };
this.npcs.set(id, npc); 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; return id;
} }
@ -288,12 +275,6 @@ export class NpcManager {
npc.isMoving = false; npc.isMoving = false;
} }
/** Включить/выключить анимацию атаки. */
setAttacking(id, on) {
const npc = this.npcs.get(Number(id));
if (npc) npc.attacking = !!on;
}
/** Реплика над головой NPC на duration секунд. */ /** Реплика над головой NPC на duration секунд. */
say(id, text, duration = 3) { say(id, text, duration = 3) {
const npc = this.npcs.get(Number(id)); const npc = this.npcs.get(Number(id));
@ -306,41 +287,10 @@ export class NpcManager {
damage(id, amount) { damage(id, amount) {
const npc = this.npcs.get(Number(id)); const npc = this.npcs.get(Number(id));
if (!npc || npc.dead) return; if (!npc || npc.dead) return;
const amt = Number(amount) || 0; npc.hp = Math.max(0, npc.hp - (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); 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 (без эффекта смерти — просто убрать). */ /** Удалить NPC по id (без эффекта смерти — просто убрать). */
removeNpc(id) { removeNpc(id) {
const npc = this.npcs.get(Number(id)); const npc = this.npcs.get(Number(id));
@ -441,22 +391,17 @@ export class NpcManager {
if (root._isWorldMatrixFrozen) { if (root._isWorldMatrixFrozen) {
try { root.unfreezeWorldMatrix(); } catch (e) {} try { root.unfreezeWorldMatrix(); } catch (e) {}
} }
// Процедурная анимация ходьбы (у Kenney-моделей нет скелета). root.position.set(npc.x, npc.y, npc.z);
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.y = npc.yaw;
root.rotation.z = lean;
// data.x/y/z — чтобы scene.find/getPosition видели NPC. // data.x/y/z — чтобы scene.find/getPosition видели NPC.
data.x = npc.x; data.y = npc.y; data.z = npc.z; data.x = npc.x; data.y = npc.y; data.z = npc.z;
// Анимация ходьбы — простое покачивание (без R15-скелета у Kenney).
if (moving) npc.walkPhase += dt * 6;
// R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator. // R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator.
if (npc.r15Animator) { if (npc.r15Animator) {
try { try {
npc.r15Animator.setState(npc.attacking ? 'attack' : (moving ? 'run' : 'idle')); npc.r15Animator.setState(moving ? 'run' : 'idle');
npc.r15Animator.update(dt); npc.r15Animator.update(dt);
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }

View File

@ -1,586 +0,0 @@
/**
* 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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,7 @@
* При касании игроком обновляет spawnPoint сцены. * При касании игроком обновляет spawnPoint сцены.
*/ */
import { import {
MeshBuilder, StandardMaterial, Color3, Vector3, Vector4, PointLight, MeshBuilder, StandardMaterial, Color3, Vector3, PointLight,
Mesh, VertexData, Mesh, VertexData,
} from '@babylonjs/core'; } from '@babylonjs/core';
// CRA→Vite адаптация: оригинальный код в setTexture()/_applyMaterial()/ // CRA→Vite адаптация: оригинальный код в setTexture()/_applyMaterial()/
@ -33,57 +33,6 @@ import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTextur
import { Texture } from '@babylonjs/core/Materials/Textures/texture'; import { Texture } from '@babylonjs/core/Materials/Textures/texture';
import { getPrimitiveType } from './PrimitiveTypes'; 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 { export class PrimitiveManager {
constructor(scene) { constructor(scene) {
this.scene = scene; this.scene = scene;
@ -124,8 +73,6 @@ export class PrimitiveManager {
const isGlowingGd = isGdKind; const isGlowingGd = isGdKind;
const isGdSpike = typeDef.kind === 'gd_spike'; const isGdSpike = typeDef.kind === 'gd_spike';
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte'); const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции) // canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike; const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
const visible = opts.visible !== false; const visible = opts.visible !== false;
@ -143,7 +90,7 @@ export class PrimitiveManager {
const rotationY = opts.rotationY ?? 0; const rotationY = opts.rotationY ?? 0;
const rotationZ = opts.rotationZ ?? 0; const rotationZ = opts.rotationZ ?? 0;
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity); const mesh = this._createMeshForType(typeDef, id, sx, sy, sz);
mesh.position = new Vector3(x, y, z); mesh.position = new Vector3(x, y, z);
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ); mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
mesh.isPickable = true; mesh.isPickable = true;
@ -156,7 +103,6 @@ export class PrimitiveManager {
primitiveId: id, primitiveId: id,
primitiveType: type, primitiveType: type,
primitiveKind: typeDef.kind, primitiveKind: typeDef.kind,
canCollide, // нужен camera-clamp: камера не цепляется за зоны canCollide:false
}; };
// textureAsset — id картинки из AssetManager (пользовательская // textureAsset — id картинки из AssetManager (пользовательская
@ -168,15 +114,13 @@ export class PrimitiveManager {
id, mesh, type, x, y, z, sx, sy, sz, id, mesh, type, x, y, z, sx, sy, sz,
rotationX, rotationY, rotationZ, rotationX, rotationY, rotationZ,
color, material, canCollide, visible, anchored, mass, color, material, canCollide, visible, anchored, mass,
textureAsset, studDensity, textureAsset,
// locked — объект защищён от выделения/перемещения в редакторе // locked — объект защищён от выделения/перемещения в редакторе
// (Фаза 5.11). На геймплей не влияет. // (Фаза 5.11). На геймплей не влияет.
locked: opts.locked === true, locked: opts.locked === true,
name: opts.name || null, name: opts.name || null,
folderId: opts.folderId ?? null, folderId: opts.folderId ?? null,
}; };
// Размеры для тайлинга studs-материала (читается в _applyMaterial).
mesh._studsDims = { type, sx, sy, sz, density: studDensity };
this._applyMaterial(mesh, typeDef, color, material); this._applyMaterial(mesh, typeDef, color, material);
this._applyVisible(mesh, visible, typeDef); this._applyVisible(mesh, visible, typeDef);
// Пользовательская текстура — поверх базового материала. // Пользовательская текстура — поверх базового материала.
@ -241,17 +185,13 @@ export class PrimitiveManager {
} }
/** Создать базовый mesh нужной формы (без материала). */ /** Создать базовый mesh нужной формы (без материала). */
_createMeshForType(typeDef, id, sx, sy, sz, material, studDensity) { _createMeshForType(typeDef, id, sx, sy, sz) {
const name = `prim_${typeDef.id}_${id}`; const name = `prim_${typeDef.id}_${id}`;
switch (typeDef.id) { switch (typeDef.id) {
case 'cube': case 'cube':
case 'trigger': { case 'trigger':
const boxOpts = { width: sx, height: sy, depth: sz }; return MeshBuilder.CreateBox(name,
// studs — per-face UV, чтобы кружки были одного размера на всех { width: sx, height: sy, depth: sz }, this.scene);
// гранях (не растягивались на длинной стороне).
if (material === 'studs') boxOpts.faceUV = _studsCubeFaceUV(sx, sy, sz, studDensity);
return MeshBuilder.CreateBox(name, boxOpts, this.scene);
}
case 'sphere': case 'sphere':
return MeshBuilder.CreateSphere(name, return MeshBuilder.CreateSphere(name,
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene); { diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
@ -485,65 +425,12 @@ export class PrimitiveManager {
break; break;
case 'glass': case 'glass':
mat.alpha = 0.4; mat.alpha = 0.4;
mat.specularColor = new Color3(0.8, 0.85, 0.9); mat.specularColor = new Color3(0.5, 0.5, 0.5);
mat.specularPower = 96;
mat.backFaceCulling = false;
break; break;
case 'neon': case 'neon':
mat.emissiveColor = Color3.FromHexString(color || '#888888'); mat.emissiveColor = Color3.FromHexString(color || '#888888');
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
break; break;
case 'chrome': {
const cc = Color3.FromHexString(color || '#cfd6e0');
mat.diffuseColor = new Color3(cc.r * 0.6, cc.g * 0.6, cc.b * 0.6);
mat.specularColor = new Color3(1, 1, 1);
mat.specularPower = 128;
mat.emissiveColor = new Color3(cc.r * 0.12, cc.g * 0.12, cc.b * 0.14);
break;
}
case 'water': {
const wc = Color3.FromHexString(color || '#3aa0ff');
mat.diffuseColor = wc;
mat.alpha = 0.55;
mat.specularColor = new Color3(0.9, 0.95, 1.0);
mat.specularPower = 64;
mat.emissiveColor = new Color3(wc.r * 0.1, wc.g * 0.14, wc.b * 0.2);
mesh._isWater = true;
break;
}
case 'iridescent': {
const ic = Color3.FromHexString(color || '#a06bff');
mat.diffuseColor = ic;
mat.emissiveColor = new Color3(ic.r * 0.5, ic.g * 0.35, ic.b * 0.6);
mat.specularColor = new Color3(1, 1, 1);
mat.specularPower = 96;
break;
}
case 'studs': {
// Лего-материал (паритет со студией): почти-белая 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': case 'matte':
default: default:
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
@ -689,12 +576,6 @@ export class PrimitiveManager {
if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; } if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; } if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
if (patch.sz !== undefined) { data.sz = patch.sz; 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) { if (scaleChanged) {
// Поскольку MeshBuilder уже создал mesh с базовыми размерами, // Поскольку MeshBuilder уже создал mesh с базовыми размерами,
// изменения через scaling кажутся правильными. Простой способ — // изменения через scaling кажутся правильными. Простой способ —
@ -720,7 +601,6 @@ export class PrimitiveManager {
if (data.mesh.material) { if (data.mesh.material) {
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ } 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); this._applyMaterial(data.mesh, typeDef, data.color, data.material);
} }
// Текстуру переприменяем если: сменили саму текстуру, или // Текстуру переприменяем если: сменили саму текстуру, или
@ -734,14 +614,10 @@ export class PrimitiveManager {
if (data.mesh.material) { if (data.mesh.material) {
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ } 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); this._applyMaterial(data.mesh, typeDef, data.color, data.material);
} }
if (patch.canCollide !== undefined) { if (patch.canCollide !== undefined) data.canCollide = patch.canCollide;
data.canCollide = patch.canCollide;
if (data.mesh?.metadata) data.mesh.metadata.canCollide = patch.canCollide;
}
if (patch.locked !== undefined) data.locked = !!patch.locked; if (patch.locked !== undefined) data.locked = !!patch.locked;
if (patch.visible !== undefined) { if (patch.visible !== undefined) {
data.visible = patch.visible; data.visible = patch.visible;
@ -828,17 +704,10 @@ export class PrimitiveManager {
const oldMat = oldMesh.material; const oldMat = oldMesh.material;
const typeDef = getPrimitiveType(data.type); const typeDef = getPrimitiveType(data.type);
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity); const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz);
newMesh.position = oldPos; newMesh.position = oldPos;
if (oldRot) newMesh.rotation = oldRot; if (oldRot) newMesh.rotation = oldRot;
// studs — материал пересоздаём заново (свежий faceUV/тайлинг). Иначе перенос. newMesh.material = oldMat;
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.isPickable = true;
newMesh.metadata = { ...oldMesh.metadata }; newMesh.metadata = { ...oldMesh.metadata };
newMesh.setEnabled(data.visible); newMesh.setEnabled(data.visible);
@ -848,7 +717,6 @@ export class PrimitiveManager {
catch (e) { /* ignore */ } catch (e) { /* ignore */ }
data.mesh = newMesh; data.mesh = newMesh;
// _studsDims и материал studs уже выставлены выше.
} }
/** Удалить инстанс. */ /** Удалить инстанс. */
@ -893,13 +761,10 @@ export class PrimitiveManager {
anchored: d.anchored, anchored: d.anchored,
mass: d.mass, mass: d.mass,
name: d.name || null, name: d.name || null,
...(d.folderId != null ? { folderId: d.folderId } : {}), // папка (парность со студией)
// locked — защита от выделения/перемещения (Фаза 5.11). // locked — защита от выделения/перемещения (Фаза 5.11).
...(d.locked ? { locked: true } : {}), ...(d.locked ? { locked: true } : {}),
// id пользовательской текстуры (картинка из AssetManager). // id пользовательской текстуры (картинка из AssetManager).
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}), ...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
// Плотность studs (если не 1) — мелкие/крупные кружки.
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
// Параметры лампы (только для type='light', иначе undefined) // Параметры лампы (только для type='light', иначе undefined)
...(d.light ? { brightness: d.brightness, range: d.range } : {}), ...(d.light ? { brightness: d.brightness, range: d.range } : {}),
// Параметр эмиттера (только для type='emitter') // Параметр эмиттера (только для type='emitter')

View File

@ -131,18 +131,6 @@ const ANIMS_STD = {
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10, { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10,
times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] }, 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) === // === ЭМОЦИИ (game.player.playAnimation) ===
// Разовые анимации поверх авто-состояния. loop=false — играют один раз, // Разовые анимации поверх авто-состояния. loop=false — играют один раз,

View File

@ -1,177 +0,0 @@
/**
* 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;
}
}

View File

@ -60,7 +60,6 @@ export class ScriptSandbox {
target: this.target, target: this.target,
selfPosition: this._initialSelfPosition || null, selfPosition: this._initialSelfPosition || null,
modules: this._modules || {}, modules: this._modules || {},
initialScene: this._initialScene || null,
}, },
}); });
} }
@ -70,11 +69,6 @@ export class ScriptSandbox {
this._initialSelfPosition = p; this._initialSelfPosition = p;
} }
/** Первичный snapshot сцены (до start) — чтобы findOne работал на старте. */
setInitialScene(snap) {
this._initialScene = snap;
}
/** Установить код модулей для game.require — { 'имя': 'код' }. Вызывать до start(). */ /** Установить код модулей для game.require — { 'имя': 'код' }. Вызывать до start(). */
setModules(modules) { setModules(modules) {
this._modules = modules || {}; this._modules = modules || {};

File diff suppressed because it is too large Load Diff

View File

@ -1,132 +0,0 @@
/**
* 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; }
}

View File

@ -1,570 +0,0 @@
/**
* 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; }
}
}

View File

@ -514,10 +514,6 @@ export class TerrainManager {
const mat = new StandardMaterial(name, this.scene); const mat = new StandardMaterial(name, this.scene);
// Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль. // Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль.
mat.specularColor = new Color3(0, 0, 0); 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 освещал материал // Ambient ставим в белый, чтобы hemisphere-light освещал материал
// с любой стороны (иначе нижние/тыловые грани выходят серыми, что // с любой стороны (иначе нижние/тыловые грани выходят серыми, что
// особенно заметно на светло-бежевом песке — он становится серым). // особенно заметно на светло-бежевом песке — он становится серым).
@ -547,12 +543,6 @@ export class TerrainManager {
mat.diffuseTexture.hasAlpha = true; mat.diffuseTexture.hasAlpha = true;
mat.useAlphaFromDiffuseTexture = true; mat.useAlphaFromDiffuseTexture = true;
mat.alpha = def.alpha; 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)) { if (Array.isArray(def.emissive)) {
mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]); mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]);

View File

@ -599,7 +599,6 @@ export class UserModelManager {
// instanceId — чтобы target-скрипты могли стабильно ссылаться // instanceId — чтобы target-скрипты могли стабильно ссылаться
// на конкретный инстанс после перезагрузки. // на конкретный инстанс после перезагрузки.
instanceId: inst.instanceId, instanceId: inst.instanceId,
...(inst.folderId != null ? { folderId: inst.folderId } : {}),
}); });
} }
return arr; return arr;
@ -664,13 +663,7 @@ export class UserModelManager {
forceInstanceId: item.instanceId, forceInstanceId: item.instanceId,
}, },
); );
if (id != null) { if (id != null) loaded++;
loaded++;
if (item.folderId != null) {
const inst = this.instances.get(id);
if (inst) inst.folderId = item.folderId;
}
}
} catch (e) { } catch (e) {
console.warn('[UserModelManager] failed to load instance', item, e); console.warn('[UserModelManager] failed to load instance', item, e);
} }

View File

@ -1,95 +0,0 @@
/**
* 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(); }
}

View File

@ -1,249 +0,0 @@
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();
}
}

View File

@ -90,17 +90,6 @@ export class WeaponSystem {
if (e.button !== 0) return; if (e.button !== 0) return;
// Если UI-режим курсора — не стреляем (мышь работает по GUI) // Если UI-режим курсора — не стреляем (мышь работает по GUI)
if (this.scene3d?.player?.isUiCursorMode?.()) return; 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._mouseDown = true;
this._tryFire(); this._tryFire();
}; };
@ -108,26 +97,14 @@ export class WeaponSystem {
if (e.button !== 0) return; if (e.button !== 0) return;
this._mouseDown = false; 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) => { const onKey = (e) => {
if (e.code === 'KeyR') this.reload(); if (e.code === 'KeyR') this.reload();
}; };
canvas.addEventListener('mousedown', onDown); canvas.addEventListener('mousedown', onDown);
window.addEventListener('mouseup', onUp); window.addEventListener('mouseup', onUp);
window.addEventListener('mousemove', onMove);
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown }); this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown });
this._listeners.push({ target: window, type: 'mouseup', fn: onUp }); 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 }); this._listeners.push({ target: window, type: 'keydown', fn: onKey });
// Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true) // Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true)
@ -606,10 +583,7 @@ export class WeaponSystem {
// (для tap-to-shoot на мобиле). Точка применяется один раз. // (для tap-to-shoot на мобиле). Точка применяется один раз.
let hit = null; let hit = null;
let ray; let ray;
let aim = this._aimScreenPoint; const aim = this._aimScreenPoint;
if (!aim && this._holdAim && document.pointerLockElement !== this.scene3d?.canvas) {
aim = this._holdAim;
}
try { try {
if (aim) { if (aim) {
ray = this.scene.createPickingRay(aim.x, aim.y, null, camera); ray = this.scene.createPickingRay(aim.x, aim.y, null, camera);

View File

@ -1,337 +0,0 @@
/**
* 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;

File diff suppressed because it is too large Load Diff

View File

@ -1,210 +0,0 @@
/**
* 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;
}
}

View File

@ -1,243 +0,0 @@
/**
* 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);
})();

View File

@ -1,187 +0,0 @@
/**
* 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 };
}

View File

@ -1,144 +0,0 @@
/**
* 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);
})();

View File

@ -1,89 +0,0 @@
/**
* 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);
})();

View File

@ -1,104 +0,0 @@
/**
* 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);
})();